fix: полный продакшен-фикс dashboard/peer/hysteria по fix35

This commit is contained in:
2026-05-09 15:38:30 +05:00
parent a4157d1363
commit 78656e74b0
18 changed files with 190 additions and 85 deletions
+43 -7
View File
@@ -6,6 +6,7 @@ import (
"hy2xs-admin/model/constant" "hy2xs-admin/model/constant"
"hy2xs-admin/model/entity" "hy2xs-admin/model/entity"
"hy2xs-admin/model/vo" "hy2xs-admin/model/vo"
"strings"
"time" "time"
) )
@@ -119,20 +120,55 @@ func DashboardTopPeers(fromMs int64, toMs int64, limit int) ([]vo.DashboardTopPe
return rows, nil return rows, nil
} }
func DashboardTrafficTimeseries(fromMs int64, toMs int64) ([]vo.DashboardSeriesPointVo, error) { func DashboardTrafficTimeseries(fromMs int64, toMs int64, bucketMs int64, source string) ([]vo.DashboardSeriesPointVo, error) {
rows := make([]vo.DashboardSeriesPointVo, 0) rows := make([]vo.DashboardSeriesPointVo, 0)
if toMs < fromMs {
return rows, nil
}
if bucketMs <= 0 {
bucketMs = int64(time.Minute / time.Millisecond)
}
sourceTable := "traffic_sample"
fromColumn := "sampled_at"
switch strings.TrimSpace(strings.ToLower(source)) {
case "hourly":
sourceTable = "traffic_aggregate_hourly"
fromColumn = "hour_start"
case "daily":
sourceTable = "traffic_aggregate_daily"
fromColumn = "day_start"
default:
sourceTable = "traffic_sample"
fromColumn = "sampled_at"
}
alignedFrom := fromMs - (fromMs % bucketMs)
if tx := sqliteDB.Raw(`SELECT if tx := sqliteDB.Raw(`SELECT
hour_start AS ts, (? + CAST((`+fromColumn+` - ?) / ? AS INTEGER) * ?) AS ts,
COALESCE(SUM(rx_bytes),0) AS download, COALESCE(SUM(rx_bytes),0) AS download,
COALESCE(SUM(tx_bytes),0) AS upload COALESCE(SUM(tx_bytes),0) AS upload
FROM traffic_aggregate_hourly FROM `+sourceTable+`
WHERE hour_start BETWEEN ? AND ? WHERE `+fromColumn+` BETWEEN ? AND ?
GROUP BY hour_start GROUP BY ts
ORDER BY hour_start ASC`, fromMs, toMs).Scan(&rows); tx.Error != nil { ORDER BY ts ASC`, alignedFrom, alignedFrom, bucketMs, bucketMs, fromMs, toMs).Scan(&rows); tx.Error != nil {
logrus.Errorf("%v", tx.Error) logrus.Errorf("%v", tx.Error)
return rows, errors.New(constant.SysError) return rows, errors.New(constant.SysError)
} }
return rows, nil
rowMap := make(map[int64]vo.DashboardSeriesPointVo, len(rows))
for _, item := range rows {
rowMap[item.Ts] = item
}
filled := make([]vo.DashboardSeriesPointVo, 0, int((toMs-alignedFrom)/bucketMs)+1)
for ts := alignedFrom; ts <= toMs; ts += bucketMs {
if item, ok := rowMap[ts]; ok {
filled = append(filled, item)
continue
}
filled = append(filled, vo.DashboardSeriesPointVo{Ts: ts, Download: 0, Upload: 0})
}
return filled, nil
} }
func DashboardSystemTimeseries(fromMs int64, toMs int64) ([]vo.DashboardSeriesPointVo, error) { func DashboardSystemTimeseries(fromMs int64, toMs int64) ([]vo.DashboardSeriesPointVo, error) {
-5
View File
@@ -8,7 +8,6 @@ import (
"gorm.io/gorm/clause" "gorm.io/gorm/clause"
"hy2xs-admin/model/constant" "hy2xs-admin/model/constant"
"hy2xs-admin/model/entity" "hy2xs-admin/model/entity"
"time"
) )
func SaveTrafficSample(sample entity.TrafficSample) error { func SaveTrafficSample(sample entity.TrafficSample) error {
@@ -29,13 +28,11 @@ func UpsertTrafficAggregateHourly(peerId int64, hourStart int64, rxBytes int64,
RxBytes: &rxBytes, RxBytes: &rxBytes,
TxBytes: &txBytes, TxBytes: &txBytes,
} }
now := time.Now()
if tx := sqliteDB.Clauses(clause.OnConflict{ if tx := sqliteDB.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "peer_id"}, {Name: "hour_start"}}, Columns: []clause.Column{{Name: "peer_id"}, {Name: "hour_start"}},
DoUpdates: clause.Assignments(map[string]interface{}{ DoUpdates: clause.Assignments(map[string]interface{}{
"rx_bytes": gormExprAdd("rx_bytes", rxBytes), "rx_bytes": gormExprAdd("rx_bytes", rxBytes),
"tx_bytes": gormExprAdd("tx_bytes", txBytes), "tx_bytes": gormExprAdd("tx_bytes", txBytes),
"update_time": now,
}), }),
}).Create(&agg); tx.Error != nil { }).Create(&agg); tx.Error != nil {
logrus.Errorf("%v", tx.Error) logrus.Errorf("%v", tx.Error)
@@ -54,13 +51,11 @@ func UpsertTrafficAggregateDaily(peerId int64, dayStart int64, rxBytes int64, tx
RxBytes: &rxBytes, RxBytes: &rxBytes,
TxBytes: &txBytes, TxBytes: &txBytes,
} }
now := time.Now()
if tx := sqliteDB.Clauses(clause.OnConflict{ if tx := sqliteDB.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "peer_id"}, {Name: "day_start"}}, Columns: []clause.Column{{Name: "peer_id"}, {Name: "day_start"}},
DoUpdates: clause.Assignments(map[string]interface{}{ DoUpdates: clause.Assignments(map[string]interface{}{
"rx_bytes": gormExprAdd("rx_bytes", rxBytes), "rx_bytes": gormExprAdd("rx_bytes", rxBytes),
"tx_bytes": gormExprAdd("tx_bytes", txBytes), "tx_bytes": gormExprAdd("tx_bytes", txBytes),
"update_time": now,
}), }),
}).Create(&agg); tx.Error != nil { }).Create(&agg); tx.Error != nil {
logrus.Errorf("%v", tx.Error) logrus.Errorf("%v", tx.Error)
+1
View File
@@ -22,6 +22,7 @@
"nprogress": "^0.2.0", "nprogress": "^0.2.0",
"path-browserify": "^1.0.1", "path-browserify": "^1.0.1",
"pinia": "^2.0.33", "pinia": "^2.0.33",
"qrcode.vue": "3.4.1",
"vue": "^3.2.45", "vue": "^3.2.45",
"vue-echarts": "^7.0.3", "vue-echarts": "^7.0.3",
"vue-i18n": "9", "vue-i18n": "9",
+12
View File
@@ -35,6 +35,9 @@ importers:
pinia: pinia:
specifier: ^2.0.33 specifier: ^2.0.33
version: 2.0.33(typescript@4.9.3)(vue@3.2.45) version: 2.0.33(typescript@4.9.3)(vue@3.2.45)
qrcode.vue:
specifier: 3.4.1
version: 3.4.1(vue@3.2.45)
vue: vue:
specifier: ^3.2.45 specifier: ^3.2.45
version: 3.2.45 version: 3.2.45
@@ -2272,6 +2275,11 @@ packages:
resolution: {integrity: sha512-rRV+zQD8tVFys26lAGR9WUuS4iUAngJScM+ZRSKtvl5tKeZ2t5bvdNFdNHBW9FWR4guGHlgmsZ1G7BSm2wTbuA==} resolution: {integrity: sha512-rRV+zQD8tVFys26lAGR9WUuS4iUAngJScM+ZRSKtvl5tKeZ2t5bvdNFdNHBW9FWR4guGHlgmsZ1G7BSm2wTbuA==}
engines: {node: '>=6'} engines: {node: '>=6'}
qrcode.vue@3.4.1:
resolution: {integrity: sha512-wq/zHsifH4FJ1GXQi8/wNxD1KfQkckIpjK1KPTc/qwYU5/Bkd4me0w4xZSg6EXk6xLBkVDE0zxVagewv5EMAVA==}
peerDependencies:
vue: ^3.0.0
query-string@4.3.4: query-string@4.3.4:
resolution: {integrity: sha512-O2XLNDBIg1DnTOa+2XrIwSiXEV8h2KImXUnjhhn2+UsvZ+Es2uyd5CCRTNQlDGbzUQOW3aYCBx9rVA6dzsiY7Q==} resolution: {integrity: sha512-O2XLNDBIg1DnTOa+2XrIwSiXEV8h2KImXUnjhhn2+UsvZ+Es2uyd5CCRTNQlDGbzUQOW3aYCBx9rVA6dzsiY7Q==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@@ -5297,6 +5305,10 @@ snapshots:
punycode@2.3.0: {} punycode@2.3.0: {}
qrcode.vue@3.4.1(vue@3.2.45):
dependencies:
vue: 3.2.45
query-string@4.3.4: query-string@4.3.4:
dependencies: dependencies:
object-assign: 4.1.1 object-assign: 4.1.1
+1 -1
View File
@@ -43,7 +43,7 @@ export interface PeerVo extends IdDto {
export interface PeerClientConfigVo { export interface PeerClientConfigVo {
url: string; url: string;
qrCode: string | Uint8Array; qrCode?: string | Uint8Array;
} }
export interface KickPeerDto { export interface KickPeerDto {
+1
View File
@@ -38,6 +38,7 @@ export default {
todayUpload: "Today upload", todayUpload: "Today upload",
trafficChart: "Traffic & System Timeseries", trafficChart: "Traffic & System Timeseries",
trafficSeriesChart: "Traffic: download/upload", trafficSeriesChart: "Traffic: download/upload",
noTrafficData: "No traffic data for the selected period",
systemSeriesChart: "System: CPU/RAM", systemSeriesChart: "System: CPU/RAM",
download: "Download", download: "Download",
upload: "Upload", upload: "Upload",
+1
View File
@@ -36,6 +36,7 @@ export default {
todayUpload: "Отдано за сегодня", todayUpload: "Отдано за сегодня",
trafficChart: "Трафик и системные метрики", trafficChart: "Трафик и системные метрики",
trafficSeriesChart: "Трафик: download/upload", trafficSeriesChart: "Трафик: download/upload",
noTrafficData: "Нет данных трафика за выбранный период",
systemSeriesChart: "Система: CPU/RAM", systemSeriesChart: "Система: CPU/RAM",
download: "Скачано", download: "Скачано",
upload: "Отдано", upload: "Отдано",
+15 -8
View File
@@ -82,7 +82,9 @@ function logout() {
<!-- Аватар пользователя --> <!-- Аватар пользователя -->
<el-dropdown trigger="click"> <el-dropdown trigger="click">
<div class="avatar-container"> <div class="avatar-container">
<img src="/src/assets/logo.png" /> <el-avatar :size="32" class="avatar-icon">
<i-ep-user-filled />
</el-avatar>
<i-ep-caret-bottom class="w-3 h-3" /> <i-ep-caret-bottom class="w-3 h-3" />
</div> </div>
<template #dropdown> <template #dropdown>
@@ -128,16 +130,21 @@ function logout() {
.avatar-container { .avatar-container {
display: flex; display: flex;
align-items: center; align-items: center;
justify-items: center; justify-content: center;
margin: 0 5px; gap: 6px;
height: 36px;
margin: 0 8px;
padding: 0 8px;
border-radius: 8px;
cursor: pointer; cursor: pointer;
img { &:hover {
width: 40px; background: rgb(249 250 251 / 100%);
height: 40px; }
border-radius: 5px;
.avatar-icon {
font-size: 18px;
} }
} }
} }
</style> </style>
+4 -1
View File
@@ -8,6 +8,7 @@ export {}
declare module '@vue/runtime-core' { declare module '@vue/runtime-core' {
export interface GlobalComponents { export interface GlobalComponents {
ElAlert: typeof import('element-plus/es')['ElAlert'] ElAlert: typeof import('element-plus/es')['ElAlert']
ElAvatar: typeof import('element-plus/es')['ElAvatar']
ElButton: typeof import('element-plus/es')['ElButton'] ElButton: typeof import('element-plus/es')['ElButton']
ElCard: typeof import('element-plus/es')['ElCard'] ElCard: typeof import('element-plus/es')['ElCard']
ElCol: typeof import('element-plus/es')['ElCol'] ElCol: typeof import('element-plus/es')['ElCol']
@@ -18,9 +19,9 @@ declare module '@vue/runtime-core' {
ElDropdown: typeof import('element-plus/es')['ElDropdown'] ElDropdown: typeof import('element-plus/es')['ElDropdown']
ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem'] ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem']
ElDropdownMenu: typeof import('element-plus/es')['ElDropdownMenu'] ElDropdownMenu: typeof import('element-plus/es')['ElDropdownMenu']
ElEmpty: typeof import('element-plus/es')['ElEmpty']
ElForm: typeof import('element-plus/es')['ElForm'] ElForm: typeof import('element-plus/es')['ElForm']
ElFormItem: typeof import('element-plus/es')['ElFormItem'] ElFormItem: typeof import('element-plus/es')['ElFormItem']
ElImage: typeof import('element-plus/es')['ElImage']
ElInput: typeof import('element-plus/es')['ElInput'] ElInput: typeof import('element-plus/es')['ElInput']
ElInputNumber: typeof import('element-plus/es')['ElInputNumber'] ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
ElMenu: typeof import('element-plus/es')['ElMenu'] ElMenu: typeof import('element-plus/es')['ElMenu']
@@ -46,10 +47,12 @@ declare module '@vue/runtime-core' {
IEpCaretBottom: typeof import('~icons/ep/caret-bottom')['default'] IEpCaretBottom: typeof import('~icons/ep/caret-bottom')['default']
IEpClose: typeof import('~icons/ep/close')['default'] IEpClose: typeof import('~icons/ep/close')['default']
IEpDownload: typeof import('~icons/ep/download')['default'] IEpDownload: typeof import('~icons/ep/download')['default']
IEpMoreFilled: typeof import('~icons/ep/more-filled')['default']
IEpRefresh: typeof import('~icons/ep/refresh')['default'] IEpRefresh: typeof import('~icons/ep/refresh')['default']
IEpRefreshRight: typeof import('~icons/ep/refresh-right')['default'] IEpRefreshRight: typeof import('~icons/ep/refresh-right')['default']
IEpSetting: typeof import('~icons/ep/setting')['default'] IEpSetting: typeof import('~icons/ep/setting')['default']
IEpUpload: typeof import('~icons/ep/upload')['default'] IEpUpload: typeof import('~icons/ep/upload')['default']
IEpUserFilled: typeof import('~icons/ep/user-filled')['default']
ImputMultiple: typeof import('./../components/ImputMultiple/index.vue')['default'] ImputMultiple: typeof import('./../components/ImputMultiple/index.vue')['default']
LangSelect: typeof import('./../components/LangSelect/index.vue')['default'] LangSelect: typeof import('./../components/LangSelect/index.vue')['default']
MapAdd: typeof import('./../components/MapAdd/index.vue')['default'] MapAdd: typeof import('./../components/MapAdd/index.vue')['default']
+24 -22
View File
@@ -52,7 +52,12 @@
</div> </div>
</template> </template>
<div v-loading="timeseriesLoading" class="chart-grid"> <div v-loading="timeseriesLoading" class="chart-grid">
<v-chart class="chart" autoresize :option="trafficChartOption" /> <el-empty
v-if="!timeseriesLoading && (timeseries.traffic?.length || 0) === 0"
:description="$t('dashboard.noTrafficData')"
class="chart-empty"
/>
<v-chart v-else class="chart" autoresize :option="trafficChartOption" />
<v-chart class="chart" autoresize :option="systemChartOption" /> <v-chart class="chart" autoresize :option="systemChartOption" />
</div> </div>
</el-card> </el-card>
@@ -160,14 +165,6 @@ const loadDashboard = async () => {
} }
}; };
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 systemXAxisLabels = computed(() => {
const allTs = new Set<number>(); const allTs = new Set<number>();
for (const item of timeseries.value.system || []) { for (const item of timeseries.value.system || []) {
@@ -176,13 +173,15 @@ const systemXAxisLabels = computed(() => {
return Array.from(allTs).sort((a, b) => a - b); return Array.from(allTs).sort((a, b) => a - b);
}); });
const trafficMap = computed(() => { const trafficDownloadData = computed(() =>
const m = new Map<number, { download: number; upload: number }>(); (timeseries.value.traffic || []).map((item) => [item.ts, item.download || 0])
for (const item of timeseries.value.traffic || []) { );
m.set(item.ts, { download: item.download || 0, upload: item.upload || 0 });
} const trafficUploadData = computed(() =>
return m; (timeseries.value.traffic || []).map((item) => [item.ts, item.upload || 0])
}); );
const hasSingleTrafficPoint = computed(() => (timeseries.value.traffic || []).length === 1);
const systemMap = computed(() => { const systemMap = computed(() => {
const m = new Map<number, { cpu: number; mem: number }>(); const m = new Map<number, { cpu: number; mem: number }>();
@@ -202,9 +201,8 @@ const trafficChartOption = computed(() => ({
grid: { left: 30, right: 20, top: 50, bottom: 50, containLabel: true }, grid: { left: 30, right: 20, top: 50, bottom: 50, containLabel: true },
dataZoom: [{ type: "inside" }, { type: "slider", height: 16, bottom: 10 }], dataZoom: [{ type: "inside" }, { type: "slider", height: 16, bottom: 10 }],
xAxis: { xAxis: {
type: "category", type: "time",
boundaryGap: false, boundaryGap: false,
data: trafficXAxisLabels.value.map((ts) => timestampToDateTime(ts)),
}, },
yAxis: { yAxis: {
type: "value", type: "value",
@@ -217,15 +215,15 @@ const trafficChartOption = computed(() => ({
name: t("dashboard.download"), name: t("dashboard.download"),
type: "line", type: "line",
smooth: true, smooth: true,
showSymbol: false, showSymbol: hasSingleTrafficPoint.value,
data: trafficXAxisLabels.value.map((ts) => trafficMap.value.get(ts)?.download || 0), data: trafficDownloadData.value,
}, },
{ {
name: t("dashboard.upload"), name: t("dashboard.upload"),
type: "line", type: "line",
smooth: true, smooth: true,
showSymbol: false, showSymbol: hasSingleTrafficPoint.value,
data: trafficXAxisLabels.value.map((ts) => trafficMap.value.get(ts)?.upload || 0), data: trafficUploadData.value,
}, },
], ],
})); }));
@@ -323,5 +321,9 @@ onUnmounted(() => {
width: 100%; width: 100%;
height: 340px; height: 340px;
} }
.chart-empty {
height: 340px;
}
</style> </style>
@@ -23,7 +23,7 @@
<el-card shadow="never"> <el-card shadow="never">
<el-form inline> <el-form inline>
<el-form-item> <el-form-item>
<el-tag style="height: 32px"> <el-tag style="height: 32px" class="hysteria-version-tag">
{{ $t("hysteria.hysteria2Version") }}: {{ $t("hysteria.hysteria2Version") }}:
{{ hysteria2Monitor.version ? hysteria2Monitor.version : "-" }} {{ hysteria2Monitor.version ? hysteria2Monitor.version : "-" }}
</el-tag> </el-tag>
@@ -1251,4 +1251,11 @@ onMounted(() => {
max-width: 1000px; max-width: 1000px;
margin: 0 auto; margin: 0 auto;
} }
.hysteria-version-tag {
max-width: 320px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style> </style>
+23 -11
View File
@@ -56,12 +56,15 @@
<el-table-column :label="$t('peer.lastConnectionAt')" min-width="170"> <el-table-column :label="$t('peer.lastConnectionAt')" min-width="170">
<template #default="scope">{{ scope.row.lastConnectionAt ? timestampToDateTime(scope.row.lastConnectionAt) : '-' }}</template> <template #default="scope">{{ scope.row.lastConnectionAt ? timestampToDateTime(scope.row.lastConnectionAt) : '-' }}</template>
</el-table-column> </el-table-column>
<el-table-column :label="$t('common.operate')" width="230" fixed="right"> <el-table-column :label="$t('common.operate')" width="230" fixed="right" align="right" header-align="right">
<template #default="scope"> <template #default="scope">
<div class="peer-actions">
<el-button link type="primary" @click="openOverview(scope.row)">{{ $t("peer.overview") }}</el-button> <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.copyUri") }}</el-button> <el-button link type="primary" @click="copyUri(scope.row)">{{ $t("peer.copyUri") }}</el-button>
<el-dropdown> <el-dropdown trigger="click" placement="bottom-end">
<span class="el-dropdown-link">{{ $t("peer.more") }}</span> <el-button text circle class="peer-more-btn" @click.stop>
<i-ep-more-filled />
</el-button>
<template #dropdown> <template #dropdown>
<el-dropdown-menu> <el-dropdown-menu>
<el-dropdown-item @click="showQr(scope.row)">{{ $t("peer.clientQr") }}</el-dropdown-item> <el-dropdown-item @click="showQr(scope.row)">{{ $t("peer.clientQr") }}</el-dropdown-item>
@@ -73,6 +76,7 @@
</el-dropdown-menu> </el-dropdown-menu>
</template> </template>
</el-dropdown> </el-dropdown>
</div>
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
@@ -101,7 +105,9 @@
</el-dialog> </el-dialog>
<el-dialog v-model="qrDialog" :title="$t('peer.clientQr')" width="420px"> <el-dialog v-model="qrDialog" :title="$t('peer.clientQr')" width="420px">
<el-image style="width:300px;height:300px" :src="qrSrc" /> <div class="qr-dialog-body">
<qrcode-vue :value="qrUrl" :size="260" level="M" render-as="svg" />
</div>
</el-dialog> </el-dialog>
<el-drawer v-model="overview.visible" :title="$t('peer.overview')" size="45%"> <el-drawer v-model="overview.visible" :title="$t('peer.overview')" size="45%">
@@ -127,8 +133,10 @@
<el-button type="primary" @click="copyUri(overview.data)">{{ $t("peer.copyUri") }}</el-button> <el-button type="primary" @click="copyUri(overview.data)">{{ $t("peer.copyUri") }}</el-button>
<el-button @click="loadOverviewQr(overview.data)">{{ $t("peer.clientQr") }}</el-button> <el-button @click="loadOverviewQr(overview.data)">{{ $t("peer.clientQr") }}</el-button>
</div> </div>
<div class="mt-2" v-if="overviewQrSrc"> <div class="mt-2" v-if="overviewQrUrl">
<el-image style="width:220px;height:220px" :src="overviewQrSrc" /> <div class="qr-dialog-body">
<qrcode-vue :value="overviewQrUrl" :size="220" level="M" render-as="svg" />
</div>
</div> </div>
</div> </div>
</el-drawer> </el-drawer>
@@ -137,6 +145,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, reactive, ref } from "vue"; import { computed, onMounted, reactive, ref } from "vue";
import QrcodeVue from "qrcode.vue";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import copy from "copy-to-clipboard"; import copy from "copy-to-clipboard";
import { ElMessage, ElMessageBox } from "element-plus"; import { ElMessage, ElMessageBox } from "element-plus";
@@ -163,9 +172,9 @@ const loading = ref(false);
const total = ref(0); const total = ref(0);
const records = ref<PeerVo[]>([]); const records = ref<PeerVo[]>([]);
const qrDialog = ref(false); const qrDialog = ref(false);
const qrSrc = ref(""); const qrUrl = ref("");
const importFileList = ref<UploadFile[]>([]); const importFileList = ref<UploadFile[]>([]);
const overviewQrSrc = ref(""); const overviewQrUrl = ref("");
const overviewClientUrl = ref(""); const overviewClientUrl = ref("");
const formRef = ref(); const formRef = ref();
const overview = reactive<{ visible: boolean; data: PeerVo | null }>({ visible: false, data: null }); const overview = reactive<{ visible: boolean; data: PeerVo | null }>({ visible: false, data: null });
@@ -204,7 +213,7 @@ function trafficPercent(row: PeerVo) {
function openOverview(row: PeerVo) { function openOverview(row: PeerVo) {
overview.data = row; overview.data = row;
overview.visible = true; overview.visible = true;
overviewQrSrc.value = ""; overviewQrUrl.value = "";
overviewClientUrl.value = ""; overviewClientUrl.value = "";
void loadOverviewClientConfig(row); void loadOverviewClientConfig(row);
} }
@@ -217,7 +226,7 @@ async function loadOverviewClientConfig(row: PeerVo) {
async function loadOverviewQr(row: PeerVo) { async function loadOverviewQr(row: PeerVo) {
const { data } = await getPeerClientConfigApi(row.id); const { data } = await getPeerClientConfigApi(row.id);
overviewClientUrl.value = data.url; overviewClientUrl.value = data.url;
overviewQrSrc.value = `data:image/png;base64,${data.qrCode}`; overviewQrUrl.value = data.url;
} }
async function handleQuery() { async function handleQuery() {
@@ -293,7 +302,7 @@ async function copyUri(row: PeerVo) {
async function showQr(row: PeerVo) { async function showQr(row: PeerVo) {
const { data } = await getPeerClientConfigApi(row.id); const { data } = await getPeerClientConfigApi(row.id);
qrSrc.value = `data:image/png;base64,${data.qrCode}`; qrUrl.value = data.url;
qrDialog.value = true; qrDialog.value = true;
} }
@@ -344,5 +353,8 @@ onMounted(handleQuery);
<style scoped> <style scoped>
.peer-title { font-weight: 600; } .peer-title { font-weight: 600; }
.peer-sub { color: #909399; font-size: 12px; } .peer-sub { color: #909399; font-size: 12px; }
.peer-actions { display: flex; align-items: center; justify-content: flex-end; gap: 8px; }
.peer-more-btn { font-size: 16px; }
.qr-dialog-body { display: flex; justify-content: center; align-items: center; padding: 12px 0 20px; }
</style> </style>
@@ -5,7 +5,6 @@ type TrafficAggregateDaily struct {
DayStart *int64 `gorm:"column:day_start;default:0;primaryKey" json:"dayStart"` DayStart *int64 `gorm:"column:day_start;default:0;primaryKey" json:"dayStart"`
RxBytes *int64 `gorm:"column:rx_bytes;default:0" json:"rxBytes"` RxBytes *int64 `gorm:"column:rx_bytes;default:0" json:"rxBytes"`
TxBytes *int64 `gorm:"column:tx_bytes;default:0" json:"txBytes"` TxBytes *int64 `gorm:"column:tx_bytes;default:0" json:"txBytes"`
BaseEntity `gorm:"embedded"`
} }
func (TrafficAggregateDaily) TableName() string { func (TrafficAggregateDaily) TableName() string {
@@ -5,7 +5,6 @@ type TrafficAggregateHourly struct {
HourStart *int64 `gorm:"column:hour_start;default:0;primaryKey" json:"hourStart"` HourStart *int64 `gorm:"column:hour_start;default:0;primaryKey" json:"hourStart"`
RxBytes *int64 `gorm:"column:rx_bytes;default:0" json:"rxBytes"` RxBytes *int64 `gorm:"column:rx_bytes;default:0" json:"rxBytes"`
TxBytes *int64 `gorm:"column:tx_bytes;default:0" json:"txBytes"` TxBytes *int64 `gorm:"column:tx_bytes;default:0" json:"txBytes"`
BaseEntity `gorm:"embedded"`
} }
func (TrafficAggregateHourly) TableName() string { func (TrafficAggregateHourly) TableName() string {
+1 -1
View File
@@ -25,5 +25,5 @@ type PeerPageVo struct {
type PeerClientConfigVo struct { type PeerClientConfigVo struct {
Url string `json:"url"` Url string `json:"url"`
QrCode []byte `json:"qrCode"` QrCode []byte `json:"qrCode,omitempty"` // deprecated: frontend renders SVG QR from Url
} }
+15 -1
View File
@@ -44,19 +44,33 @@ func DashboardSummary() (vo.DashboardSummaryVo, error) {
func DashboardTimeseries(rangeKey string) (vo.DashboardTimeseriesVo, error) { func DashboardTimeseries(rangeKey string) (vo.DashboardTimeseriesVo, error) {
nowMs := time.Now().UnixMilli() nowMs := time.Now().UnixMilli()
fromMs := nowMs - int64(24*time.Hour/time.Millisecond) fromMs := nowMs - int64(24*time.Hour/time.Millisecond)
bucketMs := int64(5 * time.Minute / time.Millisecond)
source := "sample"
r := strings.TrimSpace(rangeKey) r := strings.TrimSpace(rangeKey)
if r == "1h" { if r == "1h" {
fromMs = nowMs - int64(time.Hour/time.Millisecond) fromMs = nowMs - int64(time.Hour/time.Millisecond)
bucketMs = int64(time.Minute / time.Millisecond)
source = "sample"
} else if r == "7d" { } else if r == "7d" {
fromMs = nowMs - int64(7*24*time.Hour/time.Millisecond) fromMs = nowMs - int64(7*24*time.Hour/time.Millisecond)
bucketMs = int64(time.Hour / time.Millisecond)
source = "hourly"
} else if r == "30d" { } else if r == "30d" {
fromMs = nowMs - int64(30*24*time.Hour/time.Millisecond) fromMs = nowMs - int64(30*24*time.Hour/time.Millisecond)
bucketMs = int64(24 * time.Hour / time.Millisecond)
source = "daily"
r = "30d" r = "30d"
} else if r == "" { } else if r == "" {
r = "24h" r = "24h"
bucketMs = int64(5 * time.Minute / time.Millisecond)
source = "sample"
} else {
r = "24h"
bucketMs = int64(5 * time.Minute / time.Millisecond)
source = "sample"
} }
traffic, err := dao.DashboardTrafficTimeseries(fromMs, nowMs) traffic, err := dao.DashboardTrafficTimeseries(fromMs, nowMs, bucketMs, source)
if err != nil { if err != nil {
return vo.DashboardTimeseriesVo{}, err return vo.DashboardTimeseriesVo{}, err
} }
+23 -2
View File
@@ -1,6 +1,10 @@
package service package service
import ( import (
"regexp"
"strings"
"github.com/sirupsen/logrus"
"hy2xs-admin/dao" "hy2xs-admin/dao"
"hy2xs-admin/model/entity" "hy2xs-admin/model/entity"
"hy2xs-admin/model/vo" "hy2xs-admin/model/vo"
@@ -11,6 +15,19 @@ import (
const hysteriaVersionCacheTTL = 15 * time.Minute const hysteriaVersionCacheTTL = 15 * time.Minute
var ansiRe = regexp.MustCompile(`\x1b\[[0-9;]*[A-Za-z]`)
var hysteriaVersionRe = regexp.MustCompile(`(?i)v?\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?`)
func normalizeHysteriaVersion(raw string) string {
cleaned := strings.TrimSpace(raw)
cleaned = ansiRe.ReplaceAllString(cleaned, "")
match := hysteriaVersionRe.FindString(cleaned)
if match == "" {
return "-"
}
return strings.TrimSpace(match)
}
type metricsSnapshot struct { type metricsSnapshot struct {
CollectedAt int64 CollectedAt int64
System vo.DashboardSystemVo System vo.DashboardSystemVo
@@ -48,12 +65,16 @@ func getCachedHysteriaVersion(now time.Time) string {
} }
return "-" return "-"
} }
normalized := normalizeHysteriaVersion(content)
if normalized == "-" {
logrus.Debugf("hysteria version parse failed, raw output: %q", strings.TrimSpace(content))
}
metricsStore.Lock() metricsStore.Lock()
metricsStore.version = content metricsStore.version = normalized
metricsStore.versionAt = now metricsStore.versionAt = now
metricsStore.Unlock() metricsStore.Unlock()
return content return normalized
} }
func CollectMetricsSnapshot() { func CollectMetricsSnapshot() {
+1 -6
View File
@@ -5,7 +5,6 @@ import (
"fmt" "fmt"
"strings" "strings"
"github.com/skip2/go-qrcode"
"hy2xs-admin/dao" "hy2xs-admin/dao"
"hy2xs-admin/model/bo" "hy2xs-admin/model/bo"
"hy2xs-admin/model/constant" "hy2xs-admin/model/constant"
@@ -171,11 +170,7 @@ func BuildPeerClientConfig(id int64) (vo.PeerClientConfigVo, error) {
if err != nil { if err != nil {
return vo.PeerClientConfigVo{}, err return vo.PeerClientConfigVo{}, err
} }
qrCode, err := qrcode.Encode(url, qrcode.Medium, 300) return vo.PeerClientConfigVo{Url: url}, nil
if err != nil {
return vo.PeerClientConfigVo{}, err
}
return vo.PeerClientConfigVo{Url: url, QrCode: qrCode}, nil
} }
func ListExportPeer(includeSecrets bool) ([]bo.PeerExport, error) { func ListExportPeer(includeSecrets bool) ([]bo.PeerExport, error) {