@hunan36/dsh-ssh-helper 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/client.js ADDED
@@ -0,0 +1,2786 @@
1
+ /**
2
+ * dsh-ssh-helper, browser half.
3
+ *
4
+ * One Settings section (`settings.section`, id `ssh-helper`, order 115) built
5
+ * as an operational console: a left rail for Accounts / Deployments / Activity,
6
+ * dense list rows, right-side detail and form sheets, a guarded-update note,
7
+ * and a copy-to-agent bridge. The page never executes updates or rollbacks:
8
+ * those stay approval-gated agent tools, so there is no update button here.
9
+ *
10
+ * The factory may request only `react`. Every control, icon and style is
11
+ * authored here; secrets never leave dialog state and request bodies.
12
+ *
13
+ * @module dsh-ssh-helper/client
14
+ */
15
+ window.__ModuleLoader__.load({
16
+ // Must equal the package name: the client module graph keys factories by
17
+ // manifest package name, so the loader row id (`ssh-helper`) is not enough.
18
+ id: '@hunan36/dsh-ssh-helper',
19
+ factory(require) {
20
+ const React = require('react')
21
+ const { useEffect, useRef, useState } = React
22
+ const h = React.createElement
23
+ const LOCALE_NS = 'sshHelper'
24
+ const STYLE_ID = 'dsh-ssh-helper-styles'
25
+ const API_PREFIX = 'ssh-helper/api/'
26
+ const LEGACY_DISMISS_KEY = 'dsh-ssh-helper:legacy-dismissed'
27
+
28
+ function readLegacyDismissed() {
29
+ try {
30
+ return window.localStorage.getItem(LEGACY_DISMISS_KEY) === '1'
31
+ } catch {
32
+ return false
33
+ }
34
+ }
35
+
36
+ function writeLegacyDismissed() {
37
+ try {
38
+ window.localStorage.setItem(LEGACY_DISMISS_KEY, '1')
39
+ } catch {
40
+ /* storage is optional */
41
+ }
42
+ }
43
+
44
+ // ---------------------------------------------------------------------
45
+ // Utilities
46
+ // ---------------------------------------------------------------------
47
+
48
+ function fmt(t, key, params) {
49
+ const value = t(key)
50
+ if (typeof value !== 'string') return key
51
+ return value.replace(/\{(\w+)\}/g, (match, name) => (
52
+ params !== undefined && Object.prototype.hasOwnProperty.call(params, name) ? String(params[name]) : match
53
+ ))
54
+ }
55
+
56
+ function text(value) {
57
+ return value === undefined || value === null ? '' : String(value)
58
+ }
59
+
60
+ function cls() {
61
+ const parts = []
62
+ for (let index = 0; index < arguments.length; index += 1) {
63
+ const value = arguments[index]
64
+ if (typeof value === 'string' && value !== '') parts.push(value)
65
+ }
66
+ return parts.join(' ')
67
+ }
68
+
69
+ function errorMessage(caught, fallback) {
70
+ if (caught !== null && typeof caught === 'object' && typeof caught.message === 'string' && caught.message !== '') return caught.message
71
+ return fallback
72
+ }
73
+
74
+ async function call(method, body, signal) {
75
+ let response
76
+ try {
77
+ response = await fetch(new URL(API_PREFIX + method, document.baseURI), {
78
+ method: 'POST',
79
+ headers: { 'content-type': 'application/json' },
80
+ body: JSON.stringify(body || {}),
81
+ ...(signal === undefined ? {} : { signal }),
82
+ })
83
+ } catch (caught) {
84
+ const offline = new Error('network')
85
+ offline.code = 'network'
86
+ throw offline
87
+ }
88
+ const payload = await response.json().catch(() => null)
89
+ if (!response.ok || payload === null || payload.ok !== true) {
90
+ const failure = payload !== null && typeof payload === 'object' ? payload.error : undefined
91
+ const message = failure !== undefined && typeof failure.message === 'string' && failure.message !== ''
92
+ ? failure.message
93
+ : `request failed (${response.status})`
94
+ const error = new Error(message)
95
+ error.code = failure !== undefined && typeof failure.code === 'string' ? failure.code : 'request-failed'
96
+ throw error
97
+ }
98
+ return payload.data
99
+ }
100
+
101
+ async function copyText(value) {
102
+ const source = text(value)
103
+ if (source === '') return
104
+ if (typeof navigator !== 'undefined' && navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
105
+ try {
106
+ await navigator.clipboard.writeText(source)
107
+ return
108
+ } catch {
109
+ /* fall back to the selection copy below */
110
+ }
111
+ }
112
+ const area = document.createElement('textarea')
113
+ area.value = source
114
+ area.setAttribute('readonly', 'true')
115
+ area.style.position = 'fixed'
116
+ area.style.opacity = '0'
117
+ document.body.appendChild(area)
118
+ area.select()
119
+ const copied = typeof document.execCommand === 'function' ? document.execCommand('copy') : false
120
+ document.body.removeChild(area)
121
+ if (!copied) throw new Error('copy failed')
122
+ }
123
+
124
+ function isEditable(target) {
125
+ if (!target || typeof target !== 'object') return false
126
+ const tag = typeof target.tagName === 'string' ? target.tagName.toLowerCase() : ''
127
+ if (tag === 'input' || tag === 'textarea' || tag === 'select') return true
128
+ return target.isContentEditable === true
129
+ }
130
+
131
+ function isInside(parent, candidate) {
132
+ if (typeof parent !== 'string' || typeof candidate !== 'string' || candidate === '') return false
133
+ try {
134
+ const normalizedParent = new URL(parent, 'file:///').pathname
135
+ const normalizedCandidate = new URL(candidate, 'file:///').pathname
136
+ return normalizedCandidate === normalizedParent || normalizedCandidate.startsWith(normalizedParent.replace(/\/$/, '') + '/')
137
+ } catch {
138
+ return false
139
+ }
140
+ }
141
+
142
+ function statusMeta(status) {
143
+ if (status === 'success') return { tone: 'success', key: 'statusSuccess' }
144
+ if (status === 'partial') return { tone: 'warn', key: 'statusPartial' }
145
+ if (status === 'failed') return { tone: 'error', key: 'statusFailed' }
146
+ if (status === 'blocked') return { tone: 'error', key: 'statusBlocked' }
147
+ return { tone: 'idle', key: 'statusNever' }
148
+ }
149
+
150
+ function matchStatusFilter(status, filter) {
151
+ if (filter === 'all') return true
152
+ if (filter === 'never') return status === undefined || status === null || status === ''
153
+ if (filter === 'issues') return status === 'partial' || status === 'failed' || status === 'blocked'
154
+ return status === filter
155
+ }
156
+
157
+ function formatRelative(iso, t) {
158
+ if (typeof iso !== 'string' || iso === '') return t('statNone')
159
+ const time = Date.parse(iso)
160
+ if (!Number.isFinite(time)) return t('statNone')
161
+ const diff = Date.now() - time
162
+ const abs = Math.abs(diff)
163
+ if (abs < 45_000) return t('relativeJustNow')
164
+ const minutes = Math.max(1, Math.round(abs / 60_000))
165
+ if (minutes < 60) return fmt(t, 'relativeMinutes', { count: minutes })
166
+ const hours = Math.max(1, Math.round(minutes / 60))
167
+ if (hours < 24) return fmt(t, 'relativeHours', { count: hours })
168
+ const days = Math.max(1, Math.round(hours / 24))
169
+ if (days < 31) return fmt(t, 'relativeDays', { count: days })
170
+ return new Date(time).toLocaleDateString()
171
+ }
172
+
173
+ function formatDateTime(iso) {
174
+ if (typeof iso !== 'string' || iso === '') return '—'
175
+ const time = Date.parse(iso)
176
+ if (!Number.isFinite(time)) return iso
177
+ return new Date(time).toLocaleString()
178
+ }
179
+
180
+ function formatTimeOnly(iso) {
181
+ if (typeof iso !== 'string' || iso === '') return '—'
182
+ const time = Date.parse(iso)
183
+ if (!Number.isFinite(time)) return iso
184
+ return new Date(time).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
185
+ }
186
+
187
+ function formatDay(iso) {
188
+ if (typeof iso !== 'string' || iso === '') return '—'
189
+ const time = Date.parse(iso)
190
+ if (!Number.isFinite(time)) return iso
191
+ return new Date(time).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })
192
+ }
193
+
194
+ function formatDuration(value) {
195
+ const ms = Number(value)
196
+ if (!Number.isFinite(ms) || ms < 0) return '—'
197
+ if (ms < 1000) return `${Math.round(ms)} ms`
198
+ if (ms < 60_000) return `${(ms / 1000).toFixed(1)} s`
199
+ return `${Math.floor(ms / 60_000)}m ${Math.round((ms % 60_000) / 1000)}s`
200
+ }
201
+
202
+ function groupHistoryByDay(entries) {
203
+ const groups = []
204
+ const index = new Map()
205
+ for (const entry of entries) {
206
+ const key = formatDay(entry.startedAt)
207
+ if (!index.has(key)) {
208
+ const group = { key, entries: [] }
209
+ index.set(key, group)
210
+ groups.push(group)
211
+ }
212
+ index.get(key).entries.push(entry)
213
+ }
214
+ return groups
215
+ }
216
+
217
+ function actionLabel(t, action) {
218
+ if (action === 'rollback') return t('actionRollback')
219
+ if (action === 'upload') return t('actionUpload')
220
+ return t('actionApply')
221
+ }
222
+
223
+ function credentialLabel(t, account) {
224
+ if (account.authType === 'key') return account.hasKey ? t('hasKey') : t('noSecret')
225
+ return account.hasPassword ? t('hasPassword') : t('noSecret')
226
+ }
227
+
228
+ function sshCommand(account) {
229
+ return `ssh -p ${account.port || 22} ${account.username}@${account.host}`
230
+ }
231
+
232
+ function promptForUpdate(t, resource) {
233
+ return fmt(t, 'promptUpdate', { name: resource.name })
234
+ }
235
+
236
+ function promptForDiscovery(t) {
237
+ return t('promptDiscovery')
238
+ }
239
+
240
+ // ---------------------------------------------------------------------
241
+ // Icons
242
+ // ---------------------------------------------------------------------
243
+
244
+ const ICON_PARTS = {
245
+ terminal: [['rect', { x: 3, y: 4, width: 18, height: 16, rx: 2.5 }], ['path', { d: 'M7 9l3 3-3 3' }], ['path', { d: 'M13 15h4' }]],
246
+ server: [['rect', { x: 3, y: 4, width: 18, height: 6.5, rx: 2 }], ['rect', { x: 3, y: 13.5, width: 18, height: 6.5, rx: 2 }], ['path', { d: 'M7 7.25h.01M7 16.75h.01' }]],
247
+ history: [['path', { d: 'M3 12a9 9 0 1 0 3-6.7' }], ['path', { d: 'M3 4v5h5' }], ['path', { d: 'M12 8v4l3 2' }]],
248
+ plus: [['path', { d: 'M12 5v14M5 12h14' }]],
249
+ search: [['circle', { cx: 11, cy: 11, r: 6.5 }], ['path', { d: 'M16 16l4 4' }]],
250
+ refresh: [['path', { d: 'M20 11a8 8 0 1 1-2.3-5.7' }], ['path', { d: 'M20 4v7h-7' }]],
251
+ copy: [['rect', { x: 9, y: 9, width: 11, height: 11, rx: 2 }], ['path', { d: 'M5 15V5a2 2 0 0 1 2-2h10' }]],
252
+ check: [['path', { d: 'M4 12.5l5 5L20 6.5' }]],
253
+ x: [['path', { d: 'M6 6l12 12M18 6L6 18' }]],
254
+ chevron: [['path', { d: 'M9 6l6 6-6 6' }]],
255
+ chevronDown: [['path', { d: 'M6 9l6 6 6-6' }]],
256
+ edit: [['path', { d: 'M4 20h4l10.5-10.5a2.1 2.1 0 0 0-3-3L5 17v3z' }], ['path', { d: 'M13.5 6.5l4 4' }]],
257
+ trash: [['path', { d: 'M4 7h16' }], ['path', { d: 'M10 11v6M14 11v6' }], ['path', { d: 'M6 7l1 13h10l1-13' }], ['path', { d: 'M9 7V4h6v3' }]],
258
+ play: [['path', { d: 'M8 5l11 7-11 7z' }]],
259
+ shield: [['path', { d: 'M12 3l7 3v5.5c0 4.4-2.8 7.6-7 9.5-4.2-1.9-7-5.1-7-9.5V6z' }], ['path', { d: 'M9 12l2 2 4-4' }]],
260
+ clock: [['circle', { cx: 12, cy: 12, r: 8 }], ['path', { d: 'M12 8v4l3 2' }]],
261
+ alert: [['path', { d: 'M12 4l9 16H3z' }], ['path', { d: 'M12 10v4M12 17h.01' }]],
262
+ folder: [['path', { d: 'M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z' }]],
263
+ key: [['circle', { cx: 8, cy: 15, r: 3.5 }], ['path', { d: 'M10.5 12.5L20 3M16 7l2 2M13 10l2 2' }]],
264
+ lock: [['rect', { x: 5, y: 10, width: 14, height: 10, rx: 2 }], ['path', { d: 'M8 10V7a4 4 0 0 1 8 0v3' }]],
265
+ dots: [['circle', { cx: 5, cy: 12, r: 1.3, fill: 'currentColor', stroke: 'none' }], ['circle', { cx: 12, cy: 12, r: 1.3, fill: 'currentColor', stroke: 'none' }], ['circle', { cx: 19, cy: 12, r: 1.3, fill: 'currentColor', stroke: 'none' }]],
266
+ }
267
+
268
+ function Icon(props) {
269
+ const size = props.size === undefined ? 16 : props.size
270
+ const parts = ICON_PARTS[props.name] || []
271
+ return h('svg', {
272
+ className: props.className,
273
+ width: size,
274
+ height: size,
275
+ viewBox: '0 0 24 24',
276
+ fill: 'none',
277
+ stroke: 'currentColor',
278
+ strokeWidth: 1.8,
279
+ strokeLinecap: 'round',
280
+ strokeLinejoin: 'round',
281
+ 'aria-hidden': 'true',
282
+ focusable: 'false',
283
+ }, parts.map((part, index) => h(part[0], { key: index, ...part[1] })))
284
+ }
285
+
286
+ // ---------------------------------------------------------------------
287
+ // Locale
288
+ // ---------------------------------------------------------------------
289
+
290
+ const zh = {
291
+ nav: 'SSH 助手',
292
+ title: 'SSH 助手',
293
+ subtitle: '账户、部署目录与受控更新',
294
+ approvalPill: '更新需审批',
295
+ statServers: '服务器',
296
+ statDeployments: '部署资源',
297
+ statLastUpdate: '最近更新',
298
+ statNone: '暂无记录',
299
+ viewAccounts: '账户',
300
+ viewDeployments: '部署资源',
301
+ viewActivity: '更新记录',
302
+ accountsTitle: '服务器账户',
303
+ accountsSubtitle: '管理 SSH 连接;凭据只保存在本机宿主侧。',
304
+ deploymentsTitle: '部署资源',
305
+ deploymentsSubtitle: '采集到的服务目录、更新命令与备份策略。',
306
+ activityTitle: '更新记录',
307
+ activitySubtitle: '每次执行更新的步骤、耗时与输出。',
308
+ addServer: '添加服务器',
309
+ addDeployment: '添加部署',
310
+ testAll: '全部测试',
311
+ importTitle: '发现旧版账户',
312
+ importDesc: '在 SSH Manager 中找到 {count} 个账户,可一键导入。',
313
+ importAction: '导入 {count} 个',
314
+ importing: '导入中…',
315
+ dismiss: '忽略',
316
+ searchAccounts: '搜索名称、主机或用户',
317
+ searchDeployments: '搜索部署、服务器或目录',
318
+ searchActivity: '搜索资源、服务器或状态',
319
+ filterAll: '全部',
320
+ filterSuccess: '成功',
321
+ filterIssues: '异常',
322
+ filterNever: '未更新',
323
+ filterPartial: '部分完成',
324
+ filterFailed: '失败',
325
+ filterBlocked: '已阻塞',
326
+ refresh: '刷新',
327
+ loading: '正在加载…',
328
+ loadFailed: '加载失败',
329
+ retry: '重试',
330
+ partialLoad: '部分数据加载失败,可刷新重试。',
331
+ emptyAccountsTitle: '还没有服务器账户',
332
+ emptyAccountsDesc: '添加一台 Linux 服务器,之后即可在会话中采集部署目录。',
333
+ emptyDeploymentsTitle: '还没有部署资源',
334
+ emptyDeploymentsDesc: '在会话中让智能体扫描服务器,或手动添加一个部署目录。',
335
+ emptyDeploymentsNoServer: '先添加一台服务器,才能登记部署目录。',
336
+ emptyActivityTitle: '还没有更新记录',
337
+ emptyActivityDesc: '在会话中通过审批执行更新后,记录会出现在这里。',
338
+ emptySearchTitle: '没有匹配结果',
339
+ emptySearchDesc: '试试更短的关键词或清除筛选。',
340
+ clearFilters: '清除筛选',
341
+ goToAccounts: '去添加服务器',
342
+ open: '查看',
343
+ test: '测试',
344
+ testing: '测试中…',
345
+ testOk: '连接正常',
346
+ testFail: '连接失败',
347
+ edit: '编辑',
348
+ delete: '删除',
349
+ cancel: '取消',
350
+ save: '保存',
351
+ saving: '保存中…',
352
+ close: '关闭',
353
+ copy: '复制',
354
+ copied: '已复制',
355
+ copyFailed: '复制失败',
356
+ copySsh: '复制 SSH 命令',
357
+ copyPath: '复制目录',
358
+ copyCommand: '复制命令',
359
+ copyUpdatePrompt: '复制更新提示词',
360
+ copyDiscoveryPrompt: '复制采集提示词',
361
+ deleteServerTitle: '删除服务器?',
362
+ deleteServerDesc: '删除后,引用它的部署资源将无法更新。',
363
+ deleteDeploymentTitle: '删除部署资源?',
364
+ deleteDeploymentDesc: '只删除登记信息,不会改动远端目录或历史记录。',
365
+ confirmDelete: '确认删除',
366
+ deleting: '删除中…',
367
+ accountAddTitle: '添加服务器',
368
+ accountEditTitle: '编辑服务器',
369
+ resourceAddTitle: '添加部署资源',
370
+ resourceEditTitle: '编辑部署资源',
371
+ accountDetail: '服务器详情',
372
+ resourceDetail: '部署详情',
373
+ sectionConnection: '连接',
374
+ sectionDeployment: '部署',
375
+ sectionCommands: '命令',
376
+ sectionBackup: '备份',
377
+ sectionDiscovery: '采集信息',
378
+ sectionRecentActivity: '最近更新',
379
+ sectionNote: '备注',
380
+ sectionDanger: '危险操作',
381
+ fieldName: '名称',
382
+ fieldNameHint: '留空则使用 用户名@主机',
383
+ fieldHost: '主机 / IP',
384
+ fieldPort: '端口',
385
+ fieldUsername: '用户名',
386
+ fieldAuthType: '认证方式',
387
+ authPassword: '密码',
388
+ authKey: '私钥',
389
+ fieldPassword: '密码',
390
+ fieldPrivateKey: '私钥',
391
+ fieldPassphrase: '私钥口令',
392
+ fieldNote: '备注',
393
+ fieldServer: '服务器',
394
+ fieldServiceName: '服务名',
395
+ fieldAppDir: '部署目录',
396
+ fieldUpdate: '更新命令',
397
+ fieldRestart: '重启命令',
398
+ fieldVerify: '校验命令',
399
+ fieldBackupEnabled: '更新前备份',
400
+ fieldBackupDir: '备份目录',
401
+ fieldBackupKeep: '保留份数',
402
+ fieldTimeout: '超时(秒)',
403
+ fieldTags: '标签',
404
+ tagsHint: '用逗号分隔,例如 web, nginx',
405
+ secretKeepHint: '留空表示保持已保存的凭据',
406
+ secretRequiredHint: '首次添加或切换认证方式时必须填写',
407
+ hasPassword: '已保存密码',
408
+ hasKey: '已保存私钥',
409
+ noSecret: '未保存凭据',
410
+ testDraft: '测试连接',
411
+ testDraftOk: '连接成功',
412
+ testDraftFail: '连接失败',
413
+ formHintAccount: '密码 / 私钥只保存在本机,不会出现在工具结果或日志中。',
414
+ formHintResource: '这里只保存元数据与命令,不会执行任何远端操作。',
415
+ validationRequired: '必填',
416
+ validationHost: '请填写主机地址',
417
+ validationPort: '端口需为 1–65535 的整数',
418
+ validationUsername: '请填写用户名',
419
+ validationPassword: '请填写密码',
420
+ validationKey: '请粘贴私钥',
421
+ validationName: '请填写名称',
422
+ validationServer: '请选择服务器',
423
+ validationAppDir: '请填写绝对路径,例如 /opt/web-api',
424
+ validationUpdate: '请填写更新命令',
425
+ validationBackupDir: '备份目录需为绝对路径',
426
+ validationBackupKeep: '保留份数需为 1–50',
427
+ validationTimeout: '超时需为 30–3600 秒',
428
+ statusSuccess: '成功',
429
+ statusPartial: '部分完成',
430
+ statusFailed: '失败',
431
+ statusBlocked: '已阻塞',
432
+ statusNever: '未更新',
433
+ statusTesting: '测试中',
434
+ actionApply: '更新',
435
+ actionRollback: '回滚',
436
+ actionUpload: '上传',
437
+ lastUpdated: '最近更新',
438
+ neverUpdated: '尚未更新',
439
+ updatedAt: '更新于',
440
+ relativeJustNow: '刚刚',
441
+ relativeMinutes: '{count} 分钟前',
442
+ relativeHours: '{count} 小时前',
443
+ relativeDays: '{count} 天前',
444
+ unknown: '未知',
445
+ unknownServer: '未知服务器',
446
+ accountCount: '{count} 台服务器',
447
+ deploymentCount: '{count} 个部署',
448
+ historyCount: '{count} 条记录',
449
+ backupOn: '已启用',
450
+ backupOff: '未启用',
451
+ backupKeepValue: '保留 {count} 份',
452
+ timeoutValue: '{count} 秒',
453
+ serviceValue: '服务 {name}',
454
+ discoveredAt: '采集于 {time}',
455
+ markers: '标记',
456
+ gitRemote: 'Git 远程',
457
+ gitBranch: '分支',
458
+ serviceUnits: '服务单元',
459
+ noDiscovery: '还没有采集信息。可以在会话中运行 ssh_helper_discover。',
460
+ noActivity: '这个部署还没有更新记录。',
461
+ viewAllActivity: '查看全部记录',
462
+ steps: '步骤',
463
+ exitCode: '退出码 {code}',
464
+ duration: '耗时 {value}',
465
+ output: '输出',
466
+ stdout: 'stdout',
467
+ stderr: 'stderr',
468
+ noOutput: '(无输出)',
469
+ showMore: '展开全部',
470
+ showLess: '收起',
471
+ approvalNote: '更新必须经过审批',
472
+ approvalNoteDesc: '执行更新或回滚时,Harness 会要求你确认目标目录与命令;拒绝则不会发生任何远端变更。',
473
+ toastSaved: '已保存',
474
+ toastDeleted: '已删除',
475
+ toastImported: '已导入 {imported} 个账户,跳过 {skipped} 个',
476
+ toastTested: '连接测试完成',
477
+ toastCopied: '已复制',
478
+ toastCopyFailed: '复制失败',
479
+ toastRefreshFailed: '刷新失败',
480
+ toastLoadFailed: '加载失败',
481
+ toastSaveFailed: '保存失败',
482
+ toastDeleteFailed: '删除失败',
483
+ toastTestFailed: '测试失败',
484
+ toastImportFailed: '导入失败',
485
+ legacyFound: '发现 {count} 个 SSH Manager 账户',
486
+ legacyImported: '已导入 {count} 个账户',
487
+ legacySkipped: '已跳过 {count} 个重复或无效账户',
488
+ promptUpdate: '请为部署资源「{name}」生成更新计划:先调用 ssh_helper_update_plan 做只读预检,把计划给我确认;不要直接执行更新。',
489
+ promptDiscovery: '请在已配置的 SSH 服务器上扫描部署目录,使用 ssh_helper_discover 的 scan 和 inspect,然后把选中的目录保存为部署资源。',
490
+ promptCopied: '提示词已复制,可粘贴到会话中',
491
+ }
492
+
493
+ const en = {
494
+ nav: 'SSH Helper',
495
+ title: 'SSH Helper',
496
+ subtitle: 'Accounts, deployment directories and guarded updates',
497
+ approvalPill: 'Approval-gated',
498
+ statServers: 'Servers',
499
+ statDeployments: 'Deployments',
500
+ statLastUpdate: 'Last update',
501
+ statNone: 'No records',
502
+ viewAccounts: 'Accounts',
503
+ viewDeployments: 'Deployments',
504
+ viewActivity: 'Activity',
505
+ accountsTitle: 'Server accounts',
506
+ accountsSubtitle: 'Manage SSH connections; credentials stay on the host.',
507
+ deploymentsTitle: 'Deployment resources',
508
+ deploymentsSubtitle: 'Discovered directories, update commands and backup policy.',
509
+ activityTitle: 'Update activity',
510
+ activitySubtitle: 'Steps, duration and output of every update run.',
511
+ addServer: 'Add server',
512
+ addDeployment: 'Add deployment',
513
+ testAll: 'Test all',
514
+ importTitle: 'Legacy accounts found',
515
+ importDesc: '{count} accounts found in SSH Manager. Import them in one click.',
516
+ importAction: 'Import {count}',
517
+ importing: 'Importing…',
518
+ dismiss: 'Dismiss',
519
+ searchAccounts: 'Search name, host or user',
520
+ searchDeployments: 'Search deployment, server or directory',
521
+ searchActivity: 'Search resource, server or status',
522
+ filterAll: 'All',
523
+ filterSuccess: 'Success',
524
+ filterIssues: 'Issues',
525
+ filterNever: 'Never',
526
+ filterPartial: 'Partial',
527
+ filterFailed: 'Failed',
528
+ filterBlocked: 'Blocked',
529
+ refresh: 'Refresh',
530
+ loading: 'Loading…',
531
+ loadFailed: 'Failed to load',
532
+ retry: 'Retry',
533
+ partialLoad: 'Some data failed to load. Refresh to retry.',
534
+ emptyAccountsTitle: 'No server accounts yet',
535
+ emptyAccountsDesc: 'Add a Linux server, then discover deployment directories in a session.',
536
+ emptyDeploymentsTitle: 'No deployments yet',
537
+ emptyDeploymentsDesc: 'Ask the agent to scan a server in a session, or add a directory manually.',
538
+ emptyDeploymentsNoServer: 'Add a server first to register a deployment directory.',
539
+ emptyActivityTitle: 'No update activity yet',
540
+ emptyActivityDesc: 'After an approval-gated update runs in a session, it appears here.',
541
+ emptySearchTitle: 'No matches',
542
+ emptySearchDesc: 'Try a shorter keyword or clear the filters.',
543
+ clearFilters: 'Clear filters',
544
+ goToAccounts: 'Go to accounts',
545
+ open: 'Open',
546
+ test: 'Test',
547
+ testing: 'Testing…',
548
+ testOk: 'Connected',
549
+ testFail: 'Connection failed',
550
+ edit: 'Edit',
551
+ delete: 'Delete',
552
+ cancel: 'Cancel',
553
+ save: 'Save',
554
+ saving: 'Saving…',
555
+ close: 'Close',
556
+ copy: 'Copy',
557
+ copied: 'Copied',
558
+ copyFailed: 'Copy failed',
559
+ copySsh: 'Copy SSH command',
560
+ copyPath: 'Copy directory',
561
+ copyCommand: 'Copy command',
562
+ copyUpdatePrompt: 'Copy update prompt',
563
+ copyDiscoveryPrompt: 'Copy discovery prompt',
564
+ deleteServerTitle: 'Delete server?',
565
+ deleteServerDesc: 'Deployments that reference this server will no longer update.',
566
+ deleteDeploymentTitle: 'Delete deployment?',
567
+ deleteDeploymentDesc: 'Only the catalogue entry is removed; the remote directory and history stay.',
568
+ confirmDelete: 'Delete',
569
+ deleting: 'Deleting…',
570
+ accountAddTitle: 'Add server',
571
+ accountEditTitle: 'Edit server',
572
+ resourceAddTitle: 'Add deployment',
573
+ resourceEditTitle: 'Edit deployment',
574
+ accountDetail: 'Server details',
575
+ resourceDetail: 'Deployment details',
576
+ sectionConnection: 'Connection',
577
+ sectionDeployment: 'Deployment',
578
+ sectionCommands: 'Commands',
579
+ sectionBackup: 'Backup',
580
+ sectionDiscovery: 'Discovered facts',
581
+ sectionRecentActivity: 'Recent activity',
582
+ sectionNote: 'Note',
583
+ sectionDanger: 'Danger zone',
584
+ fieldName: 'Name',
585
+ fieldNameHint: 'Leave empty to use user@host',
586
+ fieldHost: 'Host / IP',
587
+ fieldPort: 'Port',
588
+ fieldUsername: 'Username',
589
+ fieldAuthType: 'Authentication',
590
+ authPassword: 'Password',
591
+ authKey: 'Private key',
592
+ fieldPassword: 'Password',
593
+ fieldPrivateKey: 'Private key',
594
+ fieldPassphrase: 'Key passphrase',
595
+ fieldNote: 'Note',
596
+ fieldServer: 'Server',
597
+ fieldServiceName: 'Service',
598
+ fieldAppDir: 'Deployment directory',
599
+ fieldUpdate: 'Update command',
600
+ fieldRestart: 'Restart command',
601
+ fieldVerify: 'Verify command',
602
+ fieldBackupEnabled: 'Back up before update',
603
+ fieldBackupDir: 'Backup directory',
604
+ fieldBackupKeep: 'Backups to keep',
605
+ fieldTimeout: 'Timeout (seconds)',
606
+ fieldTags: 'Tags',
607
+ tagsHint: 'Comma separated, e.g. web, nginx',
608
+ secretKeepHint: 'Leave empty to keep the saved credential',
609
+ secretRequiredHint: 'Required for a new account or when switching authentication',
610
+ hasPassword: 'Password saved',
611
+ hasKey: 'Key saved',
612
+ noSecret: 'No credential',
613
+ testDraft: 'Test connection',
614
+ testDraftOk: 'Connected',
615
+ testDraftFail: 'Connection failed',
616
+ formHintAccount: 'Passwords and keys stay on this host; they never appear in tool results or logs.',
617
+ formHintResource: 'Only metadata and commands are saved here. Nothing runs on the remote host.',
618
+ validationRequired: 'Required',
619
+ validationHost: 'Enter a host',
620
+ validationPort: 'Port must be an integer from 1 to 65535',
621
+ validationUsername: 'Enter a username',
622
+ validationPassword: 'Enter a password',
623
+ validationKey: 'Paste a private key',
624
+ validationName: 'Enter a name',
625
+ validationServer: 'Choose a server',
626
+ validationAppDir: 'Enter an absolute path, e.g. /opt/web-api',
627
+ validationUpdate: 'Enter an update command',
628
+ validationBackupDir: 'Backup directory must be absolute',
629
+ validationBackupKeep: 'Keep between 1 and 50 backups',
630
+ validationTimeout: 'Timeout must be 30–3600 seconds',
631
+ statusSuccess: 'Success',
632
+ statusPartial: 'Partial',
633
+ statusFailed: 'Failed',
634
+ statusBlocked: 'Blocked',
635
+ statusNever: 'Never',
636
+ statusTesting: 'Testing',
637
+ actionApply: 'Update',
638
+ actionRollback: 'Rollback',
639
+ actionUpload: 'Upload',
640
+ lastUpdated: 'Last update',
641
+ neverUpdated: 'Never updated',
642
+ updatedAt: 'Updated',
643
+ relativeJustNow: 'just now',
644
+ relativeMinutes: '{count} min ago',
645
+ relativeHours: '{count} h ago',
646
+ relativeDays: '{count} d ago',
647
+ unknown: 'Unknown',
648
+ unknownServer: 'Unknown server',
649
+ accountCount: '{count} servers',
650
+ deploymentCount: '{count} deployments',
651
+ historyCount: '{count} records',
652
+ backupOn: 'Enabled',
653
+ backupOff: 'Disabled',
654
+ backupKeepValue: 'Keep {count}',
655
+ timeoutValue: '{count} s',
656
+ serviceValue: 'Service {name}',
657
+ discoveredAt: 'Discovered {time}',
658
+ markers: 'Markers',
659
+ gitRemote: 'Git remote',
660
+ gitBranch: 'Branch',
661
+ serviceUnits: 'Service units',
662
+ noDiscovery: 'No discovery facts yet. Run ssh_helper_discover in a session.',
663
+ noActivity: 'This deployment has no update activity yet.',
664
+ viewAllActivity: 'View all activity',
665
+ steps: 'Steps',
666
+ exitCode: 'Exit {code}',
667
+ duration: 'Duration {value}',
668
+ output: 'Output',
669
+ stdout: 'stdout',
670
+ stderr: 'stderr',
671
+ noOutput: '(no output)',
672
+ showMore: 'Show all',
673
+ showLess: 'Show less',
674
+ approvalNote: 'Updates need approval',
675
+ approvalNoteDesc: 'Before an update or rollback, Harness asks you to confirm the directory and command. Rejecting it changes nothing remotely.',
676
+ toastSaved: 'Saved',
677
+ toastDeleted: 'Deleted',
678
+ toastImported: 'Imported {imported} accounts, skipped {skipped}',
679
+ toastTested: 'Connection tests finished',
680
+ toastCopied: 'Copied',
681
+ toastCopyFailed: 'Copy failed',
682
+ toastRefreshFailed: 'Refresh failed',
683
+ toastLoadFailed: 'Failed to load',
684
+ toastSaveFailed: 'Save failed',
685
+ toastDeleteFailed: 'Delete failed',
686
+ toastTestFailed: 'Test failed',
687
+ toastImportFailed: 'Import failed',
688
+ legacyFound: '{count} SSH Manager accounts found',
689
+ legacyImported: '{count} accounts imported',
690
+ legacySkipped: '{count} duplicate or invalid accounts skipped',
691
+ promptUpdate: 'Plan an update for deployment "{name}": call ssh_helper_update_plan for a read-only preflight first, show me the plan, and do not execute the update directly.',
692
+ promptDiscovery: 'Scan the configured SSH servers for deployment directories with ssh_helper_discover (scan and inspect), then save the selected directory as a deployment resource.',
693
+ promptCopied: 'Prompt copied; paste it into a session',
694
+ }
695
+
696
+ // ---------------------------------------------------------------------
697
+ // Styles
698
+ // ---------------------------------------------------------------------
699
+
700
+ const CSS = `
701
+ .dsh-ssh-helper-page {
702
+ /* Theme tokens live on the page root because the side sheet is a sibling
703
+ of the section node; defining them on .dsh-ssh-helper-section left every
704
+ --ssh-* reference inside the sheet unresolved. */
705
+ --ssh-accent: var(--dsw-alias-brand-primary, #2b7cd3);
706
+ --ssh-success: var(--dsw-alias-state-success-primary, #12805c);
707
+ --ssh-warn: var(--dsw-alias-state-warn-primary, #b54708);
708
+ --ssh-error: var(--dsw-alias-state-error-primary, #d92d20);
709
+ --ssh-idle: var(--dsw-alias-state-idle-primary, #8e8e93);
710
+ --ssh-text: var(--dsw-alias-label-primary, #1d1d1f);
711
+ --ssh-text-2: var(--dsw-alias-label-secondary, #6e6e73);
712
+ --ssh-text-3: var(--dsw-alias-label-tertiary, #8e8e93);
713
+ --ssh-bg: var(--dsw-alias-bg-base, #ffffff);
714
+ --ssh-on-accent: var(--dsw-alias-label-primary-foreground, #ffffff);
715
+ --ssh-layer-1: var(--dsw-alias-bg-layer-1, #ffffff);
716
+ --ssh-layer-2: var(--dsw-alias-bg-layer-2, #f5f5f7);
717
+ --ssh-layer-3: var(--dsw-alias-bg-layer-3, #ececf0);
718
+ --ssh-border: var(--dsw-alias-border-l1, rgba(0, 0, 0, 0.1));
719
+ --ssh-border-2: var(--dsw-alias-border-l2, rgba(0, 0, 0, 0.16));
720
+ --ssh-hover: var(--dsw-alias-interactive-bg-hover, rgba(0, 0, 0, 0.045));
721
+ --ssh-radius: 12px;
722
+ --ssh-mono: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
723
+ box-sizing: border-box;
724
+ height: 100%;
725
+ min-height: 0;
726
+ overflow-y: auto;
727
+ padding: calc(24px + var(--dsh-frame-top-clearance, 0px)) max(18px, min(4vw, 44px)) 40px;
728
+ color: var(--ssh-text);
729
+ font-size: 13px;
730
+ line-height: 1.5;
731
+ font-variant-numeric: tabular-nums;
732
+ }
733
+ .dsh-ssh-helper-section {
734
+ max-width: 1180px;
735
+ margin: 0 auto;
736
+ width: 100%;
737
+ }
738
+ :where(.dsh-ssh-helper-page) *,
739
+ :where(.dsh-ssh-helper-page) *::before,
740
+ :where(.dsh-ssh-helper-page) *::after { box-sizing: border-box; }
741
+ :where(.dsh-ssh-helper-page) button,
742
+ :where(.dsh-ssh-helper-page) input,
743
+ :where(.dsh-ssh-helper-page) textarea,
744
+ :where(.dsh-ssh-helper-page) select { font: inherit; color: inherit; }
745
+ :where(.dsh-ssh-helper-page) button { cursor: pointer; }
746
+ .dsh-ssh-helper-shell { display: flex; flex-direction: column; gap: 16px; }
747
+ .dsh-ssh-helper-hero {
748
+ position: relative;
749
+ overflow: hidden;
750
+ display: flex;
751
+ align-items: flex-start;
752
+ justify-content: space-between;
753
+ gap: 18px;
754
+ padding: 18px 20px;
755
+ border: 1px solid var(--ssh-border);
756
+ border-radius: 16px;
757
+ background:
758
+ radial-gradient(120% 150% at 0% 0%, color-mix(in srgb, var(--ssh-accent) 12%, transparent), transparent 58%),
759
+ var(--ssh-layer-1);
760
+ }
761
+ .dsh-ssh-helper-hero::after {
762
+ content: "";
763
+ position: absolute;
764
+ inset: 0;
765
+ pointer-events: none;
766
+ background-image: linear-gradient(color-mix(in srgb, var(--ssh-border) 45%, transparent) 1px, transparent 1px), linear-gradient(90deg, color-mix(in srgb, var(--ssh-border) 45%, transparent) 1px, transparent 1px);
767
+ background-size: 26px 26px;
768
+ mask-image: linear-gradient(120deg, transparent 35%, #000 100%);
769
+ opacity: 0.45;
770
+ }
771
+ .dsh-ssh-helper-hero-main { display: flex; flex-direction: column; gap: 10px; position: relative; z-index: 1; min-width: 0; }
772
+ .dsh-ssh-helper-brand { display: flex; align-items: center; gap: 12px; }
773
+ .dsh-ssh-helper-brand-mark {
774
+ width: 40px;
775
+ height: 40px;
776
+ flex: none;
777
+ display: grid;
778
+ place-items: center;
779
+ border-radius: 12px;
780
+ color: var(--ssh-on-accent);
781
+ background: linear-gradient(135deg, var(--ssh-accent), color-mix(in srgb, var(--ssh-accent) 42%, var(--dsw-alias-bg-overlay, #061423)));
782
+ box-shadow: 0 8px 20px -12px color-mix(in srgb, var(--ssh-accent) 70%, transparent);
783
+ }
784
+ .dsh-ssh-helper-brand h2 { margin: 0; font-size: 18px; letter-spacing: -0.015em; font-weight: 650; }
785
+ .dsh-ssh-helper-brand p { margin: 2px 0 0; color: var(--ssh-text-2); font-size: 12px; }
786
+ .dsh-ssh-helper-hero-pill {
787
+ display: inline-flex;
788
+ align-items: center;
789
+ gap: 6px;
790
+ align-self: flex-start;
791
+ padding: 4px 9px;
792
+ border: 1px solid color-mix(in srgb, var(--ssh-accent) 28%, transparent);
793
+ border-radius: 999px;
794
+ background: color-mix(in srgb, var(--ssh-accent) 9%, transparent);
795
+ color: var(--ssh-accent);
796
+ font-size: 11px;
797
+ font-weight: 600;
798
+ white-space: nowrap;
799
+ }
800
+ .dsh-ssh-helper-stats { position: relative; z-index: 1; display: flex; align-items: stretch; gap: 8px; flex-wrap: wrap; }
801
+ .dsh-ssh-helper-stat {
802
+ min-width: 92px;
803
+ padding: 8px 11px;
804
+ border: 1px solid var(--ssh-border);
805
+ border-radius: 10px;
806
+ background: color-mix(in srgb, var(--ssh-layer-1) 78%, transparent);
807
+ backdrop-filter: blur(4px);
808
+ }
809
+ .dsh-ssh-helper-stat-label { display: block; font-size: 10px; letter-spacing: 0.09em; text-transform: uppercase; color: var(--ssh-text-3); font-weight: 700; }
810
+ .dsh-ssh-helper-stat-value { display: flex; align-items: center; gap: 6px; margin-top: 2px; font-size: 14px; font-weight: 650; white-space: nowrap; }
811
+ .dsh-ssh-helper-console { display: grid; grid-template-columns: 166px minmax(0, 1fr); gap: 16px; align-items: start; }
812
+ .dsh-ssh-helper-rail {
813
+ position: sticky;
814
+ top: 0;
815
+ display: flex;
816
+ flex-direction: column;
817
+ gap: 4px;
818
+ padding: 8px;
819
+ border: 1px solid var(--ssh-border);
820
+ border-radius: 14px;
821
+ background: var(--ssh-layer-1);
822
+ }
823
+ .dsh-ssh-helper-rail-item {
824
+ position: relative;
825
+ display: flex;
826
+ align-items: center;
827
+ gap: 9px;
828
+ width: 100%;
829
+ padding: 9px 10px;
830
+ border: 0;
831
+ border-radius: 9px;
832
+ background: transparent;
833
+ color: var(--ssh-text-2);
834
+ text-align: left;
835
+ transition: background 140ms ease, color 140ms ease;
836
+ }
837
+ .dsh-ssh-helper-rail-item:hover { background: var(--ssh-hover); color: var(--ssh-text); }
838
+ .dsh-ssh-helper-rail-item[aria-current="page"] {
839
+ background: color-mix(in srgb, var(--ssh-accent) 11%, transparent);
840
+ color: var(--ssh-text);
841
+ box-shadow: inset 3px 0 0 var(--ssh-accent);
842
+ }
843
+ .dsh-ssh-helper-rail-item[aria-current="page"] .dsh-ssh-helper-rail-icon { color: var(--ssh-accent); }
844
+ .dsh-ssh-helper-rail-icon { display: grid; place-items: center; color: var(--ssh-text-3); }
845
+ .dsh-ssh-helper-rail-label { flex: 1; min-width: 0; font-size: 12.5px; font-weight: 600; }
846
+ .dsh-ssh-helper-rail-count {
847
+ min-width: 20px;
848
+ padding: 1px 6px;
849
+ border-radius: 999px;
850
+ background: var(--ssh-layer-2);
851
+ color: var(--ssh-text-2);
852
+ font-size: 10.5px;
853
+ text-align: center;
854
+ font-variant-numeric: tabular-nums;
855
+ }
856
+ .dsh-ssh-helper-rail-note {
857
+ display: flex;
858
+ gap: 8px;
859
+ align-items: flex-start;
860
+ margin-top: 6px;
861
+ padding: 10px 8px 4px;
862
+ border-top: 1px dashed var(--ssh-border);
863
+ color: var(--ssh-text-3);
864
+ font-size: 10.5px;
865
+ line-height: 1.45;
866
+ }
867
+ .dsh-ssh-helper-rail-note svg { flex: none; margin-top: 1px; color: var(--ssh-accent); }
868
+ .dsh-ssh-helper-rail-note strong { display: block; color: var(--ssh-text-2); font-size: 10.5px; font-weight: 650; }
869
+ .dsh-ssh-helper-main { min-width: 0; display: flex; flex-direction: column; gap: 12px; }
870
+ .dsh-ssh-helper-view { display: flex; flex-direction: column; gap: 12px; min-width: 0; }
871
+ .dsh-ssh-helper-view-head { display: flex; align-items: flex-end; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
872
+ .dsh-ssh-helper-view-head h3 { margin: 0; font-size: 15px; letter-spacing: -0.01em; font-weight: 650; }
873
+ .dsh-ssh-helper-view-head p { margin: 3px 0 0; color: var(--ssh-text-2); font-size: 12px; max-width: 52ch; }
874
+ .dsh-ssh-helper-view-actions { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
875
+ .dsh-ssh-helper-toolbar { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
876
+ .dsh-ssh-helper-search { position: relative; flex: 1; min-width: 170px; }
877
+ .dsh-ssh-helper-search > svg { position: absolute; left: 9px; top: 50%; transform: translateY(-50%); color: var(--ssh-text-3); pointer-events: none; }
878
+ .dsh-ssh-helper-search input {
879
+ width: 100%;
880
+ height: 32px;
881
+ padding: 0 10px 0 31px;
882
+ border: 1px solid var(--ssh-border);
883
+ border-radius: 8px;
884
+ background: var(--ssh-layer-1);
885
+ color: var(--ssh-text);
886
+ outline: none;
887
+ transition: border-color 140ms ease, box-shadow 140ms ease;
888
+ }
889
+ .dsh-ssh-helper-search input::placeholder { color: var(--ssh-text-3); }
890
+ .dsh-ssh-helper-search input:focus { border-color: color-mix(in srgb, var(--ssh-accent) 55%, var(--ssh-border)); box-shadow: 0 0 0 3px color-mix(in srgb, var(--ssh-accent) 15%, transparent); }
891
+ .dsh-ssh-helper-filter-row { display: flex; align-items: center; gap: 4px; flex-wrap: wrap; }
892
+ .dsh-ssh-helper-filter {
893
+ height: 26px;
894
+ padding: 0 9px;
895
+ border: 1px solid transparent;
896
+ border-radius: 999px;
897
+ background: transparent;
898
+ color: var(--ssh-text-2);
899
+ font-size: 11.5px;
900
+ font-weight: 600;
901
+ }
902
+ .dsh-ssh-helper-filter:hover { background: var(--ssh-hover); color: var(--ssh-text); }
903
+ .dsh-ssh-helper-filter[aria-pressed="true"] { border-color: color-mix(in srgb, var(--ssh-accent) 30%, transparent); background: color-mix(in srgb, var(--ssh-accent) 10%, transparent); color: var(--ssh-accent); }
904
+ .dsh-ssh-helper-list { list-style: none; margin: 0; padding: 0; border: 1px solid var(--ssh-border); border-radius: var(--ssh-radius); background: var(--ssh-layer-1); overflow: hidden; }
905
+ .dsh-ssh-helper-row {
906
+ display: flex;
907
+ align-items: center;
908
+ gap: 10px;
909
+ padding: 0 12px;
910
+ min-height: 58px;
911
+ border-bottom: 1px solid var(--ssh-border);
912
+ transition: background 140ms ease;
913
+ }
914
+ .dsh-ssh-helper-row:last-child { border-bottom: 0; }
915
+ .dsh-ssh-helper-row:hover { background: var(--ssh-hover); }
916
+ .dsh-ssh-helper-row.is-selected { background: color-mix(in srgb, var(--ssh-accent) 7%, transparent); }
917
+ .dsh-ssh-helper-row-hit {
918
+ display: flex;
919
+ align-items: center;
920
+ gap: 11px;
921
+ flex: 1;
922
+ min-width: 0;
923
+ align-self: stretch;
924
+ padding: 9px 0;
925
+ border: 0;
926
+ background: transparent;
927
+ text-align: left;
928
+ color: inherit;
929
+ }
930
+ .dsh-ssh-helper-row-hit:focus-visible { outline: 2px solid var(--ssh-accent); outline-offset: -2px; border-radius: 8px; }
931
+ .dsh-ssh-helper-avatar {
932
+ width: 32px;
933
+ height: 32px;
934
+ flex: none;
935
+ display: grid;
936
+ place-items: center;
937
+ border: 1px solid var(--ssh-border);
938
+ border-radius: 9px;
939
+ background: var(--ssh-layer-2);
940
+ color: var(--ssh-text-2);
941
+ font-size: 11px;
942
+ font-weight: 700;
943
+ letter-spacing: 0.02em;
944
+ }
945
+ .dsh-ssh-helper-row-main { min-width: 0; flex: 1; display: flex; flex-direction: column; gap: 2px; }
946
+ .dsh-ssh-helper-row-title { display: flex; align-items: center; gap: 6px; min-width: 0; font-size: 13px; font-weight: 620; }
947
+ .dsh-ssh-helper-row-title > span:first-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
948
+ .dsh-ssh-helper-row-sub { display: flex; align-items: center; gap: 6px; min-width: 0; color: var(--ssh-text-2); font-size: 11.5px; }
949
+ .dsh-ssh-helper-row-sub .dsh-ssh-helper-truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
950
+ .dsh-ssh-helper-row-note { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--ssh-text-3); }
951
+ .dsh-ssh-helper-row-side { display: flex; align-items: center; gap: 7px; flex: none; }
952
+ .dsh-ssh-helper-row-actions { display: flex; align-items: center; gap: 1px; opacity: 0; transition: opacity 120ms ease; }
953
+ .dsh-ssh-helper-row:hover .dsh-ssh-helper-row-actions,
954
+ .dsh-ssh-helper-row:focus-within .dsh-ssh-helper-row-actions { opacity: 1; }
955
+ .dsh-ssh-helper-row-time { color: var(--ssh-text-3); font-size: 11px; white-space: nowrap; }
956
+ .dsh-ssh-helper-mono { font-family: var(--ssh-mono); font-size: 0.94em; }
957
+ .dsh-ssh-helper-muted { color: var(--ssh-text-2); }
958
+ .dsh-ssh-helper-truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
959
+ .dsh-ssh-helper-sep { color: var(--ssh-text-3); }
960
+ .dsh-ssh-helper-chip {
961
+ display: inline-flex;
962
+ align-items: center;
963
+ gap: 5px;
964
+ height: 20px;
965
+ padding: 0 7px;
966
+ border: 1px solid var(--ssh-border);
967
+ border-radius: 999px;
968
+ background: var(--ssh-layer-2);
969
+ color: var(--ssh-text-2);
970
+ font-size: 10.5px;
971
+ font-weight: 600;
972
+ white-space: nowrap;
973
+ }
974
+ .dsh-ssh-helper-chip--success { border-color: color-mix(in srgb, var(--ssh-success) 28%, transparent); background: color-mix(in srgb, var(--ssh-success) 10%, transparent); color: var(--ssh-success); }
975
+ .dsh-ssh-helper-chip--warn { border-color: color-mix(in srgb, var(--ssh-warn) 30%, transparent); background: color-mix(in srgb, var(--ssh-warn) 10%, transparent); color: var(--ssh-warn); }
976
+ .dsh-ssh-helper-chip--error { border-color: color-mix(in srgb, var(--ssh-error) 28%, transparent); background: color-mix(in srgb, var(--ssh-error) 9%, transparent); color: var(--ssh-error); }
977
+ .dsh-ssh-helper-chip--brand { border-color: color-mix(in srgb, var(--ssh-accent) 30%, transparent); background: color-mix(in srgb, var(--ssh-accent) 9%, transparent); color: var(--ssh-accent); }
978
+ .dsh-ssh-helper-chip--idle { color: var(--ssh-text-3); }
979
+ .dsh-ssh-helper-dot {
980
+ width: 7px;
981
+ height: 7px;
982
+ flex: none;
983
+ border-radius: 50%;
984
+ background: currentColor;
985
+ box-shadow: 0 0 0 3px color-mix(in srgb, currentColor 14%, transparent);
986
+ }
987
+ .dsh-ssh-helper-dot--success { color: var(--ssh-success); }
988
+ .dsh-ssh-helper-dot--warn { color: var(--ssh-warn); }
989
+ .dsh-ssh-helper-dot--error { color: var(--ssh-error); }
990
+ .dsh-ssh-helper-dot--idle { color: var(--ssh-idle); }
991
+ .dsh-ssh-helper-dot--pulse { animation: dsh-ssh-helper-pulse 1.1s ease-in-out infinite; }
992
+ .dsh-ssh-helper-btn {
993
+ display: inline-flex;
994
+ align-items: center;
995
+ justify-content: center;
996
+ gap: 6px;
997
+ height: 30px;
998
+ padding: 0 10px;
999
+ border: 1px solid var(--ssh-border);
1000
+ border-radius: 7px;
1001
+ background: var(--ssh-layer-1);
1002
+ color: var(--ssh-text);
1003
+ font-size: 12px;
1004
+ font-weight: 600;
1005
+ white-space: nowrap;
1006
+ transition: background 120ms ease, border-color 120ms ease, transform 80ms ease, opacity 120ms ease;
1007
+ }
1008
+ .dsh-ssh-helper-btn:hover { background: var(--ssh-layer-2); }
1009
+ .dsh-ssh-helper-btn:active { transform: scale(0.985); }
1010
+ .dsh-ssh-helper-btn:focus-visible { outline: 2px solid var(--ssh-accent); outline-offset: 2px; }
1011
+ .dsh-ssh-helper-btn:disabled { opacity: 0.5; cursor: not-allowed; transform: none; }
1012
+ .dsh-ssh-helper-btn.dsh-ssh-helper-btn--primary {
1013
+ border-color: var(--dsw-alias-button-primary-fill, var(--ssh-accent));
1014
+ background: var(--dsw-alias-button-primary-fill, var(--ssh-accent));
1015
+ color: #ffffff;
1016
+ color: var(--ssh-on-accent, #ffffff);
1017
+ box-shadow: 0 6px 14px -10px color-mix(in srgb, var(--ssh-accent) 80%, transparent);
1018
+ }
1019
+ .dsh-ssh-helper-btn.dsh-ssh-helper-btn--primary > span,
1020
+ .dsh-ssh-helper-btn.dsh-ssh-helper-btn--primary > svg { color: #ffffff; color: var(--ssh-on-accent, #ffffff); }
1021
+ .dsh-ssh-helper-btn.dsh-ssh-helper-btn--primary:hover { border-color: var(--dsw-alias-button-primary-hover, var(--ssh-accent)); background: var(--dsw-alias-button-primary-hover, color-mix(in srgb, var(--ssh-accent) 88%, var(--ssh-text))); }
1022
+ .dsh-ssh-helper-btn--ghost { border-color: transparent; background: transparent; color: var(--ssh-text-2); }
1023
+ .dsh-ssh-helper-btn--ghost:hover { background: var(--ssh-hover); color: var(--ssh-text); }
1024
+ .dsh-ssh-helper-btn--danger { border-color: color-mix(in srgb, var(--ssh-error) 30%, transparent); background: color-mix(in srgb, var(--ssh-error) 8%, transparent); color: var(--ssh-error); }
1025
+ .dsh-ssh-helper-btn--danger:hover { background: color-mix(in srgb, var(--ssh-error) 14%, transparent); }
1026
+ .dsh-ssh-helper-btn--icon { width: 27px; height: 27px; padding: 0; border-color: transparent; background: transparent; color: var(--ssh-text-3); }
1027
+ .dsh-ssh-helper-btn--icon:hover { background: var(--ssh-hover); color: var(--ssh-text); }
1028
+ .dsh-ssh-helper-btn--sm { height: 26px; padding: 0 8px; font-size: 11.5px; }
1029
+ .dsh-ssh-helper-spinner {
1030
+ width: 12px;
1031
+ height: 12px;
1032
+ flex: none;
1033
+ border: 1.6px solid color-mix(in srgb, currentColor 25%, transparent);
1034
+ border-top-color: currentColor;
1035
+ border-radius: 50%;
1036
+ animation: dsh-ssh-helper-spin 0.7s linear infinite;
1037
+ }
1038
+ .dsh-ssh-helper-banner {
1039
+ display: flex;
1040
+ align-items: center;
1041
+ gap: 10px;
1042
+ padding: 9px 11px;
1043
+ border: 1px solid color-mix(in srgb, var(--ssh-accent) 25%, transparent);
1044
+ border-radius: 10px;
1045
+ background: color-mix(in srgb, var(--ssh-accent) 7%, transparent);
1046
+ color: var(--ssh-text-2);
1047
+ font-size: 11.5px;
1048
+ }
1049
+ .dsh-ssh-helper-banner > svg { flex: none; color: var(--ssh-accent); }
1050
+ .dsh-ssh-helper-banner strong { color: var(--ssh-text); font-weight: 650; }
1051
+ .dsh-ssh-helper-banner-actions { margin-left: auto; display: flex; gap: 6px; flex: none; }
1052
+ .dsh-ssh-helper-error-banner {
1053
+ display: flex;
1054
+ align-items: center;
1055
+ gap: 8px;
1056
+ padding: 8px 10px;
1057
+ border: 1px solid color-mix(in srgb, var(--ssh-error) 28%, transparent);
1058
+ border-radius: 9px;
1059
+ background: color-mix(in srgb, var(--ssh-error) 7%, transparent);
1060
+ color: var(--ssh-error);
1061
+ font-size: 11.5px;
1062
+ }
1063
+ .dsh-ssh-helper-empty {
1064
+ display: flex;
1065
+ flex-direction: column;
1066
+ align-items: center;
1067
+ gap: 10px;
1068
+ padding: 52px 24px;
1069
+ border: 1px dashed var(--ssh-border-2);
1070
+ border-radius: var(--ssh-radius);
1071
+ background: color-mix(in srgb, var(--ssh-layer-1) 65%, transparent);
1072
+ text-align: center;
1073
+ }
1074
+ .dsh-ssh-helper-empty-mark {
1075
+ width: 44px;
1076
+ height: 44px;
1077
+ display: grid;
1078
+ place-items: center;
1079
+ border: 1px solid var(--ssh-border);
1080
+ border-radius: 13px;
1081
+ background: var(--ssh-layer-2);
1082
+ color: var(--ssh-text-3);
1083
+ }
1084
+ .dsh-ssh-helper-empty h4 { margin: 0; font-size: 14px; font-weight: 650; }
1085
+ .dsh-ssh-helper-empty p { margin: 0; max-width: 44ch; color: var(--ssh-text-2); font-size: 12px; }
1086
+ .dsh-ssh-helper-empty-actions { display: flex; gap: 7px; flex-wrap: wrap; justify-content: center; margin-top: 4px; }
1087
+ .dsh-ssh-helper-skeleton { display: flex; flex-direction: column; gap: 8px; }
1088
+ .dsh-ssh-helper-skeleton-row { display: flex; align-items: center; gap: 12px; padding: 14px; border: 1px solid var(--ssh-border); border-radius: 10px; background: var(--ssh-layer-1); }
1089
+ .dsh-ssh-helper-skeleton-block {
1090
+ border-radius: 6px;
1091
+ background: linear-gradient(90deg, var(--ssh-layer-2) 25%, var(--ssh-layer-3) 37%, var(--ssh-layer-2) 63%);
1092
+ background-size: 400% 100%;
1093
+ animation: dsh-ssh-helper-shimmer 1.4s ease infinite;
1094
+ }
1095
+ .dsh-ssh-helper-skeleton-avatar { width: 32px; height: 32px; border-radius: 9px; flex: none; }
1096
+ .dsh-ssh-helper-skeleton-lines { display: flex; flex-direction: column; gap: 7px; flex: 1; }
1097
+ .dsh-ssh-helper-skeleton-line { height: 9px; }
1098
+ .dsh-ssh-helper-skeleton-line.is-short { width: 42%; }
1099
+ .dsh-ssh-helper-footnote { margin: 0; color: var(--ssh-text-3); font-size: 10.5px; display: flex; align-items: center; gap: 6px; }
1100
+ .dsh-ssh-helper-footnote svg { flex: none; }
1101
+ .dsh-ssh-helper-sheet-root { position: fixed; inset: 0; z-index: 1000; display: flex; justify-content: flex-end; }
1102
+ .dsh-ssh-helper-scrim { position: absolute; inset: 0; background: rgba(8, 11, 16, 0.42); -webkit-backdrop-filter: blur(4px) saturate(0.9); backdrop-filter: blur(4px) saturate(0.9); animation: dsh-ssh-helper-fade-in 160ms ease; }
1103
+ .dsh-ssh-helper-sheet {
1104
+ position: relative;
1105
+ width: min(450px, 100vw);
1106
+ height: 100%;
1107
+ display: flex;
1108
+ flex-direction: column;
1109
+ border-left: 1px solid var(--ssh-border-2);
1110
+ background-color: var(--dsw-alias-bg-base, #ffffff);
1111
+ background-image: linear-gradient(var(--ssh-layer-1), var(--ssh-layer-1));
1112
+ box-shadow: -32px 0 80px -36px rgba(0, 0, 0, 0.55), -1px 0 0 rgba(0, 0, 0, 0.04);
1113
+ animation: dsh-ssh-helper-slide-in 200ms cubic-bezier(0.2, 0.8, 0.2, 1);
1114
+ }
1115
+ .dsh-ssh-helper-sheet.is-wide { width: min(530px, 100vw); }
1116
+ .dsh-ssh-helper-sheet-head { display: flex; align-items: flex-start; gap: 10px; padding: 17px 18px 13px; border-bottom: 1px solid var(--ssh-border); }
1117
+ .dsh-ssh-helper-sheet-head h2 { margin: 0; font-size: 15px; font-weight: 650; letter-spacing: -0.01em; }
1118
+ .dsh-ssh-helper-sheet-head p { margin: 2px 0 0; color: var(--ssh-text-2); font-size: 11.5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 320px; }
1119
+ .dsh-ssh-helper-sheet-head .dsh-ssh-helper-btn { margin-left: auto; flex: none; }
1120
+ .dsh-ssh-helper-sheet-body { flex: 1; overflow: auto; padding: 16px 18px 24px; overscroll-behavior: contain; scrollbar-gutter: stable; }
1121
+ .dsh-ssh-helper-detail { display: flex; flex-direction: column; gap: 2px; }
1122
+ .dsh-ssh-helper-detail-summary { display: flex; align-items: center; gap: 10px; padding-bottom: 12px; border-bottom: 1px solid var(--ssh-border); }
1123
+ .dsh-ssh-helper-detail-summary .dsh-ssh-helper-avatar { width: 36px; height: 36px; }
1124
+ .dsh-ssh-helper-detail-title { min-width: 0; flex: 1; }
1125
+ .dsh-ssh-helper-detail-title h3 { margin: 0; font-size: 14px; font-weight: 650; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1126
+ .dsh-ssh-helper-detail-title p { margin: 2px 0 0; color: var(--ssh-text-2); font-size: 11.5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1127
+ .dsh-ssh-helper-detail-actions { display: flex; gap: 6px; flex-wrap: wrap; padding: 12px 0 2px; }
1128
+ .dsh-ssh-helper-section-label { display: flex; align-items: center; gap: 6px; margin: 16px 0 7px; color: var(--ssh-text-3); font-size: 10px; font-weight: 700; letter-spacing: 0.11em; text-transform: uppercase; }
1129
+ .dsh-ssh-helper-meta { display: grid; grid-template-columns: 92px minmax(0, 1fr); gap: 7px 12px; align-items: start; }
1130
+ .dsh-ssh-helper-meta-row { display: contents; }
1131
+ .dsh-ssh-helper-meta-label { color: var(--ssh-text-3); font-size: 11.5px; }
1132
+ .dsh-ssh-helper-meta-value { display: flex; align-items: center; gap: 6px; min-width: 0; font-size: 12px; }
1133
+ .dsh-ssh-helper-meta-value .dsh-ssh-helper-truncate { min-width: 0; }
1134
+ .dsh-ssh-helper-code { border: 1px solid var(--ssh-border); border-radius: 8px; background: var(--ssh-layer-2); overflow: hidden; }
1135
+ .dsh-ssh-helper-code + .dsh-ssh-helper-code { margin-top: 7px; }
1136
+ .dsh-ssh-helper-code-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 5px 8px 5px 10px; border-bottom: 1px solid var(--ssh-border); color: var(--ssh-text-3); font-size: 10.5px; font-weight: 650; letter-spacing: 0.02em; }
1137
+ .dsh-ssh-helper-code-head-actions { display: flex; align-items: center; gap: 2px; }
1138
+ .dsh-ssh-helper-code pre {
1139
+ margin: 0;
1140
+ padding: 9px 10px;
1141
+ max-height: 170px;
1142
+ overflow: auto;
1143
+ color: var(--ssh-text);
1144
+ font-family: var(--ssh-mono);
1145
+ font-size: 11px;
1146
+ line-height: 1.55;
1147
+ white-space: pre-wrap;
1148
+ word-break: break-word;
1149
+ }
1150
+ .dsh-ssh-helper-code.is-expanded pre { max-height: none; }
1151
+ .dsh-ssh-helper-output { margin-top: 7px; border: 1px solid var(--ssh-border); border-radius: 8px; background: var(--ssh-layer-3); overflow: hidden; }
1152
+ .dsh-ssh-helper-output-label { padding: 4px 9px; border-bottom: 1px solid var(--ssh-border); color: var(--ssh-text-3); font-size: 10px; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; }
1153
+ .dsh-ssh-helper-output pre { margin: 0; padding: 8px 10px; max-height: 190px; overflow: auto; font-family: var(--ssh-mono); font-size: 10.5px; line-height: 1.5; white-space: pre-wrap; word-break: break-word; }
1154
+ .dsh-ssh-helper-form { display: flex; flex-direction: column; gap: 12px; }
1155
+ .dsh-ssh-helper-form-hint { margin: 0; color: var(--ssh-text-3); font-size: 10.5px; line-height: 1.5; }
1156
+ .dsh-ssh-helper-grid-2 { display: grid; grid-template-columns: minmax(0, 1fr) 92px; gap: 8px; }
1157
+ .dsh-ssh-helper-field { display: flex; flex-direction: column; gap: 5px; min-width: 0; }
1158
+ .dsh-ssh-helper-field > label { color: var(--ssh-text-2); font-size: 11.5px; font-weight: 620; }
1159
+ .dsh-ssh-helper-field-hint { color: var(--ssh-text-3); font-size: 10.5px; }
1160
+ .dsh-ssh-helper-field-error { color: var(--ssh-error); font-size: 10.5px; }
1161
+ .dsh-ssh-helper-required { color: var(--ssh-error); font-weight: 700; }
1162
+ .dsh-ssh-helper-input,
1163
+ .dsh-ssh-helper-textarea,
1164
+ .dsh-ssh-helper-select {
1165
+ width: 100%;
1166
+ border: 1px solid color-mix(in srgb, var(--ssh-text) 22%, transparent);
1167
+ border-radius: 7px;
1168
+ background: var(--ssh-layer-2);
1169
+ box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.045);
1170
+ color: var(--ssh-text);
1171
+ outline: none;
1172
+ transition: border-color 130ms ease, box-shadow 130ms ease, background 130ms ease;
1173
+ }
1174
+ .dsh-ssh-helper-input:hover,
1175
+ .dsh-ssh-helper-textarea:hover,
1176
+ .dsh-ssh-helper-select:hover { border-color: color-mix(in srgb, var(--ssh-text) 34%, transparent); }
1177
+ .dsh-ssh-helper-input::placeholder,
1178
+ .dsh-ssh-helper-textarea::placeholder { color: var(--ssh-text-3); }
1179
+ .dsh-ssh-helper-input,
1180
+ .dsh-ssh-helper-select { height: 34px; padding: 0 10px; }
1181
+ .dsh-ssh-helper-textarea { padding: 8px 10px; min-height: 82px; resize: vertical; font-family: var(--ssh-mono); font-size: 11.5px; line-height: 1.5; }
1182
+ .dsh-ssh-helper-input:focus,
1183
+ .dsh-ssh-helper-textarea:focus,
1184
+ .dsh-ssh-helper-select:focus { border-color: color-mix(in srgb, var(--ssh-accent) 55%, var(--ssh-border)); box-shadow: 0 0 0 3px color-mix(in srgb, var(--ssh-accent) 14%, transparent); }
1185
+ .dsh-ssh-helper-input.is-invalid,
1186
+ .dsh-ssh-helper-textarea.is-invalid,
1187
+ .dsh-ssh-helper-select.is-invalid { border-color: color-mix(in srgb, var(--ssh-error) 55%, var(--ssh-border)); }
1188
+ .dsh-ssh-helper-auth-row { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; }
1189
+ .dsh-ssh-helper-auth-option {
1190
+ display: flex;
1191
+ align-items: center;
1192
+ gap: 7px;
1193
+ padding: 7px 9px;
1194
+ border: 1px solid var(--ssh-border);
1195
+ border-radius: 7px;
1196
+ background: var(--ssh-layer-1);
1197
+ color: var(--ssh-text-2);
1198
+ font-size: 12px;
1199
+ text-align: left;
1200
+ }
1201
+ .dsh-ssh-helper-auth-option:hover { background: var(--ssh-hover); }
1202
+ .dsh-ssh-helper-auth-option[aria-pressed="true"] { border-color: color-mix(in srgb, var(--ssh-accent) 45%, transparent); background: color-mix(in srgb, var(--ssh-accent) 8%, transparent); color: var(--ssh-text); }
1203
+ .dsh-ssh-helper-auth-option svg { color: var(--ssh-accent); }
1204
+ .dsh-ssh-helper-toggle { display: flex; align-items: flex-start; gap: 9px; padding: 8px 9px; border: 1px solid var(--ssh-border); border-radius: 8px; background: var(--ssh-layer-1); }
1205
+ .dsh-ssh-helper-toggle input { position: absolute; opacity: 0; pointer-events: none; }
1206
+ .dsh-ssh-helper-toggle-track { position: relative; width: 30px; height: 18px; flex: none; margin-top: 1px; border-radius: 999px; background: var(--ssh-layer-3); transition: background 140ms ease; }
1207
+ .dsh-ssh-helper-toggle-track::after { content: ""; position: absolute; top: 2px; left: 2px; width: 14px; height: 14px; border-radius: 50%; background: var(--ssh-layer-1); box-shadow: 0 1px 3px rgba(0, 0, 0, 0.25); transition: transform 140ms ease; }
1208
+ .dsh-ssh-helper-toggle input:checked + .dsh-ssh-helper-toggle-track { background: var(--ssh-accent); }
1209
+ .dsh-ssh-helper-toggle input:checked + .dsh-ssh-helper-toggle-track::after { transform: translateX(12px); }
1210
+ .dsh-ssh-helper-toggle input:focus-visible + .dsh-ssh-helper-toggle-track { outline: 2px solid var(--ssh-accent); outline-offset: 2px; }
1211
+ .dsh-ssh-helper-toggle-copy { display: flex; flex-direction: column; gap: 1px; }
1212
+ .dsh-ssh-helper-toggle-copy strong { font-size: 11.5px; font-weight: 620; }
1213
+ .dsh-ssh-helper-toggle-copy span { color: var(--ssh-text-3); font-size: 10.5px; }
1214
+ .dsh-ssh-helper-advanced-toggle { display: flex; align-items: center; gap: 6px; align-self: flex-start; padding: 0; border: 0; background: transparent; color: var(--ssh-accent); font-size: 11.5px; font-weight: 620; }
1215
+ .dsh-ssh-helper-advanced-toggle svg { transition: transform 140ms ease; }
1216
+ .dsh-ssh-helper-advanced-toggle[aria-expanded="true"] svg { transform: rotate(180deg); }
1217
+ .dsh-ssh-helper-advanced { display: flex; flex-direction: column; gap: 12px; padding: 10px; border: 1px dashed var(--ssh-border-2); border-radius: 9px; }
1218
+ .dsh-ssh-helper-form-footer { position: sticky; bottom: -24px; display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-top: 8px; padding: 10px 0 4px; border-top: 1px solid var(--ssh-border); background-color: var(--dsw-alias-bg-base, #ffffff); background-image: linear-gradient(var(--ssh-layer-1), var(--ssh-layer-1)); box-shadow: 0 -10px 18px -16px rgba(0, 0, 0, 0.28); }
1219
+ .dsh-ssh-helper-form-footer-right { display: flex; gap: 6px; }
1220
+ .dsh-ssh-helper-test-result { display: flex; flex-direction: column; gap: 5px; padding: 8px 10px; border: 1px solid var(--ssh-border); border-radius: 8px; background: var(--ssh-layer-2); font-size: 11.5px; }
1221
+ .dsh-ssh-helper-test-result.is-ok { border-color: color-mix(in srgb, var(--ssh-success) 30%, transparent); background: color-mix(in srgb, var(--ssh-success) 7%, transparent); }
1222
+ .dsh-ssh-helper-test-result.is-fail { border-color: color-mix(in srgb, var(--ssh-error) 30%, transparent); background: color-mix(in srgb, var(--ssh-error) 7%, transparent); }
1223
+ .dsh-ssh-helper-test-result-head { display: flex; align-items: center; gap: 6px; font-weight: 620; }
1224
+ .dsh-ssh-helper-test-result pre { margin: 0; max-height: 100px; overflow: auto; font-family: var(--ssh-mono); font-size: 10.5px; white-space: pre-wrap; word-break: break-word; }
1225
+ .dsh-ssh-helper-confirm { display: flex; flex-direction: column; gap: 8px; padding: 10px; border: 1px solid color-mix(in srgb, var(--ssh-error) 30%, transparent); border-radius: 9px; background: color-mix(in srgb, var(--ssh-error) 6%, transparent); }
1226
+ .dsh-ssh-helper-confirm strong { color: var(--ssh-error); font-size: 12px; }
1227
+ .dsh-ssh-helper-confirm p { margin: 0; color: var(--ssh-text-2); font-size: 11px; }
1228
+ .dsh-ssh-helper-confirm-actions { display: flex; gap: 6px; justify-content: flex-end; }
1229
+ .dsh-ssh-helper-timeline { display: flex; flex-direction: column; gap: 4px; }
1230
+ .dsh-ssh-helper-day { margin: 8px 0 2px; color: var(--ssh-text-3); font-size: 10px; font-weight: 700; letter-spacing: 0.1em; text-transform: uppercase; }
1231
+ .dsh-ssh-helper-day:first-child { margin-top: 0; }
1232
+ .dsh-ssh-helper-activity { display: grid; grid-template-columns: 48px 16px minmax(0, 1fr); gap: 7px; align-items: start; }
1233
+ .dsh-ssh-helper-activity-time { padding-top: 10px; color: var(--ssh-text-3); font-size: 10.5px; text-align: right; font-family: var(--ssh-mono); }
1234
+ .dsh-ssh-helper-activity-axis { display: flex; flex-direction: column; align-items: center; padding-top: 13px; }
1235
+ .dsh-ssh-helper-activity-axis::after { content: ""; flex: 1; width: 1px; margin-top: 5px; background: var(--ssh-border); min-height: 12px; }
1236
+ .dsh-ssh-helper-activity:last-child .dsh-ssh-helper-activity-axis::after { display: none; }
1237
+ .dsh-ssh-helper-activity-card { margin: 3px 0; border: 1px solid var(--ssh-border); border-radius: 9px; background: var(--ssh-layer-1); overflow: hidden; }
1238
+ .dsh-ssh-helper-activity-summary { display: flex; align-items: center; gap: 8px; width: 100%; padding: 9px 10px; border: 0; background: transparent; color: inherit; text-align: left; }
1239
+ .dsh-ssh-helper-activity-summary:hover { background: var(--ssh-hover); }
1240
+ .dsh-ssh-helper-activity-summary-main { min-width: 0; flex: 1; }
1241
+ .dsh-ssh-helper-activity-summary-title { display: flex; align-items: center; gap: 6px; font-size: 12px; font-weight: 620; }
1242
+ .dsh-ssh-helper-activity-summary-sub { margin-top: 1px; color: var(--ssh-text-2); font-size: 10.5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1243
+ .dsh-ssh-helper-activity-body { padding: 0 10px 10px; border-top: 1px solid var(--ssh-border); }
1244
+ .dsh-ssh-helper-activity-message { margin: 8px 0 0; padding: 7px 9px; border-radius: 7px; background: var(--ssh-layer-2); color: var(--ssh-text-2); font-size: 11px; }
1245
+ .dsh-ssh-helper-step { display: grid; grid-template-columns: 8px minmax(0, 1fr); gap: 8px; padding: 7px 0; border-bottom: 1px solid var(--ssh-border); }
1246
+ .dsh-ssh-helper-step:last-child { border-bottom: 0; }
1247
+ .dsh-ssh-helper-step-dot { margin-top: 6px; }
1248
+ .dsh-ssh-helper-step-title { font-size: 11.5px; font-weight: 600; }
1249
+ .dsh-ssh-helper-step-meta { display: flex; gap: 8px; margin-top: 1px; color: var(--ssh-text-3); font-size: 10px; }
1250
+ .dsh-ssh-helper-toasts { position: fixed; right: 18px; bottom: 18px; z-index: 1100; display: flex; flex-direction: column; gap: 7px; pointer-events: none; }
1251
+ .dsh-ssh-helper-toast {
1252
+ display: flex;
1253
+ align-items: center;
1254
+ gap: 8px;
1255
+ min-width: 220px;
1256
+ max-width: 360px;
1257
+ padding: 9px 10px;
1258
+ border: 1px solid var(--ssh-border);
1259
+ border-radius: 9px;
1260
+ background: var(--ssh-layer-1);
1261
+ box-shadow: 0 14px 40px -18px rgba(0, 0, 0, 0.4);
1262
+ color: var(--ssh-text);
1263
+ font-size: 11.5px;
1264
+ pointer-events: auto;
1265
+ animation: dsh-ssh-helper-toast-in 180ms ease;
1266
+ }
1267
+ .dsh-ssh-helper-toast.is-success svg { color: var(--ssh-success); }
1268
+ .dsh-ssh-helper-toast.is-error svg { color: var(--ssh-error); }
1269
+ .dsh-ssh-helper-toast-close { margin-left: auto; }
1270
+ .dsh-ssh-helper-loading-panel { display: flex; flex-direction: column; gap: 12px; padding: 24px; border: 1px solid var(--ssh-border); border-radius: var(--ssh-radius); background: var(--ssh-layer-1); }
1271
+ .dsh-ssh-helper-loading-panel p { margin: 0; color: var(--ssh-text-2); font-size: 12px; }
1272
+ .dsh-ssh-helper-error-panel { display: flex; flex-direction: column; align-items: center; gap: 10px; padding: 42px 20px; border: 1px solid color-mix(in srgb, var(--ssh-error) 25%, transparent); border-radius: var(--ssh-radius); background: color-mix(in srgb, var(--ssh-error) 5%, transparent); text-align: center; }
1273
+ .dsh-ssh-helper-error-panel h4 { margin: 0; font-size: 14px; }
1274
+ .dsh-ssh-helper-error-panel p { margin: 0; max-width: 44ch; color: var(--ssh-text-2); font-size: 12px; }
1275
+ @keyframes dsh-ssh-helper-fade-in { from { opacity: 0; } to { opacity: 1; } }
1276
+ @keyframes dsh-ssh-helper-slide-in { from { transform: translateX(26px); opacity: 0; } to { transform: none; opacity: 1; } }
1277
+ @keyframes dsh-ssh-helper-toast-in { from { transform: translateY(7px); opacity: 0; } to { transform: none; opacity: 1; } }
1278
+ @keyframes dsh-ssh-helper-shimmer { from { background-position: 100% 0; } to { background-position: 0 0; } }
1279
+ @keyframes dsh-ssh-helper-spin { to { transform: rotate(360deg); } }
1280
+ @keyframes dsh-ssh-helper-pulse { 0%, 100% { box-shadow: 0 0 0 3px color-mix(in srgb, currentColor 14%, transparent); } 50% { box-shadow: 0 0 0 5px color-mix(in srgb, currentColor 6%, transparent); } }
1281
+ @media (max-width: 760px) {
1282
+ .dsh-ssh-helper-page { padding: calc(16px + var(--dsh-frame-top-clearance, 0px)) 14px 28px; }
1283
+ .dsh-ssh-helper-hero { flex-direction: column; }
1284
+ .dsh-ssh-helper-stats { width: 100%; }
1285
+ .dsh-ssh-helper-stat { flex: 1; min-width: 0; }
1286
+ .dsh-ssh-helper-console { grid-template-columns: 1fr; }
1287
+ .dsh-ssh-helper-rail { position: static; flex-direction: row; overflow-x: auto; }
1288
+ .dsh-ssh-helper-rail-item { width: auto; flex: 1; justify-content: center; }
1289
+ .dsh-ssh-helper-rail-count { display: none; }
1290
+ .dsh-ssh-helper-rail-note { display: none; }
1291
+ .dsh-ssh-helper-view-head { align-items: flex-start; flex-direction: column; }
1292
+ .dsh-ssh-helper-row-time { display: none; }
1293
+ .dsh-ssh-helper-row-actions { opacity: 1; }
1294
+ }
1295
+ @media (max-width: 520px) {
1296
+ .dsh-ssh-helper-sheet { width: 100vw; }
1297
+ .dsh-ssh-helper-activity { grid-template-columns: 0 16px minmax(0, 1fr); }
1298
+ .dsh-ssh-helper-activity-time { display: none; }
1299
+ .dsh-ssh-helper-auth-row { grid-template-columns: 1fr; }
1300
+ .dsh-ssh-helper-grid-2 { grid-template-columns: 1fr; }
1301
+ }
1302
+ @media (prefers-reduced-motion: reduce) {
1303
+ .dsh-ssh-helper-section *,
1304
+ .dsh-ssh-helper-section *::before,
1305
+ .dsh-ssh-helper-section *::after { animation-duration: 0.001ms !important; animation-iteration-count: 1 !important; transition-duration: 0.001ms !important; }
1306
+ }
1307
+ `
1308
+
1309
+ function installStyles() {
1310
+ const previous = document.getElementById(STYLE_ID)
1311
+ if (previous !== null && previous.parentNode !== null) previous.parentNode.removeChild(previous)
1312
+ const style = document.createElement('style')
1313
+ style.id = STYLE_ID
1314
+ style.textContent = CSS
1315
+ document.head.appendChild(style)
1316
+ return () => {
1317
+ if (style.parentNode !== null) style.parentNode.removeChild(style)
1318
+ }
1319
+ }
1320
+
1321
+ // ---------------------------------------------------------------------
1322
+ // Primitives
1323
+ // ---------------------------------------------------------------------
1324
+
1325
+ function Button(props) {
1326
+ const variant = props.variant === undefined ? 'secondary' : props.variant
1327
+ const size = props.size === undefined ? 'md' : props.size
1328
+ return h('button', {
1329
+ type: props.type === undefined ? 'button' : props.type,
1330
+ className: cls('dsh-ssh-helper-btn', `dsh-ssh-helper-btn--${variant}`, size === 'sm' && 'dsh-ssh-helper-btn--sm', props.className),
1331
+ onClick: props.onClick,
1332
+ disabled: props.disabled === true || props.loading === true,
1333
+ title: props.title,
1334
+ 'aria-label': props.ariaLabel,
1335
+ 'aria-expanded': props.ariaExpanded,
1336
+ form: props.form,
1337
+ },
1338
+ props.loading === true ? h('span', { className: 'dsh-ssh-helper-spinner' }) : null,
1339
+ props.icon === undefined ? null : h(Icon, { name: props.icon, size: 14 }),
1340
+ props.children === undefined ? null : h('span', null, props.children))
1341
+ }
1342
+
1343
+ function IconButton(props) {
1344
+ return h('button', {
1345
+ type: 'button',
1346
+ className: cls('dsh-ssh-helper-btn', 'dsh-ssh-helper-btn--icon', props.className),
1347
+ onClick: props.onClick,
1348
+ disabled: props.disabled === true,
1349
+ title: props.label,
1350
+ 'aria-label': props.label,
1351
+ }, h(Icon, { name: props.icon, size: props.size === undefined ? 15 : props.size }))
1352
+ }
1353
+
1354
+ function SearchBox(props) {
1355
+ return h('div', { className: 'dsh-ssh-helper-search' },
1356
+ h(Icon, { name: 'search', size: 14 }),
1357
+ h('input', {
1358
+ type: 'search',
1359
+ value: props.value,
1360
+ placeholder: props.placeholder,
1361
+ onChange: (event) => props.onChange(event.target.value),
1362
+ 'aria-label': props.placeholder,
1363
+ spellCheck: false,
1364
+ }))
1365
+ }
1366
+
1367
+ function Chip(props) {
1368
+ return h('span', { className: cls('dsh-ssh-helper-chip', props.tone === undefined ? undefined : `dsh-ssh-helper-chip--${props.tone}`) }, props.children)
1369
+ }
1370
+
1371
+ function StatusChip(props) {
1372
+ const meta = statusMeta(props.status)
1373
+ return h(Chip, { tone: meta.tone }, h('span', { className: cls('dsh-ssh-helper-dot', `dsh-ssh-helper-dot--${meta.tone}`) }), props.t(props.key))
1374
+ }
1375
+
1376
+ function StatusDot(props) {
1377
+ return h('span', { className: cls('dsh-ssh-helper-dot', `dsh-ssh-helper-dot--${props.tone || 'idle'}`, props.pulse === true && 'dsh-ssh-helper-dot--pulse') })
1378
+ }
1379
+
1380
+ function Field(props) {
1381
+ return h('div', { className: cls('dsh-ssh-helper-field', props.className) },
1382
+ h('label', { htmlFor: props.htmlFor }, props.label, props.required === true ? h('span', { className: 'dsh-ssh-helper-required' }, ' *') : null),
1383
+ props.children,
1384
+ props.hint === undefined ? null : h('span', { className: 'dsh-ssh-helper-field-hint' }, props.hint),
1385
+ props.error === undefined ? null : h('span', { className: 'dsh-ssh-helper-field-error', role: 'alert' }, props.error))
1386
+ }
1387
+
1388
+ function TextInput(props) {
1389
+ return h('input', {
1390
+ id: props.id,
1391
+ type: props.type === undefined ? 'text' : props.type,
1392
+ value: props.value,
1393
+ placeholder: props.placeholder,
1394
+ autoComplete: props.autoComplete,
1395
+ className: cls('dsh-ssh-helper-input', props.invalid === true && 'is-invalid'),
1396
+ onChange: (event) => props.onChange(event.target.value),
1397
+ onKeyDown: props.onKeyDown,
1398
+ spellCheck: false,
1399
+ ref: props.inputRef,
1400
+ })
1401
+ }
1402
+
1403
+ function TextArea(props) {
1404
+ return h('textarea', {
1405
+ id: props.id,
1406
+ value: props.value,
1407
+ rows: props.rows === undefined ? 4 : props.rows,
1408
+ placeholder: props.placeholder,
1409
+ className: cls('dsh-ssh-helper-textarea', props.invalid === true && 'is-invalid'),
1410
+ onChange: (event) => props.onChange(event.target.value),
1411
+ spellCheck: false,
1412
+ ref: props.inputRef,
1413
+ })
1414
+ }
1415
+
1416
+ function Select(props) {
1417
+ return h('select', {
1418
+ id: props.id,
1419
+ value: props.value,
1420
+ className: cls('dsh-ssh-helper-select', props.invalid === true && 'is-invalid'),
1421
+ onChange: (event) => props.onChange(event.target.value),
1422
+ }, props.children)
1423
+ }
1424
+
1425
+ function Toggle(props) {
1426
+ return h('label', { className: 'dsh-ssh-helper-toggle' },
1427
+ h('input', {
1428
+ type: 'checkbox',
1429
+ checked: props.checked,
1430
+ onChange: (event) => props.onChange(event.target.checked),
1431
+ }),
1432
+ h('span', { className: 'dsh-ssh-helper-toggle-track', 'aria-hidden': 'true' }),
1433
+ h('span', { className: 'dsh-ssh-helper-toggle-copy' },
1434
+ h('strong', null, props.label),
1435
+ props.description === undefined ? null : h('span', null, props.description)))
1436
+ }
1437
+
1438
+ function SectionLabel(props) {
1439
+ return h('div', { className: 'dsh-ssh-helper-section-label' }, props.icon === undefined ? null : h(Icon, { name: props.icon, size: 12 }), props.children)
1440
+ }
1441
+
1442
+ function MetaRow(props) {
1443
+ return h('div', { className: 'dsh-ssh-helper-meta-row' },
1444
+ h('div', { className: 'dsh-ssh-helper-meta-label' }, props.label),
1445
+ h('div', { className: 'dsh-ssh-helper-meta-value' },
1446
+ props.children,
1447
+ props.onCopy === undefined ? null : h(IconButton, { icon: 'copy', label: props.copyLabel || 'Copy', size: 13, onClick: props.onCopy })))
1448
+ }
1449
+
1450
+ function MetaGrid(props) {
1451
+ return h('div', { className: 'dsh-ssh-helper-meta' }, props.children)
1452
+ }
1453
+
1454
+ function CodeBlock(props) {
1455
+ const [expanded, setExpanded] = useState(false)
1456
+ const value = text(props.value)
1457
+ if (value === '') return null
1458
+ const long = value.length > 420 || value.split('\n').length > 7
1459
+ return h('div', { className: cls('dsh-ssh-helper-code', expanded && 'is-expanded') },
1460
+ h('div', { className: 'dsh-ssh-helper-code-head' },
1461
+ h('span', null, props.label),
1462
+ h('div', { className: 'dsh-ssh-helper-code-head-actions' },
1463
+ long ? h(Button, { variant: 'ghost', size: 'sm', onClick: () => setExpanded(!expanded) }, expanded ? props.t('showLess') : props.t('showMore')) : null,
1464
+ h(IconButton, { icon: 'copy', label: props.t('copyCommand'), size: 13, onClick: () => props.onCopy(value) }))),
1465
+ h('pre', null, value))
1466
+ }
1467
+
1468
+ function TestResult(props) {
1469
+ const result = props.result
1470
+ if (!result) return null
1471
+ const ok = result.status === 'ok'
1472
+ const toneClass = ok ? 'is-ok' : (result.status === 'testing' ? '' : 'is-fail')
1473
+ return h('div', { className: cls('dsh-ssh-helper-test-result', toneClass) },
1474
+ h('div', { className: 'dsh-ssh-helper-test-result-head' },
1475
+ result.status === 'testing' ? h('span', { className: 'dsh-ssh-helper-spinner' }) : h(StatusDot, { tone: ok ? 'success' : 'error' }),
1476
+ h('span', null, result.status === 'testing' ? props.t('testing') : (ok ? props.t('testOk') : props.t('testFail'))),
1477
+ ok && typeof result.latencyMs === 'number' ? h('span', { className: 'dsh-ssh-helper-muted' }, `${result.latencyMs} ms`) : null,
1478
+ !ok && typeof result.message === 'string' && result.message !== '' ? h('span', { className: 'dsh-ssh-helper-muted' }, `· ${result.message}`) : null),
1479
+ ok && typeof result.output === 'string' && result.output !== '' ? h('pre', null, result.output) : null)
1480
+ }
1481
+
1482
+ function EmptyState(props) {
1483
+ return h('div', { className: 'dsh-ssh-helper-empty' },
1484
+ h('div', { className: 'dsh-ssh-helper-empty-mark' }, h(Icon, { name: props.icon || 'terminal', size: 20 })),
1485
+ h('h4', null, props.title),
1486
+ h('p', null, props.description),
1487
+ props.actions === undefined ? null : h('div', { className: 'dsh-ssh-helper-empty-actions' }, props.actions))
1488
+ }
1489
+
1490
+ function SkeletonList(props) {
1491
+ const rows = props.rows === undefined ? 4 : props.rows
1492
+ return h('div', { className: 'dsh-ssh-helper-skeleton', 'aria-hidden': 'true' },
1493
+ Array.from({ length: rows }, (_, index) => h('div', { key: index, className: 'dsh-ssh-helper-skeleton-row' },
1494
+ h('div', { className: 'dsh-ssh-helper-skeleton-block dsh-ssh-helper-skeleton-avatar' }),
1495
+ h('div', { className: 'dsh-ssh-helper-skeleton-lines' },
1496
+ h('div', { className: 'dsh-ssh-helper-skeleton-block dsh-ssh-helper-skeleton-line' }),
1497
+ h('div', { className: 'dsh-ssh-helper-skeleton-block dsh-ssh-helper-skeleton-line is-short' })))))
1498
+ }
1499
+
1500
+ function ToastStack(props) {
1501
+ return h('div', { className: 'dsh-ssh-helper-toasts', 'aria-live': 'polite' },
1502
+ props.toasts.map((toast) => h('div', { key: toast.id, className: cls('dsh-ssh-helper-toast', toast.tone === 'error' ? 'is-error' : 'is-success') },
1503
+ h(Icon, { name: toast.tone === 'error' ? 'alert' : 'check', size: 15 }),
1504
+ h('span', null, toast.message),
1505
+ h(IconButton, { icon: 'x', label: 'Dismiss', size: 13, className: 'dsh-ssh-helper-toast-close', onClick: () => props.onDismiss(toast.id) }))))
1506
+ }
1507
+
1508
+ function ConfirmInline(props) {
1509
+ const [busy, setBusy] = useState(false)
1510
+ const [error, setError] = useState('')
1511
+ async function confirm() {
1512
+ setBusy(true)
1513
+ setError('')
1514
+ try {
1515
+ await props.onConfirm()
1516
+ } catch (caught) {
1517
+ setError(errorMessage(caught, props.t('toastDeleteFailed')))
1518
+ setBusy(false)
1519
+ }
1520
+ }
1521
+ return h('div', { className: 'dsh-ssh-helper-confirm' },
1522
+ h('strong', null, props.title),
1523
+ h('p', null, props.description),
1524
+ error === '' ? null : h('span', { className: 'dsh-ssh-helper-field-error' }, error),
1525
+ h('div', { className: 'dsh-ssh-helper-confirm-actions' },
1526
+ h(Button, { variant: 'ghost', size: 'sm', onClick: props.onCancel, disabled: busy }, props.t('cancel')),
1527
+ h(Button, { variant: 'danger', size: 'sm', loading: busy, onClick: confirm }, props.t('confirmDelete'))))
1528
+ }
1529
+
1530
+ function Sheet(props) {
1531
+ const panelRef = useRef(null)
1532
+ const closeRef = useRef(props.onClose)
1533
+ closeRef.current = props.onClose
1534
+ const titleId = useRef(`dsh-ssh-helper-sheet-${Math.random().toString(36).slice(2)}`).current
1535
+ const previousFocus = useRef(null)
1536
+ useEffect(() => {
1537
+ previousFocus.current = document.activeElement
1538
+ const body = document.body
1539
+ const previousOverflow = body.style.overflow
1540
+ const previousPadding = body.style.paddingRight
1541
+ const scrollbar = Math.max(0, window.innerWidth - document.documentElement.clientWidth)
1542
+ body.style.overflow = 'hidden'
1543
+ if (scrollbar > 0) body.style.paddingRight = `${scrollbar}px`
1544
+ const panel = panelRef.current
1545
+ const firstInput = panel === null ? null : panel.querySelector('input, textarea, select')
1546
+ const firstControl = firstInput !== null ? firstInput : (panel === null ? null : panel.querySelector('button, [href], [tabindex]:not([tabindex="-1"])'))
1547
+ if (firstControl !== null) firstControl.focus()
1548
+ function onKeyDown(event) {
1549
+ if (event.key === 'Escape') {
1550
+ event.preventDefault()
1551
+ event.stopPropagation()
1552
+ closeRef.current()
1553
+ return
1554
+ }
1555
+ if (event.key !== 'Tab' || panel === null) return
1556
+ const focusable = Array.from(panel.querySelectorAll('input, textarea, select, button, [href], [tabindex]:not([tabindex="-1"])'))
1557
+ .filter((element) => element.disabled !== true && element.getAttribute('aria-hidden') !== 'true' && element.offsetParent !== null)
1558
+ if (focusable.length === 0) return
1559
+ const firstElement = focusable[0]
1560
+ const lastElement = focusable[focusable.length - 1]
1561
+ if (event.shiftKey && document.activeElement === firstElement) {
1562
+ event.preventDefault()
1563
+ lastElement.focus()
1564
+ } else if (!event.shiftKey && document.activeElement === lastElement) {
1565
+ event.preventDefault()
1566
+ firstElement.focus()
1567
+ }
1568
+ }
1569
+ document.addEventListener('keydown', onKeyDown, true)
1570
+ return () => {
1571
+ document.removeEventListener('keydown', onKeyDown, true)
1572
+ body.style.overflow = previousOverflow
1573
+ body.style.paddingRight = previousPadding
1574
+ if (previousFocus.current !== null && typeof previousFocus.current.focus === 'function') previousFocus.current.focus()
1575
+ }
1576
+ }, [])
1577
+ return h('div', { className: 'dsh-ssh-helper-sheet-root', role: 'presentation' },
1578
+ h('div', { className: 'dsh-ssh-helper-scrim', onClick: props.onClose, 'aria-hidden': 'true' }),
1579
+ h('section', {
1580
+ className: cls('dsh-ssh-helper-sheet', props.wide === true && 'is-wide'),
1581
+ role: 'dialog',
1582
+ 'aria-modal': 'true',
1583
+ 'aria-labelledby': titleId,
1584
+ ref: panelRef,
1585
+ },
1586
+ h('header', { className: 'dsh-ssh-helper-sheet-head' },
1587
+ h('div', null,
1588
+ h('h2', { id: titleId }, props.title),
1589
+ props.subtitle === undefined ? null : h('p', { title: props.subtitle }, props.subtitle)),
1590
+ h(IconButton, { icon: 'x', label: props.t('close'), onClick: props.onClose })),
1591
+ h('div', { className: 'dsh-ssh-helper-sheet-body' }, props.children)))
1592
+ }
1593
+
1594
+ // ---------------------------------------------------------------------
1595
+ // Account draft, form and detail
1596
+ // ---------------------------------------------------------------------
1597
+
1598
+ function emptyAccountDraft() {
1599
+ return {
1600
+ id: undefined,
1601
+ name: '',
1602
+ host: '',
1603
+ port: '22',
1604
+ username: '',
1605
+ authType: 'password',
1606
+ password: '',
1607
+ privateKey: '',
1608
+ passphrase: '',
1609
+ note: '',
1610
+ }
1611
+ }
1612
+
1613
+ function accountDraftFrom(account) {
1614
+ if (!account) return emptyAccountDraft()
1615
+ return {
1616
+ id: account.id,
1617
+ name: account.name || '',
1618
+ host: account.host || '',
1619
+ port: String(account.port === undefined ? 22 : account.port),
1620
+ username: account.username || '',
1621
+ authType: account.authType === 'key' ? 'key' : 'password',
1622
+ password: '',
1623
+ privateKey: '',
1624
+ passphrase: '',
1625
+ note: account.note || '',
1626
+ }
1627
+ }
1628
+
1629
+ function accountPayload(draft) {
1630
+ const payload = {
1631
+ name: draft.name.trim(),
1632
+ host: draft.host.trim(),
1633
+ port: Number(draft.port),
1634
+ username: draft.username.trim(),
1635
+ authType: draft.authType,
1636
+ note: draft.note.trim(),
1637
+ }
1638
+ if (draft.id !== undefined && draft.id !== null && draft.id !== '') payload.id = draft.id
1639
+ if (draft.authType === 'password') {
1640
+ if (draft.password !== '') payload.password = draft.password
1641
+ } else {
1642
+ if (draft.privateKey.trim() !== '') payload.privateKey = draft.privateKey
1643
+ if (draft.passphrase !== '') payload.passphrase = draft.passphrase
1644
+ }
1645
+ return payload
1646
+ }
1647
+
1648
+ function validateAccountDraft(t, draft, account) {
1649
+ const errors = {}
1650
+ if (draft.host.trim() === '') errors.host = t('validationHost')
1651
+ const port = Number(draft.port)
1652
+ if (!Number.isInteger(port) || port < 1 || port > 65535) errors.port = t('validationPort')
1653
+ if (draft.username.trim() === '') errors.username = t('validationUsername')
1654
+ if (draft.authType === 'password') {
1655
+ const keeps = account !== null && account !== undefined && account.authType === 'password' && account.hasPassword === true
1656
+ if (draft.password === '' && !keeps) errors.password = t('validationPassword')
1657
+ } else {
1658
+ const keeps = account !== null && account !== undefined && account.authType === 'key' && account.hasKey === true
1659
+ if (draft.privateKey.trim() === '' && !keeps) errors.privateKey = t('validationKey')
1660
+ }
1661
+ return errors
1662
+ }
1663
+
1664
+ function AccountForm(props) {
1665
+ const { t, account, onSaved } = props
1666
+ const [draft, setDraft] = useState(accountDraftFrom(account))
1667
+ const [errors, setErrors] = useState({})
1668
+ const [saving, setSaving] = useState(false)
1669
+ const [testing, setTesting] = useState(false)
1670
+ const [testResult, setTestResult] = useState(null)
1671
+ const [formError, setFormError] = useState('')
1672
+ const firstRef = useRef(null)
1673
+ useEffect(() => {
1674
+ if (firstRef.current !== null) firstRef.current.focus()
1675
+ }, [])
1676
+ function patch(key, value) {
1677
+ setDraft((previous) => ({ ...previous, [key]: value }))
1678
+ setErrors((previous) => {
1679
+ if (previous[key] === undefined) return previous
1680
+ const next = { ...previous }
1681
+ delete next[key]
1682
+ return next
1683
+ })
1684
+ }
1685
+ async function submit(event) {
1686
+ event.preventDefault()
1687
+ const nextErrors = validateAccountDraft(t, draft, account)
1688
+ if (Object.keys(nextErrors).length > 0) {
1689
+ setErrors(nextErrors)
1690
+ const order = ['host', 'port', 'username', 'password', 'privateKey']
1691
+ const firstKey = order.find((key) => nextErrors[key] !== undefined)
1692
+ const element = firstKey === undefined ? null : document.getElementById(`ssh-helper-account-${firstKey}`)
1693
+ if (element !== null) element.focus()
1694
+ return
1695
+ }
1696
+ setSaving(true)
1697
+ setFormError('')
1698
+ try {
1699
+ const data = await call('accounts/save', accountPayload(draft))
1700
+ await onSaved(data.account)
1701
+ } catch (caught) {
1702
+ setFormError(errorMessage(caught, t('toastSaveFailed')))
1703
+ setSaving(false)
1704
+ }
1705
+ }
1706
+ async function testDraft() {
1707
+ const nextErrors = validateAccountDraft(t, draft, account)
1708
+ if (Object.keys(nextErrors).length > 0) {
1709
+ setErrors(nextErrors)
1710
+ return
1711
+ }
1712
+ setTesting(true)
1713
+ setTestResult(null)
1714
+ setFormError('')
1715
+ try {
1716
+ const data = await call('accounts/test-draft', accountPayload(draft))
1717
+ setTestResult({
1718
+ status: data.success === true ? 'ok' : 'fail',
1719
+ latencyMs: data.latencyMs,
1720
+ message: data.message,
1721
+ output: data.output,
1722
+ })
1723
+ } catch (caught) {
1724
+ setTestResult({ status: 'fail', message: errorMessage(caught, t('testFail')) })
1725
+ } finally {
1726
+ setTesting(false)
1727
+ }
1728
+ }
1729
+ const auth = draft.authType
1730
+ return h('form', { className: 'dsh-ssh-helper-form', onSubmit: submit },
1731
+ h('p', { className: 'dsh-ssh-helper-form-hint' }, t('formHintAccount')),
1732
+ h(Field, { label: t('fieldName'), htmlFor: 'ssh-helper-account-name', hint: t('fieldNameHint'), error: errors.name },
1733
+ h(TextInput, { id: 'ssh-helper-account-name', value: draft.name, onChange: (value) => patch('name', value), inputRef: firstRef })),
1734
+ h('div', { className: 'dsh-ssh-helper-grid-2' },
1735
+ h(Field, { label: t('fieldHost'), htmlFor: 'ssh-helper-account-host', required: true, error: errors.host },
1736
+ h(TextInput, { id: 'ssh-helper-account-host', value: draft.host, onChange: (value) => patch('host', value), invalid: errors.host !== undefined, placeholder: '10.0.0.10' })),
1737
+ h(Field, { label: t('fieldPort'), htmlFor: 'ssh-helper-account-port', required: true, error: errors.port },
1738
+ h(TextInput, { id: 'ssh-helper-account-port', value: draft.port, onChange: (value) => patch('port', value), invalid: errors.port !== undefined, type: 'number' }))),
1739
+ h(Field, { label: t('fieldUsername'), htmlFor: 'ssh-helper-account-username', required: true, error: errors.username },
1740
+ h(TextInput, { id: 'ssh-helper-account-username', value: draft.username, onChange: (value) => patch('username', value), invalid: errors.username !== undefined, placeholder: 'root' })),
1741
+ h(Field, { label: t('fieldAuthType') },
1742
+ h('div', { className: 'dsh-ssh-helper-auth-row' },
1743
+ h('button', { type: 'button', className: 'dsh-ssh-helper-auth-option', 'aria-pressed': auth === 'password' ? 'true' : 'false', onClick: () => patch('authType', 'password') },
1744
+ h(Icon, { name: 'key', size: 14 }), t('authPassword')),
1745
+ h('button', { type: 'button', className: 'dsh-ssh-helper-auth-option', 'aria-pressed': auth === 'key' ? 'true' : 'false', onClick: () => patch('authType', 'key') },
1746
+ h(Icon, { name: 'lock', size: 14 }), t('authKey')))),
1747
+ auth === 'password'
1748
+ ? h(Field, { label: t('fieldPassword'), htmlFor: 'ssh-helper-account-password', required: true, error: errors.password, hint: account && account.hasPassword ? t('secretKeepHint') : t('secretRequiredHint') },
1749
+ h(TextInput, { id: 'ssh-helper-account-password', value: draft.password, onChange: (value) => patch('password', value), invalid: errors.password !== undefined, type: 'password', autoComplete: 'new-password' }))
1750
+ : h('div', null,
1751
+ h(Field, { label: t('fieldPrivateKey'), htmlFor: 'ssh-helper-account-privateKey', required: true, error: errors.privateKey, hint: account && account.hasKey ? t('secretKeepHint') : t('secretRequiredHint') },
1752
+ h(TextArea, { id: 'ssh-helper-account-privateKey', value: draft.privateKey, onChange: (value) => patch('privateKey', value), invalid: errors.privateKey !== undefined, rows: 5, placeholder: '-----BEGIN OPENSSH PRIVATE KEY-----' })),
1753
+ h(Field, { label: t('fieldPassphrase'), htmlFor: 'ssh-helper-account-passphrase', hint: account && account.hasKey ? t('secretKeepHint') : undefined },
1754
+ h(TextInput, { id: 'ssh-helper-account-passphrase', value: draft.passphrase, onChange: (value) => patch('passphrase', value), type: 'password', autoComplete: 'new-password' }))),
1755
+ h(Field, { label: t('fieldNote'), htmlFor: 'ssh-helper-account-note' },
1756
+ h(TextArea, { id: 'ssh-helper-account-note', value: draft.note, onChange: (value) => patch('note', value), rows: 2 })),
1757
+ formError === '' ? null : h('div', { className: 'dsh-ssh-helper-error-banner', role: 'alert' }, h(Icon, { name: 'alert', size: 14 }), formError),
1758
+ testResult === null ? null : h(TestResult, { t, result: testResult }),
1759
+ h('div', { className: 'dsh-ssh-helper-form-footer' },
1760
+ h(Button, { variant: 'ghost', loading: testing, onClick: testDraft, disabled: saving }, t('testDraft')),
1761
+ h('div', { className: 'dsh-ssh-helper-form-footer-right' },
1762
+ h(Button, { variant: 'ghost', onClick: props.onClose, disabled: saving }, t('cancel')),
1763
+ h(Button, { variant: 'primary', type: 'submit', loading: saving }, saving ? t('saving') : t('save')))))
1764
+ }
1765
+
1766
+ function AccountDetail(props) {
1767
+ const { t, account, testResult, onTest, onEdit, onDelete, onCopy, onClose } = props
1768
+ const [confirming, setConfirming] = useState(false)
1769
+ const initials = (account.name || account.username || '?').trim().slice(0, 2).toUpperCase()
1770
+ return h('div', { className: 'dsh-ssh-helper-detail' },
1771
+ h('div', { className: 'dsh-ssh-helper-detail-summary' },
1772
+ h('div', { className: 'dsh-ssh-helper-avatar' }, initials),
1773
+ h('div', { className: 'dsh-ssh-helper-detail-title' },
1774
+ h('h3', null, account.name),
1775
+ h('p', { className: 'dsh-ssh-helper-mono' }, `${account.username}@${account.host}:${account.port}`)),
1776
+ h(StatusDot, { tone: testResult !== null && testResult !== undefined ? (testResult.status === 'ok' ? 'success' : 'error') : 'idle' })),
1777
+ h('div', { className: 'dsh-ssh-helper-detail-actions' },
1778
+ h(Button, { variant: 'secondary', icon: 'play', loading: testResult !== null && testResult !== undefined && testResult.status === 'testing', onClick: () => onTest(account) }, t('test')),
1779
+ h(Button, { variant: 'secondary', icon: 'edit', onClick: () => onEdit(account) }, t('edit')),
1780
+ h(Button, { variant: 'ghost', icon: 'copy', onClick: () => onCopy(sshCommand(account)) }, t('copySsh')),
1781
+ h(Button, { variant: 'danger', icon: 'trash', onClick: () => setConfirming(true) }, t('delete'))),
1782
+ confirming
1783
+ ? h(ConfirmInline, {
1784
+ t,
1785
+ title: t('deleteServerTitle'),
1786
+ description: t('deleteServerDesc'),
1787
+ onCancel: () => setConfirming(false),
1788
+ onConfirm: async () => { await onDelete(account) },
1789
+ })
1790
+ : null,
1791
+ h(TestResult, { t, result: testResult }),
1792
+ h(SectionLabel, { icon: 'server' }, t('sectionConnection')),
1793
+ h(MetaGrid, null,
1794
+ h(MetaRow, { label: t('fieldHost'), onCopy: () => onCopy(account.host), copyLabel: t('copy') }, h('span', { className: 'dsh-ssh-helper-mono' }, account.host)),
1795
+ h(MetaRow, { label: t('fieldPort') }, h('span', { className: 'dsh-ssh-helper-mono' }, String(account.port))),
1796
+ h(MetaRow, { label: t('fieldUsername') }, h('span', { className: 'dsh-ssh-helper-mono' }, account.username)),
1797
+ h(MetaRow, { label: t('fieldAuthType') },
1798
+ h(Chip, { tone: 'neutral' }, account.authType === 'key' ? t('authKey') : t('authPassword')),
1799
+ h(Chip, { tone: account.hasPassword || account.hasKey ? 'success' : 'idle' }, credentialLabel(t, account))),
1800
+ h(MetaRow, { label: t('updatedAt') }, h('span', { className: 'dsh-ssh-helper-muted' }, formatDateTime(account.updatedAt)))),
1801
+ account.note === undefined || account.note === '' ? null : h('div', null,
1802
+ h(SectionLabel, { icon: 'edit' }, t('sectionNote')),
1803
+ h('p', { className: 'dsh-ssh-helper-form-hint' }, account.note)))
1804
+ }
1805
+
1806
+ // ---------------------------------------------------------------------
1807
+ // Resource draft, form and detail
1808
+ // ---------------------------------------------------------------------
1809
+
1810
+ function emptyResourceDraft(accounts) {
1811
+ return {
1812
+ id: undefined,
1813
+ name: '',
1814
+ server: accounts.length > 0 ? accounts[0].id : '',
1815
+ serviceName: '',
1816
+ appDir: '',
1817
+ updateCommand: '',
1818
+ restartCommand: '',
1819
+ verifyCommand: '',
1820
+ backupEnabled: true,
1821
+ backupDir: '',
1822
+ backupKeep: '5',
1823
+ timeoutSeconds: '600',
1824
+ note: '',
1825
+ tagsText: '',
1826
+ }
1827
+ }
1828
+
1829
+ function resourceDraftFrom(resource, accounts) {
1830
+ if (!resource) return emptyResourceDraft(accounts)
1831
+ return {
1832
+ id: resource.id,
1833
+ name: resource.name || '',
1834
+ server: resource.serverId || (accounts.length > 0 ? accounts[0].id : ''),
1835
+ serviceName: resource.serviceName || '',
1836
+ appDir: resource.appDir || '',
1837
+ updateCommand: resource.updateCommand || '',
1838
+ restartCommand: resource.restartCommand || '',
1839
+ verifyCommand: resource.verifyCommand || '',
1840
+ backupEnabled: resource.backupEnabled !== false,
1841
+ backupDir: resource.backupDir || '',
1842
+ backupKeep: String(resource.backupKeep === undefined ? 5 : resource.backupKeep),
1843
+ timeoutSeconds: String(resource.timeoutSeconds === undefined ? 600 : resource.timeoutSeconds),
1844
+ note: resource.note || '',
1845
+ tagsText: Array.isArray(resource.tags) ? resource.tags.join(', ') : '',
1846
+ }
1847
+ }
1848
+
1849
+ function resourcePayload(draft) {
1850
+ const tags = draft.tagsText.split(',').map((tag) => tag.trim()).filter((tag) => tag !== '')
1851
+ const payload = {
1852
+ name: draft.name.trim(),
1853
+ server: draft.server,
1854
+ serviceName: draft.serviceName.trim(),
1855
+ appDir: draft.appDir.trim(),
1856
+ updateCommand: draft.updateCommand,
1857
+ restartCommand: draft.restartCommand,
1858
+ verifyCommand: draft.verifyCommand,
1859
+ backupEnabled: draft.backupEnabled,
1860
+ backupDir: draft.backupDir.trim(),
1861
+ backupKeep: Number(draft.backupKeep),
1862
+ timeoutSeconds: Number(draft.timeoutSeconds),
1863
+ note: draft.note.trim(),
1864
+ tags,
1865
+ }
1866
+ if (draft.id !== undefined && draft.id !== null && draft.id !== '') payload.id = draft.id
1867
+ return payload
1868
+ }
1869
+
1870
+ function validateResourceDraft(t, draft) {
1871
+ const errors = {}
1872
+ if (draft.name.trim() === '') errors.name = t('validationName')
1873
+ if (draft.server === '') errors.server = t('validationServer')
1874
+ const appDir = draft.appDir.trim()
1875
+ if (appDir === '' || !appDir.startsWith('/')) errors.appDir = t('validationAppDir')
1876
+ if (draft.updateCommand.trim() === '') errors.updateCommand = t('validationUpdate')
1877
+ if (draft.backupDir.trim() !== '' && !draft.backupDir.trim().startsWith('/')) errors.backupDir = t('validationBackupDir')
1878
+ const keep = Number(draft.backupKeep)
1879
+ if (!Number.isInteger(keep) || keep < 1 || keep > 50) errors.backupKeep = t('validationBackupKeep')
1880
+ const timeout = Number(draft.timeoutSeconds)
1881
+ if (!Number.isInteger(timeout) || timeout < 30 || timeout > 3600) errors.timeoutSeconds = t('validationTimeout')
1882
+ return errors
1883
+ }
1884
+
1885
+ function ResourceForm(props) {
1886
+ const { t, resource, accounts, onSaved } = props
1887
+ const [draft, setDraft] = useState(resourceDraftFrom(resource, accounts))
1888
+ const [errors, setErrors] = useState({})
1889
+ const [saving, setSaving] = useState(false)
1890
+ const [formError, setFormError] = useState('')
1891
+ const [advanced, setAdvanced] = useState(
1892
+ resource !== null && resource !== undefined && (
1893
+ text(resource.restartCommand) !== '' ||
1894
+ text(resource.verifyCommand) !== '' ||
1895
+ text(resource.backupDir) !== '' ||
1896
+ (Array.isArray(resource.tags) && resource.tags.length > 0) ||
1897
+ text(resource.note) !== ''
1898
+ )
1899
+ )
1900
+ const firstRef = useRef(null)
1901
+ useEffect(() => {
1902
+ if (firstRef.current !== null) firstRef.current.focus()
1903
+ }, [])
1904
+ function patch(key, value) {
1905
+ setDraft((previous) => ({ ...previous, [key]: value }))
1906
+ setErrors((previous) => {
1907
+ if (previous[key] === undefined) return previous
1908
+ const next = { ...previous }
1909
+ delete next[key]
1910
+ return next
1911
+ })
1912
+ }
1913
+ async function submit(event) {
1914
+ event.preventDefault()
1915
+ const nextErrors = validateResourceDraft(t, draft)
1916
+ if (Object.keys(nextErrors).length > 0) {
1917
+ setErrors(nextErrors)
1918
+ const order = ['name', 'server', 'appDir', 'updateCommand', 'backupDir', 'backupKeep', 'timeoutSeconds']
1919
+ const firstKey = order.find((key) => nextErrors[key] !== undefined)
1920
+ const element = firstKey === undefined ? null : document.getElementById(`ssh-helper-resource-${firstKey}`)
1921
+ if (element !== null) element.focus()
1922
+ return
1923
+ }
1924
+ setSaving(true)
1925
+ setFormError('')
1926
+ try {
1927
+ const data = await call('resources/save', resourcePayload(draft))
1928
+ await onSaved(data.resource)
1929
+ } catch (caught) {
1930
+ setFormError(errorMessage(caught, t('toastSaveFailed')))
1931
+ setSaving(false)
1932
+ }
1933
+ }
1934
+ return h('form', { className: 'dsh-ssh-helper-form', onSubmit: submit },
1935
+ h('p', { className: 'dsh-ssh-helper-form-hint' }, t('formHintResource')),
1936
+ h(Field, { label: t('fieldName'), htmlFor: 'ssh-helper-resource-name', required: true, error: errors.name },
1937
+ h(TextInput, { id: 'ssh-helper-resource-name', value: draft.name, onChange: (value) => patch('name', value), invalid: errors.name !== undefined, inputRef: firstRef, placeholder: 'web-api' })),
1938
+ h(Field, { label: t('fieldServer'), htmlFor: 'ssh-helper-resource-server', required: true, error: errors.server },
1939
+ h(Select, { id: 'ssh-helper-resource-server', value: draft.server, onChange: (value) => patch('server', value), invalid: errors.server !== undefined },
1940
+ accounts.map((account) => h('option', { key: account.id, value: account.id }, `${account.name} — ${account.username}@${account.host}`)))),
1941
+ h(Field, { label: t('fieldAppDir'), htmlFor: 'ssh-helper-resource-appDir', required: true, error: errors.appDir },
1942
+ h(TextInput, { id: 'ssh-helper-resource-appDir', value: draft.appDir, onChange: (value) => patch('appDir', value), invalid: errors.appDir !== undefined, placeholder: '/opt/web-api' })),
1943
+ h(Field, { label: t('fieldUpdate'), htmlFor: 'ssh-helper-resource-updateCommand', required: true, error: errors.updateCommand },
1944
+ h(TextArea, { id: 'ssh-helper-resource-updateCommand', value: draft.updateCommand, onChange: (value) => patch('updateCommand', value), invalid: errors.updateCommand !== undefined, rows: 5, placeholder: 'git pull --ff-only' })),
1945
+ h('button', { type: 'button', className: 'dsh-ssh-helper-advanced-toggle', 'aria-expanded': advanced ? 'true' : 'false', onClick: () => setAdvanced(!advanced) },
1946
+ h(Icon, { name: 'chevronDown', size: 13 }), advanced ? t('showLess') : t('showMore')),
1947
+ advanced ? h('div', { className: 'dsh-ssh-helper-advanced' },
1948
+ h(Field, { label: t('fieldServiceName'), htmlFor: 'ssh-helper-resource-serviceName' },
1949
+ h(TextInput, { id: 'ssh-helper-resource-serviceName', value: draft.serviceName, onChange: (value) => patch('serviceName', value), placeholder: 'web-api' })),
1950
+ h(Field, { label: t('fieldRestart'), htmlFor: 'ssh-helper-resource-restartCommand' },
1951
+ h(TextArea, { id: 'ssh-helper-resource-restartCommand', value: draft.restartCommand, onChange: (value) => patch('restartCommand', value), rows: 2, placeholder: 'systemctl restart web-api' })),
1952
+ h(Field, { label: t('fieldVerify'), htmlFor: 'ssh-helper-resource-verifyCommand' },
1953
+ h(TextArea, { id: 'ssh-helper-resource-verifyCommand', value: draft.verifyCommand, onChange: (value) => patch('verifyCommand', value), rows: 2, placeholder: 'curl -fsS http://127.0.0.1:8080/health' })),
1954
+ h(Toggle, { checked: draft.backupEnabled, onChange: (value) => patch('backupEnabled', value), label: t('fieldBackupEnabled'), description: t('sectionBackup') }),
1955
+ draft.backupEnabled ? h('div', { className: 'dsh-ssh-helper-grid-2' },
1956
+ h(Field, { label: t('fieldBackupDir'), htmlFor: 'ssh-helper-resource-backupDir', error: errors.backupDir },
1957
+ h(TextInput, { id: 'ssh-helper-resource-backupDir', value: draft.backupDir, onChange: (value) => patch('backupDir', value), invalid: errors.backupDir !== undefined, placeholder: '/opt/.ssh-helper-backups' })),
1958
+ h(Field, { label: t('fieldBackupKeep'), htmlFor: 'ssh-helper-resource-backupKeep', error: errors.backupKeep },
1959
+ h(TextInput, { id: 'ssh-helper-resource-backupKeep', value: draft.backupKeep, onChange: (value) => patch('backupKeep', value), invalid: errors.backupKeep !== undefined, type: 'number' }))) : null,
1960
+ h(Field, { label: t('fieldTimeout'), htmlFor: 'ssh-helper-resource-timeoutSeconds', error: errors.timeoutSeconds },
1961
+ h(TextInput, { id: 'ssh-helper-resource-timeoutSeconds', value: draft.timeoutSeconds, onChange: (value) => patch('timeoutSeconds', value), invalid: errors.timeoutSeconds !== undefined, type: 'number' })),
1962
+ h(Field, { label: t('fieldTags'), htmlFor: 'ssh-helper-resource-tags', hint: t('tagsHint') },
1963
+ h(TextInput, { id: 'ssh-helper-resource-tags', value: draft.tagsText, onChange: (value) => patch('tagsText', value), placeholder: 'web, nginx' })),
1964
+ h(Field, { label: t('fieldNote'), htmlFor: 'ssh-helper-resource-note' },
1965
+ h(TextArea, { id: 'ssh-helper-resource-note', value: draft.note, onChange: (value) => patch('note', value), rows: 2 }))) : null,
1966
+ formError === '' ? null : h('div', { className: 'dsh-ssh-helper-error-banner', role: 'alert' }, h(Icon, { name: 'alert', size: 14 }), formError),
1967
+ h('div', { className: 'dsh-ssh-helper-form-footer' },
1968
+ h('span', null),
1969
+ h('div', { className: 'dsh-ssh-helper-form-footer-right' },
1970
+ h(Button, { variant: 'ghost', onClick: props.onClose, disabled: saving }, t('cancel')),
1971
+ h(Button, { variant: 'primary', type: 'submit', loading: saving }, saving ? t('saving') : t('save')))))
1972
+ }
1973
+
1974
+ function ResourceDetail(props) {
1975
+ const { t, resource, activity, onEdit, onDelete, onCopy, onViewActivity } = props
1976
+ const [confirming, setConfirming] = useState(false)
1977
+ const meta = statusMeta(resource.lastUpdateStatus)
1978
+ const discovered = resource.discovered !== null && typeof resource.discovered === 'object' ? resource.discovered : null
1979
+ return h('div', { className: 'dsh-ssh-helper-detail' },
1980
+ h('div', { className: 'dsh-ssh-helper-detail-summary' },
1981
+ h(StatusDot, { tone: meta.tone }),
1982
+ h('div', { className: 'dsh-ssh-helper-detail-title' },
1983
+ h('h3', null, resource.name),
1984
+ h('p', { className: 'dsh-ssh-helper-mono' }, `${resource.serverName || t('unknownServer')} · ${resource.appDir}`)),
1985
+ h(StatusChip, { t, status: resource.lastUpdateStatus })),
1986
+ h('div', { className: 'dsh-ssh-helper-detail-actions' },
1987
+ h(Button, { variant: 'secondary', icon: 'edit', onClick: () => onEdit(resource) }, t('edit')),
1988
+ h(Button, { variant: 'secondary', icon: 'copy', onClick: () => onCopy(promptForUpdate(t, resource), t('promptCopied')) }, t('copyUpdatePrompt')),
1989
+ h(Button, { variant: 'danger', icon: 'trash', onClick: () => setConfirming(true) }, t('delete'))),
1990
+ confirming
1991
+ ? h(ConfirmInline, {
1992
+ t,
1993
+ title: t('deleteDeploymentTitle'),
1994
+ description: t('deleteDeploymentDesc'),
1995
+ onCancel: () => setConfirming(false),
1996
+ onConfirm: async () => { await onDelete(resource) },
1997
+ })
1998
+ : null,
1999
+ h(SectionLabel, { icon: 'folder' }, t('sectionDeployment')),
2000
+ h(MetaGrid, null,
2001
+ h(MetaRow, { label: t('fieldServer') }, h('span', { className: 'dsh-ssh-helper-mono' }, resource.serverName || t('unknownServer'))),
2002
+ h(MetaRow, { label: t('fieldAppDir'), onCopy: () => onCopy(resource.appDir), copyLabel: t('copyPath') }, h('span', { className: 'dsh-ssh-helper-mono dsh-ssh-helper-truncate', title: resource.appDir }, resource.appDir)),
2003
+ resource.serviceName === undefined || resource.serviceName === '' ? null : h(MetaRow, { label: t('fieldServiceName') }, h('span', { className: 'dsh-ssh-helper-mono' }, resource.serviceName)),
2004
+ h(MetaRow, { label: t('lastUpdated') }, h('span', { className: 'dsh-ssh-helper-muted' }, resource.lastUpdateAt === undefined || resource.lastUpdateAt === '' ? t('neverUpdated') : `${formatDateTime(resource.lastUpdateAt)} · ${formatRelative(resource.lastUpdateAt, t)}`)),
2005
+ Array.isArray(resource.tags) && resource.tags.length > 0 ? h(MetaRow, { label: t('fieldTags') }, h('div', { className: 'dsh-ssh-helper-view-actions' }, resource.tags.map((tag) => h(Chip, { key: tag, tone: 'neutral' }, tag)))) : null),
2006
+ h(SectionLabel, { icon: 'terminal' }, t('sectionCommands')),
2007
+ h(CodeBlock, { t, label: t('fieldUpdate'), value: resource.updateCommand, onCopy }),
2008
+ resource.restartCommand === undefined || resource.restartCommand === '' ? null : h(CodeBlock, { t, label: t('fieldRestart'), value: resource.restartCommand, onCopy }),
2009
+ resource.verifyCommand === undefined || resource.verifyCommand === '' ? null : h(CodeBlock, { t, label: t('fieldVerify'), value: resource.verifyCommand, onCopy }),
2010
+ h(SectionLabel, { icon: 'shield' }, t('sectionBackup')),
2011
+ h(MetaGrid, null,
2012
+ h(MetaRow, { label: t('fieldBackupEnabled') }, h(Chip, { tone: resource.backupEnabled === false ? 'idle' : 'success' }, resource.backupEnabled === false ? t('backupOff') : t('backupOn'))),
2013
+ resource.backupDir === undefined || resource.backupDir === '' ? null : h(MetaRow, { label: t('fieldBackupDir') }, h('span', { className: 'dsh-ssh-helper-mono dsh-ssh-helper-truncate', title: resource.backupDir }, resource.backupDir)),
2014
+ h(MetaRow, { label: t('fieldBackupKeep') }, h('span', null, fmt(t, 'backupKeepValue', { count: resource.backupKeep === undefined ? 5 : resource.backupKeep }))),
2015
+ h(MetaRow, { label: t('fieldTimeout') }, h('span', null, fmt(t, 'timeoutValue', { count: resource.timeoutSeconds === undefined ? 600 : resource.timeoutSeconds })))),
2016
+ h(SectionLabel, { icon: 'search' }, t('sectionDiscovery')),
2017
+ discovered === null
2018
+ ? h('p', { className: 'dsh-ssh-helper-form-hint' }, t('noDiscovery'))
2019
+ : h('div', { className: 'dsh-ssh-helper-form' },
2020
+ discovered.inspectedAt === undefined ? null : h('p', { className: 'dsh-ssh-helper-form-hint' }, fmt(t, 'discoveredAt', { time: formatDateTime(discovered.inspectedAt) })),
2021
+ Array.isArray(discovered.markers) && discovered.markers.length > 0 ? h('div', { className: 'dsh-ssh-helper-view-actions' }, discovered.markers.map((marker) => h(Chip, { key: marker, tone: 'brand' }, marker))) : null,
2022
+ discovered.gitRemote === undefined || discovered.gitRemote === '' ? null : h(MetaGrid, null, h(MetaRow, { label: t('gitRemote') }, h('span', { className: 'dsh-ssh-helper-mono dsh-ssh-helper-truncate' }, discovered.gitRemote))),
2023
+ discovered.gitBranch === undefined || discovered.gitBranch === '' ? null : h(MetaGrid, null, h(MetaRow, { label: t('gitBranch') }, h('span', { className: 'dsh-ssh-helper-mono' }, discovered.gitBranch))),
2024
+ Array.isArray(discovered.serviceUnits) && discovered.serviceUnits.length > 0 ? h(MetaGrid, null, h(MetaRow, { label: t('serviceUnits') }, h('span', { className: 'dsh-ssh-helper-mono dsh-ssh-helper-truncate', title: discovered.serviceUnits.join(', ') }, discovered.serviceUnits.join(', ')))) : null),
2025
+ h(SectionLabel, { icon: 'history' }, t('sectionRecentActivity')),
2026
+ activity.length === 0
2027
+ ? h('p', { className: 'dsh-ssh-helper-form-hint' }, t('noActivity'))
2028
+ : h('div', { className: 'dsh-ssh-helper-timeline' },
2029
+ activity.slice(0, 5).map((entry) => h('div', { key: entry.id, className: 'dsh-ssh-helper-activity' },
2030
+ h('div', { className: 'dsh-ssh-helper-activity-time' }, formatTimeOnly(entry.startedAt)),
2031
+ h('div', { className: 'dsh-ssh-helper-activity-axis' }, h(StatusDot, { tone: statusMeta(entry.status).tone })),
2032
+ h('div', { className: 'dsh-ssh-helper-activity-card' },
2033
+ h('div', { className: 'dsh-ssh-helper-activity-summary' },
2034
+ h('div', { className: 'dsh-ssh-helper-activity-summary-main' },
2035
+ h('div', { className: 'dsh-ssh-helper-activity-summary-title' }, `${actionLabel(t, entry.action)} · ${entry.resourceName}`),
2036
+ h('div', { className: 'dsh-ssh-helper-activity-summary-sub' }, `${formatDuration(entry.durationMs)} · ${formatDateTime(entry.startedAt)}`))))))),
2037
+ activity.length > 5 ? h(Button, { variant: 'ghost', onClick: () => onViewActivity(resource) }, t('viewAllActivity')) : null,
2038
+ resource.note === undefined || resource.note === '' ? null : h('div', null,
2039
+ h(SectionLabel, { icon: 'edit' }, t('sectionNote')),
2040
+ h('p', { className: 'dsh-ssh-helper-form-hint' }, resource.note)))
2041
+ }
2042
+
2043
+ // ---------------------------------------------------------------------
2044
+ // Lists
2045
+ // ---------------------------------------------------------------------
2046
+
2047
+ function AccountRow(props) {
2048
+ const { t, account, result, selected, onOpen, onEdit, onTest } = props
2049
+ const initials = (account.name || account.username || '?').trim().slice(0, 2).toUpperCase()
2050
+ const testing = result !== null && result !== undefined && result.status === 'testing'
2051
+ return h('li', { className: cls('dsh-ssh-helper-row', selected && 'is-selected') },
2052
+ h('button', { type: 'button', className: 'dsh-ssh-helper-row-hit', onClick: () => onOpen(account), 'aria-label': `${t('open')}: ${account.name}` },
2053
+ h('div', { className: 'dsh-ssh-helper-avatar' }, initials),
2054
+ h('div', { className: 'dsh-ssh-helper-row-main' },
2055
+ h('div', { className: 'dsh-ssh-helper-row-title' },
2056
+ h('span', null, account.name),
2057
+ h(Chip, { tone: 'neutral' }, account.authType === 'key' ? t('authKey') : t('authPassword'))),
2058
+ h('div', { className: 'dsh-ssh-helper-row-sub' },
2059
+ h('span', { className: 'dsh-ssh-helper-mono' }, `${account.username}@${account.host}:${account.port}`),
2060
+ account.note === undefined || account.note === '' ? null : h('span', { className: 'dsh-ssh-helper-row-note' }, `· ${account.note}`)))),
2061
+ h('div', { className: 'dsh-ssh-helper-row-side' },
2062
+ testing
2063
+ ? h('span', { className: 'dsh-ssh-helper-spinner', title: t('testing') })
2064
+ : result !== null && result !== undefined
2065
+ ? h(Chip, { tone: result.status === 'ok' ? 'success' : 'error' }, result.status === 'ok' ? `${t('testOk')} · ${result.latencyMs || 0} ms` : t('testFail'))
2066
+ : null,
2067
+ h('div', { className: 'dsh-ssh-helper-row-actions' },
2068
+ h(IconButton, { icon: 'play', label: t('test'), onClick: (event) => { event.stopPropagation(); onTest(account) } }),
2069
+ h(IconButton, { icon: 'edit', label: t('edit'), onClick: (event) => { event.stopPropagation(); onEdit(account) } }),
2070
+ h(IconButton, { icon: 'chevron', label: t('open'), onClick: (event) => { event.stopPropagation(); onOpen(account) } }))))
2071
+ }
2072
+
2073
+ function DeploymentRow(props) {
2074
+ const { t, resource, selected, onOpen, onEdit } = props
2075
+ const meta = statusMeta(resource.lastUpdateStatus)
2076
+ return h('li', { className: cls('dsh-ssh-helper-row', selected && 'is-selected') },
2077
+ h('button', { type: 'button', className: 'dsh-ssh-helper-row-hit', onClick: () => onOpen(resource), 'aria-label': `${t('open')}: ${resource.name}` },
2078
+ h(StatusDot, { tone: meta.tone }),
2079
+ h('div', { className: 'dsh-ssh-helper-row-main' },
2080
+ h('div', { className: 'dsh-ssh-helper-row-title' },
2081
+ h('span', null, resource.name),
2082
+ resource.serviceName === undefined || resource.serviceName === '' ? null : h(Chip, { tone: 'neutral' }, fmt(t, 'serviceValue', { name: resource.serviceName }))),
2083
+ h('div', { className: 'dsh-ssh-helper-row-sub' },
2084
+ h('span', { className: 'dsh-ssh-helper-mono' }, resource.serverName || t('unknownServer')),
2085
+ h('span', { className: 'dsh-ssh-helper-sep' }, '·'),
2086
+ h('span', { className: 'dsh-ssh-helper-mono dsh-ssh-helper-truncate', title: resource.appDir }, resource.appDir)))),
2087
+ h('div', { className: 'dsh-ssh-helper-row-side' },
2088
+ h(StatusChip, { t, status: resource.lastUpdateStatus }),
2089
+ h('span', { className: 'dsh-ssh-helper-row-time' }, resource.lastUpdateAt === undefined || resource.lastUpdateAt === '' ? t('neverUpdated') : formatRelative(resource.lastUpdateAt, t)),
2090
+ h('div', { className: 'dsh-ssh-helper-row-actions' },
2091
+ h(IconButton, { icon: 'edit', label: t('edit'), onClick: (event) => { event.stopPropagation(); onEdit(resource) } }),
2092
+ h(IconButton, { icon: 'chevron', label: t('open'), onClick: (event) => { event.stopPropagation(); onOpen(resource) } }))))
2093
+ }
2094
+
2095
+ function importBanner(t, legacy, importing, onImport, onDismiss) {
2096
+ if (!legacy.available || legacy.count <= 0) return null
2097
+ return h('div', { className: 'dsh-ssh-helper-banner', key: 'import' },
2098
+ h(Icon, { name: 'link', size: 15 }),
2099
+ h('span', null, h('strong', null, t('importTitle')), ' · ', fmt(t, 'importDesc', { count: legacy.count })),
2100
+ h('div', { className: 'dsh-ssh-helper-banner-actions' },
2101
+ h(Button, { variant: 'ghost', size: 'sm', onClick: onDismiss }, t('dismiss')),
2102
+ h(Button, { variant: 'primary', size: 'sm', loading: importing, onClick: onImport }, fmt(t, 'importAction', { count: legacy.count }))))
2103
+ }
2104
+
2105
+ function AccountsView(props) {
2106
+ const { t, accounts, total, search, onSearch, filtersActive, onClear, testResults, onTest, onTestAll, testingAll, onAdd, onEdit, onOpen, legacy, importing, onImport, onDismissLegacy, selectedId } = props
2107
+ const pendingLegacy = legacy.available && legacy.count > 0
2108
+ return h('section', { className: 'dsh-ssh-helper-view' },
2109
+ h('div', { className: 'dsh-ssh-helper-view-head' },
2110
+ h('div', null, h('h3', null, t('accountsTitle')), h('p', null, t('accountsSubtitle'))),
2111
+ h('div', { className: 'dsh-ssh-helper-view-actions' },
2112
+ h(Button, { variant: 'ghost', icon: 'play', loading: testingAll, onClick: onTestAll, disabled: total === 0 }, t('testAll')),
2113
+ h(Button, { variant: 'primary', icon: 'plus', onClick: onAdd }, t('addServer')))),
2114
+ h('div', { className: 'dsh-ssh-helper-toolbar' },
2115
+ h(SearchBox, { value: search, onChange: onSearch, placeholder: t('searchAccounts') })),
2116
+ pendingLegacy ? importBanner(t, legacy, importing, onImport, onDismissLegacy) : null,
2117
+ total === 0
2118
+ ? h(EmptyState, {
2119
+ icon: 'server',
2120
+ title: t('emptyAccountsTitle'),
2121
+ description: t('emptyAccountsDesc'),
2122
+ actions: [h(Button, { key: 'add', variant: 'primary', icon: 'plus', onClick: onAdd }, t('addServer'))],
2123
+ })
2124
+ : accounts.length === 0
2125
+ ? h(EmptyState, { icon: 'search', title: t('emptySearchTitle'), description: t('emptySearchDesc'), actions: [h(Button, { key: 'clear', variant: 'secondary', onClick: onClear }, t('clearFilters'))] })
2126
+ : h('ul', { className: 'dsh-ssh-helper-list' },
2127
+ accounts.map((account) => h(AccountRow, {
2128
+ key: account.id,
2129
+ t,
2130
+ account,
2131
+ result: testResults[account.id],
2132
+ selected: selectedId === account.id,
2133
+ onOpen,
2134
+ onEdit,
2135
+ onTest,
2136
+ }))),
2137
+ h('p', { className: 'dsh-ssh-helper-footnote' }, h(Icon, { name: 'lock', size: 12 }), t('formHintAccount')))
2138
+ }
2139
+
2140
+ function DeploymentsView(props) {
2141
+ const { t, resources, total, search, onSearch, filter, onFilter, onAdd, onEdit, onOpen, onCopyPrompt, hasAccounts, selectedId, onGoToAccounts } = props
2142
+ return h('section', { className: 'dsh-ssh-helper-view' },
2143
+ h('div', { className: 'dsh-ssh-helper-view-head' },
2144
+ h('div', null, h('h3', null, t('deploymentsTitle')), h('p', null, t('deploymentsSubtitle'))),
2145
+ h('div', { className: 'dsh-ssh-helper-view-actions' },
2146
+ h(Button, { variant: 'ghost', icon: 'copy', onClick: () => onCopyPrompt(promptForDiscovery(t)) }, t('copyDiscoveryPrompt')),
2147
+ h(Button, { variant: 'primary', icon: hasAccounts ? 'plus' : 'server', onClick: hasAccounts ? onAdd : onGoToAccounts }, hasAccounts ? t('addDeployment') : t('goToAccounts')))),
2148
+ h('div', { className: 'dsh-ssh-helper-toolbar' },
2149
+ h(SearchBox, { value: search, onChange: onSearch, placeholder: t('searchDeployments') }),
2150
+ h('div', { className: 'dsh-ssh-helper-filter-row', role: 'group', 'aria-label': t('searchDeployments') },
2151
+ ['all', 'success', 'issues', 'never'].map((value) => h('button', {
2152
+ key: value,
2153
+ type: 'button',
2154
+ className: 'dsh-ssh-helper-filter',
2155
+ 'aria-pressed': filter === value ? 'true' : 'false',
2156
+ onClick: () => onFilter(value),
2157
+ }, value === 'all' ? t('filterAll') : value === 'success' ? t('filterSuccess') : value === 'issues' ? t('filterIssues') : t('filterNever'))))),
2158
+ total === 0
2159
+ ? h(EmptyState, {
2160
+ icon: 'terminal',
2161
+ title: hasAccounts ? t('emptyDeploymentsTitle') : t('emptyDeploymentsNoServer'),
2162
+ description: hasAccounts ? t('emptyDeploymentsDesc') : t('emptyDeploymentsNoServer'),
2163
+ actions: hasAccounts
2164
+ ? [
2165
+ h(Button, { key: 'add', variant: 'primary', icon: 'plus', onClick: onAdd }, t('addDeployment')),
2166
+ h(Button, { key: 'prompt', variant: 'secondary', icon: 'copy', onClick: () => onCopyPrompt(promptForDiscovery(t)) }, t('copyDiscoveryPrompt')),
2167
+ ]
2168
+ : [h(Button, { key: 'accounts', variant: 'primary', icon: 'server', onClick: onGoToAccounts }, t('goToAccounts'))],
2169
+ })
2170
+ : resources.length === 0
2171
+ ? h(EmptyState, { icon: 'search', title: t('emptySearchTitle'), description: t('emptySearchDesc'), actions: [h(Button, { key: 'clear', variant: 'secondary', onClick: () => { onSearch(''); onFilter('all') } }, t('clearFilters'))] })
2172
+ : h('ul', { className: 'dsh-ssh-helper-list' },
2173
+ resources.map((resource) => h(DeploymentRow, {
2174
+ key: resource.id,
2175
+ t,
2176
+ resource,
2177
+ selected: selectedId === resource.id,
2178
+ onOpen,
2179
+ onEdit,
2180
+ }))))
2181
+ }
2182
+
2183
+ function ActivityStep(props) {
2184
+ const { t, step, onCopy } = props
2185
+ const meta = statusMeta(step.status)
2186
+ return h('div', { className: 'dsh-ssh-helper-step' },
2187
+ h('div', { className: 'dsh-ssh-helper-step-dot' }, h(StatusDot, { tone: meta.tone })),
2188
+ h('div', null,
2189
+ h('div', { className: 'dsh-ssh-helper-step-title' }, step.title),
2190
+ h('div', { className: 'dsh-ssh-helper-step-meta' },
2191
+ step.exitCode === null || step.exitCode === undefined ? null : h('span', null, fmt(t, 'exitCode', { code: step.exitCode })),
2192
+ h('span', null, fmt(t, 'duration', { value: formatDuration(step.durationMs) }))),
2193
+ step.command === undefined || step.command === '' ? null : h(CodeBlock, { t, label: t('sectionCommands'), value: step.command, onCopy }),
2194
+ step.stdoutTail === undefined || step.stdoutTail === '' ? null : h('div', { className: 'dsh-ssh-helper-output' },
2195
+ h('div', { className: 'dsh-ssh-helper-output-label' }, t('stdout')),
2196
+ h('pre', null, step.stdoutTail)),
2197
+ step.stderrTail === undefined || step.stderrTail === '' ? null : h('div', { className: 'dsh-ssh-helper-output' },
2198
+ h('div', { className: 'dsh-ssh-helper-output-label' }, t('stderr')),
2199
+ h('pre', null, step.stderrTail))))
2200
+ }
2201
+
2202
+ function ActivityEntry(props) {
2203
+ const { t, entry, onCopy } = props
2204
+ const [open, setOpen] = useState(false)
2205
+ const meta = statusMeta(entry.status)
2206
+ return h('div', { className: 'dsh-ssh-helper-activity' },
2207
+ h('div', { className: 'dsh-ssh-helper-activity-time' }, formatTimeOnly(entry.startedAt)),
2208
+ h('div', { className: 'dsh-ssh-helper-activity-axis' }, h(StatusDot, { tone: meta.tone })),
2209
+ h('div', { className: 'dsh-ssh-helper-activity-card' },
2210
+ h('button', { type: 'button', className: 'dsh-ssh-helper-activity-summary', onClick: () => setOpen(!open), 'aria-expanded': open ? 'true' : 'false' },
2211
+ h('div', { className: 'dsh-ssh-helper-activity-summary-main' },
2212
+ h('div', { className: 'dsh-ssh-helper-activity-summary-title' },
2213
+ h('span', null, `${actionLabel(t, entry.action)} · ${entry.resourceName}`),
2214
+ h(StatusChip, { t, status: entry.status })),
2215
+ h('div', { className: 'dsh-ssh-helper-activity-summary-sub' },
2216
+ `${entry.serverName || t('unknownServer')} · ${formatDuration(entry.durationMs)} · ${formatDateTime(entry.startedAt)}`)),
2217
+ h(Icon, { name: open ? 'chevronDown' : 'chevron', size: 15 })),
2218
+ open ? h('div', { className: 'dsh-ssh-helper-activity-body' },
2219
+ entry.message === undefined || entry.message === '' ? null : h('p', { className: 'dsh-ssh-helper-activity-message' }, entry.message),
2220
+ Array.isArray(entry.steps) && entry.steps.length > 0
2221
+ ? h('div', null, h(SectionLabel, null, t('steps')), entry.steps.map((step, index) => h(ActivityStep, { key: `${entry.id}-${index}`, t, step, onCopy })))
2222
+ : h('p', { className: 'dsh-ssh-helper-form-hint' }, t('noOutput'))) : null))
2223
+ }
2224
+
2225
+ function ActivityView(props) {
2226
+ const { t, entries, total, search, onSearch, filter, onFilter, onRefresh, onCopy, refreshing } = props
2227
+ const groups = groupHistoryByDay(entries)
2228
+ return h('section', { className: 'dsh-ssh-helper-view' },
2229
+ h('div', { className: 'dsh-ssh-helper-view-head' },
2230
+ h('div', null, h('h3', null, t('activityTitle')), h('p', null, t('activitySubtitle'))),
2231
+ h('div', { className: 'dsh-ssh-helper-view-actions' },
2232
+ h(Button, { variant: 'secondary', icon: 'refresh', loading: refreshing, onClick: onRefresh }, t('refresh')))),
2233
+ h('div', { className: 'dsh-ssh-helper-toolbar' },
2234
+ h(SearchBox, { value: search, onChange: onSearch, placeholder: t('searchActivity') }),
2235
+ h('div', { className: 'dsh-ssh-helper-filter-row', role: 'group', 'aria-label': t('searchActivity') },
2236
+ ['all', 'success', 'issues'].map((value) => h('button', {
2237
+ key: value,
2238
+ type: 'button',
2239
+ className: 'dsh-ssh-helper-filter',
2240
+ 'aria-pressed': filter === value ? 'true' : 'false',
2241
+ onClick: () => onFilter(value),
2242
+ }, value === 'all' ? t('filterAll') : value === 'success' ? t('filterSuccess') : t('filterIssues'))))),
2243
+ total === 0
2244
+ ? h(EmptyState, { icon: 'history', title: t('emptyActivityTitle'), description: t('emptyActivityDesc') })
2245
+ : entries.length === 0
2246
+ ? h(EmptyState, { icon: 'search', title: t('emptySearchTitle'), description: t('emptySearchDesc'), actions: [h(Button, { key: 'clear', variant: 'secondary', onClick: () => { onSearch(''); onFilter('all') } }, t('clearFilters'))] })
2247
+ : h('div', { className: 'dsh-ssh-helper-timeline' },
2248
+ groups.map((group) => h('div', { key: group.key },
2249
+ h('div', { className: 'dsh-ssh-helper-day' }, group.key),
2250
+ group.entries.map((entry) => h(ActivityEntry, { key: entry.id, t, entry, onCopy }))))))
2251
+ }
2252
+
2253
+ // ---------------------------------------------------------------------
2254
+ // Section
2255
+ // ---------------------------------------------------------------------
2256
+
2257
+ function Section(props) {
2258
+ const t = props.t
2259
+ const rootRef = useRef(null)
2260
+ const mounted = useRef(true)
2261
+ const toastSeq = useRef(0)
2262
+ const viewChosen = useRef(false)
2263
+ const [bootStatus, setBootStatus] = useState('loading')
2264
+ const [bootError, setBootError] = useState('')
2265
+ const [partialError, setPartialError] = useState(false)
2266
+ const [accounts, setAccounts] = useState([])
2267
+ const [resources, setResources] = useState([])
2268
+ const [history, setHistory] = useState([])
2269
+ const [legacy, setLegacy] = useState({ available: false, count: 0 })
2270
+ const [legacyDismissed, setLegacyDismissed] = useState(readLegacyDismissed)
2271
+ const [view, setView] = useState('accounts')
2272
+ const [accountSearch, setAccountSearch] = useState('')
2273
+ const [deploymentSearch, setDeploymentSearch] = useState('')
2274
+ const [activitySearch, setActivitySearch] = useState('')
2275
+ const [deploymentFilter, setDeploymentFilter] = useState('all')
2276
+ const [activityFilter, setActivityFilter] = useState('all')
2277
+ const [selectedAccountId, setSelectedAccountId] = useState(null)
2278
+ const [selectedResourceId, setSelectedResourceId] = useState(null)
2279
+ const [sheet, setSheet] = useState(null)
2280
+ const [testResults, setTestResults] = useState({})
2281
+ const [testingAll, setTestingAll] = useState(false)
2282
+ const [importing, setImporting] = useState(false)
2283
+ const [refreshing, setRefreshing] = useState(false)
2284
+ const [toasts, setToasts] = useState([])
2285
+
2286
+ function pushToast(message, tone) {
2287
+ toastSeq.current += 1
2288
+ const id = toastSeq.current
2289
+ setToasts((previous) => [...previous, { id, message, tone: tone === undefined ? 'success' : tone }])
2290
+ window.setTimeout(() => {
2291
+ setToasts((previous) => previous.filter((toast) => toast.id !== id))
2292
+ }, tone === 'error' ? 6000 : 3500)
2293
+ }
2294
+
2295
+ function dismissToast(id) {
2296
+ setToasts((previous) => previous.filter((toast) => toast.id !== id))
2297
+ }
2298
+
2299
+ async function loadAll(initial) {
2300
+ if (initial) setBootStatus('loading')
2301
+ setBootError('')
2302
+ setPartialError(false)
2303
+ const results = await Promise.allSettled([
2304
+ call('accounts/list'),
2305
+ call('resources/list'),
2306
+ call('history/list', { limit: 200 }),
2307
+ ])
2308
+ if (!mounted.current) return false
2309
+ let failed = 0
2310
+ if (results[0].status === 'fulfilled' && results[0].value !== null && typeof results[0].value === 'object') {
2311
+ setAccounts(Array.isArray(results[0].value.accounts) ? results[0].value.accounts : [])
2312
+ setLegacy(results[0].value.legacy !== null && typeof results[0].value.legacy === 'object' ? results[0].value.legacy : { available: false, count: 0 })
2313
+ } else {
2314
+ failed += 1
2315
+ }
2316
+ if (results[1].status === 'fulfilled' && results[1].value !== null && typeof results[1].value === 'object') {
2317
+ setResources(Array.isArray(results[1].value.resources) ? results[1].value.resources : [])
2318
+ } else {
2319
+ failed += 1
2320
+ }
2321
+ if (results[2].status === 'fulfilled' && results[2].value !== null && typeof results[2].value === 'object') {
2322
+ setHistory(Array.isArray(results[2].value.history) ? results[2].value.history : [])
2323
+ } else {
2324
+ failed += 1
2325
+ }
2326
+ if (failed === 3) {
2327
+ setBootStatus('error')
2328
+ setBootError(errorMessage(results[0].reason, t('loadFailed')))
2329
+ return false
2330
+ }
2331
+ if (failed > 0) setPartialError(true)
2332
+ setBootStatus('ready')
2333
+ if (!viewChosen.current) {
2334
+ viewChosen.current = true
2335
+ const accountCount = results[0].status === 'fulfilled' && results[0].value && Array.isArray(results[0].value.accounts) ? results[0].value.accounts.length : 0
2336
+ const resourceCount = results[1].status === 'fulfilled' && results[1].value && Array.isArray(results[1].value.resources) ? results[1].value.resources.length : 0
2337
+ setView(resourceCount > 0 || accountCount > 0 ? (resourceCount > 0 ? 'deployments' : 'accounts') : 'accounts')
2338
+ }
2339
+ return failed === 0
2340
+ }
2341
+
2342
+ useEffect(() => {
2343
+ mounted.current = true
2344
+ loadAll(true)
2345
+ return () => { mounted.current = false }
2346
+ }, [])
2347
+
2348
+ useEffect(() => {
2349
+ function onKeyDown(event) {
2350
+ if (event.defaultPrevented || event.metaKey || event.ctrlKey || event.altKey) return
2351
+ if (event.key !== '/') return
2352
+ if (sheet !== null || isEditable(event.target)) return
2353
+ const input = rootRef.current === null ? null : rootRef.current.querySelector('.dsh-ssh-helper-search input')
2354
+ if (input !== null) {
2355
+ event.preventDefault()
2356
+ input.focus()
2357
+ input.select()
2358
+ }
2359
+ }
2360
+ document.addEventListener('keydown', onKeyDown)
2361
+ return () => document.removeEventListener('keydown', onKeyDown)
2362
+ }, [sheet])
2363
+
2364
+ async function refreshAll() {
2365
+ setRefreshing(true)
2366
+ const ok = await loadAll(false)
2367
+ if (!mounted.current) return
2368
+ setRefreshing(false)
2369
+ if (!ok) pushToast(t('toastRefreshFailed'), 'error')
2370
+ }
2371
+
2372
+ async function refreshAccounts() {
2373
+ try {
2374
+ const data = await call('accounts/list')
2375
+ if (!mounted.current) return
2376
+ setAccounts(Array.isArray(data.accounts) ? data.accounts : [])
2377
+ setLegacy(data.legacy !== null && typeof data.legacy === 'object' ? data.legacy : { available: false, count: 0 })
2378
+ } catch (caught) {
2379
+ if (mounted.current) pushToast(errorMessage(caught, t('toastRefreshFailed')), 'error')
2380
+ }
2381
+ }
2382
+
2383
+ async function refreshResources() {
2384
+ try {
2385
+ const data = await call('resources/list')
2386
+ if (mounted.current) setResources(Array.isArray(data.resources) ? data.resources : [])
2387
+ } catch (caught) {
2388
+ if (mounted.current) pushToast(errorMessage(caught, t('toastRefreshFailed')), 'error')
2389
+ }
2390
+ }
2391
+
2392
+ async function refreshHistory() {
2393
+ try {
2394
+ const data = await call('history/list', { limit: 200 })
2395
+ if (mounted.current) setHistory(Array.isArray(data.history) ? data.history : [])
2396
+ } catch (caught) {
2397
+ if (mounted.current) pushToast(errorMessage(caught, t('toastRefreshFailed')), 'error')
2398
+ }
2399
+ }
2400
+
2401
+ async function refreshActivity() {
2402
+ setRefreshing(true)
2403
+ await refreshHistory()
2404
+ if (mounted.current) setRefreshing(false)
2405
+ }
2406
+
2407
+ async function copyValue(value, message) {
2408
+ try {
2409
+ await copyText(value)
2410
+ pushToast(typeof message === 'string' && message !== '' ? message : t('toastCopied'))
2411
+ } catch {
2412
+ pushToast(t('toastCopyFailed'), 'error')
2413
+ }
2414
+ }
2415
+
2416
+ async function copyPrompt(value) {
2417
+ await copyValue(value, t('promptCopied'))
2418
+ }
2419
+
2420
+ async function testAccount(account) {
2421
+ setTestResults((previous) => ({ ...previous, [account.id]: { status: 'testing' } }))
2422
+ try {
2423
+ const data = await call('accounts/test', { id: account.id })
2424
+ if (!mounted.current) return
2425
+ setTestResults((previous) => ({
2426
+ ...previous,
2427
+ [account.id]: {
2428
+ status: data.success === true ? 'ok' : 'fail',
2429
+ latencyMs: data.latencyMs,
2430
+ message: data.message,
2431
+ output: data.output,
2432
+ },
2433
+ }))
2434
+ } catch (caught) {
2435
+ if (!mounted.current) return
2436
+ setTestResults((previous) => ({ ...previous, [account.id]: { status: 'fail', message: errorMessage(caught, t('testFail')) } }))
2437
+ }
2438
+ }
2439
+
2440
+ async function testAll() {
2441
+ setTestingAll(true)
2442
+ try {
2443
+ for (const account of accounts) {
2444
+ if (!mounted.current) return
2445
+ await testAccount(account)
2446
+ }
2447
+ if (mounted.current) pushToast(t('toastTested'))
2448
+ } finally {
2449
+ if (mounted.current) setTestingAll(false)
2450
+ }
2451
+ }
2452
+
2453
+ function dismissLegacy() {
2454
+ writeLegacyDismissed()
2455
+ setLegacyDismissed(true)
2456
+ }
2457
+
2458
+ async function importLegacy() {
2459
+ setImporting(true)
2460
+ try {
2461
+ const data = await call('accounts/import')
2462
+ await refreshAccounts()
2463
+ if (!mounted.current) return
2464
+ dismissLegacy()
2465
+ pushToast(fmt(t, 'toastImported', { imported: data.imported, skipped: data.skipped }))
2466
+ } catch (caught) {
2467
+ if (mounted.current) pushToast(errorMessage(caught, t('toastImportFailed')), 'error')
2468
+ } finally {
2469
+ if (mounted.current) setImporting(false)
2470
+ }
2471
+ }
2472
+
2473
+ async function saveAccount(account) {
2474
+ await refreshAccounts()
2475
+ if (!mounted.current) return
2476
+ setSelectedAccountId(account.id)
2477
+ setSheet(null)
2478
+ pushToast(t('toastSaved'))
2479
+ }
2480
+
2481
+ async function saveResource(resource) {
2482
+ await refreshResources()
2483
+ if (!mounted.current) return
2484
+ setSelectedResourceId(resource.id)
2485
+ setSheet(null)
2486
+ pushToast(t('toastSaved'))
2487
+ }
2488
+
2489
+ async function deleteAccount(account) {
2490
+ await call('accounts/delete', { id: account.id })
2491
+ if (!mounted.current) return
2492
+ setSheet(null)
2493
+ setSelectedAccountId(null)
2494
+ await refreshAccounts()
2495
+ pushToast(t('toastDeleted'))
2496
+ }
2497
+
2498
+ async function deleteResource(resource) {
2499
+ await call('resources/delete', { id: resource.id })
2500
+ if (!mounted.current) return
2501
+ setSheet(null)
2502
+ setSelectedResourceId(null)
2503
+ await refreshResources()
2504
+ await refreshHistory()
2505
+ pushToast(t('toastDeleted'))
2506
+ }
2507
+
2508
+ function openAccount(account) {
2509
+ setSelectedAccountId(account.id)
2510
+ setSheet({ type: 'account-detail', id: account.id })
2511
+ }
2512
+
2513
+ function openResource(resource) {
2514
+ setSelectedResourceId(resource.id)
2515
+ setSheet({ type: 'resource-detail', id: resource.id })
2516
+ }
2517
+
2518
+ function openAccountForm(account) {
2519
+ setSheet({ type: 'account-form', id: account === undefined ? undefined : account.id })
2520
+ }
2521
+
2522
+ function openResourceForm(resource) {
2523
+ setSheet({ type: 'resource-form', id: resource === undefined ? undefined : resource.id })
2524
+ }
2525
+
2526
+ function goToAccounts() {
2527
+ viewChosen.current = true
2528
+ setView('accounts')
2529
+ setSheet(null)
2530
+ }
2531
+
2532
+ const accountById = new Map(accounts.map((account) => [account.id, account]))
2533
+ const resourceById = new Map(resources.map((resource) => [resource.id, resource]))
2534
+
2535
+ const accountQuery = accountSearch.trim().toLowerCase()
2536
+ const filteredAccounts = accounts.filter((account) => {
2537
+ if (accountQuery === '') return true
2538
+ return [account.name, account.host, account.username, account.note]
2539
+ .some((value) => text(value).toLowerCase().includes(accountQuery))
2540
+ })
2541
+
2542
+ const deploymentQuery = deploymentSearch.trim().toLowerCase()
2543
+ const filteredResources = resources.filter((resource) => {
2544
+ const matchesQuery = deploymentQuery === '' || [resource.name, resource.serverName, resource.appDir, resource.serviceName, resource.note, ...(Array.isArray(resource.tags) ? resource.tags : [])]
2545
+ .some((value) => text(value).toLowerCase().includes(deploymentQuery))
2546
+ return matchesQuery && matchStatusFilter(resource.lastUpdateStatus, deploymentFilter)
2547
+ })
2548
+
2549
+ const activityQuery = activitySearch.trim().toLowerCase()
2550
+ const filteredHistory = history.filter((entry) => {
2551
+ const matchesQuery = activityQuery === '' || [entry.resourceName, entry.serverName, entry.appDir, entry.status, entry.action]
2552
+ .some((value) => text(value).toLowerCase().includes(activityQuery))
2553
+ return matchesQuery && matchStatusFilter(entry.status, activityFilter)
2554
+ })
2555
+
2556
+ const lastEntry = history.length > 0 ? history[0] : null
2557
+ const lastMeta = lastEntry === null ? { tone: 'idle' } : statusMeta(lastEntry.status)
2558
+
2559
+ let sheetNode = null
2560
+ if (sheet !== null && sheet.type === 'account-detail') {
2561
+ const account = accountById.get(sheet.id)
2562
+ if (account !== undefined) {
2563
+ sheetNode = h(Sheet, { t, title: t('accountDetail'), subtitle: account.name, onClose: () => setSheet(null) },
2564
+ h(AccountDetail, {
2565
+ t,
2566
+ account,
2567
+ testResult: testResults[account.id],
2568
+ onTest: testAccount,
2569
+ onEdit: openAccountForm,
2570
+ onDelete: deleteAccount,
2571
+ onCopy: copyValue,
2572
+ onClose: () => setSheet(null),
2573
+ }))
2574
+ }
2575
+ } else if (sheet !== null && sheet.type === 'account-form') {
2576
+ const account = sheet.id === undefined ? null : accountById.get(sheet.id)
2577
+ sheetNode = h(Sheet, {
2578
+ t,
2579
+ title: account === null || account === undefined ? t('accountAddTitle') : t('accountEditTitle'),
2580
+ subtitle: account === null || account === undefined ? undefined : account.name,
2581
+ onClose: () => setSheet(null),
2582
+ wide: true,
2583
+ }, h(AccountForm, {
2584
+ t,
2585
+ account,
2586
+ onSaved: saveAccount,
2587
+ onClose: () => setSheet(null),
2588
+ }))
2589
+ } else if (sheet !== null && sheet.type === 'resource-detail') {
2590
+ const resource = resourceById.get(sheet.id)
2591
+ if (resource !== undefined) {
2592
+ sheetNode = h(Sheet, { t, title: t('resourceDetail'), subtitle: resource.name, onClose: () => setSheet(null) },
2593
+ h(ResourceDetail, {
2594
+ t,
2595
+ resource,
2596
+ activity: history.filter((entry) => entry.resourceId === resource.id),
2597
+ onEdit: openResourceForm,
2598
+ onDelete: deleteResource,
2599
+ onCopy: copyValue,
2600
+ onViewActivity: (target) => {
2601
+ setActivitySearch(target.name)
2602
+ setActivityFilter('all')
2603
+ setView('activity')
2604
+ setSheet(null)
2605
+ },
2606
+ }))
2607
+ }
2608
+ } else if (sheet !== null && sheet.type === 'resource-form') {
2609
+ const resource = sheet.id === undefined ? null : resourceById.get(sheet.id)
2610
+ sheetNode = h(Sheet, {
2611
+ t,
2612
+ title: resource === null || resource === undefined ? t('resourceAddTitle') : t('resourceEditTitle'),
2613
+ subtitle: resource === null || resource === undefined ? undefined : resource.name,
2614
+ onClose: () => setSheet(null),
2615
+ wide: true,
2616
+ }, h(ResourceForm, {
2617
+ t,
2618
+ resource,
2619
+ accounts,
2620
+ onSaved: saveResource,
2621
+ onClose: () => setSheet(null),
2622
+ }))
2623
+ }
2624
+
2625
+ const railItems = [
2626
+ { id: 'accounts', icon: 'server', label: t('viewAccounts'), count: accounts.length },
2627
+ { id: 'deployments', icon: 'terminal', label: t('viewDeployments'), count: resources.length },
2628
+ { id: 'activity', icon: 'history', label: t('viewActivity'), count: history.length },
2629
+ ]
2630
+
2631
+ let content = null
2632
+ if (bootStatus === 'loading') {
2633
+ content = h('div', { className: 'dsh-ssh-helper-loading-panel' },
2634
+ h('p', null, t('loading')),
2635
+ h(SkeletonList, { rows: 4 }))
2636
+ } else if (bootStatus === 'error') {
2637
+ content = h('div', { className: 'dsh-ssh-helper-error-panel' },
2638
+ h(Icon, { name: 'alert', size: 22 }),
2639
+ h('h4', null, t('loadFailed')),
2640
+ h('p', null, bootError || t('toastLoadFailed')),
2641
+ h(Button, { variant: 'primary', icon: 'refresh', onClick: () => loadAll(true) }, t('retry')))
2642
+ } else if (view === 'accounts') {
2643
+ content = h(AccountsView, {
2644
+ t,
2645
+ accounts: filteredAccounts,
2646
+ total: accounts.length,
2647
+ search: accountSearch,
2648
+ onSearch: setAccountSearch,
2649
+ filtersActive: accountSearch !== '',
2650
+ onClear: () => setAccountSearch(''),
2651
+ testResults,
2652
+ onTest: testAccount,
2653
+ onTestAll: testAll,
2654
+ testingAll,
2655
+ onAdd: () => openAccountForm(),
2656
+ onEdit: openAccountForm,
2657
+ onOpen: openAccount,
2658
+ legacy: legacyDismissed ? { available: false, count: 0 } : legacy,
2659
+ importing,
2660
+ onImport: importLegacy,
2661
+ onDismissLegacy: dismissLegacy,
2662
+ selectedId: selectedAccountId,
2663
+ })
2664
+ } else if (view === 'deployments') {
2665
+ content = h(DeploymentsView, {
2666
+ t,
2667
+ resources: filteredResources,
2668
+ total: resources.length,
2669
+ search: deploymentSearch,
2670
+ onSearch: setDeploymentSearch,
2671
+ filter: deploymentFilter,
2672
+ onFilter: setDeploymentFilter,
2673
+ onAdd: () => openResourceForm(),
2674
+ onEdit: openResourceForm,
2675
+ onOpen: openResource,
2676
+ onCopyPrompt: copyPrompt,
2677
+ hasAccounts: accounts.length > 0,
2678
+ selectedId: selectedResourceId,
2679
+ onGoToAccounts: goToAccounts,
2680
+ })
2681
+ } else {
2682
+ content = h(ActivityView, {
2683
+ t,
2684
+ entries: filteredHistory,
2685
+ total: history.length,
2686
+ search: activitySearch,
2687
+ onSearch: setActivitySearch,
2688
+ filter: activityFilter,
2689
+ onFilter: setActivityFilter,
2690
+ onRefresh: refreshActivity,
2691
+ onCopy: copyValue,
2692
+ refreshing,
2693
+ })
2694
+ }
2695
+
2696
+ return h('div', { className: 'dsh-ssh-helper-page', ref: rootRef },
2697
+ h('div', { className: 'dsh-ssh-helper-section' },
2698
+ h('div', { className: 'dsh-ssh-helper-shell' },
2699
+ h('header', { className: 'dsh-ssh-helper-hero' },
2700
+ h('div', { className: 'dsh-ssh-helper-hero-main' },
2701
+ h('div', { className: 'dsh-ssh-helper-brand' },
2702
+ h('div', { className: 'dsh-ssh-helper-brand-mark' }, h(Icon, { name: 'terminal', size: 20 })),
2703
+ h('div', null,
2704
+ h('h2', null, t('title')),
2705
+ h('p', null, t('subtitle')))),
2706
+ h('span', { className: 'dsh-ssh-helper-hero-pill' }, h(Icon, { name: 'shield', size: 13 }), t('approvalPill'))),
2707
+ h('div', { className: 'dsh-ssh-helper-stats' },
2708
+ h('div', { className: 'dsh-ssh-helper-stat' },
2709
+ h('span', { className: 'dsh-ssh-helper-stat-label' }, t('statServers')),
2710
+ h('span', { className: 'dsh-ssh-helper-stat-value' }, String(accounts.length))),
2711
+ h('div', { className: 'dsh-ssh-helper-stat' },
2712
+ h('span', { className: 'dsh-ssh-helper-stat-label' }, t('statDeployments')),
2713
+ h('span', { className: 'dsh-ssh-helper-stat-value' }, String(resources.length))),
2714
+ h('div', { className: 'dsh-ssh-helper-stat' },
2715
+ h('span', { className: 'dsh-ssh-helper-stat-label' }, t('statLastUpdate')),
2716
+ h('span', { className: 'dsh-ssh-helper-stat-value' },
2717
+ h(StatusDot, { tone: lastMeta.tone }),
2718
+ lastEntry === null ? t('statNone') : formatRelative(lastEntry.startedAt, t))),
2719
+ h(IconButton, { icon: 'refresh', label: t('refresh'), onClick: refreshAll, disabled: refreshing }))),
2720
+ h('div', { className: 'dsh-ssh-helper-console' },
2721
+ h('nav', { className: 'dsh-ssh-helper-rail', 'aria-label': t('nav') },
2722
+ railItems.map((item) => h('button', {
2723
+ key: item.id,
2724
+ type: 'button',
2725
+ className: 'dsh-ssh-helper-rail-item',
2726
+ 'aria-current': view === item.id ? 'page' : undefined,
2727
+ onClick: () => { viewChosen.current = true; setView(item.id); setSheet(null) },
2728
+ },
2729
+ h('span', { className: 'dsh-ssh-helper-rail-icon' }, h(Icon, { name: item.icon, size: 16 })),
2730
+ h('span', { className: 'dsh-ssh-helper-rail-label' }, item.label),
2731
+ h('span', { className: 'dsh-ssh-helper-rail-count' }, String(item.count)))),
2732
+ h('div', { className: 'dsh-ssh-helper-rail-note' },
2733
+ h(Icon, { name: 'shield', size: 14 }),
2734
+ h('div', null,
2735
+ h('strong', null, t('approvalNote')),
2736
+ h('span', { title: t('approvalNoteDesc') }, t('approvalPill'))))),
2737
+ h('main', { className: 'dsh-ssh-helper-main' },
2738
+ partialError ? h('div', { className: 'dsh-ssh-helper-error-banner', role: 'alert' },
2739
+ h(Icon, { name: 'alert', size: 14 }),
2740
+ t('partialLoad'),
2741
+ h(Button, { variant: 'ghost', size: 'sm', onClick: refreshAll }, t('retry'))) : null,
2742
+ content)))),
2743
+ sheetNode,
2744
+ h(ToastStack, { toasts, onDismiss: dismissToast }))
2745
+ }
2746
+
2747
+ // ---------------------------------------------------------------------
2748
+ // Plugin mount
2749
+ // ---------------------------------------------------------------------
2750
+
2751
+ /**
2752
+ * Sidebar icon for the global panel row. The host supplies `size` and
2753
+ * `active`; the row owns the button, label and active styling.
2754
+ * @param {{ size?: number, active?: boolean }} props - icon presentation.
2755
+ * @returns {object} icon element.
2756
+ */
2757
+ function PanelIcon(props) {
2758
+ const size = props !== null && props !== undefined && typeof props.size === 'number' ? props.size : 20
2759
+ return h(Icon, { name: 'terminal', size })
2760
+ }
2761
+
2762
+ function apply(ctx) {
2763
+ const translate = ctx.locale.bind(LOCALE_NS)
2764
+ ctx.effect(() => ctx.locale.register(LOCALE_NS, { zh, en }), 'dsh-ssh-helper: locale dictionary')
2765
+ ctx.effect(installStyles, 'dsh-ssh-helper: stylesheet')
2766
+ const Page = (slotProps) => h(Section, {
2767
+ t: slotProps !== null && slotProps !== undefined && typeof slotProps.t === 'function' ? slotProps.t : translate,
2768
+ })
2769
+ ctx.effect(() => ctx.slots.inject('main', () => ctx.slots.register({
2770
+ name: 'main',
2771
+ key: 'ssh-helper',
2772
+ locale: LOCALE_NS,
2773
+ inject: () => ({ t: translate }),
2774
+ }, Page)), 'dsh-ssh-helper: main panel')
2775
+ ctx.effect(() => ctx.slots.inject('sidebar.panellist', () => ctx.slots.register({
2776
+ name: 'sidebar.panellist',
2777
+ id: 'ssh-helper',
2778
+ order: 15,
2779
+ locale: LOCALE_NS,
2780
+ label: () => translate('nav'),
2781
+ }, PanelIcon)), 'dsh-ssh-helper: sidebar entry')
2782
+ }
2783
+
2784
+ return { inject: ['slots', 'locale'], apply }
2785
+ },
2786
+ })