@twinsearth/dsh-2xray 0.2.0 → 0.2.2

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 CHANGED
@@ -1,5 +1,27 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.2 — 2026-10-29
4
+
5
+ ### 新增
6
+
7
+ - **界面三段式重做**:左侧「2Xray」面板改为「当前 VPN 配置 / 实时连接日志 / 状态栏(延迟 · VPN 名称 · 出口 IP)」。
8
+ - **设置页卡片**:客户端注册 `plugins.item` 卡片(分享链接、启用、takeover、xray 路径、端口),解决 DSH 不会自动渲染表单的问题;侧边栏面板与设置卡片共用同一配置。
9
+ - **Host 状态通道**:`/api/xray/status`(经 `ctx.connection.fetch` 鉴权围栏)返回快照 + 日志环 + 延迟/出口 IP;日志 1000 条环缓冲、按 cursor 增量拉取。
10
+ - **延迟/出口探测**:经本地入站 absolute-form 请求,仅接管后运行,30s/10min TTL,单飞。
11
+
12
+ ### 修复
13
+
14
+ - 功能探测失败不再杀进程:改为 `degraded(probe-failed)`,健康循环重试并自动接管。
15
+ - 默认探测目标从 gstatic 改为 `cp.cloudflare.com/generate_204`,避免大陆网络误判。
16
+ - `xray_status` 增加 `settingsSchema`,可远程确认宿主是否加载了设置表单 schema。
17
+
18
+ ## 0.2.1 — 2026-10-29
19
+
20
+ ### 修复
21
+
22
+ - **设置页仍不显示**:Schemastery 改为优先通过 `createRequire(import.meta.url)` / profile 锚点解析文件后动态 import,绕开宿主模块加载器对裸 specifier 的受限解析;加载结果写入 `xray_status.settingsSchema`,可远程确认表单是否具备渲染条件。
23
+ - **侧边栏入口优先注册**:先注册 `sidebar.panellist`,再注册主面板;主面板失败不再连带隐藏入口。
24
+
3
25
  ## 0.2.0 — 2026-10-29
4
26
 
5
27
  ### 新增
package/index.js CHANGED
@@ -6,6 +6,8 @@ import { createLogger } from './lib/log.js'
6
6
  import { resolveDataDir } from './lib/paths.js'
7
7
  import { ControlClient } from './lib/control/client.js'
8
8
  import { installTools } from './lib/tools.js'
9
+ import { LogBuffer } from './lib/logbuf.js'
10
+ import { Monitor } from './lib/monitor.js'
9
11
 
10
12
  export const name = 'dsh-2xray'
11
13
 
@@ -36,7 +38,23 @@ export { Config }
36
38
  * disposer returned from `apply` are the lifecycle seam.
37
39
  */
38
40
  export function apply(ctx, rawConfig) {
39
- const logger = createLogger(ctx)
41
+ const ring = new LogBuffer(1000)
42
+ const baseLogger = createLogger(ctx)
43
+ const logger = {
44
+ info: (...args) => {
45
+ ring.append('info', args.join(' '))
46
+ baseLogger.info(...args)
47
+ },
48
+ warn: (...args) => {
49
+ ring.append('warn', args.join(' '))
50
+ baseLogger.warn(...args)
51
+ },
52
+ error: (...args) => {
53
+ ring.append('error', args.join(' '))
54
+ baseLogger.error(...args)
55
+ },
56
+ debug: (...args) => baseLogger.debug(...args),
57
+ }
40
58
  const { config, errors, warnings } = normalizeConfig(rawConfig)
41
59
 
42
60
  for (const warning of warnings) logger.warn(`配置警告:${warning}`)
@@ -90,10 +108,62 @@ export function apply(ctx, rawConfig) {
90
108
  launcher,
91
109
  route,
92
110
  control,
111
+ schemaLoaded: Boolean(Config),
93
112
  logger,
94
113
  dataDir,
95
114
  })
96
115
 
116
+ const monitor = new Monitor({
117
+ host: config.inbound.host,
118
+ port: config.inbound.port,
119
+ latencyUrl: config.functionalProbe.url,
120
+ logger,
121
+ })
122
+
123
+ // Authenticated status/log feed for the browser panel. Optional service: a
124
+ // profile without `connection` keeps the proxy and the tools, it only loses
125
+ // the live panel data.
126
+ if (typeof ctx.inject === 'function') {
127
+ ctx.inject(['connection'], (child) => {
128
+ try {
129
+ child.effect(
130
+ () =>
131
+ child.connection.fetch.register({
132
+ path: '/api/xray/status',
133
+ methods: ['GET'],
134
+ requestBody: 'buffered',
135
+ fetch: (request) => {
136
+ const url = new URL(request.url)
137
+ const after = Number(url.searchParams.get('after') ?? 0) || 0
138
+ const snapshot = supervisor.snapshot()
139
+ const proxied = snapshot.state === 'ready' && snapshot.route?.installed === true
140
+ monitor.maybeRefresh({ ready: proxied })
141
+ const node = snapshot.nodes?.find((entry) => entry.tag === snapshot.activeNode)
142
+ const page = ring.since(after)
143
+ const body = JSON.stringify({
144
+ ok: true,
145
+ status: {
146
+ ...snapshot,
147
+ proxied,
148
+ vpnName: node?.id ?? snapshot.activeNode ?? null,
149
+ probe: monitor.snapshot(),
150
+ },
151
+ logs: page.items,
152
+ cursor: page.cursor,
153
+ })
154
+ return new Response(body, {
155
+ headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' },
156
+ })
157
+ },
158
+ }),
159
+ 'dsh-2xray: status api',
160
+ )
161
+ } catch (error) {
162
+ logger.warn(`状态 API 注册失败:${String(error)}`)
163
+ }
164
+ })
165
+ }
166
+
97
167
  const dispose = ctx.effect(
98
168
  () => async () => {
99
169
  await supervisor.stop()
package/lib/client.js CHANGED
@@ -5,14 +5,40 @@ window.__ModuleLoader__.load({
5
5
  const h = React.createElement
6
6
 
7
7
  const PANEL_ID = 'xray'
8
+ const NS = 'xray-proxy'
8
9
  const inject = ['slots', 'locale']
9
10
 
10
- function GlobeIcon() {
11
+ const C = {
12
+ page: { maxWidth: 980, margin: '0 auto', padding: '20px 24px 16px', color: 'var(--dsw-alias-label-primary, #0f1115)' },
13
+ header: { display: 'flex', alignItems: 'center', gap: 10, marginBottom: 14 },
14
+ globe: { display: 'inline-flex', alignItems: 'center', justifyContent: 'center' },
15
+ title: { fontSize: 18, fontWeight: 600, margin: 0 },
16
+ sub: { fontSize: 12, color: 'var(--dsw-alias-label-secondary, #61666b)', margin: '2px 0 0' },
17
+ pill: { marginLeft: 'auto', display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: 12, fontWeight: 500, padding: '4px 10px', borderRadius: 999, border: '1px solid var(--dsw-alias-border-l2, rgba(0,0,0,.12))' },
18
+ dot: { width: 8, height: 8, borderRadius: 999, background: '#9ca3af' },
19
+ card: { border: '1px solid var(--dsw-alias-border-l2, rgba(0,0,0,.12))', borderRadius: 12, padding: 12, marginBottom: 12, background: 'var(--dsw-alias-bg-layer-1, transparent)' },
20
+ cardHead: { display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8, fontSize: 13, fontWeight: 600 },
21
+ table: { width: '100%', borderCollapse: 'collapse', fontSize: 12.5 },
22
+ th: { textAlign: 'left', fontWeight: 500, color: 'var(--dsw-alias-label-secondary, #61666b)', padding: '6px 8px', borderBottom: '1px solid var(--dsw-alias-border-l2, rgba(0,0,0,.12))' },
23
+ td: { padding: '7px 8px', borderBottom: '1px solid var(--dsw-alias-border-l1, rgba(0,0,0,.06))', verticalAlign: 'middle' },
24
+ mono: { fontFamily: 'ui-monospace, Consolas, monospace' },
25
+ logBox: { height: 220, overflow: 'auto', border: '1px solid var(--dsw-alias-border-l2, rgba(0,0,0,.12))', borderRadius: 8, padding: '6px 8px', background: 'var(--dsw-alias-markdown-code-block, #f9fafb)' },
26
+ logLine: { display: 'flex', gap: 8, fontSize: 11.5, lineHeight: '18px', fontFamily: 'ui-monospace, Consolas, monospace' },
27
+ statusBar: { display: 'flex', alignItems: 'center', gap: 14, flexWrap: 'wrap', border: '1px solid var(--dsw-alias-border-l2, rgba(0,0,0,.12))', borderRadius: 12, padding: '10px 14px', fontSize: 12.5 },
28
+ btn: { padding: '5px 10px', borderRadius: 7, border: '1px solid var(--dsw-alias-border-l2, rgba(0,0,0,.16))', background: 'transparent', color: 'inherit', cursor: 'pointer', fontSize: 12 },
29
+ primaryBtn: { padding: '6px 12px', borderRadius: 7, border: '1px solid #2563eb', background: '#2563eb', color: '#fff', cursor: 'pointer', fontSize: 12.5 },
30
+ input: { width: '100%', boxSizing: 'border-box', padding: '7px 9px', borderRadius: 7, border: '1px solid var(--dsw-alias-border-l2, rgba(0,0,0,.16))', background: 'transparent', color: 'inherit', fontSize: 12.5 },
31
+ label: { display: 'block', fontSize: 12, color: 'var(--dsw-alias-label-secondary, #61666b)', margin: '8px 0 4px' },
32
+ muted: { color: 'var(--dsw-alias-label-secondary, #61666b)' },
33
+ error: { color: '#c81e1e' },
34
+ }
35
+
36
+ function GlobeIcon({ size = 20 } = {}) {
11
37
  return h(
12
38
  'svg',
13
39
  {
14
- width: 20,
15
- height: 20,
40
+ width: size,
41
+ height: size,
16
42
  viewBox: '0 0 24 24',
17
43
  fill: 'none',
18
44
  stroke: 'currentColor',
@@ -27,241 +53,268 @@ window.__ModuleLoader__.load({
27
53
  )
28
54
  }
29
55
 
30
- const styles = {
31
- page: { maxWidth: 760, margin: '0 auto', padding: '28px 20px 48px', color: 'var(--dsh-text, #e2e8f0)' },
32
- title: { fontSize: 20, fontWeight: 600, margin: '0 0 4px' },
33
- hint: { color: 'var(--dsh-text-secondary, #94a3b8)', fontSize: 13, margin: '0 0 20px' },
34
- card: { border: '1px solid var(--dsh-border, #334155)', borderRadius: 12, padding: 16, marginBottom: 16 },
35
- h2: { fontSize: 15, margin: '0 0 10px' },
36
- textarea: {
37
- width: '100%',
38
- boxSizing: 'border-box',
39
- minHeight: 84,
40
- padding: 10,
41
- borderRadius: 8,
42
- border: '1px solid var(--dsh-border, #334155)',
43
- background: 'var(--dsh-surface, #0b1220)',
44
- color: 'inherit',
45
- font: '13px/1.5 ui-monospace, Consolas, monospace',
46
- resize: 'vertical',
47
- },
48
- row: { display: 'flex', gap: 10, flexWrap: 'wrap', alignItems: 'center', marginTop: 10 },
49
- button: {
50
- padding: '8px 14px',
51
- borderRadius: 8,
52
- border: '1px solid var(--dsh-border, #334155)',
53
- background: 'var(--dsh-surface-raised, #334155)',
54
- color: 'inherit',
55
- cursor: 'pointer',
56
- fontSize: 14,
57
- },
58
- primary: { background: '#2563eb', borderColor: '#2563eb', color: '#fff' },
59
- status: { fontSize: 13, marginTop: 10, minHeight: 20, color: 'var(--dsh-text-secondary, #94a3b8)' },
60
- video: { width: '100%', maxHeight: 320, borderRadius: 8, background: '#000', marginTop: 12, display: 'none' },
61
- steps: { margin: '10px 0 0', paddingLeft: 20, fontSize: 13, color: 'var(--dsh-text-secondary, #94a3b8)' },
56
+ function phaseOf(status) {
57
+ if (!status) return { label: '状态未知', color: '#9ca3af' }
58
+ if (status.proxied) return { label: '已连接', color: '#15803d' }
59
+ if (status.state === 'ready') return { label: '已就绪 · 未接管', color: '#b45309' }
60
+ if (status.state === 'degraded') return { label: '连接降级', color: '#b45309' }
61
+ if (status.state === 'starting' || status.state === 'resolving' || status.state === 'backoff') return { label: '正在连接', color: '#2f5fbf' }
62
+ if (status.state === 'failed') return { label: '连接失败', color: '#c81e1e' }
63
+ if (status.state === 'stopped') return { label: '已停止', color: '#9ca3af' }
64
+ return { label: '未启用', color: '#9ca3af' }
62
65
  }
63
66
 
64
- function XrayPage() {
65
- const [text, setText] = React.useState('')
66
- const [status, setStatus] = React.useState('')
67
- const [statusKind, setStatusKind] = React.useState('')
68
- const [scanning, setScanning] = React.useState(false)
69
- const videoRef = React.useRef(null)
70
- const streamRef = React.useRef(null)
71
- const timerRef = React.useRef(null)
72
- const detectorRef = React.useRef(null)
67
+ function fmtTime(ts) {
68
+ const d = new Date(ts)
69
+ return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}:${String(d.getSeconds()).padStart(2, '0')}`
70
+ }
73
71
 
74
- const say = (message, kind) => {
75
- setStatus(message)
76
- setStatusKind(kind || '')
77
- }
72
+ function StatusPill({ status }) {
73
+ const phase = phaseOf(status)
74
+ return h('span', { style: C.pill }, h('span', { style: { ...C.dot, background: phase.color } }), phase.label)
75
+ }
78
76
 
79
- const detector = () => {
80
- if (detectorRef.current !== null) return detectorRef.current
81
- detectorRef.current = 'BarcodeDetector' in window ? new window.BarcodeDetector({ formats: ['qr_code'] }) : false
82
- return detectorRef.current
83
- }
77
+ function useStatusFeed() {
78
+ const [status, setStatus] = React.useState(null)
79
+ const [logs, setLogs] = React.useState([])
80
+ const [error, setError] = React.useState(null)
81
+ const cursor = React.useRef(0)
84
82
 
85
- const decodeCanvas = async (canvas) => {
86
- const det = detector()
87
- if (!det) return null
88
- const codes = await det.detect(canvas)
89
- return codes && codes.length > 0 ? codes[0].rawValue : null
90
- }
83
+ React.useEffect(() => {
84
+ let cancelled = false
85
+ let timer
86
+ const tick = async () => {
87
+ try {
88
+ const url = new URL(`api/xray/status?after=${cursor.current}`, document.baseURI)
89
+ const response = await fetch(url, { credentials: 'same-origin', headers: { accept: 'application/json' } })
90
+ if (!response.ok) throw new Error(`HTTP ${response.status}`)
91
+ const data = await response.json()
92
+ if (cancelled) return
93
+ setStatus(data.status)
94
+ if (Array.isArray(data.logs) && data.logs.length > 0) {
95
+ setLogs((previous) => {
96
+ const merged = previous.concat(data.logs)
97
+ return merged.length > 500 ? merged.slice(merged.length - 500) : merged
98
+ })
99
+ }
100
+ cursor.current = data.cursor ?? cursor.current
101
+ setError(null)
102
+ } catch (cause) {
103
+ if (!cancelled) setError(cause.message || String(cause))
104
+ }
105
+ if (!cancelled) timer = setTimeout(tick, 2000)
106
+ }
107
+ tick()
108
+ return () => {
109
+ cancelled = true
110
+ clearTimeout(timer)
111
+ }
112
+ }, [])
91
113
 
92
- const accept = (value) => {
93
- const trimmed = String(value || '').trim()
94
- if (!trimmed) return false
95
- setText(trimmed)
96
- say('已识别:' + trimmed.slice(0, 64) + (trimmed.length > 64 ? '…' : ''), 'ok')
97
- return true
98
- }
114
+ return { status, logs, error, setLogs }
115
+ }
99
116
 
100
- const onFile = async (event) => {
101
- const file = event.target.files && event.target.files[0]
102
- if (!file) return
103
- say('正在解析图片…')
104
- try {
105
- const bitmap = await createImageBitmap(file)
106
- const scale = Math.min(1, 1600 / Math.max(bitmap.width, bitmap.height))
107
- const canvas = document.createElement('canvas')
108
- canvas.width = Math.max(1, Math.round(bitmap.width * scale))
109
- canvas.height = Math.max(1, Math.round(bitmap.height * scale))
110
- canvas.getContext('2d').drawImage(bitmap, 0, 0, canvas.width, canvas.height)
111
- const value = await decodeCanvas(canvas)
112
- if (!value) say('图片里没有识别到二维码;换一张更清晰/更大的图,或使用插件目录里的 tools/scanner.html(带 jsQR 兜底)。', 'error')
113
- else accept(value)
114
- } catch (error) {
115
- say('解析失败:' + error.message, 'error')
116
- }
117
+ function NodeTable({ status }) {
118
+ const nodes = status?.nodes ?? []
119
+ if (nodes.length === 0) {
120
+ return h('div', { style: { ...C.muted, padding: '10px 0', fontSize: 12.5 } }, '还没有节点。到「设置 → 插件 → Xray 代理」粘贴 vless 链接并保存,重启 DSH 后这里会显示。')
117
121
  }
122
+ return h(
123
+ 'table',
124
+ { style: C.table },
125
+ h('thead', null, h('tr', null,
126
+ h('th', { style: C.th }), h('th', { style: C.th }, '节点'), h('th', { style: C.th }, '地址'),
127
+ h('th', { style: C.th }, '端口'), h('th', { style: C.th }, '状态'))),
128
+ h('tbody', null, nodes.map((node) => {
129
+ const active = node.tag === status.activeNode
130
+ return h('tr', { key: node.tag },
131
+ h('td', { style: C.td }, active
132
+ ? h('span', { style: { ...C.dot, background: '#15803d', display: 'inline-block' } })
133
+ : h('span', { style: { ...C.dot, border: '1px solid currentColor', background: 'transparent', display: 'inline-block' } })),
134
+ h('td', { style: C.td }, node.id ?? node.tag),
135
+ h('td', { style: { ...C.td, ...C.mono } }, node.address || '—'),
136
+ h('td', { style: { ...C.td, ...C.mono } }, node.port ?? '—'),
137
+ h('td', { style: C.td }, active ? '当前' : '—'))
138
+ })),
139
+ )
140
+ }
118
141
 
119
- const stop = async () => {
120
- if (timerRef.current) {
121
- clearInterval(timerRef.current)
122
- timerRef.current = null
123
- }
124
- if (streamRef.current) {
125
- for (const track of streamRef.current.getTracks()) track.stop()
126
- streamRef.current = null
127
- }
128
- if (videoRef.current) videoRef.current.srcObject = null
129
- setScanning(false)
130
- }
142
+ function LogList({ logs, setLogs }) {
143
+ const box = React.useRef(null)
144
+ const [follow, setFollow] = React.useState(true)
145
+ React.useEffect(() => {
146
+ if (follow && box.current) box.current.scrollTop = box.current.scrollHeight
147
+ }, [logs, follow])
148
+ return h('div', null,
149
+ h('div', { style: { display: 'flex', gap: 8, marginBottom: 6 } },
150
+ h('button', { type: 'button', style: C.btn, onClick: () => setFollow((v) => !v) }, follow ? '自动滚动:开' : '自动滚动:关'),
151
+ h('button', { type: 'button', style: C.btn, onClick: () => setLogs([]) }, '清空视图')),
152
+ h('div', { ref: box, style: C.logBox },
153
+ logs.length === 0
154
+ ? h('div', { style: { ...C.muted, fontSize: 12 } }, '暂无连接日志')
155
+ : logs.map((line) => h('div', { key: line.seq, style: C.logLine },
156
+ h('span', { style: { ...C.muted, flex: '0 0 66px' } }, fmtTime(line.ts)),
157
+ h('span', { style: { flex: '0 0 40px', color: line.level === 'error' ? '#c81e1e' : line.level === 'warn' ? '#b45309' : 'inherit' } }, line.level.toUpperCase()),
158
+ h('span', { style: { flex: 1, whiteSpace: 'pre-wrap', wordBreak: 'break-word' } }, line.text)))))
159
+ }
131
160
 
132
- const tick = async () => {
133
- const video = videoRef.current
134
- if (!video || !streamRef.current) return
135
- if (video.readyState < 2 || !video.videoWidth) return
136
- const canvas = document.createElement('canvas')
137
- canvas.width = video.videoWidth
138
- canvas.height = video.videoHeight
139
- canvas.getContext('2d').drawImage(video, 0, 0)
140
- try {
141
- const value = await decodeCanvas(canvas)
142
- if (value) {
143
- accept(value)
144
- await stop()
145
- say('已识别并停止扫描。', 'ok')
146
- }
147
- } catch {
148
- // a failed frame is normal while the code is out of view
149
- }
161
+ function XrayPage() {
162
+ const { status, logs, error, setLogs } = useStatusFeed()
163
+ const phase = phaseOf(status)
164
+ const probe = status?.probe ?? {}
165
+ const latency = probe.latency?.ms
166
+ const egress = probe.egress ?? {}
167
+ const localEndpoint = status ? `127.0.0.1:${status.port}` : '—'
168
+
169
+ return h('div', { style: C.page },
170
+ h('div', { style: C.header },
171
+ h('span', { style: C.globe }, h(GlobeIcon, { size: 22 })),
172
+ h('div', null,
173
+ h('h1', { style: C.title }, '2Xray'),
174
+ h('p', { style: C.sub }, `Xray 全局代理 · 本地 ${localEndpoint}`)),
175
+ h(StatusPill, { status })),
176
+ error
177
+ ? h('div', { style: { ...C.card, ...C.error, fontSize: 12.5 } }, `状态通道不可用:${error}(代理本身可能仍在工作)`)
178
+ : null,
179
+ status?.reason && status.state !== 'ready'
180
+ ? h('div', { style: { ...C.card, fontSize: 12.5 } }, h('b', null, '原因:'), ` ${status.reason}`,
181
+ status.detail ? h('div', { style: { ...C.muted, marginTop: 4 } }, status.detail) : null)
182
+ : null,
183
+ h('section', { style: C.card },
184
+ h('div', { style: C.cardHead }, '当前 VPN 配置',
185
+ h('span', { style: { ...C.muted, fontWeight: 400, marginLeft: 'auto' } }, `${status?.nodes?.length ?? 0} 个节点`)),
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)}` : '等待状态')))
197
+ }
198
+
199
+ function Field({ label, children }) {
200
+ return h('div', null, h('label', { style: C.label }, label), children)
201
+ }
202
+
203
+ function readFormValue(form) {
204
+ try {
205
+ const snapshot = form?.getSnapshot?.()
206
+ const candidate = snapshot?.value ?? snapshot?.state?.view?.value ?? snapshot?.view?.value
207
+ return candidate && typeof candidate === 'object' ? candidate : {}
208
+ } catch {
209
+ return {}
150
210
  }
211
+ }
151
212
 
152
- const startScreen = async () => {
153
- if (!navigator.mediaDevices || !navigator.mediaDevices.getDisplayMedia) {
154
- say('当前浏览器不支持屏幕捕获;请改用「二维码图片」或 tools/scanner.html。', 'error')
155
- return
156
- }
157
- if (!detector()) {
158
- say('当前浏览器没有 BarcodeDetector;请使用插件目录里的 tools/scanner.html(内置 jsQR 兜底)。', 'error')
159
- return
160
- }
161
- try {
162
- const stream = await navigator.mediaDevices.getDisplayMedia({ video: { frameRate: 10 }, audio: false })
163
- streamRef.current = stream
164
- setScanning(true)
165
- if (videoRef.current) {
166
- videoRef.current.srcObject = stream
167
- videoRef.current.style.display = 'block'
168
- await videoRef.current.play()
169
- }
170
- stream.getVideoTracks()[0].addEventListener('ended', stop)
171
- say('请把二维码放到共享屏幕上,正在识别…')
172
- timerRef.current = setInterval(tick, 350)
173
- } catch (error) {
174
- say('屏幕共享未开始:' + error.message, 'error')
175
- setScanning(false)
176
- }
213
+ async function setField(form, field, value) {
214
+ if (typeof form?.set === 'function' && typeof field === 'string') return form.set(field, value)
215
+ const snapshot = form?.getSnapshot?.()
216
+ const revision = snapshot?.revision ?? snapshot?.state?.view?.revision
217
+ if (typeof form?.mutate === 'function') {
218
+ return form.mutate([{ op: 'set', path: Array.isArray(field) ? field : [field], value }], revision)
177
219
  }
220
+ throw new Error('设置表单不可写')
221
+ }
178
222
 
179
- React.useEffect(() => () => {
180
- if (timerRef.current) clearInterval(timerRef.current)
181
- if (streamRef.current) for (const track of streamRef.current.getTracks()) track.stop()
182
- }, [])
223
+ function XraySettingsCard({ form }) {
224
+ const value = readFormValue(form)
225
+ const [shareLink, setShareLink] = React.useState(value.shareLink ?? '')
226
+ const [binaryPath, setBinaryPath] = React.useState(value.binary?.path ?? '')
227
+ const [port, setPort] = React.useState(String(value.inbound?.port ?? 20808))
228
+ const [enabled, setEnabled] = React.useState(value.enabled === true)
229
+ const [takeover, setTakeover] = React.useState(value.takeover ?? 'auto')
230
+ const [message, setMessage] = React.useState('')
231
+ const [busy, setBusy] = React.useState(false)
183
232
 
184
- const copy = async () => {
185
- if (!text.trim()) {
186
- say('还没有链接可复制。', 'error')
187
- return
188
- }
233
+ const save = async () => {
234
+ setBusy(true)
189
235
  try {
190
- await navigator.clipboard.writeText(text.trim())
191
- say('已复制。粘贴到「插件 → Xray 代理 → 分享链接」后保存,并重启 DSH。', 'ok')
192
- } catch {
193
- say('复制失败,请手动选中文本复制。', 'error')
236
+ await setField(form, 'shareLink', shareLink.trim())
237
+ await setField(form, 'enabled', enabled)
238
+ await setField(form, 'takeover', takeover)
239
+ const numericPort = Number(port)
240
+ if (Number.isInteger(numericPort) && numericPort > 0) await setField(form, ['inbound', 'port'], numericPort)
241
+ if (binaryPath.trim()) await setField(form, ['binary', 'path'], binaryPath.trim())
242
+ setMessage('已保存,重启 DSH 后生效。')
243
+ } catch (error) {
244
+ setMessage('保存失败:' + (error.message || error))
245
+ } finally {
246
+ setBusy(false)
194
247
  }
195
248
  }
196
249
 
197
- return h(
198
- 'div',
199
- { style: styles.page },
200
- h('h1', { style: styles.title }, '2Xray'),
201
- h('p', { style: styles.hint }, '扫描或粘贴 vless 分享链接,然后到「插件 → Xray 代理」里保存。'),
202
- h(
203
- 'section',
204
- { style: styles.card },
205
- h('h2', { style: styles.h2 }, '1. 二维码图片'),
206
- h('input', { type: 'file', accept: 'image/*', onChange: onFile, disabled: scanning }),
207
- h('div', { style: styles.status, 'aria-live': 'polite' }, status),
208
- ),
209
- h(
210
- 'section',
211
- { style: styles.card },
212
- h('h2', { style: styles.h2 }, '2. 扫描屏幕二维码'),
213
- h(
214
- 'div',
215
- { style: styles.row },
216
- h('button', { type: 'button', style: { ...styles.button, ...styles.primary }, onClick: startScreen, disabled: scanning }, scanning ? '扫描中…' : '开始扫描屏幕'),
217
- h('button', { type: 'button', style: styles.button, onClick: stop, disabled: !scanning }, '停止'),
218
- ),
219
- h('video', { ref: videoRef, playsInline: true, muted: true, style: styles.video }),
220
- ),
221
- h(
222
- 'section',
223
- { style: styles.card },
224
- h('h2', { style: styles.h2 }, '3. 分享链接'),
250
+ return h('div', { style: { ...C.card, marginTop: 8 } },
251
+ h('div', { style: C.cardHead }, h(GlobeIcon), '2Xray 连接'),
252
+ h(Field, { label: 'VLESS 分享链接(粘贴或扫码)' },
225
253
  h('textarea', {
226
- style: styles.textarea,
227
- value: text,
254
+ style: { ...C.input, minHeight: 64, fontFamily: 'ui-monospace, Consolas, monospace' },
255
+ value: shareLink,
228
256
  placeholder: 'vless://uuid@host:443?security=reality&sni=...&pbk=...#备注',
229
- onChange: (event) => setText(event.target.value),
230
- }),
231
- h(
232
- 'div',
233
- { style: styles.row },
234
- h('button', { type: 'button', style: { ...styles.button, ...styles.primary }, onClick: copy }, '复制链接'),
235
- h('span', { style: { ...styles.status, marginTop: 0, color: statusKind === 'error' ? '#f87171' : statusKind === 'ok' ? '#4ade80' : undefined } }, status),
236
- ),
237
- h(
238
- 'ol',
239
- { style: styles.steps },
240
- h('li', null, '复制上面的 vless:// 链接;'),
241
- h('li', null, '打开 设置 → 插件 → Xray 代理,粘贴到「分享链接」并保存;'),
242
- h('li', null, '重启 DSH,状态变为 ready 后即生效。'),
243
- ),
244
- ),
245
- )
257
+ onChange: (event) => setShareLink(event.target.value),
258
+ })),
259
+ h('div', { style: { display: 'flex', gap: 12, flexWrap: 'wrap' } },
260
+ h('label', { style: { fontSize: 12.5, display: 'flex', alignItems: 'center', gap: 6, marginTop: 8 } },
261
+ h('input', { type: 'checkbox', checked: enabled, onChange: (event) => setEnabled(event.target.checked) }), '启用代理'),
262
+ h('label', { style: { fontSize: 12.5, display: 'flex', alignItems: 'center', gap: 6, marginTop: 8 } }, 'takeover',
263
+ h('select', { value: takeover, onChange: (event) => setTakeover(event.target.value), style: { padding: '4px 8px', borderRadius: 6, background: 'transparent', color: 'inherit' } },
264
+ h('option', { value: 'auto' }, 'auto'), h('option', { value: 'force' }, 'force'), h('option', { value: 'off' }, 'off')))),
265
+ h(Field, { label: 'xray 可执行文件路径(留空自动查找)' },
266
+ h('input', { style: { ...C.input, fontFamily: 'ui-monospace, Consolas, monospace' }, value: binaryPath, placeholder: '例如 D:\\v2rayN\\xray.exe', onChange: (event) => setBinaryPath(event.target.value) })),
267
+ h(Field, { label: '本地 HTTP 入站端口' },
268
+ h('input', { style: C.input, value: port, onChange: (event) => setPort(event.target.value) })),
269
+ h('div', { style: { display: 'flex', gap: 10, alignItems: 'center', marginTop: 12 } },
270
+ h('button', { type: 'button', style: C.primaryBtn, disabled: busy, onClick: save }, busy ? '保存中…' : '保存'),
271
+ h('span', { style: { ...C.muted, fontSize: 12 } }, message)),
272
+ h('p', { style: { ...C.muted, fontSize: 12, marginTop: 10 } }, '打开左侧「2Xray」面板可扫码导入、查看节点/日志/延迟/出口 IP。'))
246
273
  }
247
274
 
248
275
  function apply(ctx) {
249
276
  ctx.effect(
250
277
  () =>
251
- ctx.slots.inject('main', () =>
252
- ctx.slots.register({ name: 'main', key: PANEL_ID }, XrayPage),
278
+ ctx.slots.inject('sidebar.panellist', () =>
279
+ ctx.slots.register({ name: 'sidebar.panellist', id: PANEL_ID, order: 1, label: () => '2Xray' }, GlobeIcon),
253
280
  ),
254
- 'dsh-2xray: main panel',
281
+ 'dsh-2xray: sidebar entry',
255
282
  )
256
283
  ctx.effect(
257
284
  () =>
258
- ctx.slots.inject('sidebar.panellist', () =>
259
- ctx.slots.register({ name: 'sidebar.panellist', id: PANEL_ID, order: 1, label: () => '2Xray' }, GlobeIcon),
285
+ ctx.slots.inject('main', () =>
286
+ ctx.slots.register({ name: 'main', key: PANEL_ID }, XrayPage),
260
287
  ),
261
- 'dsh-2xray: sidebar entry',
288
+ 'dsh-2xray: main panel',
262
289
  )
290
+ if (typeof ctx.inject === 'function') {
291
+ ctx.inject(['configForms'], (child) => {
292
+ try {
293
+ child.effect(
294
+ () =>
295
+ child.configForms.whileServed([NS], () =>
296
+ child.slots.inject('plugins.item', () =>
297
+ child.slots.register(
298
+ {
299
+ name: 'plugins.item',
300
+ id: PANEL_ID,
301
+ order: 40,
302
+ label: () => '2Xray 连接',
303
+ inject: () => ({ form: child.configForms.get(NS) }),
304
+ },
305
+ XraySettingsCard,
306
+ ),
307
+ ),
308
+ ),
309
+ 'dsh-2xray: settings card',
310
+ )
311
+ } catch {
312
+ // a client without the settings service keeps the sidebar panel
313
+ }
314
+ })
315
+ }
263
316
  }
264
317
 
265
- return { apply, inject, PANEL_ID }
318
+ return { apply, inject, PANEL_ID, NS }
266
319
  },
267
320
  })
package/lib/config.js CHANGED
@@ -79,8 +79,10 @@ export const DEFAULT_CONFIG = {
79
79
  },
80
80
  functionalProbe: {
81
81
  enabled: true,
82
- url: 'http://www.gstatic.com/generate_204',
83
- timeoutMs: 5000,
82
+ // Reachable from mainland networks through most nodes; gstatic is often
83
+ // blocked and would misreport a working tunnel as failed.
84
+ url: 'http://cp.cloudflare.com/generate_204',
85
+ timeoutMs: 8000,
84
86
  },
85
87
  log: { level: 'warning' },
86
88
  }
package/lib/logbuf.js ADDED
@@ -0,0 +1,27 @@
1
+ /** Bounded in-memory log ring served to the panel over the authenticated API. */
2
+ export class LogBuffer {
3
+ #items = []
4
+ #capacity
5
+ #seq = 0
6
+
7
+ constructor(capacity = 1000) {
8
+ this.#capacity = capacity
9
+ }
10
+
11
+ append(level, text) {
12
+ const entry = {
13
+ seq: ++this.#seq,
14
+ ts: Date.now(),
15
+ level: ['debug', 'info', 'warn', 'error'].includes(level) ? level : 'info',
16
+ text: String(text ?? '').slice(0, 2000),
17
+ }
18
+ this.#items.push(entry)
19
+ if (this.#items.length > this.#capacity) this.#items.splice(0, this.#items.length - this.#capacity)
20
+ return entry
21
+ }
22
+
23
+ since(after = 0, limit = 200) {
24
+ const items = this.#items.filter((entry) => entry.seq > after).slice(0, limit)
25
+ return { items, cursor: this.#seq, dropped: this.#seq - this.#capacity > after }
26
+ }
27
+ }
package/lib/monitor.js ADDED
@@ -0,0 +1,150 @@
1
+ import http from 'node:http'
2
+
3
+ /**
4
+ * Latency and egress-IP probes. Both go through the local HTTP inbound with an
5
+ * absolute-form request line, so they exercise the tunnel instead of the
6
+ * direct network. Plain http only: no CONNECT/TLS work is needed here.
7
+ */
8
+ export class Monitor {
9
+ #host
10
+ #port
11
+ #latencyUrl
12
+ #egressUrl
13
+ #latencyTimeoutMs
14
+ #egressTimeoutMs
15
+ #latencyTtlMs
16
+ #egressTtlMs
17
+ #logger
18
+ #state = {
19
+ latency: { ms: null, at: null, state: 'idle' },
20
+ egress: { ip: null, country: null, at: null, state: 'idle' },
21
+ }
22
+ #latencyFlight = null
23
+ #egressFlight = null
24
+
25
+ constructor({
26
+ host = '127.0.0.1',
27
+ port = 20808,
28
+ latencyUrl = 'http://cp.cloudflare.com/generate_204',
29
+ egressUrl = 'http://ip-api.com/json/?fields=status,country,countryCode,query',
30
+ latencyTimeoutMs = 4000,
31
+ egressTimeoutMs = 5000,
32
+ latencyTtlMs = 30_000,
33
+ egressTtlMs = 600_000,
34
+ logger,
35
+ } = {}) {
36
+ this.#host = host
37
+ this.#port = port
38
+ this.#latencyUrl = latencyUrl
39
+ this.#egressUrl = egressUrl
40
+ this.#latencyTimeoutMs = latencyTimeoutMs
41
+ this.#egressTimeoutMs = egressTimeoutMs
42
+ this.#latencyTtlMs = latencyTtlMs
43
+ this.#egressTtlMs = egressTtlMs
44
+ this.#logger = logger
45
+ }
46
+
47
+ snapshot() {
48
+ const now = Date.now()
49
+ const latencyFresh = this.#state.latency.at !== null && now - this.#state.latency.at < this.#latencyTtlMs
50
+ const egressFresh = this.#state.egress.at !== null && now - this.#state.egress.at < this.#egressTtlMs
51
+ return {
52
+ latency: { ...this.#state.latency, stale: !latencyFresh },
53
+ egress: { ...this.#state.egress, stale: !egressFresh },
54
+ }
55
+ }
56
+
57
+ /** Kick a refresh when data is stale; never throws, never blocks the caller. */
58
+ maybeRefresh({ ready }) {
59
+ if (!ready) {
60
+ this.#state.latency = { ...this.#state.latency, state: 'skipped' }
61
+ this.#state.egress = { ...this.#state.egress, state: 'skipped' }
62
+ return
63
+ }
64
+ const now = Date.now()
65
+ if (this.#state.latency.at === null || now - this.#state.latency.at >= this.#latencyTtlMs) {
66
+ if (this.#latencyFlight === null) {
67
+ this.#latencyFlight = this.#probeLatency()
68
+ .catch(() => {})
69
+ .finally(() => {
70
+ this.#latencyFlight = null
71
+ })
72
+ }
73
+ }
74
+ if (this.#state.egress.at === null || now - this.#state.egress.at >= this.#egressTtlMs) {
75
+ if (this.#egressFlight === null) {
76
+ this.#egressFlight = this.#probeEgress()
77
+ .catch(() => {})
78
+ .finally(() => {
79
+ this.#egressFlight = null
80
+ })
81
+ }
82
+ }
83
+ }
84
+
85
+ async #probeLatency() {
86
+ const started = Date.now()
87
+ const result = await this.#getThroughProxy(this.#latencyUrl, this.#latencyTimeoutMs)
88
+ const ms = Date.now() - started
89
+ if (result.status >= 200 && result.status < 400) {
90
+ this.#state.latency = { ms, at: Date.now(), state: 'ok' }
91
+ this.#logger?.info?.(`延迟探测:${ms}ms(${this.#latencyUrl})`)
92
+ } else {
93
+ this.#state.latency = { ms: null, at: Date.now(), state: 'error' }
94
+ this.#logger?.warn?.(`延迟探测失败:${result.error ?? result.status}`)
95
+ }
96
+ }
97
+
98
+ async #probeEgress() {
99
+ const result = await this.#getThroughProxy(this.#egressUrl, this.#egressTimeoutMs)
100
+ if (result.status >= 200 && result.status < 300) {
101
+ try {
102
+ const data = JSON.parse(result.body)
103
+ if (data && data.query) {
104
+ this.#state.egress = { ip: data.query, country: data.countryCode ?? data.country ?? null, at: Date.now(), state: 'ok' }
105
+ this.#logger?.info?.(`出口 IP:${data.query}${data.countryCode ? ` (${data.countryCode})` : ''}`)
106
+ return
107
+ }
108
+ } catch {
109
+ // fall through to error state
110
+ }
111
+ }
112
+ this.#state.egress = { ip: null, country: null, at: Date.now(), state: 'error' }
113
+ this.#logger?.warn?.(`出口 IP 探测失败:${result.error ?? result.status}`)
114
+ }
115
+
116
+ #getThroughProxy(url, timeoutMs) {
117
+ return new Promise((resolve) => {
118
+ let target
119
+ try {
120
+ target = new URL(url)
121
+ } catch (error) {
122
+ resolve({ status: 0, body: '', error: String(error) })
123
+ return
124
+ }
125
+ const request = http.request(
126
+ {
127
+ host: this.#host,
128
+ port: this.#port,
129
+ method: 'GET',
130
+ path: target.href,
131
+ headers: { host: target.host, connection: 'close' },
132
+ timeout: timeoutMs,
133
+ },
134
+ (response) => {
135
+ const chunks = []
136
+ response.on('data', (chunk) => chunks.push(chunk))
137
+ response.on('end', () =>
138
+ resolve({ status: response.statusCode ?? 0, body: Buffer.concat(chunks).toString('utf8') }),
139
+ )
140
+ },
141
+ )
142
+ request.on('timeout', () => {
143
+ request.destroy(new Error('timeout'))
144
+ resolve({ status: 0, body: '', error: 'timeout' })
145
+ })
146
+ request.on('error', (error) => resolve({ status: 0, body: '', error: error.code ?? error.message }))
147
+ request.end()
148
+ })
149
+ }
150
+ }
package/lib/schema.js CHANGED
@@ -19,11 +19,6 @@ import { join } from 'node:path'
19
19
  import { pathToFileURL } from 'node:url'
20
20
 
21
21
  async function loadSchema() {
22
- try {
23
- return (await import('@deepseek-ai/schemastery')).default
24
- } catch {
25
- // local checkout / test: borrow the profile's copy
26
- }
27
22
  const home = process.env.USERPROFILE ?? process.env.HOME
28
23
  const anchors = home
29
24
  ? [
@@ -31,13 +26,29 @@ async function loadSchema() {
31
26
  join(home, '.dsh', 'profiles', 'web', 'package.json'),
32
27
  ]
33
28
  : []
34
- for (const anchor of anchors) {
35
- try {
29
+
30
+ // Prefer Node's own resolver: a bare `import()` inside a plugin may be served
31
+ // by the harness module loader, whose table does not necessarily contain a
32
+ // peer package such as Schemastery even though it sits in node_modules.
33
+ const attempts = [
34
+ () => {
35
+ const file = createRequire(import.meta.url).resolve('@deepseek-ai/schemastery')
36
+ return import(pathToFileURL(file).href)
37
+ },
38
+ ...anchors.map((anchor) => () => {
36
39
  const file = createRequire(anchor).resolve('@deepseek-ai/schemastery')
37
- const module = await import(pathToFileURL(file).href)
38
- return module.default ?? module
40
+ return import(pathToFileURL(file).href)
41
+ }),
42
+ () => import('@deepseek-ai/schemastery'),
43
+ ]
44
+
45
+ for (const attempt of attempts) {
46
+ try {
47
+ const module = await attempt()
48
+ const candidate = module?.default ?? module
49
+ if (typeof candidate === 'function' && typeof candidate.object === 'function') return candidate
39
50
  } catch {
40
- // try the next profile
51
+ // try the next resolution route
41
52
  }
42
53
  }
43
54
  throw new Error('@deepseek-ai/schemastery is not resolvable')
package/lib/supervisor.js CHANGED
@@ -86,6 +86,7 @@ export class Supervisor {
86
86
  #resolveBinaryFn
87
87
  #control
88
88
  #activeNode
89
+ #schemaLoaded = false
89
90
  #now
90
91
 
91
92
  constructor({
@@ -100,6 +101,7 @@ export class Supervisor {
100
101
  probes = {},
101
102
  resolveBinaryFn = resolveBinary,
102
103
  control,
104
+ schemaLoaded = false,
103
105
  now = () => Date.now(),
104
106
  }) {
105
107
  this.config = config
@@ -108,6 +110,7 @@ export class Supervisor {
108
110
  this.#launcher = launcher
109
111
  this.#route = route
110
112
  this.#control = control
113
+ this.#schemaLoaded = schemaLoaded
111
114
  this.#activeNode = config.activeNode
112
115
  this.#logger = logger
113
116
  this.dataDir = dataDir
@@ -317,14 +320,16 @@ export class Supervisor {
317
320
  this.#functionalProbe = probe.ok ? 'pass' : `fail:${probe.status ?? probe.error ?? 'unknown'}`
318
321
  if (!probe.ok) {
319
322
  this.#lastStdio = safeTail(handle) || this.#lastStdio
320
- await this.#killHandle(handle)
323
+ // Keep the process: a node whose egress blocks the probe target must
324
+ // not tear down an otherwise healthy tunnel. The health loop retries
325
+ // the probe and installs the route once it passes.
321
326
  this.#setState(
322
- 'failed',
323
- 'functional-probe-failed',
327
+ 'degraded',
328
+ 'probe-failed',
324
329
  `经本地入站的探测请求失败(${probe.error ?? `status ${probe.status}`})。` +
325
- '常见原因:uuid/flow/SNI/shortId/REALITY 公钥与服务端不一致,或远程不可达。' +
326
- '确认链路正常后可设置 functionalProbe.enabled: false 只做 TCP 就绪检查。',
330
+ '进程保留、未接管流量;健康循环会继续重试。常见原因:uuid/flow/SNI/shortId/REALITY 公钥不一致,或探测目标不可达。',
327
331
  )
332
+ this.#startHealthLoop(generation)
328
333
  return
329
334
  }
330
335
  } else {
@@ -561,6 +566,44 @@ export class Supervisor {
561
566
  this.#restartTimer.unref?.()
562
567
  }
563
568
 
569
+ /** Retry the functional probe after a `probe-failed` degradation. */
570
+ async #retryProbeAndInstall() {
571
+ if (!this.config.functionalProbe.enabled) return
572
+ const host = this.config.inbound.host
573
+ const port = this.config.inbound.port
574
+ const probe = await this.#probes.httpProxyProbe({
575
+ host,
576
+ port,
577
+ url: this.config.functionalProbe.url,
578
+ timeoutMs: this.config.functionalProbe.timeoutMs,
579
+ })
580
+ if (!probe.ok) {
581
+ this.#logger?.warn?.(`功能探测仍未通过:${probe.error ?? probe.status ?? 'unknown'}`)
582
+ return
583
+ }
584
+ this.#functionalProbe = 'pass'
585
+ if (this.config.takeover === 'off') {
586
+ this.#setState('ready', 'takeover-off', 'xray 已就绪,但 takeover=off,未接管全局流量')
587
+ return
588
+ }
589
+ try {
590
+ const scheme = this.config.inbound.protocol === 'socks' ? 'socks5' : 'http'
591
+ const result = await this.#route.install({
592
+ proxyUrl: `${scheme}://${formatHost(host)}:${port}`,
593
+ noProxy: this.config.noProxy,
594
+ force: this.config.takeover === 'force',
595
+ })
596
+ if (result.reason === 'external-proxy-active') {
597
+ this.#setState('degraded', 'external-proxy-active', '启动器已有代理策略;未接管。设置 takeover: force 可强制覆盖')
598
+ return
599
+ }
600
+ await this.#applyActiveNode()
601
+ this.#setState('ready')
602
+ } catch (error) {
603
+ this.#setState('degraded', error.code ?? 'route-install-failed', error.message)
604
+ }
605
+ }
606
+
564
607
  #startHealthLoop(generation) {
565
608
  const interval = this.config.lifecycle.healthIntervalMs
566
609
  if (!(interval > 0)) return
@@ -578,7 +621,11 @@ export class Supervisor {
578
621
  if (this.#stopRequested || this.#stopped || generation !== this.#generation) return
579
622
  if (ok) {
580
623
  this.#unhealthy = 0
581
- if (this.#state === 'degraded' && this.#reason === 'unhealthy') this.#setState('ready', 'health-recovered')
624
+ if (this.#state === 'degraded' && this.#reason === 'probe-failed') {
625
+ await this.#retryProbeAndInstall()
626
+ } else if (this.#state === 'degraded' && this.#reason === 'unhealthy') {
627
+ this.#setState('ready', 'health-recovered')
628
+ }
582
629
  } else {
583
630
  this.#unhealthy++
584
631
  if (this.#unhealthy >= this.config.lifecycle.unhealthyThreshold) {
@@ -646,6 +693,7 @@ export class Supervisor {
646
693
  detail: this.#detail ?? null,
647
694
  enabled: this.config.enabled,
648
695
  takeover: this.config.takeover,
696
+ settingsSchema: this.#schemaLoaded,
649
697
  nodes: this.config.nodes.map((node) => ({ id: node.id, tag: node.tag, address: node.address, port: node.port })),
650
698
  activeNode: this.#activeNode,
651
699
  control: this.#control ? { enabled: true, ...this.#control.snapshot() } : { enabled: false },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@twinsearth/dsh-2xray",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
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",
@@ -45,12 +45,12 @@ test('the client bundle registers a main panel and a sidebar entry', async () =>
45
45
 
46
46
  assert.deepEqual(
47
47
  registrations.map((entry) => entry.definition.name),
48
- ['main', 'sidebar.panellist'],
48
+ ['sidebar.panellist', 'main'],
49
49
  )
50
- assert.equal(registrations[0].definition.key, 'xray')
51
- assert.equal(registrations[1].definition.id, 'xray')
52
- assert.equal(registrations[1].definition.order, 1)
53
- assert.equal(registrations[1].definition.label(), '2Xray')
54
- const icon = registrations[1].component()
50
+ assert.equal(registrations[0].definition.id, 'xray')
51
+ assert.equal(registrations[0].definition.order, 1)
52
+ assert.equal(registrations[0].definition.label(), '2Xray')
53
+ const icon = registrations[0].component()
55
54
  assert.equal(icon.type, 'svg')
55
+ assert.equal(registrations[1].definition.key, 'xray')
56
56
  })
@@ -0,0 +1,32 @@
1
+ import test from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { LogBuffer } from '../lib/logbuf.js'
4
+
5
+ test('the log ring pages by cursor and reports the head', () => {
6
+ const ring = new LogBuffer(10)
7
+ ring.append('info', 'one')
8
+ ring.append('warn', 'two')
9
+ const first = ring.since(0)
10
+ assert.equal(first.items.length, 2)
11
+ assert.equal(first.cursor, 2)
12
+ const second = ring.since(first.cursor)
13
+ assert.equal(second.items.length, 0)
14
+ ring.append('error', 'three')
15
+ const third = ring.since(first.cursor)
16
+ assert.deepEqual(third.items.map((entry) => entry.text), ['three'])
17
+ })
18
+
19
+ test('the ring drops the oldest entries at capacity', () => {
20
+ const ring = new LogBuffer(3)
21
+ for (let index = 1; index <= 5; index++) ring.append('info', `line-${index}`)
22
+ const page = ring.since(0)
23
+ assert.deepEqual(page.items.map((entry) => entry.text), ['line-3', 'line-4', 'line-5'])
24
+ assert.equal(page.cursor, 5)
25
+ })
26
+
27
+ test('levels outside the known set fall back to info and text is bounded', () => {
28
+ const ring = new LogBuffer()
29
+ const entry = ring.append('verbose', 'x'.repeat(5000))
30
+ assert.equal(entry.level, 'info')
31
+ assert.equal(entry.text.length, 2000)
32
+ })
@@ -523,3 +523,35 @@ test('validateConfig reports a missing binary instead of throwing', async () =>
523
523
  }
524
524
  })
525
525
 
526
+ test('a failing functional probe degrades but keeps the process alive', async () => {
527
+ const { dir, cleanup } = workdir()
528
+ try {
529
+ const config = makeConfig({ functionalProbe: { enabled: true, url: 'http://probe.invalid/', timeoutMs: 1000 } })
530
+ const log = []
531
+ const ready = readyProbes(log)
532
+ const { supervisor, route, launcher } = makeSupervisor({
533
+ dataDir: dir,
534
+ config,
535
+ log,
536
+ probes: {
537
+ async portIsFree() {
538
+ return true
539
+ },
540
+ waitForPort: ready.waitForPort,
541
+ async httpProxyProbe() {
542
+ return { ok: false, status: 502 }
543
+ },
544
+ },
545
+ })
546
+ await supervisor.start()
547
+ const snap = await supervisor.waitFor((s) => s.state === 'degraded')
548
+ assert.equal(snap.reason, 'probe-failed')
549
+ assert.equal(snap.process.alive, true)
550
+ assert.equal(route.installCalls, 0)
551
+ assert.equal(launcher.runs.length, 1)
552
+ await supervisor.stop()
553
+ } finally {
554
+ cleanup()
555
+ }
556
+ })
557
+