mirror of
				https://github.com/MHSanaei/3x-ui.git
				synced 2025-10-27 10:30:08 +00:00 
			
		
		
		
	Compare commits
	
		
			5 commits
		
	
	
		
			135f843b3e
			...
			417c323e0a
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|   | 417c323e0a | ||
|   | 1c8689dea9 | ||
|   | a128f75f64 | ||
|   | 299572a4c2 | ||
|   | 22afa50901 | 
					 6 changed files with 654 additions and 611 deletions
				
			
		|  | @ -22,16 +22,13 @@ type ServerController struct { | |||
| 	settingService service.SettingService | ||||
| 
 | ||||
| 	lastStatus *service.Status | ||||
| 	lastGetStatusTime time.Time | ||||
| 
 | ||||
| 	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() | ||||
| func (a *ServerController) status(c *gin.Context) { jsonObj(c, a.lastStatus, nil) } | ||||
| 
 | ||||
| 	jsonObj(c, a.lastStatus, nil) | ||||
| 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 | ||||
| 	} | ||||
| 
 | ||||
| // 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 | ||||
| 	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 !allowed[bucket] { | ||||
| 		jsonMsg(c, "invalid bucket", fmt.Errorf("unsupported bucket")) | ||||
| 		return | ||||
| 	} | ||||
| 	if mins < 1 { | ||||
| 		mins = 1 | ||||
| 	} | ||||
| 	if mins > 360 { | ||||
| 		mins = 360 | ||||
| 	} | ||||
| 	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 { | ||||
|  |  | |||
|  | @ -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) | ||||
| } | ||||
| 
 | ||||
|  |  | |||
|  | @ -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,8 +26,7 @@ | |||
|                     <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) ]] | ||||
|  | @ -38,7 +34,8 @@ | |||
|                               <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-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-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-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-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> | ||||
|  | @ -363,26 +370,19 @@ | |||
|     </a-form> | ||||
|     <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-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,17 +400,13 @@ | |||
|         <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="downloadXrayLogs"></a-button> | ||||
|       </a-form-item> | ||||
|     </a-form> | ||||
|     <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-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> | ||||
|  | @ -429,33 +425,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> | ||||
|  | @ -609,7 +597,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,6 +638,7 @@ | |||
|     `, | ||||
|   }) | ||||
| 
 | ||||
| 
 | ||||
|   class CurTotal { | ||||
| 
 | ||||
|     constructor(current, total) { | ||||
|  | @ -872,7 +862,6 @@ | |||
|         this.visible = false; | ||||
|       }, | ||||
|     }; | ||||
| 
 | ||||
|   const backupModal = { | ||||
|     visible: false, | ||||
|     show() { | ||||
|  | @ -894,10 +883,10 @@ | |||
|         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 }, | ||||
|   cpuHistory: [], // small live widget history | ||||
|   cpuHistoryLong: [], // aggregated points from backend | ||||
|   cpuHistoryLabels: [], | ||||
|   cpuHistoryModal: { visible: false, bucket: 2 }, | ||||
|       versionModal, | ||||
|       logModal, | ||||
|       xraylogModal, | ||||
|  | @ -935,37 +924,35 @@ | |||
|         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 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') | ||||
|               return `${hh}:${mm}` | ||||
|             }) | ||||
|             this.cpuHistoryLong = arr | ||||
|               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) | ||||
|           console.error('Failed to fetch bucketed cpu history', e) | ||||
|         } | ||||
|       }, | ||||
|       async openSelectV2rayVersion() { | ||||
|  | @ -1049,6 +1036,25 @@ | |||
|         await PromiseUtil.sleep(500); | ||||
|         xraylogModal.loading = false; | ||||
|       }, | ||||
|       downloadXrayLogs() { | ||||
|         if (!Array.isArray(this.xraylogModal.logs) || this.xraylogModal.logs.length === 0) { | ||||
|           FileManager.downloadTextFile('', 'x-ui.log'); | ||||
|           return; | ||||
|         } | ||||
|         const lines = this.xraylogModal.logs.map(l => { | ||||
|           try { | ||||
|             const dt = l.DateTime ? new Date(l.DateTime) : null; | ||||
|             const dateStr = dt && !isNaN(dt.getTime()) ? dt.toISOString() : ''; | ||||
|             const eventMap = { 0: 'DIRECT', 1: 'BLOCKED', 2: 'PROXY' }; | ||||
|             const eventText = eventMap[l.Event] || String(l.Event ?? ''); | ||||
|             const emailPart = l.Email ? ` Email=${l.Email}` : ''; | ||||
|             return `${dateStr} FROM=${l.FromAddress || ''} TO=${l.ToAddress || ''} INBOUND=${l.Inbound || ''} OUTBOUND=${l.Outbound || ''}${emailPart} EVENT=${eventText}`.trim(); | ||||
|           } catch (e) { | ||||
|             return JSON.stringify(l); | ||||
|           } | ||||
|         }).join('\n'); | ||||
|         FileManager.downloadTextFile(lines, 'x-ui.log'); | ||||
|       }, | ||||
|       async openConfig() { | ||||
|         this.loading(true); | ||||
|         const msg = await HttpUtil.get('/panel/api/server/getConfigJson'); | ||||
|  |  | |||
|  | @ -1951,8 +1951,8 @@ func (s *InboundService) GetClientTrafficByEmail(email string) (traffic *xray.Cl | |||
| 		return nil, err | ||||
| 	} | ||||
| 	if t != nil && client != nil { | ||||
| 		// Ensure enable mirrors the client's current enable flag in settings
 | ||||
| 		t.Enable = client.Enable | ||||
| 		t.SubId = client.SubID | ||||
| 		return t, nil | ||||
| 	} | ||||
| 	return nil, nil | ||||
|  | @ -1993,6 +1993,7 @@ func (s *InboundService) GetClientTrafficByID(id string) ([]xray.ClientTraffic, | |||
| 	for i := range traffics { | ||||
| 		if ct, client, e := s.GetClientByEmail(traffics[i].Email); e == nil && ct != nil && client != nil { | ||||
| 			traffics[i].Enable = client.Enable | ||||
| 			traffics[i].SubId = client.SubID | ||||
| 		} | ||||
| 	} | ||||
| 	return traffics, err | ||||
|  |  | |||
|  | @ -99,17 +99,76 @@ type ServerService struct { | |||
| 	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 | ||||
| 	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
 | ||||
|  | @ -178,14 +237,31 @@ func (s *ServerService) GetStatus(lastStatus *Status) *Status { | |||
| 
 | ||||
| 	status.LogicalPro = runtime.NumCPU() | ||||
| 
 | ||||
| 	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) | ||||
| 	} else if len(cpuInfos) > 0 { | ||||
| 		status.CpuSpeedMhz = cpuInfos[0].Mhz | ||||
| 				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
 | ||||
| 	upTime, err := host.Uptime() | ||||
|  | @ -332,55 +408,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} | ||||
| 	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 len(s.cpuHistory) > s.cpuCapacity { | ||||
| 		drop := len(s.cpuHistory) - s.cpuCapacity | ||||
| 		s.cpuHistory = s.cpuHistory[drop:] | ||||
| 	} | ||||
| 	if len(s.cpuHistory) > capacity { | ||||
| 		s.cpuHistory = s.cpuHistory[len(s.cpuHistory)-capacity:] | ||||
| 	} | ||||
| } | ||||
| 
 | ||||
| // 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 i < 0 { | ||||
| 		i = 0 | ||||
| 	} | ||||
| 	// 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" { | ||||
|  |  | |||
|  | @ -5,6 +5,7 @@ type ClientTraffic struct { | |||
| 	InboundId  int    `json:"inboundId" form:"inboundId"` | ||||
| 	Enable     bool   `json:"enable" form:"enable"` | ||||
| 	Email      string `json:"email" form:"email" gorm:"unique"` | ||||
| 	SubId      string `json:"subId" form:"subId" gorm:"-"` | ||||
| 	Up         int64  `json:"up" form:"up"` | ||||
| 	Down       int64  `json:"down" form:"down"` | ||||
| 	AllTime    int64  `json:"allTime" form:"allTime"` | ||||
|  |  | |||
		Loading…
	
		Reference in a new issue