diff --git a/frontend/src/pages/api-docs/endpoints.js b/frontend/src/pages/api-docs/endpoints.js index baf2bda5..ca82e585 100644 --- a/frontend/src/pages/api-docs/endpoints.js +++ b/frontend/src/pages/api-docs/endpoints.js @@ -325,7 +325,7 @@ export const sections = [ { method: 'GET', path: '/panel/api/server/getNewVlessEnc', - summary: 'Generate a new VLESS encryption keypair.', + summary: 'Generate VLESS encryption auth options. Returns auths with id, label, decryption, and encryption.', }, { method: 'POST', diff --git a/frontend/src/pages/inbounds/InboundFormModal.vue b/frontend/src/pages/inbounds/InboundFormModal.vue index cd01691d..fd045bbb 100644 --- a/frontend/src/pages/inbounds/InboundFormModal.vue +++ b/frontend/src/pages/inbounds/InboundFormModal.vue @@ -393,16 +393,29 @@ async function fetchDefaultCertSettings() { } // === VLESS encryption helpers ======================================= -// `xray vlessenc` returns both X25519 and ML-KEM-768 variants every -// call; the user clicks one of two buttons to pick which block goes -// into decryption/encryption. -async function getNewVlessEnc(authLabel) { - if (!authLabel || !inbound.value?.settings) return; +// `xray vlessenc` returns both X25519 and ML-KEM-768 auth variants every +// call; the user clicks one button to pick which block goes into +// decryption/encryption. Both generated strings share the same hybrid +// mlkem768x25519plus prefix; the auth choice is the final key block. +function normalizeVlessAuthLabel(label = '') { + return label.toLowerCase().replace(/[-_\s]/g, ''); +} + +function matchesVlessAuth(block, authId) { + if (block?.id === authId) return true; + const label = normalizeVlessAuthLabel(block?.label); + if (authId === 'mlkem768') return label.includes('mlkem768'); + if (authId === 'x25519') return label.includes('x25519'); + return false; +} + +async function getNewVlessEnc(authId) { + if (!authId || !inbound.value?.settings) return; saving.value = true; try { const msg = await HttpUtil.get('/panel/api/server/getNewVlessEnc'); if (!msg?.success) return; - const block = (msg.obj?.auths || []).find((a) => a.label === authLabel); + const block = (msg.obj?.auths || []).find((a) => matchesVlessAuth(a, authId)); if (!block) return; inbound.value.settings.decryption = block.decryption; inbound.value.settings.encryption = block.encryption; @@ -417,6 +430,17 @@ function clearVlessEnc() { inbound.value.settings.encryption = 'none'; } +const selectedVlessAuth = computed(() => { + const encryption = inbound.value?.settings?.encryption; + if (!encryption || encryption === 'none') return 'None'; + + const parts = encryption.split('.').filter(Boolean); + const authKey = parts[parts.length - 1] || ''; + if (!authKey) return 'Custom'; + + return authKey.length > 300 ? 'ML-KEM-768 auth' : 'X25519 auth'; +}); + // === SS method change tracks legacy semantics ========================= function onSSMethodChange() { inbound.value.settings.password = RandomUtil.randomShadowsocksPassword(inbound.value.settings.method); @@ -731,14 +755,17 @@ watch( - - X25519 + + X25519 auth - - ML-KEM-768 + + ML-KEM-768 auth Clear + + Selected: {{ selectedVlessAuth }} + @@ -1741,6 +1768,11 @@ watch( color: #ff4d4f; } +.vless-auth-state { + display: block; + margin-top: 6px; +} + .json-editor { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; font-size: 12px; diff --git a/frontend/src/pages/inbounds/InboundList.vue b/frontend/src/pages/inbounds/InboundList.vue index 88b39bc8..c425ced5 100644 --- a/frontend/src/pages/inbounds/InboundList.vue +++ b/frontend/src/pages/inbounds/InboundList.vue @@ -1,5 +1,5 @@ + + + + + {{ t('pages.index.xrayMetricsTitle') }} + + 2m + 30m + 1h + 2h + 3h + 5h + + + + + + + + + + + + + + + + + {{ tg.tag }} + + + + + + {{ activeObsTag.alive ? t('pages.index.xrayObservatoryAlive') : t('pages.index.xrayObservatoryDead') }} + + {{ activeObsTag.delay }} ms + + {{ t('pages.index.xrayObservatoryLastSeen') }}: {{ fmtTimestamp(activeObsTag.lastSeenTime) }} + + + {{ t('pages.index.xrayObservatoryLastTry') }}: {{ fmtTimestamp(activeObsTag.lastTryTime) }} + + + + + + + + Timeframe: {{ bucket }} sec per point (total {{ points.length }} points) + · {{ state.listen }} + + + + + + + diff --git a/main.go b/main.go index 9bb1d0b9..90db08da 100644 --- a/main.go +++ b/main.go @@ -81,11 +81,7 @@ func runWebServer() { case syscall.SIGHUP: logger.Info("Received SIGHUP signal. Restarting servers...") - // --- FIX FOR TELEGRAM BOT CONFLICT (409): Stop bot before restart --- - service.StopBot() - // -- - - err := server.Stop() + err := server.StopPanelOnly() if err != nil { logger.Debug("Error stopping web server:", err) } @@ -96,7 +92,7 @@ func runWebServer() { server = web.NewServer() global.SetWebServer(server) - err = server.Start() + err = server.StartPanelOnly() if err != nil { log.Fatalf("Error restarting web server: %v", err) return diff --git a/web/controller/server.go b/web/controller/server.go index 441a0d88..4d5aa356 100644 --- a/web/controller/server.go +++ b/web/controller/server.go @@ -23,9 +23,10 @@ var filenameRegex = regexp.MustCompile(`^[a-zA-Z0-9_\-.]+$`) type ServerController struct { BaseController - serverService service.ServerService - settingService service.SettingService - panelService service.PanelService + serverService service.ServerService + settingService service.SettingService + panelService service.PanelService + xrayMetricsService service.XrayMetricsService lastStatus *service.Status @@ -47,6 +48,10 @@ func (a *ServerController) initRouter(g *gin.RouterGroup) { g.GET("/status", a.status) g.GET("/cpuHistory/:bucket", a.getCpuHistoryBucket) g.GET("/history/:metric/:bucket", a.getMetricHistoryBucket) + g.GET("/xrayMetricsState", a.getXrayMetricsState) + g.GET("/xrayMetricsHistory/:metric/:bucket", a.getXrayMetricsHistoryBucket) + g.GET("/xrayObservatory", a.getXrayObservatory) + g.GET("/xrayObservatoryHistory/:tag/:bucket", a.getXrayObservatoryHistoryBucket) g.GET("/getXrayVersion", a.getXrayVersion) g.GET("/getPanelUpdateInfo", a.getPanelUpdateInfo) g.GET("/getConfigJson", a.getConfigJson) @@ -75,7 +80,9 @@ func (a *ServerController) initRouter(g *gin.RouterGroup) { func (a *ServerController) refreshStatus() { a.lastStatus = a.serverService.GetStatus(a.lastStatus) if a.lastStatus != nil { - a.serverService.AppendStatusSample(time.Now(), a.lastStatus) + now := time.Now() + a.serverService.AppendStatusSample(now, a.lastStatus) + a.xrayMetricsService.Sample(now) // Broadcast status update via WebSocket websocket.BroadcastStatus(a.lastStatus) } @@ -143,6 +150,42 @@ func (a *ServerController) getMetricHistoryBucket(c *gin.Context) { jsonObj(c, a.serverService.AggregateSystemMetric(metric, bucket, 60), nil) } +func (a *ServerController) getXrayMetricsState(c *gin.Context) { + jsonObj(c, a.xrayMetricsService.State(), nil) +} + +func (a *ServerController) getXrayMetricsHistoryBucket(c *gin.Context) { + metric := c.Param("metric") + if !slices.Contains(service.XrayMetricKeys, metric) { + jsonMsg(c, "invalid metric", fmt.Errorf("unknown metric")) + return + } + bucket, err := strconv.Atoi(c.Param("bucket")) + if err != nil || bucket <= 0 || !allowedHistoryBuckets[bucket] { + jsonMsg(c, "invalid bucket", fmt.Errorf("unsupported bucket")) + return + } + jsonObj(c, a.xrayMetricsService.AggregateMetric(metric, bucket, 60), nil) +} + +func (a *ServerController) getXrayObservatory(c *gin.Context) { + jsonObj(c, a.xrayMetricsService.ObservatorySnapshot(), nil) +} + +func (a *ServerController) getXrayObservatoryHistoryBucket(c *gin.Context) { + tag := c.Param("tag") + if !a.xrayMetricsService.HasObservatoryTag(tag) { + jsonMsg(c, "invalid tag", fmt.Errorf("unknown observatory tag")) + return + } + bucket, err := strconv.Atoi(c.Param("bucket")) + if err != nil || bucket <= 0 || !allowedHistoryBuckets[bucket] { + jsonMsg(c, "invalid bucket", fmt.Errorf("unsupported bucket")) + return + } + jsonObj(c, a.xrayMetricsService.AggregateObservatory(tag, bucket, 60), nil) +} + func (a *ServerController) getXrayVersion(c *gin.Context) { const cacheTTLSeconds = 15 * 60 diff --git a/web/service/metric_history.go b/web/service/metric_history.go index f180d3b9..5905b678 100644 --- a/web/service/metric_history.go +++ b/web/service/metric_history.go @@ -130,6 +130,7 @@ func (h *metricHistory) aggregate(metric string, bucketSeconds int, maxPoints in var ( systemMetrics = newMetricHistory() nodeMetrics = newMetricHistory() + xrayMetrics = newMetricHistory() ) // SystemMetricKeys lists the metric names ServerService writes on every @@ -141,3 +142,11 @@ var SystemMetricKeys = []string{ // NodeMetricKeys lists the per-node metric names NodeHeartbeatJob writes. var NodeMetricKeys = []string{"cpu", "mem"} + +// XrayMetricKeys lists series sourced from xray's /debug/vars expvar +// endpoint. Populated by XrayMetricsService.Sample on the same 2s cadence +// as the system metrics, but only when the xray config has a `metrics` +// block configured. +var XrayMetricKeys = []string{ + "xrAlloc", "xrSys", "xrHeapObjects", "xrNumGC", "xrPauseNs", +} diff --git a/web/service/node.go b/web/service/node.go index 9cdaf2b7..5ed76d69 100644 --- a/web/service/node.go +++ b/web/service/node.go @@ -53,6 +53,20 @@ func (s *NodeService) GetById(id int) (*model.Node, error) { return n, nil } +func normalizeBasePath(p string) string { + p = strings.TrimSpace(p) + if p == "" { + return "/" + } + if !strings.HasPrefix(p, "/") { + p = "/" + p + } + if !strings.HasSuffix(p, "/") { + p = p + "/" + } + return p +} + func (s *NodeService) normalize(n *model.Node) error { n.Name = strings.TrimSpace(n.Name) n.Address = strings.TrimSpace(n.Address) @@ -69,15 +83,7 @@ func (s *NodeService) normalize(n *model.Node) error { if n.Scheme != "http" && n.Scheme != "https" { n.Scheme = "https" } - if n.BasePath == "" { - n.BasePath = "/" - } - if !strings.HasPrefix(n.BasePath, "/") { - n.BasePath = "/" + n.BasePath - } - if !strings.HasSuffix(n.BasePath, "/") { - n.BasePath = n.BasePath + "/" - } + n.BasePath = normalizeBasePath(n.BasePath) return nil } @@ -169,7 +175,7 @@ func (s *NodeService) AggregateNodeMetric(id int, metric string, bucketSeconds i func (s *NodeService) Probe(ctx context.Context, n *model.Node) (HeartbeatPatch, error) { patch := HeartbeatPatch{LastHeartbeat: time.Now().Unix()} url := fmt.Sprintf("%s://%s:%d%spanel/api/server/status", - n.Scheme, n.Address, n.Port, n.BasePath) + n.Scheme, n.Address, n.Port, normalizeBasePath(n.BasePath)) req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { diff --git a/web/service/server.go b/web/service/server.go index e2ad9deb..e8aed5bc 100644 --- a/web/service/server.go +++ b/web/service/server.go @@ -1275,7 +1275,13 @@ func (s *ServerService) GetNewVlessEnc() (any, error) { return nil, err } - lines := strings.Split(out.String(), "\n") + return map[string]any{ + "auths": parseVlessEncAuths(out.String()), + }, nil +} + +func parseVlessEncAuths(output string) []map[string]string { + lines := strings.Split(output, "\n") var auths []map[string]string var current map[string]string @@ -1285,14 +1291,18 @@ func (s *ServerService) GetNewVlessEnc() (any, error) { if current != nil { auths = append(auths, current) } + label := strings.TrimSpace(strings.TrimPrefix(line, "Authentication:")) current = map[string]string{ - "label": strings.TrimSpace(strings.TrimPrefix(line, "Authentication:")), + "id": vlessEncAuthID(label), + "label": label, } } else if strings.HasPrefix(line, `"decryption"`) || strings.HasPrefix(line, `"encryption"`) { parts := strings.SplitN(line, ":", 2) if len(parts) == 2 && current != nil { key := strings.Trim(parts[0], `" `) - val := strings.Trim(parts[1], `" `) + val := strings.TrimSpace(parts[1]) + val = strings.TrimSuffix(val, ",") + val = strings.Trim(val, `" `) current[key] = val } } @@ -1302,9 +1312,19 @@ func (s *ServerService) GetNewVlessEnc() (any, error) { auths = append(auths, current) } - return map[string]any{ - "auths": auths, - }, nil + return auths +} + +func vlessEncAuthID(label string) string { + normalized := strings.NewReplacer("-", "", "_", "", " ", "").Replace(strings.ToLower(label)) + switch { + case strings.Contains(normalized, "mlkem768"): + return "mlkem768" + case strings.Contains(normalized, "x25519"): + return "x25519" + default: + return normalized + } } func (s *ServerService) GetNewUUID() (map[string]string, error) { diff --git a/web/service/server_vlessenc_test.go b/web/service/server_vlessenc_test.go new file mode 100644 index 00000000..2e8b8572 --- /dev/null +++ b/web/service/server_vlessenc_test.go @@ -0,0 +1,82 @@ +package service + +import "testing" + +func TestParseVlessEncAuthsAddsStableIDs(t *testing.T) { + output := ` +Authentication: X25519, not Post-Quantum +{ + "decryption": "mlkem768x25519plus.native.600s.server-x25519", + "encryption": "mlkem768x25519plus.native.0rtt.client-x25519" +} + +Authentication: ML-KEM-768, Post-Quantum +{ + "decryption": "mlkem768x25519plus.native.600s.server-mlkem", + "encryption": "mlkem768x25519plus.native.0rtt.client-mlkem" +} +` + + auths := parseVlessEncAuths(output) + if len(auths) != 2 { + t.Fatalf("expected 2 auth blocks, got %d", len(auths)) + } + + tests := []struct { + index int + id string + label string + decryption string + encryption string + }{ + { + index: 0, + id: "x25519", + label: "X25519, not Post-Quantum", + decryption: "mlkem768x25519plus.native.600s.server-x25519", + encryption: "mlkem768x25519plus.native.0rtt.client-x25519", + }, + { + index: 1, + id: "mlkem768", + label: "ML-KEM-768, Post-Quantum", + decryption: "mlkem768x25519plus.native.600s.server-mlkem", + encryption: "mlkem768x25519plus.native.0rtt.client-mlkem", + }, + } + + for _, test := range tests { + auth := auths[test.index] + if auth["id"] != test.id { + t.Errorf("auth[%d] id = %q, want %q", test.index, auth["id"], test.id) + } + if auth["label"] != test.label { + t.Errorf("auth[%d] label = %q, want %q", test.index, auth["label"], test.label) + } + if auth["decryption"] != test.decryption { + t.Errorf("auth[%d] decryption = %q, want %q", test.index, auth["decryption"], test.decryption) + } + if auth["encryption"] != test.encryption { + t.Errorf("auth[%d] encryption = %q, want %q", test.index, auth["encryption"], test.encryption) + } + } +} + +func TestParseVlessEncAuthsHandlesMissingTrailingComma(t *testing.T) { + output := ` +Authentication: X25519, not Post-Quantum +"decryption": "server" +"encryption": "client" +` + + auths := parseVlessEncAuths(output) + if len(auths) != 1 { + t.Fatalf("expected 1 auth block, got %d", len(auths)) + } + if auths[0]["decryption"] != "server" { + t.Fatalf("decryption = %q, want server", auths[0]["decryption"]) + } + if auths[0]["encryption"] != "client" { + t.Fatalf("encryption = %q, want client", auths[0]["encryption"]) + } +} diff --git a/web/service/traffic_writer.go b/web/service/traffic_writer.go index b15c459a..f7b3fef6 100644 --- a/web/service/traffic_writer.go +++ b/web/service/traffic_writer.go @@ -23,6 +23,7 @@ type trafficWriteRequest struct { var ( twMu sync.Mutex twQueue chan *trafficWriteRequest + twCtx context.Context twCancel context.CancelFunc twDone chan struct{} ) @@ -37,16 +38,26 @@ var ( func StartTrafficWriter() { twMu.Lock() defer twMu.Unlock() - if twQueue != nil { - return + + if twCancel != nil && twDone != nil { + select { + case <-twDone: + clearTrafficWriterState() + default: + return + } } + queue := make(chan *trafficWriteRequest, trafficWriterQueueSize) ctx, cancel := context.WithCancel(context.Background()) done := make(chan struct{}) + twQueue = queue + twCtx = ctx twCancel = cancel twDone = done - go runTrafficWriter(queue, ctx, done) + + go runTrafficWriter(ctx, queue, done) } // StopTrafficWriter cancels the writer context and waits for the goroutine to @@ -56,20 +67,30 @@ func StopTrafficWriter() { twMu.Lock() cancel := twCancel done := twDone - twQueue = nil - twCancel = nil - twDone = nil + if cancel == nil || done == nil { + twMu.Unlock() + return + } + cancel() twMu.Unlock() - if cancel != nil { - cancel() - } - if done != nil { - <-done + <-done + + twMu.Lock() + if twDone == done { + clearTrafficWriterState() } + twMu.Unlock() } -func runTrafficWriter(queue chan *trafficWriteRequest, ctx context.Context, done chan struct{}) { +func clearTrafficWriterState() { + twQueue = nil + twCtx = nil + twCancel = nil + twDone = nil +} + +func runTrafficWriter(ctx context.Context, queue chan *trafficWriteRequest, done chan struct{}) { defer close(done) for { select { @@ -99,18 +120,43 @@ func safeApply(fn func() error) (err error) { } func submitTrafficWrite(fn func() error) error { + req := &trafficWriteRequest{apply: fn, done: make(chan error, 1)} + twMu.Lock() queue := twQueue - twMu.Unlock() - - if queue == nil { + ctx := twCtx + done := twDone + if queue == nil || ctx == nil || done == nil { + twMu.Unlock() return safeApply(fn) } - req := &trafficWriteRequest{apply: fn, done: make(chan error, 1)} + + select { + case <-ctx.Done(): + twMu.Unlock() + return safeApply(fn) + default: + } + + timer := time.NewTimer(trafficWriterSubmitTimeout) + defer timer.Stop() select { case queue <- req: - case <-time.After(trafficWriterSubmitTimeout): + twMu.Unlock() + case <-timer.C: + twMu.Unlock() return errors.New("traffic writer queue full") } - return <-req.done + + select { + case err := <-req.done: + return err + case <-done: + select { + case err := <-req.done: + return err + default: + return errors.New("traffic writer stopped before write completed") + } + } } diff --git a/web/service/traffic_writer_test.go b/web/service/traffic_writer_test.go new file mode 100644 index 00000000..6ecb5eb9 --- /dev/null +++ b/web/service/traffic_writer_test.go @@ -0,0 +1,190 @@ +package service + +import ( + "sync/atomic" + "testing" + "time" +) + +func TestTrafficWriterStartStopStartAcceptsWrites(t *testing.T) { + resetTrafficWriterForTest(t) + + StartTrafficWriter() + var writes atomic.Int32 + if err := submitTrafficWrite(func() error { + writes.Add(1) + return nil + }); err != nil { + t.Fatalf("first submitTrafficWrite: %v", err) + } + + StopTrafficWriter() + StartTrafficWriter() + if err := submitTrafficWrite(func() error { + writes.Add(1) + return nil + }); err != nil { + t.Fatalf("second submitTrafficWrite: %v", err) + } + + if got := writes.Load(); got != 2 { + t.Fatalf("writes = %d, want 2", got) + } +} + +func TestTrafficWriterSubmitAfterStopRunsInline(t *testing.T) { + resetTrafficWriterForTest(t) + + StartTrafficWriter() + StopTrafficWriter() + + ran := make(chan struct{}) + errCh := make(chan error, 1) + go func() { + errCh <- submitTrafficWrite(func() error { + close(ran) + return nil + }) + }() + + select { + case <-ran: + case <-time.After(time.Second): + t.Fatal("submitTrafficWrite did not run after traffic writer stopped") + } + if err := waitTrafficWriterErr(t, errCh); err != nil { + t.Fatalf("submitTrafficWrite after stop: %v", err) + } +} + +func TestTrafficWriterStopDrainsQueuedWrite(t *testing.T) { + resetTrafficWriterForTest(t) + + StartTrafficWriter() + firstStarted := make(chan struct{}) + releaseFirst := make(chan struct{}) + firstErr := make(chan error, 1) + go func() { + firstErr <- submitTrafficWrite(func() error { + close(firstStarted) + <-releaseFirst + return nil + }) + }() + waitTrafficWriterSignal(t, firstStarted, "first write did not start") + + secondRan := make(chan struct{}) + secondErr := make(chan error, 1) + go func() { + secondErr <- submitTrafficWrite(func() error { + close(secondRan) + return nil + }) + }() + waitTrafficWriterQueued(t) + + stopDone := make(chan struct{}) + go func() { + StopTrafficWriter() + close(stopDone) + }() + + select { + case <-stopDone: + t.Fatal("StopTrafficWriter returned before in-flight write was released") + case <-time.After(50 * time.Millisecond): + } + + close(releaseFirst) + waitTrafficWriterSignal(t, stopDone, "StopTrafficWriter did not return") + waitTrafficWriterSignal(t, secondRan, "queued write was not drained") + + if err := waitTrafficWriterErr(t, firstErr); err != nil { + t.Fatalf("first submitTrafficWrite: %v", err) + } + if err := waitTrafficWriterErr(t, secondErr); err != nil { + t.Fatalf("second submitTrafficWrite: %v", err) + } +} + +func TestTrafficWriterConcurrentStopDuringSubmitDoesNotHang(t *testing.T) { + resetTrafficWriterForTest(t) + + StartTrafficWriter() + started := make(chan struct{}) + release := make(chan struct{}) + errCh := make(chan error, 1) + go func() { + errCh <- submitTrafficWrite(func() error { + close(started) + <-release + return nil + }) + }() + waitTrafficWriterSignal(t, started, "write did not start") + + stopDone := make(chan struct{}) + go func() { + StopTrafficWriter() + close(stopDone) + }() + + close(release) + waitTrafficWriterSignal(t, stopDone, "StopTrafficWriter hung during submit") + if err := waitTrafficWriterErr(t, errCh); err != nil { + t.Fatalf("submitTrafficWrite during stop: %v", err) + } +} + +func resetTrafficWriterForTest(t *testing.T) { + t.Helper() + StopTrafficWriter() + twMu.Lock() + clearTrafficWriterState() + twMu.Unlock() + t.Cleanup(func() { + StopTrafficWriter() + twMu.Lock() + clearTrafficWriterState() + twMu.Unlock() + }) +} + +func waitTrafficWriterQueued(t *testing.T) { + t.Helper() + + deadline := time.Now().Add(time.Second) + for time.Now().Before(deadline) { + twMu.Lock() + queued := 0 + if twQueue != nil { + queued = len(twQueue) + } + twMu.Unlock() + if queued > 0 { + return + } + time.Sleep(10 * time.Millisecond) + } + t.Fatal("write was not queued") +} + +func waitTrafficWriterSignal(t *testing.T, ch <-chan struct{}, msg string) { + t.Helper() + select { + case <-ch: + case <-time.After(time.Second): + t.Fatal(msg) + } +} + +func waitTrafficWriterErr(t *testing.T, ch <-chan error) error { + t.Helper() + select { + case err := <-ch: + return err + case <-time.After(time.Second): + t.Fatal("timed out waiting for traffic writer result") + return nil + } +} diff --git a/web/service/xray_metrics.go b/web/service/xray_metrics.go new file mode 100644 index 00000000..9eb08039 --- /dev/null +++ b/web/service/xray_metrics.go @@ -0,0 +1,224 @@ +package service + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "regexp" + "sort" + "strings" + "sync" + "time" + + "github.com/mhsanaei/3x-ui/v3/logger" +) + +type xrayMetricsState struct { + Enabled bool `json:"enabled"` + Listen string `json:"listen"` + Reason string `json:"reason,omitempty"` +} + +type ObsTagSnapshot struct { + Tag string `json:"tag"` + Alive bool `json:"alive"` + Delay int64 `json:"delay"` + LastSeenTime int64 `json:"lastSeenTime"` + LastTryTime int64 `json:"lastTryTime"` + UpdatedAt int64 `json:"updatedAt"` +} + +type XrayMetricsService struct { + settingService SettingService + + mu sync.RWMutex + state xrayMetricsState + client *http.Client + obsByTag map[string]ObsTagSnapshot +} + +var validObsTag = regexp.MustCompile(`^[a-zA-Z0-9._\-]+$`) + +func obsHistoryKey(tag string) string { + return "xrObs." + tag + ".delay" +} + +func newXrayMetricsClient() *http.Client { + return &http.Client{Timeout: 1500 * time.Millisecond} +} + +func (s *XrayMetricsService) getClient() *http.Client { + s.mu.Lock() + defer s.mu.Unlock() + if s.client == nil { + s.client = newXrayMetricsClient() + } + return s.client +} + +func (s *XrayMetricsService) State() xrayMetricsState { + s.mu.RLock() + defer s.mu.RUnlock() + return s.state +} + +func (s *XrayMetricsService) AggregateMetric(metric string, bucketSeconds, maxPoints int) []map[string]any { + return xrayMetrics.aggregate(metric, bucketSeconds, maxPoints) +} + +func (s *XrayMetricsService) ObservatorySnapshot() []ObsTagSnapshot { + s.mu.RLock() + defer s.mu.RUnlock() + out := make([]ObsTagSnapshot, 0, len(s.obsByTag)) + for _, v := range s.obsByTag { + out = append(out, v) + } + sort.Slice(out, func(i, j int) bool { return out[i].Tag < out[j].Tag }) + return out +} + +func (s *XrayMetricsService) HasObservatoryTag(tag string) bool { + if !validObsTag.MatchString(tag) { + return false + } + s.mu.RLock() + defer s.mu.RUnlock() + _, ok := s.obsByTag[tag] + return ok +} + +func (s *XrayMetricsService) AggregateObservatory(tag string, bucketSeconds, maxPoints int) []map[string]any { + if !validObsTag.MatchString(tag) { + return []map[string]any{} + } + return xrayMetrics.aggregate(obsHistoryKey(tag), bucketSeconds, maxPoints) +} + +func (s *XrayMetricsService) discoverListen() (string, error) { + tmpl, err := s.settingService.GetXrayConfigTemplate() + if err != nil { + return "", err + } + var parsed struct { + Metrics *struct { + Listen string `json:"listen"` + } `json:"metrics"` + } + if err := json.Unmarshal([]byte(tmpl), &parsed); err != nil { + return "", err + } + if parsed.Metrics == nil || strings.TrimSpace(parsed.Metrics.Listen) == "" { + return "", nil + } + return strings.TrimSpace(parsed.Metrics.Listen), nil +} + +type rawObsEntry struct { + Alive bool `json:"alive"` + Delay int64 `json:"delay"` + LastSeenTime int64 `json:"last_seen_time"` + LastTryTime int64 `json:"last_try_time"` + OutboundTag string `json:"outbound_tag"` +} + +func (s *XrayMetricsService) Sample(t time.Time) { + listen, err := s.discoverListen() + if err != nil { + s.setState(xrayMetricsState{Reason: err.Error()}) + return + } + if listen == "" { + s.setState(xrayMetricsState{Reason: "metrics block not configured in xray template"}) + return + } + + ctx, cancel := context.WithTimeout(context.Background(), 1500*time.Millisecond) + defer cancel() + url := fmt.Sprintf("http://%s/debug/vars", listen) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + s.setState(xrayMetricsState{Listen: listen, Reason: err.Error()}) + return + } + resp, err := s.getClient().Do(req) + if err != nil { + s.setState(xrayMetricsState{Listen: listen, Reason: err.Error()}) + return + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + s.setState(xrayMetricsState{Listen: listen, Reason: fmt.Sprintf("HTTP %d", resp.StatusCode)}) + return + } + + var payload struct { + MemStats struct { + HeapAlloc uint64 `json:"HeapAlloc"` + Sys uint64 `json:"Sys"` + HeapObjects uint64 `json:"HeapObjects"` + NumGC uint32 `json:"NumGC"` + PauseNs [256]uint64 `json:"PauseNs"` + } `json:"memstats"` + Observatory map[string]rawObsEntry `json:"observatory"` + } + if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { + s.setState(xrayMetricsState{Listen: listen, Reason: err.Error()}) + return + } + + xrayMetrics.append("xrAlloc", t, float64(payload.MemStats.HeapAlloc)) + xrayMetrics.append("xrSys", t, float64(payload.MemStats.Sys)) + xrayMetrics.append("xrHeapObjects", t, float64(payload.MemStats.HeapObjects)) + xrayMetrics.append("xrNumGC", t, float64(payload.MemStats.NumGC)) + var lastPause uint64 + if payload.MemStats.NumGC > 0 { + idx := (payload.MemStats.NumGC + 255) % 256 + lastPause = payload.MemStats.PauseNs[idx] + } + xrayMetrics.append("xrPauseNs", t, float64(lastPause)) + + s.applyObservatory(t, payload.Observatory) + s.setState(xrayMetricsState{Enabled: true, Listen: listen}) +} + +func (s *XrayMetricsService) applyObservatory(t time.Time, entries map[string]rawObsEntry) { + next := make(map[string]ObsTagSnapshot, len(entries)) + for key, e := range entries { + tag := e.OutboundTag + if tag == "" { + tag = key + } + if !validObsTag.MatchString(tag) { + continue + } + snap := ObsTagSnapshot{ + Tag: tag, + Alive: e.Alive, + Delay: e.Delay, + LastSeenTime: e.LastSeenTime, + LastTryTime: e.LastTryTime, + UpdatedAt: t.Unix(), + } + next[tag] = snap + xrayMetrics.append(obsHistoryKey(tag), t, float64(e.Delay)) + } + + s.mu.Lock() + for tag := range s.obsByTag { + if _, kept := next[tag]; !kept { + xrayMetrics.drop(obsHistoryKey(tag)) + } + } + s.obsByTag = next + s.mu.Unlock() +} + +func (s *XrayMetricsService) setState(st xrayMetricsState) { + s.mu.Lock() + s.state = st + s.mu.Unlock() + if !st.Enabled && st.Reason != "" { + logger.Debugf("xray metrics unavailable: %s", st.Reason) + } +} diff --git a/web/translation/ar-EG.json b/web/translation/ar-EG.json index 47152b57..7ab23c2c 100644 --- a/web/translation/ar-EG.json +++ b/web/translation/ar-EG.json @@ -143,6 +143,17 @@ "xrayErrorPopoverTitle": "حصل خطأ أثناء تشغيل Xray", "operationHours": "مدة التشغيل", "systemHistoryTitle": "تاريخ النظام", + "charts": "الرسوم البيانية", + "xrayMetricsTitle": "مقاييس Xray", + "xrayMetricsDisabled": "نقطة نهاية مقاييس Xray غير مهيأة", + "xrayMetricsHint": "أضف كتلة metrics على المستوى الأعلى في إعدادات xray مع tag باسم metrics_out و listen على 127.0.0.1:11111، ثم أعد تشغيل xray.", + "xrayObservatoryEmpty": "لا توجد بيانات Observatory بعد", + "xrayObservatoryHint": "أضف كتلة observatory إلى إعدادات xray مع قائمة وسوم outbound للفحص، ثم أعد تشغيل xray.", + "xrayObservatoryTagPlaceholder": "اختر outbound", + "xrayObservatoryAlive": "نشط", + "xrayObservatoryDead": "غير متصل", + "xrayObservatoryLastSeen": "آخر مشاهدة", + "xrayObservatoryLastTry": "آخر محاولة", "trendLast2Min": "آخر دقيقتين", "systemLoad": "تحميل النظام", "systemLoadDesc": "متوسط تحميل النظام في الدقائق 1, 5, و15", diff --git a/web/translation/en-US.json b/web/translation/en-US.json index bd1f882f..c8e08798 100644 --- a/web/translation/en-US.json +++ b/web/translation/en-US.json @@ -143,6 +143,17 @@ "xrayErrorPopoverTitle": "An error occurred while running Xray", "operationHours": "Uptime", "systemHistoryTitle": "System History", + "charts": "Charts", + "xrayMetricsTitle": "Xray Metrics", + "xrayMetricsDisabled": "Xray metrics endpoint not configured", + "xrayMetricsHint": "Add a top-level metrics block to the xray config with tag metrics_out and listen 127.0.0.1:11111, then restart xray.", + "xrayObservatoryEmpty": "No observatory data yet", + "xrayObservatoryHint": "Add an observatory block to the xray config listing the outbound tags to probe, then restart xray.", + "xrayObservatoryTagPlaceholder": "Select outbound", + "xrayObservatoryAlive": "Alive", + "xrayObservatoryDead": "Down", + "xrayObservatoryLastSeen": "Last seen", + "xrayObservatoryLastTry": "Last try", "trendLast2Min": "Last 2 minutes", "systemLoad": "System Load", "systemLoadDesc": "System load average for the past 1, 5, and 15 minutes", diff --git a/web/translation/es-ES.json b/web/translation/es-ES.json index 4fad3fdc..2e610b2b 100644 --- a/web/translation/es-ES.json +++ b/web/translation/es-ES.json @@ -143,6 +143,17 @@ "xrayErrorPopoverTitle": "Se produjo un error al ejecutar Xray", "operationHours": "Tiempo de Funcionamiento", "systemHistoryTitle": "Historial del Sistema", + "charts": "Gráficos", + "xrayMetricsTitle": "Métricas de Xray", + "xrayMetricsDisabled": "Endpoint de métricas de Xray no configurado", + "xrayMetricsHint": "Añade un bloque metrics de nivel superior a la configuración de xray con tag metrics_out y listen 127.0.0.1:11111, luego reinicia xray.", + "xrayObservatoryEmpty": "Aún no hay datos de Observatory", + "xrayObservatoryHint": "Añade un bloque observatory a la configuración de xray listando los tags de outbound a sondear, luego reinicia xray.", + "xrayObservatoryTagPlaceholder": "Seleccionar outbound", + "xrayObservatoryAlive": "Activo", + "xrayObservatoryDead": "Caído", + "xrayObservatoryLastSeen": "Visto por última vez", + "xrayObservatoryLastTry": "Último intento", "trendLast2Min": "Últimos 2 minutos", "systemLoad": "Carga del Sistema", "systemLoadDesc": "promedio de carga del sistema en los últimos 1, 5 y 15 minutos", diff --git a/web/translation/fa-IR.json b/web/translation/fa-IR.json index 6b75c54b..fc5ba7cd 100644 --- a/web/translation/fa-IR.json +++ b/web/translation/fa-IR.json @@ -143,6 +143,17 @@ "xrayErrorPopoverTitle": "خطا در هنگام اجرای Xray رخ داد", "operationHours": "مدتکارکرد", "systemHistoryTitle": "تاریخچه سیستم", + "charts": "نمودارها", + "xrayMetricsTitle": "متریکهای Xray", + "xrayMetricsDisabled": "نقطه پایانی متریکهای Xray پیکربندی نشده", + "xrayMetricsHint": "یک بلاک metrics در سطح بالای پیکربندی xray با tag برابر metrics_out و listen برابر 127.0.0.1:11111 اضافه کنید، سپس xray را راهاندازی مجدد کنید.", + "xrayObservatoryEmpty": "هنوز دادهای از Observatory دریافت نشده", + "xrayObservatoryHint": "یک بلاک observatory در پیکربندی xray اضافه کنید و outbound tagهایی که میخواهید بررسی شوند را لیست کنید، سپس xray را راهاندازی مجدد کنید.", + "xrayObservatoryTagPlaceholder": "انتخاب outbound", + "xrayObservatoryAlive": "فعال", + "xrayObservatoryDead": "غیرفعال", + "xrayObservatoryLastSeen": "آخرین مشاهده", + "xrayObservatoryLastTry": "آخرین تلاش", "trendLast2Min": "۲ دقیقه اخیر", "systemLoad": "بارسیستم", "systemLoadDesc": "میانگین بار سیستم برای 1، 5 و 15 دقیقه گذشته", diff --git a/web/translation/id-ID.json b/web/translation/id-ID.json index 4a189655..c651d64f 100644 --- a/web/translation/id-ID.json +++ b/web/translation/id-ID.json @@ -143,6 +143,17 @@ "xrayErrorPopoverTitle": "Terjadi kesalahan saat menjalankan Xray", "operationHours": "Waktu Aktif", "systemHistoryTitle": "Riwayat Sistem", + "charts": "Grafik", + "xrayMetricsTitle": "Metrik Xray", + "xrayMetricsDisabled": "Endpoint metrik Xray belum dikonfigurasi", + "xrayMetricsHint": "Tambahkan blok metrics tingkat atas ke konfigurasi xray dengan tag metrics_out dan listen 127.0.0.1:11111, lalu mulai ulang xray.", + "xrayObservatoryEmpty": "Belum ada data Observatory", + "xrayObservatoryHint": "Tambahkan blok observatory ke konfigurasi xray yang mencantumkan tag outbound untuk diuji, lalu mulai ulang xray.", + "xrayObservatoryTagPlaceholder": "Pilih outbound", + "xrayObservatoryAlive": "Aktif", + "xrayObservatoryDead": "Mati", + "xrayObservatoryLastSeen": "Terakhir terlihat", + "xrayObservatoryLastTry": "Percobaan terakhir", "trendLast2Min": "2 menit terakhir", "systemLoad": "Beban Sistem", "systemLoadDesc": "Rata-rata beban sistem selama 1, 5, dan 15 menit terakhir", diff --git a/web/translation/ja-JP.json b/web/translation/ja-JP.json index 5c2f3013..1daca2e5 100644 --- a/web/translation/ja-JP.json +++ b/web/translation/ja-JP.json @@ -143,6 +143,17 @@ "xrayErrorPopoverTitle": "Xrayの実行中にエラーが発生しました", "operationHours": "システム稼働時間", "systemHistoryTitle": "システム履歴", + "charts": "チャート", + "xrayMetricsTitle": "Xray メトリクス", + "xrayMetricsDisabled": "Xray メトリクスエンドポイントが設定されていません", + "xrayMetricsHint": "xray 設定にトップレベルの metrics ブロック(tag: metrics_out、listen: 127.0.0.1:11111)を追加し、xray を再起動してください。", + "xrayObservatoryEmpty": "Observatory データはまだありません", + "xrayObservatoryHint": "xray 設定に observatory ブロックを追加し、プローブする outbound タグを列挙してから xray を再起動してください。", + "xrayObservatoryTagPlaceholder": "Outbound を選択", + "xrayObservatoryAlive": "稼働中", + "xrayObservatoryDead": "停止", + "xrayObservatoryLastSeen": "最終確認", + "xrayObservatoryLastTry": "最終試行", "trendLast2Min": "直近2分", "systemLoad": "システム負荷", "systemLoadDesc": "過去1、5、15分間のシステム平均負荷", diff --git a/web/translation/pt-BR.json b/web/translation/pt-BR.json index cfec3c58..f639f230 100644 --- a/web/translation/pt-BR.json +++ b/web/translation/pt-BR.json @@ -143,6 +143,17 @@ "xrayErrorPopoverTitle": "Ocorreu um erro ao executar o Xray", "operationHours": "Tempo de Atividade", "systemHistoryTitle": "Histórico do Sistema", + "charts": "Gráficos", + "xrayMetricsTitle": "Métricas do Xray", + "xrayMetricsDisabled": "Endpoint de métricas do Xray não configurado", + "xrayMetricsHint": "Adicione um bloco metrics de nível superior à configuração do xray com tag metrics_out e listen 127.0.0.1:11111, depois reinicie o xray.", + "xrayObservatoryEmpty": "Ainda não há dados do Observatory", + "xrayObservatoryHint": "Adicione um bloco observatory à configuração do xray listando as tags de outbound a sondar, depois reinicie o xray.", + "xrayObservatoryTagPlaceholder": "Selecionar outbound", + "xrayObservatoryAlive": "Ativo", + "xrayObservatoryDead": "Inativo", + "xrayObservatoryLastSeen": "Visto pela última vez", + "xrayObservatoryLastTry": "Última tentativa", "trendLast2Min": "Últimos 2 minutos", "systemLoad": "Carga do Sistema", "systemLoadDesc": "Média de carga do sistema nos últimos 1, 5 e 15 minutos", diff --git a/web/translation/ru-RU.json b/web/translation/ru-RU.json index efb4ac9f..b2b88cfe 100644 --- a/web/translation/ru-RU.json +++ b/web/translation/ru-RU.json @@ -143,6 +143,17 @@ "xrayErrorPopoverTitle": "Ошибка при запуске Xray", "operationHours": "Время работы системы", "systemHistoryTitle": "История системы", + "charts": "Графики", + "xrayMetricsTitle": "Метрики Xray", + "xrayMetricsDisabled": "Конечная точка метрик Xray не настроена", + "xrayMetricsHint": "Добавьте блок metrics верхнего уровня в конфигурацию xray с tag metrics_out и listen 127.0.0.1:11111, затем перезапустите xray.", + "xrayObservatoryEmpty": "Данных Observatory пока нет", + "xrayObservatoryHint": "Добавьте блок observatory в конфигурацию xray с указанием тегов outbound для проверки, затем перезапустите xray.", + "xrayObservatoryTagPlaceholder": "Выберите outbound", + "xrayObservatoryAlive": "Активен", + "xrayObservatoryDead": "Недоступен", + "xrayObservatoryLastSeen": "Последняя активность", + "xrayObservatoryLastTry": "Последняя попытка", "trendLast2Min": "Последние 2 минуты", "systemLoad": "Нагрузка на систему", "systemLoadDesc": "Средняя загрузка системы за последние 1, 5 и 15 минут", diff --git a/web/translation/tr-TR.json b/web/translation/tr-TR.json index 3c8932c8..05136a79 100644 --- a/web/translation/tr-TR.json +++ b/web/translation/tr-TR.json @@ -143,6 +143,17 @@ "xrayErrorPopoverTitle": "Xray çalıştırılırken bir hata oluştu", "operationHours": "Çalışma Süresi", "systemHistoryTitle": "Sistem Geçmişi", + "charts": "Grafikler", + "xrayMetricsTitle": "Xray Metrikleri", + "xrayMetricsDisabled": "Xray metrik uç noktası yapılandırılmadı", + "xrayMetricsHint": "xray yapılandırmasına tag metrics_out ve listen 127.0.0.1:11111 olan üst düzey bir metrics bloğu ekleyin, sonra xray'i yeniden başlatın.", + "xrayObservatoryEmpty": "Henüz Observatory verisi yok", + "xrayObservatoryHint": "xray yapılandırmasına test edilecek outbound etiketlerini listeleyen bir observatory bloğu ekleyin, sonra xray'i yeniden başlatın.", + "xrayObservatoryTagPlaceholder": "Outbound seç", + "xrayObservatoryAlive": "Aktif", + "xrayObservatoryDead": "Kapalı", + "xrayObservatoryLastSeen": "Son görülme", + "xrayObservatoryLastTry": "Son deneme", "trendLast2Min": "Son 2 dakika", "systemLoad": "Sistem Yükü", "systemLoadDesc": "Geçmiş 1, 5 ve 15 dakika için sistem yük ortalaması", diff --git a/web/translation/uk-UA.json b/web/translation/uk-UA.json index 66839b55..eb451b14 100644 --- a/web/translation/uk-UA.json +++ b/web/translation/uk-UA.json @@ -143,6 +143,17 @@ "xrayErrorPopoverTitle": "Під час роботи Xray сталася помилка", "operationHours": "Час роботи", "systemHistoryTitle": "Історія системи", + "charts": "Графіки", + "xrayMetricsTitle": "Метрики Xray", + "xrayMetricsDisabled": "Кінцева точка метрик Xray не налаштована", + "xrayMetricsHint": "Додайте блок metrics верхнього рівня до конфігурації xray з tag metrics_out і listen 127.0.0.1:11111, потім перезапустіть xray.", + "xrayObservatoryEmpty": "Даних Observatory ще немає", + "xrayObservatoryHint": "Додайте блок observatory до конфігурації xray зі списком outbound тегів для перевірки, потім перезапустіть xray.", + "xrayObservatoryTagPlaceholder": "Виберіть outbound", + "xrayObservatoryAlive": "Активний", + "xrayObservatoryDead": "Недоступний", + "xrayObservatoryLastSeen": "Остання активність", + "xrayObservatoryLastTry": "Остання спроба", "trendLast2Min": "Останні 2 хвилини", "systemLoad": "Завантаження системи", "systemLoadDesc": "Середнє завантаження системи за останні 1, 5 і 15 хвилин", diff --git a/web/translation/vi-VN.json b/web/translation/vi-VN.json index 5353d6cc..8e0ff6e6 100644 --- a/web/translation/vi-VN.json +++ b/web/translation/vi-VN.json @@ -143,6 +143,17 @@ "xrayErrorPopoverTitle": "Đã xảy ra lỗi khi chạy Xray", "operationHours": "Thời gian hoạt động", "systemHistoryTitle": "Lịch sử hệ thống", + "charts": "Biểu đồ", + "xrayMetricsTitle": "Chỉ số Xray", + "xrayMetricsDisabled": "Điểm cuối chỉ số Xray chưa được cấu hình", + "xrayMetricsHint": "Thêm khối metrics cấp cao nhất vào cấu hình xray với tag là metrics_out và listen là 127.0.0.1:11111, sau đó khởi động lại xray.", + "xrayObservatoryEmpty": "Chưa có dữ liệu Observatory", + "xrayObservatoryHint": "Thêm khối observatory vào cấu hình xray liệt kê các tag outbound cần kiểm tra, sau đó khởi động lại xray.", + "xrayObservatoryTagPlaceholder": "Chọn outbound", + "xrayObservatoryAlive": "Hoạt động", + "xrayObservatoryDead": "Ngừng", + "xrayObservatoryLastSeen": "Lần cuối thấy", + "xrayObservatoryLastTry": "Lần thử cuối", "trendLast2Min": "2 phút gần nhất", "systemLoad": "Tải hệ thống", "systemLoadDesc": "trung bình tải hệ thống trong 1, 5 và 15 phút qua", diff --git a/web/translation/zh-CN.json b/web/translation/zh-CN.json index 2c501498..e27c3652 100644 --- a/web/translation/zh-CN.json +++ b/web/translation/zh-CN.json @@ -143,6 +143,17 @@ "xrayErrorPopoverTitle": "运行Xray时发生错误", "operationHours": "系统正常运行时间", "systemHistoryTitle": "系统历史", + "charts": "图表", + "xrayMetricsTitle": "Xray 指标", + "xrayMetricsDisabled": "未配置 Xray 指标端点", + "xrayMetricsHint": "在 xray 配置中添加顶级 metrics 块,tag 为 metrics_out,listen 为 127.0.0.1:11111,然后重启 xray。", + "xrayObservatoryEmpty": "暂无 Observatory 数据", + "xrayObservatoryHint": "在 xray 配置中添加 observatory 块,列出要探测的出站 tag,然后重启 xray。", + "xrayObservatoryTagPlaceholder": "选择出站", + "xrayObservatoryAlive": "在线", + "xrayObservatoryDead": "离线", + "xrayObservatoryLastSeen": "最后在线", + "xrayObservatoryLastTry": "最后尝试", "trendLast2Min": "最近 2 分钟", "systemLoad": "系统负载", "systemLoadDesc": "过去 1、5 和 15 分钟的系统平均负载", diff --git a/web/translation/zh-TW.json b/web/translation/zh-TW.json index 2e1fe327..78f26dee 100644 --- a/web/translation/zh-TW.json +++ b/web/translation/zh-TW.json @@ -143,6 +143,17 @@ "xrayErrorPopoverTitle": "執行Xray時發生錯誤", "operationHours": "系統正常執行時間", "systemHistoryTitle": "系統歷史", + "charts": "圖表", + "xrayMetricsTitle": "Xray 指標", + "xrayMetricsDisabled": "未設定 Xray 指標端點", + "xrayMetricsHint": "在 xray 設定中加入頂層 metrics 區塊,tag 為 metrics_out,listen 為 127.0.0.1:11111,然後重啟 xray。", + "xrayObservatoryEmpty": "尚無 Observatory 資料", + "xrayObservatoryHint": "在 xray 設定中加入 observatory 區塊,列出要探測的出站 tag,然後重啟 xray。", + "xrayObservatoryTagPlaceholder": "選擇出站", + "xrayObservatoryAlive": "在線", + "xrayObservatoryDead": "離線", + "xrayObservatoryLastSeen": "最後在線", + "xrayObservatoryLastTry": "最後嘗試", "trendLast2Min": "最近 2 分鐘", "systemLoad": "系統負載", "systemLoadDesc": "過去 1、5 和 15 分鐘的系統平均負載", diff --git a/web/web.go b/web/web.go index 4ba70550..2b8157ed 100644 --- a/web/web.go +++ b/web/web.go @@ -259,11 +259,13 @@ func (s *Server) initRouter() (*gin.Engine, error) { // startTask schedules background jobs (Xray checks, traffic jobs, cron // jobs) which the panel relies on for periodic maintenance and monitoring. -func (s *Server) startTask() { +func (s *Server) startTask(restartXray bool) { s.customGeoService.EnsureOnStartup() - err := s.xrayService.RestartXray(true) - if err != nil { - logger.Warning("start xray failed:", err) + if restartXray { + err := s.xrayService.RestartXray(true) + if err != nil { + logger.Warning("start xray failed:", err) + } } // Check whether xray is running every second s.cron.AddJob("@every 1s", job.NewCheckXrayRunningJob()) @@ -348,6 +350,15 @@ func (s *Server) startTask() { // Start initializes and starts the web server with configured settings, routes, and background jobs. func (s *Server) Start() (err error) { + return s.start(true, true) +} + +// StartPanelOnly initializes the panel during an in-process panel restart without cycling Xray. +func (s *Server) StartPanelOnly() (err error) { + return s.start(false, false) +} + +func (s *Server) start(restartXray bool, startTgBot bool) (err error) { // This is an anonymous function, no function name defer func() { if err != nil { @@ -427,12 +438,14 @@ func (s *Server) Start() (err error) { s.httpServer.Serve(listener) }() - s.startTask() + s.startTask(restartXray) - isTgbotenabled, err := s.settingService.GetTgbotEnabled() - if (err == nil) && (isTgbotenabled) { - tgBot := s.tgbotService.NewTgbot() - tgBot.Start(i18nFS) + if startTgBot { + isTgbotenabled, err := s.settingService.GetTgbotEnabled() + if (err == nil) && (isTgbotenabled) { + tgBot := s.tgbotService.NewTgbot() + tgBot.Start(i18nFS) + } } return nil @@ -440,13 +453,26 @@ func (s *Server) Start() (err error) { // Stop gracefully shuts down the web server, stops Xray, cron jobs, and Telegram bot. func (s *Server) Stop() error { + return s.stop(true, true) +} + +// StopPanelOnly stops only panel-owned HTTP/background resources for an in-process panel restart. +func (s *Server) StopPanelOnly() error { + return s.stop(false, false) +} + +func (s *Server) stop(stopXray bool, stopTgBot bool) error { s.cancel() - s.xrayService.StopXray() + if stopXray { + s.xrayService.StopXray() + } if s.cron != nil { s.cron.Stop() } - service.StopTrafficWriter() - if s.tgbotService.IsRunning() { + if stopXray { + service.StopTrafficWriter() + } + if stopTgBot && s.tgbotService.IsRunning() { s.tgbotService.Stop() } // Gracefully stop WebSocket hub