feat: readable report and host detail layout in web UI (0.9.8)

Field grid for event/host cards, human-readable daily report labels,
and normalized plain-text report bodies.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
PTah
2026-06-11 10:25:53 +10:00
parent fed21c536b
commit befaf86bf4
8 changed files with 203 additions and 139 deletions
+41 -99
View File
@@ -1,131 +1,91 @@
<template>
<p>
<RouterLink v-if="fromReports" :to="{ path: '/reports', query: backToReportsQuery }"> Отчёты</RouterLink>
<RouterLink v-else :to="{ path: '/events', query: backToEventsQuery }"> События</RouterLink>
</p>
<p v-if="error" class="error">{{ error }}</p>
<p v-else-if="loading">Загрузка</p>
<template v-else-if="event">
<h1>{{ event.title }}</h1>
<div class="card report-meta">
<p>
<strong>ID:</strong> {{ event.id }} · <strong>event_id:</strong> {{ event.event_id }}
</p>
<p>
<strong>Хост:</strong>&nbsp;
<RouterLink :to="{ path: '/reports', query: { hostname: event.hostname } }">
{{ event.hostname }}
</RouterLink>
· <strong>Severity:</strong>&nbsp;
<span :class="'sev-' + event.severity">{{ event.severity }}</span>
</p>
<p><strong>Type:</strong> <code>{{ event.type }}</code></p>
<p><strong>Occurred:</strong> {{ formatDt(event.occurred_at) }}</p>
<p v-if="!isReport"><strong>Summary:</strong> {{ event.summary }}</p>
<dl class="detail-fields">
<div class="detail-field">
<dt>ID</dt>
<dd>{{ event.id }}</dd>
</div>
<div class="detail-field detail-field-wide">
<dt>event_id</dt>
<dd><code>{{ event.event_id }}</code></dd>
</div>
<div class="detail-field">
<dt>Хост</dt>
<dd>
<RouterLink :to="{ path: '/reports', query: { hostname: event.hostname } }">
{{ event.hostname }}
</RouterLink>
</dd>
</div>
<div class="detail-field">
<dt>Severity</dt>
<dd><span :class="'sev-' + event.severity">{{ event.severity }}</span></dd>
</div>
<div class="detail-field">
<dt>Тип</dt>
<dd>
<template v-if="isReport">{{ reportTypeLabel(event.type) }}</template>
<code v-else>{{ event.type }}</code>
</dd>
</div>
<div class="detail-field">
<dt>Occurred</dt>
<dd>{{ formatDt(event.occurred_at) }}</dd>
</div>
<div v-if="!isReport" class="detail-field detail-field-wide">
<dt>Summary</dt>
<dd>{{ event.summary }}</dd>
</div>
</dl>
</div>
<template v-if="isReport">
<h2>Отчёт</h2>
<p class="report-summary-line">{{ event.summary }}</p>
<ReportBodyCard :type="event.type" :summary="event.summary" :details="event.details" />
</template>
<details v-if="event.details && !isReport" class="raw-details">
<summary>details (JSON)</summary>
<pre>{{ JSON.stringify(event.details, null, 2) }}</pre>
</details>
<details v-if="isReport && event.details" class="raw-details">
<summary>details (JSON)</summary>
<pre>{{ JSON.stringify(event.details, null, 2) }}</pre>
</details>
<details class="raw-details">
<summary>payload (JSON)</summary>
<pre>{{ JSON.stringify(event.payload, null, 2) }}</pre>
</details>
</template>
</template>
<script setup lang="ts">
import { computed, onMounted, ref, watch } from "vue";
import { useRoute } from "vue-router";
import { apiFetch, type EventDetail } from "../api";
import ReportBodyCard from "../components/ReportBodyCard.vue";
import { eventsBackQueryFromDetail } from "../utils/eventsListQuery";
import { isDailyReportType } from "../utils/reportDisplay";
import { dailyReportTypeLabel, isDailyReportType } from "../utils/reportDisplay";
const props = defineProps<{ id: string }>();
const route = useRoute();
const event = ref<EventDetail | null>(null);
const loading = ref(false);
const error = ref("");
const fromReports = computed(() => route.query.from === "reports");
const isReport = computed(() => (event.value ? isDailyReportType(event.value.type) : false));
const backToReportsQuery = computed(() => {
@@ -137,44 +97,26 @@ const backToReportsQuery = computed(() => {
const backToEventsQuery = computed(() => eventsBackQueryFromDetail(route.query));
function reportTypeLabel(type: string) {
return dailyReportTypeLabel(type);
}
function formatDt(iso: string) {
return new Date(iso).toLocaleString("ru-RU");
}
async function load() {
loading.value = true;
error.value = "";
try {
event.value = await apiFetch<EventDetail>(`/api/v1/events/${props.id}`);
} catch (e) {
error.value = e instanceof Error ? e.message : "Не найдено";
} finally {
loading.value = false;
}
}
onMounted(load);
watch(() => props.id, load);
</script>
+68 -24
View File
@@ -5,30 +5,60 @@
<template v-else-if="host">
<h1>{{ host.display_name || host.hostname }}</h1>
<div class="card report-meta">
<p>
<strong>Hostname:</strong> {{ host.hostname }}
· <strong>ID:</strong> {{ host.id }}
· <strong>Агент:</strong> <span :class="'agent-' + host.agent_status">{{ agentLabel(host.agent_status) }}</span>
</p>
<p>
<strong>Версия агента:</strong> {{ host.product_version || "—" }}
· <strong>OS:</strong> {{ host.os_family }} {{ host.os_version || "" }}
· <strong>IPv4:</strong> {{ host.ipv4 || "—" }}
</p>
<p>
<strong>Heartbeat:</strong> {{ host.last_heartbeat_at ? formatDt(host.last_heartbeat_at) : "—" }}
· <strong>Last seen:</strong> {{ formatDt(host.last_seen_at) }}
· <strong>Инвентаризация:</strong>
{{ host.last_inventory_at ? formatDt(host.last_inventory_at) : "—" }}
· <strong>Событий:</strong> {{ host.event_count ?? 0 }}
</p>
<dl class="detail-fields">
<div class="detail-field">
<dt>Hostname</dt>
<dd>{{ host.hostname }}</dd>
</div>
<div class="detail-field">
<dt>ID</dt>
<dd>{{ host.id }}</dd>
</div>
<div class="detail-field">
<dt>Агент</dt>
<dd><span :class="'agent-' + host.agent_status">{{ agentLabel(host.agent_status) }}</span></dd>
</div>
<div class="detail-field">
<dt>Версия агента</dt>
<dd>{{ host.product_version || "—" }}</dd>
</div>
<div class="detail-field">
<dt>OS</dt>
<dd>{{ host.os_family }} {{ host.os_version || "" }}</dd>
</div>
<div class="detail-field">
<dt>IPv4</dt>
<dd>{{ host.ipv4 || "—" }}</dd>
</div>
<div class="detail-field">
<dt>Heartbeat</dt>
<dd>{{ host.last_heartbeat_at ? formatDt(host.last_heartbeat_at) : "—" }}</dd>
</div>
<div class="detail-field">
<dt>Last seen</dt>
<dd>{{ formatDt(host.last_seen_at) }}</dd>
</div>
<div class="detail-field">
<dt>Инвентаризация</dt>
<dd>{{ host.last_inventory_at ? formatDt(host.last_inventory_at) : "—" }}</dd>
</div>
<div class="detail-field">
<dt>Событий</dt>
<dd>{{ host.event_count ?? 0 }}</dd>
</div>
</dl>
</div>
<section v-if="inventory" class="host-inventory card">
<h2>Железо и ПО</h2>
<div v-if="windowsInfo" class="inv-block">
<h3>Windows</h3>
<p>{{ windowsInfo }}</p>
<dl class="detail-fields detail-fields-compact">
<div v-for="item in windowsFields" :key="item.label" class="detail-field">
<dt>{{ item.label }}</dt>
<dd>{{ item.value }}</dd>
</div>
</dl>
</div>
<div v-if="computerName" class="inv-block">
<p><strong>Имя компьютера:</strong> {{ computerName }}</p>
@@ -38,7 +68,7 @@
<ul>
<li v-for="(p, i) in processors" :key="'cpu-' + i">
{{ p.name }}
<span v-if="p.cores != null"> {{ p.cores }} ядер / {{ p.logical_processors }} потоков</span>
<span v-if="p.cores != null"> {{ p.cores }} ядер / {{ p.logical }} потоков</span>
</li>
</ul>
</div>
@@ -160,13 +190,21 @@ const hostId = computed(() => parseInt(props.id, 10));
const inventory = computed(() => host.value?.inventory ?? null);
const windowsInfo = computed(() => {
const windowsFields = computed(() => {
const w = inventory.value?.windows as Record<string, string> | undefined;
if (!w) return "";
const parts = [w.product_name, w.version, w.os_hardware_abstraction_layer].filter(Boolean);
return parts.join(" · ");
if (!w) return [];
const labels: Record<string, string> = {
product_name: "Продукт",
version: "Версия",
os_hardware_abstraction_layer: "HAL",
};
return Object.entries(labels)
.map(([key, label]) => ({ label, value: w[key]?.trim() || "" }))
.filter((item) => item.value);
});
const windowsInfo = computed(() => windowsFields.value.length > 0);
const computerName = computed(() => {
const v = inventory.value?.computer_name;
return typeof v === "string" && v.trim() ? v : "";
@@ -175,7 +213,13 @@ const computerName = computed(() => {
const processors = computed(() => {
const raw = inventory.value?.processor;
if (!Array.isArray(raw)) return [];
return raw.filter((p): p is Record<string, unknown> => p != null && typeof p === "object");
return raw
.filter((p): p is Record<string, unknown> => p != null && typeof p === "object")
.map((p) => ({
name: String(p.name ?? "—"),
cores: typeof p.cores === "number" ? p.cores : null,
logical: typeof p.logical_processors === "number" ? p.logical_processors : null,
}));
});
const motherboard = computed(() => {
+9 -5
View File
@@ -1,15 +1,14 @@
<template>
<h1>Отчёты агентов</h1>
<p class="report-intro">
Ежедневные сводки с узлов (<code>report.daily.ssh</code>, <code>report.daily.rdp</code>). Нажмите на строку,
чтобы открыть полный отчёт с форматированием.
Ежедневные сводки с агентов. Нажмите на строку, чтобы открыть полный отчёт с форматированием.
</p>
<div class="filters">
<input v-model="hostname" placeholder="hostname / имя сервера" @keyup.enter="load(1)" />
<select v-model="reportType" @change="load(1)">
<option value="report.daily.ssh">SSH (report.daily.ssh)</option>
<option value="report.daily.rdp">RDP (report.daily.rdp)</option>
<option value="report.daily.ssh">Отчёты ssh клиентов</option>
<option value="report.daily.rdp">Отчёты RDP клиентов</option>
</select>
<button type="button" @click="load(1)">Применить</button>
</div>
@@ -42,7 +41,7 @@
<strong>{{ e.hostname }}</strong>
</td>
<td>{{ formatServerName(e.display_name) }}</td>
<td><code>{{ e.type }}</code></td>
<td>{{ reportTypeLabel(e.type) }}</td>
<td class="report-summary-cell">{{ e.summary }}</td>
</tr>
</tbody>
@@ -67,6 +66,11 @@ import { onMounted, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { apiFetch, type EventListResponse } from "../api";
import { formatServerName } from "../utils/hostDisplay";
import { dailyReportTypeLabel } from "../utils/reportDisplay";
function reportTypeLabel(type: string) {
return dailyReportTypeLabel(type);
}
const route = useRoute();
const router = useRouter();