Initial InfraPulse scaffold
This commit is contained in:
@@ -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
@@ -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/
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -0,0 +1,3 @@
|
||||
__pycache__/
|
||||
.pytest_cache/
|
||||
.venv/
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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")
|
||||
@@ -0,0 +1 @@
|
||||
"""InfraPulse backend package."""
|
||||
@@ -0,0 +1 @@
|
||||
"""API routers."""
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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"}
|
||||
@@ -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()
|
||||
)
|
||||
@@ -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()
|
||||
@@ -0,0 +1 @@
|
||||
"""Authentication helpers."""
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
"""Core backend configuration."""
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
"""Database helpers."""
|
||||
@@ -0,0 +1,3 @@
|
||||
from app.models.core import Base
|
||||
|
||||
__all__ = ["Base"]
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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())
|
||||
@@ -0,0 +1 @@
|
||||
"""Pydantic API schemas."""
|
||||
@@ -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}
|
||||
@@ -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}
|
||||
@@ -0,0 +1 @@
|
||||
"""Domain services."""
|
||||
@@ -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()
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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:
|
||||
@@ -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:
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
```
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
dist/
|
||||
*.tsbuildinfo
|
||||
@@ -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"]
|
||||
@@ -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>
|
||||
Generated
+3024
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
@@ -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",
|
||||
}),
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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],
|
||||
);
|
||||
}
|
||||
@@ -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>,
|
||||
);
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -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;
|
||||
@@ -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": []
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5173,
|
||||
},
|
||||
});
|
||||
Executable
+4
@@ -0,0 +1,4 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
docker compose -f docker-compose.dev.yml down
|
||||
Executable
+4
@@ -0,0 +1,4 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
docker compose -f docker-compose.dev.yml up --build
|
||||
Executable
+5
@@ -0,0 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
python3 -m compileall backend/app worker/app
|
||||
npm --prefix frontend run typecheck
|
||||
Executable
+5
@@ -0,0 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
pytest backend/tests
|
||||
npm --prefix frontend run typecheck
|
||||
@@ -0,0 +1,2 @@
|
||||
__pycache__/
|
||||
.venv/
|
||||
@@ -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"]
|
||||
@@ -0,0 +1 @@
|
||||
"""InfraPulse worker package."""
|
||||
@@ -0,0 +1 @@
|
||||
"""Collector implementations."""
|
||||
@@ -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")
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -0,0 +1 @@
|
||||
"""Background jobs."""
|
||||
@@ -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")
|
||||
@@ -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})
|
||||
@@ -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())
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user