diff --git a/backend/app/config.py b/backend/app/config.py index c8d83be..67b9ee6 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -83,7 +83,7 @@ class Settings(BaseSettings): sac_daily_report_require_activity: bool = True # Порог «живости» агента - sac_heartbeat_stale_minutes: int = 780 + sac_heartbeat_stale_minutes: int = 300 # Периодическое сканирование stale heartbeat (proactive host_silence) sac_host_silence_scan_enabled: bool = True diff --git a/backend/app/services/mobile_notify.py b/backend/app/services/mobile_notify.py index 73ac3db..de3b57b 100644 --- a/backend/app/services/mobile_notify.py +++ b/backend/app/services/mobile_notify.py @@ -98,11 +98,22 @@ def _active_device_tokens(db: Session | None) -> list[str]: return tokens -def _send_fcm_data(tokens: list[str], data: dict[str, str], *, title: str, body: str) -> None: +def _send_fcm_data( + tokens: list[str], + data: dict[str, str], + *, + title: str, + body: str, + require_success: bool = False, +) -> None: if not tokens: + if require_success: + raise MobileSendError("Нет FCM-токенов для отправки") return cfg = get_effective_fcm_config() if not cfg.active: + if require_success: + raise MobileNotConfiguredError("FCM не настроен на сервере") return access_token = _fcm_access_token(cfg.service_account_path) @@ -111,6 +122,7 @@ def _send_fcm_data(tokens: list[str], data: dict[str, str], *, title: str, body: "Authorization": f"Bearer {access_token}", "Content-Type": "application/json", } + errors: list[str] = [] with httpx.Client(timeout=12.0) as client: for token in tokens: @@ -128,8 +140,13 @@ def _send_fcm_data(tokens: list[str], data: dict[str, str], *, title: str, body: except httpx.HTTPStatusError as exc: detail = exc.response.text[:200] if exc.response is not None else str(exc) logger.warning("FCM send failed for token prefix %s: %s", token[:8], detail) - except Exception: + errors.append(detail or f"HTTP {exc.response.status_code if exc.response else '?'}") + except Exception as exc: logger.exception("FCM send failed") + errors.append(str(exc)) + + if require_success and errors: + raise MobileSendError(errors[0], status_code=502) def _event_payload(event: Event) -> tuple[str, str, dict[str, str]]: @@ -200,7 +217,8 @@ def send_test_push(*, db: Session, device_id: int) -> None: _send_fcm_data( [token], - {"kind": "test", "id": "0"}, + {"kind": "test", "id": "0", "severity": "info"}, title="SAC: тестовое push", body="Канал Seaca (FCM) работает.", + require_success=True, ) diff --git a/backend/app/services/notify_dispatch.py b/backend/app/services/notify_dispatch.py index 2890be1..9ca64b0 100644 --- a/backend/app/services/notify_dispatch.py +++ b/backend/app/services/notify_dispatch.py @@ -8,6 +8,7 @@ from app.models import Event, Problem from app.services import email_notify, mobile_notify, telegram_notify, webhook_notify from app.services.notification_cooldown import should_notify_event, should_notify_problem from app.services.notification_policy import get_effective_notification_policy +from app.services.host_health import HEARTBEAT_TYPE from app.services.notification_severity import severity_meets_minimum logger = logging.getLogger(__name__) @@ -51,6 +52,9 @@ def _dispatch_problem_channels(problem: Problem, event: Event | None, *, db: Ses def notify_event(event: Event, *, db: Session | None = None) -> None: + # Heartbeat — только для UI/статуса хоста, не для Telegram/email/push. + if event.type == HEARTBEAT_TYPE: + return policy = get_effective_notification_policy(db) if not severity_meets_minimum(event.severity, policy.min_severity): return diff --git a/backend/app/version.py b/backend/app/version.py index f9ceaf5..84872d1 100644 --- a/backend/app/version.py +++ b/backend/app/version.py @@ -1,5 +1,5 @@ """Единый источник версии SAC (API, health, логи, OpenAPI).""" APP_NAME = "Security Alert Center" -APP_VERSION = "0.9.1" +APP_VERSION = "0.9.2" APP_VERSION_LABEL = f"{APP_NAME} v.{APP_VERSION}" diff --git a/backend/tests/test_notify_dispatch.py b/backend/tests/test_notify_dispatch.py index b94de70..7c0d5f4 100644 --- a/backend/tests/test_notify_dispatch.py +++ b/backend/tests/test_notify_dispatch.py @@ -64,6 +64,38 @@ def test_notify_event_calls_selected_channels(): mock_em.notify_event.assert_not_called() +def test_notify_event_skips_agent_heartbeat(): + event = Event( + event_id="00000000-0000-4000-8000-000000000404", + host_id=1, + occurred_at=datetime(2026, 5, 29, 15, 0, tzinfo=timezone.utc), + category="agent", + type="agent.heartbeat", + severity="info", + title="heartbeat", + summary="skip", + payload={}, + ) + policy = NotificationPolicyConfig( + min_severity="info", + use_telegram=True, + use_webhook=True, + use_email=True, + use_mobile=True, + source="db", + ) + with patch.object(notify_dispatch, "get_effective_notification_policy", return_value=policy): + with patch.object(notify_dispatch, "telegram_notify") as mock_tg: + with patch.object(notify_dispatch, "webhook_notify") as mock_wh: + with patch.object(notify_dispatch, "email_notify") as mock_em: + with patch.object(notify_dispatch, "mobile_notify") as mock_mob: + notify_dispatch.notify_event(event) + mock_tg.notify_event.assert_not_called() + mock_wh.notify_event.assert_not_called() + mock_em.notify_event.assert_not_called() + mock_mob.notify_event.assert_not_called() + + def test_notify_event_skipped_by_cooldown(): event = Event( event_id="00000000-0000-4000-8000-000000000403", diff --git a/deploy/env.native.example b/deploy/env.native.example index 817400c..644fa12 100644 --- a/deploy/env.native.example +++ b/deploy/env.native.example @@ -61,7 +61,8 @@ SAC_DAILY_REPORT_SKIP_IF_AGENT_SENT=true SAC_DAILY_REPORT_REQUIRE_ACTIVITY=true # Статус хоста по agent.heartbeat -SAC_HEARTBEAT_STALE_MINUTES=780 +# Порог stale для agent.heartbeat (мин). При интервале агента ~4 ч — 300 (5 ч) даёт запас. +SAC_HEARTBEAT_STALE_MINUTES=300 # Проактивный алерт host_silence (без ожидания нового события с хоста) SAC_HOST_SILENCE_SCAN_ENABLED=true diff --git a/frontend/src/views/SettingsView.vue b/frontend/src/views/SettingsView.vue index ba1ad68..edf9012 100644 --- a/frontend/src/views/SettingsView.vue +++ b/frontend/src/views/SettingsView.vue @@ -206,9 +206,10 @@ v-if="device.is_active && device.has_fcm_token" type="button" class="secondary" + :disabled="testingPushDeviceId === device.id" @click="testDevicePush(device.id)" > - Тест push + {{ testingPushDeviceId === device.id ? "Отправка…" : "Тест push" }}