P7: restore agent node type, agent heartbeat expiry, build-version injection, remove log/history/xray-metrics modules, optional sub port

- nodes: port node-type (traditional/agent) picker back from web-src into
  the embedded frontend; backend annotates NodeView.nodeType from
  agent_nodes bindings; fix agent-node update path (skip reachability
  probe and apiToken requirement for agent-managed nodes); restore i18n
  keys in all 13 locales; install command now uses the Gitea mirror
- jobs: expire agent-bound nodes whose agent_nodes.last_seen is older
  than max(3x heartbeat interval, 120s) = 180s; last_seen=0 (never
  registered) is left untouched; transitions reuse UpdateHeartbeat +
  node event broadcast
- config: GetBaseVersion honors ldflags-injected buildVersion; release
  builds inject 3.7.0-agent.1 so the panel no longer reports a phantom
  update against the Gitea release tag
- logger/server: remove the three log viewers (panel/xray/amneziawg),
  their API endpoints and the 10240-entry in-memory log buffer; file
  logging via lumberjack unchanged; tests assert via a new optional
  logger hook instead of the removed buffer
- server: remove system history and xray metrics modules end to end
  (endpoints, sampling appends in RefreshStatus, per-minute persist
  cron, gob snapshot/restore, XrayMetricsService); realtime status and
  per-node history are untouched
- settings: subPort may be 0/unset - subscription links are generated
  without a port while the subscription service keeps binding the
  default 2096; the settings form declares the cleared state via
  onNumber's new onClear option instead of snapping back to 1
This commit is contained in:
Muse
2026-10-21 06:44:00 +00:00
parent 2c85f85549
commit f887f47bb9
66 changed files with 1093 additions and 5129 deletions
+19 -3
View File
@@ -24,9 +24,17 @@ var name string
// CI per-commit (dev channel) builds; see .github/workflows/release.yml. They
// stay empty for a plain `go build` and for stable tagged releases, which is how
// IsDevBuild tells a rolling dev build apart from a stable/local one.
//
// buildVersion is injected the same way by this fork's release builds
// (e.g. "3.7.0-agent.1", matching the Gitea release tag) so the binary reports
// the fork release it was built from — the embedded version file always names
// the upstream base release, which made the update check compare "3.7.0"
// against the fork's "v3.7.0-agent.1" tag and report a phantom update forever.
// It stays empty for a plain `go build`, which falls back to the embedded file.
var (
buildCommit string
buildDate string
buildCommit string
buildDate string
buildVersion string
)
// LogLevel represents the logging level for the application.
@@ -41,11 +49,19 @@ const (
Error LogLevel = "error"
)
// GetBaseVersion returns the raw embedded release version of the 3x-ui panel
// GetBaseVersion returns the release version of the 3x-ui panel
// (e.g. "3.4.0"). This is the panel's own version, not the Xray version. For the
// version a panel advertises/displays (which adds a "dev+<sha>" label on dev
// builds), use GetPanelVersion.
//
// A fork release build injects buildVersion via `-ldflags -X`, which wins over
// the embedded version file; every version consumer (UI display, update check,
// panel-version advertisement to a master) reads through here, so the injected
// value is the single source of truth for the running binary.
func GetBaseVersion() string {
if v := strings.TrimSpace(buildVersion); v != "" {
return v
}
return strings.TrimSpace(version)
}
+28
View File
@@ -25,6 +25,34 @@ func TestGetPanelVersion(t *testing.T) {
}
}
func TestGetBaseVersionBuildVersionOverride(t *testing.T) {
orig := buildVersion
t.Cleanup(func() { buildVersion = orig })
// No injection (plain `go build`): the embedded version file wins.
buildVersion = ""
embedded := GetBaseVersion()
if embedded == "" {
t.Fatal("embedded version file must not be empty")
}
// Injected fork release version wins over the embedded file and flows
// into GetPanelVersion untouched (stable channel, no dev label).
buildVersion = "3.7.0-agent.1"
if got := GetBaseVersion(); got != "3.7.0-agent.1" {
t.Fatalf("injected build: GetBaseVersion = %q, want %q", got, "3.7.0-agent.1")
}
if got := GetPanelVersion(); got != "3.7.0-agent.1" {
t.Fatalf("injected build: GetPanelVersion = %q, want %q", got, "3.7.0-agent.1")
}
// Whitespace-only injection counts as unset.
buildVersion = " "
if got := GetBaseVersion(); got != embedded {
t.Fatalf("blank injection: GetBaseVersion = %q, want embedded %q", got, embedded)
}
}
func TestGetPortOverride(t *testing.T) {
tests := []struct {
name string
+50 -79
View File
@@ -1,5 +1,7 @@
// Package logger provides logging functionality for the 3x-ui panel with
// dual-backend logging (console/syslog and file) and buffered log storage for web UI.
// dual-backend logging (console/syslog and file). The in-memory log buffer
// that fed the web UI's log viewer was removed together with that feature;
// logs live on disk (rotating file under the log folder) and in syslog.
package logger
import (
@@ -8,7 +10,6 @@ import (
"path/filepath"
"runtime"
"sync"
"time"
"github.com/op/go-logging"
@@ -18,9 +19,8 @@ import (
)
const (
maxLogBufferSize = 10240 // Maximum log entries kept in memory
logFileName = "3xui.log" // Log file name
timeFormat = "2006/01/02 15:04:05" // Log timestamp format
logFileName = "3xui.log" // Log file name
timeFormat = "2006/01/02 15:04:05" // Log timestamp format
// On-disk rotation limits — single file capped, old segments pruned automatically.
maxLogFileMB = 10 // rotate active log when larger than this
@@ -35,16 +35,33 @@ var (
logger = logging.MustGetLogger("x-ui")
fileRotate *lumberjack.Logger // nil when file backend disabled
// logBuffer maintains recent log entries in memory for web UI retrieval;
// logBufferMu guards it — written from many goroutines, read by the web UI.
logBufferMu sync.Mutex
logBuffer []struct {
time string
level logging.Level
log string
}
// logHook, when set, additionally receives every log entry. Production
// never sets it; it exists so tests can assert on emitted logs now that
// the web UI's in-memory buffer is gone.
logHookMu sync.RWMutex
logHook func(level, msg string)
)
// SetLogHook installs a sink receiving every log entry (level name and
// formatted message) in addition to the normal backends, and returns the
// previously installed hook (nil in production). Passing nil clears it.
func SetLogHook(h func(level, msg string)) func(level, msg string) {
logHookMu.Lock()
defer logHookMu.Unlock()
prev := logHook
logHook = h
return prev
}
func emitLogHook(level, msg string) {
logHookMu.RLock()
h := logHook
logHookMu.RUnlock()
if h != nil {
h(level, msg)
}
}
// InitLogger initializes dual logging backends: console/syslog and file.
// Console logging uses the specified level, file logging always uses DEBUG level.
func InitLogger(level logging.Level) {
@@ -135,108 +152,62 @@ func CloseLogger() {
}
}
// Debug logs a debug message and adds it to the log buffer.
// Debug logs a debug message and emits it to the log hook.
func Debug(args ...any) {
logger.Debug(args...)
addToBuffer("DEBUG", fmt.Sprint(args...))
emitLogHook("DEBUG", fmt.Sprint(args...))
}
// Debugf logs a formatted debug message and adds it to the log buffer.
// Debugf logs a formatted debug message and emits it to the log hook.
func Debugf(format string, args ...any) {
logger.Debugf(format, args...)
addToBuffer("DEBUG", fmt.Sprintf(format, args...))
emitLogHook("DEBUG", fmt.Sprintf(format, args...))
}
// Info logs an info message and adds it to the log buffer.
// Info logs an info message and emits it to the log hook.
func Info(args ...any) {
logger.Info(args...)
addToBuffer("INFO", fmt.Sprint(args...))
emitLogHook("INFO", fmt.Sprint(args...))
}
// Infof logs a formatted info message and adds it to the log buffer.
// Infof logs a formatted info message and emits it to the log hook.
func Infof(format string, args ...any) {
logger.Infof(format, args...)
addToBuffer("INFO", fmt.Sprintf(format, args...))
emitLogHook("INFO", fmt.Sprintf(format, args...))
}
// Notice logs a notice message and adds it to the log buffer.
// Notice logs a notice message and emits it to the log hook.
func Notice(args ...any) {
logger.Notice(args...)
addToBuffer("NOTICE", fmt.Sprint(args...))
emitLogHook("NOTICE", fmt.Sprint(args...))
}
// Noticef logs a formatted notice message and adds it to the log buffer.
// Noticef logs a formatted notice message and emits it to the log hook.
func Noticef(format string, args ...any) {
logger.Noticef(format, args...)
addToBuffer("NOTICE", fmt.Sprintf(format, args...))
emitLogHook("NOTICE", fmt.Sprintf(format, args...))
}
// Warning logs a warning message and adds it to the log buffer.
// Warning logs a warning message and emits it to the log hook.
func Warning(args ...any) {
logger.Warning(args...)
addToBuffer("WARNING", fmt.Sprint(args...))
emitLogHook("WARNING", fmt.Sprint(args...))
}
// Warningf logs a formatted warning message and adds it to the log buffer.
// Warningf logs a formatted warning message and emits it to the log hook.
func Warningf(format string, args ...any) {
logger.Warningf(format, args...)
addToBuffer("WARNING", fmt.Sprintf(format, args...))
emitLogHook("WARNING", fmt.Sprintf(format, args...))
}
// Error logs an error message and adds it to the log buffer.
// Error logs an error message and emits it to the log hook.
func Error(args ...any) {
logger.Error(args...)
addToBuffer("ERROR", fmt.Sprint(args...))
emitLogHook("ERROR", fmt.Sprint(args...))
}
// Errorf logs a formatted error message and adds it to the log buffer.
// Errorf logs a formatted error message and emits it to the log hook.
func Errorf(format string, args ...any) {
logger.Errorf(format, args...)
addToBuffer("ERROR", fmt.Sprintf(format, args...))
}
// addToBuffer adds a log entry to the in-memory ring buffer for web UI retrieval.
func addToBuffer(level string, newLog string) {
t := time.Now()
logBufferMu.Lock()
defer logBufferMu.Unlock()
if len(logBuffer) >= maxLogBufferSize {
logBuffer = logBuffer[1:]
}
logLevel, _ := logging.LogLevel(level)
logBuffer = append(logBuffer, struct {
time string
level logging.Level
log string
}{
time: t.Format(timeFormat),
level: logLevel,
log: newLog,
})
}
// GetLogs retrieves up to c log entries from the buffer that are at or below the specified level.
func GetLogs(c int, level string) []string {
var output []string
logLevel, _ := logging.LogLevel(level)
// Snapshot (copy) under the lock, then filter/format unlocked: a UI log fetch
// must not block addToBuffer — and thus all logging — for the formatting loop.
// A copy (not a reslice) is required, since addToBuffer can append in place.
logBufferMu.Lock()
snapshot := make([]struct {
time string
level logging.Level
log string
}, len(logBuffer))
copy(snapshot, logBuffer)
logBufferMu.Unlock()
for i := len(snapshot) - 1; i >= 0 && len(output) < c; i-- {
if snapshot[i].level <= logLevel {
output = append(output, fmt.Sprintf("%s %s - %s", snapshot[i].time, snapshot[i].level, snapshot[i].log))
}
}
return output
emitLogHook("ERROR", fmt.Sprintf(format, args...))
}
+36 -18
View File
@@ -2,29 +2,47 @@ package logger
import (
"fmt"
"sync"
"testing"
)
// TestGetLogs_ReturnsAtMostC guards the documented "up to c entries" contract.
// The loop condition must cap output at c (ERROR entries are queried at "debug"
// level so the level filter passes all of them, isolating the count).
func TestGetLogs_ReturnsAtMostC(t *testing.T) {
logBufferMu.Lock()
logBuffer = nil
logBufferMu.Unlock()
for i := range 5 {
addToBuffer("ERROR", fmt.Sprintf("m%d", i))
}
// TestLogHookReceivesEntries replaces the old in-memory buffer tests: the
// buffer (and its GetLogs accessor) was removed with the web log viewer, and
// the hook is now the only side channel tests assert on.
func TestLogHookReceivesEntries(t *testing.T) {
var mu sync.Mutex
var got []string
prev := SetLogHook(func(level, msg string) {
mu.Lock()
defer mu.Unlock()
got = append(got, level+" "+msg)
})
t.Cleanup(func() { SetLogHook(prev) })
cases := []struct{ c, want int }{
{0, 0},
{2, 2},
{5, 5},
{10, 5}, // capped at what's available
Debug("d1")
Infof("i%d", 2)
Warning("w3")
Errorf("e%s", "4")
want := []string{"DEBUG d1", "INFO i2", "WARNING w3", "ERROR e4"}
mu.Lock()
defer mu.Unlock()
if len(got) != len(want) {
t.Fatalf("hook captured %v, want %v", got, want)
}
for _, tc := range cases {
if got := GetLogs(tc.c, "debug"); len(got) != tc.want {
t.Errorf("GetLogs(%d) returned %d entries, want %d", tc.c, len(got), tc.want)
for i := range want {
if got[i] != want[i] {
t.Fatalf("hook entry %d = %q, want %q", i, got[i], want[i])
}
}
}
// TestLogHookUnsetIsSilent guards the production path: with no hook
// installed, logging must simply not panic.
func TestLogHookUnsetIsSilent(t *testing.T) {
prev := SetLogHook(nil)
t.Cleanup(func() { SetLogHook(prev) })
for i := range 3 {
Info(fmt.Sprintf("m%d", i))
}
}
+19 -1
View File
@@ -4,6 +4,7 @@ import (
"reflect"
"strconv"
"strings"
"sync"
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
@@ -20,6 +21,22 @@ func TestExtraSalamanderKeys(t *testing.T) {
}
}
// captureLogLines installs a logger hook collecting "LEVEL message" lines
// for the rest of the test. It replaces reads of the in-memory log buffer,
// which was removed together with the web log viewer.
func captureLogLines(t *testing.T) *[]string {
t.Helper()
var mu sync.Mutex
lines := &[]string{}
prev := logger.SetLogHook(func(level, msg string) {
mu.Lock()
defer mu.Unlock()
*lines = append(*lines, level+" "+msg)
})
t.Cleanup(func() { logger.SetLogHook(prev) })
return lines
}
func TestGenHysteriaLinkWarnsOnceForUnsupportedSalamanderSettings(t *testing.T) {
makeInbound := func(id int, settings string) *model.Inbound {
return &model.Inbound{
@@ -28,10 +45,11 @@ func TestGenHysteriaLinkWarnsOnceForUnsupportedSalamanderSettings(t *testing.T)
StreamSettings: `{"security":"tls","finalmask":{"udp":[{"type":"salamander","settings":` + settings + `}]}}`,
}
}
logLines := captureLogLines(t)
countWarnings := func(id int) int {
needle := "inbound " + strconv.Itoa(id) + ": salamander settings"
count := 0
for _, line := range logger.GetLogs(100, "warning") {
for _, line := range *logLines {
if strings.Contains(line, needle) {
count++
}
+7
View File
@@ -299,6 +299,13 @@ func (s *Server) Start() (err error) {
if err != nil {
return err
}
// subPort 0 only means "no port in generated links" (see
// SettingService.BuildSubURIBase); the listener itself always binds a
// real port — fall back to the setting's default so an empty port can
// never turn into a ":0" bind on a random OS-assigned port.
if port <= 0 {
port = 2096
}
listenAddr := net.JoinHostPort(listen, strconv.Itoa(port))
listener, err := (&net.ListenConfig{}).Listen(context.Background(), "tcp", listenAddr)
+3 -9
View File
@@ -73,15 +73,9 @@ func (a *APIController) checkAPIAuth(c *gin.Context) {
// monitorScopeAllow exposes only status/metrics routes without sensitive data.
// Keys are route patterns relative to /panel/api.
var monitorScopeAllow = map[string]struct{}{
"/server/status": {},
"/server/cpuHistory/:bucket": {},
"/server/history/:metric/:bucket": {},
"/server/xrayMetricsState": {},
"/server/xrayMetricsHistory/:metric/:bucket": {},
"/server/xrayObservatory": {},
"/server/xrayObservatoryHistory/:tag/:bucket": {},
"/server/getPanelUpdateInfo": {},
"/nodes/history/:id/:metric/:bucket": {},
"/server/status": {},
"/server/getPanelUpdateInfo": {},
"/nodes/history/:id/:metric/:bucket": {},
}
// nodeSyncScopeAllow is the node-sync route/method allowlist relative to
+5 -1
View File
@@ -169,6 +169,7 @@ func (a *NodeController) add(c *gin.Context) {
// For agent nodes, issue an agent token bound to the new node and
// return it so the UI can display it (shown only once).
if isAgent {
view.NodeType = "agent"
nodeID := view.Id
token, _, err := issueAgentToken(&nodeID, n.Name)
if err != nil {
@@ -200,7 +201,10 @@ func (a *NodeController) update(c *gin.Context) {
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.obtain"), err)
return
}
if n.OutboundTag == "" && old.OutboundTag == "" && (!n.ClearApiToken || n.Enable) {
// Agent nodes have no panel to probe and no panel API token; editing one
// must skip the reachability check exactly like creation does.
isAgent := n.NodeType == "agent" || a.nodeService.IsAgentManaged(id)
if !isAgent && n.OutboundTag == "" && old.OutboundTag == "" && (!n.ClearApiToken || n.Enable) {
if err := a.ensureReachable(c, n, id); err != nil {
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.update"), err)
return
+8 -124
View File
@@ -4,9 +4,7 @@ import (
"fmt"
"net/http"
"regexp"
"slices"
"strconv"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/logger"
@@ -25,16 +23,14 @@ var filenameRegex = regexp.MustCompile(`^[a-zA-Z0-9_\-.]+$`)
type ServerController struct {
BaseController
serverService service.ServerService
settingService service.SettingService
panelService panel.PanelService
xrayMetricsService service.XrayMetricsService
serverService service.ServerService
settingService service.SettingService
panelService panel.PanelService
}
// NewServerController creates a new ServerController, initializes routes, and starts background tasks.
func NewServerController(g *gin.RouterGroup) *ServerController {
a := &ServerController{}
service.RestoreSystemMetrics()
a.initRouter(g)
a.startTask()
return a
@@ -43,12 +39,6 @@ func NewServerController(g *gin.RouterGroup) *ServerController {
// initRouter sets up the routes for server status, Xray management, and utility endpoints.
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("/getPanelUpdateInfo", a.getPanelUpdateInfo)
g.GET("/getUpdateStatus", a.getUpdateStatus)
g.GET("/getConfigJson", a.getConfigJson)
@@ -68,9 +58,6 @@ func (a *ServerController) initRouter(g *gin.RouterGroup) {
g.POST("/restartXrayService", a.restartXrayService)
g.POST("/updatePanel", a.updatePanel)
g.POST("/setUpdateChannel", a.setUpdateChannel)
g.POST("/logs/:count", a.getLogs)
g.POST("/xraylogs/:count", a.getXrayLogs)
g.POST("/amneziawglogs/:count", a.getAmneziaWGLogs)
g.POST("/importDB", a.importDB)
g.POST("/getNewEchCert", a.getNewEchCert)
g.POST("/getCertHash", a.getCertHash)
@@ -80,10 +67,11 @@ func (a *ServerController) initRouter(g *gin.RouterGroup) {
g.POST("/clientIps", a.setClientIps)
}
// startTask registers the @2s ticker that refreshes server status, samples
// xray metrics, and pushes the new snapshot to all websocket subscribers.
// State + sampling live in ServerService; the controller only orchestrates
// the cross-service side effects (xrayMetrics sample + websocket broadcast).
// startTask registers the @2s ticker that refreshes server status and pushes
// the new snapshot to all websocket subscribers. State lives in
// ServerService; the controller only orchestrates the broadcast. (The xray
// metrics sampling and the per-minute system-history persistence that used
// to ride along here were removed with those features.)
func (a *ServerController) startTask() {
c := global.GetWebServer().GetCron()
_, _ = c.AddFunc("@every 2s", func() {
@@ -91,14 +79,8 @@ func (a *ServerController) startTask() {
if status == nil {
return
}
a.xrayMetricsService.Sample(time.Now())
websocket.BroadcastStatus(status)
})
_, _ = c.AddFunc("@every 1m", func() {
if err := service.PersistSystemMetrics(); err != nil {
logger.Warning("persist system metrics failed:", err)
}
})
}
// status returns the current server status information.
@@ -108,76 +90,6 @@ func (a *ServerController) getFail2banStatus(c *gin.Context) {
jsonObj(c, a.serverService.GetFail2banStatus(), nil)
}
func parseHistoryBucket(c *gin.Context) (int, bool) {
bucket, err := strconv.Atoi(c.Param("bucket"))
if err != nil || bucket <= 0 || !service.IsAllowedHistoryBucket(bucket) {
jsonMsg(c, "invalid bucket", fmt.Errorf("unsupported bucket"))
return 0, false
}
return bucket, true
}
// getCpuHistoryBucket retrieves aggregated CPU usage history based on the specified time bucket.
// Kept for back-compat; new callers should use /history/cpu/:bucket which
// returns {"t","v"} (uniform across all metrics) instead of {"t","cpu"}.
func (a *ServerController) getCpuHistoryBucket(c *gin.Context) {
bucket, ok := parseHistoryBucket(c)
if !ok {
return
}
jsonObj(c, a.serverService.AggregateCpuHistory(bucket, 60), nil)
}
// getMetricHistoryBucket returns up to 60 buckets of history for a single
// system metric (cpu, mem, netUp, netDown, online, load1/5/15). The
// SystemHistoryModal calls one endpoint per active tab.
func (a *ServerController) getMetricHistoryBucket(c *gin.Context) {
metric := c.Param("metric")
if !slices.Contains(service.SystemMetricKeys, metric) {
jsonMsg(c, "invalid metric", fmt.Errorf("unknown metric"))
return
}
bucket, ok := parseHistoryBucket(c)
if !ok {
return
}
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, ok := parseHistoryBucket(c)
if !ok {
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, ok := parseHistoryBucket(c)
if !ok {
return
}
jsonObj(c, a.xrayMetricsService.AggregateObservatory(tag, bucket, 60), nil)
}
// getPanelUpdateInfo retrieves the current and latest panel version.
func (a *ServerController) getPanelUpdateInfo(c *gin.Context) {
info, err := a.panelService.GetUpdateInfo()
@@ -266,34 +178,6 @@ func (a *ServerController) restartXrayService(c *gin.Context) {
)
}
// getLogs retrieves the application logs based on count, level, and syslog filters.
func (a *ServerController) getLogs(c *gin.Context) {
logs := a.serverService.GetLogs(c.Param("count"), c.PostForm("level"), c.PostForm("syslog"))
jsonObj(c, logs, nil)
}
// getXrayLogs retrieves Xray logs with filtering options for direct, blocked, and proxy traffic.
func (a *ServerController) getXrayLogs(c *gin.Context) {
freedoms, blackholes := a.serverService.GetDefaultLogOutboundTags()
logs := a.serverService.GetXrayLogs(
c.Param("count"),
c.PostForm("filter"),
c.PostForm("showDirect"),
c.PostForm("showBlocked"),
c.PostForm("showProxy"),
freedoms,
blackholes,
)
jsonObj(c, logs, nil)
}
// getAmneziaWGLogs retrieves the live AmneziaWG peer activity and the panel's
// own AmneziaWG event lines, optionally narrowed by a free-text filter.
func (a *ServerController) getAmneziaWGLogs(c *gin.Context) {
logs := a.serverService.GetAmneziaWGLogs(c.Param("count"), c.PostForm("filter"))
jsonObj(c, logs, nil)
}
// getConfigJson retrieves the Xray configuration as JSON.
func (a *ServerController) getConfigJson(c *gin.Context) {
configJson, err := a.serverService.GetConfigJson()
+19 -15
View File
@@ -40,19 +40,23 @@ type AllSetting struct {
TimeLocation string `json:"timeLocation" form:"timeLocation"`
SubEnable bool `json:"subEnable" form:"subEnable"`
SubJsonEnable bool `json:"subJsonEnable" form:"subJsonEnable"`
SubJsonAutoDetect bool `json:"subJsonAutoDetect" form:"subJsonAutoDetect"`
SubJsonAlwaysArray bool `json:"subJsonAlwaysArray" form:"subJsonAlwaysArray"`
SubJsonUserAgentRegex string `json:"subJsonUserAgentRegex" form:"subJsonUserAgentRegex"`
SubClashAutoDetect bool `json:"subClashAutoDetect" form:"subClashAutoDetect"`
SubClashUserAgentRegex string `json:"subClashUserAgentRegex" form:"subClashUserAgentRegex"`
SubTitle string `json:"subTitle" form:"subTitle"`
SubSupportUrl string `json:"subSupportUrl" form:"subSupportUrl"`
SubProfileUrl string `json:"subProfileUrl" form:"subProfileUrl"`
SubAnnounce string `json:"subAnnounce" form:"subAnnounce"`
SubListen string `json:"subListen" form:"subListen"`
SubPort int `json:"subPort" form:"subPort" validate:"gte=1,lte=65535"`
SubEnable bool `json:"subEnable" form:"subEnable"`
SubJsonEnable bool `json:"subJsonEnable" form:"subJsonEnable"`
SubJsonAutoDetect bool `json:"subJsonAutoDetect" form:"subJsonAutoDetect"`
SubJsonAlwaysArray bool `json:"subJsonAlwaysArray" form:"subJsonAlwaysArray"`
SubJsonUserAgentRegex string `json:"subJsonUserAgentRegex" form:"subJsonUserAgentRegex"`
SubClashAutoDetect bool `json:"subClashAutoDetect" form:"subClashAutoDetect"`
SubClashUserAgentRegex string `json:"subClashUserAgentRegex" form:"subClashUserAgentRegex"`
SubTitle string `json:"subTitle" form:"subTitle"`
SubSupportUrl string `json:"subSupportUrl" form:"subSupportUrl"`
SubProfileUrl string `json:"subProfileUrl" form:"subProfileUrl"`
SubAnnounce string `json:"subAnnounce" form:"subAnnounce"`
SubListen string `json:"subListen" form:"subListen"`
// SubPort 0 means "no explicit port": subscription links are generated
// without a port (standard scheme port behind a reverse proxy) while the
// sub server still binds the default port. See SettingService.BuildSubURIBase
// and the sub server bootstrap for the two sides of that split.
SubPort int `json:"subPort" form:"subPort" validate:"gte=0,lte=65535"`
SubPath string `json:"subPath" form:"subPath"`
SubDomain string `json:"subDomain" form:"subDomain"`
SubCertFile string `json:"subCertFile" form:"subCertFile"`
@@ -147,11 +151,11 @@ func (s *AllSetting) CheckValid() error {
return common.NewError("web port is not a valid port:", s.WebPort)
}
if s.SubPort <= 0 || s.SubPort > math.MaxUint16 {
if s.SubPort < 0 || s.SubPort > math.MaxUint16 {
return common.NewError("Sub port is not a valid port:", s.SubPort)
}
if (s.SubPort == s.WebPort) && listenAddressesConflict(s.WebListen, s.SubListen) {
if s.SubPort > 0 && (s.SubPort == s.WebPort) && listenAddressesConflict(s.WebListen, s.SubListen) {
return common.NewError("Sub and Web could not use same ip:port, ", s.SubListen, ":", s.SubPort, " & ", s.WebListen, ":", s.WebPort)
}
+19 -1
View File
@@ -2,6 +2,7 @@ package job
import (
"strings"
"sync"
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
@@ -10,6 +11,22 @@ import (
// The protocol gate must let hysteria through: XrayAPI supports it, and the
// skip left over-limit Hysteria2 sessions alive until the fail2ban ban caught up.
// captureLogLines installs a logger hook collecting "LEVEL message" lines
// for the rest of the test. It replaces reads of the in-memory log buffer,
// which was removed together with the web log viewer.
func captureLogLines(t *testing.T) *[]string {
t.Helper()
var mu sync.Mutex
lines := &[]string{}
prev := logger.SetLogHook(func(level, msg string) {
mu.Lock()
defer mu.Unlock()
*lines = append(*lines, level+" "+msg)
})
t.Cleanup(func() { logger.SetLogHook(prev) })
return lines
}
func TestDisconnectClientTemporarilyAllowsHysteria(t *testing.T) {
setupIntegrationDB(t)
@@ -22,10 +39,11 @@ func TestDisconnectClientTemporarilyAllowsHysteria(t *testing.T) {
}
clients := []model.Client{{Email: email, Auth: "secret"}}
logLines := captureLogLines(t)
(&CheckClientIpJob{}).disconnectClientTemporarily(inbound, email, clients)
var unsupported, attempted bool
for _, line := range logger.GetLogs(500, "warning") {
for _, line := range *logLines {
if strings.Contains(line, "Temporary disconnect is not supported for protocol hysteria") {
unsupported = true
}
@@ -0,0 +1,126 @@
package job
import (
"testing"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
)
func TestAgentHeartbeatStale(t *testing.T) {
now := time.Unix(1_700_000_000, 0)
ms := func(d time.Duration) int64 { return now.Add(-d).UnixMilli() }
cases := []struct {
name string
lastSeenMs int64
want bool
}{
{"never checked in", 0, false},
{"negative stamp", -1, false},
{"fresh heartbeat", ms(60 * time.Second), false},
{"two missed heartbeats", ms(179 * time.Second), false},
{"past threshold", ms(181 * time.Second), true},
{"long gone", ms(24 * time.Hour), true},
}
for _, tc := range cases {
if got := agentHeartbeatStale(tc.lastSeenMs, now); got != tc.want {
t.Errorf("%s: agentHeartbeatStale(%d) = %v, want %v", tc.name, tc.lastSeenMs, got, tc.want)
}
}
}
func seedAgentNode(t *testing.T, name, status string, lastSeenMs int64) *model.Node {
t.Helper()
db := database.GetDB()
n := &model.Node{
Name: name,
Address: "agent",
Port: 2053,
Enable: true,
Status: status,
}
if err := db.Create(n).Error; err != nil {
t.Fatalf("create node: %v", err)
}
a := &model.AgentNode{
TokenHash: "hash-" + name,
Name: name,
NodeID: &n.Id,
LastSeen: lastSeenMs,
}
if err := db.Create(a).Error; err != nil {
t.Fatalf("create agent: %v", err)
}
return n
}
func nodeStatus(t *testing.T, id int) string {
t.Helper()
var n model.Node
if err := database.GetDB().Where("id = ?", id).First(&n).Error; err != nil {
t.Fatalf("reload node: %v", err)
}
return n.Status
}
func TestProbeOneExpiresStaleAgentNode(t *testing.T) {
setupIntegrationDB(t)
stale := time.Now().Add(-10 * time.Minute).UnixMilli()
n := seedAgentNode(t, "stale-agent", "online", stale)
(&NodeHeartbeatJob{}).probeOne(n)
if got := nodeStatus(t, n.Id); got != "offline" {
t.Fatalf("stale agent node status = %q, want offline", got)
}
}
func TestProbeOneKeepsFreshAgentNodeOnline(t *testing.T) {
setupIntegrationDB(t)
fresh := time.Now().Add(-30 * time.Second).UnixMilli()
n := seedAgentNode(t, "fresh-agent", "online", fresh)
(&NodeHeartbeatJob{}).probeOne(n)
if got := nodeStatus(t, n.Id); got != "online" {
t.Fatalf("fresh agent node status = %q, want online", got)
}
}
func TestProbeOneLeavesUnactivatedAgentNodeAlone(t *testing.T) {
setupIntegrationDB(t)
// Token issued, agent never checked in: last_seen = 0. The node must not
// be forced offline — it was never online/activated in the first place.
n := seedAgentNode(t, "dormant-agent", "unknown", 0)
(&NodeHeartbeatJob{}).probeOne(n)
if got := nodeStatus(t, n.Id); got != "unknown" {
t.Fatalf("unactivated agent node status = %q, want unknown (untouched)", got)
}
}
func TestProbeOnePanelNodeStillProbed(t *testing.T) {
setupIntegrationDB(t)
db := database.GetDB()
// No agent bound: the legacy probe path must still run and mark an
// unreachable panel node offline.
n := &model.Node{
Name: "panel-node",
Scheme: "http",
Address: "127.0.0.1",
Port: 1, // nothing listens here; probe fails fast
Enable: true,
Status: "online",
}
if err := db.Create(n).Error; err != nil {
t.Fatalf("create node: %v", err)
}
(&NodeHeartbeatJob{}).probeOne(n)
if got := nodeStatus(t, n.Id); got != "offline" {
t.Fatalf("unreachable panel node status = %q, want offline", got)
}
}
+74 -1
View File
@@ -20,6 +20,13 @@ const (
nodeHeartbeatRequestTimeout = 4 * time.Second
)
// agentHeartbeatStaleAfter is how long an agent-managed node may go without
// an agent heartbeat before the heartbeat job marks it offline. The node-side
// agent heartbeats every 60s (defaultHeartbeatInterval in internal/agent);
// the threshold is max(3 intervals, 120s) = 180s, so two consecutive lost
// heartbeats still don't flap the node offline.
const agentHeartbeatStaleAfter = 180 * time.Second
type NodeHeartbeatJob struct {
nodeService service.NodeService
running sync.Mutex
@@ -75,10 +82,13 @@ func (j *NodeHeartbeatJob) Run() {
func (j *NodeHeartbeatJob) probeOne(n *model.Node) {
// Agent-managed nodes are heartbeated via the agent token, not the
// legacy panel probe. Skip the probe so we don't clobber the
// agent-driven online status.
// agent-driven online status — but do expire that status when the
// agent stops heartbeating, or an uninstalled agent would leave its
// node showing online forever.
var agentCount int64
_ = database.GetDB().Model(&model.AgentNode{}).Where("node_id = ?", n.Id).Count(&agentCount).Error
if agentCount > 0 {
j.expireStaleAgentNode(n)
return
}
ctx, cancel := context.WithTimeout(context.Background(), nodeHeartbeatRequestTimeout)
@@ -106,6 +116,69 @@ func (j *NodeHeartbeatJob) probeOne(n *model.Node) {
}
}
// agentLastSeenMs returns the newest last_seen (Unix milliseconds, the unit
// the agent controller stamps on register/heartbeat) across the agents bound
// to the node, or 0 when no bound agent has ever checked in.
func agentLastSeenMs(nodeID int) int64 {
var lastSeen *int64 // MAX() over zero rows is NULL
_ = database.GetDB().Model(&model.AgentNode{}).
Where("node_id = ?", nodeID).
Select("MAX(last_seen)").
Scan(&lastSeen).Error
if lastSeen == nil {
return 0
}
return *lastSeen
}
// agentHeartbeatStale decides expiry from the agent's last_seen stamp. A
// zero stamp means the token was issued but the agent never checked in: the
// node was never activated, so expiry leaves it alone (its status stays
// whatever creation left it — "unknown") instead of forcing a just-created
// node offline before its agent had a chance to come up.
func agentHeartbeatStale(lastSeenMs int64, now time.Time) bool {
if lastSeenMs <= 0 {
return false
}
return now.Sub(time.UnixMilli(lastSeenMs)) > agentHeartbeatStaleAfter
}
// expireStaleAgentNode flips an agent-managed node offline once its agent's
// heartbeats stop. Only the status changes: the patch carries the node's
// stored heartbeat fields through unchanged (including the last_heartbeat
// stamp — the truth is "last seen then", not "now"), and the transition goes
// through the same UpdateHeartbeat + broadcast path as a failed panel probe,
// so node.down subscribers see agent expiries too.
func (j *NodeHeartbeatJob) expireStaleAgentNode(n *model.Node) {
if n.Status == "offline" {
return
}
if !agentHeartbeatStale(agentLastSeenMs(n.Id), time.Now()) {
return
}
patch := service.HeartbeatPatch{
Status: "offline",
LastHeartbeat: n.LastHeartbeat,
LatencyMs: n.LatencyMs,
XrayVersion: n.XrayVersion,
PanelVersion: n.PanelVersion,
Guid: n.Guid,
CpuPct: n.CpuPct,
MemPct: n.MemPct,
UptimeSecs: n.UptimeSecs,
NetUp: n.NetUp,
NetDown: n.NetDown,
LastError: n.LastError,
XrayState: n.XrayState,
XrayError: n.XrayError,
}
if err := j.nodeService.UpdateHeartbeat(n.Id, patch); err != nil {
logger.Warning("node heartbeat: expire agent node", n.Id, "failed:", err)
return
}
publishNodeTransition(n, n.Status, patch)
}
// publishNodeTransition emits node.down / node.up only on a genuine state change.
// An "unknown"/empty previous status (fresh start) is treated as not-online, so a
// node coming up for the first time fires node.up but never a spurious node.down.
+21 -2
View File
@@ -11,6 +11,7 @@ import (
"path/filepath"
"runtime"
"strings"
"sync"
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/logger"
@@ -27,11 +28,28 @@ type testAddr string
func (a testAddr) Network() string { return string(a) }
func (a testAddr) String() string { return string(a) }
// captureLogLines installs a logger hook collecting "LEVEL message" lines
// for the rest of the test. It replaces reads of the in-memory log buffer,
// which was removed together with the web log viewer.
func captureLogLines(t *testing.T) *[]string {
t.Helper()
var mu sync.Mutex
lines := &[]string{}
prev := logger.SetLogHook(func(level, msg string) {
mu.Lock()
defer mu.Unlock()
*lines = append(*lines, level+" "+msg)
})
t.Cleanup(func() { logger.SetLogHook(prev) })
return lines
}
func TestServeHTTPLogsUnexpectedListenerFailure(t *testing.T) {
errInjected := errors.New("injected listener failure")
logLines := captureLogLines(t)
ServeHTTP(&http.Server{}, failingListener{err: errInjected}, "Test server")
for _, line := range logger.GetLogs(100, "error") {
for _, line := range *logLines {
if strings.Contains(line, errInjected.Error()) {
return
}
@@ -41,9 +59,10 @@ func TestServeHTTPLogsUnexpectedListenerFailure(t *testing.T) {
func TestServeHTTPSuppressesNormalServerClose(t *testing.T) {
const marker = "normal-close-must-stay-silent"
logLines := captureLogLines(t)
ServeHTTP(&http.Server{}, failingListener{err: http.ErrServerClosed}, marker)
for _, line := range logger.GetLogs(100, "error") {
for _, line := range *logLines {
if strings.Contains(line, marker) {
t.Fatalf("normal http.ErrServerClosed was recorded as an error: %s", line)
}
@@ -1,45 +0,0 @@
package service
import "testing"
func TestParseAccessLogFields(t *testing.T) {
malformed := []string{
"",
"singletoken",
"2024/01/02",
"2024/01/02 15:04:05.000000 from",
"2024/01/02 15:04:05.000000 accepted",
"2024/01/02 15:04:05.000000 email:",
}
for _, line := range malformed {
func() {
defer func() {
if r := recover(); r != nil {
t.Errorf("parseAccessLogFields panicked on %q: %v", line, r)
}
}()
_ = parseAccessLogFields(line)
}()
}
line := "2024/01/02 15:04:05.123456 from 1.2.3.4:555 accepted tcp:example.com:443 [inbound-tag >> outbound-tag] email: alice@example.com"
entry := parseAccessLogFields(line)
if entry.FromAddress != "1.2.3.4:555" {
t.Errorf("FromAddress = %q, want %q", entry.FromAddress, "1.2.3.4:555")
}
if entry.ToAddress != "tcp:example.com:443" {
t.Errorf("ToAddress = %q, want %q", entry.ToAddress, "tcp:example.com:443")
}
if entry.Inbound != "inbound-tag" {
t.Errorf("Inbound = %q, want %q", entry.Inbound, "inbound-tag")
}
if entry.Outbound != "outbound-tag" {
t.Errorf("Outbound = %q, want %q", entry.Outbound, "outbound-tag")
}
if entry.Email != "alice@example.com" {
t.Errorf("Email = %q, want %q", entry.Email, "alice@example.com")
}
if entry.DateTime.IsZero() {
t.Error("DateTime was not parsed from a well-formed line")
}
}
@@ -7,13 +7,10 @@ import (
"strings"
"testing"
"github.com/op/go-logging"
"github.com/mhsanaei/3x-ui/v3/internal/amneziawg"
"github.com/mhsanaei/3x-ui/v3/internal/amneziawgnet"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/logger"
)
func TestCheckForwardedPortsConflict_EmptySpecNoConflict(t *testing.T) {
@@ -275,37 +272,6 @@ func TestNormalizeAmneziaWGSettings_CanonicalizesClientAllowedIPs(t *testing.T)
}
}
func TestGetAmneziaWGLogs_ClampsCountAndFiltersEvents(t *testing.T) {
logger.InitLogger(logging.DEBUG)
logger.Info("amneziawg: started interface awg1 for inbound 1")
logger.Info("xray: unrelated line that must never show up here")
logger.Warning("amneziawgnet: reconcile failed for inbound 2: handshake timeout")
svc := &ServerService{}
logs := svc.GetAmneziaWGLogs("not-a-number", "")
if logs == nil {
t.Fatal("GetAmneziaWGLogs must never return nil")
}
for _, line := range logs.Events {
if !strings.Contains(strings.ToLower(line), "amneziawg") {
t.Fatalf("non-AmneziaWG line leaked into the event list: %q", line)
}
}
if len(logs.Events) < 2 {
t.Fatalf("both AmneziaWG lines should be present, got %v", logs.Events)
}
// count caps the event list, so an operator asking for 1 gets 1.
if one := svc.GetAmneziaWGLogs("1", ""); len(one.Events) != 1 {
t.Fatalf("count=1 must cap the event list, got %d", len(one.Events))
}
// filter narrows further, case-insensitively.
filtered := svc.GetAmneziaWGLogs("100", "RECONCILE")
if len(filtered.Events) != 1 || !strings.Contains(filtered.Events[0], "reconcile") {
t.Fatalf("filter must narrow to the matching line, got %v", filtered.Events)
}
}
func TestCheckForwardedPortsConflict_RejectsSpecOverCap(t *testing.T) {
setupConflictDB(t)
svc := &InboundService{}
@@ -3,6 +3,7 @@ package service
import (
"strconv"
"strings"
"sync"
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/database"
@@ -15,8 +16,25 @@ import (
// Removing a central inbound also drops its clients' traffic history, so the
// sweep must name what it removed — a silent drop is undiagnosable in the field.
// captureLogLines installs a logger hook collecting "LEVEL message" lines
// for the rest of the test. It replaces reads of the in-memory log buffer,
// which was removed together with the web log viewer.
func captureLogLines(t *testing.T) *[]string {
t.Helper()
var mu sync.Mutex
lines := &[]string{}
prev := logger.SetLogHook(func(level, msg string) {
mu.Lock()
defer mu.Unlock()
*lines = append(*lines, level+" "+msg)
})
t.Cleanup(func() { logger.SetLogHook(prev) })
return lines
}
func TestNodeSnapshotSweepLogsRemovedInbound(t *testing.T) {
logger.InitLogger(logging.WARNING)
logLines := captureLogLines(t)
setupBulkDB(t)
nodeID, _ := setupNodeRuntime(t)
@@ -41,7 +59,7 @@ func TestNodeSnapshotSweepLogsRemovedInbound(t *testing.T) {
}
want := strconv.Itoa(gone.Id)
for _, line := range logger.GetLogs(200, "warning") {
for _, line := range *logLines {
if strings.Contains(line, gone.Tag) && strings.Contains(line, want) {
return
}
+7 -158
View File
@@ -1,15 +1,9 @@
package service
import (
"encoding/gob"
"os"
"path/filepath"
"slices"
"sync"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/config"
"github.com/mhsanaei/3x-ui/v3/internal/logger"
)
// MetricSample is one point of any time-series we keep in memory.
@@ -118,8 +112,7 @@ func (s *series) pickTier(spanSeconds int64) *tierBuf {
}
// metricHistory is a thread-safe, in-memory store of tiered series keyed by
// arbitrary strings. Three singletons live below: system-wide host metrics,
// per-node metrics, and xray expvar metrics.
// arbitrary strings. One singleton lives below: per-node metrics.
type metricHistory struct {
mu sync.Mutex
series map[string]*series
@@ -215,156 +208,12 @@ func (h *metricHistory) aggregate(metric string, bucketSeconds int, maxPoints in
return out
}
// persistedTier and persistedSeries are the on-disk shape of a series. Tiers are
// matched back by resolution on restore, so changing the ladder degrades
// gracefully (unmatched layers are dropped) instead of corrupting state.
type persistedTier struct {
Resolution int
Samples []MetricSample
}
type persistedSeries struct {
Tiers []persistedTier
}
// snapshot returns a deep copy of every series' closed buckets, safe to
// serialize without holding the lock during disk I/O.
func (h *metricHistory) snapshot() map[string]persistedSeries {
h.mu.Lock()
defer h.mu.Unlock()
out := make(map[string]persistedSeries, len(h.series))
for k, s := range h.series {
ps := persistedSeries{Tiers: make([]persistedTier, len(s.tiers))}
for i, tb := range s.tiers {
cp := make([]MetricSample, len(tb.samples))
copy(cp, tb.samples)
ps.Tiers[i] = persistedTier{Resolution: tb.resolution, Samples: cp}
}
out[k] = ps
}
return out
}
// restore replaces the in-memory series with a previously persisted set,
// re-applying each tier's capacity cap so a tampered or oversized file can't grow
// the working set unbounded.
func (h *metricHistory) restore(data map[string]persistedSeries) {
h.mu.Lock()
defer h.mu.Unlock()
for k, ps := range data {
s := newSeries()
for _, pt := range ps.Tiers {
for _, tb := range s.tiers {
if tb.resolution != pt.Resolution {
continue
}
samples := pt.Samples
if len(samples) > tb.capacity {
samples = samples[len(samples)-tb.capacity:]
}
tb.samples = samples
break
}
}
h.series[k] = s
}
}
// systemMetrics holds whole-host time series (cpu, mem, netUp, etc.) fed by
// ServerService.RefreshStatus every 2s. nodeMetrics holds per-node CPU/Mem fed
// by NodeHeartbeatJob. xrayMetrics holds xray expvar series. Only systemMetrics
// is persisted; the others rebuild from live connections.
var (
systemMetrics = newMetricHistory()
nodeMetrics = newMetricHistory()
xrayMetrics = newMetricHistory()
)
// SystemMetricKeys lists the metric names ServerService writes on every status
// sample. Exposed for documentation/test purposes; the controller validates
// incoming names against an allow-list.
var SystemMetricKeys = []string{
"cpu", "mem", "swap", "netUp", "netDown", "pktUp", "pktDown", "diskRead", "diskWrite", "diskUsage", "tcpCount", "udpCount", "online", "load1", "load5", "load15",
}
// nodeMetrics is the remaining singleton: per-node CPU/Mem/net series fed by
// NodeHeartbeatJob (via UpdateHeartbeat) and read by the node history panel.
// The whole-host system series and the xray expvar series that used to live
// here were removed with the system-history and xray-metrics features,
// together with the gob persistence of the host series.
var nodeMetrics = newMetricHistory()
// NodeMetricKeys lists the per-node metric names NodeHeartbeatJob writes.
var NodeMetricKeys = []string{"cpu", "mem", "netUp", "netDown"}
// XrayMetricKeys lists series sourced from xray's /debug/vars expvar endpoint.
var XrayMetricKeys = []string{
"xrAlloc", "xrSys", "xrHeapObjects", "xrNumGC", "xrPauseNs",
}
// systemMetricsStorePath is where the host time-series is persisted between
// restarts. It lives next to the database so a single volume mount carries both.
func systemMetricsStorePath() string {
return filepath.Join(config.GetDBFolderPath(), "system_metrics.gob")
}
// PersistSystemMetrics writes the host time-series to disk via a temp file +
// rename so a crash mid-write can't corrupt the previous snapshot. Called on a
// timer and at shutdown.
func PersistSystemMetrics() error {
path := systemMetricsStorePath()
tmp := path + ".tmp"
f, err := os.Create(tmp)
if err != nil {
return err
}
if err := gob.NewEncoder(f).Encode(systemMetrics.snapshot()); err != nil {
f.Close()
os.Remove(tmp)
return err
}
if err := f.Close(); err != nil {
os.Remove(tmp)
return err
}
return os.Rename(tmp, path)
}
// RestoreSystemMetrics loads a previously persisted host time-series on startup.
// A missing file is not an error (first boot). A pre-tier flat snapshot is
// migrated by replaying its samples through the rollup.
func RestoreSystemMetrics() {
path := systemMetricsStorePath()
f, err := os.Open(path)
if err != nil {
if !os.IsNotExist(err) {
logger.Warning("restore system metrics failed:", err)
}
return
}
var data map[string]persistedSeries
decErr := gob.NewDecoder(f).Decode(&data)
f.Close()
if decErr == nil {
systemMetrics.restore(data)
return
}
if migrateLegacySystemMetrics(path) {
return
}
logger.Warning("decode system metrics failed:", decErr)
}
// migrateLegacySystemMetrics loads a pre-tier flat snapshot
// (map[string][]MetricSample) and replays it through append so the new tiers are
// seeded from the existing history instead of starting empty.
func migrateLegacySystemMetrics(path string) bool {
f, err := os.Open(path)
if err != nil {
return false
}
defer f.Close()
var legacy map[string][]MetricSample
if err := gob.NewDecoder(f).Decode(&legacy); err != nil {
return false
}
for metric, samples := range legacy {
for _, p := range samples {
systemMetrics.append(metric, time.Unix(p.T, 0), p.V)
}
}
return true
}
@@ -144,21 +144,6 @@ func TestAggregateLongSpanUsesCoarseTier(t *testing.T) {
}
}
func TestSnapshotRestoreRoundTrip(t *testing.T) {
h := newMetricHistory()
now := time.Now().Unix()
for i := range int64(10) {
h.append("cpu", time.Unix(now-(9-i)*2, 0), float64(i))
}
h2 := newMetricHistory()
h2.restore(h.snapshot())
if out := h2.aggregate("cpu", 2, 60); len(out) == 0 {
t.Fatalf("expected restored series to aggregate")
}
}
func TestAggregateMissingMetricIsEmpty(t *testing.T) {
h := newMetricHistory()
if out := h.aggregate("nope", 2, 60); len(out) != 0 {
+7 -2
View File
@@ -363,7 +363,9 @@ func (s *NodeService) GetViewById(id int) (*NodeView, error) {
if err != nil {
return nil, err
}
return toNodeView(n), nil
view := toNodeView(n)
annotateNodeTypes([]*NodeView{view})
return view, nil
}
// NodeExists reports whether a node with the given id exists on this panel.
@@ -566,7 +568,10 @@ func (s *NodeService) UpdateFromRequest(id int, req *NodeMutationRequest) error
return err
}
}
if apiToken == "" && in.Enable && in.TlsVerifyMode != "mtls" {
// Agent nodes authenticate with their agent token, never a panel API
// token — the requirement below must not make them uneditable.
isAgent := req.NodeType == "agent" || s.IsAgentManaged(id)
if !isAgent && apiToken == "" && in.Enable && in.TlsVerifyMode != "mtls" {
return common.NewError("apiToken is required unless mtls is enabled")
}
updates := map[string]any{
+72 -26
View File
@@ -3,6 +3,7 @@ package service
import (
"strings"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/util/common"
)
@@ -25,32 +26,38 @@ type NodeView struct {
InboundSyncMode string `json:"inboundSyncMode" example:"all"`
InboundTags []string `json:"inboundTags" example:"[\"in-443-tcp\"]"`
OutboundTag string `json:"outboundTag" example:"direct"`
Guid string `json:"guid" example:"node-guid"`
Status string `json:"status" example:"online"`
LastHeartbeat int64 `json:"lastHeartbeat" example:"1700000000"`
LatencyMs int `json:"latencyMs" example:"42"`
XrayVersion string `json:"xrayVersion" example:"25.10.31"`
PanelVersion string `json:"panelVersion" example:"v3.x.x"`
CpuPct float64 `json:"cpuPct" example:"12.5"`
MemPct float64 `json:"memPct" example:"45.2"`
UptimeSecs uint64 `json:"uptimeSecs" example:"86400"`
NetUp uint64 `json:"netUp" example:"2097152"`
NetDown uint64 `json:"netDown" example:"1048576"`
LastError string `json:"lastError" example:""`
XrayState string `json:"xrayState" example:"running"`
XrayError string `json:"xrayError" example:""`
ConfigDirty bool `json:"configDirty" example:"false"`
ConfigDirtyAt int64 `json:"configDirtyAt" example:"0"`
InboundCount int `json:"inboundCount" example:"3"`
ClientCount int `json:"clientCount" example:"25"`
OnlineCount int `json:"onlineCount" example:"5"`
ActiveCount int `json:"activeCount" example:"20"`
DisabledCount int `json:"disabledCount" example:"2"`
DepletedCount int `json:"depletedCount" example:"1"`
ParentGuid string `json:"parentGuid,omitempty" example:""`
Transitive bool `json:"transitive,omitempty" example:"false"`
CreatedAt int64 `json:"createdAt" example:"1700000000"`
UpdatedAt int64 `json:"updatedAt" example:"1700003600"`
// NodeType is "agent" for lightweight agent-managed nodes (an agent is
// bound to the node) and "traditional" otherwise. It is derived from the
// agent binding, not stored on the node row; read paths that serve the
// panel UI annotate it via annotateNodeTypes. Empty on views built
// outside those paths.
NodeType string `json:"nodeType,omitempty" example:"traditional"`
Guid string `json:"guid" example:"node-guid"`
Status string `json:"status" example:"online"`
LastHeartbeat int64 `json:"lastHeartbeat" example:"1700000000"`
LatencyMs int `json:"latencyMs" example:"42"`
XrayVersion string `json:"xrayVersion" example:"25.10.31"`
PanelVersion string `json:"panelVersion" example:"v3.x.x"`
CpuPct float64 `json:"cpuPct" example:"12.5"`
MemPct float64 `json:"memPct" example:"45.2"`
UptimeSecs uint64 `json:"uptimeSecs" example:"86400"`
NetUp uint64 `json:"netUp" example:"2097152"`
NetDown uint64 `json:"netDown" example:"1048576"`
LastError string `json:"lastError" example:""`
XrayState string `json:"xrayState" example:"running"`
XrayError string `json:"xrayError" example:""`
ConfigDirty bool `json:"configDirty" example:"false"`
ConfigDirtyAt int64 `json:"configDirtyAt" example:"0"`
InboundCount int `json:"inboundCount" example:"3"`
ClientCount int `json:"clientCount" example:"25"`
OnlineCount int `json:"onlineCount" example:"5"`
ActiveCount int `json:"activeCount" example:"20"`
DisabledCount int `json:"disabledCount" example:"2"`
DepletedCount int `json:"depletedCount" example:"1"`
ParentGuid string `json:"parentGuid,omitempty" example:""`
Transitive bool `json:"transitive,omitempty" example:"false"`
CreatedAt int64 `json:"createdAt" example:"1700000000"`
UpdatedAt int64 `json:"updatedAt" example:"1700003600"`
}
func toNodeView(n *model.Node) *NodeView {
@@ -110,6 +117,45 @@ func toNodeViews(nodes []*model.Node) []*NodeView {
return views
}
// agentManagedNodeIDs returns the set of node ids with at least one agent
// bound to them — the definition of an "agent node" in this codebase (the
// heartbeat job's probe skip uses the same test).
func agentManagedNodeIDs() map[int]bool {
ids := map[int]bool{}
var rows []int
_ = database.GetDB().Model(&model.AgentNode{}).
Where("node_id IS NOT NULL").
Distinct().
Pluck("node_id", &rows).Error
for _, id := range rows {
ids[id] = true
}
return ids
}
// IsAgentManaged reports whether the node has an agent bound to it.
func (s *NodeService) IsAgentManaged(id int) bool {
var count int64
_ = database.GetDB().Model(&model.AgentNode{}).Where("node_id = ?", id).Count(&count).Error
return count > 0
}
// annotateNodeTypes fills NodeType on views from the agent bindings so the
// UI can tell agent nodes apart from traditional panel nodes when editing.
func annotateNodeTypes(views []*NodeView) {
if len(views) == 0 {
return
}
agents := agentManagedNodeIDs()
for _, v := range views {
if agents[v.Id] {
v.NodeType = "agent"
} else {
v.NodeType = "traditional"
}
}
}
// NodeMutationRequest is the node write/probe contract. ApiToken is accepted
// only as input. On update, nil means keep the stored token; replacement and
// clearing are explicit and mutually exclusive.
+3 -1
View File
@@ -162,7 +162,9 @@ func (s *NodeService) GetNodeTreeView() ([]*NodeView, error) {
if err != nil {
return nil, err
}
return toNodeViews(nodes), nil
views := toNodeViews(nodes)
annotateNodeTypes(views)
return views, nil
}
// recountByGuid recomputes InboundCount/OnlineCount/DepletedCount for every node
+7
View File
@@ -24,6 +24,13 @@ func TestIsNewerVersion(t *testing.T) {
{"v2.9.3", "2.9.3", false},
{"v2.9.2", "2.9.3", false},
{"v3.0.0", "2.9.3", true},
// Fork release: the Gitea tag carries the fork suffix while the binary
// reports the same suffix (injected via -ldflags -X buildVersion), so
// the pair must compare as up-to-date, not as a phantom update. The
// pre-fix binary reported plain "3.7.0" here and compared newer.
{"v3.7.0-agent.1", "3.7.0-agent.1", false},
{"v3.7.0-agent.1", "3.7.0", true},
{"v3.7.0-agent.2", "3.7.0-agent.1", true},
}
for _, tc := range cases {
+5 -432
View File
@@ -1,9 +1,7 @@
package service
import (
"bufio"
"bytes"
"cmp"
"context"
"crypto/ecdh"
"crypto/rand"
@@ -16,7 +14,6 @@ import (
"errors"
"fmt"
"io"
"math"
"mime/multipart"
stdnet "net"
"net/http"
@@ -26,13 +23,10 @@ import (
"path/filepath"
"regexp"
"runtime"
"slices"
"strconv"
"strings"
"sync"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/amneziawg"
"github.com/mhsanaei/3x-ui/v3/internal/amneziawgnet"
"github.com/mhsanaei/3x-ui/v3/internal/config"
"github.com/mhsanaei/3x-ui/v3/internal/database"
@@ -244,10 +238,11 @@ func (s *ServerService) isFail2banInstalled() bool {
return s.fail2banInstalled
}
// RefreshStatus collects a new system snapshot, stores it as LastStatus, and
// appends it to the system-metrics time series. Returns the new snapshot (may
// be nil if collection failed). Called by the background ticker; the caller is
// responsible for any side effects (websocket broadcast, xray metrics sample).
// RefreshStatus collects a new system snapshot and stores it as LastStatus.
// Returns the new snapshot (may be nil if collection failed). Called by the
// background ticker; the caller is responsible for any side effects
// (websocket broadcast). The realtime status served to the UI comes from
// this snapshot — the removed system-history series used to be fed here too.
func (s *ServerService) RefreshStatus() *Status {
next := s.GetStatus(s.LastStatus())
if next == nil {
@@ -256,76 +251,9 @@ func (s *ServerService) RefreshStatus() *Status {
s.lastStatusMu.Lock()
s.lastStatus = next
s.lastStatusMu.Unlock()
s.AppendStatusSample(time.Now(), next)
return next
}
// GetDefaultLogOutboundTags scans the default Xray config for freedom and
// blackhole outbound tags so /getXrayLogs can colour-code log lines without
// the controller re-doing the JSON walk. Falls back to the historical
// "direct"/"blocked" defaults when the config can't be read.
func (s *ServerService) GetDefaultLogOutboundTags() (freedoms, blackholes []string) {
config, err := s.settingService.GetDefaultXrayConfig()
if err == nil && config != nil {
if cfgMap, ok := config.(map[string]any); ok {
if outbounds, ok := cfgMap["outbounds"].([]any); ok {
for _, outbound := range outbounds {
obMap, ok := outbound.(map[string]any)
if !ok {
continue
}
tag, _ := obMap["tag"].(string)
if tag == "" {
continue
}
switch obMap["protocol"] {
case "freedom":
freedoms = append(freedoms, tag)
case "blackhole":
blackholes = append(blackholes, tag)
}
}
}
}
}
if len(freedoms) == 0 {
freedoms = []string{"direct"}
}
if len(blackholes) == 0 {
blackholes = []string{"blocked"}
}
return freedoms, blackholes
}
// AggregateCpuHistory returns up to maxPoints averaged buckets of size bucketSeconds.
// Kept for back-compat with the original /panel/api/server/cpuHistory/:bucket route;
// the response key is "cpu" (not "v") so legacy consumers parse unchanged.
func (s *ServerService) AggregateCpuHistory(bucketSeconds int, maxPoints int) []map[string]any {
out := systemMetrics.aggregate("cpu", bucketSeconds, maxPoints)
for _, p := range out {
p["cpu"] = p["v"]
delete(p, "v")
}
return out
}
// AggregateSystemMetric returns up to maxPoints averaged buckets for any
// known system metric (see SystemMetricKeys). Output points have keys
// {"t": unixSec, "v": value}; the caller decides how to format the value.
func (s *ServerService) AggregateSystemMetric(metric string, bucketSeconds int, maxPoints int) []map[string]any {
return systemMetrics.aggregate(metric, bucketSeconds, maxPoints)
}
type LogEntry struct {
DateTime time.Time
FromAddress string
ToAddress string
Inbound string
Outbound string
Email string
Event int
}
func getPublicIP(url string) string {
client := &http.Client{
Timeout: 3 * time.Second,
@@ -621,52 +549,6 @@ func (s *ServerService) GetStatus(lastStatus *Status) *Status {
return status
}
// AppendCpuSample is preserved for callers that only have the CPU number.
// New callers should prefer AppendStatusSample which writes the full set.
func (s *ServerService) AppendCpuSample(t time.Time, v float64) {
systemMetrics.append("cpu", t, v)
}
// AppendStatusSample writes one tick of every metric we keep — CPU, memory
// percent, network throughput (bytes/s), online client count, and the three
// load averages. Called by RefreshStatus on the same @2s cadence as
// AppendCpuSample, so all series stay aligned.
func (s *ServerService) AppendStatusSample(t time.Time, status *Status) {
if status == nil {
return
}
systemMetrics.append("cpu", t, status.Cpu)
if status.Mem.Total > 0 {
systemMetrics.append("mem", t, float64(status.Mem.Current)*100.0/float64(status.Mem.Total))
}
if status.Swap.Total > 0 {
systemMetrics.append("swap", t, float64(status.Swap.Current)*100.0/float64(status.Swap.Total))
} else {
systemMetrics.append("swap", t, 0)
}
systemMetrics.append("netUp", t, float64(status.NetIO.Up))
systemMetrics.append("netDown", t, float64(status.NetIO.Down))
systemMetrics.append("diskRead", t, float64(status.DiskIO.Read))
systemMetrics.append("diskWrite", t, float64(status.DiskIO.Write))
if status.Disk.Total > 0 {
systemMetrics.append("diskUsage", t, float64(status.Disk.Current)*100.0/float64(status.Disk.Total))
}
systemMetrics.append("pktUp", t, float64(status.NetIO.PktUp))
systemMetrics.append("pktDown", t, float64(status.NetIO.PktDown))
systemMetrics.append("tcpCount", t, float64(status.TcpCount))
systemMetrics.append("udpCount", t, float64(status.UdpCount))
online := 0
if process := currentXrayProcess(); process != nil && process.IsRunning() {
online = len(process.GetOnlineClients())
}
systemMetrics.append("online", t, float64(online))
if len(status.Loads) >= 3 {
systemMetrics.append("load1", t, status.Loads[0])
systemMetrics.append("load5", t, status.Loads[1])
systemMetrics.append("load15", t, status.Loads[2])
}
}
func (s *ServerService) sampleCPUUtilization() (float64, error) {
// Try native platform-specific CPU implementation first (Windows, Linux, macOS)
if pct, err := sys.CPUPercentRaw(); err == nil {
@@ -770,306 +652,6 @@ func (s *ServerService) RestartXrayService() error {
return nil
}
func (s *ServerService) GetLogs(count string, level string, syslog string) []string {
c, _ := strconv.Atoi(count)
var lines []string
if syslog == "true" {
// Check if running on Windows - journalctl is not available
if runtime.GOOS == "windows" {
return []string{"Syslog is not supported on Windows. Please use application logs instead by unchecking the 'Syslog' option."}
}
// Validate and sanitize count parameter
countInt, err := strconv.Atoi(count)
if err != nil || countInt < 1 || countInt > 10000 {
return []string{"Invalid count parameter - must be a number between 1 and 10000"}
}
// Validate level parameter - only allow valid syslog levels
validLevels := map[string]bool{
"0": true, "emerg": true,
"1": true, "alert": true,
"2": true, "crit": true,
"3": true, "err": true,
"4": true, "warning": true,
"5": true, "notice": true,
"6": true, "info": true,
"7": true, "debug": true,
}
if !validLevels[level] {
return []string{"Invalid level parameter - must be a valid syslog level"}
}
// Use hardcoded command with validated parameters
cmd := exec.CommandContext(context.Background(), "journalctl", "-u", "x-ui", "--no-pager", "-n", strconv.Itoa(countInt), "-p", level)
var out bytes.Buffer
cmd.Stdout = &out
err = cmd.Run()
if err != nil {
return []string{"Failed to run journalctl command! Make sure systemd is available and x-ui service is registered."}
}
lines = strings.Split(out.String(), "\n")
} else {
lines = logger.GetLogs(c, level)
}
return lines
}
// parseAccessLogFields extracts the structured fields from one Xray access-log
// line. Lines are attacker-influenced (a client's requested destination lands in
// the log verbatim) and may be truncated, so every positional lookup is length
// guarded: a malformed line yields a partial entry rather than panicking.
func parseAccessLogFields(line string) LogEntry {
var entry LogEntry
parts := strings.Fields(line)
for i, part := range parts {
if i == 0 && len(parts) > 1 {
dateTime, err := time.ParseInLocation("2006/01/02 15:04:05.999999", parts[0]+" "+parts[1], time.Local)
if err != nil {
continue
}
entry.DateTime = dateTime.UTC()
}
if part == "from" && i+1 < len(parts) {
entry.FromAddress = strings.TrimLeft(parts[i+1], "/")
} else if part == "accepted" && i+1 < len(parts) {
entry.ToAddress = strings.TrimLeft(parts[i+1], "/")
} else if strings.HasPrefix(part, "[") {
entry.Inbound = part[1:]
} else if strings.HasSuffix(part, "]") {
entry.Outbound = part[:len(part)-1]
} else if part == "email:" && i+1 < len(parts) {
entry.Email = parts[i+1]
}
}
return entry
}
// PeerActivity is one peer's live embedded-Device-reported state, the
// counterpart of an Xray access-log entry: a tunnel logs no requests, only
// handshakes and bytes.
type PeerActivity struct {
Interface string `json:"interface" example:"awg1"`
Tag string `json:"tag" example:"inbound-51820"`
InboundId int `json:"inboundId" example:"1"`
Email string `json:"email" example:"peer@example.com"`
Endpoint string `json:"endpoint" example:"203.0.113.9:51820"`
AllowedIPs string `json:"allowedIPs" example:"10.8.1.2/32"`
// Handshake is unix milliseconds, 0 when the peer has never connected.
Handshake int64 `json:"handshake" example:"1735732800000"`
Up int64 `json:"up" example:"1048576"`
Down int64 `json:"down" example:"4194304"`
Online bool `json:"online" example:"true"`
}
// amneziawgOnlineWindow mirrors the standard WireGuard convention (and this
// fork's own prior kernel-module behavior): a handshake this recent counts
// as online.
const amneziawgOnlineWindow = 180 * time.Second
// AmneziaWGLogs is what the overview's AmneziaWG log view renders: the live
// per-peer activity of every running embedded interface, plus the panel's
// own recent AmneziaWG lifecycle log lines that explain a peer being absent
// from Peers at all.
type AmneziaWGLogs struct {
Peers []PeerActivity `json:"peers"`
Events []string `json:"events" example:"[\"2025/01/01 12:00:00 amneziawg: started interface awg1 for inbound 1\"]"`
Running bool `json:"running" example:"true"`
}
// amneziawgEventMarker selects the panel's own AmneziaWG log lines: every
// logger call in internal/amneziawg, internal/amneziawgnet and their jobs
// prefixes its message with it.
const amneziawgEventMarker = "amneziawg"
// amneziawgLogActivity gathers live PeerActivity rows across every enabled,
// non-node-hosted AmneziaWG inbound, newest handshake first. An inbound
// amneziawgnet has no running Device for yet (not reconciled, disabled,
// errored) contributes no rows -- not reported as an error, since the
// caller (GetAmneziaWGLogs) already has a device-agnostic Running flag from
// amneziawgnet.GetManager().HasRunning() for that.
// clampUint64ToInt64 saturates at math.MaxInt64 instead of wrapping negative,
// for a live uint64 byte counter (amneziawgnet's own UAPI-dump snapshot, not
// a DB-accumulated total) going into an int64 API field -- unreachable in
// practice at real traffic volumes, but a silent negative value would be
// worse than a saturated one if it were ever hit.
func clampUint64ToInt64(v uint64) int64 {
if v > math.MaxInt64 {
return math.MaxInt64
}
return int64(v)
}
func amneziawgLogActivity() []PeerActivity {
var inbounds []*model.Inbound
if err := database.GetDB().
Where("protocol = ? AND enable = ? AND node_id IS NULL", model.AmneziaWG, true).
Find(&inbounds).Error; err != nil {
logger.Warning("amneziawg logs: list inbounds failed:", err)
return nil
}
now := time.Now()
var out []PeerActivity
for _, inbound := range inbounds {
inst, ok := amneziawg.InstanceFromInbound(inbound)
if !ok {
continue
}
diag := amneziawgnet.Diagnose(inbound.Id, inst.Peers)
if !diag.Running {
continue
}
for _, cd := range diag.Clients {
var handshakeMs int64
online := false
if !cd.LastHandshake.IsZero() {
handshakeMs = cd.LastHandshake.UnixMilli()
online = now.Sub(cd.LastHandshake) < amneziawgOnlineWindow
}
out = append(out, PeerActivity{
Interface: inst.InterfaceName,
Tag: inbound.Tag,
InboundId: inbound.Id,
Email: cd.Email,
Endpoint: cd.Endpoint,
AllowedIPs: cd.AllowedIPs,
Handshake: handshakeMs,
Up: clampUint64ToInt64(cd.RxBytes),
Down: clampUint64ToInt64(cd.TxBytes),
Online: online,
})
}
}
slices.SortFunc(out, func(a, b PeerActivity) int {
if a.Handshake != b.Handshake {
return cmp.Compare(b.Handshake, a.Handshake)
}
return strings.Compare(a.Email, b.Email)
})
return out
}
// GetAmneziaWGLogs returns at most count peer rows and count event lines,
// optionally narrowed to rows whose text contains filter (case-insensitive),
// mirroring GetXrayLogs' own count+filter contract.
func (s *ServerService) GetAmneziaWGLogs(count string, filter string) *AmneziaWGLogs {
limit, err := strconv.Atoi(count)
if err != nil || limit < 1 || limit > 10000 {
limit = 100
}
needle := strings.ToLower(strings.TrimSpace(filter))
logs := &AmneziaWGLogs{Peers: []PeerActivity{}, Events: []string{}, Running: amneziawgnet.GetManager().HasRunning()}
for _, peer := range amneziawgLogActivity() {
if len(logs.Peers) >= limit {
break
}
if needle != "" && !strings.Contains(strings.ToLower(peer.Email+" "+peer.Tag+" "+peer.Interface+" "+peer.Endpoint+" "+peer.AllowedIPs), needle) {
continue
}
logs.Peers = append(logs.Peers, peer)
}
for _, line := range logger.GetLogs(10000, "debug") {
if len(logs.Events) >= limit {
break
}
if !strings.Contains(strings.ToLower(line), amneziawgEventMarker) {
continue
}
if needle != "" && !strings.Contains(strings.ToLower(line), needle) {
continue
}
logs.Events = append(logs.Events, line)
}
return logs
}
func (s *ServerService) GetXrayLogs(
count string,
filter string,
showDirect string,
showBlocked string,
showProxy string,
freedoms []string,
blackholes []string,
) []LogEntry {
const (
Direct = iota
Blocked
Proxied
)
countInt, _ := strconv.Atoi(count)
var entries []LogEntry
pathToAccessLog, err := xray.GetAccessLogPath()
if err != nil {
return nil
}
file, err := os.Open(pathToAccessLog)
if err != nil {
return nil
}
defer file.Close()
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.Contains(line, "api -> api") {
// skipping empty lines and api calls
continue
}
if filter != "" && !strings.Contains(line, filter) {
// applying filter if it's not empty
continue
}
entry := parseAccessLogFields(line)
if logEntryContains(line, freedoms) {
if showDirect == "false" {
continue
}
entry.Event = Direct
} else if logEntryContains(line, blackholes) {
if showBlocked == "false" {
continue
}
entry.Event = Blocked
} else {
if showProxy == "false" {
continue
}
entry.Event = Proxied
}
entries = append(entries, entry)
}
if err := scanner.Err(); err != nil {
return nil
}
if len(entries) > countInt {
entries = entries[len(entries)-countInt:]
}
return entries
}
// isVirtualInterface returns true for loopback and virtual/tunnel interfaces
// that should be excluded from network traffic statistics.
func isVirtualInterface(name string) bool {
@@ -1098,15 +680,6 @@ func isVirtualInterface(name string) bool {
return false
}
func logEntryContains(line string, suffixes []string) bool {
for _, sfx := range suffixes {
if strings.Contains(line, sfx+"]") {
return true
}
}
return false
}
func (s *ServerService) GetConfigJson() (any, error) {
config, err := s.xrayService.GetXrayConfig()
if err != nil {
+4 -1
View File
@@ -928,7 +928,10 @@ func (s *SettingService) BuildSubURIBase(host string) string {
if subTLS {
scheme = "https"
}
if (subPort == 443 && subTLS) || (subPort == 80 && !subTLS) {
// subPort 0 is the explicit "no port in links" setting (reverse proxy /
// tunnel on the scheme's standard port); the sub server keeps binding
// the default port — only the generated links omit the port.
if subPort <= 0 || (subPort == 443 && subTLS) || (subPort == 80 && !subTLS) {
return scheme + "://" + subDomain
}
return fmt.Sprintf("%s://%s:%d", scheme, subDomain, subPort)
-330
View File
@@ -1,330 +0,0 @@
package service
import (
"context"
"encoding/json"
"fmt"
"net/http"
"sort"
"strings"
"sync"
"time"
"unicode"
"unicode/utf8"
"github.com/mhsanaei/3x-ui/v3/internal/eventbus"
"github.com/mhsanaei/3x-ui/v3/internal/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"`
}
// eventBus is the shared bus for publishing observatory state-change events.
// Set once during startup via SetEventBus; nil when no bus is configured.
var eventBus *eventbus.Bus
// SetEventBus assigns the global event bus used by applyObservatory to publish
// outbound health transitions. Must be called once during startup before any
// Sample tick runs.
func SetEventBus(b *eventbus.Bus) { eventBus = b }
type XrayMetricsService struct {
settingService SettingService
mu sync.RWMutex
state xrayMetricsState
client *http.Client
obsByTag map[string]ObsTagSnapshot
health map[string]outboundHealth
}
// outboundHealth debounces observatory flapping. Xray flips an outbound's
// alive flag on a single failed probe, so raw transitions produce a storm of
// down/up notifications on a flaky link. We instead require failStreak to reach
// the configured threshold (consecutive FAILED probes, tracked per new probe
// via lastTry) before publishing outbound.down, and only publish outbound.up
// once a down has actually been notified.
type outboundHealth struct {
lastTry int64
failStreak int
notified bool
}
const maxObsTagLength = 128
func validObsTag(tag string) bool {
if tag == "" || len(tag) > maxObsTagLength || !utf8.ValidString(tag) {
return false
}
for _, r := range tag {
if unicode.IsControl(r) {
return false
}
}
return true
}
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(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(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) {
// The local xray core never runs in this build (control-plane only),
// so there is no /debug/vars endpoint to sample. Report the metrics
// feed as disabled and stay silent instead of probing a permanently
// dead endpoint on every 2s status tick.
s.setStateQuiet(xrayMetricsState{Enabled: false, Reason: "local xray core disabled in this build"})
}
func (s *XrayMetricsService) sampleLocalXrayLegacy(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(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))
}
threshold := 3
if v, err := s.settingService.GetOutboundDownThreshold(); err == nil && v > 0 {
threshold = v
}
s.mu.Lock()
// Debounce observatory flapping into stable down/up notifications.
if eventBus != nil {
if s.health == nil {
s.health = make(map[string]outboundHealth, len(next))
}
for tag, cur := range next {
// React only to a genuinely new probe attempt (lastTry advanced).
// The sampler polls far more often than xray probes, so counting
// samples instead of probes would trip the threshold instantly.
h := s.health[tag]
if cur.LastTryTime == 0 || cur.LastTryTime == h.lastTry {
continue
}
h.lastTry = cur.LastTryTime
if cur.Alive {
if h.notified {
eventBus.Publish(eventbus.Event{
Type: eventbus.EventOutboundUp,
Source: tag,
Data: &eventbus.OutboundHealthData{Delay: cur.Delay},
})
}
h.failStreak = 0
h.notified = false
} else {
h.failStreak++
if h.failStreak >= threshold && !h.notified {
errMsg := ""
if cur.Delay < 0 {
errMsg = "probe failed"
}
eventBus.Publish(eventbus.Event{
Type: eventbus.EventOutboundDown,
Source: tag,
Data: &eventbus.OutboundHealthData{Delay: cur.Delay, Error: errMsg},
})
h.notified = true
}
}
s.health[tag] = h
}
// Forget tags that vanished from the observatory.
for tag := range s.health {
if _, ok := next[tag]; !ok {
delete(s.health, tag)
}
}
}
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)
}
}
// setStateQuiet updates the metrics state without the Debug log line, for
// states that are permanent by design rather than a transient failure.
func (s *XrayMetricsService) setStateQuiet(st xrayMetricsState) {
s.mu.Lock()
s.state = st
s.mu.Unlock()
}
-170
View File
@@ -1,170 +0,0 @@
package service
import (
"path/filepath"
"strings"
"testing"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/eventbus"
)
// probe is one observatory sample: whether the outbound is alive and the
// last_try_time xray reports for it (a new probe advances lastTry).
type probe struct {
alive bool
lastTry int64
}
const testSentinel eventbus.EventType = "test.sentinel"
// runObservatory feeds a probe sequence through applyObservatory with the given
// threshold and returns the outbound.* events it published, in order.
func runObservatory(t *testing.T, threshold int, seq []probe) []eventbus.EventType {
t.Helper()
ss := SettingService{}
if err := ss.SetOutboundDownThreshold(threshold); err != nil {
t.Fatalf("set threshold: %v", err)
}
bus := eventbus.New(256)
events := make(chan eventbus.Event, 256)
bus.Subscribe("test", func(e eventbus.Event) { events <- e })
SetEventBus(bus)
t.Cleanup(func() {
SetEventBus(nil)
bus.Stop()
})
s := &XrayMetricsService{settingService: ss}
for _, p := range seq {
s.applyObservatory(time.Unix(p.lastTry, 0), map[string]rawObsEntry{
"proxy": {Alive: p.alive, Delay: 10, LastTryTime: p.lastTry, OutboundTag: "proxy"},
})
}
bus.Publish(eventbus.Event{Type: testSentinel, Source: "x"})
var got []eventbus.EventType
for {
select {
case e := <-events:
if e.Type == testSentinel {
return got
}
got = append(got, e.Type)
case <-time.After(2 * time.Second):
t.Fatal("timed out waiting for events to drain")
}
}
}
func TestApplyObservatoryDebounce(t *testing.T) {
if err := database.InitDB(filepath.Join(t.TempDir(), "x-ui.db")); err != nil {
t.Fatalf("init db: %v", err)
}
t.Cleanup(func() { _ = database.CloseDB() })
tests := []struct {
name string
threshold int
seq []probe
want []eventbus.EventType
}{
{
name: "notifies only after threshold consecutive failed probes",
threshold: 3,
seq: []probe{
{true, 1},
{false, 2},
{false, 3},
{false, 4},
{false, 5},
{true, 6},
{false, 7},
{true, 8},
},
want: []eventbus.EventType{eventbus.EventOutboundDown, eventbus.EventOutboundUp},
},
{
name: "repeated samples of the same probe do not advance the streak",
threshold: 3,
seq: []probe{{false, 2}, {false, 2}, {false, 2}, {false, 2}, {false, 2}},
want: nil,
},
{
name: "single-probe blip never notifies",
threshold: 3,
seq: []probe{{true, 1}, {false, 2}, {true, 3}},
want: nil,
},
{
name: "threshold 1 keeps the legacy notify-on-first-failure behaviour",
threshold: 1,
seq: []probe{{true, 1}, {false, 2}},
want: []eventbus.EventType{eventbus.EventOutboundDown},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := runObservatory(t, tt.threshold, tt.seq)
if len(got) != len(tt.want) {
t.Fatalf("events = %v, want %v", got, tt.want)
}
for i := range got {
if got[i] != tt.want[i] {
t.Fatalf("event[%d] = %q, want %q (full: %v)", i, got[i], tt.want[i], got)
}
}
})
}
}
func TestValidObsTag(t *testing.T) {
tests := []struct {
name string
tag string
want bool
}{
{"plain ascii", "proxy-1", true},
{"dots and underscores", "warp_us.east", true},
{"flag emoji", "🇩🇪 Germany", true},
{"cyrillic", "Германия", true},
{"spaces allowed", "US proxy 2", true},
{"empty rejected", "", false},
{"control char rejected", "bad\x00tag", false},
{"newline rejected", "bad\ntag", false},
{"invalid utf8 rejected", string([]byte{0xff, 0xfe}), false},
{"overlong rejected", strings.Repeat("a", maxObsTagLength+1), false},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if got := validObsTag(tc.tag); got != tc.want {
t.Fatalf("validObsTag(%q) = %v, want %v", tc.tag, got, tc.want)
}
})
}
}
func TestApplyObservatoryKeepsUnicodeTags(t *testing.T) {
dbDir := t.TempDir()
if err := database.InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil {
t.Fatalf("InitDB: %v", err)
}
t.Cleanup(func() { _ = database.CloseDB() })
s := &XrayMetricsService{settingService: SettingService{}}
s.applyObservatory(time.Unix(1000, 0), map[string]rawObsEntry{
"🇩🇪 Berlin": {Alive: true, Delay: 42, LastTryTime: 1},
})
if !s.HasObservatoryTag("🇩🇪 Berlin") {
t.Fatal("emoji-tagged outbound must appear in the observatory")
}
snaps := s.ObservatorySnapshot()
if len(snaps) != 1 || snaps[0].Tag != "🇩🇪 Berlin" {
t.Fatalf("snapshot = %+v, want the emoji tag", snaps)
}
}
+14 -64
View File
@@ -136,45 +136,6 @@
"currentCommit": "الكومِت الحالي",
"latestCommit": "أحدث كومِت",
"updateChannelChanged": "تم تغيير قناة التحديث",
"systemHistoryTitle": "تاريخ النظام",
"historyTitleCpu": "استخدام المعالج",
"historyTitleMem": "استخدام الذاكرة",
"historyTitleNetwork": "عرض النطاق الترددي للشبكة",
"historyTitlePackets": "حزم الشبكة",
"historyTitleDisk": "إدخال/إخراج القرص",
"historyTitleOnline": "العملاء المتصلون",
"historyTitleLoad": "متوسط حمل النظام (1 / 5 / 15 دقيقة)",
"historyTitleConnections": "الاتصالات النشطة (TCP / UDP)",
"historyTitleDiskUsage": "استخدام مساحة القرص",
"historyTabBandwidth": "عرض النطاق",
"historyTabPackets": "الحزم",
"historyTabDisk": "قرص I/O",
"historyTabOnline": "متصل",
"historyTabLoad": "الحِمل",
"historyTabConnections": "الاتصالات",
"historyTabDiskUsage": "استخدام القرص",
"xrayMetricsTitle": "مقاييس Xray",
"xrayTitleHeap": "ذاكرة الكومة المخصصة",
"xrayTitleSys": "الذاكرة المحجوزة من نظام التشغيل",
"xrayTitleObjects": "كائنات الكومة النشطة",
"xrayTitleGcCount": "دورات GC المكتملة",
"xrayTitleGcPause": "مدة توقف GC",
"xrayTitleObservatory": "صحة الاتصال الصادر",
"xrayTabHeap": "Heap",
"xrayTabSys": "Sys",
"xrayTabObjects": "الكائنات",
"xrayTabGcCount": "عدد GC",
"xrayTabGcPause": "توقف GC",
"xrayTabObservatory": "المرصد",
"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": "آخر محاولة",
"connectionCount": "إحصائيات الاتصال",
"ipAddresses": "عناوين IP",
"toggleIpVisibility": "بدل إظهار IP",
@@ -191,18 +152,6 @@
"panelUpdateUnknownTitle": "تعذّر التأكد من اكتمال التحديث",
"panelUpdateUnknownDesc": "لم تُبلغ اللوحة بنتيجة في الوقت المحدد. أعد تحميل الصفحة للتحقق من الإصدار الحالي، أو تحقق من سجلات الخادم.",
"dontRefresh": "التثبيت شغال، متعملش Refresh للصفحة",
"logs": "السجلات",
"accessLogs": "سجلات الوصول",
"autoUpdate": "تحديث تلقائي",
"amneziawgLogs": "سجلات AmneziaWG",
"amneziawgHandshake": "آخر مصافحة",
"amneziawgInterface": "الواجهة",
"amneziawgInbound": "الوارد",
"amneziawgEndpoint": "نقطة الاتصال",
"amneziawgIdle": "خامل",
"amneziawgEvents": "الأحداث",
"amneziawgNoPeers": "لا يوجد أقران AmneziaWG نشطون حاليًا",
"amneziawgNoEvents": "لم تُسجَّل أحداث AmneziaWG بعد",
"config": "الإعدادات",
"backupTitle": "نسخ احتياطي واستعادة",
"exportDatabase": "اخزن نسخة",
@@ -219,25 +168,14 @@
"importDatabasePgDesc": "انقر لاختيار ورفع نسخة احتياطية من PostgreSQL (.dump) أو قاعدة بيانات SQLite (.db) أو ملف ترحيل SQLite لاستعادة قاعدة البيانات. سيؤدي هذا إلى استبدال جميع البيانات الحالية.",
"migrationDownload": "تنزيل ملف الترحيل",
"migrationDownloadPgDesc": "انقر لتنزيل قاعدة بيانات SQLite بامتداد .db مبنية من بيانات PostgreSQL الخاصة بك، جاهزة لتشغيل هذه اللوحة على SQLite.",
"avg": "متوسط",
"peak": "ذروة",
"free": "متاح",
"openSockets": "مقابس مفتوحة",
"throughputSub": "إجمالي الواجهة",
"avgWindow": "متوسط الفترة",
"healthWarm": "{list} — مرتفع قليلاً",
"healthCritical": "{list} — حرج",
"panel": "اللوحة",
"threads": "الخيوط",
"uptime": "مدة التشغيل",
"logLevelDebug": "Debug",
"logLevelInfo": "Info",
"logLevelNotice": "Notice",
"logLevelWarning": "Warning",
"logLevelError": "Error",
"accessDirect": "DIRECT",
"accessBlocked": "BLOCKED",
"accessProxy": "PROXY",
"importKeepHostSettings": "الاحتفاظ بإعدادات هذا الجهاز",
"importKeepHostSettingsDesc": "يحتفظ بعناوين الاستماع والمنافذ والمسار الأساسي والشهادات وهوية العقدة الخاصة بهذه اللوحة بدلًا من أخذها من الملف المرفوع."
},
@@ -970,7 +908,19 @@
"pinnedCertPlaceholder": "SHA-256 بصيغة base64 أو hex",
"fetchPin": "جلب",
"pinFetched": "تم جلب شهادة العقدة الحالية",
"pinFetchFailed": "تعذّر جلب الشهادة"
"pinFetchFailed": "تعذّر جلب الشهادة",
"nodeType": "Node Type",
"nodeTypeTraditional": "Traditional Panel Node",
"nodeTypeAgent": "Agent Lightweight Node",
"agentHint": "Agent nodes connect out to the master; no address, port or API token needed. An install command is generated on creation.",
"agentTokenTitle": "Agent Node Setup",
"agentTokenCreated": "Node {{name}} created successfully",
"agentTokenHint": "Copy the command below and run it on the node machine. This token is shown only once!",
"agentTokenInstructions": "The command downloads the installer, configures the master URL and token. The node comes online in ~30s.",
"agentTokenCopied": "Install command copied",
"agentTokenCopyFailed": "Copy failed, please copy manually",
"copyToken": "Copy Install Command",
"agentMasterUrlHint": "Master URL (used by agent to reach the master; change to public address if behind proxy):"
},
"settings": {
"defaultTag": "افتراضي",
@@ -1725,4 +1675,4 @@
"noIpRecord": "❗ لا يوجد سجل IP!"
},
"email": {}
}
}
+15 -65
View File
@@ -136,45 +136,6 @@
"currentCommit": "Current commit",
"latestCommit": "Latest commit",
"updateChannelChanged": "Update channel changed",
"systemHistoryTitle": "System History",
"historyTitleCpu": "CPU Usage",
"historyTitleMem": "Memory Usage",
"historyTitleNetwork": "Network Bandwidth",
"historyTitlePackets": "Network Packets",
"historyTitleDisk": "Disk I/O",
"historyTitleOnline": "Online Clients",
"historyTitleLoad": "System Load Average (1m / 5m / 15m)",
"historyTitleConnections": "Active Connections (TCP / UDP)",
"historyTitleDiskUsage": "Disk Space Usage",
"historyTabBandwidth": "Bandwidth",
"historyTabPackets": "Packets",
"historyTabDisk": "Disk I/O",
"historyTabOnline": "Online",
"historyTabLoad": "Load",
"historyTabConnections": "Connections",
"historyTabDiskUsage": "Disk Usage",
"xrayMetricsTitle": "Xray Metrics",
"xrayTitleHeap": "Allocated Heap Memory",
"xrayTitleSys": "Memory Reserved from OS",
"xrayTitleObjects": "Live Heap Objects",
"xrayTitleGcCount": "Completed GC Cycles",
"xrayTitleGcPause": "GC Pause Duration",
"xrayTitleObservatory": "Outbound Connection Health",
"xrayTabHeap": "Heap",
"xrayTabSys": "Sys",
"xrayTabObjects": "Objects",
"xrayTabGcCount": "GC Count",
"xrayTabGcPause": "GC Pause",
"xrayTabObservatory": "Observatory",
"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",
"connectionCount": "Connection Stats",
"ipAddresses": "IP Addresses",
"toggleIpVisibility": "Toggle visibility of the IP",
@@ -191,18 +152,6 @@
"panelUpdateUnknownTitle": "Couldn't confirm the update finished",
"panelUpdateUnknownDesc": "The panel didn't report a result in time. Reload to check the current version, or check the server logs.",
"dontRefresh": "Installation is in progress, please do not refresh this page",
"logs": "Logs",
"accessLogs": "Access Logs",
"autoUpdate": "Auto Update",
"amneziawgLogs": "AmneziaWG Logs",
"amneziawgHandshake": "Last Handshake",
"amneziawgInterface": "Interface",
"amneziawgInbound": "Inbound",
"amneziawgEndpoint": "Endpoint",
"amneziawgIdle": "Idle",
"amneziawgEvents": "Events",
"amneziawgNoPeers": "No AmneziaWG peers are currently up",
"amneziawgNoEvents": "No AmneziaWG events recorded yet",
"config": "Config",
"backupTitle": "Backup & Restore",
"exportDatabase": "Back Up",
@@ -219,25 +168,14 @@
"importDatabasePgDesc": "Click to select and upload a PostgreSQL backup (.dump), a SQLite database (.db), or a SQLite migration dump to restore your database. This replaces all current data.",
"migrationDownload": "Download Migration",
"migrationDownloadPgDesc": "Click to download a .db SQLite database built from your PostgreSQL data, ready to run this panel on SQLite.",
"avg": "avg",
"peak": "peak",
"free": "free",
"openSockets": "open sockets",
"throughputSub": "Interface total",
"avgWindow": "Avg over window",
"healthWarm": "{list} — running warm",
"healthCritical": "{list} — critical",
"panel": "Panel",
"threads": "Threads",
"uptime": "Uptime",
"logLevelDebug": "Debug",
"logLevelInfo": "Info",
"logLevelNotice": "Notice",
"logLevelWarning": "Warning",
"logLevelError": "Error",
"accessDirect": "DIRECT",
"accessBlocked": "BLOCKED",
"accessProxy": "PROXY",
"importKeepHostSettings": "Keep this machine's settings",
"importKeepHostSettingsDesc": "Keeps this panel's listen addresses, ports, base path, certificates and node identity instead of taking them from the uploaded file."
},
@@ -1088,7 +1026,19 @@
"pinnedCertPlaceholder": "base64 or hex SHA-256",
"fetchPin": "Fetch",
"pinFetched": "Fetched the node's current certificate",
"pinFetchFailed": "Could not fetch the certificate"
"pinFetchFailed": "Could not fetch the certificate",
"nodeType": "Node Type",
"nodeTypeTraditional": "Traditional Panel Node",
"nodeTypeAgent": "Agent Lightweight Node",
"agentHint": "Agent nodes connect out to the master; no address, port or API token needed. An install command is generated on creation.",
"agentTokenTitle": "Agent Node Setup",
"agentTokenCreated": "Node {{name}} created successfully",
"agentTokenHint": "Copy the command below and run it on the node machine. This token is shown only once!",
"agentTokenInstructions": "The command downloads the installer, configures the master URL and token. The node comes online in ~30s.",
"agentTokenCopied": "Install command copied",
"agentTokenCopyFailed": "Copy failed, please copy manually",
"copyToken": "Copy Install Command",
"agentMasterUrlHint": "Master URL (used by agent to reach the master; change to public address if behind proxy):"
},
"settings": {
"defaultTag": "Default",
@@ -1192,7 +1142,7 @@
"subListen": "Listen IP",
"subListenDesc": "The IP address for the subscription service. (leave blank to listen on all IPs)",
"subPort": "Listen Port",
"subPortDesc": "The port number for the subscription service. (must be an unused port). Also used to build the subscription link/QR shown in the panel when \"Reverse Proxy URI\" below is empty — if the subscription is reached through a reverse proxy on a different port, set \"Reverse Proxy URI\" instead.",
"subPortDesc": "The port number for the subscription service. (must be an unused port). Also used to build the subscription link/QR shown in the panel when \"Reverse Proxy URI\" below is empty — if the subscription is reached through a reverse proxy on a different port, set \"Reverse Proxy URI\" instead.Leave empty to omit the port from subscription links (the service still listens on the default port 2096).",
"subCertPath": "Public Key Path",
"subCertPathDesc": "The public key file path for the subscription service. (begins with ‘/‘)",
"subKeyPath": "Private Key Path",
@@ -1725,4 +1675,4 @@
"noIpRecord": "❗ No IP Record!"
},
"email": {}
}
}
+14 -64
View File
@@ -136,45 +136,6 @@
"currentCommit": "Commit actual",
"latestCommit": "Último commit",
"updateChannelChanged": "Canal de actualización cambiado",
"systemHistoryTitle": "Historial del Sistema",
"historyTitleCpu": "Uso de CPU",
"historyTitleMem": "Uso de Memoria",
"historyTitleNetwork": "Ancho de Banda de Red",
"historyTitlePackets": "Paquetes de Red",
"historyTitleDisk": "E/S de Disco",
"historyTitleOnline": "Clientes en Línea",
"historyTitleLoad": "Carga Media del Sistema (1 / 5 / 15 min)",
"historyTitleConnections": "Conexiones Activas (TCP / UDP)",
"historyTitleDiskUsage": "Uso del Espacio en Disco",
"historyTabBandwidth": "Ancho de Banda",
"historyTabPackets": "Paquetes",
"historyTabDisk": "Disco I/O",
"historyTabOnline": "En línea",
"historyTabLoad": "Carga",
"historyTabConnections": "Conexiones",
"historyTabDiskUsage": "Uso de Disco",
"xrayMetricsTitle": "Métricas de Xray",
"xrayTitleHeap": "Memoria Heap Asignada",
"xrayTitleSys": "Memoria Reservada del SO",
"xrayTitleObjects": "Objetos Heap Activos",
"xrayTitleGcCount": "Ciclos de GC Completados",
"xrayTitleGcPause": "Duración de Pausa de GC",
"xrayTitleObservatory": "Estado de Conexiones Salientes",
"xrayTabHeap": "Heap",
"xrayTabSys": "Sys",
"xrayTabObjects": "Objetos",
"xrayTabGcCount": "Recuento GC",
"xrayTabGcPause": "Pausa GC",
"xrayTabObservatory": "Observatorio",
"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",
"connectionCount": "Número de Conexiones",
"ipAddresses": "Direcciones IP",
"toggleIpVisibility": "Alternar visibilidad de la IP",
@@ -191,18 +152,6 @@
"panelUpdateUnknownTitle": "No se pudo confirmar si la actualización terminó",
"panelUpdateUnknownDesc": "El panel no informó un resultado a tiempo. Recarga la página para comprobar la versión actual, o revisa los registros del servidor.",
"dontRefresh": "La instalación está en progreso, por favor no actualices esta página.",
"logs": "Registros",
"accessLogs": "Registros de acceso",
"autoUpdate": "Actualización automática",
"amneziawgLogs": "Registros de AmneziaWG",
"amneziawgHandshake": "Último handshake",
"amneziawgInterface": "Interfaz",
"amneziawgInbound": "Entrada",
"amneziawgEndpoint": "Endpoint",
"amneziawgIdle": "Inactivo",
"amneziawgEvents": "Eventos",
"amneziawgNoPeers": "No hay peers de AmneziaWG activos",
"amneziawgNoEvents": "Aún no hay eventos de AmneziaWG registrados",
"config": "Configuración",
"backupTitle": "Copia & Restauración",
"exportDatabase": "Copia de seguridad",
@@ -219,25 +168,14 @@
"importDatabasePgDesc": "Haz clic para seleccionar y subir una copia de seguridad de PostgreSQL (.dump), una base de datos SQLite (.db) o un volcado de migración de SQLite para restaurar tu base de datos. Esto reemplaza todos los datos actuales.",
"migrationDownload": "Descargar migración",
"migrationDownloadPgDesc": "Haz clic para descargar una base de datos SQLite .db creada a partir de tus datos de PostgreSQL, lista para ejecutar este panel en SQLite.",
"avg": "media",
"peak": "pico",
"free": "libre",
"openSockets": "sockets abiertos",
"throughputSub": "Total de la interfaz",
"avgWindow": "Media del periodo",
"healthWarm": "{list} — algo elevado",
"healthCritical": "{list} — crítico",
"panel": "Panel",
"threads": "Hilos",
"uptime": "Tiempo activo",
"logLevelDebug": "Debug",
"logLevelInfo": "Info",
"logLevelNotice": "Notice",
"logLevelWarning": "Warning",
"logLevelError": "Error",
"accessDirect": "DIRECT",
"accessBlocked": "BLOCKED",
"accessProxy": "PROXY",
"importKeepHostSettings": "Mantener la configuración de esta máquina",
"importKeepHostSettingsDesc": "Conserva las direcciones de escucha, los puertos, la ruta base, los certificados y la identidad de nodo de este panel en lugar de tomarlos del archivo subido."
},
@@ -970,7 +908,19 @@
"pinnedCertPlaceholder": "SHA-256 en base64 o hex",
"fetchPin": "Obtener",
"pinFetched": "Se obtuvo el certificado actual del nodo",
"pinFetchFailed": "No se pudo obtener el certificado"
"pinFetchFailed": "No se pudo obtener el certificado",
"nodeType": "Node Type",
"nodeTypeTraditional": "Traditional Panel Node",
"nodeTypeAgent": "Agent Lightweight Node",
"agentHint": "Agent nodes connect out to the master; no address, port or API token needed. An install command is generated on creation.",
"agentTokenTitle": "Agent Node Setup",
"agentTokenCreated": "Node {{name}} created successfully",
"agentTokenHint": "Copy the command below and run it on the node machine. This token is shown only once!",
"agentTokenInstructions": "The command downloads the installer, configures the master URL and token. The node comes online in ~30s.",
"agentTokenCopied": "Install command copied",
"agentTokenCopyFailed": "Copy failed, please copy manually",
"copyToken": "Copy Install Command",
"agentMasterUrlHint": "Master URL (used by agent to reach the master; change to public address if behind proxy):"
},
"settings": {
"defaultTag": "Predeterminado",
@@ -1725,4 +1675,4 @@
"noIpRecord": "❗ ¡No hay registro de IP!"
},
"email": {}
}
}
+14 -64
View File
@@ -136,45 +136,6 @@
"currentCommit": "کامیت فعلی",
"latestCommit": "آخرین کامیت",
"updateChannelChanged": "کانال به‌روزرسانی تغییر کرد",
"systemHistoryTitle": "تاریخچه سیستم",
"historyTitleCpu": "مصرف پردازنده",
"historyTitleMem": "مصرف حافظه",
"historyTitleNetwork": "پهنای باند شبکه",
"historyTitlePackets": "بسته‌های شبکه",
"historyTitleDisk": "ورودی/خروجی دیسک",
"historyTitleOnline": "کاربران آنلاین",
"historyTitleLoad": "میانگین بار سیستم (۱ / ۵ / ۱۵ دقیقه)",
"historyTitleConnections": "اتصالات فعال (TCP / UDP)",
"historyTitleDiskUsage": "مصرف فضای دیسک",
"historyTabBandwidth": "پهنای باند",
"historyTabPackets": "بسته‌ها",
"historyTabDisk": "دیسک I/O",
"historyTabOnline": "آنلاین",
"historyTabLoad": "بار",
"historyTabConnections": "اتصالات",
"historyTabDiskUsage": "مصرف دیسک",
"xrayMetricsTitle": "متریک‌های Xray",
"xrayTitleHeap": "حافظه‌ی Heap تخصیص‌یافته",
"xrayTitleSys": "حافظه‌ی رزروشده از سیستم‌عامل",
"xrayTitleObjects": "اشیای زنده‌ی Heap",
"xrayTitleGcCount": "چرخه‌های کامل‌شده‌ی GC",
"xrayTitleGcPause": "مدت مکث GC",
"xrayTitleObservatory": "سلامت اتصال خروجی",
"xrayTabHeap": "Heap",
"xrayTabSys": "Sys",
"xrayTabObjects": "اشیا",
"xrayTabGcCount": "تعداد GC",
"xrayTabGcPause": "مکث GC",
"xrayTabObservatory": "رصدخانه",
"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": "آخرین تلاش",
"connectionCount": "تعداد کانکشن ها",
"ipAddresses": "آدرس‌های IP",
"toggleIpVisibility": "تغییر وضعیت نمایش IP",
@@ -191,18 +152,6 @@
"panelUpdateUnknownTitle": "تأیید نشد که به‌روزرسانی به پایان رسیده باشد",
"panelUpdateUnknownDesc": "پنل به‌موقع نتیجه‌ای گزارش نکرد. صفحه را بارگذاری مجدد کنید تا نسخه فعلی را بررسی کنید یا گزارش‌های سرور را بررسی کنید.",
"dontRefresh": "در حال نصب، لطفا صفحه را رفرش نکنید",
"logs": "لاگ‌ها",
"accessLogs": "لاگ‌های دسترسی",
"autoUpdate": "به‌روزرسانی خودکار",
"amneziawgLogs": "لاگ‌های AmneziaWG",
"amneziawgHandshake": "آخرین دست‌دهی",
"amneziawgInterface": "رابط شبکه",
"amneziawgInbound": "ورودی",
"amneziawgEndpoint": "نقطه اتصال",
"amneziawgIdle": "بی‌کار",
"amneziawgEvents": "رویدادها",
"amneziawgNoPeers": "هیچ پیر فعالی برای AmneziaWG وجود ندارد",
"amneziawgNoEvents": "هنوز رویدادی برای AmneziaWG ثبت نشده است",
"config": "پیکربندی",
"backupTitle": "پشتیبان‌گیری و بازیابی",
"exportDatabase": "پشتیبان‌گیری",
@@ -219,25 +168,14 @@
"importDatabasePgDesc": "برای انتخاب و بارگذاری یک پشتیبان PostgreSQL (.dump)، پایگاه‌دادهٔ SQLite (.db) یا فایل مهاجرت SQLite جهت بازیابی پایگاه داده کلیک کنید. این کار همه داده‌های فعلی را جایگزین می‌کند.",
"migrationDownload": "دانلود فایل مهاجرت",
"migrationDownloadPgDesc": "برای دانلود یک پایگاه‌دادهٔ SQLite با پسوند ‎.db که از داده‌های PostgreSQL شما ساخته می‌شود کلیک کنید؛ آمادهٔ اجرای این پنل روی SQLite.",
"avg": "میانگین",
"peak": "اوج",
"free": "آزاد",
"openSockets": "اتصال باز",
"throughputSub": "کل اینترفیس",
"avgWindow": "میانگین بازه",
"healthWarm": "{list} — رو به گرم شدن",
"healthCritical": "{list} — بحرانی",
"panel": "پنل",
"threads": "نخ‌ها",
"uptime": "مدت کارکرد",
"logLevelDebug": "Debug",
"logLevelInfo": "Info",
"logLevelNotice": "Notice",
"logLevelWarning": "Warning",
"logLevelError": "Error",
"accessDirect": "DIRECT",
"accessBlocked": "BLOCKED",
"accessProxy": "PROXY",
"importKeepHostSettings": "حفظ تنظیمات این دستگاه",
"importKeepHostSettingsDesc": "آدرس‌ها و پورت‌های شنود، مسیر پایه، گواهی‌ها و هویت نودِ همین پنل را نگه می‌دارد و آن‌ها را از فایل بارگذاری‌شده نمی‌گیرد."
},
@@ -970,7 +908,19 @@
"pinnedCertPlaceholder": "SHA-256 به‌صورت base64 یا hex",
"fetchPin": "دریافت",
"pinFetched": "گواهیِ فعلیِ نود دریافت شد",
"pinFetchFailed": "دریافت گواهی ممکن نشد"
"pinFetchFailed": "دریافت گواهی ممکن نشد",
"nodeType": "Node Type",
"nodeTypeTraditional": "Traditional Panel Node",
"nodeTypeAgent": "Agent Lightweight Node",
"agentHint": "Agent nodes connect out to the master; no address, port or API token needed. An install command is generated on creation.",
"agentTokenTitle": "Agent Node Setup",
"agentTokenCreated": "Node {{name}} created successfully",
"agentTokenHint": "Copy the command below and run it on the node machine. This token is shown only once!",
"agentTokenInstructions": "The command downloads the installer, configures the master URL and token. The node comes online in ~30s.",
"agentTokenCopied": "Install command copied",
"agentTokenCopyFailed": "Copy failed, please copy manually",
"copyToken": "Copy Install Command",
"agentMasterUrlHint": "Master URL (used by agent to reach the master; change to public address if behind proxy):"
},
"settings": {
"defaultTag": "پیش‌فرض",
@@ -1725,4 +1675,4 @@
"noIpRecord": "❗ رکورد آی پی وجود ندارد!"
},
"email": {}
}
}
+14 -64
View File
@@ -136,45 +136,6 @@
"currentCommit": "Commit saat ini",
"latestCommit": "Commit terbaru",
"updateChannelChanged": "Kanal pembaruan diubah",
"systemHistoryTitle": "Riwayat Sistem",
"historyTitleCpu": "Penggunaan CPU",
"historyTitleMem": "Penggunaan Memori",
"historyTitleNetwork": "Bandwidth Jaringan",
"historyTitlePackets": "Paket Jaringan",
"historyTitleDisk": "I/O Disk",
"historyTitleOnline": "Klien Online",
"historyTitleLoad": "Rata-rata Beban Sistem (1 / 5 / 15 mnt)",
"historyTitleConnections": "Koneksi Aktif (TCP / UDP)",
"historyTitleDiskUsage": "Penggunaan Ruang Disk",
"historyTabBandwidth": "Bandwidth",
"historyTabPackets": "Paket",
"historyTabDisk": "Disk I/O",
"historyTabOnline": "Online",
"historyTabLoad": "Beban",
"historyTabConnections": "Koneksi",
"historyTabDiskUsage": "Penggunaan Disk",
"xrayMetricsTitle": "Metrik Xray",
"xrayTitleHeap": "Memori Heap Teralokasi",
"xrayTitleSys": "Memori Dicadangkan dari OS",
"xrayTitleObjects": "Objek Heap Aktif",
"xrayTitleGcCount": "Siklus GC Selesai",
"xrayTitleGcPause": "Durasi Jeda GC",
"xrayTitleObservatory": "Kesehatan Koneksi Keluar",
"xrayTabHeap": "Heap",
"xrayTabSys": "Sys",
"xrayTabObjects": "Objek",
"xrayTabGcCount": "Jumlah GC",
"xrayTabGcPause": "Jeda GC",
"xrayTabObservatory": "Observatorium",
"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",
"connectionCount": "Statistik Koneksi",
"ipAddresses": "Alamat IP",
"toggleIpVisibility": "Alihkan visibilitas IP",
@@ -191,18 +152,6 @@
"panelUpdateUnknownTitle": "Tidak dapat memastikan pembaruan selesai",
"panelUpdateUnknownDesc": "Panel tidak melaporkan hasil tepat waktu. Muat ulang untuk memeriksa versi saat ini, atau periksa log server.",
"dontRefresh": "Instalasi sedang berlangsung, harap jangan menyegarkan halaman ini",
"logs": "Log",
"accessLogs": "Log Akses",
"autoUpdate": "Pembaruan Otomatis",
"amneziawgLogs": "Log AmneziaWG",
"amneziawgHandshake": "Handshake Terakhir",
"amneziawgInterface": "Antarmuka",
"amneziawgInbound": "Inbound",
"amneziawgEndpoint": "Endpoint",
"amneziawgIdle": "Idle",
"amneziawgEvents": "Peristiwa",
"amneziawgNoPeers": "Tidak ada peer AmneziaWG yang aktif",
"amneziawgNoEvents": "Belum ada peristiwa AmneziaWG yang tercatat",
"config": "Konfigurasi",
"backupTitle": "Cadangan & Pulihkan",
"exportDatabase": "Cadangkan",
@@ -219,25 +168,14 @@
"importDatabasePgDesc": "Klik untuk memilih dan mengunggah cadangan PostgreSQL (.dump), basis data SQLite (.db), atau dump migrasi SQLite guna memulihkan basis data Anda. Ini menggantikan semua data saat ini.",
"migrationDownload": "Unduh migrasi",
"migrationDownloadPgDesc": "Klik untuk mengunduh basis data SQLite .db yang dibuat dari data PostgreSQL Anda, siap menjalankan panel ini di SQLite.",
"avg": "rerata",
"peak": "puncak",
"free": "tersisa",
"openSockets": "soket terbuka",
"throughputSub": "Total antarmuka",
"avgWindow": "Rerata periode",
"healthWarm": "{list} — mulai tinggi",
"healthCritical": "{list} — kritis",
"panel": "Panel",
"threads": "Thread",
"uptime": "Waktu aktif",
"logLevelDebug": "Debug",
"logLevelInfo": "Info",
"logLevelNotice": "Notice",
"logLevelWarning": "Warning",
"logLevelError": "Error",
"accessDirect": "DIRECT",
"accessBlocked": "BLOCKED",
"accessProxy": "PROXY",
"importKeepHostSettings": "Pertahankan pengaturan mesin ini",
"importKeepHostSettingsDesc": "Mempertahankan alamat dengar, port, path dasar, sertifikat, dan identitas node panel ini alih-alih mengambilnya dari berkas yang diunggah."
},
@@ -970,7 +908,19 @@
"pinnedCertPlaceholder": "SHA-256 base64 atau hex",
"fetchPin": "Ambil",
"pinFetched": "Berhasil mengambil sertifikat node saat ini",
"pinFetchFailed": "Tidak dapat mengambil sertifikat"
"pinFetchFailed": "Tidak dapat mengambil sertifikat",
"nodeType": "Node Type",
"nodeTypeTraditional": "Traditional Panel Node",
"nodeTypeAgent": "Agent Lightweight Node",
"agentHint": "Agent nodes connect out to the master; no address, port or API token needed. An install command is generated on creation.",
"agentTokenTitle": "Agent Node Setup",
"agentTokenCreated": "Node {{name}} created successfully",
"agentTokenHint": "Copy the command below and run it on the node machine. This token is shown only once!",
"agentTokenInstructions": "The command downloads the installer, configures the master URL and token. The node comes online in ~30s.",
"agentTokenCopied": "Install command copied",
"agentTokenCopyFailed": "Copy failed, please copy manually",
"copyToken": "Copy Install Command",
"agentMasterUrlHint": "Master URL (used by agent to reach the master; change to public address if behind proxy):"
},
"settings": {
"defaultTag": "Bawaan",
@@ -1725,4 +1675,4 @@
"noIpRecord": "❗ Tidak ada Catatan IP!"
},
"email": {}
}
}
+14 -64
View File
@@ -136,45 +136,6 @@
"currentCommit": "現在のコミット",
"latestCommit": "最新のコミット",
"updateChannelChanged": "更新チャンネルを変更しました",
"systemHistoryTitle": "システム履歴",
"historyTitleCpu": "CPU 使用率",
"historyTitleMem": "メモリ使用率",
"historyTitleNetwork": "ネットワーク帯域幅",
"historyTitlePackets": "ネットワークパケット",
"historyTitleDisk": "ディスク I/O",
"historyTitleOnline": "オンラインクライアント",
"historyTitleLoad": "システム平均負荷(1分 / 5分 / 15分)",
"historyTitleConnections": "アクティブな接続 (TCP / UDP)",
"historyTitleDiskUsage": "ディスク使用率",
"historyTabBandwidth": "帯域幅",
"historyTabPackets": "パケット",
"historyTabDisk": "ディスク I/O",
"historyTabOnline": "オンライン",
"historyTabLoad": "負荷",
"historyTabConnections": "接続数",
"historyTabDiskUsage": "ディスク使用量",
"xrayMetricsTitle": "Xray メトリクス",
"xrayTitleHeap": "割り当て済みヒープメモリ",
"xrayTitleSys": "OS から確保したメモリ",
"xrayTitleObjects": "ヒープオブジェクト数",
"xrayTitleGcCount": "完了した GC サイクル",
"xrayTitleGcPause": "GC 一時停止時間",
"xrayTitleObservatory": "アウトバウンド接続の状態",
"xrayTabHeap": "ヒープ",
"xrayTabSys": "Sys",
"xrayTabObjects": "オブジェクト",
"xrayTabGcCount": "GC 回数",
"xrayTabGcPause": "GC 一時停止",
"xrayTabObservatory": "オブザーバトリ",
"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": "最終試行",
"connectionCount": "接続数",
"ipAddresses": "IPアドレス",
"toggleIpVisibility": "IPの表示を切り替える",
@@ -191,18 +152,6 @@
"panelUpdateUnknownTitle": "更新が完了したか確認できませんでした",
"panelUpdateUnknownDesc": "パネルから時間内に結果が報告されませんでした。再読み込みして現在のバージョンを確認するか、サーバーのログを確認してください。",
"dontRefresh": "インストール中、このページをリロードしないでください",
"logs": "ログ",
"accessLogs": "アクセスログ",
"autoUpdate": "自動更新",
"amneziawgLogs": "AmneziaWG ログ",
"amneziawgHandshake": "最終ハンドシェイク",
"amneziawgInterface": "インターフェース",
"amneziawgInbound": "インバウンド",
"amneziawgEndpoint": "エンドポイント",
"amneziawgIdle": "アイドル",
"amneziawgEvents": "イベント",
"amneziawgNoPeers": "稼働中の AmneziaWG ピアはありません",
"amneziawgNoEvents": "AmneziaWG のイベントはまだ記録されていません",
"config": "設定",
"backupTitle": "バックアップと復元",
"exportDatabase": "バックアップ",
@@ -219,25 +168,14 @@
"importDatabasePgDesc": "データベースを復元するために PostgreSQL バックアップ (.dump)、SQLite データベース (.db)、または SQLite 移行ダンプを選択してアップロードするにはクリックしてください。現在のすべてのデータが置き換えられます。",
"migrationDownload": "移行ファイルをダウンロード",
"migrationDownloadPgDesc": "PostgreSQL のデータから作成した .db SQLite データベースをダウンロードします。このパネルを SQLite で実行する準備が整います。",
"avg": "平均",
"peak": "ピーク",
"free": "空き",
"openSockets": "オープンソケット",
"throughputSub": "インターフェース合計",
"avgWindow": "期間平均",
"healthWarm": "{list} — 高めです",
"healthCritical": "{list} — 危険水準",
"panel": "パネル",
"threads": "スレッド",
"uptime": "稼働時間",
"logLevelDebug": "Debug",
"logLevelInfo": "Info",
"logLevelNotice": "Notice",
"logLevelWarning": "Warning",
"logLevelError": "Error",
"accessDirect": "DIRECT",
"accessBlocked": "BLOCKED",
"accessProxy": "PROXY",
"importKeepHostSettings": "このマシンの設定を保持する",
"importKeepHostSettingsDesc": "アップロードしたファイルの値ではなく、このパネルのリッスンアドレス、ポート、ベースパス、証明書、ノード ID を保持します。"
},
@@ -970,7 +908,19 @@
"pinnedCertPlaceholder": "base64 または hex の SHA-256",
"fetchPin": "取得",
"pinFetched": "ノードの現在の証明書を取得しました",
"pinFetchFailed": "証明書を取得できませんでした"
"pinFetchFailed": "証明書を取得できませんでした",
"nodeType": "Node Type",
"nodeTypeTraditional": "Traditional Panel Node",
"nodeTypeAgent": "Agent Lightweight Node",
"agentHint": "Agent nodes connect out to the master; no address, port or API token needed. An install command is generated on creation.",
"agentTokenTitle": "Agent Node Setup",
"agentTokenCreated": "Node {{name}} created successfully",
"agentTokenHint": "Copy the command below and run it on the node machine. This token is shown only once!",
"agentTokenInstructions": "The command downloads the installer, configures the master URL and token. The node comes online in ~30s.",
"agentTokenCopied": "Install command copied",
"agentTokenCopyFailed": "Copy failed, please copy manually",
"copyToken": "Copy Install Command",
"agentMasterUrlHint": "Master URL (used by agent to reach the master; change to public address if behind proxy):"
},
"settings": {
"defaultTag": "デフォルト",
@@ -1725,4 +1675,4 @@
"noIpRecord": "❗ IPレコードがありません!"
},
"email": {}
}
}
+14 -64
View File
@@ -136,45 +136,6 @@
"currentCommit": "Commit atual",
"latestCommit": "Último commit",
"updateChannelChanged": "Canal de atualização alterado",
"systemHistoryTitle": "Histórico do Sistema",
"historyTitleCpu": "Uso da CPU",
"historyTitleMem": "Uso de Memória",
"historyTitleNetwork": "Largura de Banda da Rede",
"historyTitlePackets": "Pacotes de Rede",
"historyTitleDisk": "E/S de Disco",
"historyTitleOnline": "Clientes Online",
"historyTitleLoad": "Média de Carga do Sistema (1 / 5 / 15 min)",
"historyTitleConnections": "Conexões Ativas (TCP / UDP)",
"historyTitleDiskUsage": "Uso do Espaço em Disco",
"historyTabBandwidth": "Largura de Banda",
"historyTabPackets": "Pacotes",
"historyTabDisk": "Disco I/O",
"historyTabOnline": "Online",
"historyTabLoad": "Carga",
"historyTabConnections": "Conexões",
"historyTabDiskUsage": "Uso de Disco",
"xrayMetricsTitle": "Métricas do Xray",
"xrayTitleHeap": "Memória Heap Alocada",
"xrayTitleSys": "Memória Reservada do SO",
"xrayTitleObjects": "Objetos Heap Ativos",
"xrayTitleGcCount": "Ciclos de GC Concluídos",
"xrayTitleGcPause": "Duração da Pausa do GC",
"xrayTitleObservatory": "Saúde das Conexões de Saída",
"xrayTabHeap": "Heap",
"xrayTabSys": "Sys",
"xrayTabObjects": "Objetos",
"xrayTabGcCount": "Contagem GC",
"xrayTabGcPause": "Pausa GC",
"xrayTabObservatory": "Observatório",
"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",
"connectionCount": "Estatísticas de Conexão",
"ipAddresses": "Endereços IP",
"toggleIpVisibility": "Alternar visibilidade do IP",
@@ -191,18 +152,6 @@
"panelUpdateUnknownTitle": "Não foi possível confirmar se a atualização terminou",
"panelUpdateUnknownDesc": "O painel não retornou um resultado a tempo. Recarregue a página para verificar a versão atual, ou verifique os logs do servidor.",
"dontRefresh": "Instalação em andamento, por favor não atualize a página",
"logs": "Logs",
"accessLogs": "Logs de acesso",
"autoUpdate": "Atualização automática",
"amneziawgLogs": "Logs do AmneziaWG",
"amneziawgHandshake": "Último handshake",
"amneziawgInterface": "Interface",
"amneziawgInbound": "Entrada",
"amneziawgEndpoint": "Endpoint",
"amneziawgIdle": "Ocioso",
"amneziawgEvents": "Eventos",
"amneziawgNoPeers": "Nenhum peer do AmneziaWG está ativo",
"amneziawgNoEvents": "Nenhum evento do AmneziaWG registrado ainda",
"config": "Configuração",
"backupTitle": "Backup & Restauração",
"exportDatabase": "Backup",
@@ -219,25 +168,14 @@
"importDatabasePgDesc": "Clique para selecionar e enviar um backup do PostgreSQL (.dump), um banco de dados SQLite (.db) ou um dump de migração do SQLite para restaurar seu banco de dados. Isso substitui todos os dados atuais.",
"migrationDownload": "Baixar migração",
"migrationDownloadPgDesc": "Clique para baixar um banco de dados SQLite .db criado a partir dos seus dados do PostgreSQL, pronto para executar este painel no SQLite.",
"avg": "média",
"peak": "pico",
"free": "livre",
"openSockets": "sockets abertos",
"throughputSub": "Total da interface",
"avgWindow": "Média do período",
"healthWarm": "{list} — um pouco alto",
"healthCritical": "{list} — crítico",
"panel": "Painel",
"threads": "Threads",
"uptime": "Tempo ativo",
"logLevelDebug": "Debug",
"logLevelInfo": "Info",
"logLevelNotice": "Notice",
"logLevelWarning": "Warning",
"logLevelError": "Error",
"accessDirect": "DIRECT",
"accessBlocked": "BLOCKED",
"accessProxy": "PROXY",
"importKeepHostSettings": "Manter as configurações desta máquina",
"importKeepHostSettingsDesc": "Mantém os endereços de escuta, as portas, o caminho base, os certificados e a identidade de nó deste painel em vez de obtê-los do arquivo enviado."
},
@@ -970,7 +908,19 @@
"pinnedCertPlaceholder": "SHA-256 em base64 ou hex",
"fetchPin": "Obter",
"pinFetched": "Certificado atual do nó obtido",
"pinFetchFailed": "Não foi possível obter o certificado"
"pinFetchFailed": "Não foi possível obter o certificado",
"nodeType": "Node Type",
"nodeTypeTraditional": "Traditional Panel Node",
"nodeTypeAgent": "Agent Lightweight Node",
"agentHint": "Agent nodes connect out to the master; no address, port or API token needed. An install command is generated on creation.",
"agentTokenTitle": "Agent Node Setup",
"agentTokenCreated": "Node {{name}} created successfully",
"agentTokenHint": "Copy the command below and run it on the node machine. This token is shown only once!",
"agentTokenInstructions": "The command downloads the installer, configures the master URL and token. The node comes online in ~30s.",
"agentTokenCopied": "Install command copied",
"agentTokenCopyFailed": "Copy failed, please copy manually",
"copyToken": "Copy Install Command",
"agentMasterUrlHint": "Master URL (used by agent to reach the master; change to public address if behind proxy):"
},
"settings": {
"defaultTag": "Padrão",
@@ -1725,4 +1675,4 @@
"noIpRecord": "❗ Nenhum registro de IP!"
},
"email": {}
}
}
+14 -64
View File
@@ -136,45 +136,6 @@
"currentCommit": "Текущий коммит",
"latestCommit": "Последний коммит",
"updateChannelChanged": "Канал обновления изменён",
"systemHistoryTitle": "История системы",
"historyTitleCpu": "Загрузка ЦП",
"historyTitleMem": "Использование памяти",
"historyTitleNetwork": "Пропускная способность сети",
"historyTitlePackets": "Сетевые пакеты",
"historyTitleDisk": "Дисковый ввод-вывод",
"historyTitleOnline": "Клиенты онлайн",
"historyTitleLoad": "Средняя нагрузка системы (1 / 5 / 15 мин)",
"historyTitleConnections": "Активные соединения (TCP / UDP)",
"historyTitleDiskUsage": "Использование дискового пространства",
"historyTabBandwidth": "Пропускная способность",
"historyTabPackets": "Пакеты",
"historyTabDisk": "Диск I/O",
"historyTabOnline": "Онлайн",
"historyTabLoad": "Нагрузка",
"historyTabConnections": "Соединения",
"historyTabDiskUsage": "Использование диска",
"xrayMetricsTitle": "Метрики Xray",
"xrayTitleHeap": "Выделенная память кучи",
"xrayTitleSys": "Память, зарезервированная у ОС",
"xrayTitleObjects": "Активные объекты кучи",
"xrayTitleGcCount": "Завершённые циклы GC",
"xrayTitleGcPause": "Длительность паузы GC",
"xrayTitleObservatory": "Состояние исходящих соединений",
"xrayTabHeap": "Куча",
"xrayTabSys": "Sys",
"xrayTabObjects": "Объекты",
"xrayTabGcCount": "Счётчик GC",
"xrayTabGcPause": "Пауза GC",
"xrayTabObservatory": "Обсерватория",
"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": "Последняя попытка",
"connectionCount": "Количество соединений",
"ipAddresses": "IP-адреса сервера",
"toggleIpVisibility": "Скрыть или показать IP-адреса сервера",
@@ -191,18 +152,6 @@
"panelUpdateUnknownTitle": "Не удалось подтвердить завершение обновления",
"panelUpdateUnknownDesc": "Панель не сообщила результат вовремя. Перезагрузите страницу, чтобы проверить текущую версию, или проверьте журналы сервера.",
"dontRefresh": "Установка в процессе. Не обновляйте страницу",
"logs": "Логи",
"accessLogs": "Логи доступа",
"autoUpdate": "Автообновление",
"amneziawgLogs": "Логи AmneziaWG",
"amneziawgHandshake": "Последнее рукопожатие",
"amneziawgInterface": "Интерфейс",
"amneziawgInbound": "Входящее",
"amneziawgEndpoint": "Точка подключения",
"amneziawgIdle": "Простой",
"amneziawgEvents": "События",
"amneziawgNoPeers": "Нет активных пиров AmneziaWG",
"amneziawgNoEvents": "События AmneziaWG пока не зафиксированы",
"config": "Конфигурация",
"backupTitle": "Бэкап и восстановление",
"exportDatabase": "Экспорт базы данных",
@@ -219,25 +168,14 @@
"importDatabasePgDesc": "Нажмите, чтобы выбрать и загрузить резервную копию PostgreSQL (.dump), базу данных SQLite (.db) или миграционный дамп SQLite для восстановления базы данных. Это заменит все текущие данные.",
"migrationDownload": "Скачать файл миграции",
"migrationDownloadPgDesc": "Нажмите, чтобы скачать базу данных SQLite (.db), собранную из ваших данных PostgreSQL и готовую для запуска панели на SQLite.",
"avg": "среднее",
"peak": "пик",
"free": "свободно",
"openSockets": "открытых сокетов",
"throughputSub": "Всего по интерфейсу",
"avgWindow": "Среднее за период",
"healthWarm": "{list} — повышенная нагрузка",
"healthCritical": "{list} — критический уровень",
"panel": "Панель",
"threads": "Потоки",
"uptime": "Время работы",
"logLevelDebug": "Отладка",
"logLevelInfo": "Информация",
"logLevelNotice": "Уведомление",
"logLevelWarning": "Предупреждение",
"logLevelError": "Ошибка",
"accessDirect": "НАПРЯМУЮ",
"accessBlocked": "ЗАБЛОКИРОВАНО",
"accessProxy": "ЧЕРЕЗ ПРОКСИ",
"importKeepHostSettings": "Сохранить настройки этой машины",
"importKeepHostSettingsDesc": "Оставляет адреса и порты этой панели, базовый путь, сертификаты и удостоверение для узлов вместо тех, что в загруженном файле."
},
@@ -970,7 +908,19 @@
"pinnedCertPlaceholder": "SHA-256 в base64 или hex",
"fetchPin": "Получить",
"pinFetched": "Текущий сертификат узла получен",
"pinFetchFailed": "Не удалось получить сертификат"
"pinFetchFailed": "Не удалось получить сертификат",
"nodeType": "Node Type",
"nodeTypeTraditional": "Traditional Panel Node",
"nodeTypeAgent": "Agent Lightweight Node",
"agentHint": "Agent nodes connect out to the master; no address, port or API token needed. An install command is generated on creation.",
"agentTokenTitle": "Agent Node Setup",
"agentTokenCreated": "Node {{name}} created successfully",
"agentTokenHint": "Copy the command below and run it on the node machine. This token is shown only once!",
"agentTokenInstructions": "The command downloads the installer, configures the master URL and token. The node comes online in ~30s.",
"agentTokenCopied": "Install command copied",
"agentTokenCopyFailed": "Copy failed, please copy manually",
"copyToken": "Copy Install Command",
"agentMasterUrlHint": "Master URL (used by agent to reach the master; change to public address if behind proxy):"
},
"settings": {
"defaultTag": "По умолчанию",
@@ -1725,4 +1675,4 @@
"noIpRecord": "❗ Нет записей об IP-адресе."
},
"email": {}
}
}
+14 -64
View File
@@ -136,45 +136,6 @@
"currentCommit": "Geçerli commit",
"latestCommit": "Son commit",
"updateChannelChanged": "Güncelleme kanalı değiştirildi",
"systemHistoryTitle": "Sistem Geçmişi",
"historyTitleCpu": "CPU Kullanımı",
"historyTitleMem": "Bellek Kullanımı",
"historyTitleNetwork": "Ağ Bant Genişliği",
"historyTitlePackets": "Ağ Paketleri",
"historyTitleDisk": "Disk G/Ç",
"historyTitleOnline": "Çevrimiçi Kullanıcılar",
"historyTitleLoad": "Sistem Yük Ortalaması (1dk / 5dk / 15dk)",
"historyTitleConnections": "Etkin Bağlantılar (TCP / UDP)",
"historyTitleDiskUsage": "Disk Alanı Kullanımı",
"historyTabBandwidth": "Bant Genişliği",
"historyTabPackets": "Paketler",
"historyTabDisk": "Disk G/Ç",
"historyTabOnline": "Çevrimiçi",
"historyTabLoad": "Yük",
"historyTabConnections": "Bağlantılar",
"historyTabDiskUsage": "Disk Kullanımı",
"xrayMetricsTitle": "Xray Metrikleri",
"xrayTitleHeap": "Ayrılan Yığın Belleği",
"xrayTitleSys": "İşletim Sisteminden Ayrılan Bellek",
"xrayTitleObjects": "Aktif Yığın Nesneleri",
"xrayTitleGcCount": "Tamamlanan GC Döngüleri",
"xrayTitleGcPause": "GC Duraklama Süresi",
"xrayTitleObservatory": "Giden Bağlantı Durumu",
"xrayTabHeap": "Heap",
"xrayTabSys": "Sys",
"xrayTabObjects": "Nesneler",
"xrayTabGcCount": "GC Sayısı",
"xrayTabGcPause": "GC Duraklaması",
"xrayTabObservatory": "Gözlemevi",
"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 Gözlemevi verisi yok",
"xrayObservatoryHint": "Xray yapılandırmasına test edilecek giden bağlantı etiketlerini listeleyen bir Gözlemevi bloğu ekleyin, sonra Xray'i yeniden başlatın.",
"xrayObservatoryTagPlaceholder": "Giden bağlantı seç",
"xrayObservatoryAlive": "Aktif",
"xrayObservatoryDead": "Kapalı",
"xrayObservatoryLastSeen": "Son görülme",
"xrayObservatoryLastTry": "Son deneme",
"connectionCount": "Bağlantı İstatistikleri",
"ipAddresses": "IP Adresleri",
"toggleIpVisibility": "IP görünürlüğünü değiştir",
@@ -191,18 +152,6 @@
"panelUpdateUnknownTitle": "Güncellemenin tamamlandığı doğrulanamadı",
"panelUpdateUnknownDesc": "Panel zamanında bir sonuç bildirmedi. Geçerli sürümü kontrol etmek için sayfayı yenileyin veya sunucu günlüklerini kontrol edin.",
"dontRefresh": "Kurulum devam ediyor, lütfen bu sayfayı yenilemeyin",
"logs": "Günlükler",
"accessLogs": "Erişim Günlükleri",
"autoUpdate": "Otomatik Güncelleme",
"amneziawgLogs": "AmneziaWG Günlükleri",
"amneziawgHandshake": "Son El Sıkışma",
"amneziawgInterface": "Arayüz",
"amneziawgInbound": "Gelen",
"amneziawgEndpoint": "Uç Nokta",
"amneziawgIdle": "Boşta",
"amneziawgEvents": "Olaylar",
"amneziawgNoPeers": "Şu anda etkin AmneziaWG eşi yok",
"amneziawgNoEvents": "Henüz AmneziaWG olayı kaydedilmedi",
"config": "Yapılandırma",
"backupTitle": "Yedekleme ve Geri Yükleme",
"exportDatabase": "Yedekle",
@@ -219,25 +168,14 @@
"importDatabasePgDesc": "Veritabanınızı geri yüklemek için bir PostgreSQL yedeği (.dump), SQLite veritabanı (.db) veya SQLite taşıma dökümü seçip yüklemek üzere tıklayın. Bu, tüm mevcut verilerin yerini alır.",
"migrationDownload": "Geçiş Dosyasını İndir",
"migrationDownloadPgDesc": "PostgreSQL verilerinizden oluşturulan ve bu paneli SQLite üzerinde çalıştırmaya hazır bir .db SQLite veritabanı indirmek için tıklayın.",
"avg": "ort.",
"peak": "tepe",
"free": "boş",
"openSockets": "açık soket",
"throughputSub": "Arayüz toplamı",
"avgWindow": "Dönem ortalaması",
"healthWarm": "{list} — yükseliyor",
"healthCritical": "{list} — kritik",
"panel": "Panel",
"threads": "İş parçacıkları",
"uptime": "Çalışma süresi",
"logLevelDebug": "Hata Ayıklama",
"logLevelInfo": "Bilgi",
"logLevelNotice": "Duyuru",
"logLevelWarning": "Uyarı",
"logLevelError": "Hata",
"accessDirect": "DIRECT",
"accessBlocked": "BLOCKED",
"accessProxy": "PROXY",
"importKeepHostSettings": "Bu makinenin ayarlarını koru",
"importKeepHostSettingsDesc": "Yüklenen dosyadan almak yerine bu panelin dinleme adreslerini, portlarını, temel yolunu, sertifikalarını ve düğüm kimliğini korur."
},
@@ -970,7 +908,19 @@
"pinnedCertPlaceholder": "base64 veya hex SHA-256",
"fetchPin": "Getir",
"pinFetched": "Düğümün geçerli sertifikası alındı",
"pinFetchFailed": "Sertifika alınamadı"
"pinFetchFailed": "Sertifika alınamadı",
"nodeType": "Node Type",
"nodeTypeTraditional": "Traditional Panel Node",
"nodeTypeAgent": "Agent Lightweight Node",
"agentHint": "Agent nodes connect out to the master; no address, port or API token needed. An install command is generated on creation.",
"agentTokenTitle": "Agent Node Setup",
"agentTokenCreated": "Node {{name}} created successfully",
"agentTokenHint": "Copy the command below and run it on the node machine. This token is shown only once!",
"agentTokenInstructions": "The command downloads the installer, configures the master URL and token. The node comes online in ~30s.",
"agentTokenCopied": "Install command copied",
"agentTokenCopyFailed": "Copy failed, please copy manually",
"copyToken": "Copy Install Command",
"agentMasterUrlHint": "Master URL (used by agent to reach the master; change to public address if behind proxy):"
},
"settings": {
"defaultTag": "Varsayılan",
@@ -1725,4 +1675,4 @@
"noIpRecord": "❗ IP Kaydı Yok!"
},
"email": {}
}
}
+14 -64
View File
@@ -136,45 +136,6 @@
"currentCommit": "Поточний коміт",
"latestCommit": "Останній коміт",
"updateChannelChanged": "Канал оновлення змінено",
"systemHistoryTitle": "Історія системи",
"historyTitleCpu": "Завантаження ЦП",
"historyTitleMem": "Використання пам’яті",
"historyTitleNetwork": "Пропускна здатність мережі",
"historyTitlePackets": "Мережеві пакети",
"historyTitleDisk": "Дисковий ввід-вивід",
"historyTitleOnline": "Клієнти онлайн",
"historyTitleLoad": "Середнє навантаження системи (1 / 5 / 15 хв)",
"historyTitleConnections": "Активні з’єднання (TCP / UDP)",
"historyTitleDiskUsage": "Використання дискового простору",
"historyTabBandwidth": "Пропускна здатність",
"historyTabPackets": "Пакети",
"historyTabDisk": "Диск I/O",
"historyTabOnline": "Онлайн",
"historyTabLoad": "Навантаження",
"historyTabConnections": "З’єднання",
"historyTabDiskUsage": "Використання диска",
"xrayMetricsTitle": "Метрики Xray",
"xrayTitleHeap": "Виділена пам’ять купи",
"xrayTitleSys": "Пам’ять, зарезервована в ОС",
"xrayTitleObjects": "Активні об’єкти купи",
"xrayTitleGcCount": "Завершені цикли GC",
"xrayTitleGcPause": "Тривалість паузи GC",
"xrayTitleObservatory": "Стан вихідних з’єднань",
"xrayTabHeap": "Купа",
"xrayTabSys": "Sys",
"xrayTabObjects": "Об’єкти",
"xrayTabGcCount": "Лічильник GC",
"xrayTabGcPause": "Пауза GC",
"xrayTabObservatory": "Обсерваторія",
"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": "Остання спроба",
"connectionCount": "Статистика з'єднання",
"ipAddresses": "IP-адреси",
"toggleIpVisibility": "Перемкнути видимість IP",
@@ -191,18 +152,6 @@
"panelUpdateUnknownTitle": "Не вдалося підтвердити завершення оновлення",
"panelUpdateUnknownDesc": "Панель не повідомила результат вчасно. Перезавантажте сторінку, щоб перевірити поточну версію, або перевірте журнали сервера.",
"dontRefresh": "Інсталяція триває, будь ласка, не оновлюйте цю сторінку",
"logs": "Логи",
"accessLogs": "Логи доступу",
"autoUpdate": "Автооновлення",
"amneziawgLogs": "Логи AmneziaWG",
"amneziawgHandshake": "Останнє рукостискання",
"amneziawgInterface": "Інтерфейс",
"amneziawgInbound": "Вхідне",
"amneziawgEndpoint": "Точка підключення",
"amneziawgIdle": "Очікування",
"amneziawgEvents": "Події",
"amneziawgNoPeers": "Немає активних пірів AmneziaWG",
"amneziawgNoEvents": "Подій AmneziaWG ще не зафіксовано",
"config": "Конфігурація",
"backupTitle": "Резервне копіювання та відновлення",
"exportDatabase": "Резервна копія",
@@ -219,25 +168,14 @@
"importDatabasePgDesc": "Натисніть, щоб вибрати та завантажити резервну копію PostgreSQL (.dump), базу даних SQLite (.db) або міграційний дамп SQLite для відновлення бази даних. Це замінить усі поточні дані.",
"migrationDownload": "Завантажити файл міграції",
"migrationDownloadPgDesc": "Натисніть, щоб завантажити базу даних SQLite (.db), створену з ваших даних PostgreSQL і готову для запуску панелі на SQLite.",
"avg": "середнє",
"peak": "пік",
"free": "вільно",
"openSockets": "відкритих сокетів",
"throughputSub": "Разом за інтерфейсом",
"avgWindow": "Середнє за період",
"healthWarm": "{list} — підвищене навантаження",
"healthCritical": "{list} — критичний рівень",
"panel": "Панель",
"threads": "Потоки",
"uptime": "Час роботи",
"logLevelDebug": "Налагодження",
"logLevelInfo": "Інформація",
"logLevelNotice": "Сповіщення",
"logLevelWarning": "Попередження",
"logLevelError": "Помилка",
"accessDirect": "НАПРЯМУ",
"accessBlocked": "ЗАБЛОКОВАНО",
"accessProxy": "ЧЕРЕЗ ПРОКСІ",
"importKeepHostSettings": "Зберегти налаштування цієї машини",
"importKeepHostSettingsDesc": "Залишає адреси та порти цієї панелі, базовий шлях, сертифікати та посвідчення для вузлів замість тих, що у завантаженому файлі."
},
@@ -970,7 +908,19 @@
"pinnedCertPlaceholder": "SHA-256 у base64 або hex",
"fetchPin": "Отримати",
"pinFetched": "Поточний сертифікат вузла отримано",
"pinFetchFailed": "Не вдалося отримати сертифікат"
"pinFetchFailed": "Не вдалося отримати сертифікат",
"nodeType": "Node Type",
"nodeTypeTraditional": "Traditional Panel Node",
"nodeTypeAgent": "Agent Lightweight Node",
"agentHint": "Agent nodes connect out to the master; no address, port or API token needed. An install command is generated on creation.",
"agentTokenTitle": "Agent Node Setup",
"agentTokenCreated": "Node {{name}} created successfully",
"agentTokenHint": "Copy the command below and run it on the node machine. This token is shown only once!",
"agentTokenInstructions": "The command downloads the installer, configures the master URL and token. The node comes online in ~30s.",
"agentTokenCopied": "Install command copied",
"agentTokenCopyFailed": "Copy failed, please copy manually",
"copyToken": "Copy Install Command",
"agentMasterUrlHint": "Master URL (used by agent to reach the master; change to public address if behind proxy):"
},
"settings": {
"defaultTag": "Типово",
@@ -1725,4 +1675,4 @@
"noIpRecord": "❗ Немає запису IP!"
},
"email": {}
}
}
+14 -64
View File
@@ -136,45 +136,6 @@
"currentCommit": "Commit hiện tại",
"latestCommit": "Commit mới nhất",
"updateChannelChanged": "Đã đổi kênh cập nhật",
"systemHistoryTitle": "Lịch sử hệ thống",
"historyTitleCpu": "Mức sử dụng CPU",
"historyTitleMem": "Mức sử dụng bộ nhớ",
"historyTitleNetwork": "Băng thông mạng",
"historyTitlePackets": "Gói tin mạng",
"historyTitleDisk": "I/O đĩa",
"historyTitleOnline": "Máy khách trực tuyến",
"historyTitleLoad": "Tải trung bình hệ thống (1 / 5 / 15 phút)",
"historyTitleConnections": "Kết nối đang hoạt động (TCP / UDP)",
"historyTitleDiskUsage": "Sử dụng dung lượng đĩa",
"historyTabBandwidth": "Băng thông",
"historyTabPackets": "Gói tin",
"historyTabDisk": "Đĩa I/O",
"historyTabOnline": "Trực tuyến",
"historyTabLoad": "Tải",
"historyTabConnections": "Kết nối",
"historyTabDiskUsage": "Sử dụng đĩa",
"xrayMetricsTitle": "Chỉ số Xray",
"xrayTitleHeap": "Bộ nhớ Heap đã cấp phát",
"xrayTitleSys": "Bộ nhớ dành riêng từ HĐH",
"xrayTitleObjects": "Đối tượng Heap đang hoạt động",
"xrayTitleGcCount": "Chu kỳ GC đã hoàn thành",
"xrayTitleGcPause": "Thời lượng tạm dừng GC",
"xrayTitleObservatory": "Tình trạng kết nối đi",
"xrayTabHeap": "Heap",
"xrayTabSys": "Sys",
"xrayTabObjects": "Đối tượng",
"xrayTabGcCount": "Số lần GC",
"xrayTabGcPause": "Tạm dừng GC",
"xrayTabObservatory": "Đài quan sát",
"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",
"connectionCount": "Số lượng kết nối",
"ipAddresses": "Địa chỉ IP",
"toggleIpVisibility": "Chuyển đổi hiển thị IP",
@@ -191,18 +152,6 @@
"panelUpdateUnknownTitle": "Không thể xác nhận việc cập nhật đã hoàn tất",
"panelUpdateUnknownDesc": "Panel không báo cáo kết quả kịp thời. Hãy tải lại trang để kiểm tra phiên bản hiện tại, hoặc kiểm tra nhật ký máy chủ.",
"dontRefresh": "Đang tiến hành cài đặt, vui lòng không làm mới trang này.",
"logs": "Nhật ký",
"accessLogs": "Nhật ký truy cập",
"autoUpdate": "Tự động cập nhật",
"amneziawgLogs": "Nhật ký AmneziaWG",
"amneziawgHandshake": "Bắt tay gần nhất",
"amneziawgInterface": "Giao diện mạng",
"amneziawgInbound": "Inbound",
"amneziawgEndpoint": "Điểm cuối",
"amneziawgIdle": "Nhàn rỗi",
"amneziawgEvents": "Sự kiện",
"amneziawgNoPeers": "Hiện không có peer AmneziaWG nào hoạt động",
"amneziawgNoEvents": "Chưa ghi nhận sự kiện AmneziaWG nào",
"config": "Cấu hình",
"backupTitle": "Sao lưu & Khôi phục",
"exportDatabase": "Sao lưu",
@@ -219,25 +168,14 @@
"importDatabasePgDesc": "Nhấn để chọn và tải lên bản sao lưu PostgreSQL (.dump), cơ sở dữ liệu SQLite (.db) hoặc tệp kết xuất di trú SQLite nhằm khôi phục cơ sở dữ liệu của bạn. Thao tác này sẽ thay thế toàn bộ dữ liệu hiện tại.",
"migrationDownload": "Tải tệp di trú",
"migrationDownloadPgDesc": "Nhấp để tải xuống cơ sở dữ liệu SQLite .db được tạo từ dữ liệu PostgreSQL của bạn, sẵn sàng chạy bảng điều khiển này trên SQLite.",
"avg": "TB",
"peak": "đỉnh",
"free": "trống",
"openSockets": "socket đang mở",
"throughputSub": "Tổng theo giao diện mạng",
"avgWindow": "Trung bình trong khoảng",
"healthWarm": "{list} — đang cao",
"healthCritical": "{list} — nguy cấp",
"panel": "Panel",
"threads": "Luồng",
"uptime": "Thời gian chạy",
"logLevelDebug": "Debug",
"logLevelInfo": "Info",
"logLevelNotice": "Notice",
"logLevelWarning": "Warning",
"logLevelError": "Error",
"accessDirect": "DIRECT",
"accessBlocked": "BLOCKED",
"accessProxy": "PROXY",
"importKeepHostSettings": "Giữ cài đặt của máy này",
"importKeepHostSettingsDesc": "Giữ nguyên địa chỉ lắng nghe, cổng, đường dẫn cơ sở, chứng chỉ và danh tính node của bảng điều khiển này thay vì lấy chúng từ tệp đã tải lên."
},
@@ -970,7 +908,19 @@
"pinnedCertPlaceholder": "SHA-256 base64 hoặc hex",
"fetchPin": "Lấy",
"pinFetched": "Đã lấy chứng chỉ hiện tại của node",
"pinFetchFailed": "Không thể lấy chứng chỉ"
"pinFetchFailed": "Không thể lấy chứng chỉ",
"nodeType": "Node Type",
"nodeTypeTraditional": "Traditional Panel Node",
"nodeTypeAgent": "Agent Lightweight Node",
"agentHint": "Agent nodes connect out to the master; no address, port or API token needed. An install command is generated on creation.",
"agentTokenTitle": "Agent Node Setup",
"agentTokenCreated": "Node {{name}} created successfully",
"agentTokenHint": "Copy the command below and run it on the node machine. This token is shown only once!",
"agentTokenInstructions": "The command downloads the installer, configures the master URL and token. The node comes online in ~30s.",
"agentTokenCopied": "Install command copied",
"agentTokenCopyFailed": "Copy failed, please copy manually",
"copyToken": "Copy Install Command",
"agentMasterUrlHint": "Master URL (used by agent to reach the master; change to public address if behind proxy):"
},
"settings": {
"defaultTag": "Mặc định",
@@ -1725,4 +1675,4 @@
"noIpRecord": "❗ Không có bản ghi IP!"
},
"email": {}
}
}
+15 -65
View File
@@ -136,45 +136,6 @@
"currentCommit": "当前提交",
"latestCommit": "最新提交",
"updateChannelChanged": "更新通道已切换",
"systemHistoryTitle": "系统历史",
"historyTitleCpu": "CPU 使用率",
"historyTitleMem": "内存使用率",
"historyTitleNetwork": "网络带宽",
"historyTitlePackets": "网络数据包",
"historyTitleDisk": "磁盘 I/O",
"historyTitleOnline": "在线客户端",
"historyTitleLoad": "系统平均负载(1 分钟 / 5 分钟 / 15 分钟)",
"historyTitleConnections": "活动连接 (TCP / UDP)",
"historyTitleDiskUsage": "磁盘空间使用率",
"historyTabBandwidth": "带宽",
"historyTabPackets": "数据包",
"historyTabDisk": "磁盘 I/O",
"historyTabOnline": "在线",
"historyTabLoad": "负载",
"historyTabConnections": "连接数",
"historyTabDiskUsage": "磁盘使用量",
"xrayMetricsTitle": "Xray 指标",
"xrayTitleHeap": "已分配的堆内存",
"xrayTitleSys": "向操作系统保留的内存",
"xrayTitleObjects": "存活的堆对象",
"xrayTitleGcCount": "已完成的 GC 周期",
"xrayTitleGcPause": "GC 暂停时间",
"xrayTitleObservatory": "出站连接健康状态",
"xrayTabHeap": "堆",
"xrayTabSys": "系统",
"xrayTabObjects": "对象",
"xrayTabGcCount": "GC 次数",
"xrayTabGcPause": "GC 暂停",
"xrayTabObservatory": "观测站",
"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": "最后尝试",
"connectionCount": "连接数",
"ipAddresses": "IP 地址",
"toggleIpVisibility": "切换 IP 可见性",
@@ -191,18 +152,6 @@
"panelUpdateUnknownTitle": "无法确认更新是否已完成",
"panelUpdateUnknownDesc": "面板未能及时返回结果。请刷新页面查看当前版本,或检查服务器日志。",
"dontRefresh": "安装中,请勿刷新此页面",
"logs": "日志",
"accessLogs": "访问日志",
"autoUpdate": "自动更新",
"amneziawgLogs": "AmneziaWG 日志",
"amneziawgHandshake": "最后握手",
"amneziawgInterface": "网络接口",
"amneziawgInbound": "入站",
"amneziawgEndpoint": "端点",
"amneziawgIdle": "空闲",
"amneziawgEvents": "事件",
"amneziawgNoPeers": "当前没有活动的 AmneziaWG 对端",
"amneziawgNoEvents": "尚未记录 AmneziaWG 事件",
"config": "配置",
"backupTitle": "备份和恢复",
"exportDatabase": "备份",
@@ -219,25 +168,14 @@
"importDatabasePgDesc": "点击选择并上传 PostgreSQL 备份(.dump)、SQLite 数据库(.db)或 SQLite 迁移导出文件以恢复您的数据库。此操作将替换所有当前数据。",
"migrationDownload": "下载迁移文件",
"migrationDownloadPgDesc": "点击下载由 PostgreSQL 数据构建的 .db SQLite 数据库,可用于在 SQLite 上运行本面板。",
"avg": "均值",
"peak": "峰值",
"free": "剩余",
"openSockets": "打开的套接字",
"throughputSub": "网卡总计",
"avgWindow": "窗口均值",
"healthWarm": "{list} — 偏高",
"healthCritical": "{list} — 危险",
"panel": "面板",
"threads": "线程",
"uptime": "运行时间",
"logLevelDebug": "Debug",
"logLevelInfo": "Info",
"logLevelNotice": "Notice",
"logLevelWarning": "Warning",
"logLevelError": "Error",
"accessDirect": "DIRECT",
"accessBlocked": "BLOCKED",
"accessProxy": "PROXY",
"importKeepHostSettings": "保留本机设置",
"importKeepHostSettingsDesc": "保留本面板的监听地址、端口、基础路径、证书和节点身份,而不是使用上传文件中的值。"
},
@@ -970,7 +908,19 @@
"pinnedCertPlaceholder": "base64 或 hex 的 SHA-256",
"fetchPin": "获取",
"pinFetched": "已获取节点当前证书",
"pinFetchFailed": "无法获取证书"
"pinFetchFailed": "无法获取证书",
"nodeType": "节点类型",
"nodeTypeTraditional": "传统面板节点",
"nodeTypeAgent": "Agent 轻量节点",
"agentHint": "Agent 节点由节点机主动连接主控,无需填写地址、端口和 API 令牌。创建后会生成安装命令。",
"agentTokenTitle": "Agent 节点安装",
"agentTokenCreated": "节点 {{name}} 创建成功",
"agentTokenHint": "复制下面的命令,在节点机上运行即可完成安装。此 token 只显示一次!",
"agentTokenInstructions": "命令会自动下载安装脚本、配置主控地址和 token,约 30 秒后节点上线。",
"agentTokenCopied": "安装命令已复制",
"agentTokenCopyFailed": "复制失败,请手动复制",
"copyToken": "复制安装命令",
"agentMasterUrlHint": "主控地址(agent 用来连接主控,如有域名或反代请改成对外地址):"
},
"settings": {
"defaultTag": "默认",
@@ -1070,7 +1020,7 @@
"subListen": "监听 IP",
"subListenDesc": "订阅服务监听的 IP 地址(留空表示监听所有 IP)",
"subPort": "监听端口",
"subPortDesc": "订阅服务监听的端口号(必须是未使用的端口)。当下方「反向代理 URI」为空时,也会用来生成面板中显示的订阅链接/二维码——如果订阅是通过反向代理的其他端口访问的,请改为设置「反向代理 URI」。",
"subPortDesc": "订阅服务监听的端口号(必须是未使用的端口)。当下方「反向代理 URI」为空时,也会用来生成面板中显示的订阅链接/二维码——如果订阅是通过反向代理的其他端口访问的,请改为设置「反向代理 URI」。留空表示订阅链接不带端口(服务仍监听默认端口 2096)。",
"subCertPath": "公钥路径",
"subCertPathDesc": "订阅服务使用的公钥文件路径(以 '/' 开头)",
"subKeyPath": "私钥路径",
@@ -1725,4 +1675,4 @@
"noIpRecord": "❗ 没有 IP 记录!"
},
"email": {}
}
}
+14 -64
View File
@@ -136,45 +136,6 @@
"currentCommit": "目前提交",
"latestCommit": "最新提交",
"updateChannelChanged": "更新通道已切換",
"systemHistoryTitle": "系統歷史",
"historyTitleCpu": "CPU 使用率",
"historyTitleMem": "記憶體使用率",
"historyTitleNetwork": "網路頻寬",
"historyTitlePackets": "網路封包",
"historyTitleDisk": "磁碟 I/O",
"historyTitleOnline": "線上用戶端",
"historyTitleLoad": "系統平均負載(1 分鐘 / 5 分鐘 / 15 分鐘)",
"historyTitleConnections": "使用中的連線 (TCP / UDP)",
"historyTitleDiskUsage": "磁碟空間使用率",
"historyTabBandwidth": "頻寬",
"historyTabPackets": "封包",
"historyTabDisk": "磁碟 I/O",
"historyTabOnline": "線上",
"historyTabLoad": "負載",
"historyTabConnections": "連線數",
"historyTabDiskUsage": "磁碟使用量",
"xrayMetricsTitle": "Xray 指標",
"xrayTitleHeap": "已配置的堆積記憶體",
"xrayTitleSys": "向作業系統保留的記憶體",
"xrayTitleObjects": "存活的堆積物件",
"xrayTitleGcCount": "已完成的 GC 週期",
"xrayTitleGcPause": "GC 暫停時間",
"xrayTitleObservatory": "出站連線健康狀態",
"xrayTabHeap": "堆積",
"xrayTabSys": "系統",
"xrayTabObjects": "物件",
"xrayTabGcCount": "GC 次數",
"xrayTabGcPause": "GC 暫停",
"xrayTabObservatory": "觀測站",
"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": "最後嘗試",
"connectionCount": "連線數",
"ipAddresses": "IP 地址",
"toggleIpVisibility": "切換 IP 可見性",
@@ -191,18 +152,6 @@
"panelUpdateUnknownTitle": "無法確認更新是否已完成",
"panelUpdateUnknownDesc": "面板未能及時回報結果。請重新整理以檢查目前版本,或檢查伺服器日誌。",
"dontRefresh": "安裝中,請勿重新整理此頁面",
"logs": "記錄",
"accessLogs": "存取記錄",
"autoUpdate": "自動更新",
"amneziawgLogs": "AmneziaWG 日誌",
"amneziawgHandshake": "最後握手",
"amneziawgInterface": "網路介面",
"amneziawgInbound": "入站",
"amneziawgEndpoint": "端點",
"amneziawgIdle": "閒置",
"amneziawgEvents": "事件",
"amneziawgNoPeers": "目前沒有活躍的 AmneziaWG 對端",
"amneziawgNoEvents": "尚未記錄 AmneziaWG 事件",
"config": "配置",
"backupTitle": "備份和恢復",
"exportDatabase": "備份",
@@ -219,25 +168,14 @@
"importDatabasePgDesc": "點擊選擇並上傳 PostgreSQL 備份(.dump)、SQLite 資料庫(.db)或 SQLite 遷移匯出檔以還原您的資料庫。此操作將取代所有目前的資料。",
"migrationDownload": "下載遷移檔案",
"migrationDownloadPgDesc": "點擊下載由 PostgreSQL 資料建立的 .db SQLite 資料庫,可用於在 SQLite 上執行本面板。",
"avg": "均值",
"peak": "峰值",
"free": "剩餘",
"openSockets": "開啟的通訊端",
"throughputSub": "網路介面總計",
"avgWindow": "視窗均值",
"healthWarm": "{list} — 偏高",
"healthCritical": "{list} — 危險",
"panel": "面板",
"threads": "執行緒",
"uptime": "執行時間",
"logLevelDebug": "Debug",
"logLevelInfo": "Info",
"logLevelNotice": "Notice",
"logLevelWarning": "Warning",
"logLevelError": "Error",
"accessDirect": "DIRECT",
"accessBlocked": "BLOCKED",
"accessProxy": "PROXY",
"importKeepHostSettings": "保留本機設定",
"importKeepHostSettingsDesc": "保留本面板的監聽位址、連接埠、基礎路徑、憑證與節點身分,而不是使用上傳檔案中的值。"
},
@@ -970,7 +908,19 @@
"pinnedCertPlaceholder": "base64 或 hex 的 SHA-256",
"fetchPin": "取得",
"pinFetched": "已取得節點目前憑證",
"pinFetchFailed": "無法取得憑證"
"pinFetchFailed": "無法取得憑證",
"nodeType": "Node Type",
"nodeTypeTraditional": "Traditional Panel Node",
"nodeTypeAgent": "Agent Lightweight Node",
"agentHint": "Agent nodes connect out to the master; no address, port or API token needed. An install command is generated on creation.",
"agentTokenTitle": "Agent Node Setup",
"agentTokenCreated": "Node {{name}} created successfully",
"agentTokenHint": "Copy the command below and run it on the node machine. This token is shown only once!",
"agentTokenInstructions": "The command downloads the installer, configures the master URL and token. The node comes online in ~30s.",
"agentTokenCopied": "Install command copied",
"agentTokenCopyFailed": "Copy failed, please copy manually",
"copyToken": "Copy Install Command",
"agentMasterUrlHint": "Master URL (used by agent to reach the master; change to public address if behind proxy):"
},
"settings": {
"defaultTag": "預設",
@@ -1725,4 +1675,4 @@
"noIpRecord": "❗ 沒有 IP 記錄!"
},
"email": {}
}
}
-4
View File
@@ -491,7 +491,6 @@ func (s *Server) start(restartXray bool) (err error) {
// Create event bus before startTask so jobs can use it
s.bus = eventbus.New(eventbus.DefaultBufferSize)
service.SetEventBus(s.bus)
job.EventBus = s.bus
// Wire xray crash callback BEFORE startTask so it's ready
@@ -531,9 +530,6 @@ func (s *Server) stop(stopXray bool) error {
if s.bus != nil {
s.bus.Stop()
}
if err := service.PersistSystemMetrics(); err != nil {
logger.Warning("persist system metrics on shutdown failed:", err)
}
if stopXray {
service.StopTrafficWriter()
}