fix: test push errors, skip heartbeat notify, stale 5h (0.9.2)
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -83,7 +83,7 @@ class Settings(BaseSettings):
|
|||||||
sac_daily_report_require_activity: bool = True
|
sac_daily_report_require_activity: bool = True
|
||||||
|
|
||||||
# Порог «живости» агента
|
# Порог «живости» агента
|
||||||
sac_heartbeat_stale_minutes: int = 780
|
sac_heartbeat_stale_minutes: int = 300
|
||||||
|
|
||||||
# Периодическое сканирование stale heartbeat (proactive host_silence)
|
# Периодическое сканирование stale heartbeat (proactive host_silence)
|
||||||
sac_host_silence_scan_enabled: bool = True
|
sac_host_silence_scan_enabled: bool = True
|
||||||
|
|||||||
@@ -98,11 +98,22 @@ def _active_device_tokens(db: Session | None) -> list[str]:
|
|||||||
return tokens
|
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 not tokens:
|
||||||
|
if require_success:
|
||||||
|
raise MobileSendError("Нет FCM-токенов для отправки")
|
||||||
return
|
return
|
||||||
cfg = get_effective_fcm_config()
|
cfg = get_effective_fcm_config()
|
||||||
if not cfg.active:
|
if not cfg.active:
|
||||||
|
if require_success:
|
||||||
|
raise MobileNotConfiguredError("FCM не настроен на сервере")
|
||||||
return
|
return
|
||||||
|
|
||||||
access_token = _fcm_access_token(cfg.service_account_path)
|
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}",
|
"Authorization": f"Bearer {access_token}",
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
}
|
}
|
||||||
|
errors: list[str] = []
|
||||||
|
|
||||||
with httpx.Client(timeout=12.0) as client:
|
with httpx.Client(timeout=12.0) as client:
|
||||||
for token in tokens:
|
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:
|
except httpx.HTTPStatusError as exc:
|
||||||
detail = exc.response.text[:200] if exc.response is not None else str(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)
|
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")
|
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]]:
|
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(
|
_send_fcm_data(
|
||||||
[token],
|
[token],
|
||||||
{"kind": "test", "id": "0"},
|
{"kind": "test", "id": "0", "severity": "info"},
|
||||||
title="SAC: тестовое push",
|
title="SAC: тестовое push",
|
||||||
body="Канал Seaca (FCM) работает.",
|
body="Канал Seaca (FCM) работает.",
|
||||||
|
require_success=True,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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 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_cooldown import should_notify_event, should_notify_problem
|
||||||
from app.services.notification_policy import get_effective_notification_policy
|
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
|
from app.services.notification_severity import severity_meets_minimum
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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:
|
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)
|
policy = get_effective_notification_policy(db)
|
||||||
if not severity_meets_minimum(event.severity, policy.min_severity):
|
if not severity_meets_minimum(event.severity, policy.min_severity):
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
"""Единый источник версии SAC (API, health, логи, OpenAPI)."""
|
"""Единый источник версии SAC (API, health, логи, OpenAPI)."""
|
||||||
|
|
||||||
APP_NAME = "Security Alert Center"
|
APP_NAME = "Security Alert Center"
|
||||||
APP_VERSION = "0.9.1"
|
APP_VERSION = "0.9.2"
|
||||||
APP_VERSION_LABEL = f"{APP_NAME} v.{APP_VERSION}"
|
APP_VERSION_LABEL = f"{APP_NAME} v.{APP_VERSION}"
|
||||||
|
|||||||
@@ -64,6 +64,38 @@ def test_notify_event_calls_selected_channels():
|
|||||||
mock_em.notify_event.assert_not_called()
|
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():
|
def test_notify_event_skipped_by_cooldown():
|
||||||
event = Event(
|
event = Event(
|
||||||
event_id="00000000-0000-4000-8000-000000000403",
|
event_id="00000000-0000-4000-8000-000000000403",
|
||||||
|
|||||||
@@ -61,7 +61,8 @@ SAC_DAILY_REPORT_SKIP_IF_AGENT_SENT=true
|
|||||||
SAC_DAILY_REPORT_REQUIRE_ACTIVITY=true
|
SAC_DAILY_REPORT_REQUIRE_ACTIVITY=true
|
||||||
|
|
||||||
# Статус хоста по agent.heartbeat
|
# Статус хоста по agent.heartbeat
|
||||||
SAC_HEARTBEAT_STALE_MINUTES=780
|
# Порог stale для agent.heartbeat (мин). При интервале агента ~4 ч — 300 (5 ч) даёт запас.
|
||||||
|
SAC_HEARTBEAT_STALE_MINUTES=300
|
||||||
|
|
||||||
# Проактивный алерт host_silence (без ожидания нового события с хоста)
|
# Проактивный алерт host_silence (без ожидания нового события с хоста)
|
||||||
SAC_HOST_SILENCE_SCAN_ENABLED=true
|
SAC_HOST_SILENCE_SCAN_ENABLED=true
|
||||||
|
|||||||
@@ -206,9 +206,10 @@
|
|||||||
v-if="device.is_active && device.has_fcm_token"
|
v-if="device.is_active && device.has_fcm_token"
|
||||||
type="button"
|
type="button"
|
||||||
class="secondary"
|
class="secondary"
|
||||||
|
:disabled="testingPushDeviceId === device.id"
|
||||||
@click="testDevicePush(device.id)"
|
@click="testDevicePush(device.id)"
|
||||||
>
|
>
|
||||||
Тест push
|
{{ testingPushDeviceId === device.id ? "Отправка…" : "Тест push" }}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
v-if="device.is_active"
|
v-if="device.is_active"
|
||||||
@@ -506,6 +507,7 @@ const creatingCode = ref(false);
|
|||||||
const testingTelegram = ref(false);
|
const testingTelegram = ref(false);
|
||||||
const testingWebhook = ref(false);
|
const testingWebhook = ref(false);
|
||||||
const testingEmail = ref(false);
|
const testingEmail = ref(false);
|
||||||
|
const testingPushDeviceId = ref<number | null>(null);
|
||||||
const error = ref("");
|
const error = ref("");
|
||||||
const success = ref("");
|
const success = ref("");
|
||||||
const telegramLoaded = ref<TelegramSettings | null>(null);
|
const telegramLoaded = ref<TelegramSettings | null>(null);
|
||||||
@@ -782,11 +784,16 @@ async function revokeDevice(deviceId: number) {
|
|||||||
async function testDevicePush(deviceId: number) {
|
async function testDevicePush(deviceId: number) {
|
||||||
error.value = "";
|
error.value = "";
|
||||||
success.value = "";
|
success.value = "";
|
||||||
|
testingPushDeviceId.value = deviceId;
|
||||||
try {
|
try {
|
||||||
const res = await testMobileDevicePush(deviceId);
|
const res = await testMobileDevicePush(deviceId);
|
||||||
success.value = res.message;
|
success.value = res.message;
|
||||||
|
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = e instanceof Error ? e.message : "Ошибка push";
|
error.value = e instanceof Error ? e.message : "Ошибка push";
|
||||||
|
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||||
|
} finally {
|
||||||
|
testingPushDeviceId.value = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user