Initial InfraPulse scaffold

This commit is contained in:
Keith Smith
2026-05-22 17:36:40 -06:00
commit a707186a5e
92 changed files with 6918 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
INFRAPULSE_ENV=development
INFRAPULSE_SECRET_KEY=change-me
DATABASE_URL=postgresql+psycopg://infrapulse:infrapulse@postgres:5432/infrapulse
REDIS_URL=redis://redis:6379/0
FRONTEND_URL=http://localhost:5173
BACKEND_URL=http://localhost:8000
INITIAL_ADMIN_EMAIL=admin@example.com
INITIAL_ADMIN_PASSWORD=change-me
+16
View File
@@ -0,0 +1,16 @@
.env
.venv/
__pycache__/
*.py[cod]
.pytest_cache/
.mypy_cache/
.ruff_cache/
node_modules/
dist/
coverage/
*.tsbuildinfo
.DS_Store
*.log
docker-data/
+20
View File
@@ -0,0 +1,20 @@
# InfraPulse 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.
## Product Guardrails
- Do not show raw SNMP OIDs in the normal UI.
- Keep monitoring separate from alerting.
- Use friendly names, profiles, and guided setup instead of raw configuration.
- Do not include LANCache in product scope.
- Avoid building full NMS features before the v0.1 vertical slice is stable.
## Engineering Guardrails
- Python backend: FastAPI, SQLAlchemy, Alembic, Pydantic.
- Frontend: React, TypeScript, Vite, Tailwind.
- Deployment: Docker Compose first.
- Authentication must exist from the beginning.
- Store secrets encrypted at rest before real credential storage is enabled.
- Never log secrets or return saved secret values after creation.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 InfraPulse 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
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+78
View File
@@ -0,0 +1,78 @@
# InfraPulse
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.
## Current Status
This repository contains the initial project foundation:
- FastAPI backend skeleton with authentication, roles, health checks, and core data models
- PostgreSQL and Alembic migration foundation
- Redis-backed worker service skeleton
- React, TypeScript, Vite, and Tailwind frontend shell
- Docker Compose development environment
- Initial product, architecture, security, discovery, alerting, plugin, and Gitea issue docs
The first working vertical slice is planned around website monitoring:
1. User logs in.
2. User adds a website monitor.
3. Worker checks HTTP status and expected text.
4. Results are stored.
5. Dashboard shows status.
6. Alert rules create incidents.
7. Mattermost, Zoom, or generic webhook notifications are sent.
8. Recovery notifications are sent when the website recovers.
## Development Setup
Prerequisites:
- Docker and Docker Compose
- Node.js 20+ if running the frontend outside Docker
- Python 3.12+ if running backend or worker outside Docker
Copy the environment file:
```bash
cp .env.example .env
```
Start the development stack:
```bash
docker compose -f docker-compose.dev.yml up --build
```
Services:
- Frontend: http://localhost:5173
- Backend API: http://localhost:8000
- API docs: http://localhost:8000/docs
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.
## Project Structure
```text
backend/ FastAPI API, models, schemas, auth, migrations, and tests
frontend/ React/Vite app with the initial authenticated dashboard shell
worker/ Background scheduler and collector skeleton
docs/ Product, architecture, security, alerting, discovery, and issue docs
scripts/ Development helper scripts
```
## Roadmap
See [docs/roadmap.md](docs/roadmap.md) and [docs/gitea-issues.md](docs/gitea-issues.md).
## License
MIT
+3
View File
@@ -0,0 +1,3 @@
__pycache__/
.pytest_cache/
.venv/
+13
View File
@@ -0,0 +1,13 @@
FROM python:3.12-slim
WORKDIR /app
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
COPY pyproject.toml ./
RUN pip install --no-cache-dir -e .
COPY . .
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
+38
View File
@@ -0,0 +1,38 @@
[alembic]
script_location = alembic
prepend_sys_path = .
sqlalchemy.url = postgresql+psycopg://infrapulse:infrapulse@postgres:5432/infrapulse
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
+46
View File
@@ -0,0 +1,46 @@
from logging.config import fileConfig
from alembic import context
from sqlalchemy import engine_from_config, pool
from app.core.config import settings
from app.db.base import Base
config = context.config
config.set_main_option("sqlalchemy.url", settings.database_url)
if config.config_file_name is not None:
fileConfig(config.config_file_name)
target_metadata = Base.metadata
def run_migrations_offline() -> None:
context.configure(
url=settings.database_url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
+25
View File
@@ -0,0 +1,25 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}
@@ -0,0 +1,145 @@
"""Initial InfraPulse schema.
Revision ID: 20260522_0001
Revises:
Create Date: 2026-05-22
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "20260522_0001"
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"users",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("email", sa.String(length=320), nullable=False, unique=True, index=True),
sa.Column("display_name", sa.String(length=120), nullable=False),
sa.Column("hashed_password", sa.String(length=255), nullable=False),
sa.Column("role", sa.String(length=32), nullable=False, server_default="owner"),
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.text("true")),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
)
op.create_table(
"assets",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("name", sa.String(length=160), nullable=False),
sa.Column("asset_type", sa.String(length=64), nullable=False),
sa.Column("address", sa.String(length=255), nullable=True),
sa.Column("status", sa.String(length=32), nullable=False, server_default="unknown"),
sa.Column("metadata", sa.JSON(), nullable=False, server_default=sa.text("'{}'::json")),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
)
op.create_table(
"credentials",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("name", sa.String(length=160), nullable=False),
sa.Column("credential_type", sa.String(length=64), nullable=False),
sa.Column("encrypted_secret", sa.Text(), nullable=True),
sa.Column("metadata", sa.JSON(), nullable=False, server_default=sa.text("'{}'::json")),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
)
op.create_table(
"notification_channels",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("name", sa.String(length=160), nullable=False),
sa.Column("channel_type", sa.String(length=64), nullable=False),
sa.Column("settings", sa.JSON(), nullable=False, server_default=sa.text("'{}'::json")),
sa.Column("encrypted_secret", sa.Text(), nullable=True),
sa.Column("is_enabled", sa.Boolean(), nullable=False, server_default=sa.text("true")),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
)
op.create_table(
"monitors",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("asset_id", sa.Integer(), sa.ForeignKey("assets.id", ondelete="CASCADE"), nullable=True),
sa.Column("name", sa.String(length=160), nullable=False),
sa.Column("monitor_type", sa.String(length=64), nullable=False),
sa.Column("target", sa.String(length=512), nullable=False),
sa.Column("config", sa.JSON(), nullable=False, server_default=sa.text("'{}'::json")),
sa.Column("interval_seconds", sa.Integer(), nullable=False, server_default="60"),
sa.Column("status", sa.String(length=32), nullable=False, server_default="unknown"),
sa.Column("last_checked_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
)
op.create_table(
"alert_rules",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("monitor_id", sa.Integer(), sa.ForeignKey("monitors.id", ondelete="CASCADE"), nullable=False),
sa.Column("name", sa.String(length=160), nullable=False),
sa.Column("severity", sa.String(length=32), nullable=False, server_default="warning"),
sa.Column("condition", sa.JSON(), nullable=False, server_default=sa.text("'{}'::json")),
sa.Column("failure_threshold", sa.Integer(), nullable=False, server_default="3"),
sa.Column("cooldown_seconds", sa.Integer(), nullable=False, server_default="300"),
sa.Column("is_enabled", sa.Boolean(), nullable=False, server_default=sa.text("true")),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
)
op.create_table(
"check_results",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("monitor_id", sa.Integer(), sa.ForeignKey("monitors.id", ondelete="CASCADE"), nullable=False),
sa.Column("status", sa.String(length=32), nullable=False),
sa.Column("response_time_ms", sa.Integer(), nullable=True),
sa.Column("message", sa.Text(), nullable=True),
sa.Column("observed_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
)
op.create_table(
"metrics",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("monitor_id", sa.Integer(), sa.ForeignKey("monitors.id", ondelete="CASCADE"), nullable=False),
sa.Column("name", sa.String(length=120), nullable=False),
sa.Column("value", sa.Float(), nullable=False),
sa.Column("unit", sa.String(length=32), nullable=True),
sa.Column("observed_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
)
op.create_table(
"incidents",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("asset_id", sa.Integer(), sa.ForeignKey("assets.id", ondelete="SET NULL"), nullable=True),
sa.Column("monitor_id", sa.Integer(), sa.ForeignKey("monitors.id", ondelete="SET NULL"), nullable=True),
sa.Column("alert_rule_id", sa.Integer(), sa.ForeignKey("alert_rules.id", ondelete="SET NULL"), nullable=True),
sa.Column("title", sa.String(length=240), nullable=False),
sa.Column("severity", sa.String(length=32), nullable=False),
sa.Column("status", sa.String(length=32), nullable=False, server_default="open"),
sa.Column("opened_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("resolved_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("acknowledged_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("silenced_until", sa.DateTime(timezone=True), nullable=True),
sa.Column("details", sa.JSON(), nullable=False, server_default=sa.text("'{}'::json")),
)
op.create_table(
"audit_events",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("actor_user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
sa.Column("event_type", sa.String(length=120), nullable=False),
sa.Column("target_type", sa.String(length=120), nullable=True),
sa.Column("target_id", sa.String(length=120), nullable=True),
sa.Column("details", sa.JSON(), nullable=False, server_default=sa.text("'{}'::json")),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
)
def downgrade() -> None:
op.drop_table("audit_events")
op.drop_table("incidents")
op.drop_table("metrics")
op.drop_table("check_results")
op.drop_table("alert_rules")
op.drop_table("monitors")
op.drop_table("notification_channels")
op.drop_table("credentials")
op.drop_table("assets")
op.drop_table("users")
+1
View File
@@ -0,0 +1 @@
"""InfraPulse backend package."""
+1
View File
@@ -0,0 +1 @@
"""API routers."""
+106
View File
@@ -0,0 +1,106 @@
from datetime import UTC, datetime, timedelta
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.auth.dependencies import get_current_user, require_role
from app.db.session import get_db
from app.models import AlertRule, Incident, User
from app.schemas.core import AlertRuleCreate, AlertRuleRead, AlertRuleUpdate, IncidentRead
router = APIRouter(tags=["alerts"])
@router.get("/alerts/rules", response_model=list[AlertRuleRead])
def list_alert_rules(_: User = Depends(get_current_user), db: Session = Depends(get_db)) -> list[AlertRule]:
return list(db.scalars(select(AlertRule).order_by(AlertRule.name)).all())
@router.post("/alerts/rules", response_model=AlertRuleRead)
def create_alert_rule(
payload: AlertRuleCreate,
_: User = Depends(require_role("admin")),
db: Session = Depends(get_db),
) -> AlertRule:
rule = AlertRule(**payload.model_dump())
db.add(rule)
db.commit()
db.refresh(rule)
return rule
@router.patch("/alerts/rules/{rule_id}", response_model=AlertRuleRead)
def update_alert_rule(
rule_id: int,
payload: AlertRuleUpdate,
_: User = Depends(require_role("admin")),
db: Session = Depends(get_db),
) -> AlertRule:
rule = db.get(AlertRule, rule_id)
if rule is None:
raise HTTPException(status_code=404, detail="Alert rule not found")
for field, value in payload.model_dump(exclude_unset=True).items():
setattr(rule, field, value)
db.commit()
db.refresh(rule)
return rule
@router.delete("/alerts/rules/{rule_id}", status_code=204)
def delete_alert_rule(
rule_id: int,
_: User = Depends(require_role("admin")),
db: Session = Depends(get_db),
) -> None:
rule = db.get(AlertRule, rule_id)
if rule is None:
raise HTTPException(status_code=404, detail="Alert rule not found")
db.delete(rule)
db.commit()
@router.get("/incidents", response_model=list[IncidentRead])
def list_incidents(_: User = Depends(get_current_user), db: Session = Depends(get_db)) -> list[Incident]:
return list(db.scalars(select(Incident).order_by(Incident.opened_at.desc())).all())
@router.get("/incidents/{incident_id}", response_model=IncidentRead)
def get_incident(incident_id: int, _: User = Depends(get_current_user), db: Session = Depends(get_db)) -> Incident:
incident = db.get(Incident, incident_id)
if incident is None:
raise HTTPException(status_code=404, detail="Incident not found")
return incident
@router.post("/incidents/{incident_id}/acknowledge", response_model=IncidentRead)
def acknowledge_incident(
incident_id: int,
_: User = Depends(require_role("operator")),
db: Session = Depends(get_db),
) -> Incident:
incident = db.get(Incident, incident_id)
if incident is None:
raise HTTPException(status_code=404, detail="Incident not found")
incident.acknowledged_at = datetime.now(UTC)
db.commit()
db.refresh(incident)
return incident
@router.post("/incidents/{incident_id}/silence", response_model=IncidentRead)
def silence_incident(
incident_id: int,
minutes: int = 60,
_: User = Depends(require_role("operator")),
db: Session = Depends(get_db),
) -> Incident:
incident = db.get(Incident, incident_id)
if incident is None:
raise HTTPException(status_code=404, detail="Incident not found")
incident.silenced_until = datetime.now(UTC) + timedelta(minutes=minutes)
db.commit()
db.refresh(incident)
return incident
+90
View File
@@ -0,0 +1,90 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.auth.dependencies import get_current_user, require_role
from app.db.session import get_db
from app.models import Asset, User
from app.schemas.core import AssetCreate, AssetRead, AssetUpdate
router = APIRouter(prefix="/assets", tags=["assets"])
def _asset_to_read(asset: Asset) -> AssetRead:
return AssetRead(
id=asset.id,
name=asset.name,
asset_type=asset.asset_type,
address=asset.address,
status=asset.status,
metadata=asset.extra,
created_at=asset.created_at,
updated_at=asset.updated_at,
)
@router.get("", response_model=list[AssetRead])
def list_assets(_: User = Depends(get_current_user), db: Session = Depends(get_db)) -> list[AssetRead]:
assets = db.scalars(select(Asset).order_by(Asset.name)).all()
return [_asset_to_read(asset) for asset in assets]
@router.post("", response_model=AssetRead)
def create_asset(
payload: AssetCreate,
_: User = Depends(require_role("admin")),
db: Session = Depends(get_db),
) -> AssetRead:
asset = Asset(
name=payload.name,
asset_type=payload.asset_type,
address=payload.address,
extra=payload.metadata,
)
db.add(asset)
db.commit()
db.refresh(asset)
return _asset_to_read(asset)
@router.get("/{asset_id}", response_model=AssetRead)
def get_asset(asset_id: int, _: User = Depends(get_current_user), db: Session = Depends(get_db)) -> AssetRead:
asset = db.get(Asset, asset_id)
if asset is None:
raise HTTPException(status_code=404, detail="Asset not found")
return _asset_to_read(asset)
@router.patch("/{asset_id}", response_model=AssetRead)
def update_asset(
asset_id: int,
payload: AssetUpdate,
_: User = Depends(require_role("admin")),
db: Session = Depends(get_db),
) -> AssetRead:
asset = db.get(Asset, asset_id)
if asset is None:
raise HTTPException(status_code=404, detail="Asset not found")
changes = payload.model_dump(exclude_unset=True)
if "metadata" in changes:
asset.extra = changes.pop("metadata")
for field, value in changes.items():
setattr(asset, field, value)
db.commit()
db.refresh(asset)
return _asset_to_read(asset)
@router.delete("/{asset_id}", status_code=204)
def delete_asset(
asset_id: int,
_: User = Depends(require_role("admin")),
db: Session = Depends(get_db),
) -> None:
asset = db.get(Asset, asset_id)
if asset is None:
raise HTTPException(status_code=404, detail="Asset not found")
db.delete(asset)
db.commit()
+32
View File
@@ -0,0 +1,32 @@
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.auth.dependencies import get_current_user
from app.auth.security import create_access_token, verify_password
from app.db.session import get_db
from app.models import User
from app.schemas.auth import TokenResponse, UserRead
router = APIRouter(prefix="/auth", tags=["auth"])
@router.post("/login", response_model=TokenResponse)
def login(form: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_db)) -> TokenResponse:
user = db.scalar(select(User).where(User.email == form.username))
if user is None or not verify_password(form.password, user.hashed_password):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid email or password")
if not user.is_active:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User is disabled")
return TokenResponse(access_token=create_access_token(user.email))
@router.post("/logout")
def logout() -> dict[str, str]:
return {"status": "ok"}
@router.get("/me", response_model=UserRead)
def me(user: User = Depends(get_current_user)) -> User:
return user
+8
View File
@@ -0,0 +1,8 @@
from fastapi import APIRouter
router = APIRouter(tags=["health"])
@router.get("/health")
def health() -> dict[str, str]:
return {"status": "ok", "service": "infrapulse-backend"}
+156
View File
@@ -0,0 +1,156 @@
from datetime import UTC, datetime
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.auth.dependencies import get_current_user, require_role
from app.db.session import get_db
from app.models import AlertRule, Asset, CheckResult, Incident, Monitor, User
from app.schemas.core import CheckResultRead, MonitorCreate, MonitorRead, MonitorUpdate, WebsiteMonitorCreate
router = APIRouter(prefix="/monitors", tags=["monitors"])
@router.get("", response_model=list[MonitorRead])
def list_monitors(_: User = Depends(get_current_user), db: Session = Depends(get_db)) -> list[Monitor]:
return list(db.scalars(select(Monitor).order_by(Monitor.name)).all())
@router.post("", response_model=MonitorRead)
def create_monitor(
payload: MonitorCreate,
_: User = Depends(require_role("admin")),
db: Session = Depends(get_db),
) -> Monitor:
monitor = Monitor(**payload.model_dump())
db.add(monitor)
db.commit()
db.refresh(monitor)
return monitor
@router.post("/website", response_model=MonitorRead)
def create_website_monitor(
payload: WebsiteMonitorCreate,
_: User = Depends(require_role("admin")),
db: Session = Depends(get_db),
) -> Monitor:
asset_id: int | None = None
if payload.create_asset:
asset = Asset(name=payload.name, asset_type="website", address=payload.url, status="unknown", extra={})
db.add(asset)
db.flush()
asset_id = asset.id
monitor = Monitor(
asset_id=asset_id,
name=payload.name,
monitor_type="http",
target=payload.url,
config={
"expected_status": payload.expected_status,
"expected_text": payload.expected_text,
"unexpected_text": payload.unexpected_text,
"timeout_seconds": payload.timeout_seconds,
},
interval_seconds=payload.interval_seconds,
status="unknown",
)
db.add(monitor)
db.flush()
if payload.alert_enabled:
db.add(
AlertRule(
monitor_id=monitor.id,
name=f"{payload.name} website failure",
severity=payload.alert_severity,
condition={"type": "status_not_up"},
failure_threshold=payload.failure_threshold,
cooldown_seconds=300,
is_enabled=True,
)
)
db.commit()
db.refresh(monitor)
return monitor
@router.get("/{monitor_id}", response_model=MonitorRead)
def get_monitor(monitor_id: int, _: User = Depends(get_current_user), db: Session = Depends(get_db)) -> Monitor:
monitor = db.get(Monitor, monitor_id)
if monitor is None:
raise HTTPException(status_code=404, detail="Monitor not found")
return monitor
@router.patch("/{monitor_id}", response_model=MonitorRead)
def update_monitor(
monitor_id: int,
payload: MonitorUpdate,
_: User = Depends(require_role("admin")),
db: Session = Depends(get_db),
) -> Monitor:
monitor = db.get(Monitor, monitor_id)
if monitor is None:
raise HTTPException(status_code=404, detail="Monitor not found")
for field, value in payload.model_dump(exclude_unset=True).items():
setattr(monitor, field, value)
db.commit()
db.refresh(monitor)
return monitor
@router.delete("/{monitor_id}", status_code=204)
def delete_monitor(
monitor_id: int,
cleanup_orphan_website_asset: bool = True,
_: User = Depends(require_role("admin")),
db: Session = Depends(get_db),
) -> None:
monitor = db.get(Monitor, monitor_id)
if monitor is None:
raise HTTPException(status_code=404, detail="Monitor not found")
asset_id = monitor.asset_id
now = datetime.now(UTC)
open_incidents = db.scalars(select(Incident).where(Incident.monitor_id == monitor_id, Incident.status == "open")).all()
for incident in open_incidents:
incident.status = "resolved"
incident.resolved_at = now
incident.details = {**(incident.details or {}), "resolution_reason": "monitor_deleted"}
db.delete(monitor)
db.flush()
if cleanup_orphan_website_asset and asset_id is not None:
remaining = db.scalar(select(func.count(Monitor.id)).where(Monitor.asset_id == asset_id))
asset = db.get(Asset, asset_id)
if remaining == 0 and asset is not None and asset.asset_type == "website":
db.delete(asset)
db.commit()
@router.get("/{monitor_id}/results", response_model=list[CheckResultRead])
def list_monitor_results(
monitor_id: int,
limit: int = 20,
_: User = Depends(get_current_user),
db: Session = Depends(get_db),
) -> list[CheckResult]:
monitor = db.get(Monitor, monitor_id)
if monitor is None:
raise HTTPException(status_code=404, detail="Monitor not found")
return list(
db.scalars(
select(CheckResult)
.where(CheckResult.monitor_id == monitor_id)
.order_by(CheckResult.observed_at.desc())
.limit(min(limit, 100))
).all()
)
+117
View File
@@ -0,0 +1,117 @@
from fastapi import APIRouter, Depends, HTTPException
import httpx
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.auth.dependencies import get_current_user, require_role
from app.core.secrets import decrypt_secret, encrypt_secret
from app.db.session import get_db
from app.models import NotificationChannel, User
from app.schemas.core import NotificationChannelCreate, NotificationChannelRead, NotificationChannelUpdate
router = APIRouter(prefix="/notifications/channels", tags=["notifications"])
def _channel_to_read(channel: NotificationChannel) -> NotificationChannelRead:
settings = dict(channel.settings or {})
settings.setdefault("username", "InfraPulse")
return NotificationChannelRead(
id=channel.id,
name=channel.name,
channel_type=channel.channel_type,
settings=settings,
has_secret=bool(channel.encrypted_secret),
is_enabled=channel.is_enabled,
created_at=channel.created_at,
updated_at=channel.updated_at,
)
@router.get("", response_model=list[NotificationChannelRead])
def list_channels(_: User = Depends(get_current_user), db: Session = Depends(get_db)) -> list[NotificationChannelRead]:
channels = db.scalars(select(NotificationChannel).order_by(NotificationChannel.name)).all()
return [_channel_to_read(channel) for channel in channels]
@router.post("", response_model=NotificationChannelRead)
def create_channel(
payload: NotificationChannelCreate,
_: User = Depends(require_role("admin")),
db: Session = Depends(get_db),
) -> NotificationChannelRead:
channel_settings = dict(payload.settings or {})
channel_settings.setdefault("username", "InfraPulse")
channel = NotificationChannel(
name=payload.name,
channel_type=payload.channel_type,
settings=channel_settings,
encrypted_secret=encrypt_secret(payload.secret),
is_enabled=payload.is_enabled,
)
db.add(channel)
db.commit()
db.refresh(channel)
return _channel_to_read(channel)
@router.post("/{channel_id}/test")
def test_channel(
channel_id: int,
_: User = Depends(require_role("admin")),
db: Session = Depends(get_db),
) -> dict[str, str]:
channel = db.get(NotificationChannel, channel_id)
if channel is None:
raise HTTPException(status_code=404, detail="Notification channel not found")
url = decrypt_secret(channel.encrypted_secret)
if not url:
raise HTTPException(status_code=400, detail="Notification channel has no usable secret URL")
try:
response = httpx.post(
url,
json={
"username": (channel.settings or {}).get("username") or "InfraPulse",
"text": f"InfraPulse test notification for {channel.name}",
},
timeout=10,
)
response.raise_for_status()
except httpx.HTTPError as exc:
raise HTTPException(status_code=502, detail="Notification test failed") from exc
return {"status": "sent", "message": "Notification test sent"}
@router.patch("/{channel_id}", response_model=NotificationChannelRead)
def update_channel(
channel_id: int,
payload: NotificationChannelUpdate,
_: User = Depends(require_role("admin")),
db: Session = Depends(get_db),
) -> NotificationChannelRead:
channel = db.get(NotificationChannel, channel_id)
if channel is None:
raise HTTPException(status_code=404, detail="Notification channel not found")
changes = payload.model_dump(exclude_unset=True)
secret = changes.pop("secret", None)
if secret is not None:
channel.encrypted_secret = encrypt_secret(secret)
for field, value in changes.items():
setattr(channel, field, value)
db.commit()
db.refresh(channel)
return _channel_to_read(channel)
@router.delete("/{channel_id}", status_code=204)
def delete_channel(
channel_id: int,
_: User = Depends(require_role("admin")),
db: Session = Depends(get_db),
) -> None:
channel = db.get(NotificationChannel, channel_id)
if channel is None:
raise HTTPException(status_code=404, detail="Notification channel not found")
db.delete(channel)
db.commit()
+1
View File
@@ -0,0 +1 @@
"""Authentication helpers."""
+41
View File
@@ -0,0 +1,41 @@
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.auth.security import decode_access_token
from app.db.session import get_db
from app.models import User
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/login")
ROLE_ORDER = {
"viewer": 10,
"operator": 20,
"admin": 30,
"owner": 40,
}
def get_current_user(token: str = Depends(oauth2_scheme), db: Session = Depends(get_db)) -> User:
email = decode_access_token(token)
if not email:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid authentication token",
headers={"WWW-Authenticate": "Bearer"},
)
user = db.scalar(select(User).where(User.email == email))
if user is None or not user.is_active:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Inactive or missing user")
return user
def require_role(minimum_role: str):
def dependency(user: User = Depends(get_current_user)) -> User:
if ROLE_ORDER.get(user.role, 0) < ROLE_ORDER[minimum_role]:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient role")
return user
return dependency
+31
View File
@@ -0,0 +1,31 @@
from datetime import UTC, datetime, timedelta
from jose import JWTError, jwt
from passlib.context import CryptContext
from app.core.config import settings
ALGORITHM = "HS256"
pwd_context = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto")
def hash_password(password: str) -> str:
return pwd_context.hash(password)
def verify_password(plain_password: str, hashed_password: str) -> bool:
return pwd_context.verify(plain_password, hashed_password)
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)
def decode_access_token(token: str) -> str | None:
try:
payload = jwt.decode(token, settings.infrapulse_secret_key, algorithms=[ALGORITHM])
return payload.get("sub")
except JWTError:
return None
+1
View File
@@ -0,0 +1 @@
"""Core backend configuration."""
+26
View File
@@ -0,0 +1,26 @@
from functools import lru_cache
from pydantic import AnyHttpUrl, Field
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"
redis_url: str = "redis://redis:6379/0"
frontend_url: AnyHttpUrl | str = "http://localhost:5173"
backend_url: AnyHttpUrl | str = "http://localhost:8000"
initial_admin_email: str = "admin@example.com"
initial_admin_password: str = "change-me"
access_token_expire_minutes: int = 60 * 12
@lru_cache
def get_settings() -> Settings:
return Settings()
settings = get_settings()
+26
View File
@@ -0,0 +1,26 @@
import base64
import hashlib
from cryptography.fernet import Fernet, InvalidToken
from app.core.config import settings
def _fernet() -> Fernet:
digest = hashlib.sha256(settings.infrapulse_secret_key.encode("utf-8")).digest()
return Fernet(base64.urlsafe_b64encode(digest))
def encrypt_secret(value: str | None) -> str | None:
if not value:
return None
return _fernet().encrypt(value.encode("utf-8")).decode("utf-8")
def decrypt_secret(value: str | None) -> str | None:
if not value:
return None
try:
return _fernet().decrypt(value.encode("utf-8")).decode("utf-8")
except InvalidToken:
return None
+1
View File
@@ -0,0 +1 @@
"""Database helpers."""
+3
View File
@@ -0,0 +1,3 @@
from app.models.core import Base
__all__ = ["Base"]
+17
View File
@@ -0,0 +1,17 @@
from collections.abc import Generator
from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker
from app.core.config import settings
engine = create_engine(settings.database_url, pool_pre_ping=True)
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False)
def get_db() -> Generator[Session, None, None]:
db = SessionLocal()
try:
yield db
finally:
db.close()
+47
View File
@@ -0,0 +1,47 @@
from contextlib import asynccontextmanager
from collections.abc import AsyncIterator
import logging
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from sqlalchemy.exc import SQLAlchemyError
from app.api import alerts, assets, auth, health, monitors, notifications
from app.core.config import settings
from app.db.session import SessionLocal
from app.services.bootstrap import ensure_initial_admin
logger = logging.getLogger(__name__)
@asynccontextmanager
async def lifespan(_: FastAPI) -> AsyncIterator[None]:
try:
with SessionLocal() as db:
ensure_initial_admin(db)
except SQLAlchemyError as exc:
logger.warning("Initial admin bootstrap skipped because the database is unavailable: %s", exc)
yield
app = FastAPI(
title="InfraPulse API",
version="0.1.0",
description="Self-hosted infrastructure monitoring API",
lifespan=lifespan,
)
app.add_middleware(
CORSMiddleware,
allow_origins=[str(settings.frontend_url)],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(health.router)
app.include_router(auth.router)
app.include_router(assets.router)
app.include_router(monitors.router)
app.include_router(alerts.router)
app.include_router(notifications.router)
+27
View File
@@ -0,0 +1,27 @@
from app.models.core import (
AlertRule,
Asset,
AuditEvent,
Base,
CheckResult,
Credential,
Incident,
Metric,
Monitor,
NotificationChannel,
User,
)
__all__ = [
"AlertRule",
"Asset",
"AuditEvent",
"Base",
"CheckResult",
"Credential",
"Incident",
"Metric",
"Monitor",
"NotificationChannel",
"User",
]
+138
View File
@@ -0,0 +1,138 @@
from datetime import datetime
from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, JSON, String, Text, func
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
class Base(DeclarativeBase):
pass
class TimestampMixin:
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
class User(TimestampMixin, Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
email: Mapped[str] = mapped_column(String(320), unique=True, index=True)
display_name: Mapped[str] = mapped_column(String(120))
hashed_password: Mapped[str] = mapped_column(String(255))
role: Mapped[str] = mapped_column(String(32), default="owner")
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
class Asset(TimestampMixin, Base):
__tablename__ = "assets"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
name: Mapped[str] = mapped_column(String(160))
asset_type: Mapped[str] = mapped_column(String(64))
address: Mapped[str | None] = mapped_column(String(255), nullable=True)
status: Mapped[str] = mapped_column(String(32), default="unknown")
extra: Mapped[dict] = mapped_column("metadata", JSON, default=dict)
monitors: Mapped[list["Monitor"]] = relationship(back_populates="asset")
class Credential(TimestampMixin, Base):
__tablename__ = "credentials"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
name: Mapped[str] = mapped_column(String(160))
credential_type: Mapped[str] = mapped_column(String(64))
encrypted_secret: Mapped[str | None] = mapped_column(Text, nullable=True)
extra: Mapped[dict] = mapped_column("metadata", JSON, default=dict)
class NotificationChannel(TimestampMixin, Base):
__tablename__ = "notification_channels"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
name: Mapped[str] = mapped_column(String(160))
channel_type: Mapped[str] = mapped_column(String(64))
settings: Mapped[dict] = mapped_column(JSON, default=dict)
encrypted_secret: Mapped[str | None] = mapped_column(Text, nullable=True)
is_enabled: Mapped[bool] = mapped_column(Boolean, default=True)
class Monitor(TimestampMixin, Base):
__tablename__ = "monitors"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
asset_id: Mapped[int | None] = mapped_column(ForeignKey("assets.id", ondelete="CASCADE"), nullable=True)
name: Mapped[str] = mapped_column(String(160))
monitor_type: Mapped[str] = mapped_column(String(64))
target: Mapped[str] = mapped_column(String(512))
config: Mapped[dict] = mapped_column(JSON, default=dict)
interval_seconds: Mapped[int] = mapped_column(Integer, default=60)
status: Mapped[str] = mapped_column(String(32), default="unknown")
last_checked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
asset: Mapped[Asset | None] = relationship(back_populates="monitors")
class CheckResult(Base):
__tablename__ = "check_results"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
monitor_id: Mapped[int] = mapped_column(ForeignKey("monitors.id", ondelete="CASCADE"))
status: Mapped[str] = mapped_column(String(32))
response_time_ms: Mapped[int | None] = mapped_column(Integer, nullable=True)
message: Mapped[str | None] = mapped_column(Text, nullable=True)
observed_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
class Metric(Base):
__tablename__ = "metrics"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
monitor_id: Mapped[int] = mapped_column(ForeignKey("monitors.id", ondelete="CASCADE"))
name: Mapped[str] = mapped_column(String(120))
value: Mapped[float] = mapped_column(Float)
unit: Mapped[str | None] = mapped_column(String(32), nullable=True)
observed_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
class AlertRule(TimestampMixin, Base):
__tablename__ = "alert_rules"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
monitor_id: Mapped[int] = mapped_column(ForeignKey("monitors.id", ondelete="CASCADE"))
name: Mapped[str] = mapped_column(String(160))
severity: Mapped[str] = mapped_column(String(32), default="warning")
condition: Mapped[dict] = mapped_column(JSON, default=dict)
failure_threshold: Mapped[int] = mapped_column(Integer, default=3)
cooldown_seconds: Mapped[int] = mapped_column(Integer, default=300)
is_enabled: Mapped[bool] = mapped_column(Boolean, default=True)
class Incident(Base):
__tablename__ = "incidents"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
asset_id: Mapped[int | None] = mapped_column(ForeignKey("assets.id", ondelete="SET NULL"), nullable=True)
monitor_id: Mapped[int | None] = mapped_column(ForeignKey("monitors.id", ondelete="SET NULL"), nullable=True)
alert_rule_id: Mapped[int | None] = mapped_column(ForeignKey("alert_rules.id", ondelete="SET NULL"), nullable=True)
title: Mapped[str] = mapped_column(String(240))
severity: Mapped[str] = mapped_column(String(32))
status: Mapped[str] = mapped_column(String(32), default="open")
opened_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
resolved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
acknowledged_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
silenced_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
details: Mapped[dict] = mapped_column(JSON, default=dict)
class AuditEvent(Base):
__tablename__ = "audit_events"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
actor_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
event_type: Mapped[str] = mapped_column(String(120))
target_type: Mapped[str | None] = mapped_column(String(120), nullable=True)
target_id: Mapped[str | None] = mapped_column(String(120), nullable=True)
details: Mapped[dict] = mapped_column(JSON, default=dict)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
+1
View File
@@ -0,0 +1 @@
"""Pydantic API schemas."""
+16
View File
@@ -0,0 +1,16 @@
from pydantic import BaseModel, EmailStr
class TokenResponse(BaseModel):
access_token: str
token_type: str = "bearer"
class UserRead(BaseModel):
id: int
email: EmailStr
display_name: str
role: str
is_active: bool
model_config = {"from_attributes": True}
+156
View File
@@ -0,0 +1,156 @@
from datetime import datetime
from typing import Any
from pydantic import BaseModel, Field
class AssetCreate(BaseModel):
name: str = Field(min_length=1, max_length=160)
asset_type: str = Field(min_length=1, max_length=64)
address: str | None = Field(default=None, max_length=255)
metadata: dict[str, Any] = Field(default_factory=dict)
class AssetUpdate(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=160)
asset_type: str | None = Field(default=None, min_length=1, max_length=64)
address: str | None = Field(default=None, max_length=255)
status: str | None = Field(default=None, max_length=32)
metadata: dict[str, Any] | None = None
class AssetRead(AssetCreate):
id: int
status: str
created_at: datetime
updated_at: datetime
model_config = {"from_attributes": True}
class MonitorCreate(BaseModel):
name: str = Field(min_length=1, max_length=160)
monitor_type: str = Field(default="http", max_length=64)
target: str = Field(min_length=1, max_length=512)
asset_id: int | None = None
config: dict[str, Any] = Field(default_factory=dict)
interval_seconds: int = Field(default=60, ge=10)
class MonitorUpdate(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=160)
monitor_type: str | None = Field(default=None, max_length=64)
target: str | None = Field(default=None, min_length=1, max_length=512)
asset_id: int | None = None
config: dict[str, Any] | None = None
interval_seconds: int | None = Field(default=None, ge=10)
status: str | None = Field(default=None, max_length=32)
class MonitorRead(MonitorCreate):
id: int
status: str
last_checked_at: datetime | None
created_at: datetime
updated_at: datetime
model_config = {"from_attributes": True}
class WebsiteMonitorCreate(BaseModel):
name: str = Field(min_length=1, max_length=160)
url: str = Field(min_length=1, max_length=512)
expected_status: int = Field(default=200, ge=100, le=599)
expected_text: str | None = None
unexpected_text: str | None = None
timeout_seconds: int = Field(default=10, ge=1, le=120)
interval_seconds: int = Field(default=60, ge=10)
create_asset: bool = True
alert_enabled: bool = True
alert_severity: str = "critical"
failure_threshold: int = Field(default=3, ge=1, le=20)
class CheckResultRead(BaseModel):
id: int
monitor_id: int
status: str
response_time_ms: int | None
message: str | None
observed_at: datetime
model_config = {"from_attributes": True}
class AlertRuleCreate(BaseModel):
monitor_id: int
name: str = Field(min_length=1, max_length=160)
severity: str = "warning"
condition: dict[str, Any] = Field(default_factory=dict)
failure_threshold: int = Field(default=3, ge=1)
cooldown_seconds: int = Field(default=300, ge=0)
is_enabled: bool = True
class AlertRuleUpdate(BaseModel):
monitor_id: int | None = None
name: str | None = Field(default=None, min_length=1, max_length=160)
severity: str | None = None
condition: dict[str, Any] | None = None
failure_threshold: int | None = Field(default=None, ge=1)
cooldown_seconds: int | None = Field(default=None, ge=0)
is_enabled: bool | None = None
class AlertRuleRead(AlertRuleCreate):
id: int
created_at: datetime
updated_at: datetime
model_config = {"from_attributes": True}
class IncidentRead(BaseModel):
id: int
asset_id: int | None
monitor_id: int | None
alert_rule_id: int | None
title: str
severity: str
status: str
opened_at: datetime
resolved_at: datetime | None
acknowledged_at: datetime | None
silenced_until: datetime | None
details: dict[str, Any]
model_config = {"from_attributes": True}
class NotificationChannelCreate(BaseModel):
name: str = Field(min_length=1, max_length=160)
channel_type: str = Field(min_length=1, max_length=64)
settings: dict[str, Any] = Field(default_factory=dict)
secret: str | None = None
is_enabled: bool = True
class NotificationChannelUpdate(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=160)
channel_type: str | None = Field(default=None, min_length=1, max_length=64)
settings: dict[str, Any] | None = None
secret: str | None = None
is_enabled: bool | None = None
class NotificationChannelRead(BaseModel):
id: int
name: str
channel_type: str
settings: dict[str, Any]
has_secret: bool
is_enabled: bool
created_at: datetime
updated_at: datetime
model_config = {"from_attributes": True}
+1
View File
@@ -0,0 +1 @@
"""Domain services."""
+22
View File
@@ -0,0 +1,22 @@
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.auth.security import hash_password
from app.core.config import settings
from app.models import User
def ensure_initial_admin(db: Session) -> None:
existing = db.scalar(select(User).where(User.email == settings.initial_admin_email))
if existing:
return
user = User(
email=settings.initial_admin_email,
display_name="Initial Owner",
hashed_password=hash_password(settings.initial_admin_password),
role="owner",
is_active=True,
)
db.add(user)
db.commit()
+32
View File
@@ -0,0 +1,32 @@
[project]
name = "infrapulse-backend"
version = "0.1.0"
description = "InfraPulse FastAPI backend"
requires-python = ">=3.12"
dependencies = [
"alembic>=1.13.3",
"cryptography>=48.0.0",
"email-validator>=2.2.0",
"fastapi>=0.115.0",
"httpx>=0.28.0",
"passlib>=1.7.4",
"psycopg[binary]>=3.2.0",
"pydantic-settings>=2.5.2",
"python-jose[cryptography]>=3.3.0",
"python-multipart>=0.0.12",
"sqlalchemy>=2.0.35",
"uvicorn[standard]>=0.30.6",
]
[project.optional-dependencies]
test = [
"pytest>=8.3.3",
"httpx>=0.27.2",
]
[tool.pytest.ini_options]
testpaths = ["tests"]
[build-system]
requires = ["setuptools>=75.0"]
build-backend = "setuptools.build_meta"
+10
View File
@@ -0,0 +1,10 @@
from fastapi.testclient import TestClient
from app.main import app
def test_health() -> None:
with TestClient(app) as client:
response = client.get("/health")
assert response.status_code == 200
assert response.json()["status"] == "ok"
+70
View File
@@ -0,0 +1,70 @@
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_USER: infrapulse
POSTGRES_PASSWORD: infrapulse
POSTGRES_DB: infrapulse
volumes:
- postgres-dev-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U infrapulse -d infrapulse"]
interval: 10s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
backend:
build:
context: ./backend
env_file:
- .env
volumes:
- ./backend:/app
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
ports:
- "8000:8000"
command: sh -c "alembic upgrade head && uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload"
worker:
build:
context: ./worker
env_file:
- .env
volumes:
- ./worker:/app
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
command: python -m app.main
frontend:
build:
context: ./frontend
environment:
VITE_API_BASE_URL: http://localhost:8000
volumes:
- ./frontend:/app
- frontend-node-modules:/app/node_modules
depends_on:
- backend
ports:
- "5173:5173"
command: npm run dev -- --host 0.0.0.0
volumes:
postgres-dev-data:
frontend-node-modules:
+59
View File
@@ -0,0 +1,59 @@
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_USER: infrapulse
POSTGRES_PASSWORD: infrapulse
POSTGRES_DB: infrapulse
volumes:
- postgres-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U infrapulse -d infrapulse"]
interval: 10s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
backend:
build:
context: ./backend
env_file:
- .env
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
ports:
- "8000:8000"
worker:
build:
context: ./worker
env_file:
- .env
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
frontend:
build:
context: ./frontend
environment:
VITE_API_BASE_URL: http://localhost:8000
depends_on:
- backend
ports:
- "5173:5173"
volumes:
postgres-data:
+29
View File
@@ -0,0 +1,29 @@
# Alerting Design
Alerting is built around alert rules, incidents, notification policies, and notification history.
## Alert Rules
An alert rule turns monitor status or metric data into an incident. Initial rule behavior should support:
- Failure thresholds
- Recovery notifications
- Cooldown
- Severity
- Acknowledge
- Silence
## Incidents
Incidents represent active or historical alert events. They include opened time, resolved time, current status, severity, related asset, related monitor, related alert rule, notification history, acknowledgement, and silence state.
## Notifications
Initial channels:
- Email / SMTP
- Mattermost incoming webhook
- 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.
+28
View File
@@ -0,0 +1,28 @@
# Architecture
InfraPulse 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.
- `frontend`: React application for authenticated operations.
- `docs`: Product, security, alerting, discovery, and planning documents.
## Backend
The backend uses FastAPI, SQLAlchemy, Alembic, Pydantic, PostgreSQL, and JWT authentication. It owns core domain models: users, assets, credentials, monitors, check results, metrics, alert rules, incidents, notification channels, and audit events.
## Worker
The worker is a separate Python process. It will poll due monitors, run collectors, write check results and metrics, evaluate alert rules, open or resolve incidents, and enqueue notification delivery.
## Frontend
The frontend uses React, TypeScript, Vite, and Tailwind CSS. It starts with protected routes, a login flow, and dashboard/inventory shells.
## Queue and Scheduling
Redis is included from the beginning so background work can move from a simple scheduler to a real queue without changing the deployment shape.
## Plugin Direction
Plugins will eventually implement connection tests, discovery, collection, and default alert rule suggestions. Initial collectors can be simpler, but they should not block future plugin extraction.
+39
View File
@@ -0,0 +1,39 @@
# Development
## Local Docker Stack
```bash
cp .env.example .env
docker compose -f docker-compose.dev.yml up --build
```
The dev stack runs PostgreSQL, Redis, backend, worker, and frontend.
## Backend
Backend source lives in `backend/app`. Migrations live in `backend/alembic`.
Useful commands from `backend/`:
```bash
alembic upgrade head
uvicorn app.main:app --reload
```
## Frontend
Frontend source lives in `frontend/src`.
Useful commands from `frontend/`:
```bash
npm install
npm run dev
```
## Tests and Checks
```bash
./scripts/lint.sh
./scripts/test.sh
```
+30
View File
@@ -0,0 +1,30 @@
# Discovery Design
Guided discovery is a core InfraPulse workflow.
```text
Add target
Choose target type
Enter address and credentials
Test connection
Discover available items
Show friendly list of discovered items
User selects what to monitor
User selects what should alert
Create monitors and optional alert rules
```
## Monitor vs Alert Separation
InfraPulse must allow monitoring without alerting. Every discovered item should eventually support separate choices:
- Collect metric
- Graph metric
- Show on dashboard
- Alert on condition
This prevents every monitor from automatically becoming an alert source.
## Friendly SNMP
The normal UI must not show raw OIDs. SNMP profiles should translate implementation details into friendly labels such as interface names, traffic counters, status, errors, uptime, CPU, and memory.
+49
View File
@@ -0,0 +1,49 @@
# Gitea Issue Plan
## Milestones
- Milestone 1: Project Foundation
- Milestone 2: Authentication and Security
- Milestone 3: Inventory and Monitor Core
- Milestone 4: First Checks and Metrics
- Milestone 5: Alerting and Notifications
- Milestone 6: Guided Discovery
- Milestone 7: MVP Polish
## Suggested Initial Issues
1. Create repository structure
2. Add Docker Compose development environment
3. Create FastAPI backend skeleton
4. Create React frontend skeleton
5. Add PostgreSQL and Alembic migrations
6. Add user model and authentication
7. Add role-based access control
8. Add asset data model
9. Add credential vault model with encrypted secrets
10. Add monitor data model
11. Add check result and metric models
12. Add alert rule and incident models
13. Add notification channel model
14. Implement ping monitor
15. Implement TCP port monitor
16. Implement HTTP status monitor
17. Implement website content monitor
18. Implement TLS expiry monitor
19. Implement basic alert evaluation
20. Implement email notifications
21. Implement Mattermost webhook notifications
22. Implement Zoom webhook notifications
23. Implement generic webhook notifications
24. Add login page
25. Add dashboard shell
26. Add asset list page
27. Add asset detail page
28. Add alert center page
29. Add notification settings page
30. Add credential vault page
31. Add first guided monitor creation wizard
32. Add audit log foundation
33. Add README setup instructions
34. Add architecture documentation
35. Add security documentation
+36
View File
@@ -0,0 +1,36 @@
# Plugin Design
Plugins will let InfraPulse add collectors and discovery logic without hard-coding every integration into the core API.
Target shape:
```python
class InfraPulsePlugin:
name: str
display_name: str
def test_connection(self, target, credentials):
pass
def discover(self, target, credentials):
pass
def collect(self, monitor):
pass
def default_alert_rules(self, discovered_item):
pass
```
The first implementation can use simple internal collectors, but the interfaces should preserve this path.
Planned plugin areas:
- Website checks
- Generic SNMP
- Proxmox VE
- Docker
- UniFi
- TrueNAS
- Technitium DNS
- Active Directory
+48
View File
@@ -0,0 +1,48 @@
# Roadmap
## v0.1
- Login system, local users, roles, and protected routes
- PostgreSQL, Alembic, API service, worker service, and frontend app
- Assets, credentials, monitors, alert rules, incidents, and notification channels
- HTTP/HTTPS status checks, expected text checks, TLS expiry checks
- Alert evaluation, incident acknowledgement, silence, and notification history
- Email, Mattermost, Zoom Team Chat, and generic webhook notification foundations
- Basic dashboard, website monitor creation, alert center, credential vault, and admin pages
## v0.2
- Proxmox VE plugin
- Docker plugin
- Linux and Windows exporter support
- Better graphing
- Maintenance windows
- Notification routing
## v0.3
- UniFi plugin
- TrueNAS plugin
- Technitium DNS plugin
- Active Directory health checks
- LDAP/AD login
- Audit log expansion
## v0.4
- Distributed collectors
- Subnet discovery
- Device templates
- Custom dashboards
- Public/internal status pages
## Future NMS Expansion
- SNMP traps
- Syslog
- NetFlow/sFlow
- Topology maps
- Config backups
- Multi-site support
- Escalation policies
- On-call schedules
+30
View File
@@ -0,0 +1,30 @@
# Security
InfraPulse must be secure from the beginning because it will store infrastructure credentials.
## Authentication
The initial implementation supports local username/password login with hashed passwords and JWT bearer tokens. Dashboard and API access must not be available anonymously.
Initial roles:
- Viewer: can view dashboards, assets, monitors, graphs, and alerts.
- Operator: can acknowledge alerts, silence alerts, and manage incidents.
- Admin: can manage assets, monitors, credentials, notification channels, and alert rules.
- Owner: can manage users, roles, global settings, and authentication settings.
## Credential Storage
Credential records are modeled separately from monitors and assets. Secret fields must be encrypted at rest before real credential storage is enabled. Stored secret values must never be returned to the frontend after creation.
Rules:
- Use `INFRAPULSE_SECRET_KEY` from the environment.
- Never log secrets.
- Mask saved secrets in the UI.
- Audit credential create, update, and delete events.
- Prefer read-only API tokens and least-privileged credentials.
## Future Authentication
Planned future options include LDAP/Active Directory login, OIDC, SAML if needed, and API tokens.
+24
View File
@@ -0,0 +1,24 @@
# InfraPulse Vision
InfraPulse 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.
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".
## Initial Scope
The initial release targets:
- Authentication and roles
- Assets, monitors, alert rules, incidents, credentials, and notification channels
- Website checks for HTTP status, expected text, and TLS expiry
- Dashboard status views
- Mattermost, Zoom Team Chat, email, and generic webhook notifications
- Foundations for guided discovery and plugins
Advanced NMS features such as topology, traps, syslog, NetFlow, distributed pollers, and config backup are future work.
+3
View File
@@ -0,0 +1,3 @@
node_modules/
dist/
*.tsbuildinfo
+10
View File
@@ -0,0 +1,10 @@
FROM node:22-alpine
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm install
COPY . .
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>InfraPulse</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+3024
View File
File diff suppressed because it is too large Load Diff
+27
View File
@@ -0,0 +1,27 @@
{
"name": "infrapulse-frontend",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@vitejs/plugin-react": "^5.0.0",
"lucide-react": "^0.468.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"recharts": "^2.13.3",
"vite": "^7.0.0"
},
"devDependencies": {
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.17",
"typescript": "^5.7.2"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
+101
View File
@@ -0,0 +1,101 @@
import type {
Asset,
Incident,
Monitor,
MonitorUpdate,
NotificationChannel,
NotificationChannelCreate,
NotificationChannelUpdate,
User,
WebsiteMonitorCreate,
} from "../types/api";
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? "http://localhost:8000";
export interface LoginResponse {
access_token: string;
token_type: string;
}
async function request<T>(path: string, token?: string, init: RequestInit = {}): Promise<T> {
const headers = new Headers(init.headers);
if (token) {
headers.set("Authorization", `Bearer ${token}`);
}
if (init.body && !headers.has("Content-Type")) {
headers.set("Content-Type", "application/json");
}
const response = await fetch(`${API_BASE_URL}${path}`, { ...init, headers });
if (!response.ok) {
const message = await response.text();
throw new Error(message || `Request failed with ${response.status}`);
}
if (response.status === 204) {
return undefined as T;
}
return (await response.json()) as T;
}
export async function login(email: string, password: string): Promise<LoginResponse> {
const body = new URLSearchParams();
body.set("username", email);
body.set("password", password);
const response = await fetch(`${API_BASE_URL}/auth/login`, {
method: "POST",
body,
});
if (!response.ok) {
throw new Error("Invalid email or password");
}
return (await response.json()) as LoginResponse;
}
export const api = {
me: (token: string) => request<User>("/auth/me", token),
assets: (token: string) => request<Asset[]>("/assets", token),
monitors: (token: string) => request<Monitor[]>("/monitors", token),
createWebsiteMonitor: (token: string, payload: WebsiteMonitorCreate) =>
request<Monitor>("/monitors/website", token, {
method: "POST",
body: JSON.stringify(payload),
}),
updateMonitor: (token: string, monitorId: number, payload: MonitorUpdate) =>
request<Monitor>(`/monitors/${monitorId}`, token, {
method: "PATCH",
body: JSON.stringify(payload),
}),
deleteMonitor: (token: string, monitorId: number) =>
request<void>(`/monitors/${monitorId}`, token, {
method: "DELETE",
}),
incidents: (token: string) => request<Incident[]>("/incidents", token),
acknowledgeIncident: (token: string, incidentId: number) =>
request<Incident>(`/incidents/${incidentId}/acknowledge`, token, {
method: "POST",
}),
silenceIncident: (token: string, incidentId: number, minutes = 60) =>
request<Incident>(`/incidents/${incidentId}/silence?minutes=${minutes}`, token, {
method: "POST",
}),
notificationChannels: (token: string) => request<NotificationChannel[]>("/notifications/channels", token),
createNotificationChannel: (token: string, payload: NotificationChannelCreate) =>
request<NotificationChannel>("/notifications/channels", token, {
method: "POST",
body: JSON.stringify(payload),
}),
updateNotificationChannel: (token: string, channelId: number, payload: NotificationChannelUpdate) =>
request<NotificationChannel>(`/notifications/channels/${channelId}`, token, {
method: "PATCH",
body: JSON.stringify(payload),
}),
testNotificationChannel: (token: string, channelId: number) =>
request<{ status: string; message: string }>(`/notifications/channels/${channelId}/test`, token, {
method: "POST",
}),
deleteNotificationChannel: (token: string, channelId: number) =>
request<void>(`/notifications/channels/${channelId}`, token, {
method: "DELETE",
}),
};
+135
View File
@@ -0,0 +1,135 @@
import { useEffect, useState } from "react";
import { api } from "../api/client";
import { Shell, type PageId } from "../components/Shell";
import { useAuth } from "../hooks/useAuth";
import { AlertsPage } from "../pages/AlertsPage";
import { DashboardPage } from "../pages/DashboardPage";
import { ListPage } from "../pages/ListPage";
import { LoginPage } from "../pages/LoginPage";
import { NotificationsPage } from "../pages/NotificationsPage";
import { WebsitesPage } from "../pages/WebsitesPage";
import type { Asset, Incident, Monitor } from "../types/api";
export function App() {
const auth = useAuth();
const initialIncidentId = getIncidentIdFromPath();
const [page, setPage] = useState<PageId>(initialIncidentId ? "alerts" : "dashboard");
const [selectedIncidentId, setSelectedIncidentId] = useState<number | null>(initialIncidentId);
const [assets, setAssets] = useState<Asset[]>([]);
const [monitors, setMonitors] = useState<Monitor[]>([]);
const [incidents, setIncidents] = useState<Incident[]>([]);
async function refreshData() {
if (!auth.token || !auth.user) return;
const [nextAssets, nextMonitors, nextIncidents] = await Promise.all([api.assets(auth.token), api.monitors(auth.token), api.incidents(auth.token)]);
setAssets(nextAssets);
setMonitors(nextMonitors);
setIncidents(nextIncidents);
}
useEffect(() => {
if (!auth.token || !auth.user) return;
refreshData()
.catch(() => {
setAssets([]);
setMonitors([]);
setIncidents([]);
});
const interval = window.setInterval(() => {
refreshData().catch(() => undefined);
}, 10000);
return () => window.clearInterval(interval);
}, [auth.token, auth.user]);
useEffect(() => {
const handlePopState = () => {
const incidentId = getIncidentIdFromPath();
setSelectedIncidentId(incidentId);
if (incidentId) setPage("alerts");
};
window.addEventListener("popstate", handlePopState);
return () => window.removeEventListener("popstate", handlePopState);
}, []);
function handlePageChange(nextPage: PageId) {
setPage(nextPage);
setSelectedIncidentId(null);
if (window.location.pathname.startsWith("/incidents/")) {
window.history.pushState({}, "", "/");
}
}
if (auth.loading) {
return <div className="flex min-h-screen items-center justify-center bg-[#090d13] text-sm text-slate-300">Loading InfraPulse...</div>;
}
if (!auth.user || !auth.token) {
return <LoginPage onLogin={auth.signIn} />;
}
return (
<Shell currentPage={page} onPageChange={handlePageChange} onSignOut={auth.signOut} user={auth.user}>
{page === "dashboard" ? <DashboardPage assets={assets} monitors={monitors} incidents={incidents} /> : null}
{page === "assets" ? (
<ListPage title="Assets" description="Servers, devices, websites, containers, services, and infrastructure targets.">
<SimpleTable rows={assets.map((asset) => [asset.name, asset.asset_type, asset.address ?? "-", asset.status])} columns={["Name", "Type", "Address", "Status"]} />
</ListPage>
) : null}
{page === "websites" ? (
<WebsitesPage token={auth.token} monitors={monitors} onCreated={refreshData} />
) : null}
{page === "alerts" ? (
<AlertsPage token={auth.token} incidents={incidents} selectedIncidentId={selectedIncidentId} onChanged={refreshData} />
) : null}
{page === "discovery" ? <ListPage title="Discovery" description="Guided target discovery with monitor and alert choices." /> : null}
{page === "graphs" ? <ListPage title="Graphs" description="Metric history and dashboard-ready charts." /> : null}
{page === "credentials" ? <ListPage title="Credentials" description="Encrypted reusable credentials with masked secrets." /> : null}
{page === "notifications" ? <NotificationsPage token={auth.token} /> : null}
{page === "admin" ? <ListPage title="Admin" description="Users, roles, authentication settings, and global configuration." /> : null}
</Shell>
);
}
function getIncidentIdFromPath(): number | null {
const match = window.location.pathname.match(/^\/incidents\/(\d+)$/);
if (!match) return null;
return Number(match[1]);
}
function SimpleTable({ columns, rows }: { columns: string[]; rows: string[][] }) {
return (
<div className="overflow-x-auto">
<table className="w-full min-w-[520px] text-left text-sm">
<thead className="text-xs uppercase text-slate-500">
<tr>
{columns.map((column) => (
<th key={column} className="border-b border-line px-3 py-2 font-medium">
{column}
</th>
))}
</tr>
</thead>
<tbody>
{rows.length ? (
rows.map((row, rowIndex) => (
<tr key={`${row.join("-")}-${rowIndex}`}>
{row.map((cell, cellIndex) => (
<td key={`${cell}-${cellIndex}`} className="border-b border-line px-3 py-3 text-slate-300">
{cell}
</td>
))}
</tr>
))
) : (
<tr>
<td className="px-3 py-5 text-slate-400" colSpan={columns.length}>
No records yet.
</td>
</tr>
)}
</tbody>
</table>
</div>
);
}
+29
View File
@@ -0,0 +1,29 @@
import type { ButtonHTMLAttributes, PropsWithChildren } from "react";
type ButtonVariant = "primary" | "ghost" | "danger";
const variants: Record<ButtonVariant, string> = {
primary: "bg-pulse text-slate-950 hover:bg-teal-300",
ghost: "border border-line bg-slate-900/60 text-slate-100 hover:bg-slate-800",
danger: "bg-danger text-white hover:bg-red-400",
};
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: ButtonVariant;
}
export function Button({
children,
className = "",
variant = "primary",
...props
}: PropsWithChildren<ButtonProps>) {
return (
<button
className={`inline-flex h-10 items-center justify-center gap-2 rounded-md px-4 text-sm font-medium transition disabled:cursor-not-allowed disabled:opacity-50 ${variants[variant]} ${className}`}
{...props}
>
{children}
</button>
);
}
+89
View File
@@ -0,0 +1,89 @@
import {
Activity,
Bell,
Database,
Gauge,
Globe,
KeyRound,
LogOut,
Network,
Radar,
Settings,
Shield,
} from "lucide-react";
import type { ReactNode } from "react";
import type { User } from "../types/api";
import { Button } from "./Button";
const navigation = [
{ id: "dashboard", label: "Dashboard", icon: Gauge },
{ id: "assets", label: "Assets", icon: Network },
{ id: "websites", label: "Websites", icon: Globe },
{ id: "alerts", label: "Alerts", icon: Bell },
{ id: "discovery", label: "Discovery", icon: Radar },
{ id: "graphs", label: "Graphs", icon: Activity },
{ id: "credentials", label: "Credentials", icon: KeyRound },
{ id: "notifications", label: "Notifications", icon: Database },
{ id: "admin", label: "Admin", icon: Settings },
] as const;
export type PageId = (typeof navigation)[number]["id"];
interface ShellProps {
children: ReactNode;
currentPage: PageId;
onPageChange: (page: PageId) => void;
onSignOut: () => void;
user: User;
}
export function Shell({ children, currentPage, onPageChange, onSignOut, user }: ShellProps) {
return (
<div className="min-h-screen bg-[#090d13] text-slate-100">
<aside className="fixed inset-y-0 left-0 hidden w-64 border-r border-line bg-[#0d131c] lg:block">
<div className="flex h-16 items-center gap-3 border-b border-line px-5">
<div className="flex h-9 w-9 items-center justify-center rounded-md bg-pulse text-slate-950">
<Shield size={19} />
</div>
<div>
<div className="text-base font-semibold">InfraPulse</div>
<div className="text-xs text-slate-400">Monitoring appliance</div>
</div>
</div>
<nav className="space-y-1 p-3">
{navigation.map((item) => {
const Icon = item.icon;
const active = currentPage === item.id;
return (
<button
key={item.id}
className={`flex h-10 w-full items-center gap-3 rounded-md px-3 text-left text-sm transition ${
active ? "bg-slate-800 text-white" : "text-slate-400 hover:bg-slate-900 hover:text-white"
}`}
onClick={() => onPageChange(item.id)}
>
<Icon size={17} />
{item.label}
</button>
);
})}
</nav>
</aside>
<div className="lg:pl-64">
<header className="sticky top-0 z-10 flex min-h-16 items-center justify-between border-b border-line bg-[#0b1018]/95 px-4 backdrop-blur lg:px-8">
<div className="min-w-0">
<div className="text-sm text-slate-400">Signed in as</div>
<div className="truncate text-sm font-medium">{user.email}</div>
</div>
<Button variant="ghost" onClick={onSignOut}>
<LogOut size={16} />
Logout
</Button>
</header>
<main className="px-4 py-6 lg:px-8">{children}</main>
</div>
</div>
);
}
+61
View File
@@ -0,0 +1,61 @@
import { useEffect, useMemo, useState } from "react";
import { api, login } from "../api/client";
import type { User } from "../types/api";
const TOKEN_KEY = "infrapulse_token";
export function useAuth() {
const [token, setToken] = useState<string | null>(() => localStorage.getItem(TOKEN_KEY));
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(Boolean(token));
useEffect(() => {
if (!token) {
setUser(null);
setLoading(false);
return;
}
let cancelled = false;
setLoading(true);
api
.me(token)
.then((nextUser) => {
if (!cancelled) setUser(nextUser);
})
.catch(() => {
localStorage.removeItem(TOKEN_KEY);
if (!cancelled) {
setToken(null);
setUser(null);
}
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [token]);
return useMemo(
() => ({
token,
user,
loading,
signIn: async (email: string, password: string) => {
const response = await login(email, password);
localStorage.setItem(TOKEN_KEY, response.access_token);
setToken(response.access_token);
},
signOut: () => {
localStorage.removeItem(TOKEN_KEY);
setToken(null);
setUser(null);
},
}),
[loading, token, user],
);
}
+11
View File
@@ -0,0 +1,11 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { App } from "./app/App";
import "./styles.css";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);
+110
View File
@@ -0,0 +1,110 @@
import { useState } from "react";
import { AlertTriangle, BellOff, CheckCheck, RefreshCw } from "lucide-react";
import { api } from "../api/client";
import { Button } from "../components/Button";
import type { Incident } from "../types/api";
interface AlertsPageProps {
token: string;
incidents: Incident[];
selectedIncidentId?: number | null;
onChanged: () => Promise<void>;
}
export function AlertsPage({ token, incidents, selectedIncidentId, onChanged }: AlertsPageProps) {
const [busyId, setBusyId] = useState<number | null>(null);
async function runAction(incidentId: number, action: "ack" | "silence") {
setBusyId(incidentId);
try {
if (action === "ack") {
await api.acknowledgeIncident(token, incidentId);
} else {
await api.silenceIncident(token, incidentId, 60);
}
await onChanged();
} finally {
setBusyId(null);
}
}
return (
<div className="space-y-6">
<div className="flex flex-col justify-between gap-4 md:flex-row md:items-end">
<div>
<h1 className="text-3xl font-semibold">Alerts</h1>
<p className="mt-2 text-sm text-slate-400">Open incidents, acknowledgements, silences, recoveries, and notification history.</p>
</div>
<Button variant="ghost" onClick={onChanged}>
<RefreshCw size={16} />
Refresh
</Button>
</div>
<div className="rounded-md border border-line bg-[#0d131c]">
<div className="grid grid-cols-[1fr_100px_130px_220px] gap-3 border-b border-line px-4 py-3 text-xs uppercase text-slate-500 max-lg:hidden">
<div>Incident</div>
<div>Severity</div>
<div>Status</div>
<div>Actions</div>
</div>
<div className="divide-y divide-line">
{incidents.length ? (
incidents.map((incident) => {
const open = incident.status === "open";
return (
<div
key={incident.id}
className={`grid gap-4 p-4 lg:grid-cols-[1fr_100px_130px_220px] lg:items-center ${
selectedIncidentId === incident.id ? "bg-slate-800/60" : ""
}`}
>
<div>
<div className="flex items-center gap-2 font-medium">
<AlertTriangle size={16} className={open ? "text-signal" : "text-pulse"} />
{incident.title}
</div>
<div className="mt-1 text-sm text-slate-400">
Opened {new Date(incident.opened_at).toLocaleString()}
{incident.resolved_at ? `, resolved ${new Date(incident.resolved_at).toLocaleString()}` : ""}
</div>
{typeof incident.details.last_message === "string" ? <div className="mt-1 text-sm text-slate-500">{incident.details.last_message}</div> : null}
</div>
<Badge value={incident.severity} tone={incident.severity === "critical" ? "critical" : "neutral"} />
<div className="space-y-1">
<Badge value={incident.status} tone={open ? "warning" : "ok"} />
{incident.acknowledged_at ? <div className="text-xs text-slate-500">Acknowledged</div> : null}
{incident.silenced_until ? <div className="text-xs text-slate-500">Silenced</div> : null}
</div>
<div className="flex flex-wrap gap-2">
<Button disabled={!open || Boolean(incident.acknowledged_at) || busyId === incident.id} onClick={() => runAction(incident.id, "ack")} variant="ghost">
<CheckCheck size={16} />
Ack
</Button>
<Button disabled={!open || busyId === incident.id} onClick={() => runAction(incident.id, "silence")} variant="ghost">
<BellOff size={16} />
Silence
</Button>
</div>
</div>
);
})
) : (
<div className="p-6 text-sm text-slate-400">No incidents recorded.</div>
)}
</div>
</div>
</div>
);
}
function Badge({ value, tone }: { value: string; tone: "critical" | "warning" | "ok" | "neutral" }) {
const classes = {
critical: "border-red-500/40 bg-red-950/40 text-red-200",
warning: "border-amber-500/40 bg-amber-950/40 text-amber-200",
ok: "border-teal-500/40 bg-teal-950/40 text-teal-200",
neutral: "border-slate-600 bg-slate-900 text-slate-300",
}[tone];
return <span className={`inline-flex h-7 w-fit items-center rounded-md border px-2 text-xs font-medium ${classes}`}>{value}</span>;
}
+115
View File
@@ -0,0 +1,115 @@
import { AlertTriangle, CheckCircle2, Clock3, Globe2, Server } from "lucide-react";
import type { Asset, Incident, Monitor } from "../types/api";
interface DashboardPageProps {
assets: Asset[];
monitors: Monitor[];
incidents: Incident[];
}
export function DashboardPage({ assets, monitors, incidents }: DashboardPageProps) {
const downMonitors = monitors.filter((monitor) => monitor.status === "down").length;
const activeIncidents = incidents.filter((incident) => incident.status === "open").length;
const websites = monitors.filter((monitor) => monitor.monitor_type === "http");
return (
<div className="space-y-6">
<div className="flex flex-col justify-between gap-4 md:flex-row md:items-end">
<div>
<h1 className="text-3xl font-semibold">Dashboard</h1>
<p className="mt-2 text-sm text-slate-400">Current infrastructure health, incidents, and website status.</p>
</div>
</div>
<section className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<StatusTile icon={CheckCircle2} label="Overall Status" value={activeIncidents ? "Attention" : "Healthy"} tone={activeIncidents ? "warn" : "ok"} />
<StatusTile icon={AlertTriangle} label="Active Incidents" value={String(activeIncidents)} tone={activeIncidents ? "warn" : "ok"} />
<StatusTile icon={Server} label="Assets" value={String(assets.length)} />
<StatusTile icon={Globe2} label="Websites" value={String(websites.length)} />
</section>
<section className="grid gap-4 xl:grid-cols-[minmax(0,1fr)_360px]">
<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>
</div>
<div className="divide-y divide-line">
{websites.length ? (
websites.map((monitor) => (
<div key={monitor.id} className="grid gap-2 p-4 md:grid-cols-[1fr_120px_110px] md:items-center">
<div>
<div className="font-medium">{monitor.name}</div>
<div className="truncate text-sm text-slate-400">{monitor.target}</div>
</div>
<div className="text-sm text-slate-400">{monitor.interval_seconds}s interval</div>
<StatusBadge status={monitor.status} />
</div>
))
) : (
<div className="p-6 text-sm text-slate-400">No website monitors yet.</div>
)}
</div>
</div>
<div className="rounded-md border border-line bg-[#0d131c]">
<div className="border-b border-line p-4">
<h2 className="text-base font-semibold">Recent Attention</h2>
</div>
<div className="divide-y divide-line">
{incidents.length ? (
incidents.slice(0, 6).map((incident) => (
<div key={incident.id} className="p-4">
<div className="flex items-center gap-2 text-sm">
<AlertTriangle size={15} className="text-signal" />
<span className="font-medium">{incident.title}</span>
</div>
<div className="mt-1 flex items-center gap-2 text-xs text-slate-400">
<Clock3 size={13} />
{new Date(incident.opened_at).toLocaleString()}
</div>
</div>
))
) : (
<div className="p-6 text-sm text-slate-400">No incidents recorded.</div>
)}
</div>
</div>
</section>
</div>
);
}
function StatusTile({
icon: Icon,
label,
value,
tone = "neutral",
}: {
icon: typeof CheckCircle2;
label: string;
value: string;
tone?: "neutral" | "ok" | "warn";
}) {
const color = tone === "ok" ? "text-pulse" : tone === "warn" ? "text-signal" : "text-slate-300";
return (
<div className="rounded-md border border-line bg-[#0d131c] p-4">
<div className={`mb-4 flex h-9 w-9 items-center justify-center rounded-md bg-slate-900 ${color}`}>
<Icon size={18} />
</div>
<div className="text-sm text-slate-400">{label}</div>
<div className="mt-1 text-2xl font-semibold">{value}</div>
</div>
);
}
function StatusBadge({ status }: { status: string }) {
const classes =
status === "up"
? "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";
return <span className={`inline-flex h-7 items-center justify-center rounded-md border px-2 text-xs font-medium ${classes}`}>{status}</span>;
}
+19
View File
@@ -0,0 +1,19 @@
import type { ReactNode } from "react";
interface ListPageProps {
title: string;
description: string;
children?: ReactNode;
}
export function ListPage({ title, description, children }: ListPageProps) {
return (
<div className="space-y-5">
<div>
<h1 className="text-3xl font-semibold">{title}</h1>
<p className="mt-2 text-sm text-slate-400">{description}</p>
</div>
<div className="rounded-md border border-line bg-[#0d131c] p-5">{children ?? <p className="text-sm text-slate-400">Initial UI shell ready for implementation.</p>}</div>
</div>
);
}
+88
View File
@@ -0,0 +1,88 @@
import { FormEvent, useState } from "react";
import { LockKeyhole, Shield } from "lucide-react";
import { Button } from "../components/Button";
interface LoginPageProps {
onLogin: (email: string, password: string) => Promise<void>;
}
export function LoginPage({ onLogin }: LoginPageProps) {
const [email, setEmail] = useState("admin@example.com");
const [password, setPassword] = useState("change-me");
const [error, setError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
async function handleSubmit(event: FormEvent) {
event.preventDefault();
setSubmitting(true);
setError(null);
try {
await onLogin(email, password);
} catch (err) {
setError(err instanceof Error ? err.message : "Login failed");
} finally {
setSubmitting(false);
}
}
return (
<main className="grid min-h-screen bg-[#090d13] text-slate-100 lg:grid-cols-[minmax(0,1fr)_440px]">
<section className="flex min-h-[42vh] flex-col justify-between bg-[linear-gradient(rgba(9,13,19,0.45),rgba(9,13,19,0.9)),url('https://images.unsplash.com/photo-1558494949-ef010cbdcc31?auto=format&fit=crop&w=1800&q=80')] bg-cover bg-center p-6 lg:min-h-screen lg:p-10">
<div className="flex items-center gap-3">
<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>
<div className="max-w-3xl pb-6">
<h1 className="max-w-2xl text-4xl font-semibold leading-tight lg:text-6xl">
Beautiful, self-hosted infrastructure monitoring.
</h1>
<p className="mt-4 max-w-xl text-base leading-7 text-slate-300">
Guided setup, clean dashboards, website checks, incidents, and notifications without enterprise-tool overhead.
</p>
</div>
</section>
<section className="flex items-center justify-center border-l border-line bg-[#0d131c] p-6">
<form className="w-full max-w-sm space-y-5" onSubmit={handleSubmit}>
<div>
<div className="mb-3 flex h-10 w-10 items-center justify-center rounded-md bg-slate-800">
<LockKeyhole size={19} />
</div>
<h2 className="text-2xl font-semibold">Sign in</h2>
</div>
<label className="block space-y-2">
<span className="text-sm text-slate-300">Email</span>
<input
className="h-11 w-full rounded-md border border-line bg-slate-950 px-3 text-sm outline-none ring-pulse/40 focus:ring-2"
value={email}
onChange={(event) => setEmail(event.target.value)}
type="email"
autoComplete="username"
/>
</label>
<label className="block space-y-2">
<span className="text-sm text-slate-300">Password</span>
<input
className="h-11 w-full rounded-md border border-line bg-slate-950 px-3 text-sm outline-none ring-pulse/40 focus:ring-2"
value={password}
onChange={(event) => setPassword(event.target.value)}
type="password"
autoComplete="current-password"
/>
</label>
{error ? <div className="rounded-md border border-red-500/40 bg-red-950/40 p-3 text-sm text-red-200">{error}</div> : null}
<Button className="w-full" disabled={submitting} type="submit">
{submitting ? "Signing in..." : "Login"}
</Button>
</form>
</section>
</main>
);
}
+214
View File
@@ -0,0 +1,214 @@
import { FormEvent, useEffect, useState } from "react";
import { Bell, Edit3, RefreshCw, Send, Trash2, X } from "lucide-react";
import { api } from "../api/client";
import { Button } from "../components/Button";
import type { NotificationChannel } from "../types/api";
interface NotificationsPageProps {
token: string;
}
export function NotificationsPage({ token }: NotificationsPageProps) {
const [channels, setChannels] = useState<NotificationChannel[]>([]);
const [name, setName] = useState("");
const [channelType, setChannelType] = useState("generic_webhook");
const [url, setUrl] = useState("");
const [username, setUsername] = useState("InfraPulse");
const [enabled, setEnabled] = useState(true);
const [editingChannelId, setEditingChannelId] = useState<number | null>(null);
const [busyId, setBusyId] = useState<number | null>(null);
const [submitting, setSubmitting] = useState(false);
const [message, setMessage] = useState<string | null>(null);
async function refresh() {
setChannels(await api.notificationChannels(token));
}
useEffect(() => {
refresh().catch(() => setChannels([]));
}, [token]);
async function submit(event: FormEvent) {
event.preventDefault();
setSubmitting(true);
setMessage(null);
try {
if (editingChannelId) {
await api.updateNotificationChannel(token, editingChannelId, {
name,
channel_type: channelType,
settings: { username: username.trim() || "InfraPulse" },
secret: url.trim() ? url.trim() : undefined,
is_enabled: enabled,
});
} else {
await api.createNotificationChannel(token, {
name,
channel_type: channelType,
settings: { username: username.trim() || "InfraPulse" },
secret: url,
is_enabled: enabled,
});
}
resetForm();
await refresh();
} catch (err) {
setMessage(err instanceof Error ? err.message : "Could not save channel");
} finally {
setSubmitting(false);
}
}
function startEdit(channel: NotificationChannel) {
setEditingChannelId(channel.id);
setName(channel.name);
setChannelType(channel.channel_type);
setUrl("");
setUsername(String(channel.settings.username || "InfraPulse"));
setEnabled(channel.is_enabled);
setMessage(null);
}
function resetForm() {
setEditingChannelId(null);
setName("");
setChannelType("generic_webhook");
setUrl("");
setUsername("InfraPulse");
setEnabled(true);
}
async function testChannel(channelId: number) {
setBusyId(channelId);
setMessage(null);
try {
const result = await api.testNotificationChannel(token, channelId);
setMessage(result.message);
} catch (err) {
setMessage(err instanceof Error ? err.message : "Notification test failed");
} finally {
setBusyId(null);
}
}
async function deleteChannel(channelId: number) {
setBusyId(channelId);
setMessage(null);
try {
await api.deleteNotificationChannel(token, channelId);
await refresh();
} catch (err) {
setMessage(err instanceof Error ? err.message : "Could not delete channel");
} finally {
setBusyId(null);
}
}
return (
<div className="space-y-6">
<div className="flex flex-col justify-between gap-4 md:flex-row md:items-end">
<div>
<h1 className="text-3xl font-semibold">Notifications</h1>
<p className="mt-2 text-sm text-slate-400">Webhook destinations for alert and recovery messages.</p>
</div>
<Button variant="ghost" onClick={refresh}>
<RefreshCw size={16} />
Refresh
</Button>
</div>
<section className="grid gap-5 xl:grid-cols-[420px_minmax(0,1fr)]">
<form className="space-y-4 rounded-md border border-line bg-[#0d131c] p-5" onSubmit={submit}>
<div className="flex items-center gap-2">
<Bell size={18} className="text-pulse" />
<h2 className="text-base font-semibold">{editingChannelId ? "Edit Webhook Channel" : "Add Webhook Channel"}</h2>
</div>
<label className="block space-y-2">
<span className="text-sm text-slate-300">Name</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" value={name} onChange={(event) => setName(event.target.value)} required />
</label>
<label className="block space-y-2">
<span className="text-sm text-slate-300">Type</span>
<select 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={channelType} onChange={(event) => setChannelType(event.target.value)}>
<option value="generic_webhook">Generic Webhook</option>
<option value="mattermost">Mattermost</option>
<option value="zoom_team_chat">Zoom Team Chat</option>
</select>
</label>
<label className="block space-y-2">
<span className="text-sm text-slate-300">Webhook URL</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" value={url} onChange={(event) => setUrl(event.target.value)} required={!editingChannelId} type="url" />
{editingChannelId ? <span className="text-xs text-slate-500">Leave blank to keep the stored webhook URL.</span> : null}
</label>
<label className="block space-y-2">
<span className="text-sm text-slate-300">Post Username</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" value={username} onChange={(event) => setUsername(event.target.value)} required />
</label>
<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">Enabled</span>
<input className="h-5 w-5 accent-teal-400" checked={enabled} onChange={(event) => setEnabled(event.target.checked)} type="checkbox" />
</div>
{message ? <div className="rounded-md border border-line bg-slate-950 p-3 text-sm text-slate-300">{message}</div> : null}
<div className="flex gap-2">
{editingChannelId ? (
<Button className="flex-1" onClick={resetForm} type="button" variant="ghost">
<X size={16} />
Cancel
</Button>
) : null}
<Button className="flex-1" disabled={submitting} type="submit">
{submitting ? "Saving..." : editingChannelId ? "Save Channel" : "Create Channel"}
</Button>
</div>
</form>
<div className="rounded-md border border-line bg-[#0d131c]">
<div className="border-b border-line p-4">
<h2 className="text-base font-semibold">Channels</h2>
</div>
<div className="divide-y divide-line">
{channels.length ? (
channels.map((channel) => (
<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-xs text-slate-500">{channel.has_secret ? "Secret stored" : "No secret"}</div>
</div>
<div className="text-sm text-slate-300">{channel.channel_type}</div>
<Status enabled={channel.is_enabled} />
<div className="flex gap-2">
<Button className="h-8 w-8 px-0" disabled={busyId === channel.id} onClick={() => testChannel(channel.id)} title="Test channel" type="button" variant="ghost">
<Send size={15} />
</Button>
<Button className="h-8 w-8 px-0" disabled={busyId === channel.id} onClick={() => startEdit(channel)} title="Edit channel" type="button" variant="ghost">
<Edit3 size={15} />
</Button>
<Button className="h-8 w-8 px-0" disabled={busyId === channel.id} onClick={() => deleteChannel(channel.id)} title="Delete channel" type="button" variant="ghost">
<Trash2 size={15} />
</Button>
</div>
</div>
))
) : (
<div className="p-6 text-sm text-slate-400">No notification channels yet.</div>
)}
</div>
</div>
</section>
</div>
);
}
function Status({ enabled }: { enabled: boolean }) {
const classes = enabled ? "border-teal-500/40 bg-teal-950/40 text-teal-200" : "border-slate-600 bg-slate-900 text-slate-300";
return <span className={`inline-flex h-7 w-fit items-center rounded-md border px-2 text-xs font-medium ${classes}`}>{enabled ? "enabled" : "disabled"}</span>;
}
+223
View File
@@ -0,0 +1,223 @@
import { FormEvent, useState } from "react";
import { Edit3, Globe2, Plus, RefreshCw, Trash2, X } from "lucide-react";
import { api } from "../api/client";
import { Button } from "../components/Button";
import type { Monitor } from "../types/api";
interface WebsitesPageProps {
token: string;
monitors: Monitor[];
onCreated: () => Promise<void>;
}
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 [expectedStatus, setExpectedStatus] = useState(200);
const [expectedText, setExpectedText] = useState("");
const [intervalSeconds, setIntervalSeconds] = useState(60);
const [failureThreshold, setFailureThreshold] = useState(3);
const [alertEnabled, setAlertEnabled] = useState(true);
const [editingMonitorId, setEditingMonitorId] = useState<number | null>(null);
const [submitting, setSubmitting] = useState(false);
const [deletingId, setDeletingId] = useState<number | null>(null);
const [error, setError] = useState<string | null>(null);
async function handleSubmit(event: FormEvent) {
event.preventDefault();
setSubmitting(true);
setError(null);
try {
if (editingMonitorId) {
await api.updateMonitor(token, editingMonitorId, {
name,
target: url,
interval_seconds: intervalSeconds,
config: {
expected_status: expectedStatus,
expected_text: expectedText.trim() ? expectedText.trim() : null,
unexpected_text: null,
timeout_seconds: 10,
},
});
} else {
await api.createWebsiteMonitor(token, {
name,
url,
expected_status: expectedStatus,
expected_text: expectedText.trim() ? expectedText.trim() : null,
unexpected_text: null,
timeout_seconds: 10,
interval_seconds: intervalSeconds,
create_asset: true,
alert_enabled: alertEnabled,
alert_severity: "critical",
failure_threshold: failureThreshold,
});
}
resetForm();
await onCreated();
} catch (err) {
setError(err instanceof Error ? err.message : "Could not save website monitor");
} finally {
setSubmitting(false);
}
}
function startEdit(monitor: Monitor) {
setEditingMonitorId(monitor.id);
setName(monitor.name);
setUrl(monitor.target);
setExpectedStatus(Number(monitor.config?.expected_status ?? 200));
setExpectedText(typeof monitor.config?.expected_text === "string" ? monitor.config.expected_text : "");
setIntervalSeconds(monitor.interval_seconds);
setAlertEnabled(true);
setFailureThreshold(3);
setError(null);
}
function resetForm() {
setEditingMonitorId(null);
setName("");
setUrl("https://");
setExpectedStatus(200);
setExpectedText("");
setIntervalSeconds(60);
setFailureThreshold(3);
setAlertEnabled(true);
}
async function deleteMonitor(monitorId: number) {
setDeletingId(monitorId);
setError(null);
try {
await api.deleteMonitor(token, monitorId);
await onCreated();
} catch (err) {
setError(err instanceof Error ? err.message : "Could not delete website monitor");
} finally {
setDeletingId(null);
}
}
return (
<div className="space-y-6">
<div className="flex flex-col justify-between gap-4 md:flex-row md:items-end">
<div>
<h1 className="text-3xl font-semibold">Websites</h1>
<p className="mt-2 text-sm text-slate-400">HTTP status, expected content, response time, and alert thresholds.</p>
</div>
<Button variant="ghost" onClick={onCreated}>
<RefreshCw size={16} />
Refresh
</Button>
</div>
<section className="grid gap-5 xl:grid-cols-[420px_minmax(0,1fr)]">
<form className="space-y-4 rounded-md border border-line bg-[#0d131c] p-5" onSubmit={handleSubmit}>
<div className="flex items-center gap-2">
<Globe2 size={18} className="text-pulse" />
<h2 className="text-base font-semibold">{editingMonitorId ? "Edit Website Monitor" : "Add Website Monitor"}</h2>
</div>
<label className="block space-y-2">
<span className="text-sm text-slate-300">Name</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" value={name} onChange={(event) => setName(event.target.value)} required />
</label>
<label className="block space-y-2">
<span className="text-sm text-slate-300">URL</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" value={url} onChange={(event) => setUrl(event.target.value)} required type="url" />
</label>
<div className="grid gap-3 sm:grid-cols-2">
<label className="block space-y-2">
<span className="text-sm text-slate-300">Expected Status</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" value={expectedStatus} onChange={(event) => setExpectedStatus(Number(event.target.value))} min={100} max={599} type="number" />
</label>
<label className="block space-y-2">
<span className="text-sm text-slate-300">Interval Seconds</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" value={intervalSeconds} onChange={(event) => setIntervalSeconds(Number(event.target.value))} min={10} type="number" />
</label>
</div>
<label className="block space-y-2">
<span className="text-sm text-slate-300">Expected Text</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" value={expectedText} onChange={(event) => setExpectedText(event.target.value)} />
</label>
{!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>
<input className="h-5 w-5 accent-teal-400" checked={alertEnabled} onChange={(event) => setAlertEnabled(event.target.checked)} type="checkbox" />
</div>
) : null}
{!editingMonitorId ? (
<label className="block space-y-2">
<span className="text-sm text-slate-300">Failure Threshold</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" value={failureThreshold} onChange={(event) => setFailureThreshold(Number(event.target.value))} min={1} max={20} type="number" />
</label>
) : null}
{error ? <div className="rounded-md border border-red-500/40 bg-red-950/40 p-3 text-sm text-red-200">{error}</div> : null}
<div className="flex gap-2">
{editingMonitorId ? (
<Button className="flex-1" onClick={resetForm} type="button" variant="ghost">
<X size={16} />
Cancel
</Button>
) : null}
<Button className="flex-1" disabled={submitting} type="submit">
<Plus size={16} />
{submitting ? "Saving..." : editingMonitorId ? "Save Monitor" : "Create Monitor"}
</Button>
</div>
</form>
<div className="rounded-md border border-line bg-[#0d131c]">
<div className="border-b border-line p-4">
<h2 className="text-base font-semibold">Configured Websites</h2>
</div>
<div className="divide-y divide-line">
{websites.length ? (
websites.map((monitor) => (
<div key={monitor.id} className="grid gap-2 p-4 md:grid-cols-[1fr_110px_170px] md:items-center">
<div>
<div className="font-medium">{monitor.name}</div>
<div className="truncate text-sm text-slate-400">{monitor.target}</div>
</div>
<Status status={monitor.status} />
<div className="flex items-center justify-between gap-3">
<div className="text-sm text-slate-400">{monitor.last_checked_at ? new Date(monitor.last_checked_at).toLocaleTimeString() : "Not checked"}</div>
<Button aria-label={`Edit ${monitor.name}`} className="h-8 w-8 px-0" onClick={() => startEdit(monitor)} title="Edit monitor" type="button" variant="ghost">
<Edit3 size={15} />
</Button>
<Button aria-label={`Delete ${monitor.name}`} className="h-8 w-8 px-0" disabled={deletingId === monitor.id} onClick={() => deleteMonitor(monitor.id)} title="Delete monitor" type="button" variant="ghost">
<Trash2 size={15} />
</Button>
</div>
</div>
))
) : (
<div className="p-6 text-sm text-slate-400">No website monitors yet.</div>
)}
</div>
</div>
</section>
</div>
);
}
function Status({ status }: { status: string }) {
const classes =
status === "up"
? "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";
return <span className={`inline-flex h-7 w-24 items-center justify-center rounded-md border text-xs font-medium ${classes}`}>{status}</span>;
}
+23
View File
@@ -0,0 +1,23 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
color-scheme: dark;
font-family:
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: #090d13;
color: #e5e7eb;
}
body {
margin: 0;
min-width: 320px;
min-height: 100vh;
}
button,
input,
select {
font: inherit;
}
+94
View File
@@ -0,0 +1,94 @@
export interface User {
id: number;
email: string;
display_name: string;
role: string;
is_active: boolean;
}
export interface Asset {
id: number;
name: string;
asset_type: string;
address?: string | null;
status: string;
}
export interface Monitor {
id: number;
asset_id?: number | null;
name: string;
monitor_type: string;
target: string;
config?: Record<string, unknown>;
status: string;
interval_seconds: number;
last_checked_at?: string | null;
}
export interface MonitorUpdate {
name?: string;
target?: string;
config?: Record<string, unknown>;
interval_seconds?: number;
}
export interface Incident {
id: number;
asset_id?: number | null;
monitor_id?: number | null;
alert_rule_id?: number | null;
title: string;
severity: string;
status: string;
opened_at: string;
resolved_at?: string | null;
acknowledged_at?: string | null;
silenced_until?: string | null;
details: Record<string, unknown>;
}
export interface NotificationChannel {
id: number;
name: string;
channel_type: string;
settings: Record<string, unknown>;
has_secret: boolean;
is_enabled: boolean;
}
export interface NotificationChannelCreate {
name: string;
channel_type: string;
settings: {
username?: string;
[key: string]: unknown;
};
secret?: string | null;
is_enabled: boolean;
}
export interface NotificationChannelUpdate {
name?: string;
channel_type?: string;
settings?: {
username?: string;
[key: string]: unknown;
};
secret?: string | null;
is_enabled?: boolean;
}
export interface WebsiteMonitorCreate {
name: string;
url: string;
expected_status: number;
expected_text?: string | null;
unexpected_text?: string | null;
timeout_seconds: number;
interval_seconds: number;
create_asset: boolean;
alert_enabled: boolean;
alert_severity: string;
failure_threshold: number;
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+19
View File
@@ -0,0 +1,19 @@
import type { Config } from "tailwindcss";
export default {
content: ["./index.html", "./src/**/*.{ts,tsx}"],
darkMode: "class",
theme: {
extend: {
colors: {
ink: "#0f172a",
panel: "#111827",
line: "#273244",
pulse: "#14b8a6",
signal: "#f59e0b",
danger: "#ef4444",
},
},
},
plugins: [],
} satisfies Config;
+21
View File
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["DOM", "DOM.Iterable", "ES2022"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": ["src"],
"references": []
}
+9
View File
@@ -0,0 +1,9 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
},
});
+4
View File
@@ -0,0 +1,4 @@
#!/usr/bin/env bash
set -euo pipefail
docker compose -f docker-compose.dev.yml down
+4
View File
@@ -0,0 +1,4 @@
#!/usr/bin/env bash
set -euo pipefail
docker compose -f docker-compose.dev.yml up --build
+5
View File
@@ -0,0 +1,5 @@
#!/usr/bin/env bash
set -euo pipefail
python3 -m compileall backend/app worker/app
npm --prefix frontend run typecheck
+5
View File
@@ -0,0 +1,5 @@
#!/usr/bin/env bash
set -euo pipefail
pytest backend/tests
npm --prefix frontend run typecheck
+2
View File
@@ -0,0 +1,2 @@
__pycache__/
.venv/
+13
View File
@@ -0,0 +1,13 @@
FROM python:3.12-slim
WORKDIR /app
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
COPY pyproject.toml ./
RUN pip install --no-cache-dir -e .
COPY . .
CMD ["python", "-m", "app.main"]
+1
View File
@@ -0,0 +1 @@
"""InfraPulse worker package."""
+1
View File
@@ -0,0 +1 @@
"""Collector implementations."""
+50
View File
@@ -0,0 +1,50 @@
from dataclasses import dataclass
from time import perf_counter
import httpx
@dataclass(frozen=True)
class WebsiteCheckConfig:
url: str
expected_status: int = 200
expected_text: str | None = None
unexpected_text: str | None = None
timeout_seconds: float = 10.0
@dataclass(frozen=True)
class WebsiteCheckResult:
status: str
response_time_ms: int | None
message: str
async def run_website_check(config: WebsiteCheckConfig) -> WebsiteCheckResult:
started = perf_counter()
try:
async with httpx.AsyncClient(follow_redirects=True, timeout=config.timeout_seconds) as client:
response = await client.get(config.url)
except httpx.HTTPError as exc:
return WebsiteCheckResult(status="down", response_time_ms=None, message=str(exc))
response_time_ms = int((perf_counter() - started) * 1000)
if response.status_code != config.expected_status:
return WebsiteCheckResult(
status="down",
response_time_ms=response_time_ms,
message=f"Expected HTTP {config.expected_status}, got {response.status_code}",
)
if config.expected_text and config.expected_text not in response.text:
return WebsiteCheckResult(
status="down",
response_time_ms=response_time_ms,
message="Expected text was not present",
)
if config.unexpected_text and config.unexpected_text in response.text:
return WebsiteCheckResult(
status="down",
response_time_ms=response_time_ms,
message="Unexpected text was present",
)
return WebsiteCheckResult(status="up", response_time_ms=response_time_ms, message="Website check passed")
+22
View File
@@ -0,0 +1,22 @@
from functools import lru_cache
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"
redis_url: str = "redis://redis:6379/0"
frontend_url: str = "http://localhost:5173"
backend_url: str = "http://localhost:8000"
@lru_cache
def get_settings() -> Settings:
return Settings()
settings = get_settings()
+23
View File
@@ -0,0 +1,23 @@
from collections.abc import Generator
from contextlib import contextmanager
from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker
from app.config import settings
engine = create_engine(settings.database_url, pool_pre_ping=True)
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False)
@contextmanager
def session_scope() -> Generator[Session, None, None]:
db = SessionLocal()
try:
yield db
db.commit()
except Exception:
db.rollback()
raise
finally:
db.close()
+1
View File
@@ -0,0 +1 @@
"""Background jobs."""
+16
View File
@@ -0,0 +1,16 @@
from dataclasses import dataclass
@dataclass(frozen=True)
class AlertEvaluation:
should_open_incident: bool
should_resolve_incident: bool
message: str
def evaluate_status_rule(current_status: str, failure_count: int, threshold: int) -> AlertEvaluation:
if current_status == "up":
return AlertEvaluation(False, True, "Monitor recovered")
if failure_count >= threshold:
return AlertEvaluation(True, False, f"Monitor failed {failure_count} times")
return AlertEvaluation(False, False, "Failure threshold not reached")
+14
View File
@@ -0,0 +1,14 @@
from dataclasses import dataclass
import httpx
@dataclass(frozen=True)
class WebhookNotification:
url: str
text: str
async def send_generic_webhook(notification: WebhookNotification) -> None:
async with httpx.AsyncClient(timeout=10) as client:
await client.post(notification.url, json={"text": notification.text})
+20
View File
@@ -0,0 +1,20 @@
import asyncio
import logging
import signal
from app.scheduler import Scheduler
async def main() -> None:
logging.basicConfig(level=logging.INFO, format="%(levelname)s [%(name)s] %(message)s")
scheduler = Scheduler()
loop = asyncio.get_running_loop()
for sig in (signal.SIGINT, signal.SIGTERM):
loop.add_signal_handler(sig, scheduler.stop)
await scheduler.run()
if __name__ == "__main__":
asyncio.run(main())
+84
View File
@@ -0,0 +1,84 @@
from datetime import datetime
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, JSON, String, Text, func
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
class Base(DeclarativeBase):
pass
class Asset(Base):
__tablename__ = "assets"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
name: Mapped[str] = mapped_column(String(160))
asset_type: Mapped[str] = mapped_column(String(64))
address: Mapped[str | None] = mapped_column(String(255), nullable=True)
status: Mapped[str] = mapped_column(String(32), default="unknown")
class Monitor(Base):
__tablename__ = "monitors"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
asset_id: Mapped[int | None] = mapped_column(ForeignKey("assets.id", ondelete="CASCADE"), nullable=True)
name: Mapped[str] = mapped_column(String(160))
monitor_type: Mapped[str] = mapped_column(String(64))
target: Mapped[str] = mapped_column(String(512))
config: Mapped[dict] = mapped_column(JSON, default=dict)
interval_seconds: Mapped[int] = mapped_column(Integer, default=60)
status: Mapped[str] = mapped_column(String(32), default="unknown")
last_checked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
class CheckResult(Base):
__tablename__ = "check_results"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
monitor_id: Mapped[int] = mapped_column(ForeignKey("monitors.id", ondelete="CASCADE"))
status: Mapped[str] = mapped_column(String(32))
response_time_ms: Mapped[int | None] = mapped_column(Integer, nullable=True)
message: Mapped[str | None] = mapped_column(Text, nullable=True)
observed_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
class AlertRule(Base):
__tablename__ = "alert_rules"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
monitor_id: Mapped[int] = mapped_column(ForeignKey("monitors.id", ondelete="CASCADE"))
name: Mapped[str] = mapped_column(String(160))
severity: Mapped[str] = mapped_column(String(32), default="warning")
condition: Mapped[dict] = mapped_column(JSON, default=dict)
failure_threshold: Mapped[int] = mapped_column(Integer, default=3)
cooldown_seconds: Mapped[int] = mapped_column(Integer, default=300)
is_enabled: Mapped[bool] = mapped_column(Boolean, default=True)
class NotificationChannel(Base):
__tablename__ = "notification_channels"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
name: Mapped[str] = mapped_column(String(160))
channel_type: Mapped[str] = mapped_column(String(64))
settings: Mapped[dict] = mapped_column(JSON, default=dict)
encrypted_secret: Mapped[str | None] = mapped_column(Text, nullable=True)
is_enabled: Mapped[bool] = mapped_column(Boolean, default=True)
class Incident(Base):
__tablename__ = "incidents"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
asset_id: Mapped[int | None] = mapped_column(ForeignKey("assets.id", ondelete="SET NULL"), nullable=True)
monitor_id: Mapped[int | None] = mapped_column(ForeignKey("monitors.id", ondelete="SET NULL"), nullable=True)
alert_rule_id: Mapped[int | None] = mapped_column(ForeignKey("alert_rules.id", ondelete="SET NULL"), nullable=True)
title: Mapped[str] = mapped_column(String(240))
severity: Mapped[str] = mapped_column(String(32))
status: Mapped[str] = mapped_column(String(32), default="open")
opened_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
resolved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
acknowledged_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
silenced_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
details: Mapped[dict] = mapped_column(JSON, default=dict)
+209
View File
@@ -0,0 +1,209 @@
import asyncio
import logging
from datetime import UTC, datetime, timedelta
from sqlalchemy import select
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.orm import Session
import httpx
from app.collectors.website import WebsiteCheckConfig, run_website_check
from app.config import settings
from app.db import session_scope
from app.models import AlertRule, Asset, CheckResult, Incident, Monitor, NotificationChannel
from app.secrets import decrypt_secret
logger = logging.getLogger(__name__)
class Scheduler:
def __init__(self, poll_interval_seconds: int = 10) -> None:
self.poll_interval_seconds = poll_interval_seconds
self._stopped = asyncio.Event()
async def run(self) -> None:
logger.info("InfraPulse worker started for %s", settings.infrapulse_env)
while not self._stopped.is_set():
await self.tick()
try:
await asyncio.wait_for(self._stopped.wait(), timeout=self.poll_interval_seconds)
except TimeoutError:
continue
async def tick(self) -> None:
try:
with session_scope() as db:
due_monitors = self._load_due_website_monitors(db)
for monitor in due_monitors:
await self._run_monitor(db, monitor)
db.commit()
except SQLAlchemyError:
logger.exception("Worker tick failed while talking to the database")
def stop(self) -> None:
self._stopped.set()
def _load_due_website_monitors(self, db: Session) -> list[Monitor]:
now = datetime.now(UTC)
monitors = db.scalars(select(Monitor).where(Monitor.monitor_type == "http").order_by(Monitor.id).limit(50)).all()
due: list[Monitor] = []
for monitor in monitors:
if monitor.last_checked_at is None:
due.append(monitor)
continue
next_due_at = monitor.last_checked_at + timedelta(seconds=monitor.interval_seconds)
if next_due_at <= now:
due.append(monitor)
return due
async def _run_monitor(self, db: Session, monitor: Monitor) -> None:
config = WebsiteCheckConfig(
url=monitor.target,
expected_status=int(monitor.config.get("expected_status", 200)),
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)),
)
result = await run_website_check(config)
now = datetime.now(UTC)
monitor.status = result.status
monitor.last_checked_at = now
db.add(
CheckResult(
monitor_id=monitor.id,
status=result.status,
response_time_ms=result.response_time_ms,
message=result.message,
observed_at=now,
)
)
db.flush()
if monitor.asset_id is not None:
asset = db.get(Asset, monitor.asset_id)
if asset is not None:
asset.status = result.status
rules = db.scalars(select(AlertRule).where(AlertRule.monitor_id == monitor.id, AlertRule.is_enabled.is_(True))).all()
for rule in rules:
await self._evaluate_rule(db, monitor, rule, now, result.message)
logger.info("Checked %s: %s (%s ms)", monitor.name, result.status, result.response_time_ms)
async def _evaluate_rule(self, db: Session, monitor: Monitor, rule: AlertRule, now: datetime, message: str) -> None:
open_incident = db.scalar(
select(Incident).where(
Incident.monitor_id == monitor.id,
Incident.alert_rule_id == rule.id,
Incident.status == "open",
)
)
if monitor.status == "up":
if open_incident is not None:
open_incident.status = "resolved"
open_incident.resolved_at = now
open_incident.details = {**(open_incident.details or {}), "recovery_message": message}
await self._send_incident_notifications(db, open_incident, monitor, "resolved", now)
return
recent_statuses = list(
db.scalars(
select(CheckResult.status)
.where(CheckResult.monitor_id == monitor.id)
.order_by(CheckResult.observed_at.desc())
.limit(rule.failure_threshold)
)
)
threshold_met = len(recent_statuses) >= rule.failure_threshold and all(status != "up" for status in recent_statuses)
if threshold_met and open_incident is None:
incident = Incident(
asset_id=monitor.asset_id,
monitor_id=monitor.id,
alert_rule_id=rule.id,
title=f"{monitor.name} is failing",
severity=rule.severity,
status="open",
opened_at=now,
details={"last_message": message, "failure_threshold": rule.failure_threshold},
)
db.add(incident)
db.flush()
await self._send_incident_notifications(db, incident, monitor, "opened", now)
async def _send_incident_notifications(
self,
db: Session,
incident: Incident,
monitor: Monitor,
event_type: str,
now: datetime,
) -> None:
state_key = "opened_sent_at" if event_type == "opened" else "resolved_sent_at"
notification_state = dict((incident.details or {}).get("notification_state") or {})
if notification_state.get(state_key):
return
channels = db.scalars(
select(NotificationChannel).where(
NotificationChannel.is_enabled.is_(True),
NotificationChannel.channel_type.in_(["generic_webhook", "webhook", "mattermost", "zoom", "zoom_team_chat"]),
)
).all()
if not channels:
return
sent_channels: list[str] = []
for channel in channels:
url = decrypt_secret(channel.encrypted_secret)
if not url:
logger.warning("Skipping notification channel %s because its secret cannot be decrypted", channel.id)
continue
try:
await self._post_webhook(
url,
self._format_incident_message(incident, monitor, event_type),
str((channel.settings or {}).get("username") or "InfraPulse"),
)
except httpx.HTTPError:
logger.exception("Notification delivery failed for channel %s", channel.id)
continue
sent_channels.append(channel.name)
if sent_channels:
notification_state[state_key] = now.isoformat()
history = list((incident.details or {}).get("notification_history") or [])
history.append({"event": event_type, "sent_at": now.isoformat(), "channels": sent_channels})
incident.details = {**(incident.details or {}), "notification_state": notification_state, "notification_history": history}
async def _post_webhook(self, url: str, message: str, username: str) -> None:
async with httpx.AsyncClient(timeout=10) as client:
response = await client.post(url, json={"username": username, "text": message})
response.raise_for_status()
def _format_incident_message(self, incident: Incident, monitor: Monitor, event_type: str) -> str:
if event_type == "resolved":
title = f"RESOLVED: {monitor.name} recovered"
body = [
title,
"",
f"Monitor: {monitor.name}",
f"Target: {monitor.target}",
f"Resolved: {incident.resolved_at or datetime.now(UTC)}",
]
else:
title = f"{incident.severity.upper()}: {incident.title}"
body = [
title,
"",
f"Monitor: {monitor.name}",
f"Target: {monitor.target}",
f"Status: {monitor.status}",
f"Started: {incident.opened_at}",
]
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}"])
return "\n".join(str(line) for line in body)
+20
View File
@@ -0,0 +1,20 @@
import base64
import hashlib
from cryptography.fernet import Fernet, InvalidToken
from app.config import settings
def _fernet() -> Fernet:
digest = hashlib.sha256(settings.infrapulse_secret_key.encode("utf-8")).digest()
return Fernet(base64.urlsafe_b64encode(digest))
def decrypt_secret(value: str | None) -> str | None:
if not value:
return None
try:
return _fernet().decrypt(value.encode("utf-8")).decode("utf-8")
except InvalidToken:
return None
+17
View File
@@ -0,0 +1,17 @@
[project]
name = "infrapulse-worker"
version = "0.1.0"
description = "InfraPulse background worker"
requires-python = ">=3.12"
dependencies = [
"cryptography>=48.0.0",
"httpx>=0.27.2",
"pydantic-settings>=2.5.2",
"redis>=5.0.8",
"sqlalchemy>=2.0.35",
"psycopg[binary]>=3.2.0",
]
[build-system]
requires = ["setuptools>=75.0"]
build-backend = "setuptools.build_meta"