dsh-plugin-ssh-console 0.0.0-stage → 1.0.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/client.js ADDED
@@ -0,0 +1,1939 @@
1
+ window.__ModuleLoader__.load({
2
+ id: 'dsh-plugin-ssh-console',
3
+ factory(require) {
4
+ const React = require('react')
5
+ const z = require('zod')
6
+ const h = React.createElement
7
+
8
+ const PKG = 'dsh-plugin-ssh-console'
9
+ const LOCALE_NS = 'ssh-console'
10
+ const SERVICE_KEY = 'sshConsole'
11
+ /** Remembers the last picked target across reloads. */
12
+ const STORAGE_KEY = 'dsh.ssh-console.serverId'
13
+
14
+ /* ---------------------------------------------------------------- styles */
15
+
16
+ const CSS = `
17
+ .ssc-page{position:relative;box-sizing:border-box;display:flex;flex-direction:column;height:100%;min-height:0;background:var(--dsw-alias-bg-base);color:var(--dsw-alias-label-primary);font-size:13px;line-height:20px}
18
+ .ssc-head{flex:none;display:flex;align-items:center;gap:10px;padding:14px 20px 12px;border-bottom:.5px solid var(--dsw-alias-border-l1)}
19
+ .ssc-head-icon{flex:none;display:inline-flex;color:var(--dsw-alias-label-secondary)}
20
+ .ssc-title{margin:0;font-size:15px;line-height:22px;font-weight:600;letter-spacing:.01em}
21
+ .ssc-spacer{flex:1}
22
+ .ssc-pill{flex:none;display:inline-flex;align-items:center;gap:6px;height:24px;padding:0 10px;border-radius:999px;border:.5px solid var(--dsw-alias-border-l2);color:var(--dsw-alias-label-secondary);font-size:11.5px;white-space:nowrap}
23
+ .ssc-pill-on{border-color:var(--dsw-alias-state-success-primary);color:var(--dsw-alias-state-success-primary)}
24
+ .ssc-dot{display:inline-block;width:6px;height:6px;border-radius:50%;background:currentColor;flex:none}
25
+ .ssc-body{flex:1;min-height:0;overflow:auto;padding:16px 20px 28px}
26
+ .ssc-notice{flex:none;display:flex;align-items:flex-start;gap:8px;margin:14px 20px 0;padding:10px 12px;border-radius:8px;border:.5px solid var(--dsw-alias-state-error-primary);background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-state-error-primary);font-size:12px;line-height:18px;overflow-wrap:anywhere}
27
+ .ssc-notice-close{flex:none;margin-left:auto;padding:0 4px;border:0;background:transparent;color:inherit;font-size:14px;line-height:16px;cursor:pointer;opacity:.7}
28
+ .ssc-notice-close:hover{opacity:1}
29
+
30
+ /* connection card */
31
+ .ssc-connect{display:flex;flex-direction:column;gap:16px;max-width:560px;margin:0 auto;padding:20px;border:.5px solid var(--dsw-alias-border-l1);border-radius:12px;background:var(--dsw-alias-bg-layer-1)}
32
+ .ssc-connect-lead{display:flex;flex-direction:column;gap:4px}
33
+ .ssc-connect-title{margin:0;font-size:14px;line-height:20px;font-weight:600}
34
+ .ssc-connect-sub{margin:0;color:var(--dsw-alias-label-secondary);font-size:12px;line-height:18px}
35
+ .ssc-field{display:flex;flex-direction:column;gap:6px;min-width:0}
36
+ .ssc-field-label{color:var(--dsw-alias-label-secondary);font-size:11.5px;font-weight:500}
37
+ .ssc-field-hint{color:var(--dsw-alias-label-secondary);font-size:11px;line-height:16px}
38
+ .ssc-control{box-sizing:border-box;width:100%;height:34px;padding:0 10px;border:.5px solid var(--dsw-alias-border-l2);border-radius:8px;background:var(--dsw-alias-bg-base);color:var(--dsw-alias-label-primary);font-family:inherit;font-size:13px;outline:none}
39
+ .ssc-control:focus{border-color:var(--dsw-alias-brand-primary)}
40
+ .ssc-control:disabled{opacity:.55}
41
+ select.ssc-control{appearance:none;padding-right:26px;cursor:pointer;background-image:linear-gradient(45deg,transparent 50%,currentColor 50%),linear-gradient(135deg,currentColor 50%,transparent 50%);background-position:calc(100% - 15px) 15px,calc(100% - 10px) 15px;background-size:5px 5px,5px 5px;background-repeat:no-repeat}
42
+ .ssc-actions{display:flex;align-items:center;gap:8px}
43
+
44
+ /* buttons */
45
+ .ssc-btn{box-sizing:border-box;display:inline-flex;align-items:center;justify-content:center;gap:6px;height:32px;padding:0 14px;border:.5px solid var(--dsw-alias-border-l2);border-radius:8px;background:transparent;color:var(--dsw-alias-label-primary);font-family:inherit;font-size:13px;line-height:20px;cursor:pointer;white-space:nowrap}
46
+ .ssc-btn:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover)}
47
+ .ssc-btn:disabled{opacity:.45;cursor:not-allowed}
48
+ .ssc-btn-primary{border-color:transparent;background:var(--dsw-alias-brand-primary);color:var(--dsw-alias-bg-base);font-weight:500}
49
+ .ssc-btn-primary:hover:not(:disabled){opacity:.9;background:var(--dsw-alias-brand-primary)}
50
+ .ssc-btn-quiet{height:26px;padding:0 10px;font-size:12px}
51
+ .ssc-btn-icon{height:26px;width:26px;padding:0;font-size:15px;line-height:1}
52
+
53
+ /* connected server chips */
54
+ .ssc-strip{flex:none;display:flex;align-items:center;gap:8px;padding:10px 20px;border-bottom:.5px solid var(--dsw-alias-border-l1);background:var(--dsw-alias-bg-layer-1);overflow-x:auto}
55
+ .ssc-chips{display:flex;align-items:center;gap:6px;min-width:0}
56
+ .ssc-chip{display:inline-flex;align-items:center;gap:6px;height:28px;padding:0 10px;border:.5px solid var(--dsw-alias-border-l2);border-radius:999px;background:transparent;color:var(--dsw-alias-label-secondary);font-family:inherit;font-size:12px;white-space:nowrap;cursor:pointer;max-width:240px}
57
+ .ssc-chip:hover{background:var(--dsw-alias-interactive-bg-hover)}
58
+ .ssc-chip-on{border-color:var(--dsw-alias-brand-primary);color:var(--dsw-alias-label-primary);font-weight:500}
59
+ .ssc-chip-name{max-width:150px;overflow:hidden;text-overflow:ellipsis}
60
+ .ssc-chip-close{flex:none;display:inline-flex;align-items:center;justify-content:center;width:15px;height:15px;border:0;border-radius:3px;background:transparent;color:inherit;font-size:12px;line-height:1;cursor:pointer;opacity:.65}
61
+ .ssc-chip-close:hover{opacity:1;background:var(--dsw-alias-border-l1)}
62
+ .ssc-scrim{position:absolute;inset:0;z-index:40;display:flex;align-items:center;justify-content:center;padding:24px;background:var(--dsw-alias-bg-base);background:color-mix(in srgb,var(--dsw-alias-bg-base) 74%,transparent);backdrop-filter:blur(2px)}
63
+ .ssc-modal{box-sizing:border-box;display:flex;flex-direction:column;width:min(520px,100%);max-height:100%;overflow:auto;border:.5px solid var(--dsw-alias-border-l2);border-radius:12px;background:var(--dsw-alias-bg-base);box-shadow:0 12px 40px var(--dsw-alias-border-l2);box-shadow:0 12px 40px color-mix(in srgb,var(--dsw-alias-label-primary) 16%,transparent);outline:none}
64
+ .ssc-modal-head{flex:none;display:flex;align-items:center;gap:12px;padding:14px 16px 0}
65
+ .ssc-modal-title{margin:0;font-size:14px;line-height:20px;font-weight:600}
66
+ .ssc-modal-close{flex:none;margin-left:auto;display:inline-flex;align-items:center;justify-content:center;width:24px;height:24px;border:0;border-radius:6px;background:transparent;color:var(--dsw-alias-label-secondary);font-size:15px;line-height:1;cursor:pointer}
67
+ .ssc-modal-close:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}
68
+ .ssc-modal-body{padding:4px 16px 16px}
69
+ .ssc-modal .ssc-connect{max-width:none;padding:0;border:0;border-radius:0;background:transparent}
70
+
71
+ /* tabs */
72
+ .ssc-tabs{flex:none;display:flex;gap:2px;padding:12px 20px 0}
73
+ .ssc-tab{height:28px;padding:0 12px;border:0;border-radius:7px;background:transparent;color:var(--dsw-alias-label-secondary);font-family:inherit;font-size:12.5px;cursor:pointer}
74
+ .ssc-tab:hover{color:var(--dsw-alias-label-primary)}
75
+ .ssc-tab-on{background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);font-weight:500}
76
+
77
+ /* overview */
78
+ .ssc-cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:10px}
79
+ .ssc-card{display:flex;flex-direction:column;gap:6px;min-width:0;padding:12px 14px;border:.5px solid var(--dsw-alias-border-l1);border-radius:10px;background:var(--dsw-alias-bg-layer-1)}
80
+ .ssc-card-label{color:var(--dsw-alias-label-secondary);font-size:11px;letter-spacing:.04em;text-transform:uppercase}
81
+ .ssc-card-value{font-size:19px;line-height:25px;font-weight:600;font-variant-numeric:tabular-nums;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
82
+ .ssc-card-sub{color:var(--dsw-alias-label-secondary);font-size:11px;font-variant-numeric:tabular-nums;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
83
+ .ssc-bar{position:relative;height:4px;border-radius:2px;background:var(--dsw-alias-border-l1);overflow:hidden}
84
+ .ssc-bar>i{position:absolute;inset:0 auto 0 0;border-radius:2px;background:var(--dsw-alias-brand-primary);transition:width .25s ease}
85
+ .ssc-bar>i.ssc-warn{background:var(--dsw-alias-state-warn-primary)}
86
+ .ssc-bar>i.ssc-hot{background:var(--dsw-alias-state-error-primary)}
87
+ .ssc-bar>i.ssc-tone-mem{background:var(--dsw-alias-label-secondary)}
88
+ .ssc-host{margin:0 0 14px;padding:14px 16px;border:.5px solid var(--dsw-alias-border-l1);border-radius:12px;background:var(--dsw-alias-bg-base)}
89
+ .ssc-host-head{display:flex;align-items:baseline;gap:10px;margin-bottom:12px;flex-wrap:wrap}
90
+ .ssc-host-name{margin:0;font-size:13.5px;line-height:20px;font-weight:600}
91
+ .ssc-host-meta{color:var(--dsw-alias-label-secondary);font-size:11.5px;font-variant-numeric:tabular-nums}
92
+ .ssc-section{margin-top:14px}
93
+ .ssc-section-head{display:flex;align-items:baseline;gap:8px;margin:0 0 8px}
94
+ .ssc-section-title{margin:0;font-size:11.5px;font-weight:600;color:var(--dsw-alias-label-secondary);letter-spacing:.04em;text-transform:uppercase}
95
+ .ssc-section-count{color:var(--dsw-alias-label-secondary);font-size:11px;font-variant-numeric:tabular-nums}
96
+ .ssc-gpus{display:grid;grid-template-columns:repeat(auto-fill,minmax(230px,1fr));gap:8px}
97
+ .ssc-gpu{display:flex;flex-direction:column;gap:9px;min-width:0;padding:10px 12px;border:.5px solid var(--dsw-alias-border-l1);border-radius:9px;background:var(--dsw-alias-bg-layer-1)}
98
+ .ssc-gpu-head{display:flex;align-items:baseline;gap:8px;min-width:0}
99
+ .ssc-gpu-idx{flex:none;font-size:11px;font-weight:600;color:var(--dsw-alias-label-secondary);font-variant-numeric:tabular-nums}
100
+ .ssc-gpu-name{flex:1;min-width:0;font-size:12px;overflow-wrap:anywhere}
101
+ .ssc-gpu-num{flex:none;font-size:15px;line-height:20px;font-weight:600;font-variant-numeric:tabular-nums}
102
+ .ssc-gpu-metric{display:flex;flex-direction:column;gap:4px;min-width:0}
103
+ .ssc-gpu-row{display:flex;align-items:baseline;gap:8px;flex-wrap:wrap;color:var(--dsw-alias-label-secondary);font-size:11px;font-variant-numeric:tabular-nums}
104
+ .ssc-gpu-row-label{flex:none;min-width:24px;letter-spacing:.04em;text-transform:uppercase;font-size:9.5px}
105
+ .ssc-gpu-extra{margin-left:auto;font-size:10.5px}
106
+ .ssc-gpu-mem{min-width:0;font-size:10.5px;overflow-wrap:anywhere}
107
+ .ssc-gpu-metric>.ssc-gpu-row>.ssc-gpu-num{margin-left:auto}
108
+ .ssc-table{width:100%;border-collapse:collapse;font-size:12px;font-variant-numeric:tabular-nums}
109
+ .ssc-table th{padding:6px 8px;text-align:left;font-weight:500;color:var(--dsw-alias-label-secondary);font-size:11px;letter-spacing:.04em;text-transform:uppercase;border-bottom:.5px solid var(--dsw-alias-border-l1)}
110
+ .ssc-table td{padding:5px 8px;border-bottom:.5px solid var(--dsw-alias-border-l1);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
111
+ /* The command column shows the whole line. Keeping the tail and dropping the
112
+ head hides the interpreter and the script, which is what identifies a job. */
113
+ .ssc-table td.ssc-cmd{white-space:normal;overflow:visible;overflow-wrap:anywhere;word-break:break-word;min-width:22ch;max-width:70ch;line-height:16px}
114
+ .ssc-table th.ssc-cmd{min-width:22ch}
115
+ .ssc-table tbody tr:hover td{background:var(--dsw-alias-interactive-bg-hover)}
116
+ /* Group heading inside the GPU process table: one per card. */
117
+ .ssc-table .ssc-group-row:hover td{background:transparent}
118
+ .ssc-table th.ssc-group{position:sticky;top:0;padding:6px 8px;background:var(--dsw-alias-bg-layer-2);border-bottom:.5px solid var(--dsw-alias-border-l1);font-weight:600;font-size:11px;letter-spacing:0;text-transform:none;color:var(--dsw-alias-label-primary)}
119
+ .ssc-group-meta{margin-left:8px;font-weight:400;color:var(--dsw-alias-label-secondary);font-size:10.5px}
120
+ .ssc-num{text-align:right;font-variant-numeric:tabular-nums}
121
+ .ssc-mono{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:11.5px}
122
+
123
+ /* files */
124
+ .ssc-path{display:flex;align-items:center;gap:8px;margin-bottom:12px}
125
+ .ssc-path-text{flex:1;min-width:0;padding:6px 10px;border:.5px solid var(--dsw-alias-border-l1);border-radius:8px;background:var(--dsw-alias-bg-layer-1);font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
126
+ .ssc-entry{display:flex;align-items:center;gap:10px;width:100%;padding:6px 10px;border:0;border-radius:7px;background:transparent;color:var(--dsw-alias-label-primary);font-family:inherit;font-size:12.5px;text-align:left;cursor:pointer}
127
+ .ssc-entry:hover{background:var(--dsw-alias-interactive-bg-hover)}
128
+ .ssc-entry-icon{flex:none;display:inline-flex;width:14px;height:14px;color:var(--dsw-alias-label-secondary)}
129
+ .ssc-entry-name{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
130
+ .ssc-entry-size{flex:none;width:84px;text-align:right;color:var(--dsw-alias-label-secondary);font-size:11.5px;font-variant-numeric:tabular-nums}
131
+ .ssc-entry-time{flex:none;width:128px;text-align:right;color:var(--dsw-alias-label-secondary);font-size:11.5px;font-variant-numeric:tabular-nums}
132
+ .ssc-preview{margin-top:16px;border:.5px solid var(--dsw-alias-border-l1);border-radius:10px;background:var(--dsw-alias-bg-layer-1);overflow:hidden}
133
+ .ssc-preview-head{display:flex;align-items:center;gap:8px;padding:8px 12px;border-bottom:.5px solid var(--dsw-alias-border-l1)}
134
+ .ssc-preview-title{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:11.5px}
135
+ .ssc-preview-body{margin:0;padding:10px 12px;max-height:320px;overflow:auto;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:11.5px;line-height:17px;white-space:pre-wrap;overflow-wrap:anywhere}
136
+
137
+ /* terminal */
138
+ .ssc-body-term{display:flex;flex-direction:column;overflow:hidden;padding:12px 20px 18px}
139
+ .ssc-term{display:flex;flex-direction:column;gap:8px;flex:1;height:100%;min-height:0}
140
+ .ssc-term-bar{flex:none;display:flex;align-items:center;gap:6px}
141
+ .ssc-term-title{font-size:12.5px;font-weight:600}
142
+ .ssc-term-who{color:var(--dsw-alias-label-secondary);font-size:11.5px}
143
+ .ssc-btn-on{border-color:var(--dsw-alias-brand-primary);color:var(--dsw-alias-brand-primary)}
144
+ /* One strip entry per open terminal; the slot badge says which pane holds it. */
145
+ .ssc-term-strip{flex:none;display:flex;align-items:center;gap:6px;flex-wrap:wrap}
146
+ .ssc-tchip{display:inline-flex;align-items:center;gap:6px;height:24px;padding:0 6px 0 10px;border:.5px solid var(--dsw-alias-border-l2);border-radius:999px;color:var(--dsw-alias-label-secondary);font-size:11.5px;cursor:pointer;white-space:nowrap}
147
+ .ssc-tchip:hover{background:var(--dsw-alias-interactive-bg-hover)}
148
+ .ssc-tchip-on{border-color:var(--dsw-alias-border-l1);color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-layer-2)}
149
+ .ssc-tchip-focus{border-color:var(--dsw-alias-brand-primary)}
150
+ .ssc-tchip-slot{color:var(--dsw-alias-brand-primary);font-size:10.5px}
151
+ .ssc-tchip-close{display:inline-flex;align-items:center;justify-content:center;width:14px;height:14px;border:0;border-radius:3px;background:transparent;color:inherit;font-size:12px;line-height:1;cursor:pointer;opacity:.6}
152
+ .ssc-tchip-close:hover{opacity:1;background:var(--dsw-alias-border-l1)}
153
+ /* Panes: one or two terminals side by side or stacked. */
154
+ .ssc-term-panes{display:flex;gap:8px;flex:1;min-height:0;min-width:0}
155
+ .ssc-term-row{flex-direction:row}
156
+ .ssc-term-column{flex-direction:column}
157
+ .ssc-pane{display:flex;flex-direction:column;flex:1 1 0;min-width:0;min-height:0}
158
+ .ssc-pane-body{display:flex;flex-direction:column;gap:6px;flex:1;min-height:0}
159
+ .ssc-pane-body>.ssc-term-shell{flex:1;min-height:0}
160
+ .ssc-pane-size{align-self:flex-end;color:var(--dsw-alias-label-secondary);font-size:10.5px;line-height:14px}
161
+ .ssc-pane-exit{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
162
+ .ssc-pane-focused>.ssc-pane-body>.ssc-term-shell{border-color:var(--dsw-alias-brand-primary)}
163
+ .ssc-pane-hint{flex:none;align-self:center;width:26px;height:26px;padding:0;border:.5px dashed var(--dsw-alias-border-l2);border-radius:50%;background:transparent;color:var(--dsw-alias-label-secondary);font-size:14px;line-height:1;cursor:pointer}
164
+ .ssc-pane-hint:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}
165
+ .ssc-term-empty{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:10px;flex:1;min-height:0}
166
+ .ssc-term-shell{position:relative;flex:1;min-height:0;border:.5px solid var(--dsw-alias-border-l1);border-radius:10px;background:var(--dsw-alias-bg-layer-1);overflow:hidden}
167
+ .ssc-term-host{position:absolute;inset:0;padding:8px}
168
+ .ssc-term-host .xterm{height:100%}
169
+ .ssc-term-host .xterm-viewport{background:transparent!important;scrollbar-width:thin}
170
+ .ssc-term-placeholder{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;padding:24px}
171
+ .ssc-table-wrap{overflow-x:auto;border:.5px solid var(--dsw-alias-border-l1);border-radius:10px}
172
+ /* The command column wraps, so the table itself needs no wide floor; this only
173
+ keeps the numeric columns from being squeezed on a narrow panel. */
174
+ .ssc-table-wide{min-width:520px}
175
+ .ssc-table-wide th:first-child,.ssc-table-wide td:first-child{padding-left:12px}
176
+ .ssc-table-wide th:last-child,.ssc-table-wide td:last-child{padding-right:12px}
177
+
178
+ /* empty + hints */
179
+ .ssc-empty{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:8px;min-height:220px;padding:24px;color:var(--dsw-alias-label-secondary);text-align:center}
180
+ .ssc-empty-title{margin:0;font-size:13px;font-weight:600;color:var(--dsw-alias-label-primary)}
181
+ .ssc-empty-body{margin:0;font-size:12px;line-height:18px;max-width:420px}
182
+ .ssc-hint{color:var(--dsw-alias-label-secondary);font-size:11.5px;line-height:17px}
183
+ .ssc-fail{color:var(--dsw-alias-state-error-primary)}
184
+ `
185
+
186
+ /**
187
+ * Insert this bundle's stylesheet once and hand back its removal.
188
+ * @returns the cleanup callback.
189
+ */
190
+ function insertStyles() {
191
+ if (typeof document === 'undefined') return () => {}
192
+ const tag = document.createElement('style')
193
+ tag.dataset.plugin = PKG
194
+ tag.textContent = CSS
195
+ document.head.appendChild(tag)
196
+ return () => {
197
+ tag.remove()
198
+ }
199
+ }
200
+
201
+ /* ----------------------------------------------------------------- icons */
202
+
203
+ /** Terminal glyph on the 16×16 grid the host's icon set uses. */
204
+ function ConsoleIcon({ size = 16 }) {
205
+ return h('svg', {
206
+ width: size, height: size, viewBox: '0 0 16 16', fill: 'none',
207
+ strokeWidth: 1.2, 'aria-hidden': true, focusable: 'false',
208
+ },
209
+ h('rect', { x: 1.5, y: 2.5, width: 13, height: 11, rx: 1.6, stroke: 'currentColor' }),
210
+ h('path', { d: 'M4.2 6.3L6.1 8L4.2 9.7', stroke: 'currentColor', strokeLinecap: 'round', strokeLinejoin: 'round' }),
211
+ h('path', { d: 'M8.2 10H11.4', stroke: 'currentColor', strokeLinecap: 'round' }))
212
+ }
213
+
214
+ /** Folder glyph. */
215
+ function FolderIcon() {
216
+ return h('svg', { width: 14, height: 14, viewBox: '0 0 16 16', fill: 'none', strokeWidth: 1.1, 'aria-hidden': true },
217
+ h('path', {
218
+ d: 'M1.8 4.1C1.8 3.6 2.2 3.2 2.7 3.2H6L7.4 4.8H13.3C13.8 4.8 14.2 5.2 14.2 5.7V11.9C14.2 12.4 13.8 12.8 13.3 12.8H2.7C2.2 12.8 1.8 12.4 1.8 11.9V4.1Z',
219
+ stroke: 'currentColor',
220
+ }))
221
+ }
222
+
223
+ /** Document glyph. */
224
+ function FileIcon() {
225
+ return h('svg', { width: 14, height: 14, viewBox: '0 0 16 16', fill: 'none', strokeWidth: 1.1, 'aria-hidden': true },
226
+ h('path', { d: 'M3.4 2.4H9.3L12.6 5.7V13.6H3.4V2.4Z', stroke: 'currentColor' }),
227
+ h('path', { d: 'M9.2 2.5V5.8H12.5', stroke: 'currentColor' }))
228
+ }
229
+
230
+ /** Link glyph. */
231
+ function LinkIcon() {
232
+ return h('svg', { width: 14, height: 14, viewBox: '0 0 16 16', fill: 'none', strokeWidth: 1.1, 'aria-hidden': true },
233
+ h('path', { d: 'M6.6 9.4L9.4 6.6', stroke: 'currentColor', strokeLinecap: 'round' }),
234
+ h('path', { d: 'M7.6 5.1L9 3.7C10.2 2.5 12.1 2.5 13.3 3.7C14.5 4.9 14.5 6.8 13.3 8L11.9 9.4', stroke: 'currentColor', strokeLinecap: 'round' }),
235
+ h('path', { d: 'M8.4 10.9L7 12.3C5.8 13.5 3.9 13.5 2.7 12.3C1.5 11.1 1.5 9.2 2.7 8L4.1 6.6', stroke: 'currentColor', strokeLinecap: 'round' }))
236
+ }
237
+
238
+ /* --------------------------------------------------------------- helpers */
239
+
240
+ const zh = {
241
+ 'panel.title': 'SSH 控制台',
242
+ 'empty.title': '没有可用的服务器',
243
+ 'empty.body': '在 ~/.ssh/config 里声明 Host,或在本插件 cordis.patch.yml 的 servers 里添加条目。',
244
+ 'connect.title': '连接到服务器',
245
+ 'connect.subtitle': '选择一台主机建立连接;可以同时连多台,连上后从标签条切换。',
246
+ 'connect.server': '服务器',
247
+ 'connect.serverNone': '未选择',
248
+ 'connect.password': '密码 / 密钥口令',
249
+ 'connect.passwordPlaceholder': '可留空',
250
+ 'connect.passwordHint': '留空则使用 ~/.ssh 下的私钥或 ssh-agent。密码只保留在内存中,不会写入磁盘。',
251
+ 'connect.action': '连接',
252
+ 'connect.connecting': '连接中…',
253
+ 'connect.disconnect': '断开',
254
+ 'connect.add': '连接其他服务器',
255
+ 'state.connected': '{count} 台已连接',
256
+ 'state.idle': '未连接',
257
+ 'tab.overview': '概览',
258
+ 'tab.processes': '进程',
259
+ 'tab.files': '文件',
260
+ 'tab.terminal': '终端',
261
+ 'cpu': 'CPU',
262
+ 'cpu.cores': '{count} 核',
263
+ 'memory': '内存',
264
+ 'load': '负载',
265
+ 'disk': '磁盘',
266
+ 'uptime': '已运行',
267
+ 'gpu': 'GPU',
268
+ 'gpu.none': '未检测到 NVIDIA GPU',
269
+ 'gpu.util': '利用率',
270
+ 'gpu.memory': '显存',
271
+ 'gpu.procs': '{count} 进程',
272
+ 'gpu.processes': 'GPU 进程',
273
+ 'processes': '进程(按 CPU 排序)',
274
+ 'processes.empty': '这台主机没有返回进程列表',
275
+ 'col.pid': 'PID',
276
+ 'col.ppid': 'PPID',
277
+ 'col.user': '用户',
278
+ 'col.cpu': 'CPU',
279
+ 'col.mem': '内存%',
280
+ 'col.rss': '常驻内存',
281
+ 'col.threads': '线程',
282
+ 'col.elapsed': '运行时长',
283
+ 'col.state': '状态',
284
+ 'col.command': '命令',
285
+ 'col.gpu': 'GPU',
286
+ 'files.up': '上一级',
287
+ 'files.refresh': '刷新',
288
+ 'files.loading': '加载中…',
289
+ 'files.empty': '这个目录是空的',
290
+ 'files.truncated': '条目过多,只显示前 {count} 项',
291
+ 'files.binary': '二进制文件,无法预览',
292
+ 'files.clipped': '内容较长,仅显示开头',
293
+ 'files.close': '关闭',
294
+ 'terminal.title': '交互式终端',
295
+ 'terminal.opening': '正在打开…',
296
+ 'terminal.close': '关闭终端',
297
+ 'terminal.reconnect': '重新打开',
298
+ 'terminal.exited': '终端已退出(退出码 {code})',
299
+ 'terminal.cols': '{cols}×{rows}',
300
+ 'terminal.new': '新建',
301
+ 'terminal.splitRow': '左右分屏',
302
+ 'terminal.splitColumn': '上下分屏',
303
+ 'terminal.unsplit': '取消分屏',
304
+ 'terminal.none': '这台服务器上没有打开的终端',
305
+ 'error.dismiss': '关闭',
306
+ 'poll.failed': '采样失败',
307
+ 'since': '已连接 {duration}',
308
+ }
309
+
310
+ const en = {
311
+ 'panel.title': 'SSH Console',
312
+ 'empty.title': 'No server available',
313
+ 'empty.body': 'Declare a Host in ~/.ssh/config, or add entries under servers in this plugin’s cordis.patch.yml.',
314
+ 'connect.title': 'Connect to a server',
315
+ 'connect.subtitle': 'Pick a host to open a connection; several can stay open and you switch between them by chip.',
316
+ 'connect.server': 'Server',
317
+ 'connect.serverNone': 'None selected',
318
+ 'connect.password': 'Password / key passphrase',
319
+ 'connect.passwordPlaceholder': 'Optional',
320
+ 'connect.passwordHint': 'Leave empty to use a key under ~/.ssh or ssh-agent. Passwords stay in memory and are never written to disk.',
321
+ 'connect.action': 'Connect',
322
+ 'connect.connecting': 'Connecting…',
323
+ 'connect.disconnect': 'Disconnect',
324
+ 'connect.add': 'Connect another server',
325
+ 'state.connected': '{count} connected',
326
+ 'state.idle': 'Not connected',
327
+ 'tab.overview': 'Overview',
328
+ 'tab.processes': 'Processes',
329
+ 'tab.files': 'Files',
330
+ 'tab.terminal': 'Terminal',
331
+ 'cpu': 'CPU',
332
+ 'cpu.cores': '{count} cores',
333
+ 'memory': 'Memory',
334
+ 'load': 'Load',
335
+ 'disk': 'Disk',
336
+ 'uptime': 'Uptime',
337
+ 'gpu': 'GPU',
338
+ 'gpu.none': 'No NVIDIA GPU detected',
339
+ 'gpu.util': 'Util',
340
+ 'gpu.memory': 'VRAM',
341
+ 'gpu.procs': '{count} processes',
342
+ 'gpu.processes': 'GPU processes',
343
+ 'processes': 'Processes (by CPU)',
344
+ 'processes.empty': 'This host returned no process list',
345
+ 'col.pid': 'PID',
346
+ 'col.ppid': 'PPID',
347
+ 'col.user': 'User',
348
+ 'col.cpu': 'CPU',
349
+ 'col.mem': 'MEM%',
350
+ 'col.rss': 'RSS',
351
+ 'col.threads': 'Threads',
352
+ 'col.elapsed': 'Elapsed',
353
+ 'col.state': 'State',
354
+ 'col.command': 'Command',
355
+ 'col.gpu': 'GPU',
356
+ 'files.up': 'Up',
357
+ 'files.refresh': 'Refresh',
358
+ 'files.loading': 'Loading…',
359
+ 'files.empty': 'This directory is empty',
360
+ 'files.truncated': 'Too many entries; showing the first {count}',
361
+ 'files.binary': 'Binary file, no preview',
362
+ 'files.clipped': 'Long file; showing the beginning',
363
+ 'files.close': 'Close',
364
+ 'terminal.title': 'Interactive terminal',
365
+ 'terminal.opening': 'Opening…',
366
+ 'terminal.close': 'Close terminal',
367
+ 'terminal.reconnect': 'Open again',
368
+ 'terminal.exited': 'The terminal exited (code {code})',
369
+ 'terminal.cols': '{cols}×{rows}',
370
+ 'terminal.new': 'New',
371
+ 'terminal.splitRow': 'Split right',
372
+ 'terminal.splitColumn': 'Split down',
373
+ 'terminal.unsplit': 'No split',
374
+ 'terminal.none': 'No terminal is open on this server',
375
+ 'error.dismiss': 'Close',
376
+ 'poll.failed': 'sampling failed',
377
+ 'since': 'connected for {duration}',
378
+ }
379
+
380
+ /**
381
+ * Read the locale id out of whatever shape the locale service exposes.
382
+ * @param snapshot - `getLocale()` result.
383
+ * @returns an id such as `zh-CN`.
384
+ */
385
+ function localeIdOf(snapshot) {
386
+ if (typeof snapshot === 'string') return snapshot
387
+ if (snapshot !== null && typeof snapshot === 'object') {
388
+ const candidate = snapshot.id ?? snapshot.locale ?? snapshot.current
389
+ if (typeof candidate === 'string') return candidate
390
+ }
391
+ return 'en'
392
+ }
393
+
394
+ /**
395
+ * Build a translate function from the bundle's own dictionaries.
396
+ * @param localeClient - the Client locale service.
397
+ * @returns `t(key, vars)`.
398
+ */
399
+ function makeFallbackTranslate(localeClient) {
400
+ return (key, vars) => {
401
+ let id = 'en'
402
+ try {
403
+ id = localeIdOf(localeClient?.getLocale?.())
404
+ } catch {
405
+ id = 'en'
406
+ }
407
+ const dictionary = id.toLowerCase().startsWith('zh') ? zh : en
408
+ const raw = dictionary[key] ?? en[key] ?? key
409
+ if (vars === undefined) return raw
410
+ return raw.replace(/\{(\w+)\}/g, (whole, name) => (
411
+ Object.prototype.hasOwnProperty.call(vars, name) ? String(vars[name]) : whole
412
+ ))
413
+ }
414
+ }
415
+
416
+ /**
417
+ * Format a byte count.
418
+ * @param bytes - size in bytes.
419
+ * @returns display text.
420
+ */
421
+ function formatBytes(bytes) {
422
+ if (typeof bytes !== 'number' || !Number.isFinite(bytes) || bytes <= 0) return '0 B'
423
+ const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB']
424
+ let value = bytes
425
+ let unit = 0
426
+ while (value >= 1024 && unit < units.length - 1) {
427
+ value /= 1024
428
+ unit += 1
429
+ }
430
+ const digits = value >= 100 || unit === 0 ? 0 : value >= 10 ? 1 : 2
431
+ return `${value.toFixed(digits)} ${units[unit]}`
432
+ }
433
+
434
+ /**
435
+ * Format a duration compactly.
436
+ * @param ms - milliseconds.
437
+ * @returns display text.
438
+ */
439
+ function formatDuration(ms) {
440
+ if (typeof ms !== 'number' || !Number.isFinite(ms) || ms < 0) return '—'
441
+ const seconds = Math.floor(ms / 1000)
442
+ if (seconds < 60) return `${seconds}s`
443
+ const minutes = Math.floor(seconds / 60)
444
+ if (minutes < 60) return `${minutes}m`
445
+ const hours = Math.floor(minutes / 60)
446
+ if (hours < 24) return `${hours}h ${minutes % 60}m`
447
+ return `${Math.floor(hours / 24)}d ${hours % 24}h`
448
+ }
449
+
450
+ /**
451
+ * Format a modification time.
452
+ * @param ms - epoch milliseconds.
453
+ * @returns display text.
454
+ */
455
+ function formatTime(ms) {
456
+ if (typeof ms !== 'number' || !Number.isFinite(ms) || ms <= 0) return '—'
457
+ const date = new Date(ms)
458
+ const pad = (value) => String(value).padStart(2, '0')
459
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`
460
+ }
461
+
462
+ /**
463
+ * Pick the bar tone for a utilization percentage.
464
+ * @param percent - 0–100.
465
+ * @returns the modifier class.
466
+ */
467
+ function toneClass(percent) {
468
+ if (typeof percent !== 'number' || !Number.isFinite(percent)) return ''
469
+ if (percent >= 90) return 'ssc-hot'
470
+ if (percent >= 70) return 'ssc-warn'
471
+ return ''
472
+ }
473
+
474
+ /* ----------------------------------------------------------------- state */
475
+
476
+ /**
477
+ * The mounted namespace service. The gateway publishes it as the Client
478
+ * service `remote.<namespace>`, so it is bound through `ctx.inject` rather
479
+ * than read with `ctx.get`, which refuses undeclared keys.
480
+ */
481
+ let api
482
+
483
+ /**
484
+ * Read the Remote result envelope, raising its failure.
485
+ * @param result - the value the namespace method resolved to.
486
+ * @returns the payload.
487
+ */
488
+ function unwrap(result) {
489
+ if (result !== null && typeof result === 'object' && typeof result.ok === 'boolean') {
490
+ if (result.ok !== true) {
491
+ const failure = result.error ?? {}
492
+ const error = new Error(failure.message ?? 'Remote call failed')
493
+ error.code = failure.code
494
+ throw error
495
+ }
496
+ return result.value
497
+ }
498
+ return result
499
+ }
500
+
501
+ /**
502
+ * Wait until the bound namespace is available. The mount creates it as a
503
+ * Client service, and `ctx.inject` delivers it on a later tick, so the first
504
+ * render can legitimately happen before the binding lands.
505
+ * @returns the namespace service.
506
+ */
507
+ async function whenBound() {
508
+ if (api !== undefined && api !== null) return api
509
+ const deadline = Date.now() + 4000
510
+ while (Date.now() < deadline) {
511
+ await new Promise((resolve) => setTimeout(resolve, 40))
512
+ if (api !== undefined && api !== null) return api
513
+ }
514
+ throw new Error('SSH service is unavailable: its Host endpoint is not mounted.')
515
+ }
516
+
517
+ /**
518
+ * Call one Host method.
519
+ *
520
+ * The gateway rebuilds the Host argument object from the descriptor's
521
+ * parameters and rejects any field the method signature does not declare, so
522
+ * every call passes exactly one `input` object — matching the one parameter
523
+ * each Host method takes.
524
+ * @param method - Remote method name.
525
+ * @param input - its single object parameter.
526
+ * @returns the payload.
527
+ */
528
+ async function call(method, input) {
529
+ const service = await whenBound()
530
+ const serviceMethod = service[method]
531
+ if (typeof serviceMethod !== 'function') {
532
+ throw new Error(`SSH service has no method "${method}".`)
533
+ }
534
+ return unwrap(await serviceMethod(input ?? {}))
535
+ }
536
+
537
+ /* ------------------------------------------------------------ components */
538
+
539
+ /** The sidebar icon: the panel row owns the button, this draws the glyph. */
540
+ function ConsolePanelIcon(props) {
541
+ return h(ConsoleIcon, { size: props?.size ?? 16 })
542
+ }
543
+
544
+ /**
545
+ * One labelled statistic with an optional bar.
546
+ * @param props - label, value, sub text, percentage.
547
+ * @returns the card.
548
+ */
549
+ function StatCard({ label, value, sub, percent }) {
550
+ return h('div', { className: 'ssc-card' },
551
+ h('span', { className: 'ssc-card-label' }, label),
552
+ h('span', { className: 'ssc-card-value' }, value),
553
+ typeof percent === 'number' && Number.isFinite(percent)
554
+ ? h('div', { className: 'ssc-bar' },
555
+ h('i', { className: toneClass(percent), style: { width: `${Math.max(0, Math.min(100, percent))}%` } }))
556
+ : null,
557
+ sub === undefined ? null : h('span', { className: 'ssc-card-sub' }, sub))
558
+ }
559
+
560
+ /**
561
+ * One GPU device card in the style of nvitop's device row.
562
+ *
563
+ * Utilization and memory each get their own bar directly under their own
564
+ * numbers. A single bar between the two rows reads as the memory gauge
565
+ * no matter which figure it actually tracks.
566
+ * @param props - the device snapshot and the locale lookup.
567
+ * @returns the card.
568
+ */
569
+ function GpuCard({ device, t }) {
570
+ const memoryPercent = device.memoryTotalMiB > 0
571
+ ? (device.memoryUsedMiB / device.memoryTotalMiB) * 100
572
+ : undefined
573
+ const clamp = (value) => Math.max(0, Math.min(100, value ?? 0))
574
+ return h('div', { className: 'ssc-gpu' },
575
+ h('div', { className: 'ssc-gpu-head' },
576
+ h('span', { className: 'ssc-gpu-idx' }, `#${device.index}`),
577
+ h('span', { className: 'ssc-gpu-name', title: device.name }, device.name)),
578
+ h('div', { className: 'ssc-gpu-metric' },
579
+ h('div', { className: 'ssc-gpu-row' },
580
+ h('span', { className: 'ssc-gpu-row-label' }, t('gpu.util')),
581
+ h('span', { className: 'ssc-gpu-num' }, `${device.utilization ?? '—'}%`),
582
+ h('span', { className: 'ssc-gpu-extra' }, [
583
+ typeof device.temperatureC === 'number' ? `${device.temperatureC}°C` : null,
584
+ typeof device.powerW === 'number' ? `${device.powerW.toFixed(0)} W` : null,
585
+ ].filter(Boolean).join(' · '))),
586
+ h('div', { className: 'ssc-bar' },
587
+ h('i', { className: toneClass(device.utilization), style: { width: `${clamp(device.utilization)}%` } }))),
588
+ h('div', { className: 'ssc-gpu-metric' },
589
+ h('div', { className: 'ssc-gpu-row' },
590
+ h('span', { className: 'ssc-gpu-row-label' }, t('gpu.memory')),
591
+ h('span', { className: 'ssc-gpu-mem' },
592
+ `${formatBytes((device.memoryUsedMiB ?? 0) * 2 ** 20)} / ${formatBytes((device.memoryTotalMiB ?? 0) * 2 ** 20)}`),
593
+ h('span', { className: 'ssc-gpu-num' }, typeof memoryPercent === 'number' ? `${memoryPercent.toFixed(0)}%` : '—')),
594
+ h('div', { className: 'ssc-bar' },
595
+ h('i', { className: 'ssc-tone-mem', style: { width: `${clamp(memoryPercent)}%` } }))))
596
+ }
597
+
598
+ /**
599
+ * One connected host's block: the four headline statistics and its GPUs.
600
+ * @param props - the session, its snapshot and the locale lookup.
601
+ * @returns the block.
602
+ */
603
+ function HostBlock({ session, snapshot, t }) {
604
+ const server = session.server ?? {}
605
+ const failed = typeof snapshot?.error === 'string'
606
+ const memory = snapshot?.memory ?? { totalBytes: 0, usedBytes: 0 }
607
+ const memoryPercent = memory.totalBytes > 0 ? (memory.usedBytes / memory.totalBytes) * 100 : undefined
608
+ const gpus = snapshot?.gpu?.devices ?? []
609
+ const gpuMemory = gpus.reduce((sum, device) => sum + (device.memoryUsedMiB ?? 0), 0)
610
+ return h('section', { className: 'ssc-host' },
611
+ h('div', { className: 'ssc-host-head' },
612
+ h('h3', { className: 'ssc-host-name' }, session.hostname ?? server.host ?? ''),
613
+ h('span', { className: 'ssc-host-meta' },
614
+ [
615
+ snapshot?.os ?? session.os,
616
+ `${server.user === undefined || server.user === '' ? '' : `${server.user}@`}${server.host}:${server.port}`,
617
+ typeof session.since === 'number' ? t('since', { duration: formatDuration(Date.now() - session.since) }) : null,
618
+ ].filter(Boolean).join(' · '))),
619
+ snapshot === null || snapshot === undefined
620
+ ? h('div', { className: 'ssc-hint' }, t('files.loading'))
621
+ : failed
622
+ ? h('div', { className: 'ssc-hint ssc-fail' }, `${t('poll.failed')}: ${snapshot.error}`)
623
+ : h(React.Fragment, null,
624
+ h('div', { className: 'ssc-cards' },
625
+ h(StatCard, {
626
+ label: t('cpu'),
627
+ value: typeof snapshot.cpu?.percent === 'number' ? `${snapshot.cpu.percent.toFixed(1)}%` : '—',
628
+ sub: typeof snapshot.cpu?.cores === 'number' ? t('cpu.cores', { count: snapshot.cpu.cores }) : undefined,
629
+ percent: snapshot.cpu?.percent,
630
+ }),
631
+ h(StatCard, {
632
+ label: t('memory'),
633
+ value: `${formatBytes(memory.usedBytes)} / ${formatBytes(memory.totalBytes)}`,
634
+ sub: typeof memoryPercent === 'number' ? `${memoryPercent.toFixed(0)}%` : undefined,
635
+ percent: memoryPercent,
636
+ }),
637
+ h(StatCard, {
638
+ label: t('load'),
639
+ value: (snapshot.cpu?.load ?? []).map((value) => value.toFixed(2)).join(' '),
640
+ sub: snapshot.uptimeSeconds === undefined
641
+ ? undefined
642
+ : `${t('uptime')} ${formatDuration(snapshot.uptimeSeconds * 1000)}`,
643
+ }),
644
+ h(StatCard, {
645
+ label: t('disk'),
646
+ value: snapshot.disk === undefined ? '—' : `${snapshot.disk.usedPercent}%`,
647
+ sub: snapshot.disk === undefined
648
+ ? undefined
649
+ : `${snapshot.disk.filesystem} · ${snapshot.disk.used} / ${snapshot.disk.size}`,
650
+ percent: snapshot.disk === undefined ? undefined : snapshot.disk.usedPercent,
651
+ })),
652
+ h('div', { className: 'ssc-section' },
653
+ h('div', { className: 'ssc-section-head' },
654
+ h('h4', { className: 'ssc-section-title' }, t('gpu')),
655
+ gpus.length > 0
656
+ ? h('span', { className: 'ssc-section-count' },
657
+ `${gpus.length} · ${t('gpu.memory')} ${formatBytes(gpuMemory * 2 ** 20)}`)
658
+ : null),
659
+ snapshot.gpu?.supported === false || gpus.length === 0
660
+ ? h('div', { className: 'ssc-hint' }, t('gpu.none'))
661
+ : h('div', { className: 'ssc-gpus' },
662
+ gpus.map((device) => h(GpuCard, { key: device.index, device, t }))))))
663
+ }
664
+
665
+ /**
666
+ * The overview tab: the selected host only.
667
+ *
668
+ * Switching host is the chip strip's job, so this renders exactly one host —
669
+ * stacking every connected machine made a second host push the first one's
670
+ * numbers off screen.
671
+ * @param props - the selected session, its snapshot and the locale lookup.
672
+ * @returns the tab body.
673
+ */
674
+ function OverviewTab({ session, snapshot, t }) {
675
+ return h(HostBlock, { session, snapshot, t })
676
+ }
677
+
678
+ /**
679
+ * The GPU processes of one host: which card, whose process, and its command.
680
+ *
681
+ * The command is shown in full and allowed to wrap. Shortening it dropped the
682
+ * interpreter and the script — exactly the part that says what the job is.
683
+ *
684
+ * Rows are grouped by card with a heading per group: a busy host runs dozens
685
+ * of compute processes, and a flat list makes "what is on #3" a scan instead
686
+ * of a glance.
687
+ * @param props - the rows and the locale lookup.
688
+ * @returns the table, or null when the host reports none.
689
+ */
690
+ function GpuProcessTable({ rows, t }) {
691
+ if (rows.length === 0) return null
692
+
693
+ const groups = new Map()
694
+ for (const process of rows) {
695
+ const key = process.gpuIndex === undefined ? -1 : process.gpuIndex
696
+ if (!groups.has(key)) groups.set(key, [])
697
+ groups.get(key).push(process)
698
+ }
699
+ const keys = [...groups.keys()].sort((left, right) => left - right)
700
+
701
+ const bodies = keys.map((key) => {
702
+ const mine = groups.get(key)
703
+ const total = mine.reduce((sum, process) => sum + (process.usedMiB ?? 0), 0)
704
+ return h('tbody', { key: `gpu-${key}` },
705
+ h('tr', { className: 'ssc-group-row' },
706
+ h('th', { className: 'ssc-group', colSpan: 5, scope: 'rowgroup' },
707
+ h('span', { className: 'ssc-mono' }, key < 0 ? '—' : `#${key}`),
708
+ h('span', { className: 'ssc-group-meta' },
709
+ `${t('gpu.procs', { count: mine.length })} · ${formatBytes(total * 2 ** 20)}`))),
710
+ mine.map((process) => h('tr', { key: `${key}-${process.pid}` },
711
+ h('td', { className: 'ssc-num ssc-mono' }, key < 0 ? '—' : `#${key}`),
712
+ h('td', { className: 'ssc-num ssc-mono' }, process.pid),
713
+ h('td', null, process.user ?? '—'),
714
+ h('td', { className: 'ssc-num' }, formatBytes((process.usedMiB ?? 0) * 2 ** 20)),
715
+ h('td', { className: 'ssc-mono ssc-cmd', title: process.command }, process.command ?? '—'))))
716
+ })
717
+
718
+ return h('div', { className: 'ssc-section' },
719
+ h('div', { className: 'ssc-section-head' },
720
+ h('h4', { className: 'ssc-section-title' }, t('gpu.processes')),
721
+ h('span', { className: 'ssc-section-count' },
722
+ `${t('gpu.procs', { count: rows.length })} · ${keys.length} GPU`)),
723
+ h('div', { className: 'ssc-table-wrap' },
724
+ h('table', { className: 'ssc-table ssc-table-wide' },
725
+ h('thead', null, h('tr', null,
726
+ h('th', { className: 'ssc-num' }, t('col.gpu')),
727
+ h('th', { className: 'ssc-num' }, t('col.pid')),
728
+ h('th', null, t('col.user')),
729
+ h('th', { className: 'ssc-num' }, t('gpu.memory')),
730
+ h('th', { className: 'ssc-cmd' }, t('col.command')))),
731
+ bodies)))
732
+ }
733
+
734
+ /**
735
+ * The top processes of one host, with the columns that make a busy GPU host
736
+ * readable: resident memory, thread count and how long it has been running.
737
+ * @param props - the rows and the locale lookup.
738
+ * @returns the table.
739
+ */
740
+ function ProcessTable({ rows, t }) {
741
+ const body = rows.map((process) => h('tr', { key: process.pid },
742
+ h('td', { className: 'ssc-num ssc-mono' }, process.pid),
743
+ h('td', null, process.user),
744
+ h('td', { className: 'ssc-num' }, `${process.cpuPercent.toFixed(1)}%`),
745
+ h('td', { className: 'ssc-num' }, `${process.memoryPercent.toFixed(1)}%`),
746
+ h('td', { className: 'ssc-num' }, formatBytes(process.rssBytes)),
747
+ h('td', { className: 'ssc-num' }, String(process.threads)),
748
+ h('td', { className: 'ssc-num ssc-mono' }, process.elapsed),
749
+ h('td', { className: 'ssc-mono', title: process.state }, process.state),
750
+ h('td', { className: 'ssc-mono ssc-cmd', title: process.command }, process.command)))
751
+ return h('div', { className: 'ssc-section' },
752
+ h('div', { className: 'ssc-section-head' },
753
+ h('h4', { className: 'ssc-section-title' }, t('processes')),
754
+ h('span', { className: 'ssc-section-count' }, String(rows.length))),
755
+ h('div', { className: 'ssc-table-wrap' },
756
+ h('table', { className: 'ssc-table ssc-table-wide' },
757
+ h('thead', null, h('tr', null,
758
+ h('th', { className: 'ssc-num' }, t('col.pid')),
759
+ h('th', null, t('col.user')),
760
+ h('th', { className: 'ssc-num' }, t('col.cpu')),
761
+ h('th', { className: 'ssc-num' }, t('col.mem')),
762
+ h('th', { className: 'ssc-num' }, t('col.rss')),
763
+ h('th', { className: 'ssc-num' }, t('col.threads')),
764
+ h('th', { className: 'ssc-num' }, t('col.elapsed')),
765
+ h('th', null, t('col.state')),
766
+ h('th', { className: 'ssc-cmd' }, t('col.command')))),
767
+ h('tbody', null, body))))
768
+ }
769
+
770
+ /**
771
+ * The processes tab: the selected host only.
772
+ *
773
+ * Same rule as the overview — one host at a time, chosen by the chip strip,
774
+ * so the tables never scroll past a second machine's heading.
775
+ * @param props - the selected session, its snapshot and the locale lookup.
776
+ * @returns the tab body.
777
+ */
778
+ function ProcessesTab({ session, snapshot, t }) {
779
+ if (snapshot === null || snapshot === undefined) {
780
+ return h('div', { className: 'ssc-hint' }, t('files.loading'))
781
+ }
782
+ if (typeof snapshot.error === 'string') {
783
+ return h('div', { className: 'ssc-hint ssc-fail' }, `${t('poll.failed')}: ${snapshot.error}`)
784
+ }
785
+ const gpuRows = snapshot.gpuProcesses ?? []
786
+ const rows = snapshot.processes ?? []
787
+ return h(React.Fragment, null,
788
+ h(GpuProcessTable, { rows: gpuRows, t }),
789
+ rows.length === 0
790
+ ? h('div', { className: 'ssc-hint' }, t('processes.empty'))
791
+ : h(ProcessTable, { rows, t }))
792
+ }
793
+
794
+ /**
795
+ * The files tab: a read-only directory walk with a text preview, against the
796
+ * active server.
797
+ * @param props - the active server id and the locale lookup.
798
+ * @returns the tab body.
799
+ */
800
+ function FilesTab({ serverId, t }) {
801
+ const [path, setPath] = React.useState('~')
802
+ const [listing, setListing] = React.useState(null)
803
+ const [loading, setLoading] = React.useState(false)
804
+ const [failure, setFailure] = React.useState('')
805
+ const [preview, setPreview] = React.useState(null)
806
+ const [previewLoading, setPreviewLoading] = React.useState(false)
807
+
808
+ const load = React.useCallback(async (next) => {
809
+ setLoading(true)
810
+ setFailure('')
811
+ try {
812
+ const value = await call('listDir', { serverId, path: next })
813
+ setListing(value)
814
+ setPath(value.path)
815
+ } catch (error) {
816
+ setFailure(error instanceof Error ? error.message : String(error))
817
+ setListing(null)
818
+ } finally {
819
+ setLoading(false)
820
+ }
821
+ }, [serverId])
822
+
823
+ React.useEffect(() => {
824
+ void load('~')
825
+ }, [load])
826
+
827
+ const join = React.useCallback((base, name) => (base.endsWith('/') ? `${base}${name}` : `${base}/${name}`), [])
828
+
829
+ const openEntry = React.useCallback((entry) => {
830
+ if (entry.kind === 'directory') {
831
+ void load(join(path, entry.name))
832
+ setPreview(null)
833
+ return
834
+ }
835
+ if (entry.kind === 'other') return
836
+ setPreviewLoading(true)
837
+ call('readText', { serverId, path: join(path, entry.name) })
838
+ .then((value) => {
839
+ setPreview(value)
840
+ })
841
+ .catch((error) => {
842
+ setPreview({
843
+ path: entry.name,
844
+ text: '',
845
+ binary: false,
846
+ error: error instanceof Error ? error.message : String(error),
847
+ })
848
+ })
849
+ .finally(() => {
850
+ setPreviewLoading(false)
851
+ })
852
+ }, [join, path, serverId])
853
+
854
+ const parentPath = React.useMemo(() => {
855
+ const parts = path.replace(/\/+$/, '').split('/')
856
+ if (parts.length <= 1) return '/'
857
+ parts.pop()
858
+ return parts.join('/') || '/'
859
+ }, [path])
860
+
861
+ return h(React.Fragment, null,
862
+ h('div', { className: 'ssc-path' },
863
+ h('button', { type: 'button', className: 'ssc-btn ssc-btn-quiet', onClick: () => void load(parentPath), disabled: loading }, t('files.up')),
864
+ h('span', { className: 'ssc-path-text', title: path }, path),
865
+ h('button', { type: 'button', className: 'ssc-btn ssc-btn-quiet', onClick: () => void load(path), disabled: loading }, t('files.refresh'))),
866
+ failure.length > 0 ? h('div', { className: 'ssc-hint ssc-fail', role: 'alert' }, failure) : null,
867
+ loading && listing === null ? h('div', { className: 'ssc-empty' }, h('span', null, t('files.loading'))) : null,
868
+ listing !== null && listing.entries.length === 0
869
+ ? h('div', { className: 'ssc-empty' }, h('span', null, t('files.empty')))
870
+ : null,
871
+ listing !== null && listing.entries.length > 0
872
+ ? h('div', null,
873
+ listing.entries.map((entry) => h('button', {
874
+ key: entry.name,
875
+ type: 'button',
876
+ className: 'ssc-entry',
877
+ onClick: () => openEntry(entry),
878
+ title: entry.name,
879
+ },
880
+ h('span', { className: 'ssc-entry-icon' },
881
+ entry.kind === 'directory' ? h(FolderIcon) : entry.kind === 'symlink' ? h(LinkIcon) : h(FileIcon)),
882
+ h('span', { className: 'ssc-entry-name' }, entry.name),
883
+ h('span', { className: 'ssc-entry-size' }, entry.kind === 'directory' ? '—' : formatBytes(entry.sizeBytes)),
884
+ h('span', { className: 'ssc-entry-time' }, formatTime(entry.modifiedMs)))),
885
+ listing.truncated ? h('div', { className: 'ssc-hint' }, t('files.truncated', { count: listing.entries.length })) : null)
886
+ : null,
887
+ previewLoading ? h('div', { className: 'ssc-hint', style: { marginTop: 12 } }, t('files.loading')) : null,
888
+ preview !== null
889
+ ? h('div', { className: 'ssc-preview' },
890
+ h('div', { className: 'ssc-preview-head' },
891
+ h(FileIcon),
892
+ h('span', { className: 'ssc-preview-title', title: preview.path }, preview.path),
893
+ preview.error === undefined && preview.binary === false
894
+ ? h('span', { className: 'ssc-hint' }, formatBytes(preview.sizeBytes))
895
+ : null,
896
+ h('button', { type: 'button', className: 'ssc-btn ssc-btn-quiet', onClick: () => setPreview(null) }, t('files.close'))),
897
+ h('pre', { className: 'ssc-preview-body' },
898
+ preview.error !== undefined
899
+ ? preview.error
900
+ : preview.binary === true
901
+ ? t('files.binary')
902
+ : `${preview.text}${preview.truncated === true ? `\n\n… ${t('files.clipped')}` : ''}`))
903
+ : null)
904
+ }
905
+
906
+ /**
907
+ * Resolve the few theme colours xterm needs.
908
+ *
909
+ * The renderer draws to a canvas, so it cannot take `var(--dsw-*)` strings;
910
+ * the tokens are read off the document instead, which keeps the terminal in
911
+ * step with the active light or dark theme.
912
+ * @returns the terminal theme object.
913
+ */
914
+ function terminalTheme() {
915
+ const fallback = {
916
+ background: '#10141a',
917
+ foreground: '#d7dde5',
918
+ cursor: '#7fd4ff',
919
+ selectionBackground: '#2b4a63',
920
+ }
921
+ if (typeof getComputedStyle !== 'function' || typeof document === 'undefined') return fallback
922
+ try {
923
+ const style = getComputedStyle(document.body)
924
+ const supports = typeof CSS === 'undefined' || typeof CSS.supports !== 'function'
925
+ ? () => true
926
+ : (value) => CSS.supports('color', value)
927
+ const read = (token) => {
928
+ const value = style.getPropertyValue(token).trim()
929
+ // The renderer wants a colour it can paint; a token holding a gradient
930
+ // or an unsupported function would leave the screen unthemed.
931
+ return value.length > 0 && supports(value) ? value : undefined
932
+ }
933
+ return {
934
+ background: read('--dsw-alias-bg-layer-1') ?? fallback.background,
935
+ foreground: read('--dsw-alias-label-primary') ?? fallback.foreground,
936
+ cursor: read('--dsw-alias-brand-primary') ?? fallback.cursor,
937
+ selectionBackground: read('--dsw-alias-interactive-bg-hover') ?? fallback.selectionBackground,
938
+ }
939
+ } catch {
940
+ return fallback
941
+ }
942
+ }
943
+
944
+ /**
945
+ * One remote PTY rendered by the vendored xterm.
946
+ *
947
+ * Output is pulled with a short poll against a byte cursor rather than pushed
948
+ * over a stream, so the transport stays request/response; the interval is
949
+ * short enough that the echo of a keystroke appears immediately.
950
+ *
951
+ * The component is mounted for every terminal its owner knows about and is
952
+ * only laid out while its pane is on screen. That is what makes a session
953
+ * survive switching: the emulator keeps its scrollback, and the Host keeps
954
+ * the PTY. Only an explicit close tears either down.
955
+ * @param props - the server id, this terminal's id, whether it is laid out,
956
+ * whether it has keyboard focus and the locale lookup.
957
+ * @returns the pane body.
958
+ */
959
+ function TerminalPane({ serverId, termId, t, visible, focused }) {
960
+ const holderRef = React.useRef(null)
961
+ const termRef = React.useRef(null)
962
+ const fitRef = React.useRef(null)
963
+ const cursorRef = React.useRef(0)
964
+ const pendingRef = React.useRef('')
965
+ const flushRef = React.useRef(undefined)
966
+ const [phase, setPhase] = React.useState('closed')
967
+ const [failure, setFailure] = React.useState('')
968
+ const [size, setSize] = React.useState(null)
969
+ const [exitCode, setExitCode] = React.useState(undefined)
970
+ /** One automatic attempt per pane: a failing host must not spin. */
971
+ const attemptedRef = React.useRef(false)
972
+ /**
973
+ * Guards the window in which `open()` has started but not finished.
974
+ *
975
+ * The guard below it (`termRef.current`) is only set after an await, so two
976
+ * overlapping calls would both pass it and mount a second emulator into the
977
+ * same holder: the stale one stays in the DOM, keeps its own `onData`, and
978
+ * forwards every keystroke a second time. That is the corruption seen on the
979
+ * first open, so the check has to be synchronous.
980
+ */
981
+ const openingRef = React.useRef(false)
982
+
983
+ /**
984
+ * Send buffered keystrokes in one call.
985
+ *
986
+ * Typing produces one `onData` per key; coalescing them keeps a fast typist
987
+ * from spending a round trip per character without adding visible latency.
988
+ */
989
+ const flush = React.useCallback(() => {
990
+ flushRef.current = undefined
991
+ const data = pendingRef.current
992
+ pendingRef.current = ''
993
+ if (data.length === 0) return
994
+ const bytes = new TextEncoder().encode(data)
995
+ let binary = ''
996
+ for (const byte of bytes) binary += String.fromCharCode(byte)
997
+ void call('termWrite', { serverId, termId, data: btoa(binary) }).catch(() => {
998
+ // A closed channel surfaces on the next read instead.
999
+ })
1000
+ }, [serverId, termId])
1001
+
1002
+ /** Write base64 bytes into the emulator, honouring a reset request. */
1003
+ const drain = React.useCallback(async () => {
1004
+ const term = termRef.current
1005
+ if (term === null) return
1006
+ const result = await call('termRead', { serverId, termId, fromSeq: cursorRef.current })
1007
+ if (result.reset === true) term.reset()
1008
+ cursorRef.current = result.seq
1009
+ if (typeof result.data === 'string' && result.data.length > 0) {
1010
+ const binary = atob(result.data)
1011
+ const bytes = new Uint8Array(binary.length)
1012
+ for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index)
1013
+ term.write(bytes)
1014
+ }
1015
+ if (result.exited === true) {
1016
+ setPhase('exited')
1017
+ setExitCode(result.exitCode)
1018
+ }
1019
+ }, [serverId, termId])
1020
+
1021
+ /** Mount the emulator in the holder and attach to this terminal. */
1022
+ const open = React.useCallback(async () => {
1023
+ if (openingRef.current) return
1024
+ openingRef.current = true
1025
+ setFailure('')
1026
+ setPhase('opening')
1027
+ try {
1028
+ const vendored = await require.async('./client.xterm.js')
1029
+ const holder = holderRef.current
1030
+ if (holder === null) throw new Error('The terminal container is not mounted yet.')
1031
+ if (termRef.current !== null) {
1032
+ termRef.current.dispose()
1033
+ termRef.current = null
1034
+ }
1035
+ // A previous instance that lost a race would still own child nodes here;
1036
+ // clearing first guarantees one canvas per pane.
1037
+ while (holder.firstChild !== null) holder.removeChild(holder.firstChild)
1038
+ const fit = new vendored.FitAddon()
1039
+ const term = new vendored.Terminal({
1040
+ cursorBlink: true,
1041
+ fontSize: 12.5,
1042
+ fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace',
1043
+ scrollback: 5000,
1044
+ allowProposedApi: true,
1045
+ theme: terminalTheme(),
1046
+ })
1047
+ term.loadAddon(fit)
1048
+ term.open(holder)
1049
+ fit.fit()
1050
+ // Registered before the round trip: xterm answers terminal queries
1051
+ // through this channel, and a program that is waiting for a reply (a
1052
+ // `resize` call in a login script, say) must not be left guessing.
1053
+ term.onData((data) => {
1054
+ pendingRef.current += data
1055
+ if (flushRef.current === undefined) flushRef.current = setTimeout(flush, 16)
1056
+ })
1057
+ const opened = await call('termOpen', {
1058
+ serverId,
1059
+ termId,
1060
+ cols: term.cols,
1061
+ rows: term.rows,
1062
+ })
1063
+ // An attached terminal keeps the size the Host already had; adopt it so
1064
+ // the emulator and the PTY agree on where lines wrap.
1065
+ if (opened.cols !== term.cols || opened.rows !== term.rows) {
1066
+ term.resize(opened.cols, opened.rows)
1067
+ }
1068
+ termRef.current = term
1069
+ fitRef.current = fit
1070
+ cursorRef.current = 0
1071
+ setSize({ cols: opened.cols, rows: opened.rows })
1072
+ term.focus()
1073
+ setPhase('open')
1074
+ await drain()
1075
+ } catch (error) {
1076
+ setPhase('closed')
1077
+ setFailure(error instanceof Error ? error.message : String(error))
1078
+ } finally {
1079
+ openingRef.current = false
1080
+ }
1081
+ }, [drain, flush, serverId, termId])
1082
+
1083
+ /** Re-open an exited or failed terminal in the same slot. */
1084
+ const reopen = React.useCallback(async () => {
1085
+ attemptedRef.current = true
1086
+ try {
1087
+ await call('termOpen', { serverId, termId })
1088
+ } catch {
1089
+ // The open below reports the real failure.
1090
+ }
1091
+ await open()
1092
+ }, [open, serverId, termId])
1093
+
1094
+ // Opening the pane opens the terminal: there is nothing to configure, so a
1095
+ // second click would only be in the way. A failed attempt stays failed
1096
+ // until the user asks again, otherwise the effect would retry in a loop.
1097
+ React.useEffect(() => {
1098
+ if (!visible || phase !== 'closed') return
1099
+ if (attemptedRef.current) return
1100
+ attemptedRef.current = true
1101
+ void open()
1102
+ // `open` is stable for this terminal; re-running on phase is the trigger.
1103
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1104
+ }, [visible, phase])
1105
+
1106
+ // Poll for output while this terminal is live.
1107
+ React.useEffect(() => {
1108
+ if (phase !== 'open') return undefined
1109
+ let alive = true
1110
+ const timer = setInterval(() => {
1111
+ if (!alive) return
1112
+ void drain().catch(() => {
1113
+ alive = false
1114
+ setPhase('exited')
1115
+ })
1116
+ }, 120)
1117
+ return () => {
1118
+ alive = false
1119
+ clearInterval(timer)
1120
+ }
1121
+ }, [drain, phase])
1122
+
1123
+ // Keep the remote PTY sized to the pane. A hidden pane reports a zero-sized
1124
+ // box, and fitting to that would resize the PTY to its floor.
1125
+ React.useEffect(() => {
1126
+ if (phase !== 'open' || !visible) return undefined
1127
+ const holder = holderRef.current
1128
+ if (holder === null) return undefined
1129
+ const refit = () => {
1130
+ const fit = fitRef.current
1131
+ const term = termRef.current
1132
+ if (fit === null || term === null || holder === null) return
1133
+ if (holder.clientWidth === 0 || holder.clientHeight === 0) return
1134
+ fit.fit()
1135
+ setSize({ cols: term.cols, rows: term.rows })
1136
+ void call('termResize', { serverId, termId, cols: term.cols, rows: term.rows }).catch(() => {})
1137
+ }
1138
+ let timer
1139
+ const onResize = () => {
1140
+ clearTimeout(timer)
1141
+ timer = setTimeout(refit, 180)
1142
+ }
1143
+ const observer = typeof ResizeObserver === 'undefined' ? undefined : new ResizeObserver(onResize)
1144
+ observer?.observe(holder)
1145
+ // Becoming visible again (tab switch, split, unsplit) needs an immediate fit.
1146
+ refit()
1147
+ return () => {
1148
+ clearTimeout(timer)
1149
+ observer?.disconnect()
1150
+ }
1151
+ }, [phase, visible, serverId, termId])
1152
+
1153
+ // Typing should go to the pane the user clicked.
1154
+ React.useEffect(() => {
1155
+ if (!visible || !focused) return
1156
+ termRef.current?.focus()
1157
+ }, [visible, focused])
1158
+
1159
+ // Unmounting the panel disposes the renderer but leaves the remote PTY
1160
+ // running, so reopening the page re-attaches to the same login.
1161
+ React.useEffect(() => () => {
1162
+ clearTimeout(flushRef.current)
1163
+ flushRef.current = undefined
1164
+ termRef.current?.dispose()
1165
+ termRef.current = null
1166
+ }, [termId])
1167
+
1168
+ return h('div', { className: 'ssc-pane-body' },
1169
+ failure.length > 0 ? h('div', { className: 'ssc-hint ssc-fail', role: 'alert' }, failure) : null,
1170
+ phase === 'exited'
1171
+ ? h('div', { className: 'ssc-pane-exit' },
1172
+ h('span', { className: 'ssc-hint' }, t('terminal.exited', { code: exitCode ?? '—' })),
1173
+ h('button', { type: 'button', className: 'ssc-btn ssc-btn-quiet', onClick: () => void reopen() }, t('terminal.reconnect')))
1174
+ : null,
1175
+ h('div', { className: 'ssc-term-shell' },
1176
+ h('div', { className: 'ssc-term-host', ref: holderRef }),
1177
+ phase === 'opening'
1178
+ ? h('div', { className: 'ssc-term-placeholder' }, h('span', { className: 'ssc-hint' }, t('terminal.opening')))
1179
+ : null),
1180
+ size === null ? null : h('span', { className: 'ssc-pane-size' }, t('terminal.cols', size)))
1181
+ }
1182
+
1183
+ /**
1184
+ * The terminal tab: a set of remote PTYs laid out as one or two panes.
1185
+ *
1186
+ * Model: a server holds N terminals (bounded by the Host), and the tab shows
1187
+ * one or two of them. The ones not shown stay mounted and keep running, so
1188
+ * switching costs nothing and loses no scrollback.
1189
+ * @param props - the server id, the prompt identity, whether the tab is on
1190
+ * screen and the locale lookup.
1191
+ * @returns the tab body.
1192
+ */
1193
+ function TerminalTab({ serverId, t, who, visible }) {
1194
+ /** Every terminal open on this server, in creation order. */
1195
+ const [termIds, setTermIds] = React.useState([])
1196
+ /** The one (or two) currently laid out. */
1197
+ const [panes, setPanes] = React.useState([])
1198
+ const [orientation, setOrientation] = React.useState('row')
1199
+ const [focused, setFocused] = React.useState(0)
1200
+ const [failure, setFailure] = React.useState('')
1201
+ const [busy, setBusy] = React.useState(false)
1202
+ const [booted, setBooted] = React.useState(false)
1203
+
1204
+ // First sight of this tab: adopt whatever the Host still holds, or open one.
1205
+ React.useEffect(() => {
1206
+ if (!visible || booted) return
1207
+ setBooted(true)
1208
+ void (async () => {
1209
+ try {
1210
+ const listed = await call('termList', { serverId })
1211
+ const existing = (listed.terminals ?? []).map((entry) => entry.termId)
1212
+ if (existing.length > 0) {
1213
+ setTermIds(existing)
1214
+ setPanes([existing[0]])
1215
+ return
1216
+ }
1217
+ const opened = await call('termOpen', { serverId })
1218
+ setTermIds([opened.termId])
1219
+ setPanes([opened.termId])
1220
+ } catch (error) {
1221
+ setBooted(false)
1222
+ setFailure(error instanceof Error ? error.message : String(error))
1223
+ }
1224
+ })()
1225
+ }, [visible, booted, serverId])
1226
+
1227
+ /** Open a new terminal and show it on its own. */
1228
+ const create = React.useCallback(async () => {
1229
+ setBusy(true)
1230
+ setFailure('')
1231
+ try {
1232
+ const opened = await call('termOpen', { serverId })
1233
+ setTermIds((current) => (current.includes(opened.termId) ? current : [...current, opened.termId]))
1234
+ setPanes([opened.termId])
1235
+ setFocused(0)
1236
+ } catch (error) {
1237
+ setFailure(error instanceof Error ? error.message : String(error))
1238
+ } finally {
1239
+ setBusy(false)
1240
+ }
1241
+ }, [serverId])
1242
+
1243
+ /**
1244
+ * Split into a second pane, creating a terminal only when none is spare.
1245
+ *
1246
+ * This mirrors what `tmux split-window` does, which is the behaviour a
1247
+ * terminal user expects from a split button.
1248
+ */
1249
+ const split = React.useCallback(async (direction) => {
1250
+ setFailure('')
1251
+ setOrientation(direction)
1252
+ if (panes.length > 1) return
1253
+ const spare = termIds.find((id) => id !== panes[0])
1254
+ if (spare !== undefined) {
1255
+ setPanes([panes[0] ?? spare, spare])
1256
+ setFocused(1)
1257
+ return
1258
+ }
1259
+ setBusy(true)
1260
+ try {
1261
+ const opened = await call('termOpen', { serverId })
1262
+ setTermIds((current) => (current.includes(opened.termId) ? current : [...current, opened.termId]))
1263
+ setPanes([panes[0] ?? opened.termId, opened.termId])
1264
+ setFocused(1)
1265
+ } catch (error) {
1266
+ setFailure(error instanceof Error ? error.message : String(error))
1267
+ } finally {
1268
+ setBusy(false)
1269
+ }
1270
+ }, [panes, serverId, termIds])
1271
+
1272
+ const unsplit = React.useCallback(() => {
1273
+ setPanes((current) => {
1274
+ const keep = current[focused] ?? current[0]
1275
+ return keep === undefined ? [] : [keep]
1276
+ })
1277
+ setFocused(0)
1278
+ }, [focused])
1279
+
1280
+ /** Close one terminal for good; its PTY goes away on the Host too. */
1281
+ const closeTerm = React.useCallback(async (termId) => {
1282
+ setBusy(true)
1283
+ try {
1284
+ await call('termClose', { serverId, termId })
1285
+ setTermIds((current) => current.filter((id) => id !== termId))
1286
+ // Dropping the id is enough: the render below adopts a survivor when
1287
+ // this leaves no pane, so the view never blanks in between.
1288
+ setPanes((shown) => shown.filter((id) => id !== termId))
1289
+ setFocused(0)
1290
+ } catch (error) {
1291
+ setFailure(error instanceof Error ? error.message : String(error))
1292
+ } finally {
1293
+ setBusy(false)
1294
+ }
1295
+ }, [serverId])
1296
+
1297
+ /**
1298
+ * Bring a terminal on screen, replacing what the focused pane held.
1299
+ * @param termId - the terminal to show.
1300
+ */
1301
+ const show = React.useCallback((termId) => {
1302
+ setPanes((current) => {
1303
+ if (current.length <= 1) return [termId]
1304
+ if (current.includes(termId)) {
1305
+ setFocused(current.indexOf(termId))
1306
+ return current
1307
+ }
1308
+ const next = [...current]
1309
+ next[focused] = termId
1310
+ return next
1311
+ })
1312
+ }, [focused])
1313
+
1314
+ // A closed terminal must not leave its slot empty. The pane list has to be
1315
+ // repaired here rather than filtered at render time: a pane whose id is
1316
+ // gone would otherwise be dropped from the layout while its sibling stayed
1317
+ // mounted and hidden, which looks exactly like "closing one closed both".
1318
+ // A pane whose terminal is gone must be repaired in state, otherwise the
1319
+ // survivor stays mounted but hidden and the view looks empty.
1320
+ React.useEffect(() => {
1321
+ if (termIds.length === 0) {
1322
+ if (panes.length > 0) setPanes([])
1323
+ return
1324
+ }
1325
+ if (panes.length === 0) {
1326
+ setPanes([termIds[0]])
1327
+ return
1328
+ }
1329
+ if (panes.every((id) => termIds.includes(id))) return
1330
+ const surviving = panes.filter((id) => termIds.includes(id))
1331
+ setPanes(surviving.length > 0 ? surviving : [termIds[0]])
1332
+ }, [termIds, panes])
1333
+
1334
+ // Derived at render time as well, so a close never shows a blank frame
1335
+ // while the repair effect above catches up.
1336
+ const live = panes.filter((id) => termIds.includes(id))
1337
+ const shown = live.length > 0 || termIds.length === 0 ? live : [termIds[0]]
1338
+ // Panes in layout order first, then the mounted-but-hidden ones.
1339
+ const ordered = [...shown, ...termIds.filter((id) => !shown.includes(id))]
1340
+ const splitting = shown.length > 1
1341
+ const spare = termIds.some((id) => !shown.includes(id))
1342
+
1343
+ return h('div', { className: 'ssc-term', style: visible ? undefined : { display: 'none' } },
1344
+ h('div', { className: 'ssc-term-bar' },
1345
+ h('span', { className: 'ssc-term-title' }, t('terminal.title')),
1346
+ h('span', { className: 'ssc-mono ssc-term-who' }, who),
1347
+ h('span', { className: 'ssc-spacer' }),
1348
+ h('button', {
1349
+ type: 'button',
1350
+ className: 'ssc-btn ssc-btn-quiet',
1351
+ onClick: () => void create(),
1352
+ disabled: busy,
1353
+ title: t('terminal.new'),
1354
+ }, `+ ${t('terminal.new')}`),
1355
+ h('button', {
1356
+ type: 'button',
1357
+ className: `ssc-btn ssc-btn-quiet ${splitting && orientation === 'row' ? 'ssc-btn-on' : ''}`,
1358
+ onClick: () => void split('row'),
1359
+ disabled: busy || shown.length === 0,
1360
+ title: t('terminal.splitRow'),
1361
+ }, '◫'),
1362
+ h('button', {
1363
+ type: 'button',
1364
+ className: `ssc-btn ssc-btn-quiet ${splitting && orientation === 'column' ? 'ssc-btn-on' : ''}`,
1365
+ onClick: () => void split('column'),
1366
+ disabled: busy || shown.length === 0,
1367
+ title: t('terminal.splitColumn'),
1368
+ }, '⬒'),
1369
+ splitting
1370
+ ? h('button', {
1371
+ type: 'button',
1372
+ className: 'ssc-btn ssc-btn-quiet',
1373
+ onClick: unsplit,
1374
+ disabled: busy,
1375
+ }, t('terminal.unsplit'))
1376
+ : null,
1377
+ h('button', {
1378
+ type: 'button',
1379
+ className: 'ssc-btn ssc-btn-quiet',
1380
+ onClick: () => {
1381
+ const target = shown[focused] ?? shown[0]
1382
+ if (target !== undefined) void closeTerm(target)
1383
+ },
1384
+ disabled: busy || shown.length === 0,
1385
+ }, t('terminal.close'))),
1386
+ failure.length > 0 ? h('div', { className: 'ssc-hint ssc-fail', role: 'alert' }, failure) : null,
1387
+ termIds.length > 1
1388
+ ? h('div', { className: 'ssc-term-strip' }, termIds.map((termId) => {
1389
+ const slot = shown.indexOf(termId)
1390
+ return h('span', {
1391
+ key: termId,
1392
+ className: `ssc-tchip ${slot >= 0 ? 'ssc-tchip-on' : ''} ${slot >= 0 && slot === focused && splitting ? 'ssc-tchip-focus' : ''}`,
1393
+ role: 'button',
1394
+ tabIndex: 0,
1395
+ title: termId,
1396
+ onClick: () => show(termId),
1397
+ onKeyDown: (event) => {
1398
+ if (event.key === 'Enter' || event.key === ' ') {
1399
+ event.preventDefault()
1400
+ show(termId)
1401
+ }
1402
+ },
1403
+ },
1404
+ h('span', { className: 'ssc-mono' }, termId),
1405
+ slot < 0 ? null : h('span', { className: 'ssc-tchip-slot' }, slot === 0 ? '①' : '②'),
1406
+ h('button', {
1407
+ type: 'button',
1408
+ className: 'ssc-tchip-close',
1409
+ 'aria-label': t('terminal.close'),
1410
+ disabled: busy,
1411
+ onClick: (event) => {
1412
+ event.stopPropagation()
1413
+ void closeTerm(termId)
1414
+ },
1415
+ }, '×'))
1416
+ }))
1417
+ : null,
1418
+ termIds.length === 0
1419
+ ? h('div', { className: 'ssc-term-empty' },
1420
+ h('p', { className: 'ssc-hint' }, t('terminal.none')),
1421
+ h('button', { type: 'button', className: 'ssc-btn ssc-btn-primary', onClick: () => void create() }, `+ ${t('terminal.new')}`))
1422
+ : h('div', { className: `ssc-term-panes ssc-term-${orientation}` },
1423
+ ordered.map((termId) => {
1424
+ const slot = shown.indexOf(termId)
1425
+ const onScreen = slot >= 0
1426
+ return h('div', {
1427
+ key: termId,
1428
+ className: `ssc-pane ${onScreen && splitting && slot === focused ? 'ssc-pane-focused' : ''}`,
1429
+ style: onScreen ? undefined : { display: 'none' },
1430
+ onMouseDown: () => {
1431
+ if (onScreen) setFocused(slot)
1432
+ },
1433
+ }, h(TerminalPane, {
1434
+ serverId,
1435
+ termId,
1436
+ t,
1437
+ visible: visible && onScreen,
1438
+ focused: onScreen && slot === focused,
1439
+ }))
1440
+ }),
1441
+ spare && !splitting
1442
+ ? h('button', {
1443
+ type: 'button',
1444
+ className: 'ssc-pane-hint',
1445
+ onClick: () => void split('row'),
1446
+ title: t('terminal.splitRow'),
1447
+ }, '+')
1448
+ : null))
1449
+ }
1450
+
1451
+ /**
1452
+ * A centred dialog over a dimmed backdrop.
1453
+ *
1454
+ * The connection form used to sit inline above the tabs and pushed every
1455
+ * panel down while it was open. A dialog keeps the console in place: the
1456
+ * backdrop also makes it obvious that the form is modal, and Escape closes it.
1457
+ * @param props - title, close handler and the body.
1458
+ * @returns the dialog, or null when closed.
1459
+ */
1460
+ function Modal({ title, onClose, children }) {
1461
+ const panelRef = React.useRef(null)
1462
+
1463
+ React.useEffect(() => {
1464
+ const onKeyDown = (event) => {
1465
+ if (event.key === 'Escape') {
1466
+ event.stopPropagation()
1467
+ onClose()
1468
+ }
1469
+ }
1470
+ document.addEventListener('keydown', onKeyDown, true)
1471
+ const previous = document.activeElement
1472
+ const panel = panelRef.current
1473
+ const focusable = panel?.querySelector('select, input, button')
1474
+ ;(focusable ?? panel)?.focus()
1475
+ return () => {
1476
+ document.removeEventListener('keydown', onKeyDown, true)
1477
+ if (previous !== null && typeof previous.focus === 'function') previous.focus()
1478
+ }
1479
+ }, [onClose])
1480
+
1481
+ return h('div', {
1482
+ className: 'ssc-scrim',
1483
+ onClick: (event) => {
1484
+ if (event.target === event.currentTarget) onClose()
1485
+ },
1486
+ },
1487
+ h('div', {
1488
+ className: 'ssc-modal',
1489
+ role: 'dialog',
1490
+ 'aria-modal': 'true',
1491
+ 'aria-label': title,
1492
+ tabIndex: -1,
1493
+ ref: panelRef,
1494
+ },
1495
+ h('div', { className: 'ssc-modal-head' },
1496
+ h('h3', { className: 'ssc-modal-title' }, title),
1497
+ h('button', {
1498
+ type: 'button',
1499
+ className: 'ssc-modal-close',
1500
+ 'aria-label': title,
1501
+ onClick: onClose,
1502
+ }, '×')),
1503
+ h('div', { className: 'ssc-modal-body' }, children)))
1504
+ }
1505
+
1506
+ /**
1507
+ * The connection form: one labelled field per row, so the target, the
1508
+ * optional secret and the action each have room.
1509
+ * @param props - the catalogue, the current selection and the handlers.
1510
+ * @returns the form.
1511
+ */
1512
+ function Connector({ catalogue, selectedId, password, busy, onSelect, onPassword, onConnect, lead = true, t }) {
1513
+ const server = catalogue?.servers.find((entry) => entry.id === selectedId)
1514
+ return h('div', { className: 'ssc-connect' },
1515
+ lead
1516
+ ? h('div', { className: 'ssc-connect-lead' },
1517
+ h('h3', { className: 'ssc-connect-title' }, t('connect.title')),
1518
+ h('p', { className: 'ssc-connect-sub' }, t('connect.subtitle')))
1519
+ : null,
1520
+ h('label', { className: 'ssc-field' },
1521
+ h('span', { className: 'ssc-field-label' }, t('connect.server')),
1522
+ h('select', {
1523
+ className: 'ssc-control',
1524
+ value: selectedId,
1525
+ disabled: busy,
1526
+ onChange: (event) => onSelect(event.target.value),
1527
+ },
1528
+ catalogue === null || catalogue.servers.length === 0
1529
+ ? h('option', { value: '' }, t('connect.serverNone'))
1530
+ : catalogue.servers.map((entry) => h('option', { key: entry.id, value: entry.id },
1531
+ `${entry.label} — ${entry.user === undefined || entry.user === '' ? entry.host : `${entry.user}@${entry.host}`}:${entry.port}`))),
1532
+ server === undefined
1533
+ ? null
1534
+ : h('span', { className: 'ssc-field-hint' }, `${server.host}:${server.port}${
1535
+ server.source === 'ssh-config' ? ' · ~/.ssh/config' : ' · plugin config'
1536
+ }`)),
1537
+ h('label', { className: 'ssc-field' },
1538
+ h('span', { className: 'ssc-field-label' }, t('connect.password')),
1539
+ h('input', {
1540
+ className: 'ssc-control',
1541
+ type: 'password',
1542
+ value: password,
1543
+ autoComplete: 'off',
1544
+ placeholder: t('connect.passwordPlaceholder'),
1545
+ disabled: busy,
1546
+ onChange: (event) => onPassword(event.target.value),
1547
+ }),
1548
+ h('span', { className: 'ssc-field-hint' }, t('connect.passwordHint'))),
1549
+ h('div', { className: 'ssc-actions' },
1550
+ h('button', {
1551
+ type: 'button',
1552
+ className: 'ssc-btn ssc-btn-primary',
1553
+ onClick: onConnect,
1554
+ disabled: busy || selectedId.length === 0,
1555
+ }, busy ? t('connect.connecting') : t('connect.action'))))
1556
+ }
1557
+
1558
+ /**
1559
+ * The page itself, registered as the main panel under the sidebar id.
1560
+ * @param props - slot props; `t` is injected by the host's locale seat.
1561
+ * @returns the page.
1562
+ */
1563
+ function SshConsolePage(props) {
1564
+ const t = props?.t ?? makeFallbackTranslate(undefined)
1565
+ const [catalogue, setCatalogue] = React.useState(null)
1566
+ const [sessions, setSessions] = React.useState([])
1567
+ const [selectedId, setSelectedId] = React.useState('')
1568
+ const [activeId, setActiveId] = React.useState('')
1569
+ const [password, setPassword] = React.useState('')
1570
+ const [snapshots, setSnapshots] = React.useState({})
1571
+ const [tab, setTab] = React.useState('overview')
1572
+ const [busy, setBusy] = React.useState(false)
1573
+ const [connectorOpen, setConnectorOpen] = React.useState(false)
1574
+ const [failure, setFailure] = React.useState('')
1575
+
1576
+ const remember = React.useCallback((serverId) => {
1577
+ setSelectedId(serverId)
1578
+ try {
1579
+ if (typeof localStorage !== 'undefined') localStorage.setItem(STORAGE_KEY, serverId)
1580
+ } catch {
1581
+ // A blocked storage area only costs the remembered default.
1582
+ }
1583
+ }, [])
1584
+
1585
+ const refreshSessions = React.useCallback(async () => {
1586
+ const value = await call('status', {})
1587
+ const next = value.sessions ?? []
1588
+ setSessions(next)
1589
+ setActiveId((current) => (
1590
+ next.some((entry) => entry.serverId === current) ? current : (next[0]?.serverId ?? '')
1591
+ ))
1592
+ return next
1593
+ }, [])
1594
+
1595
+ React.useEffect(() => {
1596
+ let alive = true
1597
+ Promise.all([call('servers', {}), call('status', {})])
1598
+ .then(([nextCatalogue, nextStatus]) => {
1599
+ if (!alive) return
1600
+ setCatalogue(nextCatalogue)
1601
+ const next = nextStatus.sessions ?? []
1602
+ setSessions(next)
1603
+ setActiveId(next[0]?.serverId ?? '')
1604
+ let remembered = ''
1605
+ try {
1606
+ remembered = typeof localStorage === 'undefined' ? '' : (localStorage.getItem(STORAGE_KEY) ?? '')
1607
+ } catch {
1608
+ remembered = ''
1609
+ }
1610
+ const known = nextCatalogue.servers.some((entry) => entry.id === remembered)
1611
+ setSelectedId(known ? remembered : (nextCatalogue.servers[0]?.id ?? ''))
1612
+ })
1613
+ .catch((error) => {
1614
+ if (!alive) return
1615
+ setFailure(error instanceof Error ? error.message : String(error))
1616
+ })
1617
+ return () => {
1618
+ alive = false
1619
+ }
1620
+ }, [])
1621
+
1622
+ // Only the host on screen is sampled: with a tab per server the others
1623
+ // would be an SSH round trip per interval that nobody is looking at.
1624
+ const pollable = tab === 'overview' || tab === 'processes'
1625
+ const pollId = pollable ? activeId : ''
1626
+
1627
+ React.useEffect(() => {
1628
+ if (pollId.length === 0) return undefined
1629
+ let alive = true
1630
+ const tick = async () => {
1631
+ try {
1632
+ const snapshot = await call('metrics', { serverId: pollId })
1633
+ if (!alive) return
1634
+ setSnapshots((current) => ({ ...current, [pollId]: snapshot }))
1635
+ } catch (error) {
1636
+ if (!alive) return
1637
+ const message = error instanceof Error ? error.message : String(error)
1638
+ setSnapshots((current) => ({ ...current, [pollId]: { error: message } }))
1639
+ }
1640
+ }
1641
+ void tick()
1642
+ const timer = setInterval(() => void tick(), catalogue?.refreshMs ?? 2000)
1643
+ return () => {
1644
+ alive = false
1645
+ clearInterval(timer)
1646
+ }
1647
+ }, [pollId, catalogue?.refreshMs])
1648
+
1649
+ const connect = React.useCallback(async () => {
1650
+ if (selectedId.length === 0) return
1651
+ setBusy(true)
1652
+ setFailure('')
1653
+ try {
1654
+ const request = { serverId: selectedId }
1655
+ if (password.length > 0) request.password = password
1656
+ await call('connect', request)
1657
+ setPassword('')
1658
+ setConnectorOpen(false)
1659
+ await refreshSessions()
1660
+ setActiveId(selectedId)
1661
+ remember(selectedId)
1662
+ } catch (error) {
1663
+ setFailure(error instanceof Error ? error.message : String(error))
1664
+ } finally {
1665
+ setBusy(false)
1666
+ }
1667
+ }, [password, refreshSessions, remember, selectedId])
1668
+
1669
+ const disconnect = React.useCallback(async (serverId) => {
1670
+ setBusy(true)
1671
+ try {
1672
+ await call('disconnect', { serverId })
1673
+ setSnapshots((current) => {
1674
+ const next = { ...current }
1675
+ delete next[serverId]
1676
+ return next
1677
+ })
1678
+ await refreshSessions()
1679
+ } catch (error) {
1680
+ setFailure(error instanceof Error ? error.message : String(error))
1681
+ } finally {
1682
+ setBusy(false)
1683
+ }
1684
+ }, [refreshSessions])
1685
+
1686
+ const connected = sessions.length > 0
1687
+ const active = sessions.find((entry) => entry.serverId === activeId)
1688
+
1689
+ // Every connected host keeps a terminal mounted. A hidden one is skipped
1690
+ // by layout, so its emulator holds the scrollback without costing a draw.
1691
+ const terminalLayers = sessions.map((session) => {
1692
+ const server = session.server ?? {}
1693
+ const identity = `${server.user === undefined || server.user === '' ? '' : `${server.user}@`}${session.hostname ?? server.host ?? ''}`
1694
+ return h(TerminalTab, {
1695
+ key: `term:${session.serverId}`,
1696
+ serverId: session.serverId,
1697
+ who: identity,
1698
+ visible: tab === 'terminal' && session.serverId === activeId,
1699
+ t,
1700
+ })
1701
+ })
1702
+
1703
+ const header = h('div', { className: 'ssc-head' },
1704
+ h('span', { className: 'ssc-head-icon' }, h(ConsoleIcon, { size: 16 })),
1705
+ h('h2', { className: 'ssc-title' }, t('panel.title')),
1706
+ h('span', { className: 'ssc-spacer' }),
1707
+ h('span', { className: `ssc-pill ${connected ? 'ssc-pill-on' : ''}` },
1708
+ h('span', { className: 'ssc-dot' }),
1709
+ connected ? t('state.connected', { count: sessions.length }) : t('state.idle')))
1710
+
1711
+ if (catalogue !== null && catalogue.servers.length === 0) {
1712
+ return h('div', { className: 'ssc-page' },
1713
+ header,
1714
+ h('div', { className: 'ssc-body' },
1715
+ h('div', { className: 'ssc-empty' },
1716
+ h(ConsoleIcon, { size: 28 }),
1717
+ h('p', { className: 'ssc-empty-title' }, t('empty.title')),
1718
+ h('p', { className: 'ssc-empty-body' }, t('empty.body')))))
1719
+ }
1720
+
1721
+ const notice = failure.length === 0
1722
+ ? null
1723
+ : h('div', { className: 'ssc-notice', role: 'alert' },
1724
+ h('span', null, failure),
1725
+ h('button', {
1726
+ type: 'button',
1727
+ className: 'ssc-notice-close',
1728
+ 'aria-label': t('error.dismiss'),
1729
+ onClick: () => setFailure(''),
1730
+ }, '×'))
1731
+
1732
+ // The form is a dialog while hosts are already open; on the empty state it
1733
+ // is the page content, because there is nothing to keep in place.
1734
+ const connector = h(Connector, {
1735
+ catalogue,
1736
+ selectedId,
1737
+ password,
1738
+ busy,
1739
+ onSelect: remember,
1740
+ onPassword: setPassword,
1741
+ onConnect: () => void connect(),
1742
+ lead: !connected,
1743
+ t,
1744
+ })
1745
+ const connectorDialog = connected && connectorOpen
1746
+ ? h(Modal, { title: t('connect.title'), onClose: () => setConnectorOpen(false) }, connector)
1747
+ : null
1748
+
1749
+ const strip = connected
1750
+ ? h('div', { className: 'ssc-strip' },
1751
+ h('div', { className: 'ssc-chips' },
1752
+ sessions.map((session) => {
1753
+ const server = session.server ?? {}
1754
+ const label = session.hostname ?? server.label ?? server.host ?? session.serverId
1755
+ return h('span', {
1756
+ key: session.serverId,
1757
+ className: `ssc-chip ${session.serverId === activeId ? 'ssc-chip-on' : ''}`,
1758
+ role: 'button',
1759
+ tabIndex: 0,
1760
+ title: `${server.user === undefined || server.user === '' ? '' : `${server.user}@`}${server.host}:${server.port}`,
1761
+ onClick: () => setActiveId(session.serverId),
1762
+ onKeyDown: (event) => {
1763
+ if (event.key === 'Enter' || event.key === ' ') {
1764
+ event.preventDefault()
1765
+ setActiveId(session.serverId)
1766
+ }
1767
+ },
1768
+ },
1769
+ h('span', { className: 'ssc-dot' }),
1770
+ h('span', { className: 'ssc-chip-name' }, label),
1771
+ h('button', {
1772
+ type: 'button',
1773
+ className: 'ssc-chip-close',
1774
+ 'aria-label': t('connect.disconnect'),
1775
+ disabled: busy,
1776
+ onClick: (event) => {
1777
+ event.stopPropagation()
1778
+ void disconnect(session.serverId)
1779
+ },
1780
+ }, '×'))
1781
+ })),
1782
+ h('span', { className: 'ssc-spacer' }),
1783
+ h('button', {
1784
+ type: 'button',
1785
+ className: 'ssc-btn ssc-btn-quiet',
1786
+ onClick: () => setConnectorOpen(true),
1787
+ disabled: busy,
1788
+ }, `+ ${t('connect.add')}`))
1789
+ : null
1790
+
1791
+ return h('div', { className: 'ssc-page' },
1792
+ header,
1793
+ notice,
1794
+ strip,
1795
+ connectorDialog,
1796
+ connected
1797
+ ? h(React.Fragment, null,
1798
+ h('div', { className: 'ssc-tabs' },
1799
+ ['overview', 'processes', 'files', 'terminal'].map((key) => h('button', {
1800
+ key,
1801
+ type: 'button',
1802
+ className: `ssc-tab ${tab === key ? 'ssc-tab-on' : ''}`,
1803
+ onClick: () => setTab(key),
1804
+ }, t(`tab.${key}`)))),
1805
+ h('div', { className: `ssc-body ${tab === 'terminal' ? 'ssc-body-term' : ''}` },
1806
+ active === undefined
1807
+ ? h('div', { className: 'ssc-hint' }, t('files.loading'))
1808
+ : tab === 'overview'
1809
+ ? h(OverviewTab, { key: `overview:${activeId}`, session: active, snapshot: snapshots[activeId] ?? null, t })
1810
+ : tab === 'processes'
1811
+ ? h(ProcessesTab, { key: `processes:${activeId}`, session: active, snapshot: snapshots[activeId] ?? null, t })
1812
+ : tab === 'files'
1813
+ ? h(FilesTab, { key: `files:${activeId}`, serverId: activeId, t })
1814
+ : null,
1815
+ // One mounted terminal per host, kept alive across tab and host
1816
+ // switches. Only the selected one is laid out and visible, so the
1817
+ // others keep their scrollback without drawing.
1818
+ terminalLayers))
1819
+ : h('div', { className: 'ssc-body', style: { flex: 'none' } }, connector))
1820
+ }
1821
+
1822
+ /* ---------------------------------------------------------------- plugin */
1823
+
1824
+ /** Required Client services: slot registry, locale dictionaries, Remote carrier. */
1825
+ const inject = ['slots', 'locale', 'remote']
1826
+
1827
+ /**
1828
+ * Build one Remote descriptor whose parameter and result travel as JSON.
1829
+ *
1830
+ * The descriptor declares exactly the one `input` parameter every Host method
1831
+ * takes: the gateway rebuilds the argument object from these fields and
1832
+ * rejects a mismatch, so the count must track the Host signature.
1833
+ * @param method - Host method name.
1834
+ * @returns the descriptor.
1835
+ */
1836
+ function descriptorFor(method) {
1837
+ const endpoint = `${PKG}#${method}`
1838
+ return {
1839
+ id: endpoint,
1840
+ service: SERVICE_KEY,
1841
+ namespace: SERVICE_KEY,
1842
+ method,
1843
+ invocation: { kind: 'direct' },
1844
+ parameters: [{
1845
+ name: 'input',
1846
+ wire: 'input',
1847
+ source: 'json',
1848
+ codec: {
1849
+ mode: 'strict',
1850
+ typeSymbol: `${endpoint}:input`,
1851
+ create: () => z.any(),
1852
+ },
1853
+ }],
1854
+ result: {
1855
+ mode: 'strict',
1856
+ typeSymbol: `${endpoint}:result`,
1857
+ create: () => z.any(),
1858
+ },
1859
+ }
1860
+ }
1861
+
1862
+ /**
1863
+ * Mount the Remote namespace, bind it, then register the sidebar icon and the
1864
+ * panel. The namespace is a Client service created by the mount, so it is
1865
+ * reached through `ctx.inject` — `ctx.get` refuses a key the plugin did not
1866
+ * declare.
1867
+ * @param ctx - Client plugin context.
1868
+ */
1869
+ async function apply(ctx) {
1870
+ const carrier = ctx.get('remote')
1871
+ if (carrier === undefined) throw new Error('ssh-console: the Remote carrier service is unavailable')
1872
+ const unmountRemote = await carrier.$mount({
1873
+ package: PKG,
1874
+ descriptors: [
1875
+ descriptorFor('servers'),
1876
+ descriptorFor('connect'),
1877
+ descriptorFor('disconnect'),
1878
+ descriptorFor('status'),
1879
+ descriptorFor('listDir'),
1880
+ descriptorFor('readText'),
1881
+ descriptorFor('metrics'),
1882
+ descriptorFor('exec'),
1883
+ descriptorFor('termOpen'),
1884
+ descriptorFor('termList'),
1885
+ descriptorFor('termRead'),
1886
+ descriptorFor('termWrite'),
1887
+ descriptorFor('termResize'),
1888
+ descriptorFor('termClose'),
1889
+ ],
1890
+ })
1891
+
1892
+ const binding = ctx.inject([`remote.${SERVICE_KEY}`], (remoteCtx) => {
1893
+ api = remoteCtx.remote[SERVICE_KEY]
1894
+ remoteCtx.effect(() => () => {
1895
+ api = undefined
1896
+ }, 'ssh-console: namespace binding')
1897
+ })
1898
+
1899
+ ctx.effect(() => () => {
1900
+ try {
1901
+ binding?.dispose?.()
1902
+ } catch {
1903
+ // A binding that never activated needs no teardown.
1904
+ }
1905
+ void unmountRemote()
1906
+ }, 'ssh-console: remote namespace')
1907
+ ctx.effect(insertStyles, 'ssh-console: stylesheet')
1908
+ ctx.effect(() => ctx.locale.register(LOCALE_NS, { zh, en }), 'ssh-console: dictionaries')
1909
+
1910
+ const fallback = makeFallbackTranslate(ctx.locale)
1911
+ const label = () => {
1912
+ try {
1913
+ return fallback('panel.title')
1914
+ } catch {
1915
+ return 'SSH Console'
1916
+ }
1917
+ }
1918
+
1919
+ ctx.slots.inject('sidebar.panellist', function* () {
1920
+ yield ctx.slots.register({
1921
+ name: 'sidebar.panellist',
1922
+ id: 'ssh-console',
1923
+ order: 20,
1924
+ label,
1925
+ locale: LOCALE_NS,
1926
+ }, ConsolePanelIcon)
1927
+ })
1928
+ ctx.slots.inject('main', function* () {
1929
+ yield ctx.slots.register({
1930
+ name: 'main',
1931
+ key: 'ssh-console',
1932
+ locale: LOCALE_NS,
1933
+ }, SshConsolePage)
1934
+ })
1935
+ }
1936
+
1937
+ return { apply, inject }
1938
+ },
1939
+ })