Rename project to OrbitalWard

Add optional TLS certificate expiry checks for website monitors and update product, package, environment, Docker, and documentation naming.
This commit is contained in:
Keith Smith
2026-05-23 14:36:28 -06:00
parent 788c01b1cc
commit 3b75075426
42 changed files with 190 additions and 89 deletions
+3 -3
View File
@@ -1,6 +1,6 @@
INFRAPULSE_ENV=development
INFRAPULSE_SECRET_KEY=change-me
DATABASE_URL=postgresql+psycopg://infrapulse:infrapulse@postgres:5432/infrapulse
ORBITALWARD_ENV=development
ORBITALWARD_SECRET_KEY=change-me
DATABASE_URL=postgresql+psycopg://orbitalward:orbitalward@postgres:5432/orbitalward
REDIS_URL=redis://redis:6379/0
FRONTEND_URL=http://localhost:5173
BACKEND_URL=http://localhost:8000
+2 -2
View File
@@ -1,6 +1,6 @@
# InfraPulse Agent Notes
# OrbitalWard Agent Notes
InfraPulse should be built incrementally. Keep the first release focused on a secure monitoring appliance with guided setup, website monitoring, alerts, and notifications.
OrbitalWard should be built incrementally. Keep the first release focused on a secure monitoring appliance with guided setup, website monitoring, alerts, and notifications.
## Product Guardrails
+1 -1
View File
@@ -1,6 +1,6 @@
MIT License
Copyright (c) 2026 InfraPulse contributors
Copyright (c) 2026 OrbitalWard contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
+3 -3
View File
@@ -1,8 +1,8 @@
# InfraPulse
# OrbitalWard
Beautiful, self-hosted infrastructure monitoring without the enterprise-tool headache.
InfraPulse is an open-source monitoring appliance for homelabs, small businesses, and internal IT teams. The first target is a secure and attractive monitoring foundation with guided setup, website checks, alerts, notifications, and clean dashboards. It is not trying to be a full Zabbix or LibreNMS replacement in the first release.
OrbitalWard is an open-source monitoring appliance for homelabs, small businesses, and internal IT teams. The first target is a secure and attractive monitoring foundation with guided setup, website checks, alerts, notifications, and clean dashboards. It is not trying to be a full Zabbix or LibreNMS replacement in the first release.
## Current Status
@@ -57,7 +57,7 @@ Default local admin credentials come from `.env`:
- `INITIAL_ADMIN_EMAIL=admin@example.com`
- `INITIAL_ADMIN_PASSWORD=change-me`
Change these values before using InfraPulse anywhere beyond local development.
Change these values before using OrbitalWard anywhere beyond local development.
## Project Structure
+1 -1
View File
@@ -2,7 +2,7 @@
script_location = alembic
prepend_sys_path = .
sqlalchemy.url = postgresql+psycopg://infrapulse:infrapulse@postgres:5432/infrapulse
sqlalchemy.url = postgresql+psycopg://orbitalward:orbitalward@postgres:5432/orbitalward
[loggers]
keys = root,sqlalchemy,alembic
@@ -1,4 +1,4 @@
"""Initial InfraPulse schema.
"""Initial OrbitalWard schema.
Revision ID: 20260522_0001
Revises:
+1 -1
View File
@@ -1 +1 @@
"""InfraPulse backend package."""
"""OrbitalWard backend package."""
+1 -1
View File
@@ -5,4 +5,4 @@ router = APIRouter(tags=["health"])
@router.get("/health")
def health() -> dict[str, str]:
return {"status": "ok", "service": "infrapulse-backend"}
return {"status": "ok", "service": "orbitalward-backend"}
+2
View File
@@ -53,6 +53,8 @@ def create_website_monitor(
"expected_text": payload.expected_text,
"unexpected_text": payload.unexpected_text,
"timeout_seconds": payload.timeout_seconds,
"check_tls_expiry": payload.check_tls_expiry,
"tls_warning_days": payload.tls_warning_days,
},
interval_seconds=payload.interval_seconds,
status="unknown",
+4 -4
View File
@@ -14,7 +14,7 @@ router = APIRouter(prefix="/notifications/channels", tags=["notifications"])
def _channel_to_read(channel: NotificationChannel) -> NotificationChannelRead:
settings = dict(channel.settings or {})
settings.setdefault("username", "InfraPulse")
settings.setdefault("username", "OrbitalWard")
return NotificationChannelRead(
id=channel.id,
name=channel.name,
@@ -40,7 +40,7 @@ def create_channel(
db: Session = Depends(get_db),
) -> NotificationChannelRead:
channel_settings = dict(payload.settings or {})
channel_settings.setdefault("username", "InfraPulse")
channel_settings.setdefault("username", "OrbitalWard")
channel = NotificationChannel(
name=payload.name,
channel_type=payload.channel_type,
@@ -70,8 +70,8 @@ def test_channel(
response = httpx.post(
url,
json={
"username": (channel.settings or {}).get("username") or "InfraPulse",
"text": f"InfraPulse test notification for {channel.name}",
"username": (channel.settings or {}).get("username") or "OrbitalWard",
"text": f"OrbitalWard test notification for {channel.name}",
},
timeout=10,
)
+2 -2
View File
@@ -20,12 +20,12 @@ def verify_password(plain_password: str, hashed_password: str) -> bool:
def create_access_token(subject: str) -> str:
expires_at = datetime.now(UTC) + timedelta(minutes=settings.access_token_expire_minutes)
payload = {"sub": subject, "exp": expires_at}
return jwt.encode(payload, settings.infrapulse_secret_key, algorithm=ALGORITHM)
return jwt.encode(payload, settings.orbitalward_secret_key, algorithm=ALGORITHM)
def decode_access_token(token: str) -> str | None:
try:
payload = jwt.decode(token, settings.infrapulse_secret_key, algorithms=[ALGORITHM])
payload = jwt.decode(token, settings.orbitalward_secret_key, algorithms=[ALGORITHM])
return payload.get("sub")
except JWTError:
return None
+3 -3
View File
@@ -7,9 +7,9 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
infrapulse_env: str = "development"
infrapulse_secret_key: str = Field(default="change-me", min_length=8)
database_url: str = "postgresql+psycopg://infrapulse:infrapulse@postgres:5432/infrapulse"
orbitalward_env: str = "development"
orbitalward_secret_key: str = Field(default="change-me", min_length=8)
database_url: str = "postgresql+psycopg://orbitalward:orbitalward@postgres:5432/orbitalward"
redis_url: str = "redis://redis:6379/0"
frontend_url: AnyHttpUrl | str = "http://localhost:5173"
backend_url: AnyHttpUrl | str = "http://localhost:8000"
+1 -1
View File
@@ -7,7 +7,7 @@ from app.core.config import settings
def _fernet() -> Fernet:
digest = hashlib.sha256(settings.infrapulse_secret_key.encode("utf-8")).digest()
digest = hashlib.sha256(settings.orbitalward_secret_key.encode("utf-8")).digest()
return Fernet(base64.urlsafe_b64encode(digest))
+1 -1
View File
@@ -25,7 +25,7 @@ async def lifespan(_: FastAPI) -> AsyncIterator[None]:
app = FastAPI(
title="InfraPulse API",
title="OrbitalWard API",
version="0.1.0",
description="Self-hosted infrastructure monitoring API",
lifespan=lifespan,
+2
View File
@@ -64,6 +64,8 @@ class WebsiteMonitorCreate(BaseModel):
expected_text: str | None = None
unexpected_text: str | None = None
timeout_seconds: int = Field(default=10, ge=1, le=120)
check_tls_expiry: bool = False
tls_warning_days: int = Field(default=30, ge=1, le=365)
interval_seconds: int = Field(default=60, ge=10)
create_asset: bool = True
alert_enabled: bool = True
+6 -2
View File
@@ -1,7 +1,7 @@
[project]
name = "infrapulse-backend"
name = "orbitalward-backend"
version = "0.1.0"
description = "InfraPulse FastAPI backend"
description = "OrbitalWard FastAPI backend"
requires-python = ">=3.12"
dependencies = [
"alembic>=1.13.3",
@@ -27,6 +27,10 @@ test = [
[tool.pytest.ini_options]
testpaths = ["tests"]
[tool.setuptools.packages.find]
include = ["app*"]
exclude = ["alembic*"]
[build-system]
requires = ["setuptools>=75.0"]
build-backend = "setuptools.build_meta"
+4 -4
View File
@@ -2,13 +2,13 @@ services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_USER: infrapulse
POSTGRES_PASSWORD: infrapulse
POSTGRES_DB: infrapulse
POSTGRES_USER: orbitalward
POSTGRES_PASSWORD: orbitalward
POSTGRES_DB: orbitalward
volumes:
- postgres-dev-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U infrapulse -d infrapulse"]
test: ["CMD-SHELL", "pg_isready -U orbitalward -d orbitalward"]
interval: 10s
timeout: 5s
retries: 5
+4 -4
View File
@@ -2,13 +2,13 @@ services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_USER: infrapulse
POSTGRES_PASSWORD: infrapulse
POSTGRES_DB: infrapulse
POSTGRES_USER: orbitalward
POSTGRES_PASSWORD: orbitalward
POSTGRES_DB: orbitalward
volumes:
- postgres-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U infrapulse -d infrapulse"]
test: ["CMD-SHELL", "pg_isready -U orbitalward -d orbitalward"]
interval: 10s
timeout: 5s
retries: 5
+1 -1
View File
@@ -26,4 +26,4 @@ Initial channels:
- Zoom Team Chat incoming webhook
- Generic webhook
Alert messages should be human-readable and include asset, check, status, duration, timestamps, and a link back to InfraPulse.
Alert messages should be human-readable and include asset, check, status, duration, timestamps, and a link back to OrbitalWard.
+1 -1
View File
@@ -1,6 +1,6 @@
# Architecture
InfraPulse is a monorepo with four main areas:
OrbitalWard is a monorepo with four main areas:
- `backend`: FastAPI service exposing REST endpoints and owning database access.
- `worker`: Background scheduler and collectors for checks and alert evaluation.
+2 -2
View File
@@ -1,6 +1,6 @@
# Discovery Design
Guided discovery is a core InfraPulse workflow.
Guided discovery is a core OrbitalWard workflow.
```text
Add target
@@ -16,7 +16,7 @@ Create monitors and optional alert rules
## Monitor vs Alert Separation
InfraPulse must allow monitoring without alerting. Every discovered item should eventually support separate choices:
OrbitalWard must allow monitoring without alerting. Every discovered item should eventually support separate choices:
- Collect metric
- Graph metric
+2 -2
View File
@@ -1,11 +1,11 @@
# Plugin Design
Plugins will let InfraPulse add collectors and discovery logic without hard-coding every integration into the core API.
Plugins will let OrbitalWard add collectors and discovery logic without hard-coding every integration into the core API.
Target shape:
```python
class InfraPulsePlugin:
class OrbitalWardPlugin:
name: str
display_name: str
+15 -16
View File
@@ -1,10 +1,10 @@
# InfraPulse Progress
# OrbitalWard Progress
Last updated: 2026-05-22
Last updated: 2026-05-23
## Current State
InfraPulse has a working Docker Compose development stack with PostgreSQL, Redis, FastAPI backend, Python worker, and React/Vite frontend.
OrbitalWard has a working Docker Compose development stack with PostgreSQL, Redis, FastAPI backend, Python worker, and React/Vite frontend.
Implemented foundation:
@@ -18,6 +18,7 @@ Implemented website-monitor slice:
- Create, edit, delete website monitors from the UI.
- HTTP status and expected-text checks.
- Optional TLS certificate expiry checks for HTTPS monitors.
- Monitor status and recent incident visibility on dashboard.
- Basic alert rules created with website monitors.
- Incidents can be acknowledged and silenced from the UI.
@@ -27,7 +28,7 @@ Implemented notification slice:
- Create, edit, test, and delete notification channels from the UI.
- Generic webhook, Mattermost, and Zoom Team Chat channel types.
- Webhook URLs encrypted at rest using `INFRAPULSE_SECRET_KEY`.
- Webhook URLs encrypted at rest using `ORBITALWARD_SECRET_KEY`.
- Saved webhook URLs are not returned to the UI.
- Configurable post username per notification channel.
- Worker sends incident open and recovery notifications.
@@ -42,7 +43,6 @@ Implemented notification slice:
- Alert rule editing UI is not implemented.
- Notification routing/policies are not implemented; all enabled webhook channels receive incident notifications.
- Email/SMTP notifications are not implemented yet.
- TLS certificate expiry checks are not implemented yet.
- Ping and TCP checks are not implemented yet.
- Graphing exists only as placeholders; metric visualization is not implemented.
- Worker scheduling is simple polling, not a Redis queue yet.
@@ -51,16 +51,15 @@ Implemented notification slice:
## Recommended Next Work
1. Add TLS certificate expiry monitor support.
2. Add ping and TCP port monitors.
3. Add alert rule editing UI and richer alert conditions.
4. Add notification policy/routing controls.
5. Add email/SMTP notification channel.
6. Add audit event writes for auth, monitor, credential, notification, and incident actions.
7. Build credential vault UI with masked secret handling.
8. Add user administration UI.
9. Add graphs for website response time and monitor status history.
10. Add backend and worker tests for the website-monitor and notification flows.
1. Add ping and TCP port monitors.
2. Add alert rule editing UI and richer alert conditions.
3. Add notification policy/routing controls.
4. Add email/SMTP notification channel.
5. Add audit event writes for auth, monitor, credential, notification, and incident actions.
6. Build credential vault UI with masked secret handling.
7. Add user administration UI.
8. Add graphs for website response time and monitor status history.
9. Add backend and worker tests for the website-monitor and notification flows.
## Operational Notes
@@ -75,4 +74,4 @@ Default local login comes from `.env`:
- `INITIAL_ADMIN_EMAIL=admin@example.com`
- `INITIAL_ADMIN_PASSWORD=change-me`
Change these values before using InfraPulse outside local development.
Change these values before using OrbitalWard outside local development.
+2 -2
View File
@@ -1,6 +1,6 @@
# Security
InfraPulse must be secure from the beginning because it will store infrastructure credentials.
OrbitalWard must be secure from the beginning because it will store infrastructure credentials.
## Authentication
@@ -19,7 +19,7 @@ Credential records are modeled separately from monitors and assets. Secret field
Rules:
- Use `INFRAPULSE_SECRET_KEY` from the environment.
- Use `ORBITALWARD_SECRET_KEY` from the environment.
- Never log secrets.
- Mask saved secrets in the UI.
- Audit credential create, update, and delete events.
+3 -3
View File
@@ -1,12 +1,12 @@
# InfraPulse Vision
# OrbitalWard Vision
InfraPulse is a secure, self-hosted monitoring platform for homelabs, small businesses, and internal IT teams.
OrbitalWard is a secure, self-hosted monitoring platform for homelabs, small businesses, and internal IT teams.
The v0.1 product should feel like a polished appliance, not a pile of raw monitoring configuration. Users should be guided through adding targets, testing connections, discovering useful items, choosing what to monitor, and separately choosing what should alert.
## Design Philosophy
InfraPulse exposes intent, not implementation details.
OrbitalWard exposes intent, not implementation details.
Raw SNMP OIDs, probe internals, and collector details belong behind friendly profiles and advanced tools. The normal UI should say things like "Port 5 outbound traffic", "Graph this port", and "Alert if port goes down".
+1 -1
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>InfraPulse</title>
<title>OrbitalWard</title>
</head>
<body>
<div id="root"></div>
+2 -2
View File
@@ -1,11 +1,11 @@
{
"name": "infrapulse-frontend",
"name": "orbitalward-frontend",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "infrapulse-frontend",
"name": "orbitalward-frontend",
"version": "0.1.0",
"dependencies": {
"@vitejs/plugin-react": "^5.0.0",
+1 -1
View File
@@ -1,5 +1,5 @@
{
"name": "infrapulse-frontend",
"name": "orbitalward-frontend",
"version": "0.1.0",
"private": true,
"type": "module",
+1 -1
View File
@@ -61,7 +61,7 @@ export function App() {
}
if (auth.loading) {
return <div className="flex min-h-screen items-center justify-center bg-[#090d13] text-sm text-slate-300">Loading InfraPulse...</div>;
return <div className="flex min-h-screen items-center justify-center bg-[#090d13] text-sm text-slate-300">Loading OrbitalWard...</div>;
}
if (!auth.user || !auth.token) {
+1 -1
View File
@@ -47,7 +47,7 @@ export function Shell({ children, currentPage, onPageChange, onSignOut, user }:
<Shield size={19} />
</div>
<div>
<div className="text-base font-semibold">InfraPulse</div>
<div className="text-base font-semibold">OrbitalWard</div>
<div className="text-xs text-slate-400">Monitoring appliance</div>
</div>
</div>
+1 -1
View File
@@ -3,7 +3,7 @@ import { useEffect, useMemo, useState } from "react";
import { api, login } from "../api/client";
import type { User } from "../types/api";
const TOKEN_KEY = "infrapulse_token";
const TOKEN_KEY = "orbitalward_token";
export function useAuth() {
const [token, setToken] = useState<string | null>(() => localStorage.getItem(TOKEN_KEY));
+5 -3
View File
@@ -9,7 +9,7 @@ interface DashboardPageProps {
}
export function DashboardPage({ assets, monitors, incidents }: DashboardPageProps) {
const downMonitors = monitors.filter((monitor) => monitor.status === "down").length;
const attentionMonitors = monitors.filter((monitor) => monitor.status !== "up" && monitor.status !== "unknown").length;
const activeIncidents = incidents.filter((incident) => incident.status === "open").length;
const websites = monitors.filter((monitor) => monitor.monitor_type === "http");
@@ -33,7 +33,7 @@ export function DashboardPage({ assets, monitors, incidents }: DashboardPageProp
<div className="rounded-md border border-line bg-[#0d131c]">
<div className="flex items-center justify-between border-b border-line p-4">
<h2 className="text-base font-semibold">Website Monitors</h2>
<span className="text-sm text-slate-400">{downMonitors} down</span>
<span className="text-sm text-slate-400">{attentionMonitors} need attention</span>
</div>
<div className="divide-y divide-line">
{websites.length ? (
@@ -110,6 +110,8 @@ function StatusBadge({ status }: { status: string }) {
? "border-teal-500/40 bg-teal-950/40 text-teal-200"
: status === "down"
? "border-red-500/40 bg-red-950/40 text-red-200"
: "border-slate-600 bg-slate-900 text-slate-300";
: status === "warning"
? "border-amber-500/40 bg-amber-950/40 text-amber-200"
: "border-slate-600 bg-slate-900 text-slate-300";
return <span className={`inline-flex h-7 items-center justify-center rounded-md border px-2 text-xs font-medium ${classes}`}>{status}</span>;
}
+1 -1
View File
@@ -33,7 +33,7 @@ export function LoginPage({ onLogin }: LoginPageProps) {
<div className="flex h-10 w-10 items-center justify-center rounded-md bg-pulse text-slate-950">
<Shield size={22} />
</div>
<div className="text-lg font-semibold">InfraPulse</div>
<div className="text-lg font-semibold">OrbitalWard</div>
</div>
<div className="max-w-3xl pb-6">
<h1 className="max-w-2xl text-4xl font-semibold leading-tight lg:text-6xl">
+6 -6
View File
@@ -14,7 +14,7 @@ export function NotificationsPage({ token }: NotificationsPageProps) {
const [name, setName] = useState("");
const [channelType, setChannelType] = useState("generic_webhook");
const [url, setUrl] = useState("");
const [username, setUsername] = useState("InfraPulse");
const [username, setUsername] = useState("OrbitalWard");
const [enabled, setEnabled] = useState(true);
const [editingChannelId, setEditingChannelId] = useState<number | null>(null);
const [busyId, setBusyId] = useState<number | null>(null);
@@ -38,7 +38,7 @@ export function NotificationsPage({ token }: NotificationsPageProps) {
await api.updateNotificationChannel(token, editingChannelId, {
name,
channel_type: channelType,
settings: { username: username.trim() || "InfraPulse" },
settings: { username: username.trim() || "OrbitalWard" },
secret: url.trim() ? url.trim() : undefined,
is_enabled: enabled,
});
@@ -46,7 +46,7 @@ export function NotificationsPage({ token }: NotificationsPageProps) {
await api.createNotificationChannel(token, {
name,
channel_type: channelType,
settings: { username: username.trim() || "InfraPulse" },
settings: { username: username.trim() || "OrbitalWard" },
secret: url,
is_enabled: enabled,
});
@@ -65,7 +65,7 @@ export function NotificationsPage({ token }: NotificationsPageProps) {
setName(channel.name);
setChannelType(channel.channel_type);
setUrl("");
setUsername(String(channel.settings.username || "InfraPulse"));
setUsername(String(channel.settings.username || "OrbitalWard"));
setEnabled(channel.is_enabled);
setMessage(null);
}
@@ -75,7 +75,7 @@ export function NotificationsPage({ token }: NotificationsPageProps) {
setName("");
setChannelType("generic_webhook");
setUrl("");
setUsername("InfraPulse");
setUsername("OrbitalWard");
setEnabled(true);
}
@@ -180,7 +180,7 @@ export function NotificationsPage({ token }: NotificationsPageProps) {
<div key={channel.id} className="grid gap-3 p-4 md:grid-cols-[1fr_140px_90px_150px] md:items-center">
<div>
<div className="font-medium">{channel.name}</div>
<div className="text-sm text-slate-400">{String(channel.settings.username || "InfraPulse")}</div>
<div className="text-sm text-slate-400">{String(channel.settings.username || "OrbitalWard")}</div>
<div className="text-xs text-slate-500">{channel.has_secret ? "Secret stored" : "No secret"}</div>
</div>
<div className="text-sm text-slate-300">{channel.channel_type}</div>
+26 -1
View File
@@ -15,8 +15,11 @@ export function WebsitesPage({ token, monitors, onCreated }: WebsitesPageProps)
const websites = monitors.filter((monitor) => monitor.monitor_type === "http");
const [name, setName] = useState("");
const [url, setUrl] = useState("https://");
const supportsTlsExpiry = url.trim().toLowerCase().startsWith("https://");
const [expectedStatus, setExpectedStatus] = useState(200);
const [expectedText, setExpectedText] = useState("");
const [checkTlsExpiry, setCheckTlsExpiry] = useState(true);
const [tlsWarningDays, setTlsWarningDays] = useState(30);
const [intervalSeconds, setIntervalSeconds] = useState(60);
const [failureThreshold, setFailureThreshold] = useState(3);
const [alertEnabled, setAlertEnabled] = useState(true);
@@ -40,6 +43,8 @@ export function WebsitesPage({ token, monitors, onCreated }: WebsitesPageProps)
expected_text: expectedText.trim() ? expectedText.trim() : null,
unexpected_text: null,
timeout_seconds: 10,
check_tls_expiry: supportsTlsExpiry && checkTlsExpiry,
tls_warning_days: tlsWarningDays,
},
});
} else {
@@ -50,6 +55,8 @@ export function WebsitesPage({ token, monitors, onCreated }: WebsitesPageProps)
expected_text: expectedText.trim() ? expectedText.trim() : null,
unexpected_text: null,
timeout_seconds: 10,
check_tls_expiry: supportsTlsExpiry && checkTlsExpiry,
tls_warning_days: tlsWarningDays,
interval_seconds: intervalSeconds,
create_asset: true,
alert_enabled: alertEnabled,
@@ -72,6 +79,8 @@ export function WebsitesPage({ token, monitors, onCreated }: WebsitesPageProps)
setUrl(monitor.target);
setExpectedStatus(Number(monitor.config?.expected_status ?? 200));
setExpectedText(typeof monitor.config?.expected_text === "string" ? monitor.config.expected_text : "");
setCheckTlsExpiry(Boolean(monitor.config?.check_tls_expiry ?? false));
setTlsWarningDays(Number(monitor.config?.tls_warning_days ?? 30));
setIntervalSeconds(monitor.interval_seconds);
setAlertEnabled(true);
setFailureThreshold(3);
@@ -84,6 +93,8 @@ export function WebsitesPage({ token, monitors, onCreated }: WebsitesPageProps)
setUrl("https://");
setExpectedStatus(200);
setExpectedText("");
setCheckTlsExpiry(true);
setTlsWarningDays(30);
setIntervalSeconds(60);
setFailureThreshold(3);
setAlertEnabled(true);
@@ -148,6 +159,17 @@ export function WebsitesPage({ token, monitors, onCreated }: WebsitesPageProps)
<input className="h-10 w-full rounded-md border border-line bg-slate-950 px-3 text-sm outline-none ring-pulse/40 focus:ring-2" value={expectedText} onChange={(event) => setExpectedText(event.target.value)} />
</label>
<div className="grid gap-3 sm:grid-cols-[1fr_140px]">
<div className="flex items-center justify-between rounded-md border border-line bg-slate-950 px-3 py-2">
<span className="text-sm text-slate-300">TLS expiry check</span>
<input className="h-5 w-5 accent-teal-400" checked={supportsTlsExpiry && checkTlsExpiry} disabled={!supportsTlsExpiry} onChange={(event) => setCheckTlsExpiry(event.target.checked)} type="checkbox" />
</div>
<label className="block space-y-2">
<span className="text-sm text-slate-300">Warn Days</span>
<input className="h-10 w-full rounded-md border border-line bg-slate-950 px-3 text-sm outline-none ring-pulse/40 focus:ring-2" disabled={!supportsTlsExpiry || !checkTlsExpiry} value={tlsWarningDays} onChange={(event) => setTlsWarningDays(Number(event.target.value))} min={1} max={365} type="number" />
</label>
</div>
{!editingMonitorId ? (
<div className="flex items-center justify-between rounded-md border border-line bg-slate-950 px-3 py-2">
<span className="text-sm text-slate-300">Alert on repeated failures</span>
@@ -189,6 +211,7 @@ export function WebsitesPage({ token, monitors, onCreated }: WebsitesPageProps)
<div>
<div className="font-medium">{monitor.name}</div>
<div className="truncate text-sm text-slate-400">{monitor.target}</div>
{monitor.config?.check_tls_expiry ? <div className="text-xs text-slate-500">TLS warning at {String(monitor.config.tls_warning_days ?? 30)} days</div> : null}
</div>
<Status status={monitor.status} />
<div className="flex items-center justify-between gap-3">
@@ -218,6 +241,8 @@ function Status({ status }: { status: string }) {
? "border-teal-500/40 bg-teal-950/40 text-teal-200"
: status === "down"
? "border-red-500/40 bg-red-950/40 text-red-200"
: "border-slate-600 bg-slate-900 text-slate-300";
: status === "warning"
? "border-amber-500/40 bg-amber-950/40 text-amber-200"
: "border-slate-600 bg-slate-900 text-slate-300";
return <span className={`inline-flex h-7 w-24 items-center justify-center rounded-md border text-xs font-medium ${classes}`}>{status}</span>;
}
+2
View File
@@ -86,6 +86,8 @@ export interface WebsiteMonitorCreate {
expected_text?: string | null;
unexpected_text?: string | null;
timeout_seconds: number;
check_tls_expiry: boolean;
tls_warning_days: number;
interval_seconds: number;
create_asset: boolean;
alert_enabled: boolean;
+1 -1
View File
@@ -1 +1 @@
"""InfraPulse worker package."""
"""OrbitalWard worker package."""
+63
View File
@@ -1,7 +1,13 @@
from dataclasses import dataclass
from datetime import UTC, datetime
import asyncio
import socket
import ssl
from time import perf_counter
from urllib.parse import urlparse
import httpx
from cryptography import x509
@dataclass(frozen=True)
@@ -11,6 +17,8 @@ class WebsiteCheckConfig:
expected_text: str | None = None
unexpected_text: str | None = None
timeout_seconds: float = 10.0
check_tls_expiry: bool = False
tls_warning_days: int = 30
@dataclass(frozen=True)
@@ -22,6 +30,12 @@ class WebsiteCheckResult:
async def run_website_check(config: WebsiteCheckConfig) -> WebsiteCheckResult:
started = perf_counter()
if config.check_tls_expiry:
tls_result = await check_tls_expiry(config.url, config.tls_warning_days, config.timeout_seconds)
if tls_result is not None:
return tls_result
try:
async with httpx.AsyncClient(follow_redirects=True, timeout=config.timeout_seconds) as client:
response = await client.get(config.url)
@@ -48,3 +62,52 @@ async def run_website_check(config: WebsiteCheckConfig) -> WebsiteCheckResult:
message="Unexpected text was present",
)
return WebsiteCheckResult(status="up", response_time_ms=response_time_ms, message="Website check passed")
async def check_tls_expiry(url: str, warning_days: int, timeout_seconds: float) -> WebsiteCheckResult | None:
parsed_url = urlparse(url)
if parsed_url.scheme != "https":
return None
if not parsed_url.hostname:
return WebsiteCheckResult(status="down", response_time_ms=None, message="TLS check could not determine the hostname")
started = perf_counter()
try:
expires_at = await _get_certificate_expiry(parsed_url.hostname, parsed_url.port or 443, timeout_seconds)
except (OSError, ssl.SSLError, ValueError) as exc:
return WebsiteCheckResult(status="down", response_time_ms=None, message=f"TLS certificate check failed: {exc}")
response_time_ms = int((perf_counter() - started) * 1000)
now = datetime.now(UTC)
days_remaining = (expires_at - now).total_seconds() / 86400
if days_remaining < 0:
return WebsiteCheckResult(
status="down",
response_time_ms=response_time_ms,
message=f"TLS certificate expired on {expires_at.date().isoformat()}",
)
if days_remaining <= warning_days:
return WebsiteCheckResult(
status="warning",
response_time_ms=response_time_ms,
message=f"TLS certificate expires in {int(days_remaining)} days on {expires_at.date().isoformat()}",
)
return None
async def _get_certificate_expiry(hostname: str, port: int, timeout_seconds: float) -> datetime:
return await asyncio.to_thread(_get_certificate_expiry_sync, hostname, port, timeout_seconds)
def _get_certificate_expiry_sync(hostname: str, port: int, timeout_seconds: float) -> datetime:
context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
context.check_hostname = False
context.verify_mode = ssl.CERT_NONE
with socket.create_connection((hostname, port), timeout=timeout_seconds) as sock:
with context.wrap_socket(sock, server_hostname=hostname) as tls_sock:
certificate_bytes = tls_sock.getpeercert(binary_form=True)
if not certificate_bytes:
raise ValueError("certificate did not include an expiry date")
certificate = x509.load_der_x509_certificate(certificate_bytes)
return certificate.not_valid_after_utc
+3 -3
View File
@@ -6,9 +6,9 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
infrapulse_env: str = "development"
infrapulse_secret_key: str = "change-me"
database_url: str = "postgresql+psycopg://infrapulse:infrapulse@postgres:5432/infrapulse"
orbitalward_env: str = "development"
orbitalward_secret_key: str = "change-me"
database_url: str = "postgresql+psycopg://orbitalward:orbitalward@postgres:5432/orbitalward"
redis_url: str = "redis://redis:6379/0"
frontend_url: str = "http://localhost:5173"
backend_url: str = "http://localhost:8000"
+5 -3
View File
@@ -22,7 +22,7 @@ class Scheduler:
self._stopped = asyncio.Event()
async def run(self) -> None:
logger.info("InfraPulse worker started for %s", settings.infrapulse_env)
logger.info("OrbitalWard worker started for %s", settings.orbitalward_env)
while not self._stopped.is_set():
await self.tick()
try:
@@ -63,6 +63,8 @@ class Scheduler:
expected_text=monitor.config.get("expected_text") or None,
unexpected_text=monitor.config.get("unexpected_text") or None,
timeout_seconds=float(monitor.config.get("timeout_seconds", 10)),
check_tls_expiry=bool(monitor.config.get("check_tls_expiry", False)),
tls_warning_days=int(monitor.config.get("tls_warning_days", 30)),
)
result = await run_website_check(config)
now = datetime.now(UTC)
@@ -164,7 +166,7 @@ class Scheduler:
await self._post_webhook(
url,
self._format_incident_message(incident, monitor, event_type),
str((channel.settings or {}).get("username") or "InfraPulse"),
str((channel.settings or {}).get("username") or "OrbitalWard"),
)
except httpx.HTTPError:
logger.exception("Notification delivery failed for channel %s", channel.id)
@@ -205,5 +207,5 @@ class Scheduler:
last_message = (incident.details or {}).get("last_message")
if last_message:
body.append(f"Last response: {last_message}")
body.extend(["", f"View in InfraPulse: {settings.frontend_url}/incidents/{incident.id}"])
body.extend(["", f"View in OrbitalWard: {settings.frontend_url}/incidents/{incident.id}"])
return "\n".join(str(line) for line in body)
+1 -1
View File
@@ -7,7 +7,7 @@ from app.config import settings
def _fernet() -> Fernet:
digest = hashlib.sha256(settings.infrapulse_secret_key.encode("utf-8")).digest()
digest = hashlib.sha256(settings.orbitalward_secret_key.encode("utf-8")).digest()
return Fernet(base64.urlsafe_b64encode(digest))
+2 -2
View File
@@ -1,7 +1,7 @@
[project]
name = "infrapulse-worker"
name = "orbitalward-worker"
version = "0.1.0"
description = "InfraPulse background worker"
description = "OrbitalWard background worker"
requires-python = ">=3.12"
dependencies = [
"cryptography>=48.0.0",