fix: test push errors, skip heartbeat notify, stale 5h (0.9.2)

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
PTah
2026-06-10 17:25:16 +10:00
parent 5e9f0f8110
commit f9baf96d88
7 changed files with 69 additions and 7 deletions
+1 -1
View File
@@ -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
+21 -3
View File
@@ -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,
)
+4
View File
@@ -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
+1 -1
View File
@@ -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}"
+32
View File
@@ -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",
+2 -1
View File
@@ -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
+8 -1
View File
@@ -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" }}
</button>
<button
v-if="device.is_active"
@@ -506,6 +507,7 @@ const creatingCode = ref(false);
const testingTelegram = ref(false);
const testingWebhook = ref(false);
const testingEmail = ref(false);
const testingPushDeviceId = ref<number | null>(null);
const error = ref("");
const success = ref("");
const telegramLoaded = ref<TelegramSettings | null>(null);
@@ -782,11 +784,16 @@ async function revokeDevice(deviceId: number) {
async function testDevicePush(deviceId: number) {
error.value = "";
success.value = "";
testingPushDeviceId.value = deviceId;
try {
const res = await testMobileDevicePush(deviceId);
success.value = res.message;
window.scrollTo({ top: 0, behavior: "smooth" });
} catch (e) {
error.value = e instanceof Error ? e.message : "Ошибка push";
window.scrollTo({ top: 0, behavior: "smooth" });
} finally {
testingPushDeviceId.value = null;
}
}