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/LICENSE +21 -0
- package/README.md +164 -2
- package/THIRD-PARTY-NOTICES.md +59 -0
- package/client.js +1939 -0
- package/client.xterm.js +38 -0
- package/cordis.patch.yml +15 -0
- package/icon.svg +18 -0
- package/index.js +1495 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +71 -4
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
|
+
})
|