216 lines
7.6 KiB
Vue
216 lines
7.6 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>
|
|
<el-table :data="timeseriesRows" size="small" v-loading="timeseriesLoading">
|
|
<el-table-column prop="ts" :label="$t('common.createTime')" width="180" />
|
|
<el-table-column prop="download" :label="$t('dashboard.download')" />
|
|
<el-table-column prop="upload" :label="$t('dashboard.upload')" />
|
|
<el-table-column prop="cpu" :label="$t('dashboard.cpu')" width="120" />
|
|
<el-table-column prop="mem" :label="$t('dashboard.ram')" width="120" />
|
|
</el-table>
|
|
</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 { 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";
|
|
|
|
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 timeseriesRows = computed(() => {
|
|
const trafficMap: Record<number, { download: number; upload: number }> = {};
|
|
for (const t of timeseries.value.traffic || []) {
|
|
trafficMap[t.ts] = { download: t.download || 0, upload: t.upload || 0 };
|
|
}
|
|
return (timeseries.value.system || []).map((s) => {
|
|
const tm = trafficMap[s.ts] || { download: 0, upload: 0 };
|
|
return {
|
|
ts: timestampToDateTime(s.ts),
|
|
download: formatBytes(tm.download),
|
|
upload: formatBytes(tm.upload),
|
|
cpu: `${Math.round((s.cpu || 0) * 10) / 10}%`,
|
|
mem: `${Math.round((s.mem || 0) * 10) / 10}%`,
|
|
};
|
|
});
|
|
});
|
|
|
|
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;
|
|
}
|
|
</style>
|
|
|