@twinsearth/dsh-2xray 0.2.2 → 0.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +8 -0
- package/index.js +18 -0
- package/lib/client.js +94 -34
- package/lib/supervisor.js +20 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.2.3 — 2026-10-29
|
|
4
|
+
|
|
5
|
+
### 界面
|
|
6
|
+
|
|
7
|
+
- 主面板改为三卡片:①当前 VPN 配置(节点下拉、协议/安全/SNI、切换节点)②实时连接信息(运行时长、代理端口、Xray PID、入站类型,日志可折叠)③连接状态(状态点、延迟、出口 IP、节点名)。
|
|
8
|
+
- 新增 `POST /api/xray/switch`(鉴权围栏)驱动节点切换;切换依赖控制面与多节点,单节点会返回明确错误。
|
|
9
|
+
- 状态快照补充 `inbound`、`uptimeSec`、节点 `security/serverName/flow/network`。
|
|
10
|
+
|
|
3
11
|
## 0.2.2 — 2026-10-29
|
|
4
12
|
|
|
5
13
|
### 新增
|
package/index.js
CHANGED
|
@@ -158,6 +158,24 @@ export function apply(ctx, rawConfig) {
|
|
|
158
158
|
}),
|
|
159
159
|
'dsh-2xray: status api',
|
|
160
160
|
)
|
|
161
|
+
child.effect(
|
|
162
|
+
() =>
|
|
163
|
+
child.connection.fetch.register({
|
|
164
|
+
path: '/api/xray/switch',
|
|
165
|
+
methods: ['POST'],
|
|
166
|
+
requestBody: 'buffered',
|
|
167
|
+
fetch: async (request) => {
|
|
168
|
+
try {
|
|
169
|
+
const body = await request.json()
|
|
170
|
+
const activeNode = await supervisor.switchNode(String(body?.node ?? ''))
|
|
171
|
+
return Response.json({ ok: true, activeNode })
|
|
172
|
+
} catch (error) {
|
|
173
|
+
return Response.json({ ok: false, error: String(error?.message ?? error) }, { status: 400 })
|
|
174
|
+
}
|
|
175
|
+
},
|
|
176
|
+
}),
|
|
177
|
+
'dsh-2xray: switch api',
|
|
178
|
+
)
|
|
161
179
|
} catch (error) {
|
|
162
180
|
logger.warn(`状态 API 注册失败:${String(error)}`)
|
|
163
181
|
}
|
package/lib/client.js
CHANGED
|
@@ -114,29 +114,100 @@ window.__ModuleLoader__.load({
|
|
|
114
114
|
return { status, logs, error, setLogs }
|
|
115
115
|
}
|
|
116
116
|
|
|
117
|
-
function
|
|
117
|
+
function fmtUptime(seconds) {
|
|
118
|
+
const total = Math.max(0, Math.floor(seconds ?? 0))
|
|
119
|
+
const hh = Math.floor(total / 3600)
|
|
120
|
+
const mm = Math.floor((total % 3600) / 60)
|
|
121
|
+
const ss = total % 60
|
|
122
|
+
return `${String(hh).padStart(2, '0')}:${String(mm).padStart(2, '0')}:${String(ss).padStart(2, '0')}`
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function ConfigCard({ status }) {
|
|
118
126
|
const nodes = status?.nodes ?? []
|
|
127
|
+
const active = nodes.find((node) => node.tag === status?.activeNode) ?? nodes[0]
|
|
128
|
+
const [switching, setSwitching] = React.useState(false)
|
|
129
|
+
const [message, setMessage] = React.useState('')
|
|
130
|
+
|
|
119
131
|
if (nodes.length === 0) {
|
|
120
|
-
return h('
|
|
132
|
+
return h('section', { style: C.card },
|
|
133
|
+
h('div', { style: C.cardHead }, '① 当前 VPN 配置'),
|
|
134
|
+
h('div', { style: { ...C.muted, fontSize: 12.5 } }, '还没有节点。到「设置 → 插件 → Xray 代理」粘贴 vless 链接并保存,重启 DSH 后这里会显示。'))
|
|
121
135
|
}
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
136
|
+
|
|
137
|
+
const switchTo = async (tag) => {
|
|
138
|
+
if (!tag || tag === status?.activeNode) return
|
|
139
|
+
setSwitching(true)
|
|
140
|
+
setMessage('')
|
|
141
|
+
try {
|
|
142
|
+
const response = await fetch(new URL('api/xray/switch', document.baseURI), {
|
|
143
|
+
method: 'POST',
|
|
144
|
+
credentials: 'same-origin',
|
|
145
|
+
headers: { 'content-type': 'application/json' },
|
|
146
|
+
body: JSON.stringify({ node: tag }),
|
|
147
|
+
})
|
|
148
|
+
const data = await response.json()
|
|
149
|
+
if (!data.ok) throw new Error(data.error || '切换失败')
|
|
150
|
+
setMessage(`已切换到 ${tag}`)
|
|
151
|
+
} catch (error) {
|
|
152
|
+
setMessage(String(error.message || error))
|
|
153
|
+
} finally {
|
|
154
|
+
setSwitching(false)
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return h('section', { style: C.card },
|
|
159
|
+
h('div', { style: C.cardHead }, '① 当前 VPN 配置',
|
|
160
|
+
h('span', { style: { ...C.muted, fontWeight: 400, marginLeft: 'auto' } }, `${nodes.length} 个节点`)),
|
|
161
|
+
h('div', { style: { display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap' } },
|
|
162
|
+
h('select', {
|
|
163
|
+
value: active?.tag ?? '',
|
|
164
|
+
disabled: switching,
|
|
165
|
+
onChange: (event) => switchTo(event.target.value),
|
|
166
|
+
style: { ...C.input, width: 'auto', minWidth: 180 },
|
|
167
|
+
}, nodes.map((node) => h('option', { key: node.tag, value: node.tag }, node.id ?? node.tag))),
|
|
168
|
+
h('span', { style: { ...C.mono, fontSize: 12.5 } }, active ? `VLESS · ${active.address}:${active.port}` : '—'),
|
|
169
|
+
h('span', { style: { ...C.muted, fontSize: 12 } }, switching ? '切换中…' : message)),
|
|
170
|
+
active
|
|
171
|
+
? h('div', { style: { ...C.muted, fontSize: 12.5, marginTop: 8 } },
|
|
172
|
+
`安全:${String(active.security || 'none').toUpperCase()}${active.serverName ? ` (${active.serverName})` : ''} · 传输:${active.network || 'raw'}${active.flow ? ` · flow:${active.flow}` : ''}`)
|
|
173
|
+
: null)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function RuntimeCard({ status, logs, setLogs }) {
|
|
177
|
+
const [showLogs, setShowLogs] = React.useState(false)
|
|
178
|
+
const inbound = status?.inbound
|
|
179
|
+
const inboundLabel = inbound
|
|
180
|
+
? `${String(inbound.protocol).toUpperCase()}:${inbound.port}${inbound.socks ? ` · SOCKS5:${inbound.socks.port}` : ''}`
|
|
181
|
+
: '—'
|
|
182
|
+
const metrics = [
|
|
183
|
+
['运行时长', fmtUptime(status?.uptimeSec)],
|
|
184
|
+
['代理端口', status ? `:${status.port}` : '—'],
|
|
185
|
+
['Xray PID', status?.process?.pid ?? '—'],
|
|
186
|
+
['入站类型', inboundLabel],
|
|
187
|
+
]
|
|
188
|
+
return h('section', { style: C.card },
|
|
189
|
+
h('div', { style: C.cardHead }, '② 实时连接信息',
|
|
190
|
+
h('button', { type: 'button', style: { ...C.btn, marginLeft: 'auto' }, onClick: () => setShowLogs((value) => !value) },
|
|
191
|
+
showLogs ? '收起日志' : '查看日志')),
|
|
192
|
+
h('div', { style: { display: 'grid', gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', gap: 10 } },
|
|
193
|
+
metrics.map(([label, value]) => h('div', { key: label },
|
|
194
|
+
h('div', { style: { ...C.muted, fontSize: 11.5 } }, label),
|
|
195
|
+
h('div', { style: { ...C.mono, fontSize: 14, fontWeight: 600, marginTop: 2 } }, String(value))))),
|
|
196
|
+
showLogs ? h('div', { style: { marginTop: 12 } }, h(LogList, { logs, setLogs })) : null)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function StatusCard({ status, latency, egress, phase }) {
|
|
200
|
+
return h('section', { style: C.card },
|
|
201
|
+
h('div', { style: C.cardHead }, '③ 连接状态'),
|
|
202
|
+
h('div', { style: { display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' } },
|
|
203
|
+
h('span', { style: { ...C.dot, background: phase.color } }),
|
|
204
|
+
h('b', { style: { fontSize: 14 } }, phase.label),
|
|
205
|
+
h('span', { style: { ...C.mono, fontSize: 13 } }, latency == null ? '延迟 —' : `延迟 ${latency} ms`),
|
|
206
|
+
h('span', { style: { ...C.mono, fontSize: 12.5, marginLeft: 'auto' } },
|
|
207
|
+
egress?.ip ? `出口 IP ${egress.ip}${egress.country ? ` (${egress.country})` : ''}` : '出口 IP —')),
|
|
208
|
+
h('div', { style: { ...C.muted, fontSize: 12.5, marginTop: 6 } },
|
|
209
|
+
`节点「${status?.vpnName ?? status?.activeNode ?? '—'}」` +
|
|
210
|
+
(status?.route?.installed ? ` · ${status.route.mode === 'harness' ? 'DSH 策略已接管' : '内置 dispatcher'}` : ' · 尚未接管')))
|
|
140
211
|
}
|
|
141
212
|
|
|
142
213
|
function LogList({ logs, setLogs }) {
|
|
@@ -180,20 +251,9 @@ window.__ModuleLoader__.load({
|
|
|
180
251
|
? h('div', { style: { ...C.card, fontSize: 12.5 } }, h('b', null, '原因:'), ` ${status.reason}`,
|
|
181
252
|
status.detail ? h('div', { style: { ...C.muted, marginTop: 4 } }, status.detail) : null)
|
|
182
253
|
: null,
|
|
183
|
-
h(
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
h(NodeTable, { status })),
|
|
187
|
-
h('section', { style: C.card },
|
|
188
|
-
h('div', { style: C.cardHead }, '实时连接日志'),
|
|
189
|
-
h(LogList, { logs, setLogs })),
|
|
190
|
-
h('div', { style: C.statusBar },
|
|
191
|
-
h('span', { style: { ...C.dot, background: phase.color } }),
|
|
192
|
-
h('b', null, phase.label),
|
|
193
|
-
h('span', { style: { ...C.mono, fontWeight: 600 } }, latency == null ? '延迟 —' : `延迟 ${latency} ms`),
|
|
194
|
-
h('span', { style: { ...C.mono } }, status?.vpnName ? String(status.vpnName) : '—'),
|
|
195
|
-
h('span', { style: { ...C.mono } }, egress?.ip ? `出口 ${egress.ip}${egress.country ? ` (${egress.country})` : ''}` : '出口 IP —'),
|
|
196
|
-
h('span', { style: { ...C.muted, marginLeft: 'auto' } }, status?.updatedAt ? `更新于 ${fmtTime(status.updatedAt)}` : '等待状态')))
|
|
254
|
+
h(ConfigCard, { status }),
|
|
255
|
+
h(RuntimeCard, { status, logs, setLogs }),
|
|
256
|
+
h(StatusCard, { status, latency, egress, phase }))
|
|
197
257
|
}
|
|
198
258
|
|
|
199
259
|
function Field({ label, children }) {
|
package/lib/supervisor.js
CHANGED
|
@@ -694,13 +694,32 @@ export class Supervisor {
|
|
|
694
694
|
enabled: this.config.enabled,
|
|
695
695
|
takeover: this.config.takeover,
|
|
696
696
|
settingsSchema: this.#schemaLoaded,
|
|
697
|
-
nodes: this.config.nodes.map((node) => ({
|
|
697
|
+
nodes: this.config.nodes.map((node) => ({
|
|
698
|
+
id: node.id,
|
|
699
|
+
tag: node.tag,
|
|
700
|
+
address: node.address,
|
|
701
|
+
port: node.port,
|
|
702
|
+
protocol: 'vless',
|
|
703
|
+
security: node.security,
|
|
704
|
+
serverName: node.serverName,
|
|
705
|
+
flow: node.flow,
|
|
706
|
+
network: node.network,
|
|
707
|
+
})),
|
|
698
708
|
activeNode: this.#activeNode,
|
|
699
709
|
control: this.#control ? { enabled: true, ...this.#control.snapshot() } : { enabled: false },
|
|
700
710
|
generation: this.#generation,
|
|
701
711
|
restarts: this.#restarts,
|
|
702
712
|
port: this.config.inbound.port,
|
|
703
713
|
portSource: 'config',
|
|
714
|
+
inbound: {
|
|
715
|
+
protocol: this.config.inbound.protocol,
|
|
716
|
+
host: this.config.inbound.host,
|
|
717
|
+
port: this.config.inbound.port,
|
|
718
|
+
socks: this.config.inbound.socks.enabled
|
|
719
|
+
? { host: this.config.inbound.socks.host, port: this.config.inbound.socks.port }
|
|
720
|
+
: null,
|
|
721
|
+
},
|
|
722
|
+
uptimeSec: this.#startedAt ? Math.max(0, Math.floor((this.#now() - this.#startedAt) / 1000)) : 0,
|
|
704
723
|
binary: this.#binary
|
|
705
724
|
? { path: this.#binary.path, source: this.#binary.source, version: this.#version ?? null }
|
|
706
725
|
: null,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@twinsearth/dsh-2xray",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.3",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "DSH-2Xray: supervises xray-core and routes DSH process-wide HTTP(S) traffic through its local inbound, with optional multi-node gRPC control plane",
|
|
6
6
|
"type": "module",
|