Files
HY2XS_flamy/apps/frontend/src/views/dashboard/index.vue
T

328 lines
10 KiB
Vue

<template>
<div class="dashboard-container">
<div class="dashboard-actions mb-2">
<el-button size="small" @click="loadDashboard">{{ $t("common.refresh") }}</el-button>
</div>
<el-alert
v-if="loadError"
:title="loadError"
type="error"
:closable="false"
class="mb-2"
/>
<el-alert
v-else-if="isStale"
:title="$t('dashboard.stale')"
type="warning"
:closable="false"
class="mb-2"
/>
<el-alert
v-for="risk in securityRisks"
:key="risk.key"
:title="$t(risk.key)"
:type="risk.severity === 'critical' ? 'error' : risk.severity === 'warning' ? 'warning' : 'info'"
:closable="risk.dismissible"
class="mb-2"
/>
<el-row :gutter="10" class="mt-2">
<el-col :xs="24" :sm="12" :lg="6"><el-card shadow="never">{{ $t("dashboard.cpu") }}: {{ summary.system.cpuPercent }}%</el-card></el-col>
<el-col :xs="24" :sm="12" :lg="6"><el-card shadow="never">{{ $t("dashboard.ram") }}: {{ summary.system.memPercent }}%</el-card></el-col>
<el-col :xs="24" :sm="12" :lg="6"><el-card shadow="never">{{ $t("dashboard.disk") }}: {{ summary.system.diskPercent }}%</el-card></el-col>
<el-col :xs="24" :sm="12" :lg="6"><el-card shadow="never">{{ $t("dashboard.peers") }}: {{ summary.peers.total }}</el-card></el-col>
<el-col :xs="24" :sm="12" :lg="6"><el-card shadow="never">{{ $t("dashboard.onlinePeers") }}: {{ summary.peers.onlinePeers }}</el-card></el-col>
<el-col :xs="24" :sm="12" :lg="6"><el-card shadow="never">{{ $t("dashboard.onlineDevices") }}: {{ summary.peers.onlineDevices }}</el-card></el-col>
<el-col :xs="24" :sm="12" :lg="6"><el-card shadow="never">{{ $t("dashboard.todayDownload") }}: {{ formatBytes(summary.traffic.todayDownloadBytes || 0) }}</el-card></el-col>
<el-col :xs="24" :sm="12" :lg="6"><el-card shadow="never">{{ $t("dashboard.todayUpload") }}: {{ formatBytes(summary.traffic.todayUploadBytes || 0) }}</el-card></el-col>
</el-row>
<el-card shadow="never" class="mt-3">
<template #header>
<div class="chart-header">
<span>{{ $t("dashboard.trafficChart") }}</span>
<el-radio-group v-model="range" size="small" @change="loadDashboard">
<el-radio-button label="1h">1h</el-radio-button>
<el-radio-button label="24h">24h</el-radio-button>
<el-radio-button label="7d">7d</el-radio-button>
<el-radio-button label="30d">30d</el-radio-button>
</el-radio-group>
</div>
</template>
<div v-loading="timeseriesLoading" class="chart-grid">
<v-chart class="chart" autoresize :option="trafficChartOption" />
<v-chart class="chart" autoresize :option="systemChartOption" />
</div>
</el-card>
<el-card shadow="never" class="mt-3">
<template #header>{{ $t("dashboard.topPeers24h") }}</template>
<el-table :data="topPeers" size="small">
<el-table-column prop="name" :label="$t('peer.name')" />
<el-table-column prop="download" :label="$t('dashboard.download')">
<template #default="scope">{{ formatBytes(scope.row.download || 0) }}</template>
</el-table-column>
<el-table-column prop="upload" :label="$t('dashboard.upload')">
<template #default="scope">{{ formatBytes(scope.row.upload || 0) }}</template>
</el-table-column>
<el-table-column prop="total" :label="$t('dashboard.total')">
<template #default="scope">{{ formatBytes(scope.row.total || 0) }}</template>
</el-table-column>
</el-table>
</el-card>
</div>
</template>
<script setup lang="ts">
import { useIntervalFn } from "@vueuse/core";
import { useI18n } from "vue-i18n";
import { use } from "echarts/core";
import { CanvasRenderer } from "echarts/renderers";
import { LineChart } from "echarts/charts";
import {
GridComponent,
TooltipComponent,
LegendComponent,
DataZoomComponent,
TitleComponent,
} from "echarts/components";
import VChart from "vue-echarts";
import { dashboardSecurityApi, dashboardSummaryApi, dashboardTimeseriesApi, dashboardTopPeersApi } from "@/api/dashboard";
import { DashboardSummaryVo, DashboardTimeseriesVo, DashboardTopPeerVo, SecurityRiskVo } from "@/api/dashboard/types";
import { formatBytes } from "@/utils/byte";
import { timestampToDateTime } from "@/utils/time";
use([CanvasRenderer, LineChart, GridComponent, TooltipComponent, LegendComponent, DataZoomComponent, TitleComponent]);
const { t } = useI18n();
const summary = ref<DashboardSummaryVo>({
collectedAt: 0,
system: { cpuPercent: 0, memUsedBytes: 0, memTotalBytes: 0, memPercent: 0, diskUsedBytes: 0, diskTotalBytes: 0, diskPercent: 0 },
hysteria: { version: "-", running: false, apiReachable: false },
peers: { total: 0, enabled: 0, disabled: 0, expired: 0, onlinePeers: 0, onlineDevices: 0 },
traffic: { downloadBytes: 0, uploadBytes: 0, totalBytes: 0, todayDownloadBytes: 0, todayUploadBytes: 0, sinceResetDownloadBytes: 0, sinceResetUploadBytes: 0 },
health: {
collector: { status: "stale" },
hysteria: { status: "ok" },
},
securityRisks: [],
});
const topPeers = ref<DashboardTopPeerVo[]>([]);
const securityRisks = ref<SecurityRiskVo[]>([]);
const timeseries = ref<DashboardTimeseriesVo>({ range: "24h", traffic: [], system: [], collectedAt: 0 });
const timeseriesLoading = ref(false);
const range = ref("24h");
const loadError = ref("");
const loading = ref(false);
const lastSuccessAt = ref(0);
const staleThresholdMs = 90_000;
const pollIntervalMs = 30_000;
const isStale = computed(() => {
if (!lastSuccessAt.value) {
return false;
}
return Date.now() - lastSuccessAt.value > staleThresholdMs;
});
const loadDashboard = async () => {
if (loading.value) {
return;
}
loading.value = true;
try {
const [summaryRes, topRes, secRes] = await Promise.all([
dashboardSummaryApi(),
dashboardTopPeersApi(range.value, 10),
dashboardSecurityApi(),
]);
summary.value = summaryRes.data;
topPeers.value = topRes.data;
securityRisks.value = secRes.data;
lastSuccessAt.value = Date.now();
loadError.value = "";
} catch (error) {
loadError.value = t("dashboard.refreshFailed");
} finally {
loading.value = false;
}
timeseriesLoading.value = true;
try {
const tsRes = await dashboardTimeseriesApi(range.value);
timeseries.value = tsRes.data;
} finally {
timeseriesLoading.value = false;
}
};
const trafficXAxisLabels = computed(() => {
const allTs = new Set<number>();
for (const item of timeseries.value.traffic || []) {
allTs.add(item.ts);
}
return Array.from(allTs).sort((a, b) => a - b);
});
const systemXAxisLabels = computed(() => {
const allTs = new Set<number>();
for (const item of timeseries.value.system || []) {
allTs.add(item.ts);
}
return Array.from(allTs).sort((a, b) => a - b);
});
const trafficMap = computed(() => {
const m = new Map<number, { download: number; upload: number }>();
for (const item of timeseries.value.traffic || []) {
m.set(item.ts, { download: item.download || 0, upload: item.upload || 0 });
}
return m;
});
const systemMap = computed(() => {
const m = new Map<number, { cpu: number; mem: number }>();
for (const item of timeseries.value.system || []) {
m.set(item.ts, { cpu: item.cpu || 0, mem: item.mem || 0 });
}
return m;
});
const trafficChartOption = computed(() => ({
title: { text: t("dashboard.trafficSeriesChart"), left: "left", textStyle: { fontSize: 14, fontWeight: 600 } },
tooltip: {
trigger: "axis",
valueFormatter: (value: number) => formatBytes(value || 0),
},
legend: { top: 0 },
grid: { left: 30, right: 20, top: 50, bottom: 50, containLabel: true },
dataZoom: [{ type: "inside" }, { type: "slider", height: 16, bottom: 10 }],
xAxis: {
type: "category",
boundaryGap: false,
data: trafficXAxisLabels.value.map((ts) => timestampToDateTime(ts)),
},
yAxis: {
type: "value",
axisLabel: {
formatter: (v: number) => formatBytes(v),
},
},
series: [
{
name: t("dashboard.download"),
type: "line",
smooth: true,
showSymbol: false,
data: trafficXAxisLabels.value.map((ts) => trafficMap.value.get(ts)?.download || 0),
},
{
name: t("dashboard.upload"),
type: "line",
smooth: true,
showSymbol: false,
data: trafficXAxisLabels.value.map((ts) => trafficMap.value.get(ts)?.upload || 0),
},
],
}));
const systemChartOption = computed(() => ({
title: { text: t("dashboard.systemSeriesChart"), left: "left", textStyle: { fontSize: 14, fontWeight: 600 } },
tooltip: {
trigger: "axis",
valueFormatter: (value: number) => `${Math.round((value || 0) * 10) / 10}%`,
},
legend: { top: 0 },
grid: { left: 30, right: 20, top: 50, bottom: 50, containLabel: true },
dataZoom: [{ type: "inside" }, { type: "slider", height: 16, bottom: 10 }],
xAxis: {
type: "category",
boundaryGap: false,
data: systemXAxisLabels.value.map((ts) => timestampToDateTime(ts)),
},
yAxis: {
type: "value",
min: 0,
max: 100,
axisLabel: { formatter: "{value}%" },
},
series: [
{
name: t("dashboard.cpu"),
type: "line",
smooth: true,
showSymbol: false,
data: systemXAxisLabels.value.map((ts) => systemMap.value.get(ts)?.cpu || 0),
},
{
name: t("dashboard.ram"),
type: "line",
smooth: true,
showSymbol: false,
data: systemXAxisLabels.value.map((ts) => systemMap.value.get(ts)?.mem || 0),
},
],
}));
const { pause: stopPolling, resume: startPolling } = useIntervalFn(
() => {
loadDashboard();
},
pollIntervalMs,
{ immediate: false }
);
const handleVisibilityChange = () => {
if (document.hidden) {
stopPolling();
return;
}
loadDashboard();
startPolling();
};
onMounted(() => {
loadDashboard();
startPolling();
document.addEventListener("visibilitychange", handleVisibilityChange);
});
onUnmounted(() => {
document.removeEventListener("visibilitychange", handleVisibilityChange);
stopPolling();
});
</script>
<style scoped lang="scss">
.dashboard-container {
padding: 16px;
}
.dashboard-actions {
display: flex;
justify-content: flex-end;
}
.chart-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.chart-grid {
display: grid;
grid-template-columns: 1fr;
gap: 16px;
}
.chart {
width: 100%;
height: 340px;
}
</style>