fix27: полный production-фикс dashboard/peer/i18n без легаси-костылей
This commit is contained in:
@@ -51,13 +51,10 @@
|
||||
</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>
|
||||
<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">
|
||||
@@ -81,11 +78,24 @@
|
||||
<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>({
|
||||
@@ -150,23 +160,109 @@ const loadDashboard = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
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 };
|
||||
const xAxisLabels = computed(() => {
|
||||
const allTs = new Set<number>();
|
||||
for (const item of timeseries.value.traffic || []) {
|
||||
allTs.add(item.ts);
|
||||
}
|
||||
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}%`,
|
||||
};
|
||||
});
|
||||
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: xAxisLabels.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: xAxisLabels.value.map((ts) => trafficMap.value.get(ts)?.download || 0),
|
||||
},
|
||||
{
|
||||
name: t("dashboard.upload"),
|
||||
type: "line",
|
||||
smooth: true,
|
||||
showSymbol: false,
|
||||
data: xAxisLabels.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: xAxisLabels.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: xAxisLabels.value.map((ts) => systemMap.value.get(ts)?.cpu || 0),
|
||||
},
|
||||
{
|
||||
name: t("dashboard.ram"),
|
||||
type: "line",
|
||||
smooth: true,
|
||||
showSymbol: false,
|
||||
data: xAxisLabels.value.map((ts) => systemMap.value.get(ts)?.mem || 0),
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
const { pause: stopPolling, resume: startPolling } = useIntervalFn(
|
||||
() => {
|
||||
loadDashboard();
|
||||
@@ -211,5 +307,16 @@ onUnmounted(() => {
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.chart-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.chart {
|
||||
width: 100%;
|
||||
height: 340px;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
|
||||
<el-tooltip
|
||||
:disabled="isCapslock === false"
|
||||
content="Caps lock is On"
|
||||
:content="$t('login.capsLockOn')"
|
||||
placement="right"
|
||||
>
|
||||
<el-form-item prop="pass">
|
||||
@@ -123,7 +123,7 @@ const loginRules = {
|
||||
},
|
||||
{
|
||||
pattern: /^[a-zA-Z0-9!@#$%^&*()_+-=]{6,32}$/,
|
||||
message: "Username format is incorrect",
|
||||
message: t("login.usernameFormatIncorrect"),
|
||||
trigger: ["change", "blur"],
|
||||
},
|
||||
],
|
||||
@@ -135,7 +135,7 @@ const loginRules = {
|
||||
},
|
||||
{
|
||||
pattern: /^[a-zA-Z0-9!@#$%^&*()_+-=]{6,32}$/,
|
||||
message: "Password format is incorrect",
|
||||
message: t("login.passwordFormatIncorrect"),
|
||||
trigger: ["change", "blur"],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="app-container">
|
||||
<el-form :model="queryParams" :inline="true" class="mb-3">
|
||||
<el-form-item :label="$t('peer.username')">
|
||||
<el-form-item :label="$t('peer.name')">
|
||||
<el-input v-model="queryParams.name" clearable style="width: 220px" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="$t('peer.remark')">
|
||||
@@ -44,15 +44,15 @@
|
||||
<el-table-column :label="$t('peer.lastConnectionAt')" min-width="170">
|
||||
<template #default="scope">{{ scope.row.lastConnectionAt ? timestampToDateTime(scope.row.lastConnectionAt) : '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="$t('common.operate')" width="340" fixed="right">
|
||||
<el-table-column :label="$t('common.operate')" width="230" fixed="right">
|
||||
<template #default="scope">
|
||||
<el-button link type="primary" @click="openOverview(scope.row)">{{ $t("peer.overview") }}</el-button>
|
||||
<el-button link type="primary" @click="copyUri(scope.row)">{{ $t("peer.clientUri") }}</el-button>
|
||||
<el-button link type="primary" @click="showQr(scope.row)">{{ $t("peer.clientQr") }}</el-button>
|
||||
<el-button link type="primary" @click="copyUri(scope.row)">{{ $t("peer.copyUri") }}</el-button>
|
||||
<el-dropdown>
|
||||
<span class="el-dropdown-link">{{ $t("common.operate") }}</span>
|
||||
<span class="el-dropdown-link">{{ $t("peer.more") }}</span>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item @click="showQr(scope.row)">{{ $t("peer.clientQr") }}</el-dropdown-item>
|
||||
<el-dropdown-item @click="handleUpdate(scope.row)">{{ $t("common.edit") }}</el-dropdown-item>
|
||||
<el-dropdown-item @click="handleResetTraffic(scope.row)">{{ $t("common.resetTraffic") }}</el-dropdown-item>
|
||||
<el-dropdown-item @click="handleKick(scope.row)">{{ $t("peer.kick") }}</el-dropdown-item>
|
||||
@@ -97,6 +97,10 @@
|
||||
<p><b>{{ $t('peer.authId') }}:</b> {{ overview.data.authId }}</p>
|
||||
<p><b>{{ $t('peer.name') }}:</b> {{ overview.data.name }}</p>
|
||||
<p><b>{{ $t('peer.remark') }}:</b> {{ overview.data.remark || '-' }}</p>
|
||||
<p><b>{{ $t('peer.createdAt') }}:</b> {{ overview.data.createTime || '-' }}</p>
|
||||
<p><b>{{ $t('peer.status') }}:</b> {{ overview.data.disabled === 1 ? $t('common.disable') : $t('common.enable') }}</p>
|
||||
<p><b>{{ $t('peer.bannedUntil') }}:</b> {{ overview.data.bannedUntil > Date.now() ? timestampToDateTime(overview.data.bannedUntil) : '-' }}</p>
|
||||
<p><b>{{ $t('peer.totalTraffic') }}:</b> {{ formatBytes((overview.data.downloadBytes || 0) + (overview.data.uploadBytes || 0)) }}</p>
|
||||
<p><b>{{ $t('peer.quota') }}:</b> {{ quotaText(overview.data.quotaBytes) }}</p>
|
||||
<p><b>{{ $t('peer.download') }}:</b> {{ formatBytes(overview.data.downloadBytes) }}</p>
|
||||
<p><b>{{ $t('peer.upload') }}:</b> {{ formatBytes(overview.data.uploadBytes) }}</p>
|
||||
@@ -104,8 +108,15 @@
|
||||
<p><b>{{ $t('peer.expireTime') }}:</b> {{ overview.data.expiresAt === 0 ? $t('peer.unlimited') : timestampToDateTime(overview.data.expiresAt) }}</p>
|
||||
<p><b>{{ $t('peer.lastConnectionAt') }}:</b> {{ overview.data.lastConnectionAt ? timestampToDateTime(overview.data.lastConnectionAt) : '-' }}</p>
|
||||
<div class="mt-2">
|
||||
<el-button type="primary" @click="copyUri(overview.data)">{{ $t("peer.clientUri") }}</el-button>
|
||||
<el-button @click="showQr(overview.data)">{{ $t("peer.clientQr") }}</el-button>
|
||||
<p><b>{{ $t('peer.clientUri') }}:</b></p>
|
||||
<el-input :model-value="overviewClientUrl" readonly />
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<el-button type="primary" @click="copyUri(overview.data)">{{ $t("peer.copyUri") }}</el-button>
|
||||
<el-button @click="loadOverviewQr(overview.data)">{{ $t("peer.clientQr") }}</el-button>
|
||||
</div>
|
||||
<div class="mt-2" v-if="overviewQrSrc">
|
||||
<el-image style="width:220px;height:220px" :src="overviewQrSrc" />
|
||||
</div>
|
||||
</div>
|
||||
</el-drawer>
|
||||
@@ -138,6 +149,8 @@ const total = ref(0);
|
||||
const records = ref<PeerVo[]>([]);
|
||||
const qrDialog = ref(false);
|
||||
const qrSrc = ref("");
|
||||
const overviewQrSrc = ref("");
|
||||
const overviewClientUrl = ref("");
|
||||
const formRef = ref();
|
||||
const overview = reactive<{ visible: boolean; data: PeerVo | null }>({ visible: false, data: null });
|
||||
|
||||
@@ -175,6 +188,20 @@ function trafficPercent(row: PeerVo) {
|
||||
function openOverview(row: PeerVo) {
|
||||
overview.data = row;
|
||||
overview.visible = true;
|
||||
overviewQrSrc.value = "";
|
||||
overviewClientUrl.value = "";
|
||||
void loadOverviewClientConfig(row);
|
||||
}
|
||||
|
||||
async function loadOverviewClientConfig(row: PeerVo) {
|
||||
const { data } = await getPeerClientConfigApi(row.id);
|
||||
overviewClientUrl.value = data.url;
|
||||
}
|
||||
|
||||
async function loadOverviewQr(row: PeerVo) {
|
||||
const { data } = await getPeerClientConfigApi(row.id);
|
||||
overviewClientUrl.value = data.url;
|
||||
overviewQrSrc.value = `data:image/png;base64,${data.qrCode}`;
|
||||
}
|
||||
|
||||
async function handleQuery() {
|
||||
|
||||
Reference in New Issue
Block a user