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:
@@ -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")
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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")
|
||||||
|
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)
|
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):
|
||||||
|
with patch.object(telegram_notify, "send_telegram_text", side_effect=fake_send):
|
||||||
telegram_notify.notify_event(event)
|
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")
|
||||||
|
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)
|
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):
|
||||||
|
with patch.object(telegram_notify, "send_telegram_text", side_effect=fake_send):
|
||||||
telegram_notify.notify_problem(problem)
|
telegram_notify.notify_problem(problem)
|
||||||
assert len(sent) == 1
|
assert len(sent) == 1
|
||||||
|
|||||||
@@ -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. Версии и доставка обновлений
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user