feat: webhook notification channel with UI and ingest dispatch
Add webhook config (DB/env), JSON POST on events/problems, settings API, Settings UI section, and notify_dispatch for multi-channel ingest. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,30 @@
|
|||||||
|
"""notification_channels: webhook columns
|
||||||
|
|
||||||
|
Revision ID: 005
|
||||||
|
Revises: 004
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "005"
|
||||||
|
down_revision: Union[str, None] = "004"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column("notification_channels", sa.Column("webhook_url", sa.Text(), nullable=True))
|
||||||
|
op.add_column(
|
||||||
|
"notification_channels",
|
||||||
|
sa.Column("webhook_secret_header", sa.String(length=64), nullable=True),
|
||||||
|
)
|
||||||
|
op.add_column("notification_channels", sa.Column("webhook_secret", sa.Text(), nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("notification_channels", "webhook_secret")
|
||||||
|
op.drop_column("notification_channels", "webhook_secret_header")
|
||||||
|
op.drop_column("notification_channels", "webhook_url")
|
||||||
@@ -17,7 +17,7 @@ from app.schemas.list_models import EventDetail, EventListResponse, EventSummary
|
|||||||
from app.services.ingest import ingest_event
|
from app.services.ingest import ingest_event
|
||||||
from app.services.problems import maybe_create_problem
|
from app.services.problems import maybe_create_problem
|
||||||
from app.services.schema_validate import validate_event_payload
|
from app.services.schema_validate import validate_event_payload
|
||||||
from app.services.telegram_notify import notify_event, notify_problem
|
from app.services.notify_dispatch import notify_event, notify_problem
|
||||||
|
|
||||||
router = APIRouter(prefix="/events", tags=["events"])
|
router = APIRouter(prefix="/events", tags=["events"])
|
||||||
logger = logging.getLogger("sac.ingest")
|
logger = logging.getLogger("sac.ingest")
|
||||||
|
|||||||
+190
-106
@@ -1,106 +1,190 @@
|
|||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from pydantic import BaseModel, Field
|
|
||||||
from sqlalchemy.orm import Session
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from app.auth.jwt_auth import get_current_user
|
from sqlalchemy.orm import Session
|
||||||
from app.database import get_db
|
|
||||||
from app.services.notification_settings import (
|
|
||||||
VALID_SEVERITIES,
|
|
||||||
TelegramConfig,
|
from app.auth.jwt_auth import get_current_user
|
||||||
get_effective_telegram_config,
|
|
||||||
upsert_telegram_channel,
|
from app.database import get_db
|
||||||
)
|
|
||||||
from app.services.telegram_notify import TelegramNotConfiguredError, TelegramSendError, send_telegram_test_message
|
from app.services.notification_settings import (
|
||||||
|
|
||||||
router = APIRouter(prefix="/settings", tags=["settings"])
|
VALID_SEVERITIES,
|
||||||
|
|
||||||
|
TelegramConfig,
|
||||||
def _mask_secret(value: str, *, visible_tail: int = 4) -> str | None:
|
|
||||||
text = value.strip()
|
WebhookConfig,
|
||||||
if not text:
|
|
||||||
return None
|
get_effective_telegram_config,
|
||||||
if len(text) <= visible_tail:
|
|
||||||
return "*" * len(text)
|
get_effective_webhook_config,
|
||||||
return f"{'*' * 8}…{text[-visible_tail:]}"
|
|
||||||
|
upsert_telegram_channel,
|
||||||
|
|
||||||
class TelegramSettingsResponse(BaseModel):
|
upsert_webhook_channel,
|
||||||
enabled: bool
|
|
||||||
configured: bool
|
)
|
||||||
chat_id_hint: str | None = None
|
|
||||||
bot_token_hint: str | None = None
|
from app.services.telegram_notify import TelegramNotConfiguredError, TelegramSendError, send_telegram_test_message
|
||||||
min_severity: str
|
|
||||||
source: str = Field(description="env или db")
|
from app.services.webhook_notify import WebhookNotConfiguredError, WebhookSendError, send_webhook_test_message
|
||||||
|
|
||||||
|
|
||||||
class NotificationSettingsResponse(BaseModel):
|
|
||||||
telegram: TelegramSettingsResponse
|
router = APIRouter(prefix="/settings", tags=["settings"])
|
||||||
|
|
||||||
|
|
||||||
class TelegramSettingsUpdate(BaseModel):
|
|
||||||
enabled: bool
|
|
||||||
min_severity: str
|
|
||||||
bot_token: str | None = None
|
def _mask_secret(value: str, *, visible_tail: int = 4) -> str | None:
|
||||||
chat_id: str | None = None
|
|
||||||
|
text = value.strip()
|
||||||
|
|
||||||
class TelegramTestResponse(BaseModel):
|
if not text:
|
||||||
ok: bool = True
|
|
||||||
message: str = "Тестовое сообщение отправлено в Telegram"
|
return None
|
||||||
|
|
||||||
|
if len(text) <= visible_tail:
|
||||||
def _telegram_to_response(cfg: TelegramConfig) -> TelegramSettingsResponse:
|
|
||||||
return TelegramSettingsResponse(
|
return "*" * len(text)
|
||||||
enabled=cfg.enabled,
|
|
||||||
configured=cfg.configured,
|
return f"{'*' * 8}…{text[-visible_tail:]}"
|
||||||
chat_id_hint=_mask_secret(cfg.chat_id),
|
|
||||||
bot_token_hint=_mask_secret(cfg.bot_token),
|
|
||||||
min_severity=cfg.min_severity,
|
|
||||||
source=cfg.source,
|
|
||||||
)
|
|
||||||
|
def _mask_url(value: str) -> str | None:
|
||||||
|
|
||||||
@router.get("/notifications", response_model=NotificationSettingsResponse)
|
text = value.strip()
|
||||||
def get_notification_settings(
|
|
||||||
db: Session = Depends(get_db),
|
if not text:
|
||||||
_user: str = Depends(get_current_user),
|
|
||||||
) -> NotificationSettingsResponse:
|
return None
|
||||||
cfg = get_effective_telegram_config(db)
|
|
||||||
return NotificationSettingsResponse(telegram=_telegram_to_response(cfg))
|
if len(text) <= 20:
|
||||||
|
|
||||||
|
return f"{'*' * 6}…{text[-4:]}"
|
||||||
@router.put("/notifications/telegram", response_model=TelegramSettingsResponse)
|
|
||||||
def update_telegram_settings(
|
return f"{text[:16]}…{text[-6:]}"
|
||||||
body: TelegramSettingsUpdate,
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
_user: str = Depends(get_current_user),
|
|
||||||
) -> TelegramSettingsResponse:
|
|
||||||
if body.min_severity not in VALID_SEVERITIES:
|
|
||||||
raise HTTPException(status_code=422, detail=f"min_severity must be one of: {sorted(VALID_SEVERITIES)}")
|
class TelegramSettingsResponse(BaseModel):
|
||||||
try:
|
|
||||||
cfg = upsert_telegram_channel(
|
enabled: bool
|
||||||
db,
|
|
||||||
enabled=body.enabled,
|
configured: bool
|
||||||
min_severity=body.min_severity,
|
|
||||||
bot_token=body.bot_token,
|
chat_id_hint: str | None = None
|
||||||
chat_id=body.chat_id,
|
|
||||||
)
|
bot_token_hint: str | None = None
|
||||||
except ValueError as exc:
|
|
||||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
min_severity: str
|
||||||
return _telegram_to_response(cfg)
|
|
||||||
|
source: str = Field(description="env или db")
|
||||||
|
|
||||||
@router.post("/notifications/telegram/test", response_model=TelegramTestResponse)
|
|
||||||
def test_telegram_settings(
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
_user: str = Depends(get_current_user),
|
|
||||||
) -> TelegramTestResponse:
|
class WebhookSettingsResponse(BaseModel):
|
||||||
cfg = get_effective_telegram_config(db)
|
|
||||||
try:
|
enabled: bool
|
||||||
send_telegram_test_message(config=cfg)
|
|
||||||
except TelegramNotConfiguredError as exc:
|
configured: bool
|
||||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
||||||
except TelegramSendError as exc:
|
url_hint: str | None = None
|
||||||
code = exc.status_code if exc.status_code and 400 <= exc.status_code < 500 else 502
|
|
||||||
raise HTTPException(status_code=code, detail=str(exc)) from exc
|
secret_header: str | None = None
|
||||||
return TelegramTestResponse()
|
|
||||||
|
secret_hint: str | None = None
|
||||||
|
|
||||||
|
min_severity: str
|
||||||
|
|
||||||
|
source: str = Field(description="env или db")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationSettingsResponse(BaseModel):
|
||||||
|
|
||||||
|
telegram: TelegramSettingsResponse
|
||||||
|
|
||||||
|
webhook: WebhookSettingsResponse
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class TelegramSettingsUpdate(BaseModel):
|
||||||
|
|
||||||
|
enabled: bool
|
||||||
|
|
||||||
|
min_severity: str
|
||||||
|
|
||||||
|
bot_token: str | None = None
|
||||||
|
|
||||||
|
chat_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class WebhookSettingsUpdate(BaseModel):
|
||||||
|
|
||||||
|
enabled: bool
|
||||||
|
|
||||||
|
min_severity: str
|
||||||
|
|
||||||
|
url: str | None = None
|
||||||
|
|
||||||
|
secret_header: str | None = None
|
||||||
|
|
||||||
|
secret: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class ChannelTestResponse(BaseModel):
|
||||||
|
|
||||||
|
ok: bool = True
|
||||||
|
|
||||||
|
message: str
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def _telegram_to_response(cfg: TelegramConfig) -> TelegramSettingsResponse:
|
||||||
|
|
||||||
|
return TelegramSettingsResponse(
|
||||||
|
|
||||||
|
enabled=cfg.enabled,
|
||||||
|
|
||||||
|
configured=cfg.configured,
|
||||||
|
|
||||||
|
chat_id_hint=_mask_secret(cfg.chat_id),
|
||||||
|
|
||||||
|
bot_token_hint=_mask_secret(cfg.bot_token),
|
||||||
|
|
||||||
|
min_severity=cfg.min_severity,
|
||||||
|
|
||||||
|
source=cfg.source,
|
||||||
|
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def _webhook_to_response(cfg: WebhookConfig) -> WebhookSettingsResponse:
|
||||||
|
|
||||||
|
|||||||
@@ -50,7 +50,13 @@ class Settings(BaseSettings):
|
|||||||
telegram_chat_id: str = ""
|
telegram_chat_id: str = ""
|
||||||
telegram_min_severity: str = "high"
|
telegram_min_severity: str = "high"
|
||||||
|
|
||||||
# Порог «живости» агента: agent.heartbeat раз в ~12 ч (ssh-monitor)
|
webhook_enabled: bool = False
|
||||||
|
webhook_url: str = ""
|
||||||
|
webhook_secret_header: str = ""
|
||||||
|
webhook_secret: str = ""
|
||||||
|
webhook_min_severity: str = "high"
|
||||||
|
|
||||||
|
# Порог «живости» агента
|
||||||
sac_heartbeat_stale_minutes: int = 780
|
sac_heartbeat_stale_minutes: int = 780
|
||||||
|
|
||||||
# Окно корреляции Problems: host + type + rule в одном open Problem
|
# Окно корреляции Problems: host + type + rule в одном open Problem
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ class NotificationChannel(Base):
|
|||||||
min_severity: Mapped[str] = mapped_column(String(16), default="warning")
|
min_severity: Mapped[str] = mapped_column(String(16), default="warning")
|
||||||
bot_token: Mapped[str | None] = mapped_column(Text, nullable=True)
|
bot_token: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
chat_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
chat_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||||
|
webhook_url: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
webhook_secret_header: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||||
|
webhook_secret: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
updated_at: Mapped[datetime] = mapped_column(
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from app.models.notification_channel import NotificationChannel
|
|||||||
|
|
||||||
VALID_SEVERITIES = frozenset({"info", "warning", "high", "critical"})
|
VALID_SEVERITIES = frozenset({"info", "warning", "high", "critical"})
|
||||||
CHANNEL_TELEGRAM = "telegram"
|
CHANNEL_TELEGRAM = "telegram"
|
||||||
|
CHANNEL_WEBHOOK = "webhook"
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -31,6 +32,24 @@ class TelegramConfig:
|
|||||||
return self.enabled and self.configured
|
return self.enabled and self.configured
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class WebhookConfig:
|
||||||
|
enabled: bool
|
||||||
|
url: str
|
||||||
|
secret_header: str
|
||||||
|
secret: str
|
||||||
|
min_severity: str
|
||||||
|
source: str # env | db
|
||||||
|
|
||||||
|
@property
|
||||||
|
def configured(self) -> bool:
|
||||||
|
return bool(self.url.strip())
|
||||||
|
|
||||||
|
@property
|
||||||
|
def active(self) -> bool:
|
||||||
|
return self.enabled and self.configured
|
||||||
|
|
||||||
|
|
||||||
def _telegram_from_env() -> TelegramConfig:
|
def _telegram_from_env() -> TelegramConfig:
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
return TelegramConfig(
|
return TelegramConfig(
|
||||||
@@ -107,3 +126,88 @@ def upsert_telegram_channel(
|
|||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(row)
|
db.refresh(row)
|
||||||
return get_effective_telegram_config(db)
|
return get_effective_telegram_config(db)
|
||||||
|
|
||||||
|
|
||||||
|
def _webhook_from_env() -> WebhookConfig:
|
||||||
|
settings = get_settings()
|
||||||
|
return WebhookConfig(
|
||||||
|
enabled=bool(settings.webhook_enabled),
|
||||||
|
url=settings.webhook_url.strip(),
|
||||||
|
secret_header=settings.webhook_secret_header.strip(),
|
||||||
|
secret=settings.webhook_secret.strip(),
|
||||||
|
min_severity=settings.webhook_min_severity.strip() or "high",
|
||||||
|
source="env",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_effective_webhook_config(db: Session | None = None) -> WebhookConfig:
|
||||||
|
if db is None:
|
||||||
|
from app.database import SessionLocal
|
||||||
|
|
||||||
|
session = SessionLocal()
|
||||||
|
try:
|
||||||
|
return get_effective_webhook_config(session)
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
row = db.scalar(select(NotificationChannel).where(NotificationChannel.channel == CHANNEL_WEBHOOK))
|
||||||
|
env_cfg = _webhook_from_env()
|
||||||
|
if row is None:
|
||||||
|
return env_cfg
|
||||||
|
|
||||||
|
url = (row.webhook_url or "").strip() or env_cfg.url
|
||||||
|
secret_header = (row.webhook_secret_header or "").strip() or env_cfg.secret_header
|
||||||
|
secret = (row.webhook_secret or "").strip() or env_cfg.secret
|
||||||
|
min_sev = (row.min_severity or "").strip() or env_cfg.min_severity
|
||||||
|
if min_sev not in VALID_SEVERITIES:
|
||||||
|
min_sev = env_cfg.min_severity
|
||||||
|
|
||||||
|
return WebhookConfig(
|
||||||
|
enabled=bool(row.enabled),
|
||||||
|
url=url,
|
||||||
|
secret_header=secret_header,
|
||||||
|
secret=secret,
|
||||||
|
min_severity=min_sev,
|
||||||
|
source="db",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def upsert_webhook_channel(
|
||||||
|
db: Session,
|
||||||
|
*,
|
||||||
|
enabled: bool | None = None,
|
||||||
|
url: str | None = None,
|
||||||
|
secret_header: str | None = None,
|
||||||
|
secret: str | None = None,
|
||||||
|
min_severity: str | None = None,
|
||||||
|
) -> WebhookConfig:
|
||||||
|
row = db.scalar(select(NotificationChannel).where(NotificationChannel.channel == CHANNEL_WEBHOOK))
|
||||||
|
env_cfg = _webhook_from_env()
|
||||||
|
|
||||||
|
if row is None:
|
||||||
|
row = NotificationChannel(
|
||||||
|
channel=CHANNEL_WEBHOOK,
|
||||||
|
enabled=env_cfg.enabled,
|
||||||
|
min_severity=env_cfg.min_severity,
|
||||||
|
webhook_url=env_cfg.url or None,
|
||||||
|
webhook_secret_header=env_cfg.secret_header or None,
|
||||||
|
webhook_secret=env_cfg.secret or None,
|
||||||
|
)
|
||||||
|
db.add(row)
|
||||||
|
|
||||||
|
if enabled is not None:
|
||||||
|
row.enabled = enabled
|
||||||
|
if min_severity is not None:
|
||||||
|
if min_severity not in VALID_SEVERITIES:
|
||||||
|
raise ValueError(f"invalid min_severity: {min_severity}")
|
||||||
|
row.min_severity = min_severity
|
||||||
|
if url is not None and url.strip():
|
||||||
|
row.webhook_url = url.strip()
|
||||||
|
if secret_header is not None:
|
||||||
|
row.webhook_secret_header = secret_header.strip() or None
|
||||||
|
if secret is not None and secret.strip():
|
||||||
|
row.webhook_secret = secret.strip()
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(row)
|
||||||
|
return get_effective_webhook_config(db)
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
"""Dispatch ingest notifications to all configured channels."""
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.models import Event, Problem
|
||||||
|
from app.services import telegram_notify, webhook_notify
|
||||||
|
|
||||||
|
|
||||||
|
def notify_event(event: Event, *, db: Session | None = None) -> None:
|
||||||
|
telegram_notify.notify_event(event, db=db)
|
||||||
|
webhook_notify.notify_event(event, db=db)
|
||||||
|
|
||||||
|
|
||||||
|
def notify_problem(problem: Problem, event: Event | None = None, *, db: Session | None = None) -> None:
|
||||||
|
telegram_notify.notify_problem(problem, event, db=db)
|
||||||
|
webhook_notify.notify_problem(problem, event, db=db)
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
"""Generic JSON webhook notifications."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.models import Event, Problem
|
||||||
|
from app.services.notification_settings import WebhookConfig, get_effective_webhook_config
|
||||||
|
from app.services.telegram_notify import SEVERITY_ORDER
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class WebhookNotConfiguredError(Exception):
|
||||||
|
"""Webhook disabled or missing URL."""
|
||||||
|
|
||||||
|
|
||||||
|
class WebhookSendError(Exception):
|
||||||
|
def __init__(self, message: str, *, status_code: int | None = None) -> None:
|
||||||
|
super().__init__(message)
|
||||||
|
self.status_code = status_code
|
||||||
|
|
||||||
|
|
||||||
|
def _severity_value(value: str) -> int:
|
||||||
|
return SEVERITY_ORDER.get(value, 0)
|
||||||
|
|
||||||
|
|
||||||
|
def _should_notify_severity(severity: str, *, config: WebhookConfig) -> bool:
|
||||||
|
return _severity_value(severity) >= _severity_value(config.min_severity)
|
||||||
|
|
||||||
|
|
||||||
|
def _host_payload(entity: Event | Problem) -> dict[str, Any]:
|
||||||
|
host = entity.host
|
||||||
|
if host is None:
|
||||||
|
return {"hostname": "unknown", "display_name": None}
|
||||||
|
return {"hostname": host.hostname, "display_name": host.display_name}
|
||||||
|
|
||||||
|
|
||||||
|
def build_event_payload(event: Event) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"kind": "event",
|
||||||
|
"event_id": event.event_id,
|
||||||
|
"type": event.type,
|
||||||
|
"severity": event.severity,
|
||||||
|
"category": event.category,
|
||||||
|
"title": event.title,
|
||||||
|
"summary": event.summary,
|
||||||
|
"host": _host_payload(event),
|
||||||
|
"occurred_at": event.occurred_at.isoformat() if event.occurred_at else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_problem_payload(problem: Problem, event: Event | None = None) -> dict[str, Any]:
|
||||||
|
payload: dict[str, Any] = {
|
||||||
|
"kind": "problem",
|
||||||
|
"problem_id": problem.id,
|
||||||
|
"rule_id": problem.rule_id,
|
||||||
|
"severity": problem.severity,
|
||||||
|
"status": problem.status,
|
||||||
|
"title": problem.title,
|
||||||
|
"summary": problem.summary,
|
||||||
|
"host": _host_payload(problem),
|
||||||
|
"opened_at": problem.opened_at.isoformat() if problem.opened_at else None,
|
||||||
|
}
|
||||||
|
if event is not None:
|
||||||
|
payload["trigger_event"] = {
|
||||||
|
"event_id": event.event_id,
|
||||||
|
"type": event.type,
|
||||||
|
"severity": event.severity,
|
||||||
|
}
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def send_webhook_payload(payload: dict[str, Any], *, config: WebhookConfig | None = None, force: bool = False) -> None:
|
||||||
|
cfg = config or get_effective_webhook_config()
|
||||||
|
if not force and not cfg.active:
|
||||||
|
return
|
||||||
|
if not cfg.url:
|
||||||
|
if force:
|
||||||
|
raise WebhookNotConfiguredError("Webhook: не задан URL")
|
||||||
|
return
|
||||||
|
|
||||||
|
headers = {"Content-Type": "application/json", "User-Agent": "SecurityAlertCenter/1.0"}
|
||||||
|
if cfg.secret_header and cfg.secret:
|
||||||
|
headers[cfg.secret_header] = cfg.secret
|
||||||
|
|
||||||
|
try:
|
||||||
|
with httpx.Client(timeout=10.0) as client:
|
||||||
|
response = client.post(cfg.url, json=payload, headers=headers)
|
||||||
|
response.raise_for_status()
|
||||||
|
except httpx.HTTPStatusError as exc:
|
||||||
|
detail = exc.response.text[:200] if exc.response is not None else str(exc)
|
||||||
|
raise WebhookSendError(
|
||||||
|
f"Webhook HTTP {exc.response.status_code}: {detail}",
|
||||||
|
status_code=exc.response.status_code,
|
||||||
|
) from exc
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception("webhook send failed")
|
||||||
|
raise WebhookSendError(str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
def send_webhook_test_message(*, config: WebhookConfig | None = None) -> None:
|
||||||
|
cfg = config or get_effective_webhook_config()
|
||||||
|
if not cfg.enabled:
|
||||||
|
raise WebhookNotConfiguredError("Webhook отключён (enabled=false)")
|
||||||
|
if not cfg.configured:
|
||||||
|
raise WebhookNotConfiguredError("Не задан webhook URL")
|
||||||
|
send_webhook_payload(
|
||||||
|
{
|
||||||
|
"kind": "test",
|
||||||
|
"message": "SAC webhook test",
|
||||||
|
"source": "security-alert-center",
|
||||||
|
},
|
||||||
|
config=cfg,
|
||||||
|
force=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def notify_event(event: Event, *, db: Session | None = None) -> None:
|
||||||
|
cfg = get_effective_webhook_config(db)
|
||||||
|
if not cfg.active or not _should_notify_severity(event.severity, config=cfg):
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
send_webhook_payload(build_event_payload(event), config=cfg)
|
||||||
|
except WebhookSendError:
|
||||||
|
logger.exception("notify_event webhook failed event_id=%s", event.event_id)
|
||||||
|
|
||||||
|
|
||||||
|
def notify_problem(problem: Problem, event: Event | None = None, *, db: Session | None = None) -> None:
|
||||||
|
cfg = get_effective_webhook_config(db)
|
||||||
|
if not cfg.active or not _should_notify_severity(problem.severity, config=cfg):
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
send_webhook_payload(build_problem_payload(problem, event), config=cfg)
|
||||||
|
except WebhookSendError:
|
||||||
|
logger.exception("notify_problem webhook failed problem_id=%s", problem.id)
|
||||||
@@ -22,6 +22,56 @@ def test_get_notification_settings_masks_secrets(jwt_headers, client, monkeypatc
|
|||||||
assert body["telegram"]["min_severity"] == "warning"
|
assert body["telegram"]["min_severity"] == "warning"
|
||||||
assert body["telegram"]["source"] == "env"
|
assert body["telegram"]["source"] == "env"
|
||||||
assert body["telegram"]["bot_token_hint"].endswith("ghij")
|
assert body["telegram"]["bot_token_hint"].endswith("ghij")
|
||||||
|
assert "webhook" in body
|
||||||
|
assert body["webhook"]["enabled"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_put_webhook_settings_persists_db(jwt_headers, client, db_session, monkeypatch):
|
||||||
|
monkeypatch.setenv("WEBHOOK_ENABLED", "false")
|
||||||
|
monkeypatch.setenv("WEBHOOK_URL", "")
|
||||||
|
get_settings.cache_clear()
|
||||||
|
|
||||||
|
response = client.put(
|
||||||
|
"/api/v1/settings/notifications/webhook",
|
||||||
|
headers=jwt_headers,
|
||||||
|
json={
|
||||||
|
"enabled": True,
|
||||||
|
"min_severity": "warning",
|
||||||
|
"url": "https://example.com/hooks/sac",
|
||||||
|
"secret_header": "X-SAC-Token",
|
||||||
|
"secret": "supersecret",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
body = response.json()
|
||||||
|
assert body["source"] == "db"
|
||||||
|
assert body["configured"] is True
|
||||||
|
|
||||||
|
row = db_session.get(NotificationChannel, "webhook")
|
||||||
|
assert row is not None
|
||||||
|
assert row.webhook_url == "https://example.com/hooks/sac"
|
||||||
|
assert row.webhook_secret_header == "X-SAC-Token"
|
||||||
|
|
||||||
|
|
||||||
|
def test_post_webhook_test_success(jwt_headers, client, db_session, monkeypatch):
|
||||||
|
monkeypatch.setenv("WEBHOOK_ENABLED", "false")
|
||||||
|
get_settings.cache_clear()
|
||||||
|
|
||||||
|
db_session.add(
|
||||||
|
NotificationChannel(
|
||||||
|
channel="webhook",
|
||||||
|
enabled=True,
|
||||||
|
min_severity="warning",
|
||||||
|
webhook_url="https://example.com/hook",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
with patch("app.api.v1.settings.send_webhook_test_message") as mock_test:
|
||||||
|
response = client.post("/api/v1/settings/notifications/webhook/test", headers=jwt_headers)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json()["ok"] is True
|
||||||
|
mock_test.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
def test_put_telegram_settings_persists_db(jwt_headers, client, db_session, monkeypatch):
|
def test_put_telegram_settings_persists_db(jwt_headers, client, db_session, monkeypatch):
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
"""Tests for SAC webhook notification helpers."""
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from app.models import Event
|
||||||
|
from app.services import webhook_notify
|
||||||
|
from app.services.notification_settings import WebhookConfig
|
||||||
|
|
||||||
|
|
||||||
|
def test_notify_event_respects_min_severity():
|
||||||
|
sent: list[dict] = []
|
||||||
|
|
||||||
|
def fake_send(payload: dict, **kwargs) -> None:
|
||||||
|
sent.append(payload)
|
||||||
|
|
||||||
|
event = Event(
|
||||||
|
event_id="00000000-0000-4000-8000-000000000201",
|
||||||
|
host_id=1,
|
||||||
|
occurred_at=datetime(2026, 5, 29, 12, 0, tzinfo=timezone.utc),
|
||||||
|
category="auth",
|
||||||
|
type="rdp.login.failed",
|
||||||
|
severity="warning",
|
||||||
|
title="failed",
|
||||||
|
summary="e2e",
|
||||||
|
payload={},
|
||||||
|
)
|
||||||
|
|
||||||
|
cfg = WebhookConfig(
|
||||||
|
enabled=True,
|
||||||
|
url="https://example.com/hook",
|
||||||
|
secret_header="",
|
||||||
|
secret="",
|
||||||
|
min_severity="high",
|
||||||
|
source="env",
|
||||||
|
)
|
||||||
|
with patch.object(webhook_notify, "get_effective_webhook_config", return_value=cfg):
|
||||||
|
with patch.object(webhook_notify, "send_webhook_payload", side_effect=fake_send):
|
||||||
|
webhook_notify.notify_event(event)
|
||||||
|
assert sent == []
|
||||||
|
|
||||||
|
cfg_high = WebhookConfig(
|
||||||
|
enabled=True,
|
||||||
|
url="https://example.com/hook",
|
||||||
|
secret_header="",
|
||||||
|
secret="",
|
||||||
|
min_severity="warning",
|
||||||
|
source="env",
|
||||||
|
)
|
||||||
|
event.severity = "warning"
|
||||||
|
with patch.object(webhook_notify, "get_effective_webhook_config", return_value=cfg_high):
|
||||||
|
with patch.object(webhook_notify, "send_webhook_payload", side_effect=fake_send):
|
||||||
|
webhook_notify.notify_event(event)
|
||||||
|
assert len(sent) == 1
|
||||||
|
assert sent[0]["kind"] == "event"
|
||||||
@@ -25,7 +25,14 @@ TELEGRAM_CHAT_ID=
|
|||||||
# exclusive: warning (failed login, problems); prod noise-only: high
|
# exclusive: warning (failed login, problems); prod noise-only: high
|
||||||
TELEGRAM_MIN_SEVERITY=high
|
TELEGRAM_MIN_SEVERITY=high
|
||||||
|
|
||||||
# Статус хоста по agent.heartbeat (минуты; ssh-monitor шлёт ~раз в 12 ч)
|
# Generic webhook (JSON POST), F-NOT-01
|
||||||
|
WEBHOOK_ENABLED=false
|
||||||
|
WEBHOOK_URL=
|
||||||
|
WEBHOOK_SECRET_HEADER=
|
||||||
|
WEBHOOK_SECRET=
|
||||||
|
WEBHOOK_MIN_SEVERITY=high
|
||||||
|
|
||||||
|
# Статус хоста по agent.heartbeat
|
||||||
SAC_HEARTBEAT_STALE_MINUTES=780
|
SAC_HEARTBEAT_STALE_MINUTES=780
|
||||||
|
|
||||||
# Корреляция Problems: host + type + rule в одном open Problem (минуты)
|
# Корреляция Problems: host + type + rule в одном open Problem (минуты)
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
| Порог severity | `telegram_min_severity` (по умолчанию **`high`**) | События **`info`** (успешный RDP `rdp.login.success`) **не** уходят в TG, пока порог не снизить до `warning` |
|
| Порог severity | `telegram_min_severity` (по умолчанию **`high`**) | События **`info`** (успешный RDP `rdp.login.success`) **не** уходят в TG, пока порог не снизить до `warning` |
|
||||||
| Вызов при ingest | `events.py` → `notify_event` / `notify_problem` | Шаблон короткий («🚨 SAC событие»), не как у агента |
|
| Вызов при ingest | `events.py` → `notify_event` / `notify_problem` | Шаблон короткий («🚨 SAC событие»), не как у агента |
|
||||||
| UI «Настройки» | `/settings`, GET/PUT Telegram | БД `notification_channels` (миграция `004`) + fallback на `sac-api.env` |
|
| UI «Настройки» | `/settings`, GET/PUT Telegram | БД `notification_channels` (миграция `004`) + fallback на `sac-api.env` |
|
||||||
| Email / webhook | TZ F-NOT-01 | **Не реализованы** в backend |
|
| Email / webhook | TZ F-NOT-01 | Webhook ✅; SMTP — `notif-20` |
|
||||||
|
|
||||||
**Вывод на завтра:** для exclusive нужно (1) включить и настроить TG на сервере SAC **или** (2) сделать раздел **«Настройки»** в UI + хранение каналов; порог **`warning` и выше** для событий и problems.
|
**Вывод на завтра:** для exclusive нужно (1) включить и настроить TG на сервере SAC **или** (2) сделать раздел **«Настройки»** в UI + хранение каналов; порог **`warning` и выше** для событий и problems.
|
||||||
|
|
||||||
@@ -45,7 +45,7 @@
|
|||||||
| ID | Задача | Критерий |
|
| ID | Задача | Критерий |
|
||||||
|----|--------|----------|
|
|----|--------|----------|
|
||||||
| `notif-20` | SMTP: host, port, user, from, to, TLS — по аналогии с RDP `login_monitor.settings` | Отправка тестового письма |
|
| `notif-20` | SMTP: host, port, user, from, to, TLS — по аналогии с RDP `login_monitor.settings` | Отправка тестового письма |
|
||||||
| `notif-21` | Webhook: URL, optional secret header, JSON payload (event/problem) | POST на httpbin/staging |
|
| `notif-21` | Webhook: URL, optional secret header, JSON payload (event/problem) | ✅ PUT/test API + UI + ingest |
|
||||||
| `notif-22` | TZ F-NOT-02 урезанно: правило «severity ≥ X → каналы» без сложного конструктора | Одна строка в настройках |
|
| `notif-22` | TZ F-NOT-02 урезанно: правило «severity ≥ X → каналы» без сложного конструктора | Одна строка в настройках |
|
||||||
|
|
||||||
### P3 — Качество сообщений (можно сдвинуть)
|
### P3 — Качество сообщений (можно сдвинуть)
|
||||||
|
|||||||
@@ -2,66 +2,122 @@
|
|||||||
<div class="settings-page">
|
<div class="settings-page">
|
||||||
<h1>Настройки</h1>
|
<h1>Настройки</h1>
|
||||||
<p class="settings-intro">
|
<p class="settings-intro">
|
||||||
Каналы оповещений SAC. Настройки Telegram сохраняются в БД (источник <code>db</code>) или берутся из
|
Каналы оповещений SAC. Значения в БД (<code>source=db</code>) или из <code>sac-api.env</code> (<code>env</code>),
|
||||||
<code>sac-api.env</code> (<code>env</code>), пока запись не создана.
|
пока запись не создана через UI.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p v-if="error" class="error">{{ error }}</p>
|
<p v-if="error" class="error">{{ error }}</p>
|
||||||
<p v-if="success" class="success">{{ success }}</p>
|
<p v-if="success" class="success">{{ success }}</p>
|
||||||
<p v-else-if="loading">Загрузка…</p>
|
<p v-else-if="loading">Загрузка…</p>
|
||||||
|
|
||||||
<section v-else class="card settings-card">
|
<template v-else>
|
||||||
<h2>Telegram</h2>
|
<section class="card settings-card">
|
||||||
|
<h2>Telegram</h2>
|
||||||
<form class="settings-form" @submit.prevent="save">
|
<form class="settings-form" @submit.prevent="saveTelegram">
|
||||||
<label class="settings-field">
|
<label class="settings-field">
|
||||||
<input v-model="form.enabled" type="checkbox" />
|
<input v-model="telegramForm.enabled" type="checkbox" />
|
||||||
Включить Telegram
|
Включить Telegram
|
||||||
</label>
|
</label>
|
||||||
|
<label class="settings-field">
|
||||||
<label class="settings-field">
|
Мин. severity
|
||||||
Мин. severity
|
<select v-model="telegramForm.min_severity">
|
||||||
<select v-model="form.min_severity">
|
<option value="info">info</option>
|
||||||
<option value="info">info</option>
|
<option value="warning">warning</option>
|
||||||
<option value="warning">warning</option>
|
<option value="high">high</option>
|
||||||
<option value="high">high</option>
|
<option value="critical">critical</option>
|
||||||
<option value="critical">critical</option>
|
</select>
|
||||||
</select>
|
</label>
|
||||||
</label>
|
<label class="settings-field">
|
||||||
|
Bot token
|
||||||
<label class="settings-field">
|
<input
|
||||||
Bot token
|
v-model="telegramForm.bot_token"
|
||||||
<input
|
type="password"
|
||||||
v-model="form.bot_token"
|
autocomplete="off"
|
||||||
type="password"
|
:placeholder="tokenPlaceholder"
|
||||||
autocomplete="off"
|
/>
|
||||||
:placeholder="tokenPlaceholder"
|
</label>
|
||||||
/>
|
<label class="settings-field">
|
||||||
</label>
|
Chat ID
|
||||||
|
<input
|
||||||
<label class="settings-field">
|
v-model="telegramForm.chat_id"
|
||||||
Chat ID
|
type="text"
|
||||||
<input v-model="form.chat_id" type="text" autocomplete="off" :placeholder="chatPlaceholder" />
|
autocomplete="off"
|
||||||
</label>
|
:placeholder="chatPlaceholder"
|
||||||
|
/>
|
||||||
<p v-if="loaded" class="settings-meta">
|
</label>
|
||||||
Источник: <code>{{ loaded.source }}</code>
|
<p v-if="telegramLoaded" class="settings-meta">
|
||||||
· настроен: {{ loaded.configured ? "да" : "нет" }}
|
Источник: <code>{{ telegramLoaded.source }}</code>
|
||||||
|
· настроен: {{ telegramLoaded.configured ? "да" : "нет" }}
|
||||||
|
</p>
|
||||||
|
<div class="settings-actions">
|
||||||
|
<button type="submit" :disabled="savingTelegram">
|
||||||
|
{{ savingTelegram ? "Сохранение…" : "Сохранить" }}
|
||||||
|
</button>
|
||||||
|
<button type="button" class="secondary" :disabled="testingTelegram" @click="testTelegram">
|
||||||
|
{{ testingTelegram ? "Отправка…" : "Проверить Telegram" }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<p class="settings-hint">
|
||||||
|
Для <strong>UseSAC=exclusive</strong> рекомендуется <code>warning</code> — failed login и problems, без
|
||||||
|
успешных RDP (<code>info</code>).
|
||||||
</p>
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
<div class="settings-actions">
|
<section class="card settings-card">
|
||||||
<button type="submit" :disabled="saving">{{ saving ? "Сохранение…" : "Сохранить" }}</button>
|
<h2>Webhook</h2>
|
||||||
<button type="button" class="secondary" :disabled="testing" @click="testTelegram">
|
<form class="settings-form" @submit.prevent="saveWebhook">
|
||||||
{{ testing ? "Отправка…" : "Проверить Telegram" }}
|
<label class="settings-field">
|
||||||
</button>
|
<input v-model="webhookForm.enabled" type="checkbox" />
|
||||||
</div>
|
Включить webhook
|
||||||
</form>
|
</label>
|
||||||
|
<label class="settings-field">
|
||||||
<p class="settings-hint">
|
Мин. severity
|
||||||
Для <strong>UseSAC=exclusive</strong> рекомендуется <code>warning</code> — failed login и problems, без
|
<select v-model="webhookForm.min_severity">
|
||||||
успешных RDP (<code>info</code>).
|
<option value="info">info</option>
|
||||||
</p>
|
<option value="warning">warning</option>
|
||||||
</section>
|
<option value="high">high</option>
|
||||||
|
<option value="critical">critical</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="settings-field">
|
||||||
|
URL
|
||||||
|
<input
|
||||||
|
v-model="webhookForm.url"
|
||||||
|
type="url"
|
||||||
|
autocomplete="off"
|
||||||
|
:placeholder="urlPlaceholder"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label class="settings-field">
|
||||||
|
Secret header (необязательно)
|
||||||
|
<input v-model="webhookForm.secret_header" type="text" autocomplete="off" placeholder="X-SAC-Token" />
|
||||||
|
</label>
|
||||||
|
<label class="settings-field">
|
||||||
|
Secret value
|
||||||
|
<input
|
||||||
|
v-model="webhookForm.secret"
|
||||||
|
type="password"
|
||||||
|
autocomplete="off"
|
||||||
|
:placeholder="webhookSecretPlaceholder"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<p v-if="webhookLoaded" class="settings-meta">
|
||||||
|
Источник: <code>{{ webhookLoaded.source }}</code>
|
||||||
|
· настроен: {{ webhookLoaded.configured ? "да" : "нет" }}
|
||||||
|
</p>
|
||||||
|
<div class="settings-actions">
|
||||||
|
<button type="submit" :disabled="savingWebhook">
|
||||||
|
{{ savingWebhook ? "Сохранение…" : "Сохранить" }}
|
||||||
|
</button>
|
||||||
|
<button type="button" class="secondary" :disabled="testingWebhook" @click="testWebhook">
|
||||||
|
{{ testingWebhook ? "Отправка…" : "Проверить webhook" }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<p class="settings-hint">JSON POST: <code>kind</code> = event | problem | test; те же пороги severity, что у Telegram.</p>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -78,37 +134,76 @@ interface TelegramSettings {
|
|||||||
source: string;
|
source: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface WebhookSettings {
|
||||||
|
enabled: boolean;
|
||||||
|
configured: boolean;
|
||||||
|
url_hint: string | null;
|
||||||
|
secret_header: string | null;
|
||||||
|
secret_hint: string | null;
|
||||||
|
min_severity: string;
|
||||||
|
source: string;
|
||||||
|
}
|
||||||
|
|
||||||
const loading = ref(true);
|
const loading = ref(true);
|
||||||
const saving = ref(false);
|
const savingTelegram = ref(false);
|
||||||
const testing = ref(false);
|
const savingWebhook = ref(false);
|
||||||
|
const testingTelegram = ref(false);
|
||||||
|
const testingWebhook = ref(false);
|
||||||
const error = ref("");
|
const error = ref("");
|
||||||
const success = ref("");
|
const success = ref("");
|
||||||
const loaded = ref<TelegramSettings | null>(null);
|
const telegramLoaded = ref<TelegramSettings | null>(null);
|
||||||
|
const webhookLoaded = ref<WebhookSettings | null>(null);
|
||||||
|
|
||||||
const form = reactive({
|
const telegramForm = reactive({
|
||||||
enabled: false,
|
enabled: false,
|
||||||
min_severity: "warning",
|
min_severity: "warning",
|
||||||
bot_token: "",
|
bot_token: "",
|
||||||
chat_id: "",
|
chat_id: "",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const webhookForm = reactive({
|
||||||
|
enabled: false,
|
||||||
|
min_severity: "warning",
|
||||||
|
url: "",
|
||||||
|
secret_header: "",
|
||||||
|
secret: "",
|
||||||
|
});
|
||||||
|
|
||||||
const tokenPlaceholder = computed(() =>
|
const tokenPlaceholder = computed(() =>
|
||||||
loaded.value?.bot_token_hint ? `оставить ${loaded.value.bot_token_hint}` : "обязателен при первой настройке",
|
telegramLoaded.value?.bot_token_hint
|
||||||
|
? `оставить ${telegramLoaded.value.bot_token_hint}`
|
||||||
|
: "обязателен при первой настройке",
|
||||||
);
|
);
|
||||||
const chatPlaceholder = computed(() =>
|
const chatPlaceholder = computed(() =>
|
||||||
loaded.value?.chat_id_hint ? `оставить ${loaded.value.chat_id_hint}` : "обязателен при первой настройке",
|
telegramLoaded.value?.chat_id_hint
|
||||||
|
? `оставить ${telegramLoaded.value.chat_id_hint}`
|
||||||
|
: "обязателен при первой настройке",
|
||||||
|
);
|
||||||
|
const urlPlaceholder = computed(() =>
|
||||||
|
webhookLoaded.value?.url_hint ? `оставить ${webhookLoaded.value.url_hint}` : "https://…",
|
||||||
|
);
|
||||||
|
const webhookSecretPlaceholder = computed(() =>
|
||||||
|
webhookLoaded.value?.secret_hint ? `оставить ${webhookLoaded.value.secret_hint}` : "необязательно",
|
||||||
);
|
);
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
error.value = "";
|
error.value = "";
|
||||||
try {
|
try {
|
||||||
const data = await apiFetch<{ telegram: TelegramSettings }>("/api/v1/settings/notifications");
|
const data = await apiFetch<{ telegram: TelegramSettings; webhook: WebhookSettings }>(
|
||||||
loaded.value = data.telegram;
|
"/api/v1/settings/notifications",
|
||||||
form.enabled = data.telegram.enabled;
|
);
|
||||||
form.min_severity = data.telegram.min_severity;
|
telegramLoaded.value = data.telegram;
|
||||||
form.bot_token = "";
|
webhookLoaded.value = data.webhook;
|
||||||
form.chat_id = "";
|
telegramForm.enabled = data.telegram.enabled;
|
||||||
|
telegramForm.min_severity = data.telegram.min_severity;
|
||||||
|
telegramForm.bot_token = "";
|
||||||
|
telegramForm.chat_id = "";
|
||||||
|
webhookForm.enabled = data.webhook.enabled;
|
||||||
|
webhookForm.min_severity = data.webhook.min_severity;
|
||||||
|
webhookForm.url = "";
|
||||||
|
webhookForm.secret_header = data.webhook.secret_header ?? "";
|
||||||
|
webhookForm.secret = "";
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = e instanceof Error ? e.message : "Ошибка загрузки";
|
error.value = e instanceof Error ? e.message : "Ошибка загрузки";
|
||||||
} finally {
|
} finally {
|
||||||
@@ -116,35 +211,61 @@ async function load() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function save() {
|
async function saveTelegram() {
|
||||||
saving.value = true;
|
savingTelegram.value = true;
|
||||||
error.value = "";
|
error.value = "";
|
||||||
success.value = "";
|
success.value = "";
|
||||||
try {
|
try {
|
||||||
const body: Record<string, unknown> = {
|
const body: Record<string, unknown> = {
|
||||||
enabled: form.enabled,
|
enabled: telegramForm.enabled,
|
||||||
min_severity: form.min_severity,
|
min_severity: telegramForm.min_severity,
|
||||||
};
|
};
|
||||||
if (form.bot_token.trim()) body.bot_token = form.bot_token.trim();
|
if (telegramForm.bot_token.trim()) body.bot_token = telegramForm.bot_token.trim();
|
||||||
if (form.chat_id.trim()) body.chat_id = form.chat_id.trim();
|
if (telegramForm.chat_id.trim()) body.chat_id = telegramForm.chat_id.trim();
|
||||||
|
|
||||||
const updated = await apiFetch<TelegramSettings>("/api/v1/settings/notifications/telegram", {
|
telegramLoaded.value = await apiFetch<TelegramSettings>("/api/v1/settings/notifications/telegram", {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
});
|
});
|
||||||
loaded.value = updated;
|
telegramForm.bot_token = "";
|
||||||
form.bot_token = "";
|
telegramForm.chat_id = "";
|
||||||
form.chat_id = "";
|
|
||||||
success.value = "Настройки Telegram сохранены.";
|
success.value = "Настройки Telegram сохранены.";
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = e instanceof Error ? e.message : "Ошибка сохранения";
|
error.value = e instanceof Error ? e.message : "Ошибка сохранения";
|
||||||
} finally {
|
} finally {
|
||||||
saving.value = false;
|
savingTelegram.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveWebhook() {
|
||||||
|
savingWebhook.value = true;
|
||||||
|
error.value = "";
|
||||||
|
success.value = "";
|
||||||
|
try {
|
||||||
|
const body: Record<string, unknown> = {
|
||||||
|
enabled: webhookForm.enabled,
|
||||||
|
min_severity: webhookForm.min_severity,
|
||||||
|
secret_header: webhookForm.secret_header.trim() || null,
|
||||||
|
};
|
||||||
|
if (webhookForm.url.trim()) body.url = webhookForm.url.trim();
|
||||||
|
if (webhookForm.secret.trim()) body.secret = webhookForm.secret.trim();
|
||||||
|
|
||||||
|
webhookLoaded.value = await apiFetch<WebhookSettings>("/api/v1/settings/notifications/webhook", {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
webhookForm.url = "";
|
||||||
|
webhookForm.secret = "";
|
||||||
|
success.value = "Настройки webhook сохранены.";
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e instanceof Error ? e.message : "Ошибка сохранения";
|
||||||
|
} finally {
|
||||||
|
savingWebhook.value = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function testTelegram() {
|
async function testTelegram() {
|
||||||
testing.value = true;
|
testingTelegram.value = true;
|
||||||
error.value = "";
|
error.value = "";
|
||||||
success.value = "";
|
success.value = "";
|
||||||
try {
|
try {
|
||||||
@@ -155,7 +276,23 @@ async function testTelegram() {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = e instanceof Error ? e.message : "Ошибка отправки";
|
error.value = e instanceof Error ? e.message : "Ошибка отправки";
|
||||||
} finally {
|
} finally {
|
||||||
testing.value = false;
|
testingTelegram.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function testWebhook() {
|
||||||
|
testingWebhook.value = true;
|
||||||
|
error.value = "";
|
||||||
|
success.value = "";
|
||||||
|
try {
|
||||||
|
const res = await apiFetch<{ message: string }>("/api/v1/settings/notifications/webhook/test", {
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
success.value = res.message;
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e instanceof Error ? e.message : "Ошибка отправки";
|
||||||
|
} finally {
|
||||||
|
testingWebhook.value = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -172,6 +309,10 @@ onMounted(load);
|
|||||||
margin-bottom: 1.25rem;
|
margin-bottom: 1.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.settings-card {
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
.settings-card h2 {
|
.settings-card h2 {
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
}
|
}
|
||||||
@@ -191,8 +332,9 @@ onMounted(load);
|
|||||||
|
|
||||||
.settings-field input[type="text"],
|
.settings-field input[type="text"],
|
||||||
.settings-field input[type="password"],
|
.settings-field input[type="password"],
|
||||||
|
.settings-field input[type="url"],
|
||||||
.settings-field select {
|
.settings-field select {
|
||||||
max-width: 24rem;
|
max-width: 28rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-meta {
|
.settings-meta {
|
||||||
|
|||||||
Reference in New Issue
Block a user