Rename product to OrbitWard

This commit is contained in:
Keith Smith
2026-05-26 21:24:54 -06:00
parent af72a6c563
commit 177bdcc8a7
41 changed files with 129 additions and 105 deletions
+3 -3
View File
@@ -1,6 +1,6 @@
ORBITALWARD_ENV=development ORBITWARD_ENV=development
ORBITALWARD_SECRET_KEY=change-me ORBITWARD_SECRET_KEY=change-me
DATABASE_URL=postgresql+psycopg://orbitalward:orbitalward@postgres:5432/orbitalward DATABASE_URL=postgresql+psycopg://orbitward:orbitward@postgres:5432/orbitward
REDIS_URL=redis://redis:6379/0 REDIS_URL=redis://redis:6379/0
FRONTEND_URL=http://localhost:5173 FRONTEND_URL=http://localhost:5173
BACKEND_URL=http://localhost:8000 BACKEND_URL=http://localhost:8000
+2 -2
View File
@@ -1,6 +1,6 @@
# OrbitalWard Agent Notes # OrbitWard Agent Notes
OrbitalWard should be built incrementally. Keep the first release focused on a secure monitoring appliance with guided setup, website monitoring, alerts, and notifications. OrbitWard should be built incrementally. Keep the first release focused on a secure monitoring appliance with guided setup, website monitoring, alerts, and notifications.
## Product Guardrails ## Product Guardrails
+1 -1
View File
@@ -1,6 +1,6 @@
MIT License MIT License
Copyright (c) 2026 OrbitalWard contributors Copyright (c) 2026 OrbitWard contributors
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal
+3 -3
View File
@@ -1,8 +1,8 @@
# OrbitalWard # OrbitWard
Beautiful, self-hosted infrastructure monitoring without the enterprise-tool headache. Beautiful, self-hosted infrastructure monitoring without the enterprise-tool headache.
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. OrbitWard 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 ## Current Status
@@ -57,7 +57,7 @@ Default local admin credentials come from `.env`:
- `INITIAL_ADMIN_EMAIL=admin@example.com` - `INITIAL_ADMIN_EMAIL=admin@example.com`
- `INITIAL_ADMIN_PASSWORD=change-me` - `INITIAL_ADMIN_PASSWORD=change-me`
Change these values before using OrbitalWard anywhere beyond local development. Change these values before using OrbitWard anywhere beyond local development.
## Project Structure ## Project Structure
+1 -1
View File
@@ -2,7 +2,7 @@
script_location = alembic script_location = alembic
prepend_sys_path = . prepend_sys_path = .
sqlalchemy.url = postgresql+psycopg://orbitalward:orbitalward@postgres:5432/orbitalward sqlalchemy.url = postgresql+psycopg://orbitward:orbitward@postgres:5432/orbitward
[loggers] [loggers]
keys = root,sqlalchemy,alembic keys = root,sqlalchemy,alembic
@@ -1,4 +1,4 @@
"""Initial OrbitalWard schema. """Initial OrbitWard schema.
Revision ID: 20260522_0001 Revision ID: 20260522_0001
Revises: Revises:
+1 -1
View File
@@ -1 +1 @@
"""OrbitalWard backend package.""" """OrbitWard backend package."""
+1 -1
View File
@@ -5,4 +5,4 @@ router = APIRouter(tags=["health"])
@router.get("/health") @router.get("/health")
def health() -> dict[str, str]: def health() -> dict[str, str]:
return {"status": "ok", "service": "orbitalward-backend"} return {"status": "ok", "service": "orbitward-backend"}
+4 -4
View File
@@ -14,7 +14,7 @@ router = APIRouter(prefix="/notifications/channels", tags=["notifications"])
def _channel_to_read(channel: NotificationChannel) -> NotificationChannelRead: def _channel_to_read(channel: NotificationChannel) -> NotificationChannelRead:
settings = dict(channel.settings or {}) settings = dict(channel.settings or {})
settings.setdefault("username", "OrbitalWard") settings.setdefault("username", "OrbitWard")
return NotificationChannelRead( return NotificationChannelRead(
id=channel.id, id=channel.id,
name=channel.name, name=channel.name,
@@ -40,7 +40,7 @@ def create_channel(
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> NotificationChannelRead: ) -> NotificationChannelRead:
channel_settings = dict(payload.settings or {}) channel_settings = dict(payload.settings or {})
channel_settings.setdefault("username", "OrbitalWard") channel_settings.setdefault("username", "OrbitWard")
channel = NotificationChannel( channel = NotificationChannel(
name=payload.name, name=payload.name,
channel_type=payload.channel_type, channel_type=payload.channel_type,
@@ -70,8 +70,8 @@ def test_channel(
response = httpx.post( response = httpx.post(
url, url,
json={ json={
"username": (channel.settings or {}).get("username") or "OrbitalWard", "username": (channel.settings or {}).get("username") or "OrbitWard",
"text": f"OrbitalWard test notification for {channel.name}", "text": f"OrbitWard test notification for {channel.name}",
}, },
timeout=10, 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: def create_access_token(subject: str) -> str:
expires_at = datetime.now(UTC) + timedelta(minutes=settings.access_token_expire_minutes) expires_at = datetime.now(UTC) + timedelta(minutes=settings.access_token_expire_minutes)
payload = {"sub": subject, "exp": expires_at} payload = {"sub": subject, "exp": expires_at}
return jwt.encode(payload, settings.orbitalward_secret_key, algorithm=ALGORITHM) return jwt.encode(payload, settings.orbitward_secret_key, algorithm=ALGORITHM)
def decode_access_token(token: str) -> str | None: def decode_access_token(token: str) -> str | None:
try: try:
payload = jwt.decode(token, settings.orbitalward_secret_key, algorithms=[ALGORITHM]) payload = jwt.decode(token, settings.orbitward_secret_key, algorithms=[ALGORITHM])
return payload.get("sub") return payload.get("sub")
except JWTError: except JWTError:
return None return None
+8 -4
View File
@@ -1,15 +1,19 @@
from functools import lru_cache from functools import lru_cache
from pydantic import AnyHttpUrl, Field from pydantic import AliasChoices, AnyHttpUrl, Field
from pydantic_settings import BaseSettings, SettingsConfigDict from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings): class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", extra="ignore") model_config = SettingsConfigDict(env_file=".env", extra="ignore")
orbitalward_env: str = "development" orbitward_env: str = Field(default="development", validation_alias=AliasChoices("ORBITWARD_ENV", "ORBITALWARD_ENV"))
orbitalward_secret_key: str = Field(default="change-me", min_length=8) orbitward_secret_key: str = Field(
database_url: str = "postgresql+psycopg://orbitalward:orbitalward@postgres:5432/orbitalward" default="change-me",
min_length=8,
validation_alias=AliasChoices("ORBITWARD_SECRET_KEY", "ORBITALWARD_SECRET_KEY"),
)
database_url: str = "postgresql+psycopg://orbitward:orbitward@postgres:5432/orbitward"
redis_url: str = "redis://redis:6379/0" redis_url: str = "redis://redis:6379/0"
frontend_url: AnyHttpUrl | str = "http://localhost:5173" frontend_url: AnyHttpUrl | str = "http://localhost:5173"
backend_url: AnyHttpUrl | str = "http://localhost:8000" backend_url: AnyHttpUrl | str = "http://localhost:8000"
+1 -1
View File
@@ -7,7 +7,7 @@ from app.core.config import settings
def _fernet() -> Fernet: def _fernet() -> Fernet:
digest = hashlib.sha256(settings.orbitalward_secret_key.encode("utf-8")).digest() digest = hashlib.sha256(settings.orbitward_secret_key.encode("utf-8")).digest()
return Fernet(base64.urlsafe_b64encode(digest)) return Fernet(base64.urlsafe_b64encode(digest))
+1 -1
View File
@@ -25,7 +25,7 @@ async def lifespan(_: FastAPI) -> AsyncIterator[None]:
app = FastAPI( app = FastAPI(
title="OrbitalWard API", title="OrbitWard API",
version="0.1.0", version="0.1.0",
description="Self-hosted infrastructure monitoring API", description="Self-hosted infrastructure monitoring API",
lifespan=lifespan, lifespan=lifespan,
+2 -2
View File
@@ -1,7 +1,7 @@
[project] [project]
name = "orbitalward-backend" name = "orbitward-backend"
version = "0.1.0" version = "0.1.0"
description = "OrbitalWard FastAPI backend" description = "OrbitWard FastAPI backend"
requires-python = ">=3.12" requires-python = ">=3.12"
dependencies = [ dependencies = [
"alembic>=1.13.3", "alembic>=1.13.3",
+7 -7
View File
@@ -11,8 +11,8 @@ def test_notification_channel_does_not_return_saved_secret(client: TestClient, d
json={ json={
"name": "Operations Webhook", "name": "Operations Webhook",
"channel_type": "generic_webhook", "channel_type": "generic_webhook",
"settings": {"username": "OrbitalWard"}, "settings": {"username": "OrbitWard"},
"secret": "https://hooks.example.test/orbitalward", "secret": "https://hooks.example.test/orbitward",
"is_enabled": True, "is_enabled": True,
}, },
) )
@@ -25,8 +25,8 @@ def test_notification_channel_does_not_return_saved_secret(client: TestClient, d
channel = db_session.get(NotificationChannel, body["id"]) channel = db_session.get(NotificationChannel, body["id"])
assert channel is not None assert channel is not None
assert channel.encrypted_secret != "https://hooks.example.test/orbitalward" assert channel.encrypted_secret != "https://hooks.example.test/orbitward"
assert decrypt_secret(channel.encrypted_secret) == "https://hooks.example.test/orbitalward" assert decrypt_secret(channel.encrypted_secret) == "https://hooks.example.test/orbitward"
list_response = client.get("/notifications/channels") list_response = client.get("/notifications/channels")
assert list_response.status_code == 200 assert list_response.status_code == 200
@@ -42,7 +42,7 @@ def test_notification_channel_update_without_secret_preserves_existing_secret(cl
json={ json={
"name": "Mattermost", "name": "Mattermost",
"channel_type": "mattermost", "channel_type": "mattermost",
"settings": {"username": "OrbitalWard"}, "settings": {"username": "OrbitWard"},
"secret": "https://hooks.example.test/mattermost", "secret": "https://hooks.example.test/mattermost",
"is_enabled": True, "is_enabled": True,
}, },
@@ -54,7 +54,7 @@ def test_notification_channel_update_without_secret_preserves_existing_secret(cl
f"/notifications/channels/{channel_id}", f"/notifications/channels/{channel_id}",
json={ json={
"name": "Mattermost Alerts", "name": "Mattermost Alerts",
"settings": {"username": "OrbitalWard Alerts"}, "settings": {"username": "OrbitWard Alerts"},
"is_enabled": False, "is_enabled": False,
}, },
) )
@@ -62,7 +62,7 @@ def test_notification_channel_update_without_secret_preserves_existing_secret(cl
assert update_response.status_code == 200 assert update_response.status_code == 200
body = update_response.json() body = update_response.json()
assert body["name"] == "Mattermost Alerts" assert body["name"] == "Mattermost Alerts"
assert body["settings"]["username"] == "OrbitalWard Alerts" assert body["settings"]["username"] == "OrbitWard Alerts"
assert body["is_enabled"] is False assert body["is_enabled"] is False
assert body["has_secret"] is True assert body["has_secret"] is True
assert "secret" not in body assert "secret" not in body
+6 -4
View File
@@ -1,14 +1,16 @@
name: orbitward
services: services:
postgres: postgres:
image: postgres:16-alpine image: postgres:16-alpine
environment: environment:
POSTGRES_USER: orbitalward POSTGRES_USER: orbitward
POSTGRES_PASSWORD: orbitalward POSTGRES_PASSWORD: orbitward
POSTGRES_DB: orbitalward POSTGRES_DB: orbitward
volumes: volumes:
- postgres-dev-data:/var/lib/postgresql/data - postgres-dev-data:/var/lib/postgresql/data
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U orbitalward -d orbitalward"] test: ["CMD-SHELL", "pg_isready -U orbitward -d orbitward"]
interval: 10s interval: 10s
timeout: 5s timeout: 5s
retries: 5 retries: 5
+6 -4
View File
@@ -1,14 +1,16 @@
name: orbitward
services: services:
postgres: postgres:
image: postgres:16-alpine image: postgres:16-alpine
environment: environment:
POSTGRES_USER: orbitalward POSTGRES_USER: orbitward
POSTGRES_PASSWORD: orbitalward POSTGRES_PASSWORD: orbitward
POSTGRES_DB: orbitalward POSTGRES_DB: orbitward
volumes: volumes:
- postgres-data:/var/lib/postgresql/data - postgres-data:/var/lib/postgresql/data
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U orbitalward -d orbitalward"] test: ["CMD-SHELL", "pg_isready -U orbitward -d orbitward"]
interval: 10s interval: 10s
timeout: 5s timeout: 5s
retries: 5 retries: 5
+16 -16
View File
@@ -4,9 +4,9 @@ Last updated: 2026-05-26
## Current Identity ## Current Identity
- Product name: OrbitalWard - Product name: OrbitWard
- Local repository path: `/home/ksmith/projects/OrbitalWard` - Local repository path: `/home/ksmith/projects/OrbitalWard` until the working directory is moved
- Git remote: `https://git.firebugit.com/ksmith/OrbitalWard.git` - Git remote: `https://git.firebugit.com/ksmith/OrbitWard.git`
- Main branch: `main` - Main branch: `main`
- Latest pushed commit: check `origin/main` with `git log -1 --oneline origin/main` - Latest pushed commit: check `origin/main` with `git log -1 --oneline origin/main`
@@ -15,14 +15,14 @@ The project was previously named InfraPulse. Do not reintroduce the old name in
## Gitea Access ## Gitea Access
- Gitea API base: `https://git.firebugit.com/api/v1` - Gitea API base: `https://git.firebugit.com/api/v1`
- Repository API path: `/repos/ksmith/OrbitalWard` - Repository API path: `/repos/ksmith/OrbitWard`
- Access token file: `/home/ksmith/.codex_security/gitea_token` - Access token file: `/home/ksmith/.codex_security/gitea_token`
Never print the token value. Read it only inside commands that call the Gitea API. Never print the token value. Read it only inside commands that call the Gitea API.
## Current Product State ## Current Product State
OrbitalWard is a secure monitoring appliance focused on the v0.1 vertical slice: OrbitWard is a secure monitoring appliance focused on the v0.1 vertical slice:
- Authenticated FastAPI backend with SQLAlchemy, Alembic, Pydantic, and JWT auth. - Authenticated FastAPI backend with SQLAlchemy, Alembic, Pydantic, and JWT auth.
- React, TypeScript, Vite, and Tailwind frontend. - React, TypeScript, Vite, and Tailwind frontend.
@@ -55,18 +55,18 @@ Recent Docker checks:
Earlier rename and monitor work also verified: Earlier rename and monitor work also verified:
- `docker compose -f docker-compose.dev.yml up -d --build` - `docker compose -f docker-compose.dev.yml up -d --build`
- Backend health returned `{"status":"ok","service":"orbitalward-backend"}`. - Backend health returned `{"status":"ok","service":"orbitward-backend"}`.
- Direct worker probes for TCP and ICMP ping checks passed inside the Docker network. - Direct worker probes for TCP and ICMP ping checks passed inside the Docker network.
- API probe created and deleted one ping monitor and one TCP monitor successfully. - API probe created and deleted one ping monitor and one TCP monitor successfully.
The final Compose project uses `orbitalward-*` containers, images, network, and volumes. The final Compose project uses `orbitward-*` containers, images, network, and volumes.
## Important Implementation Notes ## Important Implementation Notes
- `ORBITALWARD_SECRET_KEY` is the encryption/JWT secret environment variable. - `ORBITWARD_SECRET_KEY` is the encryption/JWT secret environment variable.
- `DATABASE_URL` now defaults to the `orbitalward` database/user in Compose. - `DATABASE_URL` now defaults to the `orbitward` database/user in Compose.
- The frontend local storage key is `orbitalward_token`. - The frontend local storage key is `orbitward_token`.
- Notification default username is `OrbitalWard`. - Notification default username is `OrbitWard`.
- The TLS expiry check lives in `worker/app/collectors/website.py` and is enabled per monitor through JSON config fields: - The TLS expiry check lives in `worker/app/collectors/website.py` and is enabled per monitor through JSON config fields:
- `check_tls_expiry` - `check_tls_expiry`
- `tls_warning_days` - `tls_warning_days`
@@ -75,11 +75,11 @@ The final Compose project uses `orbitalward-*` containers, images, network, and
Use the Gitea API with the token file above. Useful endpoints: Use the Gitea API with the token file above. Useful endpoints:
- List issues: `GET /repos/ksmith/OrbitalWard/issues?state=all` - List issues: `GET /repos/ksmith/OrbitWard/issues?state=all`
- Create issue: `POST /repos/ksmith/OrbitalWard/issues` - Create issue: `POST /repos/ksmith/OrbitWard/issues`
- Update issue: `PATCH /repos/ksmith/OrbitalWard/issues/{index}` - Update issue: `PATCH /repos/ksmith/OrbitWard/issues/{index}`
- List milestones: `GET /repos/ksmith/OrbitalWard/milestones` - List milestones: `GET /repos/ksmith/OrbitWard/milestones`
- List labels: `GET /repos/ksmith/OrbitalWard/labels` - List labels: `GET /repos/ksmith/OrbitWard/labels`
Issue source docs: Issue source docs:
+1 -1
View File
@@ -26,4 +26,4 @@ Initial channels:
- Zoom Team Chat incoming webhook - Zoom Team Chat incoming webhook
- Generic webhook - Generic webhook
Alert messages should be human-readable and include asset, check, status, duration, timestamps, and a link back to OrbitalWard. Alert messages should be human-readable and include asset, check, status, duration, timestamps, and a link back to OrbitWard.
+1 -1
View File
@@ -1,6 +1,6 @@
# Architecture # Architecture
OrbitalWard is a monorepo with four main areas: OrbitWard is a monorepo with four main areas:
- `backend`: FastAPI service exposing REST endpoints and owning database access. - `backend`: FastAPI service exposing REST endpoints and owning database access.
- `worker`: Background scheduler and collectors for checks and alert evaluation. - `worker`: Background scheduler and collectors for checks and alert evaluation.
+2 -2
View File
@@ -1,6 +1,6 @@
# Discovery Design # Discovery Design
Guided discovery is a core OrbitalWard workflow. Guided discovery is a core OrbitWard workflow.
```text ```text
Add target Add target
@@ -16,7 +16,7 @@ Create monitors and optional alert rules
## Monitor vs Alert Separation ## Monitor vs Alert Separation
OrbitalWard must allow monitoring without alerting. Every discovered item should eventually support separate choices: OrbitWard must allow monitoring without alerting. Every discovered item should eventually support separate choices:
- Collect metric - Collect metric
- Graph metric - Graph metric
+1 -1
View File
@@ -58,7 +58,7 @@
44. Show and graph SNMP interface throughput 44. Show and graph SNMP interface throughput
45. Build asset detail UI for monitors, metrics, and context 45. Build asset detail UI for monitors, metrics, and context
46. Refine metric-only monitor status semantics 46. Refine metric-only monitor status semantics
47. Rename product from OrbitalWard to OrbitWard 47. Rename product to OrbitWard
## Current Implementation Snapshot ## Current Implementation Snapshot
+2 -2
View File
@@ -1,11 +1,11 @@
# Plugin Design # Plugin Design
Plugins will let OrbitalWard add collectors and discovery logic without hard-coding every integration into the core API. Plugins will let OrbitWard add collectors and discovery logic without hard-coding every integration into the core API.
Target shape: Target shape:
```python ```python
class OrbitalWardPlugin: class OrbitWardPlugin:
name: str name: str
display_name: str display_name: str
+4 -4
View File
@@ -1,10 +1,10 @@
# OrbitalWard Progress # OrbitWard Progress
Last updated: 2026-05-26 Last updated: 2026-05-26
## Current State ## Current State
OrbitalWard has a working Docker Compose development stack with PostgreSQL, Redis, FastAPI backend, Python worker, and React/Vite frontend. OrbitWard has a working Docker Compose development stack with PostgreSQL, Redis, FastAPI backend, Python worker, and React/Vite frontend.
Implemented foundation: Implemented foundation:
@@ -35,7 +35,7 @@ Implemented notification slice:
- Create, edit, test, and delete notification channels from the UI. - Create, edit, test, and delete notification channels from the UI.
- Generic webhook, Mattermost, and Zoom Team Chat channel types. - Generic webhook, Mattermost, and Zoom Team Chat channel types.
- Webhook URLs encrypted at rest using `ORBITALWARD_SECRET_KEY`. - Webhook URLs encrypted at rest using `ORBITWARD_SECRET_KEY`.
- Saved webhook URLs are not returned to the UI. - Saved webhook URLs are not returned to the UI.
- Configurable post username per notification channel. - Configurable post username per notification channel.
- Worker sends incident open and recovery notifications. - Worker sends incident open and recovery notifications.
@@ -148,4 +148,4 @@ Default local login comes from `.env`:
- `INITIAL_ADMIN_EMAIL=admin@example.com` - `INITIAL_ADMIN_EMAIL=admin@example.com`
- `INITIAL_ADMIN_PASSWORD=change-me` - `INITIAL_ADMIN_PASSWORD=change-me`
Change these values before using OrbitalWard outside local development. Change these values before using OrbitWard outside local development.
+2 -2
View File
@@ -1,6 +1,6 @@
# Security # Security
OrbitalWard must be secure from the beginning because it will store infrastructure credentials. OrbitWard must be secure from the beginning because it will store infrastructure credentials.
## Authentication ## Authentication
@@ -19,7 +19,7 @@ Credential records are modeled separately from monitors and assets. Secret field
Rules: Rules:
- Use `ORBITALWARD_SECRET_KEY` from the environment. - Use `ORBITWARD_SECRET_KEY` from the environment.
- Never log secrets. - Never log secrets.
- Mask saved secrets in the UI. - Mask saved secrets in the UI.
- Audit credential create, update, and delete events. - Audit credential create, update, and delete events.
+3 -3
View File
@@ -1,12 +1,12 @@
# OrbitalWard Vision # OrbitWard Vision
OrbitalWard is a secure, self-hosted monitoring platform for homelabs, small businesses, and internal IT teams. OrbitWard 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. 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 ## Design Philosophy
OrbitalWard exposes intent, not implementation details. OrbitWard 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". 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> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>OrbitalWard</title> <title>OrbitWard</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+2 -2
View File
@@ -1,11 +1,11 @@
{ {
"name": "orbitalward-frontend", "name": "orbitward-frontend",
"version": "0.1.0", "version": "0.1.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "orbitalward-frontend", "name": "orbitward-frontend",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@vitejs/plugin-react": "^5.0.0", "@vitejs/plugin-react": "^5.0.0",
+1 -1
View File
@@ -1,5 +1,5 @@
{ {
"name": "orbitalward-frontend", "name": "orbitward-frontend",
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"type": "module", "type": "module",
+1 -1
View File
@@ -65,7 +65,7 @@ export function App() {
} }
if (auth.loading) { if (auth.loading) {
return <div className="flex min-h-screen items-center justify-center bg-[#090d13] text-sm text-slate-300">Loading OrbitalWard...</div>; return <div className="flex min-h-screen items-center justify-center bg-[#090d13] text-sm text-slate-300">Loading OrbitWard...</div>;
} }
if (!auth.user || !auth.token) { if (!auth.user || !auth.token) {
+1 -1
View File
@@ -49,7 +49,7 @@ export function Shell({ children, currentPage, onPageChange, onSignOut, user }:
<Shield size={19} /> <Shield size={19} />
</div> </div>
<div> <div>
<div className="text-base font-semibold">OrbitalWard</div> <div className="text-base font-semibold">OrbitWard</div>
<div className="text-xs text-slate-400">Monitoring appliance</div> <div className="text-xs text-slate-400">Monitoring appliance</div>
</div> </div>
</div> </div>
+14 -2
View File
@@ -3,10 +3,20 @@ import { useEffect, useMemo, useState } from "react";
import { api, login } from "../api/client"; import { api, login } from "../api/client";
import type { User } from "../types/api"; import type { User } from "../types/api";
const TOKEN_KEY = "orbitalward_token"; const TOKEN_KEY = "orbitward_token";
const LEGACY_TOKEN_KEY = "orbitalward_token";
export function useAuth() { export function useAuth() {
const [token, setToken] = useState<string | null>(() => localStorage.getItem(TOKEN_KEY)); const [token, setToken] = useState<string | null>(() => {
const currentToken = localStorage.getItem(TOKEN_KEY);
if (currentToken) return currentToken;
const legacyToken = localStorage.getItem(LEGACY_TOKEN_KEY);
if (legacyToken) {
localStorage.setItem(TOKEN_KEY, legacyToken);
localStorage.removeItem(LEGACY_TOKEN_KEY);
}
return legacyToken;
});
const [user, setUser] = useState<User | null>(null); const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(Boolean(token)); const [loading, setLoading] = useState(Boolean(token));
@@ -26,6 +36,7 @@ export function useAuth() {
}) })
.catch(() => { .catch(() => {
localStorage.removeItem(TOKEN_KEY); localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(LEGACY_TOKEN_KEY);
if (!cancelled) { if (!cancelled) {
setToken(null); setToken(null);
setUser(null); setUser(null);
@@ -52,6 +63,7 @@ export function useAuth() {
}, },
signOut: () => { signOut: () => {
localStorage.removeItem(TOKEN_KEY); localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(LEGACY_TOKEN_KEY);
setToken(null); setToken(null);
setUser(null); setUser(null);
}, },
+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"> <div className="flex h-10 w-10 items-center justify-center rounded-md bg-pulse text-slate-950">
<Shield size={22} /> <Shield size={22} />
</div> </div>
<div className="text-lg font-semibold">OrbitalWard</div> <div className="text-lg font-semibold">OrbitWard</div>
</div> </div>
<div className="max-w-3xl pb-6"> <div className="max-w-3xl pb-6">
<h1 className="max-w-2xl text-4xl font-semibold leading-tight lg:text-6xl"> <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 [name, setName] = useState("");
const [channelType, setChannelType] = useState("generic_webhook"); const [channelType, setChannelType] = useState("generic_webhook");
const [url, setUrl] = useState(""); const [url, setUrl] = useState("");
const [username, setUsername] = useState("OrbitalWard"); const [username, setUsername] = useState("OrbitWard");
const [enabled, setEnabled] = useState(true); const [enabled, setEnabled] = useState(true);
const [editingChannelId, setEditingChannelId] = useState<number | null>(null); const [editingChannelId, setEditingChannelId] = useState<number | null>(null);
const [busyId, setBusyId] = 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, { await api.updateNotificationChannel(token, editingChannelId, {
name, name,
channel_type: channelType, channel_type: channelType,
settings: { username: username.trim() || "OrbitalWard" }, settings: { username: username.trim() || "OrbitWard" },
secret: url.trim() ? url.trim() : undefined, secret: url.trim() ? url.trim() : undefined,
is_enabled: enabled, is_enabled: enabled,
}); });
@@ -46,7 +46,7 @@ export function NotificationsPage({ token }: NotificationsPageProps) {
await api.createNotificationChannel(token, { await api.createNotificationChannel(token, {
name, name,
channel_type: channelType, channel_type: channelType,
settings: { username: username.trim() || "OrbitalWard" }, settings: { username: username.trim() || "OrbitWard" },
secret: url, secret: url,
is_enabled: enabled, is_enabled: enabled,
}); });
@@ -65,7 +65,7 @@ export function NotificationsPage({ token }: NotificationsPageProps) {
setName(channel.name); setName(channel.name);
setChannelType(channel.channel_type); setChannelType(channel.channel_type);
setUrl(""); setUrl("");
setUsername(String(channel.settings.username || "OrbitalWard")); setUsername(String(channel.settings.username || "OrbitWard"));
setEnabled(channel.is_enabled); setEnabled(channel.is_enabled);
setMessage(null); setMessage(null);
} }
@@ -75,7 +75,7 @@ export function NotificationsPage({ token }: NotificationsPageProps) {
setName(""); setName("");
setChannelType("generic_webhook"); setChannelType("generic_webhook");
setUrl(""); setUrl("");
setUsername("OrbitalWard"); setUsername("OrbitWard");
setEnabled(true); 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_260px] md:items-center"> <div key={channel.id} className="grid gap-3 p-4 md:grid-cols-[1fr_140px_90px_260px] md:items-center">
<div> <div>
<div className="font-medium">{channel.name}</div> <div className="font-medium">{channel.name}</div>
<div className="text-sm text-slate-400">{String(channel.settings.username || "OrbitalWard")}</div> <div className="text-sm text-slate-400">{String(channel.settings.username || "OrbitWard")}</div>
<div className="text-xs text-slate-500">{channel.has_secret ? "Secret stored" : "No secret"}</div> <div className="text-xs text-slate-500">{channel.has_secret ? "Secret stored" : "No secret"}</div>
</div> </div>
<div className="text-sm text-slate-300">{channel.channel_type}</div> <div className="text-sm text-slate-300">{channel.channel_type}</div>
+1 -1
View File
@@ -1 +1 @@
"""OrbitalWard worker package.""" """OrbitWard worker package."""
+1 -1
View File
@@ -78,7 +78,7 @@ def _resolve_ipv4(host: str) -> str:
def _build_icmp_echo_request(identifier: int, sequence: int) -> bytes: def _build_icmp_echo_request(identifier: int, sequence: int) -> bytes:
payload = b"OrbitalWard ping" payload = b"OrbitWard ping"
header = struct.pack("!BBHHH", 8, 0, 0, identifier, sequence) header = struct.pack("!BBHHH", 8, 0, 0, identifier, sequence)
checksum = _icmp_checksum(header + payload) checksum = _icmp_checksum(header + payload)
header = struct.pack("!BBHHH", 8, 0, checksum, identifier, sequence) header = struct.pack("!BBHHH", 8, 0, checksum, identifier, sequence)
+7 -3
View File
@@ -1,14 +1,18 @@
from functools import lru_cache from functools import lru_cache
from pydantic import AliasChoices, Field
from pydantic_settings import BaseSettings, SettingsConfigDict from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings): class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", extra="ignore") model_config = SettingsConfigDict(env_file=".env", extra="ignore")
orbitalward_env: str = "development" orbitward_env: str = Field(default="development", validation_alias=AliasChoices("ORBITWARD_ENV", "ORBITALWARD_ENV"))
orbitalward_secret_key: str = "change-me" orbitward_secret_key: str = Field(
database_url: str = "postgresql+psycopg://orbitalward:orbitalward@postgres:5432/orbitalward" default="change-me",
validation_alias=AliasChoices("ORBITWARD_SECRET_KEY", "ORBITALWARD_SECRET_KEY"),
)
database_url: str = "postgresql+psycopg://orbitward:orbitward@postgres:5432/orbitward"
redis_url: str = "redis://redis:6379/0" redis_url: str = "redis://redis:6379/0"
frontend_url: str = "http://localhost:5173" frontend_url: str = "http://localhost:5173"
backend_url: str = "http://localhost:8000" backend_url: str = "http://localhost:8000"
+3 -3
View File
@@ -24,7 +24,7 @@ class Scheduler:
self._stopped = asyncio.Event() self._stopped = asyncio.Event()
async def run(self) -> None: async def run(self) -> None:
logger.info("OrbitalWard worker started for %s", settings.orbitalward_env) logger.info("OrbitWard worker started for %s", settings.orbitward_env)
while not self._stopped.is_set(): while not self._stopped.is_set():
await self.tick() await self.tick()
try: try:
@@ -249,7 +249,7 @@ class Scheduler:
await self._post_webhook( await self._post_webhook(
url, url,
self._format_incident_message(incident, monitor, event_type), self._format_incident_message(incident, monitor, event_type),
str((channel.settings or {}).get("username") or "OrbitalWard"), str((channel.settings or {}).get("username") or "OrbitWard"),
) )
except httpx.HTTPError: except httpx.HTTPError:
logger.exception("Notification delivery failed for channel %s", channel.id) logger.exception("Notification delivery failed for channel %s", channel.id)
@@ -296,5 +296,5 @@ class Scheduler:
last_message = (incident.details or {}).get("last_message") last_message = (incident.details or {}).get("last_message")
if last_message: if last_message:
body.append(f"Last response: {last_message}") body.append(f"Last response: {last_message}")
body.extend(["", f"View in OrbitalWard: {settings.frontend_url}/incidents/{incident.id}"]) body.extend(["", f"View in OrbitWard: {settings.frontend_url}/incidents/{incident.id}"])
return "\n".join(str(line) for line in body) 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: def _fernet() -> Fernet:
digest = hashlib.sha256(settings.orbitalward_secret_key.encode("utf-8")).digest() digest = hashlib.sha256(settings.orbitward_secret_key.encode("utf-8")).digest()
return Fernet(base64.urlsafe_b64encode(digest)) return Fernet(base64.urlsafe_b64encode(digest))
+2 -2
View File
@@ -1,7 +1,7 @@
[project] [project]
name = "orbitalward-worker" name = "orbitward-worker"
version = "0.1.0" version = "0.1.0"
description = "OrbitalWard background worker" description = "OrbitWard background worker"
requires-python = ">=3.12" requires-python = ">=3.12"
dependencies = [ dependencies = [
"cryptography>=48.0.0", "cryptography>=48.0.0",
+5 -5
View File
@@ -16,7 +16,7 @@ from app.scheduler import Scheduler
def encrypt_secret(value: str) -> str: def encrypt_secret(value: str) -> str:
digest = hashlib.sha256(settings.orbitalward_secret_key.encode("utf-8")).digest() digest = hashlib.sha256(settings.orbitward_secret_key.encode("utf-8")).digest()
return Fernet(base64.urlsafe_b64encode(digest)).encrypt(value.encode("utf-8")).decode("utf-8") return Fernet(base64.urlsafe_b64encode(digest)).encrypt(value.encode("utf-8")).decode("utf-8")
@@ -102,8 +102,8 @@ class SchedulerTestCase(unittest.IsolatedAsyncioTestCase):
channel = NotificationChannel( channel = NotificationChannel(
name="Ops Webhook", name="Ops Webhook",
channel_type="generic_webhook", channel_type="generic_webhook",
settings={"username": "OrbitalWard"}, settings={"username": "OrbitWard"},
encrypted_secret=encrypt_secret("https://hooks.example.test/orbitalward"), encrypted_secret=encrypt_secret("https://hooks.example.test/orbitward"),
is_enabled=True, is_enabled=True,
) )
self.db.add(channel) self.db.add(channel)
@@ -121,8 +121,8 @@ class SchedulerTestCase(unittest.IsolatedAsyncioTestCase):
assert incident is not None assert incident is not None
assert incident.status == "open" assert incident.status == "open"
assert len(scheduler.posts) == 1 assert len(scheduler.posts) == 1
assert scheduler.posts[0]["url"] == "https://hooks.example.test/orbitalward" assert scheduler.posts[0]["url"] == "https://hooks.example.test/orbitward"
assert scheduler.posts[0]["username"] == "OrbitalWard" assert scheduler.posts[0]["username"] == "OrbitWard"
assert incident.details["notification_history"][0]["event"] == "opened" assert incident.details["notification_history"][0]["event"] == "opened"
await scheduler._send_incident_notifications(self.db, incident, monitor, "opened", datetime.now(UTC)) await scheduler._send_incident_notifications(self.db, incident, monitor, "opened", datetime.now(UTC))