mirror of
https://github.com/Buriburizaem0n/nezha_domains.git
synced 2026-05-06 13:48:52 +00:00
e61772e858
* feat: tsdb * fix(ci): remove --parseGoList=false from swag init to fix dependency resolution * fix(ci): fix swag init directory and temporary remove s390x support due to cgo issues * fix(ci): fix swag init output directory to cmd/dashboard/docs * fix(ci): set GOTOOLCHAIN=auto for gosec * feat: add system storage maintenance for SQLite and TSDB * shit * feat: add s390x support and improve service monitoring * ci: upgrade goreleaser-cross image to v1.25 * ci: add libzstd-dev:s390x for cross-compilation * ci: build libzstd for s390x from source * ci: add libzstd_linux_s390x.go for gozstd linking * ci: use vendor mode for s390x gozstd build * ci: clone zstd source for s390x build * refactor(tsdb): rename MaxDiskUsageGB to MinFreeDiskSpaceGB and optimize queries - Rename config to accurately reflect VictoriaMetrics behavior: minimum free disk space threshold - Add QueryServiceHistoryByServerID for batch query optimization - Fix hasStatus to avoid false status counting when only delay data exists - Fix service aggregation boundary: use successCount*2 >= count - Fix serviceID parsing with strconv.ParseUint error handling - Add TagFiltersCacheSize for better query performance * feat(api): add server metrics endpoint and simplify service history response - Add /server/:id/metrics API for querying TSDB server metrics - Simplify getServiceHistory by removing redundant data conversion - Change AvgDelay type from float32 to float64 - Remove generated swagger docs (to be regenerated) - Update TSDB query, writer and tests * chore: 临时禁用不支持前端 * ci: cache zstd build for s390x to speed up CI * fix(tsdb): fix race conditions, data correctness and optimize performance - Fix TOCTOU race between IsClosed() and write/query by holding RLock - Fix delay=0 excluded from stats by using hasDelay flag instead of value > 0 - Fix fmt.Sscanf -> strconv.ParseUint for server_id parsing with error logging - Fix buffer unbounded growth by flushing inside lock when over maxSize - Split makeMetricRow into makeServerMetricRow/makeServiceMetricRow - Extract InitGlobalSettings() from Open() for VictoriaMetrics globals - Remove redundant instance/GetInstance/SetInstance singleton - Add error logging for silently skipped block decode errors - Optimize WriteBatch* to build all rows in single write call - Optimize downsample to use linear scan instead of map for sorted data - Optimize query slice reuse across block iterations * 服务添加DisplayIndex (#1166) * 服务添加DisplayIndex * 根据ai建议修改 --------- Co-authored-by: huYang <306061454@qq.com> * fix(tsdb): restore SQLite fallback and monthly status reload on restart - Restore ServiceHistory model and SQLite write fallback when TSDB is disabled - Reload monthlyStatus (30-day) and serviceStatusToday from TSDB/SQLite on startup - Add SQLite fallback query for /service/:id/history and /server/:id/service - Remove breaking GET /service/:id endpoint, keep /service/:id/history only - Add QueryServiceDailyStats to TSDB for per-day aggregation - Add tests for monthly status and today stats loading from both TSDB and SQLite - Migrate ServiceHistory table only when TSDB is disabled * ci: exclude false-positive gosec rules G117, G703, G704 * feat(api): expose tsdb_enabled in setting response * ci: restore G115 exclusion accidentally dropped in previous commit * fix: update version numbers for OfficialAdmin and Official templates * chore: upgrade frontend * chore: upgrade frontend --------- Co-authored-by: 胡说丷刂 <34758853+laosan-xx@users.noreply.github.com> Co-authored-by: huYang <306061454@qq.com>
456 lines
12 KiB
Go
456 lines
12 KiB
Go
package controller
|
|
|
|
import (
|
|
"slices"
|
|
"strconv"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/goccy/go-json"
|
|
"github.com/jinzhu/copier"
|
|
"gorm.io/gorm"
|
|
|
|
"github.com/nezhahq/nezha/model"
|
|
"github.com/nezhahq/nezha/pkg/tsdb"
|
|
pb "github.com/nezhahq/nezha/proto"
|
|
"github.com/nezhahq/nezha/service/singleton"
|
|
)
|
|
|
|
// List server
|
|
// @Summary List server
|
|
// @Security BearerAuth
|
|
// @Schemes
|
|
// @Description List server
|
|
// @Tags auth required
|
|
// @Param id query uint false "Resource ID"
|
|
// @Produce json
|
|
// @Success 200 {object} model.CommonResponse[[]model.Server]
|
|
// @Router /server [get]
|
|
func listServer(c *gin.Context) ([]*model.Server, error) {
|
|
slist := singleton.ServerShared.GetSortedList()
|
|
|
|
var ssl []*model.Server
|
|
if err := copier.Copy(&ssl, &slist); err != nil {
|
|
return nil, err
|
|
}
|
|
return ssl, nil
|
|
}
|
|
|
|
// Edit server
|
|
// @Summary Edit server
|
|
// @Security BearerAuth
|
|
// @Schemes
|
|
// @Description Edit server
|
|
// @Tags auth required
|
|
// @Accept json
|
|
// @Param id path uint true "Server ID"
|
|
// @Param body body model.ServerForm true "ServerForm"
|
|
// @Produce json
|
|
// @Success 200 {object} model.CommonResponse[any]
|
|
// @Router /server/{id} [patch]
|
|
func updateServer(c *gin.Context) (any, error) {
|
|
idStr := c.Param("id")
|
|
id, err := strconv.ParseUint(idStr, 10, 64)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var sf model.ServerForm
|
|
if err := c.ShouldBindJSON(&sf); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if !singleton.DDNSShared.CheckPermission(c, slices.Values(sf.DDNSProfiles)) {
|
|
return nil, singleton.Localizer.ErrorT("permission denied")
|
|
}
|
|
|
|
var s model.Server
|
|
if err := singleton.DB.First(&s, id).Error; err != nil {
|
|
return nil, singleton.Localizer.ErrorT("server id %d does not exist", id)
|
|
}
|
|
|
|
if !s.HasPermission(c) {
|
|
return nil, singleton.Localizer.ErrorT("permission denied")
|
|
}
|
|
|
|
s.Name = sf.Name
|
|
s.DisplayIndex = sf.DisplayIndex
|
|
s.Note = sf.Note
|
|
s.PublicNote = sf.PublicNote
|
|
s.HideForGuest = sf.HideForGuest
|
|
s.EnableDDNS = sf.EnableDDNS
|
|
s.DDNSProfiles = sf.DDNSProfiles
|
|
s.OverrideDDNSDomains = sf.OverrideDDNSDomains
|
|
|
|
ddnsProfilesRaw, err := json.Marshal(s.DDNSProfiles)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
s.DDNSProfilesRaw = string(ddnsProfilesRaw)
|
|
|
|
overrideDomainsRaw, err := json.Marshal(sf.OverrideDDNSDomains)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
s.OverrideDDNSDomainsRaw = string(overrideDomainsRaw)
|
|
|
|
if err := singleton.DB.Save(&s).Error; err != nil {
|
|
return nil, newGormError("%v", err)
|
|
}
|
|
|
|
rs, _ := singleton.ServerShared.Get(s.ID)
|
|
s.CopyFromRunningServer(rs)
|
|
singleton.ServerShared.Update(&s, "")
|
|
|
|
return nil, nil
|
|
}
|
|
|
|
// Batch delete server
|
|
// @Summary Batch delete server
|
|
// @Security BearerAuth
|
|
// @Schemes
|
|
// @Description Batch delete server
|
|
// @Tags auth required
|
|
// @Accept json
|
|
// @param request body []uint64 true "id list"
|
|
// @Produce json
|
|
// @Success 200 {object} model.CommonResponse[any]
|
|
// @Router /batch-delete/server [post]
|
|
func batchDeleteServer(c *gin.Context) (any, error) {
|
|
var servers []uint64
|
|
if err := c.ShouldBindJSON(&servers); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if !singleton.ServerShared.CheckPermission(c, slices.Values(servers)) {
|
|
return nil, singleton.Localizer.ErrorT("permission denied")
|
|
}
|
|
|
|
err := singleton.DB.Transaction(func(tx *gorm.DB) error {
|
|
if err := tx.Unscoped().Delete(&model.Server{}, "id in (?)", servers).Error; err != nil {
|
|
return err
|
|
}
|
|
if err := tx.Unscoped().Delete(&model.ServerGroupServer{}, "server_id in (?)", servers).Error; err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
})
|
|
|
|
if err != nil {
|
|
return nil, newGormError("%v", err)
|
|
}
|
|
|
|
singleton.AlertsLock.Lock()
|
|
for _, sid := range servers {
|
|
for _, alert := range singleton.Alerts {
|
|
if singleton.AlertsCycleTransferStatsStore[alert.ID] != nil {
|
|
delete(singleton.AlertsCycleTransferStatsStore[alert.ID].ServerName, sid)
|
|
delete(singleton.AlertsCycleTransferStatsStore[alert.ID].Transfer, sid)
|
|
delete(singleton.AlertsCycleTransferStatsStore[alert.ID].NextUpdate, sid)
|
|
}
|
|
}
|
|
}
|
|
singleton.DB.Unscoped().Delete(&model.Transfer{}, "server_id in (?)", servers)
|
|
singleton.AlertsLock.Unlock()
|
|
|
|
singleton.ServerShared.Delete(servers)
|
|
return nil, nil
|
|
}
|
|
|
|
// Force update Agent
|
|
// @Summary Force update Agent
|
|
// @Security BearerAuth
|
|
// @Schemes
|
|
// @Description Force update Agent
|
|
// @Tags auth required
|
|
// @Accept json
|
|
// @param request body []uint64 true "id list"
|
|
// @Produce json
|
|
// @Success 200 {object} model.CommonResponse[model.ServerTaskResponse]
|
|
// @Router /force-update/server [post]
|
|
func forceUpdateServer(c *gin.Context) (*model.ServerTaskResponse, error) {
|
|
var forceUpdateServers []uint64
|
|
if err := c.ShouldBindJSON(&forceUpdateServers); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
forceUpdateResp := new(model.ServerTaskResponse)
|
|
|
|
for _, sid := range forceUpdateServers {
|
|
server, _ := singleton.ServerShared.Get(sid)
|
|
if server != nil && server.TaskStream != nil {
|
|
if !server.HasPermission(c) {
|
|
return nil, singleton.Localizer.ErrorT("permission denied")
|
|
}
|
|
if err := server.TaskStream.Send(&pb.Task{
|
|
Type: model.TaskTypeUpgrade,
|
|
}); err != nil {
|
|
forceUpdateResp.Failure = append(forceUpdateResp.Failure, sid)
|
|
} else {
|
|
forceUpdateResp.Success = append(forceUpdateResp.Success, sid)
|
|
}
|
|
} else {
|
|
forceUpdateResp.Offline = append(forceUpdateResp.Offline, sid)
|
|
}
|
|
}
|
|
|
|
return forceUpdateResp, nil
|
|
}
|
|
|
|
// Get server config
|
|
// @Summary Get server config
|
|
// @Security BearerAuth
|
|
// @Schemes
|
|
// @Description Get server config
|
|
// @Tags auth required
|
|
// @Produce json
|
|
// @Success 200 {object} model.CommonResponse[string]
|
|
// @Router /server/config/{id} [get]
|
|
func getServerConfig(c *gin.Context) (string, error) {
|
|
idStr := c.Param("id")
|
|
id, err := strconv.ParseUint(idStr, 10, 64)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
s, ok := singleton.ServerShared.Get(id)
|
|
if !ok || s.TaskStream == nil {
|
|
return "", nil
|
|
}
|
|
|
|
if !s.HasPermission(c) {
|
|
return "", singleton.Localizer.ErrorT("permission denied")
|
|
}
|
|
|
|
if err := s.TaskStream.Send(&pb.Task{
|
|
Type: model.TaskTypeReportConfig,
|
|
}); err != nil {
|
|
return "", err
|
|
}
|
|
|
|
timeout := time.NewTimer(time.Second * 10)
|
|
select {
|
|
case <-timeout.C:
|
|
return "", singleton.Localizer.ErrorT("operation timeout")
|
|
case data := <-s.ConfigCache:
|
|
timeout.Stop()
|
|
switch data := data.(type) {
|
|
case string:
|
|
return data, nil
|
|
case error:
|
|
return "", singleton.Localizer.ErrorT("get server config failed: %v", data)
|
|
}
|
|
}
|
|
|
|
return "", singleton.Localizer.ErrorT("get server config failed")
|
|
}
|
|
|
|
// Set server config
|
|
// @Summary Set server config
|
|
// @Security BearerAuth
|
|
// @Schemes
|
|
// @Description Set server config
|
|
// @Tags auth required
|
|
// @Accept json
|
|
// @Param body body model.ServerConfigForm true "ServerConfigForm"
|
|
// @Produce json
|
|
// @Success 200 {object} model.CommonResponse[model.ServerTaskResponse]
|
|
// @Router /server/config [post]
|
|
func setServerConfig(c *gin.Context) (*model.ServerTaskResponse, error) {
|
|
var configForm model.ServerConfigForm
|
|
if err := c.ShouldBindJSON(&configForm); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var resp model.ServerTaskResponse
|
|
slist := singleton.ServerShared.GetList()
|
|
servers := make([]*model.Server, 0, len(configForm.Servers))
|
|
for _, sid := range configForm.Servers {
|
|
if s, ok := slist[sid]; ok {
|
|
if !s.HasPermission(c) {
|
|
return nil, singleton.Localizer.ErrorT("permission denied")
|
|
}
|
|
if s.TaskStream == nil {
|
|
resp.Offline = append(resp.Offline, s.ID)
|
|
continue
|
|
}
|
|
servers = append(servers, s)
|
|
}
|
|
}
|
|
|
|
var wg sync.WaitGroup
|
|
var respMu sync.Mutex
|
|
|
|
for i := 0; i < len(servers); i += 10 {
|
|
end := min(i+10, len(servers))
|
|
group := servers[i:end]
|
|
|
|
wg.Add(1)
|
|
go func(srvGroup []*model.Server) {
|
|
defer wg.Done()
|
|
for _, s := range srvGroup {
|
|
// Create and send the task.
|
|
task := &pb.Task{
|
|
Type: model.TaskTypeApplyConfig,
|
|
Data: configForm.Config,
|
|
}
|
|
if err := s.TaskStream.Send(task); err != nil {
|
|
respMu.Lock()
|
|
resp.Failure = append(resp.Failure, s.ID)
|
|
respMu.Unlock()
|
|
continue
|
|
}
|
|
respMu.Lock()
|
|
resp.Success = append(resp.Success, s.ID)
|
|
respMu.Unlock()
|
|
}
|
|
}(group)
|
|
}
|
|
|
|
wg.Wait()
|
|
return &resp, nil
|
|
}
|
|
|
|
// Batch move servers to other user
|
|
// @Summary Batch move servers to other user
|
|
// @Security BearerAuth
|
|
// @Schemes
|
|
// @Description Batch move servers to other user
|
|
// @Tags auth required
|
|
// @Accept json
|
|
// @Param request body model.BatchMoveServerForm true "BatchMoveServerForm"
|
|
// @Produce json
|
|
// @Success 200 {object} model.CommonResponse[any]
|
|
// @Router /batch-move/server [post]
|
|
func batchMoveServer(c *gin.Context) (any, error) {
|
|
var moveForm model.BatchMoveServerForm
|
|
if err := c.ShouldBindJSON(&moveForm); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if !singleton.ServerShared.CheckPermission(c, slices.Values(moveForm.Ids)) {
|
|
return nil, singleton.Localizer.ErrorT("permission denied")
|
|
}
|
|
|
|
if moveForm.ToUser == 0 {
|
|
return nil, singleton.Localizer.ErrorT("user id is required")
|
|
}
|
|
|
|
singleton.UserLock.RLock()
|
|
defer singleton.UserLock.RUnlock()
|
|
if _, ok := singleton.UserInfoMap[moveForm.ToUser]; !ok {
|
|
return nil, singleton.Localizer.ErrorT("user id %d does not exist", moveForm.ToUser)
|
|
}
|
|
|
|
err := singleton.DB.Transaction(func(tx *gorm.DB) error {
|
|
if err := tx.Model(&model.Server{}).Where("id in (?)", moveForm.Ids).Update("user_id", moveForm.ToUser).Error; err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
})
|
|
|
|
if err != nil {
|
|
return nil, newGormError("%v", err)
|
|
}
|
|
|
|
idsMap := make(map[uint64]bool)
|
|
for _, id := range moveForm.Ids {
|
|
idsMap[id] = true
|
|
}
|
|
|
|
for _, s := range singleton.ServerShared.Range {
|
|
if s == nil || !idsMap[s.ID] {
|
|
continue
|
|
}
|
|
s.UserID = moveForm.ToUser
|
|
}
|
|
|
|
return nil, nil
|
|
}
|
|
|
|
var serverMetricMap = map[string]tsdb.MetricType{
|
|
"cpu": tsdb.MetricServerCPU,
|
|
"memory": tsdb.MetricServerMemory,
|
|
"swap": tsdb.MetricServerSwap,
|
|
"disk": tsdb.MetricServerDisk,
|
|
"net_in_speed": tsdb.MetricServerNetInSpeed,
|
|
"net_out_speed": tsdb.MetricServerNetOutSpeed,
|
|
"net_in_transfer": tsdb.MetricServerNetInTransfer,
|
|
"net_out_transfer": tsdb.MetricServerNetOutTransfer,
|
|
"load1": tsdb.MetricServerLoad1,
|
|
"load5": tsdb.MetricServerLoad5,
|
|
"load15": tsdb.MetricServerLoad15,
|
|
"tcp_conn": tsdb.MetricServerTCPConn,
|
|
"udp_conn": tsdb.MetricServerUDPConn,
|
|
"process_count": tsdb.MetricServerProcessCount,
|
|
"temperature": tsdb.MetricServerTemperature,
|
|
"uptime": tsdb.MetricServerUptime,
|
|
"gpu": tsdb.MetricServerGPU,
|
|
}
|
|
|
|
// Get server metrics history
|
|
// @Summary Get server metrics history
|
|
// @Security BearerAuth
|
|
// @Schemes
|
|
// @Description Get server metrics history for a specific server
|
|
// @Tags common
|
|
// @param id path uint true "Server ID"
|
|
// @param metric query string true "Metric name: cpu, memory, swap, disk, net_in_speed, net_out_speed, net_in_transfer, net_out_transfer, load1, load5, load15, tcp_conn, udp_conn, process_count, temperature, uptime, gpu"
|
|
// @param period query string false "Time period: 1d, 7d, 30d (default: 1d)"
|
|
// @Produce json
|
|
// @Success 200 {object} model.CommonResponse[model.ServerMetricsResponse]
|
|
// @Router /server/{id}/metrics [get]
|
|
func getServerMetrics(c *gin.Context) (*model.ServerMetricsResponse, error) {
|
|
idStr := c.Param("id")
|
|
serverID, err := strconv.ParseUint(idStr, 10, 64)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
server, ok := singleton.ServerShared.Get(serverID)
|
|
if !ok {
|
|
return nil, singleton.Localizer.ErrorT("server not found")
|
|
}
|
|
|
|
_, isMember := c.Get(model.CtxKeyAuthorizedUser)
|
|
if server.HideForGuest && !isMember {
|
|
return nil, singleton.Localizer.ErrorT("unauthorized")
|
|
}
|
|
|
|
metricName := c.Query("metric")
|
|
metricType, ok := serverMetricMap[metricName]
|
|
if !ok {
|
|
return nil, singleton.Localizer.ErrorT("invalid metric name")
|
|
}
|
|
|
|
periodStr := c.DefaultQuery("period", "1d")
|
|
period, err := tsdb.ParseQueryPeriod(periodStr)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if !isMember && period != tsdb.Period1Day {
|
|
return nil, singleton.Localizer.ErrorT("unauthorized: only 1d data available for guests")
|
|
}
|
|
|
|
response := &model.ServerMetricsResponse{
|
|
ServerID: serverID,
|
|
ServerName: server.Name,
|
|
Metric: metricName,
|
|
DataPoints: make([]model.ServerMetricsDataPoint, 0),
|
|
}
|
|
|
|
if !singleton.TSDBEnabled() {
|
|
return response, nil
|
|
}
|
|
|
|
points, err := singleton.TSDBShared.QueryServerMetrics(serverID, metricType, period)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
response.DataPoints = points
|
|
|
|
return response, nil
|
|
}
|