feat: reports page with formatted daily report cards
This commit is contained in:
@@ -14,7 +14,9 @@ SAC отображает их в **События** и на **Dashboard / Хос
|
|||||||
## UI SAC
|
## UI SAC
|
||||||
|
|
||||||
- **Dashboard:** счётчики heartbeat/отчётов за 24 ч, хосты с устаревшим heartbeat.
|
- **Dashboard:** счётчики heartbeat/отчётов за 24 ч, хосты с устаревшим heartbeat.
|
||||||
- **Хосты:** колонки *Агент* (`online` / `stale` / `unknown`), время последнего heartbeat и отчёта.
|
- **Хосты:** колонки *Агент* (`online` / `stale` / `unknown`), время последнего heartbeat и отчёта (ссылка на **Отчёты** по hostname).
|
||||||
|
- **Отчёты** (`/reports`): список `report.daily.*` по узлам; клик по строке — карточка с метриками и полным текстом (HTML/`report_body` в `details`).
|
||||||
|
- Старые события без `details.report_body` показывают подсказку обновить агент.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
<span class="brand">{{ appTitle }}</span>
|
<span class="brand">{{ appTitle }}</span>
|
||||||
<RouterLink to="/dashboard">Dashboard</RouterLink>
|
<RouterLink to="/dashboard">Dashboard</RouterLink>
|
||||||
<RouterLink to="/events">События</RouterLink>
|
<RouterLink to="/events">События</RouterLink>
|
||||||
|
<RouterLink to="/reports">Отчёты</RouterLink>
|
||||||
<RouterLink to="/problems">Проблемы</RouterLink>
|
<RouterLink to="/problems">Проблемы</RouterLink>
|
||||||
<RouterLink to="/hosts">Хосты</RouterLink>
|
<RouterLink to="/hosts">Хосты</RouterLink>
|
||||||
<button type="button" class="secondary" @click="logout">Выход</button>
|
<button type="button" class="secondary" @click="logout">Выход</button>
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
<template>
|
||||||
|
<div class="report-card">
|
||||||
|
<div v-if="statsEntries.length" class="report-stats">
|
||||||
|
<div v-for="item in statsEntries" :key="item.key" class="report-stat">
|
||||||
|
<div class="report-stat-value">{{ item.value }}</div>
|
||||||
|
<div class="report-stat-label">{{ item.label }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-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 class="report-empty">
|
||||||
|
Полный текст отчёта не сохранён (старая версия агента). Обновите ssh-monitor и дождитесь следующего
|
||||||
|
ежедневного отчёта.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from "vue";
|
||||||
|
import {
|
||||||
|
reportBodyFromDetails,
|
||||||
|
reportHtmlFromDetails,
|
||||||
|
reportStatsFromDetails,
|
||||||
|
sanitizeAgentHtml,
|
||||||
|
type DailyReportStats,
|
||||||
|
} from "../utils/reportDisplay";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
type: string;
|
||||||
|
summary: string;
|
||||||
|
details: Record<string, unknown> | null;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const stats = computed(() => reportStatsFromDetails(props.details));
|
||||||
|
|
||||||
|
const statLabels: Record<keyof DailyReportStats, string> = {
|
||||||
|
successful_ssh: "Успешных SSH",
|
||||||
|
failed_ssh: "Неудачных SSH",
|
||||||
|
sudo_commands: "Sudo",
|
||||||
|
active_bans: "Активных банов",
|
||||||
|
active_sessions: "Сессий (SSH)",
|
||||||
|
active_sessions_rdp: "Сессий (RDP)",
|
||||||
|
};
|
||||||
|
|
||||||
|
const statsEntries = computed(() => {
|
||||||
|
const s = stats.value;
|
||||||
|
if (!s) return [];
|
||||||
|
const out: { key: string; label: string; value: string | number }[] = [];
|
||||||
|
for (const [key, label] of Object.entries(statLabels)) {
|
||||||
|
const k = key as keyof DailyReportStats;
|
||||||
|
const v = s[k];
|
||||||
|
if (typeof v === "number") {
|
||||||
|
out.push({ key, label, value: v });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Array.isArray(s.unique_users) && s.unique_users.length) {
|
||||||
|
out.push({
|
||||||
|
key: "unique_users",
|
||||||
|
label: "Уникальные логины",
|
||||||
|
value: s.unique_users.length,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
});
|
||||||
|
|
||||||
|
const htmlContent = computed(() => {
|
||||||
|
const raw = reportHtmlFromDetails(props.details);
|
||||||
|
return raw ? sanitizeAgentHtml(raw) : null;
|
||||||
|
});
|
||||||
|
|
||||||
|
const plainBody = computed(() => reportBodyFromDetails(props.details));
|
||||||
|
</script>
|
||||||
@@ -6,6 +6,7 @@ import HostsView from "./views/HostsView.vue";
|
|||||||
import LoginView from "./views/LoginView.vue";
|
import LoginView from "./views/LoginView.vue";
|
||||||
import ProblemsView from "./views/ProblemsView.vue";
|
import ProblemsView from "./views/ProblemsView.vue";
|
||||||
import DashboardView from "./views/DashboardView.vue";
|
import DashboardView from "./views/DashboardView.vue";
|
||||||
|
import ReportsView from "./views/ReportsView.vue";
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
history: createWebHistory(),
|
history: createWebHistory(),
|
||||||
@@ -15,6 +16,7 @@ const router = createRouter({
|
|||||||
{ path: "/login", component: LoginView },
|
{ path: "/login", component: LoginView },
|
||||||
{ path: "/events", component: EventsView },
|
{ path: "/events", component: EventsView },
|
||||||
{ path: "/events/:id", component: EventDetailView, props: true },
|
{ path: "/events/:id", component: EventDetailView, props: true },
|
||||||
|
{ path: "/reports", component: ReportsView },
|
||||||
{ path: "/hosts", component: HostsView },
|
{ path: "/hosts", component: HostsView },
|
||||||
{ path: "/problems", component: ProblemsView },
|
{ path: "/problems", component: ProblemsView },
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -189,3 +189,102 @@ pre {
|
|||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.05em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.report-intro {
|
||||||
|
color: #9aa4b2;
|
||||||
|
margin-top: -0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reports-table .report-row {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reports-table .report-row:hover,
|
||||||
|
.reports-table .report-row:focus {
|
||||||
|
background: #1a2332;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-summary-cell {
|
||||||
|
max-width: 420px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-card {
|
||||||
|
background: #1a2332;
|
||||||
|
border: 1px solid #2a3441;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1.25rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-stats {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-stat {
|
||||||
|
background: #0f1419;
|
||||||
|
border: 1px solid #2a3441;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0.75rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-stat-value {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-stat-label {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #9aa4b2;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-body-html {
|
||||||
|
line-height: 1.55;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-body-html .agent-report {
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-body-plain {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
margin: 0;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
background: #0f1419;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid #2a3441;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-empty,
|
||||||
|
.report-fallback {
|
||||||
|
color: #9aa4b2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-summary-line {
|
||||||
|
color: #9aa4b2;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-meta {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.raw-details {
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.raw-details summary {
|
||||||
|
cursor: pointer;
|
||||||
|
color: #9aa4b2;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
/** Безопасный вывод HTML из агента (только базовая разметка). */
|
||||||
|
export function sanitizeAgentHtml(html: string): string {
|
||||||
|
const allowed = /^(b|strong|i|em|u|code|pre|br|p|div|span|ul|ol|li|hr)$/i;
|
||||||
|
return html.replace(/<\/?([a-z][a-z0-9]*)\b[^>]*>/gi, (tag, name: string) => {
|
||||||
|
if (allowed.test(name)) {
|
||||||
|
if (tag.toLowerCase().startsWith("</")) {
|
||||||
|
return `</${name.toLowerCase()}>`;
|
||||||
|
}
|
||||||
|
if (name.toLowerCase() === "br") {
|
||||||
|
return "<br>";
|
||||||
|
}
|
||||||
|
return `<${name.toLowerCase()}>`;
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DailyReportStats {
|
||||||
|
successful_ssh?: number;
|
||||||
|
failed_ssh?: number;
|
||||||
|
sudo_commands?: number;
|
||||||
|
active_bans?: number;
|
||||||
|
active_sessions?: number;
|
||||||
|
active_sessions_rdp?: number;
|
||||||
|
unique_users?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isDailyReportType(type: string): boolean {
|
||||||
|
return type === "report.daily.ssh" || type === "report.daily.rdp";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function reportStatsFromDetails(details: Record<string, unknown> | null): DailyReportStats | null {
|
||||||
|
if (!details || typeof details.stats !== "object" || details.stats === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return details.stats as DailyReportStats;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function reportHtmlFromDetails(details: Record<string, unknown> | null): string | null {
|
||||||
|
if (!details) return null;
|
||||||
|
const html = details.report_html;
|
||||||
|
return typeof html === "string" && html.trim() ? html : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function reportBodyFromDetails(details: Record<string, unknown> | null): string | null {
|
||||||
|
if (!details) return null;
|
||||||
|
const body = details.report_body;
|
||||||
|
return typeof body === "string" && body.trim() ? body : null;
|
||||||
|
}
|
||||||
@@ -32,7 +32,7 @@
|
|||||||
<div class="dash-card">
|
<div class="dash-card">
|
||||||
<div class="dash-value">{{ data.daily_reports_24h }}</div>
|
<div class="dash-value">{{ data.daily_reports_24h }}</div>
|
||||||
<div class="dash-label">Отчёты за 24 ч</div>
|
<div class="dash-label">Отчёты за 24 ч</div>
|
||||||
<RouterLink to="/events?type=report.daily.ssh">События →</RouterLink>
|
<RouterLink to="/reports">Отчёты →</RouterLink>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,34 +1,73 @@
|
|||||||
<template>
|
<template>
|
||||||
<p><RouterLink to="/events">← События</RouterLink></p>
|
|
||||||
|
<p>
|
||||||
|
|
||||||
|
<RouterLink v-if="fromReports" to="/reports">← Отчёты</RouterLink>
|
||||||
|
|
||||||
<RouterLink v-else to="/events">← События</RouterLink>
|
<RouterLink v-else to="/events">← События</RouterLink>
|
||||||
|
|
||||||
</p>
|
</p>
|
||||||
<div class="card">
|
|
||||||
<p><strong>ID:</strong> {{ event.id }} · <strong>event_id:</strong> {{ event.event_id }}</p>
|
<p v-if="error" class="error">{{ error }}</p>
|
||||||
<p><strong>Хост:</strong> {{ event.hostname }} · <strong>Severity:</strong>
|
|
||||||
|
<p v-else-if="loading">Загрузка…</p>
|
||||||
|
|
||||||
|
<template v-else-if="event">
|
||||||
|
|
||||||
|
<h1>{{ event.title }}</h1>
|
||||||
|
|
||||||
|
<div class="card report-meta">
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
|
|
||||||
<p><strong>Occurred:</strong> {{ event.occurred_at }}</p>
|
<strong>ID:</strong> {{ event.id }} · <strong>event_id:</strong> {{ event.event_id }}
|
||||||
<p>{{ event.summary }}</p>
|
|
||||||
</p>
|
</p>
|
||||||
<h2>details</h2>
|
|
||||||
|
<p>
|
||||||
|
|
||||||
|
<strong>Хост:</strong>
|
||||||
|
|
||||||
|
<RouterLink :to="{ path: '/reports', query: { hostname: event.hostname } }">
|
||||||
|
|
||||||
|
{{ event.hostname }}
|
||||||
|
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
<h2>payload</h2>
|
|
||||||
|
· <strong>Severity:</strong>
|
||||||
|
|
||||||
|
<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><strong>Occurred:</strong> {{ formatDt(event.occurred_at) }}</p>
|
||||||
|
|
||||||
<p v-if="!isReport"><strong>Summary:</strong> {{ event.summary }}</p>
|
<p v-if="!isReport"><strong>Summary:</strong> {{ event.summary }}</p>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
import { onMounted, ref, watch } from "vue";
|
|
||||||
|
|
||||||
<template v-if="isReport">
|
<template v-if="isReport">
|
||||||
|
|
||||||
|
<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" />
|
||||||
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<details v-if="event.details && !isReport" class="raw-details">
|
||||||
|
|
||||||
|
<summary>details (JSON)</summary>
|
||||||
|
|
||||||
|
<pre>{{ JSON.stringify(event.details, null, 2) }}</pre>
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
|
||||||
@@ -44,3 +83,4 @@ async function load() {
|
|||||||
|
|
||||||
|
|
||||||
<details class="raw-details">
|
<details class="raw-details">
|
||||||
|
|
||||||
@@ -28,7 +28,15 @@
|
|||||||
<td>{{ h.ipv4 || "—" }}</td>
|
<td>{{ h.ipv4 || "—" }}</td>
|
||||||
<td :class="'agent-' + h.agent_status">{{ agentLabel(h.agent_status) }}</td>
|
<td :class="'agent-' + h.agent_status">{{ agentLabel(h.agent_status) }}</td>
|
||||||
<td>{{ h.last_heartbeat_at ? formatDt(h.last_heartbeat_at) : "—" }}</td>
|
<td>{{ h.last_heartbeat_at ? formatDt(h.last_heartbeat_at) : "—" }}</td>
|
||||||
<td>{{ h.last_daily_report_at ? formatDt(h.last_daily_report_at) : "—" }}</td>
|
<td>
|
||||||
|
<RouterLink
|
||||||
|
v-if="h.last_daily_report_at"
|
||||||
|
:to="{ path: '/reports', query: { hostname: h.hostname } }"
|
||||||
|
>
|
||||||
|
{{ formatDt(h.last_daily_report_at) }}
|
||||||
|
</RouterLink>
|
||||||
|
<span v-else>—</span>
|
||||||
|
</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>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
<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>
|
||||||
|
</select>
|
||||||
|
<button type="button" @click="load(1)">Применить</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="error" class="error">{{ error }}</p>
|
||||||
|
<p v-else-if="loading">Загрузка…</p>
|
||||||
|
<template v-else>
|
||||||
|
<p>Всего: {{ data?.total ?? 0 }}</p>
|
||||||
|
<table class="reports-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Время</th>
|
||||||
|
<th>Хост</th>
|
||||||
|
<th>Тип</th>
|
||||||
|
<th>Сводка</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr
|
||||||
|
v-for="e in data?.items ?? []"
|
||||||
|
:key="e.id"
|
||||||
|
class="report-row"
|
||||||
|
tabindex="0"
|
||||||
|
@click="openReport(e.id)"
|
||||||
|
@keyup.enter="openReport(e.id)"
|
||||||
|
>
|
||||||
|
<td>{{ formatDt(e.occurred_at) }}</td>
|
||||||
|
<td>
|
||||||
|
<strong>{{ e.hostname }}</strong>
|
||||||
|
</td>
|
||||||
|
<td><code>{{ e.type }}</code></td>
|
||||||
|
<td class="report-summary-cell">{{ e.summary }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div class="filters" style="margin-top: 1rem">
|
||||||
|
<button type="button" class="secondary" :disabled="page <= 1" @click="load(page - 1)">Назад</button>
|
||||||
|
<span>Стр. {{ page }}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="secondary"
|
||||||
|
:disabled="!data || page * pageSize >= data.total"
|
||||||
|
@click="load(page + 1)"
|
||||||
|
>
|
||||||
|
Вперёд
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, ref, watch } from "vue";
|
||||||
|
import { useRoute, useRouter } from "vue-router";
|
||||||
|
import { apiFetch, type EventListResponse } from "../api";
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const data = ref<EventListResponse | null>(null);
|
||||||
|
const loading = ref(false);
|
||||||
|
const error = ref("");
|
||||||
|
const page = ref(1);
|
||||||
|
const pageSize = 30;
|
||||||
|
const hostname = ref("");
|
||||||
|
const reportType = ref("report.daily.ssh");
|
||||||
|
|
||||||
|
function formatDt(iso: string) {
|
||||||
|
return new Date(iso).toLocaleString("ru-RU");
|
||||||
|
}
|
||||||
|
|
||||||
|
function openReport(id: number) {
|
||||||
|
router.push({ path: `/events/${id}`, query: { from: "reports" } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load(p: number) {
|
||||||
|
page.value = p;
|
||||||
|
loading.value = true;
|
||||||
|
error.value = "";
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
page: String(page.value),
|
||||||
|
page_size: String(pageSize),
|
||||||
|
severity: "info",
|
||||||
|
});
|
||||||
|
params.set("type", reportType.value);
|
||||||
|
if (hostname.value.trim()) {
|
||||||
|
params.set("hostname", hostname.value.trim());
|
||||||
|
}
|
||||||
|
data.value = await apiFetch<EventListResponse>(`/api/v1/events?${params}`);
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e instanceof Error ? e.message : "Ошибка загрузки";
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
const h = route.query.hostname;
|
||||||
|
if (typeof h === "string" && h) hostname.value = h;
|
||||||
|
const t = route.query.type;
|
||||||
|
if (typeof t === "string" && t) reportType.value = t;
|
||||||
|
load(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => route.query.hostname,
|
||||||
|
(h) => {
|
||||||
|
if (typeof h === "string") {
|
||||||
|
hostname.value = h;
|
||||||
|
load(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
</script>
|
||||||
Reference in New Issue
Block a user