dsh-manage-session 0.3.3 → 0.5.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/CHANGELOG.ja.md +242 -188
- package/CHANGELOG.ko.md +242 -189
- package/CHANGELOG.md +341 -261
- package/INSTALL.ja.md +179 -164
- package/INSTALL.ko.md +179 -164
- package/INSTALL.md +179 -164
- package/INSTALL.zh.md +179 -164
- package/LICENSE +21 -21
- package/README.de.md +120 -0
- package/README.en.md +12 -0
- package/README.es.md +120 -0
- package/README.fr.md +120 -0
- package/README.it.md +120 -0
- package/README.ja.md +12 -0
- package/README.ko.md +12 -0
- package/README.md +14 -0
- package/README.ru.md +120 -0
- package/cordis.patch.yml +11 -11
- package/dsh.plugin.json +22 -22
- package/lib/client.js +623 -35
- package/lib/index.d.ts +123 -8
- package/lib/index.js +1147 -815
- package/package.json +125 -122
- package/src/client/card.tsx +9 -12
- package/src/client/health-panel.tsx +51 -0
- package/src/client/history-panel.tsx +319 -319
- package/src/client/host-api.ts +14 -1
- package/src/client/index.ts +150 -30
- package/src/client/locales.ts +491 -1
- package/src/config.ts +33 -33
- package/src/host/health/attribution.ts +118 -118
- package/src/host/health/gates.ts +4 -1
- package/src/host/health/lossless.ts +128 -128
- package/src/host/health/source-kind.ts +350 -0
- package/src/host/history/archive-source.ts +202 -202
- package/src/host/history/archive.ts +194 -191
- package/src/host/history/purge.ts +370 -323
- package/src/index.ts +56 -48
|
@@ -1,319 +1,319 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* 养老院(会话历史文件)面板 —— 自 dsh-session-search-toggle `src/client/archive-panel.tsx`
|
|
3
|
-
* **迁入**并适配到本包的方法名。
|
|
4
|
-
*
|
|
5
|
-
* 与迁移前的关键差异(**两个操作刻意分开,不可互换**):
|
|
6
|
-
*
|
|
7
|
-
* | 操作 | 路由 | 改什么 | 可逆 |
|
|
8
|
-
* |---|---|---|---|
|
|
9
|
-
* | 取消归档状态 | `session-history-prune` | 只改归档数组 | ✅ 重启后会话回到侧边栏 |
|
|
10
|
-
* | 清理归档文件 | `session-history-purge` | **真删**转录目录 + 投影缓存 + 两处 id | ❌ 不可逆 |
|
|
11
|
-
*
|
|
12
|
-
* 「删除」一词在本面板**不存在** —— 旧文案承诺「删除」却只改了个数组,
|
|
13
|
-
* 是导致「点了确认没反应」那类误判的一部分。
|
|
14
|
-
* 体积**按行**显示(实测单条可从 0 到 23 MB,报一个总量没有意义),按钮上给所选小计。
|
|
15
|
-
*/
|
|
16
|
-
import { createElement, useEffect, useState, type ReactElement } from 'react'
|
|
17
|
-
import { callHostAny, type HistoryRow } from './host-api.ts'
|
|
18
|
-
import { translate, type LocaleKey } from './locales.ts'
|
|
19
|
-
|
|
20
|
-
/** 面板收到的字典面。 */
|
|
21
|
-
export type PanelTranslate = (key: LocaleKey, params?: Record<string, unknown>) => string
|
|
22
|
-
|
|
23
|
-
/** 当前正在跑的操作;任一进行中都锁住两个按钮。 */
|
|
24
|
-
type Busy = 'unarchive' | 'purge' | null
|
|
25
|
-
|
|
26
|
-
/** 人类可读体积。 */
|
|
27
|
-
export function fmtSize(bytes: number): string {
|
|
28
|
-
if (!Number.isFinite(bytes) || bytes <= 0) return '0 B'
|
|
29
|
-
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
|
30
|
-
let value = bytes
|
|
31
|
-
let unit = 0
|
|
32
|
-
while (value >= 1024 && unit < units.length - 1) {
|
|
33
|
-
value /= 1024
|
|
34
|
-
unit++
|
|
35
|
-
}
|
|
36
|
-
return `${unit === 0 || value >= 100 ? Math.round(value) : value.toFixed(1)} ${units[unit]}`
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
/** 养老院面板。 */
|
|
40
|
-
export function HistoryPanel({
|
|
41
|
-
t,
|
|
42
|
-
onClose,
|
|
43
|
-
}: {
|
|
44
|
-
t?: PanelTranslate
|
|
45
|
-
onClose: () => void
|
|
46
|
-
}): ReactElement {
|
|
47
|
-
const [items, setItems] = useState<HistoryRow[] | null>(null)
|
|
48
|
-
const [source, setSource] = useState<string>('')
|
|
49
|
-
const [degraded, setDegraded] = useState<string>('')
|
|
50
|
-
const [pendingRestart, setPendingRestart] = useState(0)
|
|
51
|
-
const [error, setError] = useState<string | null>(null)
|
|
52
|
-
const [attempt, setAttempt] = useState(0)
|
|
53
|
-
const [selected, setSelected] = useState<ReadonlySet<string>>(new Set())
|
|
54
|
-
const [busy, setBusy] = useState<Busy>(null)
|
|
55
|
-
const [note, setNote] = useState<string | null>(null)
|
|
56
|
-
/** 清理失败明细(逐条);空数组表示无失败。 */
|
|
57
|
-
const [failures, setFailures] = useState<{ sessionId: string; reason: string }[]>([])
|
|
58
|
-
const [editing, setEditing] = useState(false)
|
|
59
|
-
|
|
60
|
-
useEffect(() => {
|
|
61
|
-
let cancelled = false
|
|
62
|
-
setError(null)
|
|
63
|
-
callHostAny<{
|
|
64
|
-
items?: HistoryRow[]
|
|
65
|
-
source?: string
|
|
66
|
-
degraded?: string
|
|
67
|
-
pendingRestart?: number
|
|
68
|
-
error?: string
|
|
69
|
-
}>(
|
|
70
|
-
'session-history-list',
|
|
71
|
-
{},
|
|
72
|
-
).then((res) => {
|
|
73
|
-
if (cancelled) return
|
|
74
|
-
if (res.ok === true && Array.isArray(res.items)) {
|
|
75
|
-
setItems(res.items)
|
|
76
|
-
setSource(typeof res.source === 'string' ? res.source : '')
|
|
77
|
-
setDegraded(typeof res.degraded === 'string' ? res.degraded : '')
|
|
78
|
-
setPendingRestart(typeof res.pendingRestart === 'number' ? res.pendingRestart : 0)
|
|
79
|
-
} else {
|
|
80
|
-
setError(res.error ?? '读取归档列表失败')
|
|
81
|
-
}
|
|
82
|
-
})
|
|
83
|
-
return () => { cancelled = true }
|
|
84
|
-
}, [attempt])
|
|
85
|
-
|
|
86
|
-
// Escape 关闭面板。
|
|
87
|
-
useEffect(() => {
|
|
88
|
-
const onKey = (event: KeyboardEvent): void => { if (event.key === 'Escape') onClose() }
|
|
89
|
-
document.addEventListener('keydown', onKey)
|
|
90
|
-
return () => { document.removeEventListener('keydown', onKey) }
|
|
91
|
-
}, [onClose])
|
|
92
|
-
|
|
93
|
-
const toggleRow = (sessionId: string): void => {
|
|
94
|
-
setSelected((prev) => {
|
|
95
|
-
const next = new Set(prev)
|
|
96
|
-
if (next.has(sessionId)) next.delete(sessionId)
|
|
97
|
-
else next.add(sessionId)
|
|
98
|
-
return next
|
|
99
|
-
})
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
/** 进出编辑模式;离开时清空选择。 */
|
|
103
|
-
const toggleEditing = (): void => {
|
|
104
|
-
setEditing(prev => !prev)
|
|
105
|
-
setSelected(new Set())
|
|
106
|
-
setNote(null)
|
|
107
|
-
setFailures([])
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
const toggleAll = (): void => {
|
|
111
|
-
setSelected(prev => (prev.size === (items?.length ?? 0)
|
|
112
|
-
? new Set<string>()
|
|
113
|
-
: new Set((items ?? []).map(item => item.sessionId))))
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
const selectedRows = (items ?? []).filter(item => selected.has(item.sessionId))
|
|
117
|
-
const selectedBytes = selectedRows.reduce((sum, item) => sum + item.bytes + item.cacheBytes, 0)
|
|
118
|
-
const totalBytes = (items ?? []).reduce((sum, item) => sum + item.bytes + item.cacheBytes, 0)
|
|
119
|
-
|
|
120
|
-
/** 确认弹窗里的逐条摘要(清理时带体积,取消归档时不带)。 */
|
|
121
|
-
const summarise = (rows: HistoryRow[], withSize: boolean): string => {
|
|
122
|
-
const lines = rows.slice(0, 5).map((row) => {
|
|
123
|
-
const label = row.title || translate(t, 'panel.untitled')
|
|
124
|
-
const size = fmtSize(row.bytes + row.cacheBytes)
|
|
125
|
-
return withSize ? `· ${label} — ${size}` : `· ${label}`
|
|
126
|
-
})
|
|
127
|
-
if (rows.length > 5) lines.push(`… 共 ${rows.length} 个`)
|
|
128
|
-
return lines.join('\n')
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
const confirm = (message: string): boolean =>
|
|
132
|
-
typeof window !== 'undefined' && typeof window.confirm === 'function' ? window.confirm(message) : false
|
|
133
|
-
|
|
134
|
-
/** 提交一次操作:确认 → 调用 → 刷新列表(列表以存储文件为准,因此立刻可见变化)。 */
|
|
135
|
-
const run = (kind: Exclude<Busy, null>, method: string, confirmText: string): void => {
|
|
136
|
-
const rows = selectedRows
|
|
137
|
-
if (rows.length === 0) return
|
|
138
|
-
if (!confirm(confirmText)) return
|
|
139
|
-
setBusy(kind)
|
|
140
|
-
setNote(null)
|
|
141
|
-
setFailures([])
|
|
142
|
-
void callHostAny<{
|
|
143
|
-
removed?: number
|
|
144
|
-
remaining?: number
|
|
145
|
-
purged?: number
|
|
146
|
-
freedBytes?: number
|
|
147
|
-
failures?: { sessionId: string; reason: string }[]
|
|
148
|
-
}>(
|
|
149
|
-
method,
|
|
150
|
-
{ sessionIds: rows.map(row => row.sessionId) },
|
|
151
|
-
60_000,
|
|
152
|
-
)
|
|
153
|
-
.then((res) => {
|
|
154
|
-
if (res.ok === true) {
|
|
155
|
-
if (kind === 'unarchive') {
|
|
156
|
-
setNote(translate(t, 'history.restartHint', {
|
|
157
|
-
removed: res.removed ?? rows.length,
|
|
158
|
-
remaining: res.remaining ?? '?',
|
|
159
|
-
}))
|
|
160
|
-
} else {
|
|
161
|
-
const freed = fmtSize(res.freedBytes ?? 0)
|
|
162
|
-
const failed = res.failures?.length ?? 0
|
|
163
|
-
setNote(failed === 0
|
|
164
|
-
? translate(t, 'history.purgeResult', { purged: res.purged ?? rows.length, size: freed })
|
|
165
|
-
: translate(t, 'history.purgePartial', { purged: res.purged ?? rows.length, size: freed, failed }))
|
|
166
|
-
setFailures(res.failures ?? [])
|
|
167
|
-
}
|
|
168
|
-
setSelected(new Set())
|
|
169
|
-
setAttempt(n => n + 1)
|
|
170
|
-
} else {
|
|
171
|
-
setNote(res.error ?? '操作失败')
|
|
172
|
-
}
|
|
173
|
-
})
|
|
174
|
-
.catch((err: unknown) => setNote(`操作失败:${String(err instanceof Error ? err.message : err)}`))
|
|
175
|
-
.finally(() => setBusy(null))
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
const unarchiveSelected = (): void => {
|
|
179
|
-
run('unarchive', 'session-history-prune',
|
|
180
|
-
translate(t, 'history.confirmUnarchive', {
|
|
181
|
-
n: selectedRows.length,
|
|
182
|
-
summary: summarise(selectedRows, false),
|
|
183
|
-
}))
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
const purgeSelected = (): void => {
|
|
187
|
-
run('purge', 'session-history-purge',
|
|
188
|
-
translate(t, 'history.confirmPurge', {
|
|
189
|
-
n: selectedRows.length,
|
|
190
|
-
summary: summarise(selectedRows, true),
|
|
191
|
-
size: fmtSize(selectedBytes),
|
|
192
|
-
}))
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
const children: ReactElement[] = []
|
|
196
|
-
if (error !== null) {
|
|
197
|
-
children.push(createElement('div', { key: 'err', className: 'dss_error' }, [
|
|
198
|
-
createElement('div', { key: 'msg' }, error === '请求超时'
|
|
199
|
-
? '读取归档列表超时:Host 可能正忙,稍后重试。'
|
|
200
|
-
: error),
|
|
201
|
-
createElement('button', {
|
|
202
|
-
key: 'retry',
|
|
203
|
-
type: 'button',
|
|
204
|
-
className: 'dss_actBtn',
|
|
205
|
-
style: { marginTop: '6px' },
|
|
206
|
-
onClick: () => { setAttempt(n => n + 1) },
|
|
207
|
-
}, '重试'),
|
|
208
|
-
]))
|
|
209
|
-
} else if (items === null) {
|
|
210
|
-
children.push(createElement('div', { key: 'loading', className: 'dss_status' }, translate(t, 'history.loading')))
|
|
211
|
-
} else if (items.length === 0) {
|
|
212
|
-
children.push(createElement('div', { key: 'empty', className: 'dss_empty' }, translate(t, 'history.empty')))
|
|
213
|
-
} else {
|
|
214
|
-
const allSelected = selected.size === items.length
|
|
215
|
-
if (editing) {
|
|
216
|
-
children.push(createElement('div', { key: 'manage', className: 'dss_btnRow', style: { padding: '4px 10px 0' } }, [
|
|
217
|
-
createElement('button', { key: 'all', type: 'button', className: 'dss_actBtn', onClick: toggleAll },
|
|
218
|
-
allSelected ? translate(t, 'history.unselectAll') : translate(t, 'history.selectAll')),
|
|
219
|
-
// 可逆:只改归档数组。
|
|
220
|
-
createElement('button', {
|
|
221
|
-
key: 'unarchive',
|
|
222
|
-
type: 'button',
|
|
223
|
-
className: 'dss_actBtn',
|
|
224
|
-
disabled: busy !== null || selected.size === 0,
|
|
225
|
-
onClick: unarchiveSelected,
|
|
226
|
-
}, busy === 'unarchive'
|
|
227
|
-
? translate(t, 'history.unarchiving')
|
|
228
|
-
: translate(t, 'history.unarchive', { n: selected.size })),
|
|
229
|
-
// 不可逆:真删磁盘实体。危险样式 + 体积小计。
|
|
230
|
-
createElement('button', {
|
|
231
|
-
key: 'purge',
|
|
232
|
-
type: 'button',
|
|
233
|
-
className: 'dss_actBtn dss_dangerBtn',
|
|
234
|
-
disabled: busy !== null || selected.size === 0,
|
|
235
|
-
onClick: purgeSelected,
|
|
236
|
-
}, busy === 'purge'
|
|
237
|
-
? translate(t, 'history.purging')
|
|
238
|
-
: translate(t, 'history.purge', { n: selected.size, size: fmtSize(selectedBytes) })),
|
|
239
|
-
]))
|
|
240
|
-
}
|
|
241
|
-
children.push(createElement('ul', {
|
|
242
|
-
key: 'list',
|
|
243
|
-
className: 'dss_list',
|
|
244
|
-
role: 'list',
|
|
245
|
-
'aria-label': translate(t, 'history.source.registry'),
|
|
246
|
-
}, items.map(item => createElement('li', { key: item.sessionId, className: 'dss_row' }, [
|
|
247
|
-
editing && createElement('label', { key: 'sel', className: 'dss_check' }, [
|
|
248
|
-
createElement('input', {
|
|
249
|
-
type: 'checkbox',
|
|
250
|
-
checked: selected.has(item.sessionId),
|
|
251
|
-
onChange: () => { toggleRow(item.sessionId) },
|
|
252
|
-
}),
|
|
253
|
-
]),
|
|
254
|
-
createElement('span', { key: 't', className: 'dss_rowTitle' }, [
|
|
255
|
-
createElement('span', { key: 'x', className: 'dss_titleText' }, item.title || translate(t, 'panel.untitled')),
|
|
256
|
-
item.updatedAt > 0 && createElement('span', { key: 'tag', className: 'dss_tag' }, fmtTime(item.updatedAt)),
|
|
257
|
-
]),
|
|
258
|
-
createElement('span', { key: 'size', className: 'dss_meta dss_size' },
|
|
259
|
-
item.bytes + item.cacheBytes > 0
|
|
260
|
-
? translate(t, 'history.size', { t: fmtSize(item.bytes), c: fmtSize(item.cacheBytes) })
|
|
261
|
-
: translate(t, 'history.sizeUnknown')),
|
|
262
|
-
item.cwd !== '' && createElement('span', { key: 'c', className: 'dss_meta' }, item.cwd),
|
|
263
|
-
createElement('span', { key: 'id', className: 'dss_meta dss_uuid' }, item.sessionId),
|
|
264
|
-
]))))
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
return createElement('div', { className: 'dss_tabBody' }, [
|
|
268
|
-
note !== null && createElement('div', { key: 'note', className: 'dss_status' }, note),
|
|
269
|
-
failures.length > 0 && createElement('div', { key: 'fail', className: 'dss_status dss_error' }, [
|
|
270
|
-
createElement('div', { key: 'h' }, translate(t, 'history.purgeFailures')),
|
|
271
|
-
...failures.slice(0, 10).map((failure, index) => createElement('div', {
|
|
272
|
-
key: `f${index}`,
|
|
273
|
-
className: 'dss_meta',
|
|
274
|
-
}, `${failure.sessionId.slice(0, 22)}… — ${failure.reason}`)),
|
|
275
|
-
]),
|
|
276
|
-
editing && createElement('div', { key: 'hint', className: 'dss_status' }, translate(t, 'history.editingHint')),
|
|
277
|
-
degraded !== '' && createElement('div', { key: 'degraded', className: 'dss_status dss_warnText' }, degraded),
|
|
278
|
-
pendingRestart > 0 && createElement('div', { key: 'pending', className: 'dss_status dss_warnText' },
|
|
279
|
-
translate(t, 'history.pendingRestart', { n: pendingRestart })),
|
|
280
|
-
source !== '' && createElement('div', { key: 'source', className: 'dss_metaLine' },
|
|
281
|
-
translate(t, source === 'registry' ? 'history.source.registry' : source === 'storage-file' ? 'history.source.storage-file' : 'history.source.none')),
|
|
282
|
-
items !== null && error === null && createElement('div', { key: 'count', className: 'dss_metaLine' },
|
|
283
|
-
translate(t, 'history.count', { n: items.length, size: fmtSize(totalBytes) })),
|
|
284
|
-
createElement('div', { key: 'headBtns', className: 'dss_btnRow' }, [
|
|
285
|
-
createElement('button', {
|
|
286
|
-
key: 'edit',
|
|
287
|
-
type: 'button',
|
|
288
|
-
className: `dss_actBtn${editing ? ' dss_editActive' : ''}`,
|
|
289
|
-
onClick: toggleEditing,
|
|
290
|
-
}, editing ? translate(t, 'history.done') : translate(t, 'history.edit')),
|
|
291
|
-
]),
|
|
292
|
-
...children,
|
|
293
|
-
])
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
/** 时间格式化(与迁移前一致)。 */
|
|
297
|
-
export function fmtTime(ms: number): string {
|
|
298
|
-
if (!ms || typeof ms !== 'number') return ''
|
|
299
|
-
try {
|
|
300
|
-
const date = new Date(ms)
|
|
301
|
-
const now = new Date()
|
|
302
|
-
const pad = (n: number): string => String(n).padStart(2, '0')
|
|
303
|
-
const sameDay = date.getFullYear() === now.getFullYear()
|
|
304
|
-
&& date.getMonth() === now.getMonth()
|
|
305
|
-
&& date.getDate() === now.getDate()
|
|
306
|
-
const time = `${pad(date.getHours())}:${pad(date.getMinutes())}`
|
|
307
|
-
if (sameDay) return time
|
|
308
|
-
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${time}`
|
|
309
|
-
} catch {
|
|
310
|
-
return ''
|
|
311
|
-
}
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
/** 供单测使用:面板依赖的方法名。锁住「两个操作是两条独立路由」,防止再被合并成一个动词。 */
|
|
315
|
-
export const HISTORY_METHOD_NAMES = [
|
|
316
|
-
'session-history-list',
|
|
317
|
-
'session-history-prune',
|
|
318
|
-
'session-history-purge',
|
|
319
|
-
] as const
|
|
1
|
+
/**
|
|
2
|
+
* 养老院(会话历史文件)面板 —— 自 dsh-session-search-toggle `src/client/archive-panel.tsx`
|
|
3
|
+
* **迁入**并适配到本包的方法名。
|
|
4
|
+
*
|
|
5
|
+
* 与迁移前的关键差异(**两个操作刻意分开,不可互换**):
|
|
6
|
+
*
|
|
7
|
+
* | 操作 | 路由 | 改什么 | 可逆 |
|
|
8
|
+
* |---|---|---|---|
|
|
9
|
+
* | 取消归档状态 | `session-history-prune` | 只改归档数组 | ✅ 重启后会话回到侧边栏 |
|
|
10
|
+
* | 清理归档文件 | `session-history-purge` | **真删**转录目录 + 投影缓存 + 两处 id | ❌ 不可逆 |
|
|
11
|
+
*
|
|
12
|
+
* 「删除」一词在本面板**不存在** —— 旧文案承诺「删除」却只改了个数组,
|
|
13
|
+
* 是导致「点了确认没反应」那类误判的一部分。
|
|
14
|
+
* 体积**按行**显示(实测单条可从 0 到 23 MB,报一个总量没有意义),按钮上给所选小计。
|
|
15
|
+
*/
|
|
16
|
+
import { createElement, useEffect, useState, type ReactElement } from 'react'
|
|
17
|
+
import { callHostAny, type HistoryRow } from './host-api.ts'
|
|
18
|
+
import { translate, type LocaleKey } from './locales.ts'
|
|
19
|
+
|
|
20
|
+
/** 面板收到的字典面。 */
|
|
21
|
+
export type PanelTranslate = (key: LocaleKey, params?: Record<string, unknown>) => string
|
|
22
|
+
|
|
23
|
+
/** 当前正在跑的操作;任一进行中都锁住两个按钮。 */
|
|
24
|
+
type Busy = 'unarchive' | 'purge' | null
|
|
25
|
+
|
|
26
|
+
/** 人类可读体积。 */
|
|
27
|
+
export function fmtSize(bytes: number): string {
|
|
28
|
+
if (!Number.isFinite(bytes) || bytes <= 0) return '0 B'
|
|
29
|
+
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
|
30
|
+
let value = bytes
|
|
31
|
+
let unit = 0
|
|
32
|
+
while (value >= 1024 && unit < units.length - 1) {
|
|
33
|
+
value /= 1024
|
|
34
|
+
unit++
|
|
35
|
+
}
|
|
36
|
+
return `${unit === 0 || value >= 100 ? Math.round(value) : value.toFixed(1)} ${units[unit]}`
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** 养老院面板。 */
|
|
40
|
+
export function HistoryPanel({
|
|
41
|
+
t,
|
|
42
|
+
onClose,
|
|
43
|
+
}: {
|
|
44
|
+
t?: PanelTranslate
|
|
45
|
+
onClose: () => void
|
|
46
|
+
}): ReactElement {
|
|
47
|
+
const [items, setItems] = useState<HistoryRow[] | null>(null)
|
|
48
|
+
const [source, setSource] = useState<string>('')
|
|
49
|
+
const [degraded, setDegraded] = useState<string>('')
|
|
50
|
+
const [pendingRestart, setPendingRestart] = useState(0)
|
|
51
|
+
const [error, setError] = useState<string | null>(null)
|
|
52
|
+
const [attempt, setAttempt] = useState(0)
|
|
53
|
+
const [selected, setSelected] = useState<ReadonlySet<string>>(new Set())
|
|
54
|
+
const [busy, setBusy] = useState<Busy>(null)
|
|
55
|
+
const [note, setNote] = useState<string | null>(null)
|
|
56
|
+
/** 清理失败明细(逐条);空数组表示无失败。 */
|
|
57
|
+
const [failures, setFailures] = useState<{ sessionId: string; reason: string }[]>([])
|
|
58
|
+
const [editing, setEditing] = useState(false)
|
|
59
|
+
|
|
60
|
+
useEffect(() => {
|
|
61
|
+
let cancelled = false
|
|
62
|
+
setError(null)
|
|
63
|
+
callHostAny<{
|
|
64
|
+
items?: HistoryRow[]
|
|
65
|
+
source?: string
|
|
66
|
+
degraded?: string
|
|
67
|
+
pendingRestart?: number
|
|
68
|
+
error?: string
|
|
69
|
+
}>(
|
|
70
|
+
'session-history-list',
|
|
71
|
+
{},
|
|
72
|
+
).then((res) => {
|
|
73
|
+
if (cancelled) return
|
|
74
|
+
if (res.ok === true && Array.isArray(res.items)) {
|
|
75
|
+
setItems(res.items)
|
|
76
|
+
setSource(typeof res.source === 'string' ? res.source : '')
|
|
77
|
+
setDegraded(typeof res.degraded === 'string' ? res.degraded : '')
|
|
78
|
+
setPendingRestart(typeof res.pendingRestart === 'number' ? res.pendingRestart : 0)
|
|
79
|
+
} else {
|
|
80
|
+
setError(res.error ?? '读取归档列表失败')
|
|
81
|
+
}
|
|
82
|
+
})
|
|
83
|
+
return () => { cancelled = true }
|
|
84
|
+
}, [attempt])
|
|
85
|
+
|
|
86
|
+
// Escape 关闭面板。
|
|
87
|
+
useEffect(() => {
|
|
88
|
+
const onKey = (event: KeyboardEvent): void => { if (event.key === 'Escape') onClose() }
|
|
89
|
+
document.addEventListener('keydown', onKey)
|
|
90
|
+
return () => { document.removeEventListener('keydown', onKey) }
|
|
91
|
+
}, [onClose])
|
|
92
|
+
|
|
93
|
+
const toggleRow = (sessionId: string): void => {
|
|
94
|
+
setSelected((prev) => {
|
|
95
|
+
const next = new Set(prev)
|
|
96
|
+
if (next.has(sessionId)) next.delete(sessionId)
|
|
97
|
+
else next.add(sessionId)
|
|
98
|
+
return next
|
|
99
|
+
})
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** 进出编辑模式;离开时清空选择。 */
|
|
103
|
+
const toggleEditing = (): void => {
|
|
104
|
+
setEditing(prev => !prev)
|
|
105
|
+
setSelected(new Set())
|
|
106
|
+
setNote(null)
|
|
107
|
+
setFailures([])
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const toggleAll = (): void => {
|
|
111
|
+
setSelected(prev => (prev.size === (items?.length ?? 0)
|
|
112
|
+
? new Set<string>()
|
|
113
|
+
: new Set((items ?? []).map(item => item.sessionId))))
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const selectedRows = (items ?? []).filter(item => selected.has(item.sessionId))
|
|
117
|
+
const selectedBytes = selectedRows.reduce((sum, item) => sum + item.bytes + item.cacheBytes, 0)
|
|
118
|
+
const totalBytes = (items ?? []).reduce((sum, item) => sum + item.bytes + item.cacheBytes, 0)
|
|
119
|
+
|
|
120
|
+
/** 确认弹窗里的逐条摘要(清理时带体积,取消归档时不带)。 */
|
|
121
|
+
const summarise = (rows: HistoryRow[], withSize: boolean): string => {
|
|
122
|
+
const lines = rows.slice(0, 5).map((row) => {
|
|
123
|
+
const label = row.title || translate(t, 'panel.untitled')
|
|
124
|
+
const size = fmtSize(row.bytes + row.cacheBytes)
|
|
125
|
+
return withSize ? `· ${label} — ${size}` : `· ${label}`
|
|
126
|
+
})
|
|
127
|
+
if (rows.length > 5) lines.push(`… 共 ${rows.length} 个`)
|
|
128
|
+
return lines.join('\n')
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const confirm = (message: string): boolean =>
|
|
132
|
+
typeof window !== 'undefined' && typeof window.confirm === 'function' ? window.confirm(message) : false
|
|
133
|
+
|
|
134
|
+
/** 提交一次操作:确认 → 调用 → 刷新列表(列表以存储文件为准,因此立刻可见变化)。 */
|
|
135
|
+
const run = (kind: Exclude<Busy, null>, method: string, confirmText: string): void => {
|
|
136
|
+
const rows = selectedRows
|
|
137
|
+
if (rows.length === 0) return
|
|
138
|
+
if (!confirm(confirmText)) return
|
|
139
|
+
setBusy(kind)
|
|
140
|
+
setNote(null)
|
|
141
|
+
setFailures([])
|
|
142
|
+
void callHostAny<{
|
|
143
|
+
removed?: number
|
|
144
|
+
remaining?: number
|
|
145
|
+
purged?: number
|
|
146
|
+
freedBytes?: number
|
|
147
|
+
failures?: { sessionId: string; reason: string }[]
|
|
148
|
+
}>(
|
|
149
|
+
method,
|
|
150
|
+
{ sessionIds: rows.map(row => row.sessionId) },
|
|
151
|
+
60_000,
|
|
152
|
+
)
|
|
153
|
+
.then((res) => {
|
|
154
|
+
if (res.ok === true) {
|
|
155
|
+
if (kind === 'unarchive') {
|
|
156
|
+
setNote(translate(t, 'history.restartHint', {
|
|
157
|
+
removed: res.removed ?? rows.length,
|
|
158
|
+
remaining: res.remaining ?? '?',
|
|
159
|
+
}))
|
|
160
|
+
} else {
|
|
161
|
+
const freed = fmtSize(res.freedBytes ?? 0)
|
|
162
|
+
const failed = res.failures?.length ?? 0
|
|
163
|
+
setNote(failed === 0
|
|
164
|
+
? translate(t, 'history.purgeResult', { purged: res.purged ?? rows.length, size: freed })
|
|
165
|
+
: translate(t, 'history.purgePartial', { purged: res.purged ?? rows.length, size: freed, failed }))
|
|
166
|
+
setFailures(res.failures ?? [])
|
|
167
|
+
}
|
|
168
|
+
setSelected(new Set())
|
|
169
|
+
setAttempt(n => n + 1)
|
|
170
|
+
} else {
|
|
171
|
+
setNote(res.error ?? '操作失败')
|
|
172
|
+
}
|
|
173
|
+
})
|
|
174
|
+
.catch((err: unknown) => setNote(`操作失败:${String(err instanceof Error ? err.message : err)}`))
|
|
175
|
+
.finally(() => setBusy(null))
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const unarchiveSelected = (): void => {
|
|
179
|
+
run('unarchive', 'session-history-prune',
|
|
180
|
+
translate(t, 'history.confirmUnarchive', {
|
|
181
|
+
n: selectedRows.length,
|
|
182
|
+
summary: summarise(selectedRows, false),
|
|
183
|
+
}))
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const purgeSelected = (): void => {
|
|
187
|
+
run('purge', 'session-history-purge',
|
|
188
|
+
translate(t, 'history.confirmPurge', {
|
|
189
|
+
n: selectedRows.length,
|
|
190
|
+
summary: summarise(selectedRows, true),
|
|
191
|
+
size: fmtSize(selectedBytes),
|
|
192
|
+
}))
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const children: ReactElement[] = []
|
|
196
|
+
if (error !== null) {
|
|
197
|
+
children.push(createElement('div', { key: 'err', className: 'dss_error' }, [
|
|
198
|
+
createElement('div', { key: 'msg' }, error === '请求超时'
|
|
199
|
+
? '读取归档列表超时:Host 可能正忙,稍后重试。'
|
|
200
|
+
: error),
|
|
201
|
+
createElement('button', {
|
|
202
|
+
key: 'retry',
|
|
203
|
+
type: 'button',
|
|
204
|
+
className: 'dss_actBtn',
|
|
205
|
+
style: { marginTop: '6px' },
|
|
206
|
+
onClick: () => { setAttempt(n => n + 1) },
|
|
207
|
+
}, '重试'),
|
|
208
|
+
]))
|
|
209
|
+
} else if (items === null) {
|
|
210
|
+
children.push(createElement('div', { key: 'loading', className: 'dss_status' }, translate(t, 'history.loading')))
|
|
211
|
+
} else if (items.length === 0) {
|
|
212
|
+
children.push(createElement('div', { key: 'empty', className: 'dss_empty' }, translate(t, 'history.empty')))
|
|
213
|
+
} else {
|
|
214
|
+
const allSelected = selected.size === items.length
|
|
215
|
+
if (editing) {
|
|
216
|
+
children.push(createElement('div', { key: 'manage', className: 'dss_btnRow', style: { padding: '4px 10px 0' } }, [
|
|
217
|
+
createElement('button', { key: 'all', type: 'button', className: 'dss_actBtn', onClick: toggleAll },
|
|
218
|
+
allSelected ? translate(t, 'history.unselectAll') : translate(t, 'history.selectAll')),
|
|
219
|
+
// 可逆:只改归档数组。
|
|
220
|
+
createElement('button', {
|
|
221
|
+
key: 'unarchive',
|
|
222
|
+
type: 'button',
|
|
223
|
+
className: 'dss_actBtn',
|
|
224
|
+
disabled: busy !== null || selected.size === 0,
|
|
225
|
+
onClick: unarchiveSelected,
|
|
226
|
+
}, busy === 'unarchive'
|
|
227
|
+
? translate(t, 'history.unarchiving')
|
|
228
|
+
: translate(t, 'history.unarchive', { n: selected.size })),
|
|
229
|
+
// 不可逆:真删磁盘实体。危险样式 + 体积小计。
|
|
230
|
+
createElement('button', {
|
|
231
|
+
key: 'purge',
|
|
232
|
+
type: 'button',
|
|
233
|
+
className: 'dss_actBtn dss_dangerBtn',
|
|
234
|
+
disabled: busy !== null || selected.size === 0,
|
|
235
|
+
onClick: purgeSelected,
|
|
236
|
+
}, busy === 'purge'
|
|
237
|
+
? translate(t, 'history.purging')
|
|
238
|
+
: translate(t, 'history.purge', { n: selected.size, size: fmtSize(selectedBytes) })),
|
|
239
|
+
]))
|
|
240
|
+
}
|
|
241
|
+
children.push(createElement('ul', {
|
|
242
|
+
key: 'list',
|
|
243
|
+
className: 'dss_list',
|
|
244
|
+
role: 'list',
|
|
245
|
+
'aria-label': translate(t, 'history.source.registry'),
|
|
246
|
+
}, items.map(item => createElement('li', { key: item.sessionId, className: 'dss_row' }, [
|
|
247
|
+
editing && createElement('label', { key: 'sel', className: 'dss_check' }, [
|
|
248
|
+
createElement('input', {
|
|
249
|
+
type: 'checkbox',
|
|
250
|
+
checked: selected.has(item.sessionId),
|
|
251
|
+
onChange: () => { toggleRow(item.sessionId) },
|
|
252
|
+
}),
|
|
253
|
+
]),
|
|
254
|
+
createElement('span', { key: 't', className: 'dss_rowTitle' }, [
|
|
255
|
+
createElement('span', { key: 'x', className: 'dss_titleText' }, item.title || translate(t, 'panel.untitled')),
|
|
256
|
+
item.updatedAt > 0 && createElement('span', { key: 'tag', className: 'dss_tag' }, fmtTime(item.updatedAt)),
|
|
257
|
+
]),
|
|
258
|
+
createElement('span', { key: 'size', className: 'dss_meta dss_size' },
|
|
259
|
+
item.bytes + item.cacheBytes > 0
|
|
260
|
+
? translate(t, 'history.size', { t: fmtSize(item.bytes), c: fmtSize(item.cacheBytes) })
|
|
261
|
+
: translate(t, 'history.sizeUnknown')),
|
|
262
|
+
item.cwd !== '' && createElement('span', { key: 'c', className: 'dss_meta' }, item.cwd),
|
|
263
|
+
createElement('span', { key: 'id', className: 'dss_meta dss_uuid' }, item.sessionId),
|
|
264
|
+
]))))
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
return createElement('div', { className: 'dss_tabBody' }, [
|
|
268
|
+
note !== null && createElement('div', { key: 'note', className: 'dss_status' }, note),
|
|
269
|
+
failures.length > 0 && createElement('div', { key: 'fail', className: 'dss_status dss_error' }, [
|
|
270
|
+
createElement('div', { key: 'h' }, translate(t, 'history.purgeFailures')),
|
|
271
|
+
...failures.slice(0, 10).map((failure, index) => createElement('div', {
|
|
272
|
+
key: `f${index}`,
|
|
273
|
+
className: 'dss_meta',
|
|
274
|
+
}, `${failure.sessionId.slice(0, 22)}… — ${failure.reason}`)),
|
|
275
|
+
]),
|
|
276
|
+
editing && createElement('div', { key: 'hint', className: 'dss_status' }, translate(t, 'history.editingHint')),
|
|
277
|
+
degraded !== '' && createElement('div', { key: 'degraded', className: 'dss_status dss_warnText' }, degraded),
|
|
278
|
+
pendingRestart > 0 && createElement('div', { key: 'pending', className: 'dss_status dss_warnText' },
|
|
279
|
+
translate(t, 'history.pendingRestart', { n: pendingRestart })),
|
|
280
|
+
source !== '' && createElement('div', { key: 'source', className: 'dss_metaLine' },
|
|
281
|
+
translate(t, source === 'registry' ? 'history.source.registry' : source === 'storage-file' ? 'history.source.storage-file' : 'history.source.none')),
|
|
282
|
+
items !== null && error === null && createElement('div', { key: 'count', className: 'dss_metaLine' },
|
|
283
|
+
translate(t, 'history.count', { n: items.length, size: fmtSize(totalBytes) })),
|
|
284
|
+
createElement('div', { key: 'headBtns', className: 'dss_btnRow' }, [
|
|
285
|
+
createElement('button', {
|
|
286
|
+
key: 'edit',
|
|
287
|
+
type: 'button',
|
|
288
|
+
className: `dss_actBtn${editing ? ' dss_editActive' : ''}`,
|
|
289
|
+
onClick: toggleEditing,
|
|
290
|
+
}, editing ? translate(t, 'history.done') : translate(t, 'history.edit')),
|
|
291
|
+
]),
|
|
292
|
+
...children,
|
|
293
|
+
])
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** 时间格式化(与迁移前一致)。 */
|
|
297
|
+
export function fmtTime(ms: number): string {
|
|
298
|
+
if (!ms || typeof ms !== 'number') return ''
|
|
299
|
+
try {
|
|
300
|
+
const date = new Date(ms)
|
|
301
|
+
const now = new Date()
|
|
302
|
+
const pad = (n: number): string => String(n).padStart(2, '0')
|
|
303
|
+
const sameDay = date.getFullYear() === now.getFullYear()
|
|
304
|
+
&& date.getMonth() === now.getMonth()
|
|
305
|
+
&& date.getDate() === now.getDate()
|
|
306
|
+
const time = `${pad(date.getHours())}:${pad(date.getMinutes())}`
|
|
307
|
+
if (sameDay) return time
|
|
308
|
+
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${time}`
|
|
309
|
+
} catch {
|
|
310
|
+
return ''
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** 供单测使用:面板依赖的方法名。锁住「两个操作是两条独立路由」,防止再被合并成一个动词。 */
|
|
315
|
+
export const HISTORY_METHOD_NAMES = [
|
|
316
|
+
'session-history-list',
|
|
317
|
+
'session-history-prune',
|
|
318
|
+
'session-history-purge',
|
|
319
|
+
] as const
|