feat: Telegram settings in DB with UI edit and test send

Store notification_channels (migration 004), effective config DB over env,
PUT/test API endpoints, Settings form, and pass db session into ingest notify.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-29 16:05:32 +10:00
parent e0ba384705
commit 79a78dc7c9
12 changed files with 564 additions and 116 deletions
@@ -0,0 +1,37 @@
"""notification_channels for UI-managed Telegram settings
Revision ID: 004
Revises: 003
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "004"
down_revision: Union[str, None] = "003"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"notification_channels",
sa.Column("channel", sa.String(length=32), nullable=False),
sa.Column("enabled", sa.Boolean(), nullable=False, server_default=sa.text("false")),
sa.Column("min_severity", sa.String(length=16), nullable=False, server_default="warning"),
sa.Column("bot_token", sa.Text(), nullable=True),
sa.Column("chat_id", sa.String(length=64), nullable=True),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.PrimaryKeyConstraint("channel"),
)
def downgrade() -> None:
op.drop_table("notification_channels")
+2 -2
View File
@@ -64,9 +64,9 @@ def post_event(
problem_created = False problem_created = False
if created: if created:
problem, problem_created = maybe_create_problem(db, event) problem, problem_created = maybe_create_problem(db, event)
notify_event(event) notify_event(event, db=db)
if problem is not None and problem_created: if problem is not None and problem_created:
notify_problem(problem, event) notify_problem(problem, event, db=db)
logger.info("ingest created event_id=%s type=%s host_id=%s", event.event_id, event.type, event.host_id) logger.info("ingest created event_id=%s type=%s host_id=%s", event.event_id, event.type, event.host_id)
else: else:
logger.info("ingest duplicate event_id=%s", event.event_id) logger.info("ingest duplicate event_id=%s", event.event_id)
+73 -16
View File
@@ -1,8 +1,16 @@
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from app.auth.jwt_auth import get_current_user from app.auth.jwt_auth import get_current_user
from app.config import get_settings from app.database import get_db
from app.services.notification_settings import (
VALID_SEVERITIES,
TelegramConfig,
get_effective_telegram_config,
upsert_telegram_channel,
)
from app.services.telegram_notify import TelegramNotConfiguredError, TelegramSendError, send_telegram_test_message
router = APIRouter(prefix="/settings", tags=["settings"]) router = APIRouter(prefix="/settings", tags=["settings"])
@@ -22,28 +30,77 @@ class TelegramSettingsResponse(BaseModel):
chat_id_hint: str | None = None chat_id_hint: str | None = None
bot_token_hint: str | None = None bot_token_hint: str | None = None
min_severity: str min_severity: str
source: str = Field(default="env", description="env until UI persistence (notif-12)") source: str = Field(description="env или db")
class NotificationSettingsResponse(BaseModel): class NotificationSettingsResponse(BaseModel):
telegram: TelegramSettingsResponse telegram: TelegramSettingsResponse
class TelegramSettingsUpdate(BaseModel):
enabled: bool
min_severity: str
bot_token: str | None = None
chat_id: str | None = None
class TelegramTestResponse(BaseModel):
ok: bool = True
message: str = "Тестовое сообщение отправлено в Telegram"
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,
)
@router.get("/notifications", response_model=NotificationSettingsResponse) @router.get("/notifications", response_model=NotificationSettingsResponse)
def get_notification_settings( def get_notification_settings(
db: Session = Depends(get_db),
_user: str = Depends(get_current_user), _user: str = Depends(get_current_user),
) -> NotificationSettingsResponse: ) -> NotificationSettingsResponse:
settings = get_settings() cfg = get_effective_telegram_config(db)
token = settings.telegram_bot_token.strip() return NotificationSettingsResponse(telegram=_telegram_to_response(cfg))
chat_id = settings.telegram_chat_id.strip()
configured = bool(token and chat_id)
return NotificationSettingsResponse( @router.put("/notifications/telegram", response_model=TelegramSettingsResponse)
telegram=TelegramSettingsResponse( def update_telegram_settings(
enabled=bool(settings.telegram_enabled and configured), body: TelegramSettingsUpdate,
configured=configured, db: Session = Depends(get_db),
chat_id_hint=_mask_secret(chat_id), _user: str = Depends(get_current_user),
bot_token_hint=_mask_secret(token), ) -> TelegramSettingsResponse:
min_severity=settings.telegram_min_severity, if body.min_severity not in VALID_SEVERITIES:
source="env", raise HTTPException(status_code=422, detail=f"min_severity must be one of: {sorted(VALID_SEVERITIES)}")
try:
cfg = upsert_telegram_channel(
db,
enabled=body.enabled,
min_severity=body.min_severity,
bot_token=body.bot_token,
chat_id=body.chat_id,
) )
) except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
return _telegram_to_response(cfg)
@router.post("/notifications/telegram/test", response_model=TelegramTestResponse)
def test_telegram_settings(
db: Session = Depends(get_db),
_user: str = Depends(get_current_user),
) -> TelegramTestResponse:
cfg = get_effective_telegram_config(db)
try:
send_telegram_test_message(config=cfg)
except TelegramNotConfiguredError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except TelegramSendError as exc:
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
return TelegramTestResponse()
+2 -1
View File
@@ -1,6 +1,7 @@
from app.models.api_key import ApiKey from app.models.api_key import ApiKey
from app.models.event import Event from app.models.event import Event
from app.models.host import Host from app.models.host import Host
from app.models.notification_channel import NotificationChannel
from app.models.problem import Problem, ProblemEvent from app.models.problem import Problem, ProblemEvent
__all__ = ["ApiKey", "Event", "Host", "Problem", "ProblemEvent"] __all__ = ["ApiKey", "Event", "Host", "NotificationChannel", "Problem", "ProblemEvent"]
@@ -0,0 +1,19 @@
from datetime import datetime
from sqlalchemy import DateTime, String, Text, func
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
class NotificationChannel(Base):
__tablename__ = "notification_channels"
channel: Mapped[str] = mapped_column(String(32), primary_key=True)
enabled: Mapped[bool] = mapped_column(default=False)
min_severity: Mapped[str] = mapped_column(String(16), default="warning")
bot_token: Mapped[str | None] = mapped_column(Text, nullable=True)
chat_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
@@ -0,0 +1,109 @@
"""Effective notification channel config (DB overrides env)."""
from __future__ import annotations
from dataclasses import dataclass
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.config import get_settings
from app.models.notification_channel import NotificationChannel
VALID_SEVERITIES = frozenset({"info", "warning", "high", "critical"})
CHANNEL_TELEGRAM = "telegram"
@dataclass(frozen=True)
class TelegramConfig:
enabled: bool
bot_token: str
chat_id: str
min_severity: str
source: str # env | db
@property
def configured(self) -> bool:
return bool(self.bot_token.strip() and self.chat_id.strip())
@property
def active(self) -> bool:
return self.enabled and self.configured
def _telegram_from_env() -> TelegramConfig:
settings = get_settings()
return TelegramConfig(
enabled=bool(settings.telegram_enabled),
bot_token=settings.telegram_bot_token.strip(),
chat_id=settings.telegram_chat_id.strip(),
min_severity=settings.telegram_min_severity.strip() or "high",
source="env",
)
def get_effective_telegram_config(db: Session | None = None) -> TelegramConfig:
if db is None:
from app.database import SessionLocal
session = SessionLocal()
try:
return get_effective_telegram_config(session)
finally:
session.close()
row = db.scalar(select(NotificationChannel).where(NotificationChannel.channel == CHANNEL_TELEGRAM))
env_cfg = _telegram_from_env()
if row is None:
return env_cfg
token = (row.bot_token or "").strip() or env_cfg.bot_token
chat_id = (row.chat_id or "").strip() or env_cfg.chat_id
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 TelegramConfig(
enabled=bool(row.enabled),
bot_token=token,
chat_id=chat_id,
min_severity=min_sev,
source="db",
)
def upsert_telegram_channel(
db: Session,
*,
enabled: bool | None = None,
bot_token: str | None = None,
chat_id: str | None = None,
min_severity: str | None = None,
) -> TelegramConfig:
row = db.scalar(select(NotificationChannel).where(NotificationChannel.channel == CHANNEL_TELEGRAM))
env_cfg = _telegram_from_env()
if row is None:
row = NotificationChannel(
channel=CHANNEL_TELEGRAM,
enabled=env_cfg.enabled,
bot_token=env_cfg.bot_token or None,
chat_id=env_cfg.chat_id or None,
min_severity=env_cfg.min_severity,
)
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 bot_token is not None and bot_token.strip():
row.bot_token = bot_token.strip()
if chat_id is not None and chat_id.strip():
row.chat_id = chat_id.strip()
db.commit()
db.refresh(row)
return get_effective_telegram_config(db)
+56 -25
View File
@@ -1,50 +1,74 @@
import logging import logging
import httpx import httpx
from sqlalchemy.orm import Session
from app.config import get_settings
from app.models import Event, Problem from app.models import Event, Problem
from app.services.notification_settings import TelegramConfig, get_effective_telegram_config
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
SEVERITY_ORDER = {"info": 10, "warning": 20, "high": 30, "critical": 40} SEVERITY_ORDER = {"info": 10, "warning": 20, "high": 30, "critical": 40}
class TelegramNotConfiguredError(Exception):
"""Telegram disabled or missing token/chat_id."""
class TelegramSendError(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: def _severity_value(value: str) -> int:
return SEVERITY_ORDER.get(value, 0) return SEVERITY_ORDER.get(value, 0)
def _is_enabled() -> bool: def send_telegram_text(message: str, *, config: TelegramConfig | None = None, force: bool = False) -> None:
settings = get_settings() cfg = config or get_effective_telegram_config()
return bool( if not force and not cfg.active:
settings.telegram_enabled
and settings.telegram_bot_token.strip()
and settings.telegram_chat_id.strip()
)
def _send_text(message: str) -> None:
settings = get_settings()
if not _is_enabled():
return return
url = f"https://api.telegram.org/bot{settings.telegram_bot_token}/sendMessage" if not cfg.bot_token or not cfg.chat_id:
payload = {"chat_id": settings.telegram_chat_id, "text": message, "disable_web_page_preview": True} if force:
raise TelegramNotConfiguredError("Telegram: не задан bot token или chat_id")
return
url = f"https://api.telegram.org/bot{cfg.bot_token}/sendMessage"
payload = {"chat_id": cfg.chat_id, "text": message, "disable_web_page_preview": True}
try: try:
with httpx.Client(timeout=8.0) as client: with httpx.Client(timeout=8.0) as client:
response = client.post(url, json=payload) response = client.post(url, json=payload)
response.raise_for_status() response.raise_for_status()
except Exception: except httpx.HTTPStatusError as exc:
detail = exc.response.text[:200] if exc.response is not None else str(exc)
raise TelegramSendError(f"Telegram API HTTP {exc.response.status_code}: {detail}", status_code=exc.response.status_code) from exc
except Exception as exc:
logger.exception("telegram send failed") logger.exception("telegram send failed")
raise TelegramSendError(str(exc)) from exc
def _should_notify_severity(severity: str) -> bool: def send_telegram_test_message(*, config: TelegramConfig | None = None) -> None:
settings = get_settings() cfg = config or get_effective_telegram_config()
min_level = _severity_value(settings.telegram_min_severity) if not cfg.enabled:
raise TelegramNotConfiguredError("Telegram отключён (enabled=false)")
if not cfg.configured:
raise TelegramNotConfiguredError("Не задан bot token или chat_id")
send_telegram_text(
"✅ SAC: тестовое сообщение\nКанал Telegram для оповещений работает.",
config=cfg,
force=True,
)
def _should_notify_severity(severity: str, *, config: TelegramConfig) -> bool:
min_level = _severity_value(config.min_severity)
return _severity_value(severity) >= min_level return _severity_value(severity) >= min_level
def notify_event(event: Event) -> None: def notify_event(event: Event, *, db: Session | None = None) -> None:
if not _should_notify_severity(event.severity): cfg = get_effective_telegram_config(db)
if not cfg.active or not _should_notify_severity(event.severity, config=cfg):
return return
host = event.host.hostname if event.host else "unknown" host = event.host.hostname if event.host else "unknown"
message = ( message = (
@@ -55,11 +79,15 @@ def notify_event(event: Event) -> None:
f"Title: {event.title}\n" f"Title: {event.title}\n"
f"Summary: {event.summary}" f"Summary: {event.summary}"
) )
_send_text(message) try:
send_telegram_text(message, config=cfg)
except TelegramSendError:
logger.exception("notify_event telegram failed event_id=%s", event.event_id)
def notify_problem(problem: Problem, event: Event | None = None) -> None: def notify_problem(problem: Problem, event: Event | None = None, *, db: Session | None = None) -> None:
if not _should_notify_severity(problem.severity): cfg = get_effective_telegram_config(db)
if not cfg.active or not _should_notify_severity(problem.severity, config=cfg):
return return
host = problem.host.hostname if problem.host else "unknown" host = problem.host.hostname if problem.host else "unknown"
related = "" related = ""
@@ -73,4 +101,7 @@ def notify_problem(problem: Problem, event: Event | None = None) -> None:
f"Title: {problem.title}\n" f"Title: {problem.title}\n"
f"Summary: {problem.summary}{related}" f"Summary: {problem.summary}{related}"
) )
_send_text(message) try:
send_telegram_text(message, config=cfg)
except TelegramSendError:
logger.exception("notify_problem telegram failed problem_id=%s", problem.id)
+73 -3
View File
@@ -1,6 +1,10 @@
"""GET /api/v1/settings/notifications""" """GET/PUT/POST /api/v1/settings/notifications"""
from app.services.telegram_notify import get_settings from unittest.mock import patch
from app.config import get_settings
from app.models.notification_channel import NotificationChannel
from app.services.notification_settings import get_effective_telegram_config
def test_get_notification_settings_masks_secrets(jwt_headers, client, monkeypatch): def test_get_notification_settings_masks_secrets(jwt_headers, client, monkeypatch):
@@ -18,4 +22,70 @@ 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 "1234567890" not in body["telegram"]["bot_token_hint"]
def test_put_telegram_settings_persists_db(jwt_headers, client, db_session, monkeypatch):
monkeypatch.setenv("TELEGRAM_ENABLED", "false")
monkeypatch.setenv("TELEGRAM_BOT_TOKEN", "")
monkeypatch.setenv("TELEGRAM_CHAT_ID", "")
monkeypatch.setenv("TELEGRAM_MIN_SEVERITY", "high")
get_settings.cache_clear()
response = client.put(
"/api/v1/settings/notifications/telegram",
headers=jwt_headers,
json={
"enabled": True,
"min_severity": "warning",
"bot_token": "1234567890:TESTTOKEN",
"chat_id": "999001",
},
)
assert response.status_code == 200
body = response.json()
assert body["source"] == "db"
assert body["enabled"] is True
assert body["min_severity"] == "warning"
row = db_session.get(NotificationChannel, "telegram")
assert row is not None
assert row.bot_token == "1234567890:TESTTOKEN"
assert row.chat_id == "999001"
cfg = get_effective_telegram_config(db_session)
assert cfg.source == "db"
assert cfg.min_severity == "warning"
def test_post_telegram_test_success(jwt_headers, client, db_session, monkeypatch):
monkeypatch.setenv("TELEGRAM_ENABLED", "true")
monkeypatch.setenv("TELEGRAM_BOT_TOKEN", "tok")
monkeypatch.setenv("TELEGRAM_CHAT_ID", "1")
get_settings.cache_clear()
db_session.add(
NotificationChannel(
channel="telegram",
enabled=True,
min_severity="warning",
bot_token="1234567890:ABCDEF",
chat_id="2843230",
)
)
db_session.commit()
with patch("app.api.v1.settings.send_telegram_test_message") as mock_test:
response = client.post("/api/v1/settings/notifications/telegram/test", headers=jwt_headers)
assert response.status_code == 200
assert response.json()["ok"] is True
mock_test.assert_called_once()
def test_post_telegram_test_not_configured(jwt_headers, client, monkeypatch):
monkeypatch.setenv("TELEGRAM_ENABLED", "false")
monkeypatch.setenv("TELEGRAM_BOT_TOKEN", "")
monkeypatch.setenv("TELEGRAM_CHAT_ID", "")
get_settings.cache_clear()
response = client.post("/api/v1/settings/notifications/telegram/test", headers=jwt_headers)
assert response.status_code == 400
+30 -27
View File
@@ -3,24 +3,27 @@
from datetime import datetime, timezone from datetime import datetime, timezone
from unittest.mock import patch from unittest.mock import patch
import pytest from app.models import Event, Problem
from app.models import Event, Host, Problem
from app.services import telegram_notify from app.services import telegram_notify
from app.services.notification_settings import TelegramConfig
def test_notify_event_respects_min_severity(monkeypatch): def _telegram_cfg(*, min_severity: str) -> TelegramConfig:
return TelegramConfig(
enabled=True,
bot_token="test-token",
chat_id="123",
min_severity=min_severity,
source="env",
)
def test_notify_event_respects_min_severity():
sent: list[str] = [] sent: list[str] = []
def fake_send(message: str) -> None: def fake_send(message: str, **kwargs) -> None:
sent.append(message) sent.append(message)
monkeypatch.setenv("TELEGRAM_ENABLED", "true")
monkeypatch.setenv("TELEGRAM_BOT_TOKEN", "test-token")
monkeypatch.setenv("TELEGRAM_CHAT_ID", "123")
monkeypatch.setenv("TELEGRAM_MIN_SEVERITY", "warning")
telegram_notify.get_settings.cache_clear()
event = Event( event = Event(
event_id="00000000-0000-4000-8000-000000000001", event_id="00000000-0000-4000-8000-000000000001",
host_id=1, host_id=1,
@@ -33,28 +36,25 @@ def test_notify_event_respects_min_severity(monkeypatch):
payload={}, payload={},
) )
with patch.object(telegram_notify, "_send_text", side_effect=fake_send): cfg = _telegram_cfg(min_severity="warning")
telegram_notify.notify_event(event) with patch.object(telegram_notify, "get_effective_telegram_config", return_value=cfg):
with patch.object(telegram_notify, "send_telegram_text", side_effect=fake_send):
telegram_notify.notify_event(event)
assert sent == [] assert sent == []
event.severity = "warning" event.severity = "warning"
with patch.object(telegram_notify, "_send_text", side_effect=fake_send): with patch.object(telegram_notify, "get_effective_telegram_config", return_value=cfg):
telegram_notify.notify_event(event) with patch.object(telegram_notify, "send_telegram_text", side_effect=fake_send):
telegram_notify.notify_event(event)
assert len(sent) == 1 assert len(sent) == 1
def test_notify_problem_respects_min_severity(monkeypatch): def test_notify_problem_respects_min_severity():
sent: list[str] = [] sent: list[str] = []
def fake_send(message: str) -> None: def fake_send(message: str, **kwargs) -> None:
sent.append(message) sent.append(message)
monkeypatch.setenv("TELEGRAM_ENABLED", "true")
monkeypatch.setenv("TELEGRAM_BOT_TOKEN", "test-token")
monkeypatch.setenv("TELEGRAM_CHAT_ID", "123")
monkeypatch.setenv("TELEGRAM_MIN_SEVERITY", "high")
telegram_notify.get_settings.cache_clear()
problem = Problem( problem = Problem(
title="brute", title="brute",
summary="burst", summary="burst",
@@ -63,11 +63,14 @@ def test_notify_problem_respects_min_severity(monkeypatch):
fingerprint="fp1", fingerprint="fp1",
) )
with patch.object(telegram_notify, "_send_text", side_effect=fake_send): cfg = _telegram_cfg(min_severity="high")
telegram_notify.notify_problem(problem) with patch.object(telegram_notify, "get_effective_telegram_config", return_value=cfg):
with patch.object(telegram_notify, "send_telegram_text", side_effect=fake_send):
telegram_notify.notify_problem(problem)
assert sent == [] assert sent == []
problem.severity = "high" problem.severity = "high"
with patch.object(telegram_notify, "_send_text", side_effect=fake_send): with patch.object(telegram_notify, "get_effective_telegram_config", return_value=cfg):
telegram_notify.notify_problem(problem) with patch.object(telegram_notify, "send_telegram_text", side_effect=fake_send):
telegram_notify.notify_problem(problem)
assert len(sent) == 1 assert len(sent) == 1
+1 -1
View File
@@ -68,7 +68,7 @@ TELEGRAM_MIN_SEVERITY=warning
Problems (`notify_problem`) учитывают тот же порог `TELEGRAM_MIN_SEVERITY`. Problems (`notify_problem`) учитывают тот же порог `TELEGRAM_MIN_SEVERITY`.
UI **Настройки** (`/settings`) — просмотр маскированной конфигурации; запись в env — до задачи `notif-12` (БД) или правка `sac-api.env` + restart `sac-api`. UI **Настройки** (`/settings`) — просмотр и редактирование Telegram (JWT admin): значения в БД `notification_channels` с fallback на `sac-api.env`, пока запись не создана. После деплоя выполнить `alembic upgrade head` на сервере SAC.
### 1.4. Версии и доставка обновлений ### 1.4. Версии и доставка обновлений
+4 -4
View File
@@ -11,7 +11,7 @@
| Telegram из SAC | `backend/app/services/telegram_notify.py` | Только если в `sac-api.env`: `TELEGRAM_ENABLED=true`, токен, chat_id | | Telegram из SAC | `backend/app/services/telegram_notify.py` | Только если в `sac-api.env`: `TELEGRAM_ENABLED=true`, токен, chat_id |
| Порог 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 «Настройки» | — | **Нет** — только 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 | **Не реализованы** в backend |
**Вывод на завтра:** для exclusive нужно (1) включить и настроить TG на сервере SAC **или** (2) сделать раздел **«Настройки»** в UI + хранение каналов; порог **`warning` и выше** для событий и problems. **Вывод на завтра:** для exclusive нужно (1) включить и настроить TG на сервере SAC **или** (2) сделать раздел **«Настройки»** в UI + хранение каналов; порог **`warning` и выше** для событий и problems.
@@ -35,10 +35,10 @@
| ID | Задача | Критерий | | ID | Задача | Критерий |
|----|--------|----------| |----|--------|----------|
| `notif-10` | Маршрут `/settings`, пункт в сайдбаре (только admin JWT) | ✅ страница + GET API | | `notif-10` | Маршрут `/settings`, пункт в сайдбаре (только admin JWT) | ✅ страница + GET API |
| `notif-11` | Блок **Telegram**: enabled, bot token, chat id, min severity | ✅ GET (read-only, маска); PUT — `notif-12` | | `notif-11` | Блок **Telegram**: enabled, bot token, chat id, min severity | ✅ форма + GET/PUT |
| `notif-12` | Хранение: **вариант A** — запись в БД `notification_channels` + fallback на env; **вариант B** — только env, UI read-only | Решение в комментарии к PR | | `notif-12` | Хранение: **вариант A** — запись в БД `notification_channels` + fallback на env | ✅ миграция `004`, `PUT /notifications/telegram` |
| `notif-13` | Секреты: токен не возвращать целиком в GET (маска `***…last4`) | ✅ GET settings | | `notif-13` | Секреты: токен не возвращать целиком в GET (маска `***…last4`) | ✅ GET settings |
| `notif-14` | Кнопка «Проверить Telegram» → тестовое сообщение | 200 / понятная ошибка | | `notif-14` | Кнопка «Проверить Telegram» → тестовое сообщение | `POST …/telegram/test` + UI |
### P2 — Email и webhook (после Telegram) ### P2 — Email и webhook (после Telegram)
+158 -37
View File
@@ -2,40 +2,71 @@
<div class="settings-page"> <div class="settings-page">
<h1>Настройки</h1> <h1>Настройки</h1>
<p class="settings-intro"> <p class="settings-intro">
Каналы оповещений SAC. Сейчас конфигурация читается из <code>sac-api.env</code> на сервере; Каналы оповещений SAC. Настройки Telegram сохраняются в БД (источник <code>db</code>) или берутся из
редактирование через UI в разработке (<code>notif-12</code>). <code>sac-api.env</code> (<code>env</code>), пока запись не создана.
</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-else-if="loading">Загрузка</p> <p v-else-if="loading">Загрузка</p>
<section v-else-if="data" class="card settings-card"> <section v-else class="card settings-card">
<h2>Telegram</h2> <h2>Telegram</h2>
<dl class="settings-dl">
<dt>Включён</dt> <form class="settings-form" @submit.prevent="save">
<dd>{{ data.telegram.enabled ? "да" : "нет" }}</dd> <label class="settings-field">
<dt>Настроен (token + chat)</dt> <input v-model="form.enabled" type="checkbox" />
<dd>{{ data.telegram.configured ? "да" : "нет" }}</dd> Включить Telegram
<dt>Мин. severity</dt> </label>
<dd><code>{{ data.telegram.min_severity }}</code></dd>
<dt>Bot token</dt> <label class="settings-field">
<dd>{{ data.telegram.bot_token_hint ?? "—" }}</dd> Мин. severity
<dt>Chat ID</dt> <select v-model="form.min_severity">
<dd>{{ data.telegram.chat_id_hint ?? "—" }}</dd> <option value="info">info</option>
<dt>Источник</dt> <option value="warning">warning</option>
<dd><code>{{ data.telegram.source }}</code></dd> <option value="high">high</option>
</dl> <option value="critical">critical</option>
</select>
</label>
<label class="settings-field">
Bot token
<input
v-model="form.bot_token"
type="password"
autocomplete="off"
:placeholder="tokenPlaceholder"
/>
</label>
<label class="settings-field">
Chat ID
<input v-model="form.chat_id" type="text" autocomplete="off" :placeholder="chatPlaceholder" />
</label>
<p v-if="loaded" class="settings-meta">
Источник: <code>{{ loaded.source }}</code>
· настроен: {{ loaded.configured ? "да" : "нет" }}
</p>
<div class="settings-actions">
<button type="submit" :disabled="saving">{{ saving ? "Сохранение…" : "Сохранить" }}</button>
<button type="button" class="secondary" :disabled="testing" @click="testTelegram">
{{ testing ? "Отправка" : "Проверить Telegram" }}
</button>
</div>
</form>
<p class="settings-hint"> <p class="settings-hint">
Для <strong>UseSAC=exclusive</strong> на агентах задайте Для <strong>UseSAC=exclusive</strong> рекомендуется <code>warning</code> failed login и problems, без
<code>TELEGRAM_ENABLED=true</code> и <code>TELEGRAM_MIN_SEVERITY=warning</code> в успешных RDP (<code>info</code>).
<code>sac-api.env</code>, затем перезапустите <code>sac-api</code>.
</p> </p>
</section> </section>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, ref } from "vue"; import { computed, onMounted, reactive, ref } from "vue";
import { apiFetch } from "../api"; import { apiFetch } from "../api";
interface TelegramSettings { interface TelegramSettings {
@@ -47,23 +78,88 @@ interface TelegramSettings {
source: string; source: string;
} }
interface NotificationSettings {
telegram: TelegramSettings;
}
const loading = ref(true); const loading = ref(true);
const saving = ref(false);
const testing = ref(false);
const error = ref(""); const error = ref("");
const data = ref<NotificationSettings | null>(null); const success = ref("");
const loaded = ref<TelegramSettings | null>(null);
onMounted(async () => { const form = reactive({
enabled: false,
min_severity: "warning",
bot_token: "",
chat_id: "",
});
const tokenPlaceholder = computed(() =>
loaded.value?.bot_token_hint ? `оставить ${loaded.value.bot_token_hint}` : "обязателен при первой настройке",
);
const chatPlaceholder = computed(() =>
loaded.value?.chat_id_hint ? `оставить ${loaded.value.chat_id_hint}` : "обязателен при первой настройке",
);
async function load() {
loading.value = true;
error.value = "";
try { try {
data.value = await apiFetch<NotificationSettings>("/api/v1/settings/notifications"); const data = await apiFetch<{ telegram: TelegramSettings }>("/api/v1/settings/notifications");
loaded.value = data.telegram;
form.enabled = data.telegram.enabled;
form.min_severity = data.telegram.min_severity;
form.bot_token = "";
form.chat_id = "";
} catch (e) { } catch (e) {
error.value = e instanceof Error ? e.message : "Ошибка загрузки"; error.value = e instanceof Error ? e.message : "Ошибка загрузки";
} finally { } finally {
loading.value = false; loading.value = false;
} }
}); }
async function save() {
saving.value = true;
error.value = "";
success.value = "";
try {
const body: Record<string, unknown> = {
enabled: form.enabled,
min_severity: form.min_severity,
};
if (form.bot_token.trim()) body.bot_token = form.bot_token.trim();
if (form.chat_id.trim()) body.chat_id = form.chat_id.trim();
const updated = await apiFetch<TelegramSettings>("/api/v1/settings/notifications/telegram", {
method: "PUT",
body: JSON.stringify(body),
});
loaded.value = updated;
form.bot_token = "";
form.chat_id = "";
success.value = "Настройки Telegram сохранены.";
} catch (e) {
error.value = e instanceof Error ? e.message : "Ошибка сохранения";
} finally {
saving.value = false;
}
}
async function testTelegram() {
testing.value = true;
error.value = "";
success.value = "";
try {
const res = await apiFetch<{ message: string }>("/api/v1/settings/notifications/telegram/test", {
method: "POST",
});
success.value = res.message;
} catch (e) {
error.value = e instanceof Error ? e.message : "Ошибка отправки";
} finally {
testing.value = false;
}
}
onMounted(load);
</script> </script>
<style scoped> <style scoped>
@@ -80,19 +176,40 @@ onMounted(async () => {
margin-top: 0; margin-top: 0;
} }
.settings-dl { .settings-form {
display: grid; display: flex;
grid-template-columns: 11rem 1fr; flex-direction: column;
gap: 0.5rem 1rem; gap: 0.85rem;
margin: 0 0 1rem; margin-bottom: 1rem;
} }
.settings-dl dt { .settings-field {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.settings-field input[type="text"],
.settings-field input[type="password"],
.settings-field select {
max-width: 24rem;
}
.settings-meta {
font-size: 0.85rem;
color: #9aa4b2; color: #9aa4b2;
margin: 0;
} }
.settings-dl dd { .settings-actions {
margin: 0; display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.settings-actions button.secondary {
background: #2a3441;
color: #c5d0dc;
} }
.settings-hint { .settings-hint {
@@ -100,4 +217,8 @@ onMounted(async () => {
color: #9aa4b2; color: #9aa4b2;
margin: 0; margin: 0;
} }
.success {
color: #6dd196;
}
</style> </style>