Полная зачистка legacy + закрытие fix24.1/fix24.2 + обновление логотипа

This commit is contained in:
2026-05-08 23:16:25 +05:00
parent de4a65a959
commit 1bca73814e
85 changed files with 4119 additions and 1911 deletions
+11 -7
View File
@@ -9,9 +9,9 @@ import (
)
var resetCmd = &cobra.Command{
Use: "reset",
Short: "Reset username and password",
Long: "Reset username and password.",
Use: "reset-admin",
Short: "Reset admin username and password",
Long: "Reset admin username and password.",
Run: runReset,
}
@@ -34,14 +34,19 @@ func runReset(cmd *cobra.Command, args []string) {
fmt.Println(err.Error())
os.Exit(1)
}
if err = dao.UpdateAccount([]int64{1}, map[string]interface{}{
admin, err := dao.GetAdminUser("id = ?", 1)
if err != nil {
fmt.Println(err.Error())
os.Exit(1)
}
if err = dao.UpdateAdminUser([]int64{*admin.Id}, map[string]interface{}{
"username": username,
"pass": func() string {
"password_hash": func() string {
hash, _ := util.HashPassword(password)
return hash
}(),
"force_password_change": 1,
"con_pass": fmt.Sprintf("%s.%s", username, password)}); err != nil {
}); err != nil {
fmt.Println(err.Error())
os.Exit(1)
}
@@ -51,5 +56,4 @@ func runReset(cmd *cobra.Command, args []string) {
}
fmt.Println(fmt.Sprintf("HY2XS admin Login Username: %s", username))
fmt.Println(fmt.Sprintf("HY2XS admin Login Password: %s", password))
fmt.Println(fmt.Sprintf("HY2XS admin Connection Password: %s", fmt.Sprintf("%s.%s", username, password)))
}
+30
View File
@@ -0,0 +1,30 @@
package controller
import (
"github.com/gin-gonic/gin"
"hy2xs-admin/model/dto"
"hy2xs-admin/model/vo"
"hy2xs-admin/service"
)
func AdminMe(c *gin.Context) {
info, err := service.GetAdminInfo(c)
if err != nil {
vo.Fail(err.Error(), c)
return
}
vo.Success(info, c)
}
func AdminChangePassword(c *gin.Context) {
changeDto, err := validateField(c, dto.AdminChangePasswordDto{})
if err != nil {
return
}
if err = service.ChangeAdminPassword(c, *changeDto.OldPassword, *changeDto.NewPassword); err != nil {
vo.Fail(err.Error(), c)
return
}
vo.Success(nil, c)
}
+1 -1
View File
@@ -287,7 +287,7 @@ func ImportConfig(c *gin.Context) {
return
}
if !strings.HasSuffix(header.Filename, ".json") {
vo.Fail("file format not supported", c)
vo.Fail(constant.InvalidError, c)
return
}
content, err := io.ReadAll(file)
+53
View File
@@ -0,0 +1,53 @@
package controller
import (
"github.com/gin-gonic/gin"
"hy2xs-admin/model/vo"
"hy2xs-admin/service"
"strconv"
)
func DashboardSummary(c *gin.Context) {
data, err := service.DashboardSummary()
if err != nil {
vo.Fail(err.Error(), c)
return
}
vo.Success(data, c)
}
func DashboardTimeseries(c *gin.Context) {
rangeKey := c.DefaultQuery("range", "24h")
data, err := service.DashboardTimeseries(rangeKey)
if err != nil {
vo.Fail(err.Error(), c)
return
}
vo.Success(data, c)
}
func DashboardTopPeers(c *gin.Context) {
rangeKey := c.DefaultQuery("range", "24h")
limit := 10
if raw := c.Query("limit"); raw != "" {
if parsed, err := strconv.Atoi(raw); err == nil && parsed > 0 {
limit = parsed
}
}
data, err := service.DashboardTopPeers(rangeKey, limit)
if err != nil {
vo.Fail(err.Error(), c)
return
}
vo.Success(data, c)
}
func DashboardSecurity(c *gin.Context) {
data, err := service.DashboardSecurity()
if err != nil {
vo.Fail(err.Error(), c)
return
}
vo.Success(data, c)
}
+24 -16
View File
@@ -4,12 +4,25 @@ import (
"github.com/gin-gonic/gin"
"github.com/skip2/go-qrcode"
"hy2xs-admin/model/dto"
"hy2xs-admin/model/entity"
"hy2xs-admin/model/vo"
"hy2xs-admin/service"
"strconv"
"strings"
"time"
)
func resolvePeerID(c *gin.Context) (*int64, bool) {
raw := strings.TrimSpace(c.Param("id"))
if raw == "" {
return nil, false
}
parsed, err := strconv.ParseInt(raw, 10, 64)
if err != nil || parsed <= 0 {
return nil, false
}
return &parsed, true
}
func Hysteria2Auth(c *gin.Context) {
var req dto.Hysteria2AuthDto
if err := c.ShouldBindJSON(&req); err != nil {
@@ -28,10 +41,7 @@ func Hysteria2Auth(c *gin.Context) {
// Обновление времени последнего подключения
now := time.Now().UnixMilli()
if err = service.UpdateAccount(entity.Account{
BaseEntity: entity.BaseEntity{Id: &id},
ConAt: &now,
}); err != nil {
if err = service.UpdatePeerLastConnectionAt(id, now); err != nil {
vo.Fail(err.Error(), c)
return
}
@@ -60,9 +70,15 @@ func ListRelease(c *gin.Context) {
}
func Hysteria2Url(c *gin.Context) {
hysteria2UrlDto, err := validateField(c, dto.Hysteria2UrlDto{})
if err != nil {
return
hysteria2UrlDto := dto.Hysteria2UrlDto{}
if id, ok := resolvePeerID(c); ok {
hysteria2UrlDto.AccountId = id
} else {
var err error
hysteria2UrlDto, err = validateField(c, dto.Hysteria2UrlDto{})
if err != nil {
return
}
}
url, err := service.Hysteria2Url(*hysteria2UrlDto.AccountId)
@@ -82,11 +98,3 @@ func Hysteria2Url(c *gin.Context) {
}
vo.Success(hysteria2UrlVo, c)
}
func Hysteria2SubscribeUrl(c *gin.Context) {
vo.Fail("subscription delivery is out of scope in HY2XS baseline", c)
}
func Hysteria2Subscribe(c *gin.Context) {
vo.Fail("subscription delivery is out of scope in HY2XS baseline", c)
}
-25
View File
@@ -1,25 +0,0 @@
package controller
import (
"github.com/gin-gonic/gin"
"hy2xs-admin/model/vo"
"hy2xs-admin/service"
)
func MonitorSystem(c *gin.Context) {
systemMonitorVo, err := service.MonitorSystem()
if err != nil {
vo.Fail(err.Error(), c)
return
}
vo.Success(systemMonitorVo, c)
}
func MonitorHysteria2(c *gin.Context) {
hysteria2MonitorVo, err := service.MonitorHysteria2()
if err != nil {
vo.Fail(err.Error(), c)
return
}
vo.Success(hysteria2MonitorVo, c)
}
@@ -4,6 +4,7 @@ import (
"encoding/json"
"fmt"
"github.com/gin-gonic/gin"
"github.com/sirupsen/logrus"
"hy2xs-admin/model/constant"
"hy2xs-admin/model/dto"
"hy2xs-admin/model/entity"
@@ -12,21 +13,31 @@ import (
"hy2xs-admin/util"
"io"
"path/filepath"
"strconv"
"strings"
"time"
)
func resolveID(c *gin.Context) (int64, error) {
if raw := strings.TrimSpace(c.Param("id")); raw != "" {
parsed, err := strconv.ParseInt(raw, 10, 64)
if err == nil && parsed > 0 {
return parsed, nil
}
}
idDto, err := validateField(c, dto.IdDto{})
if err != nil {
return 0, err
}
return *idDto.Id, nil
}
func Login(c *gin.Context) {
loginDto, err := validateField(c, dto.LoginDto{})
if err != nil {
return
}
if !service.ExistAccountUsername(*loginDto.Username, 0) {
vo.Fail("account not exist", c)
return
}
token, forcePasswordChange, err := service.Login(*loginDto.Username, *loginDto.Pass)
if err != nil {
vo.Fail(err.Error(), c)
@@ -40,12 +51,12 @@ func Login(c *gin.Context) {
vo.Success(jwtVo, c)
}
func PageAccount(c *gin.Context) {
accountPageDto, err := validateField(c, dto.AccountPageDto{})
func PagePeer(c *gin.Context) {
peerPageDto, err := validateField(c, dto.PeerPageDto{})
if err != nil {
return
}
accounts, total, err := service.PageAccount(accountPageDto)
accounts, total, err := service.PagePeer(peerPageDto)
if err != nil {
vo.Fail(err.Error(), c)
return
@@ -91,13 +102,13 @@ func PageAccount(c *gin.Context) {
vo.Success(accountPageVo, c)
}
func SaveAccount(c *gin.Context) {
func SavePeer(c *gin.Context) {
accountSaveDto, err := validateField(c, dto.AccountSaveDto{})
if err != nil {
return
}
if service.ExistAccountUsername(*accountSaveDto.Username, 0) {
if service.ExistPeerName(*accountSaveDto.Username, 0) {
vo.Fail(fmt.Sprintf("username %s already exists", *accountSaveDto.Username), c)
return
}
@@ -118,7 +129,7 @@ func SaveAccount(c *gin.Context) {
Deleted: accountSaveDto.Deleted,
Remark: accountSaveDto.Remark,
}
err = service.SaveAccount(account)
err = service.SavePeer(account)
if err != nil {
vo.Fail(err.Error(), c)
return
@@ -126,12 +137,12 @@ func SaveAccount(c *gin.Context) {
vo.Success(nil, c)
}
func DeleteAccount(c *gin.Context) {
idDto, err := validateField(c, dto.IdDto{})
func DeletePeer(c *gin.Context) {
id, err := resolveID(c)
if err != nil {
return
}
account, err := service.GetAccount(*idDto.Id)
account, err := service.GetPeer(id)
if err != nil {
vo.Fail(err.Error(), c)
return
@@ -140,7 +151,7 @@ func DeleteAccount(c *gin.Context) {
vo.Fail("admin cannot be deleted", c)
return
}
err = service.DeleteAccount([]int64{*idDto.Id})
err = service.DeletePeer([]int64{id})
if err != nil {
vo.Fail(err.Error(), c)
return
@@ -148,19 +159,72 @@ func DeleteAccount(c *gin.Context) {
vo.Success(nil, c)
}
func UpdateAccount(c *gin.Context) {
func UpdatePeer(c *gin.Context) {
accountUpdateDto, err := validateField(c, dto.AccountUpdateDto{})
if err != nil {
// PATCH /peers/:id compatibility flow: id in route param, payload without id
if c.Request.Method == "PATCH" && strings.TrimSpace(c.Param("id")) != "" {
id, parseErr := strconv.ParseInt(strings.TrimSpace(c.Param("id")), 10, 64)
if parseErr != nil || id <= 0 {
vo.Fail(constant.InvalidError, c)
return
}
var body map[string]interface{}
if bindErr := c.ShouldBindJSON(&body); bindErr != nil {
vo.Fail(constant.InvalidError, c)
return
}
legacy := entity.Account{BaseEntity: entity.BaseEntity{Id: &id}}
if v, ok := body["username"].(string); ok {
legacy.Username = &v
}
if v, ok := body["pass"].(string); ok && strings.TrimSpace(v) != "" {
hash, hashErr := util.HashPassword(v)
if hashErr != nil {
vo.Fail(hashErr.Error(), c)
return
}
legacy.Pass = &hash
}
if v, ok := body["conPass"].(string); ok {
legacy.ConPass = &v
}
if v, ok := body["quota"].(float64); ok {
t := int64(v)
legacy.Quota = &t
}
if v, ok := body["expireTime"].(float64); ok {
t := int64(v)
legacy.ExpireTime = &t
}
if v, ok := body["deviceNo"].(float64); ok {
t := int64(v)
legacy.DeviceNo = &t
}
if v, ok := body["deleted"].(float64); ok {
t := int64(v)
legacy.Deleted = &t
}
if v, ok := body["remark"].(string); ok {
legacy.Remark = &v
}
if uErr := service.UpdatePeer(legacy); uErr != nil {
vo.Fail(uErr.Error(), c)
return
}
vo.Success(nil, c)
return
}
return
}
if accountUpdateDto.Username != nil && *accountUpdateDto.Username != "" && service.ExistAccountUsername(*accountUpdateDto.Username, *accountUpdateDto.Id) {
if accountUpdateDto.Username != nil && *accountUpdateDto.Username != "" && service.ExistPeerName(*accountUpdateDto.Username, *accountUpdateDto.Id) {
vo.Fail(fmt.Sprintf("username %s already exists", *accountUpdateDto.Username), c)
return
}
if accountUpdateDto.Deleted != nil && *accountUpdateDto.Deleted == 1 {
account, err := service.GetAccount(*accountUpdateDto.Id)
account, err := service.GetPeer(*accountUpdateDto.Id)
if err != nil {
vo.Fail(err.Error(), c)
return
@@ -194,7 +258,7 @@ func UpdateAccount(c *gin.Context) {
Id: accountUpdateDto.Id,
},
}
if err = service.UpdateAccount(account); err != nil {
if err = service.UpdatePeer(account); err != nil {
vo.Fail(err.Error(), c)
return
}
@@ -202,41 +266,38 @@ func UpdateAccount(c *gin.Context) {
}
func ResetTraffic(c *gin.Context) {
idDto, err := validateField(c, dto.IdDto{})
id, err := resolveID(c)
if err != nil {
return
}
if err = service.ResetTraffic(*idDto.Id); err != nil {
if err = service.ResetTraffic(id); err != nil {
vo.Fail(err.Error(), c)
return
}
vo.Success(nil, c)
}
func GetAccountInfo(c *gin.Context) {
accountInfoVo, err := service.GetAccountInfo(c)
func GetAdminInfo(c *gin.Context) {
accountInfoVo, err := service.GetAdminInfo(c)
if err != nil {
vo.Fail(err.Error(), c)
return
}
// Обновление времени последнего входа
now := time.Now().UnixMilli()
if err = service.UpdateAccount(entity.Account{
BaseEntity: entity.BaseEntity{Id: &accountInfoVo.Id},
LoginAt: &now,
}); err != nil {
if err = service.UpdateAdminLastLoginAt(accountInfoVo.Id, now); err != nil {
vo.Fail(err.Error(), c)
return
}
vo.Success(accountInfoVo, c)
}
func GetAccount(c *gin.Context) {
idDto, err := validateField(c, dto.IdDto{})
func GetPeer(c *gin.Context) {
id, err := resolveID(c)
if err != nil {
return
}
account, err := service.GetAccount(*idDto.Id)
account, err := service.GetPeer(id)
if err != nil {
vo.Fail(err.Error(), c)
return
@@ -259,7 +320,7 @@ func GetAccount(c *gin.Context) {
vo.Success(accountVo, c)
}
func ImportAccount(c *gin.Context) {
func ImportPeer(c *gin.Context) {
file, header, err := c.Request.FormFile("file")
if err != nil {
vo.Fail(constant.SysError, c)
@@ -272,7 +333,7 @@ func ImportAccount(c *gin.Context) {
}
// Расширение файла .json
if !strings.HasSuffix(header.Filename, ".json") {
vo.Fail("file format not supported", c)
vo.Fail(constant.InvalidError, c)
return
}
content, err := io.ReadAll(file)
@@ -285,15 +346,15 @@ func ImportAccount(c *gin.Context) {
vo.Fail("content Unmarshal err", c)
return
}
if err = service.UpsertAccount(accounts); err != nil {
if err = service.UpsertPeer(accounts); err != nil {
vo.Fail(err.Error(), c)
return
}
vo.Success(nil, c)
}
func ExportAccount(c *gin.Context) {
accountExports, err := service.ListExportAccount()
func ExportPeer(c *gin.Context) {
accountExports, err := service.ListExportPeer()
if err != nil {
vo.Fail(err.Error(), c)
return
@@ -318,12 +379,13 @@ func ExportAccount(c *gin.Context) {
c.File(filePath)
}
func ReleaseKickAccount(c *gin.Context) {
idDto, err := validateField(c, dto.IdDto{})
func ReleaseKickPeer(c *gin.Context) {
id, err := resolveID(c)
if err != nil {
return
}
if err = service.ReleaseKickAccount(*idDto.Id); err != nil {
if err = service.ReleaseKickPeer(id); err != nil {
logrus.Debugf("release kick err: %v", err)
vo.Fail(err.Error(), c)
return
}
@@ -331,15 +393,15 @@ func ReleaseKickAccount(c *gin.Context) {
}
func VerifyDefaultPass(c *gin.Context) {
info, err := service.GetAccountInfo(c)
info, err := service.GetAdminInfo(c)
if err != nil {
vo.Fail(err.Error(), c)
return
}
account, err := service.GetAccount(info.Id)
admin, err := service.GetAdminAccount(info.Id)
if err != nil {
vo.Fail(err.Error(), c)
return
}
vo.Success(account.Pass != nil && !util.IsBcryptHash(*account.Pass), c)
vo.Success(admin.PasswordHash != nil && !util.IsBcryptHash(*admin.PasswordHash), c)
}
-119
View File
@@ -1,119 +0,0 @@
package dao
import (
"errors"
"fmt"
"github.com/sirupsen/logrus"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"hy2xs-admin/model/constant"
"hy2xs-admin/model/dto"
"hy2xs-admin/model/entity"
"time"
)
func SaveAccount(account entity.Account) (int64, error) {
if tx := sqliteDB.Save(&account); tx.Error != nil {
logrus.Errorf("%v", tx.Error)
return 0, errors.New(constant.SysError)
}
return *account.Id, nil
}
func DeleteAccount(ids []int64) error {
if tx := sqliteDB.Where("id in ?", ids).Delete(&entity.Account{}); tx.Error != nil {
logrus.Errorf("%v", tx.Error)
return errors.New(constant.SysError)
}
return nil
}
func UpdateAccount(ids []int64, updates map[string]interface{}) error {
if len(updates) > 0 {
updates["update_time"] = time.Now().Format("2006-01-02 15:04:05")
if tx := sqliteDB.Model(&entity.Account{}).
Where("id in ?", ids).
Updates(updates); tx.Error != nil {
logrus.Errorf("%v", tx.Error)
return errors.New(constant.SysError)
}
}
return nil
}
func UpsertAccount(accounts []entity.Account) error {
if tx := sqliteDB.Model(&entity.Account{}).Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "username"}},
DoUpdates: clause.AssignmentColumns([]string{"pass", "con_pass", "quota", "download", "upload", "expire_time", "kick_util_time", "device_no", "role", "deleted", "create_time", "update_time", "login_at", "con_at", "remark"}),
}).Create(accounts); tx.Error != nil {
logrus.Errorf("%v", tx.Error)
return errors.New(constant.SysError)
}
return nil
}
func UpdateAccountTraffic(username string, download int64, upload int64) error {
if upload != 0 || download != 0 {
updates := map[string]interface{}{}
if download != 0 {
updates["download"] = gorm.Expr("download + ?", download)
}
if upload != 0 {
updates["upload"] = gorm.Expr("upload + ?", upload)
}
updates["update_time"] = time.Now().Format("2006-01-02 15:04:05")
if tx := sqliteDB.Model(&entity.Account{}).
Where("username = ?", username).
Updates(updates); tx.Error != nil {
logrus.Errorf("%v", tx.Error)
return errors.New(constant.SysError)
}
}
return nil
}
func GetAccount(query interface{}, args ...interface{}) (entity.Account, error) {
var account entity.Account
if tx := sqliteDB.Model(&entity.Account{}).
Where(query, args...).First(&account); tx.Error != nil {
if tx.Error == gorm.ErrRecordNotFound {
return account, errors.New(constant.WrongPassword)
}
logrus.Errorf("%v", tx.Error)
return account, errors.New(constant.SysError)
}
return account, nil
}
func PageAccount(accountPageDto dto.AccountPageDto) ([]entity.Account, int64, error) {
var accounts []entity.Account
var total int64
tx := sqliteDB.Model(&entity.Account{})
if accountPageDto.Username != nil && *accountPageDto.Username != "" {
tx.Where("username like ?", fmt.Sprintf("%%%s%%", *accountPageDto.Username))
}
if accountPageDto.Deleted != nil {
tx.Where("deleted = ?", *accountPageDto.Deleted)
}
if accountPageDto.Remark != nil && *accountPageDto.Remark != "" {
tx.Where("remark like ?", fmt.Sprintf("%%%s%%", *accountPageDto.Remark))
}
tx.Count(&total)
if tx.Scopes(Paginate(accountPageDto.PageNum, accountPageDto.PageSize)).
Order("role,create_time desc").
Find(&accounts); tx.Error != nil {
logrus.Errorf("%v", tx.Error)
return accounts, 0, errors.New(constant.SysError)
}
return accounts, total, nil
}
func ListAccount(query interface{}, args ...interface{}) ([]entity.Account, error) {
var accounts []entity.Account
if tx := sqliteDB.Model(&entity.Account{}).
Where(query, args...).Order("role,create_time desc").Find(&accounts); tx.Error != nil {
logrus.Errorf("%v", tx.Error)
return accounts, errors.New(constant.SysError)
}
return accounts, nil
}
+43
View File
@@ -0,0 +1,43 @@
package dao
import (
"errors"
"github.com/sirupsen/logrus"
"gorm.io/gorm"
"hy2xs-admin/model/constant"
"hy2xs-admin/model/entity"
"time"
)
func GetAdminUser(query interface{}, args ...interface{}) (entity.AdminUser, error) {
var admin entity.AdminUser
if tx := sqliteDB.Model(&entity.AdminUser{}).Where(query, args...).First(&admin); tx.Error != nil {
if tx.Error == gorm.ErrRecordNotFound {
return admin, errors.New(constant.WrongPassword)
}
logrus.Errorf("%v", tx.Error)
return admin, errors.New(constant.SysError)
}
return admin, nil
}
func SaveAdminUser(admin entity.AdminUser) (int64, error) {
if tx := sqliteDB.Save(&admin); tx.Error != nil {
logrus.Errorf("%v", tx.Error)
return 0, errors.New(constant.SysError)
}
return *admin.Id, nil
}
func UpdateAdminUser(ids []int64, updates map[string]interface{}) error {
if len(updates) == 0 {
return nil
}
updates["update_time"] = time.Now().Format("2006-01-02 15:04:05")
if tx := sqliteDB.Model(&entity.AdminUser{}).Where("id in ?", ids).Updates(updates); tx.Error != nil {
logrus.Errorf("%v", tx.Error)
return errors.New(constant.SysError)
}
return nil
}
+155
View File
@@ -0,0 +1,155 @@
package dao
import (
"errors"
"github.com/sirupsen/logrus"
"hy2xs-admin/model/constant"
"hy2xs-admin/model/entity"
"hy2xs-admin/model/vo"
"time"
)
func SaveMetricSample(sample entity.MetricSample) error {
if tx := sqliteDB.Save(&sample); tx.Error != nil {
logrus.Errorf("%v", tx.Error)
return errors.New(constant.SysError)
}
return nil
}
func LastMetricSample() (entity.MetricSample, error) {
var sample entity.MetricSample
if tx := sqliteDB.Model(&entity.MetricSample{}).Order("sampled_at desc").Limit(1).Find(&sample); tx.Error != nil {
logrus.Errorf("%v", tx.Error)
return sample, errors.New(constant.SysError)
}
return sample, nil
}
func CleanupMetricSample(olderThanMs int64) error {
if !tableExists("metric_sample") {
return nil
}
if tx := sqliteDB.Exec("DELETE FROM metric_sample WHERE sampled_at < ?", olderThanMs); tx.Error != nil {
logrus.Errorf("%v", tx.Error)
return errors.New(constant.SysError)
}
return nil
}
func DashboardPeerSummary(nowMs int64) (vo.DashboardPeerVo, error) {
result := vo.DashboardPeerVo{}
type row struct {
Total int64
Enabled int64
Disabled int64
Expired int64
}
var r row
if tx := sqliteDB.Raw(`SELECT
COUNT(1) AS total,
COALESCE(SUM(CASE WHEN disabled = 0 THEN 1 ELSE 0 END),0) AS enabled,
COALESCE(SUM(CASE WHEN disabled = 1 THEN 1 ELSE 0 END),0) AS disabled,
COALESCE(SUM(CASE WHEN expires_at > 0 AND expires_at < ? THEN 1 ELSE 0 END),0) AS expired
FROM peer`, nowMs).Scan(&r); tx.Error != nil {
logrus.Errorf("%v", tx.Error)
return result, errors.New(constant.SysError)
}
result.Total = r.Total
result.Enabled = r.Enabled
result.Disabled = r.Disabled
result.Expired = r.Expired
return result, nil
}
func DashboardTrafficSummary() (vo.DashboardTrafficVo, error) {
result := vo.DashboardTrafficVo{}
type row struct {
Download int64
Upload int64
}
var r row
if tx := sqliteDB.Raw(`SELECT
COALESCE(SUM(download_bytes),0) AS download,
COALESCE(SUM(upload_bytes),0) AS upload
FROM peer`).Scan(&r); tx.Error != nil {
logrus.Errorf("%v", tx.Error)
return result, errors.New(constant.SysError)
}
result.DownloadBytes = r.Download
result.UploadBytes = r.Upload
result.TotalBytes = r.Download + r.Upload
result.SinceResetDownloadBytes = r.Download
result.SinceResetUploadBytes = r.Upload
now := time.Now().UnixMilli()
dayStart := now - (now % int64(24*time.Hour/time.Millisecond))
var today row
if tx := sqliteDB.Raw(`SELECT
COALESCE(SUM(rx_bytes),0) AS download,
COALESCE(SUM(tx_bytes),0) AS upload
FROM traffic_sample WHERE sampled_at >= ?`, dayStart).Scan(&today); tx.Error == nil {
result.TodayDownloadBytes = today.Download
result.TodayUploadBytes = today.Upload
}
return result, nil
}
func DashboardTopPeers(fromMs int64, toMs int64, limit int) ([]vo.DashboardTopPeerVo, error) {
if limit <= 0 {
limit = 10
}
rows := make([]vo.DashboardTopPeerVo, 0)
if tx := sqliteDB.Raw(`SELECT
p.id AS peer_id,
p.name AS name,
p.remark AS remark,
COALESCE(SUM(ts.rx_bytes),0) AS download,
COALESCE(SUM(ts.tx_bytes),0) AS upload,
COALESCE(SUM(ts.rx_bytes + ts.tx_bytes),0) AS total
FROM traffic_sample ts
JOIN peer p ON p.id = ts.peer_id
WHERE ts.sampled_at BETWEEN ? AND ?
GROUP BY p.id, p.name, p.remark
ORDER BY total DESC
LIMIT ?`, fromMs, toMs, limit).Scan(&rows); tx.Error != nil {
logrus.Errorf("%v", tx.Error)
return rows, errors.New(constant.SysError)
}
return rows, nil
}
func DashboardTrafficTimeseries(fromMs int64, toMs int64) ([]vo.DashboardSeriesPointVo, error) {
rows := make([]vo.DashboardSeriesPointVo, 0)
if tx := sqliteDB.Raw(`SELECT
hour_start AS ts,
COALESCE(SUM(rx_bytes),0) AS download,
COALESCE(SUM(tx_bytes),0) AS upload
FROM traffic_aggregate_hourly
WHERE hour_start BETWEEN ? AND ?
GROUP BY hour_start
ORDER BY hour_start ASC`, fromMs, toMs).Scan(&rows); tx.Error != nil {
logrus.Errorf("%v", tx.Error)
return rows, errors.New(constant.SysError)
}
return rows, nil
}
func DashboardSystemTimeseries(fromMs int64, toMs int64) ([]vo.DashboardSeriesPointVo, error) {
rows := make([]vo.DashboardSeriesPointVo, 0)
if !tableExists("metric_sample") {
return rows, nil
}
if tx := sqliteDB.Raw(`SELECT
sampled_at AS ts,
cpu_percent AS cpu,
mem_percent AS mem
FROM metric_sample
WHERE sampled_at BETWEEN ? AND ?
ORDER BY sampled_at ASC`, fromMs, toMs).Scan(&rows); tx.Error != nil {
logrus.Errorf("%v", tx.Error)
return rows, errors.New(constant.SysError)
}
return rows, nil
}
+102
View File
@@ -0,0 +1,102 @@
package dao
import (
"errors"
"fmt"
"github.com/sirupsen/logrus"
"gorm.io/gorm"
"hy2xs-admin/model/constant"
"hy2xs-admin/model/dto"
"hy2xs-admin/model/entity"
"time"
)
func SavePeer(peer entity.Peer) (int64, error) {
if tx := sqliteDB.Save(&peer); tx.Error != nil {
logrus.Errorf("%v", tx.Error)
return 0, errors.New(constant.SysError)
}
return *peer.Id, nil
}
func DeletePeer(ids []int64) error {
if tx := sqliteDB.Where("id in ?", ids).Delete(&entity.Peer{}); tx.Error != nil {
logrus.Errorf("%v", tx.Error)
return errors.New(constant.SysError)
}
return nil
}
func UpdatePeer(ids []int64, updates map[string]interface{}) error {
if len(updates) == 0 {
return nil
}
updates["update_time"] = time.Now().Format("2006-01-02 15:04:05")
if tx := sqliteDB.Model(&entity.Peer{}).Where("id in ?", ids).Updates(updates); tx.Error != nil {
logrus.Errorf("%v", tx.Error)
return errors.New(constant.SysError)
}
return nil
}
func GetPeer(query interface{}, args ...interface{}) (entity.Peer, error) {
var peer entity.Peer
if tx := sqliteDB.Model(&entity.Peer{}).Where(query, args...).First(&peer); tx.Error != nil {
if tx.Error == gorm.ErrRecordNotFound {
return peer, errors.New(constant.WrongPassword)
}
logrus.Errorf("%v", tx.Error)
return peer, errors.New(constant.SysError)
}
return peer, nil
}
func ListPeer(query interface{}, args ...interface{}) ([]entity.Peer, error) {
var peers []entity.Peer
if tx := sqliteDB.Model(&entity.Peer{}).Where(query, args...).Order("create_time desc").Find(&peers); tx.Error != nil {
logrus.Errorf("%v", tx.Error)
return peers, errors.New(constant.SysError)
}
return peers, nil
}
func PagePeer(peerPageDto dto.PeerPageDto) ([]entity.Peer, int64, error) {
var peers []entity.Peer
var total int64
tx := sqliteDB.Model(&entity.Peer{})
if peerPageDto.Username != nil && *peerPageDto.Username != "" {
tx.Where("name like ?", fmt.Sprintf("%%%s%%", *peerPageDto.Username))
}
if peerPageDto.Deleted != nil {
tx.Where("disabled = ?", *peerPageDto.Deleted)
}
if peerPageDto.Remark != nil && *peerPageDto.Remark != "" {
tx.Where("remark like ?", fmt.Sprintf("%%%s%%", *peerPageDto.Remark))
}
tx.Count(&total)
if tx.Scopes(Paginate(peerPageDto.PageNum, peerPageDto.PageSize)).Order("create_time desc").Find(&peers); tx.Error != nil {
logrus.Errorf("%v", tx.Error)
return peers, 0, errors.New(constant.SysError)
}
return peers, total, nil
}
func UpdatePeerTraffic(name string, download int64, upload int64) error {
if upload == 0 && download == 0 {
return nil
}
updates := map[string]interface{}{}
if download != 0 {
updates["download_bytes"] = gorm.Expr("download_bytes + ?", download)
}
if upload != 0 {
updates["upload_bytes"] = gorm.Expr("upload_bytes + ?", upload)
}
updates["update_time"] = time.Now().Format("2006-01-02 15:04:05")
if tx := sqliteDB.Model(&entity.Peer{}).Where("name = ?", name).Updates(updates); tx.Error != nil {
logrus.Errorf("%v", tx.Error)
return errors.New(constant.SysError)
}
return nil
}
+342 -56
View File
@@ -2,6 +2,7 @@ package dao
import (
"errors"
"fmt"
"github.com/glebarez/sqlite"
"github.com/sirupsen/logrus"
"gorm.io/gorm"
@@ -12,13 +13,13 @@ import (
"hy2xs-admin/util"
"log"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
)
var sqlInitStr = "CREATE TABLE IF NOT EXISTS account\n(\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n username TEXT NOT NULL UNIQUE DEFAULT '',\n pass TEXT NOT NULL DEFAULT '',\n con_pass TEXT NOT NULL DEFAULT '',\n quota INTEGER NOT NULL DEFAULT 0,\n download INTEGER NOT NULL DEFAULT 0,\n upload INTEGER NOT NULL DEFAULT 0,\n expire_time INTEGER NOT NULL DEFAULT 0,\n kick_util_time INTEGER NOT NULL DEFAULT 0,\n device_no INTEGER NOT NULL DEFAULT 3,\n role TEXT NOT NULL DEFAULT 'user',\n deleted INTEGER NOT NULL DEFAULT 0,\n create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,\n update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP\n);\nALTER TABLE account\n ADD COLUMN login_at INTEGER NOT NULL DEFAULT 0;\nALTER TABLE account\n ADD COLUMN con_at INTEGER NOT NULL DEFAULT 0;\nALTER TABLE account\n ADD COLUMN remark INTEGER NOT NULL DEFAULT '';\nALTER TABLE account\n ADD COLUMN force_password_change INTEGER NOT NULL DEFAULT 0;\nCREATE INDEX IF NOT EXISTS account_deleted_index ON account (deleted);\nCREATE INDEX IF NOT EXISTS account_username_index ON account (username);\nCREATE INDEX IF NOT EXISTS account_con_pass_index ON account (con_pass);\nCREATE INDEX IF NOT EXISTS account_pass_index ON account (pass);\nCREATE TABLE IF NOT EXISTS config\n(\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n key TEXT NOT NULL UNIQUE DEFAULT '',\n value TEXT NOT NULL DEFAULT '',\n remark TEXT NOT NULL DEFAULT '',\n create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,\n update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP\n);\nCREATE INDEX IF NOT EXISTS config_key_index ON config (key);\nINSERT INTO config (key, value, remark)\nSELECT 'H_UI_WEB_PORT', '8081', 'HY2XS admin Web Port'\n WHERE NOT EXISTS (SELECT 1 FROM config WHERE key = 'H_UI_WEB_PORT');\nINSERT INTO config (key, value, remark)\nSELECT 'H_UI_WEB_CONTEXT', '/', 'HY2XS admin Web Context'\n WHERE NOT EXISTS (SELECT 1 FROM config WHERE key = 'H_UI_WEB_CONTEXT');\nINSERT INTO config (key, value, remark)\nSELECT 'H_UI_CRT_PATH', '', 'HY2XS admin CRT File Path'\n WHERE NOT EXISTS (SELECT 1 FROM config WHERE key = 'H_UI_CRT_PATH');\nINSERT INTO config (key, value, remark)\nSELECT 'H_UI_KEY_PATH', '', 'HY2XS admin KEY File Path'\n WHERE NOT EXISTS (SELECT 1 FROM config WHERE key = 'H_UI_KEY_PATH');\nINSERT INTO config (key, value, remark)\nSELECT 'JWT_SECRET', hex(randomblob(10)), 'JWT Secret'\n WHERE NOT EXISTS (SELECT 1 FROM config WHERE key = 'JWT_SECRET');\nINSERT INTO config (key, value, remark)\nSELECT 'HYSTERIA2_ENABLE', '0', 'Hysteria2 Switch'\n WHERE NOT EXISTS (SELECT 1 FROM config WHERE key = 'HYSTERIA2_ENABLE');\nINSERT INTO config (key, value, remark)\nSELECT 'HYSTERIA2_CONFIG', '', 'Hysteria2 Config'\n WHERE NOT EXISTS (SELECT 1 FROM config WHERE key = 'HYSTERIA2_CONFIG');\nINSERT INTO config (key, value, remark)\nSELECT 'HYSTERIA2_TRAFFIC_TIME', '1', 'Hysteria2 Traffic Time'\n WHERE NOT EXISTS (SELECT 1 FROM config WHERE key = 'HYSTERIA2_TRAFFIC_TIME');\nINSERT INTO config (key, value, remark)\nSELECT 'HYSTERIA2_CONFIG_REMARK', '', 'Hysteria2 Config Remark'\n WHERE NOT EXISTS (SELECT 1 FROM config WHERE key = 'HYSTERIA2_CONFIG_REMARK');\nINSERT INTO config (key, value, remark)\nSELECT 'RESET_TRAFFIC_CRON', '', 'Reset Traffic Cron'\n WHERE NOT EXISTS (SELECT 1 FROM config WHERE key = 'RESET_TRAFFIC_CRON');\nINSERT INTO config (key, value, remark)\nSELECT 'CLASH_EXTENSION', '', 'Clash Subscription Extension'\n WHERE NOT EXISTS (SELECT 1 FROM config WHERE key = 'CLASH_EXTENSION');"
var sqliteDB *gorm.DB
func InitSqliteDB() error {
@@ -50,7 +51,7 @@ func InitSql(port string) error {
if err := InitSqliteDB(); err != nil {
return err
}
if err := sqliteInit(sqlInitStr); err != nil {
if err := runMigrations(); err != nil {
return err
}
if port != "" {
@@ -59,10 +60,6 @@ func InitSql(port string) error {
return errors.New("sqlite exec err")
}
}
if err := ensureAccountSchema(); err != nil {
return err
}
if err := ensureSecureBootstrapAdmin(); err != nil {
return err
}
@@ -96,14 +93,6 @@ func envBoolAsInt(name string, fallback int) int {
return fallback
}
func ensureAccountSchema() error {
if tx := sqliteDB.Exec("ALTER TABLE account ADD COLUMN force_password_change INTEGER NOT NULL DEFAULT 0"); tx.Error != nil && !strings.Contains(tx.Error.Error(), "duplicate column name") {
logrus.Errorf("sqlite exec err: %v", tx.Error)
return errors.New("sqlite exec err")
}
return nil
}
func ensureSecureBootstrapAdmin() error {
adminUser := strings.TrimSpace(os.Getenv("HY2XS_ADMIN_USER"))
if adminUser == "" {
@@ -118,50 +107,363 @@ func ensureSecureBootstrapAdmin() error {
adminPassword = password
}
forcePasswordChange := envBoolAsInt("HY2XS_FORCE_PASSWORD_CHANGE", 1)
quota := int64(-1)
expireTime := int64(253370736000000)
deviceNo := int64(envInt("HY2XS_ADMIN_DEVICE_NO", 6))
role := "admin"
deleted := int64(0)
conPass := strings.TrimSpace(os.Getenv("HY2XS_ADMIN_CON_PASS"))
if conPass == "" {
generated, genErr := util.RandomString(28)
if genErr != nil {
return genErr
}
conPass = generated
}
status := int64(1)
tokenVersion := int64(1)
passwordChangedAt := time.Now().UnixMilli()
hash, hashErr := util.HashPassword(adminPassword)
if hashErr != nil {
return hashErr
}
admin, err := GetAccount("role = 'admin' and deleted = 0")
admin, err := GetAdminUser("username = ?", adminUser)
if err != nil {
username := adminUser
account := entity.Account{
account := entity.AdminUser{
Username: &username,
Pass: &hash,
ConPass: &conPass,
Quota: &quota,
ExpireTime: &expireTime,
DeviceNo: &deviceNo,
Role: &role,
Deleted: &deleted,
PasswordHash: &hash,
Status: &status,
TokenVersion: &tokenVersion,
PasswordChangedAt: &passwordChangedAt,
ForcePasswordChange: func() *int64 { v := int64(forcePasswordChange); return &v }(),
}
if _, saveErr := SaveAccount(account); saveErr != nil {
if _, saveErr := SaveAdminUser(account); saveErr != nil {
return saveErr
}
return nil
}
if admin.Pass == nil {
if admin.PasswordHash == nil {
return nil
}
return nil
}
func runMigrations() error {
if tx := sqliteDB.Exec(`CREATE TABLE IF NOT EXISTS schema_migrations (
version TEXT PRIMARY KEY,
applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)`); tx.Error != nil {
logrus.Errorf("sqlite migration init err: %v", tx.Error)
return errors.New("sqlite migration init err")
}
type migration struct {
version string
apply func() error
}
migrations := []migration{
{version: "001_admin_peer_split", apply: migrateAdminPeerSplit},
{version: "002_migrate_legacy_accounts", apply: migrateLegacyAccounts},
{version: "003_archive_legacy_account", apply: archiveLegacyAccount},
{version: "004_traffic_samples_and_aggregates", apply: migrateTrafficTables},
{version: "005_metric_sample", apply: migrateMetricSampleTable},
}
for _, m := range migrations {
if isApplied, err := migrationApplied(m.version); err != nil {
return err
} else if isApplied {
continue
}
if err := m.apply(); err != nil {
return err
}
if tx := sqliteDB.Exec("INSERT INTO schema_migrations(version) VALUES(?)", m.version); tx.Error != nil {
logrus.Errorf("sqlite migration mark err: %v", tx.Error)
return errors.New("sqlite migration mark err")
}
}
return nil
}
func migrationApplied(version string) (bool, error) {
var count int64
if tx := sqliteDB.Raw("SELECT COUNT(1) FROM schema_migrations WHERE version = ?", version).Scan(&count); tx.Error != nil {
logrus.Errorf("sqlite migration query err: %v", tx.Error)
return false, errors.New("sqlite migration query err")
}
return count > 0, nil
}
func migrateAdminPeerSplit() error {
stmts := []string{
`CREATE TABLE IF NOT EXISTS admin_user (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE DEFAULT '',
password_hash TEXT NOT NULL DEFAULT '',
status INTEGER NOT NULL DEFAULT 1,
force_password_change INTEGER NOT NULL DEFAULT 0,
last_login_at INTEGER NOT NULL DEFAULT 0,
password_changed_at INTEGER NOT NULL DEFAULT 0,
token_version INTEGER NOT NULL DEFAULT 1,
create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)`,
`CREATE INDEX IF NOT EXISTS admin_user_username_index ON admin_user (username)`,
`CREATE TABLE IF NOT EXISTS peer (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE DEFAULT '',
remark TEXT NOT NULL DEFAULT '',
auth_id TEXT NOT NULL UNIQUE DEFAULT '',
secret_digest TEXT NOT NULL UNIQUE DEFAULT '',
secret_ciphertext TEXT NOT NULL DEFAULT '',
quota_bytes INTEGER NOT NULL DEFAULT 0,
download_bytes INTEGER NOT NULL DEFAULT 0,
upload_bytes INTEGER NOT NULL DEFAULT 0,
expires_at INTEGER NOT NULL DEFAULT 0,
max_devices INTEGER NOT NULL DEFAULT 3,
disabled INTEGER NOT NULL DEFAULT 0,
banned_until INTEGER NOT NULL DEFAULT 0,
last_connection_at INTEGER NOT NULL DEFAULT 0,
create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)`,
`CREATE INDEX IF NOT EXISTS peer_name_index ON peer (name)`,
`CREATE INDEX IF NOT EXISTS peer_auth_id_index ON peer (auth_id)`,
`CREATE INDEX IF NOT EXISTS peer_secret_digest_index ON peer (secret_digest)`,
}
for _, stmt := range stmts {
if tx := sqliteDB.Exec(stmt); tx.Error != nil {
logrus.Errorf("sqlite migration exec err: %v", tx.Error)
return errors.New("sqlite migration exec err")
}
}
return nil
}
func migrateLegacyAccounts() error {
if !tableExists("account") {
return nil
}
var accounts []entity.Account
if tx := sqliteDB.Model(&entity.Account{}).Order("id asc").Find(&accounts); tx.Error != nil {
logrus.Errorf("sqlite legacy account query err: %v", tx.Error)
return errors.New("sqlite legacy account query err")
}
nowMs := time.Now().UnixMilli()
for _, acc := range accounts {
if acc.Role != nil && *acc.Role == "admin" {
if acc.Username == nil || acc.Pass == nil {
continue
}
_, getErr := GetAdminUser("username = ?", *acc.Username)
if getErr == nil {
continue
}
status := int64(1)
if acc.Deleted != nil && *acc.Deleted == 1 {
status = 0
}
lastLogin := int64(0)
if acc.LoginAt != nil {
lastLogin = *acc.LoginAt
}
passwordChangedAt := nowMs
admin := entity.AdminUser{
Username: acc.Username,
PasswordHash: acc.Pass,
Status: &status,
ForcePasswordChange: acc.ForcePasswordChange,
LastLoginAt: &lastLogin,
PasswordChangedAt: &passwordChangedAt,
TokenVersion: func() *int64 { v := int64(1); return &v }(),
}
if _, saveErr := SaveAdminUser(admin); saveErr != nil {
return saveErr
}
continue
}
if acc.Username == nil || acc.ConPass == nil {
continue
}
_, getPeerErr := GetPeer("name = ?", *acc.Username)
if getPeerErr == nil {
continue
}
authId, authErr := util.RandomString(18)
if authErr != nil {
return authErr
}
secretDigest := util.PeerSecretDigest(*acc.ConPass)
secretCiphertext := *acc.ConPass
quota := int64(0)
if acc.Quota != nil {
quota = *acc.Quota
}
download := int64(0)
if acc.Download != nil {
download = *acc.Download
}
upload := int64(0)
if acc.Upload != nil {
upload = *acc.Upload
}
expires := int64(0)
if acc.ExpireTime != nil {
expires = *acc.ExpireTime
}
maxDevices := int64(3)
if acc.DeviceNo != nil {
maxDevices = *acc.DeviceNo
}
disabled := int64(0)
if acc.Deleted != nil {
disabled = *acc.Deleted
}
bannedUntil := int64(0)
if acc.KickUtilTime != nil {
bannedUntil = *acc.KickUtilTime
}
lastConnection := int64(0)
if acc.ConAt != nil {
lastConnection = *acc.ConAt
}
remark := ""
if acc.Remark != nil {
remark = *acc.Remark
}
peer := entity.Peer{
Name: acc.Username,
Remark: &remark,
AuthId: &authId,
SecretDigest: &secretDigest,
SecretCiphertext: &secretCiphertext,
QuotaBytes: &quota,
DownloadBytes: &download,
UploadBytes: &upload,
ExpiresAt: &expires,
MaxDevices: &maxDevices,
Disabled: &disabled,
BannedUntil: &bannedUntil,
LastConnectionAt: &lastConnection,
}
if _, saveErr := SavePeer(peer); saveErr != nil {
return saveErr
}
}
return nil
}
func archiveLegacyAccount() error {
if !tableExists("account") {
return nil
}
backupName := fmt.Sprintf("legacy_account_backup_%d", time.Now().Unix())
if tx := sqliteDB.Exec("ALTER TABLE account RENAME TO " + backupName); tx.Error != nil {
logrus.Errorf("sqlite legacy archive err: %v", tx.Error)
return errors.New("sqlite legacy archive err")
}
return nil
}
func migrateTrafficTables() error {
stmts := []string{
`CREATE TABLE IF NOT EXISTS traffic_sample (
id INTEGER PRIMARY KEY AUTOINCREMENT,
peer_id INTEGER NOT NULL,
auth_id TEXT NOT NULL,
rx_bytes INTEGER NOT NULL DEFAULT 0,
tx_bytes INTEGER NOT NULL DEFAULT 0,
sampled_at INTEGER NOT NULL,
create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)`,
`CREATE INDEX IF NOT EXISTS idx_traffic_sample_time ON traffic_sample(sampled_at)`,
`CREATE INDEX IF NOT EXISTS idx_traffic_sample_peer_time ON traffic_sample(peer_id, sampled_at)`,
`CREATE TABLE IF NOT EXISTS traffic_aggregate_hourly (
peer_id INTEGER NOT NULL,
hour_start INTEGER NOT NULL,
rx_bytes INTEGER NOT NULL DEFAULT 0,
tx_bytes INTEGER NOT NULL DEFAULT 0,
create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY(peer_id, hour_start)
)`,
`CREATE INDEX IF NOT EXISTS idx_traffic_hourly_hour_start ON traffic_aggregate_hourly(hour_start)`,
`CREATE TABLE IF NOT EXISTS traffic_aggregate_daily (
peer_id INTEGER NOT NULL,
day_start INTEGER NOT NULL,
rx_bytes INTEGER NOT NULL DEFAULT 0,
tx_bytes INTEGER NOT NULL DEFAULT 0,
create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY(peer_id, day_start)
)`,
`CREATE INDEX IF NOT EXISTS idx_traffic_daily_day_start ON traffic_aggregate_daily(day_start)`,
}
for _, stmt := range stmts {
if tx := sqliteDB.Exec(stmt); tx.Error != nil {
logrus.Errorf("sqlite migration exec err: %v", tx.Error)
return errors.New("sqlite migration exec err")
}
}
return nil
}
func migrateMetricSampleTable() error {
stmts := []string{
`CREATE TABLE IF NOT EXISTS metric_sample (
id INTEGER PRIMARY KEY AUTOINCREMENT,
sampled_at INTEGER NOT NULL,
cpu_percent REAL NOT NULL DEFAULT 0,
load1 REAL NOT NULL DEFAULT 0,
mem_used_bytes INTEGER NOT NULL DEFAULT 0,
mem_total_bytes INTEGER NOT NULL DEFAULT 0,
mem_percent REAL NOT NULL DEFAULT 0,
disk_path TEXT NOT NULL DEFAULT '/',
disk_used_bytes INTEGER NOT NULL DEFAULT 0,
disk_total_bytes INTEGER NOT NULL DEFAULT 0,
disk_percent REAL NOT NULL DEFAULT 0,
hysteria_running INTEGER NOT NULL DEFAULT 0,
online_peers INTEGER NOT NULL DEFAULT 0,
online_devices INTEGER NOT NULL DEFAULT 0,
create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)`,
`CREATE INDEX IF NOT EXISTS idx_metric_sample_time ON metric_sample(sampled_at)`,
}
for _, stmt := range stmts {
if tx := sqliteDB.Exec(stmt); tx.Error != nil {
logrus.Errorf("sqlite migration exec err: %v", tx.Error)
return errors.New("sqlite migration exec err")
}
}
return nil
}
func tableExists(tableName string) bool {
var count int64
if tx := sqliteDB.Raw("SELECT COUNT(1) FROM sqlite_master WHERE type='table' AND name=?", tableName).Scan(&count); tx.Error != nil {
return false
}
return count > 0
}
func listSQLMigrationFiles(dir string) ([]string, error) {
entries, err := os.ReadDir(dir)
if err != nil {
return nil, err
}
files := make([]string, 0)
for _, entry := range entries {
if entry.IsDir() {
continue
}
name := entry.Name()
if strings.HasSuffix(strings.ToLower(name), ".sql") {
files = append(files, filepath.Join(dir, name))
}
}
sort.Strings(files)
return files, nil
}
func ensureTrafficStatsSecret() error {
envSecret := strings.TrimSpace(os.Getenv("HY2XS_HYSTERIA_TRAFFIC_STATS_SECRET"))
if envSecret != "" {
@@ -194,23 +496,6 @@ func ensureTrafficStatsSecret() error {
return nil
}
func sqliteInit(sqlStr string) error {
if sqliteDB != nil {
sqls := strings.Split(strings.Replace(sqlStr, "\r\n", "\n", -1), ";\n")
for _, s := range sqls {
s = strings.TrimSpace(s)
if s != "" {
tx := sqliteDB.Exec(s)
if tx.Error != nil && !strings.HasPrefix(tx.Error.Error(), "SQL logic error: duplicate column name") {
logrus.Errorf("sqlite exec err: %v", tx.Error)
return errors.New("sqlite exec err")
}
}
}
}
return nil
}
func CloseSqliteDB() error {
if sqliteDB != nil {
db, err := sqliteDB.DB()
@@ -253,3 +538,4 @@ func Paginate(pageNum *int64, pageSize *int64) func(db *gorm.DB) *gorm.DB {
return db.Offset(int((num - 1) * size)).Limit(int(size))
}
}
+75
View File
@@ -0,0 +1,75 @@
package dao
import (
"errors"
"fmt"
"github.com/sirupsen/logrus"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"hy2xs-admin/model/constant"
"hy2xs-admin/model/entity"
"time"
)
func SaveTrafficSample(sample entity.TrafficSample) error {
if tx := sqliteDB.Save(&sample); tx.Error != nil {
logrus.Errorf("%v", tx.Error)
return errors.New(constant.SysError)
}
return nil
}
func UpsertTrafficAggregateHourly(peerId int64, hourStart int64, rxBytes int64, txBytes int64) error {
if rxBytes == 0 && txBytes == 0 {
return nil
}
agg := entity.TrafficAggregateHourly{
PeerId: &peerId,
HourStart: &hourStart,
RxBytes: &rxBytes,
TxBytes: &txBytes,
}
now := time.Now()
if tx := sqliteDB.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "peer_id"}, {Name: "hour_start"}},
DoUpdates: clause.Assignments(map[string]interface{}{
"rx_bytes": gormExprAdd("rx_bytes", rxBytes),
"tx_bytes": gormExprAdd("tx_bytes", txBytes),
"update_time": now,
}),
}).Create(&agg); tx.Error != nil {
logrus.Errorf("%v", tx.Error)
return errors.New(constant.SysError)
}
return nil
}
func UpsertTrafficAggregateDaily(peerId int64, dayStart int64, rxBytes int64, txBytes int64) error {
if rxBytes == 0 && txBytes == 0 {
return nil
}
agg := entity.TrafficAggregateDaily{
PeerId: &peerId,
DayStart: &dayStart,
RxBytes: &rxBytes,
TxBytes: &txBytes,
}
now := time.Now()
if tx := sqliteDB.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "peer_id"}, {Name: "day_start"}},
DoUpdates: clause.Assignments(map[string]interface{}{
"rx_bytes": gormExprAdd("rx_bytes", rxBytes),
"tx_bytes": gormExprAdd("tx_bytes", txBytes),
"update_time": now,
}),
}).Create(&agg); tx.Error != nil {
logrus.Errorf("%v", tx.Error)
return errors.New(constant.SysError)
}
return nil
}
func gormExprAdd(column string, delta int64) interface{} {
return gorm.Expr(fmt.Sprintf("%s + ?", column), delta)
}
-1
View File
@@ -29,4 +29,3 @@ module.exports = {
OptionType: "readonly",
},
};
цц
-157
View File
@@ -1,157 +0,0 @@
import request from "@/utils/request";
import { AxiosPromise } from "axios";
import {
AccountSaveDto,
AccountInfo,
AccountLoginDto,
AccountLoginVo,
AccountPageDto,
AccountUpdateDto,
AccountVo,
} from "./types";
/**
* Поиск
*/
export function getAccountApi(data: IdDto): AxiosPromise<AccountVo> {
return request({
url: "/account/getAccount",
method: "get",
params: data,
});
}
/**
* Сохранение
*
* @param data
*/
export function saveAccountApi(data: AccountSaveDto): AxiosPromise {
return request({
url: "/account/saveAccount",
method: "post",
data: data,
});
}
/**
* Запрос текущего пользователя
*/
export function getAccountInfoApi(): AxiosPromise<AccountInfo> {
return request({
url: "/account/getAccountInfo",
method: "get",
});
}
/**
* Пагинация
* @param data
*/
export function pageAccountApi(
data: AccountPageDto
): AxiosPromise<PageVo<AccountVo>> {
return request({
url: "/account/pageAccount",
method: "get",
params: data,
});
}
/**
* Удаление
*
* @param data
*/
export function deleteAccountApi(data: IdDto): AxiosPromise {
return request({
url: "/account/deleteAccount",
method: "post",
data: data,
});
}
/**
* Изменение
* @param data
*/
export function updateAccountApi(data: AccountUpdateDto): AxiosPromise {
return request({
url: "/account/updateAccount",
method: "post",
data: data,
});
}
/**
* Сброс трафика
* @param data
*/
export function resetTrafficApi(data: IdDto): AxiosPromise {
return request({
url: "/account/resetTraffic",
method: "post",
data: data,
});
}
/**
* Вход
* @param data
*/
export function loginApi(data: AccountLoginDto): AxiosPromise<AccountLoginVo> {
return request({
url: "/auth/login",
method: "post",
data: data,
});
}
/**
* Импорт
*/
export function importAccountApi(data: FormData): AxiosPromise {
return request({
url: "/account/importAccount",
method: "post",
headers: {
"Content-Type": "multipart/form-data",
},
data: data,
});
}
/**
* Экспорт
*/
export function exportAccountApi(): AxiosPromise {
return request({
url: "/account/exportAccount",
method: "post",
responseType: "blob",
});
}
/**
* Снятие статуса отключения
*/
export function releaseKickAccountApi(data: IdDto): AxiosPromise {
return request({
url: "/account/releaseKickAccount",
method: "post",
data: data,
});
}
/**
* Проверка пароля по умолчанию
* @param data
*/
export function verifyDefaultPassApi(): AxiosPromise {
return request({
url: "/account/verifyDefaultPass",
method: "get",
});
}
+26
View File
@@ -0,0 +1,26 @@
import request from "@/utils/request";
import { AxiosPromise } from "axios";
import { AdminInfo, AdminLoginDto, AdminLoginVo } from "./types";
export function loginApi(data: AdminLoginDto): AxiosPromise<AdminLoginVo> {
return request({
url: "/auth/login",
method: "post",
data,
});
}
export function getAdminInfoApi(): AxiosPromise<AdminInfo> {
return request({
url: "/admin/me",
method: "get",
});
}
export function verifyDefaultPassApi(): AxiosPromise<boolean> {
return request({
url: "/admin/verify-default-pass",
method: "get",
});
}
+16
View File
@@ -0,0 +1,16 @@
export interface AdminLoginDto {
username: string;
pass: string;
}
export interface AdminLoginVo {
accessToken: string;
tokenType: string;
}
export interface AdminInfo {
id: number;
username: string;
roles: string[];
}
+39
View File
@@ -0,0 +1,39 @@
import request from "@/utils/request";
import { AxiosPromise } from "axios";
import {
DashboardSummaryVo,
DashboardTimeseriesVo,
DashboardTopPeerVo,
SecurityRiskVo,
} from "./types";
export function dashboardSummaryApi(): AxiosPromise<DashboardSummaryVo> {
return request({
url: "/dashboard/summary",
method: "get",
});
}
export function dashboardTimeseriesApi(range = "24h"): AxiosPromise<DashboardTimeseriesVo> {
return request({
url: "/dashboard/timeseries",
method: "get",
params: { range },
});
}
export function dashboardTopPeersApi(range = "24h", limit = 10): AxiosPromise<DashboardTopPeerVo[]> {
return request({
url: "/dashboard/top-peers",
method: "get",
params: { range, limit },
});
}
export function dashboardSecurityApi(): AxiosPromise<SecurityRiskVo[]> {
return request({
url: "/dashboard/security",
method: "get",
});
}
+72
View File
@@ -0,0 +1,72 @@
export interface SecurityRiskVo {
key: string;
severity: "info" | "warning" | "critical";
actionRoute?: string;
dismissible: boolean;
}
export interface DashboardSummaryVo {
collectedAt: number;
system: {
cpuPercent: number;
memUsedBytes: number;
memTotalBytes: number;
memPercent: number;
diskUsedBytes: number;
diskTotalBytes: number;
diskPercent: number;
};
hysteria: {
version: string;
running: boolean;
apiReachable: boolean;
lastApiError?: string;
};
peers: {
total: number;
enabled: number;
disabled: number;
expired: number;
onlinePeers: number;
onlineDevices: number;
};
traffic: {
downloadBytes: number;
uploadBytes: number;
totalBytes: number;
todayDownloadBytes: number;
todayUploadBytes: number;
sinceResetDownloadBytes: number;
sinceResetUploadBytes: number;
};
health: {
collector: {
status: "ok" | "stale" | "error";
messageKey?: string;
lastSuccessAt?: number;
};
hysteria: {
status: "ok" | "stale" | "error";
messageKey?: string;
lastSuccessAt?: number;
};
};
securityRisks: SecurityRiskVo[];
}
export interface DashboardTimeseriesVo {
range: string;
traffic: Array<{ ts: number; download?: number; upload?: number }>;
system: Array<{ ts: number; cpu?: number; mem?: number }>;
collectedAt: number;
}
export interface DashboardTopPeerVo {
peerId: number;
name: string;
remark: string;
download: number;
upload: number;
total: number;
}
-12
View File
@@ -3,8 +3,6 @@ import { Hysteria2ServerConfig } from "@/api/config/types";
import request from "@/utils/request";
import {
Hysteria2KickDto,
Hysteria2SubscribeVo,
Hysteria2SubscribeUrlDto,
Hysteria2UrlDto,
Hysteria2UrlVo,
} from "@/api/hysteria2/types";
@@ -19,16 +17,6 @@ export function hysteria2KickApi(
});
}
export function hysteria2SubscribeUrlApi(
dto: Hysteria2SubscribeUrlDto
): AxiosPromise<Hysteria2SubscribeVo> {
return request({
url: "/hysteria2/hysteria2SubscribeUrl",
method: "get",
params: dto,
});
}
export function hysteria2UrlApi(
dto: Hysteria2UrlDto
): AxiosPromise<Hysteria2UrlVo> {
-12
View File
@@ -3,24 +3,12 @@ export interface Hysteria2KickDto {
kickUtilTime: number;
}
export interface Hysteria2SubscribeUrlDto {
accountId: number;
protocol: string;
}
export interface Hysteria2UrlDto {
accountId: number;
}
export interface Hysteria2SubscribeVo {
url: string;
qrCode: string;
}
export interface Hysteria2UrlVo {
url: string;
qrCode: string;
}
-19
View File
@@ -1,19 +0,0 @@
import { AxiosPromise } from "axios";
import request from "@/utils/request";
import { Hysteria2MonitorVo, SystemMonitorVo } from "@/api/monitor/types";
export function monitorSystemApi(): AxiosPromise<SystemMonitorVo> {
return request({
url: "/monitor/monitorSystem",
method: "get",
});
}
export function monitorHysteria2Api(): AxiosPromise<Hysteria2MonitorVo> {
return request({
url: "/monitor/monitorHysteria2",
method: "get",
});
}
-15
View File
@@ -1,15 +0,0 @@
export interface SystemMonitorVo {
huiVersion: string;
cpuPercent: number;
diskPercent: number;
memPercent: number;
}
export interface Hysteria2MonitorVo {
userTotal: number;
deviceTotal: number;
version: string;
running: boolean;
}
+80
View File
@@ -0,0 +1,80 @@
import request from "@/utils/request";
import { AxiosPromise } from "axios";
import {
PeerPageDto,
PeerSaveDto,
PeerUpdateDto,
PeerVo,
} from "./types";
export function getPeerApi(data: IdDto): AxiosPromise<PeerVo> {
return request({
url: `/peers/${data.id}`,
method: "get",
});
}
export function savePeerApi(data: PeerSaveDto): AxiosPromise {
return request({
url: "/peers",
method: "post",
data,
});
}
export function pagePeerApi(data: PeerPageDto): AxiosPromise<PageVo<PeerVo>> {
return request({
url: "/peers",
method: "get",
params: data,
});
}
export function deletePeerApi(data: IdDto): AxiosPromise {
return request({
url: `/peers/${data.id}`,
method: "delete",
});
}
export function updatePeerApi(data: PeerUpdateDto): AxiosPromise {
return request({
url: `/peers/${data.id}`,
method: "patch",
data,
});
}
export function resetPeerTrafficApi(data: IdDto): AxiosPromise {
return request({
url: `/peers/${data.id}/reset-traffic`,
method: "post",
});
}
export function releaseKickPeerApi(data: IdDto): AxiosPromise {
return request({
url: `/peers/${data.id}/release-kick`,
method: "post",
});
}
export function importPeerApi(data: FormData): AxiosPromise {
return request({
url: "/peers/import",
method: "post",
headers: {
"Content-Type": "multipart/form-data",
},
data,
});
}
export function exportPeerApi(): AxiosPromise {
return request({
url: "/peers/export",
method: "post",
responseType: "blob",
});
}
@@ -1,10 +1,10 @@
export interface AccountPageDto extends BaseDto {
export interface PeerPageDto extends BaseDto {
username?: string;
deleted?: number;
remark?: string;
}
export interface AccountUpdateDto extends IdDto {
export interface PeerUpdateDto extends IdDto {
username: string;
pass: string;
conPass: string;
@@ -15,7 +15,7 @@ export interface AccountUpdateDto extends IdDto {
remark: string;
}
export interface AccountSaveDto {
export interface PeerSaveDto {
username: string;
pass: string;
conPass: string;
@@ -26,12 +26,7 @@ export interface AccountSaveDto {
remark: string;
}
export interface AccountLoginDto {
username: string;
pass: string;
}
export interface AccountVo extends IdDto {
export interface PeerVo extends IdDto {
username: string;
quota: number;
download: number;
@@ -42,27 +37,14 @@ export interface AccountVo extends IdDto {
role: string;
deleted: number;
createTime: string;
online: boolean;
device: number;
loginAt: number;
conAt: number;
remark: string;
}
export interface AccountLoginVo {
accessToken: string;
tokenType: string;
}
export interface AccountInfo {
id: number;
username: string;
roles: string[];
}
export interface AccountForm extends IdDto {
export interface PeerForm extends IdDto {
username: string;
pass: string;
conPass: string;
@@ -73,9 +55,8 @@ export interface AccountForm extends IdDto {
remark: string;
}
export interface KickAccountForm {
export interface KickPeerForm {
ids: number[];
kickUtilTime: number;
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.9 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

@@ -4,15 +4,15 @@ import SvgIcon from "@/components/SvgIcon/index.vue";
import { useAppStore } from "@/store/modules/app";
const appStore = useAppStore();
const { locale } = useI18n();
const { locale, t } = useI18n();
function handleLanguageChange(lang: string) {
locale.value = lang;
appStore.changeLanguage(lang);
if (lang == "en") {
ElMessage.success("Switch Language Successful!");
ElMessage.success(t("common.switchLanguageSuccess"));
} else {
ElMessage.success("Язык переключён");
ElMessage.success(t("common.switchLanguageSuccess"));
}
}
</script>
@@ -77,6 +77,7 @@ export default {
<script setup lang="ts">
import { PropType } from "vue";
import { useI18n } from "vue-i18n";
interface Form {
key: string;
@@ -96,6 +97,7 @@ const emit = defineEmits<{
}>();
const mapObject = useVModel(props, "mapObject", emit);
const { t } = useI18n();
const dataFormRef = ref(ElForm);
@@ -103,14 +105,14 @@ const dataFormRules = {
key: [
{
required: true,
message: "Required",
message: t("common.required"),
trigger: ["change", "blur"],
},
],
value: [
{
required: true,
message: "Required",
message: t("common.required"),
trigger: ["change", "blur"],
},
],
@@ -157,7 +159,7 @@ const submitForm = () => {
dataFormRef.value.validate((valid: any) => {
if (valid) {
if (mapObject.value[state.dataForm.key]) {
ElMessage.error("key cannot be repeated");
ElMessage.error(t("common.invalid"));
return;
}
mapObject.value[state.dataForm.key] = state.dataForm.value;
@@ -1,17 +1,19 @@
<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: "Обычный", value: "default" },
{ label: "Крупный", value: "large" },
{ label: "Компактный", value: "small" },
{ 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("Размер интерфейса изменён");
ElMessage.success(t("common.sizeChanged"));
}
</script>
@@ -11,7 +11,7 @@
/>
<el-select
v-model="unit"
:placeholder="$t('account.unit')"
:placeholder="$t('peer.unit')"
style="width: 100px"
>
<el-option
@@ -74,3 +74,4 @@ watch(
<style lang="scss" scoped></style>
@@ -1,4 +1,4 @@
import { useAccountStoreHook } from "@/store/modules/account";
import { useAdminStoreHook } from "@/store/modules/admin";
import { Directive, DirectiveBinding } from "vue";
/**
@@ -10,7 +10,7 @@ export const hasRole: Directive = {
if (value) {
const requiredRoles = value; // Коды ролей, требуемые DOM-привязкой
const { roles } = useAccountStoreHook();
const { roles } = useAdminStoreHook();
const hasRole = roles.some((perm) => {
return requiredRoles.includes(perm);
});
+24 -9
View File
@@ -1,8 +1,9 @@
export default {
// МаршрутЛокализация
route: {
account: "Account",
accountList: "Account Manage",
dashboard: "Dashboard",
peer: "Peers",
peerList: "Peer Management",
hysteria: "Hysteria",
hysteriaList: "Hysteria Manage",
config: "System",
@@ -12,8 +13,6 @@ export default {
log: "Log",
logSystem: "System Log",
logHysteria: "Hysteria Log",
info: "Info",
infoAccount: "Account Info",
},
// Локализация страницы входа
login: {
@@ -42,8 +41,6 @@ export default {
confirm: "Confirm",
cancel: "Cancel",
copySuccess: "Copy successful",
subscribe: "Subscribe",
subscribeQrCode: "Subscribe QR Code",
nodeUrl: "Node URL",
nodeQrCode: "Node QR Code",
resetTraffic: "Reset traffic",
@@ -60,8 +57,27 @@ export default {
yes: "Yes",
no: "No",
securityRisk: "Security Risks",
defaultPassTip: `Please change the default login password as soon as possible, It is recommended to set a strong password to protect your account security. <a href="/#/account/list?focus=change-pass" style="color: #00BFFF">Click here</a> to change`,
defaultPassTip: `Please change the default login password as soon as possible, It is recommended to set a strong password to protect your account security. <a href="/#/peers/list?focus=change-pass" style="color: #00BFFF">Click here</a> to change`,
noHttpsTip: `Your website is not using HTTPS, making data transmission insecure, Please enable HTTPS as soon as possible to protect user information. <a href="/#/config/list?focus=huiHttps" style="color: #00BFFF">Click here</a> to enable`,
required: "Required",
warning: "Warning",
fileFormatUnsupported: "File format not supported",
fileTooLarge: "The file is too big, less than 2 MB",
weekLater: "A week later",
monthLater: "A month later",
yearLater: "A year later",
hourLater: "A hour later",
dayLater: "A day later",
deleteConfirm: "Are you sure to delete the user \u300c{username}\u300d?",
resetTrafficConfirm: "Are you sure to reset 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",
},
info: {
expireTime: "y-M-d H:m:s",
@@ -72,7 +88,7 @@ export default {
greeting5:
"I want to be a shooting star, cutting through the darkness, just to illuminate your dreams, good night🌛!",
},
account: {
peer: {
remark: "Remark",
username: "Username",
pass: "Pass",
@@ -157,7 +173,6 @@ export default {
config: {
enable: "Enable/Disable",
remark: "Remark",
clashExtension: "Clash subscription extension",
listen:
"When the IP address is omitted, the server will listen on all interfaces, both IPv4 and IPv6. To listen on IPv4 only, you can use 0.0.0.0:443. To listen on IPv6 only, you can use [::]:443.",
tlsType: "TLS type",
+24 -9
View File
@@ -1,7 +1,8 @@
export default {
route: {
account: "Аккаунты",
accountList: "Управление аккаунтами",
dashboard: "Дашборд",
peer: "Пиры",
peerList: "Управление пирами",
hysteria: "Hysteria",
hysteriaList: "Управление Hysteria",
config: "Система",
@@ -11,8 +12,6 @@ export default {
log: "Логи",
logSystem: "Системные логи",
logHysteria: "Логи Hysteria",
info: "Информация",
infoAccount: "Профиль",
},
login: {
title: "HY2XS admin",
@@ -39,8 +38,6 @@ export default {
confirm: "Подтвердить",
cancel: "Отмена",
copySuccess: "Скопировано",
subscribe: "Ссылка подписки",
subscribeQrCode: "QR подписки",
nodeUrl: "URL узла",
nodeQrCode: "QR узла",
resetTraffic: "Сбросить трафик",
@@ -57,8 +54,27 @@ export default {
yes: "Да",
no: "Нет",
securityRisk: "Риски безопасности",
defaultPassTip: `Смените пароль по умолчанию как можно скорее. <a href="/#/account/list?focus=change-pass" style="color: #00BFFF">Перейти к смене</a>`,
defaultPassTip: `Смените пароль по умолчанию как можно скорее. <a href="/#/peers/list?focus=change-pass" style="color: #00BFFF">Перейти к смене</a>`,
noHttpsTip: `Панель работает без HTTPS. Включите HTTPS для защиты данных. <a href="/#/config/list?focus=huiHttps" style="color: #00BFFF">Открыть настройки</a>`,
required: "Обязательное поле",
warning: "Внимание",
fileFormatUnsupported: "Формат файла не поддерживается",
fileTooLarge: "Файл слишком большой, не более 2 МБ",
weekLater: "Через неделю",
monthLater: "Через месяц",
yearLater: "Через год",
hourLater: "Через час",
dayLater: "Через день",
deleteConfirm: "Вы уверены, что хотите удалить пользователя «{username}»?",
resetTrafficConfirm: "Сбросить трафик для пользователя?",
invalid: "Некорректное значение",
switchLanguageSuccess: "Язык переключён",
sizeChanged: "Размер интерфейса изменён",
sizeDefault: "Обычный",
sizeLarge: "Крупный",
sizeSmall: "Компактный",
logoutConfirm: "Выйти из системы?",
sessionExpired: "Текущая сессия истекла, войдите снова",
},
info: {
expireTime: "г-М-д Ч:м:с",
@@ -68,7 +84,7 @@ export default {
greeting4: "Добрый вечер,",
greeting5: "Доброй ночи,",
},
account: {
peer: {
remark: "Комментарий",
username: "Логин",
pass: "Пароль входа",
@@ -152,7 +168,6 @@ export default {
config: {
enable: "Включить/отключить",
remark: "Комментарий",
clashExtension: "Расширение подписки Clash",
listen: "Адрес и порт прослушивания. Для IPv4 используйте 0.0.0.0:443.",
tlsType: "Тип TLS",
tls: {
@@ -1,13 +1,15 @@
<script setup lang="ts">
import { storeToRefs } from "pinia";
import { useRoute, useRouter } from "vue-router";
import { useI18n } from "vue-i18n";
import { useAppStore } from "@/store/modules/app";
import { useTagsViewStore } from "@/store/modules/tagsView";
import { useAccountStore } from "@/store/modules/account";
import { useAdminStore } from "@/store/modules/admin";
const appStore = useAppStore();
const tagsViewStore = useTagsViewStore();
const accountStore = useAccountStore();
const adminStore = useAdminStore();
const { t } = useI18n();
const route = useRoute();
const router = useRouter();
@@ -30,12 +32,12 @@ const { isFullscreen, toggle } = useFullscreen();
* Выход из системы.
*/
function logout() {
ElMessageBox.confirm("Выйти из системы?", "Подтверждение", {
confirmButtonText: "Выйти",
cancelButtonText: "Отмена",
ElMessageBox.confirm(t("common.logoutConfirm"), t("common.warning"), {
confirmButtonText: t("navbar.logout"),
cancelButtonText: t("common.cancel"),
type: "warning",
}).then(() => {
accountStore
adminStore
.logout()
.then(() => {
tagsViewStore.delAllViews();
@@ -139,4 +141,3 @@ function logout() {
}
</style>
@@ -92,7 +92,14 @@ function resolvePath(routePath: string) {
:icon-class="onlyOneChild.meta.icon"
/>
<template #title>
{{ translateRouteTitleI18n(onlyOneChild.meta.title) }}
<el-tooltip
:content="translateRouteTitleI18n(onlyOneChild.meta.title)"
placement="right"
>
<span class="menu-title">{{
translateRouteTitleI18n(onlyOneChild.meta.title)
}}</span>
</el-tooltip>
</template>
</el-menu-item>
</app-link>
@@ -105,9 +112,13 @@ function resolvePath(routePath: string) {
v-if="item.meta && item.meta.icon"
:icon-class="item.meta.icon"
/>
<span v-if="item.meta && item.meta.title">{{
translateRouteTitleI18n(item.meta.title)
}}</span>
<el-tooltip
v-if="item.meta && item.meta.title"
:content="translateRouteTitleI18n(item.meta.title)"
placement="right"
>
<span class="menu-title">{{ translateRouteTitleI18n(item.meta.title) }}</span>
</el-tooltip>
</template>
<sidebar-item
@@ -120,4 +131,12 @@ function resolvePath(routePath: string) {
</div>
</template>
<style scoped lang="scss">
.menu-title {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>
+5 -5
View File
@@ -1,5 +1,5 @@
import router from "@/router";
import { useAccountStoreHook } from "@/store/modules/account";
import { useAdminStoreHook } from "@/store/modules/admin";
import { usePermissionStoreHook } from "@/store/modules/permission";
import NProgress from "nprogress";
@@ -21,8 +21,8 @@ router.beforeEach(async (to, from, next) => {
next({ path: "/" });
NProgress.done();
} else {
const AccountStore = useAccountStoreHook();
const hasRoles = AccountStore.roles && AccountStore.roles.length > 0;
const adminStore = useAdminStoreHook();
const hasRoles = adminStore.roles && adminStore.roles.length > 0;
if (hasRoles) {
// Если маршрут не найден, перейти на 404
if (to.matched.length === 0) {
@@ -32,7 +32,7 @@ router.beforeEach(async (to, from, next) => {
}
} else {
try {
const { roles } = await AccountStore.getAccountInfo();
const { roles } = await adminStore.getAdminInfo();
const accessRoutes = permissionStore.generateRoutes(roles);
accessRoutes.forEach((route) => {
router.addRoute(route);
@@ -40,7 +40,7 @@ router.beforeEach(async (to, from, next) => {
next({ ...to, replace: true });
} catch (error) {
// Удалить token и перейти на страницу входа
await AccountStore.resetToken();
await adminStore.resetToken();
next(`/login?redirect=${to.path}`);
NProgress.done();
}
+19 -40
View File
@@ -29,7 +29,7 @@ export const constantRoutes: RouteRecordRaw[] = [
{
path: "/",
component: Layout,
redirect: "/info/account",
redirect: "/dashboard/index",
children: [
{
path: "401",
@@ -47,41 +47,41 @@ export const constantRoutes: RouteRecordRaw[] = [
export const asyncRoutes: any[] = [
{
path: "/info",
path: "/dashboard",
component: "Layout",
redirect: "/account",
name: "Info",
redirect: "/dashboard/index",
name: "Dashboard",
meta: {
title: "info",
icon: "user",
roles: ["user", "admin"],
title: "dashboard",
icon: "dashboard",
roles: ["admin"],
},
children: [
{
path: "account",
component: "info/account/index",
name: "AccountInfo",
path: "index",
component: "dashboard/index",
name: "DashboardIndex",
meta: {
title: "infoAccount",
icon: "user",
roles: ["user", "admin"],
title: "dashboard",
icon: "dashboard",
roles: ["admin"],
},
},
],
},
{
path: "/account",
path: "/peers",
component: "Layout",
redirect: "/list",
name: "Account",
meta: { title: "account", icon: "users", roles: ["admin"] },
name: "Peer",
meta: { title: "peer", icon: "users", roles: ["admin"] },
children: [
{
path: "list",
component: "account/list/index",
name: "AccountList",
component: "peer/list/index",
name: "PeerList",
meta: {
title: "accountList",
title: "peerList",
icon: "users",
roles: ["admin"],
},
@@ -132,25 +132,6 @@ export const asyncRoutes: any[] = [
},
],
},
{
path: "/monitor",
component: "Layout",
redirect: "/monitor",
name: "Monitor",
meta: { title: "monitor", icon: "report", roles: ["admin"] },
children: [
{
path: "system",
component: "monitor/system/index",
name: "MonitorSystem",
meta: {
title: "monitorSystem",
icon: "report",
roles: ["admin"],
},
},
],
},
{
path: "/log",
component: "Layout",
@@ -201,5 +182,3 @@ export function resetRouter() {
}
export default router;
@@ -1,31 +1,25 @@
import { defineStore } from "pinia";
import { getAccountInfoApi, loginApi } from "@/api/account";
import { getAdminInfoApi, loginApi } from "@/api/admin";
import { resetRouter } from "@/router";
import { store } from "@/store";
import { AccountInfo, AccountLoginDto } from "@/api/account/types";
import { AdminInfo, AdminLoginDto } from "@/api/admin/types";
import { useStorage } from "@vueuse/core";
export const useAccountStore = defineStore("account", () => {
// state
export const useAdminStore = defineStore("admin", () => {
const token = useStorage("accessToken", "");
const id = ref(0);
const username = ref("");
const roles = ref<Array<string>>([]); // Коды ролей пользователя для проверки доступа к маршрутам
const roles = ref<Array<string>>([]);
/**
* Вход
*
* @returns
*/
function login(accountLoginDto: AccountLoginDto) {
function login(adminLoginDto: AdminLoginDto) {
return new Promise<void>((resolve, reject) => {
loginApi(accountLoginDto)
loginApi(adminLoginDto)
.then((response) => {
const { tokenType, accessToken } = response.data;
token.value = tokenType + " " + accessToken; // Bearer eyJhbGciOiJIUzI1NiJ9.xxx.xxx
token.value = tokenType + " " + accessToken;
resolve();
})
.catch((error) => {
@@ -34,16 +28,15 @@ export const useAccountStore = defineStore("account", () => {
});
}
// Запрос текущего пользователя
function getAccountInfo() {
return new Promise<AccountInfo>((resolve, reject) => {
getAccountInfoApi()
function getAdminInfo() {
return new Promise<AdminInfo>((resolve, reject) => {
getAdminInfoApi()
.then(({ data }) => {
if (!data) {
return reject("Verification failed, please Login again.");
}
if (!data.roles || data.roles.length <= 0) {
reject("getAccountInfoApi: roles must be a non-null array!");
reject("getAdminInfoApi: roles must be a non-null array!");
}
id.value = data.id;
username.value = data.username;
@@ -56,16 +49,14 @@ export const useAccountStore = defineStore("account", () => {
});
}
// Выход
function logout() {
return new Promise<void>((resolve, reject) => {
return new Promise<void>((resolve) => {
resetRouter();
resetToken();
resolve();
});
}
// Сброс
function resetToken() {
token.value = "";
id.value = 0;
@@ -79,15 +70,13 @@ export const useAccountStore = defineStore("account", () => {
username,
roles,
login,
getAccountInfo,
getAdminInfo,
logout,
resetToken,
};
});
// Вне setup
export function useAccountStoreHook() {
return useAccountStore(store);
export function useAdminStoreHook() {
return useAdminStore(store);
}
+25 -11
View File
@@ -46,8 +46,32 @@
display: none;
}
.el-menu-item,
.el-sub-menu__title {
display: flex;
align-items: center;
gap: 12px;
height: 48px;
line-height: normal;
padding: 0 16px !important;
}
.svg-icon {
margin-right: 16px;
flex: 0 0 18px;
width: 18px;
height: 18px;
margin-right: 0;
}
.menu-title {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.el-sub-menu__icon-arrow {
margin-left: auto;
}
.sub-el-icon {
@@ -100,16 +124,6 @@
overflow: hidden;
& > .el-sub-menu__title {
padding: 0 !important;
.svg-icon {
margin-left: 20px;
}
.sub-el-icon {
margin-left: 19px;
}
.el-sub-menu__icon-arrow {
display: none;
}
-6
View File
@@ -3,11 +3,8 @@ export {}
declare global {
const EffectScope: typeof import('vue')['EffectScope']
const ElForm: typeof import('element-plus/es')['ElForm']
const ElInput: typeof import('element-plus/es')['ElInput']
const ElMessage: typeof import('element-plus/es')['ElMessage']
const ElMessageBox: typeof import('element-plus/es')['ElMessageBox']
const ElNotification: typeof import('element-plus/es')['ElNotification']
const ElSelect: typeof import('element-plus/es')['ElSelect']
const asyncComputed: typeof import('@vueuse/core')['asyncComputed']
const autoResetRef: typeof import('@vueuse/core')['autoResetRef']
const computed: typeof import('vue')['computed']
@@ -269,11 +266,8 @@ declare module 'vue' {
interface ComponentCustomProperties {
readonly EffectScope: UnwrapRef<typeof import('vue')['EffectScope']>
readonly ElForm: UnwrapRef<typeof import('element-plus/es')['ElForm']>
readonly ElInput: UnwrapRef<typeof import('element-plus/es')['ElInput']>
readonly ElMessage: UnwrapRef<typeof import('element-plus/es')['ElMessage']>
readonly ElMessageBox: UnwrapRef<typeof import('element-plus/es')['ElMessageBox']>
readonly ElNotification: UnwrapRef<typeof import('element-plus/es')['ElNotification']>
readonly ElSelect: UnwrapRef<typeof import('element-plus/es')['ElSelect']>
readonly asyncComputed: UnwrapRef<typeof import('@vueuse/core')['asyncComputed']>
readonly autoResetRef: UnwrapRef<typeof import('@vueuse/core')['autoResetRef']>
readonly computed: UnwrapRef<typeof import('vue')['computed']>
+8 -8
View File
@@ -1,8 +1,10 @@
import axios, { InternalAxiosRequestConfig, AxiosResponse } from "axios";
import { useAccountStoreHook } from "@/store/modules/account";
import { useAdminStoreHook } from "@/store/modules/admin";
import i18n from "@/lang/index";
const dynamicBase = (window as any).__dynamic_base__ || "";
const API_BASE = "/hui";
const t = i18n.global.t;
// Создание axios instance
const service = axios.create({
baseURL: `${dynamicBase}${API_BASE}`,
@@ -13,9 +15,9 @@ const service = axios.create({
// Request interceptor
service.interceptors.request.use(
(config: InternalAxiosRequestConfig) => {
const accountStore = useAccountStoreHook();
if (accountStore.token) {
config.headers.Authorization = accountStore.token;
const adminStore = useAdminStoreHook();
if (adminStore.token) {
config.headers.Authorization = adminStore.token;
}
return config;
},
@@ -44,8 +46,8 @@ service.interceptors.response.use(
const { code, msg } = error.response.data;
// Token истёк, нужен повторный вход
if (code === "A0230") {
ElMessageBox.confirm("Текущая сессия истекла, войдите снова", "Подтверждение", {
confirmButtonText: "ОК",
ElMessageBox.confirm(t("common.sessionExpired"), t("common.warning"), {
confirmButtonText: t("common.confirm"),
type: "warning",
}).then(() => {
localStorage.clear();
@@ -61,5 +63,3 @@ service.interceptors.response.use(
// Export axios instance
export default service;
@@ -177,7 +177,7 @@ const { t } = useI18n();
const route = useRoute();
const dataFormRef = ref(ElForm);
const huiHttpsRef = ref(ElSelect);
const huiHttpsRef = ref<any>(null);
const huiWebPortKey = "H_UI_WEB_PORT";
const huiWebContext = "H_UI_WEB_CONTEXT";
@@ -200,7 +200,7 @@ const dataFormRules = {
huiWebPort: [
{
required: true,
message: "Required",
message: t("common.required"),
trigger: ["change", "blur"],
},
{
@@ -212,7 +212,7 @@ const dataFormRules = {
huiWebContext: [
{
required: true,
message: "Required",
message: t("common.required"),
trigger: ["change", "blur"],
},
{
@@ -224,7 +224,7 @@ const dataFormRules = {
hysteria2TrafficTime: [
{
required: true,
message: "Required",
message: t("common.required"),
trigger: ["change", "blur"],
},
{
@@ -321,11 +321,11 @@ const handleImport = async (params: UploadRequestOptions) => {
};
const beforeImport = (file: UploadRawFile) => {
if (!file.name.endsWith(".json")) {
ElMessage.error("file format not supported");
ElMessage.error(t("common.fileFormatUnsupported"));
return false;
}
if (file.size / 1024 / 1024 > 2) {
ElMessage.error("the file is too big, less than 2 MB");
ElMessage.error(t("common.fileTooLarge"));
return false;
}
};
@@ -353,7 +353,7 @@ const handleExport = async () => {
const handleRestartServer = async () => {
try {
ElMessageBox.confirm("Are you sure to restart panel?", "Warning", {
ElMessageBox.confirm(t("config.restartTip"), t("common.warning"), {
confirmButtonText: t("common.confirm"),
cancelButtonText: t("common.cancel"),
type: "warning",
@@ -385,5 +385,3 @@ onMounted(() => {
margin: 0 auto;
}
</style>
+156
View File
@@ -0,0 +1,156 @@
<template>
<div class="dashboard-container">
<div class="dashboard-actions mb-2">
<el-button size="small" @click="loadDashboard">Refresh</el-button>
</div>
<el-alert
v-if="loadError"
:title="loadError"
type="error"
:closable="false"
class="mb-2"
/>
<el-alert
v-else-if="isStale"
title="Dashboard data is stale. Retrying automatically..."
type="warning"
:closable="false"
class="mb-2"
/>
<el-alert
v-for="risk in securityRisks"
:key="risk.key"
:title="risk.key"
:type="risk.severity === 'critical' ? 'error' : risk.severity === 'warning' ? 'warning' : 'info'"
:closable="risk.dismissible"
class="mb-2"
/>
<el-row :gutter="10" class="mt-2">
<el-col :xs="24" :sm="12" :lg="6"><el-card shadow="never">CPU: {{ summary.system.cpuPercent }}%</el-card></el-col>
<el-col :xs="24" :sm="12" :lg="6"><el-card shadow="never">RAM: {{ summary.system.memPercent }}%</el-card></el-col>
<el-col :xs="24" :sm="12" :lg="6"><el-card shadow="never">Disk: {{ summary.system.diskPercent }}%</el-card></el-col>
<el-col :xs="24" :sm="12" :lg="6"><el-card shadow="never">Peers: {{ summary.peers.total }}</el-card></el-col>
<el-col :xs="24" :sm="12" :lg="6"><el-card shadow="never">Online peers: {{ summary.peers.onlinePeers }}</el-card></el-col>
<el-col :xs="24" :sm="12" :lg="6"><el-card shadow="never">Online devices: {{ summary.peers.onlineDevices }}</el-card></el-col>
<el-col :xs="24" :sm="12" :lg="6"><el-card shadow="never">Today download: {{ formatBytes(summary.traffic.todayDownloadBytes || 0) }}</el-card></el-col>
<el-col :xs="24" :sm="12" :lg="6"><el-card shadow="never">Today upload: {{ formatBytes(summary.traffic.todayUploadBytes || 0) }}</el-card></el-col>
</el-row>
<el-card shadow="never" class="mt-3">
<template #header>Top peers (24h)</template>
<el-table :data="topPeers" size="small">
<el-table-column prop="name" label="Peer" />
<el-table-column prop="download" label="Download">
<template #default="scope">{{ formatBytes(scope.row.download || 0) }}</template>
</el-table-column>
<el-table-column prop="upload" label="Upload">
<template #default="scope">{{ formatBytes(scope.row.upload || 0) }}</template>
</el-table-column>
<el-table-column prop="total" label="Total">
<template #default="scope">{{ formatBytes(scope.row.total || 0) }}</template>
</el-table-column>
</el-table>
</el-card>
</div>
</template>
<script setup lang="ts">
import { useIntervalFn } from "@vueuse/core";
import { dashboardSecurityApi, dashboardSummaryApi, dashboardTopPeersApi } from "@/api/dashboard";
import { DashboardSummaryVo, DashboardTopPeerVo, SecurityRiskVo } from "@/api/dashboard/types";
import { formatBytes } from "@/utils/byte";
const summary = ref<DashboardSummaryVo>({
collectedAt: 0,
system: { cpuPercent: 0, memUsedBytes: 0, memTotalBytes: 0, memPercent: 0, diskUsedBytes: 0, diskTotalBytes: 0, diskPercent: 0 },
hysteria: { version: "-", running: false, apiReachable: false },
peers: { total: 0, enabled: 0, disabled: 0, expired: 0, onlinePeers: 0, onlineDevices: 0 },
traffic: { downloadBytes: 0, uploadBytes: 0, totalBytes: 0, todayDownloadBytes: 0, todayUploadBytes: 0, sinceResetDownloadBytes: 0, sinceResetUploadBytes: 0 },
health: {
collector: { status: "stale" },
hysteria: { status: "ok" },
},
securityRisks: [],
});
const topPeers = ref<DashboardTopPeerVo[]>([]);
const securityRisks = ref<SecurityRiskVo[]>([]);
const loadError = ref("");
const loading = ref(false);
const lastSuccessAt = ref(0);
const staleThresholdMs = 90_000;
const pollIntervalMs = 30_000;
const isStale = computed(() => {
if (!lastSuccessAt.value) {
return false;
}
return Date.now() - lastSuccessAt.value > staleThresholdMs;
});
const loadDashboard = async () => {
if (loading.value) {
return;
}
loading.value = true;
try {
const [summaryRes, topRes, secRes] = await Promise.all([
dashboardSummaryApi(),
dashboardTopPeersApi("24h", 10),
dashboardSecurityApi(),
]);
summary.value = summaryRes.data;
topPeers.value = topRes.data;
securityRisks.value = secRes.data;
lastSuccessAt.value = Date.now();
loadError.value = "";
} catch (error) {
loadError.value = "Failed to refresh dashboard data";
} finally {
loading.value = false;
}
};
const { pause: stopPolling, resume: startPolling } = useIntervalFn(
() => {
loadDashboard();
},
pollIntervalMs,
{ immediate: false }
);
const handleVisibilityChange = () => {
if (document.hidden) {
stopPolling();
return;
}
loadDashboard();
startPolling();
};
onMounted(() => {
loadDashboard();
startPolling();
document.addEventListener("visibilitychange", handleVisibilityChange);
});
onUnmounted(() => {
document.removeEventListener("visibilitychange", handleVisibilityChange);
stopPolling();
});
</script>
<style scoped lang="scss">
.dashboard-container {
padding: 16px;
}
.dashboard-actions {
display: flex;
justify-content: flex-end;
}
</style>
@@ -283,6 +283,9 @@ import {
} from "@/api/config/types";
import { PropType } from "vue";
import { deepCopy } from "@/utils/copy";
import { useI18n } from "vue-i18n";
const { t } = useI18n();
const props = defineProps({
outbounds: {
@@ -308,11 +311,11 @@ const state = reactive({
...defaultHysteria2ServerConfigOutbound,
} as Hysteria2ServerConfigOutbound,
dialog: {
title: "Add Outbound",
title: t("hysteria.addOutbound"),
visible: false,
} as DialogType,
outboundInfoDialog: {
title: "Outbound Info",
title: t("hysteria.outbounds"),
visible: false,
},
outboundInfo: {} as Hysteria2ServerConfigOutbound,
@@ -344,7 +347,7 @@ const submitForm = () => {
dataFormRef.value.validate((valid: any) => {
if (valid) {
if (outbounds.value.some((item) => item.name === state.dataForm.name)) {
ElMessage.error("name cannot be repeated");
ElMessage.error(t("common.invalid"));
return;
}
if (state.dataForm.type === "socks5") {
@@ -63,19 +63,6 @@
<el-input v-model="configForm.remark" clearable />
</el-form-item>
</el-tooltip>
<el-tooltip
:content="$t('hysteria.config.clashExtension')"
placement="bottom"
>
<el-form-item label="clashExtension" prop="clashExtension">
<el-input
v-model="configForm.clashExtension"
type="textarea"
:autosize="{ minRows: 3 }"
@keydown="(e:KeyboardEvent) => e.stopPropagation()"
/>
</el-form-item>
</el-tooltip>
</el-tab-pane>
<el-tab-pane :label="$t('hysteria.listen')" name="listen">
<el-tooltip
@@ -1011,27 +998,26 @@ import {
UploadFile,
UploadRequestOptions,
} from "element-plus/lib/components";
import { monitorHysteria2Api } from "@/api/monitor";
import { dashboardSummaryApi } from "@/api/dashboard";
import { UploadUserFile } from "element-plus";
const { t } = useI18n();
const hysteria2Remark = "HYSTERIA2_CONFIG_REMARK";
const clashExtension = "CLASH_EXTENSION";
const dataFormRef = ref(ElForm);
const dataFormRules = {
listen: [
{
required: true,
message: "Required",
message: t("common.required"),
trigger: ["change", "blur"],
},
],
"trafficStats.listen": [
{
required: true,
message: "Required",
message: t("common.required"),
trigger: ["change", "blur"],
},
],
@@ -1057,7 +1043,6 @@ const masqueradeTypes = ref<string[]>(["file", "proxy", "string"]);
const state = reactive({
configForm: {
remark: "",
clashExtension: "",
},
dataForm: { ...defaultHysteria2ServerConfig } as Hysteria2ServerConfig,
activeName: "extension",
@@ -1189,14 +1174,12 @@ const handleExport = async () => {
const setConfig = () => {
listConfigApi({
keys: [hysteria2Remark, clashExtension],
keys: [hysteria2Remark],
}).then((response) => {
const data = response.data;
data.forEach((configVo) => {
if (configVo.key === hysteria2Remark) {
state.configForm.remark = configVo.value;
} else if (configVo.key === clashExtension) {
state.configForm.clashExtension = configVo.value;
}
});
});
@@ -1225,8 +1208,9 @@ const setConfig = () => {
};
const setHysteria2Monitor = async () => {
const { data } = await monitorHysteria2Api();
Object.assign(state.hysteria2Monitor, data);
const { data } = await dashboardSummaryApi();
state.hysteria2Monitor.version = data.hysteria.version;
state.hysteria2Monitor.running = data.hysteria.running;
};
const uploadCertFile = async (params: UploadRequestOptions) => {
@@ -1238,10 +1222,10 @@ const uploadCertFile = async (params: UploadRequestOptions) => {
!params.file.name.endsWith(".crt") &&
!params.file.name.endsWith(".key")
) {
ElMessage.error("file format not supported");
ElMessage.error(t("common.fileFormatUnsupported"));
}
if (params.file.size > 1024 * 1024) {
ElMessage.error("the file is too big");
ElMessage.error(t("common.fileTooLarge"));
}
let formData = new FormData();
formData.append("file", params.file);
@@ -1,337 +0,0 @@
<template>
<div class="dashboard-container">
<el-card shadow="never">
<el-row justify="space-between">
<el-col :span="12" :xs="24">
<div class="flex h-full items-center">
<img
class="w-20 h-20 mr-5 rounded-full"
src="/src/assets/logo.png"
/>
<div>
<p>{{ greetings }}</p>
<p class="text-sm text-gray">
{{ $t("account.createTime") }}:
{{ timestampToDateTime(account.createTime) }}
</p>
</div>
</div>
</el-col>
<el-col :span="12" :xs="24">
<div class="flex h-full items-center" style="justify-content: right">
<el-button type="primary" :icon="Share" @click="handleSubscribe">
{{ $t("common.subscribe") }}
</el-button>
<el-button
type="primary"
:icon="Share"
@click="handleSubscribeQrCode"
>
{{ $t("common.subscribeQrCode") }}
</el-button>
<el-button type="primary" :icon="Share" @click="handleNodeUrl">
{{ $t("common.nodeUrl") }}
</el-button>
<el-button type="primary" :icon="Share" @click="handleUrlQrCode">
{{ $t("common.nodeQrCode") }}
</el-button>
</div>
</el-col>
</el-row>
</el-card>
<el-row :gutter="10" class="mt-3">
<el-col :xs="24" :sm="12" :lg="6">
<el-card shadow="never">
<template #header>
<div class="flex items-center justify-between">
<span class="text-[var(--el-text-color-secondary)]">
{{ $t("account.quota") }}
</span>
<el-tag type="success"
>{{ formatStorageUnit(account.quota) }}
</el-tag>
</div>
</template>
<div class="flex items-center justify-between mt-5">
<div class="text-lg text-right">
{{ formatBytes(account.quota) }}
</div>
<svg-icon icon-class="quota" size="2em" />
</div>
</el-card>
</el-col>
<el-col :xs="24" :sm="12" :lg="6">
<el-card shadow="never">
<template #header>
<div class="flex items-center justify-between">
<span class="text-[var(--el-text-color-secondary)]">
{{ $t("account.download") }}
</span>
<el-tag type="success"
>{{ formatStorageUnit(account.download) }}
</el-tag>
</div>
</template>
<div class="flex items-center justify-between mt-5">
<div class="text-lg text-right">
{{ formatBytes(account.download) }}
</div>
<svg-icon icon-class="download" size="2em" />
</div>
</el-card>
</el-col>
<el-col :xs="24" :sm="12" :lg="6">
<el-card shadow="never">
<template #header>
<div class="flex items-center justify-between">
<span class="text-[var(--el-text-color-secondary)]">
{{ $t("account.upload") }}
</span>
<el-tag type="success"
>{{ formatStorageUnit(account.upload) }}
</el-tag>
</div>
</template>
<div class="flex items-center justify-between mt-5">
<div class="text-lg text-right">
{{ formatBytes(account.upload) }}
</div>
<svg-icon icon-class="upload" size="2em" />
</div>
</el-card>
</el-col>
<el-col :xs="24" :sm="12" :lg="6">
<el-card shadow="never">
<template #header>
<div class="flex items-center justify-between">
<span class="text-[var(--el-text-color-secondary)]">
{{ $t("account.expireTime") }}
</span>
<el-tag type="success">{{ $t("info.expireTime") }}</el-tag>
</div>
</template>
<div class="flex items-center justify-between mt-5">
<div class="text-lg text-right">
{{ timestampToDateTime(account.expireTime) }}
</div>
<svg-icon icon-class="expire-time" size="2em" />
</div>
</el-card>
</el-col>
</el-row>
<el-dialog
:title="qrCodeDialog.title"
v-model="qrCodeDialog.visible"
width="600px"
append-to-body
@close="qrCodeDialog.visible = false"
>
<el-form style="text-align: center">
<el-image
style="width: 300px; height: 300px"
:src="qrCodeSrc"
></el-image>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button type="primary" @click="qrCodeDialog.visible = false"
>{{ $t("common.confirm") }}
</el-button>
</div>
</template>
</el-dialog>
</div>
</template>
<script lang="ts">
export default {
name: "index",
};
</script>
<script setup lang="ts">
import { getAccountApi, verifyDefaultPassApi } from "@/api/account";
import { AccountVo } from "@/api/account/types";
import { useAccountStore } from "@/store/modules/account";
import { timestampToDateTime } from "@/utils/time";
import { formatBytes, formatStorageUnit } from "@/utils/byte";
import { Share } from "@element-plus/icons-vue";
import { useI18n } from "vue-i18n";
import {
Hysteria2SubscribeUrlDto,
Hysteria2UrlDto,
} from "@/api/hysteria2/types";
import { hysteria2SubscribeUrlApi, hysteria2UrlApi } from "@/api/hysteria2";
import copy from "copy-to-clipboard";
const { t } = useI18n();
const accountStore = useAccountStore();
const date: Date = new Date();
const greetings = computed(() => {
const hours = date.getHours();
if (hours >= 6 && hours < 8) {
return t("info.greeting1");
} else if (hours >= 8 && hours < 12) {
return t("info.greeting2") + accountStore.username + "";
} else if (hours >= 12 && hours < 18) {
return t("info.greeting3") + accountStore.username + "";
} else if (hours >= 18 && hours < 24) {
return t("info.greeting4") + accountStore.username + "";
} else if (hours >= 0 && hours < 6) {
return t("info.greeting5");
}
return "Hello HY2XS";
});
const state = reactive({
account: {} as AccountVo,
qrCodeDialog: {
title: "QR Code",
visible: false,
} as DialogType,
qrCodeSrc: "",
});
const { qrCodeDialog, account, qrCodeSrc } = toRefs(state);
const handleSubscribe = async () => {
try {
const dto: Hysteria2SubscribeUrlDto = {
accountId: accountStore.id,
protocol: window.location.protocol,
};
const { data } = await hysteria2SubscribeUrlApi(dto);
copy(data.url);
ElMessage.success(t("common.copySuccess"));
} catch (e) {
/* empty */
}
};
const handleSubscribeQrCode = async () => {
try {
const dto: Hysteria2SubscribeUrlDto = {
accountId: accountStore.id,
protocol: window.location.protocol,
};
const { data } = await hysteria2SubscribeUrlApi(dto);
state.qrCodeSrc = "data:image/png;base64," + data.qrCode;
state.qrCodeDialog.visible = true;
} catch (e) {
/* empty */
}
};
const handleNodeUrl = async () => {
try {
const dto: Hysteria2UrlDto = {
accountId: accountStore.id,
};
const { data } = await hysteria2UrlApi(dto);
copy(data.url);
ElMessage.success(t("common.copySuccess"));
} catch (e) {
/* empty */
}
};
const handleUrlQrCode = async () => {
try {
const dto: Hysteria2UrlDto = {
accountId: accountStore.id,
};
const { data } = await hysteria2UrlApi(dto);
state.qrCodeSrc = "data:image/png;base64," + data.qrCode;
state.qrCodeDialog.visible = true;
} catch (e) {
/* empty */
}
};
onMounted(() => {
getAccountApi({ id: accountStore.id }).then((response) => {
Object.assign(state.account, response.data);
});
if (accountStore.roles.indexOf("admin") != -1) {
verifyDefaultPassApi().then((response) => {
if (response.data) {
ElNotification({
title: t("common.securityRisk"),
dangerouslyUseHTMLString: true,
message: t("common.defaultPassTip"),
type: "warning",
});
}
});
if (window.location.protocol !== "https:") {
ElNotification({
title: t("common.securityRisk"),
dangerouslyUseHTMLString: true,
message: t("common.noHttpsTip"),
type: "warning",
});
}
}
});
</script>
<style lang="scss" scoped>
.dashboard-container {
position: relative;
padding: 24px;
.user-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
}
.github-corner {
position: absolute;
top: 0;
right: 0;
z-index: 1;
border: 0;
}
.data-box {
display: flex;
justify-content: space-between;
padding: 20px;
font-weight: bold;
color: var(--el-text-color-regular);
background: var(--el-bg-color-overlay);
border-color: var(--el-border-color);
box-shadow: var(--el-box-shadow-dark);
}
.svg-icon {
fill: currentcolor !important;
}
}
.flex.h-full.items-center {
.el-button {
margin: 10px;
}
}
@media (max-width: 768px) {
.flex.h-full.items-center {
justify-content: center;
}
}
@media (max-width: 634px) {
.flex.h-full.items-center {
flex-direction: column;
}
}
</style>
+9 -9
View File
@@ -75,16 +75,18 @@ export default {
import router from "@/router";
import LangSelect from "@/components/LangSelect/index.vue";
import SvgIcon from "@/components/SvgIcon/index.vue";
import { useI18n } from "vue-i18n";
// Зависимость store
import { useAccountStore } from "@/store/modules/account";
import { useAdminStore } from "@/store/modules/admin";
// Зависимость API
import { LocationQuery, LocationQueryValue, useRoute } from "vue-router";
import { AccountLoginDto } from "@/api/account/types";
import { AdminLoginDto } from "@/api/admin/types";
const accountStore = useAccountStore();
const adminStore = useAdminStore();
const route = useRoute();
const { t } = useI18n();
/**
* Состояние загрузки кнопки
@@ -107,7 +109,7 @@ const loginFormRef = ref(ElForm);
/**
* Форма входа
*/
const loginForm = ref<AccountLoginDto>({
const loginForm = ref<AdminLoginDto>({
username: "",
pass: "",
});
@@ -116,7 +118,7 @@ const loginRules = {
username: [
{
required: true,
message: "Required",
message: t("common.required"),
trigger: ["change", "blur"],
},
{
@@ -128,7 +130,7 @@ const loginRules = {
pass: [
{
required: true,
message: "Required",
message: t("common.required"),
trigger: ["change", "blur"],
},
{
@@ -155,7 +157,7 @@ const handleLogin = () => {
if (valid) {
loading.value = true;
const params = { ...loginForm.value };
accountStore
adminStore
.login(params)
.then(() => {
const query: LocationQuery = route.query;
@@ -238,5 +240,3 @@ const handleLogin = () => {
}
}
</style>
@@ -1,224 +0,0 @@
<template>
<div class="dashboard-container">
<el-row :gutter="10" class="mt-3">
<el-col :xs="24" :sm="12" :lg="6">
<el-card shadow="never">
<template #header>
<div class="flex items-center justify-between">
<span class="text-[var(--el-text-color-secondary)]">
{{ $t("monitor.huiVersion") }}
</span>
<el-tag type="success">version</el-tag>
</div>
</template>
<div class="flex items-center justify-between mt-5">
<div class="text-lg text-right">
{{ systemMonitor.huiVersion ? systemMonitor.huiVersion : "-" }}
</div>
</div>
</el-card>
</el-col>
<el-col :xs="24" :sm="12" :lg="6">
<el-card shadow="never">
<template #header>
<div class="flex items-center justify-between">
<span class="text-[var(--el-text-color-secondary)]">
{{ $t("monitor.cpuPercent") }}
</span>
<el-tag type="success">%</el-tag>
</div>
</template>
<div class="flex items-center justify-between mt-5">
<div class="text-lg text-right">
{{
systemMonitor.cpuPercent ? systemMonitor.cpuPercent + "%" : "-"
}}
</div>
</div>
</el-card>
</el-col>
<el-col :xs="24" :sm="12" :lg="6">
<el-card shadow="never">
<template #header>
<div class="flex items-center justify-between">
<span class="text-[var(--el-text-color-secondary)]">
{{ $t("monitor.memPercent") }}
</span>
<el-tag type="success">%</el-tag>
</div>
</template>
<div class="flex items-center justify-between mt-5">
<div class="text-lg text-right">
{{
systemMonitor.memPercent ? systemMonitor.memPercent + "%" : "-"
}}
</div>
</div>
</el-card>
</el-col>
<el-col :xs="24" :sm="12" :lg="6">
<el-card shadow="never">
<template #header>
<div class="flex items-center justify-between">
<span class="text-[var(--el-text-color-secondary)]">
{{ $t("monitor.diskPercent") }}
</span>
<el-tag type="success">%</el-tag>
</div>
</template>
<div class="flex items-center justify-between mt-5">
<div class="text-lg text-right">
{{
systemMonitor.diskPercent
? systemMonitor.diskPercent + "%"
: "-"
}}
</div>
</div>
</el-card>
</el-col>
<el-col :xs="24" :sm="12" :lg="6">
<el-card shadow="never">
<template #header>
<div class="flex items-center justify-between">
<span class="text-[var(--el-text-color-secondary)]">
{{ $t("monitor.hysteria2Version") }}
</span>
<el-tag type="success">version</el-tag>
</div>
</template>
<div class="flex items-center justify-between mt-5">
<div class="text-lg text-right">
{{ hysteria2Monitor.version ? hysteria2Monitor.version : "-" }}
</div>
</div>
</el-card>
</el-col>
<el-col :xs="24" :sm="12" :lg="6">
<el-card shadow="never">
<template #header>
<div class="flex items-center justify-between">
<span class="text-[var(--el-text-color-secondary)]">
{{ $t("monitor.hysteria2Running") }}
</span>
<el-tag type="success">running</el-tag>
</div>
</template>
<div class="flex items-center justify-between mt-5">
<div
class="text-lg text-right"
:style="
hysteria2Monitor.running === undefined
? '-'
: hysteria2Monitor.running
? 'color: #2ecc71'
: 'color: #e74c3c'
"
>
{{
hysteria2Monitor.running === undefined
? "-"
: hysteria2Monitor.running
? $t("monitor.hysteria2RunningTrue")
: $t("monitor.hysteria2RunningFalse")
}}
</div>
</div>
</el-card>
</el-col>
<el-col :xs="24" :sm="12" :lg="6">
<el-card shadow="never">
<template #header>
<div class="flex items-center justify-between">
<span class="text-[var(--el-text-color-secondary)]">
{{ $t("monitor.hysteria2UserTotal") }}
</span>
<el-tag type="success">account</el-tag>
</div>
</template>
<div class="flex items-center justify-between mt-5">
<div class="text-lg text-right">
{{ hysteria2Monitor.userTotal }}
</div>
</div>
</el-card>
</el-col>
<el-col :xs="24" :sm="12" :lg="6">
<el-card shadow="never">
<template #header>
<div class="flex items-center justify-between">
<span class="text-[var(--el-text-color-secondary)]">
{{ $t("monitor.hysteria2DeviceTotal") }}
</span>
<el-tag type="success">device</el-tag>
</div>
</template>
<div class="flex items-center justify-between mt-5">
<div class="text-lg text-right">
{{ hysteria2Monitor.deviceTotal }}
</div>
</div>
</el-card>
</el-col>
</el-row>
</div>
</template>
<script lang="ts">
export default {
name: "index",
};
</script>
<script setup lang="ts">
import { monitorHysteria2Api, monitorSystemApi } from "@/api/monitor";
const state = reactive({
systemMonitor: {
huiVersion: "",
cpuPercent: 0,
memPercent: 0,
diskPercent: 0,
},
hysteria2Monitor: {
userTotal: 0,
deviceTotal: 0,
version: undefined,
running: undefined,
},
});
const { systemMonitor, hysteria2Monitor } = toRefs(state);
const setMonitor = () => {
monitorSystemApi().then((response) => {
const { data } = response;
Object.assign(state.systemMonitor, data);
});
monitorHysteria2Api().then((response) => {
const { data } = response;
Object.assign(state.hysteria2Monitor, data);
});
};
onMounted(() => {
setMonitor();
});
</script>
<style lang="scss" scoped>
.dashboard-container {
position: relative;
padding: 24px;
.svg-icon {
fill: currentcolor !important;
}
.el-col {
margin-bottom: 10px;
}
}
</style>
@@ -2,19 +2,19 @@
<div class="app-container">
<div class="search">
<el-form ref="queryFormRef" :model="queryParams" :inline="true">
<el-form-item :label="$t('account.remark')" prop="remark">
<el-form-item :label="$t('peer.remark')" prop="remark">
<el-input
v-model="queryParams.remark"
:placeholder="$t('account.remark')"
:placeholder="$t('peer.remark')"
clearable
style="width: 200px"
@keyup.enter="handleQuery"
/>
</el-form-item>
<el-form-item :label="$t('account.username')" prop="username">
<el-form-item :label="$t('peer.username')" prop="username">
<el-input
v-model="queryParams.username"
:placeholder="$t('account.username')"
:placeholder="$t('peer.username')"
clearable
style="width: 200px"
@keyup.enter="handleQuery"
@@ -86,25 +86,19 @@
/>
<el-table-column
key="remark"
:label="$t('account.remark')"
:label="$t('peer.remark')"
align="center"
prop="remark"
/>
<el-table-column
key="username"
:label="$t('account.username')"
:label="$t('peer.username')"
align="center"
prop="username"
/>
<el-table-column
key="role"
:label="$t('account.role')"
align="center"
prop="role"
/>
<el-table-column
key="quota"
:label="$t('account.quota')"
:label="$t('peer.quota')"
align="center"
prop="quota"
>
@@ -114,7 +108,7 @@
</el-table-column>
<el-table-column
key="download"
:label="$t('account.download')"
:label="$t('peer.download')"
align="center"
prop="download"
>
@@ -124,7 +118,7 @@
</el-table-column>
<el-table-column
key="upload"
:label="$t('account.upload')"
:label="$t('peer.upload')"
align="center"
prop="upload"
>
@@ -134,32 +128,32 @@
</el-table-column>
<el-table-column
key="online"
:label="$t('account.onlineStatus')"
:label="$t('peer.onlineStatus')"
align="center"
prop="online"
>
<template #default="scope">
<el-tag v-if="scope.row.online" type="success"
>{{ $t("account.online") }}
>{{ $t("peer.online") }}
</el-tag>
<el-tag v-else type="info">{{ $t("account.offline") }}</el-tag>
<el-tag v-else type="info">{{ $t("peer.offline") }}</el-tag>
</template>
</el-table-column>
<el-table-column
key="device"
:label="$t('account.device')"
:label="$t('peer.device')"
align="center"
prop="device"
/>
<el-table-column
key="deviceNo"
:label="$t('account.deviceNo')"
:label="$t('peer.deviceNo')"
align="center"
prop="deviceNo"
/>
<el-table-column
key="kickUtilTime"
:label="$t('account.kickUtilTimeLast')"
:label="$t('peer.kickUtilTimeLast')"
align="center"
prop="kickUtilTime"
>
@@ -169,7 +163,7 @@
</el-table-column>
<el-table-column
key="expireTime"
:label="$t('account.expireTime')"
:label="$t('peer.expireTime')"
align="center"
prop="expireTime"
width="160"
@@ -180,7 +174,7 @@
</el-table-column>
<el-table-column
key="loginAt"
:label="$t('account.loginAt')"
:label="$t('peer.loginAt')"
align="center"
prop="loginAt"
width="160"
@@ -193,7 +187,7 @@
</el-table-column>
<el-table-column
key="conAt"
:label="$t('account.conAt')"
:label="$t('peer.conAt')"
align="center"
prop="conAt"
width="160"
@@ -232,9 +226,6 @@
width="300"
>
<template #default="scope">
<el-button type="primary" link @click="handleSubscribe(scope.row)"
>{{ $t("common.subscribe") }}
</el-button>
<el-button type="primary" link @click="handleNodeUrl(scope.row)"
>{{ $t("common.nodeUrl") }}
</el-button>
@@ -242,7 +233,7 @@
{{ $t("common.nodeQrCode") }}
</el-button>
<el-popconfirm
title="Are you sure to reset traffic?"
:title="$t('common.resetTrafficConfirm')"
@confirm="resetTraffic(scope.row)"
>
<template #reference>
@@ -258,16 +249,16 @@
>{{ $t("common.delete") }}
</el-button>
<el-button type="danger" link @click="handleKick(scope.row)"
>{{ $t("account.kick") }}
>{{ $t("peer.kick") }}
</el-button>
<el-popconfirm
:title="$t('account.releaseKickTip')"
:title="$t('peer.releaseKickTip')"
@confirm="confirmReleaseKick(scope.row)"
v-if="calculateTimeDifference(scope.row.kickUtilTime) !== '-'"
>
<template #reference>
<el-button type="danger" link
>{{ $t("account.releaseKick") }}
>{{ $t("peer.releaseKick") }}
</el-button>
</template>
</el-popconfirm>
@@ -301,51 +292,39 @@
:model="dataForm"
label-width="100px"
>
<el-form-item :label="$t('account.remark')" prop="remark">
<el-form-item :label="$t('peer.remark')" prop="remark">
<el-input
v-model="dataForm.remark"
:placeholder="$t('account.remark')"
:placeholder="$t('peer.remark')"
maxlength="50"
clearable
/>
</el-form-item>
<el-form-item :label="$t('account.username')" prop="username">
<el-form-item :label="$t('peer.username')" prop="username">
<el-input
v-model="dataForm.username"
:placeholder="$t('account.username')"
:placeholder="$t('peer.username')"
maxlength="50"
clearable
/>
</el-form-item>
<el-form-item :label="$t('account.pass')" prop="pass">
<el-input
v-model="dataForm.pass"
:placeholder="$t('account.pass')"
maxlength="50"
clearable
type="password"
show-password
ref="dataFormPassRef"
/>
</el-form-item>
<el-form-item :label="$t('account.conPass')" prop="conPass">
<el-form-item :label="$t('peer.conPass')" prop="conPass">
<el-input
v-model="dataForm.conPass"
:placeholder="$t('account.conPass')"
:placeholder="$t('peer.conPass')"
maxlength="50"
clearable
type="password"
show-password
ref="dataFormConPassRef"
/>
</el-form-item>
<el-form-item :label="$t('account.quota')" prop="quota">
<el-form-item :label="$t('peer.quota')" prop="quota">
<unit-select :setValue="setQuota" :valueTmp="quotaTmp" />
</el-form-item>
<el-form-item :label="$t('account.deviceNo')" prop="deviceNo">
<el-form-item :label="$t('peer.deviceNo')" prop="deviceNo">
<el-input-number
v-model="dataForm.deviceNo"
:placeholder="$t('account.deviceNo')"
:placeholder="$t('peer.deviceNo')"
:min="1"
:controls="false"
:precision="0"
@@ -353,11 +332,11 @@
style="width: 220px"
/>
</el-form-item>
<el-form-item :label="$t('account.expireTime')" prop="expireTime">
<el-form-item :label="$t('peer.expireTime')" prop="expireTime">
<el-date-picker
v-model="dataForm.expireTime"
type="datetime"
:placeholder="$t('account.expireTime')"
:placeholder="$t('peer.expireTime')"
value-format="x"
:shortcuts="shortcuts"
clearable
@@ -388,11 +367,11 @@
@close="closeDialogKick"
>
<el-form ref="kickFormRef" :model="kickForm" label-width="100px">
<el-form-item :label="$t('account.kickUtilTime')" prop="kickUtilTime">
<el-form-item :label="$t('peer.kickUtilTime')" prop="kickUtilTime">
<el-date-picker
v-model="kickForm.kickUtilTime"
type="datetime"
:placeholder="$t('account.kickUtilTime')"
:placeholder="$t('peer.kickUtilTime')"
value-format="x"
:shortcuts="shortcutsKick"
clearable
@@ -443,23 +422,23 @@ export default {
<script setup lang="ts">
import {
AccountForm,
AccountPageDto,
AccountUpdateDto,
AccountVo,
KickAccountForm,
} from "@/api/account/types";
KickPeerForm,
PeerForm,
PeerPageDto,
PeerUpdateDto,
PeerVo,
} from "@/api/peer/types";
import {
saveAccountApi,
deleteAccountApi,
getAccountApi,
pageAccountApi,
updateAccountApi,
exportAccountApi,
releaseKickAccountApi,
importAccountApi,
resetTrafficApi,
} from "@/api/account";
deletePeerApi,
exportPeerApi,
getPeerApi,
importPeerApi,
pagePeerApi,
releaseKickPeerApi,
resetPeerTrafficApi,
savePeerApi,
updatePeerApi,
} from "@/api/peer";
import { Search, Plus, Refresh } from "@element-plus/icons-vue";
import {
timestampToDateTime,
@@ -474,7 +453,6 @@ import { formatBytes } from "@/utils/byte";
import {
hysteria2KickApi,
hysteria2SubscribeUrlApi,
hysteria2UrlApi,
} from "@/api/hysteria2";
import {
@@ -484,20 +462,13 @@ import {
} from "element-plus/lib/components";
import { useI18n } from "vue-i18n";
import {
Hysteria2SubscribeUrlDto,
Hysteria2UrlDto,
} from "@/api/hysteria2/types";
import { Hysteria2UrlDto } from "@/api/hysteria2/types";
import copy from "copy-to-clipboard";
import { useRoute } from "vue-router";
const { t } = useI18n();
const route = useRoute();
const queryFormRef = ref(ElForm); // Форма поиска
const dataFormRef = ref(ElForm); // Форма пользователя
const kickFormRef = ref(ElForm); // Форма отключения пользователя
const dataFormPassRef = ref(ElInput);
const dataFormConPassRef = ref(ElInput);
const dataFormAddRules = {
remark: [
@@ -511,7 +482,7 @@ const dataFormAddRules = {
username: [
{
required: true,
message: "Required",
message: t("common.required"),
trigger: ["change", "blur"],
},
{
@@ -520,22 +491,10 @@ const dataFormAddRules = {
trigger: ["change", "blur"],
},
],
pass: [
{
required: true,
message: "Required",
trigger: ["change", "blur"],
},
{
pattern: /^[a-zA-Z0-9!@#$%^&*()_+-=]{6,32}$/,
message: "Pass format is incorrect",
trigger: ["change", "blur"],
},
],
conPass: [
{
required: true,
message: "Required",
message: t("common.required"),
trigger: ["change", "blur"],
},
{
@@ -547,21 +506,21 @@ const dataFormAddRules = {
expireTime: [
{
required: true,
message: "Required",
message: t("common.required"),
trigger: ["change", "blur"],
},
],
deviceNo: [
{
required: true,
message: "Required",
message: t("common.required"),
trigger: ["change", "blur"],
},
],
deleted: [
{
required: true,
message: "Required",
message: t("common.required"),
trigger: ["change", "blur"],
},
],
@@ -583,13 +542,6 @@ const dataFormUpdateRules = {
trigger: ["change", "blur"],
},
],
pass: [
{
pattern: /^[a-zA-Z0-9!@#$%^&*()_+-=]{6,32}$/,
message: "Pass format is incorrect",
trigger: ["change", "blur"],
},
],
conPass: [
{
pattern: /^[a-zA-Z0-9!@#$%^&*()_+-=]{6,32}$/,
@@ -601,34 +553,34 @@ const dataFormUpdateRules = {
const shortcuts = [
{
text: "A week later",
text: t("common.weekLater"),
value: getWeekLater,
},
{
text: "A month later",
text: t("common.monthLater"),
value: getMonthLater,
},
{
text: "A year later",
text: t("common.yearLater"),
value: getYearLater,
},
];
const shortcutsKick = [
{
text: "A hour later",
text: t("common.hourLater"),
value: getHourLater,
},
{
text: "A day later",
text: t("common.dayLater"),
value: getDayLater,
},
{
text: "A week later",
text: t("common.weekLater"),
value: getWeekLater,
},
{
text: "A month later",
text: t("common.monthLater"),
value: getMonthLater,
},
];
@@ -636,7 +588,7 @@ const shortcutsKick = [
const state = reactive({
loading: true,
total: 0,
records: [] as AccountVo[],
records: [] as PeerVo[],
dialog: {
visible: false,
} as DialogType,
@@ -648,17 +600,17 @@ const state = reactive({
expireTime: getMonthLater(),
deviceNo: 6,
deleted: 0,
} as AccountForm,
} as PeerForm,
kickForm: {
kickUtilTime: getHourLater(),
} as KickAccountForm,
} as KickPeerForm,
queryParams: {
remark: undefined,
username: undefined,
deleted: undefined,
pageNum: 1,
pageSize: 10,
} as AccountPageDto,
} as PeerPageDto,
quotaTmp: 0,
fileList: [] as UploadFile[],
qrCodeDialog: {
@@ -699,7 +651,7 @@ const resetDataForm = () => {
const handleQuery = async () => {
state.loading = true;
try {
const { data } = await pageAccountApi(state.queryParams);
const { data } = await pagePeerApi(state.queryParams);
state.records = data.records;
state.total = data.total;
} finally {
@@ -730,7 +682,7 @@ const handleAdd = () => {
**/
const handleUpdate = async (row: { [key: string]: any }) => {
const id = row.id;
const { data } = await getAccountApi({ id: id });
const { data } = await getPeerApi({ id: id });
Object.assign(state.dataForm, data);
quotaTmp.value = data.quota;
dialog.value = {
@@ -750,15 +702,15 @@ const submitForm = () => {
dataFormRef.value.validate((valid: any) => {
if (valid) {
const accountId = state.dataForm.id;
let accountUpdateDto: AccountUpdateDto = { ...state.dataForm };
let accountUpdateDto: PeerUpdateDto = { ...state.dataForm };
if (accountId) {
updateAccountApi(accountUpdateDto).then(() => {
updatePeerApi(accountUpdateDto).then(() => {
ElMessage.success(t("common.success"));
closeDialog();
handleQuery();
});
} else {
saveAccountApi(accountUpdateDto).then(() => {
savePeerApi(accountUpdateDto).then(() => {
ElMessage.success(t("common.success"));
closeDialog();
handleQuery();
@@ -791,10 +743,8 @@ const handleDelete = (row: { [key: string]: any }) => {
const id = row.id;
const username = row.username;
ElMessageBox.confirm(
"Are you sure to delete the data item with the username「" +
username +
"」?",
"Warning",
t("common.deleteConfirm", { username }),
t("common.warning"),
{
confirmButtonText: t("common.confirm"),
cancelButtonText: t("common.cancel"),
@@ -802,7 +752,7 @@ const handleDelete = (row: { [key: string]: any }) => {
}
)
.then(() => {
deleteAccountApi({ id: id }).then(() => {
deletePeerApi({ id: id }).then(() => {
ElMessage.success(t("common.success"));
handleQuery();
});
@@ -817,7 +767,7 @@ const handleDelete = (row: { [key: string]: any }) => {
const handleKick = (row: { [key: string]: any }) => {
state.kickForm.ids = [row.id];
dialogKick.value = {
title: t("account.kickTip"),
title: t("peer.kickTip"),
visible: true,
};
};
@@ -827,8 +777,8 @@ const handleKick = (row: { [key: string]: any }) => {
* @param row
*/
const confirmReleaseKick = (row: { [key: string]: any }) => {
releaseKickAccountApi({ id: row.id }).then(() => {
ElMessage.success(t("account.releaseSuccess"));
releaseKickPeerApi({ id: row.id }).then(() => {
ElMessage.success(t("peer.releaseSuccess"));
handleQuery();
});
};
@@ -862,20 +812,21 @@ const handleImport = (params: UploadRequestOptions) => {
if (state.fileList.length > 0) {
let formData = new FormData();
formData.append("file", params.file);
importAccountApi(formData).then(() => {
importPeerApi(formData).then(() => {
ElMessage.success(t("common.success"));
});
state.fileList = [];
}
return Promise.resolve();
};
const beforeImport = (file: UploadRawFile) => {
if (!file.name.endsWith(".json")) {
ElMessage.error("file format not supported");
ElMessage.error(t("common.fileFormatUnsupported"));
return false;
}
if (file.size / 1024 / 1024 > 2) {
ElMessage.error("the file is too big, less than 2 MB");
ElMessage.error(t("common.fileTooLarge"));
return false;
}
};
@@ -884,7 +835,7 @@ const beforeImport = (file: UploadRawFile) => {
* Экспорт
*/
const handleExport = () => {
exportAccountApi().then((res) => {
exportPeerApi().then((res) => {
const blob = new Blob([res.data], {
type: "application/octet-stream",
});
@@ -901,20 +852,6 @@ const handleExport = () => {
});
};
const handleSubscribe = async (row: { [key: string]: any }) => {
try {
const dto: Hysteria2SubscribeUrlDto = {
accountId: row.id,
protocol: window.location.protocol,
};
const { data } = await hysteria2SubscribeUrlApi(dto);
copy(data.url);
ElMessage.success(t("common.copySuccess"));
} catch (e) {
/* empty */
}
};
const handleNodeUrl = async (row: { [key: string]: any }) => {
try {
const dto: Hysteria2UrlDto = {
@@ -943,7 +880,7 @@ const handleQrCode = async (row: { [key: string]: any }) => {
const resetTraffic = async (row: { [key: string]: any }) => {
try {
await resetTrafficApi({ id: row.id });
await resetPeerTrafficApi({ id: row.id });
ElMessage.success(t("common.success"));
await handleQuery();
} catch (e) {
@@ -954,21 +891,6 @@ const resetTraffic = async (row: { [key: string]: any }) => {
onMounted(() => {
// Инициализация списка пользователей
handleQuery();
if (route.query.focus === "change-pass") {
nextTick(() => {
handleUpdate({ id: 1 }).then(() => {
setTimeout(() => {
const inputPass = dataFormPassRef.value.$el.querySelector(
".el-input__wrapper input"
);
if (inputPass) {
inputPass.focus();
}
}, 50);
});
});
}
});
</script>
+1 -1
View File
@@ -16,7 +16,7 @@ func AdminHandler() gin.HandlerFunc {
c.Abort()
return
}
if !util.ArrContain(myClaims.AccountBo.Roles, "admin") {
if !util.ArrContain(myClaims.Admin.Roles, "admin") {
vo.Fail(constant.ForbiddenError, c)
c.Abort()
return
+10
View File
@@ -18,6 +18,16 @@ func InitCron() error {
logrus.Errorf("cron add func CronHandleAccount err: %v", err)
return errors.New("cron add func CronHandleAccount err")
}
_, err = c.AddFunc("@every 10s", service.CollectMetricsSnapshot)
if err != nil {
logrus.Errorf("cron add func CollectMetricsSnapshot err: %v", err)
return errors.New("cron add func CollectMetricsSnapshot err")
}
_, err = c.AddFunc("@every 10m", service.CleanupMetricsRetention)
if err != nil {
logrus.Errorf("cron add func CleanupMetricsRetention err: %v", err)
return errors.New("cron add func CleanupMetricsRetention err")
}
resetTrafficCron, err := dao.GetConfig("key = ?", constant.ResetTrafficCron)
if err != nil {
return err
+1 -1
View File
@@ -28,7 +28,7 @@ func JWTHandler() gin.HandlerFunc {
c.Abort()
return
}
if myClaims.AccountBo.Deleted != 0 {
if myClaims.Admin.Deleted != 0 {
vo.Fail("this account has been disabled", c)
c.Abort()
return
-1
View File
@@ -12,5 +12,4 @@ const (
Hysteria2TrafficTime = "HYSTERIA2_TRAFFIC_TIME"
Hysteria2ConfigRemark = "HYSTERIA2_CONFIG_REMARK"
ResetTrafficCron = "RESET_TRAFFIC_CRON"
ClashExtension = "CLASH_EXTENSION"
)
+1 -1
View File
@@ -1,6 +1,6 @@
package dto
type AccountPageDto struct {
type PeerPageDto struct {
BaseDto
Username *string `json:"username" form:"username" validate:"omitempty,min=1,max=32"`
Deleted *int64 `json:"deleted" form:"deleted" validate:"omitempty,oneof=0 1"`
+7
View File
@@ -0,0 +1,7 @@
package dto
type AdminChangePasswordDto struct {
OldPassword *string `json:"oldPassword" form:"oldPassword" validate:"required,min=6,max=64"`
NewPassword *string `json:"newPassword" form:"newPassword" validate:"required,min=6,max=64"`
}
-5
View File
@@ -15,11 +15,6 @@ type Hysteria2VersionDto struct {
Version *string `json:"version" form:"version" validate:"required,min=1,max=10"`
}
type Hysteria2SubscribeUrlDto struct {
AccountId *int64 `json:"accountId" form:"accountId" validate:"required,gt=0"`
Protocol *string `json:"protocol" form:"protocol" validate:"required,min=1,max=8"`
}
type Hysteria2UrlDto struct {
AccountId *int64 `json:"accountId" form:"accountId" validate:"required,gt=0"`
}
+17
View File
@@ -0,0 +1,17 @@
package entity
type AdminUser struct {
Username *string `gorm:"column:username;default:''" json:"username"`
PasswordHash *string `gorm:"column:password_hash;default:''" json:"passwordHash"`
Status *int64 `gorm:"column:status;default:1" json:"status"`
ForcePasswordChange *int64 `gorm:"column:force_password_change;default:0" json:"forcePasswordChange"`
LastLoginAt *int64 `gorm:"column:last_login_at;default:0" json:"lastLoginAt"`
PasswordChangedAt *int64 `gorm:"column:password_changed_at;default:0" json:"passwordChangedAt"`
TokenVersion *int64 `gorm:"column:token_version;default:1" json:"tokenVersion"`
BaseEntity `gorm:"embedded"`
}
func (AdminUser) TableName() string {
return "admin_user"
}
+23
View File
@@ -0,0 +1,23 @@
package entity
type MetricSample struct {
BaseEntity
SampledAt *int64 `gorm:"column:sampled_at"`
CpuPercent *float64 `gorm:"column:cpu_percent"`
Load1 *float64 `gorm:"column:load1"`
MemUsedBytes *int64 `gorm:"column:mem_used_bytes"`
MemTotalBytes *int64 `gorm:"column:mem_total_bytes"`
MemPercent *float64 `gorm:"column:mem_percent"`
DiskPath *string `gorm:"column:disk_path"`
DiskUsedBytes *int64 `gorm:"column:disk_used_bytes"`
DiskTotalBytes *int64 `gorm:"column:disk_total_bytes"`
DiskPercent *float64 `gorm:"column:disk_percent"`
HysteriaRunning *int64 `gorm:"column:hysteria_running"`
OnlinePeers *int64 `gorm:"column:online_peers"`
OnlineDevices *int64 `gorm:"column:online_devices"`
}
func (MetricSample) TableName() string {
return "metric_sample"
}
+23
View File
@@ -0,0 +1,23 @@
package entity
type Peer struct {
Name *string `gorm:"column:name;default:''" json:"name"`
Remark *string `gorm:"column:remark;default:''" json:"remark"`
AuthId *string `gorm:"column:auth_id;default:''" json:"authId"`
SecretDigest *string `gorm:"column:secret_digest;default:''" json:"secretDigest"`
SecretCiphertext *string `gorm:"column:secret_ciphertext;default:''" json:"secretCiphertext"`
QuotaBytes *int64 `gorm:"column:quota_bytes;default:0" json:"quotaBytes"`
DownloadBytes *int64 `gorm:"column:download_bytes;default:0" json:"downloadBytes"`
UploadBytes *int64 `gorm:"column:upload_bytes;default:0" json:"uploadBytes"`
ExpiresAt *int64 `gorm:"column:expires_at;default:0" json:"expiresAt"`
MaxDevices *int64 `gorm:"column:max_devices;default:3" json:"maxDevices"`
Disabled *int64 `gorm:"column:disabled;default:0" json:"disabled"`
BannedUntil *int64 `gorm:"column:banned_until;default:0" json:"bannedUntil"`
LastConnectionAt *int64 `gorm:"column:last_connection_at;default:0" json:"lastConnectionAt"`
BaseEntity `gorm:"embedded"`
}
func (Peer) TableName() string {
return "peer"
}
@@ -0,0 +1,14 @@
package entity
type TrafficAggregateDaily struct {
PeerId *int64 `gorm:"column:peer_id;default:0;primaryKey" json:"peerId"`
DayStart *int64 `gorm:"column:day_start;default:0;primaryKey" json:"dayStart"`
RxBytes *int64 `gorm:"column:rx_bytes;default:0" json:"rxBytes"`
TxBytes *int64 `gorm:"column:tx_bytes;default:0" json:"txBytes"`
BaseEntity `gorm:"embedded"`
}
func (TrafficAggregateDaily) TableName() string {
return "traffic_aggregate_daily"
}
@@ -0,0 +1,14 @@
package entity
type TrafficAggregateHourly struct {
PeerId *int64 `gorm:"column:peer_id;default:0;primaryKey" json:"peerId"`
HourStart *int64 `gorm:"column:hour_start;default:0;primaryKey" json:"hourStart"`
RxBytes *int64 `gorm:"column:rx_bytes;default:0" json:"rxBytes"`
TxBytes *int64 `gorm:"column:tx_bytes;default:0" json:"txBytes"`
BaseEntity `gorm:"embedded"`
}
func (TrafficAggregateHourly) TableName() string {
return "traffic_aggregate_hourly"
}
+15
View File
@@ -0,0 +1,15 @@
package entity
type TrafficSample struct {
PeerId *int64 `gorm:"column:peer_id;default:0" json:"peerId"`
AuthId *string `gorm:"column:auth_id;default:''" json:"authId"`
RxBytes *int64 `gorm:"column:rx_bytes;default:0" json:"rxBytes"`
TxBytes *int64 `gorm:"column:tx_bytes;default:0" json:"txBytes"`
SampledAt *int64 `gorm:"column:sampled_at;default:0" json:"sampledAt"`
BaseEntity `gorm:"embedded"`
}
func (TrafficSample) TableName() string {
return "traffic_sample"
}
+90
View File
@@ -0,0 +1,90 @@
package vo
type DashboardSummaryVo struct {
CollectedAt int64 `json:"collectedAt"`
System DashboardSystemVo `json:"system"`
Hysteria DashboardHysteriaVo `json:"hysteria"`
Peers DashboardPeerVo `json:"peers"`
Traffic DashboardTrafficVo `json:"traffic"`
Health DashboardHealthVo `json:"health"`
SecurityRisks []SecurityRiskVo `json:"securityRisks"`
}
type DashboardHealthVo struct {
Collector DataHealthVo `json:"collector"`
Hysteria DataHealthVo `json:"hysteria"`
}
type DataHealthVo struct {
Status string `json:"status"`
MessageKey string `json:"messageKey,omitempty"`
LastSuccessAt int64 `json:"lastSuccessAt,omitempty"`
}
type DashboardSystemVo struct {
CpuPercent float64 `json:"cpuPercent"`
MemUsedBytes uint64 `json:"memUsedBytes"`
MemTotalBytes uint64 `json:"memTotalBytes"`
MemPercent float64 `json:"memPercent"`
DiskUsedBytes uint64 `json:"diskUsedBytes"`
DiskTotalBytes uint64 `json:"diskTotalBytes"`
DiskPercent float64 `json:"diskPercent"`
}
type DashboardHysteriaVo struct {
Version string `json:"version"`
Running bool `json:"running"`
ApiReachable bool `json:"apiReachable"`
LastApiError string `json:"lastApiError,omitempty"`
}
type DashboardPeerVo struct {
Total int64 `json:"total"`
Enabled int64 `json:"enabled"`
Disabled int64 `json:"disabled"`
Expired int64 `json:"expired"`
OnlinePeers int64 `json:"onlinePeers"`
OnlineDevices int64 `json:"onlineDevices"`
}
type DashboardTrafficVo struct {
DownloadBytes int64 `json:"downloadBytes"`
UploadBytes int64 `json:"uploadBytes"`
TotalBytes int64 `json:"totalBytes"`
TodayDownloadBytes int64 `json:"todayDownloadBytes"`
TodayUploadBytes int64 `json:"todayUploadBytes"`
SinceResetDownloadBytes int64 `json:"sinceResetDownloadBytes"`
SinceResetUploadBytes int64 `json:"sinceResetUploadBytes"`
}
type SecurityRiskVo struct {
Key string `json:"key"`
Severity string `json:"severity"`
ActionRoute string `json:"actionRoute,omitempty"`
Dismissible bool `json:"dismissible"`
}
type DashboardTopPeerVo struct {
PeerId int64 `json:"peerId"`
Name string `json:"name"`
Remark string `json:"remark"`
Download int64 `json:"download"`
Upload int64 `json:"upload"`
Total int64 `json:"total"`
}
type DashboardSeriesPointVo struct {
Ts int64 `json:"ts"`
Download int64 `json:"download,omitempty"`
Upload int64 `json:"upload,omitempty"`
Cpu float64 `json:"cpu,omitempty"`
Mem float64 `json:"mem,omitempty"`
}
type DashboardTimeseriesVo struct {
Range string `json:"range"`
Traffic []DashboardSeriesPointVo `json:"traffic"`
System []DashboardSeriesPointVo `json:"system"`
CollectedAt int64 `json:"collectedAt"`
}
-5
View File
@@ -31,11 +31,6 @@ func Hysteria2AuthBadRequest(c *gin.Context) {
})
}
type Hysteria2SubscribeVo struct {
Url string `json:"url"`
QrCode []byte `json:"qrCode"`
}
type Hysteria2UrlVo struct {
Url string `json:"url"`
QrCode []byte `json:"qrCode"`
-15
View File
@@ -1,15 +0,0 @@
package vo
type SystemMonitorVo struct {
HUIVersion string `json:"huiVersion"`
CpuPercent float64 `json:"cpuPercent"`
MemPercent float64 `json:"memPercent"`
DiskPercent float64 `json:"diskPercent"`
}
type Hysteria2MonitorVo struct {
UserTotal int64 `json:"userTotal"` // Количество пользователей онлайн
DeviceTotal int64 `json:"deviceTotal"` // Количество устройств онлайн
Version string `json:"version"` // Версия
Running bool `json:"running"` // Статус выполнения
}
-23
View File
@@ -1,23 +0,0 @@
package router
import (
"github.com/gin-gonic/gin"
"hy2xs-admin/controller"
)
func initAccountAdminRouter(accountApi *gin.RouterGroup) {
account := accountApi.Group("/account")
{
account.GET("/pageAccount", controller.PageAccount)
account.POST("/saveAccount", controller.SaveAccount)
account.POST("/deleteAccount", controller.DeleteAccount)
account.POST("/updateAccount", controller.UpdateAccount)
account.POST("/resetTraffic", controller.ResetTraffic)
account.GET("/getAccountInfo", controller.GetAccountInfo)
account.GET("/getAccount", controller.GetAccount)
account.POST("/importAccount", controller.ImportAccount)
account.POST("/exportAccount", controller.ExportAccount)
account.POST("/releaseKickAccount", controller.ReleaseKickAccount)
account.GET("/verifyDefaultPass", controller.VerifyDefaultPass)
}
}
+16
View File
@@ -0,0 +1,16 @@
package router
import (
"github.com/gin-gonic/gin"
"hy2xs-admin/controller"
)
func initAdminRouter(adminApi *gin.RouterGroup) {
admin := adminApi.Group("/admin")
{
admin.GET("/me", controller.AdminMe)
admin.POST("/change-password", controller.AdminChangePassword)
admin.GET("/verify-default-pass", controller.VerifyDefaultPass)
}
}
+17
View File
@@ -0,0 +1,17 @@
package router
import (
"github.com/gin-gonic/gin"
"hy2xs-admin/controller"
)
func initDashboardRouter(api *gin.RouterGroup) {
dashboard := api.Group("/dashboard")
{
dashboard.GET("/summary", controller.DashboardSummary)
dashboard.GET("/timeseries", controller.DashboardTimeseries)
dashboard.GET("/top-peers", controller.DashboardTopPeers)
dashboard.GET("/security", controller.DashboardSecurity)
}
}
-1
View File
@@ -18,7 +18,6 @@ func initHysteria2Router(hysteria2Api *gin.RouterGroup) {
hysteria2.POST("/hysteria2Kick", controller.Hysteria2Kick)
hysteria2.POST("/hysteria2ChangeVersion", controller.Hysteria2ChangeVersion)
hysteria2.GET("/listRelease", controller.ListRelease)
hysteria2.GET("/hysteria2SubscribeUrl", controller.Hysteria2SubscribeUrl)
hysteria2.GET("/hysteria2Url", controller.Hysteria2Url)
}
}
-14
View File
@@ -1,14 +0,0 @@
package router
import (
"github.com/gin-gonic/gin"
"hy2xs-admin/controller"
)
func initMonitorRouter(accountApi *gin.RouterGroup) {
account := accountApi.Group("/monitor")
{
account.GET("/monitorSystem", controller.MonitorSystem)
account.GET("/monitorHysteria2", controller.MonitorHysteria2)
}
}
+27
View File
@@ -0,0 +1,27 @@
package router
import (
"github.com/gin-gonic/gin"
"hy2xs-admin/controller"
)
func initPeerRouter(peerApi *gin.RouterGroup) {
peers := peerApi.Group("/peers")
{
peers.GET("", controller.PagePeer)
peers.POST("", controller.SavePeer)
peers.GET("/:id", controller.GetPeer)
peers.PATCH("/:id", controller.UpdatePeer)
peers.DELETE("/:id", controller.DeletePeer)
peers.POST("/:id/reset-traffic", controller.ResetTraffic)
peers.POST("/:id/release-kick", controller.ReleaseKickPeer)
peers.POST("/:id/kick", controller.Hysteria2Kick)
peers.POST("/import", controller.ImportPeer)
peers.POST("/export", controller.ExportPeer)
// совместимость с текущим transport-форматом (query accountId)
peers.GET("/:id/client-url", controller.Hysteria2Url)
peers.GET("/:id/qr", controller.Hysteria2Url)
}
}
+3 -2
View File
@@ -61,9 +61,10 @@ func Router(router *gin.Engine, huiWebContext *string) {
middleware.JWTHandler(),
middleware.AdminHandler(),
)
initAccountAdminRouter(huiAdminApi)
initAdminRouter(huiAdminApi)
initDashboardRouter(huiAdminApi)
initPeerRouter(huiAdminApi)
initConfigRouter(huiAdminApi)
initHysteria2Router(huiAdminApi)
initLogRouter(huiAdminApi)
initMonitorRouter(huiAdminApi)
}
-181
View File
@@ -1,181 +0,0 @@
package service
import (
"errors"
"fmt"
"github.com/gin-gonic/gin"
"hy2xs-admin/dao"
"hy2xs-admin/model/bo"
"hy2xs-admin/model/constant"
"hy2xs-admin/model/dto"
"hy2xs-admin/model/entity"
"hy2xs-admin/model/vo"
"hy2xs-admin/util"
)
func Login(username string, plainPassword string) (string, bool, error) {
account, err := dao.GetAccount("username = ? and role = 'admin' and deleted = 0", username)
if err != nil {
return "", false, err
}
verified, legacy := util.VerifyPassword(plainPassword, *account.Pass)
if !verified {
return "", false, errors.New(constant.WrongPassword)
}
if legacy {
hash, hashErr := util.HashPassword(plainPassword)
if hashErr == nil {
_ = dao.UpdateAccount([]int64{*account.Id}, map[string]interface{}{"pass": hash})
}
}
accountBo := bo.AccountBo{
Id: *account.Id,
Username: *account.Username,
Roles: []string{*account.Role},
Deleted: *account.Deleted,
}
token, tokenErr := GenToken(accountBo)
if tokenErr != nil {
return "", false, tokenErr
}
requirePasswordChange := legacy
if account.ForcePasswordChange != nil {
requirePasswordChange = *account.ForcePasswordChange != 0
}
return token, requirePasswordChange, nil
}
func PageAccount(accountPageDto dto.AccountPageDto) ([]entity.Account, int64, error) {
return dao.PageAccount(accountPageDto)
}
func SaveAccount(account entity.Account) error {
_, err := dao.SaveAccount(account)
return err
}
func DeleteAccount(ids []int64) error {
return dao.DeleteAccount(ids)
}
func UpdateAccount(account entity.Account) error {
updates := map[string]interface{}{}
if account.Username != nil && *account.Username != "" {
updates["username"] = *account.Username
}
if account.Pass != nil && *account.Pass != "" {
updates["pass"] = *account.Pass
}
if account.ConPass != nil && *account.ConPass != "" {
updates["con_pass"] = fmt.Sprintf("%s.%s", *account.Username, *account.ConPass)
}
if account.Quota != nil {
updates["quota"] = *account.Quota
}
if account.ExpireTime != nil {
updates["expire_time"] = *account.ExpireTime
}
if account.Download != nil {
updates["download"] = *account.Download
}
if account.Upload != nil {
updates["upload"] = *account.Upload
}
if account.DeviceNo != nil {
updates["device_no"] = *account.DeviceNo
}
if account.Deleted != nil {
updates["deleted"] = *account.Deleted
}
if account.LoginAt != nil && *account.LoginAt > 0 {
updates["login_at"] = *account.LoginAt
}
if account.ConAt != nil && *account.ConAt > 0 {
updates["con_at"] = *account.ConAt
}
if account.Remark != nil {
updates["remark"] = *account.Remark
}
return dao.UpdateAccount([]int64{*account.Id}, updates)
}
func ResetTraffic(id int64) error {
return dao.UpdateAccount([]int64{id}, map[string]interface{}{"download": 0, "upload": 0})
}
func ExistAccountUsername(username string, id int64) bool {
var err error
if id != 0 {
_, err = dao.GetAccount("username = ? and id != ?", username, id)
} else {
_, err = dao.GetAccount("username = ?", username)
}
if err != nil {
if err.Error() == constant.WrongPassword {
return false
}
}
return true
}
func GetAccount(id int64) (entity.Account, error) {
return dao.GetAccount("id = ?", id)
}
func ListExportAccount() ([]bo.AccountExport, error) {
accounts, err := dao.ListAccount(nil, nil)
if err != nil {
return nil, errors.New(constant.SysError)
}
var accountExports []bo.AccountExport
for _, item := range accounts {
accountExport := bo.AccountExport{
Id: *item.Id,
Username: *item.Username,
Pass: *item.Pass,
ConPass: *item.ConPass,
Quota: *item.Quota,
Download: *item.Download,
Upload: *item.Upload,
ExpireTime: *item.ExpireTime,
DeviceNo: *item.DeviceNo,
KickUtilTime: *item.KickUtilTime,
Role: *item.Role,
Deleted: *item.Deleted,
CreateTime: *item.CreateTime,
UpdateTime: *item.UpdateTime,
LoginAt: *item.LoginAt,
ConAt: *item.ConAt,
Remark: *item.Remark,
}
accountExports = append(accountExports, accountExport)
}
return accountExports, nil
}
func ReleaseKickAccount(id int64) error {
return dao.UpdateAccount([]int64{id}, map[string]interface{}{"kick_util_time": 0})
}
func UpsertAccount(accounts []entity.Account) error {
return dao.UpsertAccount(accounts)
}
func GetAccountInfo(c *gin.Context) (vo.AccountInfoVo, error) {
myClaims, err := ParseToken(GetToken(c))
if err != nil {
return vo.AccountInfoVo{}, err
}
if myClaims.AccountBo.Deleted != 0 {
return vo.AccountInfoVo{}, errors.New("this account has been disabled")
}
return vo.AccountInfoVo{
Id: myClaims.AccountBo.Id,
Username: myClaims.AccountBo.Username,
Roles: myClaims.AccountBo.Roles,
}, nil
}
+61 -34
View File
@@ -2,12 +2,12 @@ package service
import (
"github.com/sirupsen/logrus"
"gorm.io/gorm"
"hy2xs-admin/dao"
"hy2xs-admin/model/bo"
"hy2xs-admin/model/constant"
"hy2xs-admin/model/entity"
"hy2xs-admin/proxy"
"hy2xs-admin/util"
"strconv"
"sync"
"time"
)
@@ -40,17 +40,17 @@ func CronHandleAccount() {
}
func CronResetTraffic() {
accounts, err := dao.ListAccount(nil, nil)
peers, err := dao.ListPeer("1=1")
if err != nil {
return
}
var ids []int64
for _, item := range accounts {
for _, item := range peers {
ids = append(ids, *item.Id)
}
idsList := util.SplitArr(ids, 100)
for _, item := range idsList {
if err := dao.UpdateAccount(item, map[string]interface{}{"download": 0, "upload": 0}); err != nil {
if err := dao.UpdatePeer(item, map[string]interface{}{"download_bytes": 0, "upload_bytes": 0}); err != nil {
continue
}
}
@@ -62,38 +62,65 @@ func saveAccountTraffic(apiPort int64, trafficStatsSecret string) {
}
defer trafficMutex.Unlock()
hysteria2TrafficTime, err := dao.GetConfig("key = ?", constant.Hysteria2TrafficTime)
if err != nil {
return
}
hysteria2TrafficTimeFloat, err := strconv.ParseFloat(*hysteria2TrafficTime.Value, 64)
if err != nil {
logrus.Errorf("hysteria2TrafficTime string conv int64 err: %v", err)
return
}
users, err := proxy.NewHysteria2Api(apiPort).ListUsers(true, trafficStatsSecret)
if err != nil {
return
}
if len(users) > 0 {
userLists := util.SplitMap(users, 10)
var wg sync.WaitGroup
for _, userList := range userLists {
wg.Add(1)
go func(userList map[string]bo.Hysteria2UserTraffic) {
defer wg.Done()
for username, traffic := range userList {
if err = dao.UpdateAccountTraffic(username, int64(float64(traffic.Rx)*hysteria2TrafficTimeFloat), int64(float64(traffic.Tx)*hysteria2TrafficTimeFloat)); err != nil {
continue
}
}
}(userList)
if len(users) == 0 {
return
}
nowMs := time.Now().UnixMilli()
hourStart := nowMs - (nowMs % int64(time.Hour/time.Millisecond))
dayStart := nowMs - (nowMs % int64(24*time.Hour/time.Millisecond))
for key, traffic := range users {
rxBytes := traffic.Rx
txBytes := traffic.Tx
if rxBytes == 0 && txBytes == 0 {
continue
}
wg.Wait()
peer, peerErr := dao.GetPeer("auth_id = ?", key)
if peerErr != nil {
peer, peerErr = dao.GetPeer("name = ?", key)
if peerErr != nil {
continue
}
}
if peer.Id == nil {
continue
}
authId := key
if peer.AuthId != nil && *peer.AuthId != "" {
authId = *peer.AuthId
}
sample := entity.TrafficSample{
PeerId: peer.Id,
AuthId: &authId,
RxBytes: &rxBytes,
TxBytes: &txBytes,
SampledAt: &nowMs,
}
if err = dao.SaveTrafficSample(sample); err != nil {
logrus.Errorf("save traffic_sample failed: %v", err)
continue
}
if err = dao.UpdatePeer([]int64{*peer.Id}, map[string]interface{}{
"download_bytes": gorm.Expr("download_bytes + ?", rxBytes),
"upload_bytes": gorm.Expr("upload_bytes + ?", txBytes),
}); err != nil {
logrus.Errorf("update peer traffic failed: %v", err)
continue
}
_ = dao.UpsertTrafficAggregateHourly(*peer.Id, hourStart, rxBytes, txBytes)
_ = dao.UpsertTrafficAggregateDaily(*peer.Id, dayStart, rxBytes, txBytes)
}
}
func kickAccount(apiPort int64, trafficStatsSecret string) {
if !kickMutex.TryLock() {
return
@@ -118,14 +145,14 @@ func kickAccount(apiPort int64, trafficStatsSecret string) {
go func(usernameList []string) {
defer wg.Done()
now := time.Now().UnixMilli()
accounts, err := dao.ListAccount("username in ? and (deleted = 1 or (quota > 0 and quota < download + upload)) or ? > expire_time or ? < kick_util_time", usernameList, now, now)
peers, err := dao.ListPeer("name in ? and (disabled = 1 or (quota_bytes > 0 and quota_bytes < download_bytes + upload_bytes) or ? > expires_at or ? < banned_until)", usernameList, now, now)
if err != nil {
return
}
kickUsernames := make([]string, len(accounts))
kickUsernames := make([]string, len(peers))
j := 0
for _, item := range accounts {
kickUsernames[j] = *item.Username
for _, item := range peers {
kickUsernames[j] = *item.Name
j++
}
if err = proxy.NewHysteria2Api(apiPort).KickUsers(kickUsernames, trafficStatsSecret); err != nil {
+105
View File
@@ -0,0 +1,105 @@
package service
import (
"hy2xs-admin/dao"
"hy2xs-admin/model/vo"
"strings"
"time"
)
func DashboardSummary() (vo.DashboardSummaryVo, error) {
nowMs := time.Now().UnixMilli()
summary := vo.DashboardSummaryVo{CollectedAt: nowMs}
summary.Health.Collector = vo.DataHealthVo{Status: "stale", MessageKey: "dashboard.error.collectorStale"}
summary.Health.Hysteria = vo.DataHealthVo{Status: "ok"}
snapshot := DashboardSnapshot()
if snapshot.CollectedAt > 0 {
summary.CollectedAt = snapshot.CollectedAt
summary.System = snapshot.System
summary.Hysteria = snapshot.Hysteria
summary.Peers.OnlinePeers = snapshot.OnlinePeers
summary.Peers.OnlineDevices = snapshot.OnlineDevices
summary.Health.Collector = snapshot.CollectorState
summary.Health.Hysteria = snapshot.HysteriaState
if nowMs-snapshot.CollectedAt > int64(90*time.Second/time.Millisecond) {
summary.Health.Collector = vo.DataHealthVo{Status: "stale", MessageKey: "dashboard.error.collectorStale", LastSuccessAt: snapshot.CollectedAt}
}
}
if peerSummary, err := dao.DashboardPeerSummary(nowMs); err == nil {
summary.Peers.Total = peerSummary.Total
summary.Peers.Enabled = peerSummary.Enabled
summary.Peers.Disabled = peerSummary.Disabled
summary.Peers.Expired = peerSummary.Expired
}
if trafficSummary, err := dao.DashboardTrafficSummary(); err == nil {
summary.Traffic = trafficSummary
}
summary.SecurityRisks = DashboardSecurityRisks(summary)
return summary, nil
}
func DashboardTimeseries(rangeKey string) (vo.DashboardTimeseriesVo, error) {
nowMs := time.Now().UnixMilli()
fromMs := nowMs - int64(24*time.Hour/time.Millisecond)
r := strings.TrimSpace(rangeKey)
if r == "1h" {
fromMs = nowMs - int64(time.Hour/time.Millisecond)
} else if r == "7d" {
fromMs = nowMs - int64(7*24*time.Hour/time.Millisecond)
} else if r == "30d" {
fromMs = nowMs - int64(30*24*time.Hour/time.Millisecond)
r = "30d"
} else if r == "" {
r = "24h"
}
traffic, err := dao.DashboardTrafficTimeseries(fromMs, nowMs)
if err != nil {
return vo.DashboardTimeseriesVo{}, err
}
systemRows, err := dao.DashboardSystemTimeseries(fromMs, nowMs)
if err != nil {
return vo.DashboardTimeseriesVo{}, err
}
return vo.DashboardTimeseriesVo{
Range: r,
Traffic: traffic,
System: systemRows,
CollectedAt: nowMs,
}, nil
}
func DashboardTopPeers(rangeKey string, limit int) ([]vo.DashboardTopPeerVo, error) {
nowMs := time.Now().UnixMilli()
fromMs := nowMs - int64(24*time.Hour/time.Millisecond)
if strings.TrimSpace(rangeKey) == "7d" {
fromMs = nowMs - int64(7*24*time.Hour/time.Millisecond)
} else if strings.TrimSpace(rangeKey) == "30d" {
fromMs = nowMs - int64(30*24*time.Hour/time.Millisecond)
}
return dao.DashboardTopPeers(fromMs, nowMs, limit)
}
func DashboardSecurity() ([]vo.SecurityRiskVo, error) {
summary, err := DashboardSummary()
if err != nil {
return []vo.SecurityRiskVo{}, err
}
return summary.SecurityRisks, nil
}
func DashboardSecurityRisks(summary vo.DashboardSummaryVo) []vo.SecurityRiskVo {
risks := make([]vo.SecurityRiskVo, 0)
if !summary.Hysteria.Running {
risks = append(risks, vo.SecurityRiskVo{Key: "dashboard.security.hysteriaStopped", Severity: "critical", Dismissible: false})
}
if !summary.Hysteria.ApiReachable {
risks = append(risks, vo.SecurityRiskVo{Key: "dashboard.security.trafficApiUnavailable", Severity: "warning", Dismissible: false})
}
return risks
}
+12 -147
View File
@@ -2,12 +2,10 @@ package service
import (
"errors"
"fmt"
"gopkg.in/yaml.v3"
"hy2xs-admin/dao"
"hy2xs-admin/model/bo"
"hy2xs-admin/model/constant"
"hy2xs-admin/proxy"
"hy2xs-admin/util"
"net"
"net/url"
"os"
@@ -38,7 +36,8 @@ func Hysteria2Auth(conPass string) (int64, string, error) {
}
now := time.Now().UnixMilli()
account, err := dao.GetAccount("con_pass = ? and deleted = 0 and (quota < 0 or quota > download + upload) and ? < expire_time and ? > kick_util_time", conPass, now, now)
secretDigest := util.PeerSecretDigest(conPass)
peer, err := dao.GetPeer("secret_digest = ? and disabled = 0 and (quota_bytes < 0 or quota_bytes > download_bytes + upload_bytes) and ? < expires_at and ? > banned_until", secretDigest, now, now)
if err != nil {
return 0, "", err
}
@@ -48,12 +47,12 @@ func Hysteria2Auth(conPass string) (int64, string, error) {
if err != nil {
return 0, "", err
}
device, exist := onlineUsers[*account.Username]
if exist && *account.DeviceNo <= device {
device, exist := onlineUsers[*peer.Name]
if exist && *peer.MaxDevices <= device {
return 0, "", errors.New("device limited")
}
return *account.Id, *account.Username, nil
return *peer.Id, *peer.Name, nil
}
func Hysteria2Online() (map[string]int64, error) {
@@ -79,17 +78,17 @@ func Hysteria2Kick(ids []int64, kickUtilTime int64) error {
if !Hysteria2IsRunning() {
return errors.New("hysteria2 is not running")
}
if err := dao.UpdateAccount(ids, map[string]interface{}{"kick_util_time": kickUtilTime}); err != nil {
if err := dao.UpdatePeer(ids, map[string]interface{}{"banned_until": kickUtilTime}); err != nil {
return err
}
accounts, err := dao.ListAccount("id in ?", ids)
peers, err := dao.ListPeer("id in ?", ids)
if err != nil {
return err
}
var keys []string
for _, item := range accounts {
keys = append(keys, *item.Username)
for _, item := range peers {
keys = append(keys, *item.Name)
}
apiPort, err := GetHysteria2ApiPort()
if err != nil {
@@ -105,140 +104,6 @@ func Hysteria2Kick(ids []int64, kickUtilTime int64) error {
return nil
}
func Hysteria2SubscribeUrl(accountId int64, protocol string) (string, error) {
account, err := dao.GetAccount("id = ?", accountId)
if err != nil {
return "", err
}
publicHost, publicPort, err := resolvePublicEndpoint()
if err != nil {
return "", err
}
config, err := dao.GetConfig("key = ?", constant.HUIWebContext)
if err != nil {
return "", err
}
webContext := ""
if config.Value != nil && *config.Value != "/" && strings.HasPrefix(*config.Value, "/") {
webContext = *config.Value
}
return fmt.Sprintf("%s//%s:%d%s/hui/%s", protocol, publicHost, publicPort, webContext, url.QueryEscape(*account.ConPass)), nil
}
func Hysteria2Subscribe(conPass string, clientType string) (string, string, error) {
hysteria2Config, err := GetHysteria2Config()
if err != nil {
return "", "", err
}
if hysteria2Config.Listen == nil || *hysteria2Config.Listen == "" {
return "", "", errors.New("hysteria2 config is empty")
}
account, err := dao.GetAccount("con_pass = ?", conPass)
if err != nil {
return "", "", err
}
publicHost, publicPort, err := resolvePublicEndpoint()
if err != nil {
return "", "", err
}
hysteria2Name := "hysteria2"
hysteria2ConfigRemark, err := dao.GetConfig("key = ?", constant.Hysteria2ConfigRemark)
if err != nil {
return "", "", err
}
if *hysteria2ConfigRemark.Value != "" {
hysteria2Name = *hysteria2ConfigRemark.Value
}
userInfo := ""
configStr := ""
if clientType == constant.Shadowrocket || clientType == constant.Clash {
userInfo = fmt.Sprintf("upload=%d; download=%d; total=%d; expire=%d",
*account.Upload,
*account.Download,
*account.Quota,
*account.ExpireTime/1000)
hysteria2 := bo.Hysteria2{
Name: hysteria2Name,
Type: "hysteria2",
Server: publicHost,
Port: strconv.Itoa(publicPort),
Password: conPass,
}
if hysteria2Config.Bandwidth != nil {
if hysteria2Config.Bandwidth.Up != nil &&
*hysteria2Config.Bandwidth.Up != "" {
hysteria2.Up = *hysteria2Config.Bandwidth.Up
}
if hysteria2Config.Bandwidth.Down != nil &&
*hysteria2Config.Bandwidth.Down != "" {
hysteria2.Down = *hysteria2Config.Bandwidth.Down
}
}
if hysteria2Config.Obfs != nil &&
hysteria2Config.Obfs.Type != nil &&
*hysteria2Config.Obfs.Type == "salamander" &&
hysteria2Config.Obfs.Salamander != nil &&
hysteria2Config.Obfs.Salamander.Password != nil &&
*hysteria2Config.Obfs.Salamander.Password != "" {
if clientType == constant.Shadowrocket {
hysteria2.Obfs = *hysteria2Config.Obfs.Salamander.Password
} else {
hysteria2.Obfs = "salamander"
hysteria2.ObfsPassword = *hysteria2Config.Obfs.Salamander.Password
}
}
if hysteria2Config.ACME != nil &&
hysteria2Config.ACME.Domains != nil &&
len(hysteria2Config.ACME.Domains) > 0 {
hysteria2.Sni = hysteria2Config.ACME.Domains[0]
}
hysteria2.SkipCertVerify = false
proxyGroup := bo.ProxyGroup{
Name: "PROXY",
Type: "select",
Proxies: []string{hysteria2Name},
}
clashConfig := bo.ClashConfig{
ProxyGroups: []bo.ProxyGroup{
proxyGroup,
},
Proxies: []interface{}{hysteria2},
}
clashConfigYaml, err := yaml.Marshal(&clashConfig)
if err != nil {
return "", "", err
}
configStr = string(clashConfigYaml)
if clientType == constant.Clash {
clashExtension, err := GetConfig(constant.ClashExtension)
if err != nil {
return "", "", err
}
if clashExtension.Value != nil && *clashExtension.Value != "" {
configStr = fmt.Sprintf("%s%s", configStr, *clashExtension.Value)
}
}
} else if clientType == constant.V2rayN {
hysteria2Url, err := Hysteria2Url(*account.Id)
if err != nil {
return "", "", err
}
configStr = hysteria2Url
}
return userInfo, configStr, nil
}
func Hysteria2Url(accountId int64) (string, error) {
hysteria2Config, err := GetHysteria2Config()
if err != nil {
@@ -252,7 +117,7 @@ func Hysteria2Url(accountId int64) (string, error) {
return "", err
}
account, err := dao.GetAccount("id = ?", accountId)
peer, err := dao.GetPeer("id = ?", accountId)
if err != nil {
return "", err
}
@@ -281,7 +146,7 @@ func Hysteria2Url(accountId int64) (string, error) {
sni = hysteria2Config.ACME.Domains[0]
}
return buildHysteria2Url(*account.ConPass, hostname, port, obfsType, obfsPassword, sni, remark), nil
return buildHysteria2Url(*peer.SecretCiphertext, hostname, port, obfsType, obfsPassword, sni, remark), nil
}
func buildHysteria2Url(conPass string, hostname string, port int, obfsType string, obfsPassword string, sni string, remark string) string {
+2 -2
View File
@@ -14,13 +14,13 @@ import (
const TokenExpireDuration = time.Hour * 24
type MyClaims struct {
AccountBo bo.AccountBo `json:"account"`
Admin bo.AccountBo `json:"admin"`
jwt.StandardClaims
}
func GenToken(accountBo bo.AccountBo) (string, error) {
c := MyClaims{
AccountBo: accountBo,
Admin: accountBo,
StandardClaims: jwt.StandardClaims{
ExpiresAt: time.Now().Add(TokenExpireDuration).Unix(),
Issuer: "hy2xs-admin",
+111
View File
@@ -0,0 +1,111 @@
package service
import (
"hy2xs-admin/dao"
"hy2xs-admin/model/entity"
"hy2xs-admin/model/vo"
"hy2xs-admin/util"
"sync"
"time"
)
type metricsSnapshot struct {
CollectedAt int64
System vo.DashboardSystemVo
Hysteria vo.DashboardHysteriaVo
OnlinePeers int64
OnlineDevices int64
CollectorState vo.DataHealthVo
HysteriaState vo.DataHealthVo
}
var metricsStore = struct {
sync.RWMutex
snapshot metricsSnapshot
lastSuccessAt int64
}{}
func CollectMetricsSnapshot() {
nowMs := time.Now().UnixMilli()
s := metricsSnapshot{
CollectedAt: nowMs,
CollectorState: vo.DataHealthVo{Status: "ok", LastSuccessAt: nowMs},
HysteriaState: vo.DataHealthVo{Status: "ok", LastSuccessAt: nowMs},
}
if cpuPercent, err := util.GetCpuPercent(); err == nil {
s.System.CpuPercent = cpuPercent
}
if memPercent, err := util.GetMemPercent(); err == nil {
s.System.MemPercent = memPercent
}
if memInfo, err := util.GetMemInfo(); err == nil {
s.System.MemUsedBytes = memInfo.Used
s.System.MemTotalBytes = memInfo.Total
}
if diskPercent, err := util.GetDiskPercent(); err == nil {
s.System.DiskPercent = diskPercent
}
if diskInfo, err := util.GetDiskInfo(); err == nil {
s.System.DiskUsedBytes = diskInfo.Used
s.System.DiskTotalBytes = diskInfo.Total
}
s.Hysteria.Running = Hysteria2IsRunning()
s.Hysteria.Version = "-"
if content, err := util.Exec(util.GetHysteria2BinPath() + " version"); err == nil {
s.Hysteria.Version = content
}
if onlineMap, err := Hysteria2Online(); err == nil {
s.Hysteria.ApiReachable = true
s.OnlinePeers = int64(len(onlineMap))
for _, c := range onlineMap {
s.OnlineDevices += c
}
} else {
s.Hysteria.ApiReachable = false
s.Hysteria.LastApiError = "dashboard.error.trafficApiUnavailable"
s.HysteriaState.Status = "error"
s.HysteriaState.MessageKey = "dashboard.error.trafficApiUnavailable"
}
metricsStore.Lock()
metricsStore.snapshot = s
metricsStore.lastSuccessAt = nowMs
metricsStore.Unlock()
running := int64(0)
if s.Hysteria.Running {
running = 1
}
diskPath := "/"
metric := entity.MetricSample{
SampledAt: &nowMs,
CpuPercent: &s.System.CpuPercent,
Load1: func() *float64 { v := float64(0); return &v }(),
MemUsedBytes: func() *int64 { v := int64(s.System.MemUsedBytes); return &v }(),
MemTotalBytes: func() *int64 { v := int64(s.System.MemTotalBytes); return &v }(),
MemPercent: &s.System.MemPercent,
DiskPath: &diskPath,
DiskUsedBytes: func() *int64 { v := int64(s.System.DiskUsedBytes); return &v }(),
DiskTotalBytes: func() *int64 { v := int64(s.System.DiskTotalBytes); return &v }(),
DiskPercent: &s.System.DiskPercent,
HysteriaRunning: &running,
OnlinePeers: &s.OnlinePeers,
OnlineDevices: &s.OnlineDevices,
}
_ = dao.SaveMetricSample(metric)
}
func CleanupMetricsRetention() {
cutoff := time.Now().Add(-72 * time.Hour).UnixMilli()
_ = dao.CleanupMetricSample(cutoff)
}
func DashboardSnapshot() metricsSnapshot {
metricsStore.RLock()
defer metricsStore.RUnlock()
return metricsStore.snapshot
}
-64
View File
@@ -1,64 +0,0 @@
package service
import (
"errors"
"fmt"
"hy2xs-admin/model/constant"
"hy2xs-admin/model/vo"
"hy2xs-admin/util"
"regexp"
"strings"
)
func MonitorSystem() (vo.SystemMonitorVo, error) {
cpuPercent, err := util.GetCpuPercent()
if err != nil {
return vo.SystemMonitorVo{}, errors.New("cpu query failed")
}
memPercent, err := util.GetMemPercent()
if err != nil {
return vo.SystemMonitorVo{}, errors.New("mem query failed")
}
diskPercent, err := util.GetDiskPercent()
if err != nil {
return vo.SystemMonitorVo{}, errors.New("disk query failed")
}
return vo.SystemMonitorVo{
HUIVersion: constant.Version,
CpuPercent: cpuPercent,
MemPercent: memPercent,
DiskPercent: diskPercent,
}, nil
}
func MonitorHysteria2() (vo.Hysteria2MonitorVo, error) {
var hysteria2MonitorVo vo.Hysteria2MonitorVo
onlineUsers, err := Hysteria2Online()
if err != nil {
return hysteria2MonitorVo, err
}
if len(onlineUsers) > 0 {
hysteria2MonitorVo.UserTotal = int64(len(onlineUsers))
var deviceTotal int64 = 0
for _, value := range onlineUsers {
deviceTotal += value
}
hysteria2MonitorVo.DeviceTotal = deviceTotal
}
hysteria2MonitorVo.Version = "-"
content, err := util.Exec(fmt.Sprintf("%s version", util.GetHysteria2BinPath()))
if err == nil {
pattern := `v\d+\.\d+\.\d+`
re := regexp.MustCompile(pattern)
matches := re.FindAllString(strings.TrimSpace(content), -1)
if len(matches) > 0 {
hysteria2MonitorVo.Version = matches[0]
}
}
running := Hysteria2IsRunning()
hysteria2MonitorVo.Running = running
return hysteria2MonitorVo, nil
}
+321
View File
@@ -0,0 +1,321 @@
package service
import (
"errors"
"fmt"
"github.com/gin-gonic/gin"
"hy2xs-admin/dao"
"hy2xs-admin/model/bo"
"hy2xs-admin/model/constant"
"hy2xs-admin/model/dto"
"hy2xs-admin/model/entity"
"hy2xs-admin/model/vo"
"hy2xs-admin/util"
"time"
)
func Login(username string, plainPassword string) (string, bool, error) {
account, err := dao.GetAdminUser("username = ? and status = 1", username)
if err != nil {
return "", false, err
}
verified, legacy := util.VerifyPassword(plainPassword, *account.PasswordHash)
if !verified {
return "", false, errors.New(constant.WrongPassword)
}
if legacy {
hash, hashErr := util.HashPassword(plainPassword)
if hashErr == nil {
_ = dao.UpdateAdminUser([]int64{*account.Id}, map[string]interface{}{"password_hash": hash})
}
}
accountBo := bo.AccountBo{
Id: *account.Id,
Username: *account.Username,
Roles: []string{"admin"},
Deleted: 0,
}
token, tokenErr := GenToken(accountBo)
if tokenErr != nil {
return "", false, tokenErr
}
requirePasswordChange := legacy
if account.ForcePasswordChange != nil {
requirePasswordChange = *account.ForcePasswordChange != 0
}
return token, requirePasswordChange, nil
}
func PagePeer(peerPageDto dto.PeerPageDto) ([]entity.Account, int64, error) {
peers, total, err := dao.PagePeer(peerPageDto)
if err != nil {
return nil, 0, err
}
accounts := make([]entity.Account, 0, len(peers))
for _, p := range peers {
role := "user"
acc := entity.Account{
BaseEntity: p.BaseEntity,
Username: p.Name,
ConPass: p.SecretCiphertext,
Quota: p.QuotaBytes,
Download: p.DownloadBytes,
Upload: p.UploadBytes,
ExpireTime: p.ExpiresAt,
KickUtilTime: p.BannedUntil,
DeviceNo: p.MaxDevices,
Role: &role,
Deleted: p.Disabled,
ConAt: p.LastConnectionAt,
Remark: p.Remark,
}
accounts = append(accounts, acc)
}
return accounts, total, nil
}
func SavePeer(account entity.Account) error {
if account.Username == nil || *account.Username == "" {
return errors.New(constant.InvalidError)
}
secret := ""
if account.ConPass != nil && *account.ConPass != "" {
secret = *account.ConPass
} else {
generated, genErr := util.RandomString(24)
if genErr != nil {
return genErr
}
secret = fmt.Sprintf("%s.%s", *account.Username, generated)
}
authId, authErr := util.RandomString(18)
if authErr != nil {
return authErr
}
secretDigest := util.PeerSecretDigest(secret)
peer := entity.Peer{
Name: account.Username,
Remark: account.Remark,
AuthId: &authId,
SecretDigest: &secretDigest,
SecretCiphertext: &secret,
QuotaBytes: account.Quota,
ExpiresAt: account.ExpireTime,
MaxDevices: account.DeviceNo,
Disabled: account.Deleted,
}
_, err := dao.SavePeer(peer)
return err
}
func DeletePeer(ids []int64) error {
return dao.DeletePeer(ids)
}
func UpdatePeer(account entity.Account) error {
updates := map[string]interface{}{}
if account.Username != nil && *account.Username != "" {
updates["username"] = *account.Username
}
_ = account.Pass
if account.ConPass != nil && *account.ConPass != "" {
updates["secret_ciphertext"] = *account.ConPass
updates["secret_digest"] = util.PeerSecretDigest(*account.ConPass)
}
if account.Quota != nil {
updates["quota_bytes"] = *account.Quota
}
if account.ExpireTime != nil {
updates["expires_at"] = *account.ExpireTime
}
if account.Download != nil {
updates["download_bytes"] = *account.Download
}
if account.Upload != nil {
updates["upload_bytes"] = *account.Upload
}
if account.DeviceNo != nil {
updates["max_devices"] = *account.DeviceNo
}
if account.Deleted != nil {
updates["disabled"] = *account.Deleted
}
if account.LoginAt != nil && *account.LoginAt > 0 {
updates["login_at"] = *account.LoginAt
}
if account.ConAt != nil && *account.ConAt > 0 {
updates["last_connection_at"] = *account.ConAt
}
if account.Remark != nil {
updates["remark"] = *account.Remark
}
return dao.UpdatePeer([]int64{*account.Id}, updates)
}
func ResetTraffic(id int64) error {
return dao.UpdatePeer([]int64{id}, map[string]interface{}{"download_bytes": 0, "upload_bytes": 0})
}
func ExistPeerName(username string, id int64) bool {
var err error
if id != 0 {
_, err = dao.GetPeer("name = ? and id != ?", username, id)
} else {
_, err = dao.GetPeer("name = ?", username)
}
if err != nil {
if err.Error() == constant.WrongPassword {
return false
}
}
return true
}
func GetPeer(id int64) (entity.Account, error) {
peer, err := dao.GetPeer("id = ?", id)
if err != nil {
return entity.Account{}, err
}
role := "user"
return entity.Account{
BaseEntity: peer.BaseEntity,
Username: peer.Name,
ConPass: peer.SecretCiphertext,
Quota: peer.QuotaBytes,
Download: peer.DownloadBytes,
Upload: peer.UploadBytes,
ExpireTime: peer.ExpiresAt,
DeviceNo: peer.MaxDevices,
KickUtilTime: peer.BannedUntil,
ConAt: peer.LastConnectionAt,
Deleted: peer.Disabled,
Remark: peer.Remark,
Role: &role,
}, nil
}
func GetAdminAccount(id int64) (entity.AdminUser, error) {
return dao.GetAdminUser("id = ?", id)
}
func ListExportPeer() ([]bo.AccountExport, error) {
peers, err := dao.ListPeer("1=1")
if err != nil {
return nil, errors.New(constant.SysError)
}
var accountExports []bo.AccountExport
for _, item := range peers {
role := "user"
conPass := ""
if item.SecretCiphertext != nil {
conPass = *item.SecretCiphertext
}
accountExport := bo.AccountExport{
Id: *item.Id,
Username: *item.Name,
Pass: "",
ConPass: conPass,
Quota: *item.QuotaBytes,
Download: *item.DownloadBytes,
Upload: *item.UploadBytes,
ExpireTime: *item.ExpiresAt,
DeviceNo: *item.MaxDevices,
KickUtilTime: *item.BannedUntil,
Role: role,
Deleted: *item.Disabled,
CreateTime: *item.CreateTime,
UpdateTime: *item.UpdateTime,
LoginAt: 0,
ConAt: *item.LastConnectionAt,
Remark: *item.Remark,
}
accountExports = append(accountExports, accountExport)
}
return accountExports, nil
}
func ReleaseKickPeer(id int64) error {
return dao.UpdatePeer([]int64{id}, map[string]interface{}{"banned_until": 0})
}
func UpsertPeer(accounts []entity.Account) error {
for _, account := range accounts {
if account.Id != nil && *account.Id > 0 {
if err := UpdatePeer(account); err != nil {
return err
}
continue
}
if err := SavePeer(account); err != nil {
return err
}
}
return nil
}
func GetAdminInfo(c *gin.Context) (vo.AccountInfoVo, error) {
myClaims, err := ParseToken(GetToken(c))
if err != nil {
return vo.AccountInfoVo{}, err
}
if myClaims.Admin.Deleted != 0 {
return vo.AccountInfoVo{}, errors.New("this account has been disabled")
}
admin, err := dao.GetAdminUser("id = ?", myClaims.Admin.Id)
if err != nil {
return vo.AccountInfoVo{}, err
}
if admin.Status != nil && *admin.Status == 0 {
return vo.AccountInfoVo{}, errors.New("this account has been disabled")
}
return vo.AccountInfoVo{
Id: myClaims.Admin.Id,
Username: myClaims.Admin.Username,
Roles: myClaims.Admin.Roles,
}, nil
}
func UpdatePeerLastConnectionAt(id int64, conAt int64) error {
return dao.UpdatePeer([]int64{id}, map[string]interface{}{"last_connection_at": conAt})
}
func UpdateAdminLastLoginAt(id int64, loginAt int64) error {
return dao.UpdateAdminUser([]int64{id}, map[string]interface{}{"last_login_at": loginAt})
}
func ChangeAdminPassword(c *gin.Context, oldPassword string, newPassword string) error {
info, err := GetAdminInfo(c)
if err != nil {
return err
}
admin, err := dao.GetAdminUser("id = ?", info.Id)
if err != nil {
return err
}
if admin.PasswordHash == nil {
return errors.New(constant.SysError)
}
verified, _ := util.VerifyPassword(oldPassword, *admin.PasswordHash)
if !verified {
return errors.New(constant.WrongPassword)
}
hash, hashErr := util.HashPassword(newPassword)
if hashErr != nil {
return hashErr
}
nowMs := time.Now().UnixMilli()
currentTokenVersion := int64(1)
if admin.TokenVersion != nil && *admin.TokenVersion > 0 {
currentTokenVersion = *admin.TokenVersion
}
return dao.UpdateAdminUser([]int64{info.Id}, map[string]interface{}{
"password_hash": hash,
"force_password_change": 0,
"password_changed_at": nowMs,
"token_version": currentTokenVersion + 1,
})
}
+21
View File
@@ -1,9 +1,11 @@
package util
import (
"crypto/hmac"
"crypto/sha256"
"errors"
"fmt"
"os"
"strings"
"golang.org/x/crypto/bcrypt"
@@ -42,3 +44,22 @@ func VerifyPassword(password string, storedHash string) (ok bool, legacy bool) {
}
return SHA224String(password) == storedHash, true
}
func HmacSHA256Hex(payload string, secret string) string {
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(payload))
sum := mac.Sum(nil)
str := ""
for _, v := range sum {
str += fmt.Sprintf("%02x", v)
}
return str
}
func PeerSecretDigest(rawSecret string) string {
secretKey := strings.TrimSpace(os.Getenv("HY2XS_PEER_SECRET_KEY"))
if secretKey == "" {
secretKey = "hy2xs-peer-secret-key"
}
return HmacSHA256Hex(rawSecret, secretKey)
}
+15
View File
@@ -78,6 +78,10 @@ func GetMemPercent() (float64, error) {
return value, err
}
func GetMemInfo() (*mem.VirtualMemoryStat, error) {
return mem.VirtualMemory()
}
func GetDiskPercent() (float64, error) {
var err error
parts, err := disk.Partitions(true)
@@ -86,6 +90,17 @@ func GetDiskPercent() (float64, error) {
return value, err
}
func GetDiskInfo() (*disk.UsageStat, error) {
parts, err := disk.Partitions(true)
if err != nil {
return nil, err
}
if len(parts) == 0 {
return nil, errors.New("disk partition not found")
}
return disk.Usage(parts[0].Mountpoint)
}
func VerifyPort(port string) error {
if port != "" {
value, err := strconv.ParseInt(port, 10, 64)
+713
View File
@@ -0,0 +1,713 @@
Да, это логичное продолжение: `Account Info` в текущей архитектуре надо не “улучшать”, а удалить как концепцию. После разделения `admin_user` и `peer` эта страница теряет смысл: админ — это оператор панели, а не клиентский peer с квотой, Node URL и QR. Правильная замена — единый стартовый `Dashboard`, куда переезжают системный мониторинг, состояние Hysteria2, агрегированная статистика peer’ов, трафик и security notices.
Ниже — дополнение к плану выше.
---
## 9. Заменить `Account Info` + `System Monitor` на полноценный Dashboard
Сейчас есть две слабые страницы:
`views/info/account/index.vue` — фактически профиль текущего `account`, но там одновременно greeting, create time, quota/download/upload/expire time, subscription buttons, QR и security notifications.
`views/monitor/system/index.vue` — набор карточек без истории: версия панели, CPU, RAM, disk, версия Hysteria2, статус, online users/devices.
После разделения админов и peer’ов это должно стать так:
```text
/dashboard
├─ общая сводка панели
├─ состояние Hysteria2
├─ общий трафик всех peer’ов
├─ графики CPU/RAM/Disk
├─ online peers/devices
├─ top peers by traffic
├─ security warnings
└─ быстрые действия оператора
```
`Account Info` удалить из меню. Профиль админа оставить в dropdown справа сверху: “Profile”, “Change password”, “Logout”. Peer-информация, Node URL и QR должны жить только в `Peer Detail / Peer Drawer`, а не в профиле администратора.
---
## 10. Что именно не так в текущей реализации
Текущий `Account Info` вызывает `getAccountApi({ id: accountStore.id })`. Пока админ и peer лежат в одной таблице, это случайно работает. После нормального разделения это станет архитектурной ошибкой: admin ID не должен использоваться как peer ID.
Кнопки `Subscribe`, `Subscribe QR`, `Node URL`, `Node QR Code` на странице админа надо убрать полностью. Они допустимы только для конкретного peer’а. Сейчас из-за смешанной модели админ может выглядеть как клиент подключения.
`System Monitor` сейчас делает два независимых запроса: `/monitor/monitorSystem` и `/monitor/monitorHysteria2`. Для дашборда это лучше заменить одним агрегирующим endpoint’ом, иначе UI будет собирать бизнес-смысл из разных API.
`MonitorSystem()` каждый раз делает live-запросы к CPU/RAM/Disk. CPU-сэмпл через `cpu.Percent(time.Second, false)` блокирует примерно на секунду. Для интерактивного Dashboard это плохая модель: страницу могут открыть несколько админов, и каждый начнёт дергать системные метрики.
`GetDiskPercent()` берёт первый partition из `disk.Partitions(true)`. Это ненадёжно: первым может оказаться не root/data partition. Для панели нужно мониторить `/`, data-dir панели и, если нужно, mountpoint с Hysteria2 config/logs.
`MonitorHysteria2()` каждый раз выполняет `hysteria2 version`. Версию бинарника не надо получать на каждый render дашборда. Её можно кешировать и обновлять редко: при старте, после смены версии, по ручному refresh или раз в несколько минут.
---
## 11. Новый backend-модуль: `dashboard`
Добавить отдельный модуль, а не расширять `monitor.go`.
```text
controller/dashboard.go
service/dashboard.go
service/metrics_collector.go
dao/dashboard.go
model/vo/dashboard.go
router/dashboard.go
```
Минимальный набор API:
```text
GET /hui/dashboard/summary
GET /hui/dashboard/timeseries?range=1h&step=1m
GET /hui/dashboard/top-peers?range=24h&limit=10
GET /hui/dashboard/security
```
Можно начать с одного endpoint’а:
```text
GET /hui/dashboard
```
Но лучше сразу разделить summary и timeseries. Summary обновляется часто, графики можно обновлять реже.
Целевой response для summary:
```go
type DashboardSummaryVo struct {
CollectedAt int64 `json:"collectedAt"`
System SystemSummaryVo `json:"system"`
Hysteria HysteriaSummaryVo `json:"hysteria"`
Peers PeerSummaryVo `json:"peers"`
Traffic TrafficSummaryVo `json:"traffic"`
SecurityRisks []SecurityRiskVo `json:"securityRisks"`
}
type SystemSummaryVo struct {
CpuPercent float64 `json:"cpuPercent"`
MemUsedBytes uint64 `json:"memUsedBytes"`
MemTotalBytes uint64 `json:"memTotalBytes"`
MemPercent float64 `json:"memPercent"`
DiskUsedBytes uint64 `json:"diskUsedBytes"`
DiskTotalBytes uint64 `json:"diskTotalBytes"`
DiskPercent float64 `json:"diskPercent"`
UptimeSeconds uint64 `json:"uptimeSeconds"`
Load1 float64 `json:"load1"`
}
type HysteriaSummaryVo struct {
Version string `json:"version"`
Running bool `json:"running"`
ApiReachable bool `json:"apiReachable"`
LastApiError string `json:"lastApiError,omitempty"`
}
type PeerSummaryVo struct {
Total int64 `json:"total"`
Enabled int64 `json:"enabled"`
Disabled int64 `json:"disabled"`
Expired int64 `json:"expired"`
OnlinePeers int64 `json:"onlinePeers"`
OnlineDevices int64 `json:"onlineDevices"`
}
type TrafficSummaryVo struct {
DownloadBytes int64 `json:"downloadBytes"`
UploadBytes int64 `json:"uploadBytes"`
TotalBytes int64 `json:"totalBytes"`
TodayDownloadBytes int64 `json:"todayDownloadBytes"`
TodayUploadBytes int64 `json:"todayUploadBytes"`
SinceResetDownloadBytes int64 `json:"sinceResetDownloadBytes"`
SinceResetUploadBytes int64 `json:"sinceResetUploadBytes"`
}
```
Важно: после разделения таблиц все peer-агрегации идут только из `peer`, не из `admin_user`.
---
## 12. Отдельный collector вместо live-сбора на каждый HTTP-запрос
Dashboard не должен каждый раз сам опрашивать систему и Hysteria2 API. Нужен background collector.
```text
MetricsCollector
├─ каждые 5–10 секунд: CPU/RAM/load/uptime
├─ каждые 30–60 секунд: disk
├─ каждые HYSTERIA2_TRAFFIC_TIME секунд: Hysteria2 traffic
├─ каждые 5–15 секунд: Hysteria2 online
└─ редко: Hysteria2 version
```
HTTP endpoint читает готовый snapshot из памяти и, если нужно, последние точки из SQLite. Это решает сразу три проблемы: быстрый UI, меньше нагрузки, стабильная история для графиков.
Пример внутренней модели:
```go
type MetricsSnapshot struct {
CollectedAt time.Time
System SystemMetrics
Hysteria HysteriaRuntimeMetrics
Online map[string]int64
}
```
Для защиты от зависаний все внешние операции — с timeout и context. Hysteria2 API уже вызывается с timeout, это надо сохранить.
---
## 13. Исправить учёт трафика перед графиками
Это критично.
В текущем `saveAccountTraffic()` берётся `/traffic?clear=1`, после чего `rx/tx` умножаются на `HYSTERIA2_TRAFFIC_TIME`. По документации Hysteria2 `/traffic` возвращает traffic statistics по клиентам, а параметр `clear=1` обнуляет статистику после возврата. То есть эти значения надо трактовать как дельту с прошлого clear, а не как rate, который нужно умножать на интервал. Иначе трафик будет завышаться. ([v2.hysteria.network][1])
Нормальный pipeline:
```text
1. collector вызывает /traffic?clear=1
2. получает rx/tx bytes по auth/client id
3. сохраняет immutable traffic_sample
4. атомарно инкрементит peer.download_bytes / peer.upload_bytes
5. обновляет aggregate таблицы для dashboard
```
Новая таблица:
```sql
CREATE TABLE traffic_sample (
id INTEGER PRIMARY KEY AUTOINCREMENT,
peer_id INTEGER NOT NULL,
auth_id TEXT NOT NULL,
rx_bytes INTEGER NOT NULL DEFAULT 0,
tx_bytes INTEGER NOT NULL DEFAULT 0,
sampled_at INTEGER NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_traffic_sample_time ON traffic_sample(sampled_at);
CREATE INDEX idx_traffic_sample_peer_time ON traffic_sample(peer_id, sampled_at);
```
Для быстрых графиков и top peers добавить агрегаты:
```sql
CREATE TABLE traffic_aggregate_hourly (
peer_id INTEGER NOT NULL,
hour_start INTEGER NOT NULL,
rx_bytes INTEGER NOT NULL DEFAULT 0,
tx_bytes INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY(peer_id, hour_start)
);
CREATE TABLE traffic_aggregate_daily (
peer_id INTEGER NOT NULL,
day_start INTEGER NOT NULL,
rx_bytes INTEGER NOT NULL DEFAULT 0,
tx_bytes INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY(peer_id, day_start)
);
```
Сброс трафика peer’а не должен удалять историю. Он должен сбрасывать только счётчики quota-period:
```text
peer.download_bytes = 0
peer.upload_bytes = 0
peer.traffic_reset_at = now
```
Иначе после reset dashboard потеряет исторические графики. В UI надо явно различать:
```text
Traffic since reset
Traffic today
Traffic last 24h
Traffic total tracked
```
---
## 14. Online users/devices: переименовать и использовать корректно
Hysteria2 `/online` возвращает map client ID → количество client instances. В документации отдельно указано, что это именно количество клиентских экземпляров, то есть “devices”, а не количество активных proxy-соединений. ([v2.hysteria.network][1])
Поэтому в Dashboard надо писать:
```text
Online peers
Online devices
```
Не “online users”, если в новой модели сущность называется `peer`.
Расчёт:
```text
onlinePeers = len(onlineMap)
onlineDevices = sum(onlineMap values)
```
После миграции ключом должен быть `peer.auth_id` или `peer.name`, но не admin username. Главное — не завязываться на таблицу админов.
---
## 15. Security warnings перенести из уведомлений в Dashboard
Сейчас `Account Info` показывает всплывающие `ElNotification`: default password и no HTTPS. Это раздражает, перекрывает интерфейс и не масштабируется.
На Dashboard сделать постоянный блок сверху:
```text
Security Center
⚠ Default admin password / force password change
⚠ Panel is served without HTTPS
⚠ Hysteria2 Traffic Stats API is not protected
⚠ Hysteria2 is stopped
⚠ Public endpoint env is not configured
```
Для Traffic Stats API это особенно важно: Hysteria2 docs прямо рекомендуют задавать `secret`, иначе любой, кто имеет доступ к API listen address, сможет смотреть traffic stats и kick users. ([v2.hysteria.network][2])
Backend должен отдавать structured warnings:
```go
type SecurityRiskVo struct {
Key string `json:"key"` // dashboard.security.noHttps
Severity string `json:"severity"` // info | warning | critical
ActionRoute string `json:"actionRoute,omitempty"`
Dismissible bool `json:"dismissible"`
}
```
Frontend переводит `key` через i18n. Никаких HTML-строк из backend. Сейчас `dangerouslyUseHTMLString` лучше убрать.
---
## 16. Новый frontend layout Dashboard
Создать:
```text
src/views/dashboard/index.vue
src/api/dashboard/index.ts
src/api/dashboard/types.ts
src/views/dashboard/components/MetricCard.vue
src/views/dashboard/components/SecurityAlerts.vue
src/views/dashboard/components/SystemChart.vue
src/views/dashboard/components/TrafficChart.vue
src/views/dashboard/components/TopPeers.vue
src/views/dashboard/components/HysteriaStatus.vue
```
Структура страницы:
```text
[Security alerts, если есть]
[Hysteria2 status] [Online peers] [Online devices] [Traffic today]
[CPU] [Memory] [Disk] [Total peers]
[Traffic chart: download/upload]
[System chart: CPU/RAM]
[Top peers by traffic]
[Recent peer activity / last connections]
```
Первый экран должен отвечать на вопросы оператора:
```text
Сервис работает?
Есть ли пользователи онлайн?
Сколько трафика прошло?
Есть ли перегруз CPU/RAM/Disk?
Кто больше всего потребляет?
Есть ли security/config warnings?
```
То, что сейчас на `System Monitor`, можно сохранить как часть Dashboard, но карточки надо сделать полезнее:
```text
CPU: 14.2%, load1 0.23
Memory: 1.2 GB / 13.7 GB, 8.7%
Disk: 11.5 GB / 40 GB, 28.8%
Hysteria2: Running, v2.8.2
```
Не только проценты.
---
## 17. Графики: как сделать без костылей
В проекте сейчас нет chart-библиотеки. Есть два нормальных варианта.
Вариант A, практичный: добавить ECharts. Для админ-панелей это стандартное решение: line/area charts, tooltip, resize, нормальная работа с time-series. Компоненты графиков лучше lazy-load’ить, чтобы не раздувать initial bundle.
Вариант B, минимальный: написать `Sparkline.vue` на SVG для CPU/RAM и traffic. Меньше зависимостей, но хуже tooltip, zoom, диапазоны и легенды.
Для твоего кейса я бы выбрал ECharts, но не тащил бы его во все страницы. Только Dashboard chunk.
Графики:
```text
TrafficChart:
series: download, upload
ranges: 1h / 24h / 7d / 30d
source: traffic_aggregate_hourly/daily
SystemChart:
series: cpuPercent, memPercent
ranges: 1h / 6h / 24h
source: metric_sample
```
Disk не нужно рисовать как частый line chart. Disk меняется медленно; достаточно карточки и, максимум, daily trend.
---
## 18. Таблица системных метрик
Добавить таблицу:
```sql
CREATE TABLE metric_sample (
id INTEGER PRIMARY KEY AUTOINCREMENT,
sampled_at INTEGER NOT NULL,
cpu_percent REAL NOT NULL DEFAULT 0,
load1 REAL NOT NULL DEFAULT 0,
mem_used_bytes INTEGER NOT NULL DEFAULT 0,
mem_total_bytes INTEGER NOT NULL DEFAULT 0,
mem_percent REAL NOT NULL DEFAULT 0,
disk_path TEXT NOT NULL DEFAULT '/',
disk_used_bytes INTEGER NOT NULL DEFAULT 0,
disk_total_bytes INTEGER NOT NULL DEFAULT 0,
disk_percent REAL NOT NULL DEFAULT 0,
hysteria_running INTEGER NOT NULL DEFAULT 0,
online_peers INTEGER NOT NULL DEFAULT 0,
online_devices INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX idx_metric_sample_time ON metric_sample(sampled_at);
```
Retention:
```text
raw metric_sample: 2472 часа
minute aggregate: 7 дней
hour aggregate: 3090 дней
daily aggregate: можно хранить дольше
```
SQLite нормально выдержит такие объёмы, если не писать каждую секунду и не хранить бесконечную raw-историю.
---
## 19. Роутинг и меню
Текущее:
```text
/ -> /info/account
/info/account
/monitor/system
```
Целевое:
```text
/ -> /dashboard
/dashboard
/peers
/hysteria
/config
/log/system
/log/hysteria
```
В `router/index.ts`:
```ts
{
path: "/",
component: Layout,
redirect: "/dashboard",
children: [...]
}
```
Новый route:
```ts
{
path: "/dashboard",
component: "Layout",
redirect: "/dashboard/index",
name: "Dashboard",
meta: {
title: "dashboard",
icon: "dashboard",
roles: ["admin"],
},
children: [
{
path: "index",
component: "dashboard/index",
name: "DashboardIndex",
meta: {
title: "dashboard",
icon: "dashboard",
roles: ["admin"],
},
},
],
}
```
`Info` route удалить. `Monitor` route либо удалить, либо временно оставить redirect на `/dashboard/index` на один релиз.
---
## 20. Что делать с бывшим `Account Info`
Разложить по новым местам.
Greeting и create time админа:
```text
navbar dropdown / admin profile
```
Security warnings:
```text
dashboard Security Center
```
Quota/download/upload/expire:
```text
peer drawer
peer list compact cards
dashboard aggregate traffic
```
Subscribe/QR/Node URL:
```text
peer detail только для конкретного peer
```
Default password warning:
```text
dashboard + forced change-password flow
```
HTTPS warning:
```text
dashboard security warning
```
Так исчезает главный conceptual bug: админ больше не выглядит как peer.
---
## 21. Быстрые действия на Dashboard
Добавить только operator actions, не peer actions:
```text
Restart Hysteria2
Open Hysteria config
Open peers
Open logs
Refresh metrics
```
Не добавлять на dashboard “Create peer” как главную кнопку, если страница перегружена. Лучше маленькая secondary action в блоке peers.
Для dangerous actions — только confirm dialog:
```text
Restart Hysteria2
Reset all traffic
```
`Reset all traffic` я бы вообще не держал на Dashboard. Это административная операция, ей место в settings или peer management.
---
## 22. Backend queries для агрегатов peer’ов
После split-модели нужны DAO-методы:
```go
CountPeersByStatus(now int64) (PeerSummary, error)
SumPeerTrafficSinceReset() (TrafficSummary, error)
TopPeersByTraffic(from, to int64, limit int) ([]TopPeerVo, error)
ListRecentPeerActivity(limit int) ([]PeerActivityVo, error)
```
SQL-логика:
```sql
SELECT
COUNT(*) AS total,
SUM(CASE WHEN disabled = 0 THEN 1 ELSE 0 END) AS enabled,
SUM(CASE WHEN disabled = 1 THEN 1 ELSE 0 END) AS disabled,
SUM(CASE WHEN expires_at > 0 AND expires_at < ? THEN 1 ELSE 0 END) AS expired
FROM peer;
```
Traffic current period:
```sql
SELECT
COALESCE(SUM(download_bytes), 0) AS download,
COALESCE(SUM(upload_bytes), 0) AS upload
FROM peer
WHERE disabled = 0;
```
Top peers из history:
```sql
SELECT
peer_id,
SUM(rx_bytes) AS download,
SUM(tx_bytes) AS upload
FROM traffic_sample
WHERE sampled_at BETWEEN ? AND ?
GROUP BY peer_id
ORDER BY download + upload DESC
LIMIT ?;
```
---
## 23. Polling на frontend
Первый production-safe вариант без WebSocket/SSE:
```text
summary: каждые 5 секунд
timeseries: каждые 30 секунд
top peers: каждые 60 секунд
```
Использовать `useIntervalFn` из `@vueuse/core`, он уже есть в dependencies.
Обязательно:
```text
pause polling on unmount
pause polling when tab hidden
show stale badge if collectedAt older than N seconds
manual refresh button
```
Если позже захочешь realtime без polling — добавить SSE:
```text
GET /hui/dashboard/events
```
Но я бы не начинал с SSE. Для такой панели polling проще, надёжнее и легче тестируется.
---
## 24. Состояния ошибок
Dashboard должен показывать не просто `-`, а причину.
Примеры:
```text
Hysteria2 stopped
Traffic API unreachable
Traffic API unauthorized
No traffic samples yet
Metrics collector stale
Disk path unavailable
```
Backend response:
```go
type DataHealthVo struct {
Status string `json:"status"` // ok | stale | error
MessageKey string `json:"messageKey,omitempty"`
LastSuccessAt int64 `json:"lastSuccessAt,omitempty"`
}
```
Например:
```json
{
"hysteria": {
"running": true,
"apiReachable": false,
"lastApiError": "dashboard.error.trafficApiUnauthorized"
}
}
```
Frontend переводит через i18n.
---
## 25. Acceptance criteria для Dashboard
Готовность можно проверять так:
```text
/ открывает /dashboard, а не /info/account.
В меню нет Account Info.
Admin profile не содержит quota, Node URL, Subscribe, QR.
Dashboard показывает Hysteria2 status, version, online peers/devices.
Dashboard показывает общий download/upload всех peer’ов.
Dashboard показывает traffic today и traffic since reset.
Dashboard показывает CPU/RAM/Disk не только процентом, но и used/total.
CPU/RAM графики строятся из history, а не из разовых live-запросов.
Traffic график не ломается после reset traffic.
Traffic accounting не умножает /traffic?clear=1 на polling interval.
Security warnings отображаются внутри Dashboard, не всплывающими HTML notification.
Если Hysteria2 API недоступен, Dashboard показывает degraded state, а не пустые карточки.
На 1366px первый экран читается без горизонтального скролла.
```
---
## 26. Приоритет внедрения
Я бы добавил этот блок в общий roadmap так:
```text
A. Security hotfix текущей account/peer модели.
B. Split admin_user / peer.
C. Исправить traffic accounting: убрать ошибочное умножение, добавить traffic_sample.
D. Создать Dashboard backend summary endpoint.
E. Перенести /info/account и /monitor/system в /dashboard.
F. Добавить графики и history retention.
G. Удалить legacy routes /info/account и /monitor/system.
```
Главное: не делать красивый Dashboard поверх старой таблицы `account`. Сначала развести admin и peer, затем строить дашборд на правильной доменной модели. Иначе ты просто перенесёшь старую путаницу в более красивый интерфейс.
[1]: https://v2.hysteria.network/docs/advanced/Traffic-Stats-API/?utm_source=chatgpt.com "Traffic Stats API"
[2]: https://v2.hysteria.network/docs/advanced/Full-Server-Config/?utm_source=chatgpt.com "Full Server Config"
+243
View File
@@ -0,0 +1,243 @@
Да, я бы убирал подписки полностью. В текущем состоянии это не недоделанная мелочь, а мёртвый функциональный слой, который создаёт ложное ожидание у оператора и путает UX.
По текущему коду видно следующее: ordinary Node URL / Node QR строятся отдельно и полезны; subscription-кнопки в UI есть, но backend endpoint `Hysteria2SubscribeUrl` фактически возвращает ошибку `subscription delivery is out of scope in HY2XS baseline`. При этом в service ещё лежит старая логика генерации подписок для Clash/Shadowrocket/v2rayN, но она не подключена нормальным публичным delivery route. То есть это уже рудимент: фронт показывает кнопки, backend говорит “не поддерживается”, а пользователи всё равно подключаются через обычный URI/QR.
Я бы добавил в общий план отдельный пункт.
---
## 27. Удалить subscription layer как неподдерживаемый рудимент
### Решение
Оставить только:
```text
Node URL
Node QR Code
Copy URI
Rotate Secret
```
Удалить:
```text
Subscribe
Subscribe QR Code
Subscription URL
Clash subscription extension
Shadowrocket/V2rayN subscription delivery
/hysteria2/hysteria2SubscribeUrl
```
То есть один peer — один обычный Hysteria2 URI/QR. Этого достаточно для текущей панели, особенно если цель — упрощённая, зрелая 3x-ui-like админка, а не subscription gateway.
---
## 28. Почему лучше удалить, а не чинить сейчас
Подписка — это отдельный продуктовый слой, а не просто “ещё один QR”.
Чтобы она была production-grade, нужны:
```text
публичный endpoint доставки подписки;
форматы под разные клиенты;
нормальная авторизация или signed token;
TTL / revoke / rotate;
rate limit;
логирование выдачи;
обработка client type;
совместимость Clash/Shadowrocket/v2rayN;
безопасное сокрытие peer secret;
корректные HTTP headers;
контроль доступа после disable/expire peer;
тесты на каждый формат клиента.
```
Сейчас этого нет. Более того, подписочный URL по старой логике строится вокруг `conPass`, то есть секрет peer’а становится частью URL. Это хуже обычного QR, потому что URL может попасть в browser history, reverse-proxy logs, access logs, Referer, скриншоты и т.д.
Обычный Node QR тоже содержит секрет, но он показывается авторизованному админу в панели для конкретного peer’а. Подписка же предполагает публичную доставку конфигурации по ссылке. Это другой threat model.
Поэтому чинить подписки сейчас — не “доделать кнопку”, а открывать отдельный блок безопасности и совместимости. Для текущего scope это лишнее.
---
## 29. Что удалить на backend
Удалить или пометить deprecated с последующим удалением:
```go
controller.Hysteria2SubscribeUrl
controller.Hysteria2Subscribe
service.Hysteria2SubscribeUrl
service.Hysteria2Subscribe
dto.Hysteria2SubscribeUrlDto
vo.Hysteria2SubscribeVo
```
Из router убрать:
```go
hysteria2.GET("/hysteria2SubscribeUrl", controller.Hysteria2SubscribeUrl)
```
Если будешь делать новую REST-модель после разделения `admin_user` / `peer`, оставить только:
```text
GET /hui/peers/:id/client-uri
GET /hui/peers/:id/client-qr
```
Или один endpoint:
```text
GET /hui/peers/:id/client-config
```
Response:
```json
{
"uri": "hysteria2://...",
"qrCode": "base64..."
}
```
Важно: этот endpoint должен работать только под admin JWT. Никакой публичной выдачи по `conPass`.
---
## 30. Что удалить на frontend
Из `src/views/account/list/index.vue` убрать:
```text
Subscribe
```
Из будущего peer drawer убрать:
```text
Subscribe
Subscribe QR
```
Из бывшего `src/views/info/account/index.vue` это всё всё равно исчезнет вместе с самой страницей `Account Info`.
Из `src/api/hysteria2/index.ts` убрать:
```ts
hysteria2SubscribeUrlApi
```
Из `src/api/hysteria2/types.ts` убрать:
```ts
Hysteria2SubscribeUrlDto
Hysteria2SubscribeVo
```
Из i18n убрать или оставить deprecated на один релиз:
```text
common.subscribe
common.subscribeQrCode
config.clashExtension
```
Если `CLASH_EXTENSION` используется только для подписки, убрать его из UI настроек. В базе можно не удалять сразу, чтобы не ломать существующие SQLite-файлы. Лучше сделать миграцию так:
```text
релиз N: поле скрыто, backend не использует;
релиз N+1: config key удаляется или игнорируется окончательно.
```
---
## 31. Что оставить вместо подписки
В peer list / peer drawer оставить понятные действия:
```text
Copy URI
Show QR
Rotate Secret
Edit
Reset Traffic
Kick
Disable
Delete
```
В detail drawer можно сделать блок:
```text
Connection
URI: hysteria2://...
[Copy]
[Show QR]
[Rotate secret]
```
И короткий warning:
```text
Rotating secret invalidates existing client configs.
```
Этого достаточно. Оператор создаёт peer, копирует URI или QR, отдаёт клиенту. Без псевдоподписок.
---
## 32. Если когда-нибудь возвращать подписки
Возвращать их стоит только как отдельную feature, не как восстановление старого кода.
Нормальная модель:
```text
subscription_token
id
peer_id
token_digest
expires_at
revoked_at
created_at
last_used_at
```
Публичный endpoint:
```text
GET /sub/:token
```
Token должен быть random, revocable, не равен peer secret. В URL нельзя класть `conPass`.
Формат:
```text
GET /sub/:token?target=clash
GET /sub/:token?target=shadowrocket
GET /sub/:token?target=v2rayn
```
Но я бы сейчас этого не делал. Это увеличит scope, а ценность низкая, если обычные URI/QR уже закрывают основной сценарий.
---
## 33. Acceptance criteria для удаления подписок
```text
В UI нигде нет Subscribe / Subscribe QR.
В peer list нет действий, ведущих к subscription endpoint.
В API клиента нет hysteria2SubscribeUrlApi.
Backend router не регистрирует /hysteria2/hysteria2SubscribeUrl.
Service-код генерации подписок удалён или явно deprecated и недоступен.
Обычный Node URL и Node QR продолжают работать.
Админ не получает Node URL/QR для себя, только для peer’ов.
В настройках нет Clash subscription extension, если подписки отключены.
```
Итог: да, подписку лучше удалить. Не скрыть кнопку CSS’ом, не оставить endpoint с `Fail`, а именно вырезать из публичного UI/API. Это упростит продукт, уберёт ложные кнопки и снизит риск утечки peer secret через URL.
+617
View File
@@ -0,0 +1,617 @@
Посмотрел архив `HY2XS_flamy-main.zip`. Проблема не косметическая: сейчас в проекте смешаны две разные доменные сущности — администратор панели и peer Hysteria2. Это видно прямо по схеме `account`: в одной таблице лежат `username/pass` для входа в админку, `con_pass` для подключения, `quota/download/upload/device_no`, `role`, `deleted`. Логин панели фильтрует `role = 'admin'`, а Hysteria2 auth ищет только по `con_pass` и не отсекает `role = 'admin'`. Поэтому админ технически остаётся peer’ом.
Ниже план нормального продакшен-исправления без «подмазать CSS и добавить if role != admin».
---
## 1. Развести админов панели и peer’ов Hysteria2
### Текущее состояние
Сейчас:
`account.pass` — пароль входа в панель.
`account.con_pass` — пароль подключения Hysteria2.
`account.role` — попытка различать `admin/user`.
`account.quota/download/upload/device_no/expire_time` — peer-поля.
`login_at` — поле админа.
`con_at` — поле peer’а.
Это плохая модель. Роль не должна превращать одну таблицу в две разные сущности.
### Целевая модель
Сделать минимум две таблицы.
`admin_user`:
```sql
id
username
password_hash
status
force_password_change
last_login_at
password_changed_at
token_version
created_at
updated_at
```
`peer`:
```sql
id
name
remark
auth_id
secret_digest
secret_ciphertext -- если нужно показывать URL/QR после создания
quota_bytes
download_bytes
upload_bytes
expires_at
max_devices
disabled
banned_until
last_connection_at
created_at
updated_at
```
`admin_user` не должен иметь `quota`, `con_pass`, `device_no`, `download`, `upload`.
`peer` не должен иметь пароль входа в панель и `role`.
### Как хранить peer secret
Текущий `con_pass` хранится как plain text. Для продакшена лучше уйти от этого.
Вариант нормальный:
`auth_id` — публичный идентификатор peer’а, например короткий random/base32.
`raw_secret` — генерируется при создании или ротации.
`secret_digest = HMAC-SHA256(raw_secret, HY2XS_PEER_SECRET_KEY)` — используется для auth lookup.
`secret_ciphertext` — опционально, если UI должен уметь повторно показать Node URL/QR. Шифровать ключом из data-dir/env, не хранить просто строкой в SQLite.
Если не хочется вводить шифрование сейчас, допустим компромисс: хранить `secret_plain` временно, но уже в таблице `peer`, не рядом с admin password. Потом отдельной миграцией заменить на digest/encrypted secret.
### Миграция
Сделать версионированные миграции, а не держать огромную строку SQL внутри `dao/sqlite.go`.
Сейчас есть два источника схемы: `apps/docs/sql/h_ui_db.sql` и inline `sqlInitStr` в `apps/dao/sqlite.go`. Они уже расходятся: в `sqlite.go` добавлен `force_password_change`, в SQL-доке его нет; `remark` объявлен как `INTEGER DEFAULT ''`, хотя в Go это `string`. Это надо убрать.
Нужен один механизм:
```sql
schema_migrations(version, applied_at)
```
Миграции:
`001_initial_legacy_snapshot.sql` — текущая схема, только для reference.
`002_admin_peer_split.sql` — создаёт `admin_user`, `peer`.
`003_migrate_legacy_accounts.sql` — переносит данные.
`004_drop_or_archive_legacy_account.sql` — не сразу удалять, а переименовать в `legacy_account_backup`.
Правила переноса:
`role = 'admin'` → `admin_user`. Переносить `username`, `pass`, `force_password_change`, `login_at`. Не переносить `con_pass`.
`role != 'admin'` → `peer`. Переносить `username` как `name`, `remark`, лимиты, трафик, `expire_time`, `kick_util_time`, `con_at`.
После миграции admin credentials больше не могут пройти Hysteria2 auth даже теоретически.
---
## 2. Переписать backend-слой по доменам
### Что заменить
Сейчас всё сидит в `account.go`: DAO, service, controller, DTO, VO. Надо разделить:
```text
dao/admin_user.go
dao/peer.go
service/auth.go
service/admin_user.go
service/peer.go
service/hysteria2_auth.go
controller/auth.go
controller/admin_user.go
controller/peer.go
controller/hysteria2.go
```
### API
Оставить `/hui/auth/login`, но он должен работать только с `admin_user`.
Добавить:
```text
GET /hui/admin/me
POST /hui/admin/change-password
GET /hui/peers
POST /hui/peers
GET /hui/peers/:id
PATCH /hui/peers/:id
DELETE /hui/peers/:id
POST /hui/peers/:id/reset-traffic
POST /hui/peers/:id/kick
POST /hui/peers/:id/release-kick
POST /hui/peers/:id/rotate-secret
GET /hui/peers/:id/client-url
GET /hui/peers/:id/qr
```
Старые `/account/*` можно оставить только как compatibility layer на один релиз, но UI уже должен ходить в `/peers/*`.
### Hysteria2 auth
Сейчас `Hysteria2Auth()` делает:
```go
dao.GetAccount("con_pass = ? and deleted = 0 ...")
```
Нужно заменить на peer-auth:
```go
peer, err := peerRepo.FindBySecretDigest(digest)
```
И проверять только peer-поля:
```text
disabled = false
now < expires_at
quota_bytes < 0 OR quota_bytes > download_bytes + upload_bytes
now > banned_until
online_devices < max_devices
```
Admin-таблица здесь вообще не импортируется.
### Сессии и JWT
JWT сейчас содержит `AccountBo` с `Roles`. Оставить можно, но лучше переименовать в `AdminClaims`.
Добавить `token_version` в `admin_user`. Тогда смена пароля, reset или принудительная инвалидизация токенов делается увеличением `token_version`.
После смены пароля обязательно сбрасывать `force_password_change = 0`. Сейчас флаг возвращается из логина, но нормального dedicated flow для смены admin password не видно.
### Reset command
`apps/cmd/reset.go` сейчас меняет row `id=1` в `account`, печатает ещё и `Connection Password`. После разделения:
```text
hy2xs-admin reset-admin
```
Должен менять только `admin_user`.
Никакого connection password для админа печатать нельзя.
---
## 3. UI: переименовать “Account Manage” в peer management
В интерфейсе сейчас название “Account” вводит в заблуждение. Там смешаны профиль админа и peer’ы. Нужна терминология:
```text
Профиль / Admin Profile
Пиры / Peers
Управление пирами / Peer Management
```
В форме создания peer убрать поле “Пароль входа”. Для peer нужен только generated/rotatable connection secret.
Нормальная форма создания peer:
```text
Комментарий
Имя / label
Квота
Срок действия
Лимит устройств
Статус
[Создать]
```
После создания показать одноразовый блок:
```text
Connection URI
QR
Copy
Сохраните сейчас: после закрытия секрет может быть скрыт
```
Если оставляете encrypted secret, можно показывать URL и позже.
---
## 4. Починить русскую локаль и “плывущие” иконки правильно
### Причина
Это не проблема русского языка как такового. Русские строки длиннее, а sidebar сейчас завязан на дефолтный layout Element Plus плюс ручные margin’ы:
```scss
.svg-icon { margin-right: 16px; }
.hideSidebar .el-sub-menu__title { padding: 0 !important; }
.hideSidebar .svg-icon { margin-left: 20px; }
```
Из-за этого при длинных заголовках, collapse/open state и sub-menu иконки начинают жить отдельно от текста.
### Исправление
В `SidebarItem.vue` обернуть title в отдельный span:
```vue
<span class="menu-title">
{{ translateRouteTitleI18n(...) }}
</span>
```
Для `el-menu-item` и `el-sub-menu__title` задать один стабильный layout:
```scss
.sidebar-container {
.el-menu-item,
.el-sub-menu__title {
display: flex;
align-items: center;
gap: 12px;
height: 48px;
line-height: normal;
padding: 0 16px !important;
}
.svg-icon {
flex: 0 0 18px;
width: 18px;
height: 18px;
margin-right: 0;
}
.menu-title {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.el-sub-menu__icon-arrow {
margin-left: auto;
}
}
```
Убрать ручные `margin-left: 20px` для collapsed state. Для collapsed меню текст скрывать штатно, а не через плавающие отступы.
Ширину sidebar лучше поднять с `210px` до `240px` или `248px`. Но это вторично. Основной фикс — стабильная flex/grid-разметка.
Для длинных пунктов включить tooltip с полным названием при hover. Не переносить текст на вторую строку внутри sidebar.
---
## 5. Перевести уведомления и ошибки без хардкода
Сейчас часть UI переведена через `$t`, но много строк осталось захардкоженными:
```text
"Required"
"Username format is incorrect"
"Are you sure to reset traffic?"
"Are you sure to delete..."
"Warning"
"A week later"
"file format not supported"
```
Плюс backend возвращает английские строки:
```text
wrong password
system error
permission denied
username already exists
admin cannot be deleted
```
### Frontend
Добавить namespace:
```ts
validation: {
required,
usernameInvalid,
passwordInvalid,
peerSecretInvalid,
integer,
number,
fileFormat,
fileTooLarge,
}
confirm: {
warning,
deletePeer,
resetTraffic,
restartPanel,
}
timeShortcut: {
hourLater,
dayLater,
weekLater,
monthLater,
yearLater,
}
```
Все validation rules сделать через `computed`, чтобы при смене языка сообщения обновлялись:
```ts
const dataFormRules = computed(() => ({
username: [
{ required: true, message: t("validation.required"), trigger: ["blur"] },
],
}))
```
Подключить `ElConfigProvider` в `App.vue` и прокидывать locale Element Plus:
```vue
<el-config-provider :locale="elementLocale">
<router-view />
</el-config-provider>
```
Иначе встроенные компоненты Element Plus, datepicker, pagination и popconfirm будут жить своей локалью.
### Backend
Не возвращать UI-текст как источник истины. Возвращать стабильный error code/message key:
```json
{
"code": 40010,
"type": "no",
"message": "peer.usernameAlreadyExists",
"params": { "username": "testuser" }
}
```
Frontend переводит `message` как i18n key. Если key неизвестен — fallback на `common.systemError`.
Для machine endpoint `/hysteria2/auth` локализация не нужна, там протокольный ответ `{ ok: true/false, id }`.
---
## 6. Сжать таблицу peer’ов до production-вида
Сейчас таблица перегружена. В `account/list/index.vue` одновременно выводятся:
```text
ID
Remark
Username
Role
Quota
Download
Upload
Online status
Online devices
Device limit
Offline remaining time
Expire time
Last login time
Last connection time
Create time
Status
Operate
```
Для peer list это слишком много. На широком экране оно всё равно не будет хорошо читаться.
### Основной список
Оставить в таблице только:
```text
Peer
Status
Traffic
Devices
Expires
Last connection
Actions
```
Где:
`Peer` — имя + remark + ID мелким текстом.
`Status` — enabled/disabled + online/offline.
`Traffic` — progress bar: used / quota, а upload/download спрятать в details.
`Devices` — online / max.
`Expires` — дата + “expired soon/expired” tag.
`Last connection` — дата или `-`.
`Actions` — 1–2 основные кнопки и dropdown.
### Details drawer / overview dialog
По клику “Обзор” открыть drawer:
```text
Peer overview
- ID
- Name
- Remark
- Created at
- Updated at
- Download
- Upload
- Quota
- Expires at
- Last connection
- Kick until
- Device limit
- Current online devices
- Node URL
- QR
```
Это лучше, чем прятать половину в троеточие без структуры.
### Actions
В таблице оставить:
```text
Copy URL
Edit
```
В dropdown:
```text
Show QR
Reset traffic
Kick
Release kick
Rotate secret
Disable / Enable
Delete
```
`Subscribe` сейчас в baseline отключён, значит в UI его лучше не показывать, пока delivery layer вне scope. Иначе оператор видит кнопку, которая всегда ведёт к ошибке.
### Поведение на малых экранах
На desktop можно оставить `el-table`.
На tablet/mobile лучше отдельный card layout, а не пытаться ужать таблицу. Например:
```text
Peer card
name / status
traffic progress
devices
expires
actions
```
---
## 7. Конкретный порядок работ
### Этап A. Быстрый security hotfix
Это временный фикс, не финальная архитектура.
1. В `Hysteria2Auth` добавить фильтр `role = 'user'`.
2. В `PageAccount` по умолчанию показывать только `role = 'user'`.
3. В `SaveAccount` явно ставить `role = 'user'`.
4. Запретить выдачу Node URL/QR для `role = 'admin'`.
5. В `reset.go` убрать вывод connection password.
Это закрывает самый опасный баг до большой миграции.
### Этап B. Нормальная доменная миграция
1. Добавить `schema_migrations`.
2. Создать `admin_user`.
3. Создать `peer`.
4. Перенести legacy data.
5. Переписать DAO/service/controller.
6. Оставить legacy `account` только как backup.
7. Добавить `reset-admin`.
8. Убрать `role` из peer flow.
### Этап C. UI/i18n
1. Переименовать раздел `Account Manage` → `Peer Manage`.
2. Убрать peer login password из формы.
3. Сделать i18n keys для всех validation/confirm/toast строк.
4. Подключить Element Plus locale provider.
5. Убрать backend English strings из UI-отображения.
6. Добавить проверку отсутствующих i18n keys в CI.
### Этап D. Layout/sidebar
1. Переписать sidebar CSS на flex/grid.
2. Убрать ручные margin hacks.
3. Добавить `.menu-title` с ellipsis.
4. Поднять sidebar width до 240248px.
5. Проверить RU/EN в expanded/collapsed состояниях.
### Этап E. Peer table redesign
1. Сделать компактные колонки.
2. Вынести details в drawer.
3. Перенести второстепенные действия в dropdown.
4. Скрыть subscription actions, если delivery layer отключён.
5. Добавить responsive card layout.
---
## 8. Тесты и критерии готовности
Backend tests:
```text
admin_user может войти в панель
admin_user не может пройти Hysteria2 auth
peer не может войти в панель
peer может пройти Hysteria2 auth
expired peer rejected
disabled peer rejected
quota-exceeded peer rejected
device-limit peer rejected
legacy migration переносит admin и peers корректно
```
Frontend checks:
```text
pnpm lint:eslint
pnpm build:prod
vue-tsc --noEmit
```
E2E/screenshot:
```text
RU sidebar expanded
RU sidebar collapsed
EN sidebar expanded
EN sidebar collapsed
Peer table 1366px
Peer table 1920px
Peer card/mobile layout
```
Acceptance criteria:
```text
Админ панели не отображается в списке peer’ов.
У админа нет Node URL, QR и connection password.
Peer не имеет password для входа в UI.
Все toast/confirm/validation сообщения переводятся.
Русская локаль не ломает sidebar.
Peer list не требует горизонтального скролла на 1366px.
Второстепенные peer-поля доступны через “Обзор”.
```
Главная мысль: не чинить это через `role` и CSS-отступы. Правильный продакшен-фикс — разделить admin identity и peer identity на уровне схемы, сервисов и UI. После этого локаль и таблица чинятся уже как нормальная фронтенд-задача, а не как борьба с последствиями смешанной модели.