yaport 0.1.0 → 0.1.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/README.md CHANGED
@@ -11,7 +11,7 @@ English documentation is available below: [English](#english).
11
11
  - 通过一层或两层跳板机连接目标机器,底层使用 OpenSSH `ProxyJump`。
12
12
  - 目标机器或跳板机需要 SSH 密码时,可以在 GUI 填写,也可以通过 CLI 环境变量传入。
13
13
  - 面板和 CLI 都能配置连接超时、命令超时,适合慢链路或两层跳板机。
14
- - 如果远程机器有 nginx 配置,丫口会把能匹配端口的外部入口 URL 展示在端口行里。
14
+ - 如果远程机器有 nginx、OpenResty、Tengine 或 Caddy 配置,丫口会把能匹配端口的外部入口 URL 展示在端口行里。
15
15
  - 当前选中的机器会复用 OpenSSH ControlMaster 连接,降低两层跳板和密码认证的重复握手成本。
16
16
 
17
17
  丫口只做只读查询,不扫描网段,也不修改远程机器配置。
@@ -60,7 +60,7 @@ GUI 能添加机器,但大量机器用 CLI 更快。`add-machine` 是非交互
60
60
  OpenSSH alias,等价于 `ssh erya`:
61
61
 
62
62
  ```bash
63
- yaport add-machine --name Erya --host erya --json
63
+ yaport add-machine --host erya --json
64
64
  ```
65
65
 
66
66
  明确指定用户和端口:
@@ -114,9 +114,9 @@ yaport add-machine \
114
114
 
115
115
  | 参数 | 说明 |
116
116
  | --- | --- |
117
- | `--name <name>` | UI 里展示的机器名称。 |
117
+ | `--name <name>` | UI 里展示的机器名称;留空时默认用 `user@host:port`。 |
118
118
  | `--host <host>` | SSH host、IP 或 OpenSSH alias。 |
119
- | `--user <user>` | SSH 用户。OpenSSH alias 已配置时可以省略。 |
119
+ | `--user <user>` | SSH 用户。OpenSSH alias 已配置时可以省略;`--host` 是 IP 地址时必须填写。 |
120
120
  | `--port <port>` | SSH 端口。OpenSSH alias 已配置时可以省略。 |
121
121
  | `--password-env <env>` | 从环境变量读取目标机器 SSH 密码。 |
122
122
  | `--connect-timeout <seconds>` | SSH 连接超时。可选值:`8`、`15`、`30`、`60`。默认 `15`。 |
@@ -134,17 +134,40 @@ yaport add-machine \
134
134
  - agent 和脚本优先使用 `--json`。
135
135
  - 密码优先使用 `--password-env` 和 `--jumpN-password-env`,不要把密码直接放进命令参数。
136
136
  - 使用 OpenSSH alias 时,`--user` 和 `--port` 可以留空。
137
+ - 使用 IP 地址连接目标机器或跳板机时,必须填写对应用户。
137
138
  - 两层跳板或密码认证握手较慢时,建议使用 `--connect-timeout 30 --command-timeout 90`。
138
139
 
140
+ ## 配置开机自启
141
+
142
+ 丫口可以安装为当前用户的系统服务,不需要 sudo。macOS 使用 LaunchAgent,Linux 使用 systemd user service。服务启动时会带 `--no-open`,不会开机自动弹浏览器。
143
+
144
+ ```bash
145
+ yaport service install --port 5173 --json
146
+ yaport service status
147
+ yaport service uninstall
148
+ ```
149
+
150
+ 常用参数:
151
+
152
+ | 参数 | 说明 |
153
+ | --- | --- |
154
+ | `install` | 写入并启动用户级服务。 |
155
+ | `uninstall` | 停止并移除用户级服务。 |
156
+ | `status` | 调用 `launchctl` 或 `systemctl --user` 查看状态。 |
157
+ | `--host <host>` | 服务监听地址,默认 `127.0.0.1`。 |
158
+ | `--port <port>` | 服务监听端口,默认 `5173`。 |
159
+ | `--data-file <path>` | 服务使用的机器配置文件;不传时仍按 `./.yaport`、`~/.yaport` 查找。 |
160
+ | `--json` | `install` / `uninstall` 输出稳定 JSON。 |
161
+
139
162
  ## 端口信息
140
163
 
141
164
  丫口通过本机 OpenSSH 登录远程机器,并读取:
142
165
 
143
166
  - `ss -H -lntup`:监听中的 TCP/UDP 端口。
144
167
  - `ps -ww -p <pid> -o pid=,args=`:尽量补全进程命令行。
145
- - `nginx -T`:如果可读,推断 nginx 外部入口。
168
+ - `nginx -T` / `openresty -T` / `tengine -T` / `/etc/caddy/Caddyfile`:对应代理进程正在监听且配置可读时,推断外部入口。
146
169
 
147
- `ss` 会先执行;拿到 PID 后,`ps` `nginx -T` 会并发执行。任意一个失败都不会影响端口清单。
170
+ `ss` 会先执行;拿到 PID 后,`ps` 和代理配置读取会并发执行。丫口只在 `ss` 结果里看到 nginx、OpenResty、Tengine Caddy 进程时才读取对应配置,避免拖慢普通机器刷新。任意一个失败都不会影响端口清单。
148
171
 
149
172
  表格展示:
150
173
 
@@ -156,28 +179,40 @@ yaport add-machine \
156
179
  - PID。
157
180
  - 外部入口。
158
181
 
159
- ## nginx 外部入口
182
+ ## 外部入口
160
183
 
161
- 当 `nginx -T` 可读时,丫口会解析 `server` 配置并按端口匹配外部入口。
184
+ 当代理配置可读时,丫口会按端口匹配外部入口。
162
185
 
163
- 规则:
186
+ nginx / OpenResty / Tengine 规则:
164
187
 
165
188
  - `listen 443 ssl` 或端口 `443` 展示为 `https://server_name`。
166
189
  - 其他端口展示为 `http://server_name:port`。
167
190
  - `server_name _`、空值、变量域名不展示。
168
- - `nginx -T` 读不到不会影响端口清单,只显示“无外部入口”。
169
- - nginx 来源的入口会带一个 nginx 小图标;后续可以继续支持其他来源类型。
170
- - nginx 外部入口按机器缓存 5 分钟,包括短时间缓存读取失败,避免静默刷新时重复等待慢失败。
191
+ - 配置读不到不会影响端口清单,只显示“无外部入口”。
192
+
193
+ Caddy 规则:
194
+
195
+ - 读取 `/etc/caddy/Caddyfile` 的站点块地址。
196
+ - `example.com` 展示为 `https://example.com`。
197
+ - `http://example.com:8080` 展示为 `http://example.com:8080`。
198
+ - `_`、变量地址、纯端口地址、通配地址暂不展示。
199
+
200
+ 外部入口按机器缓存 5 分钟,包括短时间缓存读取失败,避免静默刷新时重复等待慢失败。
201
+ 不同来源会显示不同小图标:nginx / OpenResty / Tengine / Caddy。
171
202
 
172
203
  ## Web UI 行为
173
204
 
174
205
  - 添加机器表单默认折叠。
175
206
  - 添加机器时可以选择“连接超时”和“命令超时”。
207
+ - 连接名称可选,留空时默认用主机信息;Host 是 IP 地址时用户必填,OpenSSH alias 可以留空。
208
+ - 机器卡片右侧可以打开本机 Terminal,并用该机器配置执行 `ssh`。已保存密码不会写入命令行。
209
+ - 左侧机器列表在页面滚动时固定在左侧,也可以折叠成窄栏。
210
+ - 右键机器卡片可以编辑或删除连接配置;编辑时密码框留空表示保留原密码。
176
211
  - TCP / UDP 指标卡带开关,默认开启,用于过滤下方记录。
177
212
  - “监听端口”指标卡可以按端口分组。
178
213
  - “进程”指标卡可以按进程分组。
179
214
  - 切换机器时按机器粒度保留上次结果,不会把上一台机器的端口展示到当前机器。
180
- - 当前选中的机器会使用 OpenSSH `ControlMaster=auto` 和 `ControlPersist=5m`,`ss`、`ps`、`nginx -T`、手动刷新和 1 分钟自动刷新会复用同一条 SSH master connection。
215
+ - 当前选中的机器会使用 OpenSSH `ControlMaster=auto` 和 `ControlPersist=5m`,`ss`、`ps`、代理配置读取、手动刷新和 1 分钟自动刷新会复用同一条 SSH master connection。
181
216
  - 未选中机器的后台静默刷新不启用连接复用,避免维护过多后台连接。
182
217
  - 网页在前台活跃时,选中机器默认每 1 分钟静默刷新,未选中机器默认每 5 分钟静默刷新。
183
218
  - 机器列表和当前选中机器会写入 `sessionStorage`,刷新页面后能先看到本地缓存,再等待 server 返回最新数据。
@@ -188,14 +223,14 @@ yaport add-machine \
188
223
  - 配置文件以 `0600` 权限写入。
189
224
  - API 和 CLI JSON 输出不会返回已保存的 SSH 密码。
190
225
  - 密码目前是本机明文保存;如果不想落盘,优先使用 SSH key 或 OpenSSH alias。
191
- - 丫口只从远程机器读取端口、进程和 nginx 配置,不会写远程文件。
226
+ - 丫口只从远程机器读取端口、进程和代理配置,不会写远程文件。
192
227
 
193
228
  ## 远程机器要求
194
229
 
195
230
  - 本机需要可用的 OpenSSH `ssh` 命令。
196
231
  - 远程机器需要有 `ss` 命令。
197
232
  - 进程命令行和 PID 是否完整,取决于当前 SSH 用户权限。
198
- - nginx 外部入口依赖远程机器能执行并读取 `nginx -T`。
233
+ - 外部入口依赖远程机器能执行或读取对应代理配置。
199
234
 
200
235
  ## 本地开发
201
236
 
@@ -226,7 +261,7 @@ Yaport is read-only. It does not scan networks and does not modify remote machin
226
261
  - Connect through one or two jump hosts via OpenSSH `ProxyJump`.
227
262
  - Support SSH passwords for both target machines and jump hosts, from the GUI or CLI environment variables.
228
263
  - Configure connection and command timeouts from both the UI and CLI for slow links or two-hop jump paths.
229
- - Infer public-facing nginx URLs and show them next to matching listening ports.
264
+ - Infer public-facing nginx, OpenResty, Tengine, and Caddy URLs and show them next to matching listening ports.
230
265
  - Reuse an OpenSSH ControlMaster connection for the currently selected machine to reduce repeated handshakes on slow jump paths.
231
266
 
232
267
  ## Installation
@@ -273,7 +308,7 @@ The GUI can add machines, but the CLI is faster for repeated setup. `add-machine
273
308
  OpenSSH alias, same as `ssh erya`:
274
309
 
275
310
  ```bash
276
- yaport add-machine --name Erya --host erya --json
311
+ yaport add-machine --host erya --json
277
312
  ```
278
313
 
279
314
  Explicit user and port:
@@ -327,9 +362,9 @@ Common options:
327
362
 
328
363
  | Option | Description |
329
364
  | --- | --- |
330
- | `--name <name>` | Display name in the UI. |
365
+ | `--name <name>` | Display name in the UI. When omitted, Yaport uses `user@host:port`. |
331
366
  | `--host <host>` | SSH host, IP address, or OpenSSH alias. |
332
- | `--user <user>` | SSH user. Omit it when an OpenSSH alias already defines `User`. |
367
+ | `--user <user>` | SSH user. Omit it when an OpenSSH alias already defines `User`; required when `--host` is an IP address. |
333
368
  | `--port <port>` | SSH port. Omit it when an OpenSSH alias already defines `Port`. |
334
369
  | `--password-env <env>` | Read the target machine SSH password from an environment variable. |
335
370
  | `--connect-timeout <seconds>` | SSH connection timeout. Choices: `8`, `15`, `30`, `60`. Default: `15`. |
@@ -347,17 +382,40 @@ Recommendations:
347
382
  - Use `--json` from agents and scripts.
348
383
  - Prefer `--password-env` and `--jumpN-password-env`; avoid putting passwords directly in process arguments.
349
384
  - Omit `--user` and `--port` when `--host` is an OpenSSH alias.
385
+ - IP targets and IP jump hosts must include a user.
350
386
  - For slow two-hop jump paths or password handshakes, use `--connect-timeout 30 --command-timeout 90`.
351
387
 
388
+ ## Autostart Service
389
+
390
+ Yaport can install a user-level service without sudo. macOS uses LaunchAgent, and Linux uses a systemd user service. The installed service starts with `--no-open`, so it does not open a browser automatically after login.
391
+
392
+ ```bash
393
+ yaport service install --port 5173 --json
394
+ yaport service status
395
+ yaport service uninstall
396
+ ```
397
+
398
+ Common options:
399
+
400
+ | Option | Description |
401
+ | --- | --- |
402
+ | `install` | Write and start the user-level service. |
403
+ | `uninstall` | Stop and remove the user-level service. |
404
+ | `status` | Show status through `launchctl` or `systemctl --user`. |
405
+ | `--host <host>` | Service bind host. Default: `127.0.0.1`. |
406
+ | `--port <port>` | Service bind port. Default: `5173`. |
407
+ | `--data-file <path>` | Machine config file used by the service. Without this, Yaport still checks `./.yaport`, then `~/.yaport`. |
408
+ | `--json` | Print deterministic JSON for `install` / `uninstall`. |
409
+
352
410
  ## Port Inventory
353
411
 
354
412
  Yaport logs in through local OpenSSH and reads:
355
413
 
356
414
  - `ss -H -lntup`: listening TCP/UDP ports.
357
415
  - `ps -ww -p <pid> -o pid=,args=`: full process command lines when available.
358
- - `nginx -T`: nginx configuration, when readable, to infer external routes.
416
+ - `nginx -T` / `openresty -T` / `tengine -T` / `/etc/caddy/Caddyfile`: proxy configuration, when the matching proxy process is listening and the config is readable, to infer external routes.
359
417
 
360
- `ss` runs first. After PIDs are available, `ps` and `nginx -T` run concurrently. Either one may fail without breaking the port list.
418
+ `ss` runs first. After PIDs are available, `ps` and proxy config reads run concurrently. Yaport only reads proxy config when the `ss` result already shows nginx, OpenResty, Tengine, or Caddy, so ordinary machines do not pay extra refresh latency. Either one may fail without breaking the port list.
361
419
 
362
420
  The table shows:
363
421
 
@@ -369,28 +427,40 @@ The table shows:
369
427
  - PID.
370
428
  - External routes.
371
429
 
372
- ## nginx External Routes
430
+ ## External Routes
373
431
 
374
- When `nginx -T` is readable, Yaport parses `server` blocks and matches routes by port.
432
+ When proxy configuration is readable, Yaport matches external routes by port.
375
433
 
376
- Rules:
434
+ nginx / OpenResty / Tengine rules:
377
435
 
378
436
  - `listen 443 ssl` or port `443` is shown as `https://server_name`.
379
437
  - Other ports are shown as `http://server_name:port`.
380
438
  - `server_name _`, empty names, and variable names are ignored.
381
- - If `nginx -T` cannot be read, the port list still works and the UI shows no external route.
382
- - nginx routes include a small nginx source icon. Other source types can be added later.
383
- - nginx external routes are cached per machine for 5 minutes, including short-lived failure caching, so silent refreshes do not repeatedly wait on slow config reads.
439
+ - If config cannot be read, the port list still works and the UI shows no external route.
440
+
441
+ Caddy rules:
442
+
443
+ - Yaport reads site block addresses from `/etc/caddy/Caddyfile`.
444
+ - `example.com` is shown as `https://example.com`.
445
+ - `http://example.com:8080` is shown as `http://example.com:8080`.
446
+ - `_`, variable addresses, port-only addresses, and wildcard addresses are ignored for now.
447
+
448
+ External routes are cached per machine for 5 minutes, including short-lived failure caching, so silent refreshes do not repeatedly wait on slow config reads.
449
+ Routes include a small source icon for nginx, OpenResty, Tengine, or Caddy.
384
450
 
385
451
  ## Web UI Behavior
386
452
 
387
453
  - The add-machine form is collapsed by default.
388
454
  - The add-machine form lets you choose connection timeout and command timeout.
455
+ - The connection name is optional. When omitted, Yaport uses the host information. IP hosts require a user; OpenSSH aliases can omit it.
456
+ - The right side of each machine card opens the local Terminal and runs `ssh` with that machine's connection settings. Stored passwords are not written into the command line.
457
+ - The left machine list stays fixed while the page scrolls and can collapse into a narrow rail.
458
+ - Right-click a machine card to edit or delete that connection config. During edits, leaving password fields blank keeps the existing password.
389
459
  - TCP and UDP metric cards include switches, both enabled by default, to filter visible records.
390
460
  - The listening-port metric card can group the table by port.
391
461
  - The process metric card can group the table by process.
392
462
  - Switching machines keeps the selected machine's own cached result; it never shows another machine's ports as stale data.
393
- - The selected machine uses OpenSSH `ControlMaster=auto` and `ControlPersist=5m`; `ss`, `ps`, `nginx -T`, manual refresh, and 1-minute auto refresh reuse the same SSH master connection.
463
+ - The selected machine uses OpenSSH `ControlMaster=auto` and `ControlPersist=5m`; `ss`, `ps`, proxy config reads, manual refresh, and 1-minute auto refresh reuse the same SSH master connection.
394
464
  - Background refreshes for unselected machines do not use connection reuse, so Yaport does not keep extra background master connections open.
395
465
  - While the page is visible, the selected machine refreshes silently every 1 minute, and unselected machines refresh silently every 5 minutes.
396
466
  - The machine list and selected machine ID are stored in `sessionStorage` for immediate visibility after page reload.
@@ -401,14 +471,14 @@ Rules:
401
471
  - The config file is written with `0600` permissions.
402
472
  - API responses and CLI JSON output never return stored SSH passwords.
403
473
  - Passwords are currently stored locally in plain text. Prefer SSH keys or OpenSSH aliases if you do not want passwords on disk.
404
- - Yaport only reads remote ports, processes, and nginx configuration. It does not write remote files.
474
+ - Yaport only reads remote ports, processes, and proxy configuration. It does not write remote files.
405
475
 
406
476
  ## Remote Requirements
407
477
 
408
478
  - Local machine: OpenSSH `ssh`.
409
479
  - Remote machine: `ss`.
410
480
  - Process command lines and PIDs depend on the SSH user's permissions.
411
- - nginx external routes require `nginx -T` to be executable and readable on the remote machine.
481
+ - External routes require the matching proxy config command or file to be executable or readable on the remote machine.
412
482
 
413
483
  ## Development
414
484
 
@@ -0,0 +1 @@
1
+ :root{--ink:#12211d;--ink-soft:#42514b;--paper:#f7f5ec;--panel:#ebe7d7;--line:#c8cfbf;--rail:#d8e8dc;--teal:#128fa1;--teal-deep:#0b6370;--amber:#c48925;--brick:#a9473b;--white:#fffdf6;--shadow:0 14px 36px #19231e1f;color:var(--ink);background:var(--paper);font-synthesis:none;text-rendering:optimizelegibility;font-family:Aptos,Inter,Segoe UI,system-ui,sans-serif}*{box-sizing:border-box}body{background:linear-gradient(90deg, #12211d08 1px, transparent 1px), linear-gradient(#12211d08 1px, transparent 1px), var(--paper);background-size:24px 24px;min-width:320px;min-height:100vh;margin:0;font-size:14px;line-height:1.35}button,input,select{font:inherit}button{cursor:pointer}button:disabled{cursor:not-allowed}button:focus-visible,input:focus-visible,select:focus-visible{outline-offset:2px;outline:3px solid #128fa159}.app-shell{grid-template-columns:minmax(280px,326px) 1fr;min-height:100vh;display:grid}.app-shell.sidebar-collapsed{grid-template-columns:64px 1fr}.machine-pane{background:var(--ink);height:100vh;min-height:0;color:var(--white);box-shadow:var(--shadow);flex-direction:column;gap:16px;padding:22px;display:flex;position:sticky;top:0;overflow:hidden}.machine-pane.collapsed{align-items:center;padding:16px 12px}.machine-pane-top{justify-content:space-between;align-items:center;gap:10px;display:flex}.machine-pane.collapsed .machine-pane-top{flex-direction:column}.machine-pane-content{flex-direction:column;flex:auto;gap:16px;min-height:0;padding-right:2px;display:flex;overflow:auto}.brand-block{align-items:center;gap:12px;display:flex}.brand-mark{background:#fffdf614;border:1px solid #fffdf642;border-radius:6px;flex:none;place-items:center;width:40px;height:40px;display:grid}.pane-collapse-button{color:#fffdf6c7;background:#fffdf612;border:1px solid #fffdf629;border-radius:6px;flex:none;place-items:center;width:32px;height:32px;display:inline-grid}.pane-collapse-button:hover{border-color:var(--amber);color:var(--white);background:#c4892529}.eyebrow,.section-label,.form-title,.metric-label{color:var(--ink-soft);letter-spacing:0;text-transform:uppercase;margin:0;font-family:SFMono-Regular,Cascadia Mono,Roboto Mono,monospace;font-size:11px;font-weight:700}.machine-pane .eyebrow,.machine-pane .section-label,.machine-pane .form-title{color:#fffdf69e}h1,h2{letter-spacing:0;margin:0;font-family:Arial Narrow,Aptos Display,Segoe UI,sans-serif;font-weight:800}h1{font-size:31px;line-height:1}h2{color:var(--ink);font-size:38px;line-height:1.02}.machine-form{gap:12px;padding-top:2px;display:grid}.add-machine-toggle{width:100%;min-height:40px;color:var(--white);text-align:left;background:#fffdf612;border:1px solid #fffdf629;border-radius:6px;justify-content:space-between;align-items:center;gap:10px;padding:0 12px;display:flex}.add-machine-toggle>span:first-child{align-items:center;gap:8px;font-weight:800;display:inline-flex}.add-machine-toggle:hover,.add-machine-toggle[aria-expanded=true]{border-color:var(--amber);background:#c4892529}.toggle-hint{color:#fffdf694;letter-spacing:0;font-family:SFMono-Regular,Cascadia Mono,Roboto Mono,monospace;font-size:11px;font-weight:800}.jump-hosts{gap:8px;padding-top:2px;display:grid}.jump-host-group{background:#fffdf60d;border:1px solid #fffdf624;border-radius:6px;gap:8px;margin:0;padding:10px;display:grid}.jump-host-group legend{color:#fffdf69e;letter-spacing:0;padding:0 5px;font-family:SFMono-Regular,Cascadia Mono,Roboto Mono,monospace;font-size:11px;font-weight:800}.form-title,.section-label,.rail-header{align-items:center;gap:7px;display:flex}label{color:#fffdf6b8;gap:6px;font-size:12px;font-weight:700;display:grid}input,select{width:100%;min-height:38px;color:var(--white);background:#fffdf617;border:1px solid #fffdf62e;border-radius:6px;padding:0 10px}select{--lightningcss-light: ;--lightningcss-dark:initial;color-scheme:dark}input::placeholder{color:#fffdf657}.form-grid{grid-template-columns:1fr 96px;gap:10px;display:grid}.primary-button,.secondary-button{border:0;border-radius:6px;justify-content:center;align-items:center;gap:7px;min-height:38px;font-size:13px;font-weight:800;display:inline-flex}.primary-button{background:var(--amber);color:#21180a}.secondary-button{border:1px solid var(--line);background:var(--white);color:var(--ink);padding:0 12px}.primary-button:disabled,.secondary-button:disabled{opacity:.55}.machine-list{gap:8px;min-height:0;display:grid}.machine-item{width:100%;color:var(--white);background:#fffdf612;border:1px solid #fffdf624;border-radius:6px;grid-template-columns:minmax(0,1fr) 34px;align-items:center;display:grid;overflow:hidden}.machine-item:hover,.machine-item.active{border-color:var(--amber);background:#c489252e}.machine-select-button{min-width:0;color:inherit;text-align:left;background:0 0;border:0;gap:4px;padding:11px 10px 11px 12px;display:grid}.machine-terminal-button{color:#fffdf6b3;background:#fffdf614;border:1px solid #fffdf624;border-radius:6px;place-items:center;width:28px;height:28px;display:inline-grid}.machine-terminal-button:hover{border-color:var(--teal);color:var(--white);background:#128fa138}.machine-name{font-size:14px;font-weight:800}.machine-address,.target-line,.quiet-state{color:#fffdf694;letter-spacing:0;font-family:SFMono-Regular,Cascadia Mono,Roboto Mono,monospace;font-size:11px}.machine-address,.target-line{overflow-wrap:anywhere}.quiet-state{border:1px dashed #fffdf633;border-radius:6px;padding:12px}.machine-context-menu{z-index:30;border:1px solid var(--line);background:var(--white);border-radius:6px;min-width:132px;padding:4px;position:fixed;box-shadow:0 12px 28px #12211d2e}.machine-context-menu button{width:100%;min-height:30px;color:var(--ink);text-align:left;background:0 0;border:0;border-radius:4px;align-items:center;gap:7px;padding:0 9px;font-size:12px;font-weight:800;display:flex}.machine-context-menu button:hover{background:#12211d14}.machine-context-menu button.danger{color:var(--brick)}.machine-context-menu button.danger:hover{background:#a9473b1c}.inventory-pane{flex-direction:column;gap:14px;min-width:0;padding:22px 30px;display:flex}.inventory-header{justify-content:space-between;align-items:flex-start;gap:14px;display:flex}.inventory-header .target-line{color:var(--ink-soft);margin:6px 0 0}.error-strip{border-left:5px solid var(--brick);color:#5d211b;white-space:pre-wrap;background:#f2d8cf;padding:10px 12px;font-size:13px;font-weight:700}.signal-rail{border:1px solid var(--line);background:var(--rail);gap:10px;padding:12px;display:grid}.rail-header{color:var(--ink);letter-spacing:0;text-transform:uppercase;font-family:SFMono-Regular,Cascadia Mono,Roboto Mono,monospace;font-size:11px;font-weight:800}.port-grid{grid-template-columns:repeat(auto-fill,minmax(10px,1fr));align-items:stretch;gap:4px;min-height:38px;display:grid}.port-cell{background:var(--teal);border:1px solid #12211d38;min-height:13px;display:block}.port-cell.udp{background:var(--amber)}.empty-matrix{color:var(--ink-soft);grid-column:1/-1;align-self:center;font-family:SFMono-Regular,Cascadia Mono,Roboto Mono,monospace;font-size:11px}.summary-grid{grid-template-columns:repeat(5,minmax(106px,1fr));gap:10px;display:grid}.metric{border:1px solid var(--line);background:var(--white);gap:6px;min-height:72px;padding:10px 12px;display:grid}.metric-topline{justify-content:space-between;align-items:flex-start;gap:8px;min-width:0;display:flex}.metric.cyan{border-bottom:4px solid var(--teal)}.metric.amber{border-bottom:4px solid var(--amber)}.metric-label{align-items:center;gap:6px;min-width:0;display:inline-flex}.metric strong{align-self:end;font-family:SFMono-Regular,Cascadia Mono,Roboto Mono,monospace;font-size:28px;line-height:1}.metric-switch{border:1px solid var(--line);background:#e7e4d8;border-radius:999px;flex:none;align-items:center;width:36px;height:20px;padding:2px;display:inline-flex}.metric-switch span{background:var(--white);border-radius:999px;width:14px;height:14px;transition:transform .14s;display:block;box-shadow:0 1px 3px #12211d38}.metric-switch[aria-checked=true]{border-color:var(--teal);background:#128fa142}.metric-switch[aria-checked=true] span{background:var(--teal-deep);transform:translate(16px)}.table-wrap{border:1px solid var(--line);background:var(--white);overflow:auto}table{border-collapse:collapse;width:100%;min-width:920px}th,td{border-bottom:1px solid var(--line);text-align:left;padding:10px 12px}th{color:var(--ink-soft);letter-spacing:0;text-transform:uppercase;background:#e4eadf;font-family:SFMono-Regular,Cascadia Mono,Roboto Mono,monospace;font-size:11px}td{font-size:13px}tbody tr:hover{background:#f1f4eb}.group-row td{border-bottom:1px solid var(--line);background:#edf0e7;padding:7px 12px}.group-row-label{color:var(--ink-soft);justify-content:space-between;align-items:center;gap:12px;font-family:SFMono-Regular,Cascadia Mono,Roboto Mono,monospace;font-size:11px;font-weight:800;display:flex}.group-row-label span:first-child{overflow-wrap:anywhere;min-width:0}.group-row-label span:last-child{flex:none}.protocol-pill{min-width:44px;color:var(--teal-deep);background:#128fa129;border-radius:999px;justify-content:center;padding:3px 7px;font-family:SFMono-Regular,Cascadia Mono,Roboto Mono,monospace;font-size:11px;font-weight:800;display:inline-flex}.protocol-pill.udp{color:#734a08;background:#c4892533}.mono,.port-number{font-variant-numeric:tabular-nums;font-family:SFMono-Regular,Cascadia Mono,Roboto Mono,monospace}.port-number{font-weight:800}.socket-count{color:var(--ink-soft);background:#e5e9de;border-radius:999px;margin-left:8px;padding:2px 6px;font-size:11px;font-weight:800;display:inline-flex}.process-cell{overflow-wrap:anywhere;min-width:180px;max-width:300px;line-height:1.35}.route-list{flex-wrap:wrap;gap:6px;min-width:190px;display:flex}.route-chip{max-width:280px;color:var(--teal-deep);letter-spacing:0;background:linear-gradient(90deg,#128fa124,#d8e8dc70);border:1px solid #128fa147;border-radius:999px;align-items:center;gap:6px;padding:5px 8px;font-family:SFMono-Regular,Cascadia Mono,Roboto Mono,monospace;font-size:11px;font-weight:800;text-decoration:none;display:inline-flex}.route-source-badge{width:17px;height:17px;color:var(--white);flex:none;justify-content:center;align-items:center;font-family:SFMono-Regular,Cascadia Mono,Roboto Mono,monospace;font-size:10px;font-weight:900;line-height:1;display:inline-flex}.route-source-badge.nginx{clip-path:polygon(25% 4%,75% 4%,100% 50%,75% 96%,25% 96%,0 50%);background:#05924d;box-shadow:inset 0 -1px #12211d3d}.route-source-badge.caddy{background:#1f88e5;border-radius:4px;box-shadow:inset 0 -1px #12211d3d}.route-source-badge.openresty{clip-path:polygon(25% 4%,75% 4%,100% 50%,75% 96%,25% 96%,0 50%);background:#6f9443;box-shadow:inset 0 -1px #12211d3d}.route-source-badge.tengine{background:#b85f19;border-radius:999px;box-shadow:inset 0 -1px #12211d3d}.route-chip-url{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.route-chip:hover{border-color:var(--teal);background:#128fa12e}.route-empty{color:#42514b8a;font-family:SFMono-Regular,Cascadia Mono,Roboto Mono,monospace;font-size:11px}.table-empty{height:140px;color:var(--ink-soft);text-align:center;position:sticky;left:0}.spin{animation:.9s linear infinite spin}@keyframes spin{to{transform:rotate(360deg)}}@media (prefers-reduced-motion:reduce){*,:before,:after{scroll-behavior:auto!important;animation-duration:1ms!important;animation-iteration-count:1!important}}@media (width<=980px){.app-shell,.app-shell.sidebar-collapsed{grid-template-columns:1fr}.machine-pane{height:auto;min-height:auto;padding:20px;position:static}.machine-pane.collapsed{align-items:stretch}.machine-pane.collapsed .machine-pane-top{flex-direction:row}h1{font-size:30px}h2{font-size:34px}.summary-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.metric.wide{grid-column:span 2}}@media (width<=560px){.machine-pane,.inventory-pane{padding:16px}h1{font-size:28px}h2{font-size:30px}.inventory-header{flex-direction:column;align-items:stretch}.form-grid,.summary-grid{grid-template-columns:1fr}.metric.wide{grid-column:auto}.secondary-button{width:100%}}