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:
@@ -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.9.7"
|
APP_VERSION = "0.9.8"
|
||||||
APP_VERSION_LABEL = f"{APP_NAME} v.{APP_VERSION}"
|
APP_VERSION_LABEL = f"{APP_NAME} v.{APP_VERSION}"
|
||||||
|
|||||||
@@ -11,9 +11,9 @@
|
|||||||
<li v-for="(line, idx) in activeUserLines" :key="idx">{{ line }}</li>
|
<li v-for="(line, idx) in activeUserLines" :key="idx">{{ line }}</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<pre v-if="plainBody" class="report-body-plain">{{ plainBody }}</pre>
|
<div v-if="htmlContent" class="report-body-html" v-html="htmlContent" />
|
||||||
|
|
||||||
<div v-else-if="htmlContent" class="report-body-html" v-html="htmlContent" />
|
<pre v-else-if="plainBody" class="report-body-plain">{{ plainBody }}</pre>
|
||||||
|
|
||||||
<p v-else-if="summary && summary !== 'Отчёт за сутки'" class="report-fallback">{{ summary }}</p>
|
<p v-else-if="summary && summary !== 'Отчёт за сутки'" class="report-fallback">{{ summary }}</p>
|
||||||
|
|
||||||
@@ -28,6 +28,7 @@
|
|||||||
import { computed } from "vue";
|
import { computed } from "vue";
|
||||||
import {
|
import {
|
||||||
reportActiveUserLines,
|
reportActiveUserLines,
|
||||||
|
normalizeReportPlainText,
|
||||||
reportBodyFromDetails,
|
reportBodyFromDetails,
|
||||||
reportHtmlFromDetails,
|
reportHtmlFromDetails,
|
||||||
reportStatEntries,
|
reportStatEntries,
|
||||||
@@ -50,5 +51,8 @@ const htmlContent = computed(() => {
|
|||||||
return raw ? sanitizeAgentHtml(raw) : null;
|
return raw ? sanitizeAgentHtml(raw) : null;
|
||||||
});
|
});
|
||||||
|
|
||||||
const plainBody = computed(() => reportBodyFromDetails(props.details));
|
const plainBody = computed(() => {
|
||||||
|
const raw = reportBodyFromDetails(props.details);
|
||||||
|
return raw ? normalizeReportPlainText(raw) : null;
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -378,6 +378,13 @@ pre {
|
|||||||
|
|
||||||
.report-body-html .agent-report {
|
.report-body-html .agent-report {
|
||||||
white-space: normal;
|
white-space: normal;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-body-html .agent-report br {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.25em;
|
||||||
|
content: "";
|
||||||
}
|
}
|
||||||
|
|
||||||
.report-body-plain {
|
.report-body-plain {
|
||||||
@@ -406,6 +413,43 @@ pre {
|
|||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.detail-fields {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||||
|
gap: 0.85rem 1.25rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-fields-compact {
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-field {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-field-wide {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-field dt {
|
||||||
|
margin: 0 0 0.2rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #9aa4b2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-field dd {
|
||||||
|
margin: 0;
|
||||||
|
color: #e8edf2;
|
||||||
|
word-break: break-word;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-field dd code {
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
.raw-details {
|
.raw-details {
|
||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,10 +49,27 @@ export interface NormalizedReportStat {
|
|||||||
value: string | number;
|
value: string | number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const DAILY_REPORT_TYPE_LABELS: Record<string, string> = {
|
||||||
|
"report.daily.ssh": "Отчёты ssh клиентов",
|
||||||
|
"report.daily.rdp": "Отчёты RDP клиентов",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function dailyReportTypeLabel(type: string): string {
|
||||||
|
return DAILY_REPORT_TYPE_LABELS[type] ?? type;
|
||||||
|
}
|
||||||
|
|
||||||
export function isDailyReportType(type: string): boolean {
|
export function isDailyReportType(type: string): boolean {
|
||||||
return type === "report.daily.ssh" || type === "report.daily.rdp";
|
return type === "report.daily.ssh" || type === "report.daily.rdp";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Текст отчёта: literal \\n и <br> из legacy-агентов → нормальные переносы. */
|
||||||
|
export function normalizeReportPlainText(body: string): string {
|
||||||
|
let text = body.replace(/\r\n/g, "\n");
|
||||||
|
text = text.replace(/\\n/g, "\n");
|
||||||
|
text = text.replace(/<br\s*\/?>/gi, "\n");
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
export function reportPlatform(type: string): "ssh" | "windows" {
|
export function reportPlatform(type: string): "ssh" | "windows" {
|
||||||
return type === "report.daily.rdp" ? "windows" : "ssh";
|
return type === "report.daily.rdp" ? "windows" : "ssh";
|
||||||
}
|
}
|
||||||
@@ -66,20 +83,29 @@ export function normalizeReportStats(
|
|||||||
const platform = stats.platform ?? reportPlatform(type);
|
const platform = stats.platform ?? reportPlatform(type);
|
||||||
const out: DailyReportStats = { ...stats, platform };
|
const out: DailyReportStats = { ...stats, platform };
|
||||||
|
|
||||||
|
const asInt = (v: unknown, fallback = 0): number => {
|
||||||
|
if (typeof v === "number" && Number.isFinite(v)) return v;
|
||||||
|
if (typeof v === "string") {
|
||||||
|
const n = parseInt(v.replace(/[^\d-]/g, ""), 10);
|
||||||
|
if (Number.isFinite(n)) return n;
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
};
|
||||||
|
|
||||||
if (platform === "ssh") {
|
if (platform === "ssh") {
|
||||||
out.successful_logins = stats.successful_logins ?? stats.successful_ssh ?? 0;
|
out.successful_logins = asInt(stats.successful_logins, asInt(stats.successful_ssh));
|
||||||
out.failed_logins = stats.failed_logins ?? stats.failed_ssh ?? 0;
|
out.failed_logins = asInt(stats.failed_logins, asInt(stats.failed_ssh));
|
||||||
out.sudo_commands = stats.sudo_commands ?? 0;
|
out.sudo_commands = asInt(stats.sudo_commands);
|
||||||
} else {
|
} else {
|
||||||
out.successful_logins = stats.successful_logins ?? stats.rdp_success ?? 0;
|
out.successful_logins = asInt(stats.successful_logins, asInt(stats.rdp_success));
|
||||||
out.failed_logins = stats.failed_logins ?? stats.rdp_failed ?? 0;
|
out.failed_logins = asInt(stats.failed_logins, asInt(stats.rdp_failed));
|
||||||
if (!out.active_users?.length && stats.unique_users?.length) {
|
if (!out.active_users?.length && stats.unique_users?.length) {
|
||||||
out.active_users = stats.unique_users.map((u) =>
|
out.active_users = stats.unique_users.map((u) =>
|
||||||
u.startsWith("👤") ? u : `👤 ${u}`,
|
u.startsWith("👤") ? u : `👤 ${u}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
out.active_bans = stats.active_bans ?? 0;
|
out.active_bans = asInt(stats.active_bans);
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
/** Fallback до загрузки /health; при релизе держите в sync с backend/app/version.py */
|
/** Fallback до загрузки /health; при релизе держите в sync с backend/app/version.py */
|
||||||
export const APP_NAME = "Security Alert Center";
|
export const APP_NAME = "Security Alert Center";
|
||||||
export const APP_VERSION = "0.9.7";
|
export const APP_VERSION = "0.9.8";
|
||||||
export const APP_VERSION_LABEL = `${APP_NAME} v.${APP_VERSION}`;
|
export const APP_VERSION_LABEL = `${APP_NAME} v.${APP_VERSION}`;
|
||||||
|
|||||||
@@ -1,131 +1,91 @@
|
|||||||
<template>
|
<template>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
|
|
||||||
<RouterLink v-if="fromReports" :to="{ path: '/reports', query: backToReportsQuery }">← Отчёты</RouterLink>
|
<RouterLink v-if="fromReports" :to="{ path: '/reports', query: backToReportsQuery }">← Отчёты</RouterLink>
|
||||||
|
|
||||||
<RouterLink v-else :to="{ path: '/events', query: backToEventsQuery }">← События</RouterLink>
|
<RouterLink v-else :to="{ path: '/events', query: backToEventsQuery }">← События</RouterLink>
|
||||||
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<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-if="event">
|
<template v-else-if="event">
|
||||||
|
|
||||||
<h1>{{ event.title }}</h1>
|
<h1>{{ event.title }}</h1>
|
||||||
|
|
||||||
<div class="card report-meta">
|
<div class="card report-meta">
|
||||||
|
<dl class="detail-fields">
|
||||||
<p>
|
<div class="detail-field">
|
||||||
|
<dt>ID</dt>
|
||||||
<strong>ID:</strong> {{ event.id }} · <strong>event_id:</strong> {{ event.event_id }}
|
<dd>{{ event.id }}</dd>
|
||||||
|
</div>
|
||||||
</p>
|
<div class="detail-field detail-field-wide">
|
||||||
|
<dt>event_id</dt>
|
||||||
<p>
|
<dd><code>{{ event.event_id }}</code></dd>
|
||||||
|
</div>
|
||||||
<strong>Хост:</strong>
|
<div class="detail-field">
|
||||||
|
<dt>Хост</dt>
|
||||||
|
<dd>
|
||||||
<RouterLink :to="{ path: '/reports', query: { hostname: event.hostname } }">
|
<RouterLink :to="{ path: '/reports', query: { hostname: event.hostname } }">
|
||||||
|
|
||||||
{{ event.hostname }}
|
{{ event.hostname }}
|
||||||
|
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
|
</dd>
|
||||||
· <strong>Severity:</strong>
|
</div>
|
||||||
|
<div class="detail-field">
|
||||||
<span :class="'sev-' + event.severity">{{ event.severity }}</span>
|
<dt>Severity</dt>
|
||||||
|
<dd><span :class="'sev-' + event.severity">{{ event.severity }}</span></dd>
|
||||||
</p>
|
</div>
|
||||||
|
<div class="detail-field">
|
||||||
<p><strong>Type:</strong> <code>{{ event.type }}</code></p>
|
<dt>Тип</dt>
|
||||||
|
<dd>
|
||||||
<p><strong>Occurred:</strong> {{ formatDt(event.occurred_at) }}</p>
|
<template v-if="isReport">{{ reportTypeLabel(event.type) }}</template>
|
||||||
|
<code v-else>{{ event.type }}</code>
|
||||||
<p v-if="!isReport"><strong>Summary:</strong> {{ event.summary }}</p>
|
</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>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<template v-if="isReport">
|
<template v-if="isReport">
|
||||||
|
|
||||||
<h2>Отчёт</h2>
|
<h2>Отчёт</h2>
|
||||||
|
|
||||||
<p class="report-summary-line">{{ event.summary }}</p>
|
<p class="report-summary-line">{{ event.summary }}</p>
|
||||||
|
|
||||||
<ReportBodyCard :type="event.type" :summary="event.summary" :details="event.details" />
|
<ReportBodyCard :type="event.type" :summary="event.summary" :details="event.details" />
|
||||||
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<details v-if="event.details && !isReport" class="raw-details">
|
<details v-if="event.details && !isReport" class="raw-details">
|
||||||
|
|
||||||
<summary>details (JSON)</summary>
|
<summary>details (JSON)</summary>
|
||||||
|
|
||||||
<pre>{{ JSON.stringify(event.details, null, 2) }}</pre>
|
<pre>{{ JSON.stringify(event.details, null, 2) }}</pre>
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<details v-if="isReport && event.details" class="raw-details">
|
<details v-if="isReport && event.details" class="raw-details">
|
||||||
|
|
||||||
<summary>details (JSON)</summary>
|
<summary>details (JSON)</summary>
|
||||||
|
|
||||||
<pre>{{ JSON.stringify(event.details, null, 2) }}</pre>
|
<pre>{{ JSON.stringify(event.details, null, 2) }}</pre>
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<details class="raw-details">
|
<details class="raw-details">
|
||||||
|
|
||||||
<summary>payload (JSON)</summary>
|
<summary>payload (JSON)</summary>
|
||||||
|
|
||||||
<pre>{{ JSON.stringify(event.payload, null, 2) }}</pre>
|
<pre>{{ JSON.stringify(event.payload, null, 2) }}</pre>
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<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 EventDetail } from "../api";
|
import { apiFetch, type EventDetail } from "../api";
|
||||||
|
|
||||||
import ReportBodyCard from "../components/ReportBodyCard.vue";
|
import ReportBodyCard from "../components/ReportBodyCard.vue";
|
||||||
|
|
||||||
import { eventsBackQueryFromDetail } from "../utils/eventsListQuery";
|
import { eventsBackQueryFromDetail } from "../utils/eventsListQuery";
|
||||||
import { isDailyReportType } from "../utils/reportDisplay";
|
import { dailyReportTypeLabel, isDailyReportType } from "../utils/reportDisplay";
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const props = defineProps<{ id: string }>();
|
const props = defineProps<{ id: string }>();
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const event = ref<EventDetail | null>(null);
|
const event = ref<EventDetail | null>(null);
|
||||||
|
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
|
|
||||||
const error = ref("");
|
const error = ref("");
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const fromReports = computed(() => route.query.from === "reports");
|
const fromReports = computed(() => route.query.from === "reports");
|
||||||
|
|
||||||
const isReport = computed(() => (event.value ? isDailyReportType(event.value.type) : false));
|
const isReport = computed(() => (event.value ? isDailyReportType(event.value.type) : false));
|
||||||
|
|
||||||
const backToReportsQuery = computed(() => {
|
const backToReportsQuery = computed(() => {
|
||||||
@@ -137,44 +97,26 @@ const backToReportsQuery = computed(() => {
|
|||||||
|
|
||||||
const backToEventsQuery = computed(() => eventsBackQueryFromDetail(route.query));
|
const backToEventsQuery = computed(() => eventsBackQueryFromDetail(route.query));
|
||||||
|
|
||||||
|
function reportTypeLabel(type: string) {
|
||||||
|
return dailyReportTypeLabel(type);
|
||||||
|
}
|
||||||
|
|
||||||
function formatDt(iso: string) {
|
function formatDt(iso: string) {
|
||||||
|
|
||||||
return new Date(iso).toLocaleString("ru-RU");
|
return new Date(iso).toLocaleString("ru-RU");
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
|
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
|
|
||||||
error.value = "";
|
error.value = "";
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
||||||
event.value = await apiFetch<EventDetail>(`/api/v1/events/${props.id}`);
|
event.value = await apiFetch<EventDetail>(`/api/v1/events/${props.id}`);
|
||||||
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
||||||
error.value = e instanceof Error ? e.message : "Не найдено";
|
error.value = e instanceof Error ? e.message : "Не найдено";
|
||||||
|
|
||||||
} finally {
|
} finally {
|
||||||
|
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
onMounted(load);
|
onMounted(load);
|
||||||
|
|
||||||
watch(() => props.id, load);
|
watch(() => props.id, load);
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -5,30 +5,60 @@
|
|||||||
<template v-else-if="host">
|
<template v-else-if="host">
|
||||||
<h1>{{ host.display_name || host.hostname }}</h1>
|
<h1>{{ host.display_name || host.hostname }}</h1>
|
||||||
<div class="card report-meta">
|
<div class="card report-meta">
|
||||||
<p>
|
<dl class="detail-fields">
|
||||||
<strong>Hostname:</strong> {{ host.hostname }}
|
<div class="detail-field">
|
||||||
· <strong>ID:</strong> {{ host.id }}
|
<dt>Hostname</dt>
|
||||||
· <strong>Агент:</strong> <span :class="'agent-' + host.agent_status">{{ agentLabel(host.agent_status) }}</span>
|
<dd>{{ host.hostname }}</dd>
|
||||||
</p>
|
</div>
|
||||||
<p>
|
<div class="detail-field">
|
||||||
<strong>Версия агента:</strong> {{ host.product_version || "—" }}
|
<dt>ID</dt>
|
||||||
· <strong>OS:</strong> {{ host.os_family }} {{ host.os_version || "" }}
|
<dd>{{ host.id }}</dd>
|
||||||
· <strong>IPv4:</strong> {{ host.ipv4 || "—" }}
|
</div>
|
||||||
</p>
|
<div class="detail-field">
|
||||||
<p>
|
<dt>Агент</dt>
|
||||||
<strong>Heartbeat:</strong> {{ host.last_heartbeat_at ? formatDt(host.last_heartbeat_at) : "—" }}
|
<dd><span :class="'agent-' + host.agent_status">{{ agentLabel(host.agent_status) }}</span></dd>
|
||||||
· <strong>Last seen:</strong> {{ formatDt(host.last_seen_at) }}
|
</div>
|
||||||
· <strong>Инвентаризация:</strong>
|
<div class="detail-field">
|
||||||
{{ host.last_inventory_at ? formatDt(host.last_inventory_at) : "—" }}
|
<dt>Версия агента</dt>
|
||||||
· <strong>Событий:</strong> {{ host.event_count ?? 0 }}
|
<dd>{{ host.product_version || "—" }}</dd>
|
||||||
</p>
|
</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>
|
</div>
|
||||||
|
|
||||||
<section v-if="inventory" class="host-inventory card">
|
<section v-if="inventory" class="host-inventory card">
|
||||||
<h2>Железо и ПО</h2>
|
<h2>Железо и ПО</h2>
|
||||||
<div v-if="windowsInfo" class="inv-block">
|
<div v-if="windowsInfo" class="inv-block">
|
||||||
<h3>Windows</h3>
|
<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>
|
||||||
<div v-if="computerName" class="inv-block">
|
<div v-if="computerName" class="inv-block">
|
||||||
<p><strong>Имя компьютера:</strong> {{ computerName }}</p>
|
<p><strong>Имя компьютера:</strong> {{ computerName }}</p>
|
||||||
@@ -38,7 +68,7 @@
|
|||||||
<ul>
|
<ul>
|
||||||
<li v-for="(p, i) in processors" :key="'cpu-' + i">
|
<li v-for="(p, i) in processors" :key="'cpu-' + i">
|
||||||
{{ p.name }}
|
{{ 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>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
@@ -160,13 +190,21 @@ const hostId = computed(() => parseInt(props.id, 10));
|
|||||||
|
|
||||||
const inventory = computed(() => host.value?.inventory ?? null);
|
const inventory = computed(() => host.value?.inventory ?? null);
|
||||||
|
|
||||||
const windowsInfo = computed(() => {
|
const windowsFields = computed(() => {
|
||||||
const w = inventory.value?.windows as Record<string, string> | undefined;
|
const w = inventory.value?.windows as Record<string, string> | undefined;
|
||||||
if (!w) return "";
|
if (!w) return [];
|
||||||
const parts = [w.product_name, w.version, w.os_hardware_abstraction_layer].filter(Boolean);
|
const labels: Record<string, string> = {
|
||||||
return parts.join(" · ");
|
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 computerName = computed(() => {
|
||||||
const v = inventory.value?.computer_name;
|
const v = inventory.value?.computer_name;
|
||||||
return typeof v === "string" && v.trim() ? v : "";
|
return typeof v === "string" && v.trim() ? v : "";
|
||||||
@@ -175,7 +213,13 @@ const computerName = computed(() => {
|
|||||||
const processors = computed(() => {
|
const processors = computed(() => {
|
||||||
const raw = inventory.value?.processor;
|
const raw = inventory.value?.processor;
|
||||||
if (!Array.isArray(raw)) return [];
|
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(() => {
|
const motherboard = computed(() => {
|
||||||
|
|||||||
@@ -1,15 +1,14 @@
|
|||||||
<template>
|
<template>
|
||||||
<h1>Отчёты агентов</h1>
|
<h1>Отчёты агентов</h1>
|
||||||
<p class="report-intro">
|
<p class="report-intro">
|
||||||
Ежедневные сводки с узлов (<code>report.daily.ssh</code>, <code>report.daily.rdp</code>). Нажмите на строку,
|
Ежедневные сводки с агентов. Нажмите на строку, чтобы открыть полный отчёт с форматированием.
|
||||||
чтобы открыть полный отчёт с форматированием.
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="filters">
|
<div class="filters">
|
||||||
<input v-model="hostname" placeholder="hostname / имя сервера" @keyup.enter="load(1)" />
|
<input v-model="hostname" placeholder="hostname / имя сервера" @keyup.enter="load(1)" />
|
||||||
<select v-model="reportType" @change="load(1)">
|
<select v-model="reportType" @change="load(1)">
|
||||||
<option value="report.daily.ssh">SSH (report.daily.ssh)</option>
|
<option value="report.daily.ssh">Отчёты ssh клиентов</option>
|
||||||
<option value="report.daily.rdp">RDP (report.daily.rdp)</option>
|
<option value="report.daily.rdp">Отчёты RDP клиентов</option>
|
||||||
</select>
|
</select>
|
||||||
<button type="button" @click="load(1)">Применить</button>
|
<button type="button" @click="load(1)">Применить</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -42,7 +41,7 @@
|
|||||||
<strong>{{ e.hostname }}</strong>
|
<strong>{{ e.hostname }}</strong>
|
||||||
</td>
|
</td>
|
||||||
<td>{{ formatServerName(e.display_name) }}</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>
|
<td class="report-summary-cell">{{ e.summary }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -67,6 +66,11 @@ import { onMounted, ref, watch } from "vue";
|
|||||||
import { useRoute, useRouter } from "vue-router";
|
import { useRoute, useRouter } from "vue-router";
|
||||||
import { apiFetch, type EventListResponse } from "../api";
|
import { apiFetch, type EventListResponse } from "../api";
|
||||||
import { formatServerName } from "../utils/hostDisplay";
|
import { formatServerName } from "../utils/hostDisplay";
|
||||||
|
import { dailyReportTypeLabel } from "../utils/reportDisplay";
|
||||||
|
|
||||||
|
function reportTypeLabel(type: string) {
|
||||||
|
return dailyReportTypeLabel(type);
|
||||||
|
}
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|||||||
Reference in New Issue
Block a user