From 11195ae64fe6e6249e09d8e309586fce94a3d360 Mon Sep 17 00:00:00 2001 From: PTah Date: Fri, 29 May 2026 16:52:05 +1000 Subject: [PATCH] feat: delete host from UI with events and problems cleanup DELETE /api/v1/hosts/{id} (JWT); host reappears on next agent ingest Co-authored-by: Cursor --- backend/app/api/v1/hosts.py | 32 +++++++++++++- backend/app/services/host_delete.py | 51 ++++++++++++++++++++++ backend/tests/test_hosts.py | 67 ++++++++++++++++++++++++++++- frontend/src/style.css | 15 +++++++ frontend/src/views/HostsView.vue | 43 ++++++++++++++++++ 5 files changed, 206 insertions(+), 2 deletions(-) create mode 100644 backend/app/services/host_delete.py 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 @@ + Действия @@ -48,6 +49,17 @@ {{ formatDt(h.last_seen_at) }} {{ h.event_count ?? 0 }} + + + @@ -74,6 +86,7 @@ type SortKey = | "last_daily_report_at"; const sortBy = ref("last_seen_at"); const sortDir = ref<"asc" | "desc">("desc"); +const deletingId = ref(null); function formatDt(iso: string) { return new Date(iso).toLocaleString("ru-RU"); @@ -169,6 +182,36 @@ async function loadHosts() { } } +interface HostDeleteResponse { + status: string; + host_id: number; + hostname: string; + deleted_events: number; + deleted_problems: number; +} + +async function confirmDelete(h: HostSummary) { + const label = h.display_name || h.hostname; + const n = h.event_count ?? 0; + const msg = + `Удалить хост «${label}» (${h.hostname})?\n\n` + + `Будут удалены все события (${n}) и проблемы этого хоста. ` + + `При новом ingest агент снова появится в списке.`; + if (!window.confirm(msg)) { + return; + } + deletingId.value = h.id; + error.value = ""; + try { + await apiFetch(`/api/v1/hosts/${h.id}`, { method: "DELETE" }); + await loadHosts(); + } catch (e) { + error.value = e instanceof Error ? e.message : "Не удалось удалить хост"; + } finally { + deletingId.value = null; + } +} + onMounted(() => { loadHosts(); });