@linxin666/dsh-ssh 0.1.19 → 0.2.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.i18n.yaml CHANGED
@@ -2,5 +2,5 @@
2
2
  # side as of the last confirmed-consistent state. Both languages carry equal authority;
3
3
  # after editing either side, bring the other along and re-record with:
4
4
  # node scripts/verify-docs.mjs --write <dir>
5
- README.md: ab9bd6b8b9dfe08a1be7f7dbb3bcd41d086659e0
6
- README.zh.md: 3972324c8541d87d0603de64be7817f32d3e98ad
5
+ README.md: 4ea116d416ac311ffde1f1052d9857e46b9c4849
6
+ README.zh.md: d5bec45d8054b999d24832a4a8d103269b2c816d
package/README.md CHANGED
@@ -8,7 +8,7 @@ Built on the capability list of [badseal/ssh-skill](https://github.com/badseal/s
8
8
 
9
9
  | Capability | Description |
10
10
  | --- | --- |
11
- | Host management | CRUD, search, connection test; config stored in `~/.dsh/dsh-ssh.json`; supports key / password auth, passphrase keys, ProxyJump jump hosts (multi-level) |
11
+ | Host management | CRUD, search, connection test; collapsible grouping by environment / tags with per-group batch test; config stored in `~/.dsh/dsh-ssh.json`; supports key / password auth, passphrase keys, ProxyJump jump hosts (multi-level) |
12
12
  | Config import | One-click parse of a standard `~/.ssh/config` (Host/HostName/User/Port/IdentityFile/ProxyJump etc.); existing aliases are skipped |
13
13
  | Persistent connection pool | Reuses a long-lived connection per host (opposite of the ssh-skill daemon), automatically disconnects after 30 minutes idle, auto-reconnects on disconnect (up to 3 times) |
14
14
  | Command execution | exec with a timeout (default 60s), stdout/stderr separated, output truncation guard (2MB) |
@@ -23,6 +23,7 @@ Built on the capability list of [badseal/ssh-skill](https://github.com/badseal/s
23
23
  - All `/api/dsh-ssh/*` routes are loopback-only (with same-origin checks) — the interfaces that execute commands against remote servers are not exposed to the LAN.
24
24
  - Passwords / key passphrases are stored in plain text in `~/.dsh/dsh-ssh.json`, file mode 0600, directory 0700 (the same trust model as ssh-skill writing passwords into ssh-config comments).
25
25
  - Tunnels only listen on `127.0.0.1`.
26
+ - Deleting a host or changing its connection fields (host / port / user / auth / proxyJump) immediately closes that alias's pooled connection and tunnels; later operations reconnect with the new configuration and never reuse a connection authenticated with the old credentials.
26
27
  - Before the Agent uses a tool, the host must first be configured in the GUI (or imported from ~/.ssh/config).
27
28
  - `ssh_upload` / `ssh_download` read/write arbitrary local paths on this machine with host-process privileges (not through the bash sandbox) — same host-local-path semantics as ssh-skill, be aware of that permission surface.
28
29
  - The remote output of exec / cluster is returned verbatim (not sanitized); a command like `env` may bring secrets from the remote environment back into the conversation log.
package/README.zh.md CHANGED
@@ -8,7 +8,7 @@
8
8
 
9
9
  | 能力 | 说明 |
10
10
  | --- | --- |
11
- | 主机管理 | 增删改查、搜索、连接测试;配置存 `~/.dsh/dsh-ssh.json`;支持密钥 / 密码认证、passphrase 密钥、ProxyJump 跳板机(多级) |
11
+ | 主机管理 | 增删改查、搜索、连接测试;支持按环境 / 标签分组折叠与组内批量测试;配置存 `~/.dsh/dsh-ssh.json`;支持密钥 / 密码认证、passphrase 密钥、ProxyJump 跳板机(多级) |
12
12
  | 配置导入 | 一键解析标准 `~/.ssh/config`(Host/HostName/User/Port/IdentityFile/ProxyJump 等),已有别名自动跳过 |
13
13
  | 持久连接池 | 每台主机复用长连接(对应 ssh-skill 的守护进程),空闲 30 分钟自动断开,断线自动重连(最多 3 次) |
14
14
  | 命令执行 | exec 带超时(默认 60s),stdout/stderr 分离,输出截断保护(2MB) |
@@ -23,6 +23,7 @@
23
23
  - 所有 `/api/dsh-ssh/*` 路由仅限 loopback 访问(含同源校验)——对远程服务器执行命令的接口不会暴露给局域网。
24
24
  - 密码 / 密钥口令以明文保存在 `~/.dsh/dsh-ssh.json`,文件权限 0600、目录 0700(与 ssh-skill 把密码写进 ssh-config 注释同一信任模型)。
25
25
  - 隧道只监听 `127.0.0.1`。
26
+ - 删除主机或修改其连接字段(host / port / user / auth / proxyJump)会立即断开该别名的池化连接与隧道,后续操作按新配置重新建连,不会复用旧凭据的已认证连接。
26
27
  - Agent 使用工具前,主机需先在 GUI 中配置(或从 ~/.ssh/config 导入)。
27
28
  - `ssh_upload` / `ssh_download` 以宿主进程权限直接读写本机任意路径(不经 bash 沙箱)——与 ssh-skill 的宿主本地路径语义一致,注意该权限面。
28
29
  - exec / cluster 的远程输出原样返回(不脱敏),命令如 `env` 可能把远端环境中的密钥带回对话记录。
package/lib/client.js CHANGED
@@ -323,6 +323,14 @@ window.__ModuleLoader__.load({
323
323
  "hosts.add": "新增主机",
324
324
  "hosts.import": "导入 ~/.ssh/config",
325
325
  "hosts.search": "搜索别名 / 描述 / 标签…",
326
+ "hosts.groupBy.label": "分组方式",
327
+ "hosts.groupBy.none": "不分组",
328
+ "hosts.groupBy.environment": "按环境分组",
329
+ "hosts.groupBy.tags": "按标签分组",
330
+ "hosts.group.ungrouped": "未设置环境",
331
+ "hosts.group.noTags": "无标签",
332
+ "hosts.group.count": "{count} 台",
333
+ "hosts.group.testAll": "测试全部",
326
334
  "hosts.col.alias": "别名",
327
335
  "hosts.col.host": "主机",
328
336
  "hosts.col.user": "用户",
@@ -452,6 +460,14 @@ window.__ModuleLoader__.load({
452
460
  "hosts.add": "Add host",
453
461
  "hosts.import": "Import ~/.ssh/config",
454
462
  "hosts.search": "Search alias / description / tags…",
463
+ "hosts.groupBy.label": "Group by",
464
+ "hosts.groupBy.none": "No grouping",
465
+ "hosts.groupBy.environment": "Group by environment",
466
+ "hosts.groupBy.tags": "Group by tags",
467
+ "hosts.group.ungrouped": "No environment",
468
+ "hosts.group.noTags": "No tags",
469
+ "hosts.group.count": "{count} hosts",
470
+ "hosts.group.testAll": "Test all",
455
471
  "hosts.col.alias": "Alias",
456
472
  "hosts.col.host": "Host",
457
473
  "hosts.col.user": "User",
@@ -595,7 +611,7 @@ window.__ModuleLoader__.load({
595
611
  }
596
612
  //#endregion
597
613
  //#region \0dsh-css:packages/dsh-ssh/src/client/panel/panel.module.css.mjs
598
- const css = "[data-pane=conversation],[class*=centerCol]{position:relative}[data-dsh-ssh-view]{z-index:60;background:var(--dsw-alias-bg-base);display:none;position:absolute;inset:0}html[data-dsh-ssh-active]:not([data-dsh-taskboard-active]) [data-dsh-ssh-view]{display:block}html[data-dsh-ssh-active]:not([data-dsh-taskboard-active]) [data-pane=conversation]>:not([data-dsh-ssh-view]),html[data-dsh-ssh-active]:not([data-dsh-taskboard-active]) [class*=centerCol]>:not([data-dsh-ssh-view]){display:none!important}.mL8Uca_entry{width:100%;height:32px;color:var(--dsw-alias-label-secondary);cursor:pointer;white-space:nowrap;background:0 0;border:none;border-radius:8px;align-items:center;gap:8px;padding:0 12px;font-size:13px;display:flex}.mL8Uca_entry:hover{background:var(--dsw-specific-sidebar-nav-item-hover);color:var(--dsw-alias-label-primary)}.mL8Uca_entry[data-active]{background:var(--dsw-specific-sidebar-nav-item-active);color:var(--dsw-alias-label-primary);font-weight:600}.mL8Uca_entryIcon{flex:none;justify-content:center;align-items:center;display:inline-flex}.mL8Uca_entryLabel{text-overflow:ellipsis;overflow:hidden}[data-dsh-frame][data-sidebar-collapsed] .mL8Uca_entry{justify-content:center;width:100%;padding:0}[data-dsh-frame][data-sidebar-collapsed] .mL8Uca_entryLabel{display:none}.mL8Uca_view{overflow:hidden}.mL8Uca_panel{background:var(--dsw-alias-bg-base);min-width:0;height:100%;min-height:0;color:var(--dsw-alias-label-primary);font-family:var(--dsw-font-family);flex-direction:column;gap:10px;padding:14px 16px 16px;display:flex}.mL8Uca_panelHeader{flex:none;align-items:center;gap:10px;display:flex}.mL8Uca_panelTitle{color:var(--dsw-alias-label-primary);white-space:nowrap;flex:1;margin:0;font-size:16px;font-weight:700}.mL8Uca_backButton{align-items:center;gap:4px;display:inline-flex}.mL8Uca_tabBar{border-bottom:1px solid var(--dsw-alias-border-l1);flex:none;gap:2px;display:flex}.mL8Uca_tab{color:var(--dsw-alias-label-secondary);cursor:pointer;white-space:nowrap;background:0 0;border:none;border-bottom:2px solid #0000;border-radius:6px 6px 0 0;padding:7px 14px;font-size:13px}.mL8Uca_tab:hover{color:var(--dsw-alias-label-primary);background:var(--dsw-alias-interactive-bg-hover)}.mL8Uca_tab[data-active]{color:var(--dsw-alias-label-primary);border-bottom-color:var(--dsw-alias-state-business-primary);font-weight:600}.mL8Uca_panelContent{flex-direction:column;flex:1;min-height:0;display:flex;overflow:hidden}.mL8Uca_tabBody{flex-direction:column;flex:1;gap:10px;min-height:0;display:flex;overflow-y:auto}.mL8Uca_fillBody{flex-direction:column;flex:1;gap:10px;min-height:0;display:flex;overflow:hidden}.mL8Uca_toolbar,.mL8Uca_controls{flex-wrap:wrap;flex:none;align-items:center;gap:8px;display:flex}.mL8Uca_controls .mL8Uca_input{flex:0 260px;min-width:150px}.mL8Uca_toolbarSpacer{flex:1}.mL8Uca_search{min-width:120px;color:var(--dsw-alias-label-primary);background:var(--dsw-specific-input-major);border:1px solid var(--dsw-alias-border-l2);border-radius:8px;outline:none;flex:0 260px;padding:6px 10px;font-size:13px}.mL8Uca_search::placeholder{color:var(--dsw-alias-label-tertiary)}.mL8Uca_tableWrap{border:1px solid var(--dsw-alias-border-l1);border-radius:10px;flex:1;min-height:0;overflow:auto}.mL8Uca_table{border-collapse:collapse;width:100%;font-size:12.5px}.mL8Uca_table th{z-index:1;text-align:left;background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-secondary);border-bottom:1px solid var(--dsw-alias-border-l1);white-space:nowrap;padding:8px 10px;font-weight:600;position:sticky;top:0}.mL8Uca_table td{border-bottom:1px solid var(--dsw-alias-separator-primary);vertical-align:top;padding:7px 10px}.mL8Uca_table tbody tr:last-child td{border-bottom:none}.mL8Uca_table tbody tr:hover td{background:var(--dsw-alias-interactive-bg-hover)}.mL8Uca_mono{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}.mL8Uca_cellMuted{color:var(--dsw-alias-label-tertiary)}.mL8Uca_actions{white-space:nowrap;align-items:center;gap:8px;display:flex}.mL8Uca_inlineTest{color:var(--dsw-alias-label-secondary);align-items:center;gap:6px;font-size:11.5px;display:inline-flex}.mL8Uca_inlineTest[data-status=ok]{color:var(--dsw-alias-state-success-primary)}.mL8Uca_inlineTest[data-status=fail]{color:var(--dsw-alias-state-error-primary)}.mL8Uca_badge{border:1px solid var(--dsw-alias-border-l2);color:var(--dsw-alias-label-secondary);white-space:nowrap;border-radius:999px;padding:1px 8px;font-size:11px;line-height:1.6;display:inline-block}.mL8Uca_badge[data-kind=key]{color:var(--dsw-alias-state-business-primary);border-color:var(--dsw-alias-state-business-primary)}.mL8Uca_badge[data-kind=password]{color:var(--dsw-alias-state-warn-primary);border-color:var(--dsw-alias-state-warn-primary)}.mL8Uca_badge[data-status=ok]{color:var(--dsw-alias-state-success-primary);border-color:var(--dsw-alias-state-success-primary)}.mL8Uca_badge[data-status=fail]{color:var(--dsw-alias-state-error-primary);border-color:var(--dsw-alias-state-error-primary)}.mL8Uca_badge[data-status=timeout]{color:var(--dsw-alias-state-warn-primary);border-color:var(--dsw-alias-state-warn-primary)}.mL8Uca_primaryButton{color:var(--dsw-alias-label-primary-foreground);background:var(--dsw-alias-button-info-fill);cursor:pointer;white-space:nowrap;border:none;border-radius:8px;padding:6px 14px;font-size:13px;font-weight:600}.mL8Uca_primaryButton:hover:not(:disabled){background:var(--dsw-alias-button-info-hover)}.mL8Uca_primaryButton:disabled{opacity:.5;cursor:default}.mL8Uca_ghostButton{color:var(--dsw-alias-label-primary);border:1px solid var(--dsw-alias-border-l2);cursor:pointer;white-space:nowrap;background:0 0;border-radius:8px;padding:5px 12px;font-size:12px}.mL8Uca_ghostButton:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover)}.mL8Uca_ghostButton:disabled{opacity:.45;cursor:default}.mL8Uca_iconButton{width:26px;height:26px;color:var(--dsw-alias-label-secondary);cursor:pointer;background:0 0;border:none;border-radius:6px;justify-content:center;align-items:center;padding:0;font-size:13px;display:inline-flex}.mL8Uca_iconButton:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}.mL8Uca_linkButton{color:var(--dsw-alias-state-business-primary);cursor:pointer;white-space:nowrap;background:0 0;border:none;padding:0;font-size:12px}.mL8Uca_linkButton:hover:not(:disabled){text-decoration:underline}.mL8Uca_linkButton:disabled{opacity:.45;cursor:default}.mL8Uca_linkButton[data-danger]{color:var(--dsw-alias-state-error-primary)}.mL8Uca_spinner{border:2px solid var(--dsw-alias-state-business-primary);vertical-align:-1px;border-top-color:#0000;border-radius:50%;flex:none;width:11px;height:11px;animation:.8s linear infinite mL8Uca_dshSshSpin;display:inline-block}@keyframes mL8Uca_dshSshSpin{to{transform:rotate(360deg)}}.mL8Uca_banner{border:1px solid var(--dsw-alias-border-l2);color:var(--dsw-alias-label-secondary);overflow-wrap:anywhere;border-radius:8px;padding:8px 12px;font-size:12.5px;line-height:1.5}.mL8Uca_banner[data-kind=ok]{color:var(--dsw-alias-state-success-primary);border-color:var(--dsw-alias-state-success-primary)}.mL8Uca_banner[data-kind=error]{color:var(--dsw-alias-state-error-primary);border-color:var(--dsw-alias-state-error-primary)}.mL8Uca_banner[data-kind=info]{color:var(--dsw-alias-state-business-primary);border-color:var(--dsw-alias-state-business-primary)}.mL8Uca_empty,.mL8Uca_loading{text-align:center;color:var(--dsw-alias-label-tertiary);padding:28px 12px;font-size:12.5px}.mL8Uca_modalBackdrop{z-index:40;background:var(--dsw-alias-bg-mask-1);justify-content:center;align-items:center;display:flex;position:fixed;inset:0}.mL8Uca_modal{background:var(--dsw-alias-bg-base);border:1px solid var(--dsw-alias-border-l2);width:min(560px,100vw - 48px);max-height:calc(100vh - 96px);box-shadow:var(--dsw-shadow-lv3);color:var(--dsw-alias-label-primary);border-radius:14px;flex-direction:column;gap:12px;padding:18px;display:flex;overflow-y:auto}.mL8Uca_modalTitle{margin:0;font-size:15px;font-weight:700}.mL8Uca_modalFooter{justify-content:flex-end;gap:10px;margin-top:4px;display:flex}.mL8Uca_formError{color:var(--dsw-alias-state-error-primary);margin:0;font-size:12px}.mL8Uca_hint{color:var(--dsw-alias-label-tertiary);font-size:11.5px}.mL8Uca_field{flex-direction:column;gap:5px;display:flex}.mL8Uca_fieldLabel{color:var(--dsw-alias-label-secondary);font-size:12px;font-weight:600}.mL8Uca_input{color:var(--dsw-alias-label-primary);background:var(--dsw-specific-input-major);border:1px solid var(--dsw-alias-border-l2);resize:vertical;border-radius:8px;outline:none;padding:7px 10px;font-family:inherit;font-size:13px}.mL8Uca_input:focus{border-color:var(--dsw-alias-state-business-primary)}.mL8Uca_input::placeholder{color:var(--dsw-alias-label-tertiary)}.mL8Uca_input:disabled{opacity:.55}.mL8Uca_formRow{grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:10px 12px;display:grid}.mL8Uca_radioRow{align-items:center;gap:16px;display:flex}.mL8Uca_radioLabel{color:var(--dsw-alias-label-primary);cursor:pointer;align-items:center;gap:6px;font-size:13px;display:inline-flex}.mL8Uca_termBody{flex-direction:column;flex:1;gap:8px;min-height:0;display:flex;overflow:hidden}.mL8Uca_termWrap{border:1px solid var(--dsw-alias-border-l1);background:#0b0e14;border-radius:10px;flex:1;min-height:0;position:relative;overflow:hidden}.mL8Uca_termContainer{padding:8px 10px;position:absolute;inset:0}.mL8Uca_termPlaceholder{z-index:2;text-align:center;color:var(--dsw-alias-label-tertiary);background:var(--dsw-alias-bg-base);justify-content:center;align-items:center;padding:0 24px;font-size:12.5px;display:flex;position:absolute;inset:0}.mL8Uca_hiddenFile{display:none}.mL8Uca_browsePanel{border:1px solid var(--dsw-alias-border-l1);border-radius:10px;flex-direction:column;flex:none;display:flex;overflow:hidden}.mL8Uca_browseHeader{background:var(--dsw-alias-bg-layer-2);border-bottom:1px solid var(--dsw-alias-border-l1);align-items:center;gap:8px;padding:8px 10px;display:flex}.mL8Uca_browsePath{text-overflow:ellipsis;white-space:nowrap;min-width:0;color:var(--dsw-alias-label-secondary);flex:1;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12px;overflow:hidden}.mL8Uca_browseList{max-height:240px;overflow-y:auto}.mL8Uca_dirRow{text-align:left;width:100%;font:inherit;color:var(--dsw-alias-label-primary);border:none;border-bottom:1px solid var(--dsw-alias-separator-primary);cursor:pointer;background:0 0;grid-template-columns:minmax(0,1fr) auto auto;gap:12px;padding:5px 10px;font-size:12.5px;display:grid}.mL8Uca_dirRow:hover{background:var(--dsw-alias-interactive-bg-hover)}.mL8Uca_dirRow[data-type=dir] .mL8Uca_dirName{color:var(--dsw-alias-state-business-primary)}.mL8Uca_dirRow[data-up]{color:var(--dsw-alias-label-secondary)}.mL8Uca_dirName{text-overflow:ellipsis;white-space:nowrap;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;overflow:hidden}.mL8Uca_dirType{color:var(--dsw-alias-label-tertiary);white-space:nowrap}.mL8Uca_dirSize{color:var(--dsw-alias-label-tertiary);white-space:nowrap;text-align:right}.mL8Uca_transferBlock{flex-direction:column;flex:none;gap:6px;display:flex}.mL8Uca_progressMeta{color:var(--dsw-alias-label-secondary);flex-wrap:wrap;gap:12px;font-size:12px;display:flex}.mL8Uca_progressTrack{background:var(--dsw-alias-interactive-bg-hover);border-radius:999px;height:8px;overflow:hidden}.mL8Uca_progressBar{background:var(--dsw-alias-state-business-primary);border-radius:999px;height:100%;transition:width .12s linear}.mL8Uca_tunnelList{flex-direction:column;flex:none;gap:8px;display:flex}.mL8Uca_tunnelRow{background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l2);border-radius:10px;align-items:center;gap:10px;padding:8px 12px;display:flex}.mL8Uca_tunnelRow[data-state=forwarding]{border-color:var(--dsw-alias-state-success-primary)}.mL8Uca_tunnelRow[data-state=connecting]{border-color:var(--dsw-alias-state-warn-primary)}.mL8Uca_tunnelRow[data-state=failed]{border-color:var(--dsw-alias-state-error-primary)}.mL8Uca_tunnelLabel{text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12.5px;overflow:hidden}.mL8Uca_formCard{background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l1);border-radius:10px;flex-direction:column;flex:none;gap:10px;padding:12px;display:flex}.mL8Uca_clusterForm{flex-direction:column;flex:none;gap:8px;display:flex}.mL8Uca_clusterFilters{grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:10px;display:grid}.mL8Uca_commandInput{min-height:96px;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}.mL8Uca_cellDetails summary{cursor:pointer;color:var(--dsw-alias-label-secondary);font-size:12px}.mL8Uca_cellPre{background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l2);white-space:pre-wrap;word-break:break-all;border-radius:6px;max-height:180px;margin:4px 0 0;padding:6px 8px;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:11.5px;overflow:auto}";
614
+ const css = "[data-pane=conversation],[class*=centerCol]{position:relative}[data-dsh-ssh-view]{z-index:60;background:var(--dsw-alias-bg-base);display:none;position:absolute;inset:0}html[data-dsh-ssh-active]:not([data-dsh-taskboard-active]) [data-dsh-ssh-view]{display:block}html[data-dsh-ssh-active]:not([data-dsh-taskboard-active]) [data-pane=conversation]>:not([data-dsh-ssh-view]),html[data-dsh-ssh-active]:not([data-dsh-taskboard-active]) [class*=centerCol]>:not([data-dsh-ssh-view]){display:none!important}.mL8Uca_entry{width:100%;height:32px;color:var(--dsw-alias-label-secondary);cursor:pointer;white-space:nowrap;background:0 0;border:none;border-radius:8px;align-items:center;gap:8px;padding:0 12px;font-size:13px;display:flex}.mL8Uca_entry:hover{background:var(--dsw-specific-sidebar-nav-item-hover);color:var(--dsw-alias-label-primary)}.mL8Uca_entry[data-active]{background:var(--dsw-specific-sidebar-nav-item-active);color:var(--dsw-alias-label-primary);font-weight:600}.mL8Uca_entryIcon{flex:none;justify-content:center;align-items:center;display:inline-flex}.mL8Uca_entryLabel{text-overflow:ellipsis;overflow:hidden}[data-dsh-frame][data-sidebar-collapsed] .mL8Uca_entry{justify-content:center;width:100%;padding:0}[data-dsh-frame][data-sidebar-collapsed] .mL8Uca_entryLabel{display:none}.mL8Uca_view{overflow:hidden}.mL8Uca_panel{background:var(--dsw-alias-bg-base);min-width:0;height:100%;min-height:0;color:var(--dsw-alias-label-primary);font-family:var(--dsw-font-family);flex-direction:column;gap:10px;padding:14px 16px 16px;display:flex}.mL8Uca_panelHeader{flex:none;align-items:center;gap:10px;display:flex}.mL8Uca_panelTitle{color:var(--dsw-alias-label-primary);white-space:nowrap;flex:1;margin:0;font-size:16px;font-weight:700}.mL8Uca_backButton{align-items:center;gap:4px;display:inline-flex}.mL8Uca_tabBar{border-bottom:1px solid var(--dsw-alias-border-l1);flex:none;gap:2px;display:flex}.mL8Uca_tab{color:var(--dsw-alias-label-secondary);cursor:pointer;white-space:nowrap;background:0 0;border:none;border-bottom:2px solid #0000;border-radius:6px 6px 0 0;padding:7px 14px;font-size:13px}.mL8Uca_tab:hover{color:var(--dsw-alias-label-primary);background:var(--dsw-alias-interactive-bg-hover)}.mL8Uca_tab[data-active]{color:var(--dsw-alias-label-primary);border-bottom-color:var(--dsw-alias-state-business-primary);font-weight:600}.mL8Uca_panelContent{flex-direction:column;flex:1;min-height:0;display:flex;overflow:hidden}.mL8Uca_tabBody{flex-direction:column;flex:1;gap:10px;min-height:0;display:flex;overflow-y:auto}.mL8Uca_fillBody{flex-direction:column;flex:1;gap:10px;min-height:0;display:flex;overflow:hidden}.mL8Uca_toolbar,.mL8Uca_controls{flex-wrap:wrap;flex:none;align-items:center;gap:8px;display:flex}.mL8Uca_controls .mL8Uca_input{flex:0 260px;min-width:150px}.mL8Uca_toolbarSpacer{flex:1}.mL8Uca_search{min-width:120px;color:var(--dsw-alias-label-primary);background:var(--dsw-specific-input-major);border:1px solid var(--dsw-alias-border-l2);border-radius:8px;outline:none;flex:0 260px;padding:6px 10px;font-size:13px}.mL8Uca_search::placeholder{color:var(--dsw-alias-label-tertiary)}.mL8Uca_groupBySelect{color:var(--dsw-alias-label-primary);background:var(--dsw-specific-input-major);border:1px solid var(--dsw-alias-border-l2);border-radius:8px;outline:none;padding:6px 8px;font-size:13px}.mL8Uca_groupSection+.mL8Uca_groupSection{border-top:1px solid var(--dsw-alias-border-l1)}.mL8Uca_groupHeader{background:var(--dsw-alias-bg-base,var(--dsw-specific-input-major));align-items:center;gap:8px;padding:4px 8px;display:flex;position:sticky;top:0}.mL8Uca_groupToggle{min-width:0;color:var(--dsw-alias-label-primary);cursor:pointer;text-align:left;background:0 0;border:none;border-radius:6px;flex:1;align-items:center;gap:6px;padding:4px 6px;font-size:12.5px;font-weight:500;display:flex}.mL8Uca_groupToggle:hover{background:var(--dsw-alias-interactive-bg-hover)}.mL8Uca_groupChevron{border-right:1.5px solid var(--dsw-alias-label-tertiary);border-bottom:1.5px solid var(--dsw-alias-label-tertiary);flex:none;width:7px;height:7px;margin-bottom:2px;transition:transform .12s;transform:rotate(45deg)}.mL8Uca_groupChevron[data-collapsed]{margin-bottom:0;transform:rotate(-45deg)}.mL8Uca_groupName{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.mL8Uca_groupCount{color:var(--dsw-alias-label-tertiary);flex:none;font-weight:400}.mL8Uca_tableWrap{border:1px solid var(--dsw-alias-border-l1);border-radius:10px;flex:1;min-height:0;overflow:auto}.mL8Uca_table{border-collapse:collapse;width:100%;font-size:12.5px}.mL8Uca_table th{z-index:1;text-align:left;background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-secondary);border-bottom:1px solid var(--dsw-alias-border-l1);white-space:nowrap;padding:8px 10px;font-weight:600;position:sticky;top:0}.mL8Uca_table td{border-bottom:1px solid var(--dsw-alias-separator-primary);vertical-align:top;padding:7px 10px}.mL8Uca_table tbody tr:last-child td{border-bottom:none}.mL8Uca_table tbody tr:hover td{background:var(--dsw-alias-interactive-bg-hover)}.mL8Uca_mono{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}.mL8Uca_cellMuted{color:var(--dsw-alias-label-tertiary)}.mL8Uca_actions{white-space:nowrap;align-items:center;gap:8px;display:flex}.mL8Uca_inlineTest{color:var(--dsw-alias-label-secondary);align-items:center;gap:6px;font-size:11.5px;display:inline-flex}.mL8Uca_inlineTest[data-status=ok]{color:var(--dsw-alias-state-success-primary)}.mL8Uca_inlineTest[data-status=fail]{color:var(--dsw-alias-state-error-primary)}.mL8Uca_badge{border:1px solid var(--dsw-alias-border-l2);color:var(--dsw-alias-label-secondary);white-space:nowrap;border-radius:999px;padding:1px 8px;font-size:11px;line-height:1.6;display:inline-block}.mL8Uca_badge[data-kind=key]{color:var(--dsw-alias-state-business-primary);border-color:var(--dsw-alias-state-business-primary)}.mL8Uca_badge[data-kind=password]{color:var(--dsw-alias-state-warn-primary);border-color:var(--dsw-alias-state-warn-primary)}.mL8Uca_badge[data-status=ok]{color:var(--dsw-alias-state-success-primary);border-color:var(--dsw-alias-state-success-primary)}.mL8Uca_badge[data-status=fail]{color:var(--dsw-alias-state-error-primary);border-color:var(--dsw-alias-state-error-primary)}.mL8Uca_badge[data-status=timeout]{color:var(--dsw-alias-state-warn-primary);border-color:var(--dsw-alias-state-warn-primary)}.mL8Uca_primaryButton{color:var(--dsw-alias-label-primary-foreground);background:var(--dsw-alias-button-info-fill);cursor:pointer;white-space:nowrap;border:none;border-radius:8px;padding:6px 14px;font-size:13px;font-weight:600}.mL8Uca_primaryButton:hover:not(:disabled){background:var(--dsw-alias-button-info-hover)}.mL8Uca_primaryButton:disabled{opacity:.5;cursor:default}.mL8Uca_ghostButton{color:var(--dsw-alias-label-primary);border:1px solid var(--dsw-alias-border-l2);cursor:pointer;white-space:nowrap;background:0 0;border-radius:8px;padding:5px 12px;font-size:12px}.mL8Uca_ghostButton:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover)}.mL8Uca_ghostButton:disabled{opacity:.45;cursor:default}.mL8Uca_iconButton{width:26px;height:26px;color:var(--dsw-alias-label-secondary);cursor:pointer;background:0 0;border:none;border-radius:6px;justify-content:center;align-items:center;padding:0;font-size:13px;display:inline-flex}.mL8Uca_iconButton:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}.mL8Uca_linkButton{color:var(--dsw-alias-state-business-primary);cursor:pointer;white-space:nowrap;background:0 0;border:none;padding:0;font-size:12px}.mL8Uca_linkButton:hover:not(:disabled){text-decoration:underline}.mL8Uca_linkButton:disabled{opacity:.45;cursor:default}.mL8Uca_linkButton[data-danger]{color:var(--dsw-alias-state-error-primary)}.mL8Uca_spinner{border:2px solid var(--dsw-alias-state-business-primary);vertical-align:-1px;border-top-color:#0000;border-radius:50%;flex:none;width:11px;height:11px;animation:.8s linear infinite mL8Uca_dshSshSpin;display:inline-block}@keyframes mL8Uca_dshSshSpin{to{transform:rotate(360deg)}}.mL8Uca_banner{border:1px solid var(--dsw-alias-border-l2);color:var(--dsw-alias-label-secondary);overflow-wrap:anywhere;border-radius:8px;padding:8px 12px;font-size:12.5px;line-height:1.5}.mL8Uca_banner[data-kind=ok]{color:var(--dsw-alias-state-success-primary);border-color:var(--dsw-alias-state-success-primary)}.mL8Uca_banner[data-kind=error]{color:var(--dsw-alias-state-error-primary);border-color:var(--dsw-alias-state-error-primary)}.mL8Uca_banner[data-kind=info]{color:var(--dsw-alias-state-business-primary);border-color:var(--dsw-alias-state-business-primary)}.mL8Uca_empty,.mL8Uca_loading{text-align:center;color:var(--dsw-alias-label-tertiary);padding:28px 12px;font-size:12.5px}.mL8Uca_modalBackdrop{z-index:40;background:var(--dsw-alias-bg-mask-1);justify-content:center;align-items:center;display:flex;position:fixed;inset:0}.mL8Uca_modal{background:var(--dsw-alias-bg-base);border:1px solid var(--dsw-alias-border-l2);width:min(560px,100vw - 48px);max-height:calc(100vh - 96px);box-shadow:var(--dsw-shadow-lv3);color:var(--dsw-alias-label-primary);border-radius:14px;flex-direction:column;gap:12px;padding:18px;display:flex;overflow-y:auto}.mL8Uca_modalTitle{margin:0;font-size:15px;font-weight:700}.mL8Uca_modalFooter{justify-content:flex-end;gap:10px;margin-top:4px;display:flex}.mL8Uca_formError{color:var(--dsw-alias-state-error-primary);margin:0;font-size:12px}.mL8Uca_hint{color:var(--dsw-alias-label-tertiary);font-size:11.5px}.mL8Uca_field{flex-direction:column;gap:5px;display:flex}.mL8Uca_fieldLabel{color:var(--dsw-alias-label-secondary);font-size:12px;font-weight:600}.mL8Uca_input{color:var(--dsw-alias-label-primary);background:var(--dsw-specific-input-major);border:1px solid var(--dsw-alias-border-l2);resize:vertical;border-radius:8px;outline:none;padding:7px 10px;font-family:inherit;font-size:13px}.mL8Uca_input:focus{border-color:var(--dsw-alias-state-business-primary)}.mL8Uca_input::placeholder{color:var(--dsw-alias-label-tertiary)}.mL8Uca_input:disabled{opacity:.55}.mL8Uca_formRow{grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:10px 12px;display:grid}.mL8Uca_radioRow{align-items:center;gap:16px;display:flex}.mL8Uca_radioLabel{color:var(--dsw-alias-label-primary);cursor:pointer;align-items:center;gap:6px;font-size:13px;display:inline-flex}.mL8Uca_termBody{flex-direction:column;flex:1;gap:8px;min-height:0;display:flex;overflow:hidden}.mL8Uca_termWrap{border:1px solid var(--dsw-alias-border-l1);background:#0b0e14;border-radius:10px;flex:1;min-height:0;position:relative;overflow:hidden}.mL8Uca_termContainer{padding:8px 10px;position:absolute;inset:0}.mL8Uca_termPlaceholder{z-index:2;text-align:center;color:var(--dsw-alias-label-tertiary);background:var(--dsw-alias-bg-base);justify-content:center;align-items:center;padding:0 24px;font-size:12.5px;display:flex;position:absolute;inset:0}.mL8Uca_hiddenFile{display:none}.mL8Uca_browsePanel{border:1px solid var(--dsw-alias-border-l1);border-radius:10px;flex-direction:column;flex:none;display:flex;overflow:hidden}.mL8Uca_browseHeader{background:var(--dsw-alias-bg-layer-2);border-bottom:1px solid var(--dsw-alias-border-l1);align-items:center;gap:8px;padding:8px 10px;display:flex}.mL8Uca_browsePath{text-overflow:ellipsis;white-space:nowrap;min-width:0;color:var(--dsw-alias-label-secondary);flex:1;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12px;overflow:hidden}.mL8Uca_browseList{max-height:240px;overflow-y:auto}.mL8Uca_dirRow{text-align:left;width:100%;font:inherit;color:var(--dsw-alias-label-primary);border:none;border-bottom:1px solid var(--dsw-alias-separator-primary);cursor:pointer;background:0 0;grid-template-columns:minmax(0,1fr) auto auto;gap:12px;padding:5px 10px;font-size:12.5px;display:grid}.mL8Uca_dirRow:hover{background:var(--dsw-alias-interactive-bg-hover)}.mL8Uca_dirRow[data-type=dir] .mL8Uca_dirName{color:var(--dsw-alias-state-business-primary)}.mL8Uca_dirRow[data-up]{color:var(--dsw-alias-label-secondary)}.mL8Uca_dirName{text-overflow:ellipsis;white-space:nowrap;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;overflow:hidden}.mL8Uca_dirType{color:var(--dsw-alias-label-tertiary);white-space:nowrap}.mL8Uca_dirSize{color:var(--dsw-alias-label-tertiary);white-space:nowrap;text-align:right}.mL8Uca_transferBlock{flex-direction:column;flex:none;gap:6px;display:flex}.mL8Uca_progressMeta{color:var(--dsw-alias-label-secondary);flex-wrap:wrap;gap:12px;font-size:12px;display:flex}.mL8Uca_progressTrack{background:var(--dsw-alias-interactive-bg-hover);border-radius:999px;height:8px;overflow:hidden}.mL8Uca_progressBar{background:var(--dsw-alias-state-business-primary);border-radius:999px;height:100%;transition:width .12s linear}.mL8Uca_tunnelList{flex-direction:column;flex:none;gap:8px;display:flex}.mL8Uca_tunnelRow{background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l2);border-radius:10px;align-items:center;gap:10px;padding:8px 12px;display:flex}.mL8Uca_tunnelRow[data-state=forwarding]{border-color:var(--dsw-alias-state-success-primary)}.mL8Uca_tunnelRow[data-state=connecting]{border-color:var(--dsw-alias-state-warn-primary)}.mL8Uca_tunnelRow[data-state=failed]{border-color:var(--dsw-alias-state-error-primary)}.mL8Uca_tunnelLabel{text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12.5px;overflow:hidden}.mL8Uca_formCard{background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l1);border-radius:10px;flex-direction:column;flex:none;gap:10px;padding:12px;display:flex}.mL8Uca_clusterForm{flex-direction:column;flex:none;gap:8px;display:flex}.mL8Uca_clusterFilters{grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:10px;display:grid}.mL8Uca_commandInput{min-height:96px;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}.mL8Uca_cellDetails summary{cursor:pointer;color:var(--dsw-alias-label-secondary);font-size:12px}.mL8Uca_cellPre{background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l2);white-space:pre-wrap;word-break:break-all;border-radius:6px;max-height:180px;margin:4px 0 0;padding:6px 8px;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:11.5px;overflow:auto}";
599
615
  const tagId = "@linxin666/dsh-ssh/panel.module.css";
600
616
  if (typeof document !== "undefined" && document.querySelector("style[data-plugin-css=" + JSON.stringify(tagId) + "]") === null) {
601
617
  const tag = document.createElement("style");
@@ -636,6 +652,13 @@ window.__ModuleLoader__.load({
636
652
  "formError": "mL8Uca_formError",
637
653
  "formRow": "mL8Uca_formRow",
638
654
  "ghostButton": "mL8Uca_ghostButton",
655
+ "groupBySelect": "mL8Uca_groupBySelect",
656
+ "groupChevron": "mL8Uca_groupChevron",
657
+ "groupCount": "mL8Uca_groupCount",
658
+ "groupHeader": "mL8Uca_groupHeader",
659
+ "groupName": "mL8Uca_groupName",
660
+ "groupSection": "mL8Uca_groupSection",
661
+ "groupToggle": "mL8Uca_groupToggle",
639
662
  "hiddenFile": "mL8Uca_hiddenFile",
640
663
  "hint": "mL8Uca_hint",
641
664
  "iconButton": "mL8Uca_iconButton",
@@ -1238,6 +1261,31 @@ window.__ModuleLoader__.load({
1238
1261
  * add/edit/delete/test actions, ~/.ssh/config import, and a connect action
1239
1262
  * that hands the alias to the terminal tab via onConnect.
1240
1263
  */
1264
+ /**
1265
+ * Bucket hosts into collapsible groups (#379). Grouping by tags places a
1266
+ * multi-tag host in every one of its tag groups (folder view); hosts without
1267
+ * the grouping key land in the '' bucket, which always sorts last. Groups
1268
+ * sort alphabetically; host order inside a group follows the API listing.
1269
+ */
1270
+ function groupHosts(hosts, groupBy) {
1271
+ if (groupBy === "none") return [{
1272
+ key: "",
1273
+ hosts
1274
+ }];
1275
+ const buckets = /* @__PURE__ */ new Map();
1276
+ const push = (key, host) => {
1277
+ const bucket = buckets.get(key);
1278
+ if (bucket === void 0) buckets.set(key, [host]);
1279
+ else bucket.push(host);
1280
+ };
1281
+ for (const host of hosts) if (groupBy === "environment") push(host.environment ?? "", host);
1282
+ else if (host.tags.length === 0) push("", host);
1283
+ else for (const tag of host.tags) push(tag, host);
1284
+ return [...buckets.entries()].sort(([a], [b]) => a === "" ? 1 : b === "" ? -1 : a.localeCompare(b)).map(([key, group]) => ({
1285
+ key,
1286
+ hosts: group
1287
+ }));
1288
+ }
1241
1289
  /** The hosts table plus its toolbar and dialogs. */
1242
1290
  function HostsTab({ api, onConnect }) {
1243
1291
  const [hosts, setHosts] = (0, react.useState)(null);
@@ -1248,6 +1296,9 @@ window.__ModuleLoader__.load({
1248
1296
  const [importing, setImporting] = (0, react.useState)(false);
1249
1297
  const [notice, setNotice] = (0, react.useState)(null);
1250
1298
  const [dialog, setDialog] = (0, react.useState)(null);
1299
+ const [groupBy, setGroupBy] = (0, react.useState)("none");
1300
+ const [collapsed, setCollapsed] = (0, react.useState)({});
1301
+ const [testingGroup, setTestingGroup] = (0, react.useState)(null);
1251
1302
  const seqRef = (0, react.useRef)(0);
1252
1303
  const load = (0, react.useCallback)(async (query) => {
1253
1304
  const seq = ++seqRef.current;
@@ -1302,6 +1353,14 @@ window.__ModuleLoader__.load({
1302
1353
  setError(errorMessage(cause));
1303
1354
  }
1304
1355
  };
1356
+ const testGroup = async (group) => {
1357
+ setTestingGroup(group.key);
1358
+ try {
1359
+ await Promise.all(group.hosts.map((host) => runTest(host.alias)));
1360
+ } finally {
1361
+ setTestingGroup(null);
1362
+ }
1363
+ };
1305
1364
  const importConfig = async () => {
1306
1365
  setImporting(true);
1307
1366
  try {
@@ -1318,6 +1377,106 @@ window.__ModuleLoader__.load({
1318
1377
  setImporting(false);
1319
1378
  }
1320
1379
  };
1380
+ const renderHostRow = (host) => {
1381
+ const test = testResults[host.alias];
1382
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("tr", { children: [
1383
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("td", {
1384
+ className: panel_module_css_default.mono,
1385
+ children: host.alias
1386
+ }),
1387
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("td", {
1388
+ className: panel_module_css_default.mono,
1389
+ children: [
1390
+ host.host,
1391
+ ":",
1392
+ host.port
1393
+ ]
1394
+ }),
1395
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("td", { children: host.user }),
1396
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("td", { children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1397
+ className: panel_module_css_default.badge,
1398
+ "data-kind": host.auth,
1399
+ children: host.auth === "key" ? tt("form.auth.key") : tt("form.auth.password")
1400
+ }) }),
1401
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("td", {
1402
+ className: panel_module_css_default.cellMuted,
1403
+ children: host.environment ?? ""
1404
+ }),
1405
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("td", {
1406
+ className: panel_module_css_default.cellMuted,
1407
+ children: host.tags.join(", ")
1408
+ }),
1409
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("td", {
1410
+ className: panel_module_css_default.cellMuted,
1411
+ children: host.description ?? ""
1412
+ }),
1413
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("td", { children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1414
+ className: panel_module_css_default.actions,
1415
+ children: [
1416
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1417
+ type: "button",
1418
+ className: panel_module_css_default.linkButton,
1419
+ disabled: testingAlias === host.alias,
1420
+ onClick: () => {
1421
+ runTest(host.alias);
1422
+ },
1423
+ children: testingAlias === host.alias ? tt("hosts.testing") : tt("hosts.test")
1424
+ }),
1425
+ testingAlias === host.alias && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1426
+ className: panel_module_css_default.spinner,
1427
+ "aria-hidden": "true"
1428
+ }),
1429
+ test !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1430
+ className: panel_module_css_default.inlineTest,
1431
+ "data-status": test.ok ? "ok" : "fail",
1432
+ children: test.ok ? tt("hosts.testOk", { latency: test.latencyMs ?? 0 }) : tt("hosts.testFail", { error: test.error ?? "" })
1433
+ }),
1434
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1435
+ type: "button",
1436
+ className: panel_module_css_default.linkButton,
1437
+ onClick: () => {
1438
+ setDialog({
1439
+ mode: "edit",
1440
+ host
1441
+ });
1442
+ },
1443
+ children: tt("hosts.edit")
1444
+ }),
1445
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1446
+ type: "button",
1447
+ className: panel_module_css_default.linkButton,
1448
+ "data-danger": true,
1449
+ onClick: () => {
1450
+ deleteHost(host.alias);
1451
+ },
1452
+ children: tt("hosts.delete")
1453
+ }),
1454
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1455
+ type: "button",
1456
+ className: panel_module_css_default.ghostButton,
1457
+ onClick: () => {
1458
+ onConnect(host.alias);
1459
+ },
1460
+ children: tt("hosts.connected")
1461
+ })
1462
+ ]
1463
+ }) })
1464
+ ] }, host.alias);
1465
+ };
1466
+ const renderHostTable = (rows) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("table", {
1467
+ className: panel_module_css_default.table,
1468
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("thead", { children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("tr", { children: [
1469
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("th", { children: tt("hosts.col.alias") }),
1470
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("th", { children: tt("hosts.col.host") }),
1471
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("th", { children: tt("hosts.col.user") }),
1472
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("th", { children: tt("hosts.col.auth") }),
1473
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("th", { children: tt("hosts.col.environment") }),
1474
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("th", { children: tt("hosts.col.tags") }),
1475
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("th", { children: tt("hosts.col.description") }),
1476
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("th", { children: tt("hosts.col.actions") })
1477
+ ] }) }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("tbody", { children: rows.map(renderHostRow) })]
1478
+ });
1479
+ const groups = hosts === null ? [] : groupHosts(hosts, groupBy);
1321
1480
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1322
1481
  className: panel_module_css_default.fillBody,
1323
1482
  children: [
@@ -1333,6 +1492,28 @@ window.__ModuleLoader__.load({
1333
1492
  setSearch(event.target.value);
1334
1493
  }
1335
1494
  }),
1495
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("select", {
1496
+ className: panel_module_css_default.groupBySelect,
1497
+ "aria-label": tt("hosts.groupBy.label"),
1498
+ value: groupBy,
1499
+ onChange: (event) => {
1500
+ setGroupBy(event.target.value);
1501
+ },
1502
+ children: [
1503
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
1504
+ value: "none",
1505
+ children: tt("hosts.groupBy.none")
1506
+ }),
1507
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
1508
+ value: "environment",
1509
+ children: tt("hosts.groupBy.environment")
1510
+ }),
1511
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
1512
+ value: "tags",
1513
+ children: tt("hosts.groupBy.tags")
1514
+ })
1515
+ ]
1516
+ }),
1336
1517
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", { className: panel_module_css_default.toolbarSpacer }),
1337
1518
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1338
1519
  type: "button",
@@ -1371,105 +1552,55 @@ window.__ModuleLoader__.load({
1371
1552
  className: panel_module_css_default.empty,
1372
1553
  children: tt("hosts.empty")
1373
1554
  }),
1374
- hosts !== null && hosts.length > 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1555
+ hosts !== null && hosts.length > 0 && groupBy === "none" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1375
1556
  className: panel_module_css_default.tableWrap,
1376
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("table", {
1377
- className: panel_module_css_default.table,
1378
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("thead", { children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("tr", { children: [
1379
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("th", { children: tt("hosts.col.alias") }),
1380
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("th", { children: tt("hosts.col.host") }),
1381
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("th", { children: tt("hosts.col.user") }),
1382
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("th", { children: tt("hosts.col.auth") }),
1383
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("th", { children: tt("hosts.col.environment") }),
1384
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("th", { children: tt("hosts.col.tags") }),
1385
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("th", { children: tt("hosts.col.description") }),
1386
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("th", { children: tt("hosts.col.actions") })
1387
- ] }) }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("tbody", { children: hosts.map((host) => {
1388
- const test = testResults[host.alias];
1389
- return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("tr", { children: [
1390
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("td", {
1391
- className: panel_module_css_default.mono,
1392
- children: host.alias
1393
- }),
1394
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("td", {
1395
- className: panel_module_css_default.mono,
1396
- children: [
1397
- host.host,
1398
- ":",
1399
- host.port
1400
- ]
1401
- }),
1402
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("td", { children: host.user }),
1403
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("td", { children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1404
- className: panel_module_css_default.badge,
1405
- "data-kind": host.auth,
1406
- children: host.auth === "key" ? tt("form.auth.key") : tt("form.auth.password")
1407
- }) }),
1408
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("td", {
1409
- className: panel_module_css_default.cellMuted,
1410
- children: host.environment ?? ""
1411
- }),
1412
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("td", {
1413
- className: panel_module_css_default.cellMuted,
1414
- children: host.tags.join(", ")
1415
- }),
1416
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("td", {
1417
- className: panel_module_css_default.cellMuted,
1418
- children: host.description ?? ""
1419
- }),
1420
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("td", { children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1421
- className: panel_module_css_default.actions,
1557
+ children: renderHostTable(hosts)
1558
+ }),
1559
+ hosts !== null && hosts.length > 0 && groupBy !== "none" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1560
+ className: panel_module_css_default.tableWrap,
1561
+ children: groups.map((group) => {
1562
+ const isCollapsed = collapsed[group.key] === true;
1563
+ const label = group.key === "" ? groupBy === "tags" ? tt("hosts.group.noTags") : tt("hosts.group.ungrouped") : group.key;
1564
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
1565
+ className: panel_module_css_default.groupSection,
1566
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1567
+ className: panel_module_css_default.groupHeader,
1568
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
1569
+ type: "button",
1570
+ className: panel_module_css_default.groupToggle,
1571
+ "aria-expanded": !isCollapsed,
1572
+ onClick: () => {
1573
+ setCollapsed((prev) => ({
1574
+ ...prev,
1575
+ [group.key]: !isCollapsed
1576
+ }));
1577
+ },
1422
1578
  children: [
1423
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1424
- type: "button",
1425
- className: panel_module_css_default.linkButton,
1426
- disabled: testingAlias === host.alias,
1427
- onClick: () => {
1428
- runTest(host.alias);
1429
- },
1430
- children: testingAlias === host.alias ? tt("hosts.testing") : tt("hosts.test")
1431
- }),
1432
- testingAlias === host.alias && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1433
- className: panel_module_css_default.spinner,
1579
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1580
+ className: panel_module_css_default.groupChevron,
1581
+ "data-collapsed": isCollapsed || void 0,
1434
1582
  "aria-hidden": "true"
1435
1583
  }),
1436
- test !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1437
- className: panel_module_css_default.inlineTest,
1438
- "data-status": test.ok ? "ok" : "fail",
1439
- children: test.ok ? tt("hosts.testOk", { latency: test.latencyMs ?? 0 }) : tt("hosts.testFail", { error: test.error ?? "" })
1584
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1585
+ className: panel_module_css_default.groupName,
1586
+ children: label
1440
1587
  }),
1441
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1442
- type: "button",
1443
- className: panel_module_css_default.linkButton,
1444
- onClick: () => {
1445
- setDialog({
1446
- mode: "edit",
1447
- host
1448
- });
1449
- },
1450
- children: tt("hosts.edit")
1451
- }),
1452
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1453
- type: "button",
1454
- className: panel_module_css_default.linkButton,
1455
- "data-danger": true,
1456
- onClick: () => {
1457
- deleteHost(host.alias);
1458
- },
1459
- children: tt("hosts.delete")
1460
- }),
1461
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1462
- type: "button",
1463
- className: panel_module_css_default.ghostButton,
1464
- onClick: () => {
1465
- onConnect(host.alias);
1466
- },
1467
- children: tt("hosts.connected")
1588
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1589
+ className: panel_module_css_default.groupCount,
1590
+ children: tt("hosts.group.count", { count: group.hosts.length })
1468
1591
  })
1469
1592
  ]
1470
- }) })
1471
- ] }, host.alias);
1472
- }) })]
1593
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1594
+ type: "button",
1595
+ className: panel_module_css_default.linkButton,
1596
+ disabled: testingGroup === group.key,
1597
+ onClick: () => {
1598
+ testGroup(group);
1599
+ },
1600
+ children: testingGroup === group.key ? tt("hosts.testing") : tt("hosts.group.testAll")
1601
+ })]
1602
+ }), !isCollapsed && renderHostTable(group.hosts)]
1603
+ }, group.key);
1473
1604
  })
1474
1605
  }),
1475
1606
  dialog !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(HostFormDialog, {