Browse Source

Add comprehensive monitoring and alerting system

Backend:
- Add host monitoring service with psutil (CPU, RAM, Load, Disk, Network)
- Background task collects metrics every 60 seconds
- Threshold-based alerting (configurable via settings)
- 30-day metrics retention with automatic cleanup
- Add alert service with modular dispatch (Dashboard/Telegram/Email)
- Alert deduplication (5-minute window)
- Add security event tracking model (brute-force, flooding detection)
- Create monitoring tables: host_metrics, security_events, alerts
- Fixed timezone support (DateTime with timezone=True)
- Fixed integer overflow (BigInteger for large values)
- Add monitoring API endpoints:
  * GET /superadmin/monitoring/host-metrics/recent
  * GET /superadmin/monitoring/host-metrics/history
  * GET /superadmin/monitoring/alerts
  * POST /superadmin/monitoring/alerts/{id}/acknowledge
  * POST /superadmin/monitoring/alerts/{id}/dismiss
  * GET /superadmin/monitoring/security-events
  * POST /superadmin/monitoring/security-events/{id}/resolve
- Add settings API endpoints:
  * GET /superadmin/settings/setting/{key}
  * PUT /superadmin/settings/setting/{key}
- Default settings migration (host_monitoring, alert_channels, security_monitoring)

Frontend:
- Add Settings page with three tabs:
  * Host Monitoring (CPU/RAM/Load/Disk/Network thresholds)
  * Security Monitoring (brute-force and flood detection thresholds)
  * Alert Channels (Telegram bot and Email SMTP configuration)
- Add Settings navigation link to sidebar
- Add bilingual localization (EN/RU) for all settings

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
root 4 weeks ago
parent
commit
f37acf769c

+ 97 - 0
backend/alembic/versions/20251229_0118_f06499634fff_add_monitoring_tables.py

@@ -0,0 +1,97 @@
+"""add_monitoring_tables
+
+Revision ID: f06499634fff
+Revises: c1148f55dcd9
+Create Date: 2025-12-29 01:18:05.570528+00:00
+
+"""
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+from sqlalchemy.dialects import postgresql
+
+
+# revision identifiers, used by Alembic.
+revision: str = 'f06499634fff'
+down_revision: Union[str, None] = 'c1148f55dcd9'
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+    # Create host_metrics table
+    op.create_table(
+        'host_metrics',
+        sa.Column('id', sa.Integer(), nullable=False),
+        sa.Column('timestamp', sa.DateTime(timezone=True), nullable=False),
+        sa.Column('cpu_percent', sa.Float(), nullable=False),
+        sa.Column('cpu_count', sa.Integer(), nullable=False),
+        sa.Column('memory_total', sa.BigInteger(), nullable=False),
+        sa.Column('memory_used', sa.BigInteger(), nullable=False),
+        sa.Column('memory_percent', sa.Float(), nullable=False),
+        sa.Column('load_1', sa.Float(), nullable=False),
+        sa.Column('load_5', sa.Float(), nullable=False),
+        sa.Column('load_15', sa.Float(), nullable=False),
+        sa.Column('disk_read_bytes', sa.BigInteger(), nullable=False),
+        sa.Column('disk_write_bytes', sa.BigInteger(), nullable=False),
+        sa.Column('disk_usage_percent', sa.Float(), nullable=False),
+        sa.Column('net_sent_bytes', sa.BigInteger(), nullable=False),
+        sa.Column('net_recv_bytes', sa.BigInteger(), nullable=False),
+        sa.PrimaryKeyConstraint('id')
+    )
+    op.create_index('ix_host_metrics_timestamp', 'host_metrics', ['timestamp'])
+
+    # Create security_events table
+    op.create_table(
+        'security_events',
+        sa.Column('id', sa.Integer(), nullable=False),
+        sa.Column('timestamp', sa.DateTime(timezone=True), nullable=False),
+        sa.Column('event_type', sa.String(length=50), nullable=False),
+        sa.Column('severity', sa.String(length=20), nullable=False),
+        sa.Column('ip_address', sa.String(length=45), nullable=True),
+        sa.Column('user_agent', sa.Text(), nullable=True),
+        sa.Column('endpoint', sa.String(length=255), nullable=True),
+        sa.Column('description', sa.Text(), nullable=False),
+        sa.Column('event_metadata', postgresql.JSON(astext_type=sa.Text()), nullable=True),
+        sa.Column('resolved', sa.Boolean(), nullable=False, server_default='false'),
+        sa.Column('resolved_at', sa.DateTime(timezone=True), nullable=True),
+        sa.Column('resolved_by', sa.Integer(), nullable=True),
+        sa.PrimaryKeyConstraint('id')
+    )
+    op.create_index('ix_security_events_timestamp', 'security_events', ['timestamp'])
+    op.create_index('ix_security_events_event_type', 'security_events', ['event_type'])
+    op.create_index('ix_security_events_ip_address', 'security_events', ['ip_address'])
+    op.create_index('ix_security_events_resolved', 'security_events', ['resolved'])
+
+    # Create alerts table
+    op.create_table(
+        'alerts',
+        sa.Column('id', sa.Integer(), nullable=False),
+        sa.Column('timestamp', sa.DateTime(timezone=True), nullable=False),
+        sa.Column('alert_type', sa.String(length=50), nullable=False),
+        sa.Column('severity', sa.String(length=20), nullable=False),
+        sa.Column('title', sa.String(length=255), nullable=False),
+        sa.Column('message', sa.Text(), nullable=False),
+        sa.Column('alert_metadata', postgresql.JSON(astext_type=sa.Text()), nullable=True),
+        sa.Column('acknowledged', sa.Boolean(), nullable=False, server_default='false'),
+        sa.Column('acknowledged_at', sa.DateTime(timezone=True), nullable=True),
+        sa.Column('acknowledged_by', sa.Integer(), nullable=True),
+        sa.Column('dismissed', sa.Boolean(), nullable=False, server_default='false'),
+        sa.Column('dismissed_at', sa.DateTime(timezone=True), nullable=True),
+        sa.Column('sent_dashboard', sa.Boolean(), nullable=False, server_default='false'),
+        sa.Column('sent_telegram', sa.Boolean(), nullable=False, server_default='false'),
+        sa.Column('sent_email', sa.Boolean(), nullable=False, server_default='false'),
+        sa.PrimaryKeyConstraint('id')
+    )
+    op.create_index('ix_alerts_timestamp', 'alerts', ['timestamp'])
+    op.create_index('ix_alerts_alert_type', 'alerts', ['alert_type'])
+    op.create_index('ix_alerts_severity', 'alerts', ['severity'])
+    op.create_index('ix_alerts_acknowledged', 'alerts', ['acknowledged'])
+    op.create_index('ix_alerts_dismissed', 'alerts', ['dismissed'])
+
+
+def downgrade() -> None:
+    op.drop_table('alerts')
+    op.drop_table('security_events')
+    op.drop_table('host_metrics')

+ 78 - 0
backend/alembic/versions/20251229_0119_7ff254c24bb5_add_default_monitoring_settings.py

@@ -0,0 +1,78 @@
+"""add_default_monitoring_settings
+
+Revision ID: 7ff254c24bb5
+Revises: f06499634fff
+Create Date: 2025-12-29 01:19:57.107683+00:00
+
+"""
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision: str = '7ff254c24bb5'
+down_revision: Union[str, None] = 'f06499634fff'
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+    # Add default host monitoring settings
+    op.execute(
+        """
+        INSERT INTO settings (key, value, updated_at)
+        VALUES ('host_monitoring', '{
+            "enabled": true,
+            "cpu_threshold": 90,
+            "memory_threshold": 90,
+            "load_threshold": 2.0,
+            "disk_threshold": 90,
+            "network_threshold_mbps": 100
+        }', NOW())
+        """
+    )
+
+    # Add default alert channels settings
+    op.execute(
+        """
+        INSERT INTO settings (key, value, updated_at)
+        VALUES ('alert_channels', '{
+            "telegram": {
+                "enabled": false,
+                "bot_token": "",
+                "chat_ids": []
+            },
+            "email": {
+                "enabled": false,
+                "smtp_server": "",
+                "smtp_port": 587,
+                "smtp_user": "",
+                "smtp_password": "",
+                "from_address": "",
+                "recipients": []
+            }
+        }', NOW())
+        """
+    )
+
+    # Add default security monitoring settings
+    op.execute(
+        """
+        INSERT INTO settings (key, value, updated_at)
+        VALUES ('security_monitoring', '{
+            "enabled": true,
+            "login_bruteforce_threshold": 5,
+            "login_bruteforce_window_minutes": 5,
+            "device_token_bruteforce_threshold": 10,
+            "device_token_bruteforce_window_minutes": 5,
+            "registration_flood_threshold": 10,
+            "registration_flood_window_minutes": 10
+        }', NOW())
+        """
+    )
+
+
+def downgrade() -> None:
+    op.execute("DELETE FROM settings WHERE key IN ('host_monitoring', 'alert_channels', 'security_monitoring')")

+ 10 - 1
backend/app/api/v1/superadmin/__init__.py

@@ -4,7 +4,15 @@ Superadmin API endpoints.
 
 from fastapi import APIRouter
 
-from app.api.v1.superadmin import default_config, devices, organizations, settings, tunnels, users
+from app.api.v1.superadmin import (
+    default_config,
+    devices,
+    monitoring,
+    organizations,
+    settings,
+    tunnels,
+    users,
+)
 
 router = APIRouter()
 
@@ -12,5 +20,6 @@ router.include_router(organizations.router, prefix="/organizations", tags=["supe
 router.include_router(users.router, prefix="/users", tags=["superadmin-users"])
 router.include_router(devices.router, prefix="/devices", tags=["superadmin-devices"])
 router.include_router(settings.router, prefix="/settings", tags=["superadmin-settings"])
+router.include_router(monitoring.router, prefix="/monitoring", tags=["superadmin-monitoring"])
 router.include_router(tunnels.router)
 router.include_router(default_config.router, tags=["superadmin-config"])

+ 205 - 0
backend/app/api/v1/superadmin/monitoring.py

@@ -0,0 +1,205 @@
+"""
+Superadmin monitoring endpoints for host metrics and alerts.
+"""
+
+from datetime import datetime, timedelta, timezone
+
+from fastapi import APIRouter, Depends, Query
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.api.deps import get_current_superadmin, get_db
+from app.models.alert import Alert
+from app.models.host_metrics import HostMetrics
+from app.models.security_event import SecurityEvent
+from app.services.alert_service import alert_service
+
+router = APIRouter()
+
+
+@router.get("/host-metrics/recent")
+async def get_recent_host_metrics(
+    limit: int = Query(default=60, le=1000),
+    db: AsyncSession = Depends(get_db),
+    _current_user=Depends(get_current_superadmin),
+):
+    """Get recent host metrics for dashboard charts (default: last 60 data points)."""
+    result = await db.execute(
+        select(HostMetrics)
+        .order_by(HostMetrics.timestamp.desc())
+        .limit(limit)
+    )
+    metrics = list(result.scalars().all())
+
+    # Return in chronological order
+    return [
+        {
+            "timestamp": m.timestamp.isoformat(),
+            "cpu_percent": m.cpu_percent,
+            "cpu_count": m.cpu_count,
+            "memory_total": m.memory_total,
+            "memory_used": m.memory_used,
+            "memory_percent": m.memory_percent,
+            "load_1": m.load_1,
+            "load_5": m.load_5,
+            "load_15": m.load_15,
+            "disk_read_bytes": m.disk_read_bytes,
+            "disk_write_bytes": m.disk_write_bytes,
+            "disk_usage_percent": m.disk_usage_percent,
+            "net_sent_bytes": m.net_sent_bytes,
+            "net_recv_bytes": m.net_recv_bytes,
+        }
+        for m in reversed(metrics)
+    ]
+
+
+@router.get("/host-metrics/history")
+async def get_host_metrics_history(
+    start_date: datetime = Query(...),
+    end_date: datetime = Query(...),
+    db: AsyncSession = Depends(get_db),
+    _current_user=Depends(get_current_superadmin),
+):
+    """Get historical host metrics for specified date range."""
+    result = await db.execute(
+        select(HostMetrics)
+        .where(HostMetrics.timestamp >= start_date)
+        .where(HostMetrics.timestamp <= end_date)
+        .order_by(HostMetrics.timestamp.asc())
+    )
+    metrics = list(result.scalars().all())
+
+    return [
+        {
+            "timestamp": m.timestamp.isoformat(),
+            "cpu_percent": m.cpu_percent,
+            "cpu_count": m.cpu_count,
+            "memory_total": m.memory_total,
+            "memory_used": m.memory_used,
+            "memory_percent": m.memory_percent,
+            "load_1": m.load_1,
+            "load_5": m.load_5,
+            "load_15": m.load_15,
+            "disk_read_bytes": m.disk_read_bytes,
+            "disk_write_bytes": m.disk_write_bytes,
+            "disk_usage_percent": m.disk_usage_percent,
+            "net_sent_bytes": m.net_sent_bytes,
+            "net_recv_bytes": m.net_recv_bytes,
+        }
+        for m in metrics
+    ]
+
+
+@router.get("/alerts")
+async def get_alerts(
+    dismissed: bool = Query(default=False),
+    db: AsyncSession = Depends(get_db),
+    _current_user=Depends(get_current_superadmin),
+):
+    """Get alerts (by default only active/non-dismissed alerts)."""
+    query = select(Alert).order_by(Alert.timestamp.desc())
+
+    if not dismissed:
+        query = query.where(Alert.dismissed == False)
+
+    result = await db.execute(query)
+    alerts = list(result.scalars().all())
+
+    return [
+        {
+            "id": a.id,
+            "timestamp": a.timestamp.isoformat(),
+            "alert_type": a.alert_type,
+            "severity": a.severity,
+            "title": a.title,
+            "message": a.message,
+            "alert_metadata": a.alert_metadata,
+            "acknowledged": a.acknowledged,
+            "acknowledged_at": a.acknowledged_at.isoformat() if a.acknowledged_at else None,
+            "acknowledged_by": a.acknowledged_by,
+            "dismissed": a.dismissed,
+            "dismissed_at": a.dismissed_at.isoformat() if a.dismissed_at else None,
+            "sent_dashboard": a.sent_dashboard,
+            "sent_telegram": a.sent_telegram,
+            "sent_email": a.sent_email,
+        }
+        for a in alerts
+    ]
+
+
+@router.post("/alerts/{alert_id}/acknowledge")
+async def acknowledge_alert(
+    alert_id: int,
+    db: AsyncSession = Depends(get_db),
+    current_user=Depends(get_current_superadmin),
+):
+    """Mark alert as acknowledged."""
+    await alert_service.acknowledge_alert(db, alert_id, current_user.id)
+    return {"status": "ok"}
+
+
+@router.post("/alerts/{alert_id}/dismiss")
+async def dismiss_alert(
+    alert_id: int,
+    db: AsyncSession = Depends(get_db),
+    _current_user=Depends(get_current_superadmin),
+):
+    """Mark alert as dismissed (hide from dashboard)."""
+    await alert_service.dismiss_alert(db, alert_id)
+    return {"status": "ok"}
+
+
+@router.get("/security-events")
+async def get_security_events(
+    resolved: bool = Query(default=False),
+    limit: int = Query(default=100, le=1000),
+    db: AsyncSession = Depends(get_db),
+    _current_user=Depends(get_current_superadmin),
+):
+    """Get security events (by default only unresolved events)."""
+    query = select(SecurityEvent).order_by(SecurityEvent.timestamp.desc()).limit(limit)
+
+    if not resolved:
+        query = query.where(SecurityEvent.resolved == False)
+
+    result = await db.execute(query)
+    events = list(result.scalars().all())
+
+    return [
+        {
+            "id": e.id,
+            "timestamp": e.timestamp.isoformat(),
+            "event_type": e.event_type,
+            "severity": e.severity,
+            "ip_address": e.ip_address,
+            "user_agent": e.user_agent,
+            "endpoint": e.endpoint,
+            "description": e.description,
+            "event_metadata": e.event_metadata,
+            "resolved": e.resolved,
+            "resolved_at": e.resolved_at.isoformat() if e.resolved_at else None,
+            "resolved_by": e.resolved_by,
+        }
+        for e in events
+    ]
+
+
+@router.post("/security-events/{event_id}/resolve")
+async def resolve_security_event(
+    event_id: int,
+    db: AsyncSession = Depends(get_db),
+    current_user=Depends(get_current_superadmin),
+):
+    """Mark security event as resolved."""
+    result = await db.execute(
+        select(SecurityEvent).where(SecurityEvent.id == event_id)
+    )
+    event = result.scalar_one_or_none()
+
+    if event:
+        event.resolved = True
+        event.resolved_at = datetime.now(timezone.utc)
+        event.resolved_by = current_user.id
+        await db.commit()
+
+    return {"status": "ok"}

+ 67 - 0
backend/app/api/v1/superadmin/settings.py

@@ -110,3 +110,70 @@ async def toggle_auto_registration(
         enabled=setting.value["enabled"],
         last_device_at=setting.value.get("last_device_at"),
     )
+
+
+class SettingUpdate(BaseModel):
+    """Generic setting update request."""
+
+    value: dict
+
+
+class SettingResponse(BaseModel):
+    """Generic setting response."""
+
+    key: str
+    value: dict
+    updated_at: str | None
+
+
+@router.get("/setting/{key}", response_model=SettingResponse)
+async def get_setting(
+    key: str,
+    db: Annotated[AsyncSession, Depends(get_db)],
+    current_user: Annotated[User, Depends(get_current_superadmin)],
+):
+    """Get a specific setting by key."""
+    result = await db.execute(select(Settings).where(Settings.key == key))
+    setting = result.scalar_one_or_none()
+
+    if not setting:
+        raise HTTPException(
+            status_code=status.HTTP_404_NOT_FOUND,
+            detail=f"setting_not_found:{key}",
+        )
+
+    return SettingResponse(
+        key=setting.key,
+        value=setting.value,
+        updated_at=setting.updated_at.isoformat() if setting.updated_at else None,
+    )
+
+
+@router.put("/setting/{key}", response_model=SettingResponse)
+async def update_setting(
+    key: str,
+    data: SettingUpdate,
+    db: Annotated[AsyncSession, Depends(get_db)],
+    current_user: Annotated[User, Depends(get_current_superadmin)],
+):
+    """Update a specific setting by key."""
+    result = await db.execute(select(Settings).where(Settings.key == key))
+    setting = result.scalar_one_or_none()
+
+    if not setting:
+        raise HTTPException(
+            status_code=status.HTTP_404_NOT_FOUND,
+            detail=f"setting_not_found:{key}",
+        )
+
+    setting.value = data.value
+    setting.updated_at = datetime.now(timezone.utc)
+
+    await db.commit()
+    await db.refresh(setting)
+
+    return SettingResponse(
+        key=setting.key,
+        value=setting.value,
+        updated_at=setting.updated_at.isoformat(),
+    )

+ 15 - 0
backend/app/main.py

@@ -56,3 +56,18 @@ async def startup_event():
     from app.services.tunnel_service import tunnel_service
     tunnel_service.start_background_cleanup()
     print("[startup] Tunnel cleanup task started")
+
+    # Start host monitoring background task
+    import asyncio
+    from app.services.host_monitor import host_monitor
+    asyncio.create_task(host_monitor.run_monitoring_loop())
+    print("[startup] Host monitoring task started")
+
+
+# Shutdown event
+@app.on_event("shutdown")
+async def shutdown_event():
+    """Cleanup on shutdown"""
+    from app.services.host_monitor import host_monitor
+    await host_monitor.stop()
+    print("[shutdown] Host monitoring stopped")

+ 47 - 0
backend/app/models/alert.py

@@ -0,0 +1,47 @@
+"""
+Alert model for system notifications.
+"""
+
+from datetime import datetime
+
+from sqlalchemy import DateTime, JSON, String, Text
+from sqlalchemy.orm import Mapped, mapped_column
+
+from app.core.database import Base
+
+
+class Alert(Base):
+    """System alerts and notifications."""
+
+    __tablename__ = "alerts"
+
+    id: Mapped[int] = mapped_column(primary_key=True)
+    timestamp: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True)
+
+    # Alert type
+    alert_type: Mapped[str] = mapped_column(
+        String(50), index=True, nullable=False
+    )  # security, host_metrics, device_offline, etc.
+
+    # Severity
+    severity: Mapped[str] = mapped_column(
+        String(20), nullable=False, index=True
+    )  # info, warning, error, critical
+
+    # Content
+    title: Mapped[str] = mapped_column(String(255), nullable=False)
+    message: Mapped[str] = mapped_column(Text, nullable=False)
+    alert_metadata: Mapped[dict | None] = mapped_column(JSON)
+
+    # State
+    acknowledged: Mapped[bool] = mapped_column(default=False, index=True)
+    acknowledged_at: Mapped[datetime | None] = mapped_column()
+    acknowledged_by: Mapped[int | None] = mapped_column()  # user_id
+
+    dismissed: Mapped[bool] = mapped_column(default=False, index=True)
+    dismissed_at: Mapped[datetime | None] = mapped_column()
+
+    # Delivery tracking
+    sent_dashboard: Mapped[bool] = mapped_column(default=False)
+    sent_telegram: Mapped[bool] = mapped_column(default=False)
+    sent_email: Mapped[bool] = mapped_column(default=False)

+ 42 - 0
backend/app/models/host_metrics.py

@@ -0,0 +1,42 @@
+"""
+Host metrics model for storing system monitoring data.
+"""
+
+from datetime import datetime
+
+from sqlalchemy import BigInteger, DateTime, Float, Integer, String
+from sqlalchemy.orm import Mapped, mapped_column
+
+from app.core.database import Base
+
+
+class HostMetrics(Base):
+    """Host system metrics (CPU, RAM, Load, Disk, Network)."""
+
+    __tablename__ = "host_metrics"
+
+    id: Mapped[int] = mapped_column(primary_key=True)
+    timestamp: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True)
+
+    # CPU
+    cpu_percent: Mapped[float] = mapped_column(Float, nullable=False)
+    cpu_count: Mapped[int] = mapped_column(Integer, nullable=False)
+
+    # Memory
+    memory_total: Mapped[int] = mapped_column(BigInteger, nullable=False)  # bytes
+    memory_used: Mapped[int] = mapped_column(BigInteger, nullable=False)
+    memory_percent: Mapped[float] = mapped_column(Float, nullable=False)
+
+    # Load Average
+    load_1: Mapped[float] = mapped_column(Float, nullable=False)
+    load_5: Mapped[float] = mapped_column(Float, nullable=False)
+    load_15: Mapped[float] = mapped_column(Float, nullable=False)
+
+    # Disk I/O
+    disk_read_bytes: Mapped[int] = mapped_column(BigInteger, nullable=False)
+    disk_write_bytes: Mapped[int] = mapped_column(BigInteger, nullable=False)
+    disk_usage_percent: Mapped[float] = mapped_column(Float, nullable=False)
+
+    # Network
+    net_sent_bytes: Mapped[int] = mapped_column(BigInteger, nullable=False)
+    net_recv_bytes: Mapped[int] = mapped_column(BigInteger, nullable=False)

+ 43 - 0
backend/app/models/security_event.py

@@ -0,0 +1,43 @@
+"""
+Security event model for tracking suspicious activity.
+"""
+
+from datetime import datetime
+
+from sqlalchemy import DateTime, JSON, String, Text
+from sqlalchemy.orm import Mapped, mapped_column
+
+from app.core.database import Base
+
+
+class SecurityEvent(Base):
+    """Security events (brute-force, flooding, suspicious activity)."""
+
+    __tablename__ = "security_events"
+
+    id: Mapped[int] = mapped_column(primary_key=True)
+    timestamp: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True)
+
+    # Event type
+    event_type: Mapped[str] = mapped_column(
+        String(50), index=True, nullable=False
+    )  # login_bruteforce, device_token_bruteforce, registration_flood, etc.
+
+    # Severity
+    severity: Mapped[str] = mapped_column(
+        String(20), nullable=False
+    )  # low, medium, high, critical
+
+    # Source
+    ip_address: Mapped[str | None] = mapped_column(String(45), index=True)
+    user_agent: Mapped[str | None] = mapped_column(Text)
+    endpoint: Mapped[str | None] = mapped_column(String(255))
+
+    # Details
+    description: Mapped[str] = mapped_column(Text, nullable=False)
+    event_metadata: Mapped[dict | None] = mapped_column(JSON)  # Additional context
+
+    # Resolution
+    resolved: Mapped[bool] = mapped_column(default=False, index=True)
+    resolved_at: Mapped[datetime | None] = mapped_column()
+    resolved_by: Mapped[int | None] = mapped_column()  # user_id

+ 176 - 0
backend/app/services/alert_service.py

@@ -0,0 +1,176 @@
+"""
+Alert service for creating and dispatching system notifications.
+"""
+
+from datetime import datetime, timezone
+
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.core.database import async_session_maker
+from app.models.alert import Alert
+from app.models.settings import Settings
+
+
+class AlertService:
+    """Manage system alerts and notifications."""
+
+    async def create_alert(
+        self,
+        alert_type: str,
+        severity: str,
+        title: str,
+        message: str,
+        alert_metadata: dict | None = None,
+    ) -> Alert:
+        """Create a new alert and dispatch to configured channels."""
+        async with async_session_maker() as session:
+            # Check if similar alert already exists (prevent spam)
+            existing = await self._find_similar_alert(session, alert_type, title)
+            if existing:
+                print(f"[AlertService] Similar alert already exists, skipping: {title}")
+                return existing
+
+            # Create alert
+            alert = Alert(
+                timestamp=datetime.now(timezone.utc),
+                alert_type=alert_type,
+                severity=severity,
+                title=title,
+                message=message,
+                alert_metadata=alert_metadata or {},
+                sent_dashboard=True,  # Always show in dashboard
+            )
+
+            session.add(alert)
+            await session.commit()
+            await session.refresh(alert)
+
+            print(f"[AlertService] Created alert: [{severity}] {title}")
+
+            # Dispatch to configured channels
+            await self._dispatch_alert(session, alert)
+
+            return alert
+
+    async def _find_similar_alert(
+        self, session: AsyncSession, alert_type: str, title: str
+    ) -> Alert | None:
+        """Find recent similar alert to prevent spam."""
+        # Check if alert with same type and title was created in last 5 minutes
+        from datetime import timedelta
+
+        cutoff = datetime.now(timezone.utc) - timedelta(minutes=5)
+
+        result = await session.execute(
+            select(Alert)
+            .where(Alert.alert_type == alert_type)
+            .where(Alert.title == title)
+            .where(Alert.timestamp > cutoff)
+            .where(Alert.dismissed == False)
+        )
+        return result.scalar_one_or_none()
+
+    async def _dispatch_alert(self, session: AsyncSession, alert: Alert):
+        """Dispatch alert to configured channels (Telegram, Email, etc)."""
+        # Get alert channels configuration
+        result = await session.execute(
+            select(Settings).where(Settings.key == "alert_channels")
+        )
+        settings = result.scalar_one_or_none()
+
+        if not settings:
+            return
+
+        channels = settings.value
+
+        # Telegram
+        if channels.get("telegram", {}).get("enabled"):
+            try:
+                await self._send_telegram(alert, channels["telegram"])
+                alert.sent_telegram = True
+            except Exception as e:
+                print(f"[AlertService] Failed to send Telegram: {e}")
+
+        # Email
+        if channels.get("email", {}).get("enabled"):
+            try:
+                await self._send_email(alert, channels["email"])
+                alert.sent_email = True
+            except Exception as e:
+                print(f"[AlertService] Failed to send Email: {e}")
+
+        await session.commit()
+
+    async def _send_telegram(self, alert: Alert, config: dict):
+        """Send alert via Telegram bot."""
+        # TODO: Implement Telegram bot integration
+        bot_token = config.get("bot_token")
+        chat_ids = config.get("chat_ids", [])
+
+        if not bot_token or not chat_ids:
+            return
+
+        # Format message
+        severity_emoji = {
+            "info": "ℹ️",
+            "warning": "⚠️",
+            "error": "❌",
+            "critical": "🚨",
+        }
+
+        emoji = severity_emoji.get(alert.severity, "📢")
+        text = f"{emoji} **{alert.title}**\n\n{alert.message}"
+
+        print(f"[AlertService] Would send Telegram to {len(chat_ids)} chats: {text[:50]}...")
+        # Import httpx and send message to Telegram API
+        # await httpx.post(f"https://api.telegram.org/bot{bot_token}/sendMessage", ...)
+
+    async def _send_email(self, alert: Alert, config: dict):
+        """Send alert via Email."""
+        # TODO: Implement Email SMTP integration
+        smtp_server = config.get("smtp_server")
+        recipients = config.get("recipients", [])
+
+        if not smtp_server or not recipients:
+            return
+
+        print(f"[AlertService] Would send Email to {len(recipients)} recipients: {alert.title}")
+        # Import smtplib and send email
+        # ...
+
+    async def get_active_alerts(self, session: AsyncSession) -> list[Alert]:
+        """Get all active (non-dismissed) alerts."""
+        result = await session.execute(
+            select(Alert)
+            .where(Alert.dismissed == False)
+            .order_by(Alert.timestamp.desc())
+        )
+        return list(result.scalars().all())
+
+    async def acknowledge_alert(
+        self, session: AsyncSession, alert_id: int, user_id: int
+    ):
+        """Mark alert as acknowledged."""
+        result = await session.execute(select(Alert).where(Alert.id == alert_id))
+        alert = result.scalar_one_or_none()
+
+        if alert:
+            alert.acknowledged = True
+            alert.acknowledged_at = datetime.now(timezone.utc)
+            alert.acknowledged_by = user_id
+            await session.commit()
+
+    async def dismiss_alert(self, session: AsyncSession, alert_id: int):
+        """Mark alert as dismissed."""
+        result = await session.execute(select(Alert).where(Alert.id == alert_id))
+        alert = result.scalar_one_or_none()
+
+        if alert:
+            alert.dismissed = True
+            alert.dismissed_at = datetime.now(timezone.utc)
+            await session.commit()
+
+
+# Global instance
+alert_service = AlertService()

+ 180 - 0
backend/app/services/host_monitor.py

@@ -0,0 +1,180 @@
+"""
+Host monitoring service for collecting system metrics.
+"""
+
+import asyncio
+from datetime import datetime, timedelta, timezone
+
+import psutil
+from sqlalchemy import delete, select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.core.database import async_session_maker
+from app.models.host_metrics import HostMetrics
+from app.services.alert_service import alert_service
+
+
+class HostMonitor:
+    """Collect and store host system metrics."""
+
+    def __init__(self):
+        self.previous_disk_io = None
+        self.previous_net_io = None
+        self.running = False
+
+    async def collect_metrics(self) -> dict:
+        """Collect current system metrics."""
+        # CPU
+        cpu_percent = psutil.cpu_percent(interval=1)
+        cpu_count = psutil.cpu_count()
+
+        # Memory
+        mem = psutil.virtual_memory()
+        memory_total = mem.total
+        memory_used = mem.used
+        memory_percent = mem.percent
+
+        # Load Average
+        load_avg = psutil.getloadavg()
+        load_1, load_5, load_15 = load_avg
+
+        # Disk I/O
+        disk_io = psutil.disk_io_counters()
+        disk_read_bytes = disk_io.read_bytes
+        disk_write_bytes = disk_io.write_bytes
+
+        # Disk Usage
+        disk_usage = psutil.disk_usage('/')
+        disk_usage_percent = disk_usage.percent
+
+        # Network
+        net_io = psutil.net_io_counters()
+        net_sent_bytes = net_io.bytes_sent
+        net_recv_bytes = net_io.bytes_recv
+
+        return {
+            'timestamp': datetime.now(timezone.utc),
+            'cpu_percent': cpu_percent,
+            'cpu_count': cpu_count,
+            'memory_total': memory_total,
+            'memory_used': memory_used,
+            'memory_percent': memory_percent,
+            'load_1': load_1,
+            'load_5': load_5,
+            'load_15': load_15,
+            'disk_read_bytes': disk_read_bytes,
+            'disk_write_bytes': disk_write_bytes,
+            'disk_usage_percent': disk_usage_percent,
+            'net_sent_bytes': net_sent_bytes,
+            'net_recv_bytes': net_recv_bytes,
+        }
+
+    async def store_metrics(self, metrics: dict):
+        """Store metrics in database."""
+        async with async_session_maker() as session:
+            metric = HostMetrics(**metrics)
+            session.add(metric)
+            await session.commit()
+
+    async def check_thresholds(self, metrics: dict):
+        """Check if metrics exceed configured thresholds and create alerts."""
+        # Get thresholds from settings
+        async with async_session_maker() as session:
+            from app.models.settings import Settings
+
+            result = await session.execute(
+                select(Settings).where(Settings.key == "host_monitoring")
+            )
+            settings = result.scalar_one_or_none()
+
+            if not settings:
+                return
+
+            thresholds = settings.value
+
+        # Check CPU
+        if metrics['cpu_percent'] > thresholds.get('cpu_threshold', 90):
+            await alert_service.create_alert(
+                alert_type='host_metrics',
+                severity='warning' if metrics['cpu_percent'] < 95 else 'critical',
+                title=f'High CPU Usage: {metrics["cpu_percent"]:.1f}%',
+                message=f'CPU usage is at {metrics["cpu_percent"]:.1f}%, threshold is {thresholds.get("cpu_threshold", 90)}%',
+                alert_metadata={'metric': 'cpu_percent', 'value': metrics['cpu_percent']},
+            )
+
+        # Check Memory
+        if metrics['memory_percent'] > thresholds.get('memory_threshold', 90):
+            await alert_service.create_alert(
+                alert_type='host_metrics',
+                severity='warning' if metrics['memory_percent'] < 95 else 'critical',
+                title=f'High Memory Usage: {metrics["memory_percent"]:.1f}%',
+                message=f'Memory usage is at {metrics["memory_percent"]:.1f}%, threshold is {thresholds.get("memory_threshold", 90)}%',
+                alert_metadata={'metric': 'memory_percent', 'value': metrics['memory_percent']},
+            )
+
+        # Check Load Average (relative to CPU count)
+        load_threshold = thresholds.get('load_threshold', 2.0) * metrics['cpu_count']
+        if metrics['load_1'] > load_threshold:
+            await alert_service.create_alert(
+                alert_type='host_metrics',
+                severity='warning',
+                title=f'High Load Average: {metrics["load_1"]:.2f}',
+                message=f'1-minute load average is {metrics["load_1"]:.2f}, threshold is {load_threshold:.2f}',
+                alert_metadata={'metric': 'load_1', 'value': metrics['load_1']},
+            )
+
+        # Check Disk Usage
+        if metrics['disk_usage_percent'] > thresholds.get('disk_threshold', 90):
+            await alert_service.create_alert(
+                alert_type='host_metrics',
+                severity='warning' if metrics['disk_usage_percent'] < 95 else 'critical',
+                title=f'High Disk Usage: {metrics["disk_usage_percent"]:.1f}%',
+                message=f'Disk usage is at {metrics["disk_usage_percent"]:.1f}%, threshold is {thresholds.get("disk_threshold", 90)}%',
+                alert_metadata={'metric': 'disk_usage_percent', 'value': metrics['disk_usage_percent']},
+            )
+
+    async def cleanup_old_metrics(self, days: int = 30):
+        """Delete metrics older than specified days."""
+        cutoff = datetime.now(timezone.utc) - timedelta(days=days)
+
+        async with async_session_maker() as session:
+            await session.execute(
+                delete(HostMetrics).where(HostMetrics.timestamp < cutoff)
+            )
+            await session.commit()
+
+    async def run_monitoring_loop(self):
+        """Main monitoring loop - runs in background."""
+        print("[HostMonitor] Starting host monitoring loop")
+        self.running = True
+
+        while self.running:
+            try:
+                # Collect metrics
+                metrics = await self.collect_metrics()
+
+                # Store in database
+                await self.store_metrics(metrics)
+
+                # Check thresholds
+                await self.check_thresholds(metrics)
+
+                # Cleanup old data once per hour
+                if datetime.now().minute == 0:
+                    await self.cleanup_old_metrics()
+
+                # Wait 60 seconds before next collection
+                await asyncio.sleep(60)
+
+            except Exception as e:
+                print(f"[HostMonitor] Error in monitoring loop: {e}")
+                await asyncio.sleep(60)
+
+    async def stop(self):
+        """Stop monitoring loop."""
+        print("[HostMonitor] Stopping host monitoring loop")
+        self.running = False
+
+
+# Global instance
+host_monitor = HostMonitor()

+ 36 - 1
backend/poetry.lock

@@ -1122,6 +1122,41 @@ files = [
 dev = ["pre-commit", "tox"]
 testing = ["coverage", "pytest", "pytest-benchmark"]
 
+[[package]]
+name = "psutil"
+version = "7.2.0"
+description = "Cross-platform lib for process and system monitoring."
+optional = false
+python-versions = ">=3.6"
+groups = ["main"]
+files = [
+    {file = "psutil-7.2.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:c31e927555539132a00380c971816ea43d089bf4bd5f3e918ed8c16776d68474"},
+    {file = "psutil-7.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:db8e44e766cef86dea47d9a1fa535d38dc76449e5878a92f33683b7dba5bfcb2"},
+    {file = "psutil-7.2.0-cp313-cp313t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:85ef849ac92169dedc59a7ac2fb565f47b3468fbe1524bf748746bc21afb94c7"},
+    {file = "psutil-7.2.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:26782bdbae2f5c14ce9ebe8ad2411dc2ca870495e0cd90f8910ede7fa5e27117"},
+    {file = "psutil-7.2.0-cp313-cp313t-win_amd64.whl", hash = "sha256:b7665f612d3b38a583391b95969667a53aaf6c5706dc27a602c9a4874fbf09e4"},
+    {file = "psutil-7.2.0-cp313-cp313t-win_arm64.whl", hash = "sha256:4413373c174520ae28a24a8974ad8ce6b21f060d27dde94e25f8c73a7effe57a"},
+    {file = "psutil-7.2.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:2f2f53fd114e7946dfba3afb98c9b7c7f376009447360ca15bfb73f2066f84c7"},
+    {file = "psutil-7.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e65c41d7e60068f60ce43b31a3a7fc90deb0dfd34ffc824a2574c2e5279b377e"},
+    {file = "psutil-7.2.0-cp314-cp314t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cc66d21366850a4261412ce994ae9976bba9852dafb4f2fa60db68ed17ff5281"},
+    {file = "psutil-7.2.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e025d67b42b8f22b096d5d20f5171de0e0fefb2f0ce983a13c5a1b5ed9872706"},
+    {file = "psutil-7.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:45f6b91f7ad63414d6454fd609e5e3556d0e1038d5d9c75a1368513bdf763f57"},
+    {file = "psutil-7.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:87b18a19574139d60a546e88b5f5b9cbad598e26cdc790d204ab95d7024f03ee"},
+    {file = "psutil-7.2.0-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:977a2fcd132d15cb05b32b2d85b98d087cad039b0ce435731670ba74da9e6133"},
+    {file = "psutil-7.2.0-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:24151011c21fadd94214d7139d7c6c54569290d7e553989bdf0eab73b13beb8c"},
+    {file = "psutil-7.2.0-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:91f211ba9279e7c61d9d8f84b713cfc38fa161cb0597d5cb3f1ca742f6848254"},
+    {file = "psutil-7.2.0-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f37415188b7ea98faf90fed51131181646c59098b077550246e2e092e127418b"},
+    {file = "psutil-7.2.0-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0d12c7ce6ed1128cd81fd54606afa054ac7dbb9773469ebb58cf2f171c49f2ac"},
+    {file = "psutil-7.2.0-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ca0faef7976530940dcd39bc5382d0d0d5eb023b186a4901ca341bd8d8684151"},
+    {file = "psutil-7.2.0-cp37-abi3-win_amd64.whl", hash = "sha256:abdb74137ca232d20250e9ad471f58d500e7743bc8253ba0bfbf26e570c0e437"},
+    {file = "psutil-7.2.0-cp37-abi3-win_arm64.whl", hash = "sha256:284e71038b3139e7ab3834b63b3eb5aa5565fcd61a681ec746ef9a0a8c457fd2"},
+    {file = "psutil-7.2.0.tar.gz", hash = "sha256:2e4f8e1552f77d14dc96fb0f6240c5b34a37081c0889f0853b3b29a496e5ef64"},
+]
+
+[package.extras]
+dev = ["abi3audit", "black", "check-manifest", "coverage", "packaging", "psleak", "pylint", "pyperf", "pypinfo", "pytest", "pytest-cov", "pytest-instafail", "pytest-xdist", "requests", "rstcheck", "ruff", "setuptools", "sphinx", "sphinx_rtd_theme", "toml-sort", "twine", "validate-pyproject[all]", "virtualenv", "vulture", "wheel"]
+test = ["psleak", "pytest", "pytest-instafail", "pytest-xdist", "setuptools"]
+
 [[package]]
 name = "pyasn1"
 version = "0.6.1"
@@ -2083,4 +2118,4 @@ files = [
 [metadata]
 lock-version = "2.1"
 python-versions = "^3.11"
-content-hash = "b0afff3764824376cc163d7898caf4519160a6ec2c4c476dd1b98b2237d19197"
+content-hash = "10d4c44b35fb4ed08e4d1a03c9bab41ce128011d9c67bc494089d4e3e9d4e3c6"

+ 1 - 0
backend/pyproject.toml

@@ -24,6 +24,7 @@ pillow = "^10.2.0"
 aiofiles = "^23.2.1"
 email-validator = "^2.3.0"
 bcrypt = "<4.0.0"
+psutil = "^7.2.0"
 
 [tool.poetry.group.dev.dependencies]
 pytest = "^7.4.4"

+ 108 - 0
frontend/src/i18n/index.js

@@ -7,6 +7,7 @@ const messages = {
       devices: 'Devices',
       organizations: 'Organizations',
       users: 'Users',
+      settings: 'Settings',
       logout: 'Logout'
     },
     common: {
@@ -14,6 +15,7 @@ const messages = {
       edit: 'Edit',
       delete: 'Delete',
       save: 'Save',
+      saving: 'Saving...',
       cancel: 'Cancel',
       search: 'Search',
       actions: 'Actions',
@@ -112,6 +114,58 @@ const messages = {
       network: 'Network',
       loadAverage: 'Load Average',
       io: 'I/O'
+    },
+    settings: {
+      title: 'Settings',
+      description: 'Configure system monitoring and alerting',
+      saved: 'Settings saved successfully',
+      saveFailed: 'Failed to save settings',
+      tabs: {
+        hostMonitoring: 'Host Monitoring',
+        securityMonitoring: 'Security Monitoring',
+        alertChannels: 'Alert Channels'
+      },
+      hostMonitoring: {
+        title: 'Host Monitoring Settings',
+        description: 'Configure thresholds for host resource monitoring',
+        enabled: 'Enable host monitoring',
+        cpuThreshold: 'CPU Threshold',
+        memoryThreshold: 'Memory Threshold',
+        loadThreshold: 'Load Average Threshold',
+        loadThresholdHint: 'Per CPU core',
+        diskThreshold: 'Disk Usage Threshold',
+        networkThreshold: 'Network Threshold'
+      },
+      securityMonitoring: {
+        title: 'Security Monitoring Settings',
+        description: 'Configure detection thresholds for security events',
+        enabled: 'Enable security monitoring',
+        loginBruteforce: 'Login Brute-force Detection',
+        deviceTokenBruteforce: 'Device Token Brute-force Detection',
+        registrationFlood: 'Registration Flood Detection',
+        threshold: 'Threshold',
+        window: 'Time Window',
+        attempts: 'attempts',
+        registrations: 'registrations',
+        minutes: 'minutes'
+      },
+      alertChannels: {
+        title: 'Alert Channels',
+        description: 'Configure notification channels for alerts',
+        enabled: 'Enabled',
+        telegram: 'Telegram Bot',
+        botToken: 'Bot Token',
+        chatIds: 'Chat IDs',
+        chatIdsHint: 'One chat ID per line',
+        email: 'Email (SMTP)',
+        smtpServer: 'SMTP Server',
+        smtpPort: 'SMTP Port',
+        smtpUser: 'SMTP Username',
+        smtpPassword: 'SMTP Password',
+        fromAddress: 'From Address',
+        recipients: 'Recipients',
+        recipientsHint: 'One email per line'
+      }
     }
   },
   ru: {
@@ -120,6 +174,7 @@ const messages = {
       devices: 'Устройства',
       organizations: 'Организации',
       users: 'Пользователи',
+      settings: 'Настройки',
       logout: 'Выйти'
     },
     common: {
@@ -127,6 +182,7 @@ const messages = {
       edit: 'Редактировать',
       delete: 'Удалить',
       save: 'Сохранить',
+      saving: 'Сохранение...',
       cancel: 'Отмена',
       search: 'Поиск',
       actions: 'Действия',
@@ -225,6 +281,58 @@ const messages = {
       network: 'Сеть',
       loadAverage: 'Средняя нагрузка',
       io: 'Ввод/вывод'
+    },
+    settings: {
+      title: 'Настройки',
+      description: 'Настройка мониторинга системы и оповещений',
+      saved: 'Настройки успешно сохранены',
+      saveFailed: 'Не удалось сохранить настройки',
+      tabs: {
+        hostMonitoring: 'Мониторинг хоста',
+        securityMonitoring: 'Мониторинг безопасности',
+        alertChannels: 'Каналы оповещений'
+      },
+      hostMonitoring: {
+        title: 'Настройки мониторинга хоста',
+        description: 'Настройка порогов для мониторинга ресурсов хоста',
+        enabled: 'Включить мониторинг хоста',
+        cpuThreshold: 'Порог CPU',
+        memoryThreshold: 'Порог памяти',
+        loadThreshold: 'Порог средней нагрузки',
+        loadThresholdHint: 'На одно ядро CPU',
+        diskThreshold: 'Порог использования диска',
+        networkThreshold: 'Порог сети'
+      },
+      securityMonitoring: {
+        title: 'Настройки мониторинга безопасности',
+        description: 'Настройка порогов обнаружения событий безопасности',
+        enabled: 'Включить мониторинг безопасности',
+        loginBruteforce: 'Обнаружение перебора логинов',
+        deviceTokenBruteforce: 'Обнаружение перебора токенов устройств',
+        registrationFlood: 'Обнаружение флуда регистраций',
+        threshold: 'Порог',
+        window: 'Временное окно',
+        attempts: 'попыток',
+        registrations: 'регистраций',
+        minutes: 'минут'
+      },
+      alertChannels: {
+        title: 'Каналы оповещений',
+        description: 'Настройка каналов для отправки оповещений',
+        enabled: 'Включено',
+        telegram: 'Telegram бот',
+        botToken: 'Токен бота',
+        chatIds: 'ID чатов',
+        chatIdsHint: 'Один ID чата на строку',
+        email: 'Email (SMTP)',
+        smtpServer: 'SMTP сервер',
+        smtpPort: 'SMTP порт',
+        smtpUser: 'SMTP логин',
+        smtpPassword: 'SMTP пароль',
+        fromAddress: 'Адрес отправителя',
+        recipients: 'Получатели',
+        recipientsHint: 'Один email на строку'
+      }
     }
   }
 }

+ 3 - 0
frontend/src/layouts/SuperadminLayout.vue

@@ -19,6 +19,9 @@
         <router-link to="/superadmin/users" class="nav-item">
           <span>👥</span> {{ $t('nav.users') }}
         </router-link>
+        <router-link to="/superadmin/settings" class="nav-item">
+          <span>⚙️</span> {{ $t('nav.settings') }}
+        </router-link>
       </nav>
 
       <div class="sidebar-footer">

+ 5 - 0
frontend/src/router/index.js

@@ -42,6 +42,11 @@ const routes = [
         path: 'users',
         name: 'SuperadminUsers',
         component: () => import('@/views/superadmin/UsersView.vue')
+      },
+      {
+        path: 'settings',
+        name: 'SuperadminSettings',
+        component: () => import('@/views/superadmin/SettingsView.vue')
       }
     ]
   },

+ 509 - 0
frontend/src/views/superadmin/SettingsView.vue

@@ -0,0 +1,509 @@
+<template>
+  <div class="page">
+    <div class="page-header">
+      <h1>{{ $t('settings.title') }}</h1>
+      <p>{{ $t('settings.description') }}</p>
+    </div>
+
+    <div class="tabs">
+      <button
+        v-for="tab in tabs"
+        :key="tab.id"
+        @click="activeTab = tab.id"
+        :class="['tab', { active: activeTab === tab.id }]"
+      >
+        {{ tab.label }}
+      </button>
+    </div>
+
+    <div class="tab-content">
+      <!-- Host Monitoring Settings -->
+      <div v-if="activeTab === 'host'" class="settings-section">
+        <h2>{{ $t('settings.hostMonitoring.title') }}</h2>
+        <p class="section-desc">{{ $t('settings.hostMonitoring.description') }}</p>
+
+        <div v-if="loading" class="loading">{{ $t('common.loading') }}</div>
+
+        <form v-else @submit.prevent="saveHostSettings" class="settings-form">
+          <div class="form-row">
+            <label>
+              <input type="checkbox" v-model="hostSettings.enabled" />
+              {{ $t('settings.hostMonitoring.enabled') }}
+            </label>
+          </div>
+
+          <div class="form-group">
+            <label>{{ $t('settings.hostMonitoring.cpuThreshold') }}</label>
+            <input type="number" v-model.number="hostSettings.cpu_threshold" min="0" max="100" />
+            <span class="hint">%</span>
+          </div>
+
+          <div class="form-group">
+            <label>{{ $t('settings.hostMonitoring.memoryThreshold') }}</label>
+            <input type="number" v-model.number="hostSettings.memory_threshold" min="0" max="100" />
+            <span class="hint">%</span>
+          </div>
+
+          <div class="form-group">
+            <label>{{ $t('settings.hostMonitoring.loadThreshold') }}</label>
+            <input type="number" v-model.number="hostSettings.load_threshold" min="0" step="0.1" />
+            <span class="hint">{{ $t('settings.hostMonitoring.loadThresholdHint') }}</span>
+          </div>
+
+          <div class="form-group">
+            <label>{{ $t('settings.hostMonitoring.diskThreshold') }}</label>
+            <input type="number" v-model.number="hostSettings.disk_threshold" min="0" max="100" />
+            <span class="hint">%</span>
+          </div>
+
+          <div class="form-group">
+            <label>{{ $t('settings.hostMonitoring.networkThreshold') }}</label>
+            <input type="number" v-model.number="hostSettings.network_threshold_mbps" min="0" />
+            <span class="hint">Mbps</span>
+          </div>
+
+          <button type="submit" class="btn btn-primary" :disabled="saving">
+            {{ saving ? $t('common.saving') : $t('common.save') }}
+          </button>
+        </form>
+      </div>
+
+      <!-- Security Monitoring Settings -->
+      <div v-if="activeTab === 'security'" class="settings-section">
+        <h2>{{ $t('settings.securityMonitoring.title') }}</h2>
+        <p class="section-desc">{{ $t('settings.securityMonitoring.description') }}</p>
+
+        <div v-if="loading" class="loading">{{ $t('common.loading') }}</div>
+
+        <form v-else @submit.prevent="saveSecuritySettings" class="settings-form">
+          <div class="form-row">
+            <label>
+              <input type="checkbox" v-model="securitySettings.enabled" />
+              {{ $t('settings.securityMonitoring.enabled') }}
+            </label>
+          </div>
+
+          <h3>{{ $t('settings.securityMonitoring.loginBruteforce') }}</h3>
+          <div class="form-group">
+            <label>{{ $t('settings.securityMonitoring.threshold') }}</label>
+            <input type="number" v-model.number="securitySettings.login_bruteforce_threshold" min="1" />
+            <span class="hint">{{ $t('settings.securityMonitoring.attempts') }}</span>
+          </div>
+          <div class="form-group">
+            <label>{{ $t('settings.securityMonitoring.window') }}</label>
+            <input type="number" v-model.number="securitySettings.login_bruteforce_window_minutes" min="1" />
+            <span class="hint">{{ $t('settings.securityMonitoring.minutes') }}</span>
+          </div>
+
+          <h3>{{ $t('settings.securityMonitoring.deviceTokenBruteforce') }}</h3>
+          <div class="form-group">
+            <label>{{ $t('settings.securityMonitoring.threshold') }}</label>
+            <input type="number" v-model.number="securitySettings.device_token_bruteforce_threshold" min="1" />
+            <span class="hint">{{ $t('settings.securityMonitoring.attempts') }}</span>
+          </div>
+          <div class="form-group">
+            <label>{{ $t('settings.securityMonitoring.window') }}</label>
+            <input type="number" v-model.number="securitySettings.device_token_bruteforce_window_minutes" min="1" />
+            <span class="hint">{{ $t('settings.securityMonitoring.minutes') }}</span>
+          </div>
+
+          <h3>{{ $t('settings.securityMonitoring.registrationFlood') }}</h3>
+          <div class="form-group">
+            <label>{{ $t('settings.securityMonitoring.threshold') }}</label>
+            <input type="number" v-model.number="securitySettings.registration_flood_threshold" min="1" />
+            <span class="hint">{{ $t('settings.securityMonitoring.registrations') }}</span>
+          </div>
+          <div class="form-group">
+            <label>{{ $t('settings.securityMonitoring.window') }}</label>
+            <input type="number" v-model.number="securitySettings.registration_flood_window_minutes" min="1" />
+            <span class="hint">{{ $t('settings.securityMonitoring.minutes') }}</span>
+          </div>
+
+          <button type="submit" class="btn btn-primary" :disabled="saving">
+            {{ saving ? $t('common.saving') : $t('common.save') }}
+          </button>
+        </form>
+      </div>
+
+      <!-- Alert Channels Settings -->
+      <div v-if="activeTab === 'alerts'" class="settings-section">
+        <h2>{{ $t('settings.alertChannels.title') }}</h2>
+        <p class="section-desc">{{ $t('settings.alertChannels.description') }}</p>
+
+        <div v-if="loading" class="loading">{{ $t('common.loading') }}</div>
+
+        <form v-else @submit.prevent="saveAlertSettings" class="settings-form">
+          <!-- Telegram -->
+          <h3>{{ $t('settings.alertChannels.telegram') }}</h3>
+          <div class="form-row">
+            <label>
+              <input type="checkbox" v-model="alertSettings.telegram.enabled" />
+              {{ $t('settings.alertChannels.enabled') }}
+            </label>
+          </div>
+          <div class="form-group">
+            <label>{{ $t('settings.alertChannels.botToken') }}</label>
+            <input type="text" v-model="alertSettings.telegram.bot_token" />
+          </div>
+          <div class="form-group">
+            <label>{{ $t('settings.alertChannels.chatIds') }}</label>
+            <textarea v-model="telegramChatIds" rows="3"></textarea>
+            <span class="hint">{{ $t('settings.alertChannels.chatIdsHint') }}</span>
+          </div>
+
+          <!-- Email -->
+          <h3>{{ $t('settings.alertChannels.email') }}</h3>
+          <div class="form-row">
+            <label>
+              <input type="checkbox" v-model="alertSettings.email.enabled" />
+              {{ $t('settings.alertChannels.enabled') }}
+            </label>
+          </div>
+          <div class="form-group">
+            <label>{{ $t('settings.alertChannels.smtpServer') }}</label>
+            <input type="text" v-model="alertSettings.email.smtp_server" />
+          </div>
+          <div class="form-group">
+            <label>{{ $t('settings.alertChannels.smtpPort') }}</label>
+            <input type="number" v-model.number="alertSettings.email.smtp_port" />
+          </div>
+          <div class="form-group">
+            <label>{{ $t('settings.alertChannels.smtpUser') }}</label>
+            <input type="text" v-model="alertSettings.email.smtp_user" />
+          </div>
+          <div class="form-group">
+            <label>{{ $t('settings.alertChannels.smtpPassword') }}</label>
+            <input type="password" v-model="alertSettings.email.smtp_password" />
+          </div>
+          <div class="form-group">
+            <label>{{ $t('settings.alertChannels.fromAddress') }}</label>
+            <input type="email" v-model="alertSettings.email.from_address" />
+          </div>
+          <div class="form-group">
+            <label>{{ $t('settings.alertChannels.recipients') }}</label>
+            <textarea v-model="emailRecipients" rows="3"></textarea>
+            <span class="hint">{{ $t('settings.alertChannels.recipientsHint') }}</span>
+          </div>
+
+          <button type="submit" class="btn btn-primary" :disabled="saving">
+            {{ saving ? $t('common.saving') : $t('common.save') }}
+          </button>
+        </form>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { ref, computed, onMounted } from 'vue'
+import { useI18n } from 'vue-i18n'
+import axios from '@/api/client'
+
+const { t } = useI18n()
+
+const activeTab = ref('host')
+const loading = ref(false)
+const saving = ref(false)
+
+const tabs = computed(() => [
+  { id: 'host', label: t('settings.tabs.hostMonitoring') },
+  { id: 'security', label: t('settings.tabs.securityMonitoring') },
+  { id: 'alerts', label: t('settings.tabs.alertChannels') }
+])
+
+// Host monitoring settings
+const hostSettings = ref({
+  enabled: true,
+  cpu_threshold: 90,
+  memory_threshold: 90,
+  load_threshold: 2.0,
+  disk_threshold: 90,
+  network_threshold_mbps: 100
+})
+
+// Security monitoring settings
+const securitySettings = ref({
+  enabled: true,
+  login_bruteforce_threshold: 5,
+  login_bruteforce_window_minutes: 5,
+  device_token_bruteforce_threshold: 10,
+  device_token_bruteforce_window_minutes: 5,
+  registration_flood_threshold: 10,
+  registration_flood_window_minutes: 10
+})
+
+// Alert channels settings
+const alertSettings = ref({
+  telegram: {
+    enabled: false,
+    bot_token: '',
+    chat_ids: []
+  },
+  email: {
+    enabled: false,
+    smtp_server: '',
+    smtp_port: 587,
+    smtp_user: '',
+    smtp_password: '',
+    from_address: '',
+    recipients: []
+  }
+})
+
+// Computed for text areas
+const telegramChatIds = computed({
+  get: () => alertSettings.value.telegram.chat_ids.join('\n'),
+  set: (val) => {
+    alertSettings.value.telegram.chat_ids = val.split('\n').map(id => id.trim()).filter(Boolean)
+  }
+})
+
+const emailRecipients = computed({
+  get: () => alertSettings.value.email.recipients.join('\n'),
+  set: (val) => {
+    alertSettings.value.email.recipients = val.split('\n').map(email => email.trim()).filter(Boolean)
+  }
+})
+
+async function loadSettings() {
+  loading.value = true
+  try {
+    const [hostRes, securityRes, alertsRes] = await Promise.all([
+      axios.get('/superadmin/settings/setting/host_monitoring'),
+      axios.get('/superadmin/settings/setting/security_monitoring'),
+      axios.get('/superadmin/settings/setting/alert_channels')
+    ])
+
+    hostSettings.value = hostRes.data.value
+    securitySettings.value = securityRes.data.value
+    alertSettings.value = alertsRes.data.value
+  } catch (error) {
+    console.error('Failed to load settings:', error)
+  } finally {
+    loading.value = false
+  }
+}
+
+async function saveHostSettings() {
+  saving.value = true
+  try {
+    await axios.put('/superadmin/settings/setting/host_monitoring', {
+      value: hostSettings.value
+    })
+    alert(t('settings.saved'))
+  } catch (error) {
+    console.error('Failed to save host settings:', error)
+    alert(t('settings.saveFailed'))
+  } finally {
+    saving.value = false
+  }
+}
+
+async function saveSecuritySettings() {
+  saving.value = true
+  try {
+    await axios.put('/superadmin/settings/setting/security_monitoring', {
+      value: securitySettings.value
+    })
+    alert(t('settings.saved'))
+  } catch (error) {
+    console.error('Failed to save security settings:', error)
+    alert(t('settings.saveFailed'))
+  } finally {
+    saving.value = false
+  }
+}
+
+async function saveAlertSettings() {
+  saving.value = true
+  try {
+    await axios.put('/superadmin/settings/setting/alert_channels', {
+      value: alertSettings.value
+    })
+    alert(t('settings.saved'))
+  } catch (error) {
+    console.error('Failed to save alert settings:', error)
+    alert(t('settings.saveFailed'))
+  } finally {
+    saving.value = false
+  }
+}
+
+onMounted(() => {
+  loadSettings()
+})
+</script>
+
+<style scoped>
+.page {
+  padding: 32px;
+  max-width: 1200px;
+  margin: 0 auto;
+}
+
+.page-header {
+  margin-bottom: 32px;
+}
+
+.page-header h1 {
+  font-size: 32px;
+  font-weight: 700;
+  margin-bottom: 8px;
+  color: #1a202c;
+}
+
+.page-header p {
+  color: #718096;
+  font-size: 16px;
+}
+
+.tabs {
+  display: flex;
+  gap: 8px;
+  margin-bottom: 24px;
+  border-bottom: 2px solid #e2e8f0;
+}
+
+.tab {
+  padding: 12px 24px;
+  background: none;
+  border: none;
+  border-bottom: 3px solid transparent;
+  color: #718096;
+  font-size: 15px;
+  font-weight: 500;
+  cursor: pointer;
+  transition: all 0.2s;
+  margin-bottom: -2px;
+}
+
+.tab:hover {
+  color: #667eea;
+}
+
+.tab.active {
+  color: #667eea;
+  border-bottom-color: #667eea;
+}
+
+.tab-content {
+  background: white;
+  border-radius: 12px;
+  padding: 32px;
+  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
+}
+
+.settings-section h2 {
+  font-size: 24px;
+  font-weight: 600;
+  margin-bottom: 8px;
+  color: #1a202c;
+}
+
+.section-desc {
+  color: #718096;
+  margin-bottom: 24px;
+}
+
+.settings-section h3 {
+  font-size: 18px;
+  font-weight: 600;
+  margin-top: 24px;
+  margin-bottom: 16px;
+  color: #2d3748;
+}
+
+.settings-form {
+  max-width: 600px;
+}
+
+.form-row {
+  margin-bottom: 20px;
+}
+
+.form-row label {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  cursor: pointer;
+  font-weight: 500;
+}
+
+.form-row input[type="checkbox"] {
+  width: 18px;
+  height: 18px;
+  cursor: pointer;
+}
+
+.form-group {
+  margin-bottom: 20px;
+}
+
+.form-group label {
+  display: block;
+  margin-bottom: 8px;
+  font-weight: 500;
+  color: #2d3748;
+}
+
+.form-group input[type="text"],
+.form-group input[type="email"],
+.form-group input[type="password"],
+.form-group input[type="number"],
+.form-group textarea {
+  width: 100%;
+  padding: 10px 12px;
+  border: 1px solid #e2e8f0;
+  border-radius: 8px;
+  font-size: 15px;
+  transition: border-color 0.2s;
+}
+
+.form-group input:focus,
+.form-group textarea:focus {
+  outline: none;
+  border-color: #667eea;
+}
+
+.form-group textarea {
+  resize: vertical;
+  font-family: inherit;
+}
+
+.hint {
+  display: block;
+  margin-top: 4px;
+  font-size: 13px;
+  color: #718096;
+}
+
+.btn {
+  padding: 12px 24px;
+  border: none;
+  border-radius: 8px;
+  font-size: 15px;
+  font-weight: 500;
+  cursor: pointer;
+  transition: all 0.2s;
+}
+
+.btn-primary {
+  background: #667eea;
+  color: white;
+}
+
+.btn-primary:hover:not(:disabled) {
+  background: #5568d3;
+}
+
+.btn:disabled {
+  opacity: 0.5;
+  cursor: not-allowed;
+}
+
+.loading {
+  text-align: center;
+  padding: 40px;
+  color: #718096;
+}
+</style>