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 <cursoragent@cursor.com>
This commit is contained in:
2026-05-29 16:52:05 +10:00
parent 2670c8cb46
commit 11195ae64f
5 changed files with 206 additions and 2 deletions
+31 -1
View File
@@ -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 import func, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -7,6 +8,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 from app.models import Event, Host
from app.schemas.list_models import HostListResponse, HostSummary from app.schemas.list_models import HostListResponse, HostSummary
from app.services.host_delete import delete_host_and_related
from app.services.host_health import ( from app.services.host_health import (
HEARTBEAT_TYPE, HEARTBEAT_TYPE,
agent_status, agent_status,
@@ -75,3 +77,31 @@ def list_hosts(
) )
return HostListResponse(items=items, total=total, page=page, page_size=page_size) 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,
)
+51
View File
@@ -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),
)
+66 -1
View File
@@ -1,8 +1,9 @@
"""Hosts list API.""" """Hosts list API."""
import uuid
from datetime import datetime, timezone 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): 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) r2 = client.get("/api/v1/hosts?hostname=nomatch", headers=jwt_headers)
assert r2.json()["total"] == 0 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
+15
View File
@@ -131,6 +131,21 @@ button.secondary {
border-color: #3d4f63; 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 { .filters {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
+43
View File
@@ -25,6 +25,7 @@
<th><button type="button" class="th-sort" @click="setSort('last_daily_report_at')">Отчёт {{ sortMark('last_daily_report_at') }}</button></th> <th><button type="button" class="th-sort" @click="setSort('last_daily_report_at')">Отчёт {{ sortMark('last_daily_report_at') }}</button></th>
<th><button type="button" class="th-sort" @click="setSort('last_seen_at')">Last seen {{ sortMark('last_seen_at') }}</button></th> <th><button type="button" class="th-sort" @click="setSort('last_seen_at')">Last seen {{ sortMark('last_seen_at') }}</button></th>
<th><button type="button" class="th-sort" @click="setSort('event_count')">Events {{ sortMark('event_count') }}</button></th> <th><button type="button" class="th-sort" @click="setSort('event_count')">Events {{ sortMark('event_count') }}</button></th>
<th>Действия</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -48,6 +49,17 @@
</td> </td>
<td>{{ formatDt(h.last_seen_at) }}</td> <td>{{ formatDt(h.last_seen_at) }}</td>
<td>{{ h.event_count ?? 0 }}</td> <td>{{ h.event_count ?? 0 }}</td>
<td class="actions">
<button
type="button"
class="danger"
:disabled="deletingId === h.id"
title="Удалить хост и все его события"
@click="confirmDelete(h)"
>
Удалить
</button>
</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
@@ -74,6 +86,7 @@ type SortKey =
| "last_daily_report_at"; | "last_daily_report_at";
const sortBy = ref<SortKey>("last_seen_at"); const sortBy = ref<SortKey>("last_seen_at");
const sortDir = ref<"asc" | "desc">("desc"); const sortDir = ref<"asc" | "desc">("desc");
const deletingId = ref<number | null>(null);
function formatDt(iso: string) { function formatDt(iso: string) {
return new Date(iso).toLocaleString("ru-RU"); 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<HostDeleteResponse>(`/api/v1/hosts/${h.id}`, { method: "DELETE" });
await loadHosts();
} catch (e) {
error.value = e instanceof Error ? e.message : "Не удалось удалить хост";
} finally {
deletingId.value = null;
}
}
onMounted(() => { onMounted(() => {
loadHosts(); loadHosts();
}); });