fix: полный продакшен-фикс dashboard/peer/hysteria по fix35
This commit is contained in:
+43
-7
@@ -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) {
|
||||||
|
|||||||
+4
-9
@@ -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)
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Generated
+12
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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: "Отдано",
|
||||||
|
|||||||
@@ -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
@@ -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']
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -56,23 +56,27 @@
|
|||||||
<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">
|
||||||
<el-button link type="primary" @click="openOverview(scope.row)">{{ $t("peer.overview") }}</el-button>
|
<div class="peer-actions">
|
||||||
<el-button link type="primary" @click="copyUri(scope.row)">{{ $t("peer.copyUri") }}</el-button>
|
<el-button link type="primary" @click="openOverview(scope.row)">{{ $t("peer.overview") }}</el-button>
|
||||||
<el-dropdown>
|
<el-button link type="primary" @click="copyUri(scope.row)">{{ $t("peer.copyUri") }}</el-button>
|
||||||
<span class="el-dropdown-link">{{ $t("peer.more") }}</span>
|
<el-dropdown trigger="click" placement="bottom-end">
|
||||||
<template #dropdown>
|
<el-button text circle class="peer-more-btn" @click.stop>
|
||||||
<el-dropdown-menu>
|
<i-ep-more-filled />
|
||||||
<el-dropdown-item @click="showQr(scope.row)">{{ $t("peer.clientQr") }}</el-dropdown-item>
|
</el-button>
|
||||||
<el-dropdown-item @click="handleUpdate(scope.row)">{{ $t("common.edit") }}</el-dropdown-item>
|
<template #dropdown>
|
||||||
<el-dropdown-item @click="handleResetTraffic(scope.row)">{{ $t("common.resetTraffic") }}</el-dropdown-item>
|
<el-dropdown-menu>
|
||||||
<el-dropdown-item @click="handleKick(scope.row)">{{ $t("peer.kick") }}</el-dropdown-item>
|
<el-dropdown-item @click="showQr(scope.row)">{{ $t("peer.clientQr") }}</el-dropdown-item>
|
||||||
<el-dropdown-item @click="handleReleaseKick(scope.row)">{{ $t("peer.releaseKick") }}</el-dropdown-item>
|
<el-dropdown-item @click="handleUpdate(scope.row)">{{ $t("common.edit") }}</el-dropdown-item>
|
||||||
<el-dropdown-item divided @click="handleDelete(scope.row)">{{ $t("common.delete") }}</el-dropdown-item>
|
<el-dropdown-item @click="handleResetTraffic(scope.row)">{{ $t("common.resetTraffic") }}</el-dropdown-item>
|
||||||
</el-dropdown-menu>
|
<el-dropdown-item @click="handleKick(scope.row)">{{ $t("peer.kick") }}</el-dropdown-item>
|
||||||
</template>
|
<el-dropdown-item @click="handleReleaseKick(scope.row)">{{ $t("peer.releaseKick") }}</el-dropdown-item>
|
||||||
</el-dropdown>
|
<el-dropdown-item divided @click="handleDelete(scope.row)">{{ $t("common.delete") }}</el-dropdown-item>
|
||||||
|
</el-dropdown-menu>
|
||||||
|
</template>
|
||||||
|
</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 {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user