Compare commits

..

2 commits

Author SHA1 Message Date
dv
8115324988 правки по задачам 2026-05-06 15:25:11 +03:00
dv
e8a712aee4 фикс алертов 2026-05-06 15:05:00 +03:00
10 changed files with 356 additions and 57 deletions

View file

@ -0,0 +1,25 @@
"""0019 task results composite index
Revision ID: 0019
Revises: 0018
Create Date: 2026-05-06
"""
from alembic import op
revision = "0019"
down_revision = "0018"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_index(
"ix_task_results_task_device",
"task_results",
["task_id", "device_id"],
)
def downgrade() -> None:
op.drop_index("ix_task_results_task_device", table_name="task_results")

View file

@ -91,6 +91,42 @@ async def list_alerts(
return [_to_read(a) for a in result.scalars().all()] return [_to_read(a) for a in result.scalars().all()]
@router.post("/acknowledge-by-object", response_model=list[AlertRead])
async def acknowledge_by_object(
object_id: int = Query(...),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
result = await db.execute(
select(Alert)
.join(Device, Alert.device_id == Device.id)
.where(Device.object_id == object_id, Alert.status == "open")
)
alerts = result.scalars().all()
now = datetime.now(timezone.utc)
for alert in alerts:
alert.status = "acknowledged"
alert.acknowledged_at = now
alert.acknowledged_by = current_user.id
await db.flush()
alert_ids = [a.id for a in alerts]
loaded = []
for aid in alert_ids:
loaded.append(await _load_alert(db, aid))
for alert in loaded:
await sse_manager.publish(
SSEEvent(
type="alert_updated",
task_id=MONITOR_CHANNEL,
data={"alert_id": alert.id, "status": "acknowledged", "device_id": alert.device_id},
)
)
return [_to_read(a) for a in loaded]
@router.post("/{alert_id}/acknowledge", response_model=AlertRead) @router.post("/{alert_id}/acknowledge", response_model=AlertRead)
async def acknowledge_alert( async def acknowledge_alert(
alert_id: int, alert_id: int,

View file

@ -549,6 +549,19 @@ async def run_dashboard_monitor_cycle(
", ".join(ips[:10]) + ("" if len(ips) > 10 else ""), ", ".join(ips[:10]) + ("" if len(ips) > 10 else ""),
) )
# Загружаем предыдущие ping-статусы до запуска — нужны для retry-логики
pre_ping_status_map: dict[int, str] = {}
if check.check_key == "ping":
pre_state_rows = (
await db.execute(
select(DashboardDeviceLiveState).where(
DashboardDeviceLiveState.check_key == "ping",
DashboardDeviceLiveState.device_id.in_([d.id for d in due_devices]),
)
)
).scalars().all()
pre_ping_status_map = {row.device_id: row.status for row in pre_state_rows}
sem = asyncio.Semaphore(max(1, check.concurrency)) sem = asyncio.Semaphore(max(1, check.concurrency))
tasks = [ tasks = [
asyncio.ensure_future(_run_check_on_device(sem, check, device, device.object)) asyncio.ensure_future(_run_check_on_device(sem, check, device, device.object))
@ -589,6 +602,78 @@ async def run_dashboard_monitor_cycle(
) )
) )
# Retry для offline-устройств (только ping):
# срабатывает только если устройство было online (переход online→offline)
if check.check_key == "ping":
offline_devices = [
r[0] for r in results
if r[2] == "offline" and pre_ping_status_map.get(r[0].id) == "online"
]
for retry_num in range(1, 3):
if not offline_devices:
break
logger.info(
"Dashboard monitor [ping] retry %d/2: %d offline устройств, ждём 60 сек",
retry_num, len(offline_devices),
)
await sse_manager.publish(
SSEEvent(
type="dashboard_monitor_update",
task_id=DASHBOARD_MONITOR_CHANNEL,
data={
"check_key": check.check_key,
"processed": completed_count,
"due": len(due_devices),
"failed": failed_count,
"retry": retry_num,
"retry_pending": len(offline_devices),
"ts": datetime.now(timezone.utc).isoformat(),
},
)
)
await asyncio.sleep(60)
retry_tasks = [
asyncio.ensure_future(_run_check_on_device(sem, check, device, device.object))
for device in offline_devices
]
retry_results = []
for coro in asyncio.as_completed(retry_tasks):
r = await coro
retry_results.append(r)
logger.debug(
"Dashboard monitor [ping] retry %d %s: %s",
retry_num, r[0].ip, r[2],
)
# Обновляем results: заменяем результат устройства если оно восстановилось
retry_map = {r[0].id: r for r in retry_results}
results = [
retry_map.get(r[0].id, r) for r in results
]
offline_devices = [r[0] for r in retry_results if r[2] == "offline"]
recovered = len(retry_results) - len(offline_devices)
failed_count = sum(1 for r in results if not r[1])
logger.info(
"Dashboard monitor [ping] retry %d/2 done: восстановилось %d, всё ещё offline %d",
retry_num, recovered, len(offline_devices),
)
await sse_manager.publish(
SSEEvent(
type="dashboard_monitor_update",
task_id=DASHBOARD_MONITOR_CHANNEL,
data={
"check_key": check.check_key,
"processed": len(due_devices),
"due": len(due_devices),
"failed": failed_count,
"retry": retry_num,
"retry_recovered": recovered,
"ts": datetime.now(timezone.utc).isoformat(),
},
)
)
async with AsyncSessionLocal() as dbw: async with AsyncSessionLocal() as dbw:
async with dbw.begin(): async with dbw.begin():
# Загружаем предыдущие статусы и baseline только для ping # Загружаем предыдущие статусы и baseline только для ping

View file

@ -87,7 +87,6 @@ async def _resolve_scope(scope: dict) -> list[tuple[Device, Object]]:
query = query.where(Device.object_id == scope["object_id"]) query = query.where(Device.object_id == scope["object_id"])
elif scope_type == "filter": elif scope_type == "filter":
# Cross-object filter scope (from WorkDevices global page) # Cross-object filter scope (from WorkDevices global page)
from app.models.object import Object as Obj
if scope.get("category"): if scope.get("category"):
cats = scope["category"] cats = scope["category"]
if isinstance(cats, list): if isinstance(cats, list):
@ -96,10 +95,12 @@ async def _resolve_scope(scope: dict) -> list[tuple[Device, Object]]:
query = query.where(Device.category == cats) query = query.where(Device.category == cats)
if scope.get("object_id"): if scope.get("object_id"):
query = query.where(Device.object_id == scope["object_id"]) query = query.where(Device.object_id == scope["object_id"])
if scope.get("city"): if scope.get("city") or scope.get("client"):
query = query.where(Obj.city == scope["city"]) query = query.join(Object, Device.object_id == Object.id)
if scope.get("client"): if scope.get("city"):
query = query.where(Obj.client == scope["client"]) query = query.where(Object.city == scope["city"])
if scope.get("client"):
query = query.where(Object.client == scope["client"])
else: else:
return [] return []
@ -117,20 +118,14 @@ async def _run_on_device(
sem: asyncio.Semaphore, sem: asyncio.Semaphore,
) -> bool | None: ) -> bool | None:
async with sem: async with sem:
# Bail out after acquiring the semaphore so large target lists don't # Session 1: cancellation check + create TaskResult
# stampede the DB pool with cancellation checks.
async with AsyncSessionLocal() as db:
task_row = await db.get(Task, task_id)
if task_row and task_row.cancel_requested:
return None # None = skipped due to cancellation
emit = lambda msg, level="info": _emit(task_id, msg, level)
# Create task_result row
result_id: int | None = None
started_at = datetime.now(timezone.utc) started_at = datetime.now(timezone.utc)
result_id: int | None = None
async with AsyncSessionLocal() as db: async with AsyncSessionLocal() as db:
async with db.begin(): async with db.begin():
task_row = await db.get(Task, task_id)
if task_row and task_row.cancel_requested:
return None # None = skipped due to cancellation
tr = TaskResult( tr = TaskResult(
task_id=task_id, task_id=task_id,
device_id=device.id, device_id=device.id,
@ -141,6 +136,8 @@ async def _run_on_device(
await db.flush() await db.flush()
result_id = tr.id result_id = tr.id
emit = lambda msg, level="info": _emit(task_id, msg, level)
try: try:
action_result = await action.execute(device, obj, params, emit, db=None) action_result = await action.execute(device, obj, params, emit, db=None)
except Exception as exc: except Exception as exc:
@ -161,6 +158,7 @@ async def _run_on_device(
finished_at = datetime.now(timezone.utc) finished_at = datetime.now(timezone.utc)
duration_ms = int((finished_at - started_at).total_seconds() * 1000) duration_ms = int((finished_at - started_at).total_seconds() * 1000)
# Session 2: update TaskResult + device status (ping) in one transaction
async with AsyncSessionLocal() as db: async with AsyncSessionLocal() as db:
async with db.begin(): async with db.begin():
tr = await db.get(TaskResult, result_id) tr = await db.get(TaskResult, result_id)
@ -172,11 +170,8 @@ async def _run_on_device(
tr.data = {**(action_data or {}), "error": action_error} if action_error else (action_data or None) tr.data = {**(action_data or {}), "error": action_error} if action_error else (action_data or None)
tr.duration_ms = duration_ms tr.duration_ms = duration_ms
# For ping action: update device status, last_seen, last_ping_ms if action.name == "ping":
if action.name == "ping": new_status = "online" if action_result_success else "offline"
new_status = "online" if action_result_success else "offline"
async with AsyncSessionLocal() as db:
async with db.begin():
device_row = await db.get(Device, device.id) device_row = await db.get(Device, device.id)
if device_row: if device_row:
device_row.status = new_status device_row.status = new_status
@ -186,6 +181,10 @@ async def _run_on_device(
ping_ms = (action_data or {}).get("ping_ms") ping_ms = (action_data or {}).get("ping_ms")
if ping_ms is not None: if ping_ms is not None:
device_row.last_ping_ms = ping_ms device_row.last_ping_ms = ping_ms
# For ping action: publish SSE device_status after DB commit
if action.name == "ping":
new_status = "online" if action_result_success else "offline"
await sse_manager.publish(SSEEvent( await sse_manager.publish(SSEEvent(
type="device_status", type="device_status",
task_id="__monitor__", task_id="__monitor__",
@ -217,6 +216,17 @@ async def _run_on_device(
return action_result_success return action_result_success
async def _heartbeat_writer(task_id: str, interval: int = 30) -> None:
"""Write task.heartbeat_at every `interval` seconds while task is running."""
while True:
await asyncio.sleep(interval)
async with AsyncSessionLocal() as db:
async with db.begin():
task = await db.get(Task, task_id)
if task and task.status == "running":
task.heartbeat_at = datetime.now(timezone.utc)
async def _run_task(task_id: str) -> None: async def _run_task(task_id: str) -> None:
await _emit(task_id, "Task started", "info") await _emit(task_id, "Task started", "info")
@ -227,6 +237,7 @@ async def _run_task(task_id: str) -> None:
return return
task.status = "running" task.status = "running"
task.started_at = datetime.now(timezone.utc) task.started_at = datetime.now(timezone.utc)
task.heartbeat_at = datetime.now(timezone.utc)
scope = dict(task.target_scope) scope = dict(task.target_scope)
action_type = task.type action_type = task.type
params = dict(task.params) params = dict(task.params)
@ -275,10 +286,20 @@ async def _run_task(task_id: str) -> None:
await _emit(task_id, f"Running on {len(targets)} device(s)...", "info") await _emit(task_id, f"Running on {len(targets)} device(s)...", "info")
sem = asyncio.Semaphore(_TASK_DEVICE_CONCURRENCY) sem = asyncio.Semaphore(_TASK_DEVICE_CONCURRENCY)
results = await asyncio.gather( # Start heartbeat writer
*[_run_on_device(task_id, device, obj, action, params, sem) for device, obj in targets], heartbeat_task = asyncio.create_task(_heartbeat_writer(task_id))
return_exceptions=True,
) try:
results = await asyncio.gather(
*[_run_on_device(task_id, device, obj, action, params, sem) for device, obj in targets],
return_exceptions=True,
)
finally:
heartbeat_task.cancel()
try:
await heartbeat_task
except asyncio.CancelledError:
pass
ok = sum(1 for r in results if r is True) ok = sum(1 for r in results if r is True)
skipped = sum(1 for r in results if r is None) skipped = sum(1 for r in results if r is None)

View file

@ -6,6 +6,7 @@ Runs once at app startup:
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
import sqlalchemy as sa
from sqlalchemy import update from sqlalchemy import update
from app.models.task import Task from app.models.task import Task
@ -20,7 +21,15 @@ async def recover_stale_tasks() -> int:
async with db.begin(): async with db.begin():
result = await db.execute( result = await db.execute(
update(Task) update(Task)
.where(Task.status == "running", Task.started_at < cutoff) .where(
Task.status == "running",
# Use heartbeat_at when available (more accurate for long tasks),
# fall back to started_at for tasks that never wrote a heartbeat.
sa.or_(
sa.and_(Task.heartbeat_at.isnot(None), Task.heartbeat_at < cutoff),
sa.and_(Task.heartbeat_at.is_(None), Task.started_at < cutoff),
),
)
.values(status="stale") .values(status="stale")
.returning(Task.id) .returning(Task.id)
) )

View file

@ -50,3 +50,16 @@ export const useResolveAlert = () => {
onSuccess: () => qc.invalidateQueries({ queryKey: ['alerts'] }), onSuccess: () => qc.invalidateQueries({ queryKey: ['alerts'] }),
}) })
} }
export const useAcknowledgeByObject = () => {
const qc = useQueryClient()
return useMutation({
mutationFn: (objectId: number) =>
api
.post<AlertItem[]>(`/api/v1/alerts/acknowledge-by-object`, null, {
params: { object_id: objectId },
})
.then((r) => r.data),
onSuccess: () => qc.invalidateQueries({ queryKey: ['alerts'] }),
})
}

View file

@ -1,5 +1,6 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { api } from './client' import { api } from './client'
import { useSSE } from '../hooks/useSSE'
export interface TaskEvent { export interface TaskEvent {
id: number id: number
@ -78,11 +79,50 @@ export const useTask = (id: string | undefined) =>
queryKey: ['tasks', id], queryKey: ['tasks', id],
queryFn: () => api.get<TaskDetail>(`/api/v1/tasks/${id}`).then((r) => r.data), queryFn: () => api.get<TaskDetail>(`/api/v1/tasks/${id}`).then((r) => r.data),
enabled: !!id, enabled: !!id,
refetchInterval: (query) => { })
const status = query.state.data?.status
return status === 'running' || status === 'pending' ? 2000 : false // Subscribe to SSE for a running task — merges results in cache, invalidates on completion.
export const useTaskSSE = (id: string | undefined) => {
const qc = useQueryClient()
useSSE(id, {
onTaskResult: (data) => {
qc.setQueryData<TaskDetail>(['tasks', id], (prev) => {
if (!prev) return prev
const existing = prev.device_results.find((r) => r.device_id === data.device_id)
const updated: TaskResult = existing
? { ...existing, status: data.status, stdout: data.stdout, stderr: data.stderr, duration_ms: data.duration_ms }
: {
id: 0,
device_id: data.device_id,
device_ip: data.device_ip,
device_hostname: data.device_hostname,
object_id: null,
object_name: null,
rack_id: null,
rack_name: null,
status: data.status,
stdout: data.stdout,
stderr: data.stderr,
exit_code: null,
data: null,
duration_ms: data.duration_ms,
executed_at: null,
}
return {
...prev,
device_results: existing
? prev.device_results.map((r) => (r.device_id === data.device_id ? updated : r))
: [...prev.device_results, updated],
}
})
},
onTaskStatus: () => {
// Task finished — do a full refetch to get final status and complete results
qc.invalidateQueries({ queryKey: ['tasks', id] })
qc.invalidateQueries({ queryKey: ['tasks'] })
}, },
}) })
}
export const useActions = () => export const useActions = () =>
useQuery({ useQuery({
@ -133,4 +173,5 @@ export const useDeviceTasks = (deviceId: number) =>
api api
.get<Task[]>('/api/v1/tasks', { params: { device_id: deviceId, limit: 100 } }) .get<Task[]>('/api/v1/tasks', { params: { device_id: deviceId, limit: 100 } })
.then((r) => r.data), .then((r) => r.data),
refetchInterval: 10_000,
}) })

View file

@ -39,8 +39,9 @@ export function useSSE(taskId: string | null | undefined, handlers: SSEHandlers)
// ignore parse errors // ignore parse errors
} }
}, },
onerror() { onerror(err) {
ctrl.abort() ctrl.abort()
throw err
}, },
}) })

View file

@ -18,13 +18,14 @@ import {
Typography, Typography,
message, message,
} from 'antd' } from 'antd'
import { useState } from 'react' import { useState, useMemo } from 'react'
import { useQueryClient } from '@tanstack/react-query' import { useQueryClient } from '@tanstack/react-query'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { import {
useAlerts, useAlerts,
useAcknowledgeAlert, useAcknowledgeAlert,
useResolveAlert, useResolveAlert,
useAcknowledgeByObject,
type AlertItem, type AlertItem,
} from '../api/alerts' } from '../api/alerts'
import { useMonitorSSE } from '../hooks/useMonitorSSE' import { useMonitorSSE } from '../hooks/useMonitorSSE'
@ -48,6 +49,12 @@ function AlertStatusTag({ status }: { status: string }) {
) )
} }
interface ObjectGroup {
object_id: number | null
object_name: string | null
alerts: AlertItem[]
}
export function AlertsPage() { export function AlertsPage() {
const navigate = useNavigate() const navigate = useNavigate()
const qc = useQueryClient() const qc = useQueryClient()
@ -61,13 +68,28 @@ export function AlertsPage() {
const acknowledge = useAcknowledgeAlert() const acknowledge = useAcknowledgeAlert()
const resolve = useResolveAlert() const resolve = useResolveAlert()
const acknowledgeByObject = useAcknowledgeByObject()
// Live updates via monitor SSE
useMonitorSSE({ useMonitorSSE({
onAlertCreated: () => qc.invalidateQueries({ queryKey: ['alerts'] }), onAlertCreated: () => qc.invalidateQueries({ queryKey: ['alerts'] }),
onAlertUpdated: () => qc.invalidateQueries({ queryKey: ['alerts'] }), onAlertUpdated: () => qc.invalidateQueries({ queryKey: ['alerts'] }),
}) })
const groups = useMemo<ObjectGroup[]>(() => {
if (!alerts) return []
const map = new Map<number | null, ObjectGroup>()
for (const a of alerts) {
const key = a.object_id ?? null
if (!map.has(key)) {
map.set(key, { object_id: key, object_name: a.object_name, alerts: [] })
}
map.get(key)!.alerts.push(a)
}
return Array.from(map.values()).sort((a, b) =>
(a.object_name ?? '').localeCompare(b.object_name ?? '', 'ru')
)
}, [alerts])
const handleAcknowledge = async (id: number) => { const handleAcknowledge = async (id: number) => {
try { try {
await acknowledge.mutateAsync(id) await acknowledge.mutateAsync(id)
@ -86,6 +108,15 @@ export function AlertsPage() {
} }
} }
const handleAcknowledgeByObject = async (objectId: number) => {
try {
await acknowledgeByObject.mutateAsync(objectId)
message.success('Все алерты объекта приняты')
} catch {
message.error('Ошибка')
}
}
const columns = [ const columns = [
{ {
title: 'Статус', title: 'Статус',
@ -108,17 +139,6 @@ export function AlertsPage() {
</Space> </Space>
), ),
}, },
{
title: 'Объект',
dataIndex: 'object_name',
key: 'object_name',
render: (v: string | null, row: AlertItem) =>
v ? (
<a onClick={() => navigate(`/objects/${row.object_id}`)}>{v}</a>
) : (
'—'
),
},
{ {
title: 'Сообщение', title: 'Сообщение',
dataIndex: 'message', dataIndex: 'message',
@ -128,6 +148,7 @@ export function AlertsPage() {
title: 'Создан', title: 'Создан',
dataIndex: 'created_at', dataIndex: 'created_at',
key: 'created_at', key: 'created_at',
width: 110,
render: (v: string) => ( render: (v: string) => (
<Tooltip title={dayjs(v).format('DD.MM.YYYY HH:mm:ss')}> <Tooltip title={dayjs(v).format('DD.MM.YYYY HH:mm:ss')}>
{dayjs(v).format('DD.MM HH:mm')} {dayjs(v).format('DD.MM HH:mm')}
@ -137,6 +158,7 @@ export function AlertsPage() {
{ {
title: 'Принят', title: 'Принят',
key: 'acknowledged', key: 'acknowledged',
width: 130,
render: (_: unknown, row: AlertItem) => render: (_: unknown, row: AlertItem) =>
row.acknowledged_at ? ( row.acknowledged_at ? (
<Space direction="vertical" size={0}> <Space direction="vertical" size={0}>
@ -205,9 +227,7 @@ export function AlertsPage() {
<Typography.Title level={4} style={{ margin: 0 }}> <Typography.Title level={4} style={{ margin: 0 }}>
Алерты Алерты
</Typography.Title> </Typography.Title>
{openCount > 0 && ( {openCount > 0 && <Badge count={openCount} color="red" />}
<Badge count={openCount} color="red" />
)}
</Space> </Space>
</Col> </Col>
<Col> <Col>
@ -225,15 +245,60 @@ export function AlertsPage() {
</Col> </Col>
</Row> </Row>
<Table {isLoading ? (
dataSource={alerts ?? []} <Table loading columns={columns} dataSource={[]} rowKey="id" />
columns={columns} ) : (
rowKey="id" groups.map((group) => {
loading={isLoading} const openInGroup = group.alerts.filter((a) => a.status === 'open').length
size="middle" return (
pagination={{ pageSize: 50 }} <div key={group.object_id ?? 'unknown'} style={{ marginBottom: 24 }}>
rowClassName={(row) => (row.status === 'open' ? 'alert-row-open' : '')} <Row justify="space-between" align="middle" style={{ marginBottom: 8 }}>
/> <Col>
<Space align="center">
{group.object_id ? (
<Typography.Link
strong
onClick={() => navigate(`/work/objects/${group.object_id}`)}
>
{group.object_name ?? `Объект #${group.object_id}`}
</Typography.Link>
) : (
<Typography.Text strong>Без объекта</Typography.Text>
)}
<Badge count={group.alerts.length} color="blue" />
</Space>
</Col>
{openInGroup > 0 && group.object_id != null && (
<Col>
<Popconfirm
title={`Принять все ${openInGroup} открытых алертов объекта?`}
okText="Принять все"
cancelText="Отмена"
onConfirm={() => handleAcknowledgeByObject(group.object_id!)}
>
<Button
size="small"
icon={<ClockCircleOutlined />}
loading={acknowledgeByObject.isPending}
>
Принять все ({openInGroup})
</Button>
</Popconfirm>
</Col>
)}
</Row>
<Table
dataSource={group.alerts}
columns={columns}
rowKey="id"
size="small"
pagination={false}
rowClassName={(row) => (row.status === 'open' ? 'alert-row-open' : '')}
/>
</div>
)
})
)}
</div> </div>
) )
} }

View file

@ -45,7 +45,7 @@ import {
} from '../api/objects' } from '../api/objects'
import { useRacks, useCreateRack, useUpdateRack, useDeleteRack, type RackItem } from '../api/racks' import { useRacks, useCreateRack, useUpdateRack, useDeleteRack, type RackItem } from '../api/racks'
import { useZones, useCreateZone, useDeleteZone, type ZoneItem } from '../api/zones' import { useZones, useCreateZone, useDeleteZone, type ZoneItem } from '../api/zones'
import { useActions, useCreateTask, useTask } from '../api/tasks' import { useActions, useCreateTask, useTask, useTaskSSE } from '../api/tasks'
import { CategoryTag } from '../components/StatusBadge' import { CategoryTag } from '../components/StatusBadge'
import { DeviceEditModal } from '../components/DeviceEditModal' import { DeviceEditModal } from '../components/DeviceEditModal'
import { DiscoveryDrawer } from '../components/DiscoveryDrawer' import { DiscoveryDrawer } from '../components/DiscoveryDrawer'
@ -90,8 +90,11 @@ function PingButton({
const [taskId, setTaskId] = useState<string | null>(null) const [taskId, setTaskId] = useState<string | null>(null)
const queryClient = useQueryClient() const queryClient = useQueryClient()
const createTask = useCreateTask() const createTask = useCreateTask()
const { data: task } = useTask(taskId ?? undefined)
useTaskSSE(taskId ?? undefined)
// When task finishes (SSE invalidates ['tasks', taskId]), refresh devices and clear taskId
const { data: task } = useTask(taskId ?? undefined)
useEffect(() => { useEffect(() => {
if (task && ['success', 'failed', 'partial', 'stale', 'cancelled'].includes(task.status)) { if (task && ['success', 'failed', 'partial', 'stale', 'cancelled'].includes(task.status)) {
queryClient.invalidateQueries({ queryKey: ['devices', objId] }) queryClient.invalidateQueries({ queryKey: ['devices', objId] })