feat: SAC 0.7.0 display_name columns and event severity overrides
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,29 @@
|
|||||||
|
"""event_severity_overrides table
|
||||||
|
|
||||||
|
Revision ID: 010
|
||||||
|
Revises: 009
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "010"
|
||||||
|
down_revision: Union[str, None] = "009"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"event_severity_overrides",
|
||||||
|
sa.Column("event_type", sa.String(length=128), nullable=False),
|
||||||
|
sa.Column("severity", sa.String(length=16), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint("event_type"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("event_severity_overrides")
|
||||||
@@ -10,6 +10,7 @@ from app.config import get_settings
|
|||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
from app.models import Event, Host, Problem
|
from app.models import Event, Host, Problem
|
||||||
from app.schemas.list_models import EventSummary
|
from app.schemas.list_models import EventSummary
|
||||||
|
from app.services.event_summary import event_to_summary
|
||||||
from app.services.host_health import DAILY_REPORT_TYPES, HEARTBEAT_TYPE, count_stale_hosts
|
from app.services.host_health import DAILY_REPORT_TYPES, HEARTBEAT_TYPE, count_stale_hosts
|
||||||
|
|
||||||
router = APIRouter(prefix="/dashboards", tags=["dashboards"])
|
router = APIRouter(prefix="/dashboards", tags=["dashboards"])
|
||||||
@@ -24,27 +25,13 @@ def fetch_recent_events(db: Session, *, limit: int = 8) -> list[EventSummary]:
|
|||||||
.order_by(Event.received_at.desc())
|
.order_by(Event.received_at.desc())
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
).all()
|
).all()
|
||||||
return [
|
return [event_to_summary(e) for e in rows]
|
||||||
EventSummary(
|
|
||||||
id=e.id,
|
|
||||||
event_id=e.event_id,
|
|
||||||
host_id=e.host_id,
|
|
||||||
hostname=e.host.hostname,
|
|
||||||
occurred_at=e.occurred_at,
|
|
||||||
received_at=e.received_at,
|
|
||||||
category=e.category,
|
|
||||||
type=e.type,
|
|
||||||
severity=e.severity,
|
|
||||||
title=e.title,
|
|
||||||
summary=e.summary,
|
|
||||||
)
|
|
||||||
for e in rows
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
class TopHostItem(BaseModel):
|
class TopHostItem(BaseModel):
|
||||||
host_id: int
|
host_id: int
|
||||||
hostname: str
|
hostname: str
|
||||||
|
display_name: str | None = None
|
||||||
count: int
|
count: int
|
||||||
|
|
||||||
|
|
||||||
@@ -108,16 +95,16 @@ def dashboard_summary(
|
|||||||
)
|
)
|
||||||
|
|
||||||
top_host_rows = db.execute(
|
top_host_rows = db.execute(
|
||||||
select(Host.id, Host.hostname, func.count())
|
select(Host.id, Host.hostname, Host.display_name, func.count())
|
||||||
.select_from(Event)
|
.select_from(Event)
|
||||||
.join(Host, Event.host_id == Host.id)
|
.join(Host, Event.host_id == Host.id)
|
||||||
.where(Event.received_at >= since)
|
.where(Event.received_at >= since)
|
||||||
.group_by(Host.id, Host.hostname)
|
.group_by(Host.id, Host.hostname, Host.display_name)
|
||||||
.order_by(func.count().desc())
|
.order_by(func.count().desc())
|
||||||
.limit(10)
|
.limit(10)
|
||||||
).all()
|
).all()
|
||||||
top_hosts = [
|
top_hosts = [
|
||||||
TopHostItem(host_id=row[0], hostname=row[1], count=row[2]) for row in top_host_rows
|
TopHostItem(host_id=row[0], hostname=row[1], display_name=row[2], count=row[3]) for row in top_host_rows
|
||||||
]
|
]
|
||||||
|
|
||||||
top_type_rows = db.execute(
|
top_type_rows = db.execute(
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ from app.database import get_db
|
|||||||
from app.models import Event, Host
|
from app.models import Event, Host
|
||||||
from app.schemas.list_models import EventDetail, EventListResponse, EventSummary
|
from app.schemas.list_models import EventDetail, EventListResponse, EventSummary
|
||||||
from app.services.ingest import ingest_event
|
from app.services.ingest import ingest_event
|
||||||
|
from app.services.event_summary import event_to_summary
|
||||||
from app.services.problems import maybe_create_problem
|
from app.services.problems import maybe_create_problem
|
||||||
from app.services.schema_validate import validate_event_payload
|
from app.services.schema_validate import validate_event_payload
|
||||||
from app.services.notify_dispatch import (
|
from app.services.notify_dispatch import (
|
||||||
@@ -133,8 +134,8 @@ def list_events(
|
|||||||
count_stmt = count_stmt.where(Event.host_id == host_id)
|
count_stmt = count_stmt.where(Event.host_id == host_id)
|
||||||
if hostname:
|
if hostname:
|
||||||
like = f"%{hostname}%"
|
like = f"%{hostname}%"
|
||||||
stmt = stmt.where(Host.hostname.ilike(like))
|
stmt = stmt.where(Host.hostname.ilike(like) | Host.display_name.ilike(like))
|
||||||
count_stmt = count_stmt.where(Host.hostname.ilike(like))
|
count_stmt = count_stmt.where(Host.hostname.ilike(like) | Host.display_name.ilike(like))
|
||||||
dt_from = _parse_optional_dt(from_time)
|
dt_from = _parse_optional_dt(from_time)
|
||||||
dt_to = _parse_optional_dt(to_time)
|
dt_to = _parse_optional_dt(to_time)
|
||||||
if dt_from:
|
if dt_from:
|
||||||
@@ -155,22 +156,7 @@ def list_events(
|
|||||||
.limit(page_size)
|
.limit(page_size)
|
||||||
).all()
|
).all()
|
||||||
|
|
||||||
items = [
|
items = [event_to_summary(e) for e in rows]
|
||||||
EventSummary(
|
|
||||||
id=e.id,
|
|
||||||
event_id=e.event_id,
|
|
||||||
host_id=e.host_id,
|
|
||||||
hostname=e.host.hostname,
|
|
||||||
occurred_at=e.occurred_at,
|
|
||||||
received_at=e.received_at,
|
|
||||||
category=e.category,
|
|
||||||
type=e.type,
|
|
||||||
severity=e.severity,
|
|
||||||
title=e.title,
|
|
||||||
summary=e.summary,
|
|
||||||
)
|
|
||||||
for e in rows
|
|
||||||
]
|
|
||||||
return EventListResponse(items=items, total=total, page=page, page_size=page_size)
|
return EventListResponse(items=items, total=total, page=page, page_size=page_size)
|
||||||
|
|
||||||
|
|
||||||
@@ -190,6 +176,7 @@ def get_event(
|
|||||||
event_id=event.event_id,
|
event_id=event.event_id,
|
||||||
host_id=event.host_id,
|
host_id=event.host_id,
|
||||||
hostname=event.host.hostname,
|
hostname=event.host.hostname,
|
||||||
|
display_name=event.host.display_name,
|
||||||
occurred_at=event.occurred_at,
|
occurred_at=event.occurred_at,
|
||||||
received_at=event.received_at,
|
received_at=event.received_at,
|
||||||
category=event.category,
|
category=event.category,
|
||||||
|
|||||||
@@ -1,174 +1,351 @@
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from sqlalchemy import func, select
|
from sqlalchemy import func, select
|
||||||
|
|
||||||
from sqlalchemy.orm import Session, joinedload
|
from sqlalchemy.orm import Session, joinedload
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
from app.auth.jwt_auth import get_current_user
|
from app.auth.jwt_auth import get_current_user
|
||||||
|
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
|
|
||||||
from app.models import Event, Host, Problem, ProblemEvent
|
from app.models import Event, Host, Problem, ProblemEvent
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/problems", tags=["problems"])
|
router = APIRouter(prefix="/problems", tags=["problems"])
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class ProblemSummary(BaseModel):
|
class ProblemSummary(BaseModel):
|
||||||
|
|
||||||
id: int
|
id: int
|
||||||
|
|
||||||
host_id: int | None
|
host_id: int | None
|
||||||
|
|
||||||
hostname: str | None
|
hostname: str | None
|
||||||
|
|
||||||
|
display_name: str | None = None
|
||||||
|
|
||||||
title: str
|
title: str
|
||||||
|
|
||||||
summary: str
|
summary: str
|
||||||
|
|
||||||
severity: str
|
severity: str
|
||||||
|
|
||||||
status: str
|
status: str
|
||||||
|
|
||||||
rule_id: str | None
|
rule_id: str | None
|
||||||
|
|
||||||
fingerprint: str
|
fingerprint: str
|
||||||
|
|
||||||
event_count: int
|
event_count: int
|
||||||
|
|
||||||
last_seen_at: datetime
|
last_seen_at: datetime
|
||||||
|
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
|
||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class ProblemListResponse(BaseModel):
|
class ProblemListResponse(BaseModel):
|
||||||
|
|
||||||
items: list[ProblemSummary]
|
items: list[ProblemSummary]
|
||||||
|
|
||||||
total: int
|
total: int
|
||||||
|
|
||||||
page: int
|
page: int
|
||||||
|
|
||||||
page_size: int
|
page_size: int
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class ProblemEventItem(BaseModel):
|
class ProblemEventItem(BaseModel):
|
||||||
|
|
||||||
id: int
|
id: int
|
||||||
|
|
||||||
event_id: str
|
event_id: str
|
||||||
|
|
||||||
occurred_at: datetime
|
occurred_at: datetime
|
||||||
|
|
||||||
type: str
|
type: str
|
||||||
|
|
||||||
severity: str
|
severity: str
|
||||||
|
|
||||||
title: str
|
title: str
|
||||||
|
|
||||||
summary: str
|
summary: str
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class ProblemDetail(ProblemSummary):
|
class ProblemDetail(ProblemSummary):
|
||||||
|
|
||||||
events: list[ProblemEventItem]
|
events: list[ProblemEventItem]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def _problem_summary(p: Problem) -> ProblemSummary:
|
def _problem_summary(p: Problem) -> ProblemSummary:
|
||||||
|
|
||||||
return ProblemSummary(
|
return ProblemSummary(
|
||||||
|
|
||||||
id=p.id,
|
id=p.id,
|
||||||
|
|
||||||
host_id=p.host_id,
|
host_id=p.host_id,
|
||||||
|
|
||||||
hostname=p.host.hostname if p.host else None,
|
hostname=p.host.hostname if p.host else None,
|
||||||
|
|
||||||
|
display_name=p.host.display_name if p.host else None,
|
||||||
|
|
||||||
title=p.title,
|
title=p.title,
|
||||||
|
|
||||||
summary=p.summary,
|
summary=p.summary,
|
||||||
|
|
||||||
severity=p.severity,
|
severity=p.severity,
|
||||||
|
|
||||||
status=p.status,
|
status=p.status,
|
||||||
|
|
||||||
rule_id=p.rule_id,
|
rule_id=p.rule_id,
|
||||||
|
|
||||||
fingerprint=p.fingerprint,
|
fingerprint=p.fingerprint,
|
||||||
|
|
||||||
event_count=p.event_count,
|
event_count=p.event_count,
|
||||||
|
|
||||||
last_seen_at=p.last_seen_at,
|
last_seen_at=p.last_seen_at,
|
||||||
|
|
||||||
created_at=p.created_at,
|
created_at=p.created_at,
|
||||||
|
|
||||||
updated_at=p.updated_at,
|
updated_at=p.updated_at,
|
||||||
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=ProblemListResponse)
|
@router.get("", response_model=ProblemListResponse)
|
||||||
|
|
||||||
def list_problems(
|
def list_problems(
|
||||||
|
|
||||||
page: int = Query(1, ge=1),
|
page: int = Query(1, ge=1),
|
||||||
|
|
||||||
page_size: int = Query(50, ge=1, le=200),
|
page_size: int = Query(50, ge=1, le=200),
|
||||||
|
|
||||||
status: str | None = Query(None),
|
status: str | None = Query(None),
|
||||||
|
|
||||||
severity: str | None = Query(None),
|
severity: str | None = Query(None),
|
||||||
|
|
||||||
host_id: int | None = Query(None),
|
host_id: int | None = Query(None),
|
||||||
|
|
||||||
hostname: str | None = Query(None),
|
hostname: str | None = Query(None),
|
||||||
|
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
|
|
||||||
_user: str = Depends(get_current_user),
|
_user: str = Depends(get_current_user),
|
||||||
|
|
||||||
) -> ProblemListResponse:
|
) -> ProblemListResponse:
|
||||||
|
|
||||||
stmt = select(Problem).join(Host, isouter=True).options(joinedload(Problem.host))
|
stmt = select(Problem).join(Host, isouter=True).options(joinedload(Problem.host))
|
||||||
|
|
||||||
count_stmt = select(func.count()).select_from(Problem).join(Host, isouter=True)
|
count_stmt = select(func.count()).select_from(Problem).join(Host, isouter=True)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
if status:
|
if status:
|
||||||
|
|
||||||
stmt = stmt.where(Problem.status == status)
|
stmt = stmt.where(Problem.status == status)
|
||||||
|
|
||||||
count_stmt = count_stmt.where(Problem.status == status)
|
count_stmt = count_stmt.where(Problem.status == status)
|
||||||
|
|
||||||
if severity:
|
if severity:
|
||||||
|
|
||||||
stmt = stmt.where(Problem.severity == severity)
|
stmt = stmt.where(Problem.severity == severity)
|
||||||
|
|
||||||
count_stmt = count_stmt.where(Problem.severity == severity)
|
count_stmt = count_stmt.where(Problem.severity == severity)
|
||||||
|
|
||||||
if host_id is not None:
|
if host_id is not None:
|
||||||
|
|
||||||
stmt = stmt.where(Problem.host_id == host_id)
|
stmt = stmt.where(Problem.host_id == host_id)
|
||||||
|
|
||||||
count_stmt = count_stmt.where(Problem.host_id == host_id)
|
count_stmt = count_stmt.where(Problem.host_id == host_id)
|
||||||
|
|
||||||
if hostname:
|
if hostname:
|
||||||
|
|
||||||
like = f"%{hostname}%"
|
like = f"%{hostname}%"
|
||||||
|
|
||||||
stmt = stmt.where(Host.hostname.ilike(like) | Host.display_name.ilike(like))
|
stmt = stmt.where(Host.hostname.ilike(like) | Host.display_name.ilike(like))
|
||||||
|
|
||||||
count_stmt = count_stmt.where(Host.hostname.ilike(like) | Host.display_name.ilike(like))
|
count_stmt = count_stmt.where(Host.hostname.ilike(like) | Host.display_name.ilike(like))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
total = db.scalar(count_stmt) or 0
|
total = db.scalar(count_stmt) or 0
|
||||||
|
|
||||||
rows = db.scalars(
|
rows = db.scalars(
|
||||||
|
|
||||||
stmt.order_by(Problem.last_seen_at.desc()).offset((page - 1) * page_size).limit(page_size)
|
stmt.order_by(Problem.last_seen_at.desc()).offset((page - 1) * page_size).limit(page_size)
|
||||||
|
|
||||||
).all()
|
).all()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
items = [_problem_summary(p) for p in rows]
|
items = [_problem_summary(p) for p in rows]
|
||||||
|
|
||||||
return ProblemListResponse(items=items, total=total, page=page, page_size=page_size)
|
return ProblemListResponse(items=items, total=total, page=page, page_size=page_size)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{problem_id}", response_model=ProblemDetail)
|
@router.get("/{problem_id}", response_model=ProblemDetail)
|
||||||
|
|
||||||
def get_problem(
|
def get_problem(
|
||||||
|
|
||||||
problem_id: int,
|
problem_id: int,
|
||||||
|
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
|
|
||||||
_user: str = Depends(get_current_user),
|
_user: str = Depends(get_current_user),
|
||||||
|
|
||||||
) -> ProblemDetail:
|
) -> ProblemDetail:
|
||||||
|
|
||||||
problem = db.scalar(
|
problem = db.scalar(
|
||||||
|
|
||||||
select(Problem).where(Problem.id == problem_id).options(joinedload(Problem.host))
|
select(Problem).where(Problem.id == problem_id).options(joinedload(Problem.host))
|
||||||
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if problem is None:
|
if problem is None:
|
||||||
|
|
||||||
raise HTTPException(status_code=404, detail="Problem not found")
|
raise HTTPException(status_code=404, detail="Problem not found")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
event_rows = db.scalars(
|
event_rows = db.scalars(
|
||||||
|
|
||||||
select(Event)
|
select(Event)
|
||||||
|
|
||||||
.join(ProblemEvent, ProblemEvent.event_id == Event.id)
|
.join(ProblemEvent, ProblemEvent.event_id == Event.id)
|
||||||
|
|
||||||
.where(ProblemEvent.problem_id == problem_id)
|
.where(ProblemEvent.problem_id == problem_id)
|
||||||
|
|
||||||
.order_by(Event.occurred_at.desc())
|
.order_by(Event.occurred_at.desc())
|
||||||
|
|
||||||
).all()
|
).all()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
base = _problem_summary(problem)
|
base = _problem_summary(problem)
|
||||||
|
|
||||||
return ProblemDetail(
|
return ProblemDetail(
|
||||||
|
|
||||||
**base.model_dump(),
|
**base.model_dump(),
|
||||||
|
|
||||||
events=[
|
events=[
|
||||||
|
|
||||||
ProblemEventItem(
|
ProblemEventItem(
|
||||||
|
|
||||||
id=e.id,
|
id=e.id,
|
||||||
|
|
||||||
event_id=e.event_id,
|
event_id=e.event_id,
|
||||||
|
|
||||||
occurred_at=e.occurred_at,
|
occurred_at=e.occurred_at,
|
||||||
|
|
||||||
type=e.type,
|
type=e.type,
|
||||||
|
|
||||||
severity=e.severity,
|
severity=e.severity,
|
||||||
|
|
||||||
title=e.title,
|
title=e.title,
|
||||||
|
|
||||||
summary=e.summary,
|
summary=e.summary,
|
||||||
|
|
||||||
)
|
)
|
||||||
|
|
||||||
for e in event_rows
|
for e in event_rows
|
||||||
|
|
||||||
],
|
],
|
||||||
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{problem_id}/ack")
|
@router.post("/{problem_id}/ack")
|
||||||
|
|
||||||
def ack_problem(
|
def ack_problem(
|
||||||
|
|
||||||
problem_id: int,
|
problem_id: int,
|
||||||
|
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
|
|
||||||
_user: str = Depends(get_current_user),
|
_user: str = Depends(get_current_user),
|
||||||
|
|
||||||
) -> dict:
|
) -> dict:
|
||||||
|
|
||||||
problem = db.get(Problem, problem_id)
|
problem = db.get(Problem, problem_id)
|
||||||
|
|
||||||
if problem is None:
|
if problem is None:
|
||||||
|
|
||||||
raise HTTPException(status_code=404, detail="Problem not found")
|
raise HTTPException(status_code=404, detail="Problem not found")
|
||||||
|
|
||||||
if problem.status == "resolved":
|
if problem.status == "resolved":
|
||||||
|
|
||||||
raise HTTPException(status_code=409, detail="Problem already resolved")
|
raise HTTPException(status_code=409, detail="Problem already resolved")
|
||||||
|
|
||||||
problem.status = "acknowledged"
|
problem.status = "acknowledged"
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
return {"id": problem.id, "status": problem.status}
|
return {"id": problem.id, "status": problem.status}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{problem_id}/resolve")
|
@router.post("/{problem_id}/resolve")
|
||||||
|
|
||||||
def resolve_problem(
|
def resolve_problem(
|
||||||
|
|
||||||
problem_id: int,
|
problem_id: int,
|
||||||
|
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
|
|
||||||
_user: str = Depends(get_current_user),
|
_user: str = Depends(get_current_user),
|
||||||
|
|
||||||
) -> dict:
|
) -> dict:
|
||||||
|
|
||||||
problem = db.get(Problem, problem_id)
|
problem = db.get(Problem, problem_id)
|
||||||
|
|
||||||
if problem is None:
|
if problem is None:
|
||||||
|
|
||||||
raise HTTPException(status_code=404, detail="Problem not found")
|
raise HTTPException(status_code=404, detail="Problem not found")
|
||||||
|
|
||||||
if problem.status == "resolved":
|
if problem.status == "resolved":
|
||||||
|
|
||||||
raise HTTPException(status_code=409, detail="Problem already resolved")
|
raise HTTPException(status_code=409, detail="Problem already resolved")
|
||||||
|
|
||||||
problem.status = "resolved"
|
problem.status = "resolved"
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
return {"id": problem.id, "status": problem.status}
|
return {"id": problem.id, "status": problem.status}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,11 @@ from app.services.notification_settings import (
|
|||||||
upsert_webhook_channel,
|
upsert_webhook_channel,
|
||||||
)
|
)
|
||||||
from app.services.telegram_notify import TelegramNotConfiguredError, TelegramSendError, send_telegram_test_message
|
from app.services.telegram_notify import TelegramNotConfiguredError, TelegramSendError, send_telegram_test_message
|
||||||
from app.services.webhook_notify import WebhookNotConfiguredError, WebhookSendError, send_webhook_test_message
|
from app.services.event_severity_overrides import (
|
||||||
|
SOURCE_DB,
|
||||||
|
list_severity_override_items,
|
||||||
|
replace_severity_overrides,
|
||||||
|
)
|
||||||
|
|
||||||
router = APIRouter(prefix="/settings", tags=["settings"])
|
router = APIRouter(prefix="/settings", tags=["settings"])
|
||||||
|
|
||||||
@@ -328,3 +332,63 @@ def test_email_settings(
|
|||||||
except EmailSendError as exc:
|
except EmailSendError as exc:
|
||||||
raise HTTPException(status_code=502, detail=str(exc)) from exc
|
raise HTTPException(status_code=502, detail=str(exc)) from exc
|
||||||
return ChannelTestResponse(message="Тестовое письмо отправлено по SMTP")
|
return ChannelTestResponse(message="Тестовое письмо отправлено по SMTP")
|
||||||
|
|
||||||
|
|
||||||
|
class EventSeverityOverrideRow(BaseModel):
|
||||||
|
event_type: str
|
||||||
|
default_severity: str
|
||||||
|
override_severity: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class EventSeverityOverridesResponse(BaseModel):
|
||||||
|
items: list[EventSeverityOverrideRow]
|
||||||
|
source: str = Field(description="db — overrides хранятся в PostgreSQL")
|
||||||
|
|
||||||
|
|
||||||
|
class EventSeverityOverridesUpdate(BaseModel):
|
||||||
|
overrides: dict[str, str | None] = Field(
|
||||||
|
description="event_type → severity; null или пустая строка сбрасывает override",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/notifications/severity-overrides", response_model=EventSeverityOverridesResponse)
|
||||||
|
def get_severity_overrides(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_user=Depends(require_admin),
|
||||||
|
) -> EventSeverityOverridesResponse:
|
||||||
|
items = list_severity_override_items(db)
|
||||||
|
return EventSeverityOverridesResponse(
|
||||||
|
items=[
|
||||||
|
EventSeverityOverrideRow(
|
||||||
|
event_type=i.event_type,
|
||||||
|
default_severity=i.default_severity,
|
||||||
|
override_severity=i.override_severity,
|
||||||
|
)
|
||||||
|
for i in items
|
||||||
|
],
|
||||||
|
source=SOURCE_DB,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/notifications/severity-overrides", response_model=EventSeverityOverridesResponse)
|
||||||
|
def update_severity_overrides(
|
||||||
|
body: EventSeverityOverridesUpdate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_user=Depends(require_admin),
|
||||||
|
) -> EventSeverityOverridesResponse:
|
||||||
|
try:
|
||||||
|
items = replace_severity_overrides(db, body.overrides)
|
||||||
|
db.commit()
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||||
|
return EventSeverityOverridesResponse(
|
||||||
|
items=[
|
||||||
|
EventSeverityOverrideRow(
|
||||||
|
event_type=i.event_type,
|
||||||
|
default_severity=i.default_severity,
|
||||||
|
override_severity=i.override_severity,
|
||||||
|
)
|
||||||
|
for i in items
|
||||||
|
],
|
||||||
|
source=SOURCE_DB,
|
||||||
|
)
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
"""Known SAC event types and default severities from agents (schema v1 / agent-integration)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
DEFAULT_EVENT_SEVERITIES: dict[str, str] = {
|
||||||
|
# Agent / lifecycle
|
||||||
|
"agent.heartbeat": "info",
|
||||||
|
"agent.lifecycle": "info",
|
||||||
|
"agent.test": "info",
|
||||||
|
"agent.recovered": "info",
|
||||||
|
# SSH
|
||||||
|
"ssh.login.success": "info",
|
||||||
|
"ssh.login.failed": "warning",
|
||||||
|
"ssh.ip.banned": "high",
|
||||||
|
"ssh.ip.bruteforce.threshold": "warning",
|
||||||
|
"ssh.bruteforce.mass": "high",
|
||||||
|
"privilege.sudo.command": "warning",
|
||||||
|
"session.logind.new": "info",
|
||||||
|
"session.logind.removed": "info",
|
||||||
|
"session.logind.failed": "warning",
|
||||||
|
"report.daily.ssh": "info",
|
||||||
|
# RDP / Windows
|
||||||
|
"rdp.login.success": "info",
|
||||||
|
"rdp.login.failed": "warning",
|
||||||
|
"rdp.shadow.control.started": "warning",
|
||||||
|
"rdp.shadow.control.stopped": "info",
|
||||||
|
"rdp.shadow.control.permission": "warning",
|
||||||
|
"winrm.session.started": "warning",
|
||||||
|
"auth.explicit.credentials": "warning",
|
||||||
|
"report.daily.rdp": "info",
|
||||||
|
"rdp.ip.banned": "high",
|
||||||
|
"rdp.ip.ban": "high",
|
||||||
|
# RD Gateway
|
||||||
|
"rdg.connection.success": "info",
|
||||||
|
"rdg.connection.disconnected": "info",
|
||||||
|
"rdg.connection.failed": "warning",
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ from app.models.notification_channel import NotificationChannel
|
|||||||
from app.models.notification_cooldown import NotificationCooldown
|
from app.models.notification_cooldown import NotificationCooldown
|
||||||
from app.models.notification_policy import NotificationPolicy
|
from app.models.notification_policy import NotificationPolicy
|
||||||
from app.models.problem import Problem, ProblemEvent
|
from app.models.problem import Problem, ProblemEvent
|
||||||
|
from app.models.event_severity_override import EventSeverityOverride
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
@@ -17,4 +18,5 @@ __all__ = [
|
|||||||
"Problem",
|
"Problem",
|
||||||
"ProblemEvent",
|
"ProblemEvent",
|
||||||
"User",
|
"User",
|
||||||
|
"EventSeverityOverride",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, String, func
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class EventSeverityOverride(Base):
|
||||||
|
__tablename__ = "event_severity_overrides"
|
||||||
|
|
||||||
|
event_type: Mapped[str] = mapped_column(String(128), primary_key=True)
|
||||||
|
severity: Mapped[str] = mapped_column(String(16), nullable=False)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
server_default=func.now(),
|
||||||
|
onupdate=func.now(),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
@@ -32,6 +32,7 @@ class EventSummary(BaseModel):
|
|||||||
event_id: str
|
event_id: str
|
||||||
host_id: int
|
host_id: int
|
||||||
hostname: str
|
hostname: str
|
||||||
|
display_name: str | None = None
|
||||||
occurred_at: datetime
|
occurred_at: datetime
|
||||||
received_at: datetime
|
received_at: datetime
|
||||||
category: str
|
category: str
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
"""Per event-type severity overrides (admin Settings UI)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from sqlalchemy import delete, select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.constants.event_types import DEFAULT_EVENT_SEVERITIES
|
||||||
|
from app.models.event_severity_override import EventSeverityOverride
|
||||||
|
from app.services.notification_settings import VALID_SEVERITIES
|
||||||
|
|
||||||
|
SOURCE_DB = "db"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class EventSeverityOverrideItem:
|
||||||
|
event_type: str
|
||||||
|
default_severity: str
|
||||||
|
override_severity: str | None
|
||||||
|
|
||||||
|
|
||||||
|
def get_override_map(db: Session) -> dict[str, str]:
|
||||||
|
rows = db.scalars(select(EventSeverityOverride)).all()
|
||||||
|
return {row.event_type: row.severity for row in rows}
|
||||||
|
|
||||||
|
|
||||||
|
def apply_severity_override(payload: dict, db: Session) -> dict:
|
||||||
|
"""Return payload copy with severity replaced when override exists."""
|
||||||
|
event_type = str(payload.get("type") or "")
|
||||||
|
if not event_type:
|
||||||
|
return payload
|
||||||
|
override_map = get_override_map(db)
|
||||||
|
override = override_map.get(event_type)
|
||||||
|
if not override:
|
||||||
|
return payload
|
||||||
|
agent_severity = str(payload.get("severity") or "info")
|
||||||
|
if agent_severity == override:
|
||||||
|
return payload
|
||||||
|
out = dict(payload)
|
||||||
|
details = dict(out.get("details") or {})
|
||||||
|
details["severity_agent"] = agent_severity
|
||||||
|
out["details"] = details
|
||||||
|
out["severity"] = override
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def list_severity_override_items(db: Session) -> list[EventSeverityOverrideItem]:
|
||||||
|
override_map = get_override_map(db)
|
||||||
|
types = set(DEFAULT_EVENT_SEVERITIES) | set(override_map)
|
||||||
|
items: list[EventSeverityOverrideItem] = []
|
||||||
|
for event_type in sorted(types):
|
||||||
|
default = DEFAULT_EVENT_SEVERITIES.get(event_type, "info")
|
||||||
|
items.append(
|
||||||
|
EventSeverityOverrideItem(
|
||||||
|
event_type=event_type,
|
||||||
|
default_severity=default,
|
||||||
|
override_severity=override_map.get(event_type),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
def replace_severity_overrides(db: Session, overrides: dict[str, str | None]) -> list[EventSeverityOverrideItem]:
|
||||||
|
for event_type, severity in overrides.items():
|
||||||
|
normalized_type = event_type.strip()
|
||||||
|
if not normalized_type:
|
||||||
|
raise ValueError("event_type is required")
|
||||||
|
if severity is None or severity == "":
|
||||||
|
db.execute(
|
||||||
|
delete(EventSeverityOverride).where(EventSeverityOverride.event_type == normalized_type)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
if severity not in VALID_SEVERITIES:
|
||||||
|
raise ValueError(f"severity must be one of: {sorted(VALID_SEVERITIES)}")
|
||||||
|
row = db.get(EventSeverityOverride, normalized_type)
|
||||||
|
if row is None:
|
||||||
|
row = EventSeverityOverride(event_type=normalized_type, severity=severity)
|
||||||
|
db.add(row)
|
||||||
|
else:
|
||||||
|
row.severity = severity
|
||||||
|
db.flush()
|
||||||
|
return list_severity_override_items(db)
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
from app.models.event import Event
|
||||||
|
from app.schemas.list_models import EventSummary
|
||||||
|
|
||||||
|
|
||||||
|
def event_to_summary(event: Event) -> EventSummary:
|
||||||
|
host = event.host
|
||||||
|
return EventSummary(
|
||||||
|
id=event.id,
|
||||||
|
event_id=event.event_id,
|
||||||
|
host_id=event.host_id,
|
||||||
|
hostname=host.hostname,
|
||||||
|
display_name=host.display_name,
|
||||||
|
occurred_at=event.occurred_at,
|
||||||
|
received_at=event.received_at,
|
||||||
|
category=event.category,
|
||||||
|
type=event.type,
|
||||||
|
severity=event.severity,
|
||||||
|
title=event.title,
|
||||||
|
summary=event.summary,
|
||||||
|
)
|
||||||
@@ -6,6 +6,7 @@ from sqlalchemy.orm import Session
|
|||||||
|
|
||||||
from app.models import Event, Host
|
from app.models import Event, Host
|
||||||
from app.services.daily_report_format import normalize_daily_report_details
|
from app.services.daily_report_format import normalize_daily_report_details
|
||||||
|
from app.services.event_severity_overrides import apply_severity_override
|
||||||
|
|
||||||
DAILY_REPORT_TYPES = frozenset({"report.daily.ssh", "report.daily.rdp"})
|
DAILY_REPORT_TYPES = frozenset({"report.daily.ssh", "report.daily.rdp"})
|
||||||
|
|
||||||
@@ -75,6 +76,7 @@ def ingest_event(db: Session, payload: dict) -> tuple[Event, bool]:
|
|||||||
return existing, False
|
return existing, False
|
||||||
|
|
||||||
host = upsert_host(db, payload)
|
host = upsert_host(db, payload)
|
||||||
|
payload = apply_severity_override(payload, db)
|
||||||
details = payload.get("details")
|
details = payload.get("details")
|
||||||
if payload.get("type") in DAILY_REPORT_TYPES:
|
if payload.get("type") in DAILY_REPORT_TYPES:
|
||||||
details = normalize_daily_report_details(details, host, payload["type"])
|
details = normalize_daily_report_details(details, host, payload["type"])
|
||||||
|
|||||||
@@ -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.6.0"
|
APP_VERSION = "0.7.0"
|
||||||
APP_VERSION_LABEL = f"{APP_NAME} v.{APP_VERSION}"
|
APP_VERSION_LABEL = f"{APP_NAME} v.{APP_VERSION}"
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
"""Tests for per-event-type severity overrides."""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from app.models.event_severity_override import EventSeverityOverride
|
||||||
|
|
||||||
|
VALID_EVENT = {
|
||||||
|
"schema_version": "1.0",
|
||||||
|
"event_id": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
|
"occurred_at": "2026-05-27T10:00:00+03:00",
|
||||||
|
"source": {"product": "rdp-login-monitor", "product_version": "1.2.3-SAC"},
|
||||||
|
"host": {"hostname": "test-host", "os_family": "windows"},
|
||||||
|
"category": "auth",
|
||||||
|
"type": "rdp.login.success",
|
||||||
|
"severity": "info",
|
||||||
|
"title": "RDP login",
|
||||||
|
"summary": "pytest",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_severity_override_applied_on_ingest(client, auth_headers, jwt_headers, db_session):
|
||||||
|
db_session.add(EventSeverityOverride(event_type="rdp.login.success", severity="warning"))
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
event_id = str(uuid.uuid4())
|
||||||
|
payload = {**VALID_EVENT, "event_id": event_id}
|
||||||
|
r = client.post("/api/v1/events", json=payload, headers=auth_headers)
|
||||||
|
assert r.status_code == 201
|
||||||
|
|
||||||
|
listed = client.get("/api/v1/events", headers=jwt_headers, params={"type": "rdp.login.success"})
|
||||||
|
row = next(i for i in listed.json()["items"] if i["event_id"] == event_id)
|
||||||
|
assert row["severity"] == "warning"
|
||||||
|
|
||||||
|
|
||||||
|
def test_severity_overrides_settings_api(client, jwt_headers, db_session):
|
||||||
|
db_session.add(EventSeverityOverride(event_type="ssh.login.failed", severity="high"))
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
r = client.get("/api/v1/settings/notifications/severity-overrides", headers=jwt_headers)
|
||||||
|
assert r.status_code == 200
|
||||||
|
items = {i["event_type"]: i for i in r.json()["items"]}
|
||||||
|
assert items["ssh.login.failed"]["override_severity"] == "high"
|
||||||
|
assert items["rdp.login.success"]["default_severity"] == "info"
|
||||||
|
|
||||||
|
put = client.put(
|
||||||
|
"/api/v1/settings/notifications/severity-overrides",
|
||||||
|
headers=jwt_headers,
|
||||||
|
json={"overrides": {"rdp.login.success": "warning", "ssh.login.failed": None}},
|
||||||
|
)
|
||||||
|
assert put.status_code == 200
|
||||||
|
updated = {i["event_type"]: i for i in put.json()["items"]}
|
||||||
|
assert updated["rdp.login.success"]["override_severity"] == "warning"
|
||||||
|
assert updated["ssh.login.failed"]["override_severity"] is None
|
||||||
@@ -4,6 +4,6 @@ from app.version import APP_NAME, APP_VERSION, APP_VERSION_LABEL
|
|||||||
|
|
||||||
|
|
||||||
def test_version_constants():
|
def test_version_constants():
|
||||||
assert APP_VERSION == "0.6.0"
|
assert APP_VERSION == "0.7.0"
|
||||||
assert APP_NAME == "Security Alert Center"
|
assert APP_NAME == "Security Alert Center"
|
||||||
assert APP_VERSION_LABEL == "Security Alert Center v.0.6.0"
|
assert APP_VERSION_LABEL == "Security Alert Center v.0.7.0"
|
||||||
|
|||||||
@@ -106,4 +106,4 @@ curl -sS -X POST "$BASE/api/v1/events" \
|
|||||||
|-----------|--------|
|
|-----------|--------|
|
||||||
| ssh-monitor | 1.2.6-SAC |
|
| ssh-monitor | 1.2.6-SAC |
|
||||||
| RDP-login-monitor | 1.2.20-SAC |
|
| RDP-login-monitor | 1.2.20-SAC |
|
||||||
| security-alert-center | 0.6.0 (`backend/app/version.py`) |
|
| security-alert-center | 0.7.0 (`backend/app/version.py`) |
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "sac-ui",
|
"name": "sac-ui",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.6.0",
|
"version": "0.7.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -132,6 +132,7 @@ export interface EventSummary {
|
|||||||
event_id: string;
|
event_id: string;
|
||||||
host_id: number;
|
host_id: number;
|
||||||
hostname: string;
|
hostname: string;
|
||||||
|
display_name?: string | null;
|
||||||
occurred_at: string;
|
occurred_at: string;
|
||||||
received_at: string;
|
received_at: string;
|
||||||
category: string;
|
category: string;
|
||||||
@@ -180,6 +181,7 @@ export interface ProblemSummary {
|
|||||||
id: number;
|
id: number;
|
||||||
host_id: number | null;
|
host_id: number | null;
|
||||||
hostname: string | null;
|
hostname: string | null;
|
||||||
|
display_name?: string | null;
|
||||||
title: string;
|
title: string;
|
||||||
summary: string;
|
summary: string;
|
||||||
severity: string;
|
severity: string;
|
||||||
@@ -228,6 +230,7 @@ export async function resolveProblem(problemId: number): Promise<{ id: number; s
|
|||||||
export interface TopHostItem {
|
export interface TopHostItem {
|
||||||
host_id: number;
|
host_id: number;
|
||||||
hostname: string;
|
hostname: string;
|
||||||
|
display_name?: string | null;
|
||||||
count: number;
|
count: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
/** Display name from host card (SAC «Имя сервера»). */
|
||||||
|
export function formatServerName(displayName?: string | null): string {
|
||||||
|
const name = displayName?.trim();
|
||||||
|
return name || "—";
|
||||||
|
}
|
||||||
@@ -1,3 +1,3 @@
|
|||||||
export const APP_NAME = "Security Alert Center";
|
export const APP_NAME = "Security Alert Center";
|
||||||
export const APP_VERSION = "0.6.0";
|
export const APP_VERSION = "0.7.0";
|
||||||
export const APP_VERSION_LABEL = `${APP_NAME} v.${APP_VERSION}`;
|
export const APP_VERSION_LABEL = `${APP_NAME} v.${APP_VERSION}`;
|
||||||
|
|||||||
@@ -234,6 +234,8 @@
|
|||||||
|
|
||||||
<th>Хост</th>
|
<th>Хост</th>
|
||||||
|
|
||||||
|
<th>Имя сервера</th>
|
||||||
|
|
||||||
<th>Severity</th>
|
<th>Severity</th>
|
||||||
|
|
||||||
<th>Title</th>
|
<th>Title</th>
|
||||||
@@ -264,6 +266,8 @@
|
|||||||
|
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
|
<td>{{ formatServerName(e.display_name) }}</td>
|
||||||
|
|
||||||
<td :class="'sev-' + e.severity">{{ e.severity }}</td>
|
<td :class="'sev-' + e.severity">{{ e.severity }}</td>
|
||||||
|
|
||||||
<td>{{ e.title }}</td>
|
<td>{{ e.title }}</td>
|
||||||
@@ -293,6 +297,7 @@
|
|||||||
import { onMounted, onUnmounted, ref } from "vue";
|
import { onMounted, onUnmounted, ref } from "vue";
|
||||||
|
|
||||||
import { apiFetch, getToken, type DashboardSummary, type EventSummary } from "../api";
|
import { apiFetch, getToken, type DashboardSummary, type EventSummary } from "../api";
|
||||||
|
import { formatServerName } from "../utils/hostDisplay";
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
<option>critical</option>
|
<option>critical</option>
|
||||||
</select>
|
</select>
|
||||||
<input v-model="typeFilter" placeholder="type" @keyup.enter="load(1)" />
|
<input v-model="typeFilter" placeholder="type" @keyup.enter="load(1)" />
|
||||||
<input v-model="hostnameFilter" placeholder="hostname" @keyup.enter="load(1)" />
|
<input v-model="hostnameFilter" placeholder="hostname / имя сервера" @keyup.enter="load(1)" />
|
||||||
<button type="button" @click="load(1)">Применить</button>
|
<button type="button" @click="load(1)">Применить</button>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="error" class="error">{{ error }}</p>
|
<p v-if="error" class="error">{{ error }}</p>
|
||||||
@@ -23,6 +23,7 @@
|
|||||||
<th>ID</th>
|
<th>ID</th>
|
||||||
<th>Время</th>
|
<th>Время</th>
|
||||||
<th>Хост</th>
|
<th>Хост</th>
|
||||||
|
<th>Имя сервера</th>
|
||||||
<th>Severity</th>
|
<th>Severity</th>
|
||||||
<th>Type</th>
|
<th>Type</th>
|
||||||
<th>Title</th>
|
<th>Title</th>
|
||||||
@@ -35,6 +36,7 @@
|
|||||||
</td>
|
</td>
|
||||||
<td>{{ formatDt(e.occurred_at) }}</td>
|
<td>{{ formatDt(e.occurred_at) }}</td>
|
||||||
<td>{{ e.hostname }}</td>
|
<td>{{ e.hostname }}</td>
|
||||||
|
<td>{{ formatServerName(e.display_name) }}</td>
|
||||||
<td :class="'sev-' + e.severity">{{ e.severity }}</td>
|
<td :class="'sev-' + e.severity">{{ e.severity }}</td>
|
||||||
<td><code>{{ e.type }}</code></td>
|
<td><code>{{ e.type }}</code></td>
|
||||||
<td>{{ e.title }}</td>
|
<td>{{ e.title }}</td>
|
||||||
@@ -60,6 +62,7 @@
|
|||||||
import { onMounted, ref } from "vue";
|
import { onMounted, ref } from "vue";
|
||||||
import { useRoute } from "vue-router";
|
import { useRoute } from "vue-router";
|
||||||
import { apiFetch, type EventListResponse } from "../api";
|
import { apiFetch, type EventListResponse } from "../api";
|
||||||
|
import { formatServerName } from "../utils/hostDisplay";
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
<option>high</option>
|
<option>high</option>
|
||||||
<option>critical</option>
|
<option>critical</option>
|
||||||
</select>
|
</select>
|
||||||
<input v-model="hostnameFilter" placeholder="hostname" @keyup.enter="load(1)" />
|
<input v-model="hostnameFilter" placeholder="hostname / имя сервера" @keyup.enter="load(1)" />
|
||||||
<button type="button" @click="load(1)">Применить</button>
|
<button type="button" @click="load(1)">Применить</button>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="error" class="error">{{ error }}</p>
|
<p v-if="error" class="error">{{ error }}</p>
|
||||||
@@ -27,6 +27,7 @@
|
|||||||
<th>ID</th>
|
<th>ID</th>
|
||||||
<th>Обновлено</th>
|
<th>Обновлено</th>
|
||||||
<th>Хост</th>
|
<th>Хост</th>
|
||||||
|
<th>Имя сервера</th>
|
||||||
<th>Severity</th>
|
<th>Severity</th>
|
||||||
<th>Status</th>
|
<th>Status</th>
|
||||||
<th>Rule</th>
|
<th>Rule</th>
|
||||||
@@ -41,6 +42,7 @@
|
|||||||
</td>
|
</td>
|
||||||
<td>{{ formatDt(p.updated_at) }}</td>
|
<td>{{ formatDt(p.updated_at) }}</td>
|
||||||
<td>{{ p.hostname ?? "—" }}</td>
|
<td>{{ p.hostname ?? "—" }}</td>
|
||||||
|
<td>{{ formatServerName(p.display_name) }}</td>
|
||||||
<td :class="'sev-' + p.severity">{{ p.severity }}</td>
|
<td :class="'sev-' + p.severity">{{ p.severity }}</td>
|
||||||
<td :class="'status-' + p.status">{{ p.status }}</td>
|
<td :class="'status-' + p.status">{{ p.status }}</td>
|
||||||
<td><code>{{ p.rule_id ?? "—" }}</code></td>
|
<td><code>{{ p.rule_id ?? "—" }}</code></td>
|
||||||
@@ -88,6 +90,7 @@
|
|||||||
import { onMounted, ref, watch } from "vue";
|
import { onMounted, ref, watch } from "vue";
|
||||||
import { useRoute } from "vue-router";
|
import { useRoute } from "vue-router";
|
||||||
import { ackProblem, apiFetch, resolveProblem, type ProblemListResponse } from "../api";
|
import { ackProblem, apiFetch, resolveProblem, type ProblemListResponse } from "../api";
|
||||||
|
import { formatServerName } from "../utils/hostDisplay";
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="filters">
|
<div class="filters">
|
||||||
<input v-model="hostname" placeholder="hostname" @keyup.enter="load(1)" />
|
<input v-model="hostname" placeholder="hostname / имя сервера" @keyup.enter="load(1)" />
|
||||||
<select v-model="reportType" @change="load(1)">
|
<select v-model="reportType" @change="load(1)">
|
||||||
<option value="report.daily.ssh">SSH (report.daily.ssh)</option>
|
<option value="report.daily.ssh">SSH (report.daily.ssh)</option>
|
||||||
<option value="report.daily.rdp">RDP (report.daily.rdp)</option>
|
<option value="report.daily.rdp">RDP (report.daily.rdp)</option>
|
||||||
@@ -23,6 +23,7 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<th>Время</th>
|
<th>Время</th>
|
||||||
<th>Хост</th>
|
<th>Хост</th>
|
||||||
|
<th>Имя сервера</th>
|
||||||
<th>Тип</th>
|
<th>Тип</th>
|
||||||
<th>Сводка</th>
|
<th>Сводка</th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -40,6 +41,7 @@
|
|||||||
<td>
|
<td>
|
||||||
<strong>{{ e.hostname }}</strong>
|
<strong>{{ e.hostname }}</strong>
|
||||||
</td>
|
</td>
|
||||||
|
<td>{{ formatServerName(e.display_name) }}</td>
|
||||||
<td><code>{{ e.type }}</code></td>
|
<td><code>{{ e.type }}</code></td>
|
||||||
<td class="report-summary-cell">{{ e.summary }}</td>
|
<td class="report-summary-cell">{{ e.summary }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -64,6 +66,7 @@
|
|||||||
import { onMounted, ref, watch } from "vue";
|
import { onMounted, ref, watch } from "vue";
|
||||||
import { useRoute, useRouter } from "vue-router";
|
import { useRoute, useRouter } from "vue-router";
|
||||||
import { apiFetch, type EventListResponse } from "../api";
|
import { apiFetch, type EventListResponse } from "../api";
|
||||||
|
import { formatServerName } from "../utils/hostDisplay";
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|||||||
@@ -53,6 +53,47 @@
|
|||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="card settings-card">
|
||||||
|
<h2>Severity по типу события</h2>
|
||||||
|
<p class="settings-hint settings-hint-top">
|
||||||
|
Переопределение значимости для новых событий после ingest. Исходное severity агента сохраняется в
|
||||||
|
<code>details.severity_agent</code>. Пустое значение — как прислал агент (см. «По умолчанию»).
|
||||||
|
</p>
|
||||||
|
<div class="settings-severity-toolbar">
|
||||||
|
<input v-model="severityFilter" type="search" placeholder="Фильтр по type…" />
|
||||||
|
<button type="button" :disabled="savingSeverity" @click="saveSeverityOverrides">
|
||||||
|
{{ savingSeverity ? "Сохранение…" : "Сохранить severity" }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="settings-severity-table-wrap">
|
||||||
|
<table class="settings-severity-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Тип события</th>
|
||||||
|
<th>По умолчанию</th>
|
||||||
|
<th>Effective</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="row in filteredSeverityRows" :key="row.event_type">
|
||||||
|
<td><code>{{ row.event_type }}</code></td>
|
||||||
|
<td>{{ row.default_severity }}</td>
|
||||||
|
<td>
|
||||||
|
<select v-model="row.selected">
|
||||||
|
<option value="">по умолчанию агента</option>
|
||||||
|
<option value="info">info</option>
|
||||||
|
<option value="warning">warning</option>
|
||||||
|
<option value="high">high</option>
|
||||||
|
<option value="critical">critical</option>
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<p class="settings-meta">Записей: {{ filteredSeverityRows.length }} / {{ severityRows.length }}</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="card settings-card">
|
<section class="card settings-card">
|
||||||
<h2>Telegram</h2>
|
<h2>Telegram</h2>
|
||||||
<form class="settings-form" @submit.prevent="saveTelegram">
|
<form class="settings-form" @submit.prevent="saveTelegram">
|
||||||
@@ -250,11 +291,22 @@ interface NotificationPolicy {
|
|||||||
source: string;
|
source: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface SeverityOverrideRowApi {
|
||||||
|
event_type: string;
|
||||||
|
default_severity: string;
|
||||||
|
override_severity: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SeverityOverrideRowUi extends SeverityOverrideRowApi {
|
||||||
|
selected: string;
|
||||||
|
}
|
||||||
|
|
||||||
const loading = ref(true);
|
const loading = ref(true);
|
||||||
const savingPolicy = ref(false);
|
const savingPolicy = ref(false);
|
||||||
const savingTelegram = ref(false);
|
const savingTelegram = ref(false);
|
||||||
const savingWebhook = ref(false);
|
const savingWebhook = ref(false);
|
||||||
const savingEmail = ref(false);
|
const savingEmail = ref(false);
|
||||||
|
const savingSeverity = 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);
|
||||||
@@ -264,6 +316,8 @@ const telegramLoaded = ref<TelegramSettings | null>(null);
|
|||||||
const webhookLoaded = ref<WebhookSettings | null>(null);
|
const webhookLoaded = ref<WebhookSettings | null>(null);
|
||||||
const emailLoaded = ref<EmailSettings | null>(null);
|
const emailLoaded = ref<EmailSettings | null>(null);
|
||||||
const policyLoaded = ref<NotificationPolicy | null>(null);
|
const policyLoaded = ref<NotificationPolicy | null>(null);
|
||||||
|
const severityRows = ref<SeverityOverrideRowUi[]>([]);
|
||||||
|
const severityFilter = ref("");
|
||||||
|
|
||||||
const policyForm = reactive({
|
const policyForm = reactive({
|
||||||
min_severity: "warning",
|
min_severity: "warning",
|
||||||
@@ -323,6 +377,58 @@ const mailToPlaceholder = computed(() =>
|
|||||||
emailLoaded.value?.mail_to_hint ? `оставить ${emailLoaded.value.mail_to_hint}` : "admin@example.com",
|
emailLoaded.value?.mail_to_hint ? `оставить ${emailLoaded.value.mail_to_hint}` : "admin@example.com",
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const filteredSeverityRows = computed(() => {
|
||||||
|
const q = severityFilter.value.trim().toLowerCase();
|
||||||
|
if (!q) return severityRows.value;
|
||||||
|
return severityRows.value.filter((row) => row.event_type.toLowerCase().includes(q));
|
||||||
|
});
|
||||||
|
|
||||||
|
function mapSeverityRows(items: SeverityOverrideRowApi[]): SeverityOverrideRowUi[] {
|
||||||
|
return items.map((row) => ({
|
||||||
|
...row,
|
||||||
|
selected: row.override_severity ?? "",
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSeverityOverrides() {
|
||||||
|
const data = await apiFetch<{ items: SeverityOverrideRowApi[] }>(
|
||||||
|
"/api/v1/settings/notifications/severity-overrides",
|
||||||
|
);
|
||||||
|
severityRows.value = mapSeverityRows(data.items);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveSeverityOverrides() {
|
||||||
|
savingSeverity.value = true;
|
||||||
|
error.value = "";
|
||||||
|
success.value = "";
|
||||||
|
try {
|
||||||
|
const overrides: Record<string, string | null> = {};
|
||||||
|
for (const row of severityRows.value) {
|
||||||
|
const prev = row.override_severity ?? "";
|
||||||
|
const next = row.selected.trim();
|
||||||
|
if (next === prev) continue;
|
||||||
|
overrides[row.event_type] = next || null;
|
||||||
|
}
|
||||||
|
if (Object.keys(overrides).length === 0) {
|
||||||
|
error.value = "Нет изменений severity для сохранения";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = await apiFetch<{ items: SeverityOverrideRowApi[] }>(
|
||||||
|
"/api/v1/settings/notifications/severity-overrides",
|
||||||
|
{
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify({ overrides }),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
severityRows.value = mapSeverityRows(data.items);
|
||||||
|
success.value = "Severity по типам событий сохранён.";
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e instanceof Error ? e.message : "Ошибка сохранения severity";
|
||||||
|
} finally {
|
||||||
|
savingSeverity.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
error.value = "";
|
error.value = "";
|
||||||
@@ -357,6 +463,7 @@ async function load() {
|
|||||||
emailForm.mail_to = "";
|
emailForm.mail_to = "";
|
||||||
emailForm.smtp_starttls = data.email.smtp_starttls;
|
emailForm.smtp_starttls = data.email.smtp_starttls;
|
||||||
emailForm.smtp_ssl = data.email.smtp_ssl;
|
emailForm.smtp_ssl = data.email.smtp_ssl;
|
||||||
|
await loadSeverityOverrides();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = e instanceof Error ? e.message : "Ошибка загрузки";
|
error.value = e instanceof Error ? e.message : "Ошибка загрузки";
|
||||||
} finally {
|
} finally {
|
||||||
@@ -522,7 +629,7 @@ onMounted(load);
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.settings-page {
|
.settings-page {
|
||||||
max-width: 42rem;
|
max-width: 52rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-intro {
|
.settings-intro {
|
||||||
@@ -614,4 +721,41 @@ onMounted(load);
|
|||||||
.success {
|
.success {
|
||||||
color: #6dd196;
|
color: #6dd196;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.settings-severity-toolbar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-severity-toolbar input[type="search"] {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 12rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-severity-table-wrap {
|
||||||
|
max-height: 24rem;
|
||||||
|
overflow: auto;
|
||||||
|
border: 1px solid #2a3441;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-severity-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-severity-table th,
|
||||||
|
.settings-severity-table td {
|
||||||
|
text-align: left;
|
||||||
|
padding: 0.4rem 0.5rem;
|
||||||
|
border-bottom: 1px solid #2a3441;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-severity-table select {
|
||||||
|
min-width: 11rem;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user