diff --git a/web/controller/server.go b/web/controller/server.go index 3b93afd9..169a1ae7 100644 --- a/web/controller/server.go +++ b/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() +func (a *ServerController) status(c *gin.Context) { jsonObj(c, a.lastStatus, nil) } - 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 { diff --git a/web/controller/xray_setting.go b/web/controller/xray_setting.go index 5b2d1036..2b5e0db1 100644 --- a/web/controller/xray_setting.go +++ b/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) } diff --git a/web/html/index.html b/web/html/index.html index b6a9993e..819d6df2 100644 --- a/web/html/index.html +++ b/web/html/index.html @@ -9,10 +9,7 @@ + message='{{ i18n "secAlertTitle" }}' color="red" description='{{ i18n "secAlertSsl" }}' show-icon closable> @@ -29,16 +26,16 @@ -
- {{ i18n "pages.index.cpu" }}: [[ CPUFormatter.cpuCoreFormat(status.cpuCores) ]] + {{ i18n "pages.index.cpu" }}: [[ CPUFormatter.cpuCoreFormat(status.cpuCores) ]] - + @@ -49,11 +46,11 @@
-
- {{ i18n "pages.index.memory"}}: [[ SizeFormatter.sizeFormat(status.mem.current) ]] / [[ SizeFormatter.sizeFormat(status.mem.total) ]] + {{ i18n "pages.index.memory"}}: [[ SizeFormatter.sizeFormat(status.mem.current) ]] / + [[ SizeFormatter.sizeFormat(status.mem.total) ]]
@@ -61,19 +58,19 @@ -
- {{ i18n "pages.index.swap" }}: [[ SizeFormatter.sizeFormat(status.swap.current) ]] / [[ SizeFormatter.sizeFormat(status.swap.total) ]] + {{ i18n "pages.index.swap" }}: [[ SizeFormatter.sizeFormat(status.swap.current) ]] / + [[ SizeFormatter.sizeFormat(status.swap.total) ]]
-
- {{ i18n "pages.index.storage"}}: [[ SizeFormatter.sizeFormat(status.disk.current) ]] / [[ SizeFormatter.sizeFormat(status.disk.total) ]] + {{ i18n "pages.index.storage"}}: [[ SizeFormatter.sizeFormat(status.disk.current) ]] + / [[ SizeFormatter.sizeFormat(status.disk.total) ]]
@@ -93,7 +90,9 @@ @@ -130,7 +130,8 @@ - [[ status.xray.version != 'Unknown' ? `v${status.xray.version}` : '{{ i18n "pages.index.xraySwitch" }}' ]] + [[ status.xray.version != 'Unknown' ? `v${status.xray.version}` : '{{ i18n + "pages.index.xraySwitch" }}' ]] @@ -175,7 +176,8 @@
- Xray: [[ TimeFormatter.formatSecond(status.appStats.uptime) ]] + Xray: [[ TimeFormatter.formatSecond(status.appStats.uptime) + ]] OS: [[ TimeFormatter.formatSecond(status.uptime) ]] @@ -193,7 +195,8 @@
- {{ i18n "pages.index.memory" }}: [[ SizeFormatter.sizeFormat(status.appStats.mem) ]] + {{ i18n "pages.index.memory" }}: [[ + SizeFormatter.sizeFormat(status.appStats.mem) ]] {{ i18n "pages.index.threads" }}: [[ status.appStats.threads ]] @@ -201,7 +204,8 @@ - + @@ -211,7 +215,8 @@ - + @@ -227,14 +232,16 @@ - + - + @@ -250,7 +257,8 @@ - + @@ -297,55 +305,54 @@
- + - - + + [[ version ]] - + - - + + [[ file ]] - + -
{{ i18n "pages.index.geofilesUpdateAll" }}
+
{{ i18n + "pages.index.geofilesUpdateAll" }}
- + - + - + 10 20 50 100 500 - + Debug Info Notice @@ -358,31 +365,25 @@ SysLog - + -
+
- + - + - + 10 20 50 @@ -400,24 +401,21 @@ Proxy - + -
+
- - + + - + @@ -429,33 +427,25 @@ - +
- + +
Timeframe: [[ cpuHistoryModal.bucket ]] sec per point (total [[ cpuHistoryLong.length ]] points)
@@ -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); - } - - get color() { - const percent = this.percent; - if (percent < 80) { - return '#008771'; // Green - } else if (percent < 90) { - return "#f37b24"; // Orange - } else { - return "#cf3c3c"; // Red - } - } + constructor(current, total) { + this.current = current; + this.total = total; } - 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}; - - this.xray = { state: 'stop', stateMsg: "", errorMsg: "", version: "", color: "" }; - - 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; - } - } + get percent() { + if (this.total === 0) { + return 0; + } + return NumberFormatter.toFixed(this.current / this.total * 100, 2); } - const versionModal = { - visible: false, - versions: [], - show(versions) { - this.visible = true; - this.versions = versions; - }, - hide() { - this.visible = false; - }, - }; + get color() { + const percent = this.percent; + if (percent < 80) { + return '#008771'; // Green + } else if (percent < 90) { + return "#f37b24"; // Orange + } else { + return "#cf3c3c"; // Red + } + } + } - 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"]; + 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 }; - logs.forEach((log, index) => { - let [data, message] = log.split(" - ",2); - const parts = data.split(" ") - if(index>0) formattedLogs += '
'; + this.xray = { state: 'stop', stateMsg: "", errorMsg: "", version: "", color: "" }; - if (parts.length === 3) { - const d = parts[0]; - const t = parts[1]; - const level = parts[2]; - const levelIndex = levels.indexOf(level,levels) || 5; + if (data == null) { + return; + } - //formattedLogs += `${index + 1}.`; - formattedLogs += `${d} ${t} `; - formattedLogs += `${level}`; - } else { - const levelIndex = levels.indexOf(data,levels) || 5; - formattedLogs += `${data}`; - } + 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(message){ - if(message.startsWith("XRAY:")) - message = "XRAY: " + message.substring(5); - else - message = "X-UI: " + message; - } + const versionModal = { + visible: false, + versions: [], + show(versions) { + this.visible = true; + this.versions = versions; + }, + hide() { + this.visible = false; + }, + }; - formattedLogs += message ? ' - ' + message : ''; - }); + 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"]; - return formattedLogs; - }, - hide() { - this.visible = false; - }, - }; + logs.forEach((log, index) => { + let [data, message] = log.split(" - ", 2); + const parts = data.split(" ") + if (index > 0) 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 = ''; + if (parts.length === 3) { + const d = parts[0]; + const t = parts[1]; + const level = parts[2]; + const levelIndex = levels.indexOf(level, levels) || 5; - logs.forEach((log, index) => { - if(index > 0) formattedLogs += '
'; + //formattedLogs += `${index + 1}.`; + formattedLogs += `${d} ${t} `; + formattedLogs += `${level}`; + } else { + const levelIndex = levels.indexOf(data, levels) || 5; + formattedLogs += `${data}`; + } - const parts = log.split(' '); + if (message) { + if (message.startsWith("XRAY:")) + message = "XRAY: " + message.substring(5); + else + message = "X-UI: " + message; + } - if(parts.length === 10) { - const dateTime = `${parts[0]} ${parts[1]}`; - const from = `${parts[3]}`; - const to = `${parts[5].replace(/^\/+/, "")}`; + formattedLogs += message ? ' - ' + message : ''; + }); - 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 - } + return formattedLogs; + }, + hide() { + this.visible = false; + }, + }; - 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 += '
'; + + const parts = log.split(' '); + + if (parts.length === 10) { + const dateTime = `${parts[0]} ${parts[1]}`; + const from = `${parts[3]}`; + const to = `${parts[5].replace(/^\/+/, "")}`; + + 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 += ` ${dateTime} ${parts[2]} ${from} @@ -845,261 +837,260 @@ ${dateTime} ${to} ${parts.slice(6, 9).join(' ')} `; - } else { - formattedLogs += `${log}`; - } - }); + } else { + formattedLogs += `${log}`; + } + }); - 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; - } - - 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) - } - }, - openCpuHistory() { - this.cpuHistoryModal.visible = true - this.loadCpuHistory() + const app = new Vue({ + delimiters: ['[[', ']]'], + el: '#app', + mixins: [MediaQueryMixin], + data: { + themeSwitcher, + loadingStates: { + fetched: false, + spinning: false }, - async loadCpuHistory() { - const mins = this.cpuHistoryModal.minutes || 60 + 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/cpuHistory?q=${mins}`) - 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 - this.cpuHistoryLabels = labels + 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 load CPU history', e) + console.error("Failed to get status:", 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 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; + 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.fetchCpuHistoryBucket() + }, + async fetchCpuHistoryBucket() { + const bucket = this.cpuHistoryModal.bucket || 2 + try { + const msg = await HttpUtil.get(`/panel/api/server/cpuHistory/${bucket}`) + if (msg.success && Array.isArray(msg.obj)) { + 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 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 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(); + }, + }, + computed: {}, + async mounted() { + if (window.location.protocol !== "https:") { + this.showAlert = true; + } - const msg = await HttpUtil.post('/panel/setting/defaultSettings'); - if (msg.success) { - this.ipLimitEnable = msg.obj.ipLimitEnable; - } + 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); - } - }, - }); + while (true) { + try { + await this.getStatus(); + } catch (e) { + console.error(e); + } + await PromiseUtil.sleep(2000); + } + }, + }); {{ template "page/body_end" .}} \ No newline at end of file diff --git a/web/service/server.go b/web/service/server.go index de6cc7f5..f0ad3265 100644 --- a/web/service/server.go +++ b/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:] + 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) > 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" {