commit a707186a5eee5e0df0a1354305307259205ffd55 Author: Keith Smith Date: Fri May 22 17:36:40 2026 -0600 Initial InfraPulse scaffold diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..49d36b3 --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ba258ff --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..faf5183 --- /dev/null +++ b/AGENTS.md @@ -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. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..835e547 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..ece76a2 --- /dev/null +++ b/README.md @@ -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 diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..234810a --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,3 @@ +__pycache__/ +.pytest_cache/ +.venv/ diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..4cb5232 --- /dev/null +++ b/backend/Dockerfile @@ -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"] diff --git a/backend/alembic.ini b/backend/alembic.ini new file mode 100644 index 0000000..f47786b --- /dev/null +++ b/backend/alembic.ini @@ -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 diff --git a/backend/alembic/env.py b/backend/alembic/env.py new file mode 100644 index 0000000..4739933 --- /dev/null +++ b/backend/alembic/env.py @@ -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() diff --git a/backend/alembic/script.py.mako b/backend/alembic/script.py.mako new file mode 100644 index 0000000..1ba49a8 --- /dev/null +++ b/backend/alembic/script.py.mako @@ -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"} diff --git a/backend/alembic/versions/20260522_0001_initial_schema.py b/backend/alembic/versions/20260522_0001_initial_schema.py new file mode 100644 index 0000000..1428bda --- /dev/null +++ b/backend/alembic/versions/20260522_0001_initial_schema.py @@ -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") diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..aa4c655 --- /dev/null +++ b/backend/app/__init__.py @@ -0,0 +1 @@ +"""InfraPulse backend package.""" diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py new file mode 100644 index 0000000..f7ec5ce --- /dev/null +++ b/backend/app/api/__init__.py @@ -0,0 +1 @@ +"""API routers.""" diff --git a/backend/app/api/alerts.py b/backend/app/api/alerts.py new file mode 100644 index 0000000..b03e2be --- /dev/null +++ b/backend/app/api/alerts.py @@ -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 diff --git a/backend/app/api/assets.py b/backend/app/api/assets.py new file mode 100644 index 0000000..6d6fe22 --- /dev/null +++ b/backend/app/api/assets.py @@ -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() diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py new file mode 100644 index 0000000..ab53603 --- /dev/null +++ b/backend/app/api/auth.py @@ -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 diff --git a/backend/app/api/health.py b/backend/app/api/health.py new file mode 100644 index 0000000..c634f59 --- /dev/null +++ b/backend/app/api/health.py @@ -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"} diff --git a/backend/app/api/monitors.py b/backend/app/api/monitors.py new file mode 100644 index 0000000..6aaacd4 --- /dev/null +++ b/backend/app/api/monitors.py @@ -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() + ) diff --git a/backend/app/api/notifications.py b/backend/app/api/notifications.py new file mode 100644 index 0000000..15bc306 --- /dev/null +++ b/backend/app/api/notifications.py @@ -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() diff --git a/backend/app/auth/__init__.py b/backend/app/auth/__init__.py new file mode 100644 index 0000000..9aca39f --- /dev/null +++ b/backend/app/auth/__init__.py @@ -0,0 +1 @@ +"""Authentication helpers.""" diff --git a/backend/app/auth/dependencies.py b/backend/app/auth/dependencies.py new file mode 100644 index 0000000..68938c5 --- /dev/null +++ b/backend/app/auth/dependencies.py @@ -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 diff --git a/backend/app/auth/security.py b/backend/app/auth/security.py new file mode 100644 index 0000000..d6204e1 --- /dev/null +++ b/backend/app/auth/security.py @@ -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 diff --git a/backend/app/core/__init__.py b/backend/app/core/__init__.py new file mode 100644 index 0000000..137046c --- /dev/null +++ b/backend/app/core/__init__.py @@ -0,0 +1 @@ +"""Core backend configuration.""" diff --git a/backend/app/core/config.py b/backend/app/core/config.py new file mode 100644 index 0000000..e46e82c --- /dev/null +++ b/backend/app/core/config.py @@ -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() diff --git a/backend/app/core/secrets.py b/backend/app/core/secrets.py new file mode 100644 index 0000000..4570ba6 --- /dev/null +++ b/backend/app/core/secrets.py @@ -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 diff --git a/backend/app/db/__init__.py b/backend/app/db/__init__.py new file mode 100644 index 0000000..e072a48 --- /dev/null +++ b/backend/app/db/__init__.py @@ -0,0 +1 @@ +"""Database helpers.""" diff --git a/backend/app/db/base.py b/backend/app/db/base.py new file mode 100644 index 0000000..12408b5 --- /dev/null +++ b/backend/app/db/base.py @@ -0,0 +1,3 @@ +from app.models.core import Base + +__all__ = ["Base"] diff --git a/backend/app/db/session.py b/backend/app/db/session.py new file mode 100644 index 0000000..35b8095 --- /dev/null +++ b/backend/app/db/session.py @@ -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() diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..e316c06 --- /dev/null +++ b/backend/app/main.py @@ -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) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100644 index 0000000..8a84cbc --- /dev/null +++ b/backend/app/models/__init__.py @@ -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", +] diff --git a/backend/app/models/core.py b/backend/app/models/core.py new file mode 100644 index 0000000..e3fdf1d --- /dev/null +++ b/backend/app/models/core.py @@ -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()) diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py new file mode 100644 index 0000000..b10d193 --- /dev/null +++ b/backend/app/schemas/__init__.py @@ -0,0 +1 @@ +"""Pydantic API schemas.""" diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth.py new file mode 100644 index 0000000..fea5dd8 --- /dev/null +++ b/backend/app/schemas/auth.py @@ -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} diff --git a/backend/app/schemas/core.py b/backend/app/schemas/core.py new file mode 100644 index 0000000..5fc8d34 --- /dev/null +++ b/backend/app/schemas/core.py @@ -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} diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py new file mode 100644 index 0000000..8162292 --- /dev/null +++ b/backend/app/services/__init__.py @@ -0,0 +1 @@ +"""Domain services.""" diff --git a/backend/app/services/bootstrap.py b/backend/app/services/bootstrap.py new file mode 100644 index 0000000..153c9c4 --- /dev/null +++ b/backend/app/services/bootstrap.py @@ -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() diff --git a/backend/pyproject.toml b/backend/pyproject.toml new file mode 100644 index 0000000..cd9df5f --- /dev/null +++ b/backend/pyproject.toml @@ -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" diff --git a/backend/tests/test_health.py b/backend/tests/test_health.py new file mode 100644 index 0000000..259e38f --- /dev/null +++ b/backend/tests/test_health.py @@ -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" diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 0000000..b8bb651 --- /dev/null +++ b/docker-compose.dev.yml @@ -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: diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..66cc988 --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/docs/alerting-design.md b/docs/alerting-design.md new file mode 100644 index 0000000..efaa496 --- /dev/null +++ b/docs/alerting-design.md @@ -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. diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..6c33210 --- /dev/null +++ b/docs/architecture.md @@ -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. diff --git a/docs/development.md b/docs/development.md new file mode 100644 index 0000000..e9f91a1 --- /dev/null +++ b/docs/development.md @@ -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 +``` diff --git a/docs/discovery-design.md b/docs/discovery-design.md new file mode 100644 index 0000000..06427b1 --- /dev/null +++ b/docs/discovery-design.md @@ -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. diff --git a/docs/gitea-issues.md b/docs/gitea-issues.md new file mode 100644 index 0000000..17a4431 --- /dev/null +++ b/docs/gitea-issues.md @@ -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 diff --git a/docs/plugin-design.md b/docs/plugin-design.md new file mode 100644 index 0000000..73cf8e8 --- /dev/null +++ b/docs/plugin-design.md @@ -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 diff --git a/docs/roadmap.md b/docs/roadmap.md new file mode 100644 index 0000000..231197d --- /dev/null +++ b/docs/roadmap.md @@ -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 diff --git a/docs/security.md b/docs/security.md new file mode 100644 index 0000000..d5dd416 --- /dev/null +++ b/docs/security.md @@ -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. diff --git a/docs/vision.md b/docs/vision.md new file mode 100644 index 0000000..9dcac86 --- /dev/null +++ b/docs/vision.md @@ -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. diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000..f4e2c6d --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,3 @@ +node_modules/ +dist/ +*.tsbuildinfo diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..c503198 --- /dev/null +++ b/frontend/Dockerfile @@ -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"] diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..43d0656 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + InfraPulse + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..164ffc3 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,3024 @@ +{ + "name": "infrapulse-frontend", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "infrapulse-frontend", + "version": "0.1.0", + "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" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.3", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.3.tgz", + "integrity": "sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.3", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz", + "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.3", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", + "integrity": "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==", + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.4.tgz", + "integrity": "sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.4.tgz", + "integrity": "sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.4.tgz", + "integrity": "sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.4.tgz", + "integrity": "sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.4.tgz", + "integrity": "sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.4.tgz", + "integrity": "sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.4.tgz", + "integrity": "sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.4.tgz", + "integrity": "sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.4.tgz", + "integrity": "sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.4.tgz", + "integrity": "sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.4.tgz", + "integrity": "sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.4.tgz", + "integrity": "sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.4.tgz", + "integrity": "sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.4.tgz", + "integrity": "sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.4.tgz", + "integrity": "sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.4.tgz", + "integrity": "sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.4.tgz", + "integrity": "sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.4.tgz", + "integrity": "sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.4.tgz", + "integrity": "sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.4.tgz", + "integrity": "sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.4.tgz", + "integrity": "sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.4.tgz", + "integrity": "sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.4.tgz", + "integrity": "sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.4.tgz", + "integrity": "sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.4.tgz", + "integrity": "sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.2.15", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.15.tgz", + "integrity": "sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz", + "integrity": "sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==", + "license": "MIT", + "dependencies": { + "@babel/core": "^7.29.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-rc.3", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.18.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/autoprefixer": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.0.tgz", + "integrity": "sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.2", + "caniuse-lite": "^1.0.30001787", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.32", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.32.tgz", + "integrity": "sha512-wbPvpyjJPC0zdfdKXxqEL3Ea+bOMD/87X4lftiJkkaBiuG6ALQy1SLmEd7BSmVCuwCQsBrCamgBoLyfFDD1EPg==", + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001793", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001793.tgz", + "integrity": "sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "license": "MIT" + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, + "license": "MIT" + }, + "node_modules/dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.361", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.361.tgz", + "integrity": "sha512-Q6Hts7N9FnJc5LeGRINFvLhCI9xZmNtTDe5ZbcVezQz7cU4a8Aua3GH1b8J2XY8Al9PF+OCwYqhgsOOheMdvkA==", + "license": "ISC" + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "license": "MIT" + }, + "node_modules/fast-equals": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.0.tgz", + "integrity": "sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "devOptional": true, + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "0.468.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.468.0.tgz", + "integrity": "sha512-6koYRhnM2N0GGZIdXzSeiNwguv1gt/FAjZOiPl76roBi3xKEXa4WmfpxgQwTTL4KipXjefrnf3oV4IsYhi4JFA==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.46", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.46.tgz", + "integrity": "sha512-GYVXHE2KnrzAfsAjl4uP++evGFCrAU1jta4ubEjIG7YWt/64Gqv66a30yKwWczVjA6j3bM4nBwH7Pk1JmDHaxQ==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/prop-types/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/react": { + "version": "19.2.6", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz", + "integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.6", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.6.tgz", + "integrity": "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.6" + } + }, + "node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "license": "MIT" + }, + "node_modules/react-refresh": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", + "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-smooth": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz", + "integrity": "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==", + "license": "MIT", + "dependencies": { + "fast-equals": "^5.0.1", + "prop-types": "^15.8.1", + "react-transition-group": "^4.4.5" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/react-transition-group": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", + "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", + "license": "BSD-3-Clause", + "dependencies": { + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + }, + "peerDependencies": { + "react": ">=16.6.0", + "react-dom": ">=16.6.0" + } + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/recharts": { + "version": "2.15.4", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.4.tgz", + "integrity": "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==", + "license": "MIT", + "dependencies": { + "clsx": "^2.0.0", + "eventemitter3": "^4.0.1", + "lodash": "^4.17.21", + "react-is": "^18.3.1", + "react-smooth": "^4.0.4", + "recharts-scale": "^0.4.4", + "tiny-invariant": "^1.3.1", + "victory-vendor": "^36.6.8" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/recharts-scale": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz", + "integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==", + "license": "MIT", + "dependencies": { + "decimal.js-light": "^2.4.1" + } + }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.4.tgz", + "integrity": "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==", + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.4", + "@rollup/rollup-android-arm64": "4.60.4", + "@rollup/rollup-darwin-arm64": "4.60.4", + "@rollup/rollup-darwin-x64": "4.60.4", + "@rollup/rollup-freebsd-arm64": "4.60.4", + "@rollup/rollup-freebsd-x64": "4.60.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.4", + "@rollup/rollup-linux-arm-musleabihf": "4.60.4", + "@rollup/rollup-linux-arm64-gnu": "4.60.4", + "@rollup/rollup-linux-arm64-musl": "4.60.4", + "@rollup/rollup-linux-loong64-gnu": "4.60.4", + "@rollup/rollup-linux-loong64-musl": "4.60.4", + "@rollup/rollup-linux-ppc64-gnu": "4.60.4", + "@rollup/rollup-linux-ppc64-musl": "4.60.4", + "@rollup/rollup-linux-riscv64-gnu": "4.60.4", + "@rollup/rollup-linux-riscv64-musl": "4.60.4", + "@rollup/rollup-linux-s390x-gnu": "4.60.4", + "@rollup/rollup-linux-x64-gnu": "4.60.4", + "@rollup/rollup-linux-x64-musl": "4.60.4", + "@rollup/rollup-openbsd-x64": "4.60.4", + "@rollup/rollup-openharmony-arm64": "4.60.4", + "@rollup/rollup-win32-arm64-msvc": "4.60.4", + "@rollup/rollup-win32-ia32-msvc": "4.60.4", + "@rollup/rollup-win32-x64-gnu": "4.60.4", + "@rollup/rollup-win32-x64-msvc": "4.60.4", + "fsevents": "~2.3.2" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", + "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.7", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/victory-vendor": { + "version": "36.9.2", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz", + "integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, + "node_modules/vite": { + "version": "7.3.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.3.tgz", + "integrity": "sha512-/4XH147Ui7OGTjg3HbdWe5arnZQSbfuRzdr9Ec7TQi5I7R+ir0Rlc9GIvD4v0XZurELqA035KVXJXpR61xhiTA==", + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "license": "ISC" + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..75d31dc --- /dev/null +++ b/frontend/package.json @@ -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" + } +} diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000..2aa7205 --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts new file mode 100644 index 0000000..bf55c67 --- /dev/null +++ b/frontend/src/api/client.ts @@ -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(path: string, token?: string, init: RequestInit = {}): Promise { + 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 { + 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("/auth/me", token), + assets: (token: string) => request("/assets", token), + monitors: (token: string) => request("/monitors", token), + createWebsiteMonitor: (token: string, payload: WebsiteMonitorCreate) => + request("/monitors/website", token, { + method: "POST", + body: JSON.stringify(payload), + }), + updateMonitor: (token: string, monitorId: number, payload: MonitorUpdate) => + request(`/monitors/${monitorId}`, token, { + method: "PATCH", + body: JSON.stringify(payload), + }), + deleteMonitor: (token: string, monitorId: number) => + request(`/monitors/${monitorId}`, token, { + method: "DELETE", + }), + incidents: (token: string) => request("/incidents", token), + acknowledgeIncident: (token: string, incidentId: number) => + request(`/incidents/${incidentId}/acknowledge`, token, { + method: "POST", + }), + silenceIncident: (token: string, incidentId: number, minutes = 60) => + request(`/incidents/${incidentId}/silence?minutes=${minutes}`, token, { + method: "POST", + }), + notificationChannels: (token: string) => request("/notifications/channels", token), + createNotificationChannel: (token: string, payload: NotificationChannelCreate) => + request("/notifications/channels", token, { + method: "POST", + body: JSON.stringify(payload), + }), + updateNotificationChannel: (token: string, channelId: number, payload: NotificationChannelUpdate) => + request(`/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(`/notifications/channels/${channelId}`, token, { + method: "DELETE", + }), +}; diff --git a/frontend/src/app/App.tsx b/frontend/src/app/App.tsx new file mode 100644 index 0000000..c664ac5 --- /dev/null +++ b/frontend/src/app/App.tsx @@ -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(initialIncidentId ? "alerts" : "dashboard"); + const [selectedIncidentId, setSelectedIncidentId] = useState(initialIncidentId); + const [assets, setAssets] = useState([]); + const [monitors, setMonitors] = useState([]); + const [incidents, setIncidents] = useState([]); + + 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
Loading InfraPulse...
; + } + + if (!auth.user || !auth.token) { + return ; + } + + return ( + + {page === "dashboard" ? : null} + {page === "assets" ? ( + + [asset.name, asset.asset_type, asset.address ?? "-", asset.status])} columns={["Name", "Type", "Address", "Status"]} /> + + ) : null} + {page === "websites" ? ( + + ) : null} + {page === "alerts" ? ( + + ) : null} + {page === "discovery" ? : null} + {page === "graphs" ? : null} + {page === "credentials" ? : null} + {page === "notifications" ? : null} + {page === "admin" ? : null} + + ); +} + +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 ( +
+ + + + {columns.map((column) => ( + + ))} + + + + {rows.length ? ( + rows.map((row, rowIndex) => ( + + {row.map((cell, cellIndex) => ( + + ))} + + )) + ) : ( + + + + )} + +
+ {column} +
+ {cell} +
+ No records yet. +
+
+ ); +} diff --git a/frontend/src/components/Button.tsx b/frontend/src/components/Button.tsx new file mode 100644 index 0000000..78aa49f --- /dev/null +++ b/frontend/src/components/Button.tsx @@ -0,0 +1,29 @@ +import type { ButtonHTMLAttributes, PropsWithChildren } from "react"; + +type ButtonVariant = "primary" | "ghost" | "danger"; + +const variants: Record = { + 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 { + variant?: ButtonVariant; +} + +export function Button({ + children, + className = "", + variant = "primary", + ...props +}: PropsWithChildren) { + return ( + + ); +} diff --git a/frontend/src/components/Shell.tsx b/frontend/src/components/Shell.tsx new file mode 100644 index 0000000..842f8f8 --- /dev/null +++ b/frontend/src/components/Shell.tsx @@ -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 ( +
+ + +
+
+
+
Signed in as
+
{user.email}
+
+ +
+
{children}
+
+
+ ); +} diff --git a/frontend/src/hooks/useAuth.ts b/frontend/src/hooks/useAuth.ts new file mode 100644 index 0000000..fbc6017 --- /dev/null +++ b/frontend/src/hooks/useAuth.ts @@ -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(() => localStorage.getItem(TOKEN_KEY)); + const [user, setUser] = useState(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], + ); +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..a1eb649 --- /dev/null +++ b/frontend/src/main.tsx @@ -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( + + + , +); diff --git a/frontend/src/pages/AlertsPage.tsx b/frontend/src/pages/AlertsPage.tsx new file mode 100644 index 0000000..2bcad0c --- /dev/null +++ b/frontend/src/pages/AlertsPage.tsx @@ -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; +} + +export function AlertsPage({ token, incidents, selectedIncidentId, onChanged }: AlertsPageProps) { + const [busyId, setBusyId] = useState(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 ( +
+
+
+

Alerts

+

Open incidents, acknowledgements, silences, recoveries, and notification history.

+
+ +
+ +
+
+
Incident
+
Severity
+
Status
+
Actions
+
+
+ {incidents.length ? ( + incidents.map((incident) => { + const open = incident.status === "open"; + return ( +
+
+
+ + {incident.title} +
+
+ Opened {new Date(incident.opened_at).toLocaleString()} + {incident.resolved_at ? `, resolved ${new Date(incident.resolved_at).toLocaleString()}` : ""} +
+ {typeof incident.details.last_message === "string" ?
{incident.details.last_message}
: null} +
+ +
+ + {incident.acknowledged_at ?
Acknowledged
: null} + {incident.silenced_until ?
Silenced
: null} +
+
+ + +
+
+ ); + }) + ) : ( +
No incidents recorded.
+ )} +
+
+
+ ); +} + +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 {value}; +} diff --git a/frontend/src/pages/DashboardPage.tsx b/frontend/src/pages/DashboardPage.tsx new file mode 100644 index 0000000..014256f --- /dev/null +++ b/frontend/src/pages/DashboardPage.tsx @@ -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 ( +
+
+
+

Dashboard

+

Current infrastructure health, incidents, and website status.

+
+
+ +
+ + + + +
+ +
+
+
+

Website Monitors

+ {downMonitors} down +
+
+ {websites.length ? ( + websites.map((monitor) => ( +
+
+
{monitor.name}
+
{monitor.target}
+
+
{monitor.interval_seconds}s interval
+ +
+ )) + ) : ( +
No website monitors yet.
+ )} +
+
+ +
+
+

Recent Attention

+
+
+ {incidents.length ? ( + incidents.slice(0, 6).map((incident) => ( +
+
+ + {incident.title} +
+
+ + {new Date(incident.opened_at).toLocaleString()} +
+
+ )) + ) : ( +
No incidents recorded.
+ )} +
+
+
+
+ ); +} + +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 ( +
+
+ +
+
{label}
+
{value}
+
+ ); +} + +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 {status}; +} diff --git a/frontend/src/pages/ListPage.tsx b/frontend/src/pages/ListPage.tsx new file mode 100644 index 0000000..85c847b --- /dev/null +++ b/frontend/src/pages/ListPage.tsx @@ -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 ( +
+
+

{title}

+

{description}

+
+
{children ??

Initial UI shell ready for implementation.

}
+
+ ); +} diff --git a/frontend/src/pages/LoginPage.tsx b/frontend/src/pages/LoginPage.tsx new file mode 100644 index 0000000..1720534 --- /dev/null +++ b/frontend/src/pages/LoginPage.tsx @@ -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; +} + +export function LoginPage({ onLogin }: LoginPageProps) { + const [email, setEmail] = useState("admin@example.com"); + const [password, setPassword] = useState("change-me"); + const [error, setError] = useState(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 ( +
+
+
+
+ +
+
InfraPulse
+
+
+

+ Beautiful, self-hosted infrastructure monitoring. +

+

+ Guided setup, clean dashboards, website checks, incidents, and notifications without enterprise-tool overhead. +

+
+
+ +
+
+
+
+ +
+

Sign in

+
+ + + + + + {error ?
{error}
: null} + + +
+
+
+ ); +} diff --git a/frontend/src/pages/NotificationsPage.tsx b/frontend/src/pages/NotificationsPage.tsx new file mode 100644 index 0000000..a2fb6c6 --- /dev/null +++ b/frontend/src/pages/NotificationsPage.tsx @@ -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([]); + 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(null); + const [busyId, setBusyId] = useState(null); + const [submitting, setSubmitting] = useState(false); + const [message, setMessage] = useState(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 ( +
+
+
+

Notifications

+

Webhook destinations for alert and recovery messages.

+
+ +
+ +
+
+
+ +

{editingChannelId ? "Edit Webhook Channel" : "Add Webhook Channel"}

+
+ + + + + + + + + +
+ Enabled + setEnabled(event.target.checked)} type="checkbox" /> +
+ + {message ?
{message}
: null} + +
+ {editingChannelId ? ( + + ) : null} + +
+
+ +
+
+

Channels

+
+
+ {channels.length ? ( + channels.map((channel) => ( +
+
+
{channel.name}
+
{String(channel.settings.username || "InfraPulse")}
+
{channel.has_secret ? "Secret stored" : "No secret"}
+
+
{channel.channel_type}
+ +
+ + + +
+
+ )) + ) : ( +
No notification channels yet.
+ )} +
+
+
+
+ ); +} + +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 {enabled ? "enabled" : "disabled"}; +} diff --git a/frontend/src/pages/WebsitesPage.tsx b/frontend/src/pages/WebsitesPage.tsx new file mode 100644 index 0000000..4046a13 --- /dev/null +++ b/frontend/src/pages/WebsitesPage.tsx @@ -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; +} + +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(null); + const [submitting, setSubmitting] = useState(false); + const [deletingId, setDeletingId] = useState(null); + const [error, setError] = useState(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 ( +
+
+
+

Websites

+

HTTP status, expected content, response time, and alert thresholds.

+
+ +
+ +
+
+
+ +

{editingMonitorId ? "Edit Website Monitor" : "Add Website Monitor"}

+
+ + + + + +
+ + +
+ + + + {!editingMonitorId ? ( +
+ Alert on repeated failures + setAlertEnabled(event.target.checked)} type="checkbox" /> +
+ ) : null} + + {!editingMonitorId ? ( + + ) : null} + + {error ?
{error}
: null} + +
+ {editingMonitorId ? ( + + ) : null} + +
+
+ +
+
+

Configured Websites

+
+
+ {websites.length ? ( + websites.map((monitor) => ( +
+
+
{monitor.name}
+
{monitor.target}
+
+ +
+
{monitor.last_checked_at ? new Date(monitor.last_checked_at).toLocaleTimeString() : "Not checked"}
+ + +
+
+ )) + ) : ( +
No website monitors yet.
+ )} +
+
+
+
+ ); +} + +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 {status}; +} diff --git a/frontend/src/styles.css b/frontend/src/styles.css new file mode 100644 index 0000000..c8221f1 --- /dev/null +++ b/frontend/src/styles.css @@ -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; +} diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts new file mode 100644 index 0000000..8603db9 --- /dev/null +++ b/frontend/src/types/api.ts @@ -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; + status: string; + interval_seconds: number; + last_checked_at?: string | null; +} + +export interface MonitorUpdate { + name?: string; + target?: string; + config?: Record; + 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; +} + +export interface NotificationChannel { + id: number; + name: string; + channel_type: string; + settings: Record; + 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; +} diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/frontend/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/frontend/tailwind.config.ts b/frontend/tailwind.config.ts new file mode 100644 index 0000000..08c2c2c --- /dev/null +++ b/frontend/tailwind.config.ts @@ -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; diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..df11947 --- /dev/null +++ b/frontend/tsconfig.json @@ -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": [] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..43a4002 --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; + +export default defineConfig({ + plugins: [react()], + server: { + port: 5173, + }, +}); diff --git a/scripts/dev-down.sh b/scripts/dev-down.sh new file mode 100755 index 0000000..66315bc --- /dev/null +++ b/scripts/dev-down.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +set -euo pipefail + +docker compose -f docker-compose.dev.yml down diff --git a/scripts/dev-up.sh b/scripts/dev-up.sh new file mode 100755 index 0000000..a44fd61 --- /dev/null +++ b/scripts/dev-up.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +set -euo pipefail + +docker compose -f docker-compose.dev.yml up --build diff --git a/scripts/lint.sh b/scripts/lint.sh new file mode 100755 index 0000000..8dd4c0c --- /dev/null +++ b/scripts/lint.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -euo pipefail + +python3 -m compileall backend/app worker/app +npm --prefix frontend run typecheck diff --git a/scripts/test.sh b/scripts/test.sh new file mode 100755 index 0000000..a5d7427 --- /dev/null +++ b/scripts/test.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -euo pipefail + +pytest backend/tests +npm --prefix frontend run typecheck diff --git a/worker/.dockerignore b/worker/.dockerignore new file mode 100644 index 0000000..670a936 --- /dev/null +++ b/worker/.dockerignore @@ -0,0 +1,2 @@ +__pycache__/ +.venv/ diff --git a/worker/Dockerfile b/worker/Dockerfile new file mode 100644 index 0000000..6f03f1c --- /dev/null +++ b/worker/Dockerfile @@ -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"] diff --git a/worker/app/__init__.py b/worker/app/__init__.py new file mode 100644 index 0000000..f7a0a6f --- /dev/null +++ b/worker/app/__init__.py @@ -0,0 +1 @@ +"""InfraPulse worker package.""" diff --git a/worker/app/collectors/__init__.py b/worker/app/collectors/__init__.py new file mode 100644 index 0000000..5a0a102 --- /dev/null +++ b/worker/app/collectors/__init__.py @@ -0,0 +1 @@ +"""Collector implementations.""" diff --git a/worker/app/collectors/website.py b/worker/app/collectors/website.py new file mode 100644 index 0000000..5f8cf39 --- /dev/null +++ b/worker/app/collectors/website.py @@ -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") diff --git a/worker/app/config.py b/worker/app/config.py new file mode 100644 index 0000000..222815c --- /dev/null +++ b/worker/app/config.py @@ -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() diff --git a/worker/app/db.py b/worker/app/db.py new file mode 100644 index 0000000..71e22a6 --- /dev/null +++ b/worker/app/db.py @@ -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() diff --git a/worker/app/jobs/__init__.py b/worker/app/jobs/__init__.py new file mode 100644 index 0000000..75ca0d0 --- /dev/null +++ b/worker/app/jobs/__init__.py @@ -0,0 +1 @@ +"""Background jobs.""" diff --git a/worker/app/jobs/alerts.py b/worker/app/jobs/alerts.py new file mode 100644 index 0000000..e3b1399 --- /dev/null +++ b/worker/app/jobs/alerts.py @@ -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") diff --git a/worker/app/jobs/notifications.py b/worker/app/jobs/notifications.py new file mode 100644 index 0000000..13b0473 --- /dev/null +++ b/worker/app/jobs/notifications.py @@ -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}) diff --git a/worker/app/main.py b/worker/app/main.py new file mode 100644 index 0000000..f397d8d --- /dev/null +++ b/worker/app/main.py @@ -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()) diff --git a/worker/app/models.py b/worker/app/models.py new file mode 100644 index 0000000..f6c522d --- /dev/null +++ b/worker/app/models.py @@ -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) diff --git a/worker/app/scheduler.py b/worker/app/scheduler.py new file mode 100644 index 0000000..22ee1ff --- /dev/null +++ b/worker/app/scheduler.py @@ -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) diff --git a/worker/app/secrets.py b/worker/app/secrets.py new file mode 100644 index 0000000..b1ccb50 --- /dev/null +++ b/worker/app/secrets.py @@ -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 diff --git a/worker/pyproject.toml b/worker/pyproject.toml new file mode 100644 index 0000000..752bf11 --- /dev/null +++ b/worker/pyproject.toml @@ -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"