fix28: полный production-фикс графиков, machine-auth, i18n и peer import/export

This commit is contained in:
2026-05-09 02:14:59 +05:00
parent 3c569061fe
commit 1139c428b6
11 changed files with 230 additions and 28 deletions
+3
View File
@@ -228,6 +228,9 @@ func ExportHysteria2Config(c *gin.Context) {
http.Insecure = &authHttpInsecure http.Insecure = &authHttpInsecure
auth.HTTP = &http auth.HTTP = &http
hysteria2ServerConfig.Auth = &auth hysteria2ServerConfig.Auth = &auth
if hysteria2ServerConfig.TrafficStats == nil {
hysteria2ServerConfig.TrafficStats = &bo.ServerConfigTrafficStats{}
}
hysteria2ServerConfig.TrafficStats.Secret = &trafficStatsSecret hysteria2ServerConfig.TrafficStats.Secret = &trafficStatsSecret
fileName := fmt.Sprintf("Hysteria2Config-%s.yaml", time.Now().Format("20060102150405")) fileName := fmt.Sprintf("Hysteria2Config-%s.yaml", time.Now().Format("20060102150405"))
+20
View File
@@ -169,6 +169,7 @@ export default {
more: "More", more: "More",
}, },
config: { config: {
orchestratorManaged: "Managed by hy2xs-orchestrator reconfigure",
huiWebPort: "HY2XS admin Web Port", huiWebPort: "HY2XS admin Web Port",
huiWebContext: "HY2XS admin Web Context", huiWebContext: "HY2XS admin Web Context",
hysteria2TrafficTime: "Hysteria2 Traffic Time", hysteria2TrafficTime: "Hysteria2 Traffic Time",
@@ -185,6 +186,10 @@ export default {
"Scheduled task expression, reference: https://pkg.go.dev/github.com/robfig/cron/v3", "Scheduled task expression, reference: https://pkg.go.dev/github.com/robfig/cron/v3",
resetTrafficMonth: "Run once a month, midnight, first of month", resetTrafficMonth: "Run once a month, midnight, first of month",
resetTrafficWeek: "Run once a week, midnight between Sat/Sun", resetTrafficWeek: "Run once a week, midnight between Sat/Sun",
mustBeInteger: "Field must be an integer",
invalidWebContext:
"Field must start with / and contain only lowercase letters (a-z) and numbers (0-9)",
invalidTrafficTime: "Field must be a number with up to one decimal place",
}, },
monitor: { monitor: {
huiVersion: "HY2XS admin Version", huiVersion: "HY2XS admin Version",
@@ -200,6 +205,21 @@ export default {
}, },
log: { log: {
numLine: "Number of lines", numLine: "Number of lines",
level: "Level",
message: "Message",
time: "Time",
},
errorPage: {
back: "Back",
oops: "Oops!",
forbiddenTitle: "You do not have permission to access this page",
forbiddenGo: "Or you can go:",
backHome: "Back to Home",
forbiddenImageAlt: "Girl has dropped her ice cream.",
notFoundHeadline: "The webmaster said that you can not enter this page...",
notFoundInfo:
"Please check that the URL you entered is correct, or click the button below to return to the homepage.",
notFoundBackHome: "Back to home",
}, },
hysteria: { hysteria: {
enable: "Enable", enable: "Enable",
+20
View File
@@ -164,6 +164,7 @@ export default {
releaseKickTip: "Снять офлайн-статус", releaseKickTip: "Снять офлайн-статус",
}, },
config: { config: {
orchestratorManaged: "Управляется hy2xs-orchestrator reconfigure",
huiWebPort: "Порт HY2XS admin", huiWebPort: "Порт HY2XS admin",
huiWebContext: "Web-контекст HY2XS admin", huiWebContext: "Web-контекст HY2XS admin",
hysteria2TrafficTime: "Период учёта трафика Hysteria2", hysteria2TrafficTime: "Период учёта трафика Hysteria2",
@@ -179,6 +180,10 @@ export default {
resetTrafficCronTip: "Cron-выражение для планового сброса трафика", resetTrafficCronTip: "Cron-выражение для планового сброса трафика",
resetTrafficMonth: "Раз в месяц, в полночь первого дня", resetTrafficMonth: "Раз в месяц, в полночь первого дня",
resetTrafficWeek: "Раз в неделю, в полночь между субботой и воскресеньем", resetTrafficWeek: "Раз в неделю, в полночь между субботой и воскресеньем",
mustBeInteger: "Поле должно быть целым числом",
invalidWebContext:
"Поле должно начинаться с / и содержать только строчные буквы (a-z) и цифры (0-9)",
invalidTrafficTime: "Поле должно быть числом максимум с одним знаком после запятой",
}, },
monitor: { monitor: {
huiVersion: "Версия HY2XS admin", huiVersion: "Версия HY2XS admin",
@@ -194,6 +199,21 @@ export default {
}, },
log: { log: {
numLine: "Количество строк", numLine: "Количество строк",
level: "Уровень",
message: "Сообщение",
time: "Время",
},
errorPage: {
back: "Назад",
oops: "Упс!",
forbiddenTitle: "У вас нет прав для доступа к этой странице",
forbiddenGo: "Или вы можете перейти:",
backHome: "На главную",
forbiddenImageAlt: "Девочка уронила своё мороженое.",
notFoundHeadline: "Вебмастер сообщил, что вам нельзя на эту страницу...",
notFoundInfo:
"Проверьте правильность URL или нажмите кнопку ниже, чтобы вернуться на главную страницу.",
notFoundBackHome: "На главную",
}, },
hysteria: { hysteria: {
enable: "Включить", enable: "Включить",
@@ -44,7 +44,7 @@
</div> </div>
<el-alert <el-alert
title="Managed by hy2xs-orchestrator reconfigure" :title="$t('config.orchestratorManaged')"
type="info" type="info"
:closable="false" :closable="false"
style="margin-bottom: 12px" style="margin-bottom: 12px"
@@ -205,7 +205,7 @@ const dataFormRules = {
}, },
{ {
pattern: /^\d+$/, pattern: /^\d+$/,
message: "field must be a integer", message: t("config.mustBeInteger"),
trigger: ["change", "blur"], trigger: ["change", "blur"],
}, },
], ],
@@ -217,7 +217,7 @@ const dataFormRules = {
}, },
{ {
pattern: /^\/([a-z0-9]+(\/[a-z0-9]+)*)?$/, pattern: /^\/([a-z0-9]+(\/[a-z0-9]+)*)?$/,
message: "field must start with / and contain only lowercase letters (a-z) and numbers (0-9)", message: t("config.invalidWebContext"),
trigger: ["change", "blur"], trigger: ["change", "blur"],
}, },
], ],
@@ -229,7 +229,7 @@ const dataFormRules = {
}, },
{ {
pattern: /^\d+(\.\d)?$/, pattern: /^\d+(\.\d)?$/,
message: "field must be a number with up to one decimal place", message: t("config.invalidTrafficTime"),
trigger: ["change", "blur"], trigger: ["change", "blur"],
}, },
], ],
+12 -7
View File
@@ -160,11 +160,16 @@ const loadDashboard = async () => {
} }
}; };
const xAxisLabels = computed(() => { const trafficXAxisLabels = computed(() => {
const allTs = new Set<number>(); const allTs = new Set<number>();
for (const item of timeseries.value.traffic || []) { for (const item of timeseries.value.traffic || []) {
allTs.add(item.ts); 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 || []) { for (const item of timeseries.value.system || []) {
allTs.add(item.ts); allTs.add(item.ts);
} }
@@ -199,7 +204,7 @@ const trafficChartOption = computed(() => ({
xAxis: { xAxis: {
type: "category", type: "category",
boundaryGap: false, boundaryGap: false,
data: xAxisLabels.value.map((ts) => timestampToDateTime(ts)), data: trafficXAxisLabels.value.map((ts) => timestampToDateTime(ts)),
}, },
yAxis: { yAxis: {
type: "value", type: "value",
@@ -213,14 +218,14 @@ const trafficChartOption = computed(() => ({
type: "line", type: "line",
smooth: true, smooth: true,
showSymbol: false, showSymbol: false,
data: xAxisLabels.value.map((ts) => trafficMap.value.get(ts)?.download || 0), data: trafficXAxisLabels.value.map((ts) => trafficMap.value.get(ts)?.download || 0),
}, },
{ {
name: t("dashboard.upload"), name: t("dashboard.upload"),
type: "line", type: "line",
smooth: true, smooth: true,
showSymbol: false, showSymbol: false,
data: xAxisLabels.value.map((ts) => trafficMap.value.get(ts)?.upload || 0), data: trafficXAxisLabels.value.map((ts) => trafficMap.value.get(ts)?.upload || 0),
}, },
], ],
})); }));
@@ -237,7 +242,7 @@ const systemChartOption = computed(() => ({
xAxis: { xAxis: {
type: "category", type: "category",
boundaryGap: false, boundaryGap: false,
data: xAxisLabels.value.map((ts) => timestampToDateTime(ts)), data: systemXAxisLabels.value.map((ts) => timestampToDateTime(ts)),
}, },
yAxis: { yAxis: {
type: "value", type: "value",
@@ -251,14 +256,14 @@ const systemChartOption = computed(() => ({
type: "line", type: "line",
smooth: true, smooth: true,
showSymbol: false, showSymbol: false,
data: xAxisLabels.value.map((ts) => systemMap.value.get(ts)?.cpu || 0), data: systemXAxisLabels.value.map((ts) => systemMap.value.get(ts)?.cpu || 0),
}, },
{ {
name: t("dashboard.ram"), name: t("dashboard.ram"),
type: "line", type: "line",
smooth: true, smooth: true,
showSymbol: false, showSymbol: false,
data: xAxisLabels.value.map((ts) => systemMap.value.get(ts)?.mem || 0), data: systemXAxisLabels.value.map((ts) => systemMap.value.get(ts)?.mem || 0),
}, },
], ],
})); }));
+9 -6
View File
@@ -8,6 +8,9 @@ export default {
<script setup lang="ts"> <script setup lang="ts">
import { reactive, toRefs } from "vue"; import { reactive, toRefs } from "vue";
import { useRouter } from "vue-router"; import { useRouter } from "vue-router";
import { useI18n } from "vue-i18n";
const { t } = useI18n();
const state = reactive({ const state = reactive({
errGif: new URL(`../../assets/401_images/401.gif`, import.meta.url).href, errGif: new URL(`../../assets/401_images/401.gif`, import.meta.url).href,
@@ -24,15 +27,15 @@ function back() {
<template> <template>
<div class="errPage-container"> <div class="errPage-container">
<el-button class="pan-back-btn" @click="back">Back</el-button> <el-button class="pan-back-btn" @click="back">{{ $t("errorPage.back") }}</el-button>
<el-row> <el-row>
<el-col :span="12"> <el-col :span="12">
<h1 class="text-jumbo text-ginormous">Oops!</h1> <h1 class="text-jumbo text-ginormous">{{ $t("errorPage.oops") }}</h1>
<h2>You do not have permission to access this page</h2> <h2>{{ $t("errorPage.forbiddenTitle") }}</h2>
<ul class="list-unstyled"> <ul class="list-unstyled">
<li>Or you can go:</li> <li>{{ $t("errorPage.forbiddenGo") }}</li>
<li class="link-type"> <li class="link-type">
<router-link to="/">Back to Home</router-link> <router-link to="/">{{ $t("errorPage.backHome") }}</router-link>
</li> </li>
</ul> </ul>
</el-col> </el-col>
@@ -41,7 +44,7 @@ function back() {
:src="errGif" :src="errGif"
width="313" width="313"
height="428" height="428"
alt="Girl has dropped her ice cream." :alt="t('errorPage.forbiddenImageAlt')"
/> />
</el-col> </el-col>
</el-row> </el-row>
+8 -5
View File
@@ -6,8 +6,12 @@ export default {
</script> </script>
<script setup lang="ts"> <script setup lang="ts">
import { useI18n } from "vue-i18n";
const { t } = useI18n();
function message() { function message() {
return "The webmaster said that you can not enter this page..."; return t("errorPage.notFoundHeadline");
} }
</script> </script>
@@ -37,13 +41,12 @@ function message() {
/> />
</div> </div>
<div class="bullshit"> <div class="bullshit">
<div class="bullshit__oops">OOPS!</div> <div class="bullshit__oops">{{ $t("errorPage.oops") }}</div>
<div class="bullshit__headline">{{ message() }}</div> <div class="bullshit__headline">{{ message() }}</div>
<div class="bullshit__info"> <div class="bullshit__info">
Please check that the URL you entered is correct, or click the button {{ $t("errorPage.notFoundInfo") }}
below to return to the homepage.
</div> </div>
<a href="" class="bullshit__return-home">Back to home</a> <a href="/#/" class="bullshit__return-home">{{ $t("errorPage.notFoundBackHome") }}</a>
</div> </div>
</div> </div>
</div> </div>
+59 -2
View File
@@ -15,6 +15,18 @@
<el-card shadow="never"> <el-card shadow="never">
<div class="mb-3"> <div class="mb-3">
<el-button type="primary" @click="handleAdd">{{ $t("common.add") }}</el-button> <el-button type="primary" @click="handleAdd">{{ $t("common.add") }}</el-button>
<el-upload
v-model:file-list="importFileList"
:http-request="handleImport"
:show-file-list="false"
accept=".json"
:limit="1"
:before-upload="beforeImport"
class="inline-block ml-2"
>
<el-button>{{ $t("common.import") }}</el-button>
</el-upload>
<el-button class="ml-2" @click="handleExport">{{ $t("common.export") }}</el-button>
</div> </div>
<el-table v-loading="loading" :data="records"> <el-table v-loading="loading" :data="records">
<el-table-column :label="$t('peer.name')" min-width="220"> <el-table-column :label="$t('peer.name')" min-width="220">
@@ -88,7 +100,7 @@
</template> </template>
</el-dialog> </el-dialog>
<el-dialog v-model="qrDialog" title="QR" width="420px"> <el-dialog v-model="qrDialog" :title="$t('peer.clientQr')" width="420px">
<el-image style="width:300px;height:300px" :src="qrSrc" /> <el-image style="width:300px;height:300px" :src="qrSrc" />
</el-dialog> </el-dialog>
@@ -127,21 +139,24 @@
import { computed, onMounted, reactive, ref } from "vue"; import { computed, onMounted, reactive, ref } from "vue";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import copy from "copy-to-clipboard"; import copy from "copy-to-clipboard";
import { ElMessageBox } from "element-plus"; import { ElMessage, ElMessageBox } from "element-plus";
import { formatBytes } from "@/utils/byte"; import { formatBytes } from "@/utils/byte";
import { getMonthLater, timestampToDateTime } from "@/utils/time"; import { getMonthLater, timestampToDateTime } from "@/utils/time";
import { import {
deletePeerApi, deletePeerApi,
getPeerApi, getPeerApi,
getPeerClientConfigApi, getPeerClientConfigApi,
importPeerApi,
kickPeerApi, kickPeerApi,
pagePeerApi, pagePeerApi,
releaseKickPeerApi, releaseKickPeerApi,
resetPeerTrafficApi, resetPeerTrafficApi,
savePeerApi, savePeerApi,
updatePeerApi, updatePeerApi,
exportPeerApi,
} from "@/api/peer"; } from "@/api/peer";
import { PeerPageDto, PeerSaveDto, PeerUpdateDto, PeerVo } from "@/api/peer/types"; import { PeerPageDto, PeerSaveDto, PeerUpdateDto, PeerVo } from "@/api/peer/types";
import { UploadFile, UploadRawFile, UploadRequestOptions } from "element-plus/lib/components";
const { t } = useI18n(); const { t } = useI18n();
const loading = ref(false); const loading = ref(false);
@@ -149,6 +164,7 @@ const total = ref(0);
const records = ref<PeerVo[]>([]); const records = ref<PeerVo[]>([]);
const qrDialog = ref(false); const qrDialog = ref(false);
const qrSrc = ref(""); const qrSrc = ref("");
const importFileList = ref<UploadFile[]>([]);
const overviewQrSrc = ref(""); const overviewQrSrc = ref("");
const overviewClientUrl = ref(""); const overviewClientUrl = ref("");
const formRef = ref(); const formRef = ref();
@@ -281,6 +297,47 @@ async function showQr(row: PeerVo) {
qrDialog.value = true; qrDialog.value = true;
} }
async function handleImport(params: UploadRequestOptions) {
if (importFileList.value.length <= 0) {
return;
}
const formData = new FormData();
formData.append("file", params.file);
await importPeerApi(formData);
importFileList.value = [];
await handleQuery();
}
function beforeImport(file: UploadRawFile) {
if (!file.name.endsWith(".json")) {
ElMessage.error(t("common.fileFormatUnsupported"));
return false;
}
if (file.size / 1024 / 1024 > 2) {
ElMessage.error(t("common.fileTooLarge"));
return false;
}
return true;
}
async function handleExport() {
try {
const response = await exportPeerApi();
const blob = new Blob([response.data], { type: "application/octet-stream" });
const url = window.URL.createObjectURL(blob);
const a = document.createElement("a");
document.body.appendChild(a);
a.href = url;
const dis = response.headers["content-disposition"] || "";
a.download = dis.split("attachment; filename=")[1] || "peers-export.json";
a.click();
window.URL.revokeObjectURL(url);
ElMessage.success(t("common.downloadSuccess"));
} catch {
ElMessage.error(t("common.invalid"));
}
}
onMounted(handleQuery); onMounted(handleQuery);
</script> </script>
+53
View File
@@ -0,0 +1,53 @@
package middleware
import (
"net/http"
"strings"
"github.com/gin-gonic/gin"
"hy2xs-admin/dao"
"hy2xs-admin/model/constant"
)
func MachineAuthHandler() gin.HandlerFunc {
return func(c *gin.Context) {
secretCfg, err := dao.GetConfig("key = ?", constant.Hysteria2TrafficStatsSecret)
if err != nil || secretCfg.Value == nil {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
"code": http.StatusForbidden,
"type": "no",
"message": "machine auth failed",
"data": nil,
})
return
}
expected := strings.TrimSpace(*secretCfg.Value)
if expected == "" {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
"code": http.StatusForbidden,
"type": "no",
"message": "machine auth not configured",
"data": nil,
})
return
}
provided := strings.TrimSpace(c.Query("access_token"))
if provided == "" {
provided = strings.TrimSpace(c.GetHeader("X-HY2XS-Machine-Token"))
}
if provided == "" || provided != expected {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
"code": http.StatusForbidden,
"type": "no",
"message": "machine auth failed",
"data": nil,
})
return
}
c.Next()
}
}
+1 -1
View File
@@ -46,7 +46,7 @@ func Router(router *gin.Engine, huiWebContext *string) {
globalGroup := router.Group(relativePath) globalGroup := router.Group(relativePath)
machineApi := globalGroup.Group("/hui") machineApi := globalGroup.Group("/hui")
machineApi.Use(middleware.LocalOnlyHandler(), middleware.LogHandler()) machineApi.Use(middleware.LocalOnlyHandler(), middleware.MachineAuthHandler(), middleware.LogHandler())
initHysteria2MachineAuthRouter(machineApi) initHysteria2MachineAuthRouter(machineApi)
authApi := globalGroup.Group("/hui") authApi := globalGroup.Group("/hui")
+41 -3
View File
@@ -9,6 +9,8 @@ import (
"hy2xs-admin/model/bo" "hy2xs-admin/model/bo"
"hy2xs-admin/model/constant" "hy2xs-admin/model/constant"
"hy2xs-admin/model/entity" "hy2xs-admin/model/entity"
"net"
"net/url"
"os" "os"
"strconv" "strconv"
"strings" "strings"
@@ -88,6 +90,9 @@ func UpdateHysteria2Config(hysteria2ServerConfig bo.Hysteria2ServerConfig) error
http.Insecure = &authHttpInsecure http.Insecure = &authHttpInsecure
auth.HTTP = &http auth.HTTP = &http
hysteria2ServerConfig.Auth = &auth hysteria2ServerConfig.Auth = &auth
if hysteria2ServerConfig.TrafficStats == nil {
hysteria2ServerConfig.TrafficStats = &bo.ServerConfigTrafficStats{}
}
hysteria2ServerConfig.TrafficStats.Secret = &trafficStatsSecret hysteria2ServerConfig.TrafficStats.Secret = &trafficStatsSecret
yamlConfig, err := yaml.Marshal(&hysteria2ServerConfig) yamlConfig, err := yaml.Marshal(&hysteria2ServerConfig)
@@ -119,15 +124,40 @@ func GetHysteria2ApiPort() (int64, error) {
logrus.Errorf(errMsg) logrus.Errorf(errMsg)
return 0, errors.New(errMsg) return 0, errors.New(errMsg)
} }
apiPort, err := strconv.ParseInt(strings.Split(*hysteria2Config.TrafficStats.Listen, ":")[1], 10, 64) apiPort, err := parseTrafficStatsPort(*hysteria2Config.TrafficStats.Listen)
if err != nil { if err != nil {
errMsg := fmt.Sprintf("apiPort: %s is invalid", *hysteria2Config.TrafficStats.Listen) errMsg := fmt.Sprintf("apiPort: %s is invalid: %v", *hysteria2Config.TrafficStats.Listen, err)
logrus.Errorf(errMsg) logrus.Errorf(errMsg)
return 0, errors.New(errMsg) return 0, errors.New(errMsg)
} }
return apiPort, nil return apiPort, nil
} }
func parseTrafficStatsPort(listen string) (int64, error) {
trimmed := strings.TrimSpace(listen)
if trimmed == "" {
return 0, errors.New("empty listen")
}
hostPort := trimmed
if strings.HasPrefix(trimmed, ":") {
hostPort = "127.0.0.1" + trimmed
}
_, portStr, err := net.SplitHostPort(hostPort)
if err != nil {
return 0, err
}
port, err := strconv.ParseInt(portStr, 10, 64)
if err != nil {
return 0, err
}
if port <= 0 || port > 65535 {
return 0, errors.New("port out of range")
}
return port, nil
}
func GetPortAndCert() (int64, string, string, error) { func GetPortAndCert() (int64, string, string, error) {
configs, err := dao.ListConfig("key in ?", []string{constant.HUIWebPort, constant.HUICrtPath, constant.HUIKeyPath}) configs, err := dao.ListConfig("key in ?", []string{constant.HUIWebPort, constant.HUICrtPath, constant.HUIKeyPath})
if err != nil { if err != nil {
@@ -173,5 +203,13 @@ func GetAuthHttpUrl() (string, error) {
if config.Value != nil && *config.Value != "/" && strings.HasPrefix(*config.Value, "/") { if config.Value != nil && *config.Value != "/" && strings.HasPrefix(*config.Value, "/") {
webContext = *config.Value webContext = *config.Value
} }
return fmt.Sprintf("%s://127.0.0.1:%d%s/hui/hysteria2/auth", protocol, port, webContext), nil trafficSecretConfig, err := dao.GetConfig("key = ?", constant.Hysteria2TrafficStatsSecret)
if err != nil {
return "", err
}
authURL := fmt.Sprintf("%s://127.0.0.1:%d%s/hui/hysteria2/auth", protocol, port, webContext)
if trafficSecretConfig.Value != nil && strings.TrimSpace(*trafficSecretConfig.Value) != "" {
authURL = fmt.Sprintf("%s?access_token=%s", authURL, url.QueryEscape(strings.TrimSpace(*trafficSecretConfig.Value)))
}
return authURL, nil
} }