fix: полный production-фикс fix40 (layout, sidebar, journald, логирование)

This commit is contained in:
2026-05-21 14:51:19 +05:00
parent 53711aecd3
commit 85798bebb6
19 changed files with 218 additions and 131 deletions
+13 -20
View File
@@ -7,6 +7,7 @@ import (
"hy2xs-admin/model/constant"
"hy2xs-admin/model/dto"
"hy2xs-admin/model/vo"
"hy2xs-admin/service"
"hy2xs-admin/util"
"time"
)
@@ -55,34 +56,17 @@ func LogHysteria2(c *gin.Context) {
if err != nil {
return
}
exists := util.Exists(constant.Hysteria2LogPath)
logHysteria2Vos := make([]vo.LogHysteria2Vo, 0)
if !exists {
vo.Success(logHysteria2Vos, c)
return
}
numLine := 0
if logSystemDto.NumLine != nil && *logSystemDto.NumLine > 0 {
numLine = *logSystemDto.NumLine
}
logLines, total, err := util.ReadLinesFromBottom(constant.Hysteria2LogPath, numLine)
logHysteria2Vos, total, err := service.ReadHysteriaJournalLogs(numLine)
if err != nil {
vo.Fail("Unable to read log file", c)
vo.Fail("Unable to read hysteria journal logs", c)
return
}
for _, line := range logLines {
if line == "" {
continue
}
logHysteria2Vo := vo.LogHysteria2Vo{}
err := json.Unmarshal([]byte(line), &logHysteria2Vo)
if err != nil {
vo.Fail("Unable to unmarshal log data", c)
continue
}
logHysteria2Vos = append(logHysteria2Vos, logHysteria2Vo)
}
vo.Success(vo.LogSystemPage[vo.LogHysteria2Vo]{
LogSystemVos: logHysteria2Vos,
Total: int64(total),
@@ -102,7 +86,16 @@ func ExportLog(c *gin.Context) {
filePath = constant.SystemLogPath
} else if *logExportDto.Option == 1 {
fileName = fmt.Sprintf("hysteria2-%s.log", time.Now().Format("20060102150405"))
filePath = constant.Hysteria2LogPath
output, exportErr := service.ExportHysteriaJournalLogs(5000)
if exportErr != nil {
vo.Fail("failed to export hysteria journal logs", c)
return
}
c.Header("Content-Type", "text/plain; charset=utf-8")
c.Header("Content-Transfer-Encoding", "binary")
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", fileName))
c.Data(200, "text/plain; charset=utf-8", []byte(output))
return
}
if !util.Exists(filePath) {
+1 -1
View File
@@ -5,7 +5,7 @@ const appStore = useAppStore();
</script>
<template>
<el-config-provider :locale="appStore.locale" :size="appStore.size">
<el-config-provider :locale="appStore.locale">
<router-view />
</el-config-provider>
</template>
-1
View File
@@ -1 +0,0 @@
<svg width="128" height="128" xmlns="http://www.w3.org/2000/svg"><path d="M0 54.857h54.796v18.286H36.531V128H18.265V73.143H0V54.857zm127.857-36.571H91.935V128H72.456V18.286H36.534V0h91.326l-.003 18.286z"/></svg>

Before

Width:  |  Height:  |  Size: 211 B

@@ -1,40 +0,0 @@
<script setup lang="ts">
import { useAppStore } from "@/store/modules/app";
import { useI18n } from "vue-i18n";
const appStore = useAppStore();
const { t } = useI18n();
const sizeOptions = ref([
{ label: t("common.sizeDefault"), value: "default" },
{ label: t("common.sizeLarge"), value: "large" },
{ label: t("common.sizeSmall"), value: "small" },
]);
function handleSizeChange(size: string) {
appStore.changeSize(size);
ElMessage.success(t("common.sizeChanged"));
}
</script>
<template>
<el-dropdown trigger="click" @command="handleSizeChange">
<div>
<svg-icon icon-class="size" />
</div>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item
v-for="item of sizeOptions"
:key="item.value"
:disabled="appStore.size == item.value"
:command="item.value"
>
{{ item.label }}
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</template>
-4
View File
@@ -112,10 +112,6 @@ export default {
resetTrafficConfirm: "Are you sure to reset peer traffic?",
invalid: "Invalid value",
switchLanguageSuccess: "Language switched successfully",
sizeChanged: "Interface size changed",
sizeDefault: "Default",
sizeLarge: "Large",
sizeSmall: "Small",
logoutConfirm: "Are you sure you want to log out?",
sessionExpired: "Current session has expired, please log in again",
},
-4
View File
@@ -108,10 +108,6 @@ export default {
resetTrafficConfirm: "Сбросить трафик для пира?",
invalid: "Некорректное значение",
switchLanguageSuccess: "Язык переключён",
sizeChanged: "Размер интерфейса изменён",
sizeDefault: "Обычный",
sizeLarge: "Крупный",
sizeSmall: "Компактный",
logoutConfirm: "Выйти из системы?",
sessionExpired: "Текущая сессия истекла, войдите снова",
},
+17 -12
View File
@@ -53,7 +53,7 @@ function logout() {
<!-- Верхняя навигационная панель -->
<div class="navbar">
<!-- Хлебные крошки слева -->
<div class="flex">
<div class="navbar-left">
<hamburger
:is-active="appStore.sidebar.opened"
@toggleClick="toggleSideBar"
@@ -62,7 +62,7 @@ function logout() {
</div>
<!-- Настройки навигации справа -->
<div class="flex">
<div class="navbar-actions">
<!-- Настройки навигации, скрытые на узком экране -->
<div class="setting-container" v-if="device !== 'mobile'">
<!-- Полноэкранный режим -->
@@ -71,15 +71,12 @@ function logout() {
:icon-class="isFullscreen ? 'exit-fullscreen' : 'fullscreen'"
/>
</div>
<!-- Размер интерфейса -->
<el-tooltip content="Размер интерфейса" effect="dark" placement="bottom">
<size-select class="setting-item" />
</el-tooltip>
<!-- Выбор языка -->
<lang-select class="setting-item" />
</div>
<!-- Аватар пользователя -->
<div class="avatar-dropdown">
<el-dropdown trigger="click">
<div class="avatar-container">
<el-avatar :size="32" class="avatar-icon">
@@ -97,6 +94,7 @@ function logout() {
</el-dropdown>
</div>
</div>
</div>
</template>
<style lang="scss" scoped>
@@ -108,17 +106,24 @@ function logout() {
background-color: #fff;
box-shadow: 0 0 1px #0003;
.setting-container {
.navbar-left,
.navbar-actions,
.setting-container,
.avatar-dropdown {
display: flex;
align-items: center;
height: 100%;
}
.setting-container {
.setting-item {
display: inline-block;
display: flex;
align-items: center;
justify-content: center;
width: 30px;
height: 50px;
line-height: 50px;
line-height: normal;
color: #5a5e66;
text-align: center;
cursor: pointer;
&:hover {
@@ -132,8 +137,8 @@ function logout() {
align-items: center;
justify-content: center;
gap: 6px;
height: 36px;
margin: 0 8px;
height: 50px;
margin: 0;
padding: 0 8px;
border-radius: 8px;
cursor: pointer;
+1 -1
View File
@@ -110,7 +110,7 @@ function handleOutsideClick() {
}
.hideSidebar .fixed-header {
width: calc(100% - 54px);
width: calc(100% - #{$sideBarCollapsedWidth});
}
.mobile .fixed-header {
-6
View File
@@ -34,11 +34,6 @@ interface DefaultSettings {
*/
theme: string;
/**
* Размер интерфейса
*/
size: string;
/**
* Язык
*/
@@ -60,7 +55,6 @@ const defaultSettings: DefaultSettings = {
* light: Светлая тема
*/
theme: "dark",
size: "default", // default |large |small
language: "ru", // ru | en
};
-6
View File
@@ -9,7 +9,6 @@ import ru from "element-plus/es/locale/lang/ru";
export const useAppStore = defineStore("app", () => {
// state
const device = useStorage("device", "desktop");
const size = useStorage<any>("size", defaultSettings.size);
const language = useStorage("language", defaultSettings.language);
const sidebarStatus = useStorage("sidebarStatus", "closed");
@@ -52,9 +51,6 @@ export const useAppStore = defineStore("app", () => {
device.value = val;
}
function changeSize(val: string) {
size.value = val;
}
/**
* Переключение языка
*
@@ -69,9 +65,7 @@ export const useAppStore = defineStore("app", () => {
sidebar,
language,
locale,
size,
toggleDevice,
changeSize,
changeLanguage,
toggleSidebar,
closeSideBar,
+22 -4
View File
@@ -109,7 +109,7 @@
.hideSidebar {
.sidebar-container {
width: 54px !important;
width: $sideBarCollapsedWidth !important;
.svg-icon {
margin-right: 0;
@@ -117,7 +117,26 @@
}
.main-container {
margin-left: 54px;
margin-left: $sideBarCollapsedWidth;
}
.sidebar-container .el-menu--collapse {
width: $sideBarCollapsedWidth !important;
}
.sidebar-container .el-menu--collapse > .el-menu-item,
.sidebar-container .el-menu--collapse > .el-sub-menu > .el-sub-menu__title {
justify-content: center;
gap: 0;
padding: 0 !important;
}
.sidebar-container .el-menu--collapse .svg-icon {
display: block;
flex: 0 0 18px;
width: 18px;
height: 18px;
margin: 0;
}
.el-sub-menu {
@@ -146,7 +165,7 @@
}
.el-menu--collapse .el-menu .el-sub-menu {
min-width: $sideBarWidth !important;
min-width: $sideBarCollapsedWidth !important;
}
// mobile responsive
@@ -218,4 +237,3 @@
}
}
+1
View File
@@ -20,5 +20,6 @@ $subMenuActiveText: var(--subMenuActiveText);
$subMenuHover: var(--subMenuHover);
$sideBarWidth: 210px;
$sideBarCollapsedWidth: 54px;
-1
View File
@@ -60,7 +60,6 @@ declare module '@vue/runtime-core' {
RightPanel: typeof import('./../components/RightPanel/index.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
SizeSelect: typeof import('./../components/SizeSelect/index.vue')['default']
SvgIcon: typeof import('./../components/SvgIcon/index.vue')['default']
UnitSelect: typeof import('./../components/UnitSelect/index.vue')['default']
}
+20 -14
View File
@@ -18,26 +18,32 @@ func InitLog() {
LocalTime: true,
})
logrus.SetFormatter(&logrus.JSONFormatter{TimestampFormat: "2006-01-02 15:04:05"})
logrus.SetLevel(logrus.WarnLevel)
logrus.SetLevel(logrus.InfoLevel)
}
func LogHandler() gin.HandlerFunc {
return func(c *gin.Context) {
startTime := time.Now()
endTime := time.Now()
statusCode := c.Writer.Status()
latencyTime := endTime.Sub(startTime)
clientIP := c.ClientIP()
reqMethod := c.Request.Method
reqUri := c.Request.RequestURI
logrus.WithFields(logrus.Fields{
"statusCode": statusCode,
"latencyTime": latencyTime,
"clientIP": clientIP,
"reqMethod": reqMethod,
"reqUri": reqUri,
}).Info()
c.Next()
statusCode := c.Writer.Status()
latencyTime := time.Since(startTime)
entry := logrus.WithFields(logrus.Fields{
"statusCode": statusCode,
"latencyTime": latencyTime.Milliseconds(),
"clientIP": c.ClientIP(),
"reqMethod": c.Request.Method,
"reqUri": c.Request.RequestURI,
})
if statusCode >= 500 {
entry.Error()
} else if statusCode >= 400 {
entry.Warn()
} else {
entry.Info()
}
}
}
-1
View File
@@ -26,7 +26,6 @@ var (
Hysteria2BinPath = "/usr/local/bin/hysteria"
SystemLogPath = filepath.Join(LogDir, "hy2xs-admin.log")
Hysteria2LogPath = filepath.Join(LogDir, "hysteria2.log")
)
const (
+120
View File
@@ -0,0 +1,120 @@
package service
import (
"bufio"
"encoding/json"
"fmt"
"hy2xs-admin/model/vo"
"hy2xs-admin/util"
"strconv"
"strings"
"time"
)
type journalctlLogLine struct {
Message string `json:"MESSAGE"`
Priority string `json:"PRIORITY"`
RealtimeTimestamp string `json:"__REALTIME_TIMESTAMP"`
}
func ReadHysteriaJournalLogs(numLine int) ([]vo.LogHysteria2Vo, int, error) {
lines := 100
if numLine > 0 {
lines = numLine
}
output, err := util.Exec(fmt.Sprintf("journalctl --no-pager -u hysteria-server.service -n %d -o json", lines))
if err != nil {
return nil, 0, err
}
result := make([]vo.LogHysteria2Vo, 0, lines)
scanner := bufio.NewScanner(strings.NewReader(output))
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" {
continue
}
item := journalctlLogLine{}
if err = json.Unmarshal([]byte(line), &item); err != nil {
continue
}
result = append(result, parseHysteriaJournalRecord(item))
}
if scanErr := scanner.Err(); scanErr != nil {
return nil, 0, scanErr
}
return result, len(result), nil
}
func ExportHysteriaJournalLogs(numLine int) (string, error) {
lines := 5000
if numLine > 0 {
lines = numLine
}
return util.Exec(fmt.Sprintf("journalctl --no-pager -u hysteria-server.service -n %d -o short-iso", lines))
}
func parseHysteriaJournalRecord(item journalctlLogLine) vo.LogHysteria2Vo {
fallbackTime := convertJournalTimestamp(item.RealtimeTimestamp)
fallbackLevel := mapJournalPriorityToLevel(item.Priority)
if strings.TrimSpace(item.Message) == "" {
return vo.LogHysteria2Vo{
Level: fallbackLevel,
Msg: "",
Time: fallbackTime,
}
}
parsed := vo.LogHysteria2Vo{}
if err := json.Unmarshal([]byte(item.Message), &parsed); err == nil {
if parsed.Level == "" {
parsed.Level = fallbackLevel
}
if parsed.Time == "" {
parsed.Time = fallbackTime
}
if parsed.Msg == "" {
parsed.Msg = item.Message
}
return parsed
}
return vo.LogHysteria2Vo{
Level: fallbackLevel,
Msg: item.Message,
Time: fallbackTime,
}
}
func convertJournalTimestamp(raw string) string {
if raw == "" {
return ""
}
us, err := strconv.ParseInt(raw, 10, 64)
if err != nil {
return ""
}
t := time.UnixMicro(us)
return t.Format("2006-01-02 15:04:05")
}
func mapJournalPriorityToLevel(priority string) string {
switch strings.TrimSpace(priority) {
case "0", "1", "2", "3":
return "error"
case "4":
return "warn"
default:
return "info"
}
}
@@ -73,6 +73,10 @@ journalctl -u hysteria-server -n 100 --no-pager
journalctl -u hy2xs-admin -n 100 --no-pager
```
Источник логов в UI:
- Страница «Логи Hysteria» читает записи из journald unit `hysteria-server.service`.
- Экспорт «Логи Hysteria» также формируется из journald (`journalctl`), а не из отдельного файла `hysteria2.log`.
Проверка install-state marker:
```bash
cat /var/lib/hy2xs/install-state.json
+1
View File
@@ -7,6 +7,7 @@ Wants=network-online.target
Type=simple
User=hy2xs-admin
Group=hy2xs-admin
SupplementaryGroups=systemd-journal
WorkingDirectory={{INSTALL_DIR}}
EnvironmentFile=/etc/hy2xs/hy2xs.env
Environment=GIN_MODE=release
+2
View File
@@ -7,6 +7,8 @@ Wants=network-online.target
Type=simple
User=hysteria
Group=hysteria
Environment=HYSTERIA_LOG_LEVEL=info
Environment=HYSTERIA_LOG_FORMAT=json
ExecStart=/usr/local/bin/hysteria server -c /etc/hysteria/config.yaml
Restart=on-failure
RestartSec=5s