diff --git a/apps/cmd/reset.go b/apps/cmd/reset.go index f5c7508..6edc00b 100644 --- a/apps/cmd/reset.go +++ b/apps/cmd/reset.go @@ -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))) } diff --git a/apps/controller/admin_user.go b/apps/controller/admin_user.go new file mode 100644 index 0000000..f2de848 --- /dev/null +++ b/apps/controller/admin_user.go @@ -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) +} + diff --git a/apps/controller/config.go b/apps/controller/config.go index 7694aa8..36ff568 100644 --- a/apps/controller/config.go +++ b/apps/controller/config.go @@ -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) diff --git a/apps/controller/dashboard.go b/apps/controller/dashboard.go new file mode 100644 index 0000000..48a79a6 --- /dev/null +++ b/apps/controller/dashboard.go @@ -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) +} + diff --git a/apps/controller/hysteria2.go b/apps/controller/hysteria2.go index 0373586..2ce0a20 100644 --- a/apps/controller/hysteria2.go +++ b/apps/controller/hysteria2.go @@ -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) -} diff --git a/apps/controller/monitor.go b/apps/controller/monitor.go deleted file mode 100644 index e3e6502..0000000 --- a/apps/controller/monitor.go +++ /dev/null @@ -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) -} diff --git a/apps/controller/account.go b/apps/controller/peer.go similarity index 66% rename from apps/controller/account.go rename to apps/controller/peer.go index 63f9627..bf616a5 100644 --- a/apps/controller/account.go +++ b/apps/controller/peer.go @@ -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) } diff --git a/apps/dao/account.go b/apps/dao/account.go deleted file mode 100644 index 37752fe..0000000 --- a/apps/dao/account.go +++ /dev/null @@ -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 -} diff --git a/apps/dao/admin_user.go b/apps/dao/admin_user.go new file mode 100644 index 0000000..e8e06c3 --- /dev/null +++ b/apps/dao/admin_user.go @@ -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 +} + diff --git a/apps/dao/dashboard.go b/apps/dao/dashboard.go new file mode 100644 index 0000000..7d1202d --- /dev/null +++ b/apps/dao/dashboard.go @@ -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 +} + diff --git a/apps/dao/peer.go b/apps/dao/peer.go new file mode 100644 index 0000000..1f49b40 --- /dev/null +++ b/apps/dao/peer.go @@ -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 +} + diff --git a/apps/dao/sqlite.go b/apps/dao/sqlite.go index abb4ffc..843d15c 100644 --- a/apps/dao/sqlite.go +++ b/apps/dao/sqlite.go @@ -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: "a, - 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: "a, + 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)) } } + diff --git a/apps/dao/traffic.go b/apps/dao/traffic.go new file mode 100644 index 0000000..cd725e5 --- /dev/null +++ b/apps/dao/traffic.go @@ -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) +} + diff --git a/apps/frontend/.eslintrc.cjs b/apps/frontend/.eslintrc.cjs index 5cb7027..097d04d 100644 --- a/apps/frontend/.eslintrc.cjs +++ b/apps/frontend/.eslintrc.cjs @@ -29,4 +29,3 @@ module.exports = { OptionType: "readonly", }, }; -цц diff --git a/apps/frontend/src/api/account/index.ts b/apps/frontend/src/api/account/index.ts deleted file mode 100644 index f70b8f1..0000000 --- a/apps/frontend/src/api/account/index.ts +++ /dev/null @@ -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 { - 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 { - return request({ - url: "/account/getAccountInfo", - method: "get", - }); -} - -/** - * Пагинация - * @param data - */ -export function pageAccountApi( - data: AccountPageDto -): AxiosPromise> { - 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 { - 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", - }); -} - - diff --git a/apps/frontend/src/api/admin/index.ts b/apps/frontend/src/api/admin/index.ts new file mode 100644 index 0000000..f21ac24 --- /dev/null +++ b/apps/frontend/src/api/admin/index.ts @@ -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 { + return request({ + url: "/auth/login", + method: "post", + data, + }); +} + +export function getAdminInfoApi(): AxiosPromise { + return request({ + url: "/admin/me", + method: "get", + }); +} + +export function verifyDefaultPassApi(): AxiosPromise { + return request({ + url: "/admin/verify-default-pass", + method: "get", + }); +} + diff --git a/apps/frontend/src/api/admin/types.ts b/apps/frontend/src/api/admin/types.ts new file mode 100644 index 0000000..fb71679 --- /dev/null +++ b/apps/frontend/src/api/admin/types.ts @@ -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[]; +} + diff --git a/apps/frontend/src/api/dashboard/index.ts b/apps/frontend/src/api/dashboard/index.ts new file mode 100644 index 0000000..1d392cf --- /dev/null +++ b/apps/frontend/src/api/dashboard/index.ts @@ -0,0 +1,39 @@ +import request from "@/utils/request"; +import { AxiosPromise } from "axios"; +import { + DashboardSummaryVo, + DashboardTimeseriesVo, + DashboardTopPeerVo, + SecurityRiskVo, +} from "./types"; + +export function dashboardSummaryApi(): AxiosPromise { + return request({ + url: "/dashboard/summary", + method: "get", + }); +} + +export function dashboardTimeseriesApi(range = "24h"): AxiosPromise { + return request({ + url: "/dashboard/timeseries", + method: "get", + params: { range }, + }); +} + +export function dashboardTopPeersApi(range = "24h", limit = 10): AxiosPromise { + return request({ + url: "/dashboard/top-peers", + method: "get", + params: { range, limit }, + }); +} + +export function dashboardSecurityApi(): AxiosPromise { + return request({ + url: "/dashboard/security", + method: "get", + }); +} + diff --git a/apps/frontend/src/api/dashboard/types.ts b/apps/frontend/src/api/dashboard/types.ts new file mode 100644 index 0000000..4e7debd --- /dev/null +++ b/apps/frontend/src/api/dashboard/types.ts @@ -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; +} + diff --git a/apps/frontend/src/api/hysteria2/index.ts b/apps/frontend/src/api/hysteria2/index.ts index c46b6df..59fe44d 100644 --- a/apps/frontend/src/api/hysteria2/index.ts +++ b/apps/frontend/src/api/hysteria2/index.ts @@ -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 { - return request({ - url: "/hysteria2/hysteria2SubscribeUrl", - method: "get", - params: dto, - }); -} - export function hysteria2UrlApi( dto: Hysteria2UrlDto ): AxiosPromise { diff --git a/apps/frontend/src/api/hysteria2/types.ts b/apps/frontend/src/api/hysteria2/types.ts index e9032f1..0c95dd0 100644 --- a/apps/frontend/src/api/hysteria2/types.ts +++ b/apps/frontend/src/api/hysteria2/types.ts @@ -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; } - diff --git a/apps/frontend/src/api/monitor/index.ts b/apps/frontend/src/api/monitor/index.ts deleted file mode 100644 index a2edb21..0000000 --- a/apps/frontend/src/api/monitor/index.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { AxiosPromise } from "axios"; -import request from "@/utils/request"; -import { Hysteria2MonitorVo, SystemMonitorVo } from "@/api/monitor/types"; - -export function monitorSystemApi(): AxiosPromise { - return request({ - url: "/monitor/monitorSystem", - method: "get", - }); -} - -export function monitorHysteria2Api(): AxiosPromise { - return request({ - url: "/monitor/monitorHysteria2", - method: "get", - }); -} - - diff --git a/apps/frontend/src/api/monitor/types.ts b/apps/frontend/src/api/monitor/types.ts deleted file mode 100644 index 7424c1f..0000000 --- a/apps/frontend/src/api/monitor/types.ts +++ /dev/null @@ -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; -} - - diff --git a/apps/frontend/src/api/peer/index.ts b/apps/frontend/src/api/peer/index.ts new file mode 100644 index 0000000..2f7fb60 --- /dev/null +++ b/apps/frontend/src/api/peer/index.ts @@ -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 { + 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> { + 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", + }); +} + diff --git a/apps/frontend/src/api/account/types.ts b/apps/frontend/src/api/peer/types.ts similarity index 63% rename from apps/frontend/src/api/account/types.ts rename to apps/frontend/src/api/peer/types.ts index 696f379..956b4fb 100644 --- a/apps/frontend/src/api/account/types.ts +++ b/apps/frontend/src/api/peer/types.ts @@ -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; } - diff --git a/apps/frontend/src/assets/logo.png b/apps/frontend/src/assets/logo.png index 51bff2a..4b630fc 100644 Binary files a/apps/frontend/src/assets/logo.png and b/apps/frontend/src/assets/logo.png differ diff --git a/apps/frontend/src/components/LangSelect/index.vue b/apps/frontend/src/components/LangSelect/index.vue index a433b72..c6c60cb 100644 --- a/apps/frontend/src/components/LangSelect/index.vue +++ b/apps/frontend/src/components/LangSelect/index.vue @@ -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")); } } diff --git a/apps/frontend/src/components/MapAdd/index.vue b/apps/frontend/src/components/MapAdd/index.vue index b671064..ff769df 100644 --- a/apps/frontend/src/components/MapAdd/index.vue +++ b/apps/frontend/src/components/MapAdd/index.vue @@ -77,6 +77,7 @@ export default { diff --git a/apps/frontend/src/components/UnitSelect/index.vue b/apps/frontend/src/components/UnitSelect/index.vue index d5fa618..31419ad 100644 --- a/apps/frontend/src/components/UnitSelect/index.vue +++ b/apps/frontend/src/components/UnitSelect/index.vue @@ -11,7 +11,7 @@ /> + diff --git a/apps/frontend/src/directive/permission/index.ts b/apps/frontend/src/directive/permission/index.ts index b9ba15f..020e56a 100644 --- a/apps/frontend/src/directive/permission/index.ts +++ b/apps/frontend/src/directive/permission/index.ts @@ -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); }); diff --git a/apps/frontend/src/lang/package/en.ts b/apps/frontend/src/lang/package/en.ts index 93e4d59..2b983bc 100644 --- a/apps/frontend/src/lang/package/en.ts +++ b/apps/frontend/src/lang/package/en.ts @@ -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. Click here 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. Click here to change`, noHttpsTip: `Your website is not using HTTPS, making data transmission insecure, Please enable HTTPS as soon as possible to protect user information. Click here 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", diff --git a/apps/frontend/src/lang/package/ru.ts b/apps/frontend/src/lang/package/ru.ts index e8c637f..d277e9b 100644 --- a/apps/frontend/src/lang/package/ru.ts +++ b/apps/frontend/src/lang/package/ru.ts @@ -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: `Смените пароль по умолчанию как можно скорее. Перейти к смене`, + defaultPassTip: `Смените пароль по умолчанию как можно скорее. Перейти к смене`, noHttpsTip: `Панель работает без HTTPS. Включите HTTPS для защиты данных. Открыть настройки`, + 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: { diff --git a/apps/frontend/src/layout/components/Navbar.vue b/apps/frontend/src/layout/components/Navbar.vue index 1178364..56ecac3 100644 --- a/apps/frontend/src/layout/components/Navbar.vue +++ b/apps/frontend/src/layout/components/Navbar.vue @@ -1,13 +1,15 @@ + + + diff --git a/apps/frontend/src/views/hysteria/list/components/Outbounds/index.vue b/apps/frontend/src/views/hysteria/list/components/Outbounds/index.vue index 93f67dd..c87c6f2 100644 --- a/apps/frontend/src/views/hysteria/list/components/Outbounds/index.vue +++ b/apps/frontend/src/views/hysteria/list/components/Outbounds/index.vue @@ -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") { diff --git a/apps/frontend/src/views/hysteria/list/index.vue b/apps/frontend/src/views/hysteria/list/index.vue index 3bf6320..9fc5099 100644 --- a/apps/frontend/src/views/hysteria/list/index.vue +++ b/apps/frontend/src/views/hysteria/list/index.vue @@ -63,19 +63,6 @@ - - - - - (["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); diff --git a/apps/frontend/src/views/info/account/index.vue b/apps/frontend/src/views/info/account/index.vue deleted file mode 100644 index 6f70a6d..0000000 --- a/apps/frontend/src/views/info/account/index.vue +++ /dev/null @@ -1,337 +0,0 @@ - - - - - - - - - diff --git a/apps/frontend/src/views/login/index.vue b/apps/frontend/src/views/login/index.vue index 44a1fe6..73bd0bb 100644 --- a/apps/frontend/src/views/login/index.vue +++ b/apps/frontend/src/views/login/index.vue @@ -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({ +const loginForm = ref({ 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 = () => { } } - - diff --git a/apps/frontend/src/views/monitor/system/index.vue b/apps/frontend/src/views/monitor/system/index.vue deleted file mode 100644 index 5be085f..0000000 --- a/apps/frontend/src/views/monitor/system/index.vue +++ /dev/null @@ -1,224 +0,0 @@ - - - - - - - - - diff --git a/apps/frontend/src/views/account/list/index.vue b/apps/frontend/src/views/peer/list/index.vue similarity index 78% rename from apps/frontend/src/views/account/list/index.vue rename to apps/frontend/src/views/peer/list/index.vue index d7cfd5a..2cd62e4 100644 --- a/apps/frontend/src/views/account/list/index.vue +++ b/apps/frontend/src/views/peer/list/index.vue @@ -2,19 +2,19 @@