fix: полный production-фикс fix40 (layout, sidebar, journald, логирование)
This commit is contained in:
+13
-20
@@ -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) {
|
||||
|
||||
@@ -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 +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>
|
||||
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -108,10 +108,6 @@ export default {
|
||||
resetTrafficConfirm: "Сбросить трафик для пира?",
|
||||
invalid: "Некорректное значение",
|
||||
switchLanguageSuccess: "Язык переключён",
|
||||
sizeChanged: "Размер интерфейса изменён",
|
||||
sizeDefault: "Обычный",
|
||||
sizeLarge: "Крупный",
|
||||
sizeSmall: "Компактный",
|
||||
logoutConfirm: "Выйти из системы?",
|
||||
sessionExpired: "Текущая сессия истекла, войдите снова",
|
||||
},
|
||||
|
||||
@@ -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,30 +71,28 @@ 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>
|
||||
|
||||
<!-- Аватар пользователя -->
|
||||
<el-dropdown trigger="click">
|
||||
<div class="avatar-container">
|
||||
<el-avatar :size="32" class="avatar-icon">
|
||||
<i-ep-user-filled />
|
||||
</el-avatar>
|
||||
<i-ep-caret-bottom class="w-3 h-3" />
|
||||
</div>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item @click="logout">
|
||||
{{ $t("navbar.logout") }}
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
<div class="avatar-dropdown">
|
||||
<el-dropdown trigger="click">
|
||||
<div class="avatar-container">
|
||||
<el-avatar :size="32" class="avatar-icon">
|
||||
<i-ep-user-filled />
|
||||
</el-avatar>
|
||||
<i-ep-caret-bottom class="w-3 h-3" />
|
||||
</div>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item @click="logout">
|
||||
{{ $t("navbar.logout") }}
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -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;
|
||||
|
||||
@@ -110,7 +110,7 @@ function handleOutsideClick() {
|
||||
}
|
||||
|
||||
.hideSidebar .fixed-header {
|
||||
width: calc(100% - 54px);
|
||||
width: calc(100% - #{$sideBarCollapsedWidth});
|
||||
}
|
||||
|
||||
.mobile .fixed-header {
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 @@
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -20,5 +20,6 @@ $subMenuActiveText: var(--subMenuActiveText);
|
||||
$subMenuHover: var(--subMenuHover);
|
||||
|
||||
$sideBarWidth: 210px;
|
||||
$sideBarCollapsedWidth: 54px;
|
||||
|
||||
|
||||
|
||||
-1
@@ -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
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,8 +25,7 @@ var (
|
||||
Hysteria2ConfigPath = "/etc/hysteria/config.yaml"
|
||||
Hysteria2BinPath = "/usr/local/bin/hysteria"
|
||||
|
||||
SystemLogPath = filepath.Join(LogDir, "hy2xs-admin.log")
|
||||
Hysteria2LogPath = filepath.Join(LogDir, "hysteria2.log")
|
||||
SystemLogPath = filepath.Join(LogDir, "hy2xs-admin.log")
|
||||
)
|
||||
|
||||
const (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user