feat: Problems API, ingest hook, and UI
This commit is contained in:
@@ -0,0 +1,48 @@
|
|||||||
|
"""problems table
|
||||||
|
|
||||||
|
Revision ID: 002
|
||||||
|
Revises: 001
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "002"
|
||||||
|
down_revision: Union[str, None] = "001"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"problems",
|
||||||
|
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column("host_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("title", sa.String(length=256), nullable=False),
|
||||||
|
sa.Column("summary", sa.Text(), nullable=False),
|
||||||
|
sa.Column("severity", sa.String(length=16), nullable=False),
|
||||||
|
sa.Column("status", sa.String(length=32), nullable=False, server_default="open"),
|
||||||
|
sa.Column("rule_id", sa.String(length=64), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(["host_id"], ["hosts.id"]),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_problems_status", "problems", ["status"])
|
||||||
|
op.create_index("ix_problems_severity", "problems", ["severity"])
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"problem_events",
|
||||||
|
sa.Column("problem_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("event_id", sa.Integer(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(["event_id"], ["events.id"], ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(["problem_id"], ["problems.id"], ondelete="CASCADE"),
|
||||||
|
sa.PrimaryKeyConstraint("problem_id", "event_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("problem_events")
|
||||||
|
op.drop_table("problems")
|
||||||
@@ -13,6 +13,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.problems import maybe_create_problem
|
||||||
from app.services.schema_validate import validate_event_payload
|
from app.services.schema_validate import validate_event_payload
|
||||||
|
|
||||||
router = APIRouter(prefix="/events", tags=["events"])
|
router = APIRouter(prefix="/events", tags=["events"])
|
||||||
@@ -23,6 +24,7 @@ class IngestResponse(BaseModel):
|
|||||||
event_id: str
|
event_id: str
|
||||||
created: bool
|
created: bool
|
||||||
sac_event_url: str
|
sac_event_url: str
|
||||||
|
problem_id: int | None = None
|
||||||
|
|
||||||
|
|
||||||
@router.post("", status_code=202, response_model=IngestResponse)
|
@router.post("", status_code=202, response_model=IngestResponse)
|
||||||
@@ -36,6 +38,9 @@ def post_event(
|
|||||||
raise HTTPException(status_code=422, detail={"schema_errors": errors[:20]})
|
raise HTTPException(status_code=422, detail={"schema_errors": errors[:20]})
|
||||||
|
|
||||||
event, created = ingest_event(db, payload)
|
event, created = ingest_event(db, payload)
|
||||||
|
problem = None
|
||||||
|
if created:
|
||||||
|
problem = maybe_create_problem(db, event)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
@@ -45,6 +50,7 @@ def post_event(
|
|||||||
event_id=event.event_id,
|
event_id=event.event_id,
|
||||||
created=created,
|
created=created,
|
||||||
sac_event_url=f"{base}/api/v1/events/{event.id}",
|
sac_event_url=f"{base}/api/v1/events/{event.id}",
|
||||||
|
problem_id=problem.id if problem else None,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
from sqlalchemy.orm import Session, joinedload
|
||||||
|
|
||||||
|
from app.auth.jwt_auth import get_current_user
|
||||||
|
from app.database import get_db
|
||||||
|
from app.models import Problem
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/problems", tags=["problems"])
|
||||||
|
|
||||||
|
|
||||||
|
class ProblemSummary(BaseModel):
|
||||||
|
id: int
|
||||||
|
host_id: int | None
|
||||||
|
hostname: str | None
|
||||||
|
title: str
|
||||||
|
summary: str
|
||||||
|
severity: str
|
||||||
|
status: str
|
||||||
|
rule_id: str | None
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
class ProblemListResponse(BaseModel):
|
||||||
|
items: list[ProblemSummary]
|
||||||
|
total: int
|
||||||
|
page: int
|
||||||
|
page_size: int
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=ProblemListResponse)
|
||||||
|
def list_problems(
|
||||||
|
page: int = Query(1, ge=1),
|
||||||
|
page_size: int = Query(50, ge=1, le=200),
|
||||||
|
status: str | None = Query(None),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_user: str = Depends(get_current_user),
|
||||||
|
) -> ProblemListResponse:
|
||||||
|
stmt = select(Problem).options(joinedload(Problem.host))
|
||||||
|
count_stmt = select(func.count()).select_from(Problem)
|
||||||
|
if status:
|
||||||
|
stmt = stmt.where(Problem.status == status)
|
||||||
|
count_stmt = count_stmt.where(Problem.status == status)
|
||||||
|
|
||||||
|
total = db.scalar(count_stmt) or 0
|
||||||
|
rows = db.scalars(
|
||||||
|
stmt.order_by(Problem.updated_at.desc()).offset((page - 1) * page_size).limit(page_size)
|
||||||
|
).all()
|
||||||
|
|
||||||
|
items = [
|
||||||
|
ProblemSummary(
|
||||||
|
id=p.id,
|
||||||
|
host_id=p.host_id,
|
||||||
|
hostname=p.host.hostname if p.host else None,
|
||||||
|
title=p.title,
|
||||||
|
summary=p.summary,
|
||||||
|
severity=p.severity,
|
||||||
|
status=p.status,
|
||||||
|
rule_id=p.rule_id,
|
||||||
|
created_at=p.created_at,
|
||||||
|
updated_at=p.updated_at,
|
||||||
|
)
|
||||||
|
for p in rows
|
||||||
|
]
|
||||||
|
return ProblemListResponse(items=items, total=total, page=page, page_size=page_size)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{problem_id}/ack")
|
||||||
|
def ack_problem(
|
||||||
|
problem_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_user: str = Depends(get_current_user),
|
||||||
|
) -> dict:
|
||||||
|
problem = db.get(Problem, problem_id)
|
||||||
|
if problem is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Problem not found")
|
||||||
|
problem.status = "acknowledged"
|
||||||
|
db.commit()
|
||||||
|
return {"id": problem.id, "status": problem.status}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{problem_id}/resolve")
|
||||||
|
def resolve_problem(
|
||||||
|
problem_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_user: str = Depends(get_current_user),
|
||||||
|
) -> dict:
|
||||||
|
problem = db.get(Problem, problem_id)
|
||||||
|
if problem is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Problem not found")
|
||||||
|
problem.status = "resolved"
|
||||||
|
db.commit()
|
||||||
|
return {"id": problem.id, "status": problem.status}
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
|
|
||||||
from app.api.v1 import auth, events, health, hosts
|
from app.api.v1 import auth, events, health, hosts, problems
|
||||||
|
|
||||||
api_router = APIRouter()
|
api_router = APIRouter()
|
||||||
api_router.include_router(health.router)
|
api_router.include_router(health.router)
|
||||||
api_router.include_router(auth.router)
|
api_router.include_router(auth.router)
|
||||||
api_router.include_router(events.router)
|
api_router.include_router(events.router)
|
||||||
api_router.include_router(hosts.router)
|
api_router.include_router(hosts.router)
|
||||||
|
api_router.include_router(problems.router)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
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.problem import Problem, ProblemEvent
|
||||||
|
|
||||||
__all__ = ["ApiKey", "Event", "Host"]
|
__all__ = ["ApiKey", "Event", "Host", "Problem", "ProblemEvent"]
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, ForeignKey, String, Text, func
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class Problem(Base):
|
||||||
|
__tablename__ = "problems"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||||
|
host_id: Mapped[int | None] = mapped_column(ForeignKey("hosts.id"), index=True)
|
||||||
|
title: Mapped[str] = mapped_column(String(256))
|
||||||
|
summary: Mapped[str] = mapped_column(Text)
|
||||||
|
severity: Mapped[str] = mapped_column(String(16), index=True)
|
||||||
|
status: Mapped[str] = mapped_column(String(32), index=True, default="open")
|
||||||
|
rule_id: Mapped[str | None] = mapped_column(String(64))
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||||
|
)
|
||||||
|
|
||||||
|
host: Mapped["Host | None"] = relationship()
|
||||||
|
event_links: Mapped[list["ProblemEvent"]] = relationship(back_populates="problem")
|
||||||
|
|
||||||
|
|
||||||
|
class ProblemEvent(Base):
|
||||||
|
__tablename__ = "problem_events"
|
||||||
|
|
||||||
|
problem_id: Mapped[int] = mapped_column(ForeignKey("problems.id", ondelete="CASCADE"), primary_key=True)
|
||||||
|
event_id: Mapped[int] = mapped_column(ForeignKey("events.id", ondelete="CASCADE"), primary_key=True)
|
||||||
|
|
||||||
|
problem: Mapped["Problem"] = relationship(back_populates="event_links")
|
||||||
|
event: Mapped["Event"] = relationship()
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
"""Auto-create Problems from ingested events (MVP rules)."""
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.models import Event, Problem, ProblemEvent
|
||||||
|
|
||||||
|
# event types that always open a problem
|
||||||
|
PROBLEM_TYPES = frozenset(
|
||||||
|
{
|
||||||
|
"ssh.ip.banned",
|
||||||
|
"ssh.bruteforce.mass",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
HIGH_SEVERITIES = frozenset({"high", "critical"})
|
||||||
|
|
||||||
|
|
||||||
|
def maybe_create_problem(db: Session, event: Event) -> Problem | None:
|
||||||
|
if event.severity not in HIGH_SEVERITIES and event.type not in PROBLEM_TYPES:
|
||||||
|
return None
|
||||||
|
|
||||||
|
rule_id = "high_severity" if event.severity in HIGH_SEVERITIES else f"type:{event.type}"
|
||||||
|
|
||||||
|
existing = db.scalar(
|
||||||
|
select(Problem)
|
||||||
|
.join(ProblemEvent)
|
||||||
|
.where(
|
||||||
|
Problem.status == "open",
|
||||||
|
Problem.rule_id == rule_id,
|
||||||
|
Problem.host_id == event.host_id,
|
||||||
|
ProblemEvent.event_id == event.id,
|
||||||
|
)
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
if existing:
|
||||||
|
return existing
|
||||||
|
|
||||||
|
# dedupe: one open problem per host+rule (append event link)
|
||||||
|
open_problem = db.scalar(
|
||||||
|
select(Problem)
|
||||||
|
.where(
|
||||||
|
Problem.status == "open",
|
||||||
|
Problem.rule_id == rule_id,
|
||||||
|
Problem.host_id == event.host_id,
|
||||||
|
)
|
||||||
|
.order_by(Problem.created_at.desc())
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
if open_problem:
|
||||||
|
db.add(ProblemEvent(problem_id=open_problem.id, event_id=event.id))
|
||||||
|
open_problem.updated_at = event.received_at
|
||||||
|
db.flush()
|
||||||
|
return open_problem
|
||||||
|
|
||||||
|
problem = Problem(
|
||||||
|
host_id=event.host_id,
|
||||||
|
title=event.title,
|
||||||
|
summary=event.summary,
|
||||||
|
severity=event.severity,
|
||||||
|
status="open",
|
||||||
|
rule_id=rule_id,
|
||||||
|
)
|
||||||
|
db.add(problem)
|
||||||
|
db.flush()
|
||||||
|
db.add(ProblemEvent(problem_id=problem.id, event_id=event.id))
|
||||||
|
db.flush()
|
||||||
|
return problem
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
| `POST /api/v1/events` (ingest 202) | ✅ |
|
| `POST /api/v1/events` (ingest 202) | ✅ |
|
||||||
| `GET /health` | ✅ |
|
| `GET /health` | ✅ |
|
||||||
| nginx + wildcard TLS (`/etc/ssl/sac/`) | ✅ |
|
| nginx + wildcard TLS (`/etc/ssl/sac/`) | ✅ |
|
||||||
| JWT UI: Events, Hosts | ✅ |
|
| JWT UI: Events, Hosts, **Problems** | ✅ (Problems после деплоя) |
|
||||||
| `SAC_ADMIN_PASSWORD` + вход в браузере | ✅ |
|
| `SAC_ADMIN_PASSWORD` + вход в браузере | ✅ |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -50,9 +50,9 @@ sudo /opt/sac-deploy.sh
|
|||||||
|
|
||||||
## Следующие шаги
|
## Следующие шаги
|
||||||
|
|
||||||
1. **1A** — маппинг событий через `notify_or_sac`; при необходимости `UseSAC=dual` на тесте
|
1. **Деплой SAC** — `sudo /opt/sac-deploy.sh`, `alembic upgrade head` (миграция `002_problems`)
|
||||||
2. **1C** — Problems, Telegram из SAC, dashboard
|
2. **ssh-monitor на ubabuba** — `update_ssh_monitor.sh` (10.10.36.9), маппинг `notify_or_sac` уже в `main`
|
||||||
3. **1A** — маппинг всех типов событий через `notify_or_sac`
|
3. **1C** — Telegram из SAC, dashboard
|
||||||
4. RDP-login-monitor — аналог UseSAC
|
4. RDP-login-monitor — аналог UseSAC
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
+4
-4
@@ -21,9 +21,9 @@
|
|||||||
|
|
||||||
| # | Задача | Статус |
|
| # | Задача | Статус |
|
||||||
|---|--------|--------|
|
|---|--------|--------|
|
||||||
| 1A.1 | Параметры `UseSAC`, `SAC_URL`, `SAC_API_KEY`, spool | 🔄 ssh-monitor |
|
| 1A.1 | Параметры `UseSAC`, `SAC_URL`, `SAC_API_KEY`, spool | ✅ ssh-monitor |
|
||||||
| 1A.2 | `build_sac_event()` + `send_sac_event()` по schema v1 | 🔄 `sac-client.sh` |
|
| 1A.2 | `build_sac_event()` + `send_sac_event()` по schema v1 | ✅ `sac-client.sh` |
|
||||||
| 1A.3 | `notify_or_sac()`: off / dual / exclusive / fallback | 🔄 |
|
| 1A.3 | `notify_or_sac()`: off / dual / exclusive / fallback | ✅ + маппинг событий |
|
||||||
| 1A.4 | `--check-sac` / `Test-SacConnection` | 🔄 ssh-monitor `--check-sac` |
|
| 1A.4 | `--check-sac` / `Test-SacConnection` | 🔄 ssh-monitor `--check-sac` |
|
||||||
| 1A.5 | README агентов | ⏳ |
|
| 1A.5 | README агентов | ⏳ |
|
||||||
|
|
||||||
@@ -51,7 +51,7 @@
|
|||||||
|---|--------|--------|
|
|---|--------|--------|
|
||||||
| 1C.1 | Frontend Vue: Events, Hosts | ✅ prod |
|
| 1C.1 | Frontend Vue: Events, Hosts | ✅ prod |
|
||||||
| 1C.2 | Auth JWT, admin bootstrap | ✅ prod |
|
| 1C.2 | Auth JWT, admin bootstrap | ✅ prod |
|
||||||
| 1C.3 | Problems (базовые правила) | ⏳ |
|
| 1C.3 | Problems (базовые правила) | ✅ API + UI (деплой ⏳) |
|
||||||
| 1C.4 | Telegram из SAC | ⏳ |
|
| 1C.4 | Telegram из SAC | ⏳ |
|
||||||
| 1C.5 | Dashboard (3 виджета), SSE | ⏳ |
|
| 1C.5 | Dashboard (3 виджета), SSE | ⏳ |
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
<nav v-if="showNav">
|
<nav v-if="showNav">
|
||||||
<span class="brand">SAC</span>
|
<span class="brand">SAC</span>
|
||||||
<RouterLink to="/events">События</RouterLink>
|
<RouterLink to="/events">События</RouterLink>
|
||||||
|
<RouterLink to="/problems">Проблемы</RouterLink>
|
||||||
<RouterLink to="/hosts">Хосты</RouterLink>
|
<RouterLink to="/hosts">Хосты</RouterLink>
|
||||||
<button type="button" class="secondary" @click="logout">Выход</button>
|
<button type="button" class="secondary" @click="logout">Выход</button>
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@@ -84,3 +84,31 @@ export interface HostListResponse {
|
|||||||
page: number;
|
page: number;
|
||||||
page_size: number;
|
page_size: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ProblemSummary {
|
||||||
|
id: number;
|
||||||
|
host_id: number | null;
|
||||||
|
hostname: string | null;
|
||||||
|
title: string;
|
||||||
|
summary: string;
|
||||||
|
severity: string;
|
||||||
|
status: string;
|
||||||
|
rule_id: string | null;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProblemListResponse {
|
||||||
|
items: ProblemSummary[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
page_size: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ackProblem(problemId: number): Promise<{ id: number; status: string }> {
|
||||||
|
return apiFetch(`/api/v1/problems/${problemId}/ack`, { method: "POST" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveProblem(problemId: number): Promise<{ id: number; status: string }> {
|
||||||
|
return apiFetch(`/api/v1/problems/${problemId}/resolve`, { method: "POST" });
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import EventsView from "./views/EventsView.vue";
|
|||||||
import EventDetailView from "./views/EventDetailView.vue";
|
import EventDetailView from "./views/EventDetailView.vue";
|
||||||
import HostsView from "./views/HostsView.vue";
|
import HostsView from "./views/HostsView.vue";
|
||||||
import LoginView from "./views/LoginView.vue";
|
import LoginView from "./views/LoginView.vue";
|
||||||
|
import ProblemsView from "./views/ProblemsView.vue";
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
history: createWebHistory(),
|
history: createWebHistory(),
|
||||||
@@ -13,6 +14,7 @@ const router = createRouter({
|
|||||||
{ path: "/events", component: EventsView },
|
{ path: "/events", component: EventsView },
|
||||||
{ path: "/events/:id", component: EventDetailView, props: true },
|
{ path: "/events/:id", component: EventDetailView, props: true },
|
||||||
{ path: "/hosts", component: HostsView },
|
{ path: "/hosts", component: HostsView },
|
||||||
|
{ path: "/problems", component: ProblemsView },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -67,6 +67,22 @@ th {
|
|||||||
color: #7eb8ff;
|
color: #7eb8ff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.status-open {
|
||||||
|
color: #ff6b6b;
|
||||||
|
}
|
||||||
|
.status-acknowledged {
|
||||||
|
color: #ffc857;
|
||||||
|
}
|
||||||
|
.status-resolved {
|
||||||
|
color: #6bcb77;
|
||||||
|
}
|
||||||
|
|
||||||
|
td.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.35rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
.card {
|
.card {
|
||||||
background: #1a2332;
|
background: #1a2332;
|
||||||
border: 1px solid #2a3441;
|
border: 1px solid #2a3441;
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
<template>
|
||||||
|
<h1>Проблемы</h1>
|
||||||
|
<div class="filters">
|
||||||
|
<select v-model="statusFilter" @change="load(1)">
|
||||||
|
<option value="">Все статусы</option>
|
||||||
|
<option value="open">open</option>
|
||||||
|
<option value="acknowledged">acknowledged</option>
|
||||||
|
<option value="resolved">resolved</option>
|
||||||
|
</select>
|
||||||
|
<button type="button" @click="load(1)">Обновить</button>
|
||||||
|
</div>
|
||||||
|
<p v-if="error" class="error">{{ error }}</p>
|
||||||
|
<p v-else-if="loading">Загрузка…</p>
|
||||||
|
<template v-else>
|
||||||
|
<p>Всего: {{ data?.total ?? 0 }}</p>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Обновлено</th>
|
||||||
|
<th>Хост</th>
|
||||||
|
<th>Severity</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Rule</th>
|
||||||
|
<th>Title</th>
|
||||||
|
<th>Действия</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="p in data?.items ?? []" :key="p.id">
|
||||||
|
<td>{{ p.id }}</td>
|
||||||
|
<td>{{ formatDt(p.updated_at) }}</td>
|
||||||
|
<td>{{ p.hostname ?? "—" }}</td>
|
||||||
|
<td :class="'sev-' + p.severity">{{ p.severity }}</td>
|
||||||
|
<td :class="'status-' + p.status">{{ p.status }}</td>
|
||||||
|
<td><code>{{ p.rule_id ?? "—" }}</code></td>
|
||||||
|
<td>{{ p.title }}</td>
|
||||||
|
<td class="actions">
|
||||||
|
<button
|
||||||
|
v-if="p.status === 'open'"
|
||||||
|
type="button"
|
||||||
|
class="secondary"
|
||||||
|
:disabled="actingId === p.id"
|
||||||
|
@click="doAck(p.id)"
|
||||||
|
>
|
||||||
|
Ack
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="p.status !== 'resolved'"
|
||||||
|
type="button"
|
||||||
|
:disabled="actingId === p.id"
|
||||||
|
@click="doResolve(p.id)"
|
||||||
|
>
|
||||||
|
Resolve
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div class="filters" style="margin-top: 1rem">
|
||||||
|
<button type="button" class="secondary" :disabled="page <= 1" @click="load(page - 1)">Назад</button>
|
||||||
|
<span>Стр. {{ page }}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="secondary"
|
||||||
|
:disabled="!data || page * pageSize >= data.total"
|
||||||
|
@click="load(page + 1)"
|
||||||
|
>
|
||||||
|
Вперёд
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, ref } from "vue";
|
||||||
|
import { ackProblem, apiFetch, resolveProblem, type ProblemListResponse } from "../api";
|
||||||
|
|
||||||
|
const data = ref<ProblemListResponse | null>(null);
|
||||||
|
const loading = ref(false);
|
||||||
|
const error = ref("");
|
||||||
|
const page = ref(1);
|
||||||
|
const pageSize = 50;
|
||||||
|
const statusFilter = ref("");
|
||||||
|
const actingId = ref<number | null>(null);
|
||||||
|
|
||||||
|
function formatDt(iso: string) {
|
||||||
|
return new Date(iso).toLocaleString("ru-RU");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load(p: number) {
|
||||||
|
page.value = p;
|
||||||
|
loading.value = true;
|
||||||
|
error.value = "";
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
page: String(page.value),
|
||||||
|
page_size: String(pageSize),
|
||||||
|
});
|
||||||
|
if (statusFilter.value) params.set("status", statusFilter.value);
|
||||||
|
data.value = await apiFetch<ProblemListResponse>(`/api/v1/problems?${params}`);
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e instanceof Error ? e.message : "Ошибка загрузки";
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doAck(id: number) {
|
||||||
|
actingId.value = id;
|
||||||
|
error.value = "";
|
||||||
|
try {
|
||||||
|
await ackProblem(id);
|
||||||
|
await load(page.value);
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e instanceof Error ? e.message : "Ошибка ack";
|
||||||
|
} finally {
|
||||||
|
actingId.value = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doResolve(id: number) {
|
||||||
|
actingId.value = id;
|
||||||
|
error.value = "";
|
||||||
|
try {
|
||||||
|
await resolveProblem(id);
|
||||||
|
await load(page.value);
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e instanceof Error ? e.message : "Ошибка resolve";
|
||||||
|
} finally {
|
||||||
|
actingId.value = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => load(1));
|
||||||
|
</script>
|
||||||
Reference in New Issue
Block a user