@riceawa/dsh-lan-gateway 0.3.0 → 0.5.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/README.md +145 -55
- package/cordis.patch.yml +5 -2
- package/lib/client.js +78 -44
- package/lib/client.js.map +1 -1
- package/lib/index.d.ts +52 -5
- package/lib/index.js +470 -108
- package/package.json +2 -2
- package/skills/lan-gateway.md +47 -29
- package/src/auth.ts +39 -6
- package/src/client/index.ts +16 -2
- package/src/client/lan-gateway-card.tsx +56 -21
- package/src/gateway.ts +230 -86
- package/src/index.ts +196 -48
- package/src/login.ts +3 -0
- package/src/state.ts +27 -6
- package/src/tool.ts +12 -8
- package/src/upstream-session.ts +187 -0
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@riceawa/dsh-lan-gateway",
|
|
3
|
-
"description": "LAN/internet reverse-proxy gateway for the DeepSeek Harness web GUI: binds 0.0.0.0
|
|
4
|
-
"version": "0.
|
|
3
|
+
"description": "LAN/internet reverse-proxy gateway for the DeepSeek Harness web GUI: binds 0.0.0.0 and forwards to the loopback dsh web server. Default-deny: every source (loopback, LAN, internet) must sign in with an HMAC session cookie unless lanPasswordless is explicitly enabled; against dsh >= 0.1.2-rc.1 the gateway relays one shared upstream browser session, so the harness's own authorization still gates every request. Fail-closed start guard (password required, plaintext needs an explicit opt-in), session revocation by epoch (password changes and secret rotation kill cookies and live WebSockets), same-site/Origin fence on HTTP and WebSocket upgrades, optional TLS (auto self-signed or user-supplied certs), and a Settings → Plugins card for live adjustment of port, CIDRs, auth, and TLS. Includes an insecure-origin UUID shim client bundle: on gateway-served plain-HTTP origins browsers lack crypto.randomUUID, so the client half patches a getRandomValues-backed randomUUID onto the Crypto prototype, fixing workspace open over LAN without touching DSH source.",
|
|
4
|
+
"version": "0.5.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
7
7
|
"types": "lib/index.d.ts",
|
package/skills/lan-gateway.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: lan-gateway
|
|
3
|
-
description: Manage remote (non-LAN) access to the DeepSeek Harness web GUI through the dsh-lan-gateway plugin. Use when the user asks to open the web GUI to the LAN/internet, set or change the remote-access password, rotate the session secret, or check whether remote access is currently enabled
|
|
4
|
-
whenToUse: The user wants the web GUI reachable from their LAN or the wider internet, wants to enable/disable that, set the login password for remote visitors, rotate the session cookie secret, or learn the current gateway status (port, target, password state,
|
|
3
|
+
description: Manage remote (non-LAN) access to the DeepSeek Harness web GUI through the dsh-lan-gateway plugin. Use when the user asks to open the web GUI to the LAN/internet, set or change the remote-access password, rotate the session secret, or check whether remote access is currently enabled, on what port, and whether the harness base supports the shared upstream session relay.
|
|
4
|
+
whenToUse: The user wants the web GUI reachable from their LAN or the wider internet, wants to enable/disable that, set the login password for remote visitors, rotate the session cookie secret, or learn the current gateway status (port, target, password state, ingress encryption, upstream-session relay, session epoch).
|
|
5
5
|
user-invocable: true
|
|
6
6
|
---
|
|
7
7
|
|
|
@@ -10,47 +10,65 @@ user-invocable: true
|
|
|
10
10
|
The `dsh-lan-gateway` plugin lets the DeepSeek Harness web GUI be reached from the
|
|
11
11
|
LAN and the wider internet. dsh itself binds only to loopback (the web CLI
|
|
12
12
|
hard-refuses `0.0.0.0`), so this plugin runs its own reverse-proxy gateway on
|
|
13
|
-
`0.0.0.0` that forwards to the loopback web server while rewriting Host/Origin
|
|
14
|
-
the `/api` trust fence still passes.
|
|
13
|
+
`0.0.0.0` that forwards to the loopback web server while rewriting Host/Origin.
|
|
15
14
|
|
|
16
|
-
|
|
15
|
+
Since v0.5.0 the model is **default-deny** (post-QVD-2026-57410 hardening):
|
|
17
16
|
|
|
18
|
-
- **
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
17
|
+
- **Every source — loopback, LAN, internet — must sign in** at the gateway login
|
|
18
|
+
page and present the HMAC session cookie. Nothing is granted by source alone.
|
|
19
|
+
- **LAN passwordless is an explicit opt-in** (`lanPasswordless: true`). When on,
|
|
20
|
+
sources in `lanCidrs` (and loopback) skip the gateway login page. The listener
|
|
21
|
+
refuses to enable `lanPasswordless` unless the dsh base enforces browser-session
|
|
22
|
+
auth (dsh ≥ 0.1.2-rc.1), where the gateway relays one shared upstream session and
|
|
23
|
+
upstream authorization still gates every request.
|
|
24
|
+
- **Encrypted ingress is required to start.** The listener refuses to run over
|
|
25
|
+
plaintext unless TLS is on (`tlsEnabled`), a trusted terminator is declared
|
|
26
|
+
(`trustedTerminator`), or `allowInsecurePlaintext: true` is set explicitly.
|
|
27
|
+
- A password is always required to run the gateway.
|
|
25
28
|
|
|
26
29
|
## Drive it through the `lan_gateway` tool
|
|
27
30
|
|
|
28
|
-
Do not edit state files by hand — use the `lan_gateway` tool.
|
|
29
|
-
to listen until a password is set (unless `authRequired` is explicitly false).
|
|
31
|
+
Do not edit state files by hand — use the `lan_gateway` tool.
|
|
30
32
|
|
|
31
33
|
- `lan_gateway` with `command: "status"` — is it listening, on which port, toward
|
|
32
|
-
which dsh port, password set?,
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
34
|
+
which dsh port, password set?, session epoch, upstream-session relay state,
|
|
35
|
+
ingress encryption, last error.
|
|
36
|
+
- `lan_gateway` with `command: "enable"` — start listening. If it refuses (no
|
|
37
|
+
password, legacy `authRequired: false`, plaintext without opt-in, `lanPasswordless`
|
|
38
|
+
without a session-capable base), the message tells you what to change.
|
|
36
39
|
- `lan_gateway` with `command: "disable"` — stop listening (dsh itself stays up).
|
|
40
|
+
- `lan_gateway` with `command: "set-password"` and `password: "<new pass>"`
|
|
41
|
+
(min 8 chars) — set the login password. Setting it revokes every existing session
|
|
42
|
+
(all sources, LAN included, must sign in again). Pass an empty password to clear —
|
|
43
|
+
clearing stops the listener (a password is required to run).
|
|
37
44
|
- `lan_gateway` with `command: "rotate-secret"` — invalidate every issued login
|
|
38
|
-
cookie (users must sign in again).
|
|
45
|
+
cookie and every live WebSocket (users must sign in again).
|
|
46
|
+
- `lan_gateway` with `command: "tls-regenerate"` — mint a fresh self-signed
|
|
47
|
+
certificate and hot-restart the listener (tlsMode must be `self-signed`).
|
|
39
48
|
|
|
40
49
|
## After enabling
|
|
41
50
|
|
|
42
51
|
Tell the user the gateway URL they can share:
|
|
43
52
|
|
|
44
|
-
- On the LAN: `http://<lan-ip>:<gatewayPort>/` (default port `3081`)
|
|
45
|
-
|
|
46
|
-
|
|
53
|
+
- On the LAN: `http://<lan-ip>:<gatewayPort>/` (default port `3081`) — requires the
|
|
54
|
+
login password unless `lanPasswordless` is enabled for their network.
|
|
55
|
+
- From outside: the machine's public address or a Tailscale IP on port `3081` —
|
|
56
|
+
that path always requires the password.
|
|
57
|
+
|
|
58
|
+
Remind the user which encrypted-ingress choice they are on: TLS / a trusted
|
|
59
|
+
terminator / explicit plaintext. If they enabled plaintext, restate the exposure
|
|
60
|
+
once (passwords and sessions travel in clear).
|
|
47
61
|
|
|
48
62
|
## Troubleshooting
|
|
49
63
|
|
|
50
|
-
- Gateway won't start: set a password first (`lan_gateway set-password`)
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
64
|
+
- Gateway won't start: set a password first (`lan_gateway set-password`); check the
|
|
65
|
+
fail-closed conditions — legacy `authRequired: false` must be removed, plaintext
|
|
66
|
+
needs `allowInsecurePlaintext: true` or TLS/`trustedTerminator`, and
|
|
67
|
+
`lanPasswordless` needs dsh ≥ 0.1.2-rc.1. `lan_gateway status` shows the last error.
|
|
68
|
+
- Remote visitors see a redirect loop or 403: check the login cookie (`dsh_gw_auth`)
|
|
69
|
+
and that their source is classified as `internet`. A `403` on `/api` from a
|
|
70
|
+
browser is the gateway's own origin/CSRF fence — a real page load carries the
|
|
71
|
+
right Origin; a hand-crafted cross-site request (or a non-browser client posting
|
|
72
|
+
without an Origin) does not.
|
|
73
|
+
- Sessions don't survive a password change / `rotate-secret`: that is by design —
|
|
74
|
+
the revocation epoch advanced and all cookies (and live WebSockets) were revoked.
|
package/src/auth.ts
CHANGED
|
@@ -114,16 +114,29 @@ function base64url(input: Buffer): string {
|
|
|
114
114
|
* Issue a signed session cookie value.
|
|
115
115
|
* @param secret - the HMAC signing secret (base64 string).
|
|
116
116
|
* @param expiresMs - epoch millis at which the session expires.
|
|
117
|
+
* @param epoch - the session revocation epoch the cookie is minted under; a
|
|
118
|
+
* cookie whose epoch no longer matches the live state is rejected by
|
|
119
|
+
* {@link verifyCookie}. Defaults to 0 (epoch-less, legacy) for callers that
|
|
120
|
+
* do not participate in revocation.
|
|
117
121
|
* @returns a `payload.signature` string suitable for the cookie value.
|
|
118
122
|
*/
|
|
119
|
-
export function signCookie(secret: string, expiresMs: number): string {
|
|
120
|
-
const payload = base64url(Buffer.from(JSON.stringify({ exp: expiresMs })))
|
|
123
|
+
export function signCookie(secret: string, expiresMs: number, epoch: number = 0): string {
|
|
124
|
+
const payload = base64url(Buffer.from(JSON.stringify({ exp: expiresMs, epoch })))
|
|
121
125
|
const sig = createHmac('sha256', secret).update(payload).digest('base64url')
|
|
122
126
|
return `${payload}.${sig}`
|
|
123
127
|
}
|
|
124
128
|
|
|
125
|
-
/**
|
|
126
|
-
|
|
129
|
+
/**
|
|
130
|
+
* Whether a cookie value is a valid, unexpired session signed with `secret`
|
|
131
|
+
* and minted under `epoch`. Epoch-less cookies (legacy payloads) count as
|
|
132
|
+
* epoch 0, so an upgrade from a pre-0.5.0 state does not log everyone out.
|
|
133
|
+
*/
|
|
134
|
+
export function verifyCookie(
|
|
135
|
+
secret: string,
|
|
136
|
+
value: string | undefined,
|
|
137
|
+
now: number,
|
|
138
|
+
epoch: number = 0,
|
|
139
|
+
): boolean {
|
|
127
140
|
if (value === undefined) return false
|
|
128
141
|
const dot = value.indexOf('.')
|
|
129
142
|
if (dot === -1) return false
|
|
@@ -139,8 +152,28 @@ export function verifyCookie(secret: string, value: string | undefined, now: num
|
|
|
139
152
|
if (expected.length !== actual.length) return false
|
|
140
153
|
if (!timingSafeEqual(expected, actual)) return false
|
|
141
154
|
try {
|
|
142
|
-
const decoded = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')) as { exp?: unknown }
|
|
143
|
-
|
|
155
|
+
const decoded = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')) as { exp?: unknown; epoch?: unknown }
|
|
156
|
+
if (typeof decoded.exp !== 'number' || decoded.exp <= now) return false
|
|
157
|
+
const cookieEpoch = typeof decoded.epoch === 'number' ? decoded.epoch : 0
|
|
158
|
+
return cookieEpoch === epoch
|
|
159
|
+
} catch {
|
|
160
|
+
return false
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Whether a browser Origin header names the same authority (hostname:port) as
|
|
166
|
+
* a request Host header. Both sides run through WHATWG URL parsing so case and
|
|
167
|
+
* an implicit scheme-default port never decide the match — the comparison the
|
|
168
|
+
* gateway uses to tell same-origin browser requests from cross-site ones.
|
|
169
|
+
* @param origin - the `Origin` header value, or undefined.
|
|
170
|
+
* @param host - the `Host` header value, or undefined.
|
|
171
|
+
* @returns true only when both parse and name the same host[:port].
|
|
172
|
+
*/
|
|
173
|
+
export function originMatchesHost(origin: string | undefined, host: string | undefined): boolean {
|
|
174
|
+
if (origin === undefined || host === undefined) return false
|
|
175
|
+
try {
|
|
176
|
+
return new URL(origin).host === new URL(`http://${host}`).host
|
|
144
177
|
} catch {
|
|
145
178
|
return false
|
|
146
179
|
}
|
package/src/client/index.ts
CHANGED
|
@@ -79,11 +79,25 @@ export function apply(ctx: ClientContext): void {
|
|
|
79
79
|
// The card rides the official Plugins → Configurable tab. Like ModLens, it
|
|
80
80
|
// registers with no inject face and fetches its own loopback config route,
|
|
81
81
|
// so it has no settings/locale/connection service dependencies.
|
|
82
|
+
//
|
|
83
|
+
// The `settings.plugin.item` slot is keyed BY the settings namespace the
|
|
84
|
+
// card edits (rc.8 contract): the configurable tab only dispatches entries
|
|
85
|
+
// whose `options.key` is both present and served by the Host's settings
|
|
86
|
+
// describe mirror. Registering with `id` alone throws
|
|
87
|
+
// `keyed slot "settings.plugin.item" requires options.key` and the card
|
|
88
|
+
// silently disappears from Settings → Plugins.
|
|
89
|
+
//
|
|
90
|
+
// `id`/`order` ride the legacy list-slot shape (older DSH versions
|
|
91
|
+
// dispatched this slot by id): harmless metadata on the keyed slot, and
|
|
92
|
+
// what keeps the card mounting if this plugin ever loads into an older
|
|
93
|
+
// deployment. Spread from a typed constant so the keyed registration type
|
|
94
|
+
// stays exact.
|
|
95
|
+
const legacyListOptions = { id: 'lan-gateway', order: 30 } as const
|
|
82
96
|
ctx.slots.inject('settings.plugin.item', function* () {
|
|
83
97
|
yield ctx.slots.register({
|
|
84
98
|
name: 'settings.plugin.item',
|
|
85
|
-
|
|
86
|
-
|
|
99
|
+
key: 'lan-gateway',
|
|
100
|
+
...legacyListOptions,
|
|
87
101
|
}, LanGatewayCard)
|
|
88
102
|
})
|
|
89
103
|
}
|
|
@@ -15,14 +15,15 @@ import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
|
|
15
15
|
|
|
16
16
|
/**
|
|
17
17
|
* The official Settings → Plugins page declares the `settings.plugin.item`
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
* the
|
|
18
|
+
* slot keyed by the settings namespace each card edits (newer DSH releases;
|
|
19
|
+
* older releases dispatched it as a list slot by `id`). The published package
|
|
20
|
+
* ships no `src/`, so the entry is re-declared here — the runtime slot is
|
|
21
|
+
* real; this only restores the compile-time table.
|
|
21
22
|
*/
|
|
22
23
|
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
|
23
24
|
interface SlotMap {
|
|
24
25
|
/** One plugin's card inside the plugin configuration section. */
|
|
25
|
-
'settings.plugin.item': { kind: '
|
|
26
|
+
'settings.plugin.item': { kind: 'keyed'; scope: 'root'; owner: { children?: never } }
|
|
26
27
|
}
|
|
27
28
|
}
|
|
28
29
|
|
|
@@ -35,7 +36,7 @@ export interface LanGatewaySettings {
|
|
|
35
36
|
gatewayPort?: number
|
|
36
37
|
dshTargetPort?: number
|
|
37
38
|
lanCidrs?: string[]
|
|
38
|
-
|
|
39
|
+
lanPasswordless?: boolean
|
|
39
40
|
cookieMaxAgeDays?: number
|
|
40
41
|
tlsEnabled?: boolean
|
|
41
42
|
tlsMode?: 'self-signed' | 'custom'
|
|
@@ -43,6 +44,8 @@ export interface LanGatewaySettings {
|
|
|
43
44
|
tlsKeyPath?: string
|
|
44
45
|
tlsSelfSignedHosts?: string
|
|
45
46
|
tlsCertMaxAgeDays?: number
|
|
47
|
+
allowInsecurePlaintext?: boolean
|
|
48
|
+
trustedTerminator?: string
|
|
46
49
|
}
|
|
47
50
|
|
|
48
51
|
/** GET /lan-gateway/config response. */
|
|
@@ -105,8 +108,12 @@ const LABELS: Record<'zh' | 'en', Labels> = {
|
|
|
105
108
|
'hint.dshTargetPort': '留空则自动跟随 dsh web 端口(默认 3080)',
|
|
106
109
|
'field.lanCidrs': '免密 LAN 网段',
|
|
107
110
|
'hint.lanCidrs': '逗号分隔的 CIDR,如 10.0.0.0/8, 192.168.0.0/16',
|
|
108
|
-
'field.
|
|
109
|
-
'hint.
|
|
111
|
+
'field.lanPasswordless': 'LAN 免登录',
|
|
112
|
+
'hint.lanPasswordless': 'LAN/回环来源跳过网关登录页,但仍共用同一上游会话(需 dsh ≥ 0.1.2)',
|
|
113
|
+
'field.allowInsecurePlaintext': '允许明文 HTTP',
|
|
114
|
+
'hint.allowInsecurePlaintext': '危险:关闭 TLS 或受信终止代理时仍启动监听,密码与会话将以明文传输',
|
|
115
|
+
'field.trustedTerminator': '受信 TLS 终止代理',
|
|
116
|
+
'hint.trustedTerminator': '可选:声明前置代理标识,视为加密入口(如 nginx)。留空 = 未声明',
|
|
110
117
|
'field.cookieMaxAgeDays': '会话有效期(天)',
|
|
111
118
|
'hint.cookieMaxAgeDays': '登录 cookie 的存活天数(默认 7)',
|
|
112
119
|
'field.tlsEnabled': '启用 TLS(HTTPS)',
|
|
@@ -147,8 +154,12 @@ const LABELS: Record<'zh' | 'en', Labels> = {
|
|
|
147
154
|
'hint.dshTargetPort': 'Leave empty to follow the dsh web port (default 3080)',
|
|
148
155
|
'field.lanCidrs': 'Password-free LAN CIDRs',
|
|
149
156
|
'hint.lanCidrs': 'Comma separated CIDRs, e.g. 10.0.0.0/8, 192.168.0.0/16',
|
|
150
|
-
'field.
|
|
151
|
-
'hint.
|
|
157
|
+
'field.lanPasswordless': 'LAN skip login',
|
|
158
|
+
'hint.lanPasswordless': 'LAN/loopback sources skip the gateway login page but still ride one shared upstream session (needs dsh >= 0.1.2)',
|
|
159
|
+
'field.allowInsecurePlaintext': 'Allow plaintext HTTP',
|
|
160
|
+
'hint.allowInsecurePlaintext': 'Dangerous: start the listener even without TLS or a trusted terminator; passwords and sessions travel in clear',
|
|
161
|
+
'field.trustedTerminator': 'Trusted TLS terminator',
|
|
162
|
+
'hint.trustedTerminator': 'Optional identifier for a front proxy (e.g. nginx) treated as the encrypted ingress. Empty = none declared',
|
|
152
163
|
'field.cookieMaxAgeDays': 'Session lifetime (days)',
|
|
153
164
|
'hint.cookieMaxAgeDays': 'Login cookie lifetime (default 7)',
|
|
154
165
|
'field.tlsEnabled': 'Enable TLS (HTTPS)',
|
|
@@ -189,7 +200,7 @@ const FIELDS: readonly FieldDef[] = [
|
|
|
189
200
|
{ field: 'gatewayPort', kind: 'number' },
|
|
190
201
|
{ field: 'dshTargetPort', kind: 'number', optional: true },
|
|
191
202
|
{ field: 'lanCidrs', kind: 'cidrs' },
|
|
192
|
-
{ field: '
|
|
203
|
+
{ field: 'lanPasswordless', kind: 'boolean' },
|
|
193
204
|
{ field: 'cookieMaxAgeDays', kind: 'number' },
|
|
194
205
|
{ field: 'tlsEnabled', kind: 'boolean' },
|
|
195
206
|
{ field: 'tlsMode', kind: 'select', options: ['self-signed', 'custom'] },
|
|
@@ -197,6 +208,8 @@ const FIELDS: readonly FieldDef[] = [
|
|
|
197
208
|
{ field: 'tlsCertPath', kind: 'text', optional: true },
|
|
198
209
|
{ field: 'tlsKeyPath', kind: 'text', optional: true },
|
|
199
210
|
{ field: 'tlsCertMaxAgeDays', kind: 'number' },
|
|
211
|
+
{ field: 'allowInsecurePlaintext', kind: 'boolean' },
|
|
212
|
+
{ field: 'trustedTerminator', kind: 'text', optional: true },
|
|
200
213
|
]
|
|
201
214
|
|
|
202
215
|
function formatValue(def: FieldDef, value: unknown): string {
|
|
@@ -423,13 +436,13 @@ export function LanGatewayCard(_props: LanGatewayCardProps): ReactNode {
|
|
|
423
436
|
aria-expanded={open}
|
|
424
437
|
onClick={() => { setOpen(!open) }}
|
|
425
438
|
>
|
|
426
|
-
<span style={styles.
|
|
439
|
+
<span style={styles.headerTop}>
|
|
427
440
|
<span style={styles.name}>{t.title}</span>
|
|
428
|
-
<span style={styles.
|
|
441
|
+
<span style={styles.status} title={statusLine}>{statusLine}</span>
|
|
442
|
+
{dirty ? <span style={styles.pending}>{t.unsaved}</span> : null}
|
|
443
|
+
<span style={open ? { ...styles.chevron, ...styles.chevronOpen } : styles.chevron}>{open ? '▾' : '▸'}</span>
|
|
429
444
|
</span>
|
|
430
|
-
<span style={styles.
|
|
431
|
-
{dirty ? <span style={styles.pending}>{t.unsaved}</span> : null}
|
|
432
|
-
<span style={open ? { ...styles.chevron, ...styles.chevronOpen } : styles.chevron}>{open ? '▾' : '▸'}</span>
|
|
445
|
+
<span style={styles.description}>{t.description}</span>
|
|
433
446
|
</button>
|
|
434
447
|
{open
|
|
435
448
|
? (
|
|
@@ -501,8 +514,9 @@ const styles: Record<string, React.CSSProperties> = {
|
|
|
501
514
|
},
|
|
502
515
|
header: {
|
|
503
516
|
display: 'flex',
|
|
504
|
-
|
|
505
|
-
|
|
517
|
+
flexDirection: 'column',
|
|
518
|
+
alignItems: 'stretch',
|
|
519
|
+
gap: '6px',
|
|
506
520
|
width: '100%',
|
|
507
521
|
padding: '14px 16px',
|
|
508
522
|
border: 0,
|
|
@@ -512,10 +526,31 @@ const styles: Record<string, React.CSSProperties> = {
|
|
|
512
526
|
textAlign: 'left',
|
|
513
527
|
cursor: 'pointer',
|
|
514
528
|
},
|
|
515
|
-
|
|
516
|
-
name: { fontSize: '15px', fontWeight: 600, lineHeight: 1.4, color: L.labelPrimary },
|
|
517
|
-
|
|
518
|
-
|
|
529
|
+
headerTop: { display: 'flex', alignItems: 'center', gap: '12px', width: '100%' },
|
|
530
|
+
name: { flex: '1 1 auto', minWidth: 0, fontSize: '15px', fontWeight: 600, lineHeight: 1.4, color: L.labelPrimary },
|
|
531
|
+
// The status carries a verbose TLS cert summary; cap it and ellipsize so it
|
|
532
|
+
// can never swallow the row or squeeze the title (the old nowrap alone
|
|
533
|
+
// caused the description to be pushed into a thin wrapping column).
|
|
534
|
+
status: {
|
|
535
|
+
flex: '0 1 auto',
|
|
536
|
+
minWidth: 0,
|
|
537
|
+
maxWidth: '60%',
|
|
538
|
+
fontSize: '11px',
|
|
539
|
+
lineHeight: 1.4,
|
|
540
|
+
color: L.labelTertiary,
|
|
541
|
+
whiteSpace: 'nowrap',
|
|
542
|
+
overflow: 'hidden',
|
|
543
|
+
textOverflow: 'ellipsis',
|
|
544
|
+
},
|
|
545
|
+
description: {
|
|
546
|
+
display: 'block',
|
|
547
|
+
fontSize: '13px',
|
|
548
|
+
lineHeight: 1.5,
|
|
549
|
+
color: L.labelTertiary,
|
|
550
|
+
whiteSpace: 'nowrap',
|
|
551
|
+
overflow: 'hidden',
|
|
552
|
+
textOverflow: 'ellipsis',
|
|
553
|
+
},
|
|
519
554
|
pending: {
|
|
520
555
|
flex: 'none',
|
|
521
556
|
borderRadius: '999px',
|