Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a81eff8c0d | ||
|
|
1d7eb16784 | ||
|
|
a13179ed9b | ||
|
|
93c1620c15 | ||
|
|
ba57fb3c00 | ||
|
|
151b07b73f | ||
|
|
cec5905483 | ||
|
|
1df78dd874 | ||
|
|
92dfdc542c | ||
|
|
a25c51003e | ||
|
|
b27bcfcb5b | ||
|
|
75f10b27fa | ||
|
|
7df3a338ef | ||
|
|
5ca22ec340 | ||
|
|
2d37a6de18 | ||
|
|
db6f666ff7 | ||
|
|
d4d259fa58 | ||
|
|
9caa4e0cf9 | ||
|
|
29de9761c8 | ||
|
|
c2f1840535 | ||
|
|
06de0e8384 | ||
|
|
0a6e9b7a7d | ||
|
|
7e15bfbf8e | ||
|
|
6de22ae2e3 | ||
|
|
05c164dc8f | ||
|
|
c610ab88ea | ||
|
|
4a532e8c5a | ||
|
|
3e2829e49d | ||
|
|
19bf6fbc22 | ||
|
|
460b314786 | ||
|
|
3a02211555 | ||
|
|
268e6596bc | ||
|
|
7df7b530d3 | ||
|
|
9e2807d3ec | ||
|
|
e43bd68986 | ||
|
|
b889526b0d | ||
|
|
031b422365 | ||
|
|
277838d7ca | ||
|
|
f646212df2 | ||
|
|
2ee20b9691 | ||
|
|
000731c4e0 | ||
|
|
2558efd9fb | ||
|
|
7ad65652ec | ||
|
|
1762e54d5f | ||
|
|
1763f94a5f | ||
|
|
0d1607c358 | ||
|
|
e03ceaa41f | ||
|
|
741cd905ff | ||
|
|
540c05b0a5 | ||
|
|
3e052c42b8 | ||
|
|
39ad8b8a2e | ||
|
|
1bd63e6625 | ||
|
|
a47e64f70a | ||
|
|
0a973bc95c | ||
|
|
9843a7ce4e | ||
|
|
750a270955 | ||
|
|
3413ccca9f |
@@ -19,6 +19,9 @@ backup/
|
|||||||
bin/
|
bin/
|
||||||
x-ui/
|
x-ui/
|
||||||
dist/
|
dist/
|
||||||
|
# Exception: the vendored embedded fallback theme ships its dist/ as
|
||||||
|
# source (go:embed) — it is not a build output.
|
||||||
|
!/internal/web/service/probe_theme_default/dist/
|
||||||
release/
|
release/
|
||||||
node_modules/
|
node_modules/
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,270 @@
|
|||||||
|
//go:build windows
|
||||||
|
|
||||||
|
// Command xui-agent-windows is the standalone Windows build of the
|
||||||
|
// 3x-ui node agent. Windows nodes are probe-only by design: the agent
|
||||||
|
// reports host facts/metrics and runs latency probes, but no xray or
|
||||||
|
// sing-box core is ever fetched or started here (the agent core
|
||||||
|
// machinery stays the master's Linux fleet concern; the local
|
||||||
|
// agent.json carries "probeOnly": true, written by
|
||||||
|
// install-agent.ps1).
|
||||||
|
//
|
||||||
|
// Usage:
|
||||||
|
//
|
||||||
|
// xui-agent-windows.exe [-config C:\x-ui\agent.json] run (service or console)
|
||||||
|
// xui-agent-windows.exe install [-config <path>] register the service
|
||||||
|
// xui-agent-windows.exe uninstall remove the service
|
||||||
|
// xui-agent-windows.exe start | stop control the service
|
||||||
|
//
|
||||||
|
// The agent lifecycle wiring (config load, baseline derivation,
|
||||||
|
// logger init, graceful stop) mirrors main.go's agent branch — the
|
||||||
|
// same internal/agent package does the work either way.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"path/filepath"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/internal/agent"
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||||
|
|
||||||
|
"github.com/op/go-logging"
|
||||||
|
"golang.org/x/sys/windows/svc"
|
||||||
|
"golang.org/x/sys/windows/svc/mgr"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
serviceName = "x-ui-agent"
|
||||||
|
defaultCfgPath = `C:\x-ui\agent.json`
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
log.SetPrefix("[x-ui-agent] ")
|
||||||
|
args := os.Args[1:]
|
||||||
|
|
||||||
|
// Management subcommands come first; anything else is a run.
|
||||||
|
if len(args) > 0 {
|
||||||
|
switch args[0] {
|
||||||
|
case "install", "uninstall", "start", "stop":
|
||||||
|
cfgPath := parseConfigFlag("xui-agent-windows "+args[0], args[1:])
|
||||||
|
if err := runControl(args[0], cfgPath); err != nil {
|
||||||
|
log.Fatalf("%s: %v", args[0], err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cfgPath := parseConfigFlag("xui-agent-windows", args)
|
||||||
|
|
||||||
|
isService, err := svc.IsWindowsService()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("detect service environment: %v", err)
|
||||||
|
}
|
||||||
|
if isService {
|
||||||
|
if err := svc.Run(serviceName, &agentHandler{cfgPath: cfgPath}); err != nil {
|
||||||
|
log.Fatalf("service run: %v", err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Interactive run: console foreground, Ctrl+C stops gracefully.
|
||||||
|
running, err := startAgent(cfgPath)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("start agent: %v", err)
|
||||||
|
}
|
||||||
|
sigCh := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
|
||||||
|
select {
|
||||||
|
case <-sigCh:
|
||||||
|
log.Println("Shutting down agent...")
|
||||||
|
running.Stop()
|
||||||
|
<-running.done
|
||||||
|
case err := <-running.done:
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Agent error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseConfigFlag(name string, args []string) string {
|
||||||
|
fs := flag.NewFlagSet(name, flag.ExitOnError)
|
||||||
|
cfg := fs.String("config", defaultCfgPath, "Path to agent config file")
|
||||||
|
_ = fs.Parse(args)
|
||||||
|
return *cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
// runningAgent is one started agent plus its completion signal.
|
||||||
|
type runningAgent struct {
|
||||||
|
ag *agent.Agent
|
||||||
|
done chan error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *runningAgent) Stop() { r.ag.Stop() }
|
||||||
|
|
||||||
|
// startAgent performs the same setup as main.go's agent branch and
|
||||||
|
// launches the agent loop in the background.
|
||||||
|
func startAgent(cfgPath string) (*runningAgent, error) {
|
||||||
|
// Agent mode uses the panel's directory layout for core binaries
|
||||||
|
// and configs; on a probe-only Windows node nothing consumes it,
|
||||||
|
// but keep the wiring identical to the Linux entry so behaviour
|
||||||
|
// cannot drift between the two.
|
||||||
|
if os.Getenv("XUI_BIN_FOLDER") == "" {
|
||||||
|
_ = os.Setenv("XUI_BIN_FOLDER", agent.DefaultBinFolder)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := agent.LoadConfig(cfgPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("load agent config from %s: %w", cfgPath, err)
|
||||||
|
}
|
||||||
|
// The acknowledged traffic baseline persists next to the agent
|
||||||
|
// config (its state home), unless the config names a path.
|
||||||
|
if cfg.BaselinePath == "" {
|
||||||
|
cfg.BaselinePath = filepath.Join(filepath.Dir(cfgPath), "agent-traffic-baseline.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.InitLogger(logging.INFO)
|
||||||
|
log.Printf("Starting 3x-ui agent (node: %s)", cfg.NodeName)
|
||||||
|
|
||||||
|
ag := agent.New(cfg)
|
||||||
|
r := &runningAgent{ag: ag, done: make(chan error, 1)}
|
||||||
|
go func() { r.done <- ag.Run() }()
|
||||||
|
return r, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// agentHandler adapts the agent loop to the SCM protocol.
|
||||||
|
type agentHandler struct {
|
||||||
|
cfgPath string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *agentHandler) Execute(_ []string, r <-chan svc.ChangeRequest, status chan<- svc.Status) (bool, uint32) {
|
||||||
|
const accepted = svc.AcceptStop | svc.AcceptShutdown
|
||||||
|
status <- svc.Status{State: svc.StartPending}
|
||||||
|
running, err := startAgent(h.cfgPath)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("start agent: %v", err)
|
||||||
|
return false, 1
|
||||||
|
}
|
||||||
|
status <- svc.Status{State: svc.Running, Accepts: accepted}
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case err := <-running.done:
|
||||||
|
// The agent loop ended on its own (e.g. fatal run error).
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("agent exited: %v", err)
|
||||||
|
return false, 1
|
||||||
|
}
|
||||||
|
return false, 0
|
||||||
|
case c := <-r:
|
||||||
|
switch c.Cmd {
|
||||||
|
case svc.Interrogate:
|
||||||
|
status <- c.CurrentStatus
|
||||||
|
case svc.Stop, svc.Shutdown:
|
||||||
|
status <- svc.Status{State: svc.StopPending}
|
||||||
|
running.Stop()
|
||||||
|
<-running.done
|
||||||
|
return false, 0
|
||||||
|
default:
|
||||||
|
log.Printf("unexpected service command: %v", c.Cmd)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// runControl implements the install/uninstall/start/stop subcommands
|
||||||
|
// through the service control manager.
|
||||||
|
func runControl(cmd, cfgPath string) error {
|
||||||
|
m, err := mgr.ConnectRemote("")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("connect to service manager: %w", err)
|
||||||
|
}
|
||||||
|
defer m.Disconnect()
|
||||||
|
|
||||||
|
switch cmd {
|
||||||
|
case "install":
|
||||||
|
exePath, err := os.Executable()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("locate own binary: %w", err)
|
||||||
|
}
|
||||||
|
absCfg, err := filepath.Abs(cfgPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("resolve config path: %w", err)
|
||||||
|
}
|
||||||
|
if s, err := m.OpenService(serviceName); err == nil {
|
||||||
|
s.Close()
|
||||||
|
return fmt.Errorf("service %s already exists (uninstall first)", serviceName)
|
||||||
|
}
|
||||||
|
s, err := m.CreateService(serviceName, exePath, mgr.Config{
|
||||||
|
DisplayName: "3x-ui Agent",
|
||||||
|
Description: "3x-ui probe-only node agent (facts, metrics and latency probes; runs no proxy core).",
|
||||||
|
StartType: mgr.StartAutomatic,
|
||||||
|
ErrorControl: mgr.ErrorNormal,
|
||||||
|
}, "-config", absCfg)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create service: %w", err)
|
||||||
|
}
|
||||||
|
defer s.Close()
|
||||||
|
log.Printf("service %s installed (config: %s)", serviceName, absCfg)
|
||||||
|
return nil
|
||||||
|
|
||||||
|
case "uninstall":
|
||||||
|
s, err := openService(m)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer s.Close()
|
||||||
|
// Stop first so the binary is not locked when the operator
|
||||||
|
// deletes or replaces it; an already-stopped service is fine.
|
||||||
|
_, _ = s.Control(svc.Stop)
|
||||||
|
deadline := time.Now().Add(15 * time.Second)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
st, err := s.Query()
|
||||||
|
if err != nil || st.State == svc.Stopped {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
time.Sleep(300 * time.Millisecond)
|
||||||
|
}
|
||||||
|
if err := s.Delete(); err != nil {
|
||||||
|
return fmt.Errorf("delete service: %w", err)
|
||||||
|
}
|
||||||
|
log.Printf("service %s removed", serviceName)
|
||||||
|
return nil
|
||||||
|
|
||||||
|
case "start":
|
||||||
|
s, err := openService(m)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer s.Close()
|
||||||
|
if err := s.Start(); err != nil {
|
||||||
|
return fmt.Errorf("start service: %w", err)
|
||||||
|
}
|
||||||
|
log.Printf("service %s started", serviceName)
|
||||||
|
return nil
|
||||||
|
|
||||||
|
case "stop":
|
||||||
|
s, err := openService(m)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer s.Close()
|
||||||
|
if _, err := s.Control(svc.Stop); err != nil {
|
||||||
|
return fmt.Errorf("stop service: %w", err)
|
||||||
|
}
|
||||||
|
log.Printf("service %s stop requested", serviceName)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("unknown subcommand %q", cmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
func openService(m *mgr.Mgr) (*mgr.Service, error) {
|
||||||
|
s, err := m.OpenService(serviceName)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("open service %s: %w", serviceName, err)
|
||||||
|
}
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
@@ -54,7 +54,7 @@ export default function Layout(props) {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{props.children}
|
{props.children}
|
||||||
<Comp document="./public/openapi.json" webhooks={[]} operations={[{"path":"/login","method":"post"},{"path":"/logout","method":"post"},{"path":"/csrf-token","method":"get"},{"path":"/getTwoFactorEnable","method":"post"}]} showTitle />
|
<Comp document="./public/openapi.json" webhooks={[]} operations={[{"path":"/login","method":"post"},{"path":"/logout","method":"post"},{"path":"/csrf-token","method":"get"}]} showTitle />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -69,7 +69,7 @@ export default function Layout(props) {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{props.children}
|
{props.children}
|
||||||
<Comp document="./public/openapi.json" webhooks={[]} operations={[{"path":"/login","method":"post"},{"path":"/logout","method":"post"},{"path":"/csrf-token","method":"get"},{"path":"/getTwoFactorEnable","method":"post"}]} showTitle />
|
<Comp document="./public/openapi.json" webhooks={[]} operations={[{"path":"/login","method":"post"},{"path":"/logout","method":"post"},{"path":"/csrf-token","method":"get"}]} showTitle />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -69,7 +69,7 @@ export default function Layout(props) {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{props.children}
|
{props.children}
|
||||||
<Comp document="./public/openapi.json" webhooks={[]} operations={[{"path":"/login","method":"post"},{"path":"/logout","method":"post"},{"path":"/csrf-token","method":"get"},{"path":"/getTwoFactorEnable","method":"post"}]} showTitle />
|
<Comp document="./public/openapi.json" webhooks={[]} operations={[{"path":"/login","method":"post"},{"path":"/logout","method":"post"},{"path":"/csrf-token","method":"get"}]} showTitle />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -66,7 +66,7 @@ export default function Layout(props) {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{props.children}
|
{props.children}
|
||||||
<Comp document="./public/openapi.json" webhooks={[]} operations={[{"path":"/login","method":"post"},{"path":"/logout","method":"post"},{"path":"/csrf-token","method":"get"},{"path":"/getTwoFactorEnable","method":"post"}]} showTitle />
|
<Comp document="./public/openapi.json" webhooks={[]} operations={[{"path":"/login","method":"post"},{"path":"/logout","method":"post"},{"path":"/csrf-token","method":"get"}]} showTitle />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -127,6 +127,10 @@ async function performFetch(
|
|||||||
if (data instanceof FormData) {
|
if (data instanceof FormData) {
|
||||||
body = data;
|
body = data;
|
||||||
headers.delete('Content-Type');
|
headers.delete('Content-Type');
|
||||||
|
} else if (data instanceof Blob || data instanceof ArrayBuffer) {
|
||||||
|
// Raw payload (e.g. one chunk of a theme upload): sent as-is.
|
||||||
|
body = data as BodyInit;
|
||||||
|
if (!headers.has('Content-Type')) headers.set('Content-Type', 'application/octet-stream');
|
||||||
} else if (!SAFE_METHODS.has(upper)) {
|
} else if (!SAFE_METHODS.has(upper)) {
|
||||||
const declaredType = (headers.get('Content-Type') || '').toLowerCase();
|
const declaredType = (headers.get('Content-Type') || '').toLowerCase();
|
||||||
if (declaredType.startsWith('application/json')) {
|
if (declaredType.startsWith('application/json')) {
|
||||||
|
|||||||
@@ -1,111 +0,0 @@
|
|||||||
import { keepPreviousData, useMutation, useQuery } from '@tanstack/react-query';
|
|
||||||
import { z } from 'zod';
|
|
||||||
|
|
||||||
import { keys } from '@/api/queryKeys';
|
|
||||||
import {
|
|
||||||
GeoCategoryPageSchema,
|
|
||||||
GeoEntryPageSchema,
|
|
||||||
GeoFileSchema,
|
|
||||||
GeodataTokenIssueSchema,
|
|
||||||
} from '@/generated/zod';
|
|
||||||
import type { GeoCategoryPage, GeoEntryPage, GeoFile, GeodataTokenIssue } from '@/generated/types';
|
|
||||||
import { HttpUtil } from '@/utils';
|
|
||||||
import { parseMsg } from '@/utils/zodValidate';
|
|
||||||
|
|
||||||
const GeoFileListSchema = z.array(GeoFileSchema);
|
|
||||||
const GeodataTokenIssueListSchema = z.array(GeodataTokenIssueSchema);
|
|
||||||
|
|
||||||
const EMPTY_CATEGORY_PAGE: GeoCategoryPage = { total: 0, items: [] };
|
|
||||||
const EMPTY_ENTRY_PAGE: GeoEntryPage = { total: 0, items: [] };
|
|
||||||
|
|
||||||
export type GeoTokenKind = 'ip' | 'domain';
|
|
||||||
|
|
||||||
export interface ValidateGeoTokensInput {
|
|
||||||
tokens: string[];
|
|
||||||
kind: GeoTokenKind;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchGeodataFiles(): Promise<GeoFile[]> {
|
|
||||||
const msg = await HttpUtil.get('/panel/api/xray/geodata/files', undefined, { silent: true });
|
|
||||||
if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch geodata files');
|
|
||||||
const validated = parseMsg(msg, GeoFileListSchema, 'xray/geodata/files');
|
|
||||||
return Array.isArray(validated.obj) ? validated.obj : [];
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchGeodataCategories(file: string, query: string): Promise<GeoCategoryPage> {
|
|
||||||
const msg = await HttpUtil.get(
|
|
||||||
'/panel/api/xray/geodata/categories',
|
|
||||||
{ file, q: query },
|
|
||||||
{ silent: true },
|
|
||||||
);
|
|
||||||
if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch geodata categories');
|
|
||||||
const validated = parseMsg(msg, GeoCategoryPageSchema, 'xray/geodata/categories');
|
|
||||||
return validated.obj ?? EMPTY_CATEGORY_PAGE;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchGeodataEntries(
|
|
||||||
file: string,
|
|
||||||
code: string,
|
|
||||||
query: string,
|
|
||||||
offset: number,
|
|
||||||
limit: number,
|
|
||||||
): Promise<GeoEntryPage> {
|
|
||||||
const msg = await HttpUtil.get(
|
|
||||||
'/panel/api/xray/geodata/entries',
|
|
||||||
{ file, code, q: query, offset, limit },
|
|
||||||
{ silent: true },
|
|
||||||
);
|
|
||||||
if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch geodata entries');
|
|
||||||
const validated = parseMsg(msg, GeoEntryPageSchema, 'xray/geodata/entries');
|
|
||||||
return validated.obj ?? EMPTY_ENTRY_PAGE;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useGeodataFiles(enabled: boolean) {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: keys.xray.geodata.files(),
|
|
||||||
queryFn: fetchGeodataFiles,
|
|
||||||
enabled,
|
|
||||||
staleTime: 5 * 60 * 1000,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useGeodataCategories(file: string | undefined, query: string, enabled: boolean) {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: keys.xray.geodata.categories(file ?? '', query),
|
|
||||||
queryFn: () => fetchGeodataCategories(file ?? '', query),
|
|
||||||
enabled: enabled && !!file,
|
|
||||||
staleTime: 5 * 60 * 1000,
|
|
||||||
placeholderData: keepPreviousData,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useGeodataEntries(
|
|
||||||
file: string | undefined,
|
|
||||||
code: string | undefined,
|
|
||||||
query: string,
|
|
||||||
offset: number,
|
|
||||||
limit: number,
|
|
||||||
enabled: boolean,
|
|
||||||
) {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: keys.xray.geodata.entries(file ?? '', code ?? '', query, offset, limit),
|
|
||||||
queryFn: () => fetchGeodataEntries(file ?? '', code ?? '', query, offset, limit),
|
|
||||||
enabled: enabled && !!file && !!code,
|
|
||||||
placeholderData: keepPreviousData,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useValidateGeoTokens() {
|
|
||||||
return useMutation<GeodataTokenIssue[], Error, ValidateGeoTokensInput>({
|
|
||||||
mutationFn: async ({ tokens, kind }) => {
|
|
||||||
const msg = await HttpUtil.post(
|
|
||||||
'/panel/api/xray/geodata/validate',
|
|
||||||
{ tokens: tokens.join(','), kind },
|
|
||||||
{ silent: true },
|
|
||||||
);
|
|
||||||
if (!msg?.success) throw new Error(msg?.msg || 'Failed to validate geodata tokens');
|
|
||||||
const validated = parseMsg(msg, GeodataTokenIssueListSchema, 'xray/geodata/validate');
|
|
||||||
return Array.isArray(validated.obj) ? validated.obj : [];
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,36 +1,58 @@
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
import { HttpUtil } from '@/utils';
|
||||||
import { keys } from '@/api/queryKeys';
|
import { keys } from '@/api/queryKeys';
|
||||||
import { fetchXrayConfig } from '@/hooks/useXraySetting';
|
|
||||||
|
// The outbound tag directory: the factory template's outbounds and
|
||||||
|
// routing balancers plus the tags contributed by outbound
|
||||||
|
// subscriptions. It used to be derived from the xray settings page's
|
||||||
|
// config payload; since the template became code-fixed (ITEM6 B4)
|
||||||
|
// the panel serves the same directory from a dedicated read endpoint.
|
||||||
|
export interface OutboundDirectory {
|
||||||
|
outbounds: { tag: string; protocol: string }[];
|
||||||
|
balancers: string[];
|
||||||
|
subscriptionOutboundTags: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchOutboundDirectory(): Promise<OutboundDirectory> {
|
||||||
|
const msg = await HttpUtil.get('/panel/api/server/outboundDirectory', undefined, {
|
||||||
|
silent: true,
|
||||||
|
});
|
||||||
|
if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch outbound directory');
|
||||||
|
const obj = (msg.obj ?? {}) as Partial<OutboundDirectory>;
|
||||||
|
return {
|
||||||
|
outbounds: Array.isArray(obj.outbounds) ? obj.outbounds : [],
|
||||||
|
balancers: Array.isArray(obj.balancers) ? obj.balancers : [],
|
||||||
|
subscriptionOutboundTags: Array.isArray(obj.subscriptionOutboundTags)
|
||||||
|
? obj.subscriptionOutboundTags
|
||||||
|
: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// Available outbound (and balancer-eligible) tags the user can route an mtproto
|
// Available outbound (and balancer-eligible) tags the user can route an mtproto
|
||||||
// inbound's Telegram traffic to. Shares the cached xray config query so opening
|
// inbound's Telegram traffic to. Shares one cached directory query so opening
|
||||||
// the inbound form costs no extra request when the Xray page was already
|
// the inbound form costs no extra request when another picker already
|
||||||
// visited; `select` derives just the tag list without disturbing other readers.
|
// visited; `select` derives just the tag list without disturbing other readers.
|
||||||
export function useOutboundTags(opts?: { excludeBlackhole?: boolean }) {
|
export function useOutboundTags(opts?: { excludeBlackhole?: boolean }) {
|
||||||
const excludeBlackhole = opts?.excludeBlackhole ?? false;
|
const excludeBlackhole = opts?.excludeBlackhole ?? false;
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: keys.xray.config(),
|
queryKey: keys.server.outboundDirectory(),
|
||||||
queryFn: fetchXrayConfig,
|
queryFn: fetchOutboundDirectory,
|
||||||
staleTime: Infinity,
|
staleTime: Infinity,
|
||||||
select: (data): string[] => {
|
select: (data): string[] => {
|
||||||
const tags = new Set<string>();
|
const tags = new Set<string>();
|
||||||
for (const o of data?.xraySetting?.outbounds ?? []) {
|
for (const ob of data.outbounds) {
|
||||||
const ob = o as { tag?: string; protocol?: string } | null;
|
|
||||||
if (!ob?.tag) continue;
|
if (!ob?.tag) continue;
|
||||||
if (excludeBlackhole && ob.protocol === 'blackhole') continue;
|
if (excludeBlackhole && ob.protocol === 'blackhole') continue;
|
||||||
tags.add(ob.tag);
|
tags.add(ob.tag);
|
||||||
}
|
}
|
||||||
for (const t of data?.subscriptionOutboundTags ?? []) {
|
for (const t of data.subscriptionOutboundTags) {
|
||||||
if (t) tags.add(t);
|
if (t) tags.add(t);
|
||||||
}
|
}
|
||||||
// Balancers are valid routing targets too — injectMtprotoEgress emits a
|
// Balancers are valid routing targets too — injectMtprotoEgress emits a
|
||||||
// balancerTag rule when the chosen tag names a balancer.
|
// balancerTag rule when the chosen tag names a balancer.
|
||||||
const balancers = (
|
for (const tag of data.balancers) {
|
||||||
data?.xraySetting?.routing as { balancers?: Array<{ tag?: string }> } | undefined
|
if (tag) tags.add(tag);
|
||||||
)?.balancers;
|
|
||||||
for (const b of balancers ?? []) {
|
|
||||||
if (b?.tag) tags.add(b.tag);
|
|
||||||
}
|
}
|
||||||
return [...tags];
|
return [...tags];
|
||||||
},
|
},
|
||||||
@@ -48,26 +70,22 @@ export interface OutboundTagGroups {
|
|||||||
export function useOutboundTagGroups(opts?: { excludeBlackhole?: boolean }) {
|
export function useOutboundTagGroups(opts?: { excludeBlackhole?: boolean }) {
|
||||||
const excludeBlackhole = opts?.excludeBlackhole ?? false;
|
const excludeBlackhole = opts?.excludeBlackhole ?? false;
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: keys.xray.config(),
|
queryKey: keys.server.outboundDirectory(),
|
||||||
queryFn: fetchXrayConfig,
|
queryFn: fetchOutboundDirectory,
|
||||||
staleTime: Infinity,
|
staleTime: Infinity,
|
||||||
select: (data): OutboundTagGroups => {
|
select: (data): OutboundTagGroups => {
|
||||||
const outbounds = new Set<string>();
|
const outbounds = new Set<string>();
|
||||||
for (const o of data?.xraySetting?.outbounds ?? []) {
|
for (const ob of data.outbounds) {
|
||||||
const ob = o as { tag?: string; protocol?: string } | null;
|
|
||||||
if (!ob?.tag) continue;
|
if (!ob?.tag) continue;
|
||||||
if (excludeBlackhole && ob.protocol === 'blackhole') continue;
|
if (excludeBlackhole && ob.protocol === 'blackhole') continue;
|
||||||
outbounds.add(ob.tag);
|
outbounds.add(ob.tag);
|
||||||
}
|
}
|
||||||
for (const t of data?.subscriptionOutboundTags ?? []) {
|
for (const t of data.subscriptionOutboundTags) {
|
||||||
if (t) outbounds.add(t);
|
if (t) outbounds.add(t);
|
||||||
}
|
}
|
||||||
const balancers: string[] = [];
|
const balancers: string[] = [];
|
||||||
const bal = (
|
for (const tag of data.balancers) {
|
||||||
data?.xraySetting?.routing as { balancers?: Array<{ tag?: string }> } | undefined
|
if (tag && !outbounds.has(tag)) balancers.push(tag);
|
||||||
)?.balancers;
|
|
||||||
for (const b of bal ?? []) {
|
|
||||||
if (b?.tag && !outbounds.has(b.tag)) balancers.push(b.tag);
|
|
||||||
}
|
}
|
||||||
return { outbounds: [...outbounds], balancers };
|
return { outbounds: [...outbounds], balancers };
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,41 +0,0 @@
|
|||||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
|
||||||
|
|
||||||
import { HttpUtil } from '@/utils';
|
|
||||||
import { keys } from '@/api/queryKeys';
|
|
||||||
import type { SubBalancerFormValues } from '@/schemas/subBalancer';
|
|
||||||
|
|
||||||
// Deliberately urlencoded (no JSON headers): the Go side binds inboundIds from
|
|
||||||
// repeated form keys, which is exactly how HttpUtil encodes arrays.
|
|
||||||
export function useSubBalancerMutations() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const invalidate = () => queryClient.invalidateQueries({ queryKey: keys.subBalancers.root() });
|
|
||||||
|
|
||||||
const createMut = useMutation({
|
|
||||||
mutationFn: (payload: SubBalancerFormValues) =>
|
|
||||||
HttpUtil.post('/panel/api/sub-balancers', payload),
|
|
||||||
onSuccess: (msg) => {
|
|
||||||
if (msg?.success) invalidate();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const updateMut = useMutation({
|
|
||||||
mutationFn: ({ id, payload }: { id: number; payload: SubBalancerFormValues }) =>
|
|
||||||
HttpUtil.post(`/panel/api/sub-balancers/${id}`, payload),
|
|
||||||
onSuccess: (msg) => {
|
|
||||||
if (msg?.success) invalidate();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const removeMut = useMutation({
|
|
||||||
mutationFn: (id: number) => HttpUtil.post(`/panel/api/sub-balancers/${id}/del`),
|
|
||||||
onSuccess: (msg) => {
|
|
||||||
if (msg?.success) invalidate();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
create: (payload: SubBalancerFormValues) => createMut.mutateAsync(payload),
|
|
||||||
update: (id: number, payload: SubBalancerFormValues) => updateMut.mutateAsync({ id, payload }),
|
|
||||||
remove: (id: number) => removeMut.mutateAsync(id),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
|
||||||
import { useMemo } from 'react';
|
|
||||||
|
|
||||||
import { HttpUtil } from '@/utils';
|
|
||||||
import { parseMsg } from '@/utils/zodValidate';
|
|
||||||
import { keys } from '@/api/queryKeys';
|
|
||||||
import { SubBalancerListSchema, type SubBalancer } from '@/schemas/subBalancer';
|
|
||||||
|
|
||||||
async function fetchSubBalancers(): Promise<SubBalancer[]> {
|
|
||||||
const msg = await HttpUtil.get('/panel/api/sub-balancers', undefined, { silent: true });
|
|
||||||
if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch subscription balancers');
|
|
||||||
const validated = parseMsg(msg, SubBalancerListSchema, 'sub-balancers');
|
|
||||||
return Array.isArray(validated.obj) ? validated.obj : [];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useSubBalancersQuery() {
|
|
||||||
const query = useQuery({
|
|
||||||
queryKey: keys.subBalancers.list(),
|
|
||||||
queryFn: fetchSubBalancers,
|
|
||||||
});
|
|
||||||
|
|
||||||
const balancers = useMemo(() => query.data ?? [], [query.data]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
balancers,
|
|
||||||
loading: query.isFetching,
|
|
||||||
fetched: query.data !== undefined || query.isError,
|
|
||||||
fetchError: query.error ? (query.error as Error).message : '',
|
|
||||||
refetch: query.refetch,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -2,6 +2,7 @@ export const keys = {
|
|||||||
server: {
|
server: {
|
||||||
status: () => ['server', 'status'] as const,
|
status: () => ['server', 'status'] as const,
|
||||||
fail2banStatus: () => ['server', 'fail2banStatus'] as const,
|
fail2banStatus: () => ['server', 'fail2banStatus'] as const,
|
||||||
|
outboundDirectory: () => ['server', 'outboundDirectory'] as const,
|
||||||
},
|
},
|
||||||
nodes: {
|
nodes: {
|
||||||
root: () => ['nodes'] as const,
|
root: () => ['nodes'] as const,
|
||||||
@@ -13,10 +14,6 @@ export const keys = {
|
|||||||
byInbound: (inboundId: number) => ['hosts', 'byInbound', inboundId] as const,
|
byInbound: (inboundId: number) => ['hosts', 'byInbound', inboundId] as const,
|
||||||
tags: () => ['hosts', 'tags'] as const,
|
tags: () => ['hosts', 'tags'] as const,
|
||||||
},
|
},
|
||||||
subBalancers: {
|
|
||||||
root: () => ['sub-balancers'] as const,
|
|
||||||
list: () => ['sub-balancers', 'list'] as const,
|
|
||||||
},
|
|
||||||
settings: {
|
settings: {
|
||||||
root: () => ['settings'] as const,
|
root: () => ['settings'] as const,
|
||||||
all: () => ['settings', 'all'] as const,
|
all: () => ['settings', 'all'] as const,
|
||||||
@@ -37,17 +34,4 @@ export const keys = {
|
|||||||
activeInbounds: () => ['clients', 'activeInbounds'] as const,
|
activeInbounds: () => ['clients', 'activeInbounds'] as const,
|
||||||
lastOnline: () => ['clients', 'lastOnline'] as const,
|
lastOnline: () => ['clients', 'lastOnline'] as const,
|
||||||
},
|
},
|
||||||
xray: {
|
|
||||||
root: () => ['xray'] as const,
|
|
||||||
config: () => ['xray', 'config'] as const,
|
|
||||||
outboundsTraffic: () => ['xray', 'outboundsTraffic'] as const,
|
|
||||||
geodata: {
|
|
||||||
root: () => ['xray', 'geodata'] as const,
|
|
||||||
files: () => ['xray', 'geodata', 'files'] as const,
|
|
||||||
categories: (file: string, query: string) =>
|
|
||||||
['xray', 'geodata', 'categories', file, query] as const,
|
|
||||||
entries: (file: string, code: string, query: string, offset: number, limit: number) =>
|
|
||||||
['xray', 'geodata', 'entries', file, code, query, offset, limit] as const,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -30,11 +30,6 @@ export function useWebSocketBridge() {
|
|||||||
}, 200);
|
}, 200);
|
||||||
};
|
};
|
||||||
|
|
||||||
const onOutbounds: Handler = (payload) => {
|
|
||||||
if (!Array.isArray(payload)) return;
|
|
||||||
queryClient.setQueryData(keys.xray.outboundsTraffic(), payload);
|
|
||||||
};
|
|
||||||
|
|
||||||
const onNodes: Handler = (payload) => {
|
const onNodes: Handler = (payload) => {
|
||||||
if (!Array.isArray(payload)) return;
|
if (!Array.isArray(payload)) return;
|
||||||
queryClient.setQueryData(keys.nodes.list(), payload);
|
queryClient.setQueryData(keys.nodes.list(), payload);
|
||||||
@@ -46,14 +41,12 @@ export function useWebSocketBridge() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
client.on('invalidate', onInvalidate);
|
client.on('invalidate', onInvalidate);
|
||||||
client.on('outbounds', onOutbounds);
|
|
||||||
client.on('nodes', onNodes);
|
client.on('nodes', onNodes);
|
||||||
client.on('inbounds', onInbounds);
|
client.on('inbounds', onInbounds);
|
||||||
client.connect();
|
client.connect();
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
client.off('invalidate', onInvalidate);
|
client.off('invalidate', onInvalidate);
|
||||||
client.off('outbounds', onOutbounds);
|
|
||||||
client.off('nodes', onNodes);
|
client.off('nodes', onNodes);
|
||||||
client.off('inbounds', onInbounds);
|
client.off('inbounds', onInbounds);
|
||||||
if (invalidateTimer != null) {
|
if (invalidateTimer != null) {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useState } from 'react';
|
||||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||||
import { Typography } from 'antd';
|
import { Typography } from 'antd';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
@@ -25,15 +25,6 @@ function ClientExpiryDemo() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function JalaliDemo() {
|
|
||||||
const [value, setValue] = useState<Dayjs | null>(dayjs('2026-12-31 23:59:59'));
|
|
||||||
useEffect(() => {
|
|
||||||
setDatepicker('jalalian');
|
|
||||||
return () => setDatepicker('gregorian');
|
|
||||||
}, []);
|
|
||||||
return <DateTimePicker value={value} onChange={setValue} placeholder="Expiry date" />;
|
|
||||||
}
|
|
||||||
|
|
||||||
const meta = {
|
const meta = {
|
||||||
title: 'Form/DateTimePicker',
|
title: 'Form/DateTimePicker',
|
||||||
component: DateTimePicker,
|
component: DateTimePicker,
|
||||||
@@ -43,7 +34,7 @@ const meta = {
|
|||||||
docs: {
|
docs: {
|
||||||
description: {
|
description: {
|
||||||
component:
|
component:
|
||||||
'Calendar-aware date/time picker used for client and inbound expiry dates. Renders an AntD DatePicker by default and switches to a Persian (Jalali) calendar — with theme-matched colors and an overlaid clear button — when the panel datepicker setting is jalalian.',
|
'Date/time picker used for client and inbound expiry dates, rendered with the AntD DatePicker.',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -90,9 +81,3 @@ export const DateOnly: Story = {
|
|||||||
placeholder: 'Start date',
|
placeholder: 'Start date',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const JalaliCalendar: Story = {
|
|
||||||
args: { value: null, onChange: () => undefined },
|
|
||||||
parameters: { docs: { disable: true } },
|
|
||||||
render: () => <JalaliDemo />,
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -1,14 +1,5 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { CloseCircleFilled } from '@ant-design/icons';
|
|
||||||
import { DatePicker } from 'antd';
|
import { DatePicker } from 'antd';
|
||||||
import dayjs from 'dayjs';
|
|
||||||
import type { Dayjs } from 'dayjs';
|
import type { Dayjs } from 'dayjs';
|
||||||
import { PersianDateTimePicker } from 'persian-calendar-suite';
|
|
||||||
|
|
||||||
import { useDatepicker } from '@/hooks/useDatepicker';
|
|
||||||
import { useTheme } from '@/hooks/useTheme';
|
|
||||||
import './DateTimePicker.css';
|
|
||||||
|
|
||||||
interface DateTimePickerProps {
|
interface DateTimePickerProps {
|
||||||
value: Dayjs | null;
|
value: Dayjs | null;
|
||||||
@@ -19,33 +10,6 @@ interface DateTimePickerProps {
|
|||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const LIGHT_THEME = {
|
|
||||||
primaryColor: '#1677ff',
|
|
||||||
backgroundColor: '#ffffff',
|
|
||||||
borderColor: '#d9d9d9',
|
|
||||||
hoverColor: 'rgba(22, 119, 255, 0.10)',
|
|
||||||
selectedTextColor: '#ffffff',
|
|
||||||
textColor: 'rgba(0, 0, 0, 0.88)',
|
|
||||||
};
|
|
||||||
|
|
||||||
const DARK_THEME = {
|
|
||||||
primaryColor: '#1677ff',
|
|
||||||
backgroundColor: '#23252b',
|
|
||||||
borderColor: 'rgba(255, 255, 255, 0.12)',
|
|
||||||
hoverColor: 'rgba(22, 119, 255, 0.18)',
|
|
||||||
selectedTextColor: '#ffffff',
|
|
||||||
textColor: 'rgba(255, 255, 255, 0.88)',
|
|
||||||
};
|
|
||||||
|
|
||||||
const ULTRA_DARK_THEME = {
|
|
||||||
primaryColor: '#1677ff',
|
|
||||||
backgroundColor: '#101013',
|
|
||||||
borderColor: 'rgba(255, 255, 255, 0.08)',
|
|
||||||
hoverColor: 'rgba(22, 119, 255, 0.16)',
|
|
||||||
selectedTextColor: '#ffffff',
|
|
||||||
textColor: 'rgba(255, 255, 255, 0.88)',
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function DateTimePicker({
|
export default function DateTimePicker({
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
@@ -54,83 +18,6 @@ export default function DateTimePicker({
|
|||||||
placeholder = '',
|
placeholder = '',
|
||||||
disabled = false,
|
disabled = false,
|
||||||
}: DateTimePickerProps) {
|
}: DateTimePickerProps) {
|
||||||
const { t } = useTranslation();
|
|
||||||
const { datepicker } = useDatepicker();
|
|
||||||
const { isDark, isUltra } = useTheme();
|
|
||||||
const jalaliRef = useRef<HTMLDivElement>(null);
|
|
||||||
// Bumped on clear: persian-calendar-suite reads `value` only on mount, so
|
|
||||||
// remounting via key is the only way to reflect an externally cleared value.
|
|
||||||
const [clearNonce, setClearNonce] = useState(0);
|
|
||||||
// Mounted without a value, persian-calendar-suite seeds today and emits it —
|
|
||||||
// which would instantly undo a clear. Armed across every (re)mount.
|
|
||||||
const suppressMountEmit = useRef(true);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
suppressMountEmit.current = false;
|
|
||||||
return () => {
|
|
||||||
suppressMountEmit.current = true;
|
|
||||||
};
|
|
||||||
}, [clearNonce]);
|
|
||||||
|
|
||||||
const persianTheme = useMemo(() => {
|
|
||||||
if (isUltra) return ULTRA_DARK_THEME;
|
|
||||||
if (isDark) return DARK_THEME;
|
|
||||||
return LIGHT_THEME;
|
|
||||||
}, [isDark, isUltra]);
|
|
||||||
|
|
||||||
// The library hardcodes a Persian placeholder and exposes no working prop to
|
|
||||||
// override it, so clear it (or apply the caller's) on the input directly so
|
|
||||||
// the empty field shows no leftover Persian text. No dep array: re-apply
|
|
||||||
// after every render (incl. clear-remounts).
|
|
||||||
useEffect(() => {
|
|
||||||
if (datepicker !== 'jalalian') return;
|
|
||||||
const input = jalaliRef.current?.querySelector('input');
|
|
||||||
if (input) input.placeholder = placeholder;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (datepicker === 'jalalian') {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={jalaliRef}
|
|
||||||
className={`jdp-wrap${isDark ? ' jdp-dark' : ''}${isUltra ? ' jdp-ultra' : ''}${disabled ? ' jdp-disabled' : ''}${value ? '' : ' jdp-empty'}`}
|
|
||||||
>
|
|
||||||
<PersianDateTimePicker
|
|
||||||
key={clearNonce}
|
|
||||||
value={value ? value.valueOf() : null}
|
|
||||||
onChange={(next: number | string | null) => {
|
|
||||||
if (suppressMountEmit.current) return;
|
|
||||||
if (next == null || next === '') {
|
|
||||||
onChange(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const ms = typeof next === 'number' ? next : Number(next);
|
|
||||||
if (Number.isFinite(ms)) onChange(dayjs(ms));
|
|
||||||
}}
|
|
||||||
showTime={showTime}
|
|
||||||
outputFormat="timestamp"
|
|
||||||
persianNumbers
|
|
||||||
rtlCalendar
|
|
||||||
theme={persianTheme}
|
|
||||||
/>
|
|
||||||
{value && !disabled && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="jdp-clear"
|
|
||||||
aria-label={t('clear')}
|
|
||||||
onMouseDown={(e) => e.preventDefault()}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onChange(null);
|
|
||||||
setClearNonce((n) => n + 1);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<CloseCircleFilled />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DatePicker
|
<DatePicker
|
||||||
value={value}
|
value={value}
|
||||||
|
|||||||
@@ -1,221 +0,0 @@
|
|||||||
.geo-browser-modal .geo-toolbar {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.geo-browser-modal .geo-toolbar .ant-input-search {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 180px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.geo-browser-modal .geo-meta {
|
|
||||||
margin-inline-start: auto;
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--ant-color-text-tertiary);
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
}
|
|
||||||
|
|
||||||
.geo-browser-modal .geo-columns {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: minmax(240px, 340px) minmax(0, 1fr);
|
|
||||||
gap: 12px;
|
|
||||||
height: 440px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Both panes are the same fixed height, and the pager sits on the pane's floor
|
|
||||||
rather than under the last row, so neither the dialog nor its controls move
|
|
||||||
as the user steps between categories with wildly different rule counts. */
|
|
||||||
.geo-browser-modal .geo-panel {
|
|
||||||
height: 100%;
|
|
||||||
min-height: 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
overflow: hidden;
|
|
||||||
border: 1px solid var(--ant-color-border-secondary);
|
|
||||||
border-radius: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.geo-browser-modal .geo-panel .ant-table-wrapper,
|
|
||||||
.geo-browser-modal .geo-panel .ant-spin-nested-loading,
|
|
||||||
.geo-browser-modal .geo-panel .ant-spin-container {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
flex: 1;
|
|
||||||
min-height: 0;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.geo-browser-modal .geo-panel .ant-table {
|
|
||||||
flex: 1;
|
|
||||||
min-height: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* The rules table fills whatever is left between the header and the pager
|
|
||||||
instead of carrying a hardcoded scroll height, so there is no dead strip
|
|
||||||
above the pager and short categories do not scroll needlessly. */
|
|
||||||
.geo-browser-modal .geo-preview-body {
|
|
||||||
flex: 1;
|
|
||||||
min-height: 0;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.geo-browser-modal .geo-pager {
|
|
||||||
margin-top: auto;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: flex-end;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
padding: 6px 12px;
|
|
||||||
border-top: 1px solid var(--ant-color-border-secondary);
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
}
|
|
||||||
|
|
||||||
.geo-browser-modal .geo-pager .ant-pagination-total-text {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--ant-color-text-tertiary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.geo-browser-modal .geo-categories .ant-table-row {
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.geo-browser-modal .geo-row-active > td {
|
|
||||||
background: var(--ant-color-primary-bg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.geo-browser-modal .geo-category {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 2px;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.geo-browser-modal .geo-code,
|
|
||||||
.geo-browser-modal .geo-entry-value,
|
|
||||||
.geo-browser-modal .geo-preview-title {
|
|
||||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
||||||
font-size: 13px;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.geo-browser-modal .geo-attrs .ant-tag {
|
|
||||||
font-size: 10px;
|
|
||||||
line-height: 16px;
|
|
||||||
margin-inline-end: 4px;
|
|
||||||
padding-inline: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.geo-browser-modal .geo-count {
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
color: var(--ant-color-text-tertiary);
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.geo-browser-modal .geo-preview-head {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
padding: 8px 12px;
|
|
||||||
border-bottom: 1px solid var(--ant-color-border-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* The title is the part that gives way: without min-width it refuses to
|
|
||||||
shrink, and the filter is pushed onto a second line instead of the long
|
|
||||||
category name being clipped. */
|
|
||||||
.geo-browser-modal .geo-preview-title {
|
|
||||||
flex: 0 1 auto;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.geo-browser-modal .geo-preview-head .ant-typography {
|
|
||||||
flex: none;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.geo-browser-modal .geo-entry-filter {
|
|
||||||
flex: none;
|
|
||||||
width: 200px;
|
|
||||||
margin-inline-start: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 520px) {
|
|
||||||
.geo-browser-modal .geo-preview-head {
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.geo-browser-modal .geo-entry-filter {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.geo-browser-modal .geo-kind {
|
|
||||||
font-size: 10px;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.04em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.geo-browser-modal .geo-kind-full {
|
|
||||||
color: var(--ant-color-success);
|
|
||||||
}
|
|
||||||
|
|
||||||
.geo-browser-modal .geo-kind-keyword {
|
|
||||||
color: var(--ant-color-warning);
|
|
||||||
}
|
|
||||||
|
|
||||||
.geo-browser-modal .geo-kind-regexp {
|
|
||||||
color: var(--ant-color-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.geo-browser-modal .geo-placeholder {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 40px 20px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.geo-browser-modal .geo-footer {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
margin-top: 12px;
|
|
||||||
padding-top: 12px;
|
|
||||||
border-top: 1px solid var(--ant-color-border-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.geo-browser-modal .geo-chips {
|
|
||||||
flex: 1;
|
|
||||||
max-height: 76px;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.geo-browser-modal .geo-selected-count {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--ant-color-text-tertiary);
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 720px) {
|
|
||||||
.geo-browser-modal .geo-columns {
|
|
||||||
grid-template-columns: minmax(0, 1fr);
|
|
||||||
height: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.geo-browser-modal .geo-panel {
|
|
||||||
height: 320px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.geo-unknown-hint {
|
|
||||||
display: block;
|
|
||||||
margin-top: 4px;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
@@ -1,864 +0,0 @@
|
|||||||
import { useEffect, useState, type ReactNode } from 'react';
|
|
||||||
import type { Decorator, Meta, StoryObj } from '@storybook/react-vite';
|
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
|
||||||
import { expect, within } from 'storybook/test';
|
|
||||||
import { Button, Space, Typography } from 'antd';
|
|
||||||
|
|
||||||
import type { GeoCategory, GeoEntry, GeoFile } from '@/generated/types';
|
|
||||||
|
|
||||||
import GeoBrowserModal, { type GeoBrowserModalProps } from './GeoBrowserModal';
|
|
||||||
|
|
||||||
type GeoResponder = (query: URLSearchParams) => unknown;
|
|
||||||
type GeoRoutes = Record<string, GeoResponder>;
|
|
||||||
|
|
||||||
const realFetch = window.fetch.bind(window);
|
|
||||||
let activeRoutes: GeoRoutes = {};
|
|
||||||
|
|
||||||
function requestUrl(input: RequestInfo | URL): URL {
|
|
||||||
if (typeof input === 'string') return new URL(input, window.location.origin);
|
|
||||||
if (input instanceof URL) return input;
|
|
||||||
return new URL(input.url, window.location.origin);
|
|
||||||
}
|
|
||||||
|
|
||||||
function geoFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
|
|
||||||
const url = requestUrl(input);
|
|
||||||
const responder = activeRoutes[url.pathname];
|
|
||||||
if (!responder) return realFetch(input, init);
|
|
||||||
const body = JSON.stringify({ success: true, msg: '', obj: responder(url.searchParams) });
|
|
||||||
return Promise.resolve(
|
|
||||||
new Response(body, { status: 200, headers: { 'content-type': 'application/json' } }),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function activate(routes: GeoRoutes): void {
|
|
||||||
activeRoutes = routes;
|
|
||||||
window.fetch = geoFetch;
|
|
||||||
}
|
|
||||||
|
|
||||||
function deactivate(routes: GeoRoutes): void {
|
|
||||||
if (activeRoutes === routes) activeRoutes = {};
|
|
||||||
}
|
|
||||||
|
|
||||||
function GeoApi({ routes, children }: { routes: GeoRoutes; children: ReactNode }) {
|
|
||||||
const [client] = useState(() => {
|
|
||||||
activate(routes);
|
|
||||||
return new QueryClient({
|
|
||||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
useEffect(() => {
|
|
||||||
activate(routes);
|
|
||||||
return () => deactivate(routes);
|
|
||||||
}, [routes]);
|
|
||||||
return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const domain = (value: string): GeoEntry => ({ kind: 'domain', value });
|
|
||||||
const full = (value: string): GeoEntry => ({ kind: 'full', value });
|
|
||||||
const keyword = (value: string): GeoEntry => ({ kind: 'keyword', value });
|
|
||||||
const regexp = (value: string): GeoEntry => ({ kind: 'regexp', value });
|
|
||||||
const cidr = (value: string): GeoEntry => ({ kind: 'cidr', value });
|
|
||||||
|
|
||||||
const cross = (names: string[], suffixes: string[]): GeoEntry[] =>
|
|
||||||
names.flatMap((name) => suffixes.map((suffix) => domain(`${name}.${suffix}`)));
|
|
||||||
|
|
||||||
const CC_TLDS = [
|
|
||||||
'ae',
|
|
||||||
'al',
|
|
||||||
'am',
|
|
||||||
'at',
|
|
||||||
'az',
|
|
||||||
'ba',
|
|
||||||
'be',
|
|
||||||
'bg',
|
|
||||||
'bi',
|
|
||||||
'bj',
|
|
||||||
'ca',
|
|
||||||
'cat',
|
|
||||||
'cd',
|
|
||||||
'cf',
|
|
||||||
'cg',
|
|
||||||
'ch',
|
|
||||||
'ci',
|
|
||||||
'cl',
|
|
||||||
'cm',
|
|
||||||
'co.id',
|
|
||||||
'co.il',
|
|
||||||
'co.in',
|
|
||||||
'co.jp',
|
|
||||||
'co.ke',
|
|
||||||
'co.kr',
|
|
||||||
'co.ma',
|
|
||||||
'co.nz',
|
|
||||||
'co.th',
|
|
||||||
'co.uk',
|
|
||||||
'co.uz',
|
|
||||||
'co.ve',
|
|
||||||
'co.za',
|
|
||||||
'com.ar',
|
|
||||||
'com.au',
|
|
||||||
'com.bd',
|
|
||||||
'com.br',
|
|
||||||
'com.co',
|
|
||||||
'com.cu',
|
|
||||||
'com.eg',
|
|
||||||
'com.gt',
|
|
||||||
'com.hk',
|
|
||||||
'com.mx',
|
|
||||||
'com.my',
|
|
||||||
'com.ng',
|
|
||||||
'com.pe',
|
|
||||||
'com.ph',
|
|
||||||
'com.pk',
|
|
||||||
'com.sa',
|
|
||||||
'com.sg',
|
|
||||||
'com.tr',
|
|
||||||
'com.tw',
|
|
||||||
'com.ua',
|
|
||||||
'com.uy',
|
|
||||||
'com.vn',
|
|
||||||
'cz',
|
|
||||||
'de',
|
|
||||||
'dj',
|
|
||||||
'dk',
|
|
||||||
'dz',
|
|
||||||
'ee',
|
|
||||||
'es',
|
|
||||||
'fi',
|
|
||||||
'fr',
|
|
||||||
'ga',
|
|
||||||
'ge',
|
|
||||||
'gl',
|
|
||||||
'gm',
|
|
||||||
'gr',
|
|
||||||
'hn',
|
|
||||||
'hr',
|
|
||||||
'ht',
|
|
||||||
'hu',
|
|
||||||
'ie',
|
|
||||||
'iq',
|
|
||||||
'is',
|
|
||||||
'it',
|
|
||||||
'je',
|
|
||||||
'jo',
|
|
||||||
'kg',
|
|
||||||
'kz',
|
|
||||||
'la',
|
|
||||||
'li',
|
|
||||||
'lk',
|
|
||||||
'lt',
|
|
||||||
'lu',
|
|
||||||
'lv',
|
|
||||||
'ly',
|
|
||||||
'md',
|
|
||||||
'me',
|
|
||||||
'mg',
|
|
||||||
'mk',
|
|
||||||
'ml',
|
|
||||||
'mn',
|
|
||||||
'mu',
|
|
||||||
'mv',
|
|
||||||
'mw',
|
|
||||||
'ne',
|
|
||||||
'nl',
|
|
||||||
'no',
|
|
||||||
'nu',
|
|
||||||
'pl',
|
|
||||||
'pt',
|
|
||||||
'ro',
|
|
||||||
'rs',
|
|
||||||
'ru',
|
|
||||||
'rw',
|
|
||||||
'se',
|
|
||||||
'sh',
|
|
||||||
'si',
|
|
||||||
'sk',
|
|
||||||
'sm',
|
|
||||||
'sn',
|
|
||||||
'so',
|
|
||||||
'sr',
|
|
||||||
'st',
|
|
||||||
'td',
|
|
||||||
'tg',
|
|
||||||
'tk',
|
|
||||||
'tl',
|
|
||||||
'tm',
|
|
||||||
'tn',
|
|
||||||
'to',
|
|
||||||
'tt',
|
|
||||||
'vg',
|
|
||||||
'vu',
|
|
||||||
'ws',
|
|
||||||
];
|
|
||||||
|
|
||||||
const AD_HOSTS = [
|
|
||||||
'adform',
|
|
||||||
'adnxs',
|
|
||||||
'adroll',
|
|
||||||
'adsrvr',
|
|
||||||
'amplitude',
|
|
||||||
'appsflyer',
|
|
||||||
'bluekai',
|
|
||||||
'branch',
|
|
||||||
'casalemedia',
|
|
||||||
'criteo',
|
|
||||||
'flurry',
|
|
||||||
'moatads',
|
|
||||||
'mopub',
|
|
||||||
'openx',
|
|
||||||
'outbrain',
|
|
||||||
'pubmatic',
|
|
||||||
'quantserve',
|
|
||||||
'rubiconproject',
|
|
||||||
'scorecardresearch',
|
|
||||||
'sharethrough',
|
|
||||||
'smartadserver',
|
|
||||||
'taboola',
|
|
||||||
'teads',
|
|
||||||
'yieldmo',
|
|
||||||
'zemanta',
|
|
||||||
];
|
|
||||||
|
|
||||||
const CN_BRANDS = [
|
|
||||||
'58',
|
|
||||||
'alibaba',
|
|
||||||
'alipay',
|
|
||||||
'aliyun',
|
|
||||||
'baidu',
|
|
||||||
'bilibili',
|
|
||||||
'cnblogs',
|
|
||||||
'csdn',
|
|
||||||
'ctrip',
|
|
||||||
'douban',
|
|
||||||
'gitee',
|
|
||||||
'huawei',
|
|
||||||
'iqiyi',
|
|
||||||
'jd',
|
|
||||||
'kuaishou',
|
|
||||||
'meituan',
|
|
||||||
'netease',
|
|
||||||
'pinduoduo',
|
|
||||||
'qq',
|
|
||||||
'sina',
|
|
||||||
'sohu',
|
|
||||||
'taobao',
|
|
||||||
'tencent',
|
|
||||||
'tmall',
|
|
||||||
'toutiao',
|
|
||||||
'weibo',
|
|
||||||
'xiaomi',
|
|
||||||
'youku',
|
|
||||||
'zhihu',
|
|
||||||
];
|
|
||||||
|
|
||||||
const SITE_ENTRIES: Record<string, GeoEntry[]> = {
|
|
||||||
amazon: [
|
|
||||||
domain('amazon.com'),
|
|
||||||
domain('amazonaws.com'),
|
|
||||||
domain('media-amazon.com'),
|
|
||||||
domain('ssl-images-amazon.com'),
|
|
||||||
domain('primevideo.com'),
|
|
||||||
domain('awsstatic.com'),
|
|
||||||
domain('cloudfront.net'),
|
|
||||||
full('www.amazon.co.jp'),
|
|
||||||
],
|
|
||||||
apple: [
|
|
||||||
domain('apple.com'),
|
|
||||||
domain('icloud.com'),
|
|
||||||
domain('cdn-apple.com'),
|
|
||||||
domain('mzstatic.com'),
|
|
||||||
domain('apple-cloudkit.com'),
|
|
||||||
domain('itunes.com'),
|
|
||||||
domain('me.com'),
|
|
||||||
domain('appstore.com'),
|
|
||||||
],
|
|
||||||
'category-ads': [
|
|
||||||
domain('adcolony.com'),
|
|
||||||
domain('applovin.com'),
|
|
||||||
domain('chartboost.com'),
|
|
||||||
domain('inmobi.com'),
|
|
||||||
domain('unityads.unity3d.com'),
|
|
||||||
keyword('banner-ad'),
|
|
||||||
],
|
|
||||||
'category-ads-all': [
|
|
||||||
domain('doubleclick.net'),
|
|
||||||
domain('googleadservices.com'),
|
|
||||||
domain('googlesyndication.com'),
|
|
||||||
domain('adservice.google.com'),
|
|
||||||
full('ads.yahoo.com'),
|
|
||||||
keyword('adservice'),
|
|
||||||
keyword('advertising'),
|
|
||||||
regexp('^ad[0-9]{1,3}\\.'),
|
|
||||||
...cross(AD_HOSTS, ['com', 'net', 'io', 'ru']),
|
|
||||||
],
|
|
||||||
cloudflare: [
|
|
||||||
domain('cloudflare.com'),
|
|
||||||
domain('cloudflare-dns.com'),
|
|
||||||
domain('cloudflareinsights.com'),
|
|
||||||
domain('workers.dev'),
|
|
||||||
domain('pages.dev'),
|
|
||||||
domain('cf-ipfs.com'),
|
|
||||||
],
|
|
||||||
cn: [full('www.gov.cn'), keyword('chinanet'), ...cross(CN_BRANDS, ['com', 'cn', 'com.cn'])],
|
|
||||||
discord: [
|
|
||||||
domain('discord.com'),
|
|
||||||
domain('discord.gg'),
|
|
||||||
domain('discordapp.com'),
|
|
||||||
domain('discordapp.net'),
|
|
||||||
domain('discord.media'),
|
|
||||||
],
|
|
||||||
facebook: [
|
|
||||||
domain('facebook.com'),
|
|
||||||
domain('fbcdn.net'),
|
|
||||||
domain('fb.com'),
|
|
||||||
domain('messenger.com'),
|
|
||||||
domain('fbsbx.com'),
|
|
||||||
domain('facebook.net'),
|
|
||||||
full('m.facebook.com'),
|
|
||||||
],
|
|
||||||
'geolocation-!cn': [
|
|
||||||
keyword('proxy'),
|
|
||||||
regexp('.*\\.onion$'),
|
|
||||||
domain('wikipedia.org'),
|
|
||||||
domain('bbc.com'),
|
|
||||||
domain('nytimes.com'),
|
|
||||||
domain('reuters.com'),
|
|
||||||
domain('medium.com'),
|
|
||||||
domain('reddit.com'),
|
|
||||||
],
|
|
||||||
'geolocation-cn': [
|
|
||||||
domain('gov.cn'),
|
|
||||||
domain('edu.cn'),
|
|
||||||
domain('org.cn'),
|
|
||||||
domain('net.cn'),
|
|
||||||
...cross(CN_BRANDS.slice(0, 18), ['cn']),
|
|
||||||
],
|
|
||||||
github: [
|
|
||||||
domain('github.com'),
|
|
||||||
domain('githubusercontent.com'),
|
|
||||||
domain('githubassets.com'),
|
|
||||||
domain('github.io'),
|
|
||||||
domain('ghcr.io'),
|
|
||||||
domain('git.io'),
|
|
||||||
],
|
|
||||||
google: [
|
|
||||||
domain('google.com'),
|
|
||||||
domain('googleapis.com'),
|
|
||||||
domain('gstatic.com'),
|
|
||||||
domain('googleusercontent.com'),
|
|
||||||
domain('google-analytics.com'),
|
|
||||||
domain('googletagmanager.com'),
|
|
||||||
domain('ggpht.com'),
|
|
||||||
domain('withgoogle.com'),
|
|
||||||
domain('android.com'),
|
|
||||||
domain('chromium.org'),
|
|
||||||
domain('abc.xyz'),
|
|
||||||
full('dl.google.com'),
|
|
||||||
...CC_TLDS.map((tld) => domain(`google.${tld}`)),
|
|
||||||
],
|
|
||||||
instagram: [domain('instagram.com'), domain('cdninstagram.com'), domain('ig.me')],
|
|
||||||
microsoft: [
|
|
||||||
domain('microsoft.com'),
|
|
||||||
domain('live.com'),
|
|
||||||
domain('office.com'),
|
|
||||||
domain('office365.com'),
|
|
||||||
domain('windows.net'),
|
|
||||||
domain('windowsupdate.com'),
|
|
||||||
domain('msn.com'),
|
|
||||||
domain('azure.com'),
|
|
||||||
domain('sharepoint.com'),
|
|
||||||
domain('skype.com'),
|
|
||||||
domain('bing.com'),
|
|
||||||
],
|
|
||||||
netflix: [
|
|
||||||
domain('netflix.com'),
|
|
||||||
domain('netflix.net'),
|
|
||||||
domain('nflximg.com'),
|
|
||||||
domain('nflximg.net'),
|
|
||||||
domain('nflxvideo.net'),
|
|
||||||
domain('nflxso.net'),
|
|
||||||
domain('nflxext.com'),
|
|
||||||
full('fast.com'),
|
|
||||||
],
|
|
||||||
openai: [
|
|
||||||
domain('openai.com'),
|
|
||||||
domain('chatgpt.com'),
|
|
||||||
domain('oaistatic.com'),
|
|
||||||
domain('oaiusercontent.com'),
|
|
||||||
domain('sora.com'),
|
|
||||||
],
|
|
||||||
spotify: [
|
|
||||||
domain('spotify.com'),
|
|
||||||
domain('scdn.co'),
|
|
||||||
domain('spotifycdn.com'),
|
|
||||||
domain('spoti.fi'),
|
|
||||||
domain('spotifycdn.net'),
|
|
||||||
],
|
|
||||||
steam: [
|
|
||||||
domain('steampowered.com'),
|
|
||||||
domain('steamcommunity.com'),
|
|
||||||
domain('steamstatic.com'),
|
|
||||||
domain('steamcontent.com'),
|
|
||||||
domain('valvesoftware.com'),
|
|
||||||
],
|
|
||||||
telegram: [
|
|
||||||
domain('telegram.org'),
|
|
||||||
domain('telegram.me'),
|
|
||||||
domain('t.me'),
|
|
||||||
domain('telesco.pe'),
|
|
||||||
domain('tdesktop.com'),
|
|
||||||
domain('telegra.ph'),
|
|
||||||
domain('cdn-telegram.org'),
|
|
||||||
full('comments.app'),
|
|
||||||
keyword('telegram'),
|
|
||||||
],
|
|
||||||
tiktok: [
|
|
||||||
domain('tiktok.com'),
|
|
||||||
domain('tiktokcdn.com'),
|
|
||||||
domain('tiktokv.com'),
|
|
||||||
domain('byteoversea.com'),
|
|
||||||
domain('ibytedtos.com'),
|
|
||||||
domain('musical.ly'),
|
|
||||||
],
|
|
||||||
twitch: [domain('twitch.tv'), domain('ttvnw.net'), domain('jtvnw.net'), domain('twitchcdn.net')],
|
|
||||||
twitter: [
|
|
||||||
domain('twitter.com'),
|
|
||||||
domain('x.com'),
|
|
||||||
domain('t.co'),
|
|
||||||
domain('twimg.com'),
|
|
||||||
domain('periscope.tv'),
|
|
||||||
],
|
|
||||||
whatsapp: [domain('whatsapp.com'), domain('whatsapp.net'), domain('wa.me')],
|
|
||||||
youtube: [
|
|
||||||
domain('youtube.com'),
|
|
||||||
domain('youtu.be'),
|
|
||||||
domain('ytimg.com'),
|
|
||||||
domain('googlevideo.com'),
|
|
||||||
domain('youtube-nocookie.com'),
|
|
||||||
domain('yt.be'),
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
const SITE_ATTRIBUTES: Record<string, string[]> = {
|
|
||||||
amazon: ['ads'],
|
|
||||||
apple: ['cn'],
|
|
||||||
facebook: ['ads'],
|
|
||||||
google: ['ads', 'cn'],
|
|
||||||
instagram: ['ads'],
|
|
||||||
microsoft: ['cn'],
|
|
||||||
tiktok: ['ads', 'cn'],
|
|
||||||
twitter: ['ads'],
|
|
||||||
youtube: ['ads'],
|
|
||||||
};
|
|
||||||
|
|
||||||
const CN_BLOCKS = [
|
|
||||||
'1.0.1.0/24',
|
|
||||||
'1.0.2.0/23',
|
|
||||||
'1.0.8.0/21',
|
|
||||||
'14.0.12.0/22',
|
|
||||||
'27.0.128.0/21',
|
|
||||||
'36.0.0.0/22',
|
|
||||||
'39.0.0.0/24',
|
|
||||||
'42.0.0.0/22',
|
|
||||||
'58.14.0.0/15',
|
|
||||||
'59.32.0.0/11',
|
|
||||||
'61.128.0.0/10',
|
|
||||||
'101.16.0.0/12',
|
|
||||||
'103.1.8.0/22',
|
|
||||||
'106.0.0.0/10',
|
|
||||||
'110.6.0.0/15',
|
|
||||||
'111.0.0.0/10',
|
|
||||||
'112.0.0.0/10',
|
|
||||||
'113.0.0.0/9',
|
|
||||||
'114.28.0.0/16',
|
|
||||||
'116.0.0.0/9',
|
|
||||||
'117.8.0.0/13',
|
|
||||||
'118.24.0.0/15',
|
|
||||||
'119.0.0.0/9',
|
|
||||||
'120.0.0.0/10',
|
|
||||||
'121.0.0.0/8',
|
|
||||||
'124.0.0.0/8',
|
|
||||||
'125.32.0.0/11',
|
|
||||||
'139.196.0.0/14',
|
|
||||||
'140.75.0.0/16',
|
|
||||||
'175.0.0.0/12',
|
|
||||||
'180.76.0.0/16',
|
|
||||||
'182.16.0.0/12',
|
|
||||||
'183.0.0.0/10',
|
|
||||||
'202.0.0.0/12',
|
|
||||||
'203.0.0.0/12',
|
|
||||||
'210.0.0.0/12',
|
|
||||||
'211.64.0.0/11',
|
|
||||||
'218.0.0.0/9',
|
|
||||||
'219.72.0.0/14',
|
|
||||||
'220.112.0.0/12',
|
|
||||||
'221.0.0.0/9',
|
|
||||||
'222.16.0.0/12',
|
|
||||||
'2001:250::/35',
|
|
||||||
'2400:3200::/32',
|
|
||||||
'2408:8000::/20',
|
|
||||||
];
|
|
||||||
|
|
||||||
const CN_EXTRA_BLOCKS = Array.from(
|
|
||||||
{ length: 96 },
|
|
||||||
(_, index) => `${39 + Math.floor(index / 16)}.${(index % 16) * 16}.0.0/12`,
|
|
||||||
);
|
|
||||||
|
|
||||||
const IP_ENTRIES: Record<string, GeoEntry[]> = {
|
|
||||||
cloudflare: [
|
|
||||||
'103.21.244.0/22',
|
|
||||||
'103.22.200.0/22',
|
|
||||||
'103.31.4.0/22',
|
|
||||||
'104.16.0.0/13',
|
|
||||||
'104.24.0.0/14',
|
|
||||||
'108.162.192.0/18',
|
|
||||||
'131.0.72.0/22',
|
|
||||||
'141.101.64.0/18',
|
|
||||||
'162.158.0.0/15',
|
|
||||||
'172.64.0.0/13',
|
|
||||||
'173.245.48.0/20',
|
|
||||||
'188.114.96.0/20',
|
|
||||||
'190.93.240.0/20',
|
|
||||||
'197.234.240.0/22',
|
|
||||||
'198.41.128.0/17',
|
|
||||||
'2400:cb00::/32',
|
|
||||||
'2606:4700::/32',
|
|
||||||
].map(cidr),
|
|
||||||
cn: [...CN_BLOCKS, ...CN_EXTRA_BLOCKS].map(cidr),
|
|
||||||
facebook: [
|
|
||||||
'31.13.24.0/21',
|
|
||||||
'31.13.64.0/18',
|
|
||||||
'66.220.144.0/20',
|
|
||||||
'69.63.176.0/20',
|
|
||||||
'69.171.224.0/19',
|
|
||||||
'157.240.0.0/16',
|
|
||||||
'179.60.192.0/22',
|
|
||||||
'185.60.216.0/22',
|
|
||||||
'2a03:2880::/32',
|
|
||||||
].map(cidr),
|
|
||||||
google: [
|
|
||||||
'8.8.4.0/24',
|
|
||||||
'8.8.8.0/24',
|
|
||||||
'34.64.0.0/10',
|
|
||||||
'35.184.0.0/13',
|
|
||||||
'64.233.160.0/19',
|
|
||||||
'66.102.0.0/20',
|
|
||||||
'72.14.192.0/18',
|
|
||||||
'74.125.0.0/16',
|
|
||||||
'108.177.8.0/21',
|
|
||||||
'142.250.0.0/15',
|
|
||||||
'172.217.0.0/16',
|
|
||||||
'216.58.192.0/19',
|
|
||||||
'2404:6800::/32',
|
|
||||||
'2607:f8b0::/32',
|
|
||||||
].map(cidr),
|
|
||||||
ir: [
|
|
||||||
'2.144.0.0/14',
|
|
||||||
'5.22.0.0/17',
|
|
||||||
'31.2.128.0/17',
|
|
||||||
'37.32.0.0/19',
|
|
||||||
'46.32.0.0/19',
|
|
||||||
'78.38.0.0/15',
|
|
||||||
'80.191.0.0/16',
|
|
||||||
'85.15.0.0/18',
|
|
||||||
'91.98.0.0/15',
|
|
||||||
'178.22.72.0/21',
|
|
||||||
'185.8.172.0/22',
|
|
||||||
'188.34.0.0/17',
|
|
||||||
'217.218.0.0/15',
|
|
||||||
].map(cidr),
|
|
||||||
netflix: [
|
|
||||||
'23.246.0.0/18',
|
|
||||||
'37.77.184.0/21',
|
|
||||||
'45.57.0.0/17',
|
|
||||||
'64.120.128.0/17',
|
|
||||||
'66.197.128.0/17',
|
|
||||||
'108.175.32.0/20',
|
|
||||||
'185.2.220.0/22',
|
|
||||||
'192.173.64.0/18',
|
|
||||||
'198.38.96.0/19',
|
|
||||||
'198.45.48.0/20',
|
|
||||||
].map(cidr),
|
|
||||||
private: [
|
|
||||||
'0.0.0.0/8',
|
|
||||||
'10.0.0.0/8',
|
|
||||||
'100.64.0.0/10',
|
|
||||||
'127.0.0.0/8',
|
|
||||||
'169.254.0.0/16',
|
|
||||||
'172.16.0.0/12',
|
|
||||||
'192.0.0.0/24',
|
|
||||||
'192.0.2.0/24',
|
|
||||||
'192.168.0.0/16',
|
|
||||||
'198.18.0.0/15',
|
|
||||||
'198.51.100.0/24',
|
|
||||||
'203.0.113.0/24',
|
|
||||||
'224.0.0.0/4',
|
|
||||||
'240.0.0.0/4',
|
|
||||||
'255.255.255.255/32',
|
|
||||||
'::1/128',
|
|
||||||
'fc00::/7',
|
|
||||||
'fe80::/10',
|
|
||||||
].map(cidr),
|
|
||||||
ru: [
|
|
||||||
'2.60.0.0/14',
|
|
||||||
'5.8.0.0/19',
|
|
||||||
'31.6.0.0/17',
|
|
||||||
'37.9.0.0/19',
|
|
||||||
'46.16.0.0/21',
|
|
||||||
'62.76.0.0/18',
|
|
||||||
'77.37.128.0/17',
|
|
||||||
'78.24.216.0/21',
|
|
||||||
'79.104.0.0/15',
|
|
||||||
'80.64.128.0/19',
|
|
||||||
'81.16.96.0/19',
|
|
||||||
'82.140.128.0/18',
|
|
||||||
'85.113.0.0/16',
|
|
||||||
'87.226.0.0/16',
|
|
||||||
'91.77.0.0/16',
|
|
||||||
'93.157.0.0/17',
|
|
||||||
'94.19.0.0/16',
|
|
||||||
'95.24.0.0/13',
|
|
||||||
'178.176.0.0/13',
|
|
||||||
'188.128.0.0/13',
|
|
||||||
'213.87.0.0/16',
|
|
||||||
'217.66.152.0/21',
|
|
||||||
'2a00:1148::/32',
|
|
||||||
].map(cidr),
|
|
||||||
telegram: [
|
|
||||||
'91.108.4.0/22',
|
|
||||||
'91.108.8.0/22',
|
|
||||||
'91.108.12.0/22',
|
|
||||||
'91.108.16.0/22',
|
|
||||||
'91.108.20.0/22',
|
|
||||||
'91.108.56.0/22',
|
|
||||||
'149.154.160.0/20',
|
|
||||||
'2001:67c:4e8::/48',
|
|
||||||
'2001:b28:f23d::/48',
|
|
||||||
'2001:b28:f23f::/48',
|
|
||||||
].map(cidr),
|
|
||||||
us: [
|
|
||||||
'3.0.0.0/9',
|
|
||||||
'12.0.0.0/8',
|
|
||||||
'23.192.0.0/11',
|
|
||||||
'34.192.0.0/10',
|
|
||||||
'50.16.0.0/14',
|
|
||||||
'52.0.0.0/10',
|
|
||||||
'63.64.0.0/11',
|
|
||||||
'65.0.0.0/10',
|
|
||||||
'68.32.0.0/11',
|
|
||||||
'71.0.0.0/11',
|
|
||||||
'96.0.0.0/9',
|
|
||||||
'128.0.0.0/10',
|
|
||||||
'199.0.0.0/12',
|
|
||||||
'208.64.0.0/12',
|
|
||||||
'2600:1f00::/24',
|
|
||||||
].map(cidr),
|
|
||||||
};
|
|
||||||
|
|
||||||
function categoriesOf(
|
|
||||||
entries: Record<string, GeoEntry[]>,
|
|
||||||
attributes: Record<string, string[]> = {},
|
|
||||||
): GeoCategory[] {
|
|
||||||
return Object.keys(entries)
|
|
||||||
.sort()
|
|
||||||
.map((code) => ({ code, entries: entries[code].length, attributes: attributes[code] ?? [] }));
|
|
||||||
}
|
|
||||||
|
|
||||||
const SITE_CATEGORIES = categoriesOf(SITE_ENTRIES, SITE_ATTRIBUTES);
|
|
||||||
const IP_CATEGORIES = categoriesOf(IP_ENTRIES);
|
|
||||||
|
|
||||||
const UPDATED_AT = Date.UTC(2026, 6, 24, 3, 12);
|
|
||||||
|
|
||||||
const GEOSITE_FILE: GeoFile = {
|
|
||||||
name: 'geosite.dat',
|
|
||||||
kind: 'site',
|
|
||||||
size: 4_812_544,
|
|
||||||
modifiedAt: UPDATED_AT,
|
|
||||||
categories: SITE_CATEGORIES.length,
|
|
||||||
};
|
|
||||||
|
|
||||||
const GEOIP_FILE: GeoFile = {
|
|
||||||
name: 'geoip.dat',
|
|
||||||
kind: 'ip',
|
|
||||||
size: 8_694_272,
|
|
||||||
modifiedAt: UPDATED_AT,
|
|
||||||
categories: IP_CATEGORIES.length,
|
|
||||||
};
|
|
||||||
|
|
||||||
const DAMAGED_FILE: GeoFile = {
|
|
||||||
name: 'geosite-custom.dat',
|
|
||||||
kind: 'site',
|
|
||||||
size: 262_144,
|
|
||||||
modifiedAt: Date.UTC(2026, 5, 2, 19, 45),
|
|
||||||
categories: 0,
|
|
||||||
error: 'proto: cannot parse invalid wire-format data',
|
|
||||||
};
|
|
||||||
|
|
||||||
const OVERSIZED_FILE: GeoFile = {
|
|
||||||
name: 'geoip-full.dat',
|
|
||||||
kind: 'ip',
|
|
||||||
size: 96_468_992,
|
|
||||||
modifiedAt: Date.UTC(2026, 6, 20, 8, 5),
|
|
||||||
categories: 0,
|
|
||||||
error: 'geodata file is too large to browse',
|
|
||||||
};
|
|
||||||
|
|
||||||
const DATASETS: Record<string, { categories: GeoCategory[]; entries: Record<string, GeoEntry[]> }> =
|
|
||||||
{
|
|
||||||
'geosite.dat': { categories: SITE_CATEGORIES, entries: SITE_ENTRIES },
|
|
||||||
'geoip.dat': { categories: IP_CATEGORIES, entries: IP_ENTRIES },
|
|
||||||
};
|
|
||||||
|
|
||||||
function routesFor(files: GeoFile[]): GeoRoutes {
|
|
||||||
return {
|
|
||||||
'/panel/api/xray/geodata/files': () => files,
|
|
||||||
'/panel/api/xray/geodata/categories': (query) => {
|
|
||||||
const dataset = DATASETS[query.get('file') ?? ''];
|
|
||||||
const needle = (query.get('q') ?? '').trim().toLowerCase();
|
|
||||||
const items = (dataset?.categories ?? []).filter((category) =>
|
|
||||||
category.code.includes(needle),
|
|
||||||
);
|
|
||||||
return { total: items.length, items };
|
|
||||||
},
|
|
||||||
'/panel/api/xray/geodata/entries': (query) => {
|
|
||||||
const dataset = DATASETS[query.get('file') ?? ''];
|
|
||||||
const needle = (query.get('q') ?? '').trim().toLowerCase();
|
|
||||||
const matched = (dataset?.entries[query.get('code') ?? ''] ?? []).filter((entry) =>
|
|
||||||
entry.value.toLowerCase().includes(needle),
|
|
||||||
);
|
|
||||||
const offset = Number(query.get('offset') ?? 0);
|
|
||||||
const limit = Number(query.get('limit') ?? 100);
|
|
||||||
return { total: matched.length, items: matched.slice(offset, offset + limit) };
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function withFiles(files: GeoFile[]): Decorator {
|
|
||||||
const routes = routesFor(files);
|
|
||||||
return function GeodataBackend(Story) {
|
|
||||||
return (
|
|
||||||
<GeoApi routes={routes}>
|
|
||||||
<Story />
|
|
||||||
</GeoApi>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const withDatabases = withFiles([GEOSITE_FILE, GEOIP_FILE]);
|
|
||||||
|
|
||||||
function BrowserDemo(props: GeoBrowserModalProps) {
|
|
||||||
const [open, setOpen] = useState(props.open);
|
|
||||||
const [value, setValue] = useState(props.value);
|
|
||||||
const [synced, setSynced] = useState({ open: props.open, value: props.value });
|
|
||||||
if (synced.open !== props.open || synced.value !== props.value) {
|
|
||||||
setSynced({ open: props.open, value: props.value });
|
|
||||||
setOpen(props.open);
|
|
||||||
setValue(props.value);
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<Space orientation="vertical" size={12}>
|
|
||||||
<Space size={8}>
|
|
||||||
<Button onClick={() => setOpen(true)}>Open geo browser</Button>
|
|
||||||
<Typography.Text code>{value || 'no rule yet'}</Typography.Text>
|
|
||||||
</Space>
|
|
||||||
<GeoBrowserModal
|
|
||||||
{...props}
|
|
||||||
open={open}
|
|
||||||
value={value}
|
|
||||||
onApply={(next) => {
|
|
||||||
setValue(next);
|
|
||||||
setOpen(false);
|
|
||||||
}}
|
|
||||||
onClose={() => setOpen(false)}
|
|
||||||
/>
|
|
||||||
</Space>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const meta = {
|
|
||||||
title: 'Geodata/GeoBrowserModal',
|
|
||||||
component: GeoBrowserModal,
|
|
||||||
tags: ['autodocs'],
|
|
||||||
parameters: {
|
|
||||||
layout: 'padded',
|
|
||||||
a11y: {
|
|
||||||
config: {
|
|
||||||
rules: [{ id: 'color-contrast', enabled: false }],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
docs: {
|
|
||||||
description: {
|
|
||||||
component:
|
|
||||||
'Browser for the geosite/geoip `.dat` databases Xray resolves `geosite:` and `geoip:` routing tokens against: pick a database, search its categories, tick the ones a rule needs, and preview the domains or CIDRs inside the highlighted category. Applying merges the ticked categories back into the rule string, keeping hand-typed domains untouched. The stories serve `/panel/api/xray/geodata/*` from an in-memory fixture, so search, paging and selection all work without a panel backend.',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
args: {
|
|
||||||
open: true,
|
|
||||||
kind: 'site',
|
|
||||||
value: '',
|
|
||||||
onApply: () => undefined,
|
|
||||||
onClose: () => undefined,
|
|
||||||
},
|
|
||||||
argTypes: {
|
|
||||||
open: { description: 'Whether the modal is visible.' },
|
|
||||||
kind: {
|
|
||||||
description:
|
|
||||||
'Which database layout the rule targets: `site` for domain rules, `ip` for CIDR rules. Decides the preselected database and the token prefix.',
|
|
||||||
control: 'inline-radio',
|
|
||||||
options: ['site', 'ip'],
|
|
||||||
},
|
|
||||||
value: {
|
|
||||||
description:
|
|
||||||
'Current rule string, comma separated. Tokens that match a category in the opened database come back preselected.',
|
|
||||||
},
|
|
||||||
onApply: { description: 'Called with the merged rule string when Apply is pressed.' },
|
|
||||||
onClose: { description: 'Called when the modal is dismissed.' },
|
|
||||||
},
|
|
||||||
render: (args) => <BrowserDemo {...args} />,
|
|
||||||
} satisfies Meta<typeof GeoBrowserModal>;
|
|
||||||
|
|
||||||
export default meta;
|
|
||||||
|
|
||||||
type Story = StoryObj<typeof meta>;
|
|
||||||
|
|
||||||
export const SiteDatabase: Story = {
|
|
||||||
decorators: [withDatabases],
|
|
||||||
args: { kind: 'site', value: 'geosite:google, geosite:telegram, ads.example.com' },
|
|
||||||
};
|
|
||||||
|
|
||||||
export const CategoryPreview: Story = {
|
|
||||||
decorators: [withDatabases],
|
|
||||||
args: { kind: 'site', value: 'geosite:google' },
|
|
||||||
parameters: {
|
|
||||||
a11y: {
|
|
||||||
config: {
|
|
||||||
rules: [
|
|
||||||
{ id: 'color-contrast', enabled: false },
|
|
||||||
{ id: 'scrollable-region-focusable', enabled: false },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
play: async ({ canvasElement, userEvent }) => {
|
|
||||||
const body = within(canvasElement.ownerDocument.body);
|
|
||||||
await userEvent.type(await body.findByPlaceholderText('Search category'), 'telegram');
|
|
||||||
await userEvent.click(await body.findByText('telegram'));
|
|
||||||
await expect(await body.findByText('t.me')).toBeVisible();
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export const IpDatabase: Story = {
|
|
||||||
decorators: [withDatabases],
|
|
||||||
args: { kind: 'ip', value: 'geoip:private, 10.0.0.0/8' },
|
|
||||||
};
|
|
||||||
|
|
||||||
export const NoDatabases: Story = {
|
|
||||||
decorators: [withFiles([])],
|
|
||||||
args: { kind: 'site', value: 'geosite:google' },
|
|
||||||
};
|
|
||||||
|
|
||||||
export const DamagedDatabase: Story = {
|
|
||||||
decorators: [withFiles([GEOSITE_FILE, DAMAGED_FILE, OVERSIZED_FILE])],
|
|
||||||
args: { kind: 'site', value: '' },
|
|
||||||
};
|
|
||||||
@@ -1,473 +0,0 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import {
|
|
||||||
Alert,
|
|
||||||
Button,
|
|
||||||
Empty,
|
|
||||||
Input,
|
|
||||||
Modal,
|
|
||||||
Pagination,
|
|
||||||
Select,
|
|
||||||
Space,
|
|
||||||
Table,
|
|
||||||
Tag,
|
|
||||||
Tooltip,
|
|
||||||
Typography,
|
|
||||||
} from 'antd';
|
|
||||||
import type { ColumnsType } from 'antd/es/table';
|
|
||||||
|
|
||||||
import { useGeodataCategories, useGeodataEntries, useGeodataFiles } from '@/api/queries/useGeodata';
|
|
||||||
import { canonicalToken, mergeSelection, selectionFromValue, tokenFor } from '@/lib/xray/geoTokens';
|
|
||||||
import { SizeFormatter } from '@/utils';
|
|
||||||
import type { GeoCategory, GeoEntry, GeoFile, GeoKind } from '@/generated/types';
|
|
||||||
|
|
||||||
import './GeoBrowserModal.css';
|
|
||||||
|
|
||||||
const ENTRY_PAGE_SIZE = 100;
|
|
||||||
const CATEGORY_SCROLL_HEIGHT = 438;
|
|
||||||
const ENTRY_FILTER_DELAY = 500;
|
|
||||||
|
|
||||||
export interface GeoBrowserModalProps {
|
|
||||||
open: boolean;
|
|
||||||
kind: GeoKind;
|
|
||||||
value: string;
|
|
||||||
onApply: (value: string) => void;
|
|
||||||
onClose: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
// A geosite category inside an ip rule (or the reverse) is a config Xray will
|
|
||||||
// reject, so a field only ever offers databases of its own kind.
|
|
||||||
function databasesFor(files: GeoFile[], kind: GeoKind): GeoFile[] {
|
|
||||||
return files.filter(
|
|
||||||
(file) => file.kind === kind || (file.error && namePrefersKind(file.name, kind)),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function namePrefersKind(name: string, kind: GeoKind): boolean {
|
|
||||||
return name.toLowerCase().includes('ip') === (kind === 'ip');
|
|
||||||
}
|
|
||||||
|
|
||||||
function preferredFile(files: GeoFile[], kind: GeoKind): string | undefined {
|
|
||||||
const usable = databasesFor(files, kind).filter((file) => !file.error);
|
|
||||||
const preferredName = kind === 'ip' ? 'geoip.dat' : 'geosite.dat';
|
|
||||||
return usable.find((file) => file.name === preferredName)?.name ?? usable[0]?.name;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function GeoBrowserModal({
|
|
||||||
open,
|
|
||||||
kind,
|
|
||||||
value,
|
|
||||||
onApply,
|
|
||||||
onClose,
|
|
||||||
}: GeoBrowserModalProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [file, setFile] = useState<string | undefined>(undefined);
|
|
||||||
const [categoryQuery, setCategoryQuery] = useState('');
|
|
||||||
const [activeCode, setActiveCode] = useState<string | undefined>(undefined);
|
|
||||||
const [entryQuery, setEntryQuery] = useState('');
|
|
||||||
const [entryFilter, setEntryFilter] = useState('');
|
|
||||||
const [entryPage, setEntryPage] = useState(1);
|
|
||||||
const [selected, setSelected] = useState<string[]>([]);
|
|
||||||
|
|
||||||
const [known, setKnown] = useState<Set<string>>(() => new Set());
|
|
||||||
const [seededFiles, setSeededFiles] = useState<Set<string>>(() => new Set());
|
|
||||||
|
|
||||||
const filesQuery = useGeodataFiles(open);
|
|
||||||
const files = useMemo(() => databasesFor(filesQuery.data ?? [], kind), [filesQuery.data, kind]);
|
|
||||||
const activeFile = useMemo(
|
|
||||||
() => files.find((candidate) => candidate.name === file),
|
|
||||||
[files, file],
|
|
||||||
);
|
|
||||||
const fileKind: GeoKind = useMemo(() => activeFile?.kind ?? kind, [activeFile, kind]);
|
|
||||||
|
|
||||||
const categoriesQuery = useGeodataCategories(file, '', open && !!file);
|
|
||||||
// While a newly picked database loads, the query still serves the previous
|
|
||||||
// one's categories; seeding or filtering against those would attribute one
|
|
||||||
// database's codes to another.
|
|
||||||
const categoriesLoaded = !categoriesQuery.isPlaceholderData && !categoriesQuery.isLoading;
|
|
||||||
const categories = useMemo(
|
|
||||||
() => (categoriesLoaded ? (categoriesQuery.data?.items ?? []) : []),
|
|
||||||
[categoriesLoaded, categoriesQuery.data],
|
|
||||||
);
|
|
||||||
|
|
||||||
// Only the settled filter reaches the query key: every request rescans the
|
|
||||||
// whole .dat file server-side, so a per-keystroke fetch would be one full
|
|
||||||
// scan per character while the box itself stays instant.
|
|
||||||
const entriesQuery = useGeodataEntries(
|
|
||||||
file,
|
|
||||||
activeCode,
|
|
||||||
entryFilter,
|
|
||||||
(entryPage - 1) * ENTRY_PAGE_SIZE,
|
|
||||||
ENTRY_PAGE_SIZE,
|
|
||||||
open && !!file && !!activeCode,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Resets clear both halves at once so a switch of database or category never
|
|
||||||
// renders with the previous filter still in the key, which would fire the
|
|
||||||
// very request the debounce exists to avoid.
|
|
||||||
const clearEntryFilter = useCallback(() => {
|
|
||||||
setEntryQuery('');
|
|
||||||
setEntryFilter('');
|
|
||||||
setEntryPage(1);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (entryQuery === entryFilter) return;
|
|
||||||
const handle = window.setTimeout(() => {
|
|
||||||
setEntryFilter(entryQuery);
|
|
||||||
setEntryPage(1);
|
|
||||||
}, ENTRY_FILTER_DELAY);
|
|
||||||
return () => window.clearTimeout(handle);
|
|
||||||
}, [entryQuery, entryFilter]);
|
|
||||||
|
|
||||||
// Opening, picking the default database and seeding the selection are all
|
|
||||||
// render-time adjustments — an effect would paint the previous state first.
|
|
||||||
const [wasOpen, setWasOpen] = useState(false);
|
|
||||||
if (open !== wasOpen) {
|
|
||||||
setWasOpen(open);
|
|
||||||
if (open) {
|
|
||||||
setKnown(new Set());
|
|
||||||
setSeededFiles(new Set());
|
|
||||||
setCategoryQuery('');
|
|
||||||
setEntryQuery('');
|
|
||||||
setEntryFilter('');
|
|
||||||
setActiveCode(undefined);
|
|
||||||
setEntryPage(1);
|
|
||||||
setSelected([]);
|
|
||||||
}
|
|
||||||
} else if (open && !file && files.length > 0) {
|
|
||||||
setFile(preferredFile(files, kind));
|
|
||||||
} else if (open && file && categories.length > 0 && !seededFiles.has(file)) {
|
|
||||||
const tokens = categories.map((category) => tokenFor(file, category.code, fileKind));
|
|
||||||
setKnown(new Set([...known, ...tokens]));
|
|
||||||
setSeededFiles(new Set(seededFiles).add(file));
|
|
||||||
const fromValue = selectionFromValue(value, new Set(tokens));
|
|
||||||
if (fromValue.length > 0) {
|
|
||||||
setSelected((previous) => [
|
|
||||||
...previous,
|
|
||||||
...fromValue.filter((token) => !previous.includes(token)),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const visibleCategories = useMemo(() => {
|
|
||||||
const query = categoryQuery.trim().toLowerCase();
|
|
||||||
if (!query) return categories;
|
|
||||||
return categories.filter((category) => category.code.includes(query));
|
|
||||||
}, [categories, categoryQuery]);
|
|
||||||
|
|
||||||
// Comparisons run through the canonical form: a field may hold the long
|
|
||||||
// ext:geosite.dat:cn spelling or a different case, and those name the same
|
|
||||||
// category as the geosite:cn this modal generates.
|
|
||||||
const selectedCodes = useMemo(() => {
|
|
||||||
if (!file) return [];
|
|
||||||
const chosen = new Set(selected.map(canonicalToken));
|
|
||||||
return categories
|
|
||||||
.filter((category) => chosen.has(canonicalToken(tokenFor(file, category.code, fileKind))))
|
|
||||||
.map((category) => category.code);
|
|
||||||
}, [categories, file, fileKind, selected]);
|
|
||||||
|
|
||||||
const toggle = useCallback(
|
|
||||||
(codes: string[]) => {
|
|
||||||
if (!file) return;
|
|
||||||
const chosen = new Set(codes.map((code) => tokenFor(file, code, fileKind)));
|
|
||||||
const chosenCanonical = new Set([...chosen].map(canonicalToken));
|
|
||||||
// The table reports keys for the rows it currently shows, so a selection
|
|
||||||
// made before the search box was narrowed must survive untouched.
|
|
||||||
const shown = new Set(
|
|
||||||
visibleCategories.map((category) =>
|
|
||||||
canonicalToken(tokenFor(file, category.code, fileKind)),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
setSelected((previous) => {
|
|
||||||
const kept = previous.filter((token) => {
|
|
||||||
const canonical = canonicalToken(token);
|
|
||||||
return !shown.has(canonical) || chosenCanonical.has(canonical);
|
|
||||||
});
|
|
||||||
const keptCanonical = new Set(kept.map(canonicalToken));
|
|
||||||
return [
|
|
||||||
...kept,
|
|
||||||
...[...chosen].filter((token) => !keptCanonical.has(canonicalToken(token))),
|
|
||||||
];
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[visibleCategories, file, fileKind],
|
|
||||||
);
|
|
||||||
|
|
||||||
const categoryColumns: ColumnsType<GeoCategory> = useMemo(
|
|
||||||
() => [
|
|
||||||
{
|
|
||||||
title: t('pages.xray.geoBrowser.searchCategory'),
|
|
||||||
dataIndex: 'code',
|
|
||||||
render: (code: string, category: GeoCategory) => (
|
|
||||||
<span className="geo-category">
|
|
||||||
<span className="geo-code">{code}</span>
|
|
||||||
{category.attributes?.length > 0 && (
|
|
||||||
<span className="geo-attrs">
|
|
||||||
{category.attributes.map((attribute) => (
|
|
||||||
<Tag key={attribute} variant="filled">
|
|
||||||
@{attribute}
|
|
||||||
</Tag>
|
|
||||||
))}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
dataIndex: 'entries',
|
|
||||||
align: 'right',
|
|
||||||
width: 90,
|
|
||||||
render: (entries: number) => <span className="geo-count">{entries.toLocaleString()}</span>,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
[t],
|
|
||||||
);
|
|
||||||
|
|
||||||
const entryColumns: ColumnsType<GeoEntry> = useMemo(
|
|
||||||
() => [
|
|
||||||
{
|
|
||||||
dataIndex: 'kind',
|
|
||||||
width: 88,
|
|
||||||
render: (entryKind: string) => (
|
|
||||||
<Tag variant="filled" className={`geo-kind geo-kind-${entryKind}`}>
|
|
||||||
{entryKind}
|
|
||||||
</Tag>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
dataIndex: 'value',
|
|
||||||
render: (entryValue: string) => <span className="geo-entry-value">{entryValue}</span>,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
const fileOptions = files.map((candidate) => ({
|
|
||||||
value: candidate.name,
|
|
||||||
label: candidate.error
|
|
||||||
? `${candidate.name} — ${describeFileError(candidate.error, t)}`
|
|
||||||
: candidate.name,
|
|
||||||
disabled: !!candidate.error,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const meta = activeFile
|
|
||||||
? t('pages.xray.geoBrowser.fileMeta', {
|
|
||||||
count: activeFile.categories.toLocaleString(),
|
|
||||||
size: SizeFormatter.sizeFormat(activeFile.size),
|
|
||||||
date: new Date(activeFile.modifiedAt).toLocaleString(),
|
|
||||||
})
|
|
||||||
: '';
|
|
||||||
|
|
||||||
const entriesTotal = entriesQuery.data?.total ?? 0;
|
|
||||||
const activeCategory = categories.find((category) => category.code === activeCode);
|
|
||||||
const countLabel = activeCategory
|
|
||||||
? t(
|
|
||||||
fileKind === 'ip'
|
|
||||||
? 'pages.xray.geoBrowser.subnetsCount'
|
|
||||||
: 'pages.xray.geoBrowser.entriesCount',
|
|
||||||
{
|
|
||||||
count: activeCategory.entries.toLocaleString(),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
: '';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal
|
|
||||||
open={open}
|
|
||||||
title={t('pages.xray.geoBrowser.title')}
|
|
||||||
width={880}
|
|
||||||
onCancel={onClose}
|
|
||||||
onOk={() => onApply(mergeSelection(value, selected, known))}
|
|
||||||
okText={t('pages.xray.geoBrowser.apply')}
|
|
||||||
cancelText={t('close')}
|
|
||||||
className="geo-browser-modal"
|
|
||||||
>
|
|
||||||
{filesQuery.isError && (
|
|
||||||
<Alert
|
|
||||||
type="error"
|
|
||||||
showIcon
|
|
||||||
title={t('pages.xray.geoBrowser.loadFailed')}
|
|
||||||
className="mb-12"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!filesQuery.isError && !filesQuery.isLoading && files.length === 0 ? (
|
|
||||||
<Empty
|
|
||||||
description={
|
|
||||||
<span>
|
|
||||||
{t('pages.xray.geoBrowser.noFiles')}
|
|
||||||
<br />
|
|
||||||
<Typography.Text type="secondary">
|
|
||||||
{t('pages.xray.geoBrowser.noFilesHint')}
|
|
||||||
</Typography.Text>
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className="geo-toolbar">
|
|
||||||
<Select
|
|
||||||
value={file}
|
|
||||||
options={fileOptions}
|
|
||||||
onChange={(next) => {
|
|
||||||
setFile(next);
|
|
||||||
setActiveCode(undefined);
|
|
||||||
setCategoryQuery('');
|
|
||||||
clearEntryFilter();
|
|
||||||
}}
|
|
||||||
style={{ minWidth: 200 }}
|
|
||||||
aria-label={t('pages.xray.geoBrowser.database')}
|
|
||||||
/>
|
|
||||||
<Input.Search
|
|
||||||
value={categoryQuery}
|
|
||||||
onChange={(event) => setCategoryQuery(event.target.value)}
|
|
||||||
placeholder={t('pages.xray.geoBrowser.searchCategory')}
|
|
||||||
allowClear
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
onClick={() =>
|
|
||||||
toggle([...new Set([...selectedCodes, ...visibleCategories.map((c) => c.code)])])
|
|
||||||
}
|
|
||||||
disabled={visibleCategories.length === 0}
|
|
||||||
>
|
|
||||||
{`${t('pages.xray.geoBrowser.selectFound')} (${visibleCategories.length.toLocaleString()})`}
|
|
||||||
</Button>
|
|
||||||
<span className="geo-meta">{meta}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="geo-columns">
|
|
||||||
<div className="geo-panel geo-categories">
|
|
||||||
<Table
|
|
||||||
size="small"
|
|
||||||
virtual
|
|
||||||
showHeader={false}
|
|
||||||
rowKey="code"
|
|
||||||
columns={categoryColumns}
|
|
||||||
dataSource={visibleCategories}
|
|
||||||
loading={
|
|
||||||
filesQuery.isLoading ||
|
|
||||||
categoriesQuery.isLoading ||
|
|
||||||
categoriesQuery.isPlaceholderData
|
|
||||||
}
|
|
||||||
pagination={false}
|
|
||||||
scroll={{ y: CATEGORY_SCROLL_HEIGHT }}
|
|
||||||
locale={{ emptyText: t('pages.xray.geoBrowser.noMatches') }}
|
|
||||||
rowSelection={{
|
|
||||||
columnWidth: 42,
|
|
||||||
preserveSelectedRowKeys: true,
|
|
||||||
selectedRowKeys: selectedCodes,
|
|
||||||
onChange: (keys) => toggle(keys as string[]),
|
|
||||||
}}
|
|
||||||
onRow={(category) => ({
|
|
||||||
onClick: (event) => {
|
|
||||||
if ((event.target as HTMLElement).closest('.ant-table-selection-column'))
|
|
||||||
return;
|
|
||||||
setActiveCode(category.code);
|
|
||||||
clearEntryFilter();
|
|
||||||
},
|
|
||||||
})}
|
|
||||||
rowClassName={(category) => (category.code === activeCode ? 'geo-row-active' : '')}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="geo-panel geo-preview">
|
|
||||||
{activeCode ? (
|
|
||||||
<>
|
|
||||||
<div className="geo-preview-head">
|
|
||||||
<Tooltip title={file ? tokenFor(file, activeCode, fileKind) : activeCode}>
|
|
||||||
<span className="geo-preview-title">{activeCode}</span>
|
|
||||||
</Tooltip>
|
|
||||||
<Typography.Text type="secondary">{countLabel}</Typography.Text>
|
|
||||||
<Input
|
|
||||||
value={entryQuery}
|
|
||||||
onChange={(event) => setEntryQuery(event.target.value)}
|
|
||||||
placeholder={t('pages.xray.geoBrowser.searchEntries')}
|
|
||||||
allowClear
|
|
||||||
className="geo-entry-filter"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="geo-preview-body">
|
|
||||||
<Table
|
|
||||||
size="small"
|
|
||||||
showHeader={false}
|
|
||||||
rowKey={(entry, index) => `${entry.value}-${index}`}
|
|
||||||
columns={entryColumns}
|
|
||||||
dataSource={entriesQuery.data?.items ?? []}
|
|
||||||
loading={entriesQuery.isLoading}
|
|
||||||
locale={{
|
|
||||||
emptyText: entriesQuery.isError
|
|
||||||
? t('pages.xray.geoBrowser.loadFailed')
|
|
||||||
: t('pages.xray.geoBrowser.noMatches'),
|
|
||||||
}}
|
|
||||||
pagination={false}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="geo-pager">
|
|
||||||
<Pagination
|
|
||||||
current={entryPage}
|
|
||||||
pageSize={ENTRY_PAGE_SIZE}
|
|
||||||
total={entriesTotal}
|
|
||||||
size="small"
|
|
||||||
showSizeChanger={false}
|
|
||||||
onChange={setEntryPage}
|
|
||||||
showTotal={(total, range) =>
|
|
||||||
t('pages.xray.geoBrowser.shownRange', {
|
|
||||||
from: range[0].toLocaleString(),
|
|
||||||
to: range[1].toLocaleString(),
|
|
||||||
total: total.toLocaleString(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<div className="geo-placeholder">
|
|
||||||
<Typography.Text type="secondary">
|
|
||||||
{t('pages.xray.geoBrowser.pickCategory')}
|
|
||||||
</Typography.Text>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="geo-footer">
|
|
||||||
{selected.length === 0 ? (
|
|
||||||
<Typography.Text type="secondary">
|
|
||||||
{t('pages.xray.geoBrowser.emptySelection')}
|
|
||||||
</Typography.Text>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Space size={4} wrap className="geo-chips">
|
|
||||||
{selected.map((token) => (
|
|
||||||
<Tag
|
|
||||||
key={token}
|
|
||||||
closable
|
|
||||||
color="processing"
|
|
||||||
onClose={() =>
|
|
||||||
setSelected((previous) => previous.filter((item) => item !== token))
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{token}
|
|
||||||
</Tag>
|
|
||||||
))}
|
|
||||||
</Space>
|
|
||||||
<span className="geo-selected-count">
|
|
||||||
{t('pages.xray.geoBrowser.selected', { count: selected.length })}
|
|
||||||
</span>
|
|
||||||
<Button type="link" size="small" onClick={() => setSelected([])}>
|
|
||||||
{t('pages.xray.geoBrowser.clearAll')}
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function describeFileError(error: string, t: (key: string) => string): string {
|
|
||||||
if (error.includes('too large')) return t('pages.xray.geoBrowser.tooLarge');
|
|
||||||
return t('pages.xray.geoBrowser.parseFailed');
|
|
||||||
}
|
|
||||||
@@ -1,296 +0,0 @@
|
|||||||
import { useEffect, useState, type ReactNode } from 'react';
|
|
||||||
import type { Decorator, Meta, StoryObj } from '@storybook/react-vite';
|
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
|
||||||
import { expect, within } from 'storybook/test';
|
|
||||||
import { Space } from 'antd';
|
|
||||||
|
|
||||||
import { parseTokens } from '@/lib/xray/geoTokens';
|
|
||||||
import type { GeoCategory, GeoEntry, GeoFile, GeodataTokenIssue } from '@/generated/types';
|
|
||||||
|
|
||||||
import GeoTokenInput, { type GeoTokenInputProps } from './GeoTokenInput';
|
|
||||||
|
|
||||||
type GeoResponder = (query: URLSearchParams, body: URLSearchParams) => unknown;
|
|
||||||
type GeoRoutes = Record<string, GeoResponder>;
|
|
||||||
|
|
||||||
const realFetch = window.fetch.bind(window);
|
|
||||||
let activeRoutes: GeoRoutes = {};
|
|
||||||
|
|
||||||
function requestUrl(input: RequestInfo | URL): URL {
|
|
||||||
if (typeof input === 'string') return new URL(input, window.location.origin);
|
|
||||||
if (input instanceof URL) return input;
|
|
||||||
return new URL(input.url, window.location.origin);
|
|
||||||
}
|
|
||||||
|
|
||||||
function geoFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
|
|
||||||
const url = requestUrl(input);
|
|
||||||
const responder = activeRoutes[url.pathname];
|
|
||||||
if (!responder) return realFetch(input, init);
|
|
||||||
const form = new URLSearchParams(typeof init?.body === 'string' ? init.body : '');
|
|
||||||
const body = JSON.stringify({ success: true, msg: '', obj: responder(url.searchParams, form) });
|
|
||||||
return Promise.resolve(
|
|
||||||
new Response(body, { status: 200, headers: { 'content-type': 'application/json' } }),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function activate(routes: GeoRoutes): void {
|
|
||||||
activeRoutes = routes;
|
|
||||||
window.fetch = geoFetch;
|
|
||||||
}
|
|
||||||
|
|
||||||
function deactivate(routes: GeoRoutes): void {
|
|
||||||
if (activeRoutes === routes) activeRoutes = {};
|
|
||||||
}
|
|
||||||
|
|
||||||
function GeoApi({ routes, children }: { routes: GeoRoutes; children: ReactNode }) {
|
|
||||||
const [client] = useState(() => {
|
|
||||||
activate(routes);
|
|
||||||
return new QueryClient({
|
|
||||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
useEffect(() => {
|
|
||||||
activate(routes);
|
|
||||||
return () => deactivate(routes);
|
|
||||||
}, [routes]);
|
|
||||||
return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const domain = (value: string): GeoEntry => ({ kind: 'domain', value });
|
|
||||||
const cidr = (value: string): GeoEntry => ({ kind: 'cidr', value });
|
|
||||||
|
|
||||||
const SITE_ENTRIES: Record<string, GeoEntry[]> = {
|
|
||||||
'category-ads-all': [
|
|
||||||
domain('doubleclick.net'),
|
|
||||||
domain('googleadservices.com'),
|
|
||||||
domain('googlesyndication.com'),
|
|
||||||
domain('criteo.com'),
|
|
||||||
domain('taboola.com'),
|
|
||||||
domain('outbrain.com'),
|
|
||||||
],
|
|
||||||
cn: [
|
|
||||||
domain('baidu.com'),
|
|
||||||
domain('qq.com'),
|
|
||||||
domain('taobao.com'),
|
|
||||||
domain('weibo.com'),
|
|
||||||
domain('bilibili.com'),
|
|
||||||
],
|
|
||||||
google: [
|
|
||||||
domain('google.com'),
|
|
||||||
domain('googleapis.com'),
|
|
||||||
domain('gstatic.com'),
|
|
||||||
domain('googleusercontent.com'),
|
|
||||||
domain('ggpht.com'),
|
|
||||||
domain('android.com'),
|
|
||||||
],
|
|
||||||
netflix: [
|
|
||||||
domain('netflix.com'),
|
|
||||||
domain('nflximg.net'),
|
|
||||||
domain('nflxvideo.net'),
|
|
||||||
domain('fast.com'),
|
|
||||||
],
|
|
||||||
telegram: [domain('telegram.org'), domain('t.me'), domain('telesco.pe'), domain('telegra.ph')],
|
|
||||||
youtube: [
|
|
||||||
domain('youtube.com'),
|
|
||||||
domain('youtu.be'),
|
|
||||||
domain('ytimg.com'),
|
|
||||||
domain('googlevideo.com'),
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
const IP_ENTRIES: Record<string, GeoEntry[]> = {
|
|
||||||
cloudflare: ['104.16.0.0/13', '172.64.0.0/13', '2606:4700::/32'].map(cidr),
|
|
||||||
cn: ['1.0.1.0/24', '36.0.0.0/22', '116.0.0.0/9', '2408:8000::/20'].map(cidr),
|
|
||||||
private: [
|
|
||||||
'10.0.0.0/8',
|
|
||||||
'127.0.0.0/8',
|
|
||||||
'169.254.0.0/16',
|
|
||||||
'172.16.0.0/12',
|
|
||||||
'192.168.0.0/16',
|
|
||||||
'::1/128',
|
|
||||||
'fc00::/7',
|
|
||||||
'fe80::/10',
|
|
||||||
].map(cidr),
|
|
||||||
telegram: ['91.108.4.0/22', '149.154.160.0/20', '2001:b28:f23d::/48'].map(cidr),
|
|
||||||
};
|
|
||||||
|
|
||||||
const SITE_ATTRIBUTES: Record<string, string[]> = {
|
|
||||||
google: ['ads', 'cn'],
|
|
||||||
youtube: ['ads'],
|
|
||||||
};
|
|
||||||
|
|
||||||
function categoriesOf(
|
|
||||||
entries: Record<string, GeoEntry[]>,
|
|
||||||
attributes: Record<string, string[]> = {},
|
|
||||||
): GeoCategory[] {
|
|
||||||
return Object.keys(entries)
|
|
||||||
.sort()
|
|
||||||
.map((code) => ({ code, entries: entries[code].length, attributes: attributes[code] ?? [] }));
|
|
||||||
}
|
|
||||||
|
|
||||||
const DATASETS: Record<string, { categories: GeoCategory[]; entries: Record<string, GeoEntry[]> }> =
|
|
||||||
{
|
|
||||||
'geosite.dat': {
|
|
||||||
categories: categoriesOf(SITE_ENTRIES, SITE_ATTRIBUTES),
|
|
||||||
entries: SITE_ENTRIES,
|
|
||||||
},
|
|
||||||
'geoip.dat': { categories: categoriesOf(IP_ENTRIES), entries: IP_ENTRIES },
|
|
||||||
};
|
|
||||||
|
|
||||||
const UPDATED_AT = Date.UTC(2026, 6, 24, 3, 12);
|
|
||||||
|
|
||||||
const FILES: GeoFile[] = [
|
|
||||||
{
|
|
||||||
name: 'geosite.dat',
|
|
||||||
kind: 'site',
|
|
||||||
size: 4_812_544,
|
|
||||||
modifiedAt: UPDATED_AT,
|
|
||||||
categories: DATASETS['geosite.dat'].categories.length,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'geoip.dat',
|
|
||||||
kind: 'ip',
|
|
||||||
size: 8_694_272,
|
|
||||||
modifiedAt: UPDATED_AT,
|
|
||||||
categories: DATASETS['geoip.dat'].categories.length,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
function referenceOf(token: string, isIP: boolean): { file: string; code: string } | null {
|
|
||||||
const [prefix, ...rest] = token.split(':');
|
|
||||||
const code = (value: string) => value.split('@')[0].toLowerCase();
|
|
||||||
if (prefix === 'geosite') return { file: 'geosite.dat', code: code(rest.join(':')) };
|
|
||||||
if (prefix === 'geoip') return { file: 'geoip.dat', code: code(rest.join(':')) };
|
|
||||||
if (prefix === 'ext') return { file: rest[0] ?? '', code: code(rest.slice(1).join(':')) };
|
|
||||||
return isIP && prefix === 'ext-ip'
|
|
||||||
? { file: rest[0] ?? '', code: code(rest.slice(1).join(':')) }
|
|
||||||
: null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function validate(tokens: string[], isIP: boolean): GeodataTokenIssue[] {
|
|
||||||
const issues: GeodataTokenIssue[] = [];
|
|
||||||
for (const token of tokens) {
|
|
||||||
const reference = referenceOf(token, isIP);
|
|
||||||
if (!reference) continue;
|
|
||||||
const dataset = DATASETS[reference.file];
|
|
||||||
if (!dataset) {
|
|
||||||
issues.push({ token, reason: 'fileMissing', file: reference.file, code: reference.code });
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (!dataset.categories.some((category) => category.code === reference.code)) {
|
|
||||||
issues.push({ token, reason: 'categoryMissing', file: reference.file, code: reference.code });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return issues;
|
|
||||||
}
|
|
||||||
|
|
||||||
const routes: GeoRoutes = {
|
|
||||||
'/csrf-token': () => 'storybook-csrf-token',
|
|
||||||
'/panel/api/xray/geodata/files': () => FILES,
|
|
||||||
'/panel/api/xray/geodata/categories': (query) => {
|
|
||||||
const dataset = DATASETS[query.get('file') ?? ''];
|
|
||||||
const needle = (query.get('q') ?? '').trim().toLowerCase();
|
|
||||||
const items = (dataset?.categories ?? []).filter((category) => category.code.includes(needle));
|
|
||||||
return { total: items.length, items };
|
|
||||||
},
|
|
||||||
'/panel/api/xray/geodata/entries': (query) => {
|
|
||||||
const dataset = DATASETS[query.get('file') ?? ''];
|
|
||||||
const needle = (query.get('q') ?? '').trim().toLowerCase();
|
|
||||||
const matched = (dataset?.entries[query.get('code') ?? ''] ?? []).filter((entry) =>
|
|
||||||
entry.value.toLowerCase().includes(needle),
|
|
||||||
);
|
|
||||||
const offset = Number(query.get('offset') ?? 0);
|
|
||||||
const limit = Number(query.get('limit') ?? 100);
|
|
||||||
return { total: matched.length, items: matched.slice(offset, offset + limit) };
|
|
||||||
},
|
|
||||||
'/panel/api/xray/geodata/validate': (_query, form) =>
|
|
||||||
validate(parseTokens(form.get('tokens') ?? ''), form.get('kind') === 'ip'),
|
|
||||||
};
|
|
||||||
|
|
||||||
const withGeodata: Decorator = function GeodataBackend(Story) {
|
|
||||||
return (
|
|
||||||
<GeoApi routes={routes}>
|
|
||||||
<Story />
|
|
||||||
</GeoApi>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
function ControlledTokenInput({ value = '', id = 'geo-rule', ...rest }: GeoTokenInputProps) {
|
|
||||||
const [current, setCurrent] = useState(value);
|
|
||||||
const [synced, setSynced] = useState(value);
|
|
||||||
if (synced !== value) {
|
|
||||||
setSynced(value);
|
|
||||||
setCurrent(value);
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<Space orientation="vertical" size={4} style={{ width: 460 }}>
|
|
||||||
<label htmlFor={id}>{rest.kind === 'ip' ? 'Target IP' : 'Target domain'}</label>
|
|
||||||
<GeoTokenInput {...rest} id={id} value={current} onChange={setCurrent} />
|
|
||||||
</Space>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const meta = {
|
|
||||||
title: 'Geodata/GeoTokenInput',
|
|
||||||
component: GeoTokenInput,
|
|
||||||
tags: ['autodocs'],
|
|
||||||
parameters: {
|
|
||||||
layout: 'padded',
|
|
||||||
a11y: {
|
|
||||||
config: {
|
|
||||||
rules: [{ id: 'color-contrast', enabled: false }],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
docs: {
|
|
||||||
description: {
|
|
||||||
component:
|
|
||||||
'Routing rule field for the xray rule editor: a comma separated list of domains/CIDRs and `geosite:` / `geoip:` tokens, with a database button in the addon that opens the geo category browser. Typed tokens are validated against the databases on disk after a short pause, and anything the running core would not resolve is called out under the field. The stories answer `/panel/api/xray/geodata/*` from an in-memory fixture, so validation and the browser both work without a panel backend.',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
decorators: [withGeodata],
|
|
||||||
args: { kind: 'domain' },
|
|
||||||
argTypes: {
|
|
||||||
value: { description: 'Comma separated rule string held by the parent form.' },
|
|
||||||
onChange: {
|
|
||||||
description: 'Called with the full rule string on every edit and on Apply from the browser.',
|
|
||||||
},
|
|
||||||
onBlur: {
|
|
||||||
description: 'Forwarded to the input; used by React Hook Form to mark the field touched.',
|
|
||||||
},
|
|
||||||
kind: {
|
|
||||||
description:
|
|
||||||
'Which database the tokens are validated against: `domain` for geosite, `ip` for geoip.',
|
|
||||||
control: 'inline-radio',
|
|
||||||
options: ['domain', 'ip'],
|
|
||||||
},
|
|
||||||
placeholder: { description: 'Placeholder shown while the field is empty.' },
|
|
||||||
id: { description: 'Input id, linked to the label rendered by the surrounding form field.' },
|
|
||||||
},
|
|
||||||
render: (args) => <ControlledTokenInput {...args} />,
|
|
||||||
} satisfies Meta<typeof GeoTokenInput>;
|
|
||||||
|
|
||||||
export default meta;
|
|
||||||
|
|
||||||
type Story = StoryObj<typeof meta>;
|
|
||||||
|
|
||||||
export const Empty: Story = {
|
|
||||||
args: { kind: 'domain', value: '', placeholder: 'geosite:google, example.com' },
|
|
||||||
};
|
|
||||||
|
|
||||||
export const DomainTokens: Story = {
|
|
||||||
args: { kind: 'domain', value: 'geosite:google, google.com' },
|
|
||||||
};
|
|
||||||
|
|
||||||
export const IpTokens: Story = {
|
|
||||||
args: { kind: 'ip', value: 'geoip:private' },
|
|
||||||
};
|
|
||||||
|
|
||||||
export const UnknownCategory: Story = {
|
|
||||||
args: { kind: 'domain', value: 'geosite:blabla, geosite:google' },
|
|
||||||
play: async ({ canvasElement }) => {
|
|
||||||
const canvas = within(canvasElement);
|
|
||||||
await expect(
|
|
||||||
await canvas.findByText(/geosite:blabla/, undefined, { timeout: 3000 }),
|
|
||||||
).toBeVisible();
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,140 +0,0 @@
|
|||||||
import { useEffect, useState } from 'react';
|
|
||||||
import type { Ref } from 'react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { Button, Input, Space, Tooltip, Typography } from 'antd';
|
|
||||||
import type { InputRef } from 'antd';
|
|
||||||
import { DatabaseOutlined } from '@ant-design/icons';
|
|
||||||
|
|
||||||
import { useValidateGeoTokens, type GeoTokenKind } from '@/api/queries/useGeodata';
|
|
||||||
import { parseTokens } from '@/lib/xray/geoTokens';
|
|
||||||
import type { GeodataTokenIssue, GeoKind } from '@/generated/types';
|
|
||||||
|
|
||||||
import GeoBrowserModal from './GeoBrowserModal';
|
|
||||||
|
|
||||||
const VALIDATION_DELAY = 600;
|
|
||||||
|
|
||||||
// Each reason needs its own wording: a missing database is fixed under Geodata,
|
|
||||||
// a missing category by picking another one, and a bad token by editing it.
|
|
||||||
const REASON_KEYS: Record<string, string> = {
|
|
||||||
fileMissing: 'pages.xray.geoBrowser.missingDatabase',
|
|
||||||
categoryMissing: 'pages.xray.geoBrowser.unknownCategories',
|
|
||||||
attributeMissing: 'pages.xray.geoBrowser.unknownAttribute',
|
|
||||||
syntax: 'pages.xray.geoBrowser.invalidToken',
|
|
||||||
wrongKind: 'pages.xray.geoBrowser.wrongKind',
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface GeoTokenInputProps {
|
|
||||||
value?: string;
|
|
||||||
onChange?: (value: string) => void;
|
|
||||||
onBlur?: () => void;
|
|
||||||
kind: GeoTokenKind;
|
|
||||||
placeholder?: string;
|
|
||||||
id?: string;
|
|
||||||
ref?: Ref<InputRef>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function GeoTokenInput({
|
|
||||||
value = '',
|
|
||||||
onChange,
|
|
||||||
onBlur,
|
|
||||||
kind,
|
|
||||||
placeholder,
|
|
||||||
id,
|
|
||||||
ref,
|
|
||||||
}: GeoTokenInputProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [browsing, setBrowsing] = useState(false);
|
|
||||||
const [issues, setIssues] = useState<GeodataTokenIssue[]>([]);
|
|
||||||
const [checkFailed, setCheckFailed] = useState(false);
|
|
||||||
const validate = useValidateGeoTokens();
|
|
||||||
const { mutateAsync } = validate;
|
|
||||||
|
|
||||||
// An empty field has nothing to validate, so it clears during render rather
|
|
||||||
// than waiting a commit for the effect to catch up.
|
|
||||||
const isEmpty = parseTokens(value).length === 0;
|
|
||||||
const [wasEmpty, setWasEmpty] = useState(isEmpty);
|
|
||||||
if (isEmpty !== wasEmpty) {
|
|
||||||
setWasEmpty(isEmpty);
|
|
||||||
if (isEmpty) {
|
|
||||||
setIssues([]);
|
|
||||||
setCheckFailed(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const tokens = parseTokens(value);
|
|
||||||
if (tokens.length === 0) return;
|
|
||||||
let cancelled = false;
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
mutateAsync({ tokens, kind })
|
|
||||||
.then((found) => {
|
|
||||||
if (cancelled) return;
|
|
||||||
setIssues(found);
|
|
||||||
setCheckFailed(false);
|
|
||||||
})
|
|
||||||
// A rejected check says nothing about the tokens, so the warnings are
|
|
||||||
// dropped but replaced by a notice — silence here reads as "all valid".
|
|
||||||
.catch(() => {
|
|
||||||
if (cancelled) return;
|
|
||||||
setIssues([]);
|
|
||||||
setCheckFailed(true);
|
|
||||||
});
|
|
||||||
}, VALIDATION_DELAY);
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
clearTimeout(timer);
|
|
||||||
};
|
|
||||||
}, [value, kind, mutateAsync]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Space.Compact block>
|
|
||||||
<Input
|
|
||||||
ref={ref}
|
|
||||||
id={id}
|
|
||||||
value={value}
|
|
||||||
placeholder={placeholder}
|
|
||||||
onChange={(event) => onChange?.(event.target.value)}
|
|
||||||
onBlur={onBlur}
|
|
||||||
/>
|
|
||||||
<Tooltip title={t('pages.xray.geoBrowser.openTooltip')}>
|
|
||||||
<Button
|
|
||||||
icon={<DatabaseOutlined />}
|
|
||||||
aria-label={t('pages.xray.geoBrowser.openTooltip')}
|
|
||||||
onClick={() => setBrowsing(true)}
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
</Space.Compact>
|
|
||||||
{groupByReason(issues).map(([reason, tokens]) => (
|
|
||||||
<Typography.Text key={reason} type="warning" className="geo-unknown-hint">
|
|
||||||
{t(REASON_KEYS[reason] ?? REASON_KEYS.categoryMissing, { tokens: tokens.join(', ') })}
|
|
||||||
</Typography.Text>
|
|
||||||
))}
|
|
||||||
{checkFailed && (
|
|
||||||
<Typography.Text type="secondary" className="geo-unknown-hint">
|
|
||||||
{t('pages.xray.geoBrowser.checkFailed')}
|
|
||||||
</Typography.Text>
|
|
||||||
)}
|
|
||||||
<GeoBrowserModal
|
|
||||||
open={browsing}
|
|
||||||
kind={(kind === 'ip' ? 'ip' : 'site') as GeoKind}
|
|
||||||
value={value}
|
|
||||||
onApply={(next) => {
|
|
||||||
onChange?.(next);
|
|
||||||
setBrowsing(false);
|
|
||||||
}}
|
|
||||||
onClose={() => setBrowsing(false)}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function groupByReason(issues: GeodataTokenIssue[]): Array<[string, string[]]> {
|
|
||||||
const grouped = new Map<string, string[]>();
|
|
||||||
for (const issue of issues) {
|
|
||||||
const tokens = grouped.get(issue.reason) ?? [];
|
|
||||||
tokens.push(issue.token);
|
|
||||||
grouped.set(issue.reason, tokens);
|
|
||||||
}
|
|
||||||
return [...grouped];
|
|
||||||
}
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
export { default as GeoBrowserModal } from './GeoBrowserModal';
|
|
||||||
export type { GeoBrowserModalProps } from './GeoBrowserModal';
|
|
||||||
export { default as GeoTokenInput } from './GeoTokenInput';
|
|
||||||
export type { GeoTokenInputProps } from './GeoTokenInput';
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
|
||||||
|
|
||||||
import { AllSetting } from '@/models/setting';
|
|
||||||
import { EmailNotifications } from './EmailNotifications';
|
|
||||||
|
|
||||||
const meta = {
|
|
||||||
title: 'UI/Notifications/EmailNotifications',
|
|
||||||
component: EmailNotifications,
|
|
||||||
tags: ['autodocs'],
|
|
||||||
parameters: {
|
|
||||||
layout: 'padded',
|
|
||||||
docs: {
|
|
||||||
description: {
|
|
||||||
component:
|
|
||||||
'Grid of grouped event checkboxes on the settings page that picks which panel events (outbound/node health, Xray crashes, CPU/RAM thresholds, login attempts) trigger an SMTP email, stored as a comma-separated list in smtpEnabledEvents.',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
argTypes: {
|
|
||||||
allSetting: {
|
|
||||||
description:
|
|
||||||
'Panel settings snapshot; smtpEnabledEvents holds the selected event keys and smtpCpu/smtpMemory the alert threshold percentages.',
|
|
||||||
},
|
|
||||||
updateSetting: {
|
|
||||||
description:
|
|
||||||
'Receives a partial settings patch when an event is toggled or a threshold input changes.',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
} satisfies Meta<typeof EmailNotifications>;
|
|
||||||
|
|
||||||
export default meta;
|
|
||||||
|
|
||||||
type Story = StoryObj<typeof meta>;
|
|
||||||
|
|
||||||
function StatefulDemo({ initial }: { initial: AllSetting }) {
|
|
||||||
const [settings, setSettings] = useState(initial);
|
|
||||||
return (
|
|
||||||
<EmailNotifications
|
|
||||||
allSetting={settings}
|
|
||||||
updateSetting={(patch) => setSettings((prev) => new AllSetting({ ...prev, ...patch }))}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const placeholderArgs = {
|
|
||||||
allSetting: new AllSetting(),
|
|
||||||
updateSetting: () => undefined,
|
|
||||||
};
|
|
||||||
|
|
||||||
export const NothingSelected: Story = {
|
|
||||||
args: placeholderArgs,
|
|
||||||
render: () => <StatefulDemo initial={new AllSetting()} />,
|
|
||||||
};
|
|
||||||
|
|
||||||
export const SystemThresholdAlerts: Story = {
|
|
||||||
args: placeholderArgs,
|
|
||||||
render: () => (
|
|
||||||
<StatefulDemo
|
|
||||||
initial={
|
|
||||||
new AllSetting({ smtpEnabledEvents: 'cpu.high,memory.high', smtpCpu: 85, smtpMemory: 90 })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
};
|
|
||||||
|
|
||||||
export const InfrastructureOnly: Story = {
|
|
||||||
args: placeholderArgs,
|
|
||||||
render: () => (
|
|
||||||
<StatefulDemo
|
|
||||||
initial={new AllSetting({ smtpEnabledEvents: 'outbound.down,node.down,node.up,xray.crash' })}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
};
|
|
||||||
|
|
||||||
export const AllEventsEnabled: Story = {
|
|
||||||
args: placeholderArgs,
|
|
||||||
render: () => (
|
|
||||||
<StatefulDemo
|
|
||||||
initial={
|
|
||||||
new AllSetting({
|
|
||||||
smtpEnabledEvents:
|
|
||||||
'outbound.down,outbound.up,xray.crash,node.down,node.up,cpu.high,memory.high,login.attempt',
|
|
||||||
smtpCpu: 80,
|
|
||||||
smtpMemory: 80,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
};
|
|
||||||
@@ -1,138 +0,0 @@
|
|||||||
import { InputNumber } from 'antd';
|
|
||||||
import {
|
|
||||||
CloudServerOutlined,
|
|
||||||
ThunderboltOutlined,
|
|
||||||
DesktopOutlined,
|
|
||||||
DashboardOutlined,
|
|
||||||
SafetyOutlined,
|
|
||||||
} from '@ant-design/icons';
|
|
||||||
import type { AllSetting } from '@/models/setting';
|
|
||||||
import { NotificationLayout } from './NotificationLayout';
|
|
||||||
import { NotificationGroup } from './NotificationGroup';
|
|
||||||
import type { NotificationGroupConfig } from './types';
|
|
||||||
|
|
||||||
const GROUPS: NotificationGroupConfig[] = [
|
|
||||||
{
|
|
||||||
icon: <CloudServerOutlined />,
|
|
||||||
title: 'eventGroupOutbound',
|
|
||||||
events: [
|
|
||||||
{
|
|
||||||
key: 'outbound.down',
|
|
||||||
label: 'eventOutboundDown',
|
|
||||||
settingKey: 'outboundDownThreshold',
|
|
||||||
extra: ({ value, onChange, ariaLabel }) => (
|
|
||||||
<InputNumber
|
|
||||||
size="small"
|
|
||||||
min={1}
|
|
||||||
max={100}
|
|
||||||
value={value}
|
|
||||||
onChange={onChange}
|
|
||||||
aria-label={ariaLabel}
|
|
||||||
style={{ width: 80 }}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{ key: 'outbound.up', label: 'eventOutboundUp', settingKey: '' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: <ThunderboltOutlined />,
|
|
||||||
title: 'eventGroupXray',
|
|
||||||
events: [{ key: 'xray.crash', label: 'eventXrayCrash', settingKey: '' }],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: <DesktopOutlined />,
|
|
||||||
title: 'eventGroupNode',
|
|
||||||
events: [
|
|
||||||
{ key: 'node.down', label: 'eventNodeDown', settingKey: '' },
|
|
||||||
{ key: 'node.up', label: 'eventNodeUp', settingKey: '' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: <DashboardOutlined />,
|
|
||||||
title: 'eventGroupSystem',
|
|
||||||
events: [
|
|
||||||
{
|
|
||||||
key: 'cpu.high',
|
|
||||||
label: 'eventCPUHigh',
|
|
||||||
settingKey: 'smtpCpu',
|
|
||||||
extra: ({ value, onChange, ariaLabel }) => (
|
|
||||||
<InputNumber
|
|
||||||
size="small"
|
|
||||||
min={0}
|
|
||||||
max={100}
|
|
||||||
value={value}
|
|
||||||
onChange={onChange}
|
|
||||||
aria-label={ariaLabel}
|
|
||||||
style={{ width: 80 }}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'memory.high',
|
|
||||||
label: 'eventMemoryHigh',
|
|
||||||
settingKey: 'smtpMemory',
|
|
||||||
extra: ({ value, onChange, ariaLabel }) => (
|
|
||||||
<InputNumber
|
|
||||||
size="small"
|
|
||||||
min={0}
|
|
||||||
max={100}
|
|
||||||
value={value}
|
|
||||||
onChange={onChange}
|
|
||||||
aria-label={ariaLabel}
|
|
||||||
style={{ width: 80 }}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: <SafetyOutlined />,
|
|
||||||
title: 'eventGroupSecurity',
|
|
||||||
events: [{ key: 'login.attempt', label: 'eventLoginAttempt', settingKey: '' }],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
allSetting: AllSetting;
|
|
||||||
updateSetting: (patch: Partial<AllSetting>) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function EmailNotifications({ allSetting, updateSetting }: Props) {
|
|
||||||
const events = allSetting.smtpEnabledEvents || '';
|
|
||||||
const selected = events
|
|
||||||
? events
|
|
||||||
.split(',')
|
|
||||||
.map((s) => s.trim())
|
|
||||||
.filter(Boolean)
|
|
||||||
: [];
|
|
||||||
|
|
||||||
function toggle(key: string) {
|
|
||||||
const next = selected.includes(key) ? selected.filter((e) => e !== key) : [...selected, key];
|
|
||||||
updateSetting({ smtpEnabledEvents: next.join(',') });
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleAll(keys: string[]) {
|
|
||||||
const allSelected = keys.every((v) => selected.includes(v));
|
|
||||||
const next = allSelected
|
|
||||||
? selected.filter((v) => !keys.includes(v))
|
|
||||||
: [...new Set([...selected, ...keys])];
|
|
||||||
updateSetting({ smtpEnabledEvents: next.join(',') });
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<NotificationLayout>
|
|
||||||
{GROUPS.map((group, i) => (
|
|
||||||
<NotificationGroup
|
|
||||||
key={i}
|
|
||||||
config={group}
|
|
||||||
selected={selected}
|
|
||||||
onToggle={toggle}
|
|
||||||
onToggleAll={toggleAll}
|
|
||||||
allSetting={allSetting}
|
|
||||||
updateSetting={updateSetting}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</NotificationLayout>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -61,7 +61,7 @@ export const Unchecked: Story = {
|
|||||||
|
|
||||||
export const Checked: Story = {
|
export const Checked: Story = {
|
||||||
args: {
|
args: {
|
||||||
label: 'pages.settings.eventXrayCrash',
|
label: 'pages.settings.eventNodeDown',
|
||||||
checked: true,
|
checked: true,
|
||||||
onToggle: () => undefined,
|
onToggle: () => undefined,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||||
import { InputNumber } from 'antd';
|
import { InputNumber } from 'antd';
|
||||||
import { CloudServerOutlined, DashboardOutlined } from '@ant-design/icons';
|
import { DashboardOutlined, DesktopOutlined } from '@ant-design/icons';
|
||||||
|
|
||||||
import { AllSetting } from '@/models/setting';
|
import { AllSetting } from '@/models/setting';
|
||||||
import { NotificationGroup } from './NotificationGroup';
|
import { NotificationGroup } from './NotificationGroup';
|
||||||
@@ -46,12 +46,12 @@ const systemGroup: NotificationGroupConfig = {
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
const outboundGroup: NotificationGroupConfig = {
|
const nodeGroup: NotificationGroupConfig = {
|
||||||
icon: <CloudServerOutlined />,
|
icon: <DesktopOutlined />,
|
||||||
title: 'eventGroupOutbound',
|
title: 'eventGroupNode',
|
||||||
events: [
|
events: [
|
||||||
{ key: 'outbound.down', label: 'eventOutboundDown', settingKey: '' },
|
{ key: 'node.down', label: 'eventNodeDown', settingKey: '' },
|
||||||
{ key: 'outbound.up', label: 'eventOutboundUp', settingKey: '' },
|
{ key: 'node.up', label: 'eventNodeUp', settingKey: '' },
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -64,7 +64,7 @@ const meta = {
|
|||||||
docs: {
|
docs: {
|
||||||
description: {
|
description: {
|
||||||
component:
|
component:
|
||||||
'Card for one notification event group (outbound, Xray, node, system, security) with a per-group select-all checkbox, a selected-count tag, and optional per-event threshold inputs. Composed by the Telegram and email notification tabs on the settings page.',
|
'Card for one notification event group (node, system, security) with a per-group select-all checkbox, a selected-count tag, and optional per-event threshold inputs. Composed by the Telegram and email notification tabs on the settings page.',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -138,7 +138,7 @@ export const PartiallySelected: Story = {
|
|||||||
|
|
||||||
export const NoneSelected: Story = {
|
export const NoneSelected: Story = {
|
||||||
args: {
|
args: {
|
||||||
config: outboundGroup,
|
config: nodeGroup,
|
||||||
selected: [],
|
selected: [],
|
||||||
onToggle: () => undefined,
|
onToggle: () => undefined,
|
||||||
onToggleAll: () => undefined,
|
onToggleAll: () => undefined,
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ export const TypicalMonitoring: Story = {
|
|||||||
new AllSetting({
|
new AllSetting({
|
||||||
tgBotEnable: true,
|
tgBotEnable: true,
|
||||||
tgBotChatId: '123456789',
|
tgBotChatId: '123456789',
|
||||||
tgEnabledEvents: 'xray.crash,node.down,cpu.high,memory.high,login.attempt',
|
tgEnabledEvents: 'node.down,cpu.high,memory.high,login.attempt',
|
||||||
tgCpu: 85,
|
tgCpu: 85,
|
||||||
tgMemory: 90,
|
tgMemory: 90,
|
||||||
})
|
})
|
||||||
@@ -77,8 +77,7 @@ export const EverythingEnabled: Story = {
|
|||||||
initial={
|
initial={
|
||||||
new AllSetting({
|
new AllSetting({
|
||||||
tgBotEnable: true,
|
tgBotEnable: true,
|
||||||
tgEnabledEvents:
|
tgEnabledEvents: 'node.down,node.up,cpu.high,memory.high,login.attempt',
|
||||||
'outbound.down,outbound.up,xray.crash,node.down,node.up,cpu.high,memory.high,login.attempt',
|
|
||||||
tgCpu: 70,
|
tgCpu: 70,
|
||||||
tgMemory: 75,
|
tgMemory: 75,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,45 +1,11 @@
|
|||||||
import { InputNumber } from 'antd';
|
import { InputNumber } from 'antd';
|
||||||
import {
|
import { DesktopOutlined, DashboardOutlined, SafetyOutlined } from '@ant-design/icons';
|
||||||
CloudServerOutlined,
|
|
||||||
ThunderboltOutlined,
|
|
||||||
DesktopOutlined,
|
|
||||||
DashboardOutlined,
|
|
||||||
SafetyOutlined,
|
|
||||||
} from '@ant-design/icons';
|
|
||||||
import type { AllSetting } from '@/models/setting';
|
import type { AllSetting } from '@/models/setting';
|
||||||
import { NotificationLayout } from './NotificationLayout';
|
import { NotificationLayout } from './NotificationLayout';
|
||||||
import { NotificationGroup } from './NotificationGroup';
|
import { NotificationGroup } from './NotificationGroup';
|
||||||
import type { NotificationGroupConfig } from './types';
|
import type { NotificationGroupConfig } from './types';
|
||||||
|
|
||||||
const GROUPS: NotificationGroupConfig[] = [
|
const GROUPS: NotificationGroupConfig[] = [
|
||||||
{
|
|
||||||
icon: <CloudServerOutlined />,
|
|
||||||
title: 'eventGroupOutbound',
|
|
||||||
events: [
|
|
||||||
{
|
|
||||||
key: 'outbound.down',
|
|
||||||
label: 'eventOutboundDown',
|
|
||||||
settingKey: 'outboundDownThreshold',
|
|
||||||
extra: ({ value, onChange, ariaLabel }) => (
|
|
||||||
<InputNumber
|
|
||||||
size="small"
|
|
||||||
min={1}
|
|
||||||
max={100}
|
|
||||||
value={value}
|
|
||||||
onChange={onChange}
|
|
||||||
aria-label={ariaLabel}
|
|
||||||
style={{ width: 80 }}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{ key: 'outbound.up', label: 'eventOutboundUp', settingKey: '' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: <ThunderboltOutlined />,
|
|
||||||
title: 'eventGroupXray',
|
|
||||||
events: [{ key: 'xray.crash', label: 'eventXrayCrash', settingKey: '' }],
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
icon: <DesktopOutlined />,
|
icon: <DesktopOutlined />,
|
||||||
title: 'eventGroupNode',
|
title: 'eventGroupNode',
|
||||||
|
|||||||
Vendored
+1
-3
@@ -12,12 +12,10 @@ interface SubPageData {
|
|||||||
expire?: string | number;
|
expire?: string | number;
|
||||||
lastOnline?: string | number;
|
lastOnline?: string | number;
|
||||||
subUrl?: string;
|
subUrl?: string;
|
||||||
subJsonUrl?: string;
|
|
||||||
subClashUrl?: string;
|
|
||||||
subTitle?: string;
|
subTitle?: string;
|
||||||
links?: string[];
|
links?: string[];
|
||||||
emails?: string[];
|
emails?: string[];
|
||||||
datepicker?: 'gregorian' | 'jalalian';
|
datepicker?: 'gregorian';
|
||||||
announce?: string;
|
announce?: string;
|
||||||
downloadByte?: string | number;
|
downloadByte?: string | number;
|
||||||
uploadByte?: string | number;
|
uploadByte?: string | number;
|
||||||
|
|||||||
@@ -3,33 +3,16 @@ export const EXAMPLES: Record<string, unknown> = {
|
|||||||
"AllSetting": {
|
"AllSetting": {
|
||||||
"datepicker": "",
|
"datepicker": "",
|
||||||
"expireDiff": 0,
|
"expireDiff": 0,
|
||||||
"ipLimitAllowlist": "",
|
|
||||||
"outboundDownThreshold": 1,
|
|
||||||
"pageSize": 0,
|
"pageSize": 0,
|
||||||
"panelOutbound": "",
|
|
||||||
"remarkTemplate": "",
|
"remarkTemplate": "",
|
||||||
"restartXrayOnClientDisable": false,
|
"restartXrayOnClientDisable": false,
|
||||||
"sessionMaxAge": 1,
|
"sessionMaxAge": 1,
|
||||||
"subAnnounce": "",
|
"subAnnounce": "",
|
||||||
"subCertFile": "",
|
"subCertFile": "",
|
||||||
"subClashAutoDetect": false,
|
|
||||||
"subClashEnable": false,
|
|
||||||
"subClashPath": "",
|
|
||||||
"subClashURI": "",
|
|
||||||
"subClashUserAgentRegex": "",
|
|
||||||
"subDomain": "",
|
"subDomain": "",
|
||||||
"subEnable": false,
|
"subEnable": false,
|
||||||
"subEncrypt": false,
|
"subEncrypt": false,
|
||||||
"subHideSettings": false,
|
"subHideSettings": false,
|
||||||
"subJsonAlwaysArray": false,
|
|
||||||
"subJsonAutoDetect": false,
|
|
||||||
"subJsonEnable": false,
|
|
||||||
"subJsonFinalMask": "",
|
|
||||||
"subJsonMux": "",
|
|
||||||
"subJsonObservatory": "",
|
|
||||||
"subJsonPath": "",
|
|
||||||
"subJsonURI": "",
|
|
||||||
"subJsonUserAgentRegex": "",
|
|
||||||
"subKeyFile": "",
|
"subKeyFile": "",
|
||||||
"subListen": "",
|
"subListen": "",
|
||||||
"subPath": "",
|
"subPath": "",
|
||||||
@@ -37,7 +20,6 @@ export const EXAMPLES: Record<string, unknown> = {
|
|||||||
"subProfileUrl": "",
|
"subProfileUrl": "",
|
||||||
"subShowIdentityOnAllLinks": false,
|
"subShowIdentityOnAllLinks": false,
|
||||||
"subSupportUrl": "",
|
"subSupportUrl": "",
|
||||||
"subThemeDir": "",
|
|
||||||
"subTitle": "",
|
"subTitle": "",
|
||||||
"subURI": "",
|
"subURI": "",
|
||||||
"subUpdates": 0,
|
"subUpdates": 0,
|
||||||
@@ -54,6 +36,7 @@ export const EXAMPLES: Record<string, unknown> = {
|
|||||||
"tgRunTime": "",
|
"tgRunTime": "",
|
||||||
"timeLocation": "",
|
"timeLocation": "",
|
||||||
"trafficDiff": 0,
|
"trafficDiff": 0,
|
||||||
|
"trafficStatsSwitches": "",
|
||||||
"trustedProxyCIDRs": "",
|
"trustedProxyCIDRs": "",
|
||||||
"warpUpdateInterval": 0,
|
"warpUpdateInterval": 0,
|
||||||
"webBasePath": "",
|
"webBasePath": "",
|
||||||
@@ -70,33 +53,16 @@ export const EXAMPLES: Record<string, unknown> = {
|
|||||||
"hasNordSecret": false,
|
"hasNordSecret": false,
|
||||||
"hasTgBotToken": false,
|
"hasTgBotToken": false,
|
||||||
"hasWarpSecret": false,
|
"hasWarpSecret": false,
|
||||||
"ipLimitAllowlist": "",
|
|
||||||
"outboundDownThreshold": 1,
|
|
||||||
"pageSize": 0,
|
"pageSize": 0,
|
||||||
"panelOutbound": "",
|
|
||||||
"remarkTemplate": "",
|
"remarkTemplate": "",
|
||||||
"restartXrayOnClientDisable": false,
|
"restartXrayOnClientDisable": false,
|
||||||
"sessionMaxAge": 1,
|
"sessionMaxAge": 1,
|
||||||
"subAnnounce": "",
|
"subAnnounce": "",
|
||||||
"subCertFile": "",
|
"subCertFile": "",
|
||||||
"subClashAutoDetect": false,
|
|
||||||
"subClashEnable": false,
|
|
||||||
"subClashPath": "",
|
|
||||||
"subClashURI": "",
|
|
||||||
"subClashUserAgentRegex": "",
|
|
||||||
"subDomain": "",
|
"subDomain": "",
|
||||||
"subEnable": false,
|
"subEnable": false,
|
||||||
"subEncrypt": false,
|
"subEncrypt": false,
|
||||||
"subHideSettings": false,
|
"subHideSettings": false,
|
||||||
"subJsonAlwaysArray": false,
|
|
||||||
"subJsonAutoDetect": false,
|
|
||||||
"subJsonEnable": false,
|
|
||||||
"subJsonFinalMask": "",
|
|
||||||
"subJsonMux": "",
|
|
||||||
"subJsonObservatory": "",
|
|
||||||
"subJsonPath": "",
|
|
||||||
"subJsonURI": "",
|
|
||||||
"subJsonUserAgentRegex": "",
|
|
||||||
"subKeyFile": "",
|
"subKeyFile": "",
|
||||||
"subListen": "",
|
"subListen": "",
|
||||||
"subPath": "",
|
"subPath": "",
|
||||||
@@ -104,7 +70,6 @@ export const EXAMPLES: Record<string, unknown> = {
|
|||||||
"subProfileUrl": "",
|
"subProfileUrl": "",
|
||||||
"subShowIdentityOnAllLinks": false,
|
"subShowIdentityOnAllLinks": false,
|
||||||
"subSupportUrl": "",
|
"subSupportUrl": "",
|
||||||
"subThemeDir": "",
|
|
||||||
"subTitle": "",
|
"subTitle": "",
|
||||||
"subURI": "",
|
"subURI": "",
|
||||||
"subUpdates": 0,
|
"subUpdates": 0,
|
||||||
@@ -121,6 +86,7 @@ export const EXAMPLES: Record<string, unknown> = {
|
|||||||
"tgRunTime": "",
|
"tgRunTime": "",
|
||||||
"timeLocation": "",
|
"timeLocation": "",
|
||||||
"trafficDiff": 0,
|
"trafficDiff": 0,
|
||||||
|
"trafficStatsSwitches": "",
|
||||||
"trustedProxyCIDRs": "",
|
"trustedProxyCIDRs": "",
|
||||||
"warpUpdateInterval": 0,
|
"warpUpdateInterval": 0,
|
||||||
"webBasePath": "",
|
"webBasePath": "",
|
||||||
@@ -290,12 +256,6 @@ export const EXAMPLES: Record<string, unknown> = {
|
|||||||
"name": "geosite.dat",
|
"name": "geosite.dat",
|
||||||
"size": 1467392
|
"size": 1467392
|
||||||
},
|
},
|
||||||
"GeodataTokenIssue": {
|
|
||||||
"code": "blabla",
|
|
||||||
"file": "geosite.dat",
|
|
||||||
"reason": "categoryMissing",
|
|
||||||
"token": "geosite:blabla"
|
|
||||||
},
|
|
||||||
"HistoryOfSeeders": {
|
"HistoryOfSeeders": {
|
||||||
"id": 0,
|
"id": 0,
|
||||||
"seederName": ""
|
"seederName": ""
|
||||||
@@ -548,7 +508,13 @@ export const EXAMPLES: Record<string, unknown> = {
|
|||||||
},
|
},
|
||||||
"NodeView": {
|
"NodeView": {
|
||||||
"activeCount": 20,
|
"activeCount": 20,
|
||||||
|
"activeEmails": [
|
||||||
|
"a@example.com"
|
||||||
|
],
|
||||||
"address": "node.example.com",
|
"address": "node.example.com",
|
||||||
|
"agentUpdateError": "",
|
||||||
|
"agentUpdateState": "failed",
|
||||||
|
"agentUpdateTarget": "A1.0.1",
|
||||||
"allowPrivateAddress": false,
|
"allowPrivateAddress": false,
|
||||||
"basePath": "/",
|
"basePath": "/",
|
||||||
"clientCount": 25,
|
"clientCount": 25,
|
||||||
@@ -557,7 +523,13 @@ export const EXAMPLES: Record<string, unknown> = {
|
|||||||
"cpuPct": 12.5,
|
"cpuPct": 12.5,
|
||||||
"createdAt": 1700000000,
|
"createdAt": 1700000000,
|
||||||
"depletedCount": 1,
|
"depletedCount": 1,
|
||||||
|
"depletedEmails": [
|
||||||
|
"c@example.com"
|
||||||
|
],
|
||||||
"disabledCount": 2,
|
"disabledCount": 2,
|
||||||
|
"disabledEmails": [
|
||||||
|
"b@example.com"
|
||||||
|
],
|
||||||
"enable": true,
|
"enable": true,
|
||||||
"guid": "node-guid",
|
"guid": "node-guid",
|
||||||
"hasApiToken": true,
|
"hasApiToken": true,
|
||||||
@@ -567,6 +539,8 @@ export const EXAMPLES: Record<string, unknown> = {
|
|||||||
"inboundTags": [
|
"inboundTags": [
|
||||||
"in-443-tcp"
|
"in-443-tcp"
|
||||||
],
|
],
|
||||||
|
"ipv4": "203.0.113.7",
|
||||||
|
"ipv6": "2001:db8::7",
|
||||||
"lastError": "",
|
"lastError": "",
|
||||||
"lastHeartbeat": 1700000000,
|
"lastHeartbeat": 1700000000,
|
||||||
"latencyMs": 42,
|
"latencyMs": 42,
|
||||||
@@ -576,6 +550,9 @@ export const EXAMPLES: Record<string, unknown> = {
|
|||||||
"netUp": 2097152,
|
"netUp": 2097152,
|
||||||
"nodeType": "traditional",
|
"nodeType": "traditional",
|
||||||
"onlineCount": 5,
|
"onlineCount": 5,
|
||||||
|
"onlineEmails": [
|
||||||
|
"a@example.com"
|
||||||
|
],
|
||||||
"outboundTag": "direct",
|
"outboundTag": "direct",
|
||||||
"panelVersion": "v3.x.x",
|
"panelVersion": "v3.x.x",
|
||||||
"parentGuid": "",
|
"parentGuid": "",
|
||||||
@@ -685,19 +662,6 @@ export const EXAMPLES: Record<string, unknown> = {
|
|||||||
"key": "",
|
"key": "",
|
||||||
"value": ""
|
"value": ""
|
||||||
},
|
},
|
||||||
"SubBalancer": {
|
|
||||||
"createdAt": 1710000000000,
|
|
||||||
"enabled": true,
|
|
||||||
"id": 1,
|
|
||||||
"inboundIds": [
|
|
||||||
1,
|
|
||||||
3
|
|
||||||
],
|
|
||||||
"remark": "auto-fastest",
|
|
||||||
"sortOrder": 1,
|
|
||||||
"strategy": "random",
|
|
||||||
"updatedAt": 1710000000000
|
|
||||||
},
|
|
||||||
"User": {
|
"User": {
|
||||||
"id": 0,
|
"id": 0,
|
||||||
"password": "",
|
"password": "",
|
||||||
|
|||||||
@@ -9,22 +9,11 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
"minimum": 0,
|
"minimum": 0,
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
},
|
},
|
||||||
"ipLimitAllowlist": {
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"outboundDownThreshold": {
|
|
||||||
"maximum": 100,
|
|
||||||
"minimum": 1,
|
|
||||||
"type": "integer"
|
|
||||||
},
|
|
||||||
"pageSize": {
|
"pageSize": {
|
||||||
"maximum": 1000,
|
"maximum": 1000,
|
||||||
"minimum": 0,
|
"minimum": 0,
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
},
|
},
|
||||||
"panelOutbound": {
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"remarkTemplate": {
|
"remarkTemplate": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@@ -42,21 +31,6 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
"subCertFile": {
|
"subCertFile": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"subClashAutoDetect": {
|
|
||||||
"type": "boolean"
|
|
||||||
},
|
|
||||||
"subClashEnable": {
|
|
||||||
"type": "boolean"
|
|
||||||
},
|
|
||||||
"subClashPath": {
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"subClashURI": {
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"subClashUserAgentRegex": {
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"subDomain": {
|
"subDomain": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@@ -69,33 +43,6 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
"subHideSettings": {
|
"subHideSettings": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
"subJsonAlwaysArray": {
|
|
||||||
"type": "boolean"
|
|
||||||
},
|
|
||||||
"subJsonAutoDetect": {
|
|
||||||
"type": "boolean"
|
|
||||||
},
|
|
||||||
"subJsonEnable": {
|
|
||||||
"type": "boolean"
|
|
||||||
},
|
|
||||||
"subJsonFinalMask": {
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"subJsonMux": {
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"subJsonObservatory": {
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"subJsonPath": {
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"subJsonURI": {
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"subJsonUserAgentRegex": {
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"subKeyFile": {
|
"subKeyFile": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@@ -120,9 +67,6 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
"subSupportUrl": {
|
"subSupportUrl": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"subThemeDir": {
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"subTitle": {
|
"subTitle": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@@ -179,6 +123,10 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
"minimum": 0,
|
"minimum": 0,
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
},
|
},
|
||||||
|
"trafficStatsSwitches": {
|
||||||
|
"description": "TrafficStatsSwitches (ITEM6 §4.3.1): the whole-network traffic\naccounting switches as one JSON string\n({inboundUplink,inboundDownlink,outboundUplink,outboundDownlink}),\ncarried verbatim so the settings page edits the same key the\nsing-box generator reads (settings.trafficStatsSwitches).",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"trustedProxyCIDRs": {
|
"trustedProxyCIDRs": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@@ -210,33 +158,16 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
"required": [
|
"required": [
|
||||||
"datepicker",
|
"datepicker",
|
||||||
"expireDiff",
|
"expireDiff",
|
||||||
"ipLimitAllowlist",
|
|
||||||
"outboundDownThreshold",
|
|
||||||
"pageSize",
|
"pageSize",
|
||||||
"panelOutbound",
|
|
||||||
"remarkTemplate",
|
"remarkTemplate",
|
||||||
"restartXrayOnClientDisable",
|
"restartXrayOnClientDisable",
|
||||||
"sessionMaxAge",
|
"sessionMaxAge",
|
||||||
"subAnnounce",
|
"subAnnounce",
|
||||||
"subCertFile",
|
"subCertFile",
|
||||||
"subClashAutoDetect",
|
|
||||||
"subClashEnable",
|
|
||||||
"subClashPath",
|
|
||||||
"subClashURI",
|
|
||||||
"subClashUserAgentRegex",
|
|
||||||
"subDomain",
|
"subDomain",
|
||||||
"subEnable",
|
"subEnable",
|
||||||
"subEncrypt",
|
"subEncrypt",
|
||||||
"subHideSettings",
|
"subHideSettings",
|
||||||
"subJsonAlwaysArray",
|
|
||||||
"subJsonAutoDetect",
|
|
||||||
"subJsonEnable",
|
|
||||||
"subJsonFinalMask",
|
|
||||||
"subJsonMux",
|
|
||||||
"subJsonObservatory",
|
|
||||||
"subJsonPath",
|
|
||||||
"subJsonURI",
|
|
||||||
"subJsonUserAgentRegex",
|
|
||||||
"subKeyFile",
|
"subKeyFile",
|
||||||
"subListen",
|
"subListen",
|
||||||
"subPath",
|
"subPath",
|
||||||
@@ -244,7 +175,6 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
"subProfileUrl",
|
"subProfileUrl",
|
||||||
"subShowIdentityOnAllLinks",
|
"subShowIdentityOnAllLinks",
|
||||||
"subSupportUrl",
|
"subSupportUrl",
|
||||||
"subThemeDir",
|
|
||||||
"subTitle",
|
"subTitle",
|
||||||
"subURI",
|
"subURI",
|
||||||
"subUpdates",
|
"subUpdates",
|
||||||
@@ -261,6 +191,7 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
"tgRunTime",
|
"tgRunTime",
|
||||||
"timeLocation",
|
"timeLocation",
|
||||||
"trafficDiff",
|
"trafficDiff",
|
||||||
|
"trafficStatsSwitches",
|
||||||
"trustedProxyCIDRs",
|
"trustedProxyCIDRs",
|
||||||
"warpUpdateInterval",
|
"warpUpdateInterval",
|
||||||
"webBasePath",
|
"webBasePath",
|
||||||
@@ -293,22 +224,11 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
"hasWarpSecret": {
|
"hasWarpSecret": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
"ipLimitAllowlist": {
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"outboundDownThreshold": {
|
|
||||||
"maximum": 100,
|
|
||||||
"minimum": 1,
|
|
||||||
"type": "integer"
|
|
||||||
},
|
|
||||||
"pageSize": {
|
"pageSize": {
|
||||||
"maximum": 1000,
|
"maximum": 1000,
|
||||||
"minimum": 0,
|
"minimum": 0,
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
},
|
},
|
||||||
"panelOutbound": {
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"remarkTemplate": {
|
"remarkTemplate": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@@ -326,21 +246,6 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
"subCertFile": {
|
"subCertFile": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"subClashAutoDetect": {
|
|
||||||
"type": "boolean"
|
|
||||||
},
|
|
||||||
"subClashEnable": {
|
|
||||||
"type": "boolean"
|
|
||||||
},
|
|
||||||
"subClashPath": {
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"subClashURI": {
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"subClashUserAgentRegex": {
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"subDomain": {
|
"subDomain": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@@ -353,33 +258,6 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
"subHideSettings": {
|
"subHideSettings": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
"subJsonAlwaysArray": {
|
|
||||||
"type": "boolean"
|
|
||||||
},
|
|
||||||
"subJsonAutoDetect": {
|
|
||||||
"type": "boolean"
|
|
||||||
},
|
|
||||||
"subJsonEnable": {
|
|
||||||
"type": "boolean"
|
|
||||||
},
|
|
||||||
"subJsonFinalMask": {
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"subJsonMux": {
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"subJsonObservatory": {
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"subJsonPath": {
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"subJsonURI": {
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"subJsonUserAgentRegex": {
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"subKeyFile": {
|
"subKeyFile": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@@ -404,9 +282,6 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
"subSupportUrl": {
|
"subSupportUrl": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"subThemeDir": {
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"subTitle": {
|
"subTitle": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@@ -463,6 +338,10 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
"minimum": 0,
|
"minimum": 0,
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
},
|
},
|
||||||
|
"trafficStatsSwitches": {
|
||||||
|
"description": "TrafficStatsSwitches (ITEM6 §4.3.1): the whole-network traffic\naccounting switches as one JSON string\n({inboundUplink,inboundDownlink,outboundUplink,outboundDownlink}),\ncarried verbatim so the settings page edits the same key the\nsing-box generator reads (settings.trafficStatsSwitches).",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"trustedProxyCIDRs": {
|
"trustedProxyCIDRs": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@@ -498,33 +377,16 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
"hasNordSecret",
|
"hasNordSecret",
|
||||||
"hasTgBotToken",
|
"hasTgBotToken",
|
||||||
"hasWarpSecret",
|
"hasWarpSecret",
|
||||||
"ipLimitAllowlist",
|
|
||||||
"outboundDownThreshold",
|
|
||||||
"pageSize",
|
"pageSize",
|
||||||
"panelOutbound",
|
|
||||||
"remarkTemplate",
|
"remarkTemplate",
|
||||||
"restartXrayOnClientDisable",
|
"restartXrayOnClientDisable",
|
||||||
"sessionMaxAge",
|
"sessionMaxAge",
|
||||||
"subAnnounce",
|
"subAnnounce",
|
||||||
"subCertFile",
|
"subCertFile",
|
||||||
"subClashAutoDetect",
|
|
||||||
"subClashEnable",
|
|
||||||
"subClashPath",
|
|
||||||
"subClashURI",
|
|
||||||
"subClashUserAgentRegex",
|
|
||||||
"subDomain",
|
"subDomain",
|
||||||
"subEnable",
|
"subEnable",
|
||||||
"subEncrypt",
|
"subEncrypt",
|
||||||
"subHideSettings",
|
"subHideSettings",
|
||||||
"subJsonAlwaysArray",
|
|
||||||
"subJsonAutoDetect",
|
|
||||||
"subJsonEnable",
|
|
||||||
"subJsonFinalMask",
|
|
||||||
"subJsonMux",
|
|
||||||
"subJsonObservatory",
|
|
||||||
"subJsonPath",
|
|
||||||
"subJsonURI",
|
|
||||||
"subJsonUserAgentRegex",
|
|
||||||
"subKeyFile",
|
"subKeyFile",
|
||||||
"subListen",
|
"subListen",
|
||||||
"subPath",
|
"subPath",
|
||||||
@@ -532,7 +394,6 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
"subProfileUrl",
|
"subProfileUrl",
|
||||||
"subShowIdentityOnAllLinks",
|
"subShowIdentityOnAllLinks",
|
||||||
"subSupportUrl",
|
"subSupportUrl",
|
||||||
"subThemeDir",
|
|
||||||
"subTitle",
|
"subTitle",
|
||||||
"subURI",
|
"subURI",
|
||||||
"subUpdates",
|
"subUpdates",
|
||||||
@@ -549,6 +410,7 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
"tgRunTime",
|
"tgRunTime",
|
||||||
"timeLocation",
|
"timeLocation",
|
||||||
"trafficDiff",
|
"trafficDiff",
|
||||||
|
"trafficStatsSwitches",
|
||||||
"trustedProxyCIDRs",
|
"trustedProxyCIDRs",
|
||||||
"warpUpdateInterval",
|
"warpUpdateInterval",
|
||||||
"webBasePath",
|
"webBasePath",
|
||||||
@@ -1217,32 +1079,6 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
],
|
],
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
"GeodataTokenIssue": {
|
|
||||||
"description": "GeodataTokenIssue reports a routing token the running core would reject,\nor would silently match nothing against.",
|
|
||||||
"properties": {
|
|
||||||
"code": {
|
|
||||||
"example": "blabla",
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"file": {
|
|
||||||
"example": "geosite.dat",
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"reason": {
|
|
||||||
"example": "categoryMissing",
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"token": {
|
|
||||||
"example": "geosite:blabla",
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required": [
|
|
||||||
"reason",
|
|
||||||
"token"
|
|
||||||
],
|
|
||||||
"type": "object"
|
|
||||||
},
|
|
||||||
"HistoryOfSeeders": {
|
"HistoryOfSeeders": {
|
||||||
"description": "HistoryOfSeeders tracks which database seeders have been executed to prevent re-running.",
|
"description": "HistoryOfSeeders tracks which database seeders have been executed to prevent re-running.",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -2272,10 +2108,31 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
"example": 20,
|
"example": 20,
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
},
|
},
|
||||||
|
"activeEmails": {
|
||||||
|
"example": [
|
||||||
|
"a@example.com"
|
||||||
|
],
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
"address": {
|
"address": {
|
||||||
"example": "node.example.com",
|
"example": "node.example.com",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"agentUpdateError": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"agentUpdateState": {
|
||||||
|
"description": "Agent one-click update bookkeeping (item 20), agent nodes only:\nthe last intent's state/target and, on failure, its error.",
|
||||||
|
"example": "failed",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"agentUpdateTarget": {
|
||||||
|
"example": "A1.0.1",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"allowPrivateAddress": {
|
"allowPrivateAddress": {
|
||||||
"example": false,
|
"example": false,
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
@@ -2310,10 +2167,28 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
"example": 1,
|
"example": 1,
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
},
|
},
|
||||||
|
"depletedEmails": {
|
||||||
|
"example": [
|
||||||
|
"c@example.com"
|
||||||
|
],
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
"disabledCount": {
|
"disabledCount": {
|
||||||
"example": 2,
|
"example": 2,
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
},
|
},
|
||||||
|
"disabledEmails": {
|
||||||
|
"example": [
|
||||||
|
"b@example.com"
|
||||||
|
],
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
"enable": {
|
"enable": {
|
||||||
"example": true,
|
"example": true,
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
@@ -2347,6 +2222,15 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
},
|
},
|
||||||
"type": "array"
|
"type": "array"
|
||||||
},
|
},
|
||||||
|
"ipv4": {
|
||||||
|
"description": "Ipv4/Ipv6 carry the resolved externally usable host addresses for\nagent nodes (P13 telemetry, public-address resolution on top).\nThe nodes page renders them as plain text on agent rows instead\nof the panel-URL link used for traditional nodes. Empty for\ntraditional nodes and for agents with no known address yet.",
|
||||||
|
"example": "203.0.113.7",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"ipv6": {
|
||||||
|
"example": "2001:db8::7",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"lastError": {
|
"lastError": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@@ -2386,6 +2270,16 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
"example": 5,
|
"example": 5,
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
},
|
},
|
||||||
|
"onlineEmails": {
|
||||||
|
"description": "Per-category client email lists behind the counts above, from the\nsame attribution bucketing, so the nodes page category chips can\npop over the actual usernames. Empty when the category is empty.",
|
||||||
|
"example": [
|
||||||
|
"a@example.com"
|
||||||
|
],
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
"outboundTag": {
|
"outboundTag": {
|
||||||
"example": "direct",
|
"example": "direct",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
@@ -2879,71 +2773,6 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
],
|
],
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
"SubBalancer": {
|
|
||||||
"description": "SubBalancer is one extra JSON-subscription config document whose members are\nthe selected inbounds' proxy outbounds. SortOrder shares SubSortIndex semantics.",
|
|
||||||
"properties": {
|
|
||||||
"createdAt": {
|
|
||||||
"example": 1710000000000,
|
|
||||||
"format": "int64",
|
|
||||||
"type": "integer"
|
|
||||||
},
|
|
||||||
"enabled": {
|
|
||||||
"description": "No gorm default:true — a bool default makes an explicit false at insert\ncollapse back to the column default (zero value is skipped).",
|
|
||||||
"example": true,
|
|
||||||
"type": "boolean"
|
|
||||||
},
|
|
||||||
"id": {
|
|
||||||
"example": 1,
|
|
||||||
"type": "integer"
|
|
||||||
},
|
|
||||||
"inboundIds": {
|
|
||||||
"example": [
|
|
||||||
1,
|
|
||||||
3
|
|
||||||
],
|
|
||||||
"items": {
|
|
||||||
"type": "integer"
|
|
||||||
},
|
|
||||||
"type": "array"
|
|
||||||
},
|
|
||||||
"remark": {
|
|
||||||
"example": "auto-fastest",
|
|
||||||
"maxLength": 256,
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"sortOrder": {
|
|
||||||
"example": 1,
|
|
||||||
"minimum": 1,
|
|
||||||
"type": "integer"
|
|
||||||
},
|
|
||||||
"strategy": {
|
|
||||||
"enum": [
|
|
||||||
"leastLoad",
|
|
||||||
"leastPing",
|
|
||||||
"random",
|
|
||||||
"roundRobin"
|
|
||||||
],
|
|
||||||
"example": "random",
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"updatedAt": {
|
|
||||||
"example": 1710000000000,
|
|
||||||
"format": "int64",
|
|
||||||
"type": "integer"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required": [
|
|
||||||
"createdAt",
|
|
||||||
"enabled",
|
|
||||||
"id",
|
|
||||||
"inboundIds",
|
|
||||||
"remark",
|
|
||||||
"sortOrder",
|
|
||||||
"strategy",
|
|
||||||
"updatedAt"
|
|
||||||
],
|
|
||||||
"type": "object"
|
|
||||||
},
|
|
||||||
"User": {
|
"User": {
|
||||||
"description": "User represents a user account in the 3x-ui panel.",
|
"description": "User represents a user account in the 3x-ui panel.",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
|||||||
@@ -12,33 +12,16 @@ export type transportBits = number;
|
|||||||
export interface AllSetting {
|
export interface AllSetting {
|
||||||
datepicker: string;
|
datepicker: string;
|
||||||
expireDiff: number;
|
expireDiff: number;
|
||||||
ipLimitAllowlist: string;
|
|
||||||
outboundDownThreshold: number;
|
|
||||||
pageSize: number;
|
pageSize: number;
|
||||||
panelOutbound: string;
|
|
||||||
remarkTemplate: string;
|
remarkTemplate: string;
|
||||||
restartXrayOnClientDisable: boolean;
|
restartXrayOnClientDisable: boolean;
|
||||||
sessionMaxAge: number;
|
sessionMaxAge: number;
|
||||||
subAnnounce: string;
|
subAnnounce: string;
|
||||||
subCertFile: string;
|
subCertFile: string;
|
||||||
subClashAutoDetect: boolean;
|
|
||||||
subClashEnable: boolean;
|
|
||||||
subClashPath: string;
|
|
||||||
subClashURI: string;
|
|
||||||
subClashUserAgentRegex: string;
|
|
||||||
subDomain: string;
|
subDomain: string;
|
||||||
subEnable: boolean;
|
subEnable: boolean;
|
||||||
subEncrypt: boolean;
|
subEncrypt: boolean;
|
||||||
subHideSettings: boolean;
|
subHideSettings: boolean;
|
||||||
subJsonAlwaysArray: boolean;
|
|
||||||
subJsonAutoDetect: boolean;
|
|
||||||
subJsonEnable: boolean;
|
|
||||||
subJsonFinalMask: string;
|
|
||||||
subJsonMux: string;
|
|
||||||
subJsonObservatory: string;
|
|
||||||
subJsonPath: string;
|
|
||||||
subJsonURI: string;
|
|
||||||
subJsonUserAgentRegex: string;
|
|
||||||
subKeyFile: string;
|
subKeyFile: string;
|
||||||
subListen: string;
|
subListen: string;
|
||||||
subPath: string;
|
subPath: string;
|
||||||
@@ -46,7 +29,6 @@ export interface AllSetting {
|
|||||||
subProfileUrl: string;
|
subProfileUrl: string;
|
||||||
subShowIdentityOnAllLinks: boolean;
|
subShowIdentityOnAllLinks: boolean;
|
||||||
subSupportUrl: string;
|
subSupportUrl: string;
|
||||||
subThemeDir: string;
|
|
||||||
subTitle: string;
|
subTitle: string;
|
||||||
subURI: string;
|
subURI: string;
|
||||||
subUpdates: number;
|
subUpdates: number;
|
||||||
@@ -63,6 +45,7 @@ export interface AllSetting {
|
|||||||
tgRunTime: string;
|
tgRunTime: string;
|
||||||
timeLocation: string;
|
timeLocation: string;
|
||||||
trafficDiff: number;
|
trafficDiff: number;
|
||||||
|
trafficStatsSwitches: string;
|
||||||
trustedProxyCIDRs: string;
|
trustedProxyCIDRs: string;
|
||||||
warpUpdateInterval: number;
|
warpUpdateInterval: number;
|
||||||
webBasePath: string;
|
webBasePath: string;
|
||||||
@@ -80,33 +63,16 @@ export interface AllSettingView {
|
|||||||
hasNordSecret: boolean;
|
hasNordSecret: boolean;
|
||||||
hasTgBotToken: boolean;
|
hasTgBotToken: boolean;
|
||||||
hasWarpSecret: boolean;
|
hasWarpSecret: boolean;
|
||||||
ipLimitAllowlist: string;
|
|
||||||
outboundDownThreshold: number;
|
|
||||||
pageSize: number;
|
pageSize: number;
|
||||||
panelOutbound: string;
|
|
||||||
remarkTemplate: string;
|
remarkTemplate: string;
|
||||||
restartXrayOnClientDisable: boolean;
|
restartXrayOnClientDisable: boolean;
|
||||||
sessionMaxAge: number;
|
sessionMaxAge: number;
|
||||||
subAnnounce: string;
|
subAnnounce: string;
|
||||||
subCertFile: string;
|
subCertFile: string;
|
||||||
subClashAutoDetect: boolean;
|
|
||||||
subClashEnable: boolean;
|
|
||||||
subClashPath: string;
|
|
||||||
subClashURI: string;
|
|
||||||
subClashUserAgentRegex: string;
|
|
||||||
subDomain: string;
|
subDomain: string;
|
||||||
subEnable: boolean;
|
subEnable: boolean;
|
||||||
subEncrypt: boolean;
|
subEncrypt: boolean;
|
||||||
subHideSettings: boolean;
|
subHideSettings: boolean;
|
||||||
subJsonAlwaysArray: boolean;
|
|
||||||
subJsonAutoDetect: boolean;
|
|
||||||
subJsonEnable: boolean;
|
|
||||||
subJsonFinalMask: string;
|
|
||||||
subJsonMux: string;
|
|
||||||
subJsonObservatory: string;
|
|
||||||
subJsonPath: string;
|
|
||||||
subJsonURI: string;
|
|
||||||
subJsonUserAgentRegex: string;
|
|
||||||
subKeyFile: string;
|
subKeyFile: string;
|
||||||
subListen: string;
|
subListen: string;
|
||||||
subPath: string;
|
subPath: string;
|
||||||
@@ -114,7 +80,6 @@ export interface AllSettingView {
|
|||||||
subProfileUrl: string;
|
subProfileUrl: string;
|
||||||
subShowIdentityOnAllLinks: boolean;
|
subShowIdentityOnAllLinks: boolean;
|
||||||
subSupportUrl: string;
|
subSupportUrl: string;
|
||||||
subThemeDir: string;
|
|
||||||
subTitle: string;
|
subTitle: string;
|
||||||
subURI: string;
|
subURI: string;
|
||||||
subUpdates: number;
|
subUpdates: number;
|
||||||
@@ -131,6 +96,7 @@ export interface AllSettingView {
|
|||||||
tgRunTime: string;
|
tgRunTime: string;
|
||||||
timeLocation: string;
|
timeLocation: string;
|
||||||
trafficDiff: number;
|
trafficDiff: number;
|
||||||
|
trafficStatsSwitches: string;
|
||||||
trustedProxyCIDRs: string;
|
trustedProxyCIDRs: string;
|
||||||
warpUpdateInterval: number;
|
warpUpdateInterval: number;
|
||||||
webBasePath: string;
|
webBasePath: string;
|
||||||
@@ -295,13 +261,6 @@ export interface GeoFile {
|
|||||||
size: number;
|
size: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GeodataTokenIssue {
|
|
||||||
code?: string;
|
|
||||||
file?: string;
|
|
||||||
reason: string;
|
|
||||||
token: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface HistoryOfSeeders {
|
export interface HistoryOfSeeders {
|
||||||
id: number;
|
id: number;
|
||||||
seederName: string;
|
seederName: string;
|
||||||
@@ -517,7 +476,11 @@ export interface NodeMutationRequest {
|
|||||||
|
|
||||||
export interface NodeView {
|
export interface NodeView {
|
||||||
activeCount: number;
|
activeCount: number;
|
||||||
|
activeEmails?: string[];
|
||||||
address: string;
|
address: string;
|
||||||
|
agentUpdateError?: string;
|
||||||
|
agentUpdateState?: string;
|
||||||
|
agentUpdateTarget?: string;
|
||||||
allowPrivateAddress: boolean;
|
allowPrivateAddress: boolean;
|
||||||
basePath: string;
|
basePath: string;
|
||||||
clientCount: number;
|
clientCount: number;
|
||||||
@@ -526,7 +489,9 @@ export interface NodeView {
|
|||||||
cpuPct: number;
|
cpuPct: number;
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
depletedCount: number;
|
depletedCount: number;
|
||||||
|
depletedEmails?: string[];
|
||||||
disabledCount: number;
|
disabledCount: number;
|
||||||
|
disabledEmails?: string[];
|
||||||
enable: boolean;
|
enable: boolean;
|
||||||
guid: string;
|
guid: string;
|
||||||
hasApiToken: boolean;
|
hasApiToken: boolean;
|
||||||
@@ -534,6 +499,8 @@ export interface NodeView {
|
|||||||
inboundCount: number;
|
inboundCount: number;
|
||||||
inboundSyncMode: string;
|
inboundSyncMode: string;
|
||||||
inboundTags: string[];
|
inboundTags: string[];
|
||||||
|
ipv4?: string;
|
||||||
|
ipv6?: string;
|
||||||
lastError: string;
|
lastError: string;
|
||||||
lastHeartbeat: number;
|
lastHeartbeat: number;
|
||||||
latencyMs: number;
|
latencyMs: number;
|
||||||
@@ -543,6 +510,7 @@ export interface NodeView {
|
|||||||
netUp: number;
|
netUp: number;
|
||||||
nodeType?: string;
|
nodeType?: string;
|
||||||
onlineCount: number;
|
onlineCount: number;
|
||||||
|
onlineEmails?: string[];
|
||||||
outboundTag: string;
|
outboundTag: string;
|
||||||
panelVersion: string;
|
panelVersion: string;
|
||||||
parentGuid?: string;
|
parentGuid?: string;
|
||||||
@@ -657,17 +625,6 @@ export interface Setting {
|
|||||||
value: string;
|
value: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SubBalancer {
|
|
||||||
createdAt: number;
|
|
||||||
enabled: boolean;
|
|
||||||
id: number;
|
|
||||||
inboundIds: number[];
|
|
||||||
remark: string;
|
|
||||||
sortOrder: number;
|
|
||||||
strategy: string;
|
|
||||||
updatedAt: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface User {
|
export interface User {
|
||||||
id: number;
|
id: number;
|
||||||
password: string;
|
password: string;
|
||||||
|
|||||||
@@ -30,33 +30,16 @@ export type transportBits = z.infer<typeof transportBitsSchema>;
|
|||||||
export const AllSettingSchema = z.object({
|
export const AllSettingSchema = z.object({
|
||||||
datepicker: z.string(),
|
datepicker: z.string(),
|
||||||
expireDiff: z.number().int().min(0),
|
expireDiff: z.number().int().min(0),
|
||||||
ipLimitAllowlist: z.string(),
|
|
||||||
outboundDownThreshold: z.number().int().min(1).max(100),
|
|
||||||
pageSize: z.number().int().min(0).max(1000),
|
pageSize: z.number().int().min(0).max(1000),
|
||||||
panelOutbound: z.string(),
|
|
||||||
remarkTemplate: z.string(),
|
remarkTemplate: z.string(),
|
||||||
restartXrayOnClientDisable: z.boolean(),
|
restartXrayOnClientDisable: z.boolean(),
|
||||||
sessionMaxAge: z.number().int().min(1).max(525600),
|
sessionMaxAge: z.number().int().min(1).max(525600),
|
||||||
subAnnounce: z.string(),
|
subAnnounce: z.string(),
|
||||||
subCertFile: z.string(),
|
subCertFile: z.string(),
|
||||||
subClashAutoDetect: z.boolean(),
|
|
||||||
subClashEnable: z.boolean(),
|
|
||||||
subClashPath: z.string(),
|
|
||||||
subClashURI: z.string(),
|
|
||||||
subClashUserAgentRegex: z.string(),
|
|
||||||
subDomain: z.string(),
|
subDomain: z.string(),
|
||||||
subEnable: z.boolean(),
|
subEnable: z.boolean(),
|
||||||
subEncrypt: z.boolean(),
|
subEncrypt: z.boolean(),
|
||||||
subHideSettings: z.boolean(),
|
subHideSettings: z.boolean(),
|
||||||
subJsonAlwaysArray: z.boolean(),
|
|
||||||
subJsonAutoDetect: z.boolean(),
|
|
||||||
subJsonEnable: z.boolean(),
|
|
||||||
subJsonFinalMask: z.string(),
|
|
||||||
subJsonMux: z.string(),
|
|
||||||
subJsonObservatory: z.string(),
|
|
||||||
subJsonPath: z.string(),
|
|
||||||
subJsonURI: z.string(),
|
|
||||||
subJsonUserAgentRegex: z.string(),
|
|
||||||
subKeyFile: z.string(),
|
subKeyFile: z.string(),
|
||||||
subListen: z.string(),
|
subListen: z.string(),
|
||||||
subPath: z.string(),
|
subPath: z.string(),
|
||||||
@@ -64,7 +47,6 @@ export const AllSettingSchema = z.object({
|
|||||||
subProfileUrl: z.string(),
|
subProfileUrl: z.string(),
|
||||||
subShowIdentityOnAllLinks: z.boolean(),
|
subShowIdentityOnAllLinks: z.boolean(),
|
||||||
subSupportUrl: z.string(),
|
subSupportUrl: z.string(),
|
||||||
subThemeDir: z.string(),
|
|
||||||
subTitle: z.string(),
|
subTitle: z.string(),
|
||||||
subURI: z.string(),
|
subURI: z.string(),
|
||||||
subUpdates: z.number().int().min(0).max(525600),
|
subUpdates: z.number().int().min(0).max(525600),
|
||||||
@@ -81,6 +63,7 @@ export const AllSettingSchema = z.object({
|
|||||||
tgRunTime: z.string(),
|
tgRunTime: z.string(),
|
||||||
timeLocation: z.string(),
|
timeLocation: z.string(),
|
||||||
trafficDiff: z.number().int().min(0).max(100),
|
trafficDiff: z.number().int().min(0).max(100),
|
||||||
|
trafficStatsSwitches: z.string(),
|
||||||
trustedProxyCIDRs: z.string(),
|
trustedProxyCIDRs: z.string(),
|
||||||
warpUpdateInterval: z.number().int().min(0),
|
warpUpdateInterval: z.number().int().min(0),
|
||||||
webBasePath: z.string(),
|
webBasePath: z.string(),
|
||||||
@@ -99,33 +82,16 @@ export const AllSettingViewSchema = z.object({
|
|||||||
hasNordSecret: z.boolean(),
|
hasNordSecret: z.boolean(),
|
||||||
hasTgBotToken: z.boolean(),
|
hasTgBotToken: z.boolean(),
|
||||||
hasWarpSecret: z.boolean(),
|
hasWarpSecret: z.boolean(),
|
||||||
ipLimitAllowlist: z.string(),
|
|
||||||
outboundDownThreshold: z.number().int().min(1).max(100),
|
|
||||||
pageSize: z.number().int().min(0).max(1000),
|
pageSize: z.number().int().min(0).max(1000),
|
||||||
panelOutbound: z.string(),
|
|
||||||
remarkTemplate: z.string(),
|
remarkTemplate: z.string(),
|
||||||
restartXrayOnClientDisable: z.boolean(),
|
restartXrayOnClientDisable: z.boolean(),
|
||||||
sessionMaxAge: z.number().int().min(1).max(525600),
|
sessionMaxAge: z.number().int().min(1).max(525600),
|
||||||
subAnnounce: z.string(),
|
subAnnounce: z.string(),
|
||||||
subCertFile: z.string(),
|
subCertFile: z.string(),
|
||||||
subClashAutoDetect: z.boolean(),
|
|
||||||
subClashEnable: z.boolean(),
|
|
||||||
subClashPath: z.string(),
|
|
||||||
subClashURI: z.string(),
|
|
||||||
subClashUserAgentRegex: z.string(),
|
|
||||||
subDomain: z.string(),
|
subDomain: z.string(),
|
||||||
subEnable: z.boolean(),
|
subEnable: z.boolean(),
|
||||||
subEncrypt: z.boolean(),
|
subEncrypt: z.boolean(),
|
||||||
subHideSettings: z.boolean(),
|
subHideSettings: z.boolean(),
|
||||||
subJsonAlwaysArray: z.boolean(),
|
|
||||||
subJsonAutoDetect: z.boolean(),
|
|
||||||
subJsonEnable: z.boolean(),
|
|
||||||
subJsonFinalMask: z.string(),
|
|
||||||
subJsonMux: z.string(),
|
|
||||||
subJsonObservatory: z.string(),
|
|
||||||
subJsonPath: z.string(),
|
|
||||||
subJsonURI: z.string(),
|
|
||||||
subJsonUserAgentRegex: z.string(),
|
|
||||||
subKeyFile: z.string(),
|
subKeyFile: z.string(),
|
||||||
subListen: z.string(),
|
subListen: z.string(),
|
||||||
subPath: z.string(),
|
subPath: z.string(),
|
||||||
@@ -133,7 +99,6 @@ export const AllSettingViewSchema = z.object({
|
|||||||
subProfileUrl: z.string(),
|
subProfileUrl: z.string(),
|
||||||
subShowIdentityOnAllLinks: z.boolean(),
|
subShowIdentityOnAllLinks: z.boolean(),
|
||||||
subSupportUrl: z.string(),
|
subSupportUrl: z.string(),
|
||||||
subThemeDir: z.string(),
|
|
||||||
subTitle: z.string(),
|
subTitle: z.string(),
|
||||||
subURI: z.string(),
|
subURI: z.string(),
|
||||||
subUpdates: z.number().int().min(0).max(525600),
|
subUpdates: z.number().int().min(0).max(525600),
|
||||||
@@ -150,6 +115,7 @@ export const AllSettingViewSchema = z.object({
|
|||||||
tgRunTime: z.string(),
|
tgRunTime: z.string(),
|
||||||
timeLocation: z.string(),
|
timeLocation: z.string(),
|
||||||
trafficDiff: z.number().int().min(0).max(100),
|
trafficDiff: z.number().int().min(0).max(100),
|
||||||
|
trafficStatsSwitches: z.string(),
|
||||||
trustedProxyCIDRs: z.string(),
|
trustedProxyCIDRs: z.string(),
|
||||||
warpUpdateInterval: z.number().int().min(0),
|
warpUpdateInterval: z.number().int().min(0),
|
||||||
webBasePath: z.string(),
|
webBasePath: z.string(),
|
||||||
@@ -328,14 +294,6 @@ export const GeoFileSchema = z.object({
|
|||||||
});
|
});
|
||||||
export type GeoFile = z.infer<typeof GeoFileSchema>;
|
export type GeoFile = z.infer<typeof GeoFileSchema>;
|
||||||
|
|
||||||
export const GeodataTokenIssueSchema = z.object({
|
|
||||||
code: z.string().optional(),
|
|
||||||
file: z.string().optional(),
|
|
||||||
reason: z.string(),
|
|
||||||
token: z.string(),
|
|
||||||
});
|
|
||||||
export type GeodataTokenIssue = z.infer<typeof GeodataTokenIssueSchema>;
|
|
||||||
|
|
||||||
export const HistoryOfSeedersSchema = z.object({
|
export const HistoryOfSeedersSchema = z.object({
|
||||||
id: z.number().int(),
|
id: z.number().int(),
|
||||||
seederName: z.string(),
|
seederName: z.string(),
|
||||||
@@ -561,7 +519,11 @@ export type NodeMutationRequest = z.infer<typeof NodeMutationRequestSchema>;
|
|||||||
|
|
||||||
export const NodeViewSchema = z.object({
|
export const NodeViewSchema = z.object({
|
||||||
activeCount: z.number().int(),
|
activeCount: z.number().int(),
|
||||||
|
activeEmails: z.array(z.string()).optional(),
|
||||||
address: z.string(),
|
address: z.string(),
|
||||||
|
agentUpdateError: z.string().optional(),
|
||||||
|
agentUpdateState: z.string().optional(),
|
||||||
|
agentUpdateTarget: z.string().optional(),
|
||||||
allowPrivateAddress: z.boolean(),
|
allowPrivateAddress: z.boolean(),
|
||||||
basePath: z.string(),
|
basePath: z.string(),
|
||||||
clientCount: z.number().int(),
|
clientCount: z.number().int(),
|
||||||
@@ -570,7 +532,9 @@ export const NodeViewSchema = z.object({
|
|||||||
cpuPct: z.number(),
|
cpuPct: z.number(),
|
||||||
createdAt: z.number().int(),
|
createdAt: z.number().int(),
|
||||||
depletedCount: z.number().int(),
|
depletedCount: z.number().int(),
|
||||||
|
depletedEmails: z.array(z.string()).optional(),
|
||||||
disabledCount: z.number().int(),
|
disabledCount: z.number().int(),
|
||||||
|
disabledEmails: z.array(z.string()).optional(),
|
||||||
enable: z.boolean(),
|
enable: z.boolean(),
|
||||||
guid: z.string(),
|
guid: z.string(),
|
||||||
hasApiToken: z.boolean(),
|
hasApiToken: z.boolean(),
|
||||||
@@ -578,6 +542,8 @@ export const NodeViewSchema = z.object({
|
|||||||
inboundCount: z.number().int(),
|
inboundCount: z.number().int(),
|
||||||
inboundSyncMode: z.string(),
|
inboundSyncMode: z.string(),
|
||||||
inboundTags: z.array(z.string()),
|
inboundTags: z.array(z.string()),
|
||||||
|
ipv4: z.string().optional(),
|
||||||
|
ipv6: z.string().optional(),
|
||||||
lastError: z.string(),
|
lastError: z.string(),
|
||||||
lastHeartbeat: z.number().int(),
|
lastHeartbeat: z.number().int(),
|
||||||
latencyMs: z.number().int(),
|
latencyMs: z.number().int(),
|
||||||
@@ -587,6 +553,7 @@ export const NodeViewSchema = z.object({
|
|||||||
netUp: z.number().int(),
|
netUp: z.number().int(),
|
||||||
nodeType: z.string().optional(),
|
nodeType: z.string().optional(),
|
||||||
onlineCount: z.number().int(),
|
onlineCount: z.number().int(),
|
||||||
|
onlineEmails: z.array(z.string()).optional(),
|
||||||
outboundTag: z.string(),
|
outboundTag: z.string(),
|
||||||
panelVersion: z.string(),
|
panelVersion: z.string(),
|
||||||
parentGuid: z.string().optional(),
|
parentGuid: z.string().optional(),
|
||||||
@@ -708,18 +675,6 @@ export const SettingSchema = z.object({
|
|||||||
});
|
});
|
||||||
export type Setting = z.infer<typeof SettingSchema>;
|
export type Setting = z.infer<typeof SettingSchema>;
|
||||||
|
|
||||||
export const SubBalancerSchema = z.object({
|
|
||||||
createdAt: z.number().int(),
|
|
||||||
enabled: z.boolean(),
|
|
||||||
id: z.number().int(),
|
|
||||||
inboundIds: z.array(z.number().int()),
|
|
||||||
remark: z.string().max(256),
|
|
||||||
sortOrder: z.number().int().min(1),
|
|
||||||
strategy: z.enum(['leastLoad', 'leastPing', 'random', 'roundRobin']),
|
|
||||||
updatedAt: z.number().int(),
|
|
||||||
});
|
|
||||||
export type SubBalancer = z.infer<typeof SubBalancerSchema>;
|
|
||||||
|
|
||||||
export const UserSchema = z.object({
|
export const UserSchema = z.object({
|
||||||
id: z.number().int(),
|
id: z.number().int(),
|
||||||
password: z.string(),
|
password: z.string(),
|
||||||
|
|||||||
@@ -51,10 +51,6 @@ const JSON_HEADERS = { headers: { 'Content-Type': 'application/json' } } as cons
|
|||||||
interface SubSettings {
|
interface SubSettings {
|
||||||
enable: boolean;
|
enable: boolean;
|
||||||
subURI: string;
|
subURI: string;
|
||||||
subJsonURI: string;
|
|
||||||
subJsonEnable: boolean;
|
|
||||||
subClashURI: string;
|
|
||||||
subClashEnable: boolean;
|
|
||||||
publicHost: string;
|
publicHost: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -267,22 +263,9 @@ export function useClients(options: UseClientsOptions = {}) {
|
|||||||
() => ({
|
() => ({
|
||||||
enable: !!defaults.subEnable,
|
enable: !!defaults.subEnable,
|
||||||
subURI: (defaults.subURI as string) || '',
|
subURI: (defaults.subURI as string) || '',
|
||||||
subJsonURI: (defaults.subJsonURI as string) || '',
|
|
||||||
subJsonEnable: !!defaults.subJsonEnable,
|
|
||||||
subClashURI: (defaults.subClashURI as string) || '',
|
|
||||||
subClashEnable: !!defaults.subClashEnable,
|
|
||||||
publicHost: (defaults.subDomain as string) || (defaults.webDomain as string) || '',
|
publicHost: (defaults.subDomain as string) || (defaults.webDomain as string) || '',
|
||||||
}),
|
}),
|
||||||
[
|
[defaults.subEnable, defaults.subURI, defaults.subDomain, defaults.webDomain],
|
||||||
defaults.subEnable,
|
|
||||||
defaults.subURI,
|
|
||||||
defaults.subJsonURI,
|
|
||||||
defaults.subJsonEnable,
|
|
||||||
defaults.subClashURI,
|
|
||||||
defaults.subClashEnable,
|
|
||||||
defaults.subDomain,
|
|
||||||
defaults.webDomain,
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const ipLimitEnable = !!defaults.ipLimitEnable;
|
const ipLimitEnable = !!defaults.ipLimitEnable;
|
||||||
@@ -304,7 +287,6 @@ export function useClients(options: UseClientsOptions = {}) {
|
|||||||
return Promise.all([
|
return Promise.all([
|
||||||
queryClient.invalidateQueries({ queryKey: keys.clients.root() }),
|
queryClient.invalidateQueries({ queryKey: keys.clients.root() }),
|
||||||
queryClient.invalidateQueries({ queryKey: keys.inbounds.root() }),
|
queryClient.invalidateQueries({ queryKey: keys.inbounds.root() }),
|
||||||
queryClient.invalidateQueries({ queryKey: keys.xray.config() }),
|
|
||||||
]);
|
]);
|
||||||
}, [queryClient]);
|
}, [queryClient]);
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { HttpUtil } from '@/utils';
|
|||||||
import { parseMsg } from '@/utils/zodValidate';
|
import { parseMsg } from '@/utils/zodValidate';
|
||||||
import { DefaultsPayloadSchema } from '@/schemas/defaults';
|
import { DefaultsPayloadSchema } from '@/schemas/defaults';
|
||||||
|
|
||||||
type Calendar = 'gregorian' | 'jalalian';
|
type Calendar = 'gregorian';
|
||||||
|
|
||||||
let cachedValue: Calendar = 'gregorian';
|
let cachedValue: Calendar = 'gregorian';
|
||||||
let fetched = false;
|
let fetched = false;
|
||||||
|
|||||||
@@ -9,9 +9,6 @@ const TITLE_KEYS: Record<string, string> = {
|
|||||||
'/nodes': 'menu.nodes',
|
'/nodes': 'menu.nodes',
|
||||||
'/hosts': 'menu.hosts',
|
'/hosts': 'menu.hosts',
|
||||||
'/settings': 'menu.settings',
|
'/settings': 'menu.settings',
|
||||||
'/xray': 'menu.xray',
|
|
||||||
'/outbound': 'menu.outbounds',
|
|
||||||
'/routing': 'menu.routing',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export function usePageTitle() {
|
export function usePageTitle() {
|
||||||
|
|||||||
@@ -1,266 +0,0 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|
||||||
import { z } from 'zod';
|
|
||||||
|
|
||||||
import { HttpUtil, Msg } from '@/utils';
|
|
||||||
import { parseMsg } from '@/utils/zodValidate';
|
|
||||||
import { keys } from '@/api/queryKeys';
|
|
||||||
import {
|
|
||||||
OutboundTrafficListSchema,
|
|
||||||
XrayConfigPayloadSchema,
|
|
||||||
XraySettingsValueSchema,
|
|
||||||
type OutboundTrafficRow,
|
|
||||||
} from '@/schemas/xray';
|
|
||||||
|
|
||||||
export type { OutboundTrafficRow };
|
|
||||||
|
|
||||||
export type XraySettingsValue = z.infer<typeof XraySettingsValueSchema>;
|
|
||||||
|
|
||||||
export type SetTemplate = (
|
|
||||||
next: XraySettingsValue | null | ((prev: XraySettingsValue | null) => XraySettingsValue | null),
|
|
||||||
) => void;
|
|
||||||
|
|
||||||
export interface UseXraySettingResult {
|
|
||||||
fetched: boolean;
|
|
||||||
spinning: boolean;
|
|
||||||
saveDisabled: boolean;
|
|
||||||
fetchError: string;
|
|
||||||
xraySetting: string;
|
|
||||||
setXraySetting: (next: string) => void;
|
|
||||||
templateSettings: XraySettingsValue | null;
|
|
||||||
setTemplateSettings: SetTemplate;
|
|
||||||
inboundTags: string[];
|
|
||||||
clientReverseTags: string[];
|
|
||||||
subscriptionOutbounds: unknown[];
|
|
||||||
subscriptionOutboundTags: string[];
|
|
||||||
outboundsTraffic: OutboundTrafficRow[];
|
|
||||||
fetchAll: () => Promise<void>;
|
|
||||||
fetchOutboundsTraffic: () => Promise<void>;
|
|
||||||
resetOutboundsTraffic: (tag: string) => Promise<void>;
|
|
||||||
saveAll: () => Promise<void>;
|
|
||||||
resetToDefault: () => Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
type XrayConfigPayload = z.infer<typeof XrayConfigPayloadSchema>;
|
|
||||||
|
|
||||||
export async function fetchXrayConfig(): Promise<XrayConfigPayload> {
|
|
||||||
const msg = await HttpUtil.post('/panel/api/xray/', undefined, { silent: true });
|
|
||||||
if (!msg?.success) throw new Error(msg?.msg || 'Failed to load xray config');
|
|
||||||
if (typeof msg.obj !== 'string')
|
|
||||||
throw new Error('Malformed xray config response: expected string');
|
|
||||||
let parsed: unknown;
|
|
||||||
try {
|
|
||||||
parsed = JSON.parse(msg.obj);
|
|
||||||
} catch (e) {
|
|
||||||
const err = e as Error;
|
|
||||||
throw new Error(`Malformed xray config response: ${err.message}`, { cause: e });
|
|
||||||
}
|
|
||||||
const result = XrayConfigPayloadSchema.safeParse(parsed);
|
|
||||||
if (!result.success) {
|
|
||||||
console.warn('[zod] xray/ config payload failed validation', result.error.issues);
|
|
||||||
return parsed as XrayConfigPayload;
|
|
||||||
}
|
|
||||||
return result.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchOutboundsTraffic(): Promise<OutboundTrafficRow[]> {
|
|
||||||
const msg = await HttpUtil.get('/panel/api/xray/getOutboundsTraffic', undefined, {
|
|
||||||
silent: true,
|
|
||||||
});
|
|
||||||
if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch outbounds traffic');
|
|
||||||
const validated = parseMsg(msg, OutboundTrafficListSchema, 'xray/getOutboundsTraffic');
|
|
||||||
return Array.isArray(validated.obj) ? validated.obj : [];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useXraySetting(): UseXraySettingResult {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
const configQuery = useQuery({
|
|
||||||
queryKey: keys.xray.config(),
|
|
||||||
queryFn: fetchXrayConfig,
|
|
||||||
staleTime: Infinity,
|
|
||||||
});
|
|
||||||
|
|
||||||
const trafficQuery = useQuery({
|
|
||||||
queryKey: keys.xray.outboundsTraffic(),
|
|
||||||
queryFn: fetchOutboundsTraffic,
|
|
||||||
staleTime: Infinity,
|
|
||||||
});
|
|
||||||
|
|
||||||
const [xraySetting, setXraySettingState] = useState('');
|
|
||||||
const [templateSettings, setTemplateSettingsState] = useState<XraySettingsValue | null>(null);
|
|
||||||
const [savedXraySetting, setSavedXraySetting] = useState('');
|
|
||||||
const config = configQuery.data;
|
|
||||||
const inboundTags = useMemo(() => config?.inboundTags || [], [config]);
|
|
||||||
const clientReverseTags = useMemo(() => config?.clientReverseTags || [], [config]);
|
|
||||||
const subscriptionOutbounds = useMemo<unknown[]>(
|
|
||||||
() => config?.subscriptionOutbounds || [],
|
|
||||||
[config],
|
|
||||||
);
|
|
||||||
const subscriptionOutboundTags = useMemo(() => config?.subscriptionOutboundTags || [], [config]);
|
|
||||||
|
|
||||||
const syncingRef = useRef(false);
|
|
||||||
const xraySettingRef = useRef('');
|
|
||||||
const savedXraySettingRef = useRef(savedXraySetting);
|
|
||||||
const templateSettingsRef = useRef<XraySettingsValue | null>(null);
|
|
||||||
const subscriptionOutboundsRef = useRef<unknown[]>([]);
|
|
||||||
|
|
||||||
const [syncedConfig, setSyncedConfig] = useState<XrayConfigPayload | undefined>();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
xraySettingRef.current = xraySetting;
|
|
||||||
savedXraySettingRef.current = savedXraySetting;
|
|
||||||
templateSettingsRef.current = templateSettings;
|
|
||||||
subscriptionOutboundsRef.current = subscriptionOutbounds;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Adopt a fetched config during render, so the editor never paints one frame
|
|
||||||
// of the previous config after a refetch. Local edits win over the refetch.
|
|
||||||
if (config && config !== syncedConfig) {
|
|
||||||
setSyncedConfig(config);
|
|
||||||
const isDirty = savedXraySetting !== xraySetting;
|
|
||||||
if (!isDirty) {
|
|
||||||
const pretty = JSON.stringify(config.xraySetting, null, 2);
|
|
||||||
setXraySettingState(pretty);
|
|
||||||
setTemplateSettingsState(config.xraySetting);
|
|
||||||
setSavedXraySetting(pretty);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const fetched = configQuery.data !== undefined || configQuery.isError;
|
|
||||||
const fetchError = configQuery.error ? (configQuery.error as Error).message : '';
|
|
||||||
|
|
||||||
const setXraySetting = useCallback((next: string) => {
|
|
||||||
setXraySettingState(next);
|
|
||||||
if (syncingRef.current) return;
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(next);
|
|
||||||
syncingRef.current = true;
|
|
||||||
setTemplateSettingsState(parsed);
|
|
||||||
syncingRef.current = false;
|
|
||||||
} catch {
|
|
||||||
/* ignore — wait for user to finish */
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const setTemplateSettings: SetTemplate = useCallback((nextOrFn) => {
|
|
||||||
setTemplateSettingsState((prev) => {
|
|
||||||
const next = typeof nextOrFn === 'function' ? nextOrFn(prev) : nextOrFn;
|
|
||||||
if (next == null) return next;
|
|
||||||
if (!syncingRef.current) {
|
|
||||||
try {
|
|
||||||
syncingRef.current = true;
|
|
||||||
setXraySettingState(JSON.stringify(next, null, 2));
|
|
||||||
} finally {
|
|
||||||
syncingRef.current = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const fetchAll = useCallback(async () => {
|
|
||||||
await queryClient.invalidateQueries({ queryKey: keys.xray.config() });
|
|
||||||
}, [queryClient]);
|
|
||||||
|
|
||||||
const fetchOutboundsTrafficCb = useCallback(async () => {
|
|
||||||
await queryClient.invalidateQueries({ queryKey: keys.xray.outboundsTraffic() });
|
|
||||||
}, [queryClient]);
|
|
||||||
|
|
||||||
const saveMut = useMutation({
|
|
||||||
mutationFn: async () => {
|
|
||||||
const sentXraySetting = xraySettingRef.current;
|
|
||||||
const msg = await HttpUtil.post('/panel/api/xray/update', {
|
|
||||||
xraySetting: sentXraySetting,
|
|
||||||
});
|
|
||||||
return { msg, sentXraySetting };
|
|
||||||
},
|
|
||||||
onSuccess: ({ msg, sentXraySetting }) => {
|
|
||||||
if (!msg?.success) return;
|
|
||||||
setSavedXraySetting(sentXraySetting);
|
|
||||||
queryClient.invalidateQueries({ queryKey: keys.xray.config() });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const resetTrafficMut = useMutation({
|
|
||||||
mutationFn: (tag: string) => HttpUtil.post('/panel/api/xray/resetOutboundsTraffic', { tag }),
|
|
||||||
onSuccess: (msg) => {
|
|
||||||
if (msg?.success) queryClient.invalidateQueries({ queryKey: keys.xray.outboundsTraffic() });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const resetDefaultMut = useMutation({
|
|
||||||
mutationFn: async (): Promise<Msg<XraySettingsValue>> => {
|
|
||||||
const raw = await HttpUtil.get('/panel/api/setting/getDefaultJsonConfig');
|
|
||||||
return parseMsg(raw, XraySettingsValueSchema, 'setting/getDefaultJsonConfig');
|
|
||||||
},
|
|
||||||
onSuccess: (msg) => {
|
|
||||||
if (msg?.success && msg.obj) {
|
|
||||||
const cloned = JSON.parse(JSON.stringify(msg.obj));
|
|
||||||
setTemplateSettings(cloned);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const saveAll = useCallback(async () => {
|
|
||||||
await saveMut.mutateAsync();
|
|
||||||
}, [saveMut]);
|
|
||||||
const resetOutboundsTraffic = useCallback(
|
|
||||||
async (tag: string) => {
|
|
||||||
await resetTrafficMut.mutateAsync(tag);
|
|
||||||
},
|
|
||||||
[resetTrafficMut],
|
|
||||||
);
|
|
||||||
const resetToDefault = useCallback(async () => {
|
|
||||||
await resetDefaultMut.mutateAsync();
|
|
||||||
}, [resetDefaultMut]);
|
|
||||||
|
|
||||||
const spinning = saveMut.isPending || resetDefaultMut.isPending;
|
|
||||||
|
|
||||||
const saveDisabled = savedXraySetting === xraySetting;
|
|
||||||
|
|
||||||
const outboundsTraffic = useMemo(() => trafficQuery.data ?? [], [trafficQuery.data]);
|
|
||||||
|
|
||||||
return useMemo(
|
|
||||||
() => ({
|
|
||||||
fetched,
|
|
||||||
spinning,
|
|
||||||
saveDisabled,
|
|
||||||
fetchError,
|
|
||||||
xraySetting,
|
|
||||||
setXraySetting,
|
|
||||||
templateSettings,
|
|
||||||
setTemplateSettings,
|
|
||||||
inboundTags,
|
|
||||||
clientReverseTags,
|
|
||||||
subscriptionOutbounds,
|
|
||||||
subscriptionOutboundTags,
|
|
||||||
outboundsTraffic,
|
|
||||||
fetchAll,
|
|
||||||
fetchOutboundsTraffic: fetchOutboundsTrafficCb,
|
|
||||||
resetOutboundsTraffic,
|
|
||||||
saveAll,
|
|
||||||
resetToDefault,
|
|
||||||
}),
|
|
||||||
[
|
|
||||||
fetched,
|
|
||||||
spinning,
|
|
||||||
saveDisabled,
|
|
||||||
fetchError,
|
|
||||||
xraySetting,
|
|
||||||
setXraySetting,
|
|
||||||
templateSettings,
|
|
||||||
setTemplateSettings,
|
|
||||||
inboundTags,
|
|
||||||
clientReverseTags,
|
|
||||||
subscriptionOutbounds,
|
|
||||||
subscriptionOutboundTags,
|
|
||||||
outboundsTraffic,
|
|
||||||
fetchAll,
|
|
||||||
fetchOutboundsTrafficCb,
|
|
||||||
resetOutboundsTraffic,
|
|
||||||
saveAll,
|
|
||||||
resetToDefault,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -6,11 +6,9 @@ import { Drawer, Layout, Menu } from 'antd';
|
|||||||
import type { MenuProps } from 'antd';
|
import type { MenuProps } from 'antd';
|
||||||
import {
|
import {
|
||||||
ApiOutlined,
|
ApiOutlined,
|
||||||
ApartmentOutlined,
|
|
||||||
CloseOutlined,
|
CloseOutlined,
|
||||||
CloudServerOutlined,
|
CloudServerOutlined,
|
||||||
ClusterOutlined,
|
ClusterOutlined,
|
||||||
CodeOutlined,
|
|
||||||
DashboardOutlined,
|
DashboardOutlined,
|
||||||
ExportOutlined,
|
ExportOutlined,
|
||||||
GithubOutlined,
|
GithubOutlined,
|
||||||
@@ -24,6 +22,7 @@ import {
|
|||||||
MoonOutlined,
|
MoonOutlined,
|
||||||
PushpinFilled,
|
PushpinFilled,
|
||||||
PushpinOutlined,
|
PushpinOutlined,
|
||||||
|
RadarChartOutlined,
|
||||||
ReadOutlined,
|
ReadOutlined,
|
||||||
SafetyOutlined,
|
SafetyOutlined,
|
||||||
SettingOutlined,
|
SettingOutlined,
|
||||||
@@ -174,9 +173,7 @@ export default function AppSidebar() {
|
|||||||
const { isDark, isUltra, toggleTheme, toggleUltra } = useTheme();
|
const { isDark, isUltra, toggleTheme, toggleUltra } = useTheme();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { pathname, hash } = useLocation();
|
const { pathname, hash } = useLocation();
|
||||||
const { allSetting } = useAllSettings();
|
useAllSettings();
|
||||||
const showSubFormats = !!(allSetting.subJsonEnable || allSetting.subClashEnable);
|
|
||||||
const showSubBalancers = !!allSetting.subJsonEnable;
|
|
||||||
|
|
||||||
const [hovered, setHovered] = useState(() => hoveredAcrossRemounts);
|
const [hovered, setHovered] = useState(() => hoveredAcrossRemounts);
|
||||||
const [pinned, setPinned] = useState(readSidebarPinned);
|
const [pinned, setPinned] = useState(readSidebarPinned);
|
||||||
@@ -217,10 +214,7 @@ export default function AppSidebar() {
|
|||||||
{ key: '/clients', icon: 'team', title: t('menu.clients') },
|
{ key: '/clients', icon: 'team', title: t('menu.clients') },
|
||||||
{ key: '/nodes', icon: 'cluster', title: t('menu.nodes') },
|
{ key: '/nodes', icon: 'cluster', title: t('menu.nodes') },
|
||||||
{ key: '/hosts', icon: 'hosts', title: t('menu.hosts') },
|
{ key: '/hosts', icon: 'hosts', title: t('menu.hosts') },
|
||||||
{ key: '/outbound', icon: 'outbound', title: t('menu.outbounds') },
|
|
||||||
{ key: '/routing', icon: 'routing', title: t('menu.routing') },
|
|
||||||
{ key: '/settings', icon: 'setting', title: t('menu.settings') },
|
{ key: '/settings', icon: 'setting', title: t('menu.settings') },
|
||||||
{ key: '/xray', icon: 'tool', title: t('menu.xray') },
|
|
||||||
{ key: LOGOUT_KEY, icon: 'logout', title: t('logout') },
|
{ key: LOGOUT_KEY, icon: 'logout', title: t('logout') },
|
||||||
],
|
],
|
||||||
[t],
|
[t],
|
||||||
@@ -251,40 +245,28 @@ export default function AppSidebar() {
|
|||||||
icon: <CloudServerOutlined />,
|
icon: <CloudServerOutlined />,
|
||||||
label: t('pages.settings.subSettings'),
|
label: t('pages.settings.subSettings'),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: '/settings#public-page',
|
||||||
|
icon: <GlobalOutlined />,
|
||||||
|
label: t('pages.settings.probeThemes.title'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: '/settings#probe',
|
||||||
|
icon: <RadarChartOutlined />,
|
||||||
|
label: t('pages.settings.probe.title'),
|
||||||
|
},
|
||||||
];
|
];
|
||||||
if (showSubFormats) {
|
|
||||||
children.push({
|
|
||||||
key: '/settings#subscription-formats',
|
|
||||||
icon: <CodeOutlined />,
|
|
||||||
label: t('menu.subFormats'),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (showSubBalancers) {
|
|
||||||
children.push({
|
|
||||||
key: '/settings#subscription-balancers',
|
|
||||||
icon: <ApartmentOutlined />,
|
|
||||||
label: t('pages.settings.subBalancers.menu'),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return children;
|
return children;
|
||||||
}, [t, showSubFormats, showSubBalancers]);
|
}, [t]);
|
||||||
|
|
||||||
const xrayChildren = useMemo<NonNullable<MenuProps['items']>>(
|
|
||||||
() => [{ key: '/xray#basic', icon: <SettingOutlined />, label: t('pages.xray.basicTemplate') }],
|
|
||||||
[t],
|
|
||||||
);
|
|
||||||
|
|
||||||
const settingsActive = pathname === '/settings';
|
const settingsActive = pathname === '/settings';
|
||||||
const xrayActive = pathname === '/xray';
|
|
||||||
const selectedKey = settingsActive
|
const selectedKey = settingsActive
|
||||||
? `/settings${hash || '#general'}`
|
? `/settings${hash || '#general'}`
|
||||||
: xrayActive
|
: pathname === ''
|
||||||
? `/xray${hash || '#basic'}`
|
? '/'
|
||||||
: pathname === ''
|
: pathname;
|
||||||
? '/'
|
|
||||||
: pathname;
|
|
||||||
|
|
||||||
const openSubmenu = settingsActive ? '/settings' : xrayActive ? '/xray' : null;
|
const openSubmenu = settingsActive ? '/settings' : null;
|
||||||
const [openKeys, setOpenKeys] = useState<string[]>(() => (openSubmenu ? [openSubmenu] : []));
|
const [openKeys, setOpenKeys] = useState<string[]>(() => (openSubmenu ? [openSubmenu] : []));
|
||||||
if (openSubmenu && !openKeys.includes(openSubmenu)) {
|
if (openSubmenu && !openKeys.includes(openSubmenu)) {
|
||||||
setOpenKeys([...openKeys, openSubmenu]);
|
setOpenKeys([...openKeys, openSubmenu]);
|
||||||
@@ -297,12 +279,9 @@ export default function AppSidebar() {
|
|||||||
if (tab.key === '/settings') {
|
if (tab.key === '/settings') {
|
||||||
return { key: tab.key, icon: <Icon />, label: tab.title, children: settingsChildren };
|
return { key: tab.key, icon: <Icon />, label: tab.title, children: settingsChildren };
|
||||||
}
|
}
|
||||||
if (tab.key === '/xray') {
|
|
||||||
return { key: tab.key, icon: <Icon />, label: tab.title, children: xrayChildren };
|
|
||||||
}
|
|
||||||
return { key: tab.key, icon: <Icon />, label: tab.title, title: '' };
|
return { key: tab.key, icon: <Icon />, label: tab.title, title: '' };
|
||||||
}),
|
}),
|
||||||
[settingsChildren, xrayChildren],
|
[settingsChildren],
|
||||||
);
|
);
|
||||||
|
|
||||||
const openLink = useCallback(
|
const openLink = useCallback(
|
||||||
|
|||||||
+13
-3
@@ -1,6 +1,16 @@
|
|||||||
import type { XraySettingsValue } from '@/hooks/useXraySetting';
|
|
||||||
import type { BalancerObject } from '@/schemas/routing';
|
import type { BalancerObject } from '@/schemas/routing';
|
||||||
|
|
||||||
|
// The settings object these helpers read/mutate: only the routing
|
||||||
|
// balancers and the two observatory sections are touched. (This was
|
||||||
|
// XraySettingsValue from the retired xray settings page's hook; the
|
||||||
|
// helpers outlived the page because the subscription balancers tab
|
||||||
|
// still consumes the defaults.)
|
||||||
|
export interface BalancerSettingsLike {
|
||||||
|
routing?: { balancers?: BalancerObject[] } | null;
|
||||||
|
observatory?: unknown;
|
||||||
|
burstObservatory?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
export const DEFAULT_OBSERVATORY = Object.freeze({
|
export const DEFAULT_OBSERVATORY = Object.freeze({
|
||||||
subjectSelector: [] as string[],
|
subjectSelector: [] as string[],
|
||||||
probeURL: 'https://www.google.com/generate_204',
|
probeURL: 'https://www.google.com/generate_204',
|
||||||
@@ -34,7 +44,7 @@ export function balancerRequiresBurstObservatory(b: BalancerObject): boolean {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function settingsRequireBurstObservatory(t: XraySettingsValue | null): boolean {
|
export function settingsRequireBurstObservatory(t: BalancerSettingsLike | null): boolean {
|
||||||
const balancers = (t?.routing?.balancers || []) as BalancerObject[];
|
const balancers = (t?.routing?.balancers || []) as BalancerObject[];
|
||||||
return balancers.some(balancerRequiresBurstObservatory);
|
return balancers.some(balancerRequiresBurstObservatory);
|
||||||
}
|
}
|
||||||
@@ -59,7 +69,7 @@ export function settingsRequireBurstObservatory(t: XraySettingsValue | null): bo
|
|||||||
// the last leastLoad) removes the burst observer again. A no-fallback
|
// the last leastLoad) removes the burst observer again. A no-fallback
|
||||||
// Random/RoundRobin balancer never expands the observer either, because those
|
// Random/RoundRobin balancer never expands the observer either, because those
|
||||||
// strategies do not consume observer data.
|
// strategies do not consume observer data.
|
||||||
export function syncObservatories(t: XraySettingsValue) {
|
export function syncObservatories(t: BalancerSettingsLike) {
|
||||||
const balancers = (t.routing?.balancers || []) as BalancerObject[];
|
const balancers = (t.routing?.balancers || []) as BalancerObject[];
|
||||||
|
|
||||||
const leastPings = balancers.filter((b) => b.strategy?.type === 'leastPing');
|
const leastPings = balancers.filter((b) => b.strategy?.type === 'leastPing');
|
||||||
@@ -62,7 +62,7 @@ export default function SniffingFields({ name, form, enableLabel }: SniffingFiel
|
|||||||
<Select
|
<Select
|
||||||
mode="tags"
|
mode="tags"
|
||||||
tokenSeparators={[',']}
|
tokenSeparators={[',']}
|
||||||
placeholder="IP/CIDR/geoip:*/ext:*"
|
placeholder="IP/CIDR"
|
||||||
style={{ width: '100%' }}
|
style={{ width: '100%' }}
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
@@ -73,7 +73,7 @@ export default function SniffingFields({ name, form, enableLabel }: SniffingFiel
|
|||||||
<Select
|
<Select
|
||||||
mode="tags"
|
mode="tags"
|
||||||
tokenSeparators={[',']}
|
tokenSeparators={[',']}
|
||||||
placeholder="domain:*/ext:*"
|
placeholder="domain:*"
|
||||||
style={{ width: '100%' }}
|
style={{ width: '100%' }}
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { default as SockoptForm } from './sockopt';
|
||||||
|
export { default as MuxForm } from './mux';
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
import type { GeoKind } from '@/generated/types';
|
|
||||||
|
|
||||||
const DEFAULT_SITE_FILE = 'geosite.dat';
|
|
||||||
const DEFAULT_IP_FILE = 'geoip.dat';
|
|
||||||
|
|
||||||
const LONG_FORMS: Array<[RegExp, string]> = [
|
|
||||||
[/^ext(?:-domain|-site)?:geosite\.dat:/, 'geosite:'],
|
|
||||||
[/^ext(?:-ip)?:geoip\.dat:/, 'geoip:'],
|
|
||||||
];
|
|
||||||
|
|
||||||
export function parseTokens(value: string): string[] {
|
|
||||||
return value
|
|
||||||
.split(',')
|
|
||||||
.map((token) => token.trim())
|
|
||||||
.filter((token) => token !== '');
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatTokens(tokens: string[]): string {
|
|
||||||
return tokens.join(', ');
|
|
||||||
}
|
|
||||||
|
|
||||||
export function tokenFor(file: string, code: string, kind: GeoKind): string {
|
|
||||||
if (kind === 'ip' && file === DEFAULT_IP_FILE) return `geoip:${code}`;
|
|
||||||
if (kind === 'site' && file === DEFAULT_SITE_FILE) return `geosite:${code}`;
|
|
||||||
return `ext:${file}:${code}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Xray treats category codes case-insensitively and accepts both the
|
|
||||||
* `geosite:cn` shorthand and its `ext:geosite.dat:cn` long form, so tokens are
|
|
||||||
* compared through this normal form. Only comparison uses it — whatever the
|
|
||||||
* user typed is what stays in the rule.
|
|
||||||
*/
|
|
||||||
export function canonicalToken(token: string): string {
|
|
||||||
const lowered = token.trim().toLowerCase();
|
|
||||||
for (const [pattern, shorthand] of LONG_FORMS) {
|
|
||||||
if (pattern.test(lowered)) return lowered.replace(pattern, shorthand);
|
|
||||||
}
|
|
||||||
return lowered;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function selectionFromValue(value: string, known: ReadonlySet<string>): string[] {
|
|
||||||
const canonicalKnown = new Set([...known].map(canonicalToken));
|
|
||||||
const selection: string[] = [];
|
|
||||||
const seen = new Set<string>();
|
|
||||||
for (const token of parseTokens(value)) {
|
|
||||||
const canonical = canonicalToken(token);
|
|
||||||
if (!canonicalKnown.has(canonical) || seen.has(canonical)) continue;
|
|
||||||
seen.add(canonical);
|
|
||||||
selection.push(token);
|
|
||||||
}
|
|
||||||
return selection;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function mergeSelection(
|
|
||||||
value: string,
|
|
||||||
selected: string[],
|
|
||||||
known: ReadonlySet<string>,
|
|
||||||
): string {
|
|
||||||
const canonicalKnown = new Set([...known].map(canonicalToken));
|
|
||||||
const kept = new Set(
|
|
||||||
selected.map((token) => canonicalToken(token)).filter((token) => token !== ''),
|
|
||||||
);
|
|
||||||
const merged: string[] = [];
|
|
||||||
const seen = new Set<string>();
|
|
||||||
const append = (token: string) => {
|
|
||||||
const canonical = canonicalToken(token);
|
|
||||||
if (canonical === '' || seen.has(canonical)) return;
|
|
||||||
seen.add(canonical);
|
|
||||||
merged.push(token);
|
|
||||||
};
|
|
||||||
for (const token of parseTokens(value)) {
|
|
||||||
const canonical = canonicalToken(token);
|
|
||||||
if (canonicalKnown.has(canonical) && !kept.has(canonical)) continue;
|
|
||||||
append(token);
|
|
||||||
}
|
|
||||||
for (const token of selected) {
|
|
||||||
append(token.trim());
|
|
||||||
}
|
|
||||||
return formatTokens(merged);
|
|
||||||
}
|
|
||||||
@@ -14,7 +14,6 @@ import type { XHttpStreamSettings } from '@/schemas/protocols/stream/xhttp';
|
|||||||
|
|
||||||
import { getHeaderValue } from './headers';
|
import { getHeaderValue } from './headers';
|
||||||
import { canEnableTlsFlow } from './protocol-capabilities';
|
import { canEnableTlsFlow } from './protocol-capabilities';
|
||||||
import { deriveSpiderX } from './spider-x';
|
|
||||||
|
|
||||||
// Share-link generators. Each per-protocol fn takes a typed inbound plus
|
// Share-link generators. Each per-protocol fn takes a typed inbound plus
|
||||||
// client overrides and returns a URL (or '' when the protocol doesn't
|
// client overrides and returns a URL (or '' when the protocol doesn't
|
||||||
@@ -353,7 +352,6 @@ export interface GenVlessLinkInput {
|
|||||||
forceTls?: ForceTls;
|
forceTls?: ForceTls;
|
||||||
remark?: string;
|
remark?: string;
|
||||||
clientId: string;
|
clientId: string;
|
||||||
clientKey?: string;
|
|
||||||
flow?: VlessClient['flow'];
|
flow?: VlessClient['flow'];
|
||||||
externalProxy?: ExternalProxyEntry | null;
|
externalProxy?: ExternalProxyEntry | null;
|
||||||
}
|
}
|
||||||
@@ -374,7 +372,7 @@ export function applyVlessRoute(id: string, route: string | undefined): string {
|
|||||||
// VLESS share link: vless://<uuid>@<host>:<port>?<query>#<remark>. The
|
// VLESS share link: vless://<uuid>@<host>:<port>?<query>#<remark>. The
|
||||||
// query carries network type, encryption, network-specific knobs, and
|
// query carries network type, encryption, network-specific knobs, and
|
||||||
// security-specific knobs (TLS fingerprint/alpn/sni or Reality
|
// security-specific knobs (TLS fingerprint/alpn/sni or Reality
|
||||||
// pbk/sid/spx). Returns '' if the inbound isn't vless.
|
// pbk/sid). Returns '' if the inbound isn't vless.
|
||||||
export function genVlessLink(input: GenVlessLinkInput): string {
|
export function genVlessLink(input: GenVlessLinkInput): string {
|
||||||
const {
|
const {
|
||||||
inbound,
|
inbound,
|
||||||
@@ -383,7 +381,6 @@ export function genVlessLink(input: GenVlessLinkInput): string {
|
|||||||
forceTls = 'same',
|
forceTls = 'same',
|
||||||
remark = '',
|
remark = '',
|
||||||
clientId,
|
clientId,
|
||||||
clientKey = '',
|
|
||||||
flow = '',
|
flow = '',
|
||||||
externalProxy = null,
|
externalProxy = null,
|
||||||
} = input;
|
} = input;
|
||||||
@@ -462,8 +459,6 @@ export function genVlessLink(input: GenVlessLinkInput): string {
|
|||||||
if (sni && sni.length > 0) params.set('sni', sni);
|
if (sni && sni.length > 0) params.set('sni', sni);
|
||||||
|
|
||||||
if (reality.shortIds.length > 0) params.set('sid', reality.shortIds[0]);
|
if (reality.shortIds.length > 0) params.set('sid', reality.shortIds[0]);
|
||||||
const spx = deriveSpiderX(reality.settings.spiderX, clientKey);
|
|
||||||
if (spx.length > 0) params.set('spx', spx);
|
|
||||||
if (reality.settings.mldsa65Verify.length > 0)
|
if (reality.settings.mldsa65Verify.length > 0)
|
||||||
params.set('pqv', reality.settings.mldsa65Verify);
|
params.set('pqv', reality.settings.mldsa65Verify);
|
||||||
}
|
}
|
||||||
@@ -560,7 +555,6 @@ function writeTlsParams(
|
|||||||
function writeRealityParams(
|
function writeRealityParams(
|
||||||
stream: NonNullable<Inbound['streamSettings']>,
|
stream: NonNullable<Inbound['streamSettings']>,
|
||||||
params: URLSearchParams,
|
params: URLSearchParams,
|
||||||
clientKey: string,
|
|
||||||
): void {
|
): void {
|
||||||
if (stream.security !== 'reality') return;
|
if (stream.security !== 'reality') return;
|
||||||
const reality = stream.realitySettings;
|
const reality = stream.realitySettings;
|
||||||
@@ -573,8 +567,6 @@ function writeRealityParams(
|
|||||||
if (sni && sni.length > 0) params.set('sni', sni);
|
if (sni && sni.length > 0) params.set('sni', sni);
|
||||||
|
|
||||||
if (reality.shortIds.length > 0) params.set('sid', reality.shortIds[0]);
|
if (reality.shortIds.length > 0) params.set('sid', reality.shortIds[0]);
|
||||||
const spx = deriveSpiderX(reality.settings.spiderX, clientKey);
|
|
||||||
if (spx.length > 0) params.set('spx', spx);
|
|
||||||
if (reality.settings.mldsa65Verify.length > 0) params.set('pqv', reality.settings.mldsa65Verify);
|
if (reality.settings.mldsa65Verify.length > 0) params.set('pqv', reality.settings.mldsa65Verify);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -585,7 +577,6 @@ export interface GenTrojanLinkInput {
|
|||||||
forceTls?: ForceTls;
|
forceTls?: ForceTls;
|
||||||
remark?: string;
|
remark?: string;
|
||||||
clientPassword: string;
|
clientPassword: string;
|
||||||
clientKey?: string;
|
|
||||||
externalProxy?: ExternalProxyEntry | null;
|
externalProxy?: ExternalProxyEntry | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -600,7 +591,6 @@ export function genTrojanLink(input: GenTrojanLinkInput): string {
|
|||||||
forceTls = 'same',
|
forceTls = 'same',
|
||||||
remark = '',
|
remark = '',
|
||||||
clientPassword,
|
clientPassword,
|
||||||
clientKey = '',
|
|
||||||
externalProxy = null,
|
externalProxy = null,
|
||||||
} = input;
|
} = input;
|
||||||
|
|
||||||
@@ -621,7 +611,7 @@ export function genTrojanLink(input: GenTrojanLinkInput): string {
|
|||||||
applyExternalProxyTLSParams(externalProxy, params, security);
|
applyExternalProxyTLSParams(externalProxy, params, security);
|
||||||
} else if (security === 'reality') {
|
} else if (security === 'reality') {
|
||||||
params.set('security', 'reality');
|
params.set('security', 'reality');
|
||||||
writeRealityParams(stream, params, clientKey);
|
writeRealityParams(stream, params);
|
||||||
} else {
|
} else {
|
||||||
params.set('security', 'none');
|
params.set('security', 'none');
|
||||||
}
|
}
|
||||||
@@ -1321,12 +1311,6 @@ type ClientShape = {
|
|||||||
subId?: string;
|
subId?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Mirror of the Go subKey: the stable per-client identity spx derivation
|
|
||||||
// keys on — subscription id first, unique email as the fallback.
|
|
||||||
function clientSubKey(client: ClientShape): string {
|
|
||||||
return client.subId || client.email || '';
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getInboundClients(inbound: Inbound): ClientShape[] | null {
|
export function getInboundClients(inbound: Inbound): ClientShape[] | null {
|
||||||
switch (inbound.protocol) {
|
switch (inbound.protocol) {
|
||||||
case 'vmess':
|
case 'vmess':
|
||||||
@@ -1392,7 +1376,6 @@ export function genLink(input: GenLinkInput): string {
|
|||||||
forceTls,
|
forceTls,
|
||||||
remark,
|
remark,
|
||||||
clientId: client.id ?? '',
|
clientId: client.id ?? '',
|
||||||
clientKey: clientSubKey(client),
|
|
||||||
flow: client.flow,
|
flow: client.flow,
|
||||||
externalProxy,
|
externalProxy,
|
||||||
});
|
});
|
||||||
@@ -1416,7 +1399,6 @@ export function genLink(input: GenLinkInput): string {
|
|||||||
forceTls,
|
forceTls,
|
||||||
remark,
|
remark,
|
||||||
clientPassword: client.password ?? '',
|
clientPassword: client.password ?? '',
|
||||||
clientKey: clientSubKey(client),
|
|
||||||
externalProxy,
|
externalProxy,
|
||||||
});
|
});
|
||||||
case 'hysteria':
|
case 'hysteria':
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
import { sha256 } from '@noble/hashes/sha2.js';
|
|
||||||
import { bytesToHex, utf8ToBytes } from '@noble/hashes/utils.js';
|
|
||||||
|
|
||||||
// Mirrors deriveSpiderX in internal/sub/service.go byte-for-byte so panel
|
|
||||||
// links and subscription links agree; returns '' when there is no seed and
|
|
||||||
// no client key (the caller then omits spx, as the legacy builder did).
|
|
||||||
export function deriveSpiderX(seed: string, clientKey: string): string {
|
|
||||||
if (!seed && !clientKey) return '';
|
|
||||||
return `/${bytesToHex(sha256(utf8ToBytes(`${seed}|${clientKey}`))).slice(0, 15)}`;
|
|
||||||
}
|
|
||||||
@@ -9,14 +9,12 @@ export class AllSetting {
|
|||||||
webBasePath = '/';
|
webBasePath = '/';
|
||||||
sessionMaxAge = 360;
|
sessionMaxAge = 360;
|
||||||
trustedProxyCIDRs = '127.0.0.1/32,::1/128';
|
trustedProxyCIDRs = '127.0.0.1/32,::1/128';
|
||||||
ipLimitAllowlist = '';
|
|
||||||
panelOutbound = '';
|
|
||||||
pageSize = 25;
|
pageSize = 25;
|
||||||
expireDiff = 0;
|
expireDiff = 0;
|
||||||
trafficDiff = 0;
|
trafficDiff = 0;
|
||||||
remarkTemplate = '{{INBOUND}}-{{EMAIL}}|📊{{TRAFFIC_LEFT}}|⏳{{DAYS_LEFT}}D';
|
remarkTemplate = '{{INBOUND}}-{{EMAIL}}|📊{{TRAFFIC_LEFT}}|⏳{{DAYS_LEFT}}D';
|
||||||
subShowIdentityOnAllLinks = false;
|
subShowIdentityOnAllLinks = false;
|
||||||
datepicker: 'gregorian' | 'jalalian' = 'gregorian';
|
datepicker = 'gregorian' as const;
|
||||||
tgBotEnable = false;
|
tgBotEnable = false;
|
||||||
tgBotToken = '';
|
tgBotToken = '';
|
||||||
tgBotAPIServer = '';
|
tgBotAPIServer = '';
|
||||||
@@ -26,97 +24,32 @@ export class AllSetting {
|
|||||||
tgCpu = 80;
|
tgCpu = 80;
|
||||||
tgMemory = 80;
|
tgMemory = 80;
|
||||||
tgLang = 'en-US';
|
tgLang = 'en-US';
|
||||||
twoFactorEnable = false;
|
trafficStatsSwitches = '';
|
||||||
twoFactorToken = '';
|
|
||||||
xrayTemplateConfig = '';
|
|
||||||
subEnable = true;
|
subEnable = true;
|
||||||
subJsonEnable = false;
|
|
||||||
subJsonAutoDetect = false;
|
|
||||||
subJsonAlwaysArray = false;
|
|
||||||
subJsonUserAgentRegex = '';
|
|
||||||
subClashAutoDetect = false;
|
|
||||||
subClashUserAgentRegex = '';
|
|
||||||
subTitle = '';
|
subTitle = '';
|
||||||
subSupportUrl = '';
|
subSupportUrl = '';
|
||||||
subProfileUrl = '';
|
subProfileUrl = '';
|
||||||
subAnnounce = '';
|
subAnnounce = '';
|
||||||
subEnableRouting = false;
|
|
||||||
subRoutingRules = '';
|
|
||||||
subIncyEnableRouting = false;
|
|
||||||
subIncyRoutingRules = '';
|
|
||||||
subListen = '';
|
subListen = '';
|
||||||
subPort = 2096;
|
subPort = 2096;
|
||||||
subPath = '/sub/';
|
subPath = '/sub/';
|
||||||
subJsonPath = '/json/';
|
|
||||||
subClashEnable = false;
|
|
||||||
subClashPath = '/clash/';
|
|
||||||
subDomain = '';
|
subDomain = '';
|
||||||
externalTrafficInformEnable = false;
|
|
||||||
externalTrafficInformURI = '';
|
|
||||||
restartXrayOnClientDisable = true;
|
restartXrayOnClientDisable = true;
|
||||||
subCertFile = '';
|
subCertFile = '';
|
||||||
subKeyFile = '';
|
subKeyFile = '';
|
||||||
subUpdates = 12;
|
subUpdates = 12;
|
||||||
subEncrypt = true;
|
subEncrypt = true;
|
||||||
subURI = '';
|
subURI = '';
|
||||||
subJsonURI = '';
|
|
||||||
subClashURI = '';
|
|
||||||
subClashEnableRouting = false;
|
|
||||||
subClashRules = '';
|
|
||||||
subJsonMux = '';
|
|
||||||
subJsonRules = '';
|
|
||||||
subJsonFinalMask = '';
|
|
||||||
subJsonObservatory = '';
|
|
||||||
subThemeDir = '';
|
|
||||||
subHideSettings = false;
|
subHideSettings = false;
|
||||||
|
|
||||||
timeLocation = 'Local';
|
timeLocation = 'Local';
|
||||||
|
|
||||||
ldapEnable = false;
|
|
||||||
ldapHost = '';
|
|
||||||
ldapPort = 389;
|
|
||||||
ldapUseTLS = false;
|
|
||||||
ldapInsecureSkipVerify = false;
|
|
||||||
ldapBindDN = '';
|
|
||||||
ldapPassword = '';
|
|
||||||
ldapBaseDN = '';
|
|
||||||
ldapUserFilter = '(objectClass=person)';
|
|
||||||
ldapUserAttr = 'mail';
|
|
||||||
ldapVlessField = 'vless_enabled';
|
|
||||||
ldapSyncCron = '@every 1m';
|
|
||||||
ldapFlagField = '';
|
|
||||||
ldapTruthyValues = 'true,1,yes,on';
|
|
||||||
ldapInvertFlag = false;
|
|
||||||
ldapInboundTags = '';
|
|
||||||
ldapAutoCreate = false;
|
|
||||||
ldapAutoDelete = false;
|
|
||||||
ldapDefaultTotalGB = 0;
|
|
||||||
ldapDefaultExpiryDays = 0;
|
|
||||||
ldapDefaultLimitIP = 0;
|
|
||||||
tgEnabledEvents = 'login.attempt,cpu.high';
|
tgEnabledEvents = 'login.attempt,cpu.high';
|
||||||
smtpEnable = false;
|
|
||||||
smtpHost = '';
|
|
||||||
smtpPort = 587;
|
|
||||||
smtpUsername = '';
|
|
||||||
smtpPassword = '';
|
|
||||||
smtpFrom = '';
|
|
||||||
smtpFromName = '';
|
|
||||||
smtpTo = '';
|
|
||||||
smtpEncryptionType = 'starttls';
|
|
||||||
smtpEnabledEvents = 'login.attempt,cpu.high';
|
|
||||||
smtpCpu = 80;
|
|
||||||
smtpMemory = 80;
|
|
||||||
outboundDownThreshold = 3;
|
|
||||||
hasTgBotToken = false;
|
hasTgBotToken = false;
|
||||||
hasTwoFactorToken = false;
|
|
||||||
hasLdapPassword = false;
|
|
||||||
hasApiToken = false;
|
hasApiToken = false;
|
||||||
hasWarpSecret = false;
|
hasWarpSecret = false;
|
||||||
hasNordSecret = false;
|
hasNordSecret = false;
|
||||||
hasSmtpPassword = false;
|
|
||||||
clearTgBotToken = false;
|
clearTgBotToken = false;
|
||||||
clearLdapPassword = false;
|
|
||||||
clearSmtpPassword = false;
|
|
||||||
|
|
||||||
constructor(data?: unknown) {
|
constructor(data?: unknown) {
|
||||||
if (data != null) {
|
if (data != null) {
|
||||||
@@ -124,10 +57,6 @@ export class AllSetting {
|
|||||||
}
|
}
|
||||||
const cpu = Math.round(Number(this.tgCpu));
|
const cpu = Math.round(Number(this.tgCpu));
|
||||||
this.tgCpu = Number.isFinite(cpu) ? Math.min(100, Math.max(0, cpu)) : 80;
|
this.tgCpu = Number.isFinite(cpu) ? Math.min(100, Math.max(0, cpu)) : 80;
|
||||||
const threshold = Math.round(Number(this.outboundDownThreshold));
|
|
||||||
this.outboundDownThreshold = Number.isFinite(threshold)
|
|
||||||
? Math.min(100, Math.max(1, threshold))
|
|
||||||
: 3;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
equals(other: AllSetting): boolean {
|
equals(other: AllSetting): boolean {
|
||||||
|
|||||||
@@ -51,10 +51,6 @@ const INBOUND_CHIP_LIMIT = 1;
|
|||||||
interface SubSettings {
|
interface SubSettings {
|
||||||
enable: boolean;
|
enable: boolean;
|
||||||
subURI: string;
|
subURI: string;
|
||||||
subJsonURI: string;
|
|
||||||
subJsonEnable: boolean;
|
|
||||||
subClashURI: string;
|
|
||||||
subClashEnable: boolean;
|
|
||||||
publicHost?: string;
|
publicHost?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,17 +72,11 @@ interface ApiMsg<T = unknown> {
|
|||||||
const DEFAULT_SUB: SubSettings = {
|
const DEFAULT_SUB: SubSettings = {
|
||||||
enable: false,
|
enable: false,
|
||||||
subURI: '',
|
subURI: '',
|
||||||
subJsonURI: '',
|
|
||||||
subJsonEnable: false,
|
|
||||||
subClashURI: '',
|
|
||||||
subClashEnable: false,
|
|
||||||
publicHost: '',
|
publicHost: '',
|
||||||
};
|
};
|
||||||
|
|
||||||
const SUBSCRIPTION_DOWNLOAD_NAMES = {
|
const SUBSCRIPTION_DOWNLOAD_NAMES = {
|
||||||
standard: 'subscription-standard.txt',
|
standard: 'subscription-standard.txt',
|
||||||
json: 'subscription-json.json',
|
|
||||||
clash: 'subscription-clash.yaml',
|
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export default function ClientInfoModal({
|
export default function ClientInfoModal({
|
||||||
@@ -170,14 +160,6 @@ export default function ClientInfoModal({
|
|||||||
|
|
||||||
const subId = client?.subId;
|
const subId = client?.subId;
|
||||||
const subLink = subId && subSettings?.subURI ? subSettings.subURI + subId : '';
|
const subLink = subId && subSettings?.subURI ? subSettings.subURI + subId : '';
|
||||||
const subJsonLink =
|
|
||||||
subId && subSettings?.subJsonEnable && subSettings?.subJsonURI
|
|
||||||
? subSettings.subJsonURI + subId
|
|
||||||
: '';
|
|
||||||
const subClashLink =
|
|
||||||
subId && subSettings?.subClashEnable && subSettings?.subClashURI
|
|
||||||
? subSettings.subClashURI + subId
|
|
||||||
: '';
|
|
||||||
|
|
||||||
const showSubscription = !!(subSettings?.enable && client?.subId);
|
const showSubscription = !!(subSettings?.enable && client?.subId);
|
||||||
const wgInbound = useMemo(
|
const wgInbound = useMemo(
|
||||||
@@ -611,120 +593,6 @@ export default function ClientInfoModal({
|
|||||||
</Popover>
|
</Popover>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{subJsonLink && (
|
|
||||||
<div className="link-row">
|
|
||||||
<Tag color="purple" className="link-row-tag">
|
|
||||||
JSON
|
|
||||||
</Tag>
|
|
||||||
<a
|
|
||||||
href={subJsonLink}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="link-row-title link-row-title-anchor"
|
|
||||||
title={subJsonLink}
|
|
||||||
>
|
|
||||||
{client.subId}
|
|
||||||
</a>
|
|
||||||
<div className="link-row-actions">
|
|
||||||
<Tooltip title={t('copy')}>
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
icon={<CopyOutlined />}
|
|
||||||
aria-label={t('copy')}
|
|
||||||
onClick={() => copyValue(subJsonLink)}
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip title={t('download')}>
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
icon={<DownloadOutlined />}
|
|
||||||
aria-label={t('download')}
|
|
||||||
loading={downloadingFormat === 'json'}
|
|
||||||
disabled={downloadingFormat !== null}
|
|
||||||
onClick={() => void downloadSubscription(subJsonLink, 'json')}
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
<Popover
|
|
||||||
trigger="click"
|
|
||||||
placement="left"
|
|
||||||
destroyOnHidden
|
|
||||||
content={
|
|
||||||
<QrPanel
|
|
||||||
value={subJsonLink}
|
|
||||||
remark={`${client.email} — JSON`}
|
|
||||||
size={220}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Tooltip title={t('pages.clients.qrCode')}>
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
icon={<QrcodeOutlined />}
|
|
||||||
aria-label={t('pages.clients.qrCode')}
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
</Popover>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{subClashLink && (
|
|
||||||
<div className="link-row">
|
|
||||||
<Tooltip title="Clash / Mihomo">
|
|
||||||
<Tag color="gold" className="link-row-tag">
|
|
||||||
CLASH
|
|
||||||
</Tag>
|
|
||||||
</Tooltip>
|
|
||||||
<a
|
|
||||||
href={subClashLink}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="link-row-title link-row-title-anchor"
|
|
||||||
title={subClashLink}
|
|
||||||
>
|
|
||||||
{client.subId}
|
|
||||||
</a>
|
|
||||||
<div className="link-row-actions">
|
|
||||||
<Tooltip title={t('copy')}>
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
icon={<CopyOutlined />}
|
|
||||||
aria-label={t('copy')}
|
|
||||||
onClick={() => copyValue(subClashLink)}
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip title={t('download')}>
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
icon={<DownloadOutlined />}
|
|
||||||
aria-label={t('download')}
|
|
||||||
loading={downloadingFormat === 'clash'}
|
|
||||||
disabled={downloadingFormat !== null}
|
|
||||||
onClick={() => void downloadSubscription(subClashLink, 'clash')}
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
<Popover
|
|
||||||
trigger="click"
|
|
||||||
placement="left"
|
|
||||||
destroyOnHidden
|
|
||||||
content={
|
|
||||||
<QrPanel
|
|
||||||
value={subClashLink}
|
|
||||||
remark={`${client.email} — Clash / Mihomo`}
|
|
||||||
size={220}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Tooltip title={t('pages.clients.qrCode')}>
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
icon={<QrcodeOutlined />}
|
|
||||||
aria-label={t('pages.clients.qrCode')}
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
</Popover>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -20,8 +20,6 @@ import {
|
|||||||
interface SubSettings {
|
interface SubSettings {
|
||||||
enable: boolean;
|
enable: boolean;
|
||||||
subURI: string;
|
subURI: string;
|
||||||
subJsonURI: string;
|
|
||||||
subJsonEnable: boolean;
|
|
||||||
publicHost?: string;
|
publicHost?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,8 +40,6 @@ interface ApiMsg<T = unknown> {
|
|||||||
const DEFAULT_SUB: SubSettings = {
|
const DEFAULT_SUB: SubSettings = {
|
||||||
enable: false,
|
enable: false,
|
||||||
subURI: '',
|
subURI: '',
|
||||||
subJsonURI: '',
|
|
||||||
subJsonEnable: false,
|
|
||||||
publicHost: '',
|
publicHost: '',
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -62,10 +58,6 @@ export default function ClientQrModal({
|
|||||||
const subId = client?.subId;
|
const subId = client?.subId;
|
||||||
const subEnabled = !!subSettings?.enable;
|
const subEnabled = !!subSettings?.enable;
|
||||||
const subLink = subId && subEnabled && subSettings?.subURI ? subSettings.subURI + subId : '';
|
const subLink = subId && subEnabled && subSettings?.subURI ? subSettings.subURI + subId : '';
|
||||||
const subJsonLink =
|
|
||||||
subId && subEnabled && subSettings?.subJsonEnable && subSettings?.subJsonURI
|
|
||||||
? subSettings.subJsonURI + subId
|
|
||||||
: '';
|
|
||||||
|
|
||||||
const wgInbound = useMemo(
|
const wgInbound = useMemo(
|
||||||
() => findWireguardInbound(client, inboundsById),
|
() => findWireguardInbound(client, inboundsById),
|
||||||
@@ -97,8 +89,7 @@ export default function ClientQrModal({
|
|||||||
);
|
);
|
||||||
}, [client, awgInbound, tunnelAllowedIPs, subSettings?.publicHost]);
|
}, [client, awgInbound, tunnelAllowedIPs, subSettings?.publicHost]);
|
||||||
|
|
||||||
const hasAnything =
|
const hasAnything = !!subLink || !!wgConfigText || !!awgConfigText || links.length > 0;
|
||||||
!!subLink || !!subJsonLink || !!wgConfigText || !!awgConfigText || links.length > 0;
|
|
||||||
|
|
||||||
// The reset runs during render so the effect only carries the request.
|
// The reset runs during render so the effect only carries the request.
|
||||||
const openSubId = open ? (client?.subId ?? '') : '';
|
const openSubId = open ? (client?.subId ?? '') : '';
|
||||||
@@ -142,13 +133,6 @@ export default function ClientQrModal({
|
|||||||
),
|
),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (subJsonLink) {
|
|
||||||
out.push({
|
|
||||||
key: 'subJson',
|
|
||||||
label: `${t('subscription.title')} (JSON)`,
|
|
||||||
children: <QrPanel value={subJsonLink} remark={`${client?.email || ''} — JSON`} />,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
links.forEach((link, idx) => {
|
links.forEach((link, idx) => {
|
||||||
const parts = parseLinkParts(link);
|
const parts = parseLinkParts(link);
|
||||||
const meta = parts ? linkMetaText(parts) : '';
|
const meta = parts ? linkMetaText(parts) : '';
|
||||||
@@ -207,7 +191,7 @@ export default function ClientQrModal({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
}, [subLink, subJsonLink, wgConfigText, awgConfigText, links, client?.email, t]);
|
}, [subLink, wgConfigText, awgConfigText, links, client?.email, t]);
|
||||||
|
|
||||||
// Expanding the first panel is a render-time adjustment, not a side effect.
|
// Expanding the first panel is a render-time adjustment, not a side effect.
|
||||||
const firstKey = open && items.length > 0 ? items[0].key : null;
|
const firstKey = open && items.length > 0 ? items[0].key : null;
|
||||||
|
|||||||
@@ -9,8 +9,6 @@ import type { ClientRecord } from '@/hooks/useClients';
|
|||||||
interface SubSettings {
|
interface SubSettings {
|
||||||
enable: boolean;
|
enable: boolean;
|
||||||
subURI: string;
|
subURI: string;
|
||||||
subJsonURI: string;
|
|
||||||
subJsonEnable: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SubLinksModalProps {
|
interface SubLinksModalProps {
|
||||||
@@ -26,7 +24,6 @@ interface Row {
|
|||||||
email: string;
|
email: string;
|
||||||
subId: string;
|
subId: string;
|
||||||
link: string;
|
link: string;
|
||||||
jsonLink: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function SubLinksModal({
|
export default function SubLinksModal({
|
||||||
@@ -40,7 +37,6 @@ export default function SubLinksModal({
|
|||||||
const [messageApi, messageContextHolder] = message.useMessage();
|
const [messageApi, messageContextHolder] = message.useMessage();
|
||||||
|
|
||||||
const enabled = !!subSettings?.enable && !!subSettings?.subURI;
|
const enabled = !!subSettings?.enable && !!subSettings?.subURI;
|
||||||
const jsonEnabled = !!subSettings?.subJsonEnable && !!subSettings?.subJsonURI;
|
|
||||||
|
|
||||||
const rows = useMemo<Row[]>(() => {
|
const rows = useMemo<Row[]>(() => {
|
||||||
if (!enabled) return [];
|
if (!enabled) return [];
|
||||||
@@ -54,21 +50,12 @@ export default function SubLinksModal({
|
|||||||
email,
|
email,
|
||||||
subId: c.subId,
|
subId: c.subId,
|
||||||
link: subSettings!.subURI + c.subId,
|
link: subSettings!.subURI + c.subId,
|
||||||
jsonLink: jsonEnabled ? subSettings!.subJsonURI + c.subId : '',
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
}, [emails, clients, enabled, jsonEnabled, subSettings]);
|
}, [emails, clients, enabled, subSettings]);
|
||||||
|
|
||||||
const allText = useMemo(
|
const allText = useMemo(() => rows.map((r) => `${r.email}\t${r.link}`).join('\n'), [rows]);
|
||||||
() =>
|
|
||||||
rows
|
|
||||||
.map((r) =>
|
|
||||||
jsonEnabled ? `${r.email}\t${r.link}\t${r.jsonLink}` : `${r.email}\t${r.link}`,
|
|
||||||
)
|
|
||||||
.join('\n'),
|
|
||||||
[rows, jsonEnabled],
|
|
||||||
);
|
|
||||||
|
|
||||||
async function copy(text: string, label?: string) {
|
async function copy(text: string, label?: string) {
|
||||||
try {
|
try {
|
||||||
@@ -129,22 +116,6 @@ export default function SubLinksModal({
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
if (jsonEnabled) {
|
|
||||||
columns.splice(2, 0, {
|
|
||||||
title: t('pages.clients.subJsonLinkColumn'),
|
|
||||||
dataIndex: 'jsonLink',
|
|
||||||
key: 'jsonLink',
|
|
||||||
ellipsis: true,
|
|
||||||
render: (link: string) => (
|
|
||||||
<Tooltip title={link} placement="topLeft">
|
|
||||||
<Typography.Text copyable={false} ellipsis>
|
|
||||||
{link}
|
|
||||||
</Typography.Text>
|
|
||||||
</Tooltip>
|
|
||||||
),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{messageContextHolder}
|
{messageContextHolder}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { MuxForm } from '@/pages/xray/outbounds/transport';
|
import { MuxForm } from '@/lib/xray/forms/outbound/transport';
|
||||||
|
|
||||||
import OutboundSubtreeJsonForm from './OutboundSubtreeJsonForm';
|
import OutboundSubtreeJsonForm from './OutboundSubtreeJsonForm';
|
||||||
import { serializeOverride } from './helpers';
|
import { serializeOverride } from './helpers';
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { SockoptForm } from '@/pages/xray/outbounds/transport';
|
import { SockoptForm } from '@/lib/xray/forms/outbound/transport';
|
||||||
import { useOutboundTagGroups } from '@/api/queries/useOutboundTags';
|
import { useOutboundTagGroups } from '@/api/queries/useOutboundTags';
|
||||||
|
|
||||||
import OutboundSubtreeJsonForm from './OutboundSubtreeJsonForm';
|
import OutboundSubtreeJsonForm from './OutboundSubtreeJsonForm';
|
||||||
|
|||||||
@@ -54,7 +54,6 @@ export default function InboundInfoModal({
|
|||||||
const [amneziawgConfigs, setAmneziawgConfigs] = useState<string[]>([]);
|
const [amneziawgConfigs, setAmneziawgConfigs] = useState<string[]>([]);
|
||||||
const [amneziawgLinks, setAmneziawgLinks] = useState<string[]>([]);
|
const [amneziawgLinks, setAmneziawgLinks] = useState<string[]>([]);
|
||||||
const [subLink, setSubLink] = useState('');
|
const [subLink, setSubLink] = useState('');
|
||||||
const [subJsonLink, setSubJsonLink] = useState('');
|
|
||||||
const [refreshing, setRefreshing] = useState(false);
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
const [clientIpsArray, setClientIpsArray] = useState<string[]>([]);
|
const [clientIpsArray, setClientIpsArray] = useState<string[]>([]);
|
||||||
const [clientIpsText, setClientIpsText] = useState('');
|
const [clientIpsText, setClientIpsText] = useState('');
|
||||||
@@ -198,12 +197,8 @@ export default function InboundInfoModal({
|
|||||||
|
|
||||||
if (clientSet?.subId) {
|
if (clientSet?.subId) {
|
||||||
setSubLink((subSettings?.subURI || '') + clientSet.subId);
|
setSubLink((subSettings?.subURI || '') + clientSet.subId);
|
||||||
setSubJsonLink(
|
|
||||||
subSettings?.subJsonEnable ? (subSettings?.subJsonURI || '') + clientSet.subId : '',
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
setSubLink('');
|
setSubLink('');
|
||||||
setSubJsonLink('');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setClientIpsArray([]);
|
setClientIpsArray([]);
|
||||||
@@ -575,29 +570,6 @@ export default function InboundInfoModal({
|
|||||||
{subLink}
|
{subLink}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
{subSettings?.subJsonEnable && subJsonLink && (
|
|
||||||
<div className="link-panel">
|
|
||||||
<div className="link-panel-header">
|
|
||||||
<Tag color="green">JSON</Tag>
|
|
||||||
<Tooltip title={t('copy')}>
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
icon={<CopyOutlined />}
|
|
||||||
aria-label={t('copy')}
|
|
||||||
onClick={() => copyText(subJsonLink, t)}
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
<a
|
|
||||||
href={subJsonLink}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="link-panel-anchor"
|
|
||||||
>
|
|
||||||
{subJsonLink}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -55,7 +55,6 @@ export default function QrCodeModal({
|
|||||||
const [amneziawgConfigs, setAmneziawgConfigs] = useState<string[]>([]);
|
const [amneziawgConfigs, setAmneziawgConfigs] = useState<string[]>([]);
|
||||||
const [amneziawgLinks, setAmneziawgLinks] = useState<string[]>([]);
|
const [amneziawgLinks, setAmneziawgLinks] = useState<string[]>([]);
|
||||||
const [subLink, setSubLink] = useState('');
|
const [subLink, setSubLink] = useState('');
|
||||||
const [subJsonLink, setSubJsonLink] = useState('');
|
|
||||||
const [activeKey, setActiveKey] = useState<string[]>([]);
|
const [activeKey, setActiveKey] = useState<string[]>([]);
|
||||||
|
|
||||||
// Building the links is a pure function of the props, so it runs during
|
// Building the links is a pure function of the props, so it runs during
|
||||||
@@ -145,13 +144,10 @@ export default function QrCodeModal({
|
|||||||
|
|
||||||
const subId = client?.subId;
|
const subId = client?.subId;
|
||||||
let nextSub = '';
|
let nextSub = '';
|
||||||
let nextSubJson = '';
|
|
||||||
if (subSettings?.enable && subId) {
|
if (subSettings?.enable && subId) {
|
||||||
nextSub = (subSettings.subURI || '') + subId;
|
nextSub = (subSettings.subURI || '') + subId;
|
||||||
nextSubJson = subSettings.subJsonEnable ? (subSettings.subJsonURI || '') + subId : '';
|
|
||||||
}
|
}
|
||||||
setSubLink(nextSub);
|
setSubLink(nextSub);
|
||||||
setSubJsonLink(nextSubJson);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const qrItems = useMemo<QrItem[]>(() => {
|
const qrItems = useMemo<QrItem[]>(() => {
|
||||||
@@ -159,13 +155,6 @@ export default function QrCodeModal({
|
|||||||
if (subLink) {
|
if (subLink) {
|
||||||
items.push({ key: 'sub', header: t('subscription.title'), value: subLink });
|
items.push({ key: 'sub', header: t('subscription.title'), value: subLink });
|
||||||
}
|
}
|
||||||
if (subJsonLink) {
|
|
||||||
items.push({
|
|
||||||
key: 'sub-json',
|
|
||||||
header: `${t('subscription.title')} (JSON)`,
|
|
||||||
value: subJsonLink,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
links.forEach((link, idx) => {
|
links.forEach((link, idx) => {
|
||||||
items.push({ key: `l${idx}`, header: link.remark || `Link ${idx + 1}`, value: link.link });
|
items.push({ key: `l${idx}`, header: link.remark || `Link ${idx + 1}`, value: link.link });
|
||||||
});
|
});
|
||||||
@@ -202,16 +191,7 @@ export default function QrCodeModal({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
return items;
|
return items;
|
||||||
}, [
|
}, [subLink, links, wireguardConfigs, wireguardLinks, amneziawgConfigs, amneziawgLinks, t]);
|
||||||
subLink,
|
|
||||||
subJsonLink,
|
|
||||||
links,
|
|
||||||
wireguardConfigs,
|
|
||||||
wireguardLinks,
|
|
||||||
amneziawgConfigs,
|
|
||||||
amneziawgLinks,
|
|
||||||
t,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const collapseItems: CollapseProps['items'] = useMemo(
|
const collapseItems: CollapseProps['items'] = useMemo(
|
||||||
() =>
|
() =>
|
||||||
|
|||||||
@@ -20,8 +20,6 @@ export interface SubSettings {
|
|||||||
enable: boolean;
|
enable: boolean;
|
||||||
subTitle: string;
|
subTitle: string;
|
||||||
subURI: string;
|
subURI: string;
|
||||||
subJsonURI: string;
|
|
||||||
subJsonEnable: boolean;
|
|
||||||
// Configured public host (Sub Domain, else Web Domain) used as the share/QR
|
// Configured public host (Sub Domain, else Web Domain) used as the share/QR
|
||||||
// link host when the panel is reached on a loopback address. Empty if neither
|
// link host when the panel is reached on a loopback address. Empty if neither
|
||||||
// is set.
|
// is set.
|
||||||
@@ -179,23 +177,19 @@ export function useInbounds() {
|
|||||||
const tgBotEnable = !!defaults.tgBotEnable;
|
const tgBotEnable = !!defaults.tgBotEnable;
|
||||||
const ipLimitEnable = !!defaults.ipLimitEnable;
|
const ipLimitEnable = !!defaults.ipLimitEnable;
|
||||||
const pageSize = defaults.pageSize ?? 0;
|
const pageSize = defaults.pageSize ?? 0;
|
||||||
const datepicker = (defaults.datepicker as 'gregorian' | 'jalalian') || 'gregorian';
|
const datepicker = (defaults.datepicker as 'gregorian') || 'gregorian';
|
||||||
|
|
||||||
const subSettings: SubSettings = useMemo(
|
const subSettings: SubSettings = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
enable: !!defaults.subEnable,
|
enable: !!defaults.subEnable,
|
||||||
subTitle: defaults.subTitle || '',
|
subTitle: defaults.subTitle || '',
|
||||||
subURI: defaults.subURI || '',
|
subURI: defaults.subURI || '',
|
||||||
subJsonURI: defaults.subJsonURI || '',
|
|
||||||
subJsonEnable: !!defaults.subJsonEnable,
|
|
||||||
publicHost: defaults.subDomain || defaults.webDomain || '',
|
publicHost: defaults.subDomain || defaults.webDomain || '',
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
defaults.subEnable,
|
defaults.subEnable,
|
||||||
defaults.subTitle,
|
defaults.subTitle,
|
||||||
defaults.subURI,
|
defaults.subURI,
|
||||||
defaults.subJsonURI,
|
|
||||||
defaults.subJsonEnable,
|
|
||||||
defaults.subDomain,
|
defaults.subDomain,
|
||||||
defaults.webDomain,
|
defaults.webDomain,
|
||||||
],
|
],
|
||||||
@@ -392,16 +386,13 @@ export function useInbounds() {
|
|||||||
// Invalidate at the inbounds root so both `slim` (this page's list)
|
// Invalidate at the inbounds root so both `slim` (this page's list)
|
||||||
// and `options` (the Clients page's inbound picker) refetch. Without
|
// and `options` (the Clients page's inbound picker) refetch. Without
|
||||||
// the options bucket, a freshly-created inbound stays invisible in
|
// the options bucket, a freshly-created inbound stays invisible in
|
||||||
// the client add/edit modal until a full page reload. The xray config
|
// the client add/edit modal until a full page reload.
|
||||||
// response carries inboundTags for the routing-rule tag picker, so it
|
|
||||||
// needs invalidating too or that list stays stale until a hard refresh.
|
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
queryClient.invalidateQueries({ queryKey: keys.inbounds.root() }),
|
queryClient.invalidateQueries({ queryKey: keys.inbounds.root() }),
|
||||||
queryClient.invalidateQueries({ queryKey: keys.clients.onlines() }),
|
queryClient.invalidateQueries({ queryKey: keys.clients.onlines() }),
|
||||||
queryClient.invalidateQueries({ queryKey: keys.clients.onlinesByGuid() }),
|
queryClient.invalidateQueries({ queryKey: keys.clients.onlinesByGuid() }),
|
||||||
queryClient.invalidateQueries({ queryKey: keys.clients.activeInbounds() }),
|
queryClient.invalidateQueries({ queryKey: keys.clients.activeInbounds() }),
|
||||||
queryClient.invalidateQueries({ queryKey: keys.clients.lastOnline() }),
|
queryClient.invalidateQueries({ queryKey: keys.clients.lastOnline() }),
|
||||||
queryClient.invalidateQueries({ queryKey: keys.xray.config() }),
|
|
||||||
]);
|
]);
|
||||||
}, [queryClient]);
|
}, [queryClient]);
|
||||||
|
|
||||||
|
|||||||
@@ -1,19 +1,7 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Button, ConfigProvider, Form, Input, Layout, Menu, Popover, Space, message } from 'antd';
|
||||||
import {
|
import {
|
||||||
Button,
|
|
||||||
ConfigProvider,
|
|
||||||
Form,
|
|
||||||
Input,
|
|
||||||
Layout,
|
|
||||||
Menu,
|
|
||||||
Popover,
|
|
||||||
Space,
|
|
||||||
Spin,
|
|
||||||
message,
|
|
||||||
} from 'antd';
|
|
||||||
import {
|
|
||||||
KeyOutlined,
|
|
||||||
LockOutlined,
|
LockOutlined,
|
||||||
MoonFilled,
|
MoonFilled,
|
||||||
MoonOutlined,
|
MoonOutlined,
|
||||||
@@ -27,7 +15,7 @@ import { HttpUtil, LanguageManager } from '@/utils';
|
|||||||
import { FormField, rhfZodValidate } from '@/components/form/rhf';
|
import { FormField, rhfZodValidate } from '@/components/form/rhf';
|
||||||
import { setMessageInstance } from '@/utils/messageBus';
|
import { setMessageInstance } from '@/utils/messageBus';
|
||||||
import { pauseAnimationsUntilLeave, useTheme } from '@/hooks/useTheme';
|
import { pauseAnimationsUntilLeave, useTheme } from '@/hooks/useTheme';
|
||||||
import { LoginFormSchema, TwoFactorCodeSchema, type LoginFormValues } from '@/schemas/login';
|
import { LoginFormSchema, type LoginFormValues } from '@/schemas/login';
|
||||||
import './LoginPage.css';
|
import './LoginPage.css';
|
||||||
|
|
||||||
const HEADLINE_INTERVAL_MS = 2000;
|
const HEADLINE_INTERVAL_MS = 2000;
|
||||||
@@ -45,12 +33,10 @@ export default function LoginPage() {
|
|||||||
setMessageInstance(messageApi);
|
setMessageInstance(messageApi);
|
||||||
}, [messageApi]);
|
}, [messageApi]);
|
||||||
|
|
||||||
const [fetched, setFetched] = useState(false);
|
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [twoFactorEnable, setTwoFactorEnable] = useState(false);
|
|
||||||
const [headlineIndex, setHeadlineIndex] = useState(0);
|
const [headlineIndex, setHeadlineIndex] = useState(0);
|
||||||
const methods = useForm<LoginForm>({
|
const methods = useForm<LoginForm>({
|
||||||
defaultValues: { username: '', password: '', twoFactorCode: '' },
|
defaultValues: { username: '', password: '' },
|
||||||
});
|
});
|
||||||
const [lang, setLang] = useState<string>(() => LanguageManager.getLanguage());
|
const [lang, setLang] = useState<string>(() => LanguageManager.getLanguage());
|
||||||
|
|
||||||
@@ -63,19 +49,6 @@ export default function LoginPage() {
|
|||||||
return () => window.clearInterval(timer);
|
return () => window.clearInterval(timer);
|
||||||
}, [headlineWords.length]);
|
}, [headlineWords.length]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let cancelled = false;
|
|
||||||
(async () => {
|
|
||||||
const msg = await HttpUtil.post('/getTwoFactorEnable');
|
|
||||||
if (cancelled) return;
|
|
||||||
if (msg.success) setTwoFactorEnable(!!msg.obj);
|
|
||||||
setFetched(true);
|
|
||||||
})();
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const onSubmit = useCallback(async (values: LoginForm) => {
|
const onSubmit = useCallback(async (values: LoginForm) => {
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
try {
|
try {
|
||||||
@@ -172,83 +145,62 @@ export default function LoginPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="login-wrapper">
|
<div className="login-wrapper">
|
||||||
{!fetched ? (
|
<div className="login-card">
|
||||||
<div className="login-loading">
|
<div className="brand">
|
||||||
<Spin size="large" />
|
<span className="brand-name">L-UI</span>
|
||||||
|
<span className="brand-accent" aria-hidden="true" />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
<h2 className="welcome">
|
||||||
<div className="login-card">
|
<b key={headlineIndex}>{headlineWords[headlineIndex]}</b>
|
||||||
<div className="brand">
|
</h2>
|
||||||
<span className="brand-name">L-UI</span>
|
|
||||||
<span className="brand-accent" aria-hidden="true" />
|
|
||||||
</div>
|
|
||||||
<h2 className="welcome">
|
|
||||||
<b key={headlineIndex}>{headlineWords[headlineIndex]}</b>
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<FormProvider {...methods}>
|
<FormProvider {...methods}>
|
||||||
<Form
|
<Form
|
||||||
layout="vertical"
|
layout="vertical"
|
||||||
className="login-form"
|
className="login-form"
|
||||||
onFinish={methods.handleSubmit(onSubmit)}
|
onFinish={methods.handleSubmit(onSubmit)}
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
name="username"
|
||||||
|
label={t('username')}
|
||||||
|
rules={{ validate: rhfZodValidate(LoginFormSchema.shape.username) }}
|
||||||
>
|
>
|
||||||
<FormField
|
<Input
|
||||||
name="username"
|
prefix={<UserOutlined />}
|
||||||
label={t('username')}
|
autoComplete="username"
|
||||||
rules={{ validate: rhfZodValidate(LoginFormSchema.shape.username) }}
|
size="large"
|
||||||
|
placeholder={t('username')}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
name="password"
|
||||||
|
label={t('password')}
|
||||||
|
rules={{ validate: rhfZodValidate(LoginFormSchema.shape.password) }}
|
||||||
|
>
|
||||||
|
<Input.Password
|
||||||
|
prefix={<LockOutlined />}
|
||||||
|
autoComplete="current-password"
|
||||||
|
size="large"
|
||||||
|
placeholder={t('password')}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<Form.Item className="submit-row">
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
htmlType="submit"
|
||||||
|
loading={submitting}
|
||||||
|
size="large"
|
||||||
|
block
|
||||||
>
|
>
|
||||||
<Input
|
{t('login')}
|
||||||
prefix={<UserOutlined />}
|
</Button>
|
||||||
autoComplete="username"
|
</Form.Item>
|
||||||
size="large"
|
</Form>
|
||||||
placeholder={t('username')}
|
</FormProvider>
|
||||||
autoFocus
|
</div>
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
name="password"
|
|
||||||
label={t('password')}
|
|
||||||
rules={{ validate: rhfZodValidate(LoginFormSchema.shape.password) }}
|
|
||||||
>
|
|
||||||
<Input.Password
|
|
||||||
prefix={<LockOutlined />}
|
|
||||||
autoComplete="current-password"
|
|
||||||
size="large"
|
|
||||||
placeholder={t('password')}
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
|
|
||||||
{twoFactorEnable && (
|
|
||||||
<FormField
|
|
||||||
name="twoFactorCode"
|
|
||||||
label={t('twoFactorCode')}
|
|
||||||
rules={{ validate: rhfZodValidate(TwoFactorCodeSchema) }}
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
prefix={<KeyOutlined />}
|
|
||||||
autoComplete="one-time-code"
|
|
||||||
size="large"
|
|
||||||
placeholder={t('twoFactorCode')}
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Form.Item className="submit-row">
|
|
||||||
<Button
|
|
||||||
type="primary"
|
|
||||||
htmlType="submit"
|
|
||||||
loading={submitting}
|
|
||||||
size="large"
|
|
||||||
block
|
|
||||||
>
|
|
||||||
{t('login')}
|
|
||||||
</Button>
|
|
||||||
</Form.Item>
|
|
||||||
</Form>
|
|
||||||
</FormProvider>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</Layout.Content>
|
</Layout.Content>
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useNavigate } from 'react-router';
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
Button,
|
Button,
|
||||||
@@ -21,6 +22,7 @@ import { NodeFormSchema, type NodeFormValues, type ProbeResult } from '@/schemas
|
|||||||
import { FormField, rhfZodValidate } from '@/components/form/rhf';
|
import { FormField, rhfZodValidate } from '@/components/form/rhf';
|
||||||
import { useOutboundTagGroups } from '@/api/queries/useOutboundTags';
|
import { useOutboundTagGroups } from '@/api/queries/useOutboundTags';
|
||||||
import { currentPanelMasterUrl } from './agentMasterUrl';
|
import { currentPanelMasterUrl } from './agentMasterUrl';
|
||||||
|
|
||||||
import './NodeFormModal.css';
|
import './NodeFormModal.css';
|
||||||
|
|
||||||
type Mode = 'add' | 'edit';
|
type Mode = 'add' | 'edit';
|
||||||
@@ -48,6 +50,7 @@ function defaultValues(): NodeFormValues {
|
|||||||
basePath: '/',
|
basePath: '/',
|
||||||
apiToken: '',
|
apiToken: '',
|
||||||
hasStoredToken: false,
|
hasStoredToken: false,
|
||||||
|
probeOnly: false,
|
||||||
enable: true,
|
enable: true,
|
||||||
allowPrivateAddress: false,
|
allowPrivateAddress: false,
|
||||||
tlsVerifyMode: 'verify',
|
tlsVerifyMode: 'verify',
|
||||||
@@ -69,6 +72,7 @@ export default function NodeFormModal({
|
|||||||
onOpenChange,
|
onOpenChange,
|
||||||
}: NodeFormModalProps) {
|
}: NodeFormModalProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
const methods = useForm<NodeFormValues>({ defaultValues: defaultValues() });
|
const methods = useForm<NodeFormValues>({ defaultValues: defaultValues() });
|
||||||
const [messageApi, messageContextHolder] = message.useMessage();
|
const [messageApi, messageContextHolder] = message.useMessage();
|
||||||
|
|
||||||
@@ -80,6 +84,9 @@ export default function NodeFormModal({
|
|||||||
const [testResult, setTestResult] = useState<ProbeResult | null>(null);
|
const [testResult, setTestResult] = useState<ProbeResult | null>(null);
|
||||||
const [agentToken, setAgentToken] = useState<string | null>(null);
|
const [agentToken, setAgentToken] = useState<string | null>(null);
|
||||||
const [agentNodeName, setAgentNodeName] = useState('');
|
const [agentNodeName, setAgentNodeName] = useState('');
|
||||||
|
// Whether the just-created agent node was flagged probe-only, so the
|
||||||
|
// install command below can carry the matching --probe-only flag.
|
||||||
|
const [agentProbeOnly, setAgentProbeOnly] = useState(false);
|
||||||
const [agentMasterUrl, setAgentMasterUrl] = useState('');
|
const [agentMasterUrl, setAgentMasterUrl] = useState('');
|
||||||
// Edit mode (item 19): the stored install material for an existing agent
|
// Edit mode (item 19): the stored install material for an existing agent
|
||||||
// node, fetched from the master; null until loaded / for non-agent nodes.
|
// node, fetched from the master; null until loaded / for non-agent nodes.
|
||||||
@@ -97,6 +104,7 @@ export default function NodeFormModal({
|
|||||||
const inboundSyncMode = useWatch({ control: methods.control, name: 'inboundSyncMode' }) ?? 'all';
|
const inboundSyncMode = useWatch({ control: methods.control, name: 'inboundSyncMode' }) ?? 'all';
|
||||||
const nodeType = useWatch({ control: methods.control, name: 'nodeType' }) ?? 'traditional';
|
const nodeType = useWatch({ control: methods.control, name: 'nodeType' }) ?? 'traditional';
|
||||||
const isAgent = nodeType === 'agent';
|
const isAgent = nodeType === 'agent';
|
||||||
|
const probeOnly = useWatch({ control: methods.control, name: 'probeOnly' }) ?? false;
|
||||||
const { data: outboundGroups } = useOutboundTagGroups({ excludeBlackhole: true });
|
const { data: outboundGroups } = useOutboundTagGroups({ excludeBlackhole: true });
|
||||||
|
|
||||||
// Outbounds and balancers share one picker (like the panel-outbound selector);
|
// Outbounds and balancers share one picker (like the panel-outbound selector);
|
||||||
@@ -175,10 +183,13 @@ export default function NodeFormModal({
|
|||||||
const base =
|
const base =
|
||||||
`bash <(curl -fL https://git.881218.xyz/rogue/3x-ui-agent/raw/branch/main/install-agent.sh)` +
|
`bash <(curl -fL https://git.881218.xyz/rogue/3x-ui-agent/raw/branch/main/install-agent.sh)` +
|
||||||
` --master ${editMasterUrl}`;
|
` --master ${editMasterUrl}`;
|
||||||
return shownInstall.hasToken
|
const cmd = shownInstall.hasToken
|
||||||
? `${base} --token ${shownInstall.token} --name ${shownInstall.name}`
|
? `${base} --token ${shownInstall.token} --name ${shownInstall.name}`
|
||||||
: `${base} --name ${shownInstall.name}`;
|
: `${base} --name ${shownInstall.name}`;
|
||||||
}, [shownInstall, editMasterUrl]);
|
// The toggle is the source of truth for the re-displayed command:
|
||||||
|
// flipping it rewrites the command the operator would re-run.
|
||||||
|
return probeOnly ? `${cmd} --probe-only` : cmd;
|
||||||
|
}, [shownInstall, editMasterUrl, probeOnly]);
|
||||||
|
|
||||||
const title = useMemo(
|
const title = useMemo(
|
||||||
() => (mode === 'edit' ? t('pages.nodes.editNode') : t('pages.nodes.addNode')),
|
() => (mode === 'edit' ? t('pages.nodes.editNode') : t('pages.nodes.addNode')),
|
||||||
@@ -208,6 +219,9 @@ export default function NodeFormModal({
|
|||||||
inboundTags: values.inboundSyncMode === 'selected' ? values.inboundTags : [],
|
inboundTags: values.inboundSyncMode === 'selected' ? values.inboundTags : [],
|
||||||
outboundTag: values.outboundTag || '',
|
outboundTag: values.outboundTag || '',
|
||||||
};
|
};
|
||||||
|
// Probe-only (V2.1.0) is an agent-node concept; traditional
|
||||||
|
// payloads stay byte-identical to before.
|
||||||
|
if (agentMode) payload.probeOnly = values.probeOnly ?? false;
|
||||||
if (token) payload.apiToken = token;
|
if (token) payload.apiToken = token;
|
||||||
return payload;
|
return payload;
|
||||||
}
|
}
|
||||||
@@ -293,6 +307,7 @@ export default function NodeFormModal({
|
|||||||
if (agentMode && mode === 'add' && obj?.agentToken) {
|
if (agentMode && mode === 'add' && obj?.agentToken) {
|
||||||
setAgentToken(obj.agentToken);
|
setAgentToken(obj.agentToken);
|
||||||
setAgentNodeName(result.data.name.trim());
|
setAgentNodeName(result.data.name.trim());
|
||||||
|
setAgentProbeOnly(result.data.probeOnly ?? false);
|
||||||
// Default the master URL to the address this panel is being
|
// Default the master URL to the address this panel is being
|
||||||
// viewed through — origin AND base path. Dropping the base
|
// viewed through — origin AND base path. Dropping the base
|
||||||
// path made the agent's registration 404. User can edit it.
|
// path made the agent's registration 404. User can edit it.
|
||||||
@@ -315,7 +330,7 @@ export default function NodeFormModal({
|
|||||||
// one would produce a command that can never register, so withhold it
|
// one would produce a command that can never register, so withhold it
|
||||||
// (and say why) instead of letting the user copy a dead command.
|
// (and say why) instead of letting the user copy a dead command.
|
||||||
const installCmd = agentMasterUrl
|
const installCmd = agentMasterUrl
|
||||||
? `bash <(curl -fL https://git.881218.xyz/rogue/3x-ui-agent/raw/branch/main/install-agent.sh) --master ${agentMasterUrl} --token ${agentToken ?? ''} --name ${agentNodeName}`
|
? `bash <(curl -fL https://git.881218.xyz/rogue/3x-ui-agent/raw/branch/main/install-agent.sh) --master ${agentMasterUrl} --token ${agentToken ?? ''} --name ${agentNodeName}${agentProbeOnly ? ' --probe-only' : ''}`
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -427,11 +442,21 @@ export default function NodeFormModal({
|
|||||||
)}
|
)}
|
||||||
{isAgent && (
|
{isAgent && (
|
||||||
<Row gutter={16}>
|
<Row gutter={16}>
|
||||||
<Col xs={24}>
|
<Col xs={24} md={12}>
|
||||||
<FormField label={t('pages.nodes.enable')} name="enable" valueProp="checked">
|
<FormField label={t('pages.nodes.enable')} name="enable" valueProp="checked">
|
||||||
<Switch />
|
<Switch />
|
||||||
</FormField>
|
</FormField>
|
||||||
</Col>
|
</Col>
|
||||||
|
<Col xs={24} md={12}>
|
||||||
|
<FormField
|
||||||
|
label={t('pages.nodes.probeOnly')}
|
||||||
|
name="probeOnly"
|
||||||
|
valueProp="checked"
|
||||||
|
tooltip={t('pages.nodes.probeOnlyHint')}
|
||||||
|
>
|
||||||
|
<Switch />
|
||||||
|
</FormField>
|
||||||
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -472,6 +497,18 @@ export default function NodeFormModal({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{mode === 'edit' && isAgent && node?.id ? (
|
||||||
|
<div style={{ marginBottom: 16 }}>
|
||||||
|
<div style={{ fontWeight: 600, marginBottom: 4 }}>
|
||||||
|
{t('pages.nodes.probe.title')}
|
||||||
|
</div>
|
||||||
|
<p style={{ marginBottom: 8 }}>{t('pages.nodes.probe.movedHint')}</p>
|
||||||
|
<Button onClick={() => navigate('/settings#probe')}>
|
||||||
|
{t('pages.nodes.probe.goSettings')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{!isAgent && (
|
{!isAgent && (
|
||||||
<FormField
|
<FormField
|
||||||
label={t('pages.nodes.allowPrivateAddress')}
|
label={t('pages.nodes.allowPrivateAddress')}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import {
|
|||||||
InfoCircleOutlined,
|
InfoCircleOutlined,
|
||||||
MoreOutlined,
|
MoreOutlined,
|
||||||
PlusOutlined,
|
PlusOutlined,
|
||||||
|
RadarChartOutlined,
|
||||||
RightOutlined,
|
RightOutlined,
|
||||||
SafetyCertificateOutlined,
|
SafetyCertificateOutlined,
|
||||||
TeamOutlined,
|
TeamOutlined,
|
||||||
@@ -48,6 +49,7 @@ interface NodeListProps {
|
|||||||
onSelectionChange: (ids: number[]) => void;
|
onSelectionChange: (ids: number[]) => void;
|
||||||
onAdd: () => void;
|
onAdd: () => void;
|
||||||
onMtls: () => void;
|
onMtls: () => void;
|
||||||
|
onProbeTasks: () => void;
|
||||||
onEdit: (node: NodeRecord) => void;
|
onEdit: (node: NodeRecord) => void;
|
||||||
onDelete: (node: NodeRecord) => void;
|
onDelete: (node: NodeRecord) => void;
|
||||||
onProbe: (node: NodeRecord) => void;
|
onProbe: (node: NodeRecord) => void;
|
||||||
@@ -218,6 +220,7 @@ export default function NodeList({
|
|||||||
onSelectionChange,
|
onSelectionChange,
|
||||||
onAdd,
|
onAdd,
|
||||||
onMtls,
|
onMtls,
|
||||||
|
onProbeTasks,
|
||||||
onEdit,
|
onEdit,
|
||||||
onDelete,
|
onDelete,
|
||||||
onProbe,
|
onProbe,
|
||||||
@@ -304,13 +307,13 @@ export default function NodeList({
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
) : (
|
) : (
|
||||||
<Space>
|
<Space>
|
||||||
<Tooltip title={t('pages.nodes.probe')}>
|
<Tooltip title={t('pages.nodes.testConnection')}>
|
||||||
<Button
|
<Button
|
||||||
type="text"
|
type="text"
|
||||||
size="small"
|
size="small"
|
||||||
style={{ fontSize: 16 }}
|
style={{ fontSize: 16 }}
|
||||||
icon={<ThunderboltOutlined />}
|
icon={<ThunderboltOutlined />}
|
||||||
aria-label={t('pages.nodes.probe')}
|
aria-label={t('pages.nodes.testConnection')}
|
||||||
onClick={() => onProbe(record)}
|
onClick={() => onProbe(record)}
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
@@ -490,10 +493,23 @@ export default function NodeList({
|
|||||||
render: (_value, record) => formatPct(record.memPct),
|
render: (_value, record) => formatPct(record.memPct),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t('pages.nodes.xrayVersion'),
|
// Core version (V2.0.1 ②): agent rows run sing-box and show
|
||||||
|
// its version; traditional rows keep showing the xray one.
|
||||||
|
// Probe-only agent rows (V2.1.0) run no core by design: the
|
||||||
|
// cell says so instead of a bare '-', which reads as a fault.
|
||||||
|
title: t('pages.nodes.coreVersion'),
|
||||||
dataIndex: 'xrayVersion',
|
dataIndex: 'xrayVersion',
|
||||||
align: 'center',
|
align: 'center',
|
||||||
render: (_value, record) => record.xrayVersion || '-',
|
render: (_value, record) =>
|
||||||
|
record.nodeType === 'agent'
|
||||||
|
? record.probeOnly
|
||||||
|
? (
|
||||||
|
<Tag color="cyan" style={{ margin: 0 }}>
|
||||||
|
{t('pages.nodes.probeOnly')}
|
||||||
|
</Tag>
|
||||||
|
)
|
||||||
|
: record.singboxVersion || record.xrayVersion || '-'
|
||||||
|
: record.xrayVersion || '-',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t('pages.nodes.panelVersion') || 'Panel version',
|
title: t('pages.nodes.panelVersion') || 'Panel version',
|
||||||
@@ -618,6 +634,9 @@ export default function NodeList({
|
|||||||
<Button icon={<SafetyCertificateOutlined />} onClick={onMtls}>
|
<Button icon={<SafetyCertificateOutlined />} onClick={onMtls}>
|
||||||
{t('pages.nodes.mtls.title')}
|
{t('pages.nodes.mtls.title')}
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button icon={<RadarChartOutlined />} onClick={onProbeTasks}>
|
||||||
|
{t('pages.nodes.probeTasks.title')}
|
||||||
|
</Button>
|
||||||
{selectedIds.length > 0 && (
|
{selectedIds.length > 0 && (
|
||||||
<Button icon={<CloudDownloadOutlined />} onClick={onUpdateSelected}>
|
<Button icon={<CloudDownloadOutlined />} onClick={onUpdateSelected}>
|
||||||
{t('pages.nodes.updateSelected', { count: selectedIds.length })}
|
{t('pages.nodes.updateSelected', { count: selectedIds.length })}
|
||||||
@@ -697,7 +716,7 @@ export default function NodeList({
|
|||||||
key: 'probe',
|
key: 'probe',
|
||||||
label: (
|
label: (
|
||||||
<>
|
<>
|
||||||
<ThunderboltOutlined /> {t('pages.nodes.probe')}
|
<ThunderboltOutlined /> {t('pages.nodes.testConnection')}
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
onClick: () => onProbe(record),
|
onClick: () => onProbe(record),
|
||||||
@@ -834,8 +853,12 @@ export default function NodeList({
|
|||||||
<Tag>{formatPct(statsNode.memPct)}</Tag>
|
<Tag>{formatPct(statsNode.memPct)}</Tag>
|
||||||
</div>
|
</div>
|
||||||
<div className="stat-row">
|
<div className="stat-row">
|
||||||
<span className="stat-label">{t('pages.nodes.xrayVersion')}</span>
|
<span className="stat-label">{t('pages.nodes.coreVersion')}</span>
|
||||||
<Tag>{statsNode.xrayVersion || '-'}</Tag>
|
<Tag>
|
||||||
|
{statsNode.nodeType === 'agent'
|
||||||
|
? statsNode.singboxVersion || statsNode.xrayVersion || '-'
|
||||||
|
: statsNode.xrayVersion || '-'}
|
||||||
|
</Tag>
|
||||||
</div>
|
</div>
|
||||||
<div className="stat-row">
|
<div className="stat-row">
|
||||||
<span className="stat-label">
|
<span className="stat-label">
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useNavigate } from 'react-router';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
@@ -69,6 +70,7 @@ function UpdateChannelChoice({ onChange }: { onChange: (dev: boolean) => void })
|
|||||||
|
|
||||||
export default function NodesPage() {
|
export default function NodesPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
const { isDark, isUltra, antdThemeConfig } = useTheme();
|
const { isDark, isUltra, antdThemeConfig } = useTheme();
|
||||||
const { isMobile } = useMediaQuery();
|
const { isMobile } = useMediaQuery();
|
||||||
const [modal, modalContextHolder] = Modal.useModal();
|
const [modal, modalContextHolder] = Modal.useModal();
|
||||||
@@ -367,6 +369,7 @@ export default function NodesPage() {
|
|||||||
onSelectionChange={setSelectedIds}
|
onSelectionChange={setSelectedIds}
|
||||||
onAdd={onAdd}
|
onAdd={onAdd}
|
||||||
onMtls={() => setMtlsOpen(true)}
|
onMtls={() => setMtlsOpen(true)}
|
||||||
|
onProbeTasks={() => navigate('/settings#probe')}
|
||||||
onEdit={onEdit}
|
onEdit={onEdit}
|
||||||
onDelete={onDelete}
|
onDelete={onDelete}
|
||||||
onProbe={onProbe}
|
onProbe={onProbe}
|
||||||
|
|||||||
@@ -0,0 +1,324 @@
|
|||||||
|
import { useEffect, useState, type ReactNode } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Button, Col, Input, InputNumber, Row, Select, Switch, message } from 'antd';
|
||||||
|
import { HttpUtil } from '@/utils';
|
||||||
|
|
||||||
|
// Item 7 B3: probe/public-page metadata for an agent node, edited in
|
||||||
|
// place from the node edit dialog. The section is deliberately outside
|
||||||
|
// the node form: it loads and saves on its own (dedicated endpoints,
|
||||||
|
// effective immediately on save — no node save, no restart).
|
||||||
|
interface ProbeMetaWire {
|
||||||
|
price: number;
|
||||||
|
currency: string;
|
||||||
|
billingCycle: string;
|
||||||
|
expiresAt: string | null;
|
||||||
|
trafficLimit: number; // bytes
|
||||||
|
trafficMode: string;
|
||||||
|
trafficResetDay: number;
|
||||||
|
public: boolean;
|
||||||
|
sort: number;
|
||||||
|
group: string;
|
||||||
|
publicRemark: string;
|
||||||
|
notify: boolean;
|
||||||
|
countryPin: string;
|
||||||
|
ipv4Pin: string;
|
||||||
|
ipv6Pin: string;
|
||||||
|
// V2.0.1: ledger corrections in GB (either sign) and the counted
|
||||||
|
// interface pin ('' = automatic selection on the agent). Since
|
||||||
|
// V2.2.1 the corrections are one-shot deltas: the inputs below are
|
||||||
|
// local state (never prefilled from the wire), a save submits them
|
||||||
|
// once, and they clear back to empty on success.
|
||||||
|
trafficCalUpGb: number;
|
||||||
|
trafficCalDownGb: number;
|
||||||
|
statIface: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// GB entry converts on the binary convention every byte formatter in
|
||||||
|
// the panel and on the public page uses (V2.2.1: was decimal 1e9,
|
||||||
|
// which made a 3000 GB limit render as 2.73 TB instead of 2.93 TB).
|
||||||
|
const BYTES_PER_GB = 1024 ** 3;
|
||||||
|
|
||||||
|
const JSON_HEADERS = { headers: { 'Content-Type': 'application/json' } };
|
||||||
|
|
||||||
|
const NAMED_CYCLES = [
|
||||||
|
'once',
|
||||||
|
'monthly',
|
||||||
|
'quarterly',
|
||||||
|
'semiannual',
|
||||||
|
'yearly',
|
||||||
|
'biennial',
|
||||||
|
'triennial',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
function Field({ label, children }: { label: string; children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div style={{ marginBottom: 12 }}>
|
||||||
|
<div style={{ marginBottom: 4 }}>{label}</div>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ProbeMetaSection({ nodeId }: { nodeId: number }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [messageApi, messageContextHolder] = message.useMessage();
|
||||||
|
// Loaded meta, keyed by node id so a stale result for a previously
|
||||||
|
// edited node is never shown (same discipline as the install block
|
||||||
|
// in NodeFormModal); visibility derives during render.
|
||||||
|
const [loaded, setLoaded] = useState<{ nodeId: number; meta: ProbeMetaWire } | null>(null);
|
||||||
|
const [trafficGb, setTrafficGb] = useState(0);
|
||||||
|
// One-shot calibration deltas (GB, either sign): null = empty box.
|
||||||
|
const [calUpGb, setCalUpGb] = useState<number | null>(null);
|
||||||
|
const [calDownGb, setCalDownGb] = useState<number | null>(null);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
HttpUtil.get<ProbeMetaWire>(`/panel/api/agent/probe-meta/${nodeId}`, {
|
||||||
|
silent: true,
|
||||||
|
}).then((msg) => {
|
||||||
|
if (!cancelled && msg?.success && msg.obj) {
|
||||||
|
setLoaded({ nodeId, meta: msg.obj });
|
||||||
|
setTrafficGb(msg.obj.trafficLimit / BYTES_PER_GB);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [nodeId]);
|
||||||
|
|
||||||
|
const meta = loaded && loaded.nodeId === nodeId ? loaded.meta : null;
|
||||||
|
if (!meta) return null;
|
||||||
|
|
||||||
|
const set = <K extends keyof ProbeMetaWire>(key: K, value: ProbeMetaWire[K]) =>
|
||||||
|
setLoaded((l) => (l ? { nodeId: l.nodeId, meta: { ...l.meta, [key]: value } } : l));
|
||||||
|
|
||||||
|
const cycleLabel = (cycle: string) => {
|
||||||
|
switch (cycle) {
|
||||||
|
case 'once':
|
||||||
|
return t('pages.nodes.probe.cycleOnce');
|
||||||
|
case 'monthly':
|
||||||
|
return t('pages.nodes.probe.cycleMonthly');
|
||||||
|
case 'quarterly':
|
||||||
|
return t('pages.nodes.probe.cycleQuarterly');
|
||||||
|
case 'semiannual':
|
||||||
|
return t('pages.nodes.probe.cycleSemiannual');
|
||||||
|
case 'yearly':
|
||||||
|
return t('pages.nodes.probe.cycleYearly');
|
||||||
|
case 'biennial':
|
||||||
|
return t('pages.nodes.probe.cycleBiennial');
|
||||||
|
case 'triennial':
|
||||||
|
return t('pages.nodes.probe.cycleTriennial');
|
||||||
|
default:
|
||||||
|
return cycle;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const cycleOptions: { value: string; label: string }[] = NAMED_CYCLES.map((cycle) => ({
|
||||||
|
value: cycle,
|
||||||
|
label: cycleLabel(cycle),
|
||||||
|
}));
|
||||||
|
if (!NAMED_CYCLES.includes(meta.billingCycle as (typeof NAMED_CYCLES)[number])) {
|
||||||
|
cycleOptions.push({ value: meta.billingCycle, label: meta.billingCycle });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onSave() {
|
||||||
|
if (!meta) return;
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const payload: ProbeMetaWire = {
|
||||||
|
...meta,
|
||||||
|
trafficLimit: Math.round(trafficGb * BYTES_PER_GB),
|
||||||
|
trafficCalUpGb: calUpGb ?? 0,
|
||||||
|
trafficCalDownGb: calDownGb ?? 0,
|
||||||
|
};
|
||||||
|
const msg = await HttpUtil.put(`/panel/api/agent/probe-meta/${nodeId}`, payload, {
|
||||||
|
silent: true,
|
||||||
|
...JSON_HEADERS,
|
||||||
|
});
|
||||||
|
if (msg?.success) {
|
||||||
|
// One-shot: the submitted corrections are folded in server-
|
||||||
|
// side; the boxes clear so a later save resubmits nothing.
|
||||||
|
setLoaded({
|
||||||
|
nodeId,
|
||||||
|
meta: { ...payload, trafficCalUpGb: 0, trafficCalDownGb: 0 },
|
||||||
|
});
|
||||||
|
setCalUpGb(null);
|
||||||
|
setCalDownGb(null);
|
||||||
|
messageApi.success(t('pages.nodes.probe.saved'));
|
||||||
|
} else {
|
||||||
|
messageApi.error(msg?.msg || t('pages.nodes.probe.saveFailed'));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ marginBottom: 16 }}>
|
||||||
|
{messageContextHolder}
|
||||||
|
<div style={{ fontWeight: 600, marginBottom: 4 }}>{t('pages.nodes.probe.title')}</div>
|
||||||
|
<p style={{ marginBottom: 12 }}>{t('pages.nodes.probe.hint')}</p>
|
||||||
|
<Row gutter={16}>
|
||||||
|
<Col xs={24} md={12}>
|
||||||
|
<Field label={t('pages.nodes.probe.public')}>
|
||||||
|
<Switch checked={meta.public} onChange={(v) => set('public', v)} />
|
||||||
|
</Field>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} md={12}>
|
||||||
|
<Field label={t('pages.nodes.probe.notify')}>
|
||||||
|
<Switch checked={meta.notify} onChange={(v) => set('notify', v)} />
|
||||||
|
</Field>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
<Row gutter={16}>
|
||||||
|
<Col xs={24} md={12}>
|
||||||
|
<Field label={t('pages.nodes.probe.group')}>
|
||||||
|
<Input value={meta.group} onChange={(e) => set('group', e.target.value)} />
|
||||||
|
</Field>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} md={12}>
|
||||||
|
<Field label={t('pages.nodes.probe.sort')}>
|
||||||
|
<InputNumber
|
||||||
|
value={meta.sort}
|
||||||
|
onChange={(v) => set('sort', v ?? 0)}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
<Field label={t('pages.nodes.probe.publicRemark')}>
|
||||||
|
<Input value={meta.publicRemark} onChange={(e) => set('publicRemark', e.target.value)} />
|
||||||
|
</Field>
|
||||||
|
<Row gutter={16}>
|
||||||
|
<Col xs={24} md={8}>
|
||||||
|
<Field label={t('pages.nodes.probe.price')}>
|
||||||
|
<InputNumber
|
||||||
|
value={meta.price}
|
||||||
|
min={0}
|
||||||
|
onChange={(v) => set('price', v ?? 0)}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} md={8}>
|
||||||
|
<Field label={t('pages.nodes.probe.currency')}>
|
||||||
|
<Input value={meta.currency} onChange={(e) => set('currency', e.target.value)} />
|
||||||
|
</Field>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} md={8}>
|
||||||
|
<Field label={t('pages.nodes.probe.billingCycle')}>
|
||||||
|
<Select
|
||||||
|
value={meta.billingCycle}
|
||||||
|
options={cycleOptions}
|
||||||
|
onChange={(v) => set('billingCycle', v)}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
<Row gutter={16}>
|
||||||
|
<Col xs={24} md={12}>
|
||||||
|
<Field label={t('pages.nodes.probe.expiresAt')}>
|
||||||
|
<Input
|
||||||
|
value={meta.expiresAt ?? ''}
|
||||||
|
placeholder="YYYY-MM-DD"
|
||||||
|
onChange={(e) => set('expiresAt', e.target.value || null)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} md={12}>
|
||||||
|
<Field label={t('pages.nodes.probe.trafficResetDay')}>
|
||||||
|
<InputNumber
|
||||||
|
value={meta.trafficResetDay}
|
||||||
|
min={1}
|
||||||
|
max={31}
|
||||||
|
onChange={(v) => set('trafficResetDay', v ?? 1)}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
<Row gutter={16}>
|
||||||
|
<Col xs={24} md={12}>
|
||||||
|
<Field label={t('pages.nodes.probe.trafficLimit')}>
|
||||||
|
<InputNumber
|
||||||
|
value={trafficGb}
|
||||||
|
min={0}
|
||||||
|
onChange={(v) => setTrafficGb(v ?? 0)}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} md={12}>
|
||||||
|
<Field label={t('pages.nodes.probe.trafficMode')}>
|
||||||
|
<Select
|
||||||
|
value={meta.trafficMode}
|
||||||
|
options={[
|
||||||
|
{ value: 'sum', label: t('pages.nodes.probe.modeSum') },
|
||||||
|
{ value: 'up', label: t('pages.nodes.probe.modeUp') },
|
||||||
|
{ value: 'down', label: t('pages.nodes.probe.modeDown') },
|
||||||
|
{ value: 'max', label: t('pages.nodes.probe.modeMax') },
|
||||||
|
]}
|
||||||
|
onChange={(v) => set('trafficMode', v)}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
<Row gutter={16}>
|
||||||
|
<Col xs={24} md={8}>
|
||||||
|
<Field label={t('pages.nodes.probe.calUp')}>
|
||||||
|
<InputNumber
|
||||||
|
value={calUpGb}
|
||||||
|
step={0.1}
|
||||||
|
placeholder="0"
|
||||||
|
onChange={(v) => setCalUpGb(v)}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} md={8}>
|
||||||
|
<Field label={t('pages.nodes.probe.calDown')}>
|
||||||
|
<InputNumber
|
||||||
|
value={calDownGb}
|
||||||
|
step={0.1}
|
||||||
|
placeholder="0"
|
||||||
|
onChange={(v) => setCalDownGb(v)}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} md={8}>
|
||||||
|
<Field label={t('pages.nodes.probe.statIface')}>
|
||||||
|
<Input
|
||||||
|
value={meta.statIface}
|
||||||
|
placeholder="eth0"
|
||||||
|
onChange={(e) => set('statIface', e.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
<p style={{ marginTop: -4, marginBottom: 12 }}>{t('pages.nodes.probe.calHint')}</p>
|
||||||
|
<Row gutter={16}>
|
||||||
|
<Col xs={24} md={8}>
|
||||||
|
<Field label={t('pages.nodes.probe.countryPin')}>
|
||||||
|
<Input value={meta.countryPin} onChange={(e) => set('countryPin', e.target.value)} />
|
||||||
|
</Field>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} md={8}>
|
||||||
|
<Field label={t('pages.nodes.probe.ipv4Pin')}>
|
||||||
|
<Input value={meta.ipv4Pin} onChange={(e) => set('ipv4Pin', e.target.value)} />
|
||||||
|
</Field>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} md={8}>
|
||||||
|
<Field label={t('pages.nodes.probe.ipv6Pin')}>
|
||||||
|
<Input value={meta.ipv6Pin} onChange={(e) => set('ipv6Pin', e.target.value)} />
|
||||||
|
</Field>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
<Button type="primary" loading={saving} onClick={onSave}>
|
||||||
|
{t('pages.nodes.probe.save')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,20 +1,15 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Input, InputNumber, Select, Switch, Tabs } from 'antd';
|
import { Input, InputNumber, Select, Switch, Tabs } from 'antd';
|
||||||
import { ClockCircleOutlined, SafetyCertificateOutlined, SettingOutlined } from '@ant-design/icons';
|
import { ClockCircleOutlined, SafetyCertificateOutlined, SettingOutlined } from '@ant-design/icons';
|
||||||
import type { AllSetting } from '@/models/setting';
|
import type { AllSetting } from '@/models/setting';
|
||||||
import { HttpUtil, LanguageManager } from '@/utils';
|
import { LanguageManager } from '@/utils';
|
||||||
import { onNumber } from '@/utils/onNumber';
|
import { onNumber } from '@/utils/onNumber';
|
||||||
import { DefaultSettingTag, SettingListItem } from '@/components/ui';
|
import { DefaultSettingTag, SettingListItem } from '@/components/ui';
|
||||||
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
||||||
import { catTabLabel } from './catTabLabel';
|
import { catTabLabel } from './catTabLabel';
|
||||||
import { sanitizePath } from './uriPath';
|
import { sanitizePath } from './uriPath';
|
||||||
|
|
||||||
interface ApiMsg<T = unknown> {
|
|
||||||
success?: boolean;
|
|
||||||
obj?: T;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface GeneralTabProps {
|
interface GeneralTabProps {
|
||||||
allSetting: AllSetting;
|
allSetting: AllSetting;
|
||||||
updateSetting: (patch: Partial<AllSetting>) => void;
|
updateSetting: (patch: Partial<AllSetting>) => void;
|
||||||
@@ -25,78 +20,38 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
|
|||||||
const { isMobile } = useMediaQuery();
|
const { isMobile } = useMediaQuery();
|
||||||
|
|
||||||
const [lang, setLang] = useState<string>(() => LanguageManager.getLanguage());
|
const [lang, setLang] = useState<string>(() => LanguageManager.getLanguage());
|
||||||
const [outboundTagList, setOutboundTagList] = useState<string[]>([]);
|
|
||||||
const [balancerTagList, setBalancerTagList] = useState<string[]>([]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
// The whole-network traffic accounting switches (settings key
|
||||||
let cancelled = false;
|
// trafficStatsSwitches, ITEM6 §4.3.1): one JSON string carrying the
|
||||||
(async () => {
|
// four direction keys the sing-box generator's stats gate reads.
|
||||||
// Candidates for the panel egress picker: template outbounds plus
|
const statsSwitches = useMemo(() => {
|
||||||
// subscription-derived outbounds, and routing balancers. The panel egress
|
try {
|
||||||
// is injected as a routing rule, so a balancer tag is a valid target
|
const parsed = JSON.parse(allSetting.trafficStatsSwitches || '') as Partial<{
|
||||||
// (it load-balances the panel's own traffic). The geodata picker, by
|
inboundUplink: boolean;
|
||||||
// contrast, dials a forced tag and can only use a concrete outbound.
|
inboundDownlink: boolean;
|
||||||
const msg = (await HttpUtil.post('/panel/api/xray/', undefined, {
|
outboundUplink: boolean;
|
||||||
silent: true,
|
outboundDownlink: boolean;
|
||||||
})) as ApiMsg<string>;
|
}>;
|
||||||
if (cancelled || !msg?.success || typeof msg.obj !== 'string') return;
|
return {
|
||||||
try {
|
inboundUplink: parsed.inboundUplink !== false,
|
||||||
const payload = JSON.parse(msg.obj) as Record<string, unknown>;
|
inboundDownlink: parsed.inboundDownlink !== false,
|
||||||
const template = (payload.xraySetting || {}) as Record<string, unknown>;
|
outboundUplink: parsed.outboundUplink === true,
|
||||||
const tags = new Set<string>();
|
outboundDownlink: parsed.outboundDownlink === true,
|
||||||
const outbounds = Array.isArray(template.outbounds) ? template.outbounds : [];
|
};
|
||||||
for (const o of outbounds) {
|
} catch {
|
||||||
if (!o || typeof o !== 'object') continue;
|
return {
|
||||||
const rec = o as Record<string, unknown>;
|
inboundUplink: true,
|
||||||
if (rec.protocol === 'blackhole') continue; // dropping traffic is never a useful egress
|
inboundDownlink: true,
|
||||||
const tag = rec.tag;
|
outboundUplink: false,
|
||||||
if (typeof tag === 'string' && tag) tags.add(tag);
|
outboundDownlink: false,
|
||||||
}
|
};
|
||||||
const subTags = Array.isArray(payload.subscriptionOutboundTags)
|
}
|
||||||
? payload.subscriptionOutboundTags
|
}, [allSetting.trafficStatsSwitches]);
|
||||||
: [];
|
|
||||||
for (const tag of subTags) {
|
|
||||||
if (typeof tag === 'string' && tag) tags.add(tag);
|
|
||||||
}
|
|
||||||
const balancerTags: string[] = [];
|
|
||||||
const routing = (template.routing || {}) as Record<string, unknown>;
|
|
||||||
const balancers = Array.isArray(routing.balancers) ? routing.balancers : [];
|
|
||||||
for (const b of balancers) {
|
|
||||||
if (!b || typeof b !== 'object') continue;
|
|
||||||
const tag = (b as Record<string, unknown>).tag;
|
|
||||||
if (typeof tag === 'string' && tag && !tags.has(tag)) balancerTags.push(tag);
|
|
||||||
}
|
|
||||||
setOutboundTagList([...tags]);
|
|
||||||
setBalancerTagList(balancerTags);
|
|
||||||
} catch {
|
|
||||||
setOutboundTagList([]);
|
|
||||||
setBalancerTagList([]);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Outbound tags and balancer tags share one picker. When balancers exist they
|
const setStatsSwitch =
|
||||||
// get their own labeled group so it's clear the selection routes through a
|
(key: 'inboundUplink' | 'inboundDownlink' | 'outboundUplink' | 'outboundDownlink') =>
|
||||||
// balancer rather than a single outbound.
|
(v: boolean) =>
|
||||||
const outboundOptions = useMemo<
|
updateSetting({ trafficStatsSwitches: JSON.stringify({ ...statsSwitches, [key]: v }) });
|
||||||
(
|
|
||||||
| { label: string; value: string }
|
|
||||||
| { label: string; options: { label: string; value: string }[] }
|
|
||||||
)[]
|
|
||||||
>(() => {
|
|
||||||
const outOpts = outboundTagList.map((tag) => ({ label: tag, value: tag }));
|
|
||||||
if (balancerTagList.length === 0) return outOpts;
|
|
||||||
return [
|
|
||||||
{ label: t('pages.xray.Outbounds'), options: outOpts },
|
|
||||||
{
|
|
||||||
label: t('pages.xray.Balancers'),
|
|
||||||
options: balancerTagList.map((tag) => ({ label: tag, value: tag })),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}, [outboundTagList, balancerTagList, t]);
|
|
||||||
|
|
||||||
function onLangChange(value: string) {
|
function onLangChange(value: string) {
|
||||||
setLang(value);
|
setLang(value);
|
||||||
@@ -207,36 +162,6 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
|
|||||||
/>
|
/>
|
||||||
</SettingListItem>
|
</SettingListItem>
|
||||||
|
|
||||||
<SettingListItem
|
|
||||||
paddings="small"
|
|
||||||
title={t('pages.settings.ipLimitAllowlist')}
|
|
||||||
description={t('pages.settings.ipLimitAllowlistDesc')}
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
value={allSetting.ipLimitAllowlist}
|
|
||||||
placeholder="203.0.113.10,198.51.100.0/24"
|
|
||||||
onChange={(e) => updateSetting({ ipLimitAllowlist: e.target.value })}
|
|
||||||
/>
|
|
||||||
</SettingListItem>
|
|
||||||
|
|
||||||
<SettingListItem
|
|
||||||
paddings="small"
|
|
||||||
title={t('pages.settings.panelOutbound')}
|
|
||||||
description={t('pages.settings.panelOutboundDesc')}
|
|
||||||
>
|
|
||||||
<Select
|
|
||||||
style={{ width: '100%' }}
|
|
||||||
allowClear
|
|
||||||
showSearch
|
|
||||||
value={allSetting.panelOutbound || undefined}
|
|
||||||
placeholder={t('pages.settings.panelOutboundPh')}
|
|
||||||
options={outboundOptions}
|
|
||||||
onChange={(v) =>
|
|
||||||
updateSetting({ panelOutbound: (v as string | undefined) || '' })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</SettingListItem>
|
|
||||||
|
|
||||||
<SettingListItem
|
<SettingListItem
|
||||||
paddings="small"
|
paddings="small"
|
||||||
title={t('pages.settings.pageSize')}
|
title={t('pages.settings.pageSize')}
|
||||||
@@ -264,6 +189,50 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
|
|||||||
/>
|
/>
|
||||||
</SettingListItem>
|
</SettingListItem>
|
||||||
|
|
||||||
|
<SettingListItem
|
||||||
|
paddings="small"
|
||||||
|
title={t('pages.settings.trafficStatsInboundUplink')}
|
||||||
|
description={t('pages.settings.trafficStatsDesc')}
|
||||||
|
>
|
||||||
|
<Switch
|
||||||
|
checked={statsSwitches.inboundUplink}
|
||||||
|
onChange={setStatsSwitch('inboundUplink')}
|
||||||
|
/>
|
||||||
|
</SettingListItem>
|
||||||
|
|
||||||
|
<SettingListItem
|
||||||
|
paddings="small"
|
||||||
|
title={t('pages.settings.trafficStatsInboundDownlink')}
|
||||||
|
description={t('pages.settings.trafficStatsDesc')}
|
||||||
|
>
|
||||||
|
<Switch
|
||||||
|
checked={statsSwitches.inboundDownlink}
|
||||||
|
onChange={setStatsSwitch('inboundDownlink')}
|
||||||
|
/>
|
||||||
|
</SettingListItem>
|
||||||
|
|
||||||
|
<SettingListItem
|
||||||
|
paddings="small"
|
||||||
|
title={t('pages.settings.trafficStatsOutboundUplink')}
|
||||||
|
description={t('pages.settings.trafficStatsDesc')}
|
||||||
|
>
|
||||||
|
<Switch
|
||||||
|
checked={statsSwitches.outboundUplink}
|
||||||
|
onChange={setStatsSwitch('outboundUplink')}
|
||||||
|
/>
|
||||||
|
</SettingListItem>
|
||||||
|
|
||||||
|
<SettingListItem
|
||||||
|
paddings="small"
|
||||||
|
title={t('pages.settings.trafficStatsOutboundDownlink')}
|
||||||
|
description={t('pages.settings.trafficStatsDesc')}
|
||||||
|
>
|
||||||
|
<Switch
|
||||||
|
checked={statsSwitches.outboundDownlink}
|
||||||
|
onChange={setStatsSwitch('outboundDownlink')}
|
||||||
|
/>
|
||||||
|
</SettingListItem>
|
||||||
|
|
||||||
<SettingListItem paddings="small" title={t('pages.settings.language')}>
|
<SettingListItem paddings="small" title={t('pages.settings.language')}>
|
||||||
<Select
|
<Select
|
||||||
value={lang}
|
value={lang}
|
||||||
@@ -318,21 +287,6 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
|
|||||||
onChange={(e) => updateSetting({ timeLocation: e.target.value })}
|
onChange={(e) => updateSetting({ timeLocation: e.target.value })}
|
||||||
/>
|
/>
|
||||||
</SettingListItem>
|
</SettingListItem>
|
||||||
<SettingListItem
|
|
||||||
paddings="small"
|
|
||||||
title={t('pages.settings.datepicker')}
|
|
||||||
description={t('pages.settings.datepickerDescription')}
|
|
||||||
>
|
|
||||||
<Select
|
|
||||||
value={allSetting.datepicker || 'gregorian'}
|
|
||||||
onChange={(v) => updateSetting({ datepicker: v as 'gregorian' | 'jalalian' })}
|
|
||||||
style={{ width: '100%' }}
|
|
||||||
options={[
|
|
||||||
{ value: 'gregorian', label: t('pages.settings.calendarGregorian') },
|
|
||||||
{ value: 'jalalian', label: t('pages.settings.calendarJalalian') },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</SettingListItem>
|
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,264 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Button, Col, Input, InputNumber, Row, Select, Space, Switch, Typography, message } from 'antd';
|
||||||
|
import { HttpUtil } from '@/utils';
|
||||||
|
import { useNodesQuery } from '@/api/queries/useNodesQuery';
|
||||||
|
import ProbeMetaSection from '../nodes/ProbeMetaSection';
|
||||||
|
import ProbeTasksPanel from './ProbeTasksPanel';
|
||||||
|
|
||||||
|
// V2.0.1 probe batch: the dedicated probe settings page. Everything
|
||||||
|
// probe-related that used to be scattered — the public-page block
|
||||||
|
// (public-page tab), the latency task manager (nodes-page modal)
|
||||||
|
// and the per-node probe metadata (node edit dialog) — is gathered
|
||||||
|
// here and lives nowhere else. Like the surfaces it replaces, each
|
||||||
|
// block loads and saves through its own endpoints and takes effect
|
||||||
|
// immediately (no panel save, no restart).
|
||||||
|
interface ProbeSettingsWire {
|
||||||
|
publicPage: boolean;
|
||||||
|
siteName: string;
|
||||||
|
site: string;
|
||||||
|
githubProxy: string;
|
||||||
|
retentionDays: number;
|
||||||
|
fastIntervalSec: number;
|
||||||
|
favicon: string;
|
||||||
|
touchIcon: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const JSON_HEADERS = { headers: { 'Content-Type': 'application/json' } };
|
||||||
|
const MAX_ICON_BYTES = 20 * 1024;
|
||||||
|
|
||||||
|
function readAsDataURL(blob: Blob): Promise<string> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => resolve(String(reader.result));
|
||||||
|
reader.onerror = () => reject(reader.error);
|
||||||
|
reader.readAsDataURL(blob);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// fileToIconDataURL fits a chosen image into the hub's 20 KiB site
|
||||||
|
// icon budget (moved unchanged from the public-page tab): small
|
||||||
|
// files pass through; a large raster is scaled down until it fits;
|
||||||
|
// a large SVG cannot be scaled and is refused here.
|
||||||
|
async function fileToIconDataURL(file: File): Promise<string> {
|
||||||
|
if (file.size <= MAX_ICON_BYTES) return readAsDataURL(file);
|
||||||
|
if (file.type === 'image/svg+xml' || file.type === 'image/x-icon' || file.type === 'image/gif') {
|
||||||
|
throw new Error('tooLarge');
|
||||||
|
}
|
||||||
|
const bitmap = await createImageBitmap(file);
|
||||||
|
try {
|
||||||
|
for (const size of [256, 180, 128, 96, 64]) {
|
||||||
|
const scale = Math.min(1, size / Math.max(bitmap.width, bitmap.height));
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = Math.max(1, Math.round(bitmap.width * scale));
|
||||||
|
canvas.height = Math.max(1, Math.round(bitmap.height * scale));
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) break;
|
||||||
|
ctx.drawImage(bitmap, 0, 0, canvas.width, canvas.height);
|
||||||
|
const blob = await new Promise<Blob | null>((resolve) => canvas.toBlob(resolve, 'image/png'));
|
||||||
|
if (blob && blob.size <= MAX_ICON_BYTES) return readAsDataURL(blob);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
bitmap.close();
|
||||||
|
}
|
||||||
|
throw new Error('tooLarge');
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ProbeSettingsTab() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [messageApi, messageContextHolder] = message.useMessage();
|
||||||
|
const [settings, setSettings] = useState<ProbeSettingsWire | null>(null);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [metaNodeId, setMetaNodeId] = useState<number | undefined>(undefined);
|
||||||
|
const faviconRef = useRef<HTMLInputElement>(null);
|
||||||
|
const touchRef = useRef<HTMLInputElement>(null);
|
||||||
|
const { nodes } = useNodesQuery();
|
||||||
|
const agentNodes = nodes.filter((n) => n.nodeType === 'agent');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
HttpUtil.get<ProbeSettingsWire>('/panel/api/probe/settings', { silent: true }).then((msg) => {
|
||||||
|
if (!cancelled && msg?.success && msg.obj) setSettings(msg.obj);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function saveSettings() {
|
||||||
|
if (!settings) return;
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const msg = await HttpUtil.put('/panel/api/probe/settings', settings, {
|
||||||
|
silent: true,
|
||||||
|
...JSON_HEADERS,
|
||||||
|
});
|
||||||
|
if (msg?.success) messageApi.success(t('pages.settings.probeThemes.saved'));
|
||||||
|
else messageApi.error(msg?.msg || t('pages.settings.probeThemes.saveFailed'));
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pickIcon(which: 'favicon' | 'touchIcon', file: File | undefined) {
|
||||||
|
if (!file || !settings) return;
|
||||||
|
try {
|
||||||
|
const url = await fileToIconDataURL(file);
|
||||||
|
setSettings({ ...settings, [which]: url });
|
||||||
|
} catch {
|
||||||
|
messageApi.error(t('pages.settings.probeThemes.iconTooLarge'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const set = <K extends keyof ProbeSettingsWire>(key: K, value: ProbeSettingsWire[K]) =>
|
||||||
|
setSettings((s) => (s ? { ...s, [key]: value } : s));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{messageContextHolder}
|
||||||
|
<Typography.Title level={5}>{t('pages.settings.probeThemes.settingsTitle')}</Typography.Title>
|
||||||
|
{settings ? (
|
||||||
|
<>
|
||||||
|
<Row gutter={[16, 12]}>
|
||||||
|
<Col xs={24} md={12}>
|
||||||
|
<div style={{ marginBottom: 4 }}>{t('pages.settings.probeThemes.publicPage')}</div>
|
||||||
|
<Switch
|
||||||
|
checked={settings.publicPage}
|
||||||
|
onChange={(v) => set('publicPage', v)}
|
||||||
|
/>
|
||||||
|
<Typography.Text type="secondary" style={{ marginLeft: 8 }}>
|
||||||
|
{t('pages.settings.probeThemes.publicPageHint')}
|
||||||
|
</Typography.Text>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} md={12}>
|
||||||
|
<div style={{ marginBottom: 4 }}>{t('pages.settings.probeThemes.siteName')}</div>
|
||||||
|
<Input
|
||||||
|
value={settings.siteName}
|
||||||
|
onChange={(e) => set('siteName', e.target.value)}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} md={12}>
|
||||||
|
<div style={{ marginBottom: 4 }}>{t('pages.settings.probeThemes.site')}</div>
|
||||||
|
<Input
|
||||||
|
value={settings.site}
|
||||||
|
placeholder="https://"
|
||||||
|
onChange={(e) => set('site', e.target.value)}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} md={12}>
|
||||||
|
<div style={{ marginBottom: 4 }}>{t('pages.settings.probeThemes.githubProxy')}</div>
|
||||||
|
<Input
|
||||||
|
value={settings.githubProxy}
|
||||||
|
placeholder="https://"
|
||||||
|
onChange={(e) => set('githubProxy', e.target.value)}
|
||||||
|
/>
|
||||||
|
<Typography.Text type="secondary">
|
||||||
|
{t('pages.settings.probeThemes.githubProxyHint')}
|
||||||
|
</Typography.Text>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} md={12}>
|
||||||
|
<div style={{ marginBottom: 4 }}>{t('pages.settings.probe.retentionDays')}</div>
|
||||||
|
<InputNumber
|
||||||
|
min={1}
|
||||||
|
max={365}
|
||||||
|
value={settings.retentionDays}
|
||||||
|
onChange={(v) => set('retentionDays', v ?? 7)}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
/>
|
||||||
|
<Typography.Text type="secondary">
|
||||||
|
{t('pages.settings.probe.retentionHint')}
|
||||||
|
</Typography.Text>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} md={12}>
|
||||||
|
<div style={{ marginBottom: 4 }}>{t('pages.settings.probe.fastInterval')}</div>
|
||||||
|
<InputNumber
|
||||||
|
min={1}
|
||||||
|
max={60}
|
||||||
|
value={settings.fastIntervalSec}
|
||||||
|
onChange={(v) => set('fastIntervalSec', v ?? 3)}
|
||||||
|
addonAfter="s"
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
/>
|
||||||
|
<Typography.Text type="secondary">
|
||||||
|
{t('pages.settings.probe.fastIntervalHint')}
|
||||||
|
</Typography.Text>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} md={12}>
|
||||||
|
<div style={{ marginBottom: 4 }}>{t('pages.settings.probeThemes.favicon')}</div>
|
||||||
|
<Space>
|
||||||
|
{settings.favicon ? (
|
||||||
|
<img src={settings.favicon} alt="" style={{ width: 24, height: 24 }} />
|
||||||
|
) : null}
|
||||||
|
<Button size="small" onClick={() => faviconRef.current?.click()}>
|
||||||
|
{t('pages.settings.probeThemes.chooseImage')}
|
||||||
|
</Button>
|
||||||
|
{settings.favicon ? (
|
||||||
|
<Button size="small" onClick={() => set('favicon', '')}>
|
||||||
|
{t('clear')}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</Space>
|
||||||
|
<input
|
||||||
|
ref={faviconRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
hidden
|
||||||
|
onChange={(e) => pickIcon('favicon', e.target.files?.[0])}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} md={12}>
|
||||||
|
<div style={{ marginBottom: 4 }}>{t('pages.settings.probeThemes.touchIcon')}</div>
|
||||||
|
<Space>
|
||||||
|
{settings.touchIcon ? (
|
||||||
|
<img src={settings.touchIcon} alt="" style={{ width: 24, height: 24 }} />
|
||||||
|
) : null}
|
||||||
|
<Button size="small" onClick={() => touchRef.current?.click()}>
|
||||||
|
{t('pages.settings.probeThemes.chooseImage')}
|
||||||
|
</Button>
|
||||||
|
{settings.touchIcon ? (
|
||||||
|
<Button size="small" onClick={() => set('touchIcon', '')}>
|
||||||
|
{t('clear')}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</Space>
|
||||||
|
<input
|
||||||
|
ref={touchRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
hidden
|
||||||
|
onChange={(e) => pickIcon('touchIcon', e.target.files?.[0])}
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<Typography.Text type="secondary">
|
||||||
|
{t('pages.settings.probeThemes.iconHint')}
|
||||||
|
</Typography.Text>
|
||||||
|
</div>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
<div style={{ marginTop: 12 }}>
|
||||||
|
<Button type="primary" loading={saving} onClick={saveSettings}>
|
||||||
|
{t('save')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Typography.Title level={5} style={{ marginTop: 28 }}>
|
||||||
|
{t('pages.nodes.probeTasks.title')}
|
||||||
|
</Typography.Title>
|
||||||
|
<ProbeTasksPanel nodes={nodes} />
|
||||||
|
|
||||||
|
<Typography.Title level={5} style={{ marginTop: 28 }}>
|
||||||
|
{t('pages.settings.probe.metaTitle')}
|
||||||
|
</Typography.Title>
|
||||||
|
<Select
|
||||||
|
placeholder={t('pages.settings.probe.metaNodePlaceholder')}
|
||||||
|
value={metaNodeId}
|
||||||
|
onChange={setMetaNodeId}
|
||||||
|
options={agentNodes.map((n) => ({ value: n.id, label: n.name }))}
|
||||||
|
style={{ minWidth: 260, marginBottom: 16 }}
|
||||||
|
/>
|
||||||
|
{metaNodeId ? <ProbeMetaSection key={metaNodeId} nodeId={metaNodeId} /> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,431 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Input,
|
||||||
|
InputNumber,
|
||||||
|
Popconfirm,
|
||||||
|
Select,
|
||||||
|
Space,
|
||||||
|
Switch,
|
||||||
|
Table,
|
||||||
|
Tag,
|
||||||
|
Typography,
|
||||||
|
message,
|
||||||
|
} from 'antd';
|
||||||
|
import { DeleteOutlined, EditOutlined, PlusOutlined } from '@ant-design/icons';
|
||||||
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
|
import { HttpUtil } from '@/utils';
|
||||||
|
import type { NodeRecord } from '@/api/queries/useNodesQuery';
|
||||||
|
|
||||||
|
// Probe (latency) task management — the panel form of what used to
|
||||||
|
// be the nodes-page modal (moved to the probe settings page in
|
||||||
|
// V2.0.1; the target set lives in exactly one place now). The
|
||||||
|
// operator maintains the target set here — grouped by the free-form
|
||||||
|
// region label, no preset list (user ruling Q4). The surface is
|
||||||
|
// self-contained: it loads and saves on its own through the
|
||||||
|
// dedicated endpoints, effective immediately (the master pushes the
|
||||||
|
// set on each agent's next config poll; nothing restarts).
|
||||||
|
interface ProbeTaskWire {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
target: string;
|
||||||
|
interval: number;
|
||||||
|
autoJoin: boolean;
|
||||||
|
sort: number;
|
||||||
|
region: string;
|
||||||
|
nodeIds: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TaskDraft {
|
||||||
|
id: number; // 0 = new task
|
||||||
|
name: string;
|
||||||
|
target: string;
|
||||||
|
interval: number;
|
||||||
|
autoJoin: boolean;
|
||||||
|
region: string;
|
||||||
|
nodeIds: number[];
|
||||||
|
baseNodeIds: number[] | null; // editor-open snapshot (existing tasks)
|
||||||
|
}
|
||||||
|
|
||||||
|
const TASKS_URL = '/panel/api/agent/probe-tasks';
|
||||||
|
const JSON_HEADERS = { headers: { 'Content-Type': 'application/json' } };
|
||||||
|
|
||||||
|
function parseTargets(text: string): string[] {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const out: string[] = [];
|
||||||
|
for (const raw of text.split(/[\n,,]+/)) {
|
||||||
|
const v = raw.trim();
|
||||||
|
if (v && !seen.has(v)) {
|
||||||
|
seen.add(v);
|
||||||
|
out.push(v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ProbeTasksPanel({ nodes }: { nodes: NodeRecord[] }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [messageApi, messageContextHolder] = message.useMessage();
|
||||||
|
const [tasks, setTasks] = useState<ProbeTaskWire[] | null>(null);
|
||||||
|
const [draft, setDraft] = useState<TaskDraft | null>(null);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
// Batch-add state (V2.0.1 ⑤): many targets pasted at once sharing
|
||||||
|
// every other field.
|
||||||
|
const [batchText, setBatchText] = useState('');
|
||||||
|
const [batchName, setBatchName] = useState('');
|
||||||
|
const [batchInterval, setBatchInterval] = useState(60);
|
||||||
|
const [batchAutoJoin, setBatchAutoJoin] = useState(false);
|
||||||
|
const [batchRegion, setBatchRegion] = useState('');
|
||||||
|
const [batchNodeIds, setBatchNodeIds] = useState<number[]>([]);
|
||||||
|
const [batchSaving, setBatchSaving] = useState(false);
|
||||||
|
|
||||||
|
const agentNodes = useMemo(() => nodes.filter((n) => n.nodeType === 'agent'), [nodes]);
|
||||||
|
const nodeName = useCallback(
|
||||||
|
(id: number) => nodes.find((n) => n.id === id)?.name ?? `#${id}`,
|
||||||
|
[nodes],
|
||||||
|
);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
const msg = await HttpUtil.get<ProbeTaskWire[]>(TASKS_URL, { silent: true });
|
||||||
|
if (msg?.success && msg.obj) {
|
||||||
|
setTasks(msg.obj);
|
||||||
|
} else {
|
||||||
|
messageApi.error(msg?.msg || t('pages.nodes.probeTasks.loadFailed'));
|
||||||
|
}
|
||||||
|
}, [messageApi, t]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
// Group by region: region labels ascending, the ungrouped bucket
|
||||||
|
// last; inside a group the panel order (sort) rules.
|
||||||
|
const groups = useMemo(() => {
|
||||||
|
const byRegion = new Map<string, ProbeTaskWire[]>();
|
||||||
|
for (const task of tasks ?? []) {
|
||||||
|
const key = task.region ?? '';
|
||||||
|
const list = byRegion.get(key) ?? [];
|
||||||
|
list.push(task);
|
||||||
|
byRegion.set(key, list);
|
||||||
|
}
|
||||||
|
return [...byRegion.entries()]
|
||||||
|
.sort(([a], [b]) => {
|
||||||
|
if (a === b) return 0;
|
||||||
|
if (a === '') return 1;
|
||||||
|
if (b === '') return -1;
|
||||||
|
return a < b ? -1 : 1;
|
||||||
|
})
|
||||||
|
.map(([region, list]) => ({ region, list }));
|
||||||
|
}, [tasks]);
|
||||||
|
|
||||||
|
const startCreate = () =>
|
||||||
|
setDraft({
|
||||||
|
id: 0,
|
||||||
|
name: '',
|
||||||
|
target: '',
|
||||||
|
interval: 60,
|
||||||
|
autoJoin: false,
|
||||||
|
region: '',
|
||||||
|
nodeIds: [],
|
||||||
|
baseNodeIds: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const startEdit = (task: ProbeTaskWire) =>
|
||||||
|
setDraft({
|
||||||
|
id: task.id,
|
||||||
|
name: task.name,
|
||||||
|
target: task.target,
|
||||||
|
interval: task.interval,
|
||||||
|
autoJoin: task.autoJoin,
|
||||||
|
region: task.region ?? '',
|
||||||
|
nodeIds: [...task.nodeIds],
|
||||||
|
baseNodeIds: [...task.nodeIds],
|
||||||
|
});
|
||||||
|
|
||||||
|
async function onSave() {
|
||||||
|
if (!draft) return;
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const payload: Record<string, unknown> = {
|
||||||
|
id: draft.id,
|
||||||
|
name: draft.name,
|
||||||
|
target: draft.target,
|
||||||
|
interval: draft.interval,
|
||||||
|
autoJoin: draft.autoJoin,
|
||||||
|
region: draft.region,
|
||||||
|
nodeIds: draft.nodeIds,
|
||||||
|
};
|
||||||
|
if (draft.baseNodeIds) payload.baseNodeIds = draft.baseNodeIds;
|
||||||
|
const msg = await HttpUtil.put<{ id: number }>(TASKS_URL, payload, {
|
||||||
|
silent: true,
|
||||||
|
...JSON_HEADERS,
|
||||||
|
});
|
||||||
|
if (msg?.success) {
|
||||||
|
messageApi.success(t('pages.nodes.probeTasks.saved'));
|
||||||
|
setDraft(null);
|
||||||
|
await load();
|
||||||
|
} else {
|
||||||
|
messageApi.error(msg?.msg || t('pages.nodes.probeTasks.saveFailed'));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onBatchAdd() {
|
||||||
|
const targets = parseTargets(batchText);
|
||||||
|
if (targets.length === 0) {
|
||||||
|
messageApi.error(t('pages.nodes.probeTasks.batchEmpty'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBatchSaving(true);
|
||||||
|
try {
|
||||||
|
const msg = await HttpUtil.post<{ ids: number[] }>(
|
||||||
|
`${TASKS_URL}/batch`,
|
||||||
|
{
|
||||||
|
targets,
|
||||||
|
name: batchName,
|
||||||
|
interval: batchInterval,
|
||||||
|
autoJoin: batchAutoJoin,
|
||||||
|
region: batchRegion,
|
||||||
|
nodeIds: batchNodeIds,
|
||||||
|
},
|
||||||
|
{ silent: true, ...JSON_HEADERS },
|
||||||
|
);
|
||||||
|
if (msg?.success) {
|
||||||
|
messageApi.success(
|
||||||
|
t('pages.nodes.probeTasks.batchDone', { count: msg.obj?.ids.length ?? targets.length }),
|
||||||
|
);
|
||||||
|
setBatchText('');
|
||||||
|
await load();
|
||||||
|
} else {
|
||||||
|
messageApi.error(msg?.msg || t('pages.nodes.probeTasks.saveFailed'));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setBatchSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onDelete(task: ProbeTaskWire) {
|
||||||
|
const msg = await HttpUtil.delete(`${TASKS_URL}/${task.id}`, { silent: true });
|
||||||
|
if (msg?.success) {
|
||||||
|
messageApi.success(t('pages.nodes.probeTasks.deleted'));
|
||||||
|
if (draft?.id === task.id) setDraft(null);
|
||||||
|
await load();
|
||||||
|
} else {
|
||||||
|
messageApi.error(msg?.msg || t('pages.nodes.probeTasks.saveFailed'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns: ColumnsType<ProbeTaskWire> = [
|
||||||
|
{ title: t('pages.nodes.probeTasks.name'), dataIndex: 'name', key: 'name' },
|
||||||
|
{
|
||||||
|
title: t('pages.nodes.probeTasks.target'),
|
||||||
|
dataIndex: 'target',
|
||||||
|
key: 'target',
|
||||||
|
render: (v: string) => <Typography.Text code>{v}</Typography.Text>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('pages.nodes.probeTasks.interval'),
|
||||||
|
dataIndex: 'interval',
|
||||||
|
key: 'interval',
|
||||||
|
width: 90,
|
||||||
|
render: (v: number) => `${v}s`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('pages.nodes.probeTasks.autoJoin'),
|
||||||
|
dataIndex: 'autoJoin',
|
||||||
|
key: 'autoJoin',
|
||||||
|
width: 90,
|
||||||
|
render: (v: boolean) =>
|
||||||
|
v ? <Tag color="blue">{t('pages.nodes.probeTasks.autoJoinOn')}</Tag> : null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('pages.nodes.probeTasks.nodes'),
|
||||||
|
dataIndex: 'nodeIds',
|
||||||
|
key: 'nodeIds',
|
||||||
|
render: (ids: number[]) => <span title={ids.map(nodeName).join(', ')}>{ids.length}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('pages.nodes.probeTasks.actions'),
|
||||||
|
key: 'actions',
|
||||||
|
width: 110,
|
||||||
|
render: (_, task) => (
|
||||||
|
<Space size="small">
|
||||||
|
<Button size="small" icon={<EditOutlined />} onClick={() => startEdit(task)}>
|
||||||
|
{t('pages.nodes.probeTasks.edit')}
|
||||||
|
</Button>
|
||||||
|
<Popconfirm
|
||||||
|
title={t('pages.nodes.probeTasks.deleteConfirm')}
|
||||||
|
onConfirm={() => void onDelete(task)}
|
||||||
|
okText={t('pages.nodes.probeTasks.delete')}
|
||||||
|
cancelText={t('pages.nodes.probeTasks.cancel')}
|
||||||
|
>
|
||||||
|
<Button size="small" danger icon={<DeleteOutlined />} />
|
||||||
|
</Popconfirm>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const setDraftField = <K extends keyof TaskDraft>(key: K, value: TaskDraft[K]) =>
|
||||||
|
setDraft((d) => (d ? { ...d, [key]: value } : d));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{messageContextHolder}
|
||||||
|
<Typography.Paragraph type="secondary" style={{ marginTop: 0 }}>
|
||||||
|
{t('pages.nodes.probeTasks.intro')}
|
||||||
|
</Typography.Paragraph>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
border: '1px solid var(--ant-color-border-secondary, #f0f0f0)',
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: 12,
|
||||||
|
marginBottom: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography.Text strong>{t('pages.nodes.probeTasks.batchTitle')}</Typography.Text>
|
||||||
|
<Input.TextArea
|
||||||
|
rows={3}
|
||||||
|
value={batchText}
|
||||||
|
placeholder={t('pages.nodes.probeTasks.batchPlaceholder')}
|
||||||
|
onChange={(e) => setBatchText(e.target.value)}
|
||||||
|
style={{ marginTop: 8, fontFamily: 'monospace' }}
|
||||||
|
/>
|
||||||
|
<Space wrap style={{ marginTop: 8 }}>
|
||||||
|
<Input
|
||||||
|
placeholder={t('pages.nodes.probeTasks.name')}
|
||||||
|
value={batchName}
|
||||||
|
onChange={(e) => setBatchName(e.target.value)}
|
||||||
|
style={{ width: 180 }}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
placeholder={t('pages.nodes.probeTasks.region')}
|
||||||
|
value={batchRegion}
|
||||||
|
onChange={(e) => setBatchRegion(e.target.value)}
|
||||||
|
style={{ width: 130 }}
|
||||||
|
/>
|
||||||
|
<InputNumber
|
||||||
|
min={5}
|
||||||
|
max={3600}
|
||||||
|
value={batchInterval}
|
||||||
|
onChange={(v) => setBatchInterval(v ?? 60)}
|
||||||
|
addonAfter="s"
|
||||||
|
style={{ width: 120 }}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
{t('pages.nodes.probeTasks.autoJoin')}{' '}
|
||||||
|
<Switch checked={batchAutoJoin} onChange={setBatchAutoJoin} />
|
||||||
|
</span>
|
||||||
|
<Select
|
||||||
|
mode="multiple"
|
||||||
|
placeholder={t('pages.nodes.probeTasks.nodesPlaceholder')}
|
||||||
|
value={batchNodeIds}
|
||||||
|
onChange={setBatchNodeIds}
|
||||||
|
options={agentNodes.map((n) => ({ value: n.id, label: n.name }))}
|
||||||
|
style={{ minWidth: 260 }}
|
||||||
|
/>
|
||||||
|
<Button type="primary" loading={batchSaving} onClick={() => void onBatchAdd()}>
|
||||||
|
{t('pages.nodes.probeTasks.batchSubmit')}
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
<div>
|
||||||
|
<Typography.Text type="secondary">
|
||||||
|
{t('pages.nodes.probeTasks.batchHint')}
|
||||||
|
</Typography.Text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{tasks === null ? (
|
||||||
|
<Typography.Text type="secondary">{t('pages.nodes.probeTasks.loading')}</Typography.Text>
|
||||||
|
) : tasks.length === 0 && !draft ? (
|
||||||
|
<Typography.Text type="secondary">{t('pages.nodes.probeTasks.empty')}</Typography.Text>
|
||||||
|
) : (
|
||||||
|
groups.map(({ region, list }) => (
|
||||||
|
<div key={region || '__ungrouped'} style={{ marginBottom: 16 }}>
|
||||||
|
<Typography.Text strong>
|
||||||
|
{region || t('pages.nodes.probeTasks.ungrouped')}
|
||||||
|
</Typography.Text>
|
||||||
|
<Table<ProbeTaskWire>
|
||||||
|
size="small"
|
||||||
|
rowKey="id"
|
||||||
|
columns={columns}
|
||||||
|
dataSource={list}
|
||||||
|
pagination={false}
|
||||||
|
style={{ marginTop: 6 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
|
||||||
|
{draft ? (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
borderTop: '1px solid var(--ant-color-border-secondary, #f0f0f0)',
|
||||||
|
paddingTop: 12,
|
||||||
|
marginTop: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Space direction="vertical" style={{ width: '100%' }} size="middle">
|
||||||
|
<Space wrap>
|
||||||
|
<Input
|
||||||
|
placeholder={t('pages.nodes.probeTasks.name')}
|
||||||
|
value={draft.name}
|
||||||
|
onChange={(e) => setDraftField('name', e.target.value)}
|
||||||
|
style={{ width: 180 }}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
placeholder={t('pages.nodes.probeTasks.region')}
|
||||||
|
value={draft.region}
|
||||||
|
onChange={(e) => setDraftField('region', e.target.value)}
|
||||||
|
style={{ width: 130 }}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
placeholder={t('pages.nodes.probeTasks.targetPlaceholder')}
|
||||||
|
value={draft.target}
|
||||||
|
onChange={(e) => setDraftField('target', e.target.value)}
|
||||||
|
style={{ width: 230, fontFamily: 'monospace' }}
|
||||||
|
/>
|
||||||
|
<InputNumber
|
||||||
|
min={5}
|
||||||
|
max={3600}
|
||||||
|
value={draft.interval}
|
||||||
|
onChange={(v) => setDraftField('interval', v ?? 60)}
|
||||||
|
addonAfter="s"
|
||||||
|
style={{ width: 120 }}
|
||||||
|
/>
|
||||||
|
</Space>
|
||||||
|
<Space wrap>
|
||||||
|
<span>
|
||||||
|
{t('pages.nodes.probeTasks.autoJoin')}{' '}
|
||||||
|
<Switch checked={draft.autoJoin} onChange={(v) => setDraftField('autoJoin', v)} />
|
||||||
|
</span>
|
||||||
|
<Select
|
||||||
|
mode="multiple"
|
||||||
|
placeholder={t('pages.nodes.probeTasks.nodesPlaceholder')}
|
||||||
|
value={draft.nodeIds}
|
||||||
|
onChange={(v) => setDraftField('nodeIds', v)}
|
||||||
|
options={agentNodes.map((n) => ({ value: n.id, label: n.name }))}
|
||||||
|
style={{ minWidth: 260 }}
|
||||||
|
/>
|
||||||
|
</Space>
|
||||||
|
<Space>
|
||||||
|
<Button type="primary" loading={saving} onClick={() => void onSave()}>
|
||||||
|
{t('pages.nodes.probeTasks.save')}
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => setDraft(null)}>{t('pages.nodes.probeTasks.cancel')}</Button>
|
||||||
|
</Space>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Button icon={<PlusOutlined />} onClick={startCreate} style={{ marginTop: 8 }}>
|
||||||
|
{t('pages.nodes.probeTasks.add')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,670 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Col,
|
||||||
|
Input,
|
||||||
|
InputNumber,
|
||||||
|
Modal,
|
||||||
|
Popconfirm,
|
||||||
|
Row,
|
||||||
|
Select,
|
||||||
|
Space,
|
||||||
|
Switch,
|
||||||
|
Tag,
|
||||||
|
Typography,
|
||||||
|
message,
|
||||||
|
} from 'antd';
|
||||||
|
import { DeleteOutlined, SettingOutlined, UploadOutlined } from '@ant-design/icons';
|
||||||
|
import { HttpUtil } from '@/utils';
|
||||||
|
import { onNumber } from '@/utils/onNumber';
|
||||||
|
|
||||||
|
// Item 7 B5: the public status page's theme store + notify block.
|
||||||
|
// (The public-page settings themselves moved to the probe settings
|
||||||
|
// page in V2.0.1.) Like the probe-meta section this surface is
|
||||||
|
// self-contained — it loads and saves through its own endpoints and
|
||||||
|
// every change takes effect immediately (no panel save, no
|
||||||
|
// restart). Theme packages are static files served by the panel;
|
||||||
|
// installing one never runs its code here.
|
||||||
|
interface ThemeConfigField {
|
||||||
|
key: string;
|
||||||
|
type: string;
|
||||||
|
label: string;
|
||||||
|
default?: unknown;
|
||||||
|
help?: string;
|
||||||
|
options?: { value: string; label: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ThemeWire {
|
||||||
|
name: string;
|
||||||
|
short: string;
|
||||||
|
description: string;
|
||||||
|
version: string;
|
||||||
|
author: string;
|
||||||
|
url: string;
|
||||||
|
config?: ThemeConfigField[];
|
||||||
|
selected: boolean;
|
||||||
|
builtin: boolean;
|
||||||
|
preview: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ThemesPayload {
|
||||||
|
themes: ThemeWire[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notify settings wire shape (B6) — mirrors probenotify.SettingsView.
|
||||||
|
// The webhook URL and headers are secrets: the GET answers only
|
||||||
|
// whether they are set, and the PUT omits them unless the admin
|
||||||
|
// edited the field (an explicit clear sends "").
|
||||||
|
interface NotifySettingsWire {
|
||||||
|
notifyGrace: number;
|
||||||
|
notifyTraffic: number;
|
||||||
|
notifyExpiry: number;
|
||||||
|
notifyLogin: boolean;
|
||||||
|
notifyTelegramText: string;
|
||||||
|
notifyWebhookBody: string;
|
||||||
|
telegramConfigured: boolean;
|
||||||
|
webhookConfigured: boolean;
|
||||||
|
notifyWebhookUrlSet: boolean;
|
||||||
|
notifyWebhookHeadersSet: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NotifyPayload {
|
||||||
|
settings: NotifySettingsWire;
|
||||||
|
}
|
||||||
|
|
||||||
|
const JSON_HEADERS = { headers: { 'Content-Type': 'application/json' } };
|
||||||
|
const UPLOAD_CHUNK = 1 << 20;
|
||||||
|
|
||||||
|
function basePathPrefix(): string {
|
||||||
|
const bp = window.X_UI_BASE_PATH;
|
||||||
|
return typeof bp === 'string' && bp !== '/' ? bp.replace(/\/$/, '') : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PublicPageTab() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [messageApi, messageContextHolder] = message.useMessage();
|
||||||
|
const [themes, setThemes] = useState<ThemeWire[]>([]);
|
||||||
|
const [installURL, setInstallURL] = useState('');
|
||||||
|
const [installing, setInstalling] = useState(false);
|
||||||
|
const [uploadPct, setUploadPct] = useState<number | null>(null);
|
||||||
|
const [busyShort, setBusyShort] = useState('');
|
||||||
|
const [notify, setNotify] = useState<NotifySettingsWire | null>(null);
|
||||||
|
const [webhookUrl, setWebhookUrl] = useState('');
|
||||||
|
const [webhookHeaders, setWebhookHeaders] = useState('');
|
||||||
|
const [urlTouched, setUrlTouched] = useState(false);
|
||||||
|
const [headersTouched, setHeadersTouched] = useState(false);
|
||||||
|
const [notifySaving, setNotifySaving] = useState(false);
|
||||||
|
const [notifyTesting, setNotifyTesting] = useState(false);
|
||||||
|
const fileRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const applyPayload = useCallback((payload: ThemesPayload) => {
|
||||||
|
setThemes(payload.themes ?? []);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const reload = useCallback(async () => {
|
||||||
|
const msg = await HttpUtil.get<ThemesPayload>('/panel/api/probe/themes', { silent: true });
|
||||||
|
if (msg?.success && msg.obj) applyPayload(msg.obj);
|
||||||
|
}, [applyPayload]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
HttpUtil.get<ThemesPayload>('/panel/api/probe/themes', { silent: true }).then((msg) => {
|
||||||
|
if (!cancelled && msg?.success && msg.obj) applyPayload(msg.obj);
|
||||||
|
});
|
||||||
|
HttpUtil.get<NotifyPayload>('/panel/api/probe/notify', { silent: true }).then((msg) => {
|
||||||
|
if (!cancelled && msg?.success && msg.obj) {
|
||||||
|
setNotify(msg.obj.settings);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [applyPayload]);
|
||||||
|
|
||||||
|
async function saveNotify() {
|
||||||
|
if (!notify) return;
|
||||||
|
setNotifySaving(true);
|
||||||
|
try {
|
||||||
|
const body: Record<string, unknown> = {
|
||||||
|
notifyGrace: notify.notifyGrace,
|
||||||
|
notifyTraffic: notify.notifyTraffic,
|
||||||
|
notifyExpiry: notify.notifyExpiry,
|
||||||
|
notifyLogin: notify.notifyLogin,
|
||||||
|
notifyTelegramText: notify.notifyTelegramText,
|
||||||
|
notifyWebhookBody: notify.notifyWebhookBody,
|
||||||
|
};
|
||||||
|
// Secrets ride along only when the field was edited; an
|
||||||
|
// edited-but-empty field clears the stored value server-side.
|
||||||
|
if (urlTouched) body.notifyWebhookUrl = webhookUrl;
|
||||||
|
if (headersTouched) body.notifyWebhookHeaders = webhookHeaders;
|
||||||
|
const msg = await HttpUtil.put<NotifyPayload>('/panel/api/probe/notify', body, {
|
||||||
|
silent: true,
|
||||||
|
...JSON_HEADERS,
|
||||||
|
});
|
||||||
|
if (msg?.success && msg.obj) {
|
||||||
|
setNotify(msg.obj.settings);
|
||||||
|
setWebhookUrl('');
|
||||||
|
setWebhookHeaders('');
|
||||||
|
setUrlTouched(false);
|
||||||
|
setHeadersTouched(false);
|
||||||
|
messageApi.success(t('pages.settings.probeThemes.saved'));
|
||||||
|
} else {
|
||||||
|
messageApi.error(msg?.msg || t('pages.settings.probeThemes.saveFailed'));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setNotifySaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function testNotify() {
|
||||||
|
setNotifyTesting(true);
|
||||||
|
try {
|
||||||
|
const msg = await HttpUtil.post<{ sent: string[] }>('/panel/api/probe/notify/test', undefined, {
|
||||||
|
silent: true,
|
||||||
|
});
|
||||||
|
if (msg?.success) {
|
||||||
|
messageApi.success(
|
||||||
|
t('pages.settings.probeNotify.testSent', { channels: (msg.obj?.sent ?? []).join('、') }),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
messageApi.error(msg?.msg || t('pages.settings.probeThemes.saveFailed'));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setNotifyTesting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadTheme(file: File | undefined) {
|
||||||
|
if (!file) return;
|
||||||
|
if (file.size === 0) {
|
||||||
|
messageApi.error(t('pages.settings.probeThemes.uploadFailed'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setUploadPct(0);
|
||||||
|
try {
|
||||||
|
let offset = 0;
|
||||||
|
while (offset < file.size) {
|
||||||
|
const slice = file.slice(offset, offset + UPLOAD_CHUNK);
|
||||||
|
const msg = await HttpUtil.post<{ received?: number; theme?: ThemeWire }>(
|
||||||
|
'/panel/api/probe/theme-upload',
|
||||||
|
slice,
|
||||||
|
{
|
||||||
|
silent: true,
|
||||||
|
params: { offset, total: file.size },
|
||||||
|
headers: { 'Content-Type': 'application/octet-stream' },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!msg?.success) {
|
||||||
|
messageApi.error(msg?.msg || t('pages.settings.probeThemes.uploadFailed'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (msg.obj?.theme) {
|
||||||
|
messageApi.success(
|
||||||
|
t('pages.settings.probeThemes.installed', { name: msg.obj.theme.name }),
|
||||||
|
);
|
||||||
|
await reload();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
offset = msg.obj?.received ?? offset + slice.size;
|
||||||
|
setUploadPct(Math.round((offset / file.size) * 100));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setUploadPct(null);
|
||||||
|
if (fileRef.current) fileRef.current.value = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function installByURL() {
|
||||||
|
setInstalling(true);
|
||||||
|
try {
|
||||||
|
const msg = await HttpUtil.post<{ theme: ThemeWire }>(
|
||||||
|
'/panel/api/probe/theme-install',
|
||||||
|
{ url: installURL },
|
||||||
|
{ silent: true, ...JSON_HEADERS },
|
||||||
|
);
|
||||||
|
if (msg?.success && msg.obj?.theme) {
|
||||||
|
messageApi.success(t('pages.settings.probeThemes.installed', { name: msg.obj.theme.name }));
|
||||||
|
setInstallURL('');
|
||||||
|
await reload();
|
||||||
|
} else {
|
||||||
|
messageApi.error(msg?.msg || t('pages.settings.probeThemes.installFailed'));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setInstalling(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function themeAction(short: string, action: 'select' | 'update' | 'delete') {
|
||||||
|
setBusyShort(short);
|
||||||
|
try {
|
||||||
|
if (action === 'delete') {
|
||||||
|
const msg = await HttpUtil.delete(`/panel/api/probe/themes/${short}`, { silent: true });
|
||||||
|
if (!msg?.success) {
|
||||||
|
messageApi.error(msg?.msg || t('pages.settings.probeThemes.saveFailed'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const msg = await HttpUtil.post<{ updated?: boolean; version?: string }>(
|
||||||
|
`/panel/api/probe/themes/${short}/${action}`,
|
||||||
|
undefined,
|
||||||
|
{ silent: true },
|
||||||
|
);
|
||||||
|
if (!msg?.success) {
|
||||||
|
messageApi.error(msg?.msg || t('pages.settings.probeThemes.saveFailed'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (action === 'update') {
|
||||||
|
if (msg.obj?.updated) {
|
||||||
|
messageApi.success(
|
||||||
|
t('pages.settings.probeThemes.updatedTo', { version: msg.obj.version }),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
messageApi.success(t('pages.settings.probeThemes.alreadyLatest'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await reload();
|
||||||
|
} finally {
|
||||||
|
setBusyShort('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{messageContextHolder}
|
||||||
|
<Typography.Title level={5}>{t('pages.settings.probeThemes.title')}</Typography.Title>
|
||||||
|
<Space wrap style={{ marginBottom: 16 }}>
|
||||||
|
<Button icon={<UploadOutlined />} onClick={() => fileRef.current?.click()}>
|
||||||
|
{uploadPct === null
|
||||||
|
? t('pages.settings.probeThemes.uploadInstall')
|
||||||
|
: `${t('pages.settings.probeThemes.uploadInstall')} ${uploadPct}%`}
|
||||||
|
</Button>
|
||||||
|
<input
|
||||||
|
ref={fileRef}
|
||||||
|
type="file"
|
||||||
|
accept=".gz,application/gzip"
|
||||||
|
hidden
|
||||||
|
onChange={(e) => uploadTheme(e.target.files?.[0])}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
style={{ width: 340 }}
|
||||||
|
value={installURL}
|
||||||
|
placeholder={t('pages.settings.probeThemes.installByUrlPlaceholder')}
|
||||||
|
onChange={(e) => setInstallURL(e.target.value)}
|
||||||
|
/>
|
||||||
|
<Button loading={installing} disabled={!installURL.trim()} onClick={installByURL}>
|
||||||
|
{t('pages.settings.probeThemes.installByUrl')}
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
|
||||||
|
<Row gutter={[16, 16]}>
|
||||||
|
{themes.map((theme) => (
|
||||||
|
<Col key={theme.short} xs={24} sm={12} lg={8}>
|
||||||
|
<Card
|
||||||
|
hoverable
|
||||||
|
cover={
|
||||||
|
theme.preview ? (
|
||||||
|
<img
|
||||||
|
alt={theme.name}
|
||||||
|
src={`${basePathPrefix()}/panel/api/probe/themes/${theme.short}/preview`}
|
||||||
|
style={{ aspectRatio: '16/9', objectFit: 'cover' }}
|
||||||
|
/>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
actions={[
|
||||||
|
theme.selected ? (
|
||||||
|
<Tag key="on" color="green">
|
||||||
|
{t('pages.settings.probeThemes.inUse')}
|
||||||
|
</Tag>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
key="use"
|
||||||
|
type="link"
|
||||||
|
loading={busyShort === theme.short}
|
||||||
|
onClick={() => themeAction(theme.short, 'select')}
|
||||||
|
>
|
||||||
|
{t('pages.settings.probeThemes.use')}
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
<Button
|
||||||
|
key="update"
|
||||||
|
type="link"
|
||||||
|
loading={busyShort === theme.short}
|
||||||
|
onClick={() => themeAction(theme.short, 'update')}
|
||||||
|
>
|
||||||
|
{t('update')}
|
||||||
|
</Button>,
|
||||||
|
...(theme.config && theme.config.length > 0
|
||||||
|
? [
|
||||||
|
<ThemeConfigButton
|
||||||
|
key="config"
|
||||||
|
theme={theme}
|
||||||
|
onSaved={() => messageApi.success(t('pages.settings.probeThemes.saved'))}
|
||||||
|
onError={(m) => messageApi.error(m)}
|
||||||
|
/>,
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
...(!theme.builtin
|
||||||
|
? [
|
||||||
|
<Popconfirm
|
||||||
|
key="del"
|
||||||
|
title={t('pages.settings.probeThemes.deleteConfirm')}
|
||||||
|
onConfirm={() => themeAction(theme.short, 'delete')}
|
||||||
|
>
|
||||||
|
<Button type="link" danger icon={<DeleteOutlined />}>
|
||||||
|
{t('delete')}
|
||||||
|
</Button>
|
||||||
|
</Popconfirm>,
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Card.Meta
|
||||||
|
title={
|
||||||
|
<Space>
|
||||||
|
{theme.name}
|
||||||
|
{theme.builtin ? <Tag>{t('pages.settings.probeThemes.builtin')}</Tag> : null}
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
description={
|
||||||
|
<div>
|
||||||
|
<div>{theme.description}</div>
|
||||||
|
<div>
|
||||||
|
v{theme.version} · {theme.author}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
))}
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
<Typography.Title level={5} style={{ marginTop: 28 }}>
|
||||||
|
{t('pages.settings.probeNotify.notifyTitle')}
|
||||||
|
</Typography.Title>
|
||||||
|
<Typography.Paragraph type="secondary">
|
||||||
|
{t('pages.settings.probeNotify.notifyHint')}
|
||||||
|
</Typography.Paragraph>
|
||||||
|
{notify ? (
|
||||||
|
<>
|
||||||
|
<Row gutter={[16, 12]}>
|
||||||
|
<Col xs={24} md={8}>
|
||||||
|
<div style={{ marginBottom: 4 }}>{t('pages.settings.probeNotify.grace')}</div>
|
||||||
|
<InputNumber
|
||||||
|
min={1}
|
||||||
|
max={30}
|
||||||
|
value={notify.notifyGrace}
|
||||||
|
onChange={onNumber((v) => setNotify({ ...notify, notifyGrace: v }))}
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<Typography.Text type="secondary">
|
||||||
|
{t('pages.settings.probeNotify.graceHint')}
|
||||||
|
</Typography.Text>
|
||||||
|
</div>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} md={8}>
|
||||||
|
<div style={{ marginBottom: 4 }}>{t('pages.settings.probeNotify.trafficPct')}</div>
|
||||||
|
<InputNumber
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
value={notify.notifyTraffic}
|
||||||
|
onChange={onNumber((v) => setNotify({ ...notify, notifyTraffic: v }))}
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<Typography.Text type="secondary">
|
||||||
|
{t('pages.settings.probeNotify.trafficHint')}
|
||||||
|
</Typography.Text>
|
||||||
|
</div>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} md={8}>
|
||||||
|
<div style={{ marginBottom: 4 }}>{t('pages.settings.probeNotify.expiryDays')}</div>
|
||||||
|
<InputNumber
|
||||||
|
min={0}
|
||||||
|
max={365}
|
||||||
|
value={notify.notifyExpiry}
|
||||||
|
onChange={onNumber((v) => setNotify({ ...notify, notifyExpiry: v }))}
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<Typography.Text type="secondary">
|
||||||
|
{t('pages.settings.probeNotify.expiryHint')}
|
||||||
|
</Typography.Text>
|
||||||
|
</div>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} md={8}>
|
||||||
|
<div style={{ marginBottom: 4 }}>{t('pages.settings.probeNotify.loginAlert')}</div>
|
||||||
|
<Switch
|
||||||
|
checked={notify.notifyLogin}
|
||||||
|
onChange={(v) => setNotify({ ...notify, notifyLogin: v })}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} md={8}>
|
||||||
|
<div style={{ marginBottom: 4 }}>
|
||||||
|
{t('pages.settings.probeNotify.telegramChannel')}
|
||||||
|
</div>
|
||||||
|
{notify.telegramConfigured ? (
|
||||||
|
<Tag color="green">{t('pages.settings.probeNotify.configured')}</Tag>
|
||||||
|
) : (
|
||||||
|
<Tag>{t('pages.settings.probeNotify.notConfigured')}</Tag>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<Typography.Text type="secondary">
|
||||||
|
{t('pages.settings.probeNotify.telegramHint')}
|
||||||
|
</Typography.Text>
|
||||||
|
</div>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} md={8}>
|
||||||
|
<div style={{ marginBottom: 4 }}>
|
||||||
|
{t('pages.settings.probeNotify.webhookChannel')}
|
||||||
|
</div>
|
||||||
|
{notify.webhookConfigured ? (
|
||||||
|
<Tag color="green">{t('pages.settings.probeNotify.configured')}</Tag>
|
||||||
|
) : (
|
||||||
|
<Tag>{t('pages.settings.probeNotify.notConfigured')}</Tag>
|
||||||
|
)}
|
||||||
|
</Col>
|
||||||
|
<Col xs={24}>
|
||||||
|
<div style={{ marginBottom: 4 }}>{t('pages.settings.probeNotify.telegramText')}</div>
|
||||||
|
<Input.TextArea
|
||||||
|
rows={3}
|
||||||
|
value={notify.notifyTelegramText}
|
||||||
|
placeholder={'{{title}}\n{{message}}'}
|
||||||
|
onChange={(e) => setNotify({ ...notify, notifyTelegramText: e.target.value })}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} md={12}>
|
||||||
|
<div style={{ marginBottom: 4 }}>{t('pages.settings.probeNotify.webhookUrl')}</div>
|
||||||
|
<Input
|
||||||
|
value={webhookUrl}
|
||||||
|
placeholder={
|
||||||
|
notify.notifyWebhookUrlSet ? t('pages.settings.probeNotify.secretSet') : 'https://'
|
||||||
|
}
|
||||||
|
onChange={(e) => {
|
||||||
|
setWebhookUrl(e.target.value);
|
||||||
|
setUrlTouched(true);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{notify.notifyWebhookUrlSet && !urlTouched ? (
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
size="small"
|
||||||
|
onClick={() => {
|
||||||
|
setWebhookUrl('');
|
||||||
|
setUrlTouched(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('pages.settings.probeNotify.clearSecret')}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} md={12}>
|
||||||
|
<div style={{ marginBottom: 4 }}>
|
||||||
|
{t('pages.settings.probeNotify.webhookHeaders')}
|
||||||
|
</div>
|
||||||
|
<Input.TextArea
|
||||||
|
rows={2}
|
||||||
|
value={webhookHeaders}
|
||||||
|
placeholder={
|
||||||
|
notify.notifyWebhookHeadersSet
|
||||||
|
? t('pages.settings.probeNotify.secretSet')
|
||||||
|
: 'Authorization: Bearer …'
|
||||||
|
}
|
||||||
|
onChange={(e) => {
|
||||||
|
setWebhookHeaders(e.target.value);
|
||||||
|
setHeadersTouched(true);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{notify.notifyWebhookHeadersSet && !headersTouched ? (
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
size="small"
|
||||||
|
onClick={() => {
|
||||||
|
setWebhookHeaders('');
|
||||||
|
setHeadersTouched(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('pages.settings.probeNotify.clearSecret')}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</Col>
|
||||||
|
<Col xs={24}>
|
||||||
|
<div style={{ marginBottom: 4 }}>{t('pages.settings.probeNotify.webhookBody')}</div>
|
||||||
|
<Input.TextArea
|
||||||
|
rows={3}
|
||||||
|
value={notify.notifyWebhookBody}
|
||||||
|
onChange={(e) => setNotify({ ...notify, notifyWebhookBody: e.target.value })}
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<Typography.Text type="secondary">
|
||||||
|
{t('pages.settings.probeNotify.templateHint')}
|
||||||
|
</Typography.Text>
|
||||||
|
</div>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
<Space style={{ marginTop: 12 }}>
|
||||||
|
<Button type="primary" loading={notifySaving} onClick={saveNotify}>
|
||||||
|
{t('save')}
|
||||||
|
</Button>
|
||||||
|
<Button loading={notifyTesting} onClick={testNotify}>
|
||||||
|
{t('pages.settings.probeNotify.testSend')}
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ThemeConfigButton edits one theme's saved config. The form is
|
||||||
|
// drawn from the theme.json config declaration (passed through
|
||||||
|
// unparsed by the server); values load from and save to the contract
|
||||||
|
// endpoint the themes themselves use, and only fields changed from
|
||||||
|
// their declared defaults are stored (the hub's semantics).
|
||||||
|
function ThemeConfigButton({
|
||||||
|
theme,
|
||||||
|
onSaved,
|
||||||
|
onError,
|
||||||
|
}: {
|
||||||
|
theme: ThemeWire;
|
||||||
|
onSaved: () => void;
|
||||||
|
onError: (msg: string) => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [values, setValues] = useState<Record<string, unknown>>({});
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const fields = theme.config ?? [];
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
let saved: Record<string, unknown> = {};
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/themes/${theme.short}/config`, { credentials: 'same-origin' });
|
||||||
|
if (res.ok) saved = (await res.json()) as Record<string, unknown>;
|
||||||
|
} catch {
|
||||||
|
/* an unreadable config edits from the defaults */
|
||||||
|
}
|
||||||
|
const merged: Record<string, unknown> = {};
|
||||||
|
for (const f of fields) merged[f.key] = saved[f.key] ?? f.default;
|
||||||
|
setValues(merged);
|
||||||
|
setOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const changed: Record<string, unknown> = {};
|
||||||
|
for (const f of fields) {
|
||||||
|
if (JSON.stringify(values[f.key]) !== JSON.stringify(f.default)) {
|
||||||
|
changed[f.key] = values[f.key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const res = await fetch(`/api/themes/${theme.short}/config`, {
|
||||||
|
method: 'PUT',
|
||||||
|
credentials: 'same-origin',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(changed),
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
setOpen(false);
|
||||||
|
onSaved();
|
||||||
|
} else {
|
||||||
|
onError((await res.text()) || t('pages.settings.probeThemes.saveFailed'));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button type="link" icon={<SettingOutlined />} onClick={load}>
|
||||||
|
{t('pages.settings.probeThemes.config')}
|
||||||
|
</Button>
|
||||||
|
<Modal
|
||||||
|
open={open}
|
||||||
|
title={`${theme.name} · ${t('pages.settings.probeThemes.config')}`}
|
||||||
|
onCancel={() => setOpen(false)}
|
||||||
|
onOk={save}
|
||||||
|
confirmLoading={saving}
|
||||||
|
>
|
||||||
|
{fields.map((f) => (
|
||||||
|
<div key={f.key} style={{ marginBottom: 12 }}>
|
||||||
|
<div style={{ marginBottom: 4 }}>{f.label}</div>
|
||||||
|
{f.type === 'boolean' ? (
|
||||||
|
<Switch
|
||||||
|
checked={Boolean(values[f.key])}
|
||||||
|
onChange={(v) => setValues({ ...values, [f.key]: v })}
|
||||||
|
/>
|
||||||
|
) : f.type === 'number' ? (
|
||||||
|
<InputNumber
|
||||||
|
value={typeof values[f.key] === 'number' ? (values[f.key] as number) : undefined}
|
||||||
|
onChange={(v) => setValues({ ...values, [f.key]: v })}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
/>
|
||||||
|
) : f.type === 'select' && f.options ? (
|
||||||
|
<Select
|
||||||
|
value={values[f.key] as string}
|
||||||
|
options={f.options}
|
||||||
|
onChange={(v) => setValues({ ...values, [f.key]: v })}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Input
|
||||||
|
value={typeof values[f.key] === 'string' ? (values[f.key] as string) : ''}
|
||||||
|
onChange={(e) => setValues({ ...values, [f.key]: e.target.value })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{f.help ? (
|
||||||
|
<div>
|
||||||
|
<Typography.Text type="secondary">{f.help}</Typography.Text>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -54,25 +54,21 @@ export default function SecurityTab() {
|
|||||||
setUser((prev) => ({ ...prev, [key]: value }));
|
setUser((prev) => ({ ...prev, [key]: value }));
|
||||||
}
|
}
|
||||||
|
|
||||||
const sendUpdateUser = useCallback(
|
const sendUpdateUser = useCallback(async () => {
|
||||||
async (twoFactorCode = '') => {
|
setUpdating(true);
|
||||||
setUpdating(true);
|
try {
|
||||||
try {
|
const msg = (await HttpUtil.post('/panel/api/setting/updateUser', {
|
||||||
const msg = (await HttpUtil.post('/panel/api/setting/updateUser', {
|
...user,
|
||||||
...user,
|
})) as ApiMsg;
|
||||||
twoFactorCode,
|
if (msg?.success) {
|
||||||
})) as ApiMsg;
|
await HttpUtil.post('/logout');
|
||||||
if (msg?.success) {
|
const basePath = window.X_UI_BASE_PATH || '/';
|
||||||
await HttpUtil.post('/logout');
|
window.location.replace(basePath);
|
||||||
const basePath = window.X_UI_BASE_PATH || '/';
|
|
||||||
window.location.replace(basePath);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
setUpdating(false);
|
|
||||||
}
|
}
|
||||||
},
|
} finally {
|
||||||
[user],
|
setUpdating(false);
|
||||||
);
|
}
|
||||||
|
}, [user]);
|
||||||
|
|
||||||
function onUpdateUserClick() {
|
function onUpdateUserClick() {
|
||||||
sendUpdateUser();
|
sendUpdateUser();
|
||||||
|
|||||||
@@ -2,8 +2,3 @@
|
|||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ldap-no-inbounds {
|
|
||||||
margin-top: 6px;
|
|
||||||
color: var(--ant-color-text-tertiary);
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -26,23 +26,16 @@ import AppSidebar from '@/layouts/AppSidebar';
|
|||||||
import GeneralTab from './GeneralTab';
|
import GeneralTab from './GeneralTab';
|
||||||
import SecurityTab from './SecurityTab';
|
import SecurityTab from './SecurityTab';
|
||||||
import SubscriptionGeneralTab from './SubscriptionGeneralTab';
|
import SubscriptionGeneralTab from './SubscriptionGeneralTab';
|
||||||
import SubscriptionFormatsTab from './SubscriptionFormatsTab';
|
|
||||||
import SubscriptionBalancersTab from './SubscriptionBalancersTab';
|
|
||||||
import TelegramTab from './TelegramTab';
|
import TelegramTab from './TelegramTab';
|
||||||
|
import PublicPageTab from './PublicPageTab';
|
||||||
|
import ProbeSettingsTab from './ProbeSettingsTab';
|
||||||
import './SettingsPage.css';
|
import './SettingsPage.css';
|
||||||
|
|
||||||
interface ApiMsg {
|
interface ApiMsg {
|
||||||
success?: boolean;
|
success?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const tabSlugs = [
|
const tabSlugs = ['general', 'security', 'telegram', 'subscription', 'public-page', 'probe'];
|
||||||
'general',
|
|
||||||
'security',
|
|
||||||
'telegram',
|
|
||||||
'subscription',
|
|
||||||
'subscription-formats',
|
|
||||||
'subscription-balancers',
|
|
||||||
];
|
|
||||||
|
|
||||||
function isIp(h: string): boolean {
|
function isIp(h: string): boolean {
|
||||||
if (typeof h !== 'string') return false;
|
if (typeof h !== 'string') return false;
|
||||||
@@ -170,19 +163,6 @@ export default function SettingsPage() {
|
|||||||
out.push(t('pages.settings.warnDefaultSubPath'));
|
out.push(t('pages.settings.warnDefaultSubPath'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (allSetting.subJsonEnable) {
|
|
||||||
let p = allSetting.subJsonPath;
|
|
||||||
if (allSetting.subJsonURI) {
|
|
||||||
try {
|
|
||||||
p = new URL(allSetting.subJsonURI).pathname;
|
|
||||||
} catch {
|
|
||||||
/* noop */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (p === '/json/') {
|
|
||||||
out.push(t('pages.settings.warnDefaultJsonPath'));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out;
|
return out;
|
||||||
}, [allSetting, t]);
|
}, [allSetting, t]);
|
||||||
|
|
||||||
@@ -201,10 +181,10 @@ export default function SettingsPage() {
|
|||||||
return <TelegramTab allSetting={allSetting} updateSetting={updateSetting} />;
|
return <TelegramTab allSetting={allSetting} updateSetting={updateSetting} />;
|
||||||
case 'subscription':
|
case 'subscription':
|
||||||
return <SubscriptionGeneralTab allSetting={allSetting} updateSetting={updateSetting} />;
|
return <SubscriptionGeneralTab allSetting={allSetting} updateSetting={updateSetting} />;
|
||||||
case 'subscription-formats':
|
case 'public-page':
|
||||||
return <SubscriptionFormatsTab allSetting={allSetting} updateSetting={updateSetting} />;
|
return <PublicPageTab />;
|
||||||
case 'subscription-balancers':
|
case 'probe':
|
||||||
return <SubscriptionBalancersTab allSetting={allSetting} updateSetting={updateSetting} />;
|
return <ProbeSettingsTab />;
|
||||||
default:
|
default:
|
||||||
return <GeneralTab allSetting={allSetting} updateSetting={updateSetting} />;
|
return <GeneralTab allSetting={allSetting} updateSetting={updateSetting} />;
|
||||||
}
|
}
|
||||||
@@ -270,7 +250,6 @@ export default function SettingsPage() {
|
|||||||
</Col>
|
</Col>
|
||||||
<Col xs={24} sm={14} className="header-info">
|
<Col xs={24} sm={14} className="header-info">
|
||||||
<FloatButton.BackTop target={scrollTarget} visibilityHeight={200} />
|
<FloatButton.BackTop target={scrollTarget} visibilityHeight={200} />
|
||||||
<Alert type="warning" showIcon title={t('pages.settings.infoDesc')} />
|
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -1,172 +0,0 @@
|
|||||||
import { useEffect, useMemo } from 'react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { Form, Input, InputNumber, Modal, Select, Switch, message } from 'antd';
|
|
||||||
import { FormProvider, useForm, useWatch } from 'react-hook-form';
|
|
||||||
|
|
||||||
import { FormField, rhfZodValidate } from '@/components/form/rhf';
|
|
||||||
import SelectAllClearButtons from '@/components/form/SelectAllClearButtons';
|
|
||||||
import { useInboundOptions } from '@/api/queries/useInboundOptions';
|
|
||||||
import { formatInboundLabel } from '@/lib/inbounds/label';
|
|
||||||
import {
|
|
||||||
SubBalancerFormSchema,
|
|
||||||
SubBalancerStrategySchema,
|
|
||||||
type SubBalancer,
|
|
||||||
type SubBalancerFormValues,
|
|
||||||
type SubBalancerStrategy,
|
|
||||||
} from '@/schemas/subBalancer';
|
|
||||||
|
|
||||||
// The JSON subscription only builds proxy outbounds for these protocols;
|
|
||||||
// mtproto has no proxy-outbound case, so it is excluded from balancer members.
|
|
||||||
const MULTI_CLIENT_PROTOCOLS = new Set([
|
|
||||||
'shadowsocks',
|
|
||||||
'vless',
|
|
||||||
'vmess',
|
|
||||||
'trojan',
|
|
||||||
'hysteria',
|
|
||||||
'wireguard',
|
|
||||||
]);
|
|
||||||
|
|
||||||
const STRATEGY_LABEL_KEYS: Record<SubBalancerStrategy, string> = {
|
|
||||||
leastLoad: 'pages.settings.subBalancers.strategyLeastLoad',
|
|
||||||
leastPing: 'pages.settings.subBalancers.strategyLeastPing',
|
|
||||||
random: 'pages.settings.subBalancers.strategyRandom',
|
|
||||||
roundRobin: 'pages.settings.subBalancers.strategyRoundRobin',
|
|
||||||
};
|
|
||||||
|
|
||||||
function initialState(balancer: SubBalancer | null): SubBalancerFormValues {
|
|
||||||
return {
|
|
||||||
remark: balancer?.remark ?? '',
|
|
||||||
strategy: balancer?.strategy ?? 'random',
|
|
||||||
inboundIds: [...(balancer?.inboundIds ?? [])],
|
|
||||||
sortOrder: balancer?.sortOrder ?? 1,
|
|
||||||
enabled: balancer?.enabled ?? true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SubBalancerFormModalProps {
|
|
||||||
open: boolean;
|
|
||||||
balancer: SubBalancer | null;
|
|
||||||
onClose: () => void;
|
|
||||||
onConfirm: (values: SubBalancerFormValues) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function SubBalancerFormModal({
|
|
||||||
open,
|
|
||||||
balancer,
|
|
||||||
onClose,
|
|
||||||
onConfirm,
|
|
||||||
}: SubBalancerFormModalProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [messageApi, messageContextHolder] = message.useMessage();
|
|
||||||
const methods = useForm<SubBalancerFormValues>({ defaultValues: initialState(balancer) });
|
|
||||||
const isEdit = balancer != null;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (open) methods.reset(initialState(balancer));
|
|
||||||
}, [open, balancer, methods]);
|
|
||||||
|
|
||||||
const inboundIds = useWatch({ control: methods.control, name: 'inboundIds' });
|
|
||||||
|
|
||||||
const { data: inboundOptionsRaw } = useInboundOptions();
|
|
||||||
const inboundOptions = useMemo(
|
|
||||||
() =>
|
|
||||||
(inboundOptionsRaw ?? [])
|
|
||||||
.filter((ib) => MULTI_CLIENT_PROTOCOLS.has(ib.protocol || ''))
|
|
||||||
.filter((ib) => ib.enable || (inboundIds || []).includes(ib.id))
|
|
||||||
.map((ib) => ({
|
|
||||||
label: formatInboundLabel(ib.tag, ib.remark),
|
|
||||||
value: ib.id,
|
|
||||||
title: formatInboundLabel(ib.tag, ib.remark),
|
|
||||||
})),
|
|
||||||
[inboundOptionsRaw, inboundIds],
|
|
||||||
);
|
|
||||||
|
|
||||||
function onFinish(values: SubBalancerFormValues) {
|
|
||||||
const parsed = SubBalancerFormSchema.safeParse(values);
|
|
||||||
if (!parsed.success) {
|
|
||||||
messageApi.error(
|
|
||||||
t(parsed.error.issues[0]?.message ?? 'pages.settings.subBalancers.errRemarkRequired'),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
onConfirm(parsed.data);
|
|
||||||
}
|
|
||||||
|
|
||||||
const strategies = SubBalancerStrategySchema.options.map((value) => ({
|
|
||||||
value,
|
|
||||||
label: t(STRATEGY_LABEL_KEYS[value]),
|
|
||||||
}));
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal
|
|
||||||
open={open}
|
|
||||||
title={
|
|
||||||
isEdit
|
|
||||||
? `${t('edit')} ${t('pages.settings.subBalancers.title')}`
|
|
||||||
: `+ ${t('pages.settings.subBalancers.add')}`
|
|
||||||
}
|
|
||||||
okText={isEdit ? t('pages.clients.submitEdit') : t('create')}
|
|
||||||
cancelText={t('close')}
|
|
||||||
mask={{ closable: false }}
|
|
||||||
width="640px"
|
|
||||||
onOk={methods.handleSubmit(onFinish)}
|
|
||||||
onCancel={onClose}
|
|
||||||
>
|
|
||||||
{messageContextHolder}
|
|
||||||
<FormProvider {...methods}>
|
|
||||||
<Form layout="vertical">
|
|
||||||
<FormField
|
|
||||||
label={t('pages.settings.subBalancers.remark')}
|
|
||||||
name="remark"
|
|
||||||
required
|
|
||||||
rules={{ validate: rhfZodValidate(SubBalancerFormSchema.shape.remark) }}
|
|
||||||
>
|
|
||||||
<Input placeholder={t('pages.settings.subBalancers.remarkPlaceholder')} />
|
|
||||||
</FormField>
|
|
||||||
|
|
||||||
<FormField label={t('pages.settings.subBalancers.strategy')} name="strategy" required>
|
|
||||||
<Select options={strategies} />
|
|
||||||
</FormField>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
label={t('pages.settings.subBalancers.sortOrder')}
|
|
||||||
name="sortOrder"
|
|
||||||
required
|
|
||||||
tooltip={t('pages.settings.subBalancers.sortOrderHelp')}
|
|
||||||
rules={{ validate: rhfZodValidate(SubBalancerFormSchema.shape.sortOrder) }}
|
|
||||||
>
|
|
||||||
<InputNumber min={1} precision={0} style={{ width: '100%' }} />
|
|
||||||
</FormField>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
label={t('pages.settings.subBalancers.inbounds')}
|
|
||||||
name="inboundIds"
|
|
||||||
required
|
|
||||||
rules={{ validate: rhfZodValidate(SubBalancerFormSchema.shape.inboundIds) }}
|
|
||||||
>
|
|
||||||
<Select
|
|
||||||
mode="multiple"
|
|
||||||
options={inboundOptions}
|
|
||||||
maxTagCount="responsive"
|
|
||||||
listHeight={220}
|
|
||||||
showSearch={{ optionFilterProp: 'label' }}
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
<SelectAllClearButtons
|
|
||||||
options={inboundOptions}
|
|
||||||
value={inboundIds || []}
|
|
||||||
onChange={(v) => methods.setValue('inboundIds', v, { shouldDirty: true })}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
label={t('pages.settings.subBalancers.enabled')}
|
|
||||||
name="enabled"
|
|
||||||
valueProp="checked"
|
|
||||||
>
|
|
||||||
<Switch />
|
|
||||||
</FormField>
|
|
||||||
</Form>
|
|
||||||
</FormProvider>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
|
||||||
import { Form } from 'antd';
|
|
||||||
|
|
||||||
import { FinalMaskForm } from '@/lib/xray/forms/transport';
|
|
||||||
import type { FinalMaskStreamSettings } from '@/schemas/protocols/stream/finalmask';
|
|
||||||
|
|
||||||
interface SubJsonFinalMaskFormProps {
|
|
||||||
value: string;
|
|
||||||
onChange: (next: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function hasValue(v: unknown): boolean {
|
|
||||||
if (v == null) return false;
|
|
||||||
if (Array.isArray(v)) return v.some(hasValue);
|
|
||||||
if (typeof v === 'object') return Object.values(v as Record<string, unknown>).some(hasValue);
|
|
||||||
if (typeof v === 'string') return v.length > 0;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseFinalMask(raw: string): FinalMaskStreamSettings {
|
|
||||||
try {
|
|
||||||
if (raw) return JSON.parse(raw) as FinalMaskStreamSettings;
|
|
||||||
} catch {
|
|
||||||
return { tcp: [], udp: [] };
|
|
||||||
}
|
|
||||||
return { tcp: [], udp: [] };
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function SubJsonFinalMaskForm({ value, onChange }: SubJsonFinalMaskFormProps) {
|
|
||||||
const [form] = Form.useForm();
|
|
||||||
const [initial] = useState(() => parseFinalMask(value));
|
|
||||||
const onChangeRef = useRef(onChange);
|
|
||||||
useEffect(() => {
|
|
||||||
onChangeRef.current = onChange;
|
|
||||||
});
|
|
||||||
|
|
||||||
const finalmask = Form.useWatch('finalmask', form) as FinalMaskStreamSettings | undefined;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (finalmask === undefined) return;
|
|
||||||
const next = hasValue(finalmask) ? JSON.stringify(finalmask) : '';
|
|
||||||
if (next !== value) onChangeRef.current(next);
|
|
||||||
}, [finalmask, value]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Form
|
|
||||||
form={form}
|
|
||||||
layout="horizontal"
|
|
||||||
labelCol={{ flex: '160px' }}
|
|
||||||
wrapperCol={{ flex: 'auto' }}
|
|
||||||
colon={false}
|
|
||||||
initialValues={{ finalmask: initial }}
|
|
||||||
>
|
|
||||||
<FinalMaskForm name="finalmask" network="" protocol="" form={form} showAll />
|
|
||||||
</Form>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,359 +0,0 @@
|
|||||||
import { useMemo, useState } from 'react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import {
|
|
||||||
Alert,
|
|
||||||
Button,
|
|
||||||
Input,
|
|
||||||
InputNumber,
|
|
||||||
Popconfirm,
|
|
||||||
Select,
|
|
||||||
Space,
|
|
||||||
Switch,
|
|
||||||
Table,
|
|
||||||
Tabs,
|
|
||||||
Tag,
|
|
||||||
Tooltip,
|
|
||||||
} from 'antd';
|
|
||||||
import {
|
|
||||||
DeleteOutlined,
|
|
||||||
DeploymentUnitOutlined,
|
|
||||||
EditOutlined,
|
|
||||||
PlusOutlined,
|
|
||||||
RadarChartOutlined,
|
|
||||||
} from '@ant-design/icons';
|
|
||||||
|
|
||||||
import { useSubBalancersQuery } from '@/api/queries/useSubBalancersQuery';
|
|
||||||
import { useSubBalancerMutations } from '@/api/queries/useSubBalancerMutations';
|
|
||||||
import { useInboundOptions } from '@/api/queries/useInboundOptions';
|
|
||||||
import { formatInboundLabel } from '@/lib/inbounds/label';
|
|
||||||
import type { AllSetting } from '@/models/setting';
|
|
||||||
import { onNumber } from '@/utils/onNumber';
|
|
||||||
import { SettingListItem } from '@/components/ui';
|
|
||||||
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
|
||||||
import type { SubBalancer, SubBalancerFormValues } from '@/schemas/subBalancer';
|
|
||||||
import { PingConfigSchema, type PingConfigObject } from '@/schemas/observatory';
|
|
||||||
import { DEFAULT_BURST_OBSERVATORY } from '@/pages/xray/balancers/balancer-helpers';
|
|
||||||
import SubBalancerFormModal from './SubBalancerFormModal';
|
|
||||||
import { catTabLabel } from './catTabLabel';
|
|
||||||
import './SubscriptionFormatsTab.css';
|
|
||||||
|
|
||||||
const STRATEGY_COLORS: Record<string, string> = {
|
|
||||||
leastLoad: 'geekblue',
|
|
||||||
leastPing: 'green',
|
|
||||||
random: 'orange',
|
|
||||||
roundRobin: 'purple',
|
|
||||||
};
|
|
||||||
|
|
||||||
// Single source for the burst-observatory ping defaults: the Zod schema and
|
|
||||||
// DEFAULT_BURST_OBSERVATORY are kept in sync, so the tab just parses through it.
|
|
||||||
const DEFAULT_PING_CONFIG = PingConfigSchema.parse({ ...DEFAULT_BURST_OBSERVATORY.pingConfig });
|
|
||||||
|
|
||||||
function parsePingConfig(raw: string): PingConfigObject {
|
|
||||||
try {
|
|
||||||
return PingConfigSchema.parse(raw ? JSON.parse(raw) : {});
|
|
||||||
} catch {
|
|
||||||
return DEFAULT_PING_CONFIG;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SubscriptionBalancersTabProps {
|
|
||||||
allSetting: AllSetting;
|
|
||||||
updateSetting: (patch: Partial<AllSetting>) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function SubscriptionBalancersTab({
|
|
||||||
allSetting,
|
|
||||||
updateSetting,
|
|
||||||
}: SubscriptionBalancersTabProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const { isMobile } = useMediaQuery();
|
|
||||||
const { balancers, loading, fetched, fetchError, refetch } = useSubBalancersQuery();
|
|
||||||
const { create, update, remove } = useSubBalancerMutations();
|
|
||||||
const { data: inboundOptionsRaw } = useInboundOptions();
|
|
||||||
const [modalOpen, setModalOpen] = useState(false);
|
|
||||||
const [editing, setEditing] = useState<SubBalancer | null>(null);
|
|
||||||
|
|
||||||
const inboundLabels = useMemo(() => {
|
|
||||||
const map = new Map<number, string>();
|
|
||||||
for (const ib of inboundOptionsRaw ?? []) {
|
|
||||||
map.set(ib.id, formatInboundLabel(ib.tag, ib.remark));
|
|
||||||
}
|
|
||||||
return map;
|
|
||||||
}, [inboundOptionsRaw]);
|
|
||||||
|
|
||||||
async function onConfirm(values: SubBalancerFormValues) {
|
|
||||||
const msg = editing ? await update(editing.id, values) : await create(values);
|
|
||||||
if (msg?.success) setModalOpen(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function toggleEnabled(balancer: SubBalancer) {
|
|
||||||
await update(balancer.id, {
|
|
||||||
remark: balancer.remark,
|
|
||||||
strategy: balancer.strategy,
|
|
||||||
inboundIds: balancer.inboundIds,
|
|
||||||
sortOrder: balancer.sortOrder,
|
|
||||||
enabled: !balancer.enabled,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const observatoryEnabled = allSetting.subJsonObservatory !== '';
|
|
||||||
const observatoryObj = useMemo(
|
|
||||||
() => parsePingConfig(allSetting.subJsonObservatory),
|
|
||||||
[allSetting.subJsonObservatory],
|
|
||||||
);
|
|
||||||
|
|
||||||
function setObservatoryEnabled(v: boolean) {
|
|
||||||
updateSetting({ subJsonObservatory: v ? JSON.stringify(DEFAULT_PING_CONFIG) : '' });
|
|
||||||
}
|
|
||||||
|
|
||||||
function setObservatoryField<K extends keyof PingConfigObject>(
|
|
||||||
key: K,
|
|
||||||
value: PingConfigObject[K],
|
|
||||||
) {
|
|
||||||
const next = { ...observatoryObj, [key]: value };
|
|
||||||
updateSetting({ subJsonObservatory: JSON.stringify(next) });
|
|
||||||
}
|
|
||||||
|
|
||||||
const columns = [
|
|
||||||
{
|
|
||||||
title: t('pages.settings.subBalancers.sortOrder'),
|
|
||||||
dataIndex: 'sortOrder',
|
|
||||||
key: 'sortOrder',
|
|
||||||
width: 80,
|
|
||||||
align: 'center' as const,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: t('pages.settings.subBalancers.remark'),
|
|
||||||
dataIndex: 'remark',
|
|
||||||
key: 'remark',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: t('pages.settings.subBalancers.strategy'),
|
|
||||||
dataIndex: 'strategy',
|
|
||||||
key: 'strategy',
|
|
||||||
width: 120,
|
|
||||||
render: (strategy: string) => (
|
|
||||||
<Tag color={STRATEGY_COLORS[strategy] ?? 'default'}>{strategy}</Tag>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: t('pages.settings.subBalancers.inbounds'),
|
|
||||||
key: 'inbounds',
|
|
||||||
render: (_: unknown, r: SubBalancer) => {
|
|
||||||
const labels = r.inboundIds.map((id) => inboundLabels.get(id) ?? `#${id}`);
|
|
||||||
return (
|
|
||||||
<Tooltip title={labels.join(', ')}>
|
|
||||||
<span>{t('pages.settings.subBalancers.inboundsCount', { count: labels.length })}</span>
|
|
||||||
</Tooltip>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: t('pages.settings.subBalancers.enabled'),
|
|
||||||
dataIndex: 'enabled',
|
|
||||||
key: 'enabled',
|
|
||||||
width: 80,
|
|
||||||
align: 'center' as const,
|
|
||||||
render: (_: unknown, r: SubBalancer) => (
|
|
||||||
<Switch size="small" checked={r.enabled} onChange={() => toggleEnabled(r)} />
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '',
|
|
||||||
key: 'actions',
|
|
||||||
width: 96,
|
|
||||||
render: (_: unknown, r: SubBalancer) => (
|
|
||||||
<Space>
|
|
||||||
<Button
|
|
||||||
aria-label={t('edit')}
|
|
||||||
size="small"
|
|
||||||
icon={<EditOutlined />}
|
|
||||||
title={t('edit')}
|
|
||||||
onClick={() => {
|
|
||||||
setEditing(r);
|
|
||||||
setModalOpen(true);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Popconfirm
|
|
||||||
title={t('pages.settings.subBalancers.deleteConfirm')}
|
|
||||||
okText={t('delete')}
|
|
||||||
cancelText={t('cancel')}
|
|
||||||
onConfirm={() => remove(r.id)}
|
|
||||||
>
|
|
||||||
<Button aria-label={t('delete')} size="small" danger icon={<DeleteOutlined />} />
|
|
||||||
</Popconfirm>
|
|
||||||
</Space>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const balancersTab = (
|
|
||||||
<div>
|
|
||||||
<Alert
|
|
||||||
type="info"
|
|
||||||
showIcon
|
|
||||||
style={{ marginBottom: 16 }}
|
|
||||||
title={t('pages.settings.subBalancers.desc')}
|
|
||||||
/>
|
|
||||||
{fetchError && (
|
|
||||||
<Alert
|
|
||||||
type="error"
|
|
||||||
showIcon
|
|
||||||
style={{ marginBottom: 16 }}
|
|
||||||
title={fetchError}
|
|
||||||
action={
|
|
||||||
<Button size="small" onClick={() => refetch()}>
|
|
||||||
{t('refresh')}
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<div style={{ marginBottom: 12 }}>
|
|
||||||
<Button
|
|
||||||
type="primary"
|
|
||||||
icon={<PlusOutlined />}
|
|
||||||
onClick={() => {
|
|
||||||
setEditing(null);
|
|
||||||
setModalOpen(true);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t('pages.settings.subBalancers.add')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<Table
|
|
||||||
size="small"
|
|
||||||
dataSource={balancers}
|
|
||||||
rowKey={(r) => r.id}
|
|
||||||
pagination={false}
|
|
||||||
loading={loading && !fetched}
|
|
||||||
scroll={{ x: true }}
|
|
||||||
locale={{ emptyText: t('pages.settings.subBalancers.empty') }}
|
|
||||||
columns={columns}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
const observatoryTab = (
|
|
||||||
<>
|
|
||||||
<Alert
|
|
||||||
type="info"
|
|
||||||
showIcon
|
|
||||||
style={{ marginBottom: 16 }}
|
|
||||||
title={t('pages.settings.subBalancers.observatory.note')}
|
|
||||||
/>
|
|
||||||
<SettingListItem
|
|
||||||
paddings="small"
|
|
||||||
title={t('pages.settings.subBalancers.observatory.title')}
|
|
||||||
description={t('pages.settings.subBalancers.observatory.desc')}
|
|
||||||
>
|
|
||||||
<Switch checked={observatoryEnabled} onChange={setObservatoryEnabled} />
|
|
||||||
</SettingListItem>
|
|
||||||
{observatoryEnabled && (
|
|
||||||
<div className="format-settings">
|
|
||||||
<SettingListItem
|
|
||||||
paddings="small"
|
|
||||||
title={t('pages.settings.subBalancers.observatory.destination')}
|
|
||||||
description={t('pages.settings.subBalancers.observatory.destinationDesc')}
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
value={observatoryObj.destination}
|
|
||||||
placeholder="https://www.google.com/generate_204"
|
|
||||||
onChange={(e) => setObservatoryField('destination', e.target.value)}
|
|
||||||
/>
|
|
||||||
</SettingListItem>
|
|
||||||
<SettingListItem
|
|
||||||
paddings="small"
|
|
||||||
title={t('pages.settings.subBalancers.observatory.connectivity')}
|
|
||||||
description={t('pages.settings.subBalancers.observatory.connectivityDesc')}
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
value={observatoryObj.connectivity}
|
|
||||||
placeholder="http://connectivitycheck.platform.hicloud.com/generate_204"
|
|
||||||
onChange={(e) => setObservatoryField('connectivity', e.target.value)}
|
|
||||||
/>
|
|
||||||
</SettingListItem>
|
|
||||||
<SettingListItem
|
|
||||||
paddings="small"
|
|
||||||
title={t('pages.settings.subBalancers.observatory.interval')}
|
|
||||||
description={t('pages.settings.subBalancers.observatory.intervalDesc')}
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
value={observatoryObj.interval}
|
|
||||||
placeholder="1m"
|
|
||||||
onChange={(e) => setObservatoryField('interval', e.target.value)}
|
|
||||||
/>
|
|
||||||
</SettingListItem>
|
|
||||||
<SettingListItem
|
|
||||||
paddings="small"
|
|
||||||
title={t('pages.settings.subBalancers.observatory.timeout')}
|
|
||||||
description={t('pages.settings.subBalancers.observatory.timeoutDesc')}
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
value={observatoryObj.timeout}
|
|
||||||
placeholder="5s"
|
|
||||||
onChange={(e) => setObservatoryField('timeout', e.target.value)}
|
|
||||||
/>
|
|
||||||
</SettingListItem>
|
|
||||||
<SettingListItem
|
|
||||||
paddings="small"
|
|
||||||
title={t('pages.settings.subBalancers.observatory.sampling')}
|
|
||||||
description={t('pages.settings.subBalancers.observatory.samplingDesc')}
|
|
||||||
>
|
|
||||||
<InputNumber
|
|
||||||
value={observatoryObj.sampling}
|
|
||||||
min={1}
|
|
||||||
style={{ width: '100%' }}
|
|
||||||
onChange={onNumber((v) => setObservatoryField('sampling', v))}
|
|
||||||
/>
|
|
||||||
</SettingListItem>
|
|
||||||
<SettingListItem
|
|
||||||
paddings="small"
|
|
||||||
title={t('pages.settings.subBalancers.observatory.httpMethod')}
|
|
||||||
description={t('pages.settings.subBalancers.observatory.httpMethodDesc')}
|
|
||||||
>
|
|
||||||
<Select
|
|
||||||
value={observatoryObj.httpMethod}
|
|
||||||
style={{ width: '100%' }}
|
|
||||||
onChange={(v) => setObservatoryField('httpMethod', v)}
|
|
||||||
options={['HEAD', 'GET'].map((m) => ({ value: m, label: m }))}
|
|
||||||
/>
|
|
||||||
</SettingListItem>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Tabs
|
|
||||||
defaultActiveKey="balancers"
|
|
||||||
items={[
|
|
||||||
{
|
|
||||||
key: 'balancers',
|
|
||||||
label: catTabLabel(
|
|
||||||
<DeploymentUnitOutlined />,
|
|
||||||
t('pages.settings.subBalancers.tabBalancers'),
|
|
||||||
isMobile,
|
|
||||||
),
|
|
||||||
children: balancersTab,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'observatory',
|
|
||||||
label: catTabLabel(
|
|
||||||
<RadarChartOutlined />,
|
|
||||||
t('pages.settings.subBalancers.tabObservatory'),
|
|
||||||
isMobile,
|
|
||||||
),
|
|
||||||
children: observatoryTab,
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
<SubBalancerFormModal
|
|
||||||
open={modalOpen}
|
|
||||||
balancer={editing}
|
|
||||||
onClose={() => setModalOpen(false)}
|
|
||||||
onConfirm={onConfirm}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
.subscription-format-sections {
|
|
||||||
display: grid;
|
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.subscription-format-card-title {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.subscription-format-card .ant-card-body {
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.format-settings {
|
|
||||||
margin-bottom: 8px;
|
|
||||||
border: 1px solid var(--ant-color-border-secondary);
|
|
||||||
border-radius: 8px;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.format-settings-list {
|
|
||||||
padding-top: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.noise-card {
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
@@ -1,411 +0,0 @@
|
|||||||
import { useMemo } from 'react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { Card, Input, InputNumber, Select, Switch, Tabs } from 'antd';
|
|
||||||
import {
|
|
||||||
FileTextOutlined,
|
|
||||||
NodeIndexOutlined,
|
|
||||||
PartitionOutlined,
|
|
||||||
RocketOutlined,
|
|
||||||
SendOutlined,
|
|
||||||
SettingOutlined,
|
|
||||||
} from '@ant-design/icons';
|
|
||||||
import type { AllSetting } from '@/models/setting';
|
|
||||||
import { onNumber } from '@/utils/onNumber';
|
|
||||||
import { SettingListItem } from '@/components/ui';
|
|
||||||
import { GoRegexInput } from '@/components/form';
|
|
||||||
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
|
||||||
import { catTabLabel } from './catTabLabel';
|
|
||||||
import { sanitizePath, normalizePath } from './uriPath';
|
|
||||||
import SubJsonFinalMaskForm from './SubJsonFinalMaskForm';
|
|
||||||
import './SubscriptionFormatsTab.css';
|
|
||||||
|
|
||||||
interface SubscriptionFormatsTabProps {
|
|
||||||
allSetting: AllSetting;
|
|
||||||
updateSetting: (patch: Partial<AllSetting>) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const DEFAULT_MUX = {
|
|
||||||
enabled: true,
|
|
||||||
concurrency: 8,
|
|
||||||
xudpConcurrency: 16,
|
|
||||||
xudpProxyUDP443: 'reject',
|
|
||||||
};
|
|
||||||
const DEFAULT_RULES: { type: string; outboundTag: string; domain?: string[]; ip?: string[] }[] = [
|
|
||||||
{ type: 'field', outboundTag: 'direct', domain: ['geosite:category-ir'] },
|
|
||||||
{ type: 'field', outboundTag: 'direct', ip: ['geoip:private', 'geoip:ir'] },
|
|
||||||
];
|
|
||||||
|
|
||||||
const directIPsOptions = [
|
|
||||||
{ label: 'Private IP', value: 'geoip:private' },
|
|
||||||
{ label: '🇮🇷 Iran', value: 'geoip:ir' },
|
|
||||||
{ label: '🇨🇳 China', value: 'geoip:cn' },
|
|
||||||
{ label: '🇷🇺 Russia', value: 'geoip:ru' },
|
|
||||||
{ label: '🇻🇳 Vietnam', value: 'geoip:vn' },
|
|
||||||
{ label: '🇪🇸 Spain', value: 'geoip:es' },
|
|
||||||
{ label: '🇮🇩 Indonesia', value: 'geoip:id' },
|
|
||||||
{ label: '🇺🇦 Ukraine', value: 'geoip:ua' },
|
|
||||||
{ label: '🇹🇷 Türkiye', value: 'geoip:tr' },
|
|
||||||
{ label: '🇧🇷 Brazil', value: 'geoip:br' },
|
|
||||||
];
|
|
||||||
const directDomainsOptions = [
|
|
||||||
{ label: 'Private DNS', value: 'geosite:private' },
|
|
||||||
{ label: '🇮🇷 Iran', value: 'geosite:category-ir' },
|
|
||||||
{ label: '🇨🇳 China', value: 'geosite:cn' },
|
|
||||||
{ label: '🇷🇺 Russia', value: 'geosite:category-ru' },
|
|
||||||
{ label: 'Apple', value: 'geosite:apple' },
|
|
||||||
{ label: 'Meta', value: 'geosite:meta' },
|
|
||||||
{ label: 'Google', value: 'geosite:google' },
|
|
||||||
];
|
|
||||||
|
|
||||||
function readJson<T>(raw: string, fallback: T): T {
|
|
||||||
try {
|
|
||||||
if (!raw) return fallback;
|
|
||||||
return JSON.parse(raw) as T;
|
|
||||||
} catch {
|
|
||||||
return fallback;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function SubscriptionFormatsTab({
|
|
||||||
allSetting,
|
|
||||||
updateSetting,
|
|
||||||
}: SubscriptionFormatsTabProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const { isMobile } = useMediaQuery();
|
|
||||||
|
|
||||||
const muxEnabled = allSetting.subJsonMux !== '';
|
|
||||||
const directEnabled = allSetting.subJsonRules !== '';
|
|
||||||
|
|
||||||
const muxObj = useMemo(
|
|
||||||
() =>
|
|
||||||
muxEnabled ? readJson<typeof DEFAULT_MUX>(allSetting.subJsonMux, DEFAULT_MUX) : DEFAULT_MUX,
|
|
||||||
[allSetting.subJsonMux, muxEnabled],
|
|
||||||
);
|
|
||||||
|
|
||||||
function setMuxEnabled(v: boolean) {
|
|
||||||
updateSetting({ subJsonMux: v ? JSON.stringify(DEFAULT_MUX) : '' });
|
|
||||||
}
|
|
||||||
|
|
||||||
function setMuxField<K extends keyof typeof DEFAULT_MUX>(key: K, value: (typeof DEFAULT_MUX)[K]) {
|
|
||||||
const next = { ...muxObj, [key]: value };
|
|
||||||
updateSetting({ subJsonMux: JSON.stringify(next) });
|
|
||||||
}
|
|
||||||
|
|
||||||
const ruleArray = useMemo(() => {
|
|
||||||
if (!directEnabled) return null;
|
|
||||||
return readJson<typeof DEFAULT_RULES | null>(allSetting.subJsonRules, null);
|
|
||||||
}, [allSetting.subJsonRules, directEnabled]);
|
|
||||||
|
|
||||||
const directIPs = useMemo(() => {
|
|
||||||
if (!ruleArray) return [];
|
|
||||||
const ipRule = ruleArray.find((r) => r.ip);
|
|
||||||
return ipRule?.ip ?? [];
|
|
||||||
}, [ruleArray]);
|
|
||||||
|
|
||||||
const directDomains = useMemo(() => {
|
|
||||||
if (!ruleArray) return [];
|
|
||||||
const dRule = ruleArray.find((r) => r.domain);
|
|
||||||
return dRule?.domain ?? [];
|
|
||||||
}, [ruleArray]);
|
|
||||||
|
|
||||||
function setDirectEnabled(v: boolean) {
|
|
||||||
updateSetting({ subJsonRules: v ? JSON.stringify(DEFAULT_RULES) : '' });
|
|
||||||
}
|
|
||||||
|
|
||||||
function setDirectIPs(value: string[]) {
|
|
||||||
if (!ruleArray) return;
|
|
||||||
let rules = [...ruleArray];
|
|
||||||
if (value.length === 0) {
|
|
||||||
rules = rules.filter((r) => !r.ip);
|
|
||||||
} else {
|
|
||||||
let idx = rules.findIndex((r) => r.ip);
|
|
||||||
if (idx === -1) {
|
|
||||||
rules.push({ ...DEFAULT_RULES[1] });
|
|
||||||
idx = rules.length - 1;
|
|
||||||
}
|
|
||||||
rules[idx] = { ...rules[idx], ip: [...value] };
|
|
||||||
}
|
|
||||||
updateSetting({ subJsonRules: JSON.stringify(rules) });
|
|
||||||
}
|
|
||||||
|
|
||||||
function setDirectDomains(value: string[]) {
|
|
||||||
if (!ruleArray) return;
|
|
||||||
let rules = [...ruleArray];
|
|
||||||
if (value.length === 0) {
|
|
||||||
rules = rules.filter((r) => !r.domain);
|
|
||||||
} else {
|
|
||||||
let idx = rules.findIndex((r) => r.domain);
|
|
||||||
if (idx === -1) {
|
|
||||||
rules.push({ ...DEFAULT_RULES[0] });
|
|
||||||
idx = rules.length - 1;
|
|
||||||
}
|
|
||||||
rules[idx] = { ...rules[idx], domain: [...value] };
|
|
||||||
}
|
|
||||||
updateSetting({ subJsonRules: JSON.stringify(rules) });
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Tabs
|
|
||||||
defaultActiveKey="1"
|
|
||||||
items={[
|
|
||||||
{
|
|
||||||
key: '1',
|
|
||||||
label: catTabLabel(<SettingOutlined />, t('pages.settings.panelSettings'), isMobile),
|
|
||||||
children: (
|
|
||||||
<div className="subscription-format-sections">
|
|
||||||
{allSetting.subJsonEnable && (
|
|
||||||
<Card
|
|
||||||
size="small"
|
|
||||||
className="subscription-format-card"
|
|
||||||
title={
|
|
||||||
<span className="subscription-format-card-title">
|
|
||||||
<FileTextOutlined />
|
|
||||||
{t('pages.settings.subJsonEnableTitle')}
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SettingListItem
|
|
||||||
paddings="small"
|
|
||||||
title={<>JSON {t('pages.settings.subPath')}</>}
|
|
||||||
description={t('pages.settings.subPathDesc')}
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
value={allSetting.subJsonPath}
|
|
||||||
placeholder="/json/"
|
|
||||||
onChange={(e) => updateSetting({ subJsonPath: sanitizePath(e.target.value) })}
|
|
||||||
onBlur={() =>
|
|
||||||
updateSetting({ subJsonPath: normalizePath(allSetting.subJsonPath) })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</SettingListItem>
|
|
||||||
<SettingListItem
|
|
||||||
paddings="small"
|
|
||||||
title={<>JSON {t('pages.settings.subURI')}</>}
|
|
||||||
description={t('pages.settings.subURIDesc')}
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
value={allSetting.subJsonURI}
|
|
||||||
placeholder="(http|https)://domain[:port]/path/"
|
|
||||||
onChange={(e) => updateSetting({ subJsonURI: e.target.value })}
|
|
||||||
/>
|
|
||||||
</SettingListItem>
|
|
||||||
<SettingListItem
|
|
||||||
paddings="small"
|
|
||||||
title={t('pages.settings.subJsonAlwaysArray')}
|
|
||||||
description={t('pages.settings.subJsonAlwaysArrayDesc')}
|
|
||||||
>
|
|
||||||
<Switch
|
|
||||||
checked={allSetting.subJsonAlwaysArray}
|
|
||||||
onChange={(value) => updateSetting({ subJsonAlwaysArray: value })}
|
|
||||||
/>
|
|
||||||
</SettingListItem>
|
|
||||||
<SettingListItem
|
|
||||||
paddings="small"
|
|
||||||
title={t('pages.settings.subJsonAutoDetect')}
|
|
||||||
description={t('pages.settings.subJsonAutoDetectDesc')}
|
|
||||||
>
|
|
||||||
<Switch
|
|
||||||
checked={allSetting.subJsonAutoDetect}
|
|
||||||
onChange={(v) => updateSetting({ subJsonAutoDetect: v })}
|
|
||||||
/>
|
|
||||||
</SettingListItem>
|
|
||||||
<SettingListItem
|
|
||||||
paddings="small"
|
|
||||||
title={t('pages.settings.subJsonUserAgentRegex')}
|
|
||||||
description={t('pages.settings.subJsonUserAgentRegexDesc')}
|
|
||||||
>
|
|
||||||
<GoRegexInput
|
|
||||||
value={allSetting.subJsonUserAgentRegex}
|
|
||||||
placeholder="(?i)^myclient([ /]|$)"
|
|
||||||
onChange={(value) => updateSetting({ subJsonUserAgentRegex: value })}
|
|
||||||
/>
|
|
||||||
</SettingListItem>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
{allSetting.subClashEnable && (
|
|
||||||
<Card
|
|
||||||
size="small"
|
|
||||||
className="subscription-format-card"
|
|
||||||
title={
|
|
||||||
<span className="subscription-format-card-title">
|
|
||||||
<NodeIndexOutlined />
|
|
||||||
{t('pages.settings.subClashEnableTitle')}
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SettingListItem
|
|
||||||
paddings="small"
|
|
||||||
title={<>Clash {t('pages.settings.subPath')}</>}
|
|
||||||
description={t('pages.settings.subPathDesc')}
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
value={allSetting.subClashPath}
|
|
||||||
placeholder="/clash/"
|
|
||||||
onChange={(e) =>
|
|
||||||
updateSetting({ subClashPath: sanitizePath(e.target.value) })
|
|
||||||
}
|
|
||||||
onBlur={() =>
|
|
||||||
updateSetting({ subClashPath: normalizePath(allSetting.subClashPath) })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</SettingListItem>
|
|
||||||
<SettingListItem
|
|
||||||
paddings="small"
|
|
||||||
title={<>Clash {t('pages.settings.subURI')}</>}
|
|
||||||
description={t('pages.settings.subURIDesc')}
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
value={allSetting.subClashURI}
|
|
||||||
placeholder="(http|https)://domain[:port]/path/"
|
|
||||||
onChange={(e) => updateSetting({ subClashURI: e.target.value })}
|
|
||||||
/>
|
|
||||||
</SettingListItem>
|
|
||||||
<SettingListItem
|
|
||||||
paddings="small"
|
|
||||||
title={t('pages.settings.subClashAutoDetect')}
|
|
||||||
description={t('pages.settings.subClashAutoDetectDesc')}
|
|
||||||
>
|
|
||||||
<Switch
|
|
||||||
checked={allSetting.subClashAutoDetect}
|
|
||||||
onChange={(v) => updateSetting({ subClashAutoDetect: v })}
|
|
||||||
/>
|
|
||||||
</SettingListItem>
|
|
||||||
<SettingListItem
|
|
||||||
paddings="small"
|
|
||||||
title={t('pages.settings.subClashUserAgentRegex')}
|
|
||||||
description={t('pages.settings.subClashUserAgentRegexDesc')}
|
|
||||||
>
|
|
||||||
<GoRegexInput
|
|
||||||
value={allSetting.subClashUserAgentRegex}
|
|
||||||
placeholder="(?i)(clash|mihomo)"
|
|
||||||
onChange={(value) => updateSetting({ subClashUserAgentRegex: value })}
|
|
||||||
/>
|
|
||||||
</SettingListItem>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: '2',
|
|
||||||
label: catTabLabel(
|
|
||||||
<RocketOutlined />,
|
|
||||||
t('pages.settings.subFormats.finalMask'),
|
|
||||||
isMobile,
|
|
||||||
),
|
|
||||||
children: (
|
|
||||||
<>
|
|
||||||
<SettingListItem
|
|
||||||
paddings="small"
|
|
||||||
title={t('pages.settings.subFormats.finalMask')}
|
|
||||||
description={t('pages.settings.subFormats.finalMaskDesc')}
|
|
||||||
/>
|
|
||||||
<SubJsonFinalMaskForm
|
|
||||||
value={allSetting.subJsonFinalMask}
|
|
||||||
onChange={(v) => updateSetting({ subJsonFinalMask: v })}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: '3',
|
|
||||||
label: catTabLabel(<PartitionOutlined />, t('pages.settings.mux'), isMobile),
|
|
||||||
children: (
|
|
||||||
<>
|
|
||||||
<SettingListItem
|
|
||||||
paddings="small"
|
|
||||||
title={t('pages.settings.mux')}
|
|
||||||
description={t('pages.settings.muxDesc')}
|
|
||||||
>
|
|
||||||
<Switch checked={muxEnabled} onChange={setMuxEnabled} />
|
|
||||||
</SettingListItem>
|
|
||||||
{muxEnabled && (
|
|
||||||
<div className="format-settings">
|
|
||||||
<SettingListItem
|
|
||||||
paddings="small"
|
|
||||||
title={t('pages.settings.subFormats.concurrency')}
|
|
||||||
>
|
|
||||||
<InputNumber
|
|
||||||
value={muxObj.concurrency}
|
|
||||||
min={-1}
|
|
||||||
max={1024}
|
|
||||||
style={{ width: '100%' }}
|
|
||||||
onChange={onNumber((v) => setMuxField('concurrency', v))}
|
|
||||||
/>
|
|
||||||
</SettingListItem>
|
|
||||||
<SettingListItem
|
|
||||||
paddings="small"
|
|
||||||
title={t('pages.settings.subFormats.xudpConcurrency')}
|
|
||||||
>
|
|
||||||
<InputNumber
|
|
||||||
value={muxObj.xudpConcurrency}
|
|
||||||
min={-1}
|
|
||||||
max={1024}
|
|
||||||
style={{ width: '100%' }}
|
|
||||||
onChange={onNumber((v) => setMuxField('xudpConcurrency', v))}
|
|
||||||
/>
|
|
||||||
</SettingListItem>
|
|
||||||
<SettingListItem
|
|
||||||
paddings="small"
|
|
||||||
title={t('pages.settings.subFormats.xudpUdp443')}
|
|
||||||
>
|
|
||||||
<Select
|
|
||||||
value={muxObj.xudpProxyUDP443}
|
|
||||||
style={{ width: '100%' }}
|
|
||||||
onChange={(v) => setMuxField('xudpProxyUDP443', v)}
|
|
||||||
options={['reject', 'allow', 'skip'].map((p) => ({ value: p, label: p }))}
|
|
||||||
/>
|
|
||||||
</SettingListItem>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: '4',
|
|
||||||
label: catTabLabel(<SendOutlined />, t('pages.settings.direct'), isMobile),
|
|
||||||
children: (
|
|
||||||
<>
|
|
||||||
<SettingListItem
|
|
||||||
paddings="small"
|
|
||||||
title={t('pages.settings.direct')}
|
|
||||||
description={t('pages.settings.directDesc')}
|
|
||||||
>
|
|
||||||
<Switch checked={directEnabled} onChange={setDirectEnabled} />
|
|
||||||
</SettingListItem>
|
|
||||||
{directEnabled && (
|
|
||||||
<div className="format-settings">
|
|
||||||
<SettingListItem paddings="small" title={<>{t('pages.settings.direct')} IPs</>}>
|
|
||||||
<Select
|
|
||||||
mode="tags"
|
|
||||||
value={directIPs}
|
|
||||||
style={{ width: '100%' }}
|
|
||||||
onChange={setDirectIPs}
|
|
||||||
options={directIPsOptions}
|
|
||||||
/>
|
|
||||||
</SettingListItem>
|
|
||||||
<SettingListItem
|
|
||||||
paddings="small"
|
|
||||||
title={
|
|
||||||
<>
|
|
||||||
{t('pages.settings.direct')} {t('domainName')}
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Select
|
|
||||||
mode="tags"
|
|
||||||
value={directDomains}
|
|
||||||
style={{ width: '100%' }}
|
|
||||||
onChange={setDirectDomains}
|
|
||||||
options={directDomainsOptions}
|
|
||||||
/>
|
|
||||||
</SettingListItem>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,15 +1,11 @@
|
|||||||
import { Alert, Button, Input, InputNumber, Switch, Tabs, Tag } from 'antd';
|
import { Input, InputNumber, Switch, Tabs } from 'antd';
|
||||||
import {
|
import {
|
||||||
BranchesOutlined,
|
|
||||||
CompassOutlined,
|
|
||||||
IdcardOutlined,
|
IdcardOutlined,
|
||||||
InfoCircleOutlined,
|
InfoCircleOutlined,
|
||||||
NodeIndexOutlined,
|
|
||||||
SafetyCertificateOutlined,
|
SafetyCertificateOutlined,
|
||||||
SettingOutlined,
|
SettingOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useNavigate } from 'react-router';
|
|
||||||
import type { AllSetting } from '@/models/setting';
|
import type { AllSetting } from '@/models/setting';
|
||||||
import { onNumber } from '@/utils/onNumber';
|
import { onNumber } from '@/utils/onNumber';
|
||||||
import { DefaultSettingTag, SettingListItem } from '@/components/ui';
|
import { DefaultSettingTag, SettingListItem } from '@/components/ui';
|
||||||
@@ -23,17 +19,11 @@ interface SubscriptionGeneralTabProps {
|
|||||||
updateSetting: (patch: Partial<AllSetting>) => void;
|
updateSetting: (patch: Partial<AllSetting>) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const isRemoteRoutingSource = (value: string) => /^https:\/\/\S+$/i.test(value.trim());
|
|
||||||
|
|
||||||
const remoteSourceBadge = (value: string) =>
|
|
||||||
isRemoteRoutingSource(value) ? <Tag color="blue">HTTPS URL</Tag> : undefined;
|
|
||||||
|
|
||||||
export default function SubscriptionGeneralTab({
|
export default function SubscriptionGeneralTab({
|
||||||
allSetting,
|
allSetting,
|
||||||
updateSetting,
|
updateSetting,
|
||||||
}: SubscriptionGeneralTabProps) {
|
}: SubscriptionGeneralTabProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const navigate = useNavigate();
|
|
||||||
const { isMobile } = useMediaQuery();
|
const { isMobile } = useMediaQuery();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -55,36 +45,6 @@ export default function SubscriptionGeneralTab({
|
|||||||
onChange={(v) => updateSetting({ subEnable: v })}
|
onChange={(v) => updateSetting({ subEnable: v })}
|
||||||
/>
|
/>
|
||||||
</SettingListItem>
|
</SettingListItem>
|
||||||
<SettingListItem
|
|
||||||
paddings="small"
|
|
||||||
title={t('pages.settings.subJsonEnableTitle')}
|
|
||||||
description={t('pages.settings.subJsonEnable')}
|
|
||||||
>
|
|
||||||
<Switch
|
|
||||||
checked={allSetting.subJsonEnable}
|
|
||||||
onChange={(v) => updateSetting({ subJsonEnable: v })}
|
|
||||||
/>
|
|
||||||
</SettingListItem>
|
|
||||||
<SettingListItem paddings="small" title={t('pages.settings.subClashEnableTitle')}>
|
|
||||||
<Switch
|
|
||||||
checked={allSetting.subClashEnable}
|
|
||||||
onChange={(v) => updateSetting({ subClashEnable: v })}
|
|
||||||
/>
|
|
||||||
</SettingListItem>
|
|
||||||
{(allSetting.subJsonEnable || allSetting.subClashEnable) && (
|
|
||||||
<Alert
|
|
||||||
type="info"
|
|
||||||
showIcon
|
|
||||||
style={{ margin: '12px 20px' }}
|
|
||||||
title={t('pages.settings.subFormatsTipTitle')}
|
|
||||||
description={t('pages.settings.subFormatsTipDesc')}
|
|
||||||
action={
|
|
||||||
<Button size="small" onClick={() => navigate('/settings#subscription-formats')}>
|
|
||||||
{t('pages.settings.subFormatsTipAction')}
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<SettingListItem
|
<SettingListItem
|
||||||
paddings="small"
|
paddings="small"
|
||||||
title={t('pages.settings.subListen')}
|
title={t('pages.settings.subListen')}
|
||||||
@@ -197,6 +157,16 @@ export default function SubscriptionGeneralTab({
|
|||||||
onChange={onNumber((v) => updateSetting({ subUpdates: v }))}
|
onChange={onNumber((v) => updateSetting({ subUpdates: v }))}
|
||||||
/>
|
/>
|
||||||
</SettingListItem>
|
</SettingListItem>
|
||||||
|
<SettingListItem
|
||||||
|
paddings="small"
|
||||||
|
title={t('pages.settings.subHideSettings')}
|
||||||
|
description={t('pages.settings.subHideSettingsDesc')}
|
||||||
|
>
|
||||||
|
<Switch
|
||||||
|
checked={allSetting.subHideSettings}
|
||||||
|
onChange={(v) => updateSetting({ subHideSettings: v })}
|
||||||
|
/>
|
||||||
|
</SettingListItem>
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -253,28 +223,6 @@ export default function SubscriptionGeneralTab({
|
|||||||
metadataOnly
|
metadataOnly
|
||||||
/>
|
/>
|
||||||
</SettingListItem>
|
</SettingListItem>
|
||||||
<SettingListItem
|
|
||||||
paddings="small"
|
|
||||||
title={t('pages.settings.subThemeDir')}
|
|
||||||
description={
|
|
||||||
<>
|
|
||||||
{t('pages.settings.subThemeDirDesc')}{' '}
|
|
||||||
<a
|
|
||||||
href="https://git.881218.xyz/rogue/3x-ui-agent/src/branch/main/docs/custom-subscription-templates.md"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
{t('pages.settings.subThemeDirDocs')}
|
|
||||||
</a>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
value={allSetting.subThemeDir}
|
|
||||||
placeholder="/etc/3x-ui/sub_templates/my-theme/"
|
|
||||||
onChange={(e) => updateSetting({ subThemeDir: e.target.value })}
|
|
||||||
/>
|
|
||||||
</SettingListItem>
|
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -306,109 +254,6 @@ export default function SubscriptionGeneralTab({
|
|||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
key: '5',
|
|
||||||
label: catTabLabel(<BranchesOutlined />, 'Happ', isMobile),
|
|
||||||
children: (
|
|
||||||
<>
|
|
||||||
<SettingListItem
|
|
||||||
paddings="small"
|
|
||||||
title={t('pages.settings.subEnableRouting')}
|
|
||||||
description={t('pages.settings.subEnableRoutingDesc')}
|
|
||||||
>
|
|
||||||
<Switch
|
|
||||||
checked={allSetting.subEnableRouting}
|
|
||||||
onChange={(v) => updateSetting({ subEnableRouting: v })}
|
|
||||||
/>
|
|
||||||
</SettingListItem>
|
|
||||||
<SettingListItem
|
|
||||||
paddings="small"
|
|
||||||
title={t('pages.settings.subRoutingRules')}
|
|
||||||
badge={remoteSourceBadge(allSetting.subRoutingRules)}
|
|
||||||
description={t('pages.settings.subRoutingRulesDesc')}
|
|
||||||
>
|
|
||||||
<Input.TextArea
|
|
||||||
value={allSetting.subRoutingRules}
|
|
||||||
placeholder="happ://routing/onadd/... or https://.../DEFAULT.DEEPLINK"
|
|
||||||
onChange={(e) => updateSetting({ subRoutingRules: e.target.value })}
|
|
||||||
/>
|
|
||||||
</SettingListItem>
|
|
||||||
<SettingListItem
|
|
||||||
paddings="small"
|
|
||||||
title={t('pages.settings.subHideSettings')}
|
|
||||||
description={t('pages.settings.subHideSettingsDesc')}
|
|
||||||
>
|
|
||||||
<Switch
|
|
||||||
checked={allSetting.subHideSettings}
|
|
||||||
onChange={(v) => updateSetting({ subHideSettings: v })}
|
|
||||||
/>
|
|
||||||
</SettingListItem>
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: '6',
|
|
||||||
label: catTabLabel(<NodeIndexOutlined />, 'Clash / Mihomo', isMobile),
|
|
||||||
children: (
|
|
||||||
<>
|
|
||||||
<SettingListItem
|
|
||||||
paddings="small"
|
|
||||||
title={t('pages.settings.subClashEnableRouting')}
|
|
||||||
description={t('pages.settings.subClashEnableRoutingDesc')}
|
|
||||||
>
|
|
||||||
<Switch
|
|
||||||
checked={allSetting.subClashEnableRouting}
|
|
||||||
onChange={(v) => updateSetting({ subClashEnableRouting: v })}
|
|
||||||
/>
|
|
||||||
</SettingListItem>
|
|
||||||
<SettingListItem
|
|
||||||
paddings="small"
|
|
||||||
title={t('pages.settings.subClashRoutingRules')}
|
|
||||||
badge={remoteSourceBadge(allSetting.subClashRules)}
|
|
||||||
description={t('pages.settings.subClashRoutingRulesDesc')}
|
|
||||||
>
|
|
||||||
<Input.TextArea
|
|
||||||
value={allSetting.subClashRules}
|
|
||||||
rows={8}
|
|
||||||
placeholder={
|
|
||||||
'https://.../routing.yaml\n\nor inline rules:\nGEOSITE,category-ir,DIRECT'
|
|
||||||
}
|
|
||||||
onChange={(e) => updateSetting({ subClashRules: e.target.value })}
|
|
||||||
/>
|
|
||||||
</SettingListItem>
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: '7',
|
|
||||||
label: catTabLabel(<CompassOutlined />, 'Incy', isMobile),
|
|
||||||
children: (
|
|
||||||
<>
|
|
||||||
<SettingListItem
|
|
||||||
paddings="small"
|
|
||||||
title={t('pages.settings.subIncyEnableRouting')}
|
|
||||||
description={t('pages.settings.subIncyEnableRoutingDesc')}
|
|
||||||
>
|
|
||||||
<Switch
|
|
||||||
checked={allSetting.subIncyEnableRouting}
|
|
||||||
onChange={(v) => updateSetting({ subIncyEnableRouting: v })}
|
|
||||||
/>
|
|
||||||
</SettingListItem>
|
|
||||||
<SettingListItem
|
|
||||||
paddings="small"
|
|
||||||
title={t('pages.settings.subIncyRoutingRules')}
|
|
||||||
badge={remoteSourceBadge(allSetting.subIncyRoutingRules)}
|
|
||||||
description={t('pages.settings.subIncyRoutingRulesDesc')}
|
|
||||||
>
|
|
||||||
<Input.TextArea
|
|
||||||
value={allSetting.subIncyRoutingRules}
|
|
||||||
placeholder="incy://routing/onadd/... or https://.../DEFAULT.JSON"
|
|
||||||
onChange={(e) => updateSetting({ subIncyRoutingRules: e.target.value })}
|
|
||||||
/>
|
|
||||||
</SettingListItem>
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -17,14 +17,12 @@ import {
|
|||||||
Row,
|
Row,
|
||||||
Space,
|
Space,
|
||||||
Tag,
|
Tag,
|
||||||
Tooltip,
|
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import {
|
import {
|
||||||
AndroidOutlined,
|
AndroidOutlined,
|
||||||
AppleOutlined,
|
AppleOutlined,
|
||||||
CopyOutlined,
|
CopyOutlined,
|
||||||
DownOutlined,
|
DownOutlined,
|
||||||
DownloadOutlined,
|
|
||||||
MoonFilled,
|
MoonFilled,
|
||||||
MoonOutlined,
|
MoonOutlined,
|
||||||
QrcodeOutlined,
|
QrcodeOutlined,
|
||||||
@@ -61,8 +59,6 @@ const totalByte = Number(subData.totalByte || 0);
|
|||||||
const expireMs = Number(subData.expire || 0) * 1000;
|
const expireMs = Number(subData.expire || 0) * 1000;
|
||||||
const lastOnlineMs = Number(subData.lastOnline || 0);
|
const lastOnlineMs = Number(subData.lastOnline || 0);
|
||||||
const subUrl = subData.subUrl || '';
|
const subUrl = subData.subUrl || '';
|
||||||
const subJsonUrl = subData.subJsonUrl || '';
|
|
||||||
const subClashUrl = subData.subClashUrl || '';
|
|
||||||
const subTitle = subData.subTitle || '';
|
const subTitle = subData.subTitle || '';
|
||||||
const links: string[] = Array.isArray(subData.links) ? subData.links : [];
|
const links: string[] = Array.isArray(subData.links) ? subData.links : [];
|
||||||
const linkEmails: string[] = Array.isArray(subData.emails) ? subData.emails : [];
|
const linkEmails: string[] = Array.isArray(subData.emails) ? subData.emails : [];
|
||||||
@@ -70,8 +66,6 @@ const subEmail = [...new Set(linkEmails.filter(Boolean))].join(', ');
|
|||||||
const datepicker = subData.datepicker || 'gregorian';
|
const datepicker = subData.datepicker || 'gregorian';
|
||||||
const announce = subData.announce || '';
|
const announce = subData.announce || '';
|
||||||
|
|
||||||
const appendRawView = (url: string) => `${url}${url.includes('?') ? '&' : '?'}view=raw`;
|
|
||||||
|
|
||||||
const isUnlimited = totalByte <= 0 && expireMs === 0;
|
const isUnlimited = totalByte <= 0 && expireMs === 0;
|
||||||
const isActive = (() => {
|
const isActive = (() => {
|
||||||
if (!enabled) return false;
|
if (!enabled) return false;
|
||||||
@@ -329,7 +323,7 @@ export default function SubPage() {
|
|||||||
isActive={isActive}
|
isActive={isActive}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{(subUrl || subJsonUrl || subClashUrl) && (
|
{subUrl && (
|
||||||
<>
|
<>
|
||||||
<Divider>{t('subscription.title')}</Divider>
|
<Divider>{t('subscription.title')}</Divider>
|
||||||
<div className="links-section">
|
<div className="links-section">
|
||||||
@@ -385,130 +379,6 @@ export default function SubPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{subJsonUrl && (
|
|
||||||
<div className="sub-link-row">
|
|
||||||
<Tag color="purple" className="sub-link-tag">
|
|
||||||
JSON
|
|
||||||
</Tag>
|
|
||||||
<a
|
|
||||||
href={subJsonUrl}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="sub-link-title sub-link-anchor"
|
|
||||||
title={subJsonUrl}
|
|
||||||
>
|
|
||||||
{sId}
|
|
||||||
</a>
|
|
||||||
<div className="sub-link-actions">
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
href={appendRawView(subJsonUrl)}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
icon={<DownloadOutlined />}
|
|
||||||
aria-label={t('download')}
|
|
||||||
title={t('download')}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
icon={<CopyOutlined />}
|
|
||||||
onClick={() => copy(subJsonUrl)}
|
|
||||||
aria-label={t('copy')}
|
|
||||||
title={t('copy')}
|
|
||||||
/>
|
|
||||||
<Popover
|
|
||||||
trigger="click"
|
|
||||||
placement="left"
|
|
||||||
destroyOnHidden
|
|
||||||
content={
|
|
||||||
<div className="sub-link-qr-popover">
|
|
||||||
<Tag color="purple" className="qr-tag">
|
|
||||||
{t('pages.settings.subSettings')} JSON
|
|
||||||
</Tag>
|
|
||||||
<QRCode
|
|
||||||
value={subJsonUrl}
|
|
||||||
size={QR_SIZE}
|
|
||||||
type="svg"
|
|
||||||
bordered={false}
|
|
||||||
color="#000000"
|
|
||||||
bgColor="#ffffff"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
icon={<QrcodeOutlined />}
|
|
||||||
aria-label="QR"
|
|
||||||
title="QR"
|
|
||||||
/>
|
|
||||||
</Popover>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{subClashUrl && (
|
|
||||||
<div className="sub-link-row">
|
|
||||||
<Tooltip title="Clash / Mihomo">
|
|
||||||
<Tag color="gold" className="sub-link-tag">
|
|
||||||
CLASH
|
|
||||||
</Tag>
|
|
||||||
</Tooltip>
|
|
||||||
<a
|
|
||||||
href={subClashUrl}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="sub-link-title sub-link-anchor"
|
|
||||||
title={subClashUrl}
|
|
||||||
>
|
|
||||||
{sId}
|
|
||||||
</a>
|
|
||||||
<div className="sub-link-actions">
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
href={appendRawView(subClashUrl)}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
icon={<DownloadOutlined />}
|
|
||||||
aria-label={t('download')}
|
|
||||||
title={t('download')}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
icon={<CopyOutlined />}
|
|
||||||
onClick={() => copy(subClashUrl)}
|
|
||||||
aria-label={t('copy')}
|
|
||||||
title={t('copy')}
|
|
||||||
/>
|
|
||||||
<Popover
|
|
||||||
trigger="click"
|
|
||||||
placement="left"
|
|
||||||
destroyOnHidden
|
|
||||||
content={
|
|
||||||
<div className="sub-link-qr-popover">
|
|
||||||
<Tag color="gold" className="qr-tag">
|
|
||||||
Clash / Mihomo
|
|
||||||
</Tag>
|
|
||||||
<QRCode
|
|
||||||
value={subClashUrl}
|
|
||||||
size={QR_SIZE}
|
|
||||||
type="svg"
|
|
||||||
bordered={false}
|
|
||||||
color="#000000"
|
|
||||||
bgColor="#ffffff"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
icon={<QrcodeOutlined />}
|
|
||||||
aria-label="QR"
|
|
||||||
title="QR"
|
|
||||||
/>
|
|
||||||
</Popover>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,40 +0,0 @@
|
|||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
|
|
||||||
import type { DeletionImpact } from './reference-cleanup';
|
|
||||||
|
|
||||||
interface DeletionImpactListProps {
|
|
||||||
impact: DeletionImpact;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function DeletionImpactList({ impact }: DeletionImpactListProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
const lines: string[] = [];
|
|
||||||
for (const rule of impact.rules) {
|
|
||||||
lines.push(
|
|
||||||
rule.fate === 'removed'
|
|
||||||
? t('pages.xray.refCleanup.ruleRemoved', { label: rule.label })
|
|
||||||
: t('pages.xray.refCleanup.ruleModified', { label: rule.label, keeps: rule.keeps ?? '' }),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
for (const balancer of impact.balancers) {
|
|
||||||
lines.push(t('pages.xray.refCleanup.balancerRemoved', { tag: balancer.tag }));
|
|
||||||
}
|
|
||||||
if (impact.observatory) lines.push(t('pages.xray.observatory.deleteAlsoObservatory'));
|
|
||||||
if (impact.burst) lines.push(t('pages.xray.observatory.deleteAlsoBurst'));
|
|
||||||
|
|
||||||
if (lines.length === 0) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<p style={{ marginBottom: 8 }}>{t('pages.xray.refCleanup.header')}</p>
|
|
||||||
<ul style={{ margin: 0, paddingInlineStart: 20 }}>
|
|
||||||
{lines.map((line, i) => (
|
|
||||||
<li key={i}>
|
|
||||||
<bdi>{line}</bdi>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
.xray-page .advanced-meta h4 {
|
|
||||||
margin: 0 0 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.xray-page .advanced-meta p {
|
|
||||||
margin: 0;
|
|
||||||
opacity: 0.7;
|
|
||||||
}
|
|
||||||
@@ -1,286 +0,0 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { useLocation } from 'react-router';
|
|
||||||
import {
|
|
||||||
Alert,
|
|
||||||
Button,
|
|
||||||
Card,
|
|
||||||
Col,
|
|
||||||
ConfigProvider,
|
|
||||||
FloatButton,
|
|
||||||
Layout,
|
|
||||||
message,
|
|
||||||
Result,
|
|
||||||
Row,
|
|
||||||
Space,
|
|
||||||
Spin,
|
|
||||||
} from 'antd';
|
|
||||||
|
|
||||||
import { useTheme } from '@/hooks/useTheme';
|
|
||||||
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
|
||||||
import { useXraySetting } from '@/hooks/useXraySetting';
|
|
||||||
import type { XraySettingsValue } from '@/hooks/useXraySetting';
|
|
||||||
import AppSidebar from '@/layouts/AppSidebar';
|
|
||||||
import { setMessageInstance } from '@/utils/messageBus';
|
|
||||||
|
|
||||||
import { BasicsTab } from './basics';
|
|
||||||
import { propagateOutboundTagRename } from './basics/helpers';
|
|
||||||
import { RoutingTab } from './routing';
|
|
||||||
import { OutboundsTab } from './outbounds';
|
|
||||||
import {
|
|
||||||
cleanupOrphanedBalancerLoopbacks,
|
|
||||||
ensureMissingBalancerLoopbacks,
|
|
||||||
detectBalancerCycles,
|
|
||||||
} from './balancers/balancer-loopback';
|
|
||||||
import { WarpModal, NordModal, PiaModal } from './overrides';
|
|
||||||
import './XrayPage.css';
|
|
||||||
|
|
||||||
const SECTION_SLUGS = ['basic', 'routing', 'outbound'];
|
|
||||||
|
|
||||||
export default function XrayPage() {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const { isDark, isUltra, antdThemeConfig } = useTheme();
|
|
||||||
const { isMobile } = useMediaQuery();
|
|
||||||
const [messageApi, messageContextHolder] = message.useMessage();
|
|
||||||
useEffect(() => {
|
|
||||||
setMessageInstance(messageApi);
|
|
||||||
}, [messageApi]);
|
|
||||||
const xs = useXraySetting();
|
|
||||||
const {
|
|
||||||
fetched,
|
|
||||||
spinning,
|
|
||||||
saveDisabled,
|
|
||||||
fetchError,
|
|
||||||
xraySetting,
|
|
||||||
setXraySetting,
|
|
||||||
templateSettings,
|
|
||||||
setTemplateSettings,
|
|
||||||
inboundTags,
|
|
||||||
clientReverseTags,
|
|
||||||
subscriptionOutbounds,
|
|
||||||
subscriptionOutboundTags,
|
|
||||||
outboundsTraffic,
|
|
||||||
fetchAll,
|
|
||||||
resetOutboundsTraffic,
|
|
||||||
saveAll,
|
|
||||||
resetToDefault,
|
|
||||||
} = xs;
|
|
||||||
|
|
||||||
const [warpOpen, setWarpOpen] = useState(false);
|
|
||||||
const [nordOpen, setNordOpen] = useState(false);
|
|
||||||
const [piaOpen, setPiaOpen] = useState(false);
|
|
||||||
const location = useLocation();
|
|
||||||
const pathSection =
|
|
||||||
location.pathname === '/outbound'
|
|
||||||
? 'outbound'
|
|
||||||
: location.pathname === '/routing'
|
|
||||||
? 'routing'
|
|
||||||
: '';
|
|
||||||
const sectionSlug = pathSection || location.hash.replace(/^#/, '');
|
|
||||||
const activeSection = SECTION_SLUGS.includes(sectionSlug) ? sectionSlug : 'basic';
|
|
||||||
|
|
||||||
const mutate = useCallback(
|
|
||||||
(mutator: (next: XraySettingsValue) => void) => {
|
|
||||||
setTemplateSettings((prev) => {
|
|
||||||
if (!prev) return prev;
|
|
||||||
const clone = JSON.parse(JSON.stringify(prev)) as XraySettingsValue;
|
|
||||||
mutator(clone);
|
|
||||||
return clone;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[setTemplateSettings],
|
|
||||||
);
|
|
||||||
|
|
||||||
function onAddOutbound(outbound: Record<string, unknown>) {
|
|
||||||
mutate((tt) => {
|
|
||||||
if (!Array.isArray(tt.outbounds)) tt.outbounds = [];
|
|
||||||
tt.outbounds.push(outbound as never);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
function onResetOutbound(payload: {
|
|
||||||
index: number;
|
|
||||||
outbound: Record<string, unknown>;
|
|
||||||
oldTag?: string;
|
|
||||||
newTag?: string;
|
|
||||||
}) {
|
|
||||||
mutate((tt) => {
|
|
||||||
if (!tt.outbounds || payload.index < 0) return;
|
|
||||||
tt.outbounds[payload.index] = payload.outbound as never;
|
|
||||||
if (payload.oldTag && payload.newTag) {
|
|
||||||
propagateOutboundTagRename(tt, payload.oldTag, payload.newTag);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
function onRemoveOutboundByTag(tag: string) {
|
|
||||||
mutate((tt) => {
|
|
||||||
if (!tt.outbounds) return;
|
|
||||||
const idx = tt.outbounds.findIndex((o) => o?.tag === tag);
|
|
||||||
if (idx >= 0) tt.outbounds.splice(idx, 1);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
function onRemoveOutboundByIndex(index: number) {
|
|
||||||
mutate((tt) => {
|
|
||||||
if (tt.outbounds && index >= 0) tt.outbounds.splice(index, 1);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
function onRemoveRoutingRules(payload: { prefix: string }) {
|
|
||||||
mutate((tt) => {
|
|
||||||
const rules = tt.routing?.rules;
|
|
||||||
if (!Array.isArray(rules)) return;
|
|
||||||
tt.routing!.rules = rules.filter((r) => !r?.outboundTag?.startsWith?.(payload.prefix));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function onSaveAll() {
|
|
||||||
try {
|
|
||||||
JSON.parse(xraySetting);
|
|
||||||
} catch (e) {
|
|
||||||
messageApi.error(`Template JSON: ${(e as Error).message}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (templateSettings) {
|
|
||||||
const clone = JSON.parse(JSON.stringify(templateSettings));
|
|
||||||
ensureMissingBalancerLoopbacks(clone);
|
|
||||||
cleanupOrphanedBalancerLoopbacks(clone);
|
|
||||||
const cycles = detectBalancerCycles(clone);
|
|
||||||
if (cycles.length > 0) {
|
|
||||||
const names = cycles.map((c) => c.join(' → ')).join(', ');
|
|
||||||
messageApi.error(t('pages.xray.balancer.balancerFallbackCycle') + ' (' + names + ')');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const serialized = JSON.stringify(clone, null, 2);
|
|
||||||
setXraySetting(serialized);
|
|
||||||
setTemplateSettings(clone);
|
|
||||||
}
|
|
||||||
saveAll();
|
|
||||||
}
|
|
||||||
|
|
||||||
const scrollTarget = () => document.getElementById('content-layout') || window;
|
|
||||||
|
|
||||||
const pageClass = `xray-page ${isDark ? 'is-dark' : ''} ${isUltra ? 'is-ultra' : ''}`.trim();
|
|
||||||
|
|
||||||
const sectionBody = (() => {
|
|
||||||
switch (activeSection) {
|
|
||||||
case 'routing':
|
|
||||||
return (
|
|
||||||
<RoutingTab
|
|
||||||
templateSettings={templateSettings}
|
|
||||||
setTemplateSettings={setTemplateSettings}
|
|
||||||
inboundTags={inboundTags}
|
|
||||||
clientReverseTags={clientReverseTags}
|
|
||||||
subscriptionOutboundTags={subscriptionOutboundTags}
|
|
||||||
isMobile={isMobile}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
case 'outbound':
|
|
||||||
return (
|
|
||||||
<OutboundsTab
|
|
||||||
templateSettings={templateSettings}
|
|
||||||
setTemplateSettings={setTemplateSettings}
|
|
||||||
outboundsTraffic={outboundsTraffic}
|
|
||||||
inboundTags={inboundTags}
|
|
||||||
subscriptionOutbounds={subscriptionOutbounds}
|
|
||||||
subscriptionOutboundTags={subscriptionOutboundTags}
|
|
||||||
isMobile={isMobile}
|
|
||||||
onResetTraffic={resetOutboundsTraffic}
|
|
||||||
onShowWarp={() => setWarpOpen(true)}
|
|
||||||
onShowNord={() => setNordOpen(true)}
|
|
||||||
onShowPia={() => setPiaOpen(true)}
|
|
||||||
onRefreshXrayData={fetchAll}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
default:
|
|
||||||
return (
|
|
||||||
<BasicsTab
|
|
||||||
templateSettings={templateSettings}
|
|
||||||
setTemplateSettings={setTemplateSettings}
|
|
||||||
onResetDefault={resetToDefault}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ConfigProvider theme={antdThemeConfig}>
|
|
||||||
{messageContextHolder}
|
|
||||||
<Layout className={pageClass}>
|
|
||||||
<AppSidebar />
|
|
||||||
|
|
||||||
<Layout className="content-shell">
|
|
||||||
<Layout.Content id="content-layout" className="content-area">
|
|
||||||
<Spin
|
|
||||||
spinning={spinning || !fetched}
|
|
||||||
delay={200}
|
|
||||||
description={t('loading')}
|
|
||||||
size="large"
|
|
||||||
>
|
|
||||||
{!fetched ? (
|
|
||||||
<div className="loading-spacer" />
|
|
||||||
) : fetchError ? (
|
|
||||||
<Result
|
|
||||||
status="error"
|
|
||||||
title={t('somethingWentWrong')}
|
|
||||||
subTitle={fetchError}
|
|
||||||
extra={
|
|
||||||
<Button type="primary" onClick={fetchAll}>
|
|
||||||
{t('check')}
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<Row gutter={[isMobile ? 8 : 16, isMobile ? 0 : 12]}>
|
|
||||||
<Col span={24}>
|
|
||||||
<Card hoverable>
|
|
||||||
<Row className="header-row">
|
|
||||||
<Col xs={24} sm={14} className="header-actions">
|
|
||||||
<Space>
|
|
||||||
<Button type="primary" disabled={saveDisabled} onClick={onSaveAll}>
|
|
||||||
{t('pages.xray.save')}
|
|
||||||
</Button>
|
|
||||||
</Space>
|
|
||||||
</Col>
|
|
||||||
<Col xs={24} sm={10} className="header-info">
|
|
||||||
<FloatButton.BackTop target={scrollTarget} visibilityHeight={200} />
|
|
||||||
<Alert type="warning" showIcon title={t('pages.settings.infoDesc')} />
|
|
||||||
</Col>
|
|
||||||
</Row>
|
|
||||||
</Card>
|
|
||||||
</Col>
|
|
||||||
|
|
||||||
<Col span={24}>
|
|
||||||
<Card hoverable>{sectionBody}</Card>
|
|
||||||
</Col>
|
|
||||||
</Row>
|
|
||||||
)}
|
|
||||||
</Spin>
|
|
||||||
</Layout.Content>
|
|
||||||
</Layout>
|
|
||||||
|
|
||||||
<WarpModal
|
|
||||||
open={warpOpen}
|
|
||||||
templateSettings={templateSettings}
|
|
||||||
onClose={() => setWarpOpen(false)}
|
|
||||||
onAddOutbound={onAddOutbound}
|
|
||||||
onResetOutbound={onResetOutbound}
|
|
||||||
onRemoveOutbound={onRemoveOutboundByTag}
|
|
||||||
/>
|
|
||||||
<NordModal
|
|
||||||
open={nordOpen}
|
|
||||||
templateSettings={templateSettings}
|
|
||||||
onClose={() => setNordOpen(false)}
|
|
||||||
onAddOutbound={onAddOutbound}
|
|
||||||
onResetOutbound={onResetOutbound}
|
|
||||||
onRemoveOutbound={onRemoveOutboundByIndex}
|
|
||||||
onRemoveRoutingRules={onRemoveRoutingRules}
|
|
||||||
/>
|
|
||||||
<PiaModal
|
|
||||||
open={piaOpen}
|
|
||||||
templateSettings={templateSettings}
|
|
||||||
onClose={() => setPiaOpen(false)}
|
|
||||||
onAddOutbound={onAddOutbound}
|
|
||||||
onResetOutbound={onResetOutbound}
|
|
||||||
/>
|
|
||||||
</Layout>
|
|
||||||
</ConfigProvider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,229 +0,0 @@
|
|||||||
import type { XraySettingsValue } from '@/hooks/useXraySetting';
|
|
||||||
|
|
||||||
const LOOPBACK_PREFIX = '_bl_';
|
|
||||||
|
|
||||||
export function isBalancerLoopbackTag(tag: string): boolean {
|
|
||||||
return tag.startsWith(LOOPBACK_PREFIX);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function loopbackTagFor(targetBalancerTag: string): string {
|
|
||||||
return LOOPBACK_PREFIX + targetBalancerTag;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function balancerTagFromLoopback(loopbackTag: string): string | null {
|
|
||||||
if (!isBalancerLoopbackTag(loopbackTag)) return null;
|
|
||||||
return loopbackTag.slice(LOOPBACK_PREFIX.length);
|
|
||||||
}
|
|
||||||
|
|
||||||
function loopbackMatchesTarget(loopbackTag: string, targetTag: string): boolean {
|
|
||||||
if (!isBalancerLoopbackTag(loopbackTag)) return false;
|
|
||||||
const target = balancerTagFromLoopback(loopbackTag);
|
|
||||||
return target === targetTag;
|
|
||||||
}
|
|
||||||
|
|
||||||
function findLoopbackTarget(settings: XraySettingsValue, loopbackTag: string): string | null {
|
|
||||||
const rules = (settings.routing?.rules || []) as Array<{
|
|
||||||
inboundTag?: string[];
|
|
||||||
balancerTag?: string;
|
|
||||||
}>;
|
|
||||||
for (const r of rules) {
|
|
||||||
if (Array.isArray(r.inboundTag) && r.inboundTag.includes(loopbackTag) && r.balancerTag) {
|
|
||||||
return r.balancerTag;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resolveLoopbackFallback(settings: XraySettingsValue, fallbackTag: string): string {
|
|
||||||
if (!fallbackTag || !isBalancerLoopbackTag(fallbackTag)) return fallbackTag;
|
|
||||||
const target = findLoopbackTarget(settings, fallbackTag);
|
|
||||||
if (target) return target;
|
|
||||||
const targetTag = balancerTagFromLoopback(fallbackTag);
|
|
||||||
return targetTag || fallbackTag;
|
|
||||||
}
|
|
||||||
|
|
||||||
function countLoopbackRefs(settings: XraySettingsValue, targetTag: string): number {
|
|
||||||
let count = 0;
|
|
||||||
for (const b of (settings.routing?.balancers || []) as Array<{ fallbackTag?: string }>) {
|
|
||||||
if (
|
|
||||||
b.fallbackTag &&
|
|
||||||
isBalancerLoopbackTag(b.fallbackTag) &&
|
|
||||||
loopbackMatchesTarget(b.fallbackTag, targetTag)
|
|
||||||
) {
|
|
||||||
count++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return count;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isGeneralRoutingRule(rule: Record<string, unknown>): boolean {
|
|
||||||
const inboundTag = rule.inboundTag;
|
|
||||||
return !Array.isArray(inboundTag) || inboundTag.length === 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
function firstGeneralRoutingRuleIndex(rules: Array<Record<string, unknown>>): number {
|
|
||||||
for (let i = 0; i < rules.length; i += 1) {
|
|
||||||
if (isGeneralRoutingRule(rules[i])) return i;
|
|
||||||
}
|
|
||||||
return rules.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ensureBalancerLoopback(
|
|
||||||
settings: XraySettingsValue,
|
|
||||||
targetBalancerTag: string,
|
|
||||||
): void {
|
|
||||||
const lbTag = loopbackTagFor(targetBalancerTag);
|
|
||||||
|
|
||||||
if (!Array.isArray(settings.outbounds)) settings.outbounds = [];
|
|
||||||
const existingIdx = (settings.outbounds as Array<{ tag?: string; protocol?: string }>).findIndex(
|
|
||||||
(o) => o.tag === lbTag,
|
|
||||||
);
|
|
||||||
const newOutbound = { tag: lbTag, protocol: 'loopback', settings: { inboundTag: lbTag } };
|
|
||||||
if (existingIdx >= 0) {
|
|
||||||
(settings.outbounds as Record<string, unknown>[])[existingIdx] = newOutbound;
|
|
||||||
} else {
|
|
||||||
(settings.outbounds as Record<string, unknown>[]).push(newOutbound);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!settings.routing) settings.routing = { rules: [], balancers: [] };
|
|
||||||
if (!Array.isArray(settings.routing.rules)) settings.routing.rules = [];
|
|
||||||
|
|
||||||
const rules = settings.routing.rules as Array<Record<string, unknown>>;
|
|
||||||
|
|
||||||
const existingRuleIdx = rules.findIndex(
|
|
||||||
(r) => Array.isArray(r.inboundTag) && (r.inboundTag as string[]).includes(lbTag),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (existingRuleIdx >= 0) {
|
|
||||||
const existing = rules[existingRuleIdx];
|
|
||||||
existing.balancerTag = targetBalancerTag;
|
|
||||||
rules.splice(existingRuleIdx, 1);
|
|
||||||
rules.splice(firstGeneralRoutingRuleIndex(rules), 0, existing);
|
|
||||||
} else {
|
|
||||||
rules.splice(firstGeneralRoutingRuleIndex(rules), 0, {
|
|
||||||
type: 'field',
|
|
||||||
inboundTag: [lbTag],
|
|
||||||
balancerTag: targetBalancerTag,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function removeBalancerLoopbackIfOrphaned(
|
|
||||||
settings: XraySettingsValue,
|
|
||||||
targetBalancerTag: string,
|
|
||||||
): void {
|
|
||||||
if (countLoopbackRefs(settings, targetBalancerTag) > 0) return;
|
|
||||||
removeBalancerLoopback(settings, targetBalancerTag);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function removeBalancerLoopback(
|
|
||||||
settings: XraySettingsValue,
|
|
||||||
targetBalancerTag: string,
|
|
||||||
): void {
|
|
||||||
const lbTag = loopbackTagFor(targetBalancerTag);
|
|
||||||
|
|
||||||
if (Array.isArray(settings.outbounds)) {
|
|
||||||
settings.outbounds = (settings.outbounds as Array<{ tag?: string }>).filter(
|
|
||||||
(o) => o.tag !== lbTag,
|
|
||||||
) as XraySettingsValue['outbounds'];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (settings.routing && Array.isArray(settings.routing.rules)) {
|
|
||||||
settings.routing.rules = settings.routing.rules.filter(
|
|
||||||
(r) => !(Array.isArray(r.inboundTag) && r.inboundTag.includes(lbTag)),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function propagateBalancerTagRename(
|
|
||||||
settings: XraySettingsValue,
|
|
||||||
oldTag: string,
|
|
||||||
newTag: string,
|
|
||||||
): void {
|
|
||||||
const oldLbTag = loopbackTagFor(oldTag);
|
|
||||||
const newLbTag = loopbackTagFor(newTag);
|
|
||||||
|
|
||||||
if (Array.isArray(settings.outbounds)) {
|
|
||||||
for (const o of settings.outbounds as Array<{
|
|
||||||
tag?: string;
|
|
||||||
settings?: { inboundTag?: string };
|
|
||||||
}>) {
|
|
||||||
if (o.tag === oldLbTag) o.tag = newLbTag;
|
|
||||||
if (o.settings?.inboundTag === oldLbTag) o.settings.inboundTag = newLbTag;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (settings.routing && Array.isArray(settings.routing.rules)) {
|
|
||||||
for (const r of settings.routing.rules as Array<{ inboundTag?: string[] }>) {
|
|
||||||
if (Array.isArray(r.inboundTag)) {
|
|
||||||
const idx = r.inboundTag.indexOf(oldLbTag);
|
|
||||||
if (idx !== -1) r.inboundTag[idx] = newLbTag;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (settings.routing && Array.isArray(settings.routing.balancers)) {
|
|
||||||
for (const b of settings.routing.balancers as Array<{ tag?: string; fallbackTag?: string }>) {
|
|
||||||
if (b.fallbackTag === oldLbTag) b.fallbackTag = newLbTag;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function detectBalancerCycles(settings: XraySettingsValue): string[][] {
|
|
||||||
const balancers = (settings.routing?.balancers || []) as Array<{
|
|
||||||
tag?: string;
|
|
||||||
fallbackTag?: string;
|
|
||||||
}>;
|
|
||||||
const cycles: string[][] = [];
|
|
||||||
|
|
||||||
for (const b of balancers) {
|
|
||||||
if (!b.tag || !b.fallbackTag || !isBalancerLoopbackTag(b.fallbackTag)) continue;
|
|
||||||
const targetTag = balancerTagFromLoopback(b.fallbackTag);
|
|
||||||
if (!targetTag) continue;
|
|
||||||
|
|
||||||
const visited = new Set<string>();
|
|
||||||
let cursor = targetTag;
|
|
||||||
while (cursor && !visited.has(cursor)) {
|
|
||||||
if (cursor === b.tag) {
|
|
||||||
cycles.push([b.tag, targetTag]);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
visited.add(cursor);
|
|
||||||
const next = balancers.find((x) => x.tag === cursor);
|
|
||||||
const fb = next?.fallbackTag;
|
|
||||||
if (!fb || !isBalancerLoopbackTag(fb)) break;
|
|
||||||
cursor = balancerTagFromLoopback(fb) || '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return cycles;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ensureMissingBalancerLoopbacks(settings: XraySettingsValue): void {
|
|
||||||
const balancers = (settings.routing?.balancers || []) as Array<{
|
|
||||||
tag?: string;
|
|
||||||
fallbackTag?: string;
|
|
||||||
}>;
|
|
||||||
for (const b of balancers) {
|
|
||||||
if (!b.fallbackTag || !isBalancerLoopbackTag(b.fallbackTag)) continue;
|
|
||||||
const targetTag = balancerTagFromLoopback(b.fallbackTag);
|
|
||||||
if (!targetTag) continue;
|
|
||||||
ensureBalancerLoopback(settings, targetTag);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function cleanupOrphanedBalancerLoopbacks(settings: XraySettingsValue): void {
|
|
||||||
if (!Array.isArray(settings.outbounds)) return;
|
|
||||||
|
|
||||||
const orphanedTags: string[] = [];
|
|
||||||
for (const o of settings.outbounds as Array<{ tag?: string; protocol?: string }>) {
|
|
||||||
if (o.protocol !== 'loopback' || !o.tag || !isBalancerLoopbackTag(o.tag)) continue;
|
|
||||||
const targetTag = balancerTagFromLoopback(o.tag);
|
|
||||||
if (targetTag && countLoopbackRefs(settings, targetTag) === 0) {
|
|
||||||
orphanedTags.push(targetTag);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const tag of orphanedTags) {
|
|
||||||
removeBalancerLoopback(settings, tag);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
.hint-alert {
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
@@ -1,499 +0,0 @@
|
|||||||
import { useCallback } from 'react';
|
|
||||||
import { onNumber } from '@/utils/onNumber';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { Alert, Button, Input, InputNumber, Modal, Select, Space, Switch, Tabs } from 'antd';
|
|
||||||
import {
|
|
||||||
BarChartOutlined,
|
|
||||||
ClockCircleOutlined,
|
|
||||||
FileTextOutlined,
|
|
||||||
ReloadOutlined,
|
|
||||||
SettingOutlined,
|
|
||||||
} from '@ant-design/icons';
|
|
||||||
|
|
||||||
import { OutboundDomainStrategies } from '@/schemas/primitives';
|
|
||||||
import { HappyEyeballsSchema } from '@/schemas/protocols/stream/sockopt';
|
|
||||||
import { SettingListItem } from '@/components/ui';
|
|
||||||
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
|
||||||
import { catTabLabel } from '@/pages/settings/catTabLabel';
|
|
||||||
import type { XraySettingsValue, SetTemplate } from '@/hooks/useXraySetting';
|
|
||||||
import './BasicsTab.css';
|
|
||||||
|
|
||||||
import {
|
|
||||||
ACCESS_LOG,
|
|
||||||
ERROR_LOG,
|
|
||||||
LOG_LEVELS,
|
|
||||||
MASK_ADDRESS,
|
|
||||||
ROUTING_DOMAIN_STRATEGIES,
|
|
||||||
} from './constants';
|
|
||||||
|
|
||||||
interface BasicsTabProps {
|
|
||||||
templateSettings: XraySettingsValue | null;
|
|
||||||
setTemplateSettings: SetTemplate;
|
|
||||||
onResetDefault: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function BasicsTab({
|
|
||||||
templateSettings,
|
|
||||||
setTemplateSettings,
|
|
||||||
onResetDefault,
|
|
||||||
}: BasicsTabProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const { isMobile } = useMediaQuery();
|
|
||||||
const [modal, modalContextHolder] = Modal.useModal();
|
|
||||||
|
|
||||||
const mutate = useCallback(
|
|
||||||
(mutator: (next: XraySettingsValue) => void) => {
|
|
||||||
setTemplateSettings((prev) => {
|
|
||||||
if (!prev) return prev;
|
|
||||||
const clone = JSON.parse(JSON.stringify(prev)) as XraySettingsValue;
|
|
||||||
mutator(clone);
|
|
||||||
return clone;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[setTemplateSettings],
|
|
||||||
);
|
|
||||||
|
|
||||||
const setLevel0 = useCallback(
|
|
||||||
(field: string, value: number | null) =>
|
|
||||||
mutate((tt) => {
|
|
||||||
if (!tt.policy) tt.policy = {};
|
|
||||||
if (!tt.policy.levels) tt.policy.levels = {};
|
|
||||||
if (!tt.policy.levels['0']) tt.policy.levels['0'] = {};
|
|
||||||
if (value === null || value === undefined) {
|
|
||||||
delete tt.policy.levels['0'][field];
|
|
||||||
} else {
|
|
||||||
tt.policy.levels['0'][field] = value;
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
[mutate],
|
|
||||||
);
|
|
||||||
|
|
||||||
const metricsCfg = (templateSettings as { metrics?: { tag?: string; listen?: string } } | null)
|
|
||||||
?.metrics;
|
|
||||||
|
|
||||||
const setMetrics = useCallback(
|
|
||||||
(field: 'tag' | 'listen', value: string) =>
|
|
||||||
mutate((tt) => {
|
|
||||||
const node = tt as {
|
|
||||||
metrics?: { tag?: string; listen?: string };
|
|
||||||
stats?: Record<string, unknown>;
|
|
||||||
};
|
|
||||||
const m: { tag?: string; listen?: string } = { ...(node.metrics ?? {}) };
|
|
||||||
if (value.trim() === '') {
|
|
||||||
delete m[field];
|
|
||||||
} else {
|
|
||||||
m[field] = value.trim();
|
|
||||||
}
|
|
||||||
if (!m.listen && !m.tag) {
|
|
||||||
delete node.metrics;
|
|
||||||
} else {
|
|
||||||
node.metrics = m;
|
|
||||||
// xray-core's metrics handler needs a stats object to populate.
|
|
||||||
if (!node.stats) node.stats = {};
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
[mutate],
|
|
||||||
);
|
|
||||||
|
|
||||||
function confirmResetDefault() {
|
|
||||||
modal.confirm({
|
|
||||||
title: t('pages.settings.resetDefaultConfig'),
|
|
||||||
okText: t('reset'),
|
|
||||||
okType: 'danger',
|
|
||||||
cancelText: t('cancel'),
|
|
||||||
onOk: () => onResetDefault(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const freedomStrategy =
|
|
||||||
(
|
|
||||||
templateSettings?.outbounds?.find((o) => o?.protocol === 'freedom' && o?.tag === 'direct')
|
|
||||||
?.settings as { domainStrategy?: string } | undefined
|
|
||||||
)?.domainStrategy ?? 'AsIs';
|
|
||||||
|
|
||||||
const directFreedomOutbound = templateSettings?.outbounds?.find(
|
|
||||||
(o) => o?.protocol === 'freedom' && o?.tag === 'direct',
|
|
||||||
);
|
|
||||||
const directHappyEyeballs = (() => {
|
|
||||||
const sockopt = (
|
|
||||||
directFreedomOutbound?.streamSettings as { sockopt?: { happyEyeballs?: unknown } } | undefined
|
|
||||||
)?.sockopt;
|
|
||||||
const raw = sockopt?.happyEyeballs;
|
|
||||||
if (raw == null || typeof raw !== 'object') return null;
|
|
||||||
const parsed = HappyEyeballsSchema.safeParse(raw);
|
|
||||||
return parsed.success ? parsed.data : null;
|
|
||||||
})();
|
|
||||||
|
|
||||||
const setDirectHappyEyeballs = useCallback(
|
|
||||||
(next: ReturnType<typeof HappyEyeballsSchema.parse> | null) => {
|
|
||||||
mutate((tt) => {
|
|
||||||
if (!tt.outbounds) tt.outbounds = [];
|
|
||||||
let idx = tt.outbounds.findIndex((o) => o?.protocol === 'freedom' && o?.tag === 'direct');
|
|
||||||
if (idx < 0) {
|
|
||||||
tt.outbounds.push({ protocol: 'freedom', tag: 'direct', settings: {} });
|
|
||||||
idx = tt.outbounds.length - 1;
|
|
||||||
}
|
|
||||||
const ob = tt.outbounds[idx];
|
|
||||||
const stream = (ob.streamSettings ?? {}) as Record<string, unknown>;
|
|
||||||
const sockopt = (stream.sockopt ?? {}) as Record<string, unknown>;
|
|
||||||
if (next == null) {
|
|
||||||
delete sockopt.happyEyeballs;
|
|
||||||
} else {
|
|
||||||
sockopt.happyEyeballs = next;
|
|
||||||
}
|
|
||||||
if (Object.keys(sockopt).length === 0) {
|
|
||||||
delete stream.sockopt;
|
|
||||||
} else {
|
|
||||||
stream.sockopt = sockopt;
|
|
||||||
}
|
|
||||||
if (Object.keys(stream).length === 0) {
|
|
||||||
delete ob.streamSettings;
|
|
||||||
} else {
|
|
||||||
ob.streamSettings = stream;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[mutate],
|
|
||||||
);
|
|
||||||
|
|
||||||
const routingStrategy = templateSettings?.routing?.domainStrategy ?? 'AsIs';
|
|
||||||
const log = (templateSettings?.log || {}) as Record<string, unknown>;
|
|
||||||
const policy = (templateSettings?.policy?.system || {}) as Record<string, boolean>;
|
|
||||||
const level0 = (templateSettings?.policy?.levels?.['0'] || {}) as Record<string, unknown>;
|
|
||||||
|
|
||||||
const items = [
|
|
||||||
{
|
|
||||||
key: '1',
|
|
||||||
label: catTabLabel(<SettingOutlined />, t('pages.xray.generalConfigs'), isMobile),
|
|
||||||
children: (
|
|
||||||
<>
|
|
||||||
<Alert
|
|
||||||
type="warning"
|
|
||||||
showIcon
|
|
||||||
className="mb-12 hint-alert"
|
|
||||||
title={t('pages.xray.generalConfigsDesc')}
|
|
||||||
/>
|
|
||||||
<SettingListItem
|
|
||||||
title={t('pages.xray.FreedomStrategy')}
|
|
||||||
description={t('pages.xray.FreedomStrategyDesc')}
|
|
||||||
paddings="small"
|
|
||||||
control={
|
|
||||||
<Select
|
|
||||||
value={freedomStrategy}
|
|
||||||
style={{ width: '100%' }}
|
|
||||||
options={OutboundDomainStrategies.map((s) => ({ value: s, label: s }))}
|
|
||||||
onChange={(next) =>
|
|
||||||
mutate((tt) => {
|
|
||||||
if (!tt.outbounds) tt.outbounds = [];
|
|
||||||
const idx = tt.outbounds.findIndex(
|
|
||||||
(o) => o?.protocol === 'freedom' && o?.tag === 'direct',
|
|
||||||
);
|
|
||||||
if (idx < 0) {
|
|
||||||
tt.outbounds.push({
|
|
||||||
protocol: 'freedom',
|
|
||||||
tag: 'direct',
|
|
||||||
settings: { domainStrategy: next },
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
const ob = tt.outbounds[idx];
|
|
||||||
ob.settings = (ob.settings || {}) as Record<string, unknown>;
|
|
||||||
(ob.settings as Record<string, unknown>).domainStrategy = next;
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<SettingListItem
|
|
||||||
title={t('pages.xray.FreedomHappyEyeballs')}
|
|
||||||
description={t('pages.xray.FreedomHappyEyeballsDesc')}
|
|
||||||
paddings="small"
|
|
||||||
control={
|
|
||||||
<Switch
|
|
||||||
checked={directHappyEyeballs != null}
|
|
||||||
onChange={(checked) => {
|
|
||||||
setDirectHappyEyeballs(checked ? HappyEyeballsSchema.parse({}) : null);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
{directHappyEyeballs != null && (
|
|
||||||
<>
|
|
||||||
<SettingListItem
|
|
||||||
title={t('pages.inbounds.form.tryDelayMs')}
|
|
||||||
description={t('pages.xray.FreedomHappyEyeballsTryDelayDesc')}
|
|
||||||
paddings="small"
|
|
||||||
control={
|
|
||||||
<InputNumber
|
|
||||||
min={0}
|
|
||||||
style={{ width: '100%' }}
|
|
||||||
value={directHappyEyeballs.tryDelayMs}
|
|
||||||
placeholder="150"
|
|
||||||
onChange={onNumber((v) =>
|
|
||||||
setDirectHappyEyeballs({
|
|
||||||
...directHappyEyeballs,
|
|
||||||
tryDelayMs: v,
|
|
||||||
}),
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<SettingListItem
|
|
||||||
title={t('pages.inbounds.form.prioritizeIPv6')}
|
|
||||||
paddings="small"
|
|
||||||
control={
|
|
||||||
<Switch
|
|
||||||
checked={directHappyEyeballs.prioritizeIPv6}
|
|
||||||
onChange={(checked) =>
|
|
||||||
setDirectHappyEyeballs({
|
|
||||||
...directHappyEyeballs,
|
|
||||||
prioritizeIPv6: checked,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<SettingListItem
|
|
||||||
title={t('pages.xray.RoutingStrategy')}
|
|
||||||
description={t('pages.xray.RoutingStrategyDesc')}
|
|
||||||
paddings="small"
|
|
||||||
control={
|
|
||||||
<Select
|
|
||||||
value={routingStrategy}
|
|
||||||
style={{ width: '100%' }}
|
|
||||||
options={ROUTING_DOMAIN_STRATEGIES.map((s) => ({ value: s, label: s }))}
|
|
||||||
onChange={(next) =>
|
|
||||||
mutate((tt) => {
|
|
||||||
if (tt.routing) tt.routing.domainStrategy = next;
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: '2',
|
|
||||||
label: catTabLabel(<BarChartOutlined />, t('pages.xray.statistics'), isMobile),
|
|
||||||
children: (
|
|
||||||
<>
|
|
||||||
{[
|
|
||||||
['statsInboundUplink', t('pages.xray.statsInboundUplink')],
|
|
||||||
['statsInboundDownlink', t('pages.xray.statsInboundDownlink')],
|
|
||||||
['statsOutboundUplink', t('pages.xray.statsOutboundUplink')],
|
|
||||||
['statsOutboundDownlink', t('pages.xray.statsOutboundDownlink')],
|
|
||||||
].map(([field, label]) => (
|
|
||||||
<SettingListItem
|
|
||||||
key={field}
|
|
||||||
title={label}
|
|
||||||
paddings="small"
|
|
||||||
control={
|
|
||||||
<Switch
|
|
||||||
checked={!!policy[field]}
|
|
||||||
onChange={(checked) =>
|
|
||||||
mutate((tt) => {
|
|
||||||
if (!tt.policy) tt.policy = {};
|
|
||||||
if (!tt.policy.system) tt.policy.system = {};
|
|
||||||
tt.policy.system[field] = checked;
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
<SettingListItem
|
|
||||||
title={t('pages.xray.metricsListen')}
|
|
||||||
description={t('pages.xray.metricsListenDesc')}
|
|
||||||
paddings="small"
|
|
||||||
control={
|
|
||||||
<Input
|
|
||||||
value={metricsCfg?.listen ?? ''}
|
|
||||||
onChange={(e) => setMetrics('listen', e.target.value)}
|
|
||||||
placeholder="127.0.0.1:11111"
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<SettingListItem
|
|
||||||
title={t('pages.xray.metricsTag')}
|
|
||||||
paddings="small"
|
|
||||||
control={
|
|
||||||
<Input
|
|
||||||
value={metricsCfg?.tag ?? ''}
|
|
||||||
onChange={(e) => setMetrics('tag', e.target.value)}
|
|
||||||
placeholder="metrics_out"
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'connection',
|
|
||||||
label: catTabLabel(<ClockCircleOutlined />, t('pages.xray.connectionLimits'), isMobile),
|
|
||||||
children: (
|
|
||||||
<>
|
|
||||||
<Alert
|
|
||||||
type="warning"
|
|
||||||
showIcon
|
|
||||||
className="mb-12 hint-alert"
|
|
||||||
title={t('pages.xray.connectionLimitsDesc')}
|
|
||||||
/>
|
|
||||||
<SettingListItem
|
|
||||||
title={t('pages.xray.connIdle')}
|
|
||||||
description={t('pages.xray.connIdleDesc')}
|
|
||||||
paddings="small"
|
|
||||||
control={
|
|
||||||
<InputNumber
|
|
||||||
value={typeof level0.connIdle === 'number' ? level0.connIdle : undefined}
|
|
||||||
min={0}
|
|
||||||
style={{ width: '100%' }}
|
|
||||||
placeholder="300"
|
|
||||||
suffix={t('pages.xray.seconds')}
|
|
||||||
onChange={(v) => setLevel0('connIdle', v as number | null)}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<SettingListItem
|
|
||||||
title={t('pages.xray.bufferSize')}
|
|
||||||
description={t('pages.xray.bufferSizeDesc')}
|
|
||||||
paddings="small"
|
|
||||||
control={
|
|
||||||
<InputNumber
|
|
||||||
value={typeof level0.bufferSize === 'number' ? level0.bufferSize : undefined}
|
|
||||||
min={0}
|
|
||||||
style={{ width: '100%' }}
|
|
||||||
placeholder={t('pages.xray.bufferSizePlaceholder')}
|
|
||||||
suffix="KB"
|
|
||||||
onChange={(v) => setLevel0('bufferSize', v as number | null)}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: '3',
|
|
||||||
label: catTabLabel(<FileTextOutlined />, t('pages.xray.logConfigs'), isMobile),
|
|
||||||
children: (
|
|
||||||
<>
|
|
||||||
<Alert
|
|
||||||
type="warning"
|
|
||||||
showIcon
|
|
||||||
className="mb-12 hint-alert"
|
|
||||||
title={t('pages.xray.logConfigsDesc')}
|
|
||||||
/>
|
|
||||||
<SettingListItem
|
|
||||||
title={t('pages.xray.logLevel')}
|
|
||||||
description={t('pages.xray.logLevelDesc')}
|
|
||||||
paddings="small"
|
|
||||||
control={
|
|
||||||
<Select
|
|
||||||
value={(log.loglevel as string) || 'warning'}
|
|
||||||
style={{ width: '100%' }}
|
|
||||||
options={LOG_LEVELS.map((s) => ({ value: s, label: s }))}
|
|
||||||
onChange={(v) =>
|
|
||||||
mutate((tt) => {
|
|
||||||
if (tt.log) tt.log.loglevel = v;
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<SettingListItem
|
|
||||||
title={t('pages.xray.accessLog')}
|
|
||||||
description={t('pages.xray.accessLogDesc')}
|
|
||||||
paddings="small"
|
|
||||||
control={
|
|
||||||
<Select
|
|
||||||
value={(log.access as string) || ''}
|
|
||||||
style={{ width: '100%' }}
|
|
||||||
options={ACCESS_LOG.map((s) => ({ value: s, label: s }))}
|
|
||||||
onChange={(v) =>
|
|
||||||
mutate((tt) => {
|
|
||||||
if (tt.log) tt.log.access = v;
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<SettingListItem
|
|
||||||
title={t('pages.xray.errorLog')}
|
|
||||||
description={t('pages.xray.errorLogDesc')}
|
|
||||||
paddings="small"
|
|
||||||
control={
|
|
||||||
<Select
|
|
||||||
value={(log.error as string) || ''}
|
|
||||||
style={{ width: '100%' }}
|
|
||||||
options={[
|
|
||||||
{ value: '', label: t('empty') },
|
|
||||||
...ERROR_LOG.map((s) => ({ value: s, label: s })),
|
|
||||||
]}
|
|
||||||
onChange={(v) =>
|
|
||||||
mutate((tt) => {
|
|
||||||
if (tt.log) tt.log.error = v;
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<SettingListItem
|
|
||||||
title={t('pages.xray.maskAddress')}
|
|
||||||
description={t('pages.xray.maskAddressDesc')}
|
|
||||||
paddings="small"
|
|
||||||
control={
|
|
||||||
<Select
|
|
||||||
value={(log.maskAddress as string) || ''}
|
|
||||||
style={{ width: '100%' }}
|
|
||||||
options={[
|
|
||||||
{ value: '', label: t('empty') },
|
|
||||||
...MASK_ADDRESS.map((s) => ({ value: s, label: s })),
|
|
||||||
]}
|
|
||||||
onChange={(v) =>
|
|
||||||
mutate((tt) => {
|
|
||||||
if (tt.log) tt.log.maskAddress = v;
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<SettingListItem
|
|
||||||
title={t('pages.xray.dnsLog')}
|
|
||||||
description={t('pages.xray.dnsLogDesc')}
|
|
||||||
paddings="small"
|
|
||||||
control={
|
|
||||||
<Switch
|
|
||||||
checked={!!log.dnsLog}
|
|
||||||
onChange={(v) =>
|
|
||||||
mutate((tt) => {
|
|
||||||
if (tt.log) tt.log.dnsLog = v;
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'reset',
|
|
||||||
label: catTabLabel(<ReloadOutlined />, t('pages.settings.resetDefaultConfig'), isMobile),
|
|
||||||
children: (
|
|
||||||
<Space style={{ padding: '0 20px' }}>
|
|
||||||
<Button type="primary" danger icon={<ReloadOutlined />} onClick={confirmResetDefault}>
|
|
||||||
{t('pages.settings.resetDefaultConfig')}
|
|
||||||
</Button>
|
|
||||||
</Space>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{modalContextHolder}
|
|
||||||
<Tabs defaultActiveKey="1" items={items} />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
export const ROUTING_DOMAIN_STRATEGIES = ['AsIs', 'IPIfNonMatch', 'IPOnDemand'];
|
|
||||||
export const LOG_LEVELS = ['none', 'debug', 'info', 'warning', 'error'];
|
|
||||||
export const ACCESS_LOG = ['none', './access.log'];
|
|
||||||
export const ERROR_LOG = ['none', './error.log'];
|
|
||||||
export const MASK_ADDRESS = ['quarter', 'half', 'full'];
|
|
||||||
export const BITTORRENT_PROTOCOLS = ['bittorrent'];
|
|
||||||
|
|
||||||
export const IPS_OPTIONS = [
|
|
||||||
{ label: 'Private IPs', value: 'geoip:private' },
|
|
||||||
{ label: '🇮🇷 Iran', value: 'ext:geoip_IR.dat:ir' },
|
|
||||||
{ label: '🇨🇳 China', value: 'geoip:cn' },
|
|
||||||
{ label: '🇷🇺 Russia', value: 'ext:geoip_RU.dat:ru' },
|
|
||||||
{ label: '🇻🇳 Vietnam', value: 'geoip:vn' },
|
|
||||||
{ label: '🇪🇸 Spain', value: 'geoip:es' },
|
|
||||||
{ label: '🇮🇩 Indonesia', value: 'geoip:id' },
|
|
||||||
{ label: '🇺🇦 Ukraine', value: 'geoip:ua' },
|
|
||||||
{ label: '🇹🇷 Türkiye', value: 'geoip:tr' },
|
|
||||||
{ label: '🇧🇷 Brazil', value: 'geoip:br' },
|
|
||||||
];
|
|
||||||
export const DOMAINS_OPTIONS = [
|
|
||||||
{ label: '🇮🇷 Iran', value: 'ext:geosite_IR.dat:ir' },
|
|
||||||
{ label: '🇮🇷 .ir', value: 'regexp:.*\\.ir$' },
|
|
||||||
{ label: '🇮🇷 .ایران', value: 'regexp:.*\\.xn--mgba3a4f16a$' },
|
|
||||||
{ label: '🇨🇳 China', value: 'geosite:cn' },
|
|
||||||
{ label: '🇨🇳 .cn', value: 'regexp:.*\\.cn$' },
|
|
||||||
{ label: '🇷🇺 Russia', value: 'ext:geosite_RU.dat:ru-available-only-inside' },
|
|
||||||
{ label: '🇷🇺 .ru', value: 'regexp:.*\\.ru$' },
|
|
||||||
{ label: '🇷🇺 .su', value: 'regexp:.*\\.su$' },
|
|
||||||
{ label: '🇷🇺 .рф', value: 'regexp:.*\\.xn--p1ai$' },
|
|
||||||
{ label: '🇻🇳 .vn', value: 'regexp:.*\\.vn$' },
|
|
||||||
];
|
|
||||||
export const BLOCK_DOMAINS_OPTIONS = [
|
|
||||||
{ label: 'Ads All', value: 'geosite:category-ads-all' },
|
|
||||||
{ label: 'Ads IR 🇮🇷', value: 'ext:geosite_IR.dat:category-ads-all' },
|
|
||||||
{ label: 'Ads RU 🇷🇺', value: 'ext:geosite_RU.dat:category-ads-all' },
|
|
||||||
{ label: 'Malware 🇮🇷', value: 'ext:geosite_IR.dat:malware' },
|
|
||||||
{ label: 'Phishing 🇮🇷', value: 'ext:geosite_IR.dat:phishing' },
|
|
||||||
{ label: 'Cryptominers 🇮🇷', value: 'ext:geosite_IR.dat:cryptominers' },
|
|
||||||
{ label: 'Adult +18', value: 'geosite:category-porn' },
|
|
||||||
{ label: '🇮🇷 Iran', value: 'ext:geosite_IR.dat:ir' },
|
|
||||||
{ label: '🇮🇷 .ir', value: 'regexp:.*\\.ir$' },
|
|
||||||
{ label: '🇮🇷 .ایران', value: 'regexp:.*\\.xn--mgba3a4f16a$' },
|
|
||||||
{ label: '🇨🇳 China', value: 'geosite:cn' },
|
|
||||||
{ label: '🇨🇳 .cn', value: 'regexp:.*\\.cn$' },
|
|
||||||
{ label: '🇷🇺 Russia', value: 'ext:geosite_RU.dat:ru-available-only-inside' },
|
|
||||||
{ label: '🇷🇺 .ru', value: 'regexp:.*\\.ru$' },
|
|
||||||
{ label: '🇷🇺 .su', value: 'regexp:.*\\.su$' },
|
|
||||||
{ label: '🇷🇺 .рф', value: 'regexp:.*\\.xn--p1ai$' },
|
|
||||||
{ label: '🇻🇳 .vn', value: 'regexp:.*\\.vn$' },
|
|
||||||
];
|
|
||||||
export const SERVICES_OPTIONS = [
|
|
||||||
{ label: 'Apple', value: 'geosite:apple' },
|
|
||||||
{ label: 'Meta', value: 'geosite:meta' },
|
|
||||||
{ label: 'Google', value: 'geosite:google' },
|
|
||||||
{ label: 'OpenAI', value: 'geosite:openai' },
|
|
||||||
{ label: 'Spotify', value: 'geosite:spotify' },
|
|
||||||
{ label: 'Netflix', value: 'geosite:netflix' },
|
|
||||||
{ label: 'Reddit', value: 'geosite:reddit' },
|
|
||||||
{ label: 'Speedtest', value: 'geosite:speedtest' },
|
|
||||||
];
|
|
||||||
|
|
||||||
export const directSettings = { tag: 'direct', protocol: 'freedom' };
|
|
||||||
export const blockedSettings = { tag: 'blocked', protocol: 'blackhole', settings: {} };
|
|
||||||
export const ipv4Settings = {
|
|
||||||
tag: 'IPv4',
|
|
||||||
protocol: 'freedom',
|
|
||||||
settings: { domainStrategy: 'UseIPv4' },
|
|
||||||
};
|
|
||||||
@@ -1,121 +0,0 @@
|
|||||||
import type { XraySettingsValue } from '@/hooks/useXraySetting';
|
|
||||||
import { blockedSettings, directSettings } from './constants';
|
|
||||||
|
|
||||||
export function ruleGetter(
|
|
||||||
t: XraySettingsValue | null,
|
|
||||||
outboundTag: string,
|
|
||||||
property: string,
|
|
||||||
): string[] {
|
|
||||||
if (!t?.routing?.rules) return [];
|
|
||||||
const out: string[] = [];
|
|
||||||
for (const rule of t.routing.rules) {
|
|
||||||
if (
|
|
||||||
rule &&
|
|
||||||
Object.prototype.hasOwnProperty.call(rule, property) &&
|
|
||||||
Object.prototype.hasOwnProperty.call(rule, 'outboundTag') &&
|
|
||||||
rule.outboundTag === outboundTag
|
|
||||||
) {
|
|
||||||
const v = (rule as Record<string, unknown>)[property];
|
|
||||||
if (Array.isArray(v)) out.push(...(v as string[]));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ruleSetter(
|
|
||||||
t: XraySettingsValue,
|
|
||||||
outboundTag: string,
|
|
||||||
property: string,
|
|
||||||
data: string[],
|
|
||||||
): void {
|
|
||||||
if (!t.routing) return;
|
|
||||||
if (!Array.isArray(t.routing.rules)) t.routing.rules = [];
|
|
||||||
const current = ruleGetter(t, outboundTag, property);
|
|
||||||
if (current.length === 0) {
|
|
||||||
t.routing.rules.push({ type: 'field', outboundTag, [property]: data });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const next: typeof t.routing.rules = [];
|
|
||||||
let inserted = false;
|
|
||||||
for (const rule of t.routing.rules) {
|
|
||||||
const matches =
|
|
||||||
rule &&
|
|
||||||
Object.prototype.hasOwnProperty.call(rule, property) &&
|
|
||||||
Object.prototype.hasOwnProperty.call(rule, 'outboundTag') &&
|
|
||||||
rule.outboundTag === outboundTag;
|
|
||||||
if (matches) {
|
|
||||||
if (!inserted && data.length > 0) {
|
|
||||||
(rule as Record<string, unknown>)[property] = data;
|
|
||||||
next.push(rule);
|
|
||||||
inserted = true;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
next.push(rule);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
t.routing.rules = next;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function syncOutbound(t: XraySettingsValue, tag: string, settings: Record<string, unknown>) {
|
|
||||||
if (!t.outbounds || !t.routing) return;
|
|
||||||
const rules = t.routing.rules || [];
|
|
||||||
const haveRules = rules.some((r) => r?.outboundTag === tag);
|
|
||||||
const idx = t.outbounds.findIndex((o) => o?.tag === tag);
|
|
||||||
if (!haveRules && idx > 0) t.outbounds.splice(idx, 1);
|
|
||||||
if (haveRules && idx < 0) t.outbounds.push(settings as never);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getDefaultOutboundTag(t: XraySettingsValue | null): string {
|
|
||||||
const tag = t?.outbounds?.[0]?.tag;
|
|
||||||
return typeof tag === 'string' && tag.length > 0 ? tag : 'direct';
|
|
||||||
}
|
|
||||||
|
|
||||||
export function setDefaultOutboundTag(t: XraySettingsValue, tag: string): void {
|
|
||||||
if (!tag) return;
|
|
||||||
if (!Array.isArray(t.outbounds)) t.outbounds = [];
|
|
||||||
const idx = t.outbounds.findIndex((o) => o?.tag === tag);
|
|
||||||
if (idx < 0) {
|
|
||||||
if (tag === 'direct') t.outbounds.push(directSettings as never);
|
|
||||||
else if (tag === 'blocked') t.outbounds.push(blockedSettings as never);
|
|
||||||
else return;
|
|
||||||
const newIdx = t.outbounds.length - 1;
|
|
||||||
const [moved] = t.outbounds.splice(newIdx, 1);
|
|
||||||
t.outbounds.unshift(moved);
|
|
||||||
} else if (idx > 0) {
|
|
||||||
const [moved] = t.outbounds.splice(idx, 1);
|
|
||||||
t.outbounds.unshift(moved);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function propagateOutboundTagRename(
|
|
||||||
t: XraySettingsValue,
|
|
||||||
oldTag: string,
|
|
||||||
newTag: string,
|
|
||||||
): void {
|
|
||||||
if (!oldTag || !newTag || oldTag === newTag) return;
|
|
||||||
|
|
||||||
const rules = t.routing?.rules;
|
|
||||||
if (Array.isArray(rules)) {
|
|
||||||
for (const rule of rules) {
|
|
||||||
if (rule?.outboundTag === oldTag) rule.outboundTag = newTag;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const balancers = t.routing?.balancers;
|
|
||||||
if (Array.isArray(balancers)) {
|
|
||||||
for (const balancer of balancers) {
|
|
||||||
if (balancer?.fallbackTag === oldTag) balancer.fallbackTag = newTag;
|
|
||||||
if (Array.isArray(balancer?.selector)) {
|
|
||||||
balancer.selector = balancer.selector.map((sel) => (sel === oldTag ? newTag : sel));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Array.isArray(t.outbounds)) {
|
|
||||||
for (const outbound of t.outbounds) {
|
|
||||||
const sockopt = (outbound as { streamSettings?: { sockopt?: { dialerProxy?: string } } })
|
|
||||||
?.streamSettings?.sockopt;
|
|
||||||
if (sockopt?.dialerProxy === oldTag) sockopt.dialerProxy = newTag;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export { default as BasicsTab } from './BasicsTab';
|
|
||||||
@@ -1,141 +0,0 @@
|
|||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { Button, Dropdown, Tag, Tooltip } from 'antd';
|
|
||||||
import {
|
|
||||||
RetweetOutlined,
|
|
||||||
MoreOutlined,
|
|
||||||
EditOutlined,
|
|
||||||
DeleteOutlined,
|
|
||||||
VerticalAlignTopOutlined,
|
|
||||||
ExportOutlined,
|
|
||||||
} from '@ant-design/icons';
|
|
||||||
|
|
||||||
import { SizeFormatter } from '@/utils';
|
|
||||||
import { OutboundProtocols as Protocols } from '@/schemas/primitives';
|
|
||||||
import type { OutboundTrafficRow } from '@/hooks/useXraySetting';
|
|
||||||
|
|
||||||
import type { OutboundRow } from './outbounds-tab-types';
|
|
||||||
import { outboundAddresses, showSecurity, trafficFor } from './outbounds-tab-helpers';
|
|
||||||
|
|
||||||
interface OutboundCardListProps {
|
|
||||||
rows: OutboundRow[];
|
|
||||||
outboundsTraffic: OutboundTrafficRow[];
|
|
||||||
setFirst: (idx: number) => void;
|
|
||||||
openEdit: (idx: number) => void;
|
|
||||||
onResetTraffic: (tag: string) => void;
|
|
||||||
confirmDelete: (idx: number) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function OutboundCardList({
|
|
||||||
rows,
|
|
||||||
outboundsTraffic,
|
|
||||||
setFirst,
|
|
||||||
openEdit,
|
|
||||||
onResetTraffic,
|
|
||||||
confirmDelete,
|
|
||||||
}: OutboundCardListProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
if (rows.length === 0) {
|
|
||||||
return (
|
|
||||||
<div className="card-empty">
|
|
||||||
<ExportOutlined style={{ fontSize: 32, marginBottom: 8 }} aria-hidden="true" />
|
|
||||||
<div>{t('noData')}</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{rows.map((record, index) => (
|
|
||||||
<div key={record.key} className="outbound-card">
|
|
||||||
<div className="card-head">
|
|
||||||
<div className="card-identity">
|
|
||||||
<span className="card-num">{index + 1}</span>
|
|
||||||
<Tooltip title={record.tag}>
|
|
||||||
<span className="tag-name">{record.tag}</span>
|
|
||||||
</Tooltip>
|
|
||||||
<Tag color="green">{record.protocol}</Tag>
|
|
||||||
{[Protocols.VMess, Protocols.VLESS, Protocols.Trojan, Protocols.Shadowsocks].includes(
|
|
||||||
record.protocol as never,
|
|
||||||
) && (
|
|
||||||
<>
|
|
||||||
<Tag>{record.streamSettings?.network}</Tag>
|
|
||||||
{showSecurity(record.streamSettings?.security) && (
|
|
||||||
<Tag color="purple">{record.streamSettings?.security}</Tag>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<Dropdown
|
|
||||||
trigger={['click']}
|
|
||||||
menu={{
|
|
||||||
items: [
|
|
||||||
...(index > 0
|
|
||||||
? [
|
|
||||||
{
|
|
||||||
key: 'top',
|
|
||||||
label: (
|
|
||||||
<>
|
|
||||||
<VerticalAlignTopOutlined /> {t('pages.xray.outbound.moveToTop')}
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
onClick: () => setFirst(index),
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: []),
|
|
||||||
{
|
|
||||||
key: 'edit',
|
|
||||||
label: (
|
|
||||||
<>
|
|
||||||
<EditOutlined /> {t('edit')}
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
onClick: () => openEdit(index),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'reset',
|
|
||||||
label: (
|
|
||||||
<>
|
|
||||||
<RetweetOutlined /> {t('pages.inbounds.resetTraffic')}
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
onClick: () => onResetTraffic(record.tag || ''),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'del',
|
|
||||||
danger: true,
|
|
||||||
label: (
|
|
||||||
<>
|
|
||||||
<DeleteOutlined /> {t('delete')}
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
onClick: () => confirmDelete(index),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Button shape="circle" size="small" icon={<MoreOutlined />} aria-label={t('more')} />
|
|
||||||
</Dropdown>
|
|
||||||
</div>
|
|
||||||
{outboundAddresses(record).length > 0 && (
|
|
||||||
<div className="address-list">
|
|
||||||
{outboundAddresses(record).map((addr) => (
|
|
||||||
<Tooltip key={addr} title={addr}>
|
|
||||||
<span className="address-pill">{addr}</span>
|
|
||||||
</Tooltip>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="card-foot">
|
|
||||||
<span className="traffic-up">
|
|
||||||
↑ {SizeFormatter.sizeFormat(trafficFor(outboundsTraffic, record).up)}
|
|
||||||
</span>
|
|
||||||
<span className="traffic-sep" />
|
|
||||||
<span className="traffic-down">
|
|
||||||
↓ {SizeFormatter.sizeFormat(trafficFor(outboundsTraffic, record).down)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
.item-heading {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 8px;
|
|
||||||
font-weight: 500;
|
|
||||||
opacity: 0.85;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sniffing-options {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 8px 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sniffing-options .ant-checkbox-wrapper {
|
|
||||||
margin-inline-start: 0;
|
|
||||||
}
|
|
||||||
@@ -1,598 +0,0 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { Form, Input, InputNumber, Modal, Radio, Select, Space, Tabs, message } from 'antd';
|
|
||||||
import { Controller, FormProvider, useForm, useWatch } from 'react-hook-form';
|
|
||||||
import { FinalMaskField, SniffingField } from '@/lib/xray/forms/fields';
|
|
||||||
import { FormField, rhfZodValidate } from '@/components/form/rhf';
|
|
||||||
import { JsonEditor } from '@/components/form';
|
|
||||||
import { Wireguard } from '@/utils';
|
|
||||||
import { formValuesToWirePayload, rawOutboundToFormValues } from '@/lib/xray/outbound-form-adapter';
|
|
||||||
import { parseOutboundLink } from '@/lib/xray/outbound-link-parser';
|
|
||||||
import { XMUX_FRESH_DEFAULTS } from '@/schemas/protocols/stream/xhttp';
|
|
||||||
import { OutboundFormBaseSchema, type OutboundFormValues } from '@/schemas/forms/outbound-form';
|
|
||||||
import {
|
|
||||||
canEnableReality,
|
|
||||||
canEnableStream,
|
|
||||||
canEnableTls,
|
|
||||||
canEnableTlsFlow,
|
|
||||||
} from '@/lib/xray/protocol-capabilities';
|
|
||||||
|
|
||||||
import {
|
|
||||||
FLOW_OPTIONS,
|
|
||||||
HYSTERIA_NETWORK_OPTION,
|
|
||||||
NETWORK_OPTIONS,
|
|
||||||
PROTOCOL_OPTIONS,
|
|
||||||
SERVER_PROTOCOLS,
|
|
||||||
TARGET_STRATEGY_OPTIONS,
|
|
||||||
} from './outbound-form-constants';
|
|
||||||
import {
|
|
||||||
applyNetworkChange,
|
|
||||||
buildAddModeValues,
|
|
||||||
hysteriaStreamSlice,
|
|
||||||
newStreamSlice,
|
|
||||||
} from './outbound-form-helpers';
|
|
||||||
import {
|
|
||||||
BlackholeFields,
|
|
||||||
DnsFields,
|
|
||||||
FreedomFields,
|
|
||||||
HttpFields,
|
|
||||||
LoopbackFields,
|
|
||||||
ServerTarget,
|
|
||||||
ShadowsocksFields,
|
|
||||||
SocksFields,
|
|
||||||
TrojanFields,
|
|
||||||
VlessFields,
|
|
||||||
VmessFields,
|
|
||||||
WireguardFields,
|
|
||||||
} from './protocols';
|
|
||||||
import {
|
|
||||||
GrpcForm,
|
|
||||||
HttpUpgradeForm,
|
|
||||||
HysteriaForm,
|
|
||||||
KcpForm,
|
|
||||||
MuxForm,
|
|
||||||
RawForm,
|
|
||||||
SockoptForm,
|
|
||||||
WsForm,
|
|
||||||
XhttpForm,
|
|
||||||
} from './transport';
|
|
||||||
import { RealityForm, TlsForm } from './security';
|
|
||||||
import './OutboundFormModal.css';
|
|
||||||
|
|
||||||
type StreamValue = OutboundFormValues['streamSettings'];
|
|
||||||
|
|
||||||
interface OutboundFormModalProps {
|
|
||||||
open: boolean;
|
|
||||||
outbound: Record<string, unknown> | null;
|
|
||||||
existingTags: string[];
|
|
||||||
dialerProxyTags?: string[];
|
|
||||||
onClose: () => void;
|
|
||||||
onConfirm: (outbound: Record<string, unknown>) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function OutboundFormModal({
|
|
||||||
open,
|
|
||||||
outbound: outboundProp,
|
|
||||||
existingTags,
|
|
||||||
dialerProxyTags,
|
|
||||||
onClose,
|
|
||||||
onConfirm,
|
|
||||||
}: OutboundFormModalProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [messageApi, messageContextHolder] = message.useMessage();
|
|
||||||
const methods = useForm<OutboundFormValues>({ defaultValues: buildAddModeValues() });
|
|
||||||
const [activeKey, setActiveKey] = useState('1');
|
|
||||||
const [jsonText, setJsonText] = useState('');
|
|
||||||
const [jsonDirty, setJsonDirty] = useState(false);
|
|
||||||
const [linkInput, setLinkInput] = useState('');
|
|
||||||
|
|
||||||
const isEdit = outboundProp != null;
|
|
||||||
const title = isEdit
|
|
||||||
? `${t('edit')} ${t('pages.xray.Outbounds')}`
|
|
||||||
: `+ ${t('pages.xray.Outbounds')}`;
|
|
||||||
const okText = isEdit ? t('pages.clients.submitEdit') : t('create');
|
|
||||||
|
|
||||||
const tag = (useWatch({ control: methods.control, name: 'tag' }) ?? '') as string;
|
|
||||||
const protocol = (useWatch({ control: methods.control, name: 'protocol' }) ?? 'vless') as string;
|
|
||||||
const network = (useWatch({ control: methods.control, name: 'streamSettings.network' }) ??
|
|
||||||
'') as string;
|
|
||||||
const security = (useWatch({ control: methods.control, name: 'streamSettings.security' }) ??
|
|
||||||
'none') as string;
|
|
||||||
const flow = (useWatch({ control: methods.control, name: 'settings.flow' }) ?? '') as string;
|
|
||||||
const reverseTag = useWatch({ control: methods.control, name: 'settings.reverseTag' });
|
|
||||||
const wgSecretKey = useWatch({ control: methods.control, name: 'settings.secretKey' }) as
|
|
||||||
| string
|
|
||||||
| undefined;
|
|
||||||
|
|
||||||
const streamAllowed = canEnableStream({ protocol });
|
|
||||||
const tlsAllowed = canEnableTls({ protocol, streamSettings: { network, security } });
|
|
||||||
const realityAllowed = canEnableReality({ protocol, streamSettings: { network, security } });
|
|
||||||
const tlsFlowAllowed = canEnableTlsFlow({ protocol, streamSettings: { network, security } });
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Parse a share link (vmess:// / vless:// / trojan:// / ss:// /
|
|
||||||
* hysteria2:// / wireguard://) and replace form state with the result.
|
|
||||||
* The current tag is preserved when the parsed link doesn't carry one.
|
|
||||||
*/
|
|
||||||
function importLink() {
|
|
||||||
const link = linkInput.trim();
|
|
||||||
if (!link) return;
|
|
||||||
const parsed = parseOutboundLink(link);
|
|
||||||
if (!parsed) {
|
|
||||||
messageApi.error('Wrong Link!');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const currentTag = methods.getValues('tag');
|
|
||||||
if (!parsed.tag && currentTag) parsed.tag = currentTag;
|
|
||||||
const next = rawOutboundToFormValues(parsed);
|
|
||||||
methods.reset(next);
|
|
||||||
setJsonText(JSON.stringify(formValuesToWirePayload(next), null, 2));
|
|
||||||
setJsonDirty(false);
|
|
||||||
setLinkInput('');
|
|
||||||
messageApi.success('Link imported successfully');
|
|
||||||
switchTab('1');
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) return;
|
|
||||||
const initial = outboundProp ? rawOutboundToFormValues(outboundProp) : buildAddModeValues();
|
|
||||||
methods.reset(initial);
|
|
||||||
setActiveKey('1');
|
|
||||||
setJsonText(JSON.stringify(formValuesToWirePayload(initial), null, 2));
|
|
||||||
setJsonDirty(false);
|
|
||||||
}, [open, outboundProp, methods]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!streamAllowed) return;
|
|
||||||
/*
|
|
||||||
* Wireguard dials its own UDP — only finalmask/sockopt apply, never a
|
|
||||||
* transport. Don't seed network 'tcp'; clear a leftover one (from a
|
|
||||||
* protocol switch) so the transmission/security blocks stay hidden.
|
|
||||||
*/
|
|
||||||
if (protocol === 'wireguard') {
|
|
||||||
if (network) methods.setValue('streamSettings', { security: 'none' } as StreamValue);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (network) return;
|
|
||||||
methods.setValue('streamSettings', {
|
|
||||||
...newStreamSlice('tcp'),
|
|
||||||
security: 'none',
|
|
||||||
} as StreamValue);
|
|
||||||
}, [streamAllowed, network, protocol, methods]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (protocol !== 'hysteria') return;
|
|
||||||
if (network === 'hysteria' && security === 'tls') return;
|
|
||||||
const existing = (methods.getValues('streamSettings') ?? {}) as Record<string, unknown>;
|
|
||||||
const slice = hysteriaStreamSlice();
|
|
||||||
if (existing.hysteriaSettings) slice.hysteriaSettings = existing.hysteriaSettings;
|
|
||||||
if (existing.tlsSettings) slice.tlsSettings = existing.tlsSettings;
|
|
||||||
methods.setValue('streamSettings', slice as StreamValue);
|
|
||||||
}, [protocol, network, security, methods]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (protocol !== 'wireguard') return;
|
|
||||||
const sk = (wgSecretKey ?? '').trim();
|
|
||||||
if (!sk) {
|
|
||||||
methods.setValue('settings.pubKey', '');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const { publicKey } = Wireguard.generateKeypair(sk);
|
|
||||||
methods.setValue('settings.pubKey', publicKey);
|
|
||||||
} catch {
|
|
||||||
methods.setValue('settings.pubKey', '');
|
|
||||||
}
|
|
||||||
}, [protocol, wgSecretKey, methods]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
/* eslint-disable-next-line react-hooks/incompatible-library */
|
|
||||||
const sub = methods.watch((_value, { name, type }) => {
|
|
||||||
if (name !== 'protocol' || type !== 'change') return;
|
|
||||||
const nextProtocol = methods.getValues('protocol');
|
|
||||||
const next = rawOutboundToFormValues({ protocol: nextProtocol });
|
|
||||||
methods.setValue('settings', next.settings);
|
|
||||||
if (nextProtocol === 'hysteria') {
|
|
||||||
methods.setValue('streamSettings', hysteriaStreamSlice() as StreamValue);
|
|
||||||
} else if ((methods.getValues('streamSettings.network') ?? '') === 'hysteria') {
|
|
||||||
methods.setValue('streamSettings', {
|
|
||||||
...newStreamSlice('tcp'),
|
|
||||||
security: 'none',
|
|
||||||
} as StreamValue);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return () => sub.unsubscribe();
|
|
||||||
}, [methods]);
|
|
||||||
|
|
||||||
function onSecurityChange(next: string) {
|
|
||||||
const stream = (methods.getValues('streamSettings') ?? {}) as Record<string, unknown>;
|
|
||||||
const cleaned = { ...stream };
|
|
||||||
delete cleaned.tlsSettings;
|
|
||||||
delete cleaned.realitySettings;
|
|
||||||
if (next === 'tls') {
|
|
||||||
cleaned.tlsSettings = {
|
|
||||||
serverName: '',
|
|
||||||
alpn: [],
|
|
||||||
fingerprint: '',
|
|
||||||
echConfigList: '',
|
|
||||||
verifyPeerCertByName: '',
|
|
||||||
pinnedPeerCertSha256: '',
|
|
||||||
};
|
|
||||||
} else if (next === 'reality') {
|
|
||||||
cleaned.realitySettings = {
|
|
||||||
publicKey: '',
|
|
||||||
fingerprint: 'chrome',
|
|
||||||
serverName: '',
|
|
||||||
shortId: '',
|
|
||||||
spiderX: '',
|
|
||||||
mldsa65Verify: '',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
cleaned.security = next;
|
|
||||||
methods.setValue('streamSettings', cleaned as StreamValue);
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Network change cascade: swap the per-network sub-key (tcpSettings,
|
|
||||||
* wsSettings, etc.) so the DU branch matches. Preserve security if
|
|
||||||
* the new network supports it, otherwise force back to 'none'.
|
|
||||||
*/
|
|
||||||
function onNetworkChange(next: string) {
|
|
||||||
const stream = (methods.getValues('streamSettings') ?? {}) as Record<string, unknown>;
|
|
||||||
methods.setValue('streamSettings', applyNetworkChange(protocol, stream, next) as StreamValue);
|
|
||||||
}
|
|
||||||
|
|
||||||
function onXmuxToggle(checked: boolean) {
|
|
||||||
if (!checked) return;
|
|
||||||
const existing = methods.getValues('streamSettings.xhttpSettings.xmux');
|
|
||||||
const hasValues = existing && typeof existing === 'object' && Object.keys(existing).length > 0;
|
|
||||||
if (hasValues) return;
|
|
||||||
methods.setValue('streamSettings.xhttpSettings.xmux', { ...XMUX_FRESH_DEFAULTS });
|
|
||||||
}
|
|
||||||
|
|
||||||
const duplicateTag = useMemo(() => {
|
|
||||||
const myTag = tag.trim();
|
|
||||||
if (!myTag) return false;
|
|
||||||
if (isEdit && (outboundProp?.tag as string | undefined) === myTag) return false;
|
|
||||||
return (existingTags || []).includes(myTag);
|
|
||||||
}, [tag, existingTags, isEdit, outboundProp]);
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Bridge form <-> JSON tab: when leaving the JSON tab back to Basic, push
|
|
||||||
* any edits into form state. When entering JSON tab, snapshot current
|
|
||||||
* form values so the user sees the live shape.
|
|
||||||
*/
|
|
||||||
function applyJsonToForm(): boolean {
|
|
||||||
if (!jsonDirty) return true;
|
|
||||||
const raw = jsonText.trim();
|
|
||||||
if (!raw) return true;
|
|
||||||
let parsed: Record<string, unknown>;
|
|
||||||
try {
|
|
||||||
parsed = JSON.parse(raw) as Record<string, unknown>;
|
|
||||||
} catch (e) {
|
|
||||||
messageApi.error(`JSON: ${(e as Error).message}`);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const next = rawOutboundToFormValues(parsed);
|
|
||||||
methods.reset(next);
|
|
||||||
setJsonDirty(false);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function switchTab(key: string) {
|
|
||||||
if (typeof document !== 'undefined') {
|
|
||||||
(document.activeElement as HTMLElement | null)?.blur?.();
|
|
||||||
}
|
|
||||||
setActiveKey(key);
|
|
||||||
}
|
|
||||||
|
|
||||||
function onTabChange(key: string) {
|
|
||||||
if (key === '2') {
|
|
||||||
const values = methods.getValues();
|
|
||||||
setJsonText(JSON.stringify(formValuesToWirePayload(values), null, 2));
|
|
||||||
setJsonDirty(false);
|
|
||||||
switchTab(key);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (key === '1' && activeKey === '2') {
|
|
||||||
if (!applyJsonToForm()) return;
|
|
||||||
}
|
|
||||||
switchTab(key);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function onOk() {
|
|
||||||
let values: OutboundFormValues;
|
|
||||||
if (activeKey === '2') {
|
|
||||||
const raw = jsonText.trim();
|
|
||||||
if (!raw) return;
|
|
||||||
let parsed: Record<string, unknown>;
|
|
||||||
try {
|
|
||||||
parsed = JSON.parse(raw) as Record<string, unknown>;
|
|
||||||
} catch (e) {
|
|
||||||
messageApi.error(`JSON: ${(e as Error).message}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
values = rawOutboundToFormValues(parsed);
|
|
||||||
methods.reset(values);
|
|
||||||
setJsonDirty(false);
|
|
||||||
} else {
|
|
||||||
if (!(await methods.trigger())) return;
|
|
||||||
values = methods.getValues();
|
|
||||||
}
|
|
||||||
const tagValue = (values.tag ?? '').trim();
|
|
||||||
if (!tagValue) {
|
|
||||||
messageApi.error(t('pages.xray.outboundForm.tagRequired'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (tagValue.startsWith('_bl_')) {
|
|
||||||
messageApi.error(t('pages.xray.balancer.reservedPrefix'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const isDuplicateTag =
|
|
||||||
(existingTags || []).includes(tagValue) &&
|
|
||||||
!(isEdit && (outboundProp?.tag as string | undefined) === tagValue);
|
|
||||||
if (isDuplicateTag) {
|
|
||||||
messageApi.error('Tag already used by another outbound');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
onConfirm(formValuesToWirePayload(values));
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{messageContextHolder}
|
|
||||||
<Modal
|
|
||||||
open={open}
|
|
||||||
title={title}
|
|
||||||
okText={okText}
|
|
||||||
cancelText={t('close')}
|
|
||||||
mask={{ closable: false }}
|
|
||||||
width={780}
|
|
||||||
onOk={onOk}
|
|
||||||
onCancel={onClose}
|
|
||||||
destroyOnHidden
|
|
||||||
>
|
|
||||||
<FormProvider {...methods}>
|
|
||||||
<Form
|
|
||||||
colon={false}
|
|
||||||
labelCol={{ md: { span: 8 } }}
|
|
||||||
wrapperCol={{ md: { span: 14 } }}
|
|
||||||
labelWrap
|
|
||||||
>
|
|
||||||
<Tabs
|
|
||||||
activeKey={activeKey}
|
|
||||||
onChange={onTabChange}
|
|
||||||
items={[
|
|
||||||
{
|
|
||||||
key: '1',
|
|
||||||
label: t('pages.xray.basicTemplate'),
|
|
||||||
children: (
|
|
||||||
<>
|
|
||||||
<FormField
|
|
||||||
label={t('protocol')}
|
|
||||||
name="protocol"
|
|
||||||
rules={{ validate: rhfZodValidate(OutboundFormBaseSchema.shape.tag) }}
|
|
||||||
>
|
|
||||||
<Select id="protocol" options={PROTOCOL_OPTIONS} />
|
|
||||||
</FormField>
|
|
||||||
|
|
||||||
<Controller
|
|
||||||
control={methods.control}
|
|
||||||
name="tag"
|
|
||||||
rules={{ required: 'pages.xray.outboundForm.tagRequired' }}
|
|
||||||
render={({ field, fieldState }) => {
|
|
||||||
const errorMessage = fieldState.error?.message
|
|
||||||
? t(fieldState.error.message, {
|
|
||||||
defaultValue: fieldState.error.message,
|
|
||||||
})
|
|
||||||
: '';
|
|
||||||
return (
|
|
||||||
<Form.Item
|
|
||||||
label={t('pages.xray.outbound.tag')}
|
|
||||||
required
|
|
||||||
validateStatus={
|
|
||||||
errorMessage ? 'error' : duplicateTag ? 'warning' : undefined
|
|
||||||
}
|
|
||||||
help={
|
|
||||||
errorMessage ||
|
|
||||||
(duplicateTag
|
|
||||||
? t('pages.xray.outboundForm.tagDuplicate')
|
|
||||||
: undefined)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
value={field.value}
|
|
||||||
onChange={(e) => field.onChange(e.target.value)}
|
|
||||||
onBlur={field.onBlur}
|
|
||||||
ref={field.ref}
|
|
||||||
placeholder={t('pages.xray.outboundForm.tagPlaceholder')}
|
|
||||||
/>
|
|
||||||
</Form.Item>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField label={t('pages.xray.outbound.sendThrough')} name="sendThrough">
|
|
||||||
<Input placeholder={t('pages.xray.outboundForm.localIpPlaceholder')} />
|
|
||||||
</FormField>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
label={t('pages.xray.outbound.targetStrategy')}
|
|
||||||
name="targetStrategy"
|
|
||||||
tooltip={t('pages.xray.outboundForm.targetStrategyHint')}
|
|
||||||
>
|
|
||||||
<Select allowClear placeholder="AsIs" options={TARGET_STRATEGY_OPTIONS} />
|
|
||||||
</FormField>
|
|
||||||
|
|
||||||
{SERVER_PROTOCOLS.has(protocol) && <ServerTarget />}
|
|
||||||
{protocol === 'vmess' && <VmessFields />}
|
|
||||||
{protocol === 'vless' && <VlessFields />}
|
|
||||||
{protocol === 'trojan' && <TrojanFields />}
|
|
||||||
{protocol === 'shadowsocks' && <ShadowsocksFields />}
|
|
||||||
{protocol === 'http' && <HttpFields />}
|
|
||||||
{protocol === 'socks' && <SocksFields />}
|
|
||||||
|
|
||||||
{protocol === 'loopback' && <LoopbackFields />}
|
|
||||||
{protocol === 'blackhole' && <BlackholeFields />}
|
|
||||||
{protocol === 'dns' && <DnsFields />}
|
|
||||||
|
|
||||||
{protocol === 'freedom' && <FreedomFields />}
|
|
||||||
|
|
||||||
{protocol === 'vless' && reverseTag && (
|
|
||||||
<Controller
|
|
||||||
control={methods.control}
|
|
||||||
name="settings.reverseSniffing"
|
|
||||||
render={({ field }) => (
|
|
||||||
<SniffingField
|
|
||||||
value={field.value}
|
|
||||||
onChange={field.onChange}
|
|
||||||
enableLabel={t('pages.xray.outboundForm.reverseSniffing')}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{protocol === 'wireguard' && <WireguardFields />}
|
|
||||||
|
|
||||||
{streamAllowed && network && (
|
|
||||||
<>
|
|
||||||
<Form.Item label={t('transmission')}>
|
|
||||||
<Select
|
|
||||||
value={network}
|
|
||||||
onChange={onNetworkChange}
|
|
||||||
options={
|
|
||||||
protocol === 'hysteria'
|
|
||||||
? [HYSTERIA_NETWORK_OPTION]
|
|
||||||
: NETWORK_OPTIONS
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
{network === 'tcp' && <RawForm />}
|
|
||||||
|
|
||||||
{network === 'kcp' && <KcpForm />}
|
|
||||||
|
|
||||||
{network === 'ws' && <WsForm />}
|
|
||||||
|
|
||||||
{network === 'grpc' && <GrpcForm />}
|
|
||||||
|
|
||||||
{network === 'httpupgrade' && <HttpUpgradeForm />}
|
|
||||||
|
|
||||||
{network === 'xhttp' && <XhttpForm onXmuxToggle={onXmuxToggle} />}
|
|
||||||
|
|
||||||
{network === 'hysteria' && <HysteriaForm />}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{tlsFlowAllowed && (
|
|
||||||
<FormField label={t('pages.clients.flow')} name={['settings', 'flow']}>
|
|
||||||
<Select
|
|
||||||
allowClear
|
|
||||||
placeholder={t('none')}
|
|
||||||
options={[{ value: '', label: t('none') }, ...FLOW_OPTIONS]}
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Vision seed knobs only meaningful for the exact
|
|
||||||
xtls-rprx-vision flow, on TCP+(tls|reality). */}
|
|
||||||
{tlsFlowAllowed && flow === 'xtls-rprx-vision' && (
|
|
||||||
<>
|
|
||||||
<FormField
|
|
||||||
label={t('pages.xray.outboundForm.visionTestpre')}
|
|
||||||
name={['settings', 'testpre']}
|
|
||||||
>
|
|
||||||
<InputNumber min={0} style={{ width: '100%' }} />
|
|
||||||
</FormField>
|
|
||||||
<Form.Item label={t('pages.inbounds.form.visionTestseed')}>
|
|
||||||
<Space.Compact block>
|
|
||||||
{[0, 1, 2, 3].map((i) => (
|
|
||||||
<FormField key={i} name={['settings', 'testseed', i]} noStyle>
|
|
||||||
<InputNumber min={1} style={{ width: '25%' }} />
|
|
||||||
</FormField>
|
|
||||||
))}
|
|
||||||
</Space.Compact>
|
|
||||||
</Form.Item>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{streamAllowed && network && (
|
|
||||||
<Form.Item label={t('security')}>
|
|
||||||
<Radio.Group
|
|
||||||
value={security}
|
|
||||||
buttonStyle="solid"
|
|
||||||
onChange={(e) => onSecurityChange(e.target.value as string)}
|
|
||||||
>
|
|
||||||
{network !== 'hysteria' && (
|
|
||||||
<Radio.Button value="none">{t('none')}</Radio.Button>
|
|
||||||
)}
|
|
||||||
{tlsAllowed && <Radio.Button value="tls">TLS</Radio.Button>}
|
|
||||||
{realityAllowed && <Radio.Button value="reality">Reality</Radio.Button>}
|
|
||||||
</Radio.Group>
|
|
||||||
</Form.Item>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{security === 'tls' && tlsAllowed && <TlsForm />}
|
|
||||||
|
|
||||||
{security === 'reality' && realityAllowed && <RealityForm />}
|
|
||||||
|
|
||||||
{((streamAllowed && network) ||
|
|
||||||
!streamAllowed ||
|
|
||||||
protocol === 'wireguard') && (
|
|
||||||
<SockoptForm outboundTags={dialerProxyTags ?? existingTags} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Controller
|
|
||||||
control={methods.control}
|
|
||||||
name="streamSettings.finalmask"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FinalMaskField
|
|
||||||
key={`${protocol}:${network}`}
|
|
||||||
value={field.value}
|
|
||||||
onChange={field.onChange}
|
|
||||||
network={network}
|
|
||||||
protocol={protocol}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<MuxForm protocol={protocol} network={network} />
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: '2',
|
|
||||||
label: 'JSON',
|
|
||||||
children: (
|
|
||||||
<Space
|
|
||||||
orientation="vertical"
|
|
||||||
size={10}
|
|
||||||
style={{ width: '100%', marginTop: 10 }}
|
|
||||||
>
|
|
||||||
<Input.Search
|
|
||||||
value={linkInput}
|
|
||||||
placeholder="vmess:// vless:// trojan:// ss:// hysteria2:// wireguard://"
|
|
||||||
enterButton="Import"
|
|
||||||
onChange={(e) => setLinkInput(e.target.value)}
|
|
||||||
onSearch={importLink}
|
|
||||||
/>
|
|
||||||
<JsonEditor
|
|
||||||
value={jsonText}
|
|
||||||
onChange={(next) => {
|
|
||||||
setJsonText(next);
|
|
||||||
setJsonDirty(true);
|
|
||||||
}}
|
|
||||||
minHeight="360px"
|
|
||||||
maxHeight="600px"
|
|
||||||
/>
|
|
||||||
</Space>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</Form>
|
|
||||||
</FormProvider>
|
|
||||||
</Modal>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,276 +0,0 @@
|
|||||||
.toolbar-right {
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-end;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Keep this in sync with the other pages' .card-empty (it's a global class):
|
|
||||||
the previous opacity:0.4 here leaked onto whichever page's empty state was
|
|
||||||
shown after the Outbounds CSS loaded, fading it. */
|
|
||||||
.card-empty {
|
|
||||||
text-align: center;
|
|
||||||
color: var(--ant-color-text-secondary);
|
|
||||||
padding: 24px 12px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.outbound-card {
|
|
||||||
border: 1px solid var(--ant-color-border-secondary);
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 12px;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-head {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-identity {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-num {
|
|
||||||
font-weight: 500;
|
|
||||||
opacity: 0.7;
|
|
||||||
min-width: 18px;
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tag-name {
|
|
||||||
font-weight: 500;
|
|
||||||
max-width: 200px;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
display: inline-block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.protocol-line {
|
|
||||||
display: inline-flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.address-list {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.address-pill {
|
|
||||||
font-size: 11px;
|
|
||||||
padding: 2px 6px;
|
|
||||||
border-radius: 4px;
|
|
||||||
background: var(--ant-color-fill-tertiary);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
.warp-cloud-icon {
|
|
||||||
width: 1em;
|
|
||||||
height: 1em;
|
|
||||||
color: #f48120;
|
|
||||||
flex: 0 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.warp-cloud-icon svg {
|
|
||||||
fill: #f48120;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ip-toggle-icon {
|
|
||||||
cursor: pointer;
|
|
||||||
opacity: 0.65;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ip-toggle-icon:hover {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.address-hidden {
|
|
||||||
filter: blur(5px);
|
|
||||||
transition: filter 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.address-visible {
|
|
||||||
filter: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.action-cell {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.row-index {
|
|
||||||
font-weight: 500;
|
|
||||||
opacity: 0.7;
|
|
||||||
min-width: 18px;
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
||||||
|
|
||||||
.action-buttons {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: flex-end;
|
|
||||||
gap: 4px;
|
|
||||||
margin-left: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.identity-cell {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 4px;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-foot {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.traffic-up {
|
|
||||||
color: #008771;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.traffic-down {
|
|
||||||
color: #3c89e8;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.traffic-sep {
|
|
||||||
display: inline-block;
|
|
||||||
width: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pill-ok,
|
|
||||||
.pill-fail {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
|
||||||
padding: 1px 8px;
|
|
||||||
border-radius: 12px;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pill-ok {
|
|
||||||
color: #008771;
|
|
||||||
background: rgba(0, 135, 113, 0.12);
|
|
||||||
}
|
|
||||||
|
|
||||||
.pill-fail {
|
|
||||||
color: #e04141;
|
|
||||||
background: rgba(224, 65, 65, 0.12);
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty {
|
|
||||||
opacity: 0.4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.outbound-test-popover .timing-breakdown {
|
|
||||||
font-size: 12px;
|
|
||||||
line-height: 1.6;
|
|
||||||
min-width: 180px;
|
|
||||||
max-width: 320px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.outbound-test-popover .td-head {
|
|
||||||
font-weight: 600;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.outbound-test-popover .td-head.ok {
|
|
||||||
color: #008771;
|
|
||||||
}
|
|
||||||
|
|
||||||
.outbound-test-popover .td-head.fail {
|
|
||||||
color: #e04141;
|
|
||||||
}
|
|
||||||
|
|
||||||
.outbound-test-popover .mode-badge {
|
|
||||||
font-size: 10px;
|
|
||||||
font-weight: 500;
|
|
||||||
padding: 0 6px;
|
|
||||||
border-radius: 8px;
|
|
||||||
background: color-mix(in srgb, var(--ant-color-primary) 12%, transparent);
|
|
||||||
color: var(--ant-color-primary);
|
|
||||||
margin-left: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.outbound-test-popover .endpoint-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
font-size: 11px;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.outbound-test-popover .endpoint-row .ep-addr {
|
|
||||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.outbound-test-popover .endpoint-row .ep-meta {
|
|
||||||
opacity: 0.75;
|
|
||||||
}
|
|
||||||
|
|
||||||
.outbound-test-popover .dot-ok {
|
|
||||||
color: #008771;
|
|
||||||
}
|
|
||||||
|
|
||||||
.outbound-test-popover .dot-fail {
|
|
||||||
color: #e04141;
|
|
||||||
}
|
|
||||||
|
|
||||||
.outbound-test-popover .breakdown-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
font-size: 11px;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.outbound-test-popover .breakdown-row .bd-label {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
opacity: 0.85;
|
|
||||||
}
|
|
||||||
|
|
||||||
.outbound-test-popover .breakdown-row .bd-value {
|
|
||||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
|
||||||
opacity: 0.75;
|
|
||||||
}
|
|
||||||
|
|
||||||
.subscription-outbounds-head {
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.subscription-outbounds-title {
|
|
||||||
font-weight: 600;
|
|
||||||
margin-bottom: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.subscription-outbounds-desc {
|
|
||||||
font-size: 12px;
|
|
||||||
opacity: 0.7;
|
|
||||||
}
|
|
||||||
@@ -1,935 +0,0 @@
|
|||||||
import { useCallback, useMemo, useRef, useState } from 'react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import {
|
|
||||||
Button,
|
|
||||||
Col,
|
|
||||||
Dropdown,
|
|
||||||
Form,
|
|
||||||
Input,
|
|
||||||
InputNumber,
|
|
||||||
Modal,
|
|
||||||
Popconfirm,
|
|
||||||
Row,
|
|
||||||
Space,
|
|
||||||
Switch,
|
|
||||||
Table,
|
|
||||||
Tag,
|
|
||||||
Tooltip,
|
|
||||||
message,
|
|
||||||
} from 'antd';
|
|
||||||
import {
|
|
||||||
PlusOutlined,
|
|
||||||
CloudOutlined,
|
|
||||||
ApiOutlined,
|
|
||||||
MoreOutlined,
|
|
||||||
RetweetOutlined,
|
|
||||||
ReloadOutlined,
|
|
||||||
DeleteOutlined,
|
|
||||||
EditOutlined,
|
|
||||||
EyeOutlined,
|
|
||||||
ArrowUpOutlined,
|
|
||||||
ArrowDownOutlined,
|
|
||||||
CheckCircleOutlined,
|
|
||||||
WarningOutlined,
|
|
||||||
ExportOutlined,
|
|
||||||
ImportOutlined,
|
|
||||||
} from '@ant-design/icons';
|
|
||||||
|
|
||||||
import { HttpUtil } from '@/utils';
|
|
||||||
import { onNumber } from '@/utils/onNumber';
|
|
||||||
import PromptModal from '@/components/feedback/PromptModal';
|
|
||||||
import TextModal from '@/components/feedback/TextModal';
|
|
||||||
|
|
||||||
import OutboundFormModal from './OutboundFormModal';
|
|
||||||
import { propagateOutboundTagRename } from '../basics/helpers';
|
|
||||||
import { planOutboundDeletion, applyOutboundDeletion } from '../reference-cleanup';
|
|
||||||
import DeletionImpactList from '../DeletionImpactList';
|
|
||||||
import { isBalancerLoopbackTag } from '../balancers/balancer-loopback';
|
|
||||||
import type { XraySettingsValue, SetTemplate, OutboundTrafficRow } from '@/hooks/useXraySetting';
|
|
||||||
import './OutboundsTab.css';
|
|
||||||
|
|
||||||
import type { OutboundRow } from './outbounds-tab-types';
|
|
||||||
import { originalOutboundIndex } from './outbounds-tab-helpers';
|
|
||||||
import { useOutboundColumns } from './useOutboundColumns';
|
|
||||||
import OutboundCardList from './OutboundCardList';
|
|
||||||
import SubscriptionOutbounds from './SubscriptionOutbounds';
|
|
||||||
|
|
||||||
interface OutboundSub {
|
|
||||||
id: number;
|
|
||||||
remark?: string;
|
|
||||||
url?: string;
|
|
||||||
enabled?: boolean;
|
|
||||||
allowPrivate?: boolean;
|
|
||||||
allowInsecure?: boolean;
|
|
||||||
prepend?: boolean;
|
|
||||||
priority?: number;
|
|
||||||
tagPrefix?: string;
|
|
||||||
updateInterval?: number;
|
|
||||||
lastUpdated?: number;
|
|
||||||
lastError?: string;
|
|
||||||
outboundCount?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface OutboundsTabProps {
|
|
||||||
templateSettings: XraySettingsValue | null;
|
|
||||||
setTemplateSettings: SetTemplate;
|
|
||||||
outboundsTraffic: OutboundTrafficRow[];
|
|
||||||
inboundTags: string[];
|
|
||||||
subscriptionOutbounds?: unknown[];
|
|
||||||
subscriptionOutboundTags?: string[];
|
|
||||||
isMobile: boolean;
|
|
||||||
onResetTraffic: (tag: string) => void;
|
|
||||||
onShowWarp: () => void;
|
|
||||||
onShowNord: () => void;
|
|
||||||
onShowPia: () => void;
|
|
||||||
onRefreshXrayData?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function OutboundsTab({
|
|
||||||
templateSettings,
|
|
||||||
setTemplateSettings,
|
|
||||||
outboundsTraffic,
|
|
||||||
inboundTags: _inboundTags,
|
|
||||||
subscriptionOutbounds,
|
|
||||||
subscriptionOutboundTags,
|
|
||||||
isMobile,
|
|
||||||
onResetTraffic,
|
|
||||||
onShowWarp,
|
|
||||||
onShowNord,
|
|
||||||
onShowPia,
|
|
||||||
onRefreshXrayData,
|
|
||||||
}: OutboundsTabProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [modal, modalContextHolder] = Modal.useModal();
|
|
||||||
const [messageApi, messageContextHolder] = message.useMessage();
|
|
||||||
const [modalOpen, setModalOpen] = useState(false);
|
|
||||||
const [editingOutbound, setEditingOutbound] = useState<Record<string, unknown> | null>(null);
|
|
||||||
const [editingIndex, setEditingIndex] = useState<number | null>(null);
|
|
||||||
const [existingTags, setExistingTags] = useState<string[]>([]);
|
|
||||||
|
|
||||||
// Subscription manager (CRUD + reorder + refresh + preview)
|
|
||||||
const [subDrawerOpen, setSubDrawerOpen] = useState(false);
|
|
||||||
const [subs, setSubs] = useState<OutboundSub[]>([]);
|
|
||||||
const [subsLoading, setSubsLoading] = useState(false);
|
|
||||||
const [newSub, setNewSub] = useState({
|
|
||||||
remark: '',
|
|
||||||
url: '',
|
|
||||||
tagPrefix: '',
|
|
||||||
updateInterval: 600,
|
|
||||||
enabled: true,
|
|
||||||
allowPrivate: false,
|
|
||||||
allowInsecure: false,
|
|
||||||
prepend: false,
|
|
||||||
});
|
|
||||||
const [editingSubId, setEditingSubId] = useState<number | null>(null);
|
|
||||||
const [savingSub, setSavingSub] = useState(false);
|
|
||||||
const [refreshingId, setRefreshingId] = useState<number | null>(null);
|
|
||||||
const [refreshingAll, setRefreshingAll] = useState(false);
|
|
||||||
const [busyId, setBusyId] = useState<number | null>(null);
|
|
||||||
const [previewing, setPreviewing] = useState(false);
|
|
||||||
const [previewData, setPreviewData] = useState<{ tag?: string; protocol?: string }[] | null>(
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Convenience: expose hours/minutes for the interval input
|
|
||||||
const intervalHours = Math.floor((newSub.updateInterval || 600) / 3600);
|
|
||||||
const intervalMinutes = Math.floor(((newSub.updateInterval || 600) % 3600) / 60);
|
|
||||||
function setIntervalHM(h: number, m: number) {
|
|
||||||
const secs = Math.max(60, (h || 0) * 3600 + (m || 0) * 60);
|
|
||||||
setNewSub((prev) => ({ ...prev, updateInterval: secs }));
|
|
||||||
}
|
|
||||||
|
|
||||||
const outbounds = useMemo(
|
|
||||||
() => (templateSettings?.outbounds || []) as unknown as OutboundRow[],
|
|
||||||
[templateSettings?.outbounds],
|
|
||||||
);
|
|
||||||
|
|
||||||
const rows = useMemo(
|
|
||||||
() =>
|
|
||||||
outbounds
|
|
||||||
.map((o, i) => ({ ...o, key: i }))
|
|
||||||
.filter((o) => !isBalancerLoopbackTag(o.tag || '')),
|
|
||||||
[outbounds],
|
|
||||||
);
|
|
||||||
const rowsRef = useRef<OutboundRow[]>([]);
|
|
||||||
rowsRef.current = rows;
|
|
||||||
|
|
||||||
const dialerProxyTags = useMemo(() => {
|
|
||||||
const tags = new Set<string>();
|
|
||||||
(templateSettings?.outbounds || []).forEach((o, i) => {
|
|
||||||
if (i === editingIndex) return;
|
|
||||||
if (o?.protocol === 'blackhole') return;
|
|
||||||
if (o?.tag) tags.add(o.tag);
|
|
||||||
});
|
|
||||||
for (const tag of subscriptionOutboundTags || []) {
|
|
||||||
if (tag) tags.add(tag);
|
|
||||||
}
|
|
||||||
return [...tags];
|
|
||||||
}, [templateSettings?.outbounds, editingIndex, subscriptionOutboundTags]);
|
|
||||||
|
|
||||||
const mutate = useCallback(
|
|
||||||
(mutator: (next: XraySettingsValue) => void) => {
|
|
||||||
setTemplateSettings((prev) => {
|
|
||||||
if (!prev) return prev;
|
|
||||||
const clone = JSON.parse(JSON.stringify(prev)) as XraySettingsValue;
|
|
||||||
mutator(clone);
|
|
||||||
return clone;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[setTemplateSettings],
|
|
||||||
);
|
|
||||||
|
|
||||||
function openAdd() {
|
|
||||||
setEditingOutbound(null);
|
|
||||||
setEditingIndex(null);
|
|
||||||
setExistingTags(
|
|
||||||
(templateSettings?.outbounds || []).map((o) => o?.tag).filter((tg): tg is string => !!tg),
|
|
||||||
);
|
|
||||||
setModalOpen(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
function openSubManager() {
|
|
||||||
setSubDrawerOpen(true);
|
|
||||||
loadSubs();
|
|
||||||
}
|
|
||||||
function openEdit(idx: number) {
|
|
||||||
const target = originalOutboundIndex(rowsRef.current, idx);
|
|
||||||
setEditingOutbound((templateSettings?.outbounds || [])[target] as Record<string, unknown>);
|
|
||||||
setEditingIndex(target);
|
|
||||||
setExistingTags(
|
|
||||||
(templateSettings?.outbounds || [])
|
|
||||||
.filter((_, i) => i !== target)
|
|
||||||
.map((o) => o?.tag)
|
|
||||||
.filter((tg): tg is string => !!tg),
|
|
||||||
);
|
|
||||||
setModalOpen(true);
|
|
||||||
}
|
|
||||||
function onConfirm(outbound: Record<string, unknown>) {
|
|
||||||
mutate((tt) => {
|
|
||||||
if (!Array.isArray(tt.outbounds)) tt.outbounds = [];
|
|
||||||
const newTag = typeof outbound.tag === 'string' ? outbound.tag : '';
|
|
||||||
if (editingIndex == null) {
|
|
||||||
if (!newTag) return;
|
|
||||||
tt.outbounds.push(outbound as never);
|
|
||||||
} else {
|
|
||||||
const oldTag = tt.outbounds[editingIndex]?.tag;
|
|
||||||
tt.outbounds[editingIndex] = outbound as never;
|
|
||||||
if (oldTag && newTag && oldTag !== newTag) {
|
|
||||||
propagateOutboundTagRename(tt, oldTag, newTag);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
setModalOpen(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
function confirmDelete(idx: number) {
|
|
||||||
const target = originalOutboundIndex(rowsRef.current, idx);
|
|
||||||
const impact = templateSettings
|
|
||||||
? planOutboundDeletion(templateSettings, target)
|
|
||||||
: { rules: [], balancers: [], observatory: false, burst: false };
|
|
||||||
modal.confirm({
|
|
||||||
title: `${t('delete')} ${t('pages.xray.Outbounds')} #${idx + 1}?`,
|
|
||||||
content: <DeletionImpactList impact={impact} />,
|
|
||||||
okText: t('delete'),
|
|
||||||
okType: 'danger',
|
|
||||||
cancelText: t('cancel'),
|
|
||||||
onOk: () => mutate((tt) => applyOutboundDeletion(tt, target)),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
function setFirst(idx: number) {
|
|
||||||
const target = originalOutboundIndex(rowsRef.current, idx);
|
|
||||||
mutate((tt) => {
|
|
||||||
if (!tt.outbounds) return;
|
|
||||||
const [moved] = tt.outbounds.splice(target, 1);
|
|
||||||
tt.outbounds.unshift(moved);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
function moveUp(idx: number) {
|
|
||||||
if (idx <= 0) return;
|
|
||||||
const target = originalOutboundIndex(rowsRef.current, idx);
|
|
||||||
const prev = originalOutboundIndex(rowsRef.current, idx - 1);
|
|
||||||
mutate((tt) => {
|
|
||||||
if (!tt.outbounds) return;
|
|
||||||
[tt.outbounds[prev], tt.outbounds[target]] = [tt.outbounds[target], tt.outbounds[prev]];
|
|
||||||
});
|
|
||||||
}
|
|
||||||
function moveDown(idx: number) {
|
|
||||||
if (idx >= rowsRef.current.length - 1) return;
|
|
||||||
const target = originalOutboundIndex(rowsRef.current, idx);
|
|
||||||
const next = originalOutboundIndex(rowsRef.current, idx + 1);
|
|
||||||
mutate((tt) => {
|
|
||||||
if (!tt.outbounds) return;
|
|
||||||
[tt.outbounds[next], tt.outbounds[target]] = [tt.outbounds[target], tt.outbounds[next]];
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const [importOpen, setImportOpen] = useState(false);
|
|
||||||
const [exportOpen, setExportOpen] = useState(false);
|
|
||||||
const [exportContent, setExportContent] = useState('');
|
|
||||||
|
|
||||||
function exportOutbounds() {
|
|
||||||
setExportContent(JSON.stringify(outbounds, null, 2));
|
|
||||||
setExportOpen(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
function importOutbounds(value: string) {
|
|
||||||
let parsed: unknown;
|
|
||||||
try {
|
|
||||||
parsed = JSON.parse(value);
|
|
||||||
} catch {
|
|
||||||
messageApi.error(t('pages.xray.importInvalidJson'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const obj = parsed as { outbounds?: unknown };
|
|
||||||
const list = Array.isArray(parsed)
|
|
||||||
? parsed
|
|
||||||
: Array.isArray(obj?.outbounds)
|
|
||||||
? obj.outbounds
|
|
||||||
: null;
|
|
||||||
if (!list) {
|
|
||||||
messageApi.error(t('pages.xray.importInvalidJson'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
mutate((tt) => {
|
|
||||||
if (!Array.isArray(tt.outbounds)) tt.outbounds = [];
|
|
||||||
tt.outbounds.push(...(list as never[]));
|
|
||||||
});
|
|
||||||
setImportOpen(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Subscription management (minimal inline UI) ---
|
|
||||||
async function loadSubs() {
|
|
||||||
setSubsLoading(true);
|
|
||||||
try {
|
|
||||||
const r = await HttpUtil.get('/panel/api/xray/outbound-subs');
|
|
||||||
if (r?.success) setSubs(Array.isArray(r.obj) ? r.obj : []);
|
|
||||||
} catch {
|
|
||||||
messageApi.error(t('pages.xray.outboundSub.toastLoadFailed'));
|
|
||||||
} finally {
|
|
||||||
setSubsLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
function subBody(src: {
|
|
||||||
remark?: string;
|
|
||||||
url?: string;
|
|
||||||
tagPrefix?: string;
|
|
||||||
updateInterval?: number;
|
|
||||||
enabled?: boolean;
|
|
||||||
allowPrivate?: boolean;
|
|
||||||
allowInsecure?: boolean;
|
|
||||||
prepend?: boolean;
|
|
||||||
}) {
|
|
||||||
return {
|
|
||||||
remark: src.remark ?? '',
|
|
||||||
url: src.url ?? '',
|
|
||||||
tagPrefix: src.tagPrefix ?? '',
|
|
||||||
updateInterval: src.updateInterval ?? 600,
|
|
||||||
enabled: src.enabled ?? true,
|
|
||||||
allowPrivate: src.allowPrivate ?? false,
|
|
||||||
allowInsecure: src.allowInsecure ?? false,
|
|
||||||
prepend: src.prepend ?? false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
function resetSubForm() {
|
|
||||||
setNewSub({
|
|
||||||
remark: '',
|
|
||||||
url: '',
|
|
||||||
tagPrefix: '',
|
|
||||||
updateInterval: 600,
|
|
||||||
enabled: true,
|
|
||||||
allowPrivate: false,
|
|
||||||
allowInsecure: false,
|
|
||||||
prepend: false,
|
|
||||||
});
|
|
||||||
setEditingSubId(null);
|
|
||||||
setPreviewData(null);
|
|
||||||
}
|
|
||||||
function openEditSub(sub: OutboundSub) {
|
|
||||||
setNewSub({
|
|
||||||
remark: sub.remark ?? '',
|
|
||||||
url: sub.url ?? '',
|
|
||||||
tagPrefix: sub.tagPrefix ?? '',
|
|
||||||
updateInterval: sub.updateInterval ?? 600,
|
|
||||||
enabled: sub.enabled ?? true,
|
|
||||||
allowPrivate: sub.allowPrivate ?? false,
|
|
||||||
allowInsecure: sub.allowInsecure ?? false,
|
|
||||||
prepend: sub.prepend ?? false,
|
|
||||||
});
|
|
||||||
setEditingSubId(sub.id);
|
|
||||||
setPreviewData(null);
|
|
||||||
}
|
|
||||||
async function saveSub() {
|
|
||||||
if (!newSub.url.trim()) {
|
|
||||||
messageApi.warning(t('pages.xray.outboundSub.toastUrlRequired'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setSavingSub(true);
|
|
||||||
try {
|
|
||||||
const url =
|
|
||||||
editingSubId != null
|
|
||||||
? `/panel/api/xray/outbound-subs/${editingSubId}`
|
|
||||||
: '/panel/api/xray/outbound-subs';
|
|
||||||
const r = await HttpUtil.post<OutboundSub>(url, subBody(newSub));
|
|
||||||
if (r?.success) {
|
|
||||||
messageApi.success(
|
|
||||||
t(
|
|
||||||
editingSubId != null
|
|
||||||
? 'pages.xray.outboundSub.toastUpdated'
|
|
||||||
: 'pages.xray.outboundSub.toastAdded',
|
|
||||||
),
|
|
||||||
);
|
|
||||||
const createdId = editingSubId == null ? r.obj?.id : undefined;
|
|
||||||
resetSubForm();
|
|
||||||
await loadSubs();
|
|
||||||
if (createdId) await refreshOne(createdId);
|
|
||||||
onRefreshXrayData?.();
|
|
||||||
} else {
|
|
||||||
messageApi.error(r?.msg || t('pages.xray.outboundSub.toastAddFailed'));
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
messageApi.error(t('pages.xray.outboundSub.toastAddFailed'));
|
|
||||||
} finally {
|
|
||||||
setSavingSub(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
async function previewSub() {
|
|
||||||
if (!newSub.url.trim()) {
|
|
||||||
messageApi.warning(t('pages.xray.outboundSub.toastUrlRequired'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setPreviewing(true);
|
|
||||||
setPreviewData(null);
|
|
||||||
try {
|
|
||||||
const r = await HttpUtil.post<{ tag?: string; protocol?: string }[]>(
|
|
||||||
'/panel/api/xray/outbound-subs/parse',
|
|
||||||
{ url: newSub.url, allowPrivate: newSub.allowPrivate },
|
|
||||||
);
|
|
||||||
if (r?.success && Array.isArray(r.obj)) {
|
|
||||||
setPreviewData(r.obj);
|
|
||||||
if (r.obj.length === 0) messageApi.info(t('pages.xray.outboundSub.previewEmpty'));
|
|
||||||
} else {
|
|
||||||
messageApi.error(r?.msg || t('pages.xray.outboundSub.previewEmpty'));
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
messageApi.error(t('pages.xray.outboundSub.previewEmpty'));
|
|
||||||
} finally {
|
|
||||||
setPreviewing(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
async function toggleEnabled(sub: OutboundSub) {
|
|
||||||
setBusyId(sub.id);
|
|
||||||
try {
|
|
||||||
const r = await HttpUtil.post(
|
|
||||||
`/panel/api/xray/outbound-subs/${sub.id}`,
|
|
||||||
subBody({ ...sub, enabled: !sub.enabled }),
|
|
||||||
);
|
|
||||||
if (r?.success) {
|
|
||||||
await loadSubs();
|
|
||||||
onRefreshXrayData?.();
|
|
||||||
} else {
|
|
||||||
messageApi.error(r?.msg || t('pages.xray.outboundSub.toastAddFailed'));
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
messageApi.error(t('pages.xray.outboundSub.toastAddFailed'));
|
|
||||||
} finally {
|
|
||||||
setBusyId(null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
async function moveSub(id: number, dir: 'up' | 'down') {
|
|
||||||
setBusyId(id);
|
|
||||||
try {
|
|
||||||
const r = await HttpUtil.post(`/panel/api/xray/outbound-subs/${id}/move`, { dir });
|
|
||||||
if (r?.success) {
|
|
||||||
await loadSubs();
|
|
||||||
onRefreshXrayData?.();
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
/* ignore */
|
|
||||||
} finally {
|
|
||||||
setBusyId(null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
async function refreshOne(id: number) {
|
|
||||||
setRefreshingId(id);
|
|
||||||
try {
|
|
||||||
const r = await HttpUtil.post(`/panel/api/xray/outbound-subs/${id}/refresh`);
|
|
||||||
if (r?.success) {
|
|
||||||
messageApi.success(t('pages.xray.outboundSub.toastRefreshed'));
|
|
||||||
await loadSubs();
|
|
||||||
onRefreshXrayData?.();
|
|
||||||
} else {
|
|
||||||
messageApi.error(r?.msg || t('pages.xray.outboundSub.toastRefreshFailed'));
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
messageApi.error(t('pages.xray.outboundSub.toastRefreshFailed'));
|
|
||||||
} finally {
|
|
||||||
setRefreshingId(null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
async function refreshAllSubs() {
|
|
||||||
if (subs.length === 0) return;
|
|
||||||
setRefreshingAll(true);
|
|
||||||
try {
|
|
||||||
for (const s of subs) {
|
|
||||||
try {
|
|
||||||
await HttpUtil.post(`/panel/api/xray/outbound-subs/${s.id}/refresh`);
|
|
||||||
} catch {
|
|
||||||
/* continue */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
messageApi.success(t('pages.xray.outboundSub.toastRefreshed'));
|
|
||||||
await loadSubs();
|
|
||||||
onRefreshXrayData?.();
|
|
||||||
} finally {
|
|
||||||
setRefreshingAll(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
async function deleteOne(id: number) {
|
|
||||||
try {
|
|
||||||
const r = await HttpUtil.post(`/panel/api/xray/outbound-subs/${id}/del`);
|
|
||||||
if (r?.success) {
|
|
||||||
messageApi.success(t('pages.xray.outboundSub.toastDeleted'));
|
|
||||||
await loadSubs();
|
|
||||||
onRefreshXrayData?.();
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
messageApi.error(t('pages.xray.outboundSub.toastDeleteFailed'));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const columns = useOutboundColumns({
|
|
||||||
rows,
|
|
||||||
outboundsTraffic,
|
|
||||||
openEdit,
|
|
||||||
setFirst,
|
|
||||||
moveUp,
|
|
||||||
moveDown,
|
|
||||||
confirmDelete,
|
|
||||||
onResetTraffic,
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{modalContextHolder}
|
|
||||||
{messageContextHolder}
|
|
||||||
<Space orientation="vertical" size="middle" style={{ width: '100%' }}>
|
|
||||||
<Row gutter={[12, 12]} align="middle" justify="space-between">
|
|
||||||
<Col xs={24} sm={12}>
|
|
||||||
<Space size="small" wrap>
|
|
||||||
<Button type="primary" icon={<PlusOutlined />} onClick={openAdd}>
|
|
||||||
{!isMobile && t('pages.xray.Outbounds')}
|
|
||||||
</Button>
|
|
||||||
<Button icon={<CloudOutlined />} onClick={openSubManager}>
|
|
||||||
{t('pages.xray.outboundSub.manage')}
|
|
||||||
</Button>
|
|
||||||
<Dropdown
|
|
||||||
trigger={['click']}
|
|
||||||
menu={{
|
|
||||||
items: [
|
|
||||||
{ key: 'warp', icon: <CloudOutlined />, label: 'WARP', onClick: onShowWarp },
|
|
||||||
{ key: 'nord', icon: <ApiOutlined />, label: 'NordVPN', onClick: onShowNord },
|
|
||||||
{
|
|
||||||
key: 'pia',
|
|
||||||
icon: <ApiOutlined />,
|
|
||||||
label: t('pages.xray.pia.menu'),
|
|
||||||
onClick: onShowPia,
|
|
||||||
},
|
|
||||||
{ type: 'divider' },
|
|
||||||
{
|
|
||||||
key: 'import',
|
|
||||||
icon: <ImportOutlined />,
|
|
||||||
label: t('pages.xray.importOutbounds'),
|
|
||||||
onClick: () => setImportOpen(true),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'export',
|
|
||||||
icon: <ExportOutlined />,
|
|
||||||
label: t('pages.xray.exportOutbounds'),
|
|
||||||
disabled: outbounds.length === 0,
|
|
||||||
onClick: exportOutbounds,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Button icon={<MoreOutlined />}>{t('more')}</Button>
|
|
||||||
</Dropdown>
|
|
||||||
</Space>
|
|
||||||
</Col>
|
|
||||||
<Col xs={24} sm={12} className="toolbar-right">
|
|
||||||
<Space size="small" wrap>
|
|
||||||
<Popconfirm
|
|
||||||
placement="topRight"
|
|
||||||
okText={t('reset')}
|
|
||||||
cancelText={t('cancel')}
|
|
||||||
title={t('pages.inbounds.resetAllTrafficContent')}
|
|
||||||
onConfirm={() => onResetTraffic('-alltags-')}
|
|
||||||
>
|
|
||||||
<Button aria-label={t('pages.inbounds.resetTraffic')} icon={<RetweetOutlined />} />
|
|
||||||
</Popconfirm>
|
|
||||||
</Space>
|
|
||||||
</Col>
|
|
||||||
</Row>
|
|
||||||
|
|
||||||
{isMobile ? (
|
|
||||||
<OutboundCardList
|
|
||||||
rows={rows}
|
|
||||||
outboundsTraffic={outboundsTraffic}
|
|
||||||
setFirst={setFirst}
|
|
||||||
openEdit={openEdit}
|
|
||||||
onResetTraffic={onResetTraffic}
|
|
||||||
confirmDelete={confirmDelete}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<Table
|
|
||||||
columns={columns}
|
|
||||||
dataSource={rows}
|
|
||||||
rowKey={(r) => r.key}
|
|
||||||
pagination={false}
|
|
||||||
size="small"
|
|
||||||
locale={{
|
|
||||||
emptyText: (
|
|
||||||
<div className="card-empty">
|
|
||||||
<ExportOutlined style={{ fontSize: 32, marginBottom: 8 }} />
|
|
||||||
<div>{t('noData')}</div>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<OutboundFormModal
|
|
||||||
open={modalOpen}
|
|
||||||
outbound={editingOutbound}
|
|
||||||
existingTags={existingTags}
|
|
||||||
dialerProxyTags={dialerProxyTags}
|
|
||||||
onClose={() => setModalOpen(false)}
|
|
||||||
onConfirm={onConfirm}
|
|
||||||
/>
|
|
||||||
<PromptModal
|
|
||||||
open={importOpen}
|
|
||||||
onClose={() => setImportOpen(false)}
|
|
||||||
title={t('pages.xray.importOutbounds')}
|
|
||||||
okText={t('pages.xray.importOutbounds')}
|
|
||||||
type="textarea"
|
|
||||||
json
|
|
||||||
onConfirm={importOutbounds}
|
|
||||||
/>
|
|
||||||
<TextModal
|
|
||||||
open={exportOpen}
|
|
||||||
onClose={() => setExportOpen(false)}
|
|
||||||
title={t('pages.xray.exportOutbounds')}
|
|
||||||
content={exportContent}
|
|
||||||
fileName="outbounds.json"
|
|
||||||
json
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Subscription outbounds (read-only, merged at runtime) */}
|
|
||||||
{Array.isArray(subscriptionOutbounds) && subscriptionOutbounds.length > 0 && (
|
|
||||||
<SubscriptionOutbounds
|
|
||||||
subscriptionOutbounds={subscriptionOutbounds}
|
|
||||||
outboundsTraffic={outboundsTraffic}
|
|
||||||
isMobile={isMobile}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Space>
|
|
||||||
|
|
||||||
<Modal
|
|
||||||
title={t('pages.xray.outboundSub.title')}
|
|
||||||
open={subDrawerOpen}
|
|
||||||
onCancel={() => setSubDrawerOpen(false)}
|
|
||||||
footer={null}
|
|
||||||
width={isMobile ? '100%' : 640}
|
|
||||||
destroyOnHidden
|
|
||||||
>
|
|
||||||
<Space orientation="vertical" style={{ width: '100%' }} size="large">
|
|
||||||
<div>
|
|
||||||
{editingSubId != null && (
|
|
||||||
<div style={{ marginBottom: 8, display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
||||||
<Tag color="blue">{t('edit')}</Tag>
|
|
||||||
<span style={{ fontWeight: 600 }}>{newSub.remark || newSub.url}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<Form layout="vertical" size="small">
|
|
||||||
<Form.Item label={t('pages.xray.outboundSub.remark')}>
|
|
||||||
<Input
|
|
||||||
value={newSub.remark}
|
|
||||||
onChange={(e) => setNewSub({ ...newSub, remark: e.target.value })}
|
|
||||||
placeholder={t('pages.xray.outboundSub.remarkPlaceholder')}
|
|
||||||
/>
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item label={t('pages.xray.outboundSub.url')} required>
|
|
||||||
<Input
|
|
||||||
value={newSub.url}
|
|
||||||
onChange={(e) => setNewSub({ ...newSub, url: e.target.value })}
|
|
||||||
placeholder={t('pages.xray.outboundSub.urlPlaceholder')}
|
|
||||||
/>
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item label={t('pages.xray.outboundSub.tagPrefix')}>
|
|
||||||
<Input
|
|
||||||
value={newSub.tagPrefix}
|
|
||||||
onChange={(e) => setNewSub({ ...newSub, tagPrefix: e.target.value })}
|
|
||||||
placeholder={t('pages.xray.outboundSub.tagPrefixPlaceholder')}
|
|
||||||
/>
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item label={t('pages.xray.outboundSub.interval')}>
|
|
||||||
<Space>
|
|
||||||
<InputNumber
|
|
||||||
min={0}
|
|
||||||
value={intervalHours}
|
|
||||||
onChange={onNumber((v) => setIntervalHM(v, intervalMinutes))}
|
|
||||||
style={{ width: 80 }}
|
|
||||||
/>{' '}
|
|
||||||
{t('pages.xray.outboundSub.hours')}
|
|
||||||
<InputNumber
|
|
||||||
min={0}
|
|
||||||
max={59}
|
|
||||||
value={intervalMinutes}
|
|
||||||
onChange={onNumber((v) => setIntervalHM(intervalHours, v))}
|
|
||||||
style={{ width: 80 }}
|
|
||||||
/>{' '}
|
|
||||||
{t('pages.xray.outboundSub.minutes')}
|
|
||||||
</Space>
|
|
||||||
<div style={{ fontSize: 12, color: '#888', marginTop: 4 }}>
|
|
||||||
{t('pages.xray.outboundSub.intervalHint')}
|
|
||||||
</div>
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item label={t('pages.xray.outboundSub.enabled')}>
|
|
||||||
<Switch
|
|
||||||
checked={newSub.enabled}
|
|
||||||
onChange={(v) => setNewSub({ ...newSub, enabled: v })}
|
|
||||||
/>
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item label={t('pages.xray.outboundSub.allowPrivate')}>
|
|
||||||
<Switch
|
|
||||||
checked={newSub.allowPrivate}
|
|
||||||
onChange={(v) => setNewSub({ ...newSub, allowPrivate: v })}
|
|
||||||
/>
|
|
||||||
<div style={{ fontSize: 12, color: '#888', marginTop: 4 }}>
|
|
||||||
{t('pages.xray.outboundSub.allowPrivateHint')}
|
|
||||||
</div>
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item label={t('pages.hosts.fields.allowInsecure')}>
|
|
||||||
<Switch
|
|
||||||
checked={newSub.allowInsecure}
|
|
||||||
onChange={(v) => setNewSub({ ...newSub, allowInsecure: v })}
|
|
||||||
/>
|
|
||||||
<div style={{ fontSize: 12, color: '#888', marginTop: 4 }}>
|
|
||||||
{t('pages.hosts.hints.allowInsecure')}
|
|
||||||
</div>
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item label={t('pages.xray.outboundSub.prepend')}>
|
|
||||||
<Switch
|
|
||||||
checked={newSub.prepend}
|
|
||||||
onChange={(v) => setNewSub({ ...newSub, prepend: v })}
|
|
||||||
/>
|
|
||||||
<div style={{ fontSize: 12, color: '#888', marginTop: 4 }}>
|
|
||||||
{t('pages.xray.outboundSub.prependHint')}
|
|
||||||
</div>
|
|
||||||
</Form.Item>
|
|
||||||
<Space wrap>
|
|
||||||
<Button
|
|
||||||
type="primary"
|
|
||||||
onClick={saveSub}
|
|
||||||
loading={savingSub}
|
|
||||||
icon={editingSubId != null ? <EditOutlined /> : <PlusOutlined />}
|
|
||||||
>
|
|
||||||
{editingSubId != null ? t('save') : t('pages.xray.outboundSub.addButton')}
|
|
||||||
</Button>
|
|
||||||
<Button onClick={previewSub} loading={previewing} icon={<EyeOutlined />}>
|
|
||||||
{t('pages.xray.outboundSub.preview')}
|
|
||||||
</Button>
|
|
||||||
{editingSubId != null && <Button onClick={resetSubForm}>{t('cancel')}</Button>}
|
|
||||||
</Space>
|
|
||||||
{previewData && previewData.length > 0 && (
|
|
||||||
<div style={{ marginTop: 8 }}>
|
|
||||||
<div style={{ fontSize: 12, color: '#888', marginBottom: 4 }}>
|
|
||||||
{previewData.length} · {t('pages.xray.Outbounds')}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
flexWrap: 'wrap',
|
|
||||||
gap: 4,
|
|
||||||
maxHeight: 120,
|
|
||||||
overflow: 'auto',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{previewData.map((o, i) => (
|
|
||||||
<Tag key={i}>
|
|
||||||
{o?.tag || '—'}
|
|
||||||
{o?.protocol ? ` · ${o.protocol}` : ''}
|
|
||||||
</Tag>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontWeight: 600,
|
|
||||||
marginBottom: 8,
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 8,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t('pages.xray.outboundSub.active')}
|
|
||||||
<Button
|
|
||||||
aria-label={t('refresh')}
|
|
||||||
size="small"
|
|
||||||
icon={<ReloadOutlined />}
|
|
||||||
onClick={loadSubs}
|
|
||||||
loading={subsLoading}
|
|
||||||
/>
|
|
||||||
{subs.length > 0 && (
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
type="primary"
|
|
||||||
icon={<ReloadOutlined />}
|
|
||||||
onClick={refreshAllSubs}
|
|
||||||
loading={refreshingAll}
|
|
||||||
>
|
|
||||||
{t('pages.xray.outboundSub.refreshAll')}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{subs.length === 0 ? (
|
|
||||||
<div style={{ color: '#888' }}>{t('pages.xray.outboundSub.empty')}</div>
|
|
||||||
) : (
|
|
||||||
<Table
|
|
||||||
size="small"
|
|
||||||
dataSource={subs}
|
|
||||||
rowKey={(r) => r.id}
|
|
||||||
pagination={false}
|
|
||||||
scroll={{ x: true }}
|
|
||||||
columns={[
|
|
||||||
{
|
|
||||||
title: '',
|
|
||||||
key: 'order',
|
|
||||||
width: 56,
|
|
||||||
render: (_: unknown, r: OutboundSub, index: number) => (
|
|
||||||
<Space size={0}>
|
|
||||||
<Button
|
|
||||||
aria-label={t('pages.inbounds.form.moveUp')}
|
|
||||||
type="text"
|
|
||||||
size="small"
|
|
||||||
icon={<ArrowUpOutlined />}
|
|
||||||
disabled={index === 0 || busyId === r.id}
|
|
||||||
onClick={() => moveSub(r.id, 'up')}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
aria-label={t('pages.inbounds.form.moveDown')}
|
|
||||||
type="text"
|
|
||||||
size="small"
|
|
||||||
icon={<ArrowDownOutlined />}
|
|
||||||
disabled={index === subs.length - 1 || busyId === r.id}
|
|
||||||
onClick={() => moveSub(r.id, 'down')}
|
|
||||||
/>
|
|
||||||
</Space>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: t('pages.xray.outboundSub.colRemark'),
|
|
||||||
key: 'remark',
|
|
||||||
render: (_: unknown, r: OutboundSub) => (
|
|
||||||
<div>
|
|
||||||
<div>{r.remark || <em>{t('pages.xray.outboundSub.auto')}</em>}</div>
|
|
||||||
{r.tagPrefix && (
|
|
||||||
<div style={{ fontSize: 11, color: '#888' }}>{r.tagPrefix}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: t('pages.xray.Outbounds'),
|
|
||||||
dataIndex: 'outboundCount',
|
|
||||||
key: 'outboundCount',
|
|
||||||
align: 'center',
|
|
||||||
render: (v) => v ?? 0,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: t('status'),
|
|
||||||
key: 'status',
|
|
||||||
align: 'center',
|
|
||||||
render: (_: unknown, r: OutboundSub) =>
|
|
||||||
r.lastError ? (
|
|
||||||
<Tooltip title={r.lastError}>
|
|
||||||
<WarningOutlined style={{ color: '#e04141' }} />
|
|
||||||
</Tooltip>
|
|
||||||
) : (
|
|
||||||
<Tooltip title={t('pages.xray.outboundSub.statusOk')}>
|
|
||||||
<CheckCircleOutlined style={{ color: '#008771' }} />
|
|
||||||
</Tooltip>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: t('pages.xray.outboundSub.colLastFetch'),
|
|
||||||
dataIndex: 'lastUpdated',
|
|
||||||
key: 'lastUpdated',
|
|
||||||
render: (v: number) =>
|
|
||||||
v ? new Date(v * 1000).toLocaleString() : t('pages.xray.outboundSub.never'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: t('pages.xray.outboundSub.colEnabled'),
|
|
||||||
key: 'enabled',
|
|
||||||
align: 'center',
|
|
||||||
render: (_: unknown, r: OutboundSub) => (
|
|
||||||
<Switch
|
|
||||||
size="small"
|
|
||||||
checked={!!r.enabled}
|
|
||||||
loading={busyId === r.id}
|
|
||||||
onChange={() => toggleEnabled(r)}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '',
|
|
||||||
key: 'actions',
|
|
||||||
render: (_: unknown, r: OutboundSub) => (
|
|
||||||
<Space>
|
|
||||||
<Button
|
|
||||||
aria-label={t('edit')}
|
|
||||||
size="small"
|
|
||||||
icon={<EditOutlined />}
|
|
||||||
onClick={() => openEditSub(r)}
|
|
||||||
title={t('edit')}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
aria-label={t('pages.xray.outboundSub.refreshNow')}
|
|
||||||
size="small"
|
|
||||||
icon={<ReloadOutlined />}
|
|
||||||
loading={refreshingId === r.id}
|
|
||||||
onClick={() => refreshOne(r.id)}
|
|
||||||
title={t('pages.xray.outboundSub.refreshNow')}
|
|
||||||
/>
|
|
||||||
<Popconfirm
|
|
||||||
title={t('pages.xray.outboundSub.deleteConfirm')}
|
|
||||||
okText={t('delete')}
|
|
||||||
cancelText={t('cancel')}
|
|
||||||
onConfirm={() => deleteOne(r.id)}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
aria-label={t('delete')}
|
|
||||||
size="small"
|
|
||||||
danger
|
|
||||||
icon={<DeleteOutlined />}
|
|
||||||
/>
|
|
||||||
</Popconfirm>
|
|
||||||
</Space>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<div style={{ marginTop: 8, fontSize: 12, color: '#666' }}>
|
|
||||||
{t('pages.xray.outboundSub.restartHint')}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Space>
|
|
||||||
</Modal>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,156 +0,0 @@
|
|||||||
import { useMemo } from 'react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { Table, Tag, Tooltip } from 'antd';
|
|
||||||
import type { ColumnsType } from 'antd/es/table';
|
|
||||||
|
|
||||||
import { SizeFormatter } from '@/utils';
|
|
||||||
import { OutboundProtocols as Protocols } from '@/schemas/primitives';
|
|
||||||
import type { OutboundTrafficRow } from '@/hooks/useXraySetting';
|
|
||||||
|
|
||||||
import type { OutboundRow } from './outbounds-tab-types';
|
|
||||||
import { outboundAddresses, showSecurity, trafficFor } from './outbounds-tab-helpers';
|
|
||||||
|
|
||||||
interface SubscriptionOutboundsProps {
|
|
||||||
subscriptionOutbounds: unknown[];
|
|
||||||
outboundsTraffic: OutboundTrafficRow[];
|
|
||||||
isMobile: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read-only view of outbounds imported from active subscriptions. They are not
|
|
||||||
// part of the editable template (so no edit/delete/move), but traffic is matched
|
|
||||||
// by tag.
|
|
||||||
export default function SubscriptionOutbounds({
|
|
||||||
subscriptionOutbounds,
|
|
||||||
outboundsTraffic,
|
|
||||||
isMobile,
|
|
||||||
}: SubscriptionOutboundsProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
const rows = useMemo<OutboundRow[]>(
|
|
||||||
() =>
|
|
||||||
(subscriptionOutbounds || []).map((o, i) => ({ ...(o as object), key: i }) as OutboundRow),
|
|
||||||
[subscriptionOutbounds],
|
|
||||||
);
|
|
||||||
|
|
||||||
if (rows.length === 0) return null;
|
|
||||||
|
|
||||||
const identityCell = (record: OutboundRow) => (
|
|
||||||
<div className="identity-cell">
|
|
||||||
<Tooltip title={record.tag}>
|
|
||||||
<span className="tag-name">{record.tag || '—'}</span>
|
|
||||||
</Tooltip>
|
|
||||||
<div className="protocol-line">
|
|
||||||
<Tag color="green">{record.protocol}</Tag>
|
|
||||||
{[Protocols.VMess, Protocols.VLESS, Protocols.Trojan, Protocols.Shadowsocks].includes(
|
|
||||||
record.protocol as never,
|
|
||||||
) && (
|
|
||||||
<>
|
|
||||||
<Tag>{record.streamSettings?.network}</Tag>
|
|
||||||
{showSecurity(record.streamSettings?.security) && (
|
|
||||||
<Tag color="purple">{record.streamSettings?.security}</Tag>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
const addressCell = (record: OutboundRow) => {
|
|
||||||
const addrs = outboundAddresses(record);
|
|
||||||
return (
|
|
||||||
<div className="address-list">
|
|
||||||
{addrs.length === 0 ? (
|
|
||||||
<span className="empty">—</span>
|
|
||||||
) : (
|
|
||||||
addrs.map((addr) => (
|
|
||||||
<Tooltip key={addr} title={addr}>
|
|
||||||
<span className="address-pill">{addr}</span>
|
|
||||||
</Tooltip>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const trafficCell = (record: OutboundRow) => {
|
|
||||||
const tr = trafficFor(outboundsTraffic, record);
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<span className="traffic-up">↑ {SizeFormatter.sizeFormat(tr.up)}</span>
|
|
||||||
<span className="traffic-sep" />
|
|
||||||
<span className="traffic-down">↓ {SizeFormatter.sizeFormat(tr.down)}</span>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const header = (
|
|
||||||
<div className="subscription-outbounds-head">
|
|
||||||
<div className="subscription-outbounds-title">
|
|
||||||
{t('pages.xray.outboundSub.fromSubsTitle')}
|
|
||||||
</div>
|
|
||||||
<div className="subscription-outbounds-desc">{t('pages.xray.outboundSub.fromSubsDesc')}</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
if (isMobile) {
|
|
||||||
return (
|
|
||||||
<div className="subscription-outbounds" style={{ marginTop: 16 }}>
|
|
||||||
{header}
|
|
||||||
{rows.map((record, index) => (
|
|
||||||
<div key={record.key} className="outbound-card">
|
|
||||||
<div className="card-head">
|
|
||||||
<div className="card-identity">
|
|
||||||
<span className="card-num">{index + 1}</span>
|
|
||||||
{identityCell(record)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{outboundAddresses(record).length > 0 && addressCell(record)}
|
|
||||||
<div className="card-foot">{trafficCell(record)}</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const columns: ColumnsType<OutboundRow> = [
|
|
||||||
{
|
|
||||||
title: '#',
|
|
||||||
key: 'num',
|
|
||||||
align: 'center',
|
|
||||||
width: 60,
|
|
||||||
render: (_v, _record, index) => <span className="row-index">{index + 1}</span>,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: t('pages.xray.outbound.tag'),
|
|
||||||
key: 'identity',
|
|
||||||
align: 'left',
|
|
||||||
render: (_v, record) => identityCell(record),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: t('pages.inbounds.address'),
|
|
||||||
key: 'address',
|
|
||||||
align: 'left',
|
|
||||||
render: (_v, record) => addressCell(record),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: t('pages.inbounds.traffic'),
|
|
||||||
key: 'traffic',
|
|
||||||
align: 'left',
|
|
||||||
width: 200,
|
|
||||||
render: (_v, record) => trafficCell(record),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="subscription-outbounds" style={{ marginTop: 16 }}>
|
|
||||||
{header}
|
|
||||||
<Table
|
|
||||||
columns={columns}
|
|
||||||
dataSource={rows}
|
|
||||||
rowKey={(r) => r.key}
|
|
||||||
pagination={false}
|
|
||||||
size="small"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
export { default as OutboundsTab } from './OutboundsTab';
|
|
||||||
export { default as OutboundFormModal } from './OutboundFormModal';
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
import { OutboundProtocols as Protocols } from '@/schemas/primitives';
|
|
||||||
import type { OutboundTrafficRow } from '@/hooks/useXraySetting';
|
|
||||||
|
|
||||||
import type { OutboundRow } from './outbounds-tab-types';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Translate a table row's positional index into that outbound's index in the
|
|
||||||
* full, unfiltered outbounds array. The table hides balancer-loopback outbounds
|
|
||||||
* but keeps each visible row's original index in `key`, so any handler that
|
|
||||||
* mutates the outbounds array (or probes an outbound by index) must map the
|
|
||||||
* positional index back through `key` or it operates on the wrong outbound once
|
|
||||||
* a hidden loopback precedes it.
|
|
||||||
*/
|
|
||||||
export function originalOutboundIndex(rows: OutboundRow[], positionalIndex: number): number {
|
|
||||||
const row = rows[positionalIndex];
|
|
||||||
return row ? row.key : positionalIndex;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function outboundAddresses(o: OutboundRow): string[] {
|
|
||||||
const settings = o.settings as Record<string, unknown> | undefined;
|
|
||||||
switch (o.protocol) {
|
|
||||||
case Protocols.VMess: {
|
|
||||||
const serverObj = settings?.vnext as Array<{ address: string; port: number }> | undefined;
|
|
||||||
return serverObj ? serverObj.map((s) => `${s.address}:${s.port}`) : [];
|
|
||||||
}
|
|
||||||
case Protocols.VLESS:
|
|
||||||
return [`${settings?.address || ''}:${settings?.port || ''}`];
|
|
||||||
case Protocols.HTTP:
|
|
||||||
case Protocols.Socks:
|
|
||||||
case Protocols.Shadowsocks:
|
|
||||||
case Protocols.Trojan: {
|
|
||||||
const serverObj = settings?.servers as Array<{ address: string; port: number }> | undefined;
|
|
||||||
return serverObj ? serverObj.map((s) => `${s.address}:${s.port}`) : [];
|
|
||||||
}
|
|
||||||
case Protocols.DNS: {
|
|
||||||
const addr = (settings?.rewriteAddress as string) || (settings?.address as string) || '';
|
|
||||||
const port =
|
|
||||||
(settings?.rewritePort as string | number) || (settings?.port as string | number) || '';
|
|
||||||
return addr || port ? [`${addr}:${port}`] : [];
|
|
||||||
}
|
|
||||||
case Protocols.Wireguard:
|
|
||||||
return ((settings?.peers as Array<{ endpoint?: string }>) || [])
|
|
||||||
.map((p) => p.endpoint || '')
|
|
||||||
.filter(Boolean);
|
|
||||||
default:
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function showSecurity(security?: string): boolean {
|
|
||||||
return security === 'tls' || security === 'reality';
|
|
||||||
}
|
|
||||||
|
|
||||||
export function trafficFor(
|
|
||||||
outboundsTraffic: OutboundTrafficRow[],
|
|
||||||
o: OutboundRow,
|
|
||||||
): { up: number; down: number } {
|
|
||||||
const tr = outboundsTraffic.find((x) => x.tag === o.tag);
|
|
||||||
return { up: tr?.up || 0, down: tr?.down || 0 };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function countryFlag(country?: string): string {
|
|
||||||
const code = (country || '').trim().toUpperCase();
|
|
||||||
if (!/^[A-Z]{2}$/.test(code)) return '';
|
|
||||||
return String.fromCodePoint(...[...code].map((ch) => 0x1f1e6 + ch.charCodeAt(0) - 65));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function countryName(country?: string, locale?: string): string {
|
|
||||||
const code = (country || '').trim().toUpperCase();
|
|
||||||
if (!/^[A-Z]{2}$/.test(code)) return '';
|
|
||||||
try {
|
|
||||||
return (
|
|
||||||
new Intl.DisplayNames(locale ? [locale] : undefined, { type: 'region' }).of(code) || code
|
|
||||||
);
|
|
||||||
} catch {
|
|
||||||
return code;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
export interface OutboundRow {
|
|
||||||
key: number;
|
|
||||||
tag?: string;
|
|
||||||
protocol?: string;
|
|
||||||
streamSettings?: { network?: string; security?: string };
|
|
||||||
settings?: Record<string, unknown>;
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { Select } from 'antd';
|
|
||||||
|
|
||||||
import { FormField } from '@/components/form/rhf';
|
|
||||||
|
|
||||||
export default function BlackholeFields() {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
return (
|
|
||||||
<FormField label={t('pages.xray.outboundForm.responseType')} name={['settings', 'type']}>
|
|
||||||
<Select
|
|
||||||
options={[
|
|
||||||
{ value: '', label: '(empty)' },
|
|
||||||
{ value: 'none', label: 'none' },
|
|
||||||
{ value: 'http', label: 'http' },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { Button, Form, Input, InputNumber, Select } from 'antd';
|
|
||||||
import { DeleteOutlined, PlusOutlined } from '@ant-design/icons';
|
|
||||||
import { useFieldArray, useFormContext } from 'react-hook-form';
|
|
||||||
|
|
||||||
import { FormField } from '@/components/form/rhf';
|
|
||||||
import { activateOnKey } from '@/utils/a11y';
|
|
||||||
import { DNSRuleActions } from '@/schemas/primitives';
|
|
||||||
|
|
||||||
export default function DnsFields() {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const { control } = useFormContext();
|
|
||||||
const { fields, append, remove } = useFieldArray({ control, name: 'settings.rules' });
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<FormField
|
|
||||||
label={t('pages.xray.outboundForm.rewriteNetwork')}
|
|
||||||
name={['settings', 'rewriteNetwork']}
|
|
||||||
>
|
|
||||||
<Select
|
|
||||||
allowClear
|
|
||||||
placeholder={t('pages.xray.outboundForm.unchanged')}
|
|
||||||
options={[
|
|
||||||
{ value: 'udp', label: 'udp' },
|
|
||||||
{ value: 'tcp', label: 'tcp' },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
<FormField
|
|
||||||
label={t('pages.inbounds.form.rewriteAddress')}
|
|
||||||
name={['settings', 'rewriteAddress']}
|
|
||||||
>
|
|
||||||
<Input placeholder={t('pages.xray.outboundForm.unchangedAddress')} />
|
|
||||||
</FormField>
|
|
||||||
<FormField label={t('pages.inbounds.form.rewritePort')} name={['settings', 'rewritePort']}>
|
|
||||||
<InputNumber min={0} max={65535} style={{ width: '100%' }} />
|
|
||||||
</FormField>
|
|
||||||
<FormField label={t('pages.xray.tun.userLevel')} name={['settings', 'userLevel']}>
|
|
||||||
<InputNumber min={0} style={{ width: '100%' }} />
|
|
||||||
</FormField>
|
|
||||||
<Form.Item label={t('pages.xray.outboundForm.rules')}>
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
type="primary"
|
|
||||||
icon={<PlusOutlined />}
|
|
||||||
aria-label={t('add')}
|
|
||||||
onClick={() => append({ action: 'direct', qType: '', domain: '', rCode: 0 })}
|
|
||||||
/>
|
|
||||||
</Form.Item>
|
|
||||||
{fields.map((field, index) => (
|
|
||||||
<div key={field.id}>
|
|
||||||
<Form.Item wrapperCol={{ md: { span: 14, offset: 8 } }}>
|
|
||||||
<div className="item-heading">
|
|
||||||
<span>{t('pages.xray.outboundForm.ruleN', { n: index + 1 })}</span>
|
|
||||||
<DeleteOutlined
|
|
||||||
className="danger-icon"
|
|
||||||
role="button"
|
|
||||||
tabIndex={0}
|
|
||||||
aria-label={t('remove')}
|
|
||||||
onClick={() => remove(index)}
|
|
||||||
onKeyDown={activateOnKey(() => remove(index))}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Form.Item>
|
|
||||||
<FormField
|
|
||||||
label={t('pages.xray.outboundForm.action')}
|
|
||||||
name={['settings', 'rules', index, 'action']}
|
|
||||||
>
|
|
||||||
<Select options={DNSRuleActions.map((a) => ({ value: a, label: a }))} />
|
|
||||||
</FormField>
|
|
||||||
<FormField label="QType" name={['settings', 'rules', index, 'qType']}>
|
|
||||||
<Input placeholder="1,3,23-24" />
|
|
||||||
</FormField>
|
|
||||||
<FormField label={t('domainName')} name={['settings', 'rules', index, 'domain']}>
|
|
||||||
<Input placeholder="domain:example.com" />
|
|
||||||
</FormField>
|
|
||||||
<FormField label="RCode" name={['settings', 'rules', index, 'rCode']}>
|
|
||||||
<InputNumber min={0} max={65535} style={{ width: '100%' }} />
|
|
||||||
</FormField>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,289 +0,0 @@
|
|||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { AutoComplete, Button, Form, Input, InputNumber, Select, Switch } from 'antd';
|
|
||||||
import { DeleteOutlined, PlusOutlined } from '@ant-design/icons';
|
|
||||||
import { useFieldArray, useFormContext, useWatch } from 'react-hook-form';
|
|
||||||
|
|
||||||
import { FormField } from '@/components/form/rhf';
|
|
||||||
import { activateOnKey } from '@/utils/a11y';
|
|
||||||
import { OutboundDomainStrategies } from '@/schemas/primitives';
|
|
||||||
|
|
||||||
interface FragmentValue {
|
|
||||||
packets?: string;
|
|
||||||
length?: string;
|
|
||||||
interval?: string;
|
|
||||||
maxSplit?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function FreedomFields() {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const { control, setValue } = useFormContext();
|
|
||||||
|
|
||||||
const fragment = (useWatch({ control, name: 'settings.fragment' }) ?? {}) as FragmentValue;
|
|
||||||
const fragmentEnabled = !!(fragment.length || fragment.interval || fragment.maxSplit);
|
|
||||||
|
|
||||||
const {
|
|
||||||
fields: noiseFields,
|
|
||||||
append: appendNoise,
|
|
||||||
remove: removeNoise,
|
|
||||||
} = useFieldArray({ control, name: 'settings.noises' });
|
|
||||||
|
|
||||||
const {
|
|
||||||
fields: finalRuleFields,
|
|
||||||
append: appendFinalRule,
|
|
||||||
remove: removeFinalRule,
|
|
||||||
} = useFieldArray({ control, name: 'settings.finalRules' });
|
|
||||||
const finalRulesValues = (useWatch({ control, name: 'settings.finalRules' }) ?? []) as {
|
|
||||||
action?: string;
|
|
||||||
}[];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<FormField
|
|
||||||
label={t('pages.xray.balancer.balancerStrategy')}
|
|
||||||
name={['settings', 'domainStrategy']}
|
|
||||||
>
|
|
||||||
<Select
|
|
||||||
options={[
|
|
||||||
{ value: '', label: `(${t('none')})` },
|
|
||||||
...OutboundDomainStrategies.map((s) => ({ value: s, label: s })),
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
<FormField label={t('pages.xray.outboundForm.redirect')} name={['settings', 'redirect']}>
|
|
||||||
<Input />
|
|
||||||
</FormField>
|
|
||||||
<FormField label={t('pages.xray.tun.userLevel')} name={['settings', 'userLevel']}>
|
|
||||||
<InputNumber min={0} style={{ width: '100%' }} />
|
|
||||||
</FormField>
|
|
||||||
<FormField
|
|
||||||
label={t('pages.xray.outboundForm.proxyProtocol')}
|
|
||||||
name={['settings', 'proxyProtocol']}
|
|
||||||
>
|
|
||||||
<Select
|
|
||||||
options={[
|
|
||||||
{ value: 0, label: `(${t('none')})` },
|
|
||||||
{ value: 1, label: 'v1' },
|
|
||||||
{ value: 2, label: 'v2' },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
|
|
||||||
<Form.Item label="Fragment">
|
|
||||||
<Switch
|
|
||||||
checked={fragmentEnabled}
|
|
||||||
onChange={(checked) => {
|
|
||||||
setValue(
|
|
||||||
'settings.fragment',
|
|
||||||
checked
|
|
||||||
? {
|
|
||||||
packets: 'tlshello',
|
|
||||||
length: '100-200',
|
|
||||||
interval: '10-20',
|
|
||||||
maxSplit: '300-400',
|
|
||||||
}
|
|
||||||
: { packets: '', length: '', interval: '', maxSplit: '' },
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Form.Item>
|
|
||||||
{fragmentEnabled && (
|
|
||||||
<>
|
|
||||||
<FormField
|
|
||||||
label={t('pages.settings.subFormats.packets')}
|
|
||||||
name={['settings', 'fragment', 'packets']}
|
|
||||||
rules={{
|
|
||||||
validate: (value) => {
|
|
||||||
const str = String(value ?? '').trim();
|
|
||||||
/* xray accepts "tlshello" or any packet-number range (#5075) */
|
|
||||||
if (str === '' || str === 'tlshello' || /^\d+-\d+$/.test(str)) return true;
|
|
||||||
return 'Use "tlshello" or a packet range like 1-3';
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<AutoComplete
|
|
||||||
options={[
|
|
||||||
{ value: 'tlshello', label: 'tlshello' },
|
|
||||||
{ value: '1-3', label: '1-3' },
|
|
||||||
{ value: '1-5', label: '1-5' },
|
|
||||||
]}
|
|
||||||
placeholder="tlshello or n-m, e.g. 1-3"
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
<FormField
|
|
||||||
label={t('pages.settings.subFormats.length')}
|
|
||||||
name={['settings', 'fragment', 'length']}
|
|
||||||
>
|
|
||||||
<Input />
|
|
||||||
</FormField>
|
|
||||||
<FormField
|
|
||||||
label={t('pages.settings.subFormats.interval')}
|
|
||||||
name={['settings', 'fragment', 'interval']}
|
|
||||||
>
|
|
||||||
<Input />
|
|
||||||
</FormField>
|
|
||||||
<FormField
|
|
||||||
label={t('pages.settings.subFormats.maxSplit')}
|
|
||||||
name={['settings', 'fragment', 'maxSplit']}
|
|
||||||
>
|
|
||||||
<Input />
|
|
||||||
</FormField>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Form.Item label={t('pages.settings.subFormats.noises')}>
|
|
||||||
<Switch
|
|
||||||
checked={noiseFields.length > 0}
|
|
||||||
onChange={(checked) => {
|
|
||||||
if (checked) {
|
|
||||||
appendNoise({ type: 'rand', packet: '10-20', delay: '10-16', applyTo: 'ip' });
|
|
||||||
} else {
|
|
||||||
removeNoise();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{noiseFields.length > 0 && (
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
type="primary"
|
|
||||||
className="ml-8"
|
|
||||||
icon={<PlusOutlined />}
|
|
||||||
aria-label={t('add')}
|
|
||||||
onClick={() =>
|
|
||||||
appendNoise({ type: 'rand', packet: '10-20', delay: '10-16', applyTo: 'ip' })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Form.Item>
|
|
||||||
{noiseFields.map((field, index) => (
|
|
||||||
<div key={field.id}>
|
|
||||||
<Form.Item wrapperCol={{ md: { span: 14, offset: 8 } }}>
|
|
||||||
<div className="item-heading">
|
|
||||||
<span>{t('pages.settings.subFormats.noiseItem', { n: index + 1 })}</span>
|
|
||||||
{noiseFields.length > 1 && (
|
|
||||||
<DeleteOutlined
|
|
||||||
className="danger-icon"
|
|
||||||
role="button"
|
|
||||||
tabIndex={0}
|
|
||||||
aria-label={t('remove')}
|
|
||||||
onClick={() => removeNoise(index)}
|
|
||||||
onKeyDown={activateOnKey(() => removeNoise(index))}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Form.Item>
|
|
||||||
<FormField
|
|
||||||
label={t('pages.settings.subFormats.type')}
|
|
||||||
name={['settings', 'noises', index, 'type']}
|
|
||||||
>
|
|
||||||
<Select
|
|
||||||
options={['rand', 'base64', 'str', 'hex'].map((v) => ({
|
|
||||||
value: v,
|
|
||||||
label: v,
|
|
||||||
}))}
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
<FormField
|
|
||||||
label={t('pages.settings.subFormats.packet')}
|
|
||||||
name={['settings', 'noises', index, 'packet']}
|
|
||||||
>
|
|
||||||
<Input />
|
|
||||||
</FormField>
|
|
||||||
<FormField
|
|
||||||
label={t('pages.settings.subFormats.delayMs')}
|
|
||||||
name={['settings', 'noises', index, 'delay']}
|
|
||||||
>
|
|
||||||
<Input />
|
|
||||||
</FormField>
|
|
||||||
<FormField
|
|
||||||
label={t('pages.settings.subFormats.applyTo')}
|
|
||||||
name={['settings', 'noises', index, 'applyTo']}
|
|
||||||
>
|
|
||||||
<Select
|
|
||||||
options={['ip', 'ipv4', 'ipv6'].map((v) => ({
|
|
||||||
value: v,
|
|
||||||
label: v,
|
|
||||||
}))}
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
<Form.Item label={t('pages.xray.outboundForm.finalRules')}>
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
type="primary"
|
|
||||||
icon={<PlusOutlined />}
|
|
||||||
aria-label={t('add')}
|
|
||||||
onClick={() =>
|
|
||||||
appendFinalRule({ action: 'allow', network: '', port: '', ip: [], blockDelay: '' })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<span className="ml-8" style={{ opacity: 0.6 }}>
|
|
||||||
{t('pages.xray.outboundForm.overrideXrayPrivateIp')}
|
|
||||||
</span>
|
|
||||||
</Form.Item>
|
|
||||||
{finalRuleFields.map((field, index) => (
|
|
||||||
<div key={field.id}>
|
|
||||||
<Form.Item wrapperCol={{ md: { span: 14, offset: 8 } }}>
|
|
||||||
<div className="item-heading">
|
|
||||||
<span>{t('pages.xray.outboundForm.ruleN', { n: index + 1 })}</span>
|
|
||||||
<DeleteOutlined
|
|
||||||
className="danger-icon"
|
|
||||||
role="button"
|
|
||||||
tabIndex={0}
|
|
||||||
aria-label={t('remove')}
|
|
||||||
onClick={() => removeFinalRule(index)}
|
|
||||||
onKeyDown={activateOnKey(() => removeFinalRule(index))}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Form.Item>
|
|
||||||
<FormField
|
|
||||||
label={t('pages.xray.outboundForm.action')}
|
|
||||||
name={['settings', 'finalRules', index, 'action']}
|
|
||||||
>
|
|
||||||
<Select
|
|
||||||
options={['allow', 'block'].map((v) => ({
|
|
||||||
value: v,
|
|
||||||
label: v,
|
|
||||||
}))}
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
<FormField
|
|
||||||
label={t('pages.inbounds.network')}
|
|
||||||
name={['settings', 'finalRules', index, 'network']}
|
|
||||||
>
|
|
||||||
<Select
|
|
||||||
allowClear
|
|
||||||
placeholder="(any)"
|
|
||||||
options={['tcp', 'udp', 'tcp,udp'].map((v) => ({
|
|
||||||
value: v,
|
|
||||||
label: v,
|
|
||||||
}))}
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
<FormField
|
|
||||||
label={t('pages.inbounds.port')}
|
|
||||||
name={['settings', 'finalRules', index, 'port']}
|
|
||||||
>
|
|
||||||
<Input placeholder="e.g. 80,443 or 1000-2000" />
|
|
||||||
</FormField>
|
|
||||||
<FormField label="IP / CIDR / geoip" name={['settings', 'finalRules', index, 'ip']}>
|
|
||||||
<Select
|
|
||||||
mode="tags"
|
|
||||||
tokenSeparators={[',', ' ']}
|
|
||||||
placeholder="e.g. 10.0.0.0/8, geoip:private"
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
{finalRulesValues[index]?.action === 'block' && (
|
|
||||||
<FormField
|
|
||||||
label={t('pages.xray.outboundForm.blockDelay')}
|
|
||||||
name={['settings', 'finalRules', index, 'blockDelay']}
|
|
||||||
>
|
|
||||||
<Input placeholder="optional: 5000-10000" />
|
|
||||||
</FormField>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { Input } from 'antd';
|
|
||||||
|
|
||||||
import { HeaderMapEditor } from '@/components/form';
|
|
||||||
import { FormField } from '@/components/form/rhf';
|
|
||||||
|
|
||||||
export default function HttpFields() {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<FormField label={t('username')} name={['settings', 'user']}>
|
|
||||||
<Input />
|
|
||||||
</FormField>
|
|
||||||
<FormField label={t('password')} name={['settings', 'pass']}>
|
|
||||||
<Input />
|
|
||||||
</FormField>
|
|
||||||
<FormField label={t('pages.inbounds.form.headers')} name={['settings', 'headers']}>
|
|
||||||
<HeaderMapEditor mode="v1" />
|
|
||||||
</FormField>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
export { default as ServerTarget } from './server-target';
|
|
||||||
export { default as VmessFields } from './vmess';
|
|
||||||
export { default as VlessFields } from './vless';
|
|
||||||
export { default as TrojanFields } from './trojan';
|
|
||||||
export { default as ShadowsocksFields } from './shadowsocks';
|
|
||||||
export { default as HttpFields } from './http';
|
|
||||||
export { default as SocksFields } from './socks';
|
|
||||||
export { default as WireguardFields } from './wireguard';
|
|
||||||
export { default as FreedomFields } from './freedom';
|
|
||||||
export { default as LoopbackFields } from './loopback';
|
|
||||||
export { default as BlackholeFields } from './blackhole';
|
|
||||||
export { default as DnsFields } from './dns';
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { Input } from 'antd';
|
|
||||||
import { Controller, useFormContext } from 'react-hook-form';
|
|
||||||
|
|
||||||
import { FormField } from '@/components/form/rhf';
|
|
||||||
import { SniffingField } from '@/lib/xray/forms/fields';
|
|
||||||
|
|
||||||
export default function LoopbackFields() {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const { control } = useFormContext();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<FormField label={t('pages.xray.outboundForm.inboundTag')} name={['settings', 'inboundTag']}>
|
|
||||||
<Input placeholder={t('pages.xray.outboundForm.inboundTagPlaceholder')} />
|
|
||||||
</FormField>
|
|
||||||
|
|
||||||
<Controller
|
|
||||||
control={control}
|
|
||||||
name="settings.sniffing"
|
|
||||||
render={({ field }) => (
|
|
||||||
<SniffingField
|
|
||||||
value={field.value}
|
|
||||||
onChange={field.onChange}
|
|
||||||
enableLabel={t('pages.inbounds.sniffingTab')}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { Input, InputNumber } from 'antd';
|
|
||||||
|
|
||||||
import { FormField } from '@/components/form/rhf';
|
|
||||||
|
|
||||||
export default function ServerTarget() {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<FormField
|
|
||||||
label={t('pages.inbounds.address')}
|
|
||||||
name={['settings', 'address']}
|
|
||||||
required
|
|
||||||
rules={{ required: 'pages.xray.outboundForm.addressRequired' }}
|
|
||||||
>
|
|
||||||
<Input />
|
|
||||||
</FormField>
|
|
||||||
<FormField
|
|
||||||
label={t('pages.inbounds.port')}
|
|
||||||
name={['settings', 'port']}
|
|
||||||
required
|
|
||||||
rules={{ required: 'pages.xray.outboundForm.portRequired' }}
|
|
||||||
>
|
|
||||||
<InputNumber min={1} max={65535} style={{ width: '100%' }} />
|
|
||||||
</FormField>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user