mirror of
https://github.com/YspCoder/clawgo.git
synced 2026-04-19 00:47:28 +08:00
feat: add turn-ready node p2p config
This commit is contained in:
46
README.md
46
README.md
@@ -200,6 +200,52 @@ user -> main -> worker -> main -> user
|
||||
|
||||
完整示例见 [config.example.json](/Users/lpf/Desktop/project/clawgo/config.example.json)。
|
||||
|
||||
## Node P2P
|
||||
|
||||
远端 node 的调度数据面现在支持:
|
||||
|
||||
- `websocket_tunnel`
|
||||
- `webrtc`
|
||||
|
||||
默认仍然关闭,只有显式配置 `gateway.nodes.p2p.enabled=true` 才会启用。建议先用 `websocket_tunnel` 验证链路,再切到 `webrtc`。
|
||||
|
||||
`webrtc` 建议同时理解这两个字段:
|
||||
|
||||
- `stun_servers`
|
||||
- 兼容旧式 STUN 列表
|
||||
- `ice_servers`
|
||||
- 推荐的新结构
|
||||
- 可以配置 `stun:`、`turn:`、`turns:` URL
|
||||
- `turn:` / `turns:` 必须同时提供 `username` 和 `credential`
|
||||
|
||||
示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"gateway": {
|
||||
"nodes": {
|
||||
"p2p": {
|
||||
"enabled": true,
|
||||
"transport": "webrtc",
|
||||
"stun_servers": ["stun:stun.l.google.com:19302"],
|
||||
"ice_servers": [
|
||||
{
|
||||
"urls": ["turn:turn.example.com:3478"],
|
||||
"username": "demo",
|
||||
"credential": "secret"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
说明:
|
||||
|
||||
- `webrtc` 建连失败时,调度层仍会回退到现有 relay / tunnel 路径
|
||||
- Dashboard、`status`、`/webui/api/nodes` 会显示当前 Node P2P 状态和会话摘要
|
||||
|
||||
## MCP 服务支持
|
||||
|
||||
ClawGo 现在支持通过 `tools.mcp` 接入 `stdio`、`http`、`streamable_http`、`sse` 型 MCP server。
|
||||
|
||||
46
README_EN.md
46
README_EN.md
@@ -200,6 +200,52 @@ Notes:
|
||||
|
||||
See [config.example.json](/Users/lpf/Desktop/project/clawgo/config.example.json) for a full example.
|
||||
|
||||
## Node P2P
|
||||
|
||||
The remote node data plane supports:
|
||||
|
||||
- `websocket_tunnel`
|
||||
- `webrtc`
|
||||
|
||||
It remains disabled by default. Node P2P is only enabled when `gateway.nodes.p2p.enabled=true` is set explicitly. In practice, start with `websocket_tunnel`, then switch to `webrtc` after validating connectivity.
|
||||
|
||||
For `webrtc`, these two fields matter:
|
||||
|
||||
- `stun_servers`
|
||||
- legacy-compatible STUN list
|
||||
- `ice_servers`
|
||||
- the preferred structured format
|
||||
- may include `stun:`, `turn:`, and `turns:` URLs
|
||||
- `turn:` / `turns:` entries require both `username` and `credential`
|
||||
|
||||
Example:
|
||||
|
||||
```json
|
||||
{
|
||||
"gateway": {
|
||||
"nodes": {
|
||||
"p2p": {
|
||||
"enabled": true,
|
||||
"transport": "webrtc",
|
||||
"stun_servers": ["stun:stun.l.google.com:19302"],
|
||||
"ice_servers": [
|
||||
{
|
||||
"urls": ["turn:turn.example.com:3478"],
|
||||
"username": "demo",
|
||||
"credential": "secret"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- when `webrtc` session setup fails, dispatch still falls back to the existing relay / tunnel path
|
||||
- Dashboard, `status`, and `/webui/api/nodes` expose the current Node P2P runtime summary
|
||||
|
||||
## MCP Server Support
|
||||
|
||||
ClawGo now supports `stdio`, `http`, `streamable_http`, and `sse` MCP servers through `tools.mcp`.
|
||||
|
||||
@@ -24,6 +24,7 @@ import (
|
||||
"clawgo/pkg/providers"
|
||||
"clawgo/pkg/runtimecfg"
|
||||
"clawgo/pkg/sentinel"
|
||||
"github.com/pion/webrtc/v4"
|
||||
)
|
||||
|
||||
func gatewayCmd() {
|
||||
@@ -133,16 +134,37 @@ func gatewayCmd() {
|
||||
if loop == nil || server == nil || runtimeCfg == nil {
|
||||
return
|
||||
}
|
||||
buildICEServers := func() []webrtc.ICEServer {
|
||||
out := make([]webrtc.ICEServer, 0, len(runtimeCfg.Gateway.Nodes.P2P.ICEServers))
|
||||
for _, serverCfg := range runtimeCfg.Gateway.Nodes.P2P.ICEServers {
|
||||
urls := make([]string, 0, len(serverCfg.URLs))
|
||||
for _, raw := range serverCfg.URLs {
|
||||
if v := strings.TrimSpace(raw); v != "" {
|
||||
urls = append(urls, v)
|
||||
}
|
||||
}
|
||||
if len(urls) == 0 {
|
||||
continue
|
||||
}
|
||||
out = append(out, webrtc.ICEServer{
|
||||
URLs: urls,
|
||||
Username: strings.TrimSpace(serverCfg.Username),
|
||||
Credential: serverCfg.Credential,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
server.SetNodeP2PStatusHandler(func() map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"enabled": runtimeCfg.Gateway.Nodes.P2P.Enabled,
|
||||
"transport": strings.TrimSpace(runtimeCfg.Gateway.Nodes.P2P.Transport),
|
||||
"configured_stun": append([]string(nil), runtimeCfg.Gateway.Nodes.P2P.STUNServers...),
|
||||
"configured_ice": len(runtimeCfg.Gateway.Nodes.P2P.ICEServers),
|
||||
}
|
||||
})
|
||||
switch {
|
||||
case runtimeCfg.Gateway.Nodes.P2P.Enabled && strings.EqualFold(strings.TrimSpace(runtimeCfg.Gateway.Nodes.P2P.Transport), "webrtc"):
|
||||
webrtcTransport := nodes.NewWebRTCTransport(runtimeCfg.Gateway.Nodes.P2P.STUNServers)
|
||||
webrtcTransport := nodes.NewWebRTCTransport(runtimeCfg.Gateway.Nodes.P2P.STUNServers, buildICEServers()...)
|
||||
loop.SetNodeP2PTransport(webrtcTransport)
|
||||
server.SetNodeWebRTCTransport(webrtcTransport)
|
||||
server.SetNodeP2PStatusHandler(func() map[string]interface{} {
|
||||
@@ -150,6 +172,7 @@ func gatewayCmd() {
|
||||
snapshot["enabled"] = true
|
||||
snapshot["transport"] = "webrtc"
|
||||
snapshot["configured_stun"] = append([]string(nil), runtimeCfg.Gateway.Nodes.P2P.STUNServers...)
|
||||
snapshot["configured_ice"] = len(runtimeCfg.Gateway.Nodes.P2P.ICEServers)
|
||||
return snapshot
|
||||
})
|
||||
default:
|
||||
|
||||
@@ -134,6 +134,8 @@ func statusCmd() {
|
||||
fmt.Printf(" - %s\n", key)
|
||||
}
|
||||
}
|
||||
fmt.Printf("Nodes P2P: enabled=%t transport=%s\n", cfg.Gateway.Nodes.P2P.Enabled, strings.TrimSpace(cfg.Gateway.Nodes.P2P.Transport))
|
||||
fmt.Printf("Nodes P2P ICE: stun=%d ice=%d\n", len(cfg.Gateway.Nodes.P2P.STUNServers), len(cfg.Gateway.Nodes.P2P.ICEServers))
|
||||
ns := nodes.DefaultManager().List()
|
||||
if len(ns) > 0 {
|
||||
online := 0
|
||||
@@ -163,7 +165,6 @@ func statusCmd() {
|
||||
}
|
||||
fmt.Printf("Nodes: total=%d online=%d\n", len(ns), online)
|
||||
fmt.Printf("Nodes Capabilities: run=%d model=%d camera=%d screen=%d location=%d canvas=%d\n", caps["run"], caps["model"], caps["camera"], caps["screen"], caps["location"], caps["canvas"])
|
||||
fmt.Printf("Nodes P2P: enabled=%t transport=%s\n", cfg.Gateway.Nodes.P2P.Enabled, strings.TrimSpace(cfg.Gateway.Nodes.P2P.Transport))
|
||||
if total, okCnt, avgMs, actionTop, transportTop, fallbackCnt, err := collectNodeDispatchStats(filepath.Join(workspace, "memory", "nodes-dispatch-audit.jsonl")); err == nil && total > 0 {
|
||||
fmt.Printf("Nodes Dispatch: total=%d ok=%d fail=%d avg_ms=%d\n", total, okCnt, total-okCnt, avgMs)
|
||||
if actionTop != "" {
|
||||
|
||||
@@ -25,6 +25,12 @@ func TestStatusCmdUsesActiveProviderDetails(t *testing.T) {
|
||||
cfg.Logging.Enabled = false
|
||||
cfg.Agents.Defaults.Workspace = workspace
|
||||
cfg.Agents.Defaults.Proxy = "backup"
|
||||
cfg.Gateway.Nodes.P2P.Enabled = true
|
||||
cfg.Gateway.Nodes.P2P.Transport = "webrtc"
|
||||
cfg.Gateway.Nodes.P2P.STUNServers = []string{"stun:stun.example.net:3478"}
|
||||
cfg.Gateway.Nodes.P2P.ICEServers = []config.GatewayICEConfig{
|
||||
{URLs: []string{"turn:turn.example.net:3478"}, Username: "user", Credential: "secret"},
|
||||
}
|
||||
cfg.Providers.Proxy.APIBase = "https://primary.example/v1"
|
||||
cfg.Providers.Proxy.APIKey = ""
|
||||
cfg.Providers.Proxies["backup"] = config.ProviderConfig{
|
||||
@@ -68,4 +74,10 @@ func TestStatusCmdUsesActiveProviderDetails(t *testing.T) {
|
||||
if !strings.Contains(out, "Provider API Key: ✓") {
|
||||
t.Fatalf("expected active provider api key status in output, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "Nodes P2P: enabled=true transport=webrtc") {
|
||||
t.Fatalf("expected nodes p2p status in output, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "Nodes P2P ICE: stun=1 ice=1") {
|
||||
t.Fatalf("expected nodes p2p ice summary in output, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -280,7 +280,14 @@
|
||||
"p2p": {
|
||||
"enabled": false,
|
||||
"transport": "websocket_tunnel",
|
||||
"stun_servers": []
|
||||
"stun_servers": [],
|
||||
"ice_servers": [
|
||||
{
|
||||
"urls": [
|
||||
"stun:stun.l.google.com:19302"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -298,10 +298,17 @@ type GatewayNodesConfig struct {
|
||||
P2P GatewayNodesP2PConfig `json:"p2p,omitempty"`
|
||||
}
|
||||
|
||||
type GatewayICEConfig struct {
|
||||
URLs []string `json:"urls,omitempty"`
|
||||
Username string `json:"username,omitempty"`
|
||||
Credential string `json:"credential,omitempty"`
|
||||
}
|
||||
|
||||
type GatewayNodesP2PConfig struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Transport string `json:"transport,omitempty"`
|
||||
STUNServers []string `json:"stun_servers,omitempty"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Transport string `json:"transport,omitempty"`
|
||||
STUNServers []string `json:"stun_servers,omitempty"`
|
||||
ICEServers []GatewayICEConfig `json:"ice_servers,omitempty"`
|
||||
}
|
||||
|
||||
type CronConfig struct {
|
||||
@@ -550,6 +557,7 @@ func DefaultConfig() *Config {
|
||||
Enabled: false,
|
||||
Transport: "websocket_tunnel",
|
||||
STUNServers: []string{},
|
||||
ICEServers: []GatewayICEConfig{},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -125,6 +125,26 @@ func Validate(cfg *Config) []error {
|
||||
errs = append(errs, fmt.Errorf("gateway.nodes.p2p.transport must be one of: websocket_tunnel, webrtc"))
|
||||
}
|
||||
errs = append(errs, validateNonEmptyStringList("gateway.nodes.p2p.stun_servers", cfg.Gateway.Nodes.P2P.STUNServers)...)
|
||||
for i, server := range cfg.Gateway.Nodes.P2P.ICEServers {
|
||||
prefix := fmt.Sprintf("gateway.nodes.p2p.ice_servers[%d]", i)
|
||||
errs = append(errs, validateNonEmptyStringList(prefix+".urls", server.URLs)...)
|
||||
needsAuth := false
|
||||
for _, raw := range server.URLs {
|
||||
u := strings.ToLower(strings.TrimSpace(raw))
|
||||
if strings.HasPrefix(u, "turn:") || strings.HasPrefix(u, "turns:") {
|
||||
needsAuth = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if needsAuth {
|
||||
if strings.TrimSpace(server.Username) == "" {
|
||||
errs = append(errs, fmt.Errorf("%s.username is required for turn/turns urls", prefix))
|
||||
}
|
||||
if strings.TrimSpace(server.Credential) == "" {
|
||||
errs = append(errs, fmt.Errorf("%s.credential is required for turn/turns urls", prefix))
|
||||
}
|
||||
}
|
||||
}
|
||||
if cfg.Cron.MinSleepSec <= 0 {
|
||||
errs = append(errs, fmt.Errorf("cron.min_sleep_sec must be > 0"))
|
||||
}
|
||||
|
||||
@@ -151,3 +151,29 @@ func TestValidateRejectsUnknownGatewayNodeP2PTransport(t *testing.T) {
|
||||
t.Fatalf("expected validation errors")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateGatewayNodeP2PIceServersAllowsStunOnly(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cfg := DefaultConfig()
|
||||
cfg.Gateway.Nodes.P2P.ICEServers = []GatewayICEConfig{
|
||||
{URLs: []string{"stun:stun.l.google.com:19302"}},
|
||||
}
|
||||
|
||||
if errs := Validate(cfg); len(errs) != 0 {
|
||||
t.Fatalf("expected config to be valid, got %v", errs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateGatewayNodeP2PIceServersRequireTurnCredentials(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cfg := DefaultConfig()
|
||||
cfg.Gateway.Nodes.P2P.ICEServers = []GatewayICEConfig{
|
||||
{URLs: []string{"turn:turn.example.com:3478?transport=udp"}},
|
||||
}
|
||||
|
||||
if errs := Validate(cfg); len(errs) == 0 {
|
||||
t.Fatalf("expected validation errors")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,24 +92,40 @@ func (s *gatewayRTCSession) snapshot() map[string]interface{} {
|
||||
}
|
||||
|
||||
type WebRTCTransport struct {
|
||||
stunServers []string
|
||||
iceServers []webrtc.ICEServer
|
||||
|
||||
mu sync.Mutex
|
||||
sessions map[string]*gatewayRTCSession
|
||||
signal map[string]WireSender
|
||||
}
|
||||
|
||||
func NewWebRTCTransport(stunServers []string) *WebRTCTransport {
|
||||
out := make([]string, 0, len(stunServers))
|
||||
func NewWebRTCTransport(stunServers []string, extraICEServers ...webrtc.ICEServer) *WebRTCTransport {
|
||||
out := make([]webrtc.ICEServer, 0, len(stunServers)+len(extraICEServers))
|
||||
for _, server := range stunServers {
|
||||
if v := strings.TrimSpace(server); v != "" {
|
||||
out = append(out, v)
|
||||
out = append(out, webrtc.ICEServer{URLs: []string{v}})
|
||||
}
|
||||
}
|
||||
for _, server := range extraICEServers {
|
||||
urls := make([]string, 0, len(server.URLs))
|
||||
for _, raw := range server.URLs {
|
||||
if v := strings.TrimSpace(raw); v != "" {
|
||||
urls = append(urls, v)
|
||||
}
|
||||
}
|
||||
if len(urls) == 0 {
|
||||
continue
|
||||
}
|
||||
out = append(out, webrtc.ICEServer{
|
||||
URLs: urls,
|
||||
Username: strings.TrimSpace(server.Username),
|
||||
Credential: server.Credential,
|
||||
})
|
||||
}
|
||||
return &WebRTCTransport{
|
||||
stunServers: out,
|
||||
sessions: map[string]*gatewayRTCSession{},
|
||||
signal: map[string]WireSender{},
|
||||
iceServers: out,
|
||||
sessions: map[string]*gatewayRTCSession{},
|
||||
signal: map[string]WireSender{},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,6 +149,7 @@ func (t *WebRTCTransport) Snapshot() map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"transport": "webrtc",
|
||||
"active_sessions": active,
|
||||
"ice_servers": len(t.iceServers),
|
||||
"nodes": nodes,
|
||||
}
|
||||
}
|
||||
@@ -276,8 +293,8 @@ func (t *WebRTCTransport) ensureSession(nodeID string) (*gatewayRTCSession, erro
|
||||
}
|
||||
|
||||
config := webrtc.Configuration{}
|
||||
if len(t.stunServers) > 0 {
|
||||
config.ICEServers = []webrtc.ICEServer{{URLs: append([]string(nil), t.stunServers...)}}
|
||||
if len(t.iceServers) > 0 {
|
||||
config.ICEServers = append([]webrtc.ICEServer(nil), t.iceServers...)
|
||||
}
|
||||
pc, err := webrtc.NewPeerConnection(config)
|
||||
if err != nil {
|
||||
|
||||
@@ -118,6 +118,18 @@ const resources = {
|
||||
nodesSnapshot: 'Nodes Snapshot',
|
||||
refreshAll: 'Refresh All',
|
||||
refresh: 'Refresh',
|
||||
dashboardNodeP2PDetail: '{{transport}} · {{sessions}} active · {{retries}} retries',
|
||||
dashboardNodeP2PTransport: 'Transport',
|
||||
dashboardNodeP2PIce: 'ICE Config',
|
||||
dashboardNodeP2PHealth: 'Health',
|
||||
configNodeP2P: 'Node P2P',
|
||||
configNodeP2PHint: 'Configure websocket tunnel or WebRTC transport for remote nodes.',
|
||||
configNodeP2PStunPlaceholder: 'Comma-separated STUN URLs',
|
||||
configNodeP2PIceServers: 'ICE Servers',
|
||||
configNodeP2PIceServersEmpty: 'No structured ICE servers configured.',
|
||||
configNodeP2PIceUrlsPlaceholder: 'Comma-separated ICE URLs',
|
||||
configNodeP2PIceUsername: 'ICE Username',
|
||||
configNodeP2PIceCredential: 'ICE Credential',
|
||||
active: 'Active',
|
||||
paused: 'Paused',
|
||||
noCronJobs: 'No cron jobs found',
|
||||
@@ -644,6 +656,18 @@ const resources = {
|
||||
nodesSnapshot: '节点快照',
|
||||
refreshAll: '刷新全部',
|
||||
refresh: '刷新',
|
||||
dashboardNodeP2PDetail: '{{transport}} · {{sessions}} 个活跃会话 · {{retries}} 次重试',
|
||||
dashboardNodeP2PTransport: '传输方式',
|
||||
dashboardNodeP2PIce: 'ICE 配置',
|
||||
dashboardNodeP2PHealth: '健康状态',
|
||||
configNodeP2P: '节点 P2P',
|
||||
configNodeP2PHint: '为远端节点配置 websocket tunnel 或 WebRTC 传输。',
|
||||
configNodeP2PStunPlaceholder: '逗号分隔的 STUN URL',
|
||||
configNodeP2PIceServers: 'ICE 服务器',
|
||||
configNodeP2PIceServersEmpty: '当前没有结构化 ICE 服务器配置。',
|
||||
configNodeP2PIceUrlsPlaceholder: '逗号分隔的 ICE URL',
|
||||
configNodeP2PIceUsername: 'ICE 用户名',
|
||||
configNodeP2PIceCredential: 'ICE 凭证',
|
||||
active: '活跃',
|
||||
paused: '已暂停',
|
||||
noCronJobs: '未找到定时任务',
|
||||
|
||||
@@ -107,6 +107,48 @@ const Config: React.FC = () => {
|
||||
setCfg((v) => setPath(v, `providers.proxies.${name}.${field}`, value));
|
||||
}
|
||||
|
||||
function updateGatewayP2PField(field: string, value: any) {
|
||||
setCfg((v) => setPath(v, `gateway.nodes.p2p.${field}`, value));
|
||||
}
|
||||
|
||||
function updateGatewayIceServer(index: number, field: string, value: any) {
|
||||
setCfg((v) => {
|
||||
const next = JSON.parse(JSON.stringify(v || {}));
|
||||
if (!next.gateway || typeof next.gateway !== 'object') next.gateway = {};
|
||||
if (!next.gateway.nodes || typeof next.gateway.nodes !== 'object') next.gateway.nodes = {};
|
||||
if (!next.gateway.nodes.p2p || typeof next.gateway.nodes.p2p !== 'object') next.gateway.nodes.p2p = {};
|
||||
if (!Array.isArray(next.gateway.nodes.p2p.ice_servers)) next.gateway.nodes.p2p.ice_servers = [];
|
||||
if (!next.gateway.nodes.p2p.ice_servers[index] || typeof next.gateway.nodes.p2p.ice_servers[index] !== 'object') {
|
||||
next.gateway.nodes.p2p.ice_servers[index] = { urls: [], username: '', credential: '' };
|
||||
}
|
||||
next.gateway.nodes.p2p.ice_servers[index][field] = value;
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function addGatewayIceServer() {
|
||||
setCfg((v) => {
|
||||
const next = JSON.parse(JSON.stringify(v || {}));
|
||||
if (!next.gateway || typeof next.gateway !== 'object') next.gateway = {};
|
||||
if (!next.gateway.nodes || typeof next.gateway.nodes !== 'object') next.gateway.nodes = {};
|
||||
if (!next.gateway.nodes.p2p || typeof next.gateway.nodes.p2p !== 'object') next.gateway.nodes.p2p = {};
|
||||
if (!Array.isArray(next.gateway.nodes.p2p.ice_servers)) next.gateway.nodes.p2p.ice_servers = [];
|
||||
next.gateway.nodes.p2p.ice_servers.push({ urls: [], username: '', credential: '' });
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function removeGatewayIceServer(index: number) {
|
||||
setCfg((v) => {
|
||||
const next = JSON.parse(JSON.stringify(v || {}));
|
||||
const iceServers = next?.gateway?.nodes?.p2p?.ice_servers;
|
||||
if (Array.isArray(iceServers)) {
|
||||
iceServers.splice(index, 1);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
async function removeProxy(name: string) {
|
||||
const ok = await ui.confirmDialog({
|
||||
title: t('configDeleteProviderConfirmTitle'),
|
||||
@@ -291,6 +333,77 @@ const Config: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{activeTop === 'gateway' && !showRaw && (
|
||||
<div className="brand-card-subtle rounded-2xl border border-zinc-800 p-3 space-y-3">
|
||||
<div className="flex items-center justify-between gap-2 flex-wrap">
|
||||
<div className="text-sm font-semibold text-zinc-200">{t('configNodeP2P')}</div>
|
||||
<div className="text-xs text-zinc-500">{t('configNodeP2PHint')}</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-2 text-xs">
|
||||
<label className="rounded-xl border border-zinc-800 bg-zinc-900/30 p-3 space-y-2">
|
||||
<div className="text-zinc-300">{t('enable')}</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={Boolean((cfg as any)?.gateway?.nodes?.p2p?.enabled)}
|
||||
onChange={(e) => updateGatewayP2PField('enabled', e.target.checked)}
|
||||
/>
|
||||
</label>
|
||||
<label className="rounded-xl border border-zinc-800 bg-zinc-900/30 p-3 space-y-2">
|
||||
<div className="text-zinc-300">{t('dashboardNodeP2PTransport')}</div>
|
||||
<select
|
||||
value={String((cfg as any)?.gateway?.nodes?.p2p?.transport || 'websocket_tunnel')}
|
||||
onChange={(e) => updateGatewayP2PField('transport', e.target.value)}
|
||||
className="w-full px-2 py-1 rounded-lg bg-zinc-950/70 border border-zinc-800"
|
||||
>
|
||||
<option value="websocket_tunnel">websocket_tunnel</option>
|
||||
<option value="webrtc">webrtc</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="rounded-xl border border-zinc-800 bg-zinc-900/30 p-3 space-y-2">
|
||||
<div className="text-zinc-300">{t('dashboardNodeP2PIce')}</div>
|
||||
<input
|
||||
value={Array.isArray((cfg as any)?.gateway?.nodes?.p2p?.stun_servers) ? (cfg as any).gateway.nodes.p2p.stun_servers.join(', ') : ''}
|
||||
onChange={(e) => updateGatewayP2PField('stun_servers', e.target.value.split(',').map((s) => s.trim()).filter(Boolean))}
|
||||
placeholder={t('configNodeP2PStunPlaceholder')}
|
||||
className="w-full px-2 py-1 rounded-lg bg-zinc-950/70 border border-zinc-800"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between gap-2 flex-wrap">
|
||||
<div className="text-sm font-medium text-zinc-200">{t('configNodeP2PIceServers')}</div>
|
||||
<button onClick={addGatewayIceServer} className="brand-button px-2 py-1 rounded-lg text-xs text-white">{t('add')}</button>
|
||||
</div>
|
||||
{Array.isArray((cfg as any)?.gateway?.nodes?.p2p?.ice_servers) && (cfg as any).gateway.nodes.p2p.ice_servers.length > 0 ? (
|
||||
((cfg as any).gateway.nodes.p2p.ice_servers as Array<any>).map((server, index) => (
|
||||
<div key={`ice-${index}`} className="grid grid-cols-1 md:grid-cols-7 gap-2 rounded-xl border border-zinc-800 bg-zinc-900/30 p-2 text-xs">
|
||||
<input
|
||||
value={Array.isArray(server?.urls) ? server.urls.join(', ') : ''}
|
||||
onChange={(e) => updateGatewayIceServer(index, 'urls', e.target.value.split(',').map((s) => s.trim()).filter(Boolean))}
|
||||
placeholder={t('configNodeP2PIceUrlsPlaceholder')}
|
||||
className="md:col-span-3 px-2 py-1 rounded-lg bg-zinc-950/70 border border-zinc-800"
|
||||
/>
|
||||
<input
|
||||
value={String(server?.username || '')}
|
||||
onChange={(e) => updateGatewayIceServer(index, 'username', e.target.value)}
|
||||
placeholder={t('configNodeP2PIceUsername')}
|
||||
className="md:col-span-1 px-2 py-1 rounded-lg bg-zinc-950/70 border border-zinc-800"
|
||||
/>
|
||||
<input
|
||||
value={String(server?.credential || '')}
|
||||
onChange={(e) => updateGatewayIceServer(index, 'credential', e.target.value)}
|
||||
placeholder={t('configNodeP2PIceCredential')}
|
||||
className="md:col-span-2 px-2 py-1 rounded-lg bg-zinc-950/70 border border-zinc-800"
|
||||
/>
|
||||
<button onClick={() => removeGatewayIceServer(index)} className="md:col-span-1 px-2 py-1 rounded bg-red-900/60 hover:bg-red-800 text-red-100">{t('delete')}</button>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="text-xs text-zinc-500">{t('configNodeP2PIceServersEmpty')}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{activeTop && activeTop !== hotReloadTabKey ? (
|
||||
<RecursiveConfig
|
||||
data={(cfg as any)?.[activeTop] || {}}
|
||||
|
||||
@@ -40,6 +40,11 @@ const Dashboard: React.FC = () => {
|
||||
const p2pEnabled = Boolean(nodeP2P?.enabled);
|
||||
const p2pTransport = String(nodeP2P?.transport || (p2pEnabled ? 'enabled' : 'disabled'));
|
||||
const p2pSessions = Number(nodeP2P?.active_sessions || 0);
|
||||
const p2pConfiguredStun = Array.isArray(nodeP2P?.configured_stun) ? nodeP2P.configured_stun.length : 0;
|
||||
const p2pConfiguredIce = Number(nodeP2P?.configured_ice || nodeP2P?.ice_servers || 0);
|
||||
const p2pRetryCount = Array.isArray(nodeP2P?.nodes)
|
||||
? nodeP2P.nodes.reduce((sum: number, session: any) => sum + Number(session?.retry_count || 0), 0)
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div className="p-4 md:p-6 xl:p-8 w-full space-y-6 xl:space-y-8">
|
||||
@@ -78,10 +83,14 @@ const Dashboard: React.FC = () => {
|
||||
<div className="brand-card rounded-[28px] border border-zinc-800 p-5 min-h-[148px]">
|
||||
<div className="flex items-center gap-2 text-zinc-200 mb-2">
|
||||
<Workflow className="w-4 h-4 text-sky-400" />
|
||||
<div className="text-sm font-medium">{t('ekgTopProvidersWorkload')}</div>
|
||||
<div className="text-sm font-medium">{t('nodeP2P')}</div>
|
||||
</div>
|
||||
<div className="text-2xl font-semibold text-zinc-100 truncate">
|
||||
{p2pEnabled ? `${p2pConfiguredIce} ICE · ${p2pConfiguredStun} STUN` : t('disabled')}
|
||||
</div>
|
||||
<div className="mt-2 text-xs text-zinc-500">
|
||||
{t('dashboardNodeP2PDetail', { transport: p2pTransport, sessions: p2pSessions, retries: p2pRetryCount })}
|
||||
</div>
|
||||
<div className="text-2xl font-semibold text-zinc-100 truncate">{ekgTopProvider}</div>
|
||||
<div className="mt-2 text-xs text-zinc-500">{t('dashboardWorkloadSnapshot')}</div>
|
||||
</div>
|
||||
<div className="brand-card rounded-[28px] border border-zinc-800 p-5 min-h-[148px]">
|
||||
<div className="flex items-center gap-2 text-zinc-200 mb-2">
|
||||
@@ -120,19 +129,22 @@ const Dashboard: React.FC = () => {
|
||||
|
||||
<div className="brand-card rounded-[30px] border border-zinc-800/80 p-6 min-h-[340px] h-full">
|
||||
<div className="flex items-center gap-2 mb-5 text-zinc-200">
|
||||
<AlertTriangle className="w-5 h-5 text-zinc-400" />
|
||||
<h2 className="text-lg font-medium">{t('statusError')}</h2>
|
||||
<Workflow className="w-5 h-5 text-zinc-400" />
|
||||
<h2 className="text-lg font-medium">{t('nodeP2P')}</h2>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{recentFailures.length === 0 ? (
|
||||
<div className="text-sm text-zinc-500 text-center py-10">-</div>
|
||||
) : recentFailures.map((task: any, index: number) => (
|
||||
<div key={`${task.task_id || 'failed'}-${index}`} className="brand-card-subtle rounded-2xl border border-zinc-800 p-4">
|
||||
<div className="text-sm font-medium text-zinc-200 truncate">{task.task_id || `task-${index + 1}`}</div>
|
||||
<div className="text-xs text-zinc-500 truncate mt-1">{task.source || '-'} · {task.channel || '-'}</div>
|
||||
<div className="text-xs text-rose-300 mt-2 break-all">{task.error || task.block_reason || '-'}</div>
|
||||
</div>
|
||||
))}
|
||||
<div className="brand-card-subtle rounded-2xl border border-zinc-800 p-4">
|
||||
<div className="text-sm font-medium text-zinc-200">{t('dashboardNodeP2PTransport')}</div>
|
||||
<div className="text-xs text-zinc-500 mt-1">{p2pTransport}</div>
|
||||
</div>
|
||||
<div className="brand-card-subtle rounded-2xl border border-zinc-800 p-4">
|
||||
<div className="text-sm font-medium text-zinc-200">{t('dashboardNodeP2PIce')}</div>
|
||||
<div className="text-xs text-zinc-500 mt-1">{`${p2pConfiguredIce} ICE · ${p2pConfiguredStun} STUN`}</div>
|
||||
</div>
|
||||
<div className="brand-card-subtle rounded-2xl border border-zinc-800 p-4">
|
||||
<div className="text-sm font-medium text-zinc-200">{t('dashboardNodeP2PHealth')}</div>
|
||||
<div className="text-xs text-zinc-500 mt-1">{t('dashboardNodeP2PDetail', { transport: p2pTransport, sessions: p2pSessions, retries: p2pRetryCount })}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user