diff --git a/.env.example b/.env.example index 827420e..723cb58 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/README.md b/README.md index 6c52c1a..183764e 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, 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) @@ -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 @@ -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 @@ -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 diff --git a/config/settings.py b/config/settings.py index 905e737..4391134 100644 --- a/config/settings.py +++ b/config/settings.py @@ -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 @@ -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", "") @@ -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") diff --git a/conftest.py b/conftest.py index d346944..a2db19f 100644 --- a/conftest.py +++ b/conftest.py @@ -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 diff --git a/frontend/src/App.css b/frontend/src/App.css index fa0d719..97b3022 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -253,6 +253,32 @@ 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)); @@ -260,6 +286,22 @@ 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; } @@ -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; } @@ -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; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 46a323d..1059244 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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) { @@ -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(null) const [historyOpenId, setHistoryOpenId] = useState(null) const [alertsOpenId, setAlertsOpenId] = useState(null) @@ -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 ( @@ -227,8 +235,16 @@ function AuthenticatedApp() {

{endpoint.name} + {endpoint.owner_username && ( + · {endpoint.owner_username} + )} {endpoint.open_incident && ( - incident + <> + incident + {endpoint.open_incident.acknowledged_at && ( + acknowledged + )} + )} {!endpoint.is_active && ( · inactive @@ -294,6 +310,18 @@ function AuthenticatedApp() { {last?.latency_ms != null ? ` · ${last.latency_ms}ms` : ""} {last?.status_code != null ? ` · HTTP ${last.status_code}` : ""}

+ {endpoint.open_incident && !endpoint.open_incident.acknowledged_at && ( + + )}
+ )} ))} diff --git a/frontend/src/components/EndpointForm.tsx b/frontend/src/components/EndpointForm.tsx index 9b54331..3fa720f 100644 --- a/frontend/src/components/EndpointForm.tsx +++ b/frontend/src/components/EndpointForm.tsx @@ -20,10 +20,17 @@ const EMPTY_FORM: EndpointInput = { alert_email: "", alert_on_failure: true, expect_body_contains: "", + expect_header_name: "", + expect_header_value: "", + expect_json_path: "", + expect_json_value: "", + request_body: "", max_latency_ms: null, check_ssl_expiry: false, ssl_warn_days: 14, mute_alerts_until: null, + quiet_hours_start: null, + quiet_hours_end: null, request_headers: {}, auth_type: "none", auth_username: "", @@ -79,6 +86,20 @@ function fromLocalDatetimeValue(value: string): string | null { return date.toISOString() } +function quietHoursToInput(value: string | null): string { + if (!value) return "" + const match = value.match(/^(\d{2}:\d{2})/) + return match ? match[1] : "" +} + +function quietHoursFromInput(value: string): string | null { + const trimmed = value.trim() + if (!trimmed) return null + if (/^\d{2}:\d{2}$/.test(trimmed)) return `${trimmed}:00` + if (/^\d{2}:\d{2}:\d{2}$/.test(trimmed)) return trimmed + return null +} + function toInput(endpoint: MonitoredEndpoint): EndpointInput { return { name: endpoint.name, @@ -93,10 +114,17 @@ function toInput(endpoint: MonitoredEndpoint): EndpointInput { alert_email: endpoint.alert_email, alert_on_failure: endpoint.alert_on_failure, expect_body_contains: endpoint.expect_body_contains ?? "", + expect_header_name: endpoint.expect_header_name ?? "", + expect_header_value: endpoint.expect_header_value ?? "", + expect_json_path: endpoint.expect_json_path ?? "", + expect_json_value: endpoint.expect_json_value ?? "", + request_body: endpoint.request_body ?? "", max_latency_ms: endpoint.max_latency_ms, check_ssl_expiry: endpoint.check_ssl_expiry, ssl_warn_days: endpoint.ssl_warn_days, mute_alerts_until: endpoint.mute_alerts_until, + quiet_hours_start: endpoint.quiet_hours_start, + quiet_hours_end: endpoint.quiet_hours_end, request_headers: endpoint.request_headers ?? {}, auth_type: endpoint.auth_type ?? "none", auth_username: endpoint.auth_username ?? "", @@ -123,6 +151,12 @@ export function EndpointForm({ const [muteLocal, setMuteLocal] = useState( initial ? toLocalDatetimeValue(initial.mute_alerts_until) : "", ) + const [quietStartLocal, setQuietStartLocal] = useState( + initial ? quietHoursToInput(initial.quiet_hours_start) : "", + ) + const [quietEndLocal, setQuietEndLocal] = useState( + initial ? quietHoursToInput(initial.quiet_hours_end) : "", + ) const [maxLatencyText, setMaxLatencyText] = useState( initial?.max_latency_ms != null ? String(initial.max_latency_ms) : "", ) @@ -134,6 +168,8 @@ export function EndpointForm({ setForm(initial ? toInput(initial) : EMPTY_FORM) setTagsText(initial ? (initial.tags ?? []).join(", ") : "") setMuteLocal(initial ? toLocalDatetimeValue(initial.mute_alerts_until) : "") + setQuietStartLocal(initial ? quietHoursToInput(initial.quiet_hours_start) : "") + setQuietEndLocal(initial ? quietHoursToInput(initial.quiet_hours_end) : "") setMaxLatencyText( initial?.max_latency_ms != null ? String(initial.max_latency_ms) : "", ) @@ -157,11 +193,15 @@ export function EndpointForm({ ? max_latency_ms : null, mute_alerts_until: fromLocalDatetimeValue(muteLocal), + quiet_hours_start: quietHoursFromInput(quietStartLocal), + quiet_hours_end: quietHoursFromInput(quietEndLocal), }) if (!initial) { setForm(EMPTY_FORM) setTagsText("") setMuteLocal("") + setQuietStartLocal("") + setQuietEndLocal("") setMaxLatencyText("") setHeadersText("") } @@ -171,269 +211,360 @@ export function EndpointForm({ return (
void handleSubmit(event)}> -
- - - - - - - - - - -