diff --git a/backend/alembic/versions/004_notification_channels.py b/backend/alembic/versions/004_notification_channels.py new file mode 100644 index 0000000..74e462a --- /dev/null +++ b/backend/alembic/versions/004_notification_channels.py @@ -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") diff --git a/backend/app/api/v1/events.py b/backend/app/api/v1/events.py index 72b04ba..6ba4902 100644 --- a/backend/app/api/v1/events.py +++ b/backend/app/api/v1/events.py @@ -64,9 +64,9 @@ def post_event( problem_created = False if created: problem, problem_created = maybe_create_problem(db, event) - notify_event(event) + notify_event(event, db=db) 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) else: logger.info("ingest duplicate event_id=%s", event.event_id) diff --git a/backend/app/api/v1/settings.py b/backend/app/api/v1/settings.py index 45c1a5c..c38158a 100644 --- a/backend/app/api/v1/settings.py +++ b/backend/app/api/v1/settings.py @@ -1,8 +1,16 @@ -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel, Field +from sqlalchemy.orm import Session 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"]) @@ -22,28 +30,77 @@ class TelegramSettingsResponse(BaseModel): chat_id_hint: str | None = None bot_token_hint: str | None = None min_severity: str - source: str = Field(default="env", description="env until UI persistence (notif-12)") + source: str = Field(description="env или db") class NotificationSettingsResponse(BaseModel): 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) def get_notification_settings( + db: Session = Depends(get_db), _user: str = Depends(get_current_user), ) -> NotificationSettingsResponse: - settings = get_settings() - token = settings.telegram_bot_token.strip() - chat_id = settings.telegram_chat_id.strip() - configured = bool(token and chat_id) - return NotificationSettingsResponse( - telegram=TelegramSettingsResponse( - enabled=bool(settings.telegram_enabled and configured), - configured=configured, - chat_id_hint=_mask_secret(chat_id), - bot_token_hint=_mask_secret(token), - min_severity=settings.telegram_min_severity, - source="env", + cfg = get_effective_telegram_config(db) + return NotificationSettingsResponse(telegram=_telegram_to_response(cfg)) + + +@router.put("/notifications/telegram", response_model=TelegramSettingsResponse) +def update_telegram_settings( + 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)}") + 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() diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 6f04c56..3ac4841 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -1,6 +1,7 @@ from app.models.api_key import ApiKey from app.models.event import Event from app.models.host import Host +from app.models.notification_channel import NotificationChannel from app.models.problem import Problem, ProblemEvent -__all__ = ["ApiKey", "Event", "Host", "Problem", "ProblemEvent"] +__all__ = ["ApiKey", "Event", "Host", "NotificationChannel", "Problem", "ProblemEvent"] diff --git a/backend/app/models/notification_channel.py b/backend/app/models/notification_channel.py new file mode 100644 index 0000000..973f886 --- /dev/null +++ b/backend/app/models/notification_channel.py @@ -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() + ) diff --git a/backend/app/services/notification_settings.py b/backend/app/services/notification_settings.py new file mode 100644 index 0000000..a53f4c5 --- /dev/null +++ b/backend/app/services/notification_settings.py @@ -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) diff --git a/backend/app/services/telegram_notify.py b/backend/app/services/telegram_notify.py index 56acd60..63ea944 100644 --- a/backend/app/services/telegram_notify.py +++ b/backend/app/services/telegram_notify.py @@ -1,50 +1,74 @@ import logging import httpx +from sqlalchemy.orm import Session -from app.config import get_settings from app.models import Event, Problem +from app.services.notification_settings import TelegramConfig, get_effective_telegram_config logger = logging.getLogger(__name__) 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: return SEVERITY_ORDER.get(value, 0) -def _is_enabled() -> bool: - settings = get_settings() - return bool( - 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(): +def send_telegram_text(message: str, *, config: TelegramConfig | None = None, force: bool = False) -> None: + cfg = config or get_effective_telegram_config() + if not force and not cfg.active: return - url = f"https://api.telegram.org/bot{settings.telegram_bot_token}/sendMessage" - payload = {"chat_id": settings.telegram_chat_id, "text": message, "disable_web_page_preview": True} + if not cfg.bot_token or not cfg.chat_id: + 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: with httpx.Client(timeout=8.0) as client: response = client.post(url, json=payload) 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") + raise TelegramSendError(str(exc)) from exc -def _should_notify_severity(severity: str) -> bool: - settings = get_settings() - min_level = _severity_value(settings.telegram_min_severity) +def send_telegram_test_message(*, config: TelegramConfig | None = None) -> None: + cfg = config or get_effective_telegram_config() + 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 -def notify_event(event: Event) -> None: - if not _should_notify_severity(event.severity): +def notify_event(event: Event, *, db: Session | None = None) -> None: + cfg = get_effective_telegram_config(db) + if not cfg.active or not _should_notify_severity(event.severity, config=cfg): return host = event.host.hostname if event.host else "unknown" message = ( @@ -55,11 +79,15 @@ def notify_event(event: Event) -> None: f"Title: {event.title}\n" 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: - if not _should_notify_severity(problem.severity): +def notify_problem(problem: Problem, event: Event | None = None, *, db: Session | None = None) -> None: + cfg = get_effective_telegram_config(db) + if not cfg.active or not _should_notify_severity(problem.severity, config=cfg): return host = problem.host.hostname if problem.host else "unknown" related = "" @@ -73,4 +101,7 @@ def notify_problem(problem: Problem, event: Event | None = None) -> None: f"Title: {problem.title}\n" 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) diff --git a/backend/tests/test_settings_api.py b/backend/tests/test_settings_api.py index 0d9fa81..f585126 100644 --- a/backend/tests/test_settings_api.py +++ b/backend/tests/test_settings_api.py @@ -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): @@ -18,4 +22,70 @@ def test_get_notification_settings_masks_secrets(jwt_headers, client, monkeypatc assert body["telegram"]["min_severity"] == "warning" assert body["telegram"]["source"] == "env" 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 diff --git a/backend/tests/test_telegram_notify.py b/backend/tests/test_telegram_notify.py index 1bf4ff5..7c655b5 100644 --- a/backend/tests/test_telegram_notify.py +++ b/backend/tests/test_telegram_notify.py @@ -3,24 +3,27 @@ from datetime import datetime, timezone from unittest.mock import patch -import pytest - -from app.models import Event, Host, Problem +from app.models import Event, Problem 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] = [] - def fake_send(message: str) -> None: + def fake_send(message: str, **kwargs) -> None: 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_id="00000000-0000-4000-8000-000000000001", host_id=1, @@ -33,28 +36,25 @@ def test_notify_event_respects_min_severity(monkeypatch): payload={}, ) - with patch.object(telegram_notify, "_send_text", side_effect=fake_send): - telegram_notify.notify_event(event) + cfg = _telegram_cfg(min_severity="warning") + 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 == [] event.severity = "warning" - with patch.object(telegram_notify, "_send_text", side_effect=fake_send): - 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 len(sent) == 1 -def test_notify_problem_respects_min_severity(monkeypatch): +def test_notify_problem_respects_min_severity(): sent: list[str] = [] - def fake_send(message: str) -> None: + def fake_send(message: str, **kwargs) -> None: 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( title="brute", summary="burst", @@ -63,11 +63,14 @@ def test_notify_problem_respects_min_severity(monkeypatch): fingerprint="fp1", ) - with patch.object(telegram_notify, "_send_text", side_effect=fake_send): - telegram_notify.notify_problem(problem) + cfg = _telegram_cfg(min_severity="high") + 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 == [] problem.severity = "high" - with patch.object(telegram_notify, "_send_text", side_effect=fake_send): - 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 len(sent) == 1 diff --git a/docs/agent-integration.md b/docs/agent-integration.md index 3e53e56..81590b9 100644 --- a/docs/agent-integration.md +++ b/docs/agent-integration.md @@ -68,7 +68,7 @@ TELEGRAM_MIN_SEVERITY=warning 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. Версии и доставка обновлений diff --git a/docs/todo-2026-05-29.md b/docs/todo-2026-05-29.md index 348d00e..2babe12 100644 --- a/docs/todo-2026-05-29.md +++ b/docs/todo-2026-05-29.md @@ -11,7 +11,7 @@ | 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` | | Вызов при 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 | **Вывод на завтра:** для exclusive нужно (1) включить и настроить TG на сервере SAC **или** (2) сделать раздел **«Настройки»** в UI + хранение каналов; порог **`warning` и выше** для событий и problems. @@ -35,10 +35,10 @@ | ID | Задача | Критерий | |----|--------|----------| | `notif-10` | Маршрут `/settings`, пункт в сайдбаре (только admin JWT) | ✅ страница + GET API | -| `notif-11` | Блок **Telegram**: enabled, bot token, chat id, min severity | ✅ GET (read-only, маска); PUT — `notif-12` | -| `notif-12` | Хранение: **вариант A** — запись в БД `notification_channels` + fallback на env; **вариант B** — только env, UI read-only | Решение в комментарии к PR | +| `notif-11` | Блок **Telegram**: enabled, bot token, chat id, min severity | ✅ форма + GET/PUT | +| `notif-12` | Хранение: **вариант A** — запись в БД `notification_channels` + fallback на env | ✅ миграция `004`, `PUT /notifications/telegram` | | `notif-13` | Секреты: токен не возвращать целиком в GET (маска `***…last4`) | ✅ GET settings | -| `notif-14` | Кнопка «Проверить Telegram» → тестовое сообщение | 200 / понятная ошибка | +| `notif-14` | Кнопка «Проверить Telegram» → тестовое сообщение | ✅ `POST …/telegram/test` + UI | ### P2 — Email и webhook (после Telegram) diff --git a/frontend/src/views/SettingsView.vue b/frontend/src/views/SettingsView.vue index 684d3f0..cccb682 100644 --- a/frontend/src/views/SettingsView.vue +++ b/frontend/src/views/SettingsView.vue @@ -2,40 +2,71 @@
- Каналы оповещений SAC. Сейчас конфигурация читается из sac-api.env на сервере;
- редактирование через UI — в разработке (notif-12).
+ Каналы оповещений SAC. Настройки Telegram сохраняются в БД (источник db) или берутся из
+ sac-api.env (env), пока запись не создана.
{{ error }}
+{{ success }}
Загрузка…
-{{ data.telegram.min_severity }}{{ data.telegram.source }}
- Для UseSAC=exclusive на агентах задайте
- TELEGRAM_ENABLED=true и TELEGRAM_MIN_SEVERITY=warning в
- sac-api.env, затем перезапустите sac-api.
+ Для UseSAC=exclusive рекомендуется warning — failed login и problems, без
+ успешных RDP (info).