Explorar o código

fix CPU History intervals

mhsanaei hai 14 horas
pai
achega
22afa50901
Modificáronse 4 ficheiros con 631 adicións e 605 borrados
  1. 27 34
      web/controller/server.go
  2. 4 4
      web/controller/xray_setting.go
  3. 496 505
      web/html/index.html
  4. 104 62
      web/service/server.go

+ 27 - 34
web/controller/server.go

@@ -21,17 +21,14 @@ type ServerController struct {
 	serverService  service.ServerService
 	settingService service.SettingService
 
-	lastStatus        *service.Status
-	lastGetStatusTime time.Time
+	lastStatus *service.Status
 
 	lastVersions        []string
-	lastGetVersionsTime time.Time
+	lastGetVersionsTime int64 // unix seconds
 }
 
 func NewServerController(g *gin.RouterGroup) *ServerController {
-	a := &ServerController{
-		lastGetStatusTime: time.Now(),
-	}
+	a := &ServerController{}
 	a.initRouter(g)
 	a.startTask()
 	return a
@@ -40,7 +37,7 @@ func NewServerController(g *gin.RouterGroup) *ServerController {
 func (a *ServerController) initRouter(g *gin.RouterGroup) {
 
 	g.GET("/status", a.status)
-	g.GET("/cpuHistory", a.getCpuHistory)
+	g.GET("/cpuHistory/:bucket", a.getCpuHistoryBucket)
 	g.GET("/getXrayVersion", a.getXrayVersion)
 	g.GET("/getConfigJson", a.getConfigJson)
 	g.GET("/getDb", a.getDb)
@@ -79,35 +76,34 @@ func (a *ServerController) startTask() {
 	})
 }
 
-func (a *ServerController) status(c *gin.Context) {
-	a.lastGetStatusTime = time.Now()
-
-	jsonObj(c, a.lastStatus, nil)
-}
+func (a *ServerController) status(c *gin.Context) { jsonObj(c, a.lastStatus, nil) }
 
-// getCpuHistory returns recent CPU utilization points.
-// Query param q=minutes (int). Bounds: 1..360 (6 hours). Defaults to 60.
-func (a *ServerController) getCpuHistory(c *gin.Context) {
-	minsStr := c.Query("q")
-	mins := 60
-	if minsStr != "" {
-		if v, err := strconv.Atoi(minsStr); err == nil {
-			mins = v
-		}
+func (a *ServerController) getCpuHistoryBucket(c *gin.Context) {
+	bucketStr := c.Param("bucket")
+	bucket, err := strconv.Atoi(bucketStr)
+	if err != nil || bucket <= 0 {
+		jsonMsg(c, "invalid bucket", fmt.Errorf("bad bucket"))
+		return
 	}
-	if mins < 1 {
-		mins = 1
+	allowed := map[int]bool{
+		2:   true, // Real-time view
+		30:  true, // 30s intervals
+		60:  true, // 1m intervals
+		120: true, // 2m intervals
+		180: true, // 3m intervals
+		300: true, // 5m intervals
 	}
-	if mins > 360 {
-		mins = 360
+	if !allowed[bucket] {
+		jsonMsg(c, "invalid bucket", fmt.Errorf("unsupported bucket"))
+		return
 	}
-	res := a.serverService.GetCpuHistory(mins)
-	jsonObj(c, res, nil)
+	points := a.serverService.AggregateCpuHistory(bucket, 60)
+	jsonObj(c, points, nil)
 }
 
 func (a *ServerController) getXrayVersion(c *gin.Context) {
-	now := time.Now()
-	if now.Sub(a.lastGetVersionsTime) <= time.Minute {
+	now := time.Now().Unix()
+	if now-a.lastGetVersionsTime <= 60 { // 1 minute cache
 		jsonObj(c, a.lastVersions, nil)
 		return
 	}
@@ -119,7 +115,7 @@ func (a *ServerController) getXrayVersion(c *gin.Context) {
 	}
 
 	a.lastVersions = versions
-	a.lastGetVersionsTime = time.Now()
+	a.lastGetVersionsTime = now
 
 	jsonObj(c, versions, nil)
 }
@@ -137,7 +133,6 @@ func (a *ServerController) updateGeofile(c *gin.Context) {
 }
 
 func (a *ServerController) stopXrayService(c *gin.Context) {
-	a.lastGetStatusTime = time.Now()
 	err := a.serverService.StopXrayService()
 	if err != nil {
 		jsonMsg(c, I18nWeb(c, "pages.xray.stopError"), err)
@@ -253,9 +248,7 @@ func (a *ServerController) importDB(c *gin.Context) {
 	defer file.Close()
 	// Always restart Xray before return
 	defer a.serverService.RestartXrayService()
-	defer func() {
-		a.lastGetStatusTime = time.Now()
-	}()
+	// lastGetStatusTime removed; no longer needed
 	// Import it
 	err = a.serverService.ImportDB(file)
 	if err != nil {

+ 4 - 4
web/controller/xray_setting.go

@@ -23,13 +23,13 @@ func NewXraySettingController(g *gin.RouterGroup) *XraySettingController {
 
 func (a *XraySettingController) initRouter(g *gin.RouterGroup) {
 	g = g.Group("/xray")
+	g.GET("/getDefaultJsonConfig", a.getDefaultXrayConfig)
+	g.GET("/getOutboundsTraffic", a.getOutboundsTraffic)
+	g.GET("/getXrayResult", a.getXrayResult)
 
 	g.POST("/", a.getXraySetting)
-	g.POST("/update", a.updateSetting)
-	g.GET("/getXrayResult", a.getXrayResult)
-	g.GET("/getDefaultJsonConfig", a.getDefaultXrayConfig)
 	g.POST("/warp/:action", a.warp)
-	g.GET("/getOutboundsTraffic", a.getOutboundsTraffic)
+	g.POST("/update", a.updateSetting)
 	g.POST("/resetOutboundsTraffic", a.resetOutboundsTraffic)
 }
 

+ 496 - 505
web/html/index.html

@@ -9,10 +9,7 @@
       <a-spin :spinning="loadingStates.spinning" :delay="200" :tip="loadingTip">
         <transition name="list" appear>
           <a-alert type="error" v-if="showAlert && loadingStates.fetched" class="mb-10"
-            message='{{ i18n "secAlertTitle" }}'
-            color="red"
-            description='{{ i18n "secAlertSsl" }}'
-            show-icon closable>
+            message='{{ i18n "secAlertTitle" }}' color="red" description='{{ i18n "secAlertSsl" }}' show-icon closable>
           </a-alert>
         </transition>
         <transition name="list" appear>
@@ -29,16 +26,16 @@
                     <a-col :sm="24" :md="12">
                       <a-row>
                         <a-col :span="12" class="text-center">
-                          <a-progress type="dashboard" status="normal"
-                            :stroke-color="status.cpu.color"
+                          <a-progress type="dashboard" status="normal" :stroke-color="status.cpu.color"
                             :percent="status.cpu.percent"></a-progress>
                           <div>
-                            <b>{{ i18n "pages.index.cpu" }}:</b> [[ CPUFormatter.cpuCoreFormat(status.cpuCores) ]] 
+                            <b>{{ i18n "pages.index.cpu" }}:</b> [[ CPUFormatter.cpuCoreFormat(status.cpuCores) ]]
                             <a-tooltip>
-                              <a-icon type="area-chart"></a-icon> 
+                              <a-icon type="area-chart"></a-icon>
                               <template slot="title">
                                 <div><b>{{ i18n "pages.index.logicalProcessors" }}:</b> [[ (status.logicalPro) ]]</div>
-                                <div><b>{{ i18n "pages.index.frequency" }}:</b> [[ CPUFormatter.cpuSpeedFormat(status.cpuSpeedMhz) ]]</div>
+                                <div><b>{{ i18n "pages.index.frequency" }}:</b> [[
+                                  CPUFormatter.cpuSpeedFormat(status.cpuSpeedMhz) ]]</div>
                               </template>
                             </a-tooltip>
                             <a-tooltip :overlay-class-name="themeSwitcher.currentTheme">
@@ -49,11 +46,11 @@
                           </div>
                         </a-col>
                         <a-col :span="12" class="text-center">
-                          <a-progress type="dashboard" status="normal"
-                            :stroke-color="status.mem.color"
+                          <a-progress type="dashboard" status="normal" :stroke-color="status.mem.color"
                             :percent="status.mem.percent"></a-progress>
                           <div>
-                            <b>{{ i18n "pages.index.memory"}}:</b> [[ SizeFormatter.sizeFormat(status.mem.current) ]] / [[ SizeFormatter.sizeFormat(status.mem.total) ]]
+                            <b>{{ i18n "pages.index.memory"}}:</b> [[ SizeFormatter.sizeFormat(status.mem.current) ]] /
+                            [[ SizeFormatter.sizeFormat(status.mem.total) ]]
                           </div>
                         </a-col>
                       </a-row>
@@ -61,19 +58,19 @@
                     <a-col :sm="24" :md="12">
                       <a-row>
                         <a-col :span="12" class="text-center">
-                          <a-progress type="dashboard" status="normal"
-                            :stroke-color="status.swap.color"
+                          <a-progress type="dashboard" status="normal" :stroke-color="status.swap.color"
                             :percent="status.swap.percent"></a-progress>
                           <div>
-                            <b>{{ i18n "pages.index.swap" }}:</b> [[ SizeFormatter.sizeFormat(status.swap.current) ]] / [[ SizeFormatter.sizeFormat(status.swap.total) ]]
+                            <b>{{ i18n "pages.index.swap" }}:</b> [[ SizeFormatter.sizeFormat(status.swap.current) ]] /
+                            [[ SizeFormatter.sizeFormat(status.swap.total) ]]
                           </div>
                         </a-col>
                         <a-col :span="12" class="text-center">
-                          <a-progress type="dashboard" status="normal"
-                            :stroke-color="status.disk.color"
+                          <a-progress type="dashboard" status="normal" :stroke-color="status.disk.color"
                             :percent="status.disk.percent"></a-progress>
                           <div>
-                            <b>{{ i18n "pages.index.storage"}}:</b> [[ SizeFormatter.sizeFormat(status.disk.current) ]] / [[ SizeFormatter.sizeFormat(status.disk.total) ]]
+                            <b>{{ i18n "pages.index.storage"}}:</b> [[ SizeFormatter.sizeFormat(status.disk.current) ]]
+                            / [[ SizeFormatter.sizeFormat(status.disk.total) ]]
                           </div>
                         </a-col>
                       </a-row>
@@ -93,7 +90,9 @@
                   </template>
                   <template #extra>
                     <template v-if="status.xray.state != 'error'">
-                      <a-badge status="processing" :class="({ green: 'xray-running-animation', orange: 'xray-stop-animation' }[status.xray.color]) || 'xray-processing-animation'" :text="status.xray.stateMsg" :color="status.xray.color"/>
+                      <a-badge status="processing"
+                        :class="({ green: 'xray-running-animation', orange: 'xray-stop-animation' }[status.xray.color]) || 'xray-processing-animation'"
+                        :text="status.xray.stateMsg" :color="status.xray.color" />
                     </template>
                     <template v-else>
                       <a-popover :overlay-class-name="themeSwitcher.currentTheme">
@@ -110,7 +109,8 @@
                         <template slot="content">
                           <span class="max-w-400" v-for="line in status.xray.errorMsg.split('\n')">[[ line ]]</span>
                         </template>
-                        <a-badge :text="status.xray.stateMsg" :color="status.xray.color" :class="status.xray.color === 'red' ? 'xray-error-animation' : ''"/>
+                        <a-badge :text="status.xray.stateMsg" :color="status.xray.color"
+                          :class="status.xray.color === 'red' ? 'xray-error-animation' : ''" />
                       </a-popover>
                     </template>
                   </template>
@@ -130,7 +130,8 @@
                     <a-space direction="horizontal" @click="openSelectV2rayVersion" class="jc-center">
                       <a-icon type="tool"></a-icon>
                       <span v-if="!isMobile">
-                        [[ status.xray.version != 'Unknown' ? `v${status.xray.version}` : '{{ i18n "pages.index.xraySwitch" }}' ]]
+                        [[ status.xray.version != 'Unknown' ? `v${status.xray.version}` : '{{ i18n
+                        "pages.index.xraySwitch" }}' ]]
                       </span>
                     </a-space>
                   </template>
@@ -175,7 +176,8 @@
               </a-col>
               <a-col :sm="24" :lg="12">
                 <a-card title='{{ i18n "pages.index.operationHours" }}' hoverable>
-                  <a-tag :color="status.xray.color">Xray: [[ TimeFormatter.formatSecond(status.appStats.uptime) ]]</a-tag>
+                  <a-tag :color="status.xray.color">Xray: [[ TimeFormatter.formatSecond(status.appStats.uptime)
+                    ]]</a-tag>
                   <a-tag color="green">OS: [[ TimeFormatter.formatSecond(status.uptime) ]]</a-tag>
                 </a-card>
               </a-col>
@@ -193,7 +195,8 @@
               </a-col>
               <a-col :sm="24" :lg="12">
                 <a-card title='{{ i18n "usage"}}' hoverable>
-                  <a-tag color="green"> {{ i18n "pages.index.memory" }}: [[ SizeFormatter.sizeFormat(status.appStats.mem) ]] </a-tag>
+                  <a-tag color="green"> {{ i18n "pages.index.memory" }}: [[
+                    SizeFormatter.sizeFormat(status.appStats.mem) ]] </a-tag>
                   <a-tag color="green"> {{ i18n "pages.index.threads" }}: [[ status.appStats.threads ]] </a-tag>
                 </a-card>
               </a-col>
@@ -201,7 +204,8 @@
                 <a-card title='{{ i18n "pages.index.overallSpeed" }}' hoverable>
                   <a-row :gutter="isMobile ? [8,8] : 0">
                     <a-col :span="12">
-                      <a-custom-statistic title='{{ i18n "pages.index.upload" }}' :value="SizeFormatter.sizeFormat(status.netIO.up)">
+                      <a-custom-statistic title='{{ i18n "pages.index.upload" }}'
+                        :value="SizeFormatter.sizeFormat(status.netIO.up)">
                         <template #prefix>
                           <a-icon type="arrow-up" />
                         </template>
@@ -211,7 +215,8 @@
                       </a-custom-statistic>
                     </a-col>
                     <a-col :span="12">
-                      <a-custom-statistic title='{{ i18n "pages.index.download" }}' :value="SizeFormatter.sizeFormat(status.netIO.down)">
+                      <a-custom-statistic title='{{ i18n "pages.index.download" }}'
+                        :value="SizeFormatter.sizeFormat(status.netIO.down)">
                         <template #prefix>
                           <a-icon type="arrow-down" />
                         </template>
@@ -227,14 +232,16 @@
                 <a-card title='{{ i18n "pages.index.totalData" }}' hoverable>
                   <a-row :gutter="isMobile ? [8,8] : 0">
                     <a-col :span="12">
-                      <a-custom-statistic title='{{ i18n "pages.index.sent" }}' :value="SizeFormatter.sizeFormat(status.netTraffic.sent)">
+                      <a-custom-statistic title='{{ i18n "pages.index.sent" }}'
+                        :value="SizeFormatter.sizeFormat(status.netTraffic.sent)">
                         <template #prefix>
                           <a-icon type="cloud-upload" />
                         </template>
                       </a-custom-statistic>
                     </a-col>
                     <a-col :span="12">
-                      <a-custom-statistic title='{{ i18n "pages.index.received" }}' :value="SizeFormatter.sizeFormat(status.netTraffic.recv)">
+                      <a-custom-statistic title='{{ i18n "pages.index.received" }}'
+                        :value="SizeFormatter.sizeFormat(status.netTraffic.recv)">
                         <template #prefix>
                           <a-icon type="cloud-download" />
                         </template>
@@ -250,7 +257,8 @@
                       <template #title>
                         {{ i18n "pages.index.toggleIpVisibility" }}
                       </template>
-                      <a-icon :type="showIp ? 'eye' : 'eye-invisible'" class="fs-1rem" @click="showIp = !showIp"></a-icon>
+                      <a-icon :type="showIp ? 'eye' : 'eye-invisible'" class="fs-1rem"
+                        @click="showIp = !showIp"></a-icon>
                     </a-tooltip>
                   </template>
                   <a-row :class="showIp ? 'ip-visible' : 'ip-hidden'" :gutter="isMobile ? [8,8] : 0">
@@ -297,55 +305,54 @@
       </a-spin>
     </a-layout-content>
   </a-layout>
-  <a-modal id="version-modal" v-model="versionModal.visible" title='{{ i18n "pages.index.xraySwitch" }}' :closable="true"
-      @ok="() => versionModal.visible = false" :class="themeSwitcher.currentTheme" footer="">
+  <a-modal id="version-modal" v-model="versionModal.visible" title='{{ i18n "pages.index.xraySwitch" }}'
+    :closable="true" @ok="() => versionModal.visible = false" :class="themeSwitcher.currentTheme" footer="">
     <a-collapse default-active-key="1">
       <a-collapse-panel key="1" header='Xray'>
-  <a-alert type="warning" class="mb-12 w-100" message='{{ i18n "pages.index.xraySwitchClickDesk" }}' show-icon></a-alert>
-  <a-list class="ant-version-list w-100" bordered>
+        <a-alert type="warning" class="mb-12 w-100" message='{{ i18n "pages.index.xraySwitchClickDesk" }}'
+          show-icon></a-alert>
+        <a-list class="ant-version-list w-100" bordered>
           <a-list-item class="ant-version-list-item" v-for="version, index in versionModal.versions">
             <a-tag :color="index % 2 == 0 ? 'purple' : 'green'">[[ version ]]</a-tag>
-            <a-radio :class="themeSwitcher.currentTheme" :checked="version === `v${status.xray.version}`" @click="switchV2rayVersion(version)"></a-radio>
+            <a-radio :class="themeSwitcher.currentTheme" :checked="version === `v${status.xray.version}`"
+              @click="switchV2rayVersion(version)"></a-radio>
           </a-list-item>
         </a-list>
       </a-collapse-panel>
       <a-collapse-panel key="2" header='Geofiles'>
-  <a-list class="ant-version-list w-100" bordered>
-          <a-list-item class="ant-version-list-item" v-for="file, index in ['geosite.dat', 'geoip.dat', 'geosite_IR.dat', 'geoip_IR.dat', 'geosite_RU.dat', 'geoip_RU.dat']">
+        <a-list class="ant-version-list w-100" bordered>
+          <a-list-item class="ant-version-list-item"
+            v-for="file, index in ['geosite.dat', 'geoip.dat', 'geosite_IR.dat', 'geoip_IR.dat', 'geosite_RU.dat', 'geoip_RU.dat']">
             <a-tag :color="index % 2 == 0 ? 'purple' : 'green'">[[ file ]]</a-tag>
-            <a-icon type="reload" @click="updateGeofile(file)" class="mr-8"/>
+            <a-icon type="reload" @click="updateGeofile(file)" class="mr-8" />
           </a-list-item>
         </a-list>
-        <div class="mt-5 d-flex justify-end"><a-button @click="updateGeofile('')">{{ i18n "pages.index.geofilesUpdateAll" }}</a-button></div>
+        <div class="mt-5 d-flex justify-end"><a-button @click="updateGeofile('')">{{ i18n
+            "pages.index.geofilesUpdateAll" }}</a-button></div>
       </a-collapse-panel>
     </a-collapse>
   </a-modal>
-  <a-modal id="log-modal" v-model="logModal.visible"
-      :closable="true" @cancel="() => logModal.visible = false"
-      :class="themeSwitcher.currentTheme"
-      width="800px" footer="">
+  <a-modal id="log-modal" v-model="logModal.visible" :closable="true" @cancel="() => logModal.visible = false"
+    :class="themeSwitcher.currentTheme" width="800px" footer="">
     <template slot="title">
       {{ i18n "pages.index.logs" }}
-      <a-icon :spin="logModal.loading"
-        type="sync"
-  class="va-middle ml-10"
-        :disabled="logModal.loading"
+      <a-icon :spin="logModal.loading" type="sync" class="va-middle ml-10" :disabled="logModal.loading"
         @click="openLogs()">
       </a-icon>
     </template>
     <a-form layout="inline">
-  <a-form-item class="mr-05">
+      <a-form-item class="mr-05">
         <a-input-group compact>
-          <a-select size="small" v-model="logModal.rows" class="w-70"
-              @change="openLogs()" :dropdown-class-name="themeSwitcher.currentTheme">
+          <a-select size="small" v-model="logModal.rows" class="w-70" @change="openLogs()"
+            :dropdown-class-name="themeSwitcher.currentTheme">
             <a-select-option value="10">10</a-select-option>
             <a-select-option value="20">20</a-select-option>
             <a-select-option value="50">50</a-select-option>
             <a-select-option value="100">100</a-select-option>
             <a-select-option value="500">500</a-select-option>
           </a-select>
-          <a-select size="small" v-model="logModal.level" class="w-95"
-              @change="openLogs()" :dropdown-class-name="themeSwitcher.currentTheme">
+          <a-select size="small" v-model="logModal.level" class="w-95" @change="openLogs()"
+            :dropdown-class-name="themeSwitcher.currentTheme">
             <a-select-option value="debug">Debug</a-select-option>
             <a-select-option value="info">Info</a-select-option>
             <a-select-option value="notice">Notice</a-select-option>
@@ -358,31 +365,25 @@
         <a-checkbox v-model="logModal.syslog" @change="openLogs()">SysLog</a-checkbox>
       </a-form-item>
       <a-form-item style="float: right;">
-        <a-button type="primary" icon="download" @click="FileManager.downloadTextFile(logModal.logs?.join('\n'), 'x-ui.log')"></a-button>
+        <a-button type="primary" icon="download"
+          @click="FileManager.downloadTextFile(logModal.logs?.join('\n'), 'x-ui.log')"></a-button>
       </a-form-item>
     </a-form>
-  <div class="ant-input log-container" v-html="logModal.formattedLogs"></div>
+    <div class="ant-input log-container" v-html="logModal.formattedLogs"></div>
   </a-modal>
-  <a-modal id="xraylog-modal"
-      v-model="xraylogModal.visible"
-      :closable="true" @cancel="() => xraylogModal.visible = false"
-      :class="themeSwitcher.currentTheme"
-      width="80vw"
-      footer="">
+  <a-modal id="xraylog-modal" v-model="xraylogModal.visible" :closable="true"
+    @cancel="() => xraylogModal.visible = false" :class="themeSwitcher.currentTheme" width="80vw" footer="">
     <template slot="title">
       {{ i18n "pages.index.logs" }}
-      <a-icon :spin="xraylogModal.loading"
-        type="sync"
-  class="va-middle ml-10"
-        :disabled="xraylogModal.loading"
+      <a-icon :spin="xraylogModal.loading" type="sync" class="va-middle ml-10" :disabled="xraylogModal.loading"
         @click="openXrayLogs()">
       </a-icon>
     </template>
     <a-form layout="inline">
-  <a-form-item class="mr-05">
+      <a-form-item class="mr-05">
         <a-input-group compact>
-          <a-select size="small" v-model="xraylogModal.rows" class="w-70"
-              @change="openXrayLogs()" :dropdown-class-name="themeSwitcher.currentTheme">
+          <a-select size="small" v-model="xraylogModal.rows" class="w-70" @change="openXrayLogs()"
+            :dropdown-class-name="themeSwitcher.currentTheme">
             <a-select-option value="10">10</a-select-option>
             <a-select-option value="20">20</a-select-option>
             <a-select-option value="50">50</a-select-option>
@@ -400,24 +401,21 @@
         <a-checkbox v-model="xraylogModal.showProxy" @change="openXrayLogs()">Proxy</a-checkbox>
       </a-form-item>
       <a-form-item style="float: right;">
-        <a-button type="primary" icon="download" @click="FileManager.downloadTextFile(xraylogModal.logs?.join('\n'), 'x-ui.log')"></a-button>
+        <a-button type="primary" icon="download"
+          @click="FileManager.downloadTextFile(xraylogModal.logs?.join('\n'), 'x-ui.log')"></a-button>
       </a-form-item>
     </a-form>
-  <div class="ant-input log-container" v-html="xraylogModal.formattedLogs"></div>
+    <div class="ant-input log-container" v-html="xraylogModal.formattedLogs"></div>
   </a-modal>
-  <a-modal id="backup-modal" 
-      v-model="backupModal.visible" 
-      title='{{ i18n "pages.index.backupTitle" }}'
-      :closable="true"
-      footer=""
-      :class="themeSwitcher.currentTheme">
-  <a-list class="ant-backup-list w-100" bordered>
+  <a-modal id="backup-modal" v-model="backupModal.visible" title='{{ i18n "pages.index.backupTitle" }}' :closable="true"
+    footer="" :class="themeSwitcher.currentTheme">
+    <a-list class="ant-backup-list w-100" bordered>
       <a-list-item class="ant-backup-list-item">
         <a-list-item-meta>
           <template #title>{{ i18n "pages.index.exportDatabase" }}</template>
           <template #description>{{ i18n "pages.index.exportDatabaseDesc" }}</template>
         </a-list-item-meta>
-        <a-button @click="exportDatabase()" type="primary" icon="download"/>
+        <a-button @click="exportDatabase()" type="primary" icon="download" />
       </a-list-item>
       <a-list-item class="ant-backup-list-item">
         <a-list-item-meta>
@@ -429,33 +427,25 @@
     </a-list>
   </a-modal>
   <!-- CPU History Modal -->
-  <a-modal id="cpu-history-modal"
-           v-model="cpuHistoryModal.visible"
-           :closable="true" @cancel="() => cpuHistoryModal.visible = false"
-           :class="themeSwitcher.currentTheme"
-           width="900px" footer="">
+  <a-modal id="cpu-history-modal" v-model="cpuHistoryModal.visible" :closable="true"
+    @cancel="() => cpuHistoryModal.visible = false" :class="themeSwitcher.currentTheme" width="900px" footer="">
     <template slot="title">
       CPU History
-      <a-select size="small" v-model="cpuHistoryModal.minutes" class="ml-10" style="width: 120px" @change="loadCpuHistory">
-        <a-select-option :value="15">15 min</a-select-option>
-        <a-select-option :value="60">1 hour</a-select-option>
-        <a-select-option :value="180">3 hours</a-select-option>
-        <a-select-option :value="360">6 hours</a-select-option>
+      <a-select size="small" v-model="cpuHistoryModal.bucket" class="ml-10" style="width: 140px"
+        @change="fetchCpuHistoryBucket">
+        <a-select-option :value="2">2s</a-select-option>
+        <a-select-option :value="30">30s</a-select-option>
+        <a-select-option :value="60">1m</a-select-option>
+        <a-select-option :value="120">2m</a-select-option>
+        <a-select-option :value="180">3m</a-select-option>
+        <a-select-option :value="300">5m</a-select-option>
       </a-select>
     </template>
     <div style="padding: 8px 0;">
-      <sparkline :data="cpuHistoryLong"
-                 :labels="cpuHistoryLabels"
-                 :vb-width="840"
-                 :height="220"
-                 :stroke="status.cpu.color"
-                 :stroke-width="2.2"
-                 :show-grid="true"
-                 :show-axes="true"
-                 :tick-count-x="5"
-                 :fill-opacity="0.18"
-                 :marker-radius="3.2"
-                 :show-tooltip="true" />
+      <sparkline :data="cpuHistoryLong" :labels="cpuHistoryLabels" :vb-width="840" :height="220"
+        :stroke="status.cpu.color" :stroke-width="2.2" :show-grid="true" :show-axes="true" :tick-count-x="5"
+        :max-points="cpuHistoryLong.length" :fill-opacity="0.18" :marker-radius="3.2" :show-tooltip="true" />
+      <div style="margin-top:4px;font-size:11px;opacity:0.65">Timeframe: [[ cpuHistoryModal.bucket ]] sec per point (total [[ cpuHistoryLong.length ]] points)</div>
     </div>
   </a-modal>
 </a-layout>
@@ -543,7 +533,7 @@
         const last = this.pointsArr[this.pointsArr.length - 1]
         const line = this.points
         // Close to bottom to create an area fill
-        return `M ${first[0]},${this.paddingTop + this.drawHeight} L ${line.replace(/ /g,' L ')} L ${last[0]},${this.paddingTop + this.drawHeight} Z`
+        return `M ${first[0]},${this.paddingTop + this.drawHeight} L ${line.replace(/ /g, ' L ')} L ${last[0]},${this.paddingTop + this.drawHeight} Z`
       },
       gridLines() {
         if (!this.showGrid) return []
@@ -609,7 +599,8 @@
         const labels = this.labelsSlice
         const idx = this.hoverIdx
         if (idx < 0 || idx >= this.dataSlice.length) return ''
-        const val = Math.max(0, Math.min(100, Number(this.dataSlice[idx] || 0)))
+        const raw = Math.max(0, Math.min(100, Number(this.dataSlice[idx] || 0)))
+        const val = Number.isFinite(raw) ? raw.toFixed(2) : raw
         const lab = labels[idx] != null ? labels[idx] : ''
         return `${val}%${lab ? ' • ' + lab : ''}`
       },
@@ -649,195 +640,196 @@
     `,
   })
 
-    class CurTotal {
 
-        constructor(current, total) {
-            this.current = current;
-            this.total = total;
-        }
+  class CurTotal {
 
-        get percent() {
-            if (this.total === 0) {
-                return 0;
-            }
-            return NumberFormatter.toFixed(this.current / this.total * 100, 2);
-        }
+    constructor(current, total) {
+      this.current = current;
+      this.total = total;
+    }
 
-        get color() {
-            const percent = this.percent;
-            if (percent < 80) {
-                return '#008771'; // Green
-            } else if (percent < 90) {
-                return "#f37b24"; // Orange
-            } else {
-                return "#cf3c3c"; // Red
-            }
-        }
+    get percent() {
+      if (this.total === 0) {
+        return 0;
+      }
+      return NumberFormatter.toFixed(this.current / this.total * 100, 2);
     }
 
-    class Status {
-        constructor(data) {
-            this.cpu = new CurTotal(0, 0);
-            this.cpuCores = 0;
-            this.logicalPro = 0;
-            this.cpuSpeedMhz = 0;
-            this.disk = new CurTotal(0, 0);
-            this.loads = [0, 0, 0];
-            this.mem = new CurTotal(0, 0);
-            this.netIO = { up: 0, down: 0 };
-            this.netTraffic = { sent: 0, recv: 0 };
-            this.publicIP = { ipv4: 0, ipv6: 0 };
-            this.swap = new CurTotal(0, 0);
-            this.tcpCount = 0;
-            this.udpCount = 0;
-            this.uptime = 0;
-            this.appUptime = 0;
-            this.appStats = {threads: 0, mem: 0, uptime: 0};
+    get color() {
+      const percent = this.percent;
+      if (percent < 80) {
+        return '#008771'; // Green
+      } else if (percent < 90) {
+        return "#f37b24"; // Orange
+      } else {
+        return "#cf3c3c"; // Red
+      }
+    }
+  }
 
-            this.xray = { state: 'stop', stateMsg: "", errorMsg: "", version: "", color: "" };
+  class Status {
+    constructor(data) {
+      this.cpu = new CurTotal(0, 0);
+      this.cpuCores = 0;
+      this.logicalPro = 0;
+      this.cpuSpeedMhz = 0;
+      this.disk = new CurTotal(0, 0);
+      this.loads = [0, 0, 0];
+      this.mem = new CurTotal(0, 0);
+      this.netIO = { up: 0, down: 0 };
+      this.netTraffic = { sent: 0, recv: 0 };
+      this.publicIP = { ipv4: 0, ipv6: 0 };
+      this.swap = new CurTotal(0, 0);
+      this.tcpCount = 0;
+      this.udpCount = 0;
+      this.uptime = 0;
+      this.appUptime = 0;
+      this.appStats = { threads: 0, mem: 0, uptime: 0 };
 
-            if (data == null) {
-              return;
-            }
+      this.xray = { state: 'stop', stateMsg: "", errorMsg: "", version: "", color: "" };
 
-            this.cpu = new CurTotal(data.cpu, 100);
-            this.cpuCores = data.cpuCores;
-            this.logicalPro = data.logicalPro;
-            this.cpuSpeedMhz = data.cpuSpeedMhz;
-            this.disk = new CurTotal(data.disk.current, data.disk.total);
-            this.loads = data.loads.map(load => NumberFormatter.toFixed(load, 2));
-            this.mem = new CurTotal(data.mem.current, data.mem.total);
-            this.netIO = data.netIO;
-            this.netTraffic = data.netTraffic;
-            this.publicIP = data.publicIP;
-            this.swap = new CurTotal(data.swap.current, data.swap.total);
-            this.tcpCount = data.tcpCount;
-            this.udpCount = data.udpCount;
-            this.uptime = data.uptime;
-            this.appUptime = data.appUptime;
-            this.appStats = data.appStats;
-            this.xray = data.xray;
-            switch (this.xray.state) {
-                case 'running':
-                    this.xray.color = "green";
-                    this.xray.stateMsg = '{{ i18n "pages.index.xrayStatusRunning" }}';
-                    break;
-                case 'stop':
-                    this.xray.color = "orange";
-                    this.xray.stateMsg = '{{ i18n "pages.index.xrayStatusStop" }}';
-                    break;
-                case 'error':
-                    this.xray.color = "red";
-                    this.xray.stateMsg ='{{ i18n "pages.index.xrayStatusError" }}';
-                    break;
-                default:
-                    this.xray.color = "gray";
-                    this.xray.stateMsg = '{{ i18n "pages.index.xrayStatusUnknown" }}';
-                    break;
-            }
-        }
+      if (data == null) {
+        return;
+      }
+
+      this.cpu = new CurTotal(data.cpu, 100);
+      this.cpuCores = data.cpuCores;
+      this.logicalPro = data.logicalPro;
+      this.cpuSpeedMhz = data.cpuSpeedMhz;
+      this.disk = new CurTotal(data.disk.current, data.disk.total);
+      this.loads = data.loads.map(load => NumberFormatter.toFixed(load, 2));
+      this.mem = new CurTotal(data.mem.current, data.mem.total);
+      this.netIO = data.netIO;
+      this.netTraffic = data.netTraffic;
+      this.publicIP = data.publicIP;
+      this.swap = new CurTotal(data.swap.current, data.swap.total);
+      this.tcpCount = data.tcpCount;
+      this.udpCount = data.udpCount;
+      this.uptime = data.uptime;
+      this.appUptime = data.appUptime;
+      this.appStats = data.appStats;
+      this.xray = data.xray;
+      switch (this.xray.state) {
+        case 'running':
+          this.xray.color = "green";
+          this.xray.stateMsg = '{{ i18n "pages.index.xrayStatusRunning" }}';
+          break;
+        case 'stop':
+          this.xray.color = "orange";
+          this.xray.stateMsg = '{{ i18n "pages.index.xrayStatusStop" }}';
+          break;
+        case 'error':
+          this.xray.color = "red";
+          this.xray.stateMsg = '{{ i18n "pages.index.xrayStatusError" }}';
+          break;
+        default:
+          this.xray.color = "gray";
+          this.xray.stateMsg = '{{ i18n "pages.index.xrayStatusUnknown" }}';
+          break;
+      }
     }
+  }
 
-    const versionModal = {
-        visible: false,
-        versions: [],
-        show(versions) {
-            this.visible = true;
-            this.versions = versions;
-        },
-        hide() {
-            this.visible = false;
-        },
-    };
+  const versionModal = {
+    visible: false,
+    versions: [],
+    show(versions) {
+      this.visible = true;
+      this.versions = versions;
+    },
+    hide() {
+      this.visible = false;
+    },
+  };
 
-    const logModal = {
-        visible: false,
-        logs: [],
-        rows: 20,
-        level: 'info',
-        syslog: false,
-        loading: false,
-        show(logs) {
-            this.visible = true;
-            this.logs = logs; 
-            this.formattedLogs = this.logs?.length > 0 ? this.formatLogs(this.logs) : "No Record...";
-        },
-        formatLogs(logs) {
-            let formattedLogs = '';
-            const levels = ["DEBUG","INFO","NOTICE","WARNING","ERROR"];
-            const levelColors = ["#3c89e8","#008771","#008771","#f37b24","#e04141","#bcbcbc"];
+  const logModal = {
+    visible: false,
+    logs: [],
+    rows: 20,
+    level: 'info',
+    syslog: false,
+    loading: false,
+    show(logs) {
+      this.visible = true;
+      this.logs = logs;
+      this.formattedLogs = this.logs?.length > 0 ? this.formatLogs(this.logs) : "No Record...";
+    },
+    formatLogs(logs) {
+      let formattedLogs = '';
+      const levels = ["DEBUG", "INFO", "NOTICE", "WARNING", "ERROR"];
+      const levelColors = ["#3c89e8", "#008771", "#008771", "#f37b24", "#e04141", "#bcbcbc"];
 
-            logs.forEach((log, index) => {
-                let [data, message] = log.split(" - ",2);
-                const parts = data.split(" ")
-                if(index>0) formattedLogs += '<br>';
+      logs.forEach((log, index) => {
+        let [data, message] = log.split(" - ", 2);
+        const parts = data.split(" ")
+        if (index > 0) formattedLogs += '<br>';
 
-                if (parts.length === 3) {
-                    const d = parts[0];
-                    const t = parts[1];
-                    const level = parts[2];
-                    const levelIndex = levels.indexOf(level,levels) || 5;
+        if (parts.length === 3) {
+          const d = parts[0];
+          const t = parts[1];
+          const level = parts[2];
+          const levelIndex = levels.indexOf(level, levels) || 5;
 
-                    //formattedLogs += `<span style="color: gray;">${index + 1}.</span>`;
-                    formattedLogs += `<span style="color: ${levelColors[0]};">${d} ${t}</span> `;
-                    formattedLogs += `<span style="color: ${levelColors[levelIndex]}">${level}</span>`;
-                } else {
-                    const levelIndex = levels.indexOf(data,levels) || 5;
-                    formattedLogs += `<span style="color: ${levelColors[levelIndex]}">${data}</span>`;
-                }
+          //formattedLogs += `<span style="color: gray;">${index + 1}.</span>`;
+          formattedLogs += `<span style="color: ${levelColors[0]};">${d} ${t}</span> `;
+          formattedLogs += `<span style="color: ${levelColors[levelIndex]}">${level}</span>`;
+        } else {
+          const levelIndex = levels.indexOf(data, levels) || 5;
+          formattedLogs += `<span style="color: ${levelColors[levelIndex]}">${data}</span>`;
+        }
 
-                if(message){
-                    if(message.startsWith("XRAY:"))
-                        message = "<b>XRAY: </b>" + message.substring(5);
-                    else
-                        message = "<b>X-UI: </b>" + message;
-                }
+        if (message) {
+          if (message.startsWith("XRAY:"))
+            message = "<b>XRAY: </b>" + message.substring(5);
+          else
+            message = "<b>X-UI: </b>" + message;
+        }
 
-                formattedLogs += message ? ' - ' + message : '';
-            });
+        formattedLogs += message ? ' - ' + message : '';
+      });
 
-            return formattedLogs;
-        },
-        hide() {
-            this.visible = false;
-        },
-    };
+      return formattedLogs;
+    },
+    hide() {
+      this.visible = false;
+    },
+  };
 
-    const xraylogModal = {
-        visible: false,
-        logs: [],
-        rows: 20,
-        showDirect: true,
-        showBlocked: true,
-        showProxy: true,
-        loading: false,
-        show(logs) {
-            this.visible = true;
-            this.logs = logs;
-            this.formattedLogs = this.logs?.length > 0 ? this.formatLogs(this.logs) : "No Record...";
-        },
-        formatLogs(logs) {
-            let formattedLogs = '';
+  const xraylogModal = {
+    visible: false,
+    logs: [],
+    rows: 20,
+    showDirect: true,
+    showBlocked: true,
+    showProxy: true,
+    loading: false,
+    show(logs) {
+      this.visible = true;
+      this.logs = logs;
+      this.formattedLogs = this.logs?.length > 0 ? this.formatLogs(this.logs) : "No Record...";
+    },
+    formatLogs(logs) {
+      let formattedLogs = '';
 
-          logs.forEach((log, index) => {
-            if(index > 0) formattedLogs += '<br>';
+      logs.forEach((log, index) => {
+        if (index > 0) formattedLogs += '<br>';
 
-            const parts = log.split(' ');
+        const parts = log.split(' ');
 
-            if(parts.length === 10) {
-              const dateTime = `<b>${parts[0]} ${parts[1]}</b>`;
-              const from = `<b>${parts[3]}</b>`;
-              const to = `<b>${parts[5].replace(/^\/+/, "")}</b>`;
+        if (parts.length === 10) {
+          const dateTime = `<b>${parts[0]} ${parts[1]}</b>`;
+          const from = `<b>${parts[3]}</b>`;
+          const to = `<b>${parts[5].replace(/^\/+/, "")}</b>`;
 
-              let outboundColor = '';
-              if (parts[9] === "b") {
-                outboundColor = ' style="color: #e04141;"'; //red for blocked
-              }
-              else if (parts[9] === "p") {
-                outboundColor = ' style="color: #3c89e8;"'; //blue for proxies
-              }
+          let outboundColor = '';
+          if (parts[9] === "b") {
+            outboundColor = ' style="color: #e04141;"'; //red for blocked
+          }
+          else if (parts[9] === "p") {
+            outboundColor = ' style="color: #3c89e8;"'; //blue for proxies
+          }
 
-              formattedLogs += `<span${outboundColor}>
+          formattedLogs += `<span${outboundColor}>
 ${dateTime}
  ${parts[2]}
  ${from}
@@ -845,261 +837,260 @@ ${dateTime}
  ${to}
  ${parts.slice(6, 9).join(' ')}
 </span>`;
-            } else {
-              formattedLogs += `<span>${log}</span>`;
-            }
-          });
+        } else {
+          formattedLogs += `<span>${log}</span>`;
+        }
+      });
 
-            return formattedLogs;
-        },
-        hide() {
-            this.visible = false;
-        },
-    };
+      return formattedLogs;
+    },
+    hide() {
+      this.visible = false;
+    },
+  };
 
-    const backupModal = {
-        visible: false,
-        show() {
-          this.visible = true;
-        },
-        hide() {
-          this.visible = false;
-        },
-    };
+  const backupModal = {
+    visible: false,
+    show() {
+      this.visible = true;
+    },
+    hide() {
+      this.visible = false;
+    },
+  };
 
-    const app = new Vue({
-        delimiters: ['[[', ']]'],
-        el: '#app',
-        mixins: [MediaQueryMixin],
-        data: {
-            themeSwitcher,
-            loadingStates: {
-              fetched: false,
-              spinning: false
-            },
-            status: new Status(),
-            cpuHistory: [], // keep last N cpu utilization points (0..100)
-            cpuHistoryLong: [], // long-range history for modal (values)
-            cpuHistoryLabels: [], // formatted timestamps matching long history
-            cpuHistoryModal: { visible: false, minutes: 60 },
-            versionModal,
-            logModal,
-            xraylogModal,
-            backupModal,
-            loadingTip: '{{ i18n "loading"}}',
-            showAlert: false,
-            showIp: false,
-            ipLimitEnable: false,
-        },
-        methods: {
-            loading(spinning, tip = '{{ i18n "loading"}}') {
-                this.loadingStates.spinning = spinning;
-                this.loadingTip = tip;
-            },
-            async getStatus() {
-                try {
-                    const msg = await HttpUtil.get('/panel/api/server/status');
-                    if (msg.success) {
-                        if (!this.loadingStates.fetched) {
-                            this.loadingStates.fetched = true;
-                        }
+  const app = new Vue({
+    delimiters: ['[[', ']]'],
+    el: '#app',
+    mixins: [MediaQueryMixin],
+    data: {
+      themeSwitcher,
+      loadingStates: {
+        fetched: false,
+        spinning: false
+      },
+      status: new Status(),
+  cpuHistory: [], // small live widget history
+  cpuHistoryLong: [], // aggregated points from backend
+  cpuHistoryLabels: [],
+  cpuHistoryModal: { visible: false, bucket: 2 },
+      versionModal,
+      logModal,
+      xraylogModal,
+      backupModal,
+      loadingTip: '{{ i18n "loading"}}',
+      showAlert: false,
+      showIp: false,
+      ipLimitEnable: false,
+    },
+    methods: {
+      loading(spinning, tip = '{{ i18n "loading"}}') {
+        this.loadingStates.spinning = spinning;
+        this.loadingTip = tip;
+      },
+      async getStatus() {
+        try {
+          const msg = await HttpUtil.get('/panel/api/server/status');
+          if (msg.success) {
+            if (!this.loadingStates.fetched) {
+              this.loadingStates.fetched = true;
+            }
 
-                        this.setStatus(msg.obj, true);
-                    }
-                } catch (e) {
-                    console.error("Failed to get status:", e);
-                }
-            },
-            setStatus(data) {
-                this.status = new Status(data);
-                // Push CPU percent into history (clamped 0..100)
-                const v = Math.max(0, Math.min(100, Number(data?.cpu ?? 0)))
-                this.cpuHistory.push(v)
-                const maxPoints = this.isMobile ? 60 : 120
-                if (this.cpuHistory.length > maxPoints) {
-                  this.cpuHistory.splice(0, this.cpuHistory.length - maxPoints)
-                }
-            },
+            this.setStatus(msg.obj, true);
+          }
+        } catch (e) {
+          console.error("Failed to get status:", e);
+        }
+      },
+      setStatus(data) {
+        this.status = new Status(data);
+        // Push CPU percent into history (clamped 0..100)
+        const v = Math.max(0, Math.min(100, Number(data?.cpu ?? 0)))
+        this.cpuHistory.push(v)
+        const maxPoints = this.isMobile ? 60 : 120
+        if (this.cpuHistory.length > maxPoints) {
+          this.cpuHistory.splice(0, this.cpuHistory.length - maxPoints)
+        }
+        // If modal open, refresh current bucketed data
+        if (this.cpuHistoryModal.visible) {
+          this.fetchCpuHistoryBucket()
+        }
+      },
       openCpuHistory() {
         this.cpuHistoryModal.visible = true
-        this.loadCpuHistory()
+        this.fetchCpuHistoryBucket()
       },
-      async loadCpuHistory() {
-        const mins = this.cpuHistoryModal.minutes || 60
+      async fetchCpuHistoryBucket() {
+        const bucket = this.cpuHistoryModal.bucket || 2
         try {
-          const msg = await HttpUtil.get(`/panel/api/server/cpuHistory?q=${mins}`)
+          const msg = await HttpUtil.get(`/panel/api/server/cpuHistory/${bucket}`)
           if (msg.success && Array.isArray(msg.obj)) {
-            // msg.obj is array of {t, cpu}
-            const arr = msg.obj.map(p => Math.max(0, Math.min(100, Number(p.cpu || 0))))
-            const labels = msg.obj.map(p => {
-              const t = p.t
-              let d
-              if (typeof t === 'number') {
-                // Heuristic: if seconds, convert to ms
-                d = new Date(t < 1e12 ? t * 1000 : t)
-              } else {
-                d = new Date(t)
-              }
-              if (isNaN(d.getTime())) return ''
-              const hh = String(d.getHours()).padStart(2, '0')
-              const mm = String(d.getMinutes()).padStart(2, '0')
-              return `${hh}:${mm}`
-            })
-            this.cpuHistoryLong = arr
+            const vals = []
+            const labels = []
+            for (const p of msg.obj) {
+              const d = new Date(p.t * 1000)
+              const hh = String(d.getHours()).padStart(2,'0')
+              const mm = String(d.getMinutes()).padStart(2,'0')
+              const ss = String(d.getSeconds()).padStart(2,'0')
+              labels.push(bucket>=60 ? `${hh}:${mm}` : `${hh}:${mm}:${ss}`)
+              vals.push(Math.max(0, Math.min(100, p.cpu)))
+            }
             this.cpuHistoryLabels = labels
+            this.cpuHistoryLong = vals
           }
-        } catch (e) {
-          console.error('Failed to load CPU history', e)
+        } catch(e) {
+          console.error('Failed to fetch bucketed cpu history', e)
+        }
+      },
+      async openSelectV2rayVersion() {
+        this.loading(true);
+        const msg = await HttpUtil.get('/panel/api/server/getXrayVersion');
+        this.loading(false);
+        if (!msg.success) {
+          return;
+        }
+        versionModal.show(msg.obj);
+      },
+      switchV2rayVersion(version) {
+        this.$confirm({
+          title: '{{ i18n "pages.index.xraySwitchVersionDialog"}}',
+          content: '{{ i18n "pages.index.xraySwitchVersionDialogDesc"}}'.replace('#version#', version),
+          okText: '{{ i18n "confirm"}}',
+          class: themeSwitcher.currentTheme,
+          cancelText: '{{ i18n "cancel"}}',
+          onOk: async () => {
+            versionModal.hide();
+            this.loading(true, '{{ i18n "pages.index.dontRefresh"}}');
+            await HttpUtil.post(`/panel/api/server/installXray/${version}`);
+            this.loading(false);
+          },
+        });
+      },
+      updateGeofile(fileName) {
+        const isSingleFile = !!fileName;
+        this.$confirm({
+          title: '{{ i18n "pages.index.geofileUpdateDialog" }}',
+          content: isSingleFile
+            ? '{{ i18n "pages.index.geofileUpdateDialogDesc" }}'.replace("#filename#", fileName)
+            : '{{ i18n "pages.index.geofilesUpdateDialogDesc" }}',
+          okText: '{{ i18n "confirm"}}',
+          class: themeSwitcher.currentTheme,
+          cancelText: '{{ i18n "cancel"}}',
+          onOk: async () => {
+            versionModal.hide();
+            this.loading(true, '{{ i18n "pages.index.dontRefresh"}}');
+            const url = isSingleFile
+              ? `/panel/api/server/updateGeofile/${fileName}`
+              : `/panel/api/server/updateGeofile`;
+            await HttpUtil.post(url);
+            this.loading(false);
+          },
+        });
+      },
+      async stopXrayService() {
+        this.loading(true);
+        const msg = await HttpUtil.post('/panel/api/server/stopXrayService');
+        this.loading(false);
+        if (!msg.success) {
+          return;
+        }
+      },
+      async restartXrayService() {
+        this.loading(true);
+        const msg = await HttpUtil.post('/panel/api/server/restartXrayService');
+        this.loading(false);
+        if (!msg.success) {
+          return;
+        }
+      },
+      async openLogs() {
+        logModal.loading = true;
+        const msg = await HttpUtil.post('/panel/api/server/logs/' + logModal.rows, { level: logModal.level, syslog: logModal.syslog });
+        if (!msg.success) {
+          return;
+        }
+        logModal.show(msg.obj);
+        await PromiseUtil.sleep(500);
+        logModal.loading = false;
+      },
+      async openXrayLogs() {
+        xraylogModal.loading = true;
+        const msg = await HttpUtil.post('/panel/api/server/xraylogs/' + xraylogModal.rows, { filter: xraylogModal.filter, showDirect: xraylogModal.showDirect, showBlocked: xraylogModal.showBlocked, showProxy: xraylogModal.showProxy });
+        if (!msg.success) {
+          return;
         }
+        xraylogModal.show(msg.obj);
+        await PromiseUtil.sleep(500);
+        xraylogModal.loading = false;
       },
-            async openSelectV2rayVersion() {
-                this.loading(true);
-                const msg = await HttpUtil.get('/panel/api/server/getXrayVersion');
-                this.loading(false);
-                if (!msg.success) {
-                    return;
-                }
-                versionModal.show(msg.obj);
-            },
-            switchV2rayVersion(version) {
-                this.$confirm({
-                    title: '{{ i18n "pages.index.xraySwitchVersionDialog"}}',
-                    content: '{{ i18n "pages.index.xraySwitchVersionDialogDesc"}}'.replace('#version#', version),
-                    okText: '{{ i18n "confirm"}}',
-                    class: themeSwitcher.currentTheme,
-                    cancelText: '{{ i18n "cancel"}}',
-                    onOk: async () => {
-                        versionModal.hide();
-                        this.loading(true, '{{ i18n "pages.index.dontRefresh"}}');
-                        await HttpUtil.post(`/panel/api/server/installXray/${version}`);
-                        this.loading(false);
-                    },
-                });
-            },
-            updateGeofile(fileName) {
-                const isSingleFile = !!fileName;
-                this.$confirm({
-                    title: '{{ i18n "pages.index.geofileUpdateDialog" }}',
-                    content: isSingleFile
-                        ? '{{ i18n "pages.index.geofileUpdateDialogDesc" }}'.replace("#filename#", fileName)
-                        : '{{ i18n "pages.index.geofilesUpdateDialogDesc" }}',
-                    okText: '{{ i18n "confirm"}}',
-                    class: themeSwitcher.currentTheme,
-                    cancelText: '{{ i18n "cancel"}}',
-                    onOk: async () => {
-                        versionModal.hide();
-                        this.loading(true, '{{ i18n "pages.index.dontRefresh"}}');
-                        const url = isSingleFile
-                            ? `/panel/api/server/updateGeofile/${fileName}`
-                            : `/panel/api/server/updateGeofile`;
-                        await HttpUtil.post(url);
-                        this.loading(false);
-                    },
-                });
-            },
-            async stopXrayService() {
-                this.loading(true);
-                const msg = await HttpUtil.post('/panel/api/server/stopXrayService');
-                this.loading(false);
-                if (!msg.success) {
-                    return;
-                }
-            },
-            async restartXrayService() {
-                this.loading(true);
-                const msg = await HttpUtil.post('/panel/api/server/restartXrayService');
-                this.loading(false);
-                if (!msg.success) {
-                    return;
-                }
-            },
-            async openLogs(){
-                logModal.loading = true;
-                const msg = await HttpUtil.post('/panel/api/server/logs/'+logModal.rows,{level: logModal.level, syslog: logModal.syslog});
-                if (!msg.success) {
-                    return;
-                }
-                logModal.show(msg.obj);
-                await PromiseUtil.sleep(500);
-                logModal.loading = false;
-            },
-            async openXrayLogs(){
-              xraylogModal.loading = true;
-                const msg = await HttpUtil.post('/panel/api/server/xraylogs/'+xraylogModal.rows,{filter: xraylogModal.filter, showDirect: xraylogModal.showDirect, showBlocked: xraylogModal.showBlocked, showProxy: xraylogModal.showProxy});
-                if (!msg.success) {
-                    return;
-                }
-              xraylogModal.show(msg.obj);
-                await PromiseUtil.sleep(500);
-              xraylogModal.loading = false;
-            },
-            async openConfig() {
-                this.loading(true);
-                const msg = await HttpUtil.get('/panel/api/server/getConfigJson');
-                this.loading(false);
-                if (!msg.success) {
-                    return;
-                }
-                txtModal.show('config.json', JSON.stringify(msg.obj, null, 2), 'config.json');
-            },
-            openBackup() {
-              backupModal.show();
-            },
-            exportDatabase() {
-                window.location = basePath + 'panel/api/server/getDb';
-            },
-            importDatabase() {
-                const fileInput = document.createElement('input');
-                fileInput.type = 'file';
-                fileInput.accept = '.db';
-                fileInput.addEventListener('change', async (event) => {
-                    const dbFile = event.target.files[0];
-                    if (dbFile) {
-                        const formData = new FormData();
-                        formData.append('db', dbFile);
-                        backupModal.hide();
-                        this.loading(true);
-                        const uploadMsg = await HttpUtil.post('/panel/api/server/importDB', formData, {
-                            headers: {
-                                'Content-Type': 'multipart/form-data',
-                            }
-                        });
-                        this.loading(false);
-                        if (!uploadMsg.success) {
-                            return;
-                        }
-                        this.loading(true);
-                        const restartMsg = await HttpUtil.post("/panel/setting/restartPanel");
-                        this.loading(false);
-                        if (restartMsg.success) {
-                            this.loading(true);
-                            await PromiseUtil.sleep(5000);
-                            location.reload();
-                        }
-                    }
-                });
-                fileInput.click();
-            },
-        },
-        async mounted() {
-            if (window.location.protocol !== "https:") {
-                this.showAlert = true;
+      async openConfig() {
+        this.loading(true);
+        const msg = await HttpUtil.get('/panel/api/server/getConfigJson');
+        this.loading(false);
+        if (!msg.success) {
+          return;
+        }
+        txtModal.show('config.json', JSON.stringify(msg.obj, null, 2), 'config.json');
+      },
+      openBackup() {
+        backupModal.show();
+      },
+      exportDatabase() {
+        window.location = basePath + 'panel/api/server/getDb';
+      },
+      importDatabase() {
+        const fileInput = document.createElement('input');
+        fileInput.type = 'file';
+        fileInput.accept = '.db';
+        fileInput.addEventListener('change', async (event) => {
+          const dbFile = event.target.files[0];
+          if (dbFile) {
+            const formData = new FormData();
+            formData.append('db', dbFile);
+            backupModal.hide();
+            this.loading(true);
+            const uploadMsg = await HttpUtil.post('/panel/api/server/importDB', formData, {
+              headers: {
+                'Content-Type': 'multipart/form-data',
+              }
+            });
+            this.loading(false);
+            if (!uploadMsg.success) {
+              return;
             }
-
-            const msg = await HttpUtil.post('/panel/setting/defaultSettings');
-            if (msg.success) {
-              this.ipLimitEnable = msg.obj.ipLimitEnable;
+            this.loading(true);
+            const restartMsg = await HttpUtil.post("/panel/setting/restartPanel");
+            this.loading(false);
+            if (restartMsg.success) {
+              this.loading(true);
+              await PromiseUtil.sleep(5000);
+              location.reload();
             }
+          }
+        });
+        fileInput.click();
+      },
+    },
+    computed: {},
+    async mounted() {
+      if (window.location.protocol !== "https:") {
+        this.showAlert = true;
+      }
 
-            while (true) {
-                try {
-                    await this.getStatus();
-                } catch (e) {
-                    console.error(e);
-                }
-                await PromiseUtil.sleep(2000);
-            }
-        },
-    });
+      const msg = await HttpUtil.post('/panel/setting/defaultSettings');
+      if (msg.success) {
+        this.ipLimitEnable = msg.obj.ipLimitEnable;
+      }
+
+      while (true) {
+        try {
+          await this.getStatus();
+        } catch (e) {
+          console.error(e);
+        }
+        await PromiseUtil.sleep(2000);
+      }
+    },
+  });
 </script>
 {{ template "page/body_end" .}}

+ 104 - 62
web/service/server.go

@@ -94,22 +94,81 @@ type Release struct {
 }
 
 type ServerService struct {
-	xrayService    XrayService
-	inboundService InboundService
-	cachedIPv4     string
-	cachedIPv6     string
-	noIPv6         bool
-	// CPU utilization smoothing state
-	mu               sync.Mutex
-	lastCPUTimes     cpu.TimesStat
-	hasLastCPUSample bool
-	emaCPU           float64
-	// CPU history buffer (in-memory, protected by mu)
-	cpuHistory  []CPUSample
-	cpuCapacity int
+	xrayService        XrayService
+	inboundService     InboundService
+	cachedIPv4         string
+	cachedIPv6         string
+	noIPv6             bool
+	mu                 sync.Mutex
+	lastCPUTimes       cpu.TimesStat
+	hasLastCPUSample   bool
+	emaCPU             float64
+	cpuHistory         []CPUSample
+	cachedCpuSpeedMhz  float64
+	lastCpuInfoAttempt time.Time
 }
 
-// CPUSample represents a single CPU utilization sample with timestamp
+// AggregateCpuHistory returns up to maxPoints averaged buckets of size bucketSeconds over recent data.
+func (s *ServerService) AggregateCpuHistory(bucketSeconds int, maxPoints int) []map[string]any {
+	if bucketSeconds <= 0 || maxPoints <= 0 {
+		return nil
+	}
+	cutoff := time.Now().Add(-time.Duration(bucketSeconds*maxPoints) * time.Second).Unix()
+	s.mu.Lock()
+	// find start index (history sorted ascending)
+	hist := s.cpuHistory
+	// binary-ish scan (simple linear from end since size capped ~10800 is fine)
+	startIdx := 0
+	for i := len(hist) - 1; i >= 0; i-- {
+		if hist[i].T < cutoff {
+			startIdx = i + 1
+			break
+		}
+	}
+	if startIdx >= len(hist) {
+		s.mu.Unlock()
+		return []map[string]any{}
+	}
+	slice := hist[startIdx:]
+	// copy for unlock
+	tmp := make([]CPUSample, len(slice))
+	copy(tmp, slice)
+	s.mu.Unlock()
+	if len(tmp) == 0 {
+		return []map[string]any{}
+	}
+	var out []map[string]any
+	var acc []float64
+	bSize := int64(bucketSeconds)
+	curBucket := (tmp[0].T / bSize) * bSize
+	flush := func(ts int64) {
+		if len(acc) == 0 {
+			return
+		}
+		sum := 0.0
+		for _, v := range acc {
+			sum += v
+		}
+		avg := sum / float64(len(acc))
+		out = append(out, map[string]any{"t": ts, "cpu": avg})
+		acc = acc[:0]
+	}
+	for _, p := range tmp {
+		b := (p.T / bSize) * bSize
+		if b != curBucket {
+			flush(curBucket)
+			curBucket = b
+		}
+		acc = append(acc, p.Cpu)
+	}
+	flush(curBucket)
+	if len(out) > maxPoints {
+		out = out[len(out)-maxPoints:]
+	}
+	return out
+}
+
+// CPUSample single CPU utilization sample
 type CPUSample struct {
 	T   int64   `json:"t"`   // unix seconds
 	Cpu float64 `json:"cpu"` // percent 0..100
@@ -168,13 +227,30 @@ func (s *ServerService) GetStatus(lastStatus *Status) *Status {
 
 	status.LogicalPro = runtime.NumCPU()
 
-	cpuInfos, err := cpu.Info()
-	if err != nil {
-		logger.Warning("get cpu info failed:", err)
-	} else if len(cpuInfos) > 0 {
-		status.CpuSpeedMhz = cpuInfos[0].Mhz
-	} else {
-		logger.Warning("could not find cpu info")
+	if status.CpuSpeedMhz = s.cachedCpuSpeedMhz; s.cachedCpuSpeedMhz == 0 && time.Since(s.lastCpuInfoAttempt) > 5*time.Minute {
+		s.lastCpuInfoAttempt = time.Now()
+		done := make(chan struct{})
+		go func() {
+			defer close(done)
+			cpuInfos, err := cpu.Info()
+			if err != nil {
+				logger.Warning("get cpu info failed:", err)
+				return
+			}
+			if len(cpuInfos) > 0 {
+				s.cachedCpuSpeedMhz = cpuInfos[0].Mhz
+				status.CpuSpeedMhz = s.cachedCpuSpeedMhz
+			} else {
+				logger.Warning("could not find cpu info")
+			}
+		}()
+		select {
+		case <-done:
+		case <-time.After(1500 * time.Millisecond):
+			logger.Warning("cpu info query timed out; will retry later")
+		}
+	} else if s.cachedCpuSpeedMhz != 0 {
+		status.CpuSpeedMhz = s.cachedCpuSpeedMhz
 	}
 
 	// Uptime
@@ -322,55 +398,21 @@ func (s *ServerService) GetStatus(lastStatus *Status) *Status {
 	return status
 }
 
-// AppendCpuSample appends a CPU sample into the in-memory history with capacity trimming.
 func (s *ServerService) AppendCpuSample(t time.Time, v float64) {
+	const capacity = 9000 // ~5 hours @ 2s interval
 	s.mu.Lock()
 	defer s.mu.Unlock()
-	if s.cpuCapacity == 0 {
-		s.cpuCapacity = 10800 // ~6 hours at 2s per sample
-	}
 	p := CPUSample{T: t.Unix(), Cpu: v}
-	s.cpuHistory = append(s.cpuHistory, p)
-	if len(s.cpuHistory) > s.cpuCapacity {
-		drop := len(s.cpuHistory) - s.cpuCapacity
-		s.cpuHistory = s.cpuHistory[drop:]
-	}
-}
-
-// GetCpuHistory returns samples from the last 'mins' minutes (bounded 1..360).
-func (s *ServerService) GetCpuHistory(mins int) []CPUSample {
-	if mins < 1 {
-		mins = 1
-	}
-	if mins > 360 {
-		mins = 360
-	}
-	cutoff := time.Now().Add(-time.Duration(mins) * time.Minute).Unix()
-	s.mu.Lock()
-	defer s.mu.Unlock()
-	if len(s.cpuHistory) == 0 {
-		return nil
-	}
-	// find first index >= cutoff (linear scan from end is fine for these sizes)
-	i := len(s.cpuHistory) - 1
-	for ; i >= 0; i-- {
-		if s.cpuHistory[i].T < cutoff {
-			i++
-			break
-		}
+	if n := len(s.cpuHistory); n > 0 && s.cpuHistory[n-1].T == p.T {
+		s.cpuHistory[n-1] = p
+	} else {
+		s.cpuHistory = append(s.cpuHistory, p)
 	}
-	if i < 0 {
-		i = 0
+	if len(s.cpuHistory) > capacity {
+		s.cpuHistory = s.cpuHistory[len(s.cpuHistory)-capacity:]
 	}
-	// copy to avoid exposing internal slice
-	out := make([]CPUSample, len(s.cpuHistory)-i)
-	copy(out, s.cpuHistory[i:])
-	return out
 }
 
-// sampleCPUUtilization returns a smoothed total CPU utilization percentage across all logical processors.
-// It computes utilization from CPU time deltas (non-blocking) and applies an exponential moving average
-// to reduce spikes similar to Task Manager's smoothing.
 func (s *ServerService) sampleCPUUtilization() (float64, error) {
 	// Prefer native Windows API to avoid external deps for CPU percent
 	if runtime.GOOS == "windows" {