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:
+3
-3
@@ -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
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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
@@ -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 @@
|
||||
"""InfraPulse backend package."""
|
||||
"""OrbitalWard backend package."""
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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,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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
Generated
+2
-2
@@ -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,5 +1,5 @@
|
||||
{
|
||||
"name": "infrapulse-frontend",
|
||||
"name": "orbitalward-frontend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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 @@
|
||||
"""InfraPulse worker package."""
|
||||
"""OrbitalWard worker package."""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user