feat(probe): public contract API + theme hosting + meta editing (item 7 B3)
This commit is contained in:
@@ -767,6 +767,13 @@ func (a *AgentController) listTokens(c *gin.Context) {
|
||||
|
||||
func (a *AgentController) deleteToken(c *gin.Context) {
|
||||
_ = database.GetDB().Delete(&model.AgentNode{}, "id = ?", c.Param("id")).Error
|
||||
// Item 7 B3: the agent's probe data dies with its row (hub
|
||||
// delete_node cascades the same set, metrics and traffic
|
||||
// included) and the shared snapshot drops it at once.
|
||||
if id, err := strconv.Atoi(c.Param("id")); err == nil {
|
||||
probe.DeleteAgentData(int64(id))
|
||||
service.InvalidateProbeSnapshot()
|
||||
}
|
||||
jsonObj(c, gin.H{"message": "deleted"}, nil)
|
||||
}
|
||||
|
||||
|
||||
@@ -195,6 +195,9 @@ func (a *APIController) initRouter(g *gin.RouterGroup) {
|
||||
// never reach these.
|
||||
agentAdmin := api.Group("/agent")
|
||||
a.agentController.RegisterAdminRoutes(agentAdmin)
|
||||
// Item 7 B3: probe metadata editing rides the same admin group
|
||||
// (/panel/api/agent/probe-meta/:nodeId).
|
||||
NewProbeMetaController(agentAdmin)
|
||||
|
||||
// Hosts API — per-inbound override endpoints for subscription links
|
||||
hosts := api.Group("/hosts")
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"net"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service/probe"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// Item 7 B3: probe metadata editing (minimal set). The fields are the
|
||||
// operator-owned display/plan data of probe_meta (ITEM7-SCOPE §5.2),
|
||||
// edited from the node page's agent edit dialog and saved through the
|
||||
// panel-authenticated agent admin group — effective immediately (the
|
||||
// snapshot cache is invalidated; no restart anywhere). Validation
|
||||
// mirrors the hub's patch_error chain field for field.
|
||||
|
||||
type ProbeMetaController struct{}
|
||||
|
||||
func NewProbeMetaController(g *gin.RouterGroup) *ProbeMetaController {
|
||||
pc := &ProbeMetaController{}
|
||||
g.GET("/probe-meta/:nodeId", pc.getMeta)
|
||||
g.PUT("/probe-meta/:nodeId", pc.putMeta)
|
||||
return pc
|
||||
}
|
||||
|
||||
// probeMetaForm is the wire shape of the edit form: the full meta row,
|
||||
// replaced wholesale on save.
|
||||
type probeMetaForm struct {
|
||||
Price float64 `json:"price"`
|
||||
Currency string `json:"currency"`
|
||||
BillingCycle string `json:"billingCycle"`
|
||||
ExpiresAt *string `json:"expiresAt"`
|
||||
TrafficLimit int64 `json:"trafficLimit"`
|
||||
TrafficMode string `json:"trafficMode"`
|
||||
TrafficResetDay int64 `json:"trafficResetDay"`
|
||||
Public bool `json:"public"`
|
||||
Sort int64 `json:"sort"`
|
||||
Group string `json:"group"`
|
||||
PublicRemark string `json:"publicRemark"`
|
||||
Notify bool `json:"notify"`
|
||||
CountryPin string `json:"countryPin"`
|
||||
IPv4Pin string `json:"ipv4Pin"`
|
||||
IPv6Pin string `json:"ipv6Pin"`
|
||||
}
|
||||
|
||||
func metaToForm(m probe.Meta) probeMetaForm {
|
||||
return probeMetaForm{
|
||||
Price: m.Price,
|
||||
Currency: m.Currency,
|
||||
BillingCycle: m.BillingCycle,
|
||||
ExpiresAt: m.ExpiresAt,
|
||||
TrafficLimit: m.TrafficLimit,
|
||||
TrafficMode: m.TrafficMode,
|
||||
TrafficResetDay: m.TrafficResetDay,
|
||||
Public: m.Public,
|
||||
Sort: m.Sort,
|
||||
Group: m.GroupName,
|
||||
PublicRemark: m.PublicRemark,
|
||||
Notify: m.Notify,
|
||||
CountryPin: m.CountryPin,
|
||||
IPv4Pin: m.IPv4Pin,
|
||||
IPv6Pin: m.IPv6Pin,
|
||||
}
|
||||
}
|
||||
|
||||
func (f probeMetaForm) toMeta(agentID int64) probe.Meta {
|
||||
return probe.Meta{
|
||||
AgentID: agentID,
|
||||
Price: f.Price,
|
||||
Currency: f.Currency,
|
||||
BillingCycle: f.BillingCycle,
|
||||
ExpiresAt: f.ExpiresAt,
|
||||
TrafficLimit: f.TrafficLimit,
|
||||
TrafficMode: f.TrafficMode,
|
||||
TrafficResetDay: f.TrafficResetDay,
|
||||
Public: f.Public,
|
||||
Sort: f.Sort,
|
||||
GroupName: f.Group,
|
||||
PublicRemark: f.PublicRemark,
|
||||
Notify: f.Notify,
|
||||
CountryPin: f.CountryPin,
|
||||
IPv4Pin: f.IPv4Pin,
|
||||
IPv6Pin: f.IPv6Pin,
|
||||
}
|
||||
}
|
||||
|
||||
// agentForNode resolves the agent bound to a panel node row.
|
||||
func agentForNode(c *gin.Context) (model.AgentNode, bool) {
|
||||
nodeID, err := strconv.Atoi(c.Param("nodeId"))
|
||||
if err != nil {
|
||||
return model.AgentNode{}, false
|
||||
}
|
||||
var agent model.AgentNode
|
||||
if err := database.GetDB().Where("node_id = ?", nodeID).First(&agent).Error; err != nil {
|
||||
return model.AgentNode{}, false
|
||||
}
|
||||
return agent, true
|
||||
}
|
||||
|
||||
func (pc *ProbeMetaController) getMeta(c *gin.Context) {
|
||||
agent, ok := agentForNode(c)
|
||||
if !ok {
|
||||
c.JSON(404, gin.H{"message": "agent not found"})
|
||||
return
|
||||
}
|
||||
meta, found, err := probe.MetaFor(int64(agent.Id))
|
||||
if err != nil {
|
||||
jsonMsg(c, "load probe meta failed", err)
|
||||
return
|
||||
}
|
||||
if !found {
|
||||
meta = probe.DefaultMeta(int64(agent.Id))
|
||||
}
|
||||
jsonObj(c, metaToForm(meta), nil)
|
||||
}
|
||||
|
||||
func (pc *ProbeMetaController) putMeta(c *gin.Context) {
|
||||
agent, ok := agentForNode(c)
|
||||
if !ok {
|
||||
c.JSON(404, gin.H{"message": "agent not found"})
|
||||
return
|
||||
}
|
||||
var form probeMetaForm
|
||||
if err := c.ShouldBindJSON(&form); err != nil {
|
||||
jsonMsg(c, "Invalid request", err)
|
||||
return
|
||||
}
|
||||
if msg := validateProbeMetaForm(&form); msg != "" {
|
||||
jsonMsg(c, msg, errRequest)
|
||||
return
|
||||
}
|
||||
if err := probe.SaveMeta(form.toMeta(int64(agent.Id))); err != nil {
|
||||
jsonMsg(c, "save probe meta failed", err)
|
||||
return
|
||||
}
|
||||
service.InvalidateProbeSnapshot()
|
||||
jsonObj(c, gin.H{"message": "ok"}, nil)
|
||||
}
|
||||
|
||||
// ---- hub patch_error ports ----
|
||||
|
||||
func hasControl(s string) bool {
|
||||
for _, r := range s {
|
||||
if unicode.IsControl(r) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
var namedCycles = map[string]int64{
|
||||
"monthly": 1, "quarterly": 3, "semiannual": 6,
|
||||
"yearly": 12, "biennial": 24, "triennial": 36,
|
||||
}
|
||||
|
||||
func cycleMonths(cycle string) (int64, bool) {
|
||||
if m, ok := namedCycles[cycle]; ok {
|
||||
return m, true
|
||||
}
|
||||
if strings.HasSuffix(cycle, "m") {
|
||||
if n, err := strconv.ParseInt(strings.TrimSuffix(cycle, "m"), 10, 64); err == nil && n >= 1 && n <= 1200 {
|
||||
return n, true
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func cycleName(months int64) string {
|
||||
for name, m := range namedCycles {
|
||||
if m == months {
|
||||
return name
|
||||
}
|
||||
}
|
||||
return fmt.Sprintf("%dm", months)
|
||||
}
|
||||
|
||||
// validateProbeMetaForm normalizes the form in place (hub patch_error
|
||||
// order: group, public remark, limits, billing, pins) and returns the
|
||||
// first refusal's message, "" when the form may be stored.
|
||||
func validateProbeMetaForm(f *probeMetaForm) string {
|
||||
f.Group = strings.TrimSpace(f.Group)
|
||||
if len([]rune(f.Group)) > 13 || hasControl(f.Group) {
|
||||
return "分组名最多 13 个字,不能含控制字符"
|
||||
}
|
||||
f.PublicRemark = strings.TrimSpace(f.PublicRemark)
|
||||
if len([]rune(f.PublicRemark)) > 100 || hasControl(f.PublicRemark) {
|
||||
return "公开备注最多 100 个字,不能含控制字符"
|
||||
}
|
||||
if f.TrafficResetDay < 1 || f.TrafficResetDay > 31 {
|
||||
return "流量重置日要在 1 到 31 之间"
|
||||
}
|
||||
if math.IsNaN(f.Price) || math.IsInf(f.Price, 0) || f.Price < 0 || f.TrafficLimit < 0 {
|
||||
return "价格和流量上限不能是负数"
|
||||
}
|
||||
f.Currency = strings.ToUpper(strings.TrimSpace(f.Currency))
|
||||
if len(f.Currency) != 3 || !isUpperASCII(f.Currency) {
|
||||
return "货币要填三个字母的代码,如 USD、CNY、HKD"
|
||||
}
|
||||
if f.BillingCycle != "once" {
|
||||
months, ok := cycleMonths(f.BillingCycle)
|
||||
if !ok {
|
||||
return "付款周期要是整月,在 1 个月到 100 年之间"
|
||||
}
|
||||
f.BillingCycle = cycleName(months)
|
||||
}
|
||||
f.CountryPin = strings.ToUpper(strings.TrimSpace(f.CountryPin))
|
||||
if f.CountryPin != "" && (len(f.CountryPin) != 2 || !isUpperASCII(f.CountryPin)) {
|
||||
return "国家要填两个字母的代码,留空则自动识别"
|
||||
}
|
||||
for _, pin := range []struct {
|
||||
val *string
|
||||
v6 bool
|
||||
}{
|
||||
{&f.IPv4Pin, false},
|
||||
{&f.IPv6Pin, true},
|
||||
} {
|
||||
typed := strings.TrimSpace(*pin.val)
|
||||
if typed == "" {
|
||||
*pin.val = ""
|
||||
continue
|
||||
}
|
||||
ip := net.ParseIP(typed)
|
||||
if ip == nil || (ip.To4() == nil) != pin.v6 {
|
||||
if pin.v6 {
|
||||
return "IPv6 要填一个 IPv6 地址,或者留空"
|
||||
}
|
||||
return "IPv4 要填一个 IPv4 地址,或者留空"
|
||||
}
|
||||
*pin.val = ip.String()
|
||||
}
|
||||
if f.ExpiresAt != nil {
|
||||
v := strings.TrimSpace(*f.ExpiresAt)
|
||||
if v == "" {
|
||||
f.ExpiresAt = nil
|
||||
} else if _, err := time.ParseInLocation("2006-01-02", v, time.Local); err != nil {
|
||||
return "到期日期格式应为 YYYY-MM-DD"
|
||||
} else {
|
||||
f.ExpiresAt = &v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func isUpperASCII(s string) bool {
|
||||
for i := 0; i < len(s); i++ {
|
||||
if s[i] < 'A' || s[i] > 'Z' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service/probe"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/session"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
ws "github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
// Item 7 B3: the public status surface — the monitor hub's contract
|
||||
// API, hosted by the panel process itself at the engine ROOT (outside
|
||||
// the panel's secret basePath, or themes' root-relative /api calls
|
||||
// could never reach it; ITEM7-SCOPE §5.5). Field shapes follow hub
|
||||
// src/api.rs exactly: the five endpoints' payloads are the bare
|
||||
// contract JSON, NOT the panel's Msg envelope.
|
||||
//
|
||||
// Audience discipline mirrors the hub: a request is "authed" (admin
|
||||
// rendering) when it carries a live panel session; anonymous callers
|
||||
// see only public=1 nodes and only while the public page is on, and
|
||||
// no rendering ever carries ip/hostname/remark/token material.
|
||||
|
||||
// historySlots bounds concurrent history builds (hub HISTORY_GATE=4):
|
||||
// refused, not queued, once full.
|
||||
var historySlots = make(chan struct{}, 4)
|
||||
|
||||
// publicStreams counts open anonymous /api/ws streams (hub
|
||||
// PUBLIC_STREAMS=1000); admin streams are not counted.
|
||||
var publicStreams atomic.Int64
|
||||
|
||||
const maxThemeConfigBytes = 64 * 1024
|
||||
|
||||
type PublicProbeController struct {
|
||||
upgrader ws.Upgrader
|
||||
}
|
||||
|
||||
// NewPublicProbeController registers the contract routes on the
|
||||
// engine root and returns the controller (web.go also calls its
|
||||
// HandleFallback from the engine's NoRoute).
|
||||
func NewPublicProbeController(engine *gin.Engine) *PublicProbeController {
|
||||
pc := &PublicProbeController{
|
||||
upgrader: ws.Upgrader{
|
||||
ReadBufferSize: 4096,
|
||||
WriteBufferSize: 4096,
|
||||
// Session trust for the stream is decided per tick from
|
||||
// the panel session, not from Origin; the hub accepts
|
||||
// any origin for the anonymous audience by design.
|
||||
CheckOrigin: func(r *http.Request) bool { return true },
|
||||
},
|
||||
}
|
||||
engine.GET("/api/me", pc.me)
|
||||
engine.GET("/api/nodes", pc.nodes)
|
||||
engine.GET("/api/nodes/:id/metrics", pc.nodeMetrics)
|
||||
engine.GET("/api/ws", pc.liveWS)
|
||||
engine.GET("/api/themes/:short/config", pc.themeConfig)
|
||||
engine.PUT("/api/themes/:short/config", pc.saveThemeConfig)
|
||||
return pc
|
||||
}
|
||||
|
||||
func probeAuthed(c *gin.Context) bool {
|
||||
return session.IsLogin(c)
|
||||
}
|
||||
|
||||
// fromOtherOrigin mirrors hub from_other_origin: a browser request
|
||||
// whose Sec-Fetch-Site is present and not "same-origin" did not come
|
||||
// from this panel's own pages (a sibling subdomain is same-site but
|
||||
// not same-origin, and a WebSocket is not held back by CORS).
|
||||
func fromOtherOrigin(c *gin.Context) bool {
|
||||
site := c.GetHeader("Sec-Fetch-Site")
|
||||
return site != "" && site != "same-origin"
|
||||
}
|
||||
|
||||
func answerText(c *gin.Context, status int, text string) {
|
||||
c.String(status, "%s", text)
|
||||
}
|
||||
|
||||
func (pc *PublicProbeController) me(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, struct {
|
||||
Authed bool `json:"authed"`
|
||||
GitHub bool `json:"github"`
|
||||
SiteName string `json:"site_name"`
|
||||
PublicPage bool `json:"public_page"`
|
||||
HistoryDays int64 `json:"history_days"`
|
||||
Site string `json:"site"`
|
||||
}{
|
||||
Authed: probeAuthed(c),
|
||||
GitHub: service.ProbeGitHubConfigured(),
|
||||
SiteName: service.ProbeSiteName(),
|
||||
PublicPage: service.ProbePublicPageEnabled(),
|
||||
HistoryDays: probe.RetentionDays(),
|
||||
Site: service.ProbeSiteURL(),
|
||||
})
|
||||
}
|
||||
|
||||
func (pc *PublicProbeController) nodes(c *gin.Context) {
|
||||
full := probeAuthed(c)
|
||||
if !full && !service.ProbePublicPageEnabled() {
|
||||
answerText(c, http.StatusUnauthorized, "需要登录后查看")
|
||||
return
|
||||
}
|
||||
if full {
|
||||
// The admin frame carries every node's addresses and private
|
||||
// notes; keep it out of every cache (hub discipline).
|
||||
c.Header("Cache-Control", "no-store, no-transform")
|
||||
}
|
||||
// The engine's gzip middleware compresses for clients that accept
|
||||
// it; the body is the shared snapshot frame either way.
|
||||
c.Data(http.StatusOK, "application/json", service.ProbeSnapshotJSON(full))
|
||||
}
|
||||
|
||||
type metricsResponse struct {
|
||||
Metrics []probe.MetricRow `json:"metrics"`
|
||||
Ping []any `json:"ping"`
|
||||
Probes map[string]string `json:"probes"`
|
||||
Loss map[string]float64 `json:"loss"`
|
||||
Step int64 `json:"step"`
|
||||
}
|
||||
|
||||
func (pc *PublicProbeController) nodeMetrics(c *gin.Context) {
|
||||
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
answerText(c, http.StatusBadRequest, "请求格式不对")
|
||||
return
|
||||
}
|
||||
full := probeAuthed(c)
|
||||
if !service.ProbeNodeReadable(full, id) {
|
||||
answerText(c, http.StatusUnauthorized, "需要登录后查看")
|
||||
return
|
||||
}
|
||||
select {
|
||||
case historySlots <- struct{}{}:
|
||||
defer func() { <-historySlots }()
|
||||
default:
|
||||
answerText(c, http.StatusServiceUnavailable, "查询历史的请求太多,稍后再试")
|
||||
return
|
||||
}
|
||||
|
||||
hours := int64(6)
|
||||
if raw := c.Query("hours"); raw != "" {
|
||||
h, perr := strconv.ParseInt(raw, 10, 64)
|
||||
if perr != nil {
|
||||
answerText(c, http.StatusBadRequest, "请求格式不对")
|
||||
return
|
||||
}
|
||||
hours = h
|
||||
}
|
||||
if max := probe.RetentionDays() * 24; hours < 1 {
|
||||
hours = 1
|
||||
} else if hours > max {
|
||||
hours = max
|
||||
}
|
||||
var points *int64
|
||||
if raw := c.Query("points"); raw != "" {
|
||||
p, perr := strconv.ParseInt(raw, 10, 64)
|
||||
if perr != nil {
|
||||
answerText(c, http.StatusBadRequest, "请求格式不对")
|
||||
return
|
||||
}
|
||||
points = &p
|
||||
}
|
||||
series := c.Query("series")
|
||||
wantMetrics := series == "" || series == "metrics"
|
||||
wantPing := series == "" || series == "ping"
|
||||
_ = wantPing // ping rows are written by B4; until then the series is empty by construction
|
||||
|
||||
span := probe.ComputeSpan(hours, points, time.Now().Unix())
|
||||
resp := metricsResponse{
|
||||
Metrics: []probe.MetricRow{},
|
||||
Ping: []any{},
|
||||
Probes: map[string]string{},
|
||||
Loss: map[string]float64{},
|
||||
Step: span.Step,
|
||||
}
|
||||
if wantMetrics {
|
||||
rows, err := probe.MetricHistory(id, span)
|
||||
if err != nil {
|
||||
logger.Warningf("probe public: metrics %d: %v", id, err)
|
||||
answerText(c, http.StatusInternalServerError, "服务内部出错")
|
||||
return
|
||||
}
|
||||
resp.Metrics = rows
|
||||
}
|
||||
c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// liveWS is the hub's browser stream: a snapshot frame every 2 s,
|
||||
// text by default, binary gzip frames for the anonymous audience when
|
||||
// opened with ?gzip. Audience eligibility is re-checked every tick —
|
||||
// a socket outlives both the session and the public-page switch.
|
||||
func (pc *PublicProbeController) liveWS(c *gin.Context) {
|
||||
authed := probeAuthed(c) && !fromOtherOrigin(c)
|
||||
if !authed && !service.ProbePublicPageEnabled() {
|
||||
answerText(c, http.StatusUnauthorized, "需要登录后查看")
|
||||
return
|
||||
}
|
||||
counted := !authed
|
||||
if counted {
|
||||
if publicStreams.Add(1) > 1000 {
|
||||
publicStreams.Add(-1)
|
||||
answerText(c, http.StatusServiceUnavailable, "同时在线的访客太多,请稍后再试")
|
||||
return
|
||||
}
|
||||
defer publicStreams.Add(-1)
|
||||
}
|
||||
_, gzipWanted := c.GetQuery("gzip")
|
||||
|
||||
conn, err := pc.upgrader.Upgrade(c.Writer, c.Request, nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
// The browser sends nothing but the close handshake; cap frames
|
||||
// at the hub's 64 KiB anyway so a rogue peer cannot make the read
|
||||
// pump buffer without bound.
|
||||
conn.SetReadLimit(64 * 1024)
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
for {
|
||||
if _, _, err := conn.ReadMessage(); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
send := func() bool {
|
||||
// Audience re-check (hub stream_audience): the admin frame is
|
||||
// sent only while the session that opened it is still live;
|
||||
// the public frame only while the page stays open. The
|
||||
// stream closes rather than downgrading.
|
||||
if authed {
|
||||
if !probeAuthed(c) {
|
||||
return false
|
||||
}
|
||||
} else if !service.ProbePublicPageEnabled() {
|
||||
return false
|
||||
}
|
||||
var frameType int
|
||||
var frame []byte
|
||||
if gzipWanted && !authed {
|
||||
frameType = ws.BinaryMessage
|
||||
frame = service.ProbeSnapshotGzip()
|
||||
} else if authed {
|
||||
frameType = ws.TextMessage
|
||||
frame = service.ProbeSnapshotJSON(true)
|
||||
} else {
|
||||
frameType = ws.TextMessage
|
||||
frame = service.ProbeSnapshotJSON(false)
|
||||
}
|
||||
_ = conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
|
||||
return conn.WriteMessage(frameType, frame) == nil
|
||||
}
|
||||
|
||||
// The first frame goes out immediately (hub's interval ticks at
|
||||
// once), then one per 2 s tick.
|
||||
if !send() {
|
||||
return
|
||||
}
|
||||
ticker := time.NewTicker(2 * time.Second)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-done:
|
||||
return
|
||||
case <-ticker.C:
|
||||
if !send() {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (pc *PublicProbeController) themeConfig(c *gin.Context) {
|
||||
if !probeAuthed(c) && !service.ProbePublicPageEnabled() {
|
||||
answerText(c, http.StatusUnauthorized, "需要登录后查看")
|
||||
return
|
||||
}
|
||||
short := c.Param("short")
|
||||
if !service.ValidThemeShort(short) {
|
||||
answerText(c, http.StatusNotFound, "请求的内容不存在")
|
||||
return
|
||||
}
|
||||
raw, err := service.ProbeThemeConfigValue(short)
|
||||
if err != nil {
|
||||
logger.Warningf("probe public: theme config %s: %v", short, err)
|
||||
answerText(c, http.StatusInternalServerError, "服务内部出错")
|
||||
return
|
||||
}
|
||||
c.Data(http.StatusOK, "application/json", []byte(raw))
|
||||
}
|
||||
|
||||
func (pc *PublicProbeController) saveThemeConfig(c *gin.Context) {
|
||||
if !probeAuthed(c) {
|
||||
answerText(c, http.StatusUnauthorized, "登录已失效,请重新登录")
|
||||
return
|
||||
}
|
||||
if fromOtherOrigin(c) {
|
||||
answerText(c, http.StatusForbidden, "请求来自其他网站,已拒绝")
|
||||
return
|
||||
}
|
||||
short := c.Param("short")
|
||||
if !service.ValidThemeShort(short) || !service.ProbeThemeInstalled(short) {
|
||||
answerText(c, http.StatusBadRequest, "主题没有安装")
|
||||
return
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(c.Request.Body, maxThemeConfigBytes+1))
|
||||
if err != nil {
|
||||
answerText(c, http.StatusBadRequest, "请求格式不对")
|
||||
return
|
||||
}
|
||||
if len(body) > maxThemeConfigBytes {
|
||||
answerText(c, http.StatusRequestEntityTooLarge, "提交的内容过大")
|
||||
return
|
||||
}
|
||||
var obj map[string]json.RawMessage
|
||||
if err := json.Unmarshal(body, &obj); err != nil || obj == nil {
|
||||
answerText(c, http.StatusBadRequest, "请求格式不对")
|
||||
return
|
||||
}
|
||||
if err := service.SaveProbeThemeConfigRaw(short, string(body)); err != nil {
|
||||
logger.Warningf("probe public: save theme config %s: %v", short, err)
|
||||
answerText(c, http.StatusInternalServerError, "服务内部出错")
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// HandleFallback serves the public theme from the engine's NoRoute:
|
||||
// unknown /api/* paths answer the hub's API 404, everything outside
|
||||
// the panel basePath falls to the current theme's dist (SPA fallback
|
||||
// inside). Returns false when the request is the panel's own (its
|
||||
// SPA fallback decides) or when no theme is installed to answer.
|
||||
func (pc *PublicProbeController) HandleFallback(c *gin.Context) bool {
|
||||
if c.Request.Method != http.MethodGet && c.Request.Method != http.MethodHead {
|
||||
return false
|
||||
}
|
||||
path := c.Request.URL.Path
|
||||
if path == "/api" || strings.HasPrefix(path, "/api/") {
|
||||
answerText(c, http.StatusNotFound, "没有这个接口:"+path)
|
||||
return true
|
||||
}
|
||||
basePath, _ := c.Get("base_path")
|
||||
if bp, ok := basePath.(string); ok && bp != "" && bp != "/" {
|
||||
if strings.HasPrefix(path, bp) {
|
||||
return false
|
||||
}
|
||||
} else if strings.HasPrefix(path, "/panel") || strings.HasPrefix(path, "/assets") {
|
||||
return false
|
||||
}
|
||||
return service.ServeProbeThemePath(c, strings.TrimPrefix(path, "/"))
|
||||
}
|
||||
@@ -0,0 +1,592 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-contrib/sessions"
|
||||
"github.com/gin-contrib/sessions/cookie"
|
||||
"github.com/gin-gonic/gin"
|
||||
ws "github.com/gorilla/websocket"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service/probe"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/session"
|
||||
)
|
||||
|
||||
// Item 7 B3 contract tests: the five public endpoints, audience
|
||||
// discipline (anonymous callers never see addresses / hostnames /
|
||||
// private remarks), theme hosting + fallback, theme config round
|
||||
// trip, and the probe-meta edit endpoints. Field sets are asserted
|
||||
// exactly against the hub's node_view / metrics definitions.
|
||||
|
||||
type probeTestRig struct {
|
||||
engine *gin.Engine
|
||||
dbDir string
|
||||
store *probe.Store
|
||||
cookies []*http.Cookie
|
||||
}
|
||||
|
||||
func newProbeTestRig(t *testing.T) *probeTestRig {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
dbDir := t.TempDir()
|
||||
t.Setenv("XUI_DB_FOLDER", dbDir)
|
||||
if err := database.InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil {
|
||||
t.Fatalf("InitDB: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.CloseDB() })
|
||||
st := probe.Default()
|
||||
if st == nil {
|
||||
t.Fatal("probe store unavailable")
|
||||
}
|
||||
service.InvalidateProbeSnapshot()
|
||||
|
||||
engine := gin.New()
|
||||
store := cookie.NewStore([]byte("probe-public-test-secret"))
|
||||
engine.Use(sessions.Sessions("3x-ui", store))
|
||||
engine.Use(func(c *gin.Context) { c.Set("base_path", "/panel/") })
|
||||
|
||||
a := &APIController{}
|
||||
engine.GET("/test-login", func(c *gin.Context) {
|
||||
var user model.User
|
||||
if err := database.GetDB().First(&user).Error; err != nil {
|
||||
c.Status(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if err := session.SetLoginUser(c, &user); err != nil {
|
||||
c.Status(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusOK)
|
||||
})
|
||||
|
||||
pc := NewPublicProbeController(engine)
|
||||
agentAdmin := engine.Group("/panel/api/agent")
|
||||
agentAdmin.Use(a.checkAPIAuth)
|
||||
NewProbeMetaController(agentAdmin)
|
||||
engine.NoRoute(func(c *gin.Context) {
|
||||
if pc.HandleFallback(c) {
|
||||
return
|
||||
}
|
||||
c.AbortWithStatus(http.StatusNotFound)
|
||||
})
|
||||
|
||||
rig := &probeTestRig{engine: engine, dbDir: dbDir, store: st}
|
||||
// Log in once; handlers that need the anonymous side just skip the
|
||||
// cookies.
|
||||
rec := rig.do(http.MethodGet, "/test-login", nil, false)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("test login: %d", rec.Code)
|
||||
}
|
||||
rig.cookies = rec.Result().Cookies()
|
||||
if len(rig.cookies) == 0 {
|
||||
t.Fatal("test login set no session cookie")
|
||||
}
|
||||
return rig
|
||||
}
|
||||
|
||||
func (r *probeTestRig) do(method, path string, body []byte, authed bool) *httptest.ResponseRecorder {
|
||||
var rd io.Reader
|
||||
if body != nil {
|
||||
rd = bytes.NewReader(body)
|
||||
}
|
||||
req := httptest.NewRequest(method, path, rd)
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
if authed {
|
||||
for _, ck := range r.cookies {
|
||||
req.AddCookie(ck)
|
||||
}
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
r.engine.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
func (r *probeTestRig) setSetting(t *testing.T, key, value string) {
|
||||
t.Helper()
|
||||
db := database.GetDB()
|
||||
var row model.Setting
|
||||
err := db.Where("key = ?", key).First(&row).Error
|
||||
if database.IsNotFound(err) {
|
||||
if err := db.Create(&model.Setting{Key: key, Value: value}).Error; err != nil {
|
||||
t.Fatalf("set setting %s: %v", key, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("read setting %s: %v", key, err)
|
||||
}
|
||||
row.Value = value
|
||||
if err := db.Save(&row).Error; err != nil {
|
||||
t.Fatalf("save setting %s: %v", key, err)
|
||||
}
|
||||
}
|
||||
|
||||
// seedAgent creates a bound node + agent pair with a live probe
|
||||
// snapshot, a meta row and a traffic ledger line.
|
||||
func (r *probeTestRig) seedAgent(t *testing.T, name string, public bool) (agentID int64, nodeID int) {
|
||||
t.Helper()
|
||||
db := database.GetDB()
|
||||
node := &model.Node{Name: name, Remark: "private-remark-" + name}
|
||||
if err := db.Create(node).Error; err != nil {
|
||||
t.Fatalf("seed node: %v", err)
|
||||
}
|
||||
now := time.Now()
|
||||
agent := &model.AgentNode{
|
||||
TokenHash: "hash-" + name, TokenPrefix: "tp-", Name: "agent-" + name,
|
||||
NodeID: &node.Id,
|
||||
LastSeen: now.UnixMilli(),
|
||||
AgentVersion: "A1.0.0",
|
||||
IPv4: "203.0.113.9",
|
||||
SeenIPv4: "198.51.100.4",
|
||||
ProbeHostname: "secret-host-" + name,
|
||||
ProbeOS: "Debian GNU/Linux 13", ProbeKernel: "6.12.1", ProbeArch: "amd64",
|
||||
ProbeVirt: "kvm", ProbeCPUName: "Test CPU", ProbeCPUCores: 4,
|
||||
ProbeIface: "eth0",
|
||||
ProbeMemTotal: 4_000_000_000, ProbeMemUsed: 1_500_000_000,
|
||||
ProbeSwapTotal: 1_000_000_000, ProbeSwapUsed: 5_000_000,
|
||||
ProbeDiskTotal: 8_000_000_000, ProbeDiskUsed: 3_000_000_000,
|
||||
ProbeNetRxTot: 111_000, ProbeNetTxTot: 222_000,
|
||||
ProbeNetRx: 1000, ProbeNetTx: 2000,
|
||||
ProbeTCP: 42, ProbeUDP: 7, ProbeProcs: 123,
|
||||
ProbeCPU: 12.5, UptimeSecs: 3600,
|
||||
ProbeAt: now.UnixMilli(),
|
||||
}
|
||||
if err := db.Create(agent).Error; err != nil {
|
||||
t.Fatalf("seed agent: %v", err)
|
||||
}
|
||||
id := int64(agent.Id)
|
||||
r.store.DeleteAgentData(id) // the default store is process-wide; start clean
|
||||
exp := now.AddDate(0, 1, 0).Format("2006-01-02")
|
||||
meta := probe.DefaultMeta(id)
|
||||
meta.Public = public
|
||||
meta.Price = 10
|
||||
meta.ExpiresAt = &exp
|
||||
meta.GroupName = "grp"
|
||||
meta.PublicRemark = "shown publicly"
|
||||
if err := probe.SaveMeta(meta); err != nil {
|
||||
t.Fatalf("seed meta: %v", err)
|
||||
}
|
||||
month := probe.PeriodStart(now, 1).Format("2006-01-02")
|
||||
day := now.Format("2006-01-02")
|
||||
if _, err := r.store.DB().Exec(`INSERT INTO probe_traffic
|
||||
(agent_id, total_rx, total_tx, month_rx, month_tx, month_start, day_rx, day_tx, day_start)
|
||||
VALUES (?, 111, 222, 33, 44, ?, 5, 6, ?)`, id, month, day); err != nil {
|
||||
t.Fatalf("seed traffic: %v", err)
|
||||
}
|
||||
return id, node.Id
|
||||
}
|
||||
|
||||
func keySet(t *testing.T, body []byte, path ...string) map[string]bool {
|
||||
t.Helper()
|
||||
var v any
|
||||
if err := json.Unmarshal(body, &v); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
cur := v
|
||||
for _, p := range path {
|
||||
m, ok := cur.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("path %v: not an object at %q", path, p)
|
||||
}
|
||||
cur = m[p]
|
||||
}
|
||||
m, ok := cur.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("path %v: not an object", path)
|
||||
}
|
||||
out := map[string]bool{}
|
||||
for k := range m {
|
||||
out[k] = true
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func assertKeys(t *testing.T, got map[string]bool, want []string) {
|
||||
t.Helper()
|
||||
w := map[string]bool{}
|
||||
for _, k := range want {
|
||||
w[k] = true
|
||||
}
|
||||
var extra, missing []string
|
||||
for k := range got {
|
||||
if !w[k] {
|
||||
extra = append(extra, k)
|
||||
}
|
||||
}
|
||||
for k := range w {
|
||||
if !got[k] {
|
||||
missing = append(missing, k)
|
||||
}
|
||||
}
|
||||
sort.Strings(extra)
|
||||
sort.Strings(missing)
|
||||
if len(extra) > 0 || len(missing) > 0 {
|
||||
t.Fatalf("key set mismatch: extra=%v missing=%v", extra, missing)
|
||||
}
|
||||
}
|
||||
|
||||
var anonNodeKeys = []string{
|
||||
"id", "name", "country", "group", "public_remark", "sort", "public",
|
||||
"online", "last_seen", "last_seen_ago", "metrics",
|
||||
"os", "kernel", "arch", "virt", "cpu_name", "cpu_cores",
|
||||
"mem_total", "swap_total", "disk_total", "agent_version",
|
||||
"price", "currency", "billing_cycle", "expires_at", "expires_in",
|
||||
"traffic_limit", "traffic_mode", "traffic_reset_day",
|
||||
"total_rx", "total_tx", "month_rx", "month_tx", "month_used",
|
||||
"month_start", "day_rx", "day_tx",
|
||||
}
|
||||
|
||||
var metricKeys = []string{
|
||||
"uptime", "cpu", "load", "mem_total", "mem_used", "swap_total",
|
||||
"swap_used", "disk_total", "disk_used", "net_rx", "net_tx",
|
||||
"tcp", "udp", "procs", "total_rx", "total_tx", "month_rx", "month_tx",
|
||||
}
|
||||
|
||||
var historyKeys = []string{
|
||||
"ts", "cpu", "mem_used", "disk_used", "net_rx", "net_tx",
|
||||
"net_rx_max", "net_tx_max", "cpu_max", "minutes",
|
||||
"swap_used", "tcp", "udp", "procs",
|
||||
}
|
||||
|
||||
func TestProbePublicContract(t *testing.T) {
|
||||
rig := newProbeTestRig(t)
|
||||
pubID, _ := rig.seedAgent(t, "PubNode", true)
|
||||
privID, privNodeID := rig.seedAgent(t, "PrivNode", false)
|
||||
service.InvalidateProbeSnapshot()
|
||||
|
||||
// --- public page off (the shipped default) ---
|
||||
rec := rig.do(http.MethodGet, "/api/me", nil, false)
|
||||
if rec.Code != 200 {
|
||||
t.Fatalf("me anon: %d", rec.Code)
|
||||
}
|
||||
assertKeys(t, keySet(t, rec.Body.Bytes()),
|
||||
[]string{"authed", "github", "site_name", "public_page", "history_days", "site"})
|
||||
var me map[string]any
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &me)
|
||||
if me["authed"] != false || me["public_page"] != false || me["site_name"] != "Monitor" ||
|
||||
me["history_days"] != float64(7) || me["github"] != false || me["site"] != "" {
|
||||
t.Fatalf("me defaults wrong: %v", me)
|
||||
}
|
||||
if rec := rig.do(http.MethodGet, "/api/nodes", nil, false); rec.Code != 401 {
|
||||
t.Fatalf("nodes anon with page off: %d", rec.Code)
|
||||
}
|
||||
if rec := rig.do(http.MethodGet, "/api/nodes/1/metrics", nil, false); rec.Code != 401 {
|
||||
t.Fatalf("metrics anon with page off: %d", rec.Code)
|
||||
}
|
||||
|
||||
// --- public page on: the anonymous audience ---
|
||||
rig.setSetting(t, "public_page", "on")
|
||||
service.InvalidateProbeSnapshot()
|
||||
|
||||
rec = rig.do(http.MethodGet, "/api/nodes", nil, false)
|
||||
if rec.Code != 200 {
|
||||
t.Fatalf("nodes anon: %d (%s)", rec.Code, rec.Body.String())
|
||||
}
|
||||
body := rec.Body.String()
|
||||
for _, leak := range []string{
|
||||
"secret-host", "private-remark", `"hostname"`, `"remark"`,
|
||||
`"ip":`, `"ipv4"`, `"ipv6"`, "203.0.113.9", "198.51.100.4", "eth0",
|
||||
} {
|
||||
if strings.Contains(body, leak) {
|
||||
t.Fatalf("anonymous frame leaks %q: %s", leak, body)
|
||||
}
|
||||
}
|
||||
var frame struct {
|
||||
Nodes []map[string]json.RawMessage `json:"nodes"`
|
||||
Admin bool `json:"admin"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &frame); err != nil {
|
||||
t.Fatalf("frame decode: %v", err)
|
||||
}
|
||||
if frame.Admin || len(frame.Nodes) != 1 {
|
||||
t.Fatalf("anon frame: admin=%v nodes=%d", frame.Admin, len(frame.Nodes))
|
||||
}
|
||||
nodeBody, _ := json.Marshal(frame.Nodes[0])
|
||||
assertKeys(t, keySet(t, nodeBody), anonNodeKeys)
|
||||
var node map[string]any
|
||||
_ = json.Unmarshal(nodeBody, &node)
|
||||
if node["id"] != float64(pubID) || node["name"] != "PubNode" || node["online"] != true ||
|
||||
node["month_rx"] != float64(33) || node["month_used"] != float64(77) ||
|
||||
node["total_tx"] != float64(222) || node["price"] != float64(10) {
|
||||
t.Fatalf("anon node values wrong: %v", node)
|
||||
}
|
||||
metricsObj, _ := json.Marshal(node["metrics"])
|
||||
assertKeys(t, keySet(t, metricsObj), metricKeys)
|
||||
|
||||
// Metrics history for the public node; the private one refuses.
|
||||
base := time.Now().Unix()
|
||||
base -= base % 60
|
||||
for _, ts := range []int64{base - 120, base - 60} {
|
||||
if _, err := rig.store.DB().Exec(`INSERT INTO probe_metric
|
||||
(agent_id, ts, cpu, mem_used, swap_used, disk_used, net_rx, net_tx,
|
||||
net_rx_max, net_tx_max, tcp, udp, procs, cpu_max)
|
||||
VALUES (?, ?, 10, 100, 0, 0, 1000, 2000, 1500, 2500, 3, 2, 10, 12)`,
|
||||
pubID, ts); err != nil {
|
||||
t.Fatalf("seed metric: %v", err)
|
||||
}
|
||||
}
|
||||
rec = rig.do(http.MethodGet, "/api/nodes/"+itoa(pubID)+"/metrics?hours=1", nil, false)
|
||||
if rec.Code != 200 {
|
||||
t.Fatalf("metrics anon: %d (%s)", rec.Code, rec.Body.String())
|
||||
}
|
||||
var hist struct {
|
||||
Metrics []map[string]json.RawMessage `json:"metrics"`
|
||||
Ping []any `json:"ping"`
|
||||
Probes map[string]string `json:"probes"`
|
||||
Loss map[string]float64 `json:"loss"`
|
||||
Step int64 `json:"step"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &hist); err != nil {
|
||||
t.Fatalf("metrics decode: %v", err)
|
||||
}
|
||||
if hist.Step != 60 || len(hist.Metrics) != 2 || len(hist.Ping) != 0 ||
|
||||
len(hist.Probes) != 0 || len(hist.Loss) != 0 {
|
||||
t.Fatalf("metrics shape wrong: %+v", hist)
|
||||
}
|
||||
rowBody, _ := json.Marshal(hist.Metrics[0])
|
||||
assertKeys(t, keySet(t, rowBody), historyKeys)
|
||||
if rec := rig.do(http.MethodGet, "/api/nodes/"+itoa(privID)+"/metrics", nil, false); rec.Code != 401 {
|
||||
t.Fatalf("metrics for private node anon: %d", rec.Code)
|
||||
}
|
||||
if rec := rig.do(http.MethodGet, "/api/nodes/abc/metrics", nil, false); rec.Code != 400 {
|
||||
t.Fatalf("metrics bad id: %d", rec.Code)
|
||||
}
|
||||
|
||||
// --- the admin audience ---
|
||||
rec = rig.do(http.MethodGet, "/api/nodes", nil, true)
|
||||
if rec.Code != 200 {
|
||||
t.Fatalf("nodes admin: %d", rec.Code)
|
||||
}
|
||||
if cc := rec.Header().Get("Cache-Control"); !strings.Contains(cc, "no-store") {
|
||||
t.Fatalf("admin frame cache header: %q", cc)
|
||||
}
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &frame)
|
||||
if !frame.Admin || len(frame.Nodes) != 2 {
|
||||
t.Fatalf("admin frame: admin=%v nodes=%d", frame.Admin, len(frame.Nodes))
|
||||
}
|
||||
adminBody := rec.Body.String()
|
||||
for _, want := range []string{"secret-host-PubNode", "private-remark-PubNode", "203.0.113.9"} {
|
||||
if !strings.Contains(adminBody, want) {
|
||||
t.Fatalf("admin frame missing %q", want)
|
||||
}
|
||||
}
|
||||
if rec := rig.do(http.MethodGet, "/api/nodes/"+itoa(privID)+"/metrics", nil, true); rec.Code != 200 {
|
||||
t.Fatalf("metrics for private node admin: %d", rec.Code)
|
||||
}
|
||||
|
||||
// --- theme hosting + API 404 discipline ---
|
||||
themeDir := filepath.Join(rig.dbDir, "themes", "testtheme", "dist")
|
||||
if err := os.MkdirAll(filepath.Join(themeDir, "assets"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
indexHTML := "<!doctype html><title>theme</title>"
|
||||
if err := os.WriteFile(filepath.Join(themeDir, "index.html"), []byte(indexHTML), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(themeDir, "assets", "app-abc123.js"), []byte("console.log(1)"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rig.setSetting(t, "theme", "testtheme")
|
||||
|
||||
rec = rig.do(http.MethodGet, "/", nil, false)
|
||||
if rec.Code != 200 || rec.Body.String() != indexHTML {
|
||||
t.Fatalf("theme root: %d %q", rec.Code, rec.Body.String())
|
||||
}
|
||||
etag := rec.Header().Get("ETag")
|
||||
if etag == "" || !strings.Contains(rec.Header().Get("Cache-Control"), "no-cache") {
|
||||
t.Fatalf("shell headers: etag=%q cc=%q", etag, rec.Header().Get("Cache-Control"))
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req.Header.Set("If-None-Match", etag)
|
||||
rec304 := httptest.NewRecorder()
|
||||
rig.engine.ServeHTTP(rec304, req)
|
||||
if rec304.Code != 304 {
|
||||
t.Fatalf("etag revalidation: %d", rec304.Code)
|
||||
}
|
||||
if rec := rig.do(http.MethodGet, "/some/spa/route", nil, false); rec.Code != 200 || rec.Body.String() != indexHTML {
|
||||
t.Fatalf("spa fallback: %d", rec.Code)
|
||||
}
|
||||
rec = rig.do(http.MethodGet, "/assets/app-abc123.js", nil, false)
|
||||
if rec.Code != 200 || !strings.Contains(rec.Header().Get("Cache-Control"), "immutable") {
|
||||
t.Fatalf("asset serving: %d cc=%q", rec.Code, rec.Header().Get("Cache-Control"))
|
||||
}
|
||||
if rec := rig.do(http.MethodGet, "/assets/missing.js", nil, false); rec.Code != 404 {
|
||||
t.Fatalf("missing asset: %d", rec.Code)
|
||||
}
|
||||
rec = rig.do(http.MethodGet, "/api/nope", nil, false)
|
||||
if rec.Code != 404 || !strings.Contains(rec.Body.String(), "没有这个接口:/api/nope") {
|
||||
t.Fatalf("api 404: %d %q", rec.Code, rec.Body.String())
|
||||
}
|
||||
if rec := rig.do(http.MethodGet, "/panel/unknown", nil, false); rec.Code != 404 {
|
||||
t.Fatalf("panel path must stay with the panel: %d", rec.Code)
|
||||
}
|
||||
|
||||
// --- theme config round trip ---
|
||||
if rec := rig.do(http.MethodGet, "/api/themes/testtheme/config", nil, false); rec.Code != 200 || rec.Body.String() != "{}" {
|
||||
t.Fatalf("config default: %d %q", rec.Code, rec.Body.String())
|
||||
}
|
||||
if rec := rig.do(http.MethodPut, "/api/themes/testtheme/config", []byte(`{"accent":"red"}`), false); rec.Code != 401 {
|
||||
t.Fatalf("config PUT anon: %d", rec.Code)
|
||||
}
|
||||
if rec := rig.do(http.MethodPut, "/api/themes/testtheme/config", []byte(`{"accent":"red"}`), true); rec.Code != 204 {
|
||||
t.Fatalf("config PUT admin: %d (%s)", rec.Code, rec.Body.String())
|
||||
}
|
||||
if rec := rig.do(http.MethodGet, "/api/themes/testtheme/config", nil, false); rec.Body.String() != `{"accent":"red"}` {
|
||||
t.Fatalf("config round trip: %q", rec.Body.String())
|
||||
}
|
||||
if rec := rig.do(http.MethodPut, "/api/themes/notinstalled/config", []byte(`{}`), true); rec.Code != 400 {
|
||||
t.Fatalf("config PUT uninstalled: %d", rec.Code)
|
||||
}
|
||||
if rec := rig.do(http.MethodPut, "/api/themes/testtheme/config", []byte(`[1,2]`), true); rec.Code != 400 {
|
||||
t.Fatalf("config PUT non-object: %d", rec.Code)
|
||||
}
|
||||
if rec := rig.do(http.MethodGet, "/api/themes/bad!short/config", nil, true); rec.Code != 404 {
|
||||
t.Fatalf("config bad short: %d", rec.Code)
|
||||
}
|
||||
|
||||
// --- probe-meta edit endpoints ---
|
||||
metaPath := "/panel/api/agent/probe-meta/" + itoa(int64(privNodeID))
|
||||
if rec := rig.do(http.MethodGet, metaPath, nil, false); rec.Code == 200 {
|
||||
t.Fatal("probe-meta GET without login must not succeed")
|
||||
}
|
||||
rec = rig.do(http.MethodGet, metaPath, nil, true)
|
||||
if rec.Code != 200 {
|
||||
t.Fatalf("probe-meta GET: %d (%s)", rec.Code, rec.Body.String())
|
||||
}
|
||||
var env struct {
|
||||
Success bool `json:"success"`
|
||||
Obj struct {
|
||||
Currency string `json:"currency"`
|
||||
BillingCycle string `json:"billingCycle"`
|
||||
Public bool `json:"public"`
|
||||
} `json:"obj"`
|
||||
}
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &env)
|
||||
if !env.Success || env.Obj.Currency != "USD" || env.Obj.Public || env.Obj.BillingCycle != "monthly" {
|
||||
t.Fatalf("probe-meta GET body: %s", rec.Body.String())
|
||||
}
|
||||
|
||||
bad := []byte(`{"price":1,"currency":"USD","billingCycle":"monthly","trafficLimit":0,` +
|
||||
`"trafficMode":"sum","trafficResetDay":32,"public":true}`)
|
||||
rec = rig.do(http.MethodPut, metaPath, bad, true)
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &env)
|
||||
if env.Success {
|
||||
t.Fatalf("reset day 32 must be refused: %s", rec.Body.String())
|
||||
}
|
||||
|
||||
good := []byte(`{"price":3.5,"currency":"usd","billingCycle":"12m","trafficLimit":2000000000,` +
|
||||
`"trafficMode":"max","trafficResetDay":15,"public":false,"ipv4Pin":" 203.0.113.9 "}`)
|
||||
rec = rig.do(http.MethodPut, metaPath, good, true)
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &env)
|
||||
if !env.Success {
|
||||
t.Fatalf("probe-meta PUT: %s", rec.Body.String())
|
||||
}
|
||||
rec = rig.do(http.MethodGet, metaPath, nil, true)
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &env)
|
||||
if env.Obj.Currency != "USD" || env.Obj.BillingCycle != "yearly" || env.Obj.Public {
|
||||
t.Fatalf("probe-meta normalization not stored: %s", rec.Body.String())
|
||||
}
|
||||
saved, ok, err := probe.MetaFor(privID)
|
||||
if err != nil || !ok || saved.IPv4Pin != "203.0.113.9" || saved.TrafficLimit != 2_000_000_000 {
|
||||
t.Fatalf("stored meta: ok=%v err=%v %+v", ok, err, saved)
|
||||
}
|
||||
if rec := rig.do(http.MethodGet, "/panel/api/agent/probe-meta/999999", nil, true); rec.Code != 404 {
|
||||
t.Fatalf("probe-meta unbound node: %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProbeWebSocket(t *testing.T) {
|
||||
rig := newProbeTestRig(t)
|
||||
_, _ = rig.seedAgent(t, "WsNode", true)
|
||||
rig.setSetting(t, "public_page", "on")
|
||||
service.InvalidateProbeSnapshot()
|
||||
|
||||
srv := httptest.NewServer(rig.engine)
|
||||
defer srv.Close()
|
||||
wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + "/api/ws"
|
||||
|
||||
readFrame := func(conn *ws.Conn) (int, map[string]any) {
|
||||
t.Helper()
|
||||
_ = conn.SetReadDeadline(time.Now().Add(5 * time.Second))
|
||||
mt, raw, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
t.Fatalf("ws read: %v", err)
|
||||
}
|
||||
if mt == ws.BinaryMessage {
|
||||
zr, err := gzip.NewReader(bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
t.Fatalf("gzip frame: %v", err)
|
||||
}
|
||||
raw, err = io.ReadAll(zr)
|
||||
if err != nil {
|
||||
t.Fatalf("gunzip frame: %v", err)
|
||||
}
|
||||
}
|
||||
var frame map[string]any
|
||||
if err := json.Unmarshal(raw, &frame); err != nil {
|
||||
t.Fatalf("frame is not JSON: %v (%q)", err, raw)
|
||||
}
|
||||
return mt, frame
|
||||
}
|
||||
|
||||
// Anonymous text frame.
|
||||
conn, _, err := ws.DefaultDialer.Dial(wsURL, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("dial anon: %v", err)
|
||||
}
|
||||
mt, frame := readFrame(conn)
|
||||
if mt != ws.TextMessage || frame["admin"] != false {
|
||||
t.Fatalf("anon frame: type=%d admin=%v", mt, frame["admin"])
|
||||
}
|
||||
if nodes, ok := frame["nodes"].([]any); !ok || len(nodes) != 1 {
|
||||
t.Fatalf("anon frame nodes: %v", frame["nodes"])
|
||||
}
|
||||
conn.Close()
|
||||
|
||||
// Anonymous gzip frame: binary, gunzips to the same shape.
|
||||
conn, _, err = ws.DefaultDialer.Dial(wsURL+"?gzip=1", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("dial gzip: %v", err)
|
||||
}
|
||||
mt, frame = readFrame(conn)
|
||||
if mt != ws.BinaryMessage || frame["admin"] != false {
|
||||
t.Fatalf("gzip frame: type=%d admin=%v", mt, frame["admin"])
|
||||
}
|
||||
conn.Close()
|
||||
|
||||
// Admin frame over the session cookie.
|
||||
hdr := http.Header{}
|
||||
for _, ck := range rig.cookies {
|
||||
hdr.Add("Cookie", ck.String())
|
||||
}
|
||||
conn, _, err = ws.DefaultDialer.Dial(wsURL, hdr)
|
||||
if err != nil {
|
||||
t.Fatalf("dial admin: %v", err)
|
||||
}
|
||||
_, frame = readFrame(conn)
|
||||
if frame["admin"] != true {
|
||||
t.Fatalf("admin frame: %v", frame["admin"])
|
||||
}
|
||||
conn.Close()
|
||||
}
|
||||
|
||||
func itoa(v int64) string {
|
||||
return strconv.FormatInt(v, 10)
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
package probe
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Meta is one agent's probe display/operations metadata (hub node row's
|
||||
// operator-owned half, ITEM7-SCOPE §5.2): what the public page shows
|
||||
// beyond the machine's own reports — price/expiry/plan figures, the
|
||||
// public switch and ordering, grouping and the public remark, the
|
||||
// notify opt-in and the hand-set address/country pins. Rows are
|
||||
// ensured with hub defaults on the agent's first report; the editing
|
||||
// surface (B3) reads and writes them here.
|
||||
type Meta struct {
|
||||
AgentID int64
|
||||
Price float64
|
||||
Currency string
|
||||
BillingCycle string
|
||||
ExpiresAt *string // "2006-01-02" or nil
|
||||
TrafficLimit int64
|
||||
TrafficMode string
|
||||
TrafficResetDay int64
|
||||
Public bool
|
||||
Sort int64
|
||||
GroupName string
|
||||
PublicRemark string
|
||||
Notify bool
|
||||
CountryPin string
|
||||
IPv4Pin string
|
||||
IPv6Pin string
|
||||
}
|
||||
|
||||
// DefaultMeta returns the metadata of an agent that has no row yet —
|
||||
// the same values the row would carry once ensured (the probe_meta
|
||||
// DDL defaults).
|
||||
func DefaultMeta(agentID int64) Meta {
|
||||
return Meta{
|
||||
AgentID: agentID,
|
||||
Currency: "USD",
|
||||
BillingCycle: "monthly",
|
||||
TrafficMode: "sum",
|
||||
TrafficResetDay: 1,
|
||||
Public: true,
|
||||
}
|
||||
}
|
||||
|
||||
const metaColumns = `agent_id, price, currency, billing_cycle, expires_at,
|
||||
traffic_limit, traffic_mode, traffic_reset_day, public, sort,
|
||||
group_name, public_remark, notify, country_pin, ipv4_pin, ipv6_pin`
|
||||
|
||||
func scanMeta(sc interface{ Scan(...any) error }) (Meta, error) {
|
||||
var m Meta
|
||||
var expires sql.NullString
|
||||
var public, notify int64
|
||||
if err := sc.Scan(&m.AgentID, &m.Price, &m.Currency, &m.BillingCycle, &expires,
|
||||
&m.TrafficLimit, &m.TrafficMode, &m.TrafficResetDay, &public, &m.Sort,
|
||||
&m.GroupName, &m.PublicRemark, ¬ify, &m.CountryPin, &m.IPv4Pin, &m.IPv6Pin); err != nil {
|
||||
return Meta{}, err
|
||||
}
|
||||
if expires.Valid && strings.TrimSpace(expires.String) != "" {
|
||||
v := expires.String
|
||||
m.ExpiresAt = &v
|
||||
}
|
||||
m.Public = public != 0
|
||||
m.Notify = notify != 0
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// Meta reads one agent's metadata row. ok is false when the agent has
|
||||
// no row yet (it never reported); callers then use DefaultMeta.
|
||||
func (s *Store) Meta(agentID int64) (Meta, bool, error) {
|
||||
row := s.db.QueryRow(`SELECT `+metaColumns+` FROM probe_meta WHERE agent_id = ?`, agentID)
|
||||
m, err := scanMeta(row)
|
||||
if err == sql.ErrNoRows {
|
||||
return Meta{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return Meta{}, false, err
|
||||
}
|
||||
return m, true, nil
|
||||
}
|
||||
|
||||
// AllMeta reads every metadata row, keyed by agent id.
|
||||
func (s *Store) AllMeta() (map[int64]Meta, error) {
|
||||
rows, err := s.db.Query(`SELECT ` + metaColumns + ` FROM probe_meta`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make(map[int64]Meta)
|
||||
for rows.Next() {
|
||||
m, err := scanMeta(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[m.AgentID] = m
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// UpsertMeta writes one agent's metadata row (insert or full replace).
|
||||
func (s *Store) UpsertMeta(m Meta) error {
|
||||
var expires any
|
||||
if m.ExpiresAt != nil && strings.TrimSpace(*m.ExpiresAt) != "" {
|
||||
expires = *m.ExpiresAt
|
||||
}
|
||||
_, err := s.db.Exec(
|
||||
`INSERT INTO probe_meta (`+metaColumns+`) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
ON CONFLICT(agent_id) DO UPDATE SET
|
||||
price=excluded.price, currency=excluded.currency,
|
||||
billing_cycle=excluded.billing_cycle, expires_at=excluded.expires_at,
|
||||
traffic_limit=excluded.traffic_limit, traffic_mode=excluded.traffic_mode,
|
||||
traffic_reset_day=excluded.traffic_reset_day, public=excluded.public,
|
||||
sort=excluded.sort, group_name=excluded.group_name,
|
||||
public_remark=excluded.public_remark, notify=excluded.notify,
|
||||
country_pin=excluded.country_pin, ipv4_pin=excluded.ipv4_pin,
|
||||
ipv6_pin=excluded.ipv6_pin`,
|
||||
m.AgentID, m.Price, m.Currency, m.BillingCycle, expires,
|
||||
m.TrafficLimit, m.TrafficMode, m.TrafficResetDay, boolToInt(m.Public), m.Sort,
|
||||
m.GroupName, m.PublicRemark, boolToInt(m.Notify), m.CountryPin, m.IPv4Pin, m.IPv6Pin)
|
||||
return err
|
||||
}
|
||||
|
||||
func boolToInt(b bool) int64 {
|
||||
if b {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// DeleteAgentData removes every probe row of an agent that no longer
|
||||
// exists: metadata, the traffic ledger and both history tiers. The
|
||||
// hub keeps history inside its own node table; here probe.db is a
|
||||
// separate file, so the panel-side agent deletion calls this to keep
|
||||
// the two stores in step (B2 report §6 cleanup point).
|
||||
func (s *Store) DeleteAgentData(agentID int64) error {
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
for _, table := range []string{
|
||||
"probe_meta", "probe_traffic", "probe_metric",
|
||||
"probe_metric_hour", "probe_ping_record", "probe_ping_hour",
|
||||
} {
|
||||
if _, err := tx.Exec(`DELETE FROM `+table+` WHERE agent_id = ?`, agentID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
@@ -83,3 +83,56 @@ func TrafficSnapshot() (map[int64]Traffic, error) {
|
||||
}
|
||||
return st.AllTraffic()
|
||||
}
|
||||
|
||||
// MetaSnapshot is the B3 read port for display metadata: every agent's
|
||||
// probe_meta row, keyed by agent id. Agents without a row are absent;
|
||||
// callers substitute DefaultMeta.
|
||||
func MetaSnapshot() (map[int64]Meta, error) {
|
||||
st := Default()
|
||||
if st == nil {
|
||||
return map[int64]Meta{}, nil
|
||||
}
|
||||
return st.AllMeta()
|
||||
}
|
||||
|
||||
// MetaFor reads one agent's metadata row from the default store.
|
||||
// ok is false when the agent never reported (no row ensured yet).
|
||||
func MetaFor(agentID int64) (Meta, bool, error) {
|
||||
st := Default()
|
||||
if st == nil {
|
||||
return Meta{}, false, nil
|
||||
}
|
||||
return st.Meta(agentID)
|
||||
}
|
||||
|
||||
// SaveMeta persists one agent's metadata row (B3 edit surface).
|
||||
func SaveMeta(m Meta) error {
|
||||
st := Default()
|
||||
if st == nil {
|
||||
return nil
|
||||
}
|
||||
return st.UpsertMeta(m)
|
||||
}
|
||||
|
||||
// MetricHistory is the B3 read port for one agent's history window.
|
||||
// With no store the window is simply empty.
|
||||
func MetricHistory(agentID int64, sp Span) ([]MetricRow, error) {
|
||||
st := Default()
|
||||
if st == nil {
|
||||
return []MetricRow{}, nil
|
||||
}
|
||||
return st.MetricHistory(agentID, sp)
|
||||
}
|
||||
|
||||
// DeleteAgentData drops a removed agent's probe rows (B3 cleanup point
|
||||
// called from agent deletion). Best-effort: failures are logged and
|
||||
// swallowed, as a stale probe row must never block a panel operation.
|
||||
func DeleteAgentData(agentID int64) {
|
||||
st := Default()
|
||||
if st == nil {
|
||||
return
|
||||
}
|
||||
if err := st.DeleteAgentData(agentID); err != nil {
|
||||
logger.Warningf("probe: delete agent %d data: %v", agentID, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
package probe
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// Span is one chart window as the read side answers it (hub db::Span):
|
||||
// the window start in unix seconds, the seconds each returned point
|
||||
// covers, and whether the window reaches past the minute tier and is
|
||||
// read from the hourly one.
|
||||
type Span struct {
|
||||
Since int64
|
||||
Step int64
|
||||
Hourly bool
|
||||
}
|
||||
|
||||
// ComputeSpan reproduces hub api::span(): the point budget defaults to
|
||||
// 1440 (a day of minutes) and the caller's points may only lower it,
|
||||
// clamped to [60, 1440]; windows longer than DetailDays read hourly
|
||||
// rows, so the step is a whole number of minutes — or of hours past the
|
||||
// detail window, so no hourly row straddles two points; the window
|
||||
// start is aligned down to a step boundary.
|
||||
func ComputeSpan(hours int64, points *int64, now int64) Span {
|
||||
const samples = 1440
|
||||
budget := int64(samples)
|
||||
if points != nil {
|
||||
budget = *points
|
||||
}
|
||||
if budget < 60 {
|
||||
budget = 60
|
||||
}
|
||||
if budget > samples {
|
||||
budget = samples
|
||||
}
|
||||
hourly := hours > DetailDays*24
|
||||
unit := int64(60)
|
||||
if hourly {
|
||||
unit = 3600
|
||||
}
|
||||
// Rounded up, or the budget would not be one.
|
||||
q := (hours*3600/unit + budget - 1) / budget
|
||||
if q < 1 {
|
||||
q = 1
|
||||
}
|
||||
step := unit * q
|
||||
return Span{
|
||||
Since: euclidDiv(now-hours*3600, step) * step,
|
||||
Step: step,
|
||||
Hourly: hourly,
|
||||
}
|
||||
}
|
||||
|
||||
func euclidDiv(a, b int64) int64 {
|
||||
q := a / b
|
||||
if a%b != 0 && (a < 0) != (b < 0) {
|
||||
q--
|
||||
}
|
||||
return q
|
||||
}
|
||||
|
||||
// MetricRow is one thinned history point, in the hub's key order and
|
||||
// with the hub's value semantics: means for the gauge columns (byte
|
||||
// columns truncated, count columns rounded — the asymmetry is the
|
||||
// hub's, kept), maxima for the peak columns, and `minutes` counting
|
||||
// the minute rows folded into the point.
|
||||
type MetricRow struct {
|
||||
Ts int64 `json:"ts"`
|
||||
Cpu float64 `json:"cpu"`
|
||||
MemUsed int64 `json:"mem_used"`
|
||||
DiskUsed int64 `json:"disk_used"`
|
||||
NetRx int64 `json:"net_rx"`
|
||||
NetTx int64 `json:"net_tx"`
|
||||
NetRxMax int64 `json:"net_rx_max"`
|
||||
NetTxMax int64 `json:"net_tx_max"`
|
||||
CpuMax float64 `json:"cpu_max"`
|
||||
Minutes int64 `json:"minutes"`
|
||||
SwapUsed int64 `json:"swap_used"`
|
||||
Tcp int64 `json:"tcp"`
|
||||
Udp int64 `json:"udp"`
|
||||
Procs int64 `json:"procs"`
|
||||
}
|
||||
|
||||
// RolledWatermark reads the roll-up watermark (unix seconds of the
|
||||
// first hour not yet folded). ok is false when no pass has run yet.
|
||||
func (s *Store) RolledWatermark() (int64, bool, error) {
|
||||
var raw string
|
||||
err := s.db.QueryRow(`SELECT value FROM probe_setting WHERE key = ?`, rolledKey).Scan(&raw)
|
||||
if err == sql.ErrNoRows {
|
||||
return 0, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return 0, false, err
|
||||
}
|
||||
n, perr := strconv.ParseInt(raw, 10, 64)
|
||||
if perr != nil {
|
||||
return 0, false, nil
|
||||
}
|
||||
return n, true, nil
|
||||
}
|
||||
|
||||
const metricColumns = `(MIN(ts)/?3)*?3, AVG(cpu), CAST(AVG(mem_used) AS INTEGER),
|
||||
CAST(AVG(disk_used) AS INTEGER),
|
||||
CAST(AVG(net_rx) AS INTEGER), CAST(AVG(net_tx) AS INTEGER),
|
||||
MAX(MAX(net_rx, net_rx_max)), MAX(MAX(net_tx, net_tx_max)),
|
||||
MAX(MAX(cpu, cpu_max)), COUNT(*), CAST(AVG(swap_used) AS INTEGER),
|
||||
CAST(ROUND(AVG(tcp)) AS INTEGER), CAST(ROUND(AVG(udp)) AS INTEGER),
|
||||
CAST(ROUND(AVG(procs)) AS INTEGER)`
|
||||
|
||||
// MetricHistory answers one agent's history window (hub Db::metrics).
|
||||
// Minute windows group minute rows directly. Hourly windows merge the
|
||||
// folded hourly tier (up to the watermark) with the not-yet-folded
|
||||
// minute rows and the newest week of minute rows, weighted by each
|
||||
// row's minute count. Both statements run in one read snapshot so a
|
||||
// roll-up committing in between cannot double-count an hour.
|
||||
func (s *Store) MetricHistory(agentID int64, sp Span) ([]MetricRow, error) {
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
var rows *sql.Rows
|
||||
if !sp.Hourly {
|
||||
rows, err = tx.Query(
|
||||
`SELECT `+metricColumns+`
|
||||
FROM probe_metric WHERE agent_id=?1 AND ts>=?2 GROUP BY ts/?3 ORDER BY ts/?3`,
|
||||
agentID, sp.Since, sp.Step)
|
||||
} else {
|
||||
rolled := int64(-1) << 62 // hub: unwrap_or(i64::MIN)
|
||||
var raw sql.NullString
|
||||
if qerr := tx.QueryRow(`SELECT value FROM probe_setting WHERE key = ?`, rolledKey).Scan(&raw); qerr != nil && qerr != sql.ErrNoRows {
|
||||
return nil, qerr
|
||||
} else if raw.Valid {
|
||||
if n, perr := strconv.ParseInt(raw.String, 10, 64); perr == nil {
|
||||
rolled = n
|
||||
}
|
||||
}
|
||||
rows, err = tx.Query(
|
||||
`SELECT (MIN(ts)/?3)*?3, SUM(cpu*w)/SUM(w), SUM(mem_used*w)/SUM(w), SUM(disk_used*w)/SUM(w),
|
||||
SUM(net_rx*w)/SUM(w), SUM(net_tx*w)/SUM(w), MAX(rx_max), MAX(tx_max), MAX(cpu_top), SUM(w),
|
||||
SUM(swap_used*w)/SUM(w), CAST(ROUND(SUM(tcp*w)*1.0/SUM(w)) AS INTEGER),
|
||||
CAST(ROUND(SUM(udp*w)*1.0/SUM(w)) AS INTEGER),
|
||||
CAST(ROUND(SUM(procs*w)*1.0/SUM(w)) AS INTEGER)
|
||||
FROM (SELECT ts, minutes AS w, cpu, mem_used, disk_used, net_rx, net_tx,
|
||||
net_rx_max AS rx_max, net_tx_max AS tx_max, cpu_max AS cpu_top,
|
||||
swap_used, tcp, udp, procs
|
||||
FROM probe_metric_hour WHERE agent_id=?1 AND ts>=?2 AND ts<?4
|
||||
UNION ALL
|
||||
SELECT ts, 1, cpu, mem_used, disk_used, net_rx, net_tx,
|
||||
MAX(net_rx, net_rx_max), MAX(net_tx, net_tx_max), MAX(cpu, cpu_max),
|
||||
swap_used, tcp, udp, procs
|
||||
FROM probe_metric WHERE agent_id=?1
|
||||
AND ts>=MAX(?2, ?4, (SELECT MAX(ts) FROM probe_metric WHERE agent_id=?1) - ?5))
|
||||
GROUP BY ts/?3 ORDER BY ts/?3`,
|
||||
agentID, sp.Since, sp.Step, rolled, DetailDays*86400)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := []MetricRow{}
|
||||
for rows.Next() {
|
||||
var r MetricRow
|
||||
if err := rows.Scan(&r.Ts, &r.Cpu, &r.MemUsed, &r.DiskUsed, &r.NetRx, &r.NetTx,
|
||||
&r.NetRxMax, &r.NetTxMax, &r.CpuMax, &r.Minutes, &r.SwapUsed, &r.Tcp, &r.Udp, &r.Procs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
package probe
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Span math (hub api::span): budget defaults to 1440 points, the
|
||||
// caller's points may only lower it and is clamped to [60, 1440];
|
||||
// windows past the 7-day detail window switch to whole-hour steps;
|
||||
// since aligns down to the step.
|
||||
func TestComputeSpan(t *testing.T) {
|
||||
now := int64(1_800_000_000) // arbitrary fixed instant
|
||||
i64 := func(v int64) *int64 { return &v }
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
hours int64
|
||||
points *int64
|
||||
wantStep int64
|
||||
wantHourly bool
|
||||
}{
|
||||
{"default 6h", 6, nil, 60, false},
|
||||
{"points lower the budget", 6, i64(60), 360, false},
|
||||
{"points above cap ignored", 6, i64(99999), 60, false},
|
||||
{"points below floor clamped", 1, i64(1), 60, false},
|
||||
{"detail edge stays minute", DetailDays * 24, nil, 420, false},
|
||||
{"past detail reads hourly", DetailDays*24 + 1, nil, 3600, true},
|
||||
{"30 days hourly", 720, nil, 3600, true},
|
||||
{"hourly step grows past budget", 24 * 365, nil, 3600 * 7, true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
sp := ComputeSpan(tc.hours, tc.points, now)
|
||||
if sp.Step != tc.wantStep || sp.Hourly != tc.wantHourly {
|
||||
t.Errorf("%s: got step=%d hourly=%v, want step=%d hourly=%v",
|
||||
tc.name, sp.Step, sp.Hourly, tc.wantStep, tc.wantHourly)
|
||||
}
|
||||
if sp.Since%sp.Step != 0 {
|
||||
t.Errorf("%s: since %d not aligned to step %d", tc.name, sp.Since, sp.Step)
|
||||
}
|
||||
if want := now - tc.hours*3600; sp.Since > want || sp.Since <= want-sp.Step {
|
||||
t.Errorf("%s: since %d not the aligned window start of %d", tc.name, sp.Since, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func insertMinute(t *testing.T, st *Store, agent, ts int64, cpu float64, memUsed, netRx, netRxMax int64, cpuMax float64, tcp int64) {
|
||||
t.Helper()
|
||||
_, err := st.db.Exec(`INSERT INTO probe_metric
|
||||
(agent_id, ts, cpu, mem_used, swap_used, disk_used, net_rx, net_tx,
|
||||
net_rx_max, net_tx_max, tcp, udp, procs, cpu_max)
|
||||
VALUES (?, ?, ?, ?, 0, 0, ?, 0, ?, 0, ?, 0, 0, ?)`,
|
||||
agent, ts, cpu, memUsed, netRx, netRxMax, tcp, cpuMax)
|
||||
if err != nil {
|
||||
t.Fatalf("insert minute: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Minute windows fold rows per bucket with the hub's asymmetric
|
||||
// aggregates: byte columns truncated means, count columns rounded,
|
||||
// peaks take the max of value and stored peak.
|
||||
func TestMetricHistoryMinuteFold(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
now := time.Now().Unix()
|
||||
base := now - now%60 - 120 // a minute boundary two minutes back
|
||||
|
||||
insertMinute(t, st, 7, base, 10, 100, 1000, 1500, 12, 3)
|
||||
insertMinute(t, st, 7, base+60, 20, 201, 2000, 500, 25, 5)
|
||||
|
||||
// 1:1 buckets (step 60).
|
||||
sp := ComputeSpan(1, nil, now)
|
||||
rows, err := st.MetricHistory(7, sp)
|
||||
if err != nil {
|
||||
t.Fatalf("history: %v", err)
|
||||
}
|
||||
if len(rows) != 2 {
|
||||
t.Fatalf("want 2 points, got %d (%+v)", len(rows), rows)
|
||||
}
|
||||
if rows[0].Ts != base || rows[0].Cpu != 10 || rows[0].MemUsed != 100 ||
|
||||
rows[0].NetRxMax != 1500 || rows[0].CpuMax != 12 || rows[0].Minutes != 1 || rows[0].Tcp != 3 {
|
||||
t.Fatalf("point 0 wrong: %+v", rows[0])
|
||||
}
|
||||
if rows[1].Ts != base+60 || rows[1].NetRxMax != 2000 { // max(2000, 500)
|
||||
t.Fatalf("point 1 wrong: %+v", rows[1])
|
||||
}
|
||||
|
||||
// Both minutes in one 120 s bucket: cpu mean 15, mem truncated
|
||||
// mean (100+201)/2 = 150, peaks survive, minutes counts rows.
|
||||
sp2 := Span{Since: base, Step: 120, Hourly: false}
|
||||
rows, err = st.MetricHistory(7, sp2)
|
||||
if err != nil {
|
||||
t.Fatalf("history 120s: %v", err)
|
||||
}
|
||||
if len(rows) != 1 {
|
||||
t.Fatalf("want 1 folded point, got %d", len(rows))
|
||||
}
|
||||
r := rows[0]
|
||||
if r.Ts != base || r.Cpu != 15 || r.MemUsed != 150 || r.Minutes != 2 ||
|
||||
r.NetRxMax != 2000 || r.CpuMax != 25 || r.Tcp != 4 { // ROUND(avg(3,5)) = 4
|
||||
t.Fatalf("folded point wrong: %+v", r)
|
||||
}
|
||||
|
||||
// Another agent sees nothing.
|
||||
rows, err = st.MetricHistory(8, sp)
|
||||
if err != nil || len(rows) != 0 {
|
||||
t.Fatalf("agent isolation: rows=%v err=%v", rows, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Hourly windows merge the folded hour tier (weighted by each row's
|
||||
// minute count) with the minute rows not yet folded.
|
||||
func TestMetricHistoryHourlyFold(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
now := time.Now().Unix()
|
||||
hour := now - now%3600
|
||||
h1 := hour - 100*3600 // folded tier
|
||||
h2 := h1 + 3600
|
||||
|
||||
ins := func(ts int64, cpu float64, mem int64, minutes int64) {
|
||||
_, err := st.db.Exec(`INSERT INTO probe_metric_hour
|
||||
(agent_id, ts, cpu, mem_used, swap_used, disk_used, net_rx, net_tx,
|
||||
net_rx_max, net_tx_max, tcp, udp, procs, cpu_max, minutes)
|
||||
VALUES (?, ?, ?, ?, 0, 0, 0, 0, 0, 0, 0, 0, 0, ?, ?)`,
|
||||
9, ts, cpu, mem, cpu, minutes)
|
||||
if err != nil {
|
||||
t.Fatalf("insert hour: %v", err)
|
||||
}
|
||||
}
|
||||
ins(h1, 30, 300, 60)
|
||||
ins(h2, 60, 600, 30)
|
||||
if _, err := st.db.Exec(`INSERT INTO probe_setting (key, value) VALUES (?, ?)`,
|
||||
rolledKey, h2+3600); err != nil {
|
||||
t.Fatalf("set watermark: %v", err)
|
||||
}
|
||||
// One unfolded minute row in the current hour.
|
||||
insertMinute(t, st, 9, hour+60, 90, 900, 5000, 5000, 90, 9)
|
||||
|
||||
sp := ComputeSpan(200, nil, now)
|
||||
if !sp.Hourly || sp.Step != 3600 {
|
||||
t.Fatalf("span: %+v", sp)
|
||||
}
|
||||
rows, err := st.MetricHistory(9, sp)
|
||||
if err != nil {
|
||||
t.Fatalf("history: %v", err)
|
||||
}
|
||||
if len(rows) != 3 {
|
||||
t.Fatalf("want 3 points (2 folded + 1 live), got %d (%+v)", len(rows), rows)
|
||||
}
|
||||
if rows[0].Ts != h1 || rows[0].Cpu != 30 || rows[0].Minutes != 60 || rows[0].MemUsed != 300 {
|
||||
t.Fatalf("folded point 0 wrong: %+v", rows[0])
|
||||
}
|
||||
if rows[1].Ts != h2 || rows[1].Cpu != 60 || rows[1].Minutes != 30 {
|
||||
t.Fatalf("folded point 1 wrong: %+v", rows[1])
|
||||
}
|
||||
if rows[2].Ts != hour || rows[2].Cpu != 90 || rows[2].Minutes != 1 || rows[2].NetRx != 5000 {
|
||||
t.Fatalf("live point wrong: %+v", rows[2])
|
||||
}
|
||||
}
|
||||
|
||||
// Meta lifecycle: defaults for unknown agents, full round-trip,
|
||||
// replace-on-upsert, and the delete cascade emptying every tier.
|
||||
func TestMetaCRUDAndDelete(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
|
||||
if _, ok, err := st.Meta(42); err != nil || ok {
|
||||
t.Fatalf("unknown agent: ok=%v err=%v", ok, err)
|
||||
}
|
||||
d := DefaultMeta(42)
|
||||
if d.Currency != "USD" || d.BillingCycle != "monthly" || !d.Public ||
|
||||
d.TrafficMode != "sum" || d.TrafficResetDay != 1 || d.Notify {
|
||||
t.Fatalf("defaults drifted from the DDL: %+v", d)
|
||||
}
|
||||
|
||||
exp := "2030-01-02"
|
||||
want := Meta{
|
||||
AgentID: 42, Price: 12.5, Currency: "USD", BillingCycle: "yearly",
|
||||
ExpiresAt: &exp, TrafficLimit: 1_000_000_000, TrafficMode: "max",
|
||||
TrafficResetDay: 15, Public: false, Sort: 3, GroupName: "g1",
|
||||
PublicRemark: "hello", Notify: true, CountryPin: "JP",
|
||||
IPv4Pin: "203.0.113.7", IPv6Pin: "2001:db8::7",
|
||||
}
|
||||
if err := st.UpsertMeta(want); err != nil {
|
||||
t.Fatalf("upsert: %v", err)
|
||||
}
|
||||
got, ok, err := st.Meta(42)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("read back: ok=%v err=%v", ok, err)
|
||||
}
|
||||
gotExp, wantExp := "", ""
|
||||
if got.ExpiresAt != nil {
|
||||
gotExp = *got.ExpiresAt
|
||||
}
|
||||
if want.ExpiresAt != nil {
|
||||
wantExp = *want.ExpiresAt
|
||||
}
|
||||
got.ExpiresAt, want.ExpiresAt = nil, nil
|
||||
if got != want || gotExp != wantExp {
|
||||
t.Fatalf("round trip mismatch:\n got %+v exp=%q\nwant %+v exp=%q", got, gotExp, want, wantExp)
|
||||
}
|
||||
want.ExpiresAt = &exp
|
||||
|
||||
want.Price = 99
|
||||
if err := st.UpsertMeta(want); err != nil {
|
||||
t.Fatalf("replace: %v", err)
|
||||
}
|
||||
all, err := st.AllMeta()
|
||||
if err != nil || len(all) != 1 || all[42].Price != 99 {
|
||||
t.Fatalf("all meta: %v %+v", err, all)
|
||||
}
|
||||
|
||||
insertMinute(t, st, 42, 1000, 1, 1, 1, 1, 1, 1)
|
||||
if _, err := st.db.Exec(`INSERT INTO probe_traffic (agent_id) VALUES (42)`); err != nil {
|
||||
t.Fatalf("seed traffic: %v", err)
|
||||
}
|
||||
if err := st.DeleteAgentData(42); err != nil {
|
||||
t.Fatalf("delete: %v", err)
|
||||
}
|
||||
if _, ok, _ := st.Meta(42); ok {
|
||||
t.Fatal("meta survived delete")
|
||||
}
|
||||
for _, table := range []string{"probe_metric", "probe_traffic"} {
|
||||
var n int
|
||||
if err := st.db.QueryRow(`SELECT COUNT(*) FROM ` + table + ` WHERE agent_id = 42`).Scan(&n); err != nil || n != 0 {
|
||||
t.Fatalf("%s survived delete: n=%d err=%v", table, n, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,539 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"encoding/json"
|
||||
"net"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"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"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service/probe"
|
||||
)
|
||||
|
||||
// Item 7 B3: the public status surface (monitor hub contract). The
|
||||
// node views are assembled whitelist-style from three sources — the
|
||||
// agent_nodes snapshot rows (facts + latest metrics, B1), the probe
|
||||
// ledger/metadata (B2) and the bound panel node's name/remark — and
|
||||
// rendered into per-audience snapshot frames shared by /api/nodes and
|
||||
// /api/ws, exactly as the hub renders one frame per audience per tick.
|
||||
|
||||
// ProbeOnlineThresholdMs is how stale an agent's last heartbeat may be
|
||||
// before the public page calls it offline. Heartbeats land every 60 s;
|
||||
// 150 s absorbs one lost beat plus jitter.
|
||||
const ProbeOnlineThresholdMs = 150_000
|
||||
|
||||
// ProbeSnapshotTTL mirrors the hub's 1900 ms frame reuse: just under
|
||||
// the 2 s push interval, so a frame is built at most once per tick.
|
||||
const ProbeSnapshotTTL = 1900 * time.Millisecond
|
||||
|
||||
// ---- public-surface settings (main settings KV, hub key names) ----
|
||||
|
||||
func probeSetting(key string) (string, bool) {
|
||||
var row model.Setting
|
||||
if err := database.GetDB().Where("key = ?", key).First(&row).Error; err != nil {
|
||||
return "", false
|
||||
}
|
||||
return row.Value, true
|
||||
}
|
||||
|
||||
func setProbeSetting(key, value string) error {
|
||||
db := database.GetDB()
|
||||
var row model.Setting
|
||||
err := db.Where("key = ?", key).First(&row).Error
|
||||
if database.IsNotFound(err) {
|
||||
return db.Create(&model.Setting{Key: key, Value: value}).Error
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
row.Value = value
|
||||
return db.Save(&row).Error
|
||||
}
|
||||
|
||||
// ProbePublicPageEnabled reports the public-page master switch. Per
|
||||
// the item-7 scope decision the page ships OFF until the operator
|
||||
// turns it on (setting public_page = "on"); the hub's own default is
|
||||
// on, the deviation is deliberate and recorded in the B3 report.
|
||||
func ProbePublicPageEnabled() bool {
|
||||
v, _ := probeSetting("public_page")
|
||||
return v == "on"
|
||||
}
|
||||
|
||||
// SetProbePublicPageEnabled flips the public-page master switch.
|
||||
func SetProbePublicPageEnabled(on bool) error {
|
||||
if on {
|
||||
return setProbeSetting("public_page", "on")
|
||||
}
|
||||
return setProbeSetting("public_page", "off")
|
||||
}
|
||||
|
||||
// ProbeSiteName is the status page's site name (hub default "Monitor").
|
||||
func ProbeSiteName() string {
|
||||
if v, ok := probeSetting("site_name"); ok && v != "" {
|
||||
return v
|
||||
}
|
||||
return "Monitor"
|
||||
}
|
||||
|
||||
// ProbeSiteURL is the hub's public URL setting ("site"), "" when unset.
|
||||
func ProbeSiteURL() string {
|
||||
v, _ := probeSetting("site")
|
||||
return v
|
||||
}
|
||||
|
||||
// ProbeGitHubConfigured mirrors /api/me's github flag: whether a
|
||||
// github_client_id is configured. (GitHub login itself is not ported;
|
||||
// the panel's own session is the only login.)
|
||||
func ProbeGitHubConfigured() bool {
|
||||
v, _ := probeSetting("github_client_id")
|
||||
return strings.TrimSpace(v) != ""
|
||||
}
|
||||
|
||||
// ProbeCurrentTheme is the selected theme short ("" = built-in default).
|
||||
func ProbeCurrentTheme() string {
|
||||
v, _ := probeSetting("theme")
|
||||
return v
|
||||
}
|
||||
|
||||
// ProbeThemeConfigValue reads a theme's saved config JSON verbatim
|
||||
// (setting key theme_config:{short}), "{}" when never saved. A read
|
||||
// failure is an error, not an empty config: a caller saving on top of
|
||||
// what it read would otherwise erase every override (hub discipline).
|
||||
func ProbeThemeConfigValue(short string) (string, error) {
|
||||
var row model.Setting
|
||||
err := database.GetDB().Where("key = ?", "theme_config:"+short).First(&row).Error
|
||||
if database.IsNotFound(err) {
|
||||
return "{}", nil
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if row.Value == "" {
|
||||
return "{}", nil
|
||||
}
|
||||
return row.Value, nil
|
||||
}
|
||||
|
||||
// SaveProbeThemeConfigRaw stores a theme's config JSON verbatim.
|
||||
func SaveProbeThemeConfigRaw(short, raw string) error {
|
||||
return setProbeSetting("theme_config:"+short, raw)
|
||||
}
|
||||
|
||||
// ---- node view assembly ----
|
||||
|
||||
type probeNodeSource struct {
|
||||
agent model.AgentNode
|
||||
name string
|
||||
remark string
|
||||
meta probe.Meta
|
||||
traffic probe.Traffic
|
||||
nowMs int64
|
||||
now time.Time
|
||||
online bool
|
||||
lastSeen int64 // unix seconds, 0 = never seen
|
||||
metricsOn bool
|
||||
metricsObj map[string]any
|
||||
}
|
||||
|
||||
func loadProbeNodeSources() []probeNodeSource {
|
||||
now := time.Now()
|
||||
nowMs := now.UnixMilli()
|
||||
|
||||
var agents []model.AgentNode
|
||||
if err := database.GetDB().Order("id ASC").Find(&agents).Error; err != nil {
|
||||
logger.Warningf("probe public: load agents: %v", err)
|
||||
return nil
|
||||
}
|
||||
nodesByID := map[int]model.Node{}
|
||||
var nodes []model.Node
|
||||
if err := database.GetDB().Find(&nodes).Error; err == nil {
|
||||
for _, n := range nodes {
|
||||
nodesByID[n.Id] = n
|
||||
}
|
||||
}
|
||||
metas, err := probe.MetaSnapshot()
|
||||
if err != nil {
|
||||
logger.Warningf("probe public: load meta: %v", err)
|
||||
metas = map[int64]probe.Meta{}
|
||||
}
|
||||
traffic, err := probe.TrafficSnapshot()
|
||||
if err != nil {
|
||||
logger.Warningf("probe public: load traffic: %v", err)
|
||||
traffic = map[int64]probe.Traffic{}
|
||||
}
|
||||
|
||||
out := make([]probeNodeSource, 0, len(agents))
|
||||
for i := range agents {
|
||||
a := agents[i]
|
||||
src := probeNodeSource{agent: a, name: a.Name, nowMs: nowMs, now: now}
|
||||
if a.NodeID != nil {
|
||||
if n, ok := nodesByID[*a.NodeID]; ok {
|
||||
if n.Name != "" {
|
||||
src.name = n.Name
|
||||
}
|
||||
src.remark = n.Remark
|
||||
}
|
||||
}
|
||||
if m, ok := metas[int64(a.Id)]; ok {
|
||||
src.meta = m
|
||||
} else {
|
||||
src.meta = probe.DefaultMeta(int64(a.Id))
|
||||
}
|
||||
src.traffic = traffic[int64(a.Id)]
|
||||
src.online = a.LastSeen > 0 && nowMs-a.LastSeen <= ProbeOnlineThresholdMs
|
||||
src.lastSeen = a.LastSeen / 1000
|
||||
if src.online && a.ProbeAt > 0 {
|
||||
src.metricsOn = true
|
||||
src.metricsObj = probeLiveMetrics(&a, src.traffic)
|
||||
}
|
||||
out = append(out, src)
|
||||
}
|
||||
sort.SliceStable(out, func(i, j int) bool {
|
||||
if out[i].meta.Sort != out[j].meta.Sort {
|
||||
return out[i].meta.Sort < out[j].meta.Sort
|
||||
}
|
||||
return out[i].agent.Id < out[j].agent.Id
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// probeLiveMetrics builds the whitelisted live metrics object
|
||||
// (hub PUBLIC_METRICS) from the latest snapshot, with the four ledger
|
||||
// figures injected (hub INJECTED — they never arrive from an agent).
|
||||
func probeLiveMetrics(a *model.AgentNode, t probe.Traffic) map[string]any {
|
||||
return map[string]any{
|
||||
"uptime": a.UptimeSecs,
|
||||
"cpu": float64(a.ProbeCPU),
|
||||
"load": []float64{float64(a.ProbeLoad1), float64(a.ProbeLoad5), float64(a.ProbeLoad15)},
|
||||
"mem_total": a.ProbeMemTotal,
|
||||
"mem_used": a.ProbeMemUsed,
|
||||
"swap_total": a.ProbeSwapTotal,
|
||||
"swap_used": a.ProbeSwapUsed,
|
||||
"disk_total": a.ProbeDiskTotal,
|
||||
"disk_used": a.ProbeDiskUsed,
|
||||
"net_rx": a.ProbeNetRx,
|
||||
"net_tx": a.ProbeNetTx,
|
||||
"tcp": a.ProbeTCP,
|
||||
"udp": a.ProbeUDP,
|
||||
"procs": a.ProbeProcs,
|
||||
"total_rx": t.TotalRx,
|
||||
"total_tx": t.TotalTx,
|
||||
"month_rx": t.MonthRx,
|
||||
"month_tx": t.MonthTx,
|
||||
}
|
||||
}
|
||||
|
||||
// expiresInDays counts whole calendar days from the local today to
|
||||
// the expires date, as the hub does on its own calendar.
|
||||
func expiresInDays(expires *string, now time.Time) any {
|
||||
if expires == nil {
|
||||
return nil
|
||||
}
|
||||
d, err := time.ParseInLocation("2006-01-02", *expires, time.Local)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
ny, nm, nd := now.Date()
|
||||
ey, em, ed := d.Date()
|
||||
days := int(time.Date(ey, em, ed, 0, 0, 0, 0, time.UTC).Unix()/86400 -
|
||||
time.Date(ny, nm, nd, 0, 0, 0, 0, time.UTC).Unix()/86400)
|
||||
return days
|
||||
}
|
||||
|
||||
// buildProbeNodeView renders one node for one audience. The public
|
||||
// shape is built by whitelist (hub node_view): address, hostname and
|
||||
// the private remark exist only in the admin (full) rendering.
|
||||
func buildProbeNodeView(src probeNodeSource, full bool) map[string]any {
|
||||
a := src.agent
|
||||
m := src.meta
|
||||
t := src.traffic
|
||||
|
||||
var lastSeenAgo any
|
||||
if src.lastSeen > 0 {
|
||||
ago := src.now.Unix() - src.lastSeen
|
||||
if ago < 0 {
|
||||
ago = 0
|
||||
}
|
||||
lastSeenAgo = ago
|
||||
}
|
||||
var metrics any
|
||||
if src.metricsOn {
|
||||
obj := make(map[string]any, len(src.metricsObj)+1)
|
||||
for k, v := range src.metricsObj {
|
||||
obj[k] = v
|
||||
}
|
||||
if full {
|
||||
obj["iface"] = a.ProbeIface
|
||||
}
|
||||
metrics = obj
|
||||
}
|
||||
|
||||
view := map[string]any{
|
||||
"id": a.Id,
|
||||
"name": src.name,
|
||||
"country": m.CountryPin,
|
||||
"group": m.GroupName,
|
||||
"public_remark": m.PublicRemark,
|
||||
"sort": m.Sort,
|
||||
"public": m.Public,
|
||||
"online": src.online,
|
||||
"last_seen": src.lastSeen,
|
||||
"last_seen_ago": lastSeenAgo,
|
||||
"metrics": metrics,
|
||||
"os": a.ProbeOS,
|
||||
"kernel": a.ProbeKernel,
|
||||
"arch": a.ProbeArch,
|
||||
"virt": a.ProbeVirt,
|
||||
"cpu_name": a.ProbeCPUName,
|
||||
"cpu_cores": a.ProbeCPUCores,
|
||||
"mem_total": a.ProbeMemTotal,
|
||||
"swap_total": a.ProbeSwapTotal,
|
||||
"disk_total": a.ProbeDiskTotal,
|
||||
"agent_version": a.AgentVersion,
|
||||
"price": m.Price,
|
||||
"currency": m.Currency,
|
||||
"billing_cycle": m.BillingCycle,
|
||||
"expires_at": m.ExpiresAt,
|
||||
"expires_in": expiresInDays(m.ExpiresAt, src.now),
|
||||
"traffic_limit": m.TrafficLimit,
|
||||
"traffic_mode": m.TrafficMode,
|
||||
"traffic_reset_day": m.TrafficResetDay,
|
||||
"total_rx": t.TotalRx,
|
||||
"total_tx": t.TotalTx,
|
||||
"month_rx": t.MonthRx,
|
||||
"month_tx": t.MonthTx,
|
||||
"month_used": t.MonthUsed(m.TrafficMode),
|
||||
"month_start": t.MonthStart,
|
||||
"day_rx": t.DayRx,
|
||||
"day_tx": t.DayTx,
|
||||
}
|
||||
if !full {
|
||||
return view
|
||||
}
|
||||
|
||||
// Admin-only half: identity and addresses never leave the panel
|
||||
// in the anonymous rendering (they are simply never built there).
|
||||
held4, held6 := a.IPv4, a.IPv6
|
||||
exits := []string{a.SeenIPv4, a.SeenIPv6}
|
||||
shown := probeAddresses(exits, held4, held6, m.IPv4Pin, m.IPv6Pin)
|
||||
addrList := make([]map[string]string, 0, len(shown))
|
||||
for _, s := range shown {
|
||||
addrList = append(addrList, map[string]string{"address": s.addr, "source": s.source})
|
||||
}
|
||||
ip := ""
|
||||
for _, e := range exits {
|
||||
if e != "" {
|
||||
ip = e
|
||||
break
|
||||
}
|
||||
}
|
||||
var interval any
|
||||
if src.online {
|
||||
interval = 60 // heartbeat cadence is fixed at 60 s by design
|
||||
}
|
||||
view["hostname"] = a.ProbeHostname
|
||||
view["ip"] = ip
|
||||
view["ipv4"] = held4
|
||||
view["ipv6"] = held6
|
||||
view["ipv4_pin"] = m.IPv4Pin
|
||||
view["ipv6_pin"] = m.IPv6Pin
|
||||
view["addresses"] = addrList
|
||||
view["ipv4_auto"] = probeAutoAddress(exits, held4, held6, "", m.IPv6Pin, false)
|
||||
view["ipv6_auto"] = probeAutoAddress(exits, held4, held6, m.IPv4Pin, "", true)
|
||||
view["interval"] = interval
|
||||
view["country_pin"] = m.CountryPin
|
||||
view["country_auto"] = ""
|
||||
view["remark"] = src.remark
|
||||
view["notify"] = m.Notify
|
||||
return view
|
||||
}
|
||||
|
||||
type probeShownAddr struct {
|
||||
addr string
|
||||
source string
|
||||
}
|
||||
|
||||
// probeAddresses is the hub's addresses() port: per family a manual
|
||||
// pin wins, then a public interface address, then — when the interface
|
||||
// holds only a private address of that family — the public exit the
|
||||
// master observed. With nothing public anywhere it falls back to the
|
||||
// held interface addresses, then to the connection source.
|
||||
func probeAddresses(exits []string, held4, held6, pin4, pin6 string) []probeShownAddr {
|
||||
public := func(s string, v6 bool) bool {
|
||||
ip := net.ParseIP(s)
|
||||
if ip == nil || (ip.To4() == nil) != v6 {
|
||||
return false
|
||||
}
|
||||
return IsPublicIPLiteral(s)
|
||||
}
|
||||
family := func(pin, held string, v6 bool) *probeShownAddr {
|
||||
if pin != "" {
|
||||
return &probeShownAddr{pin, "manual"}
|
||||
}
|
||||
if public(held, v6) {
|
||||
return &probeShownAddr{held, "interface"}
|
||||
}
|
||||
if held == "" {
|
||||
return nil
|
||||
}
|
||||
for _, e := range exits {
|
||||
if public(e, v6) {
|
||||
return &probeShownAddr{e, "exit"}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
var shown []probeShownAddr
|
||||
if s := family(pin4, held4, false); s != nil {
|
||||
shown = append(shown, *s)
|
||||
}
|
||||
if s := family(pin6, held6, true); s != nil {
|
||||
shown = append(shown, *s)
|
||||
}
|
||||
if len(shown) > 0 {
|
||||
return shown
|
||||
}
|
||||
for _, h := range []string{held4, held6} {
|
||||
if h != "" {
|
||||
shown = append(shown, probeShownAddr{h, "interface"})
|
||||
}
|
||||
}
|
||||
if len(shown) > 0 {
|
||||
return shown
|
||||
}
|
||||
for _, e := range exits {
|
||||
if e != "" {
|
||||
return []probeShownAddr{{e, "connection"}}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// probeAutoAddress recomputes one family's shown address with its own
|
||||
// pin cleared (the edit form's fallback hint, hub ipv4_auto/ipv6_auto).
|
||||
func probeAutoAddress(exits []string, held4, held6, pin4, pin6 string, v6 bool) string {
|
||||
for _, s := range probeAddresses(exits, held4, held6, pin4, pin6) {
|
||||
if strings.Contains(s.addr, ":") == v6 {
|
||||
return s.addr
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// BuildProbeNodeViews renders every node for one audience; anonymous
|
||||
// renders only public=1 nodes.
|
||||
func BuildProbeNodeViews(full bool) []map[string]any {
|
||||
sources := loadProbeNodeSources()
|
||||
views := make([]map[string]any, 0, len(sources))
|
||||
for _, src := range sources {
|
||||
if !full && !src.meta.Public {
|
||||
continue
|
||||
}
|
||||
views = append(views, buildProbeNodeView(src, full))
|
||||
}
|
||||
return views
|
||||
}
|
||||
|
||||
// ---- snapshot frames (shared by /api/nodes and /api/ws) ----
|
||||
|
||||
type probeFrame struct {
|
||||
at time.Time
|
||||
body []byte
|
||||
gz []byte
|
||||
}
|
||||
|
||||
var probeSnapshots struct {
|
||||
mu sync.Mutex
|
||||
frames [2]probeFrame
|
||||
}
|
||||
|
||||
// ProbeSnapshotJSON returns the audience's rendered snapshot frame,
|
||||
// rebuilding it at most once per ProbeSnapshotTTL.
|
||||
func ProbeSnapshotJSON(full bool) []byte {
|
||||
probeSnapshots.mu.Lock()
|
||||
defer probeSnapshots.mu.Unlock()
|
||||
idx := 0
|
||||
if full {
|
||||
idx = 1
|
||||
}
|
||||
f := &probeSnapshots.frames[idx]
|
||||
if f.body == nil || time.Since(f.at) < 0 || time.Since(f.at) >= ProbeSnapshotTTL {
|
||||
frame := struct {
|
||||
Nodes []map[string]any `json:"nodes"`
|
||||
Admin bool `json:"admin"`
|
||||
}{Nodes: BuildProbeNodeViews(full), Admin: full}
|
||||
body, err := json.Marshal(frame)
|
||||
if err != nil {
|
||||
body = []byte(`{"nodes":[],"admin":false}`)
|
||||
}
|
||||
f.at = time.Now()
|
||||
f.body = body
|
||||
f.gz = nil
|
||||
}
|
||||
return f.body
|
||||
}
|
||||
|
||||
// ProbeSnapshotGzip is the anonymous frame gzipped, built lazily once
|
||||
// per frame (hub live_gzip) for /api/ws ?gzip binary frames and for
|
||||
// pre-compressed /api/nodes answers.
|
||||
func ProbeSnapshotGzip() []byte {
|
||||
body := ProbeSnapshotJSON(false)
|
||||
probeSnapshots.mu.Lock()
|
||||
defer probeSnapshots.mu.Unlock()
|
||||
f := &probeSnapshots.frames[0]
|
||||
if f.gz == nil {
|
||||
var buf bytes.Buffer
|
||||
w := gzip.NewWriter(&buf)
|
||||
_, _ = w.Write(body)
|
||||
_ = w.Close()
|
||||
f.gz = buf.Bytes()
|
||||
}
|
||||
return f.gz
|
||||
}
|
||||
|
||||
// InvalidateProbeSnapshot drops both cached frames so the next read
|
||||
// rebuilds (called after metadata edits, as the hub does on node
|
||||
// writes).
|
||||
func InvalidateProbeSnapshot() {
|
||||
probeSnapshots.mu.Lock()
|
||||
defer probeSnapshots.mu.Unlock()
|
||||
probeSnapshots.frames = [2]probeFrame{}
|
||||
}
|
||||
|
||||
// ProbeNodeReadable mirrors the hub's readable(): the panel sees
|
||||
// every node; an anonymous caller only public=1 nodes, and only while
|
||||
// the public page is on. As on the hub, an authed caller may read a
|
||||
// window for a node that does not exist (it comes back empty); the
|
||||
// anonymous side needs the agent to exist with the public flag set,
|
||||
// consistent with what /api/nodes lists.
|
||||
func ProbeNodeReadable(full bool, agentID int64) bool {
|
||||
if full {
|
||||
return true
|
||||
}
|
||||
if !ProbePublicPageEnabled() || !ProbeAgentExists(agentID) {
|
||||
return false
|
||||
}
|
||||
meta, ok, err := probe.MetaFor(agentID)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if !ok {
|
||||
return probe.DefaultMeta(agentID).Public
|
||||
}
|
||||
return meta.Public
|
||||
}
|
||||
|
||||
// ProbeAgentExists reports whether an agent row exists.
|
||||
func ProbeAgentExists(agentID int64) bool {
|
||||
var count int64
|
||||
if err := database.GetDB().Model(&model.AgentNode{}).Where("id = ?", agentID).Count(&count).Error; err != nil {
|
||||
return false
|
||||
}
|
||||
return count > 0
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"mime"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/config"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// Item 7 B3: public theme hosting (hub frontend.rs serve/disk/asset
|
||||
// port). Themes are static directory packages on disk —
|
||||
// <db folder>/themes/<short>/dist — served by the panel process
|
||||
// itself at the engine root, next to the contract API. A theme's
|
||||
// files are only ever read as static bytes, never executed. The
|
||||
// built-in fallback theme (hub embeds one) lands in B5 with the
|
||||
// theme store; until then a path no installed theme answers is a 404.
|
||||
|
||||
// ProbeThemesRoot is the on-disk themes directory.
|
||||
func ProbeThemesRoot() string {
|
||||
return filepath.Join(config.GetDBFolderPath(), "themes")
|
||||
}
|
||||
|
||||
// ValidThemeShort mirrors the hub's valid_short: the name of a theme
|
||||
// directory, so no separators and nothing empty ([A-Za-z0-9_-]+).
|
||||
func ValidThemeShort(short string) bool {
|
||||
if short == "" {
|
||||
return false
|
||||
}
|
||||
for _, r := range short {
|
||||
if !(r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '-' || r == '_') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// probeExternalTheme resolves the dist directory of an installed
|
||||
// theme (hub external_theme): the theme counts only when dist and
|
||||
// its index.html are actually there.
|
||||
func probeExternalTheme(short string) (string, bool) {
|
||||
if short == "" {
|
||||
short = "default"
|
||||
}
|
||||
if !ValidThemeShort(short) {
|
||||
return "", false
|
||||
}
|
||||
themes, err := filepath.EvalSymlinks(ProbeThemesRoot())
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
root, err := filepath.EvalSymlinks(filepath.Join(themes, short))
|
||||
if err != nil || !strings.HasPrefix(root, themes+string(os.PathSeparator)) {
|
||||
return "", false
|
||||
}
|
||||
dist, err := filepath.EvalSymlinks(filepath.Join(root, "dist"))
|
||||
if err != nil || !strings.HasPrefix(dist, root+string(os.PathSeparator)) {
|
||||
return "", false
|
||||
}
|
||||
info, err := os.Stat(filepath.Join(dist, "index.html"))
|
||||
if err != nil || info.IsDir() {
|
||||
return "", false
|
||||
}
|
||||
return dist, true
|
||||
}
|
||||
|
||||
// ProbeThemeInstalled reports whether a theme short is selectable
|
||||
// (hub selectable): an installed external theme.
|
||||
func ProbeThemeInstalled(short string) bool {
|
||||
_, ok := probeExternalTheme(short)
|
||||
return ok
|
||||
}
|
||||
|
||||
// readInsideTheme reads a regular file whose canonical path stays
|
||||
// below root (hub read_inside), rejecting escapes and symlinks out.
|
||||
func readInsideTheme(root, relative string) ([]byte, bool) {
|
||||
canonRoot, err := filepath.EvalSymlinks(root)
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
file, err := filepath.EvalSymlinks(filepath.Join(canonRoot, relative))
|
||||
if err != nil || !strings.HasPrefix(file, canonRoot+string(os.PathSeparator)) {
|
||||
return nil, false
|
||||
}
|
||||
info, err := os.Stat(file)
|
||||
if err != nil || !info.Mode().IsRegular() {
|
||||
return nil, false
|
||||
}
|
||||
data, err := os.ReadFile(file)
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
return data, true
|
||||
}
|
||||
|
||||
func isThemeAssetPath(p string) bool { return strings.HasPrefix(p, "assets/") }
|
||||
|
||||
// serveThemeAsset writes one theme file with the caching policy its
|
||||
// path warrants (hub asset()): hashed files under assets/ are
|
||||
// immutable for a year; everything else is the SPA shell — no-cache
|
||||
// plus a strong ETag (half the SHA-256 of the body), so a theme
|
||||
// switch lands on the next request at the cost of a 304.
|
||||
func serveThemeAsset(c *gin.Context, path string, data []byte) {
|
||||
contentType := mime.TypeByExtension(filepath.Ext(path))
|
||||
if contentType == "" {
|
||||
contentType = "application/octet-stream"
|
||||
}
|
||||
if isThemeAssetPath(path) {
|
||||
c.Header("Cache-Control", "public, max-age=31536000, immutable")
|
||||
c.Data(http.StatusOK, contentType, data)
|
||||
return
|
||||
}
|
||||
sum := sha256.Sum256(data)
|
||||
etag := "\"" + hex.EncodeToString(sum[:])[:32] + "\""
|
||||
c.Header("Content-Type", contentType)
|
||||
c.Header("Cache-Control", "no-cache")
|
||||
c.Header("ETag", etag)
|
||||
if c.GetHeader("If-None-Match") == etag {
|
||||
c.AbortWithStatus(http.StatusNotModified)
|
||||
return
|
||||
}
|
||||
c.Data(http.StatusOK, contentType, data)
|
||||
}
|
||||
|
||||
// ServeProbeThemePath answers one non-API root path from the current
|
||||
// theme (hub respond/disk): the file itself when it exists, the
|
||||
// theme's index.html as the SPA fallback for non-asset paths, and a
|
||||
// true 404 for missing hashed assets. Returns false when no theme is
|
||||
// installed to answer at all (caller emits its own 404).
|
||||
func ServeProbeThemePath(c *gin.Context, path string) bool {
|
||||
dist, ok := probeExternalTheme(ProbeCurrentTheme())
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
rel := path
|
||||
if rel == "" {
|
||||
rel = "index.html"
|
||||
}
|
||||
if data, found := readInsideTheme(dist, rel); found {
|
||||
serveThemeAsset(c, rel, data)
|
||||
return true
|
||||
}
|
||||
if isThemeAssetPath(rel) {
|
||||
c.String(http.StatusNotFound, "没有这个文件:/%s", rel)
|
||||
return true
|
||||
}
|
||||
if data, found := readInsideTheme(dist, "index.html"); found {
|
||||
serveThemeAsset(c, "index.html", data)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -926,7 +926,40 @@
|
||||
"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):",
|
||||
"agentMasterUrlRequired": "Master URL is empty — enter the master address first; the install command is generated only then."
|
||||
"agentMasterUrlRequired": "Master URL is empty — enter the master address first; the install command is generated only then.",
|
||||
"probe": {
|
||||
"title": "Public status page",
|
||||
"hint": "Display and plan data for the public status page. Saving takes effect immediately.",
|
||||
"public": "Show on public page",
|
||||
"publicRemark": "Public remark",
|
||||
"group": "Group",
|
||||
"sort": "Sort order",
|
||||
"price": "Price",
|
||||
"currency": "Currency",
|
||||
"billingCycle": "Billing cycle",
|
||||
"cycleOnce": "One-time",
|
||||
"cycleMonthly": "Monthly",
|
||||
"cycleQuarterly": "Quarterly",
|
||||
"cycleSemiannual": "Semiannual",
|
||||
"cycleYearly": "Yearly",
|
||||
"cycleBiennial": "Biennial",
|
||||
"cycleTriennial": "Triennial",
|
||||
"expiresAt": "Expires on (YYYY-MM-DD)",
|
||||
"trafficLimit": "Traffic limit (GB, 0 = unlimited)",
|
||||
"trafficMode": "Traffic counting",
|
||||
"modeSum": "Up + down",
|
||||
"modeUp": "Up only",
|
||||
"modeDown": "Down only",
|
||||
"modeMax": "Larger of up/down",
|
||||
"trafficResetDay": "Traffic reset day",
|
||||
"notify": "Expiry / traffic alerts",
|
||||
"countryPin": "Country pin (e.g. JP)",
|
||||
"ipv4Pin": "IPv4 pin",
|
||||
"ipv6Pin": "IPv6 pin",
|
||||
"save": "Save status page info",
|
||||
"saved": "Status page info saved",
|
||||
"saveFailed": "Saving status page info failed"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"defaultTag": "افتراضي",
|
||||
|
||||
@@ -1066,7 +1066,40 @@
|
||||
"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):",
|
||||
"agentMasterUrlRequired": "Master URL is empty — enter the master address first; the install command is generated only then."
|
||||
"agentMasterUrlRequired": "Master URL is empty — enter the master address first; the install command is generated only then.",
|
||||
"probe": {
|
||||
"title": "Public status page",
|
||||
"hint": "Display and plan data for the public status page. Saving takes effect immediately.",
|
||||
"public": "Show on public page",
|
||||
"publicRemark": "Public remark",
|
||||
"group": "Group",
|
||||
"sort": "Sort order",
|
||||
"price": "Price",
|
||||
"currency": "Currency",
|
||||
"billingCycle": "Billing cycle",
|
||||
"cycleOnce": "One-time",
|
||||
"cycleMonthly": "Monthly",
|
||||
"cycleQuarterly": "Quarterly",
|
||||
"cycleSemiannual": "Semiannual",
|
||||
"cycleYearly": "Yearly",
|
||||
"cycleBiennial": "Biennial",
|
||||
"cycleTriennial": "Triennial",
|
||||
"expiresAt": "Expires on (YYYY-MM-DD)",
|
||||
"trafficLimit": "Traffic limit (GB, 0 = unlimited)",
|
||||
"trafficMode": "Traffic counting",
|
||||
"modeSum": "Up + down",
|
||||
"modeUp": "Up only",
|
||||
"modeDown": "Down only",
|
||||
"modeMax": "Larger of up/down",
|
||||
"trafficResetDay": "Traffic reset day",
|
||||
"notify": "Expiry / traffic alerts",
|
||||
"countryPin": "Country pin (e.g. JP)",
|
||||
"ipv4Pin": "IPv4 pin",
|
||||
"ipv6Pin": "IPv6 pin",
|
||||
"save": "Save status page info",
|
||||
"saved": "Status page info saved",
|
||||
"saveFailed": "Saving status page info failed"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"defaultTag": "Default",
|
||||
|
||||
@@ -926,7 +926,40 @@
|
||||
"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):",
|
||||
"agentMasterUrlRequired": "Master URL is empty — enter the master address first; the install command is generated only then."
|
||||
"agentMasterUrlRequired": "Master URL is empty — enter the master address first; the install command is generated only then.",
|
||||
"probe": {
|
||||
"title": "Public status page",
|
||||
"hint": "Display and plan data for the public status page. Saving takes effect immediately.",
|
||||
"public": "Show on public page",
|
||||
"publicRemark": "Public remark",
|
||||
"group": "Group",
|
||||
"sort": "Sort order",
|
||||
"price": "Price",
|
||||
"currency": "Currency",
|
||||
"billingCycle": "Billing cycle",
|
||||
"cycleOnce": "One-time",
|
||||
"cycleMonthly": "Monthly",
|
||||
"cycleQuarterly": "Quarterly",
|
||||
"cycleSemiannual": "Semiannual",
|
||||
"cycleYearly": "Yearly",
|
||||
"cycleBiennial": "Biennial",
|
||||
"cycleTriennial": "Triennial",
|
||||
"expiresAt": "Expires on (YYYY-MM-DD)",
|
||||
"trafficLimit": "Traffic limit (GB, 0 = unlimited)",
|
||||
"trafficMode": "Traffic counting",
|
||||
"modeSum": "Up + down",
|
||||
"modeUp": "Up only",
|
||||
"modeDown": "Down only",
|
||||
"modeMax": "Larger of up/down",
|
||||
"trafficResetDay": "Traffic reset day",
|
||||
"notify": "Expiry / traffic alerts",
|
||||
"countryPin": "Country pin (e.g. JP)",
|
||||
"ipv4Pin": "IPv4 pin",
|
||||
"ipv6Pin": "IPv6 pin",
|
||||
"save": "Save status page info",
|
||||
"saved": "Status page info saved",
|
||||
"saveFailed": "Saving status page info failed"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"defaultTag": "Predeterminado",
|
||||
|
||||
@@ -926,7 +926,40 @@
|
||||
"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):",
|
||||
"agentMasterUrlRequired": "Master URL is empty — enter the master address first; the install command is generated only then."
|
||||
"agentMasterUrlRequired": "Master URL is empty — enter the master address first; the install command is generated only then.",
|
||||
"probe": {
|
||||
"title": "Public status page",
|
||||
"hint": "Display and plan data for the public status page. Saving takes effect immediately.",
|
||||
"public": "Show on public page",
|
||||
"publicRemark": "Public remark",
|
||||
"group": "Group",
|
||||
"sort": "Sort order",
|
||||
"price": "Price",
|
||||
"currency": "Currency",
|
||||
"billingCycle": "Billing cycle",
|
||||
"cycleOnce": "One-time",
|
||||
"cycleMonthly": "Monthly",
|
||||
"cycleQuarterly": "Quarterly",
|
||||
"cycleSemiannual": "Semiannual",
|
||||
"cycleYearly": "Yearly",
|
||||
"cycleBiennial": "Biennial",
|
||||
"cycleTriennial": "Triennial",
|
||||
"expiresAt": "Expires on (YYYY-MM-DD)",
|
||||
"trafficLimit": "Traffic limit (GB, 0 = unlimited)",
|
||||
"trafficMode": "Traffic counting",
|
||||
"modeSum": "Up + down",
|
||||
"modeUp": "Up only",
|
||||
"modeDown": "Down only",
|
||||
"modeMax": "Larger of up/down",
|
||||
"trafficResetDay": "Traffic reset day",
|
||||
"notify": "Expiry / traffic alerts",
|
||||
"countryPin": "Country pin (e.g. JP)",
|
||||
"ipv4Pin": "IPv4 pin",
|
||||
"ipv6Pin": "IPv6 pin",
|
||||
"save": "Save status page info",
|
||||
"saved": "Status page info saved",
|
||||
"saveFailed": "Saving status page info failed"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"defaultTag": "پیشفرض",
|
||||
|
||||
@@ -926,7 +926,40 @@
|
||||
"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):",
|
||||
"agentMasterUrlRequired": "Master URL is empty — enter the master address first; the install command is generated only then."
|
||||
"agentMasterUrlRequired": "Master URL is empty — enter the master address first; the install command is generated only then.",
|
||||
"probe": {
|
||||
"title": "Public status page",
|
||||
"hint": "Display and plan data for the public status page. Saving takes effect immediately.",
|
||||
"public": "Show on public page",
|
||||
"publicRemark": "Public remark",
|
||||
"group": "Group",
|
||||
"sort": "Sort order",
|
||||
"price": "Price",
|
||||
"currency": "Currency",
|
||||
"billingCycle": "Billing cycle",
|
||||
"cycleOnce": "One-time",
|
||||
"cycleMonthly": "Monthly",
|
||||
"cycleQuarterly": "Quarterly",
|
||||
"cycleSemiannual": "Semiannual",
|
||||
"cycleYearly": "Yearly",
|
||||
"cycleBiennial": "Biennial",
|
||||
"cycleTriennial": "Triennial",
|
||||
"expiresAt": "Expires on (YYYY-MM-DD)",
|
||||
"trafficLimit": "Traffic limit (GB, 0 = unlimited)",
|
||||
"trafficMode": "Traffic counting",
|
||||
"modeSum": "Up + down",
|
||||
"modeUp": "Up only",
|
||||
"modeDown": "Down only",
|
||||
"modeMax": "Larger of up/down",
|
||||
"trafficResetDay": "Traffic reset day",
|
||||
"notify": "Expiry / traffic alerts",
|
||||
"countryPin": "Country pin (e.g. JP)",
|
||||
"ipv4Pin": "IPv4 pin",
|
||||
"ipv6Pin": "IPv6 pin",
|
||||
"save": "Save status page info",
|
||||
"saved": "Status page info saved",
|
||||
"saveFailed": "Saving status page info failed"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"defaultTag": "Bawaan",
|
||||
|
||||
@@ -926,7 +926,40 @@
|
||||
"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):",
|
||||
"agentMasterUrlRequired": "Master URL is empty — enter the master address first; the install command is generated only then."
|
||||
"agentMasterUrlRequired": "Master URL is empty — enter the master address first; the install command is generated only then.",
|
||||
"probe": {
|
||||
"title": "Public status page",
|
||||
"hint": "Display and plan data for the public status page. Saving takes effect immediately.",
|
||||
"public": "Show on public page",
|
||||
"publicRemark": "Public remark",
|
||||
"group": "Group",
|
||||
"sort": "Sort order",
|
||||
"price": "Price",
|
||||
"currency": "Currency",
|
||||
"billingCycle": "Billing cycle",
|
||||
"cycleOnce": "One-time",
|
||||
"cycleMonthly": "Monthly",
|
||||
"cycleQuarterly": "Quarterly",
|
||||
"cycleSemiannual": "Semiannual",
|
||||
"cycleYearly": "Yearly",
|
||||
"cycleBiennial": "Biennial",
|
||||
"cycleTriennial": "Triennial",
|
||||
"expiresAt": "Expires on (YYYY-MM-DD)",
|
||||
"trafficLimit": "Traffic limit (GB, 0 = unlimited)",
|
||||
"trafficMode": "Traffic counting",
|
||||
"modeSum": "Up + down",
|
||||
"modeUp": "Up only",
|
||||
"modeDown": "Down only",
|
||||
"modeMax": "Larger of up/down",
|
||||
"trafficResetDay": "Traffic reset day",
|
||||
"notify": "Expiry / traffic alerts",
|
||||
"countryPin": "Country pin (e.g. JP)",
|
||||
"ipv4Pin": "IPv4 pin",
|
||||
"ipv6Pin": "IPv6 pin",
|
||||
"save": "Save status page info",
|
||||
"saved": "Status page info saved",
|
||||
"saveFailed": "Saving status page info failed"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"defaultTag": "デフォルト",
|
||||
|
||||
@@ -926,7 +926,40 @@
|
||||
"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):",
|
||||
"agentMasterUrlRequired": "Master URL is empty — enter the master address first; the install command is generated only then."
|
||||
"agentMasterUrlRequired": "Master URL is empty — enter the master address first; the install command is generated only then.",
|
||||
"probe": {
|
||||
"title": "Public status page",
|
||||
"hint": "Display and plan data for the public status page. Saving takes effect immediately.",
|
||||
"public": "Show on public page",
|
||||
"publicRemark": "Public remark",
|
||||
"group": "Group",
|
||||
"sort": "Sort order",
|
||||
"price": "Price",
|
||||
"currency": "Currency",
|
||||
"billingCycle": "Billing cycle",
|
||||
"cycleOnce": "One-time",
|
||||
"cycleMonthly": "Monthly",
|
||||
"cycleQuarterly": "Quarterly",
|
||||
"cycleSemiannual": "Semiannual",
|
||||
"cycleYearly": "Yearly",
|
||||
"cycleBiennial": "Biennial",
|
||||
"cycleTriennial": "Triennial",
|
||||
"expiresAt": "Expires on (YYYY-MM-DD)",
|
||||
"trafficLimit": "Traffic limit (GB, 0 = unlimited)",
|
||||
"trafficMode": "Traffic counting",
|
||||
"modeSum": "Up + down",
|
||||
"modeUp": "Up only",
|
||||
"modeDown": "Down only",
|
||||
"modeMax": "Larger of up/down",
|
||||
"trafficResetDay": "Traffic reset day",
|
||||
"notify": "Expiry / traffic alerts",
|
||||
"countryPin": "Country pin (e.g. JP)",
|
||||
"ipv4Pin": "IPv4 pin",
|
||||
"ipv6Pin": "IPv6 pin",
|
||||
"save": "Save status page info",
|
||||
"saved": "Status page info saved",
|
||||
"saveFailed": "Saving status page info failed"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"defaultTag": "Padrão",
|
||||
|
||||
@@ -926,7 +926,40 @@
|
||||
"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):",
|
||||
"agentMasterUrlRequired": "Master URL is empty — enter the master address first; the install command is generated only then."
|
||||
"agentMasterUrlRequired": "Master URL is empty — enter the master address first; the install command is generated only then.",
|
||||
"probe": {
|
||||
"title": "Public status page",
|
||||
"hint": "Display and plan data for the public status page. Saving takes effect immediately.",
|
||||
"public": "Show on public page",
|
||||
"publicRemark": "Public remark",
|
||||
"group": "Group",
|
||||
"sort": "Sort order",
|
||||
"price": "Price",
|
||||
"currency": "Currency",
|
||||
"billingCycle": "Billing cycle",
|
||||
"cycleOnce": "One-time",
|
||||
"cycleMonthly": "Monthly",
|
||||
"cycleQuarterly": "Quarterly",
|
||||
"cycleSemiannual": "Semiannual",
|
||||
"cycleYearly": "Yearly",
|
||||
"cycleBiennial": "Biennial",
|
||||
"cycleTriennial": "Triennial",
|
||||
"expiresAt": "Expires on (YYYY-MM-DD)",
|
||||
"trafficLimit": "Traffic limit (GB, 0 = unlimited)",
|
||||
"trafficMode": "Traffic counting",
|
||||
"modeSum": "Up + down",
|
||||
"modeUp": "Up only",
|
||||
"modeDown": "Down only",
|
||||
"modeMax": "Larger of up/down",
|
||||
"trafficResetDay": "Traffic reset day",
|
||||
"notify": "Expiry / traffic alerts",
|
||||
"countryPin": "Country pin (e.g. JP)",
|
||||
"ipv4Pin": "IPv4 pin",
|
||||
"ipv6Pin": "IPv6 pin",
|
||||
"save": "Save status page info",
|
||||
"saved": "Status page info saved",
|
||||
"saveFailed": "Saving status page info failed"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"defaultTag": "По умолчанию",
|
||||
|
||||
@@ -926,7 +926,40 @@
|
||||
"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):",
|
||||
"agentMasterUrlRequired": "Master URL is empty — enter the master address first; the install command is generated only then."
|
||||
"agentMasterUrlRequired": "Master URL is empty — enter the master address first; the install command is generated only then.",
|
||||
"probe": {
|
||||
"title": "Public status page",
|
||||
"hint": "Display and plan data for the public status page. Saving takes effect immediately.",
|
||||
"public": "Show on public page",
|
||||
"publicRemark": "Public remark",
|
||||
"group": "Group",
|
||||
"sort": "Sort order",
|
||||
"price": "Price",
|
||||
"currency": "Currency",
|
||||
"billingCycle": "Billing cycle",
|
||||
"cycleOnce": "One-time",
|
||||
"cycleMonthly": "Monthly",
|
||||
"cycleQuarterly": "Quarterly",
|
||||
"cycleSemiannual": "Semiannual",
|
||||
"cycleYearly": "Yearly",
|
||||
"cycleBiennial": "Biennial",
|
||||
"cycleTriennial": "Triennial",
|
||||
"expiresAt": "Expires on (YYYY-MM-DD)",
|
||||
"trafficLimit": "Traffic limit (GB, 0 = unlimited)",
|
||||
"trafficMode": "Traffic counting",
|
||||
"modeSum": "Up + down",
|
||||
"modeUp": "Up only",
|
||||
"modeDown": "Down only",
|
||||
"modeMax": "Larger of up/down",
|
||||
"trafficResetDay": "Traffic reset day",
|
||||
"notify": "Expiry / traffic alerts",
|
||||
"countryPin": "Country pin (e.g. JP)",
|
||||
"ipv4Pin": "IPv4 pin",
|
||||
"ipv6Pin": "IPv6 pin",
|
||||
"save": "Save status page info",
|
||||
"saved": "Status page info saved",
|
||||
"saveFailed": "Saving status page info failed"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"defaultTag": "Varsayılan",
|
||||
|
||||
@@ -926,7 +926,40 @@
|
||||
"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):",
|
||||
"agentMasterUrlRequired": "Master URL is empty — enter the master address first; the install command is generated only then."
|
||||
"agentMasterUrlRequired": "Master URL is empty — enter the master address first; the install command is generated only then.",
|
||||
"probe": {
|
||||
"title": "Public status page",
|
||||
"hint": "Display and plan data for the public status page. Saving takes effect immediately.",
|
||||
"public": "Show on public page",
|
||||
"publicRemark": "Public remark",
|
||||
"group": "Group",
|
||||
"sort": "Sort order",
|
||||
"price": "Price",
|
||||
"currency": "Currency",
|
||||
"billingCycle": "Billing cycle",
|
||||
"cycleOnce": "One-time",
|
||||
"cycleMonthly": "Monthly",
|
||||
"cycleQuarterly": "Quarterly",
|
||||
"cycleSemiannual": "Semiannual",
|
||||
"cycleYearly": "Yearly",
|
||||
"cycleBiennial": "Biennial",
|
||||
"cycleTriennial": "Triennial",
|
||||
"expiresAt": "Expires on (YYYY-MM-DD)",
|
||||
"trafficLimit": "Traffic limit (GB, 0 = unlimited)",
|
||||
"trafficMode": "Traffic counting",
|
||||
"modeSum": "Up + down",
|
||||
"modeUp": "Up only",
|
||||
"modeDown": "Down only",
|
||||
"modeMax": "Larger of up/down",
|
||||
"trafficResetDay": "Traffic reset day",
|
||||
"notify": "Expiry / traffic alerts",
|
||||
"countryPin": "Country pin (e.g. JP)",
|
||||
"ipv4Pin": "IPv4 pin",
|
||||
"ipv6Pin": "IPv6 pin",
|
||||
"save": "Save status page info",
|
||||
"saved": "Status page info saved",
|
||||
"saveFailed": "Saving status page info failed"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"defaultTag": "Типово",
|
||||
|
||||
@@ -926,7 +926,40 @@
|
||||
"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):",
|
||||
"agentMasterUrlRequired": "Master URL is empty — enter the master address first; the install command is generated only then."
|
||||
"agentMasterUrlRequired": "Master URL is empty — enter the master address first; the install command is generated only then.",
|
||||
"probe": {
|
||||
"title": "Public status page",
|
||||
"hint": "Display and plan data for the public status page. Saving takes effect immediately.",
|
||||
"public": "Show on public page",
|
||||
"publicRemark": "Public remark",
|
||||
"group": "Group",
|
||||
"sort": "Sort order",
|
||||
"price": "Price",
|
||||
"currency": "Currency",
|
||||
"billingCycle": "Billing cycle",
|
||||
"cycleOnce": "One-time",
|
||||
"cycleMonthly": "Monthly",
|
||||
"cycleQuarterly": "Quarterly",
|
||||
"cycleSemiannual": "Semiannual",
|
||||
"cycleYearly": "Yearly",
|
||||
"cycleBiennial": "Biennial",
|
||||
"cycleTriennial": "Triennial",
|
||||
"expiresAt": "Expires on (YYYY-MM-DD)",
|
||||
"trafficLimit": "Traffic limit (GB, 0 = unlimited)",
|
||||
"trafficMode": "Traffic counting",
|
||||
"modeSum": "Up + down",
|
||||
"modeUp": "Up only",
|
||||
"modeDown": "Down only",
|
||||
"modeMax": "Larger of up/down",
|
||||
"trafficResetDay": "Traffic reset day",
|
||||
"notify": "Expiry / traffic alerts",
|
||||
"countryPin": "Country pin (e.g. JP)",
|
||||
"ipv4Pin": "IPv4 pin",
|
||||
"ipv6Pin": "IPv6 pin",
|
||||
"save": "Save status page info",
|
||||
"saved": "Status page info saved",
|
||||
"saveFailed": "Saving status page info failed"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"defaultTag": "Mặc định",
|
||||
|
||||
@@ -948,7 +948,40 @@
|
||||
"agentTokenCopyFailed": "复制失败,请手动复制",
|
||||
"copyToken": "复制安装命令",
|
||||
"agentMasterUrlHint": "主控地址(agent 用来连接主控,如有域名或反代请改成对外地址):",
|
||||
"agentMasterUrlRequired": "主控地址为空——请先填入主控地址,之后才会生成安装命令。"
|
||||
"agentMasterUrlRequired": "主控地址为空——请先填入主控地址,之后才会生成安装命令。",
|
||||
"probe": {
|
||||
"title": "公开状态页",
|
||||
"hint": "公开状态页的展示与套餐信息,保存后立即生效。",
|
||||
"public": "在公开页显示",
|
||||
"publicRemark": "公开备注",
|
||||
"group": "分组",
|
||||
"sort": "排序",
|
||||
"price": "价格",
|
||||
"currency": "货币",
|
||||
"billingCycle": "付款周期",
|
||||
"cycleOnce": "一次性",
|
||||
"cycleMonthly": "每月",
|
||||
"cycleQuarterly": "每季",
|
||||
"cycleSemiannual": "每半年",
|
||||
"cycleYearly": "每年",
|
||||
"cycleBiennial": "每两年",
|
||||
"cycleTriennial": "每三年",
|
||||
"expiresAt": "到期日期(YYYY-MM-DD)",
|
||||
"trafficLimit": "流量上限(GB,0 = 不限)",
|
||||
"trafficMode": "流量统计口径",
|
||||
"modeSum": "上行 + 下行",
|
||||
"modeUp": "只算上行",
|
||||
"modeDown": "只算下行",
|
||||
"modeMax": "取上下行较大值",
|
||||
"trafficResetDay": "流量重置日",
|
||||
"notify": "到期 / 流量提醒",
|
||||
"countryPin": "国家锁定(如 JP)",
|
||||
"ipv4Pin": "IPv4 锁定",
|
||||
"ipv6Pin": "IPv6 锁定",
|
||||
"save": "保存状态页信息",
|
||||
"saved": "状态页信息已保存",
|
||||
"saveFailed": "状态页信息保存失败"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"defaultTag": "默认",
|
||||
|
||||
@@ -926,7 +926,40 @@
|
||||
"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):",
|
||||
"agentMasterUrlRequired": "Master URL is empty — enter the master address first; the install command is generated only then."
|
||||
"agentMasterUrlRequired": "Master URL is empty — enter the master address first; the install command is generated only then.",
|
||||
"probe": {
|
||||
"title": "Public status page",
|
||||
"hint": "Display and plan data for the public status page. Saving takes effect immediately.",
|
||||
"public": "Show on public page",
|
||||
"publicRemark": "Public remark",
|
||||
"group": "Group",
|
||||
"sort": "Sort order",
|
||||
"price": "Price",
|
||||
"currency": "Currency",
|
||||
"billingCycle": "Billing cycle",
|
||||
"cycleOnce": "One-time",
|
||||
"cycleMonthly": "Monthly",
|
||||
"cycleQuarterly": "Quarterly",
|
||||
"cycleSemiannual": "Semiannual",
|
||||
"cycleYearly": "Yearly",
|
||||
"cycleBiennial": "Biennial",
|
||||
"cycleTriennial": "Triennial",
|
||||
"expiresAt": "Expires on (YYYY-MM-DD)",
|
||||
"trafficLimit": "Traffic limit (GB, 0 = unlimited)",
|
||||
"trafficMode": "Traffic counting",
|
||||
"modeSum": "Up + down",
|
||||
"modeUp": "Up only",
|
||||
"modeDown": "Down only",
|
||||
"modeMax": "Larger of up/down",
|
||||
"trafficResetDay": "Traffic reset day",
|
||||
"notify": "Expiry / traffic alerts",
|
||||
"countryPin": "Country pin (e.g. JP)",
|
||||
"ipv4Pin": "IPv4 pin",
|
||||
"ipv6Pin": "IPv6 pin",
|
||||
"save": "Save status page info",
|
||||
"saved": "Status page info saved",
|
||||
"saveFailed": "Saving status page info failed"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"defaultTag": "預設",
|
||||
|
||||
@@ -112,6 +112,7 @@ type Server struct {
|
||||
|
||||
index *controller.IndexController
|
||||
panel *controller.XUIController
|
||||
probe *controller.PublicProbeController
|
||||
api *controller.APIController
|
||||
ws *controller.WebSocketController
|
||||
|
||||
@@ -251,6 +252,13 @@ func (s *Server) initRouter() (*gin.Engine, error) {
|
||||
s.panel = controller.NewXUIController(g)
|
||||
s.api = controller.NewAPIController(g)
|
||||
|
||||
// Item 7 B3: the monitor contract API + public theme hosting live
|
||||
// at the engine root, outside the basePath group (themes address
|
||||
// /api/... root-relative; see controller/probe_public.go). Panel
|
||||
// session cookies cover the root too, so admin rendering works
|
||||
// from the same login.
|
||||
s.probe = controller.NewPublicProbeController(engine)
|
||||
|
||||
// Initialize WebSocket hub
|
||||
s.wsHub = websocket.NewHub()
|
||||
go s.wsHub.Run()
|
||||
@@ -269,6 +277,12 @@ func (s *Server) initRouter() (*gin.Engine, error) {
|
||||
// Let unknown panel document routes fall back to the SPA shell, while every
|
||||
// non-SPA miss still returns a hard 404.
|
||||
engine.NoRoute(func(c *gin.Context) {
|
||||
// The public probe surface answers first for paths outside the
|
||||
// panel basePath (theme hosting + /api 404s); panel paths keep
|
||||
// their SPA fallback.
|
||||
if s.probe != nil && s.probe.HandleFallback(c) {
|
||||
return
|
||||
}
|
||||
if s.panel.HandleNoRoutePanelSPA(c) {
|
||||
return
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user