feat(ui): live host row update via SSE without full reload
On new ingest, patch visible host from GET /hosts/{id}; SAC 0.8.1.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
"""Единый источник версии SAC (API, health, логи, OpenAPI)."""
|
"""Единый источник версии SAC (API, health, логи, OpenAPI)."""
|
||||||
|
|
||||||
APP_NAME = "Security Alert Center"
|
APP_NAME = "Security Alert Center"
|
||||||
APP_VERSION = "0.8.0"
|
APP_VERSION = "0.8.1"
|
||||||
APP_VERSION_LABEL = f"{APP_NAME} v.{APP_VERSION}"
|
APP_VERSION_LABEL = f"{APP_NAME} v.{APP_VERSION}"
|
||||||
|
|||||||
@@ -214,6 +214,10 @@ export function fetchHost(id: number): Promise<HostDetail> {
|
|||||||
return apiFetch<HostDetail>(`/api/v1/hosts/${id}`);
|
return apiFetch<HostDetail>(`/api/v1/hosts/${id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function fetchRecentEvents(limit = 1): Promise<EventSummary[]> {
|
||||||
|
return apiFetch<EventSummary[]>(`/api/v1/dashboards/recent-events?limit=${limit}`);
|
||||||
|
}
|
||||||
|
|
||||||
export interface HostListResponse {
|
export interface HostListResponse {
|
||||||
items: HostSummary[];
|
items: HostSummary[];
|
||||||
total: number;
|
total: number;
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { onMounted, onUnmounted, ref } from "vue";
|
||||||
|
import { getToken } from "../api";
|
||||||
|
|
||||||
|
export interface SacDashboardStreamMessage {
|
||||||
|
type?: string;
|
||||||
|
last_event_id?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** SSE /api/v1/stream/events — вызывает onNewEvent при новом last_event_id (после первого тика). */
|
||||||
|
export function useSacLiveEventStream(onNewEvent: () => void) {
|
||||||
|
const live = ref(false);
|
||||||
|
let eventSource: EventSource | null = null;
|
||||||
|
let lastKnownEventDbId: number | null = null;
|
||||||
|
|
||||||
|
function handleStreamMessage(msg: SacDashboardStreamMessage) {
|
||||||
|
if (msg.type !== "dashboard") return;
|
||||||
|
const incoming = msg.last_event_id;
|
||||||
|
if (incoming === undefined) return;
|
||||||
|
|
||||||
|
if (incoming === null) {
|
||||||
|
if (lastKnownEventDbId !== null) {
|
||||||
|
onNewEvent();
|
||||||
|
}
|
||||||
|
lastKnownEventDbId = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastKnownEventDbId === null) {
|
||||||
|
lastKnownEventDbId = incoming;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (incoming !== lastKnownEventDbId) {
|
||||||
|
lastKnownEventDbId = incoming;
|
||||||
|
onNewEvent();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function connect() {
|
||||||
|
const token = getToken();
|
||||||
|
if (!token) return;
|
||||||
|
|
||||||
|
eventSource?.close();
|
||||||
|
const url = `/api/v1/stream/events?token=${encodeURIComponent(token)}`;
|
||||||
|
eventSource = new EventSource(url);
|
||||||
|
|
||||||
|
eventSource.onopen = () => {
|
||||||
|
live.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
eventSource.onmessage = (ev) => {
|
||||||
|
try {
|
||||||
|
handleStreamMessage(JSON.parse(ev.data) as SacDashboardStreamMessage);
|
||||||
|
} catch {
|
||||||
|
/* ignore malformed SSE */
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
eventSource.onerror = () => {
|
||||||
|
live.value = false;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => connect());
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
eventSource?.close();
|
||||||
|
eventSource = null;
|
||||||
|
live.value = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
return { live };
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import type { HostDetail, HostSummary } from "../api";
|
||||||
|
|
||||||
|
/** Обновляет поля строки списка хостов из GET /hosts/{id} (без перезагрузки таблицы). */
|
||||||
|
export function patchHostSummaryFromDetail(target: HostSummary, detail: HostDetail): void {
|
||||||
|
target.display_name = detail.display_name;
|
||||||
|
target.os_version = detail.os_version;
|
||||||
|
target.product_version = detail.product_version;
|
||||||
|
target.ipv4 = detail.ipv4;
|
||||||
|
target.last_seen_at = detail.last_seen_at;
|
||||||
|
target.event_count = detail.event_count;
|
||||||
|
target.last_heartbeat_at = detail.last_heartbeat_at;
|
||||||
|
target.last_daily_report_at = detail.last_daily_report_at;
|
||||||
|
target.last_inventory_at = detail.last_inventory_at;
|
||||||
|
target.agent_status = detail.agent_status;
|
||||||
|
}
|
||||||
@@ -1,3 +1,3 @@
|
|||||||
export const APP_NAME = "Security Alert Center";
|
export const APP_NAME = "Security Alert Center";
|
||||||
export const APP_VERSION = "0.8.0";
|
export const APP_VERSION = "0.8.1";
|
||||||
export const APP_VERSION_LABEL = `${APP_NAME} v.${APP_VERSION}`;
|
export const APP_VERSION_LABEL = `${APP_NAME} v.${APP_VERSION}`;
|
||||||
|
|||||||
@@ -130,7 +130,14 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref, watch } from "vue";
|
import { computed, onMounted, ref, watch } from "vue";
|
||||||
import { useRoute } from "vue-router";
|
import { useRoute } from "vue-router";
|
||||||
import { apiFetch, type EventListResponse, type HostDetail } from "../api";
|
import { useSacLiveEventStream } from "../composables/useSacLiveEventStream";
|
||||||
|
import {
|
||||||
|
apiFetch,
|
||||||
|
fetchHost,
|
||||||
|
fetchRecentEvents,
|
||||||
|
type EventListResponse,
|
||||||
|
type HostDetail,
|
||||||
|
} from "../api";
|
||||||
|
|
||||||
const props = defineProps<{ id: string }>();
|
const props = defineProps<{ id: string }>();
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
@@ -143,6 +150,11 @@ const error = ref("");
|
|||||||
const eventsError = ref("");
|
const eventsError = ref("");
|
||||||
const eventsPage = ref(1);
|
const eventsPage = ref(1);
|
||||||
const eventsPageSize = 50;
|
const eventsPageSize = 50;
|
||||||
|
let refreshingLive = false;
|
||||||
|
|
||||||
|
useSacLiveEventStream(() => {
|
||||||
|
void refreshFromLatestEvent();
|
||||||
|
});
|
||||||
|
|
||||||
const hostId = computed(() => parseInt(props.id, 10));
|
const hostId = computed(() => parseInt(props.id, 10));
|
||||||
|
|
||||||
@@ -211,7 +223,7 @@ async function loadHost() {
|
|||||||
loading.value = true;
|
loading.value = true;
|
||||||
error.value = "";
|
error.value = "";
|
||||||
try {
|
try {
|
||||||
host.value = await apiFetch<HostDetail>(`/api/v1/hosts/${hostId.value}`);
|
host.value = await fetchHost(hostId.value);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = e instanceof Error ? e.message : "Ошибка загрузки хоста";
|
error.value = e instanceof Error ? e.message : "Ошибка загрузки хоста";
|
||||||
} finally {
|
} finally {
|
||||||
@@ -219,6 +231,24 @@ async function loadHost() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function refreshFromLatestEvent() {
|
||||||
|
if (refreshingLive) return;
|
||||||
|
refreshingLive = true;
|
||||||
|
try {
|
||||||
|
const recent = await fetchRecentEvents(1);
|
||||||
|
const ev = recent[0];
|
||||||
|
if (!ev || ev.host_id !== hostId.value) return;
|
||||||
|
host.value = await fetchHost(hostId.value);
|
||||||
|
if (eventsPage.value === 1) {
|
||||||
|
await loadEvents(1);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* ignore transient errors */
|
||||||
|
} finally {
|
||||||
|
refreshingLive = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function loadEvents(page = 1) {
|
async function loadEvents(page = 1) {
|
||||||
eventsLoading.value = true;
|
eventsLoading.value = true;
|
||||||
eventsError.value = "";
|
eventsError.value = "";
|
||||||
|
|||||||
@@ -10,7 +10,10 @@
|
|||||||
<p v-if="error" class="error">{{ error }}</p>
|
<p v-if="error" class="error">{{ error }}</p>
|
||||||
<p v-else-if="loading">Загрузка…</p>
|
<p v-else-if="loading">Загрузка…</p>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<p>Всего: {{ data?.total ?? 0 }}</p>
|
<p>
|
||||||
|
Всего: {{ data?.total ?? 0 }}
|
||||||
|
<span v-if="live" class="live-badge">live</span>
|
||||||
|
</p>
|
||||||
<table class="hosts-table">
|
<table class="hosts-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -77,7 +80,15 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref } from "vue";
|
import { computed, onMounted, ref } from "vue";
|
||||||
import { useRouter } from "vue-router";
|
import { useRouter } from "vue-router";
|
||||||
import { apiFetch, type HostListResponse, type HostSummary } from "../api";
|
import { useSacLiveEventStream } from "../composables/useSacLiveEventStream";
|
||||||
|
import {
|
||||||
|
apiFetch,
|
||||||
|
fetchHost,
|
||||||
|
fetchRecentEvents,
|
||||||
|
type HostListResponse,
|
||||||
|
type HostSummary,
|
||||||
|
} from "../api";
|
||||||
|
import { patchHostSummaryFromDetail } from "../utils/hostsLiveUpdate";
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
@@ -139,6 +150,11 @@ const initialSort = loadHostsSortPrefs();
|
|||||||
const sortBy = ref<SortKey>(initialSort.sortBy);
|
const sortBy = ref<SortKey>(initialSort.sortBy);
|
||||||
const sortDir = ref<"asc" | "desc">(initialSort.sortDir);
|
const sortDir = ref<"asc" | "desc">(initialSort.sortDir);
|
||||||
const deletingId = ref<number | null>(null);
|
const deletingId = ref<number | null>(null);
|
||||||
|
let patchingHostRow = false;
|
||||||
|
|
||||||
|
const { live } = useSacLiveEventStream(() => {
|
||||||
|
void patchHostRowFromLatestEvent();
|
||||||
|
});
|
||||||
|
|
||||||
function formatDt(iso: string) {
|
function formatDt(iso: string) {
|
||||||
return new Date(iso).toLocaleString("ru-RU");
|
return new Date(iso).toLocaleString("ru-RU");
|
||||||
@@ -248,6 +264,24 @@ async function loadHosts() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function patchHostRowFromLatestEvent() {
|
||||||
|
if (!data.value?.items.length || patchingHostRow) return;
|
||||||
|
patchingHostRow = true;
|
||||||
|
try {
|
||||||
|
const recent = await fetchRecentEvents(1);
|
||||||
|
const ev = recent[0];
|
||||||
|
if (!ev) return;
|
||||||
|
const row = data.value.items.find((h) => h.id === ev.host_id);
|
||||||
|
if (!row) return;
|
||||||
|
const detail = await fetchHost(ev.host_id);
|
||||||
|
patchHostSummaryFromDetail(row, detail);
|
||||||
|
} catch {
|
||||||
|
/* keep table on transient errors */
|
||||||
|
} finally {
|
||||||
|
patchingHostRow = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
interface HostDeleteResponse {
|
interface HostDeleteResponse {
|
||||||
status: string;
|
status: string;
|
||||||
host_id: number;
|
host_id: number;
|
||||||
|
|||||||
Reference in New Issue
Block a user