@riceawa/dsh-lan-gateway 0.3.0
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/LICENSE +20 -0
- package/README.md +221 -0
- package/cordis.patch.yml +10 -0
- package/lib/client.js +733 -0
- package/lib/client.js.map +1 -0
- package/lib/index.d.ts +71 -0
- package/lib/index.js +1310 -0
- package/package.json +86 -0
- package/skills/lan-gateway.md +56 -0
- package/src/auth.ts +182 -0
- package/src/client/index.ts +89 -0
- package/src/client/lan-gateway-card.tsx +603 -0
- package/src/gateway.ts +343 -0
- package/src/index.ts +498 -0
- package/src/login.ts +133 -0
- package/src/state.ts +93 -0
- package/src/tls.ts +164 -0
- package/src/tool.ts +82 -0
- package/src/x509.ts +314 -0
|
@@ -0,0 +1,603 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The lan-gateway settings card shown in the official DSH Settings → Plugins
|
|
3
|
+
* page (the `settings.plugin.item` slot).
|
|
4
|
+
*
|
|
5
|
+
* ModLens-style: the card carries NO injected services. It reads and writes
|
|
6
|
+
* the loopback-only `/lan-gateway/config` host route (the browser never sees
|
|
7
|
+
* the settings seam or any secret), so the client bundle's only dependency is
|
|
8
|
+
* the `slots` service that every plugin already has.
|
|
9
|
+
*
|
|
10
|
+
* @module @riceawa/dsh-lan-gateway/client/card
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { useEffect, useState, type ChangeEvent, type ReactNode } from 'react'
|
|
14
|
+
import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The official Settings → Plugins page declares the `settings.plugin.item`
|
|
18
|
+
* list slot (kind list, root scope, empty owner share) in its own package.
|
|
19
|
+
* The published package ships no `src/`, so the entry is re-declared here —
|
|
20
|
+
* the runtime slot is real; this only restores the compile-time table.
|
|
21
|
+
*/
|
|
22
|
+
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
|
23
|
+
interface SlotMap {
|
|
24
|
+
/** One plugin's card inside the plugin configuration section. */
|
|
25
|
+
'settings.plugin.item': { kind: 'list'; scope: 'root'; owner: { children?: never } }
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Props the renderer binds for this card (unused — the card is self-loading). */
|
|
30
|
+
export type LanGatewayCardProps = PropsRuntime<'settings.plugin.item'>
|
|
31
|
+
|
|
32
|
+
/** The wire shape of the `lan-gateway` config section. */
|
|
33
|
+
export interface LanGatewaySettings {
|
|
34
|
+
enabled?: boolean
|
|
35
|
+
gatewayPort?: number
|
|
36
|
+
dshTargetPort?: number
|
|
37
|
+
lanCidrs?: string[]
|
|
38
|
+
authRequired?: boolean
|
|
39
|
+
cookieMaxAgeDays?: number
|
|
40
|
+
tlsEnabled?: boolean
|
|
41
|
+
tlsMode?: 'self-signed' | 'custom'
|
|
42
|
+
tlsCertPath?: string
|
|
43
|
+
tlsKeyPath?: string
|
|
44
|
+
tlsSelfSignedHosts?: string
|
|
45
|
+
tlsCertMaxAgeDays?: number
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** GET /lan-gateway/config response. */
|
|
49
|
+
interface RouteState {
|
|
50
|
+
config: LanGatewaySettings
|
|
51
|
+
running: boolean
|
|
52
|
+
port: number
|
|
53
|
+
tls: string
|
|
54
|
+
lastError: string | null
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/* ------------------------------------------------------------------ */
|
|
58
|
+
/* Bilingual copy (ModLens-style: two small sets, picked by browser) */
|
|
59
|
+
/* ------------------------------------------------------------------ */
|
|
60
|
+
|
|
61
|
+
interface Labels {
|
|
62
|
+
title: string
|
|
63
|
+
description: string
|
|
64
|
+
unsaved: string
|
|
65
|
+
save: string
|
|
66
|
+
saving: string
|
|
67
|
+
discard: string
|
|
68
|
+
reset: string
|
|
69
|
+
overridden: string
|
|
70
|
+
readOnly: string
|
|
71
|
+
saveFailed: string
|
|
72
|
+
loadFailed: string
|
|
73
|
+
emptyMeansClear: string
|
|
74
|
+
running: string
|
|
75
|
+
stopped: string
|
|
76
|
+
tls: string
|
|
77
|
+
lastError: string
|
|
78
|
+
[key: `field.${string}`]: string
|
|
79
|
+
[key: `hint.${string}`]: string
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const LABELS: Record<'zh' | 'en', Labels> = {
|
|
83
|
+
zh: {
|
|
84
|
+
title: 'LAN 网关',
|
|
85
|
+
description: '远程访问开关、端口、TLS 证书、受信网段等网关设置',
|
|
86
|
+
unsaved: '未保存',
|
|
87
|
+
save: '保存',
|
|
88
|
+
saving: '保存中…',
|
|
89
|
+
discard: '放弃',
|
|
90
|
+
reset: '重置',
|
|
91
|
+
overridden: '已覆盖',
|
|
92
|
+
readOnly: '网关设置当前不可用(读不到配置路由)。',
|
|
93
|
+
saveFailed: '保存未生效,请检查输入后重试。',
|
|
94
|
+
loadFailed: '加载网关配置失败。',
|
|
95
|
+
emptyMeansClear: '留空 = 使用默认',
|
|
96
|
+
running: '运行中',
|
|
97
|
+
stopped: '已停止',
|
|
98
|
+
tls: 'TLS',
|
|
99
|
+
lastError: '上次错误',
|
|
100
|
+
'field.enabled': '启用网关',
|
|
101
|
+
'hint.enabled': '启动时监听 0.0.0.0 网关端口',
|
|
102
|
+
'field.gatewayPort': '网关端口',
|
|
103
|
+
'hint.gatewayPort': '绑定到 0.0.0.0 的监听端口(默认 3081)',
|
|
104
|
+
'field.dshTargetPort': 'dsh 目标端口',
|
|
105
|
+
'hint.dshTargetPort': '留空则自动跟随 dsh web 端口(默认 3080)',
|
|
106
|
+
'field.lanCidrs': '免密 LAN 网段',
|
|
107
|
+
'hint.lanCidrs': '逗号分隔的 CIDR,如 10.0.0.0/8, 192.168.0.0/16',
|
|
108
|
+
'field.authRequired': '非 LAN 访问需要密码',
|
|
109
|
+
'hint.authRequired': '公网来源必须登录后才能访问',
|
|
110
|
+
'field.cookieMaxAgeDays': '会话有效期(天)',
|
|
111
|
+
'hint.cookieMaxAgeDays': '登录 cookie 的存活天数(默认 7)',
|
|
112
|
+
'field.tlsEnabled': '启用 TLS(HTTPS)',
|
|
113
|
+
'hint.tlsEnabled': '以 HTTPS 提供网关服务',
|
|
114
|
+
'field.tlsMode': '证书来源',
|
|
115
|
+
'hint.tlsMode': 'self-signed = 自动生成自签名证书;custom = 使用自己的证书',
|
|
116
|
+
'field.tlsSelfSignedHosts': '自签名证书域名/IP',
|
|
117
|
+
'hint.tlsSelfSignedHosts': '逗号分隔,写入证书 SAN,如 localhost, 192.168.1.5',
|
|
118
|
+
'field.tlsCertPath': '证书文件路径(custom)',
|
|
119
|
+
'hint.tlsCertPath': 'PEM 格式证书(或证书链)的绝对路径',
|
|
120
|
+
'field.tlsKeyPath': '私钥文件路径(custom)',
|
|
121
|
+
'hint.tlsKeyPath': '与证书配套的 PEM 私钥绝对路径',
|
|
122
|
+
'field.tlsCertMaxAgeDays': '自签名证书有效期(天)',
|
|
123
|
+
'hint.tlsCertMaxAgeDays': '默认 825(约 27 个月)',
|
|
124
|
+
},
|
|
125
|
+
en: {
|
|
126
|
+
title: 'LAN Gateway',
|
|
127
|
+
description: 'Remote-access switch, port, TLS certificate, trusted CIDRs and more',
|
|
128
|
+
unsaved: 'Unsaved',
|
|
129
|
+
save: 'Save',
|
|
130
|
+
saving: 'Saving…',
|
|
131
|
+
discard: 'Discard',
|
|
132
|
+
reset: 'Reset',
|
|
133
|
+
overridden: 'overridden',
|
|
134
|
+
readOnly: 'Gateway settings unavailable (config route unreachable).',
|
|
135
|
+
saveFailed: 'The save did not land — check the inputs and retry.',
|
|
136
|
+
loadFailed: 'Failed to load gateway configuration.',
|
|
137
|
+
emptyMeansClear: 'Empty = default',
|
|
138
|
+
running: 'Running',
|
|
139
|
+
stopped: 'Stopped',
|
|
140
|
+
tls: 'TLS',
|
|
141
|
+
lastError: 'Last error',
|
|
142
|
+
'field.enabled': 'Enable gateway',
|
|
143
|
+
'hint.enabled': 'Listen on the gateway port at boot',
|
|
144
|
+
'field.gatewayPort': 'Gateway port',
|
|
145
|
+
'hint.gatewayPort': 'Port bound on 0.0.0.0 (default 3081)',
|
|
146
|
+
'field.dshTargetPort': 'dsh target port',
|
|
147
|
+
'hint.dshTargetPort': 'Leave empty to follow the dsh web port (default 3080)',
|
|
148
|
+
'field.lanCidrs': 'Password-free LAN CIDRs',
|
|
149
|
+
'hint.lanCidrs': 'Comma separated CIDRs, e.g. 10.0.0.0/8, 192.168.0.0/16',
|
|
150
|
+
'field.authRequired': 'Password required for non-LAN',
|
|
151
|
+
'hint.authRequired': 'Internet sources must sign in before reaching the GUI',
|
|
152
|
+
'field.cookieMaxAgeDays': 'Session lifetime (days)',
|
|
153
|
+
'hint.cookieMaxAgeDays': 'Login cookie lifetime (default 7)',
|
|
154
|
+
'field.tlsEnabled': 'Enable TLS (HTTPS)',
|
|
155
|
+
'hint.tlsEnabled': 'Serve the gateway over HTTPS',
|
|
156
|
+
'field.tlsMode': 'Certificate source',
|
|
157
|
+
'hint.tlsMode': 'self-signed = auto-generated certificate; custom = your own files',
|
|
158
|
+
'field.tlsSelfSignedHosts': 'Self-signed hosts (SANs)',
|
|
159
|
+
'hint.tlsSelfSignedHosts': 'Comma separated DNS/IP names, e.g. localhost, 192.168.1.5',
|
|
160
|
+
'field.tlsCertPath': 'Certificate path (custom)',
|
|
161
|
+
'hint.tlsCertPath': 'Absolute path to a PEM certificate (or chain)',
|
|
162
|
+
'field.tlsKeyPath': 'Private key path (custom)',
|
|
163
|
+
'hint.tlsKeyPath': 'Absolute path to the matching PEM private key',
|
|
164
|
+
'field.tlsCertMaxAgeDays': 'Self-signed validity (days)',
|
|
165
|
+
'hint.tlsCertMaxAgeDays': 'Default 825 (about 27 months)',
|
|
166
|
+
},
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function labels(): Labels {
|
|
170
|
+
const lang = (typeof navigator !== 'undefined' ? navigator.language : 'en').toLowerCase()
|
|
171
|
+
return lang.startsWith('zh') ? LABELS.zh : LABELS.en
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/* ------------------------------------------------------------------ */
|
|
175
|
+
/* Field model */
|
|
176
|
+
/* ------------------------------------------------------------------ */
|
|
177
|
+
|
|
178
|
+
type FieldKind = 'boolean' | 'number' | 'text' | 'cidrs' | 'select'
|
|
179
|
+
|
|
180
|
+
interface FieldDef {
|
|
181
|
+
field: keyof LanGatewaySettings
|
|
182
|
+
kind: FieldKind
|
|
183
|
+
optional?: boolean
|
|
184
|
+
options?: readonly string[]
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const FIELDS: readonly FieldDef[] = [
|
|
188
|
+
{ field: 'enabled', kind: 'boolean' },
|
|
189
|
+
{ field: 'gatewayPort', kind: 'number' },
|
|
190
|
+
{ field: 'dshTargetPort', kind: 'number', optional: true },
|
|
191
|
+
{ field: 'lanCidrs', kind: 'cidrs' },
|
|
192
|
+
{ field: 'authRequired', kind: 'boolean' },
|
|
193
|
+
{ field: 'cookieMaxAgeDays', kind: 'number' },
|
|
194
|
+
{ field: 'tlsEnabled', kind: 'boolean' },
|
|
195
|
+
{ field: 'tlsMode', kind: 'select', options: ['self-signed', 'custom'] },
|
|
196
|
+
{ field: 'tlsSelfSignedHosts', kind: 'text' },
|
|
197
|
+
{ field: 'tlsCertPath', kind: 'text', optional: true },
|
|
198
|
+
{ field: 'tlsKeyPath', kind: 'text', optional: true },
|
|
199
|
+
{ field: 'tlsCertMaxAgeDays', kind: 'number' },
|
|
200
|
+
]
|
|
201
|
+
|
|
202
|
+
function formatValue(def: FieldDef, value: unknown): string {
|
|
203
|
+
switch (def.kind) {
|
|
204
|
+
case 'boolean': return value === true ? 'true' : 'false'
|
|
205
|
+
case 'number': return typeof value === 'number' ? String(value) : ''
|
|
206
|
+
case 'cidrs': return Array.isArray(value) ? value.join(', ') : ''
|
|
207
|
+
case 'select': return typeof value === 'string' ? value : (def.options?.[0] ?? '')
|
|
208
|
+
case 'text': return typeof value === 'string' ? value : ''
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
type Write = { kind: 'set'; value: unknown } | { kind: 'clear' }
|
|
213
|
+
|
|
214
|
+
/** Parse draft text into a value for the POST body; undefined blocks saving. */
|
|
215
|
+
function parseValue(def: FieldDef, text: string): Write | undefined {
|
|
216
|
+
const trimmed = text.trim()
|
|
217
|
+
switch (def.kind) {
|
|
218
|
+
case 'boolean':
|
|
219
|
+
if (trimmed === 'true') return { kind: 'set', value: true }
|
|
220
|
+
if (trimmed === 'false') return { kind: 'set', value: false }
|
|
221
|
+
return undefined
|
|
222
|
+
case 'number':
|
|
223
|
+
if (trimmed === '') return def.optional ? { kind: 'clear' } : undefined
|
|
224
|
+
if (!/^\d+$/.test(trimmed)) return undefined
|
|
225
|
+
return { kind: 'set', value: Number(trimmed) }
|
|
226
|
+
case 'cidrs': {
|
|
227
|
+
const cidrs = trimmed.split(',').map(s => s.trim()).filter(s => s !== '')
|
|
228
|
+
return cidrs.length === 0 ? { kind: 'clear' } : { kind: 'set', value: cidrs }
|
|
229
|
+
}
|
|
230
|
+
case 'select':
|
|
231
|
+
return def.options?.includes(trimmed) ? { kind: 'set', value: trimmed } : undefined
|
|
232
|
+
case 'text':
|
|
233
|
+
return trimmed === '' ? (def.optional ? { kind: 'clear' } : undefined) : { kind: 'set', value: trimmed }
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/* ------------------------------------------------------------------ */
|
|
238
|
+
/* Card */
|
|
239
|
+
/* ------------------------------------------------------------------ */
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Render the LAN gateway card. Self-loading: fetches the config route on
|
|
243
|
+
* mount, posts the edited config on save.
|
|
244
|
+
* @param _props - unused; the card needs no injected face.
|
|
245
|
+
* @returns the card, or nothing while the route is unreachable.
|
|
246
|
+
*/
|
|
247
|
+
export function LanGatewayCard(_props: LanGatewayCardProps): ReactNode {
|
|
248
|
+
const t = labels()
|
|
249
|
+
const [open, setOpen] = useState(false)
|
|
250
|
+
const [route, setRoute] = useState<RouteState | null>(null)
|
|
251
|
+
const [loadFailed, setLoadFailed] = useState(false)
|
|
252
|
+
const [drafts, setDrafts] = useState<Partial<Record<string, string>>>({})
|
|
253
|
+
const [saving, setSaving] = useState(false)
|
|
254
|
+
const [failed, setFailed] = useState<string | null>(null)
|
|
255
|
+
|
|
256
|
+
useEffect(() => {
|
|
257
|
+
let cancelled = false
|
|
258
|
+
fetch('/lan-gateway/config')
|
|
259
|
+
.then(async (response) => {
|
|
260
|
+
if (cancelled) return
|
|
261
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`)
|
|
262
|
+
setRoute(await response.json() as RouteState)
|
|
263
|
+
})
|
|
264
|
+
.catch(() => {
|
|
265
|
+
if (!cancelled) setLoadFailed(true)
|
|
266
|
+
})
|
|
267
|
+
return () => { cancelled = true }
|
|
268
|
+
}, [])
|
|
269
|
+
|
|
270
|
+
if (loadFailed) return null
|
|
271
|
+
if (route === null) return null
|
|
272
|
+
|
|
273
|
+
const { config } = route
|
|
274
|
+
const draftOf = (field: keyof LanGatewaySettings): string =>
|
|
275
|
+
drafts[field] ?? formatValue(FIELDS.find(f => f.field === field)!, config[field])
|
|
276
|
+
|
|
277
|
+
const stage = (field: string, text: string): void => {
|
|
278
|
+
setDrafts(prev => ({ ...prev, [field]: text }))
|
|
279
|
+
setFailed(null)
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const resetField = (def: FieldDef): void => {
|
|
283
|
+
setDrafts(prev => {
|
|
284
|
+
const next = { ...prev }
|
|
285
|
+
delete next[def.field]
|
|
286
|
+
return next
|
|
287
|
+
})
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const discard = (): void => {
|
|
291
|
+
setDrafts({})
|
|
292
|
+
setFailed(null)
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const invalid = (): boolean =>
|
|
296
|
+
Object.entries(drafts).some(([field, text]) => {
|
|
297
|
+
const def = FIELDS.find(f => f.field === field)
|
|
298
|
+
return def === undefined || parseValue(def, text ?? '') === undefined
|
|
299
|
+
})
|
|
300
|
+
|
|
301
|
+
const dirty = Object.keys(drafts).length > 0
|
|
302
|
+
|
|
303
|
+
const save = async (): Promise<void> => {
|
|
304
|
+
if (!dirty || saving || invalid()) return
|
|
305
|
+
setSaving(true)
|
|
306
|
+
setFailed(null)
|
|
307
|
+
try {
|
|
308
|
+
// Build the next full config: the loaded one with drafts applied.
|
|
309
|
+
const next: Record<string, unknown> = {}
|
|
310
|
+
for (const def of FIELDS) {
|
|
311
|
+
const text = drafts[def.field] ?? formatValue(def, config[def.field])
|
|
312
|
+
const write = parseValue(def, text)
|
|
313
|
+
if (write === undefined) continue
|
|
314
|
+
next[def.field] = write.kind === 'clear' ? null : write.value
|
|
315
|
+
}
|
|
316
|
+
const response = await fetch('/lan-gateway/config', {
|
|
317
|
+
method: 'POST',
|
|
318
|
+
headers: { 'content-type': 'application/json' },
|
|
319
|
+
body: JSON.stringify(next),
|
|
320
|
+
})
|
|
321
|
+
const body = await response.json().catch(() => ({})) as Partial<RouteState> & { error?: string }
|
|
322
|
+
if (!response.ok) {
|
|
323
|
+
setFailed(body.error ?? `HTTP ${response.status}`)
|
|
324
|
+
return
|
|
325
|
+
}
|
|
326
|
+
if (body.config !== undefined) {
|
|
327
|
+
setRoute({
|
|
328
|
+
config: body.config,
|
|
329
|
+
running: body.running ?? false,
|
|
330
|
+
port: body.port ?? 0,
|
|
331
|
+
tls: body.tls ?? '',
|
|
332
|
+
lastError: body.lastError ?? null,
|
|
333
|
+
})
|
|
334
|
+
}
|
|
335
|
+
setDrafts({})
|
|
336
|
+
} catch {
|
|
337
|
+
setFailed(t.saveFailed)
|
|
338
|
+
} finally {
|
|
339
|
+
setSaving(false)
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const renderControl = (def: FieldDef): ReactNode => {
|
|
344
|
+
const field = def.field
|
|
345
|
+
const label = t[`field.${field}`]
|
|
346
|
+
const hint = t[`hint.${field}`]
|
|
347
|
+
const text = draftOf(field)
|
|
348
|
+
switch (def.kind) {
|
|
349
|
+
case 'boolean':
|
|
350
|
+
return (
|
|
351
|
+
<div style={styles.field}>
|
|
352
|
+
<label style={styles.checkRow}>
|
|
353
|
+
<input
|
|
354
|
+
type="checkbox"
|
|
355
|
+
checked={text === 'true'}
|
|
356
|
+
disabled={saving}
|
|
357
|
+
onChange={(e: ChangeEvent<HTMLInputElement>) =>
|
|
358
|
+
stage(field, e.target.checked ? 'true' : 'false')}
|
|
359
|
+
/>
|
|
360
|
+
<span style={styles.label}>{label}</span>
|
|
361
|
+
<button
|
|
362
|
+
type="button"
|
|
363
|
+
style={styles.reset}
|
|
364
|
+
disabled={saving || !drafts[field]}
|
|
365
|
+
onClick={() => resetField(def)}
|
|
366
|
+
>
|
|
367
|
+
{t.reset}
|
|
368
|
+
</button>
|
|
369
|
+
</label>
|
|
370
|
+
<span style={styles.hint}>{hint}</span>
|
|
371
|
+
</div>
|
|
372
|
+
)
|
|
373
|
+
case 'select':
|
|
374
|
+
return (
|
|
375
|
+
<div style={styles.field}>
|
|
376
|
+
<label style={styles.label} htmlFor={`lan-gw-${field}`}>{label}</label>
|
|
377
|
+
<select
|
|
378
|
+
id={`lan-gw-${field}`}
|
|
379
|
+
style={styles.input}
|
|
380
|
+
value={text}
|
|
381
|
+
disabled={saving}
|
|
382
|
+
onChange={(e: ChangeEvent<HTMLSelectElement>) => stage(field, e.target.value)}
|
|
383
|
+
>
|
|
384
|
+
{def.options?.map(option => <option key={option} value={option}>{option}</option>)}
|
|
385
|
+
</select>
|
|
386
|
+
<span style={styles.hint}>{hint}</span>
|
|
387
|
+
<button
|
|
388
|
+
type="button"
|
|
389
|
+
style={styles.reset}
|
|
390
|
+
disabled={saving || !drafts[field]}
|
|
391
|
+
onClick={() => resetField(def)}
|
|
392
|
+
>
|
|
393
|
+
{t.reset}
|
|
394
|
+
</button>
|
|
395
|
+
</div>
|
|
396
|
+
)
|
|
397
|
+
default:
|
|
398
|
+
return (
|
|
399
|
+
<div style={styles.field}>
|
|
400
|
+
<label style={styles.label} htmlFor={`lan-gw-${field}`}>{label}</label>
|
|
401
|
+
<input
|
|
402
|
+
id={`lan-gw-${field}`}
|
|
403
|
+
style={styles.input}
|
|
404
|
+
type={def.kind === 'number' ? 'number' : 'text'}
|
|
405
|
+
value={text}
|
|
406
|
+
disabled={saving}
|
|
407
|
+
placeholder={def.optional ? t.emptyMeansClear : undefined}
|
|
408
|
+
onChange={(e: ChangeEvent<HTMLInputElement>) => stage(field, e.target.value)}
|
|
409
|
+
/>
|
|
410
|
+
<span style={styles.hint}>{hint}</span>
|
|
411
|
+
</div>
|
|
412
|
+
)
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
const statusLine = `${route.running ? t.running : t.stopped} · ${t.tls}: ${route.tls} · :${route.port}`
|
|
417
|
+
|
|
418
|
+
return (
|
|
419
|
+
<li style={open ? { ...styles.card, ...styles.cardOpen } : styles.card}>
|
|
420
|
+
<button
|
|
421
|
+
type="button"
|
|
422
|
+
style={styles.header}
|
|
423
|
+
aria-expanded={open}
|
|
424
|
+
onClick={() => { setOpen(!open) }}
|
|
425
|
+
>
|
|
426
|
+
<span style={styles.headText}>
|
|
427
|
+
<span style={styles.name}>{t.title}</span>
|
|
428
|
+
<span style={styles.description}>{t.description}</span>
|
|
429
|
+
</span>
|
|
430
|
+
<span style={styles.status}>{statusLine}</span>
|
|
431
|
+
{dirty ? <span style={styles.pending}>{t.unsaved}</span> : null}
|
|
432
|
+
<span style={open ? { ...styles.chevron, ...styles.chevronOpen } : styles.chevron}>{open ? '▾' : '▸'}</span>
|
|
433
|
+
</button>
|
|
434
|
+
{open
|
|
435
|
+
? (
|
|
436
|
+
<div style={styles.body}>
|
|
437
|
+
{route.lastError ? <p style={styles.error} role="status">{t.lastError}: {route.lastError}</p> : null}
|
|
438
|
+
{FIELDS.map(def => <div key={def.field}>{renderControl(def)}</div>)}
|
|
439
|
+
<div style={styles.footer}>
|
|
440
|
+
{failed ? <p style={styles.error} role="status">{failed}</p> : null}
|
|
441
|
+
<button
|
|
442
|
+
type="button"
|
|
443
|
+
style={styles.discard}
|
|
444
|
+
disabled={!dirty || saving}
|
|
445
|
+
onClick={discard}
|
|
446
|
+
>
|
|
447
|
+
{t.discard}
|
|
448
|
+
</button>
|
|
449
|
+
<button
|
|
450
|
+
type="button"
|
|
451
|
+
style={styles.save}
|
|
452
|
+
disabled={!dirty || invalid() || saving}
|
|
453
|
+
onClick={() => { void save() }}
|
|
454
|
+
>
|
|
455
|
+
{saving ? t.saving : t.save}
|
|
456
|
+
</button>
|
|
457
|
+
</div>
|
|
458
|
+
</div>
|
|
459
|
+
)
|
|
460
|
+
: null}
|
|
461
|
+
</li>
|
|
462
|
+
)
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/* ------------------------------------------------------------------ */
|
|
466
|
+
/* Styling — the official DSH theme tokens (light/dark aware), with */
|
|
467
|
+
/* neutral fallbacks so the card never renders black-on-black or */
|
|
468
|
+
/* white-on-white even if a token is missing. */
|
|
469
|
+
/* ------------------------------------------------------------------ */
|
|
470
|
+
|
|
471
|
+
/** Theme token with a fallback for token-less environments. */
|
|
472
|
+
function tk(token: string, fallback: string): string {
|
|
473
|
+
return `var(${token}, ${fallback})`
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
const L = {
|
|
477
|
+
border: tk('--dsw-alias-border-l2', 'rgba(127,127,127,0.35)'),
|
|
478
|
+
bg: tk('--dsw-alias-bg-layer-3', 'transparent'),
|
|
479
|
+
bgOpen: tk('--dsw-alias-bg-layer-2', 'transparent'),
|
|
480
|
+
labelPrimary: tk('--dsw-alias-label-primary', 'inherit'),
|
|
481
|
+
labelSecondary: tk('--dsw-alias-label-secondary', 'inherit'),
|
|
482
|
+
labelTertiary: tk('--dsw-alias-label-tertiary', 'rgba(127,127,127,0.8)'),
|
|
483
|
+
labelDimmed: tk('--dsw-alias-label-dimmed', 'rgba(127,127,127,0.6)'),
|
|
484
|
+
error: tk('--dsw-alias-label-error', '#d1242f'),
|
|
485
|
+
brand: tk('--dsw-alias-brand-primary', '#4f6ef7'),
|
|
486
|
+
badgeBg: tk('--dsw-alias-bg-module-platform', 'rgba(127,127,127,0.14)'),
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
const styles: Record<string, React.CSSProperties> = {
|
|
490
|
+
card: {
|
|
491
|
+
listStyle: 'none',
|
|
492
|
+
border: `1px solid ${L.border}`,
|
|
493
|
+
borderRadius: '12px',
|
|
494
|
+
background: L.bg,
|
|
495
|
+
transition: 'border-color .16s, background .16s',
|
|
496
|
+
overflow: 'hidden',
|
|
497
|
+
},
|
|
498
|
+
cardOpen: {
|
|
499
|
+
background: L.bgOpen,
|
|
500
|
+
borderColor: L.labelDimmed,
|
|
501
|
+
},
|
|
502
|
+
header: {
|
|
503
|
+
display: 'flex',
|
|
504
|
+
alignItems: 'center',
|
|
505
|
+
gap: '12px',
|
|
506
|
+
width: '100%',
|
|
507
|
+
padding: '14px 16px',
|
|
508
|
+
border: 0,
|
|
509
|
+
background: 'none',
|
|
510
|
+
font: 'inherit',
|
|
511
|
+
color: 'inherit',
|
|
512
|
+
textAlign: 'left',
|
|
513
|
+
cursor: 'pointer',
|
|
514
|
+
},
|
|
515
|
+
headText: { display: 'flex', flexDirection: 'column', gap: '4px', flex: 1, minWidth: 0 },
|
|
516
|
+
name: { fontSize: '15px', fontWeight: 600, lineHeight: 1.4, color: L.labelPrimary },
|
|
517
|
+
description: { fontSize: '13px', lineHeight: 1.5, color: L.labelTertiary },
|
|
518
|
+
status: { fontSize: '11px', color: L.labelTertiary, whiteSpace: 'nowrap' },
|
|
519
|
+
pending: {
|
|
520
|
+
flex: 'none',
|
|
521
|
+
borderRadius: '999px',
|
|
522
|
+
padding: '1px 8px',
|
|
523
|
+
fontSize: '11px',
|
|
524
|
+
lineHeight: '17px',
|
|
525
|
+
fontWeight: 500,
|
|
526
|
+
whiteSpace: 'nowrap',
|
|
527
|
+
background: L.badgeBg,
|
|
528
|
+
color: L.labelSecondary,
|
|
529
|
+
},
|
|
530
|
+
chevron: { flex: 'none', color: L.labelTertiary, fontSize: '12px', transition: 'transform .16s' },
|
|
531
|
+
chevronOpen: { transform: 'rotate(180deg)' },
|
|
532
|
+
body: {
|
|
533
|
+
borderTop: `1px solid ${L.border}`,
|
|
534
|
+
margin: '0 16px',
|
|
535
|
+
paddingBottom: '8px',
|
|
536
|
+
display: 'flex',
|
|
537
|
+
flexDirection: 'column',
|
|
538
|
+
},
|
|
539
|
+
field: {
|
|
540
|
+
display: 'flex',
|
|
541
|
+
flexDirection: 'column',
|
|
542
|
+
gap: '6px',
|
|
543
|
+
padding: '12px 0',
|
|
544
|
+
},
|
|
545
|
+
label: { fontSize: '13px', fontWeight: 500, lineHeight: 1.5, color: L.labelPrimary },
|
|
546
|
+
hint: { margin: 0, fontSize: '12px', lineHeight: 1.5, color: L.labelTertiary },
|
|
547
|
+
checkRow: { display: 'flex', alignItems: 'center', gap: '8px', cursor: 'pointer' },
|
|
548
|
+
reset: {
|
|
549
|
+
border: 'none',
|
|
550
|
+
background: 'none',
|
|
551
|
+
padding: 0,
|
|
552
|
+
font: 'inherit',
|
|
553
|
+
fontSize: '12px',
|
|
554
|
+
lineHeight: 1.5,
|
|
555
|
+
color: L.labelSecondary,
|
|
556
|
+
cursor: 'pointer',
|
|
557
|
+
alignSelf: 'flex-start',
|
|
558
|
+
},
|
|
559
|
+
input: {
|
|
560
|
+
height: '34px',
|
|
561
|
+
padding: '0 12px',
|
|
562
|
+
border: `1px solid ${L.border}`,
|
|
563
|
+
borderRadius: '8px',
|
|
564
|
+
background: L.bg,
|
|
565
|
+
font: 'inherit',
|
|
566
|
+
fontSize: '13px',
|
|
567
|
+
lineHeight: 1.5,
|
|
568
|
+
color: L.labelPrimary,
|
|
569
|
+
},
|
|
570
|
+
footer: {
|
|
571
|
+
display: 'flex',
|
|
572
|
+
alignItems: 'center',
|
|
573
|
+
justifyContent: 'flex-end',
|
|
574
|
+
gap: '8px',
|
|
575
|
+
padding: '12px 0 4px',
|
|
576
|
+
borderTop: `1px solid ${L.border}`,
|
|
577
|
+
},
|
|
578
|
+
error: { flex: 1, minWidth: 0, margin: 0, fontSize: '12px', lineHeight: 1.5, color: L.error },
|
|
579
|
+
discard: {
|
|
580
|
+
appearance: 'none',
|
|
581
|
+
border: `1px solid ${L.border}`,
|
|
582
|
+
borderRadius: '8px',
|
|
583
|
+
padding: '5px 14px',
|
|
584
|
+
font: 'inherit',
|
|
585
|
+
fontSize: '13px',
|
|
586
|
+
lineHeight: 1.5,
|
|
587
|
+
background: 'none',
|
|
588
|
+
color: L.labelSecondary,
|
|
589
|
+
cursor: 'pointer',
|
|
590
|
+
},
|
|
591
|
+
save: {
|
|
592
|
+
appearance: 'none',
|
|
593
|
+
border: '1px solid transparent',
|
|
594
|
+
borderRadius: '8px',
|
|
595
|
+
padding: '5px 14px',
|
|
596
|
+
font: 'inherit',
|
|
597
|
+
fontSize: '13px',
|
|
598
|
+
lineHeight: 1.5,
|
|
599
|
+
background: L.labelPrimary,
|
|
600
|
+
color: L.bg,
|
|
601
|
+
cursor: 'pointer',
|
|
602
|
+
},
|
|
603
|
+
}
|