diff --git a/backend/app/api/v1/hosts.py b/backend/app/api/v1/hosts.py index e7a9439..41c9dba 100644 --- a/backend/app/api/v1/hosts.py +++ b/backend/app/api/v1/hosts.py @@ -1,4 +1,5 @@ -from fastapi import APIRouter, Depends, Query +from fastapi import APIRouter, Depends, HTTPException, Query +from pydantic import BaseModel from sqlalchemy import func, select from sqlalchemy.orm import Session @@ -7,6 +8,7 @@ from app.config import get_settings from app.database import get_db from app.models import Event, Host from app.schemas.list_models import HostListResponse, HostSummary +from app.services.host_delete import delete_host_and_related from app.services.host_health import ( HEARTBEAT_TYPE, agent_status, @@ -75,3 +77,31 @@ def list_hosts( ) return HostListResponse(items=items, total=total, page=page, page_size=page_size) + + +class HostDeleteResponse(BaseModel): + status: str + host_id: int + hostname: str + deleted_events: int + deleted_problems: int + + +@router.delete("/{host_id}", response_model=HostDeleteResponse) +def delete_host( + host_id: int, + db: Session = Depends(get_db), + _user: str = Depends(get_current_user), +) -> HostDeleteResponse: + result = delete_host_and_related(db, host_id) + if result is None: + raise HTTPException(status_code=404, detail="Host not found") + db.commit() + return HostDeleteResponse( + status="deleted", + host_id=result.host_id, + hostname=result.hostname, + deleted_events=result.deleted_events, + deleted_problems=result.deleted_problems, + ) + diff --git a/backend/app/services/host_delete.py b/backend/app/services/host_delete.py new file mode 100644 index 0000000..35b0321 --- /dev/null +++ b/backend/app/services/host_delete.py @@ -0,0 +1,51 @@ +"""Удаление хоста и связанных events/problems (ручная очистка UI).""" + +from __future__ import annotations + +from dataclasses import dataclass + +from sqlalchemy import delete, or_, select +from sqlalchemy.orm import Session + +from app.models import Event, Host, Problem, ProblemEvent + + +@dataclass(frozen=True) +class HostDeleteResult: + host_id: int + hostname: str + deleted_events: int + deleted_problems: int + + +def delete_host_and_related(db: Session, host_id: int) -> HostDeleteResult | None: + host = db.get(Host, host_id) + if host is None: + return None + + hostname = host.hostname + event_ids = list(db.scalars(select(Event.id).where(Event.host_id == host_id)).all()) + problem_ids = list(db.scalars(select(Problem.id).where(Problem.host_id == host_id)).all()) + + if event_ids or problem_ids: + link_filter = [] + if event_ids: + link_filter.append(ProblemEvent.event_id.in_(event_ids)) + if problem_ids: + link_filter.append(ProblemEvent.problem_id.in_(problem_ids)) + db.execute(delete(ProblemEvent).where(or_(*link_filter))) + + if problem_ids: + db.execute(delete(Problem).where(Problem.id.in_(problem_ids))) + if event_ids: + db.execute(delete(Event).where(Event.id.in_(event_ids))) + + db.delete(host) + db.flush() + + return HostDeleteResult( + host_id=host_id, + hostname=hostname, + deleted_events=len(event_ids), + deleted_problems=len(problem_ids), + ) diff --git a/backend/tests/test_hosts.py b/backend/tests/test_hosts.py index 44df5a5..c27a961 100644 --- a/backend/tests/test_hosts.py +++ b/backend/tests/test_hosts.py @@ -1,8 +1,9 @@ """Hosts list API.""" +import uuid from datetime import datetime, timezone -from app.models import Host +from app.models import Event, Host, Problem def test_hosts_search_by_display_name(client, db_session, jwt_headers): @@ -24,3 +25,67 @@ def test_hosts_search_by_display_name(client, db_session, jwt_headers): r2 = client.get("/api/v1/hosts?hostname=nomatch", headers=jwt_headers) assert r2.json()["total"] == 0 + + +def test_delete_host_removes_events_and_problems(client, db_session, jwt_headers): + h = Host( + hostname="test-host", + os_family="linux", + product="ssh-monitor", + last_seen_at=datetime.now(timezone.utc), + ) + db_session.add(h) + db_session.flush() + ev = Event( + event_id=str(uuid.uuid4()), + host_id=h.id, + occurred_at=datetime.now(timezone.utc), + category="agent", + type="agent.test", + severity="info", + title="t", + summary="s", + payload={}, + ) + db_session.add(ev) + db_session.add( + Problem( + host_id=h.id, + title="p", + summary="s", + severity="warning", + status="open", + rule_id="rule:test", + fingerprint="fp-del-host", + event_count=1, + last_seen_at=datetime.now(timezone.utc), + ) + ) + db_session.commit() + + r = client.delete(f"/api/v1/hosts/{h.id}", headers=jwt_headers) + assert r.status_code == 200 + body = r.json() + assert body["hostname"] == "test-host" + assert body["deleted_events"] == 1 + assert body["deleted_problems"] == 1 + assert db_session.get(Host, h.id) is None + + +def test_delete_host_404(client, jwt_headers): + r = client.delete("/api/v1/hosts/999999", headers=jwt_headers) + assert r.status_code == 404 + + +def test_delete_host_requires_jwt(client, db_session): + h = Host( + hostname="no-auth", + os_family="linux", + product="ssh-monitor", + last_seen_at=datetime.now(timezone.utc), + ) + db_session.add(h) + db_session.commit() + r = client.delete(f"/api/v1/hosts/{h.id}") + assert r.status_code == 401 + diff --git a/frontend/src/style.css b/frontend/src/style.css index f575ed0..ef164df 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -131,6 +131,21 @@ button.secondary { border-color: #3d4f63; } +button.danger { + background: transparent; + border-color: #b91c1c; + color: #f87171; +} + +button.danger:hover:not(:disabled) { + background: #3f1515; +} + +button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + .filters { display: flex; flex-wrap: wrap; diff --git a/frontend/src/views/HostsView.vue b/frontend/src/views/HostsView.vue index 8f1ed6a..3a34b8c 100644 --- a/frontend/src/views/HostsView.vue +++ b/frontend/src/views/HostsView.vue @@ -25,6 +25,7 @@