dsh-install-radar 1.0.1 → 1.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/README.md +14 -1
- package/package.json +1 -1
- package/src/client.js +212 -6
- package/src/engine/clean.js +13 -1
- package/src/engine/radar.js +12 -1
- package/src/index.js +175 -2
package/README.md
CHANGED
|
@@ -10,7 +10,20 @@
|
|
|
10
10
|
| **目录快照对比**(基线 vs 现在) | 已知监视根里凭空多出来的条目、明显变大的目录 | 需要先立基线;监视根以外的目录看不见 |
|
|
11
11
|
| **时间戳扫描**(`今天` 栏) | 「今天/近 N 天,哪个目录在吃盘」——包括临时目录、其它工具偷偷写的 | 只看时间窗口,不判断「该不该存在」 |
|
|
12
12
|
|
|
13
|
-
##
|
|
13
|
+
## v1.1.1 变更
|
|
14
|
+
|
|
15
|
+
- **卸载结果有回执**:点「一键卸载」后出一张结果条——成功是绿色 `✅ 卸载成功 · 释放 X`(+ 输出尾部),失败是红色 `❌ 卸载失败:winget 退出码 5`,下面跟 `🛠 解决办法` 列表(按工具给:程序还在运行 / 需要管理员 / 包 id 不对 / 指定解释器 / 路径被占用……)与「知道了」收起。
|
|
16
|
+
- 特别处理「退出码 0 但其实没装」:`pip` 的 `Skipping … not installed`、`winget` 的 `No installed package found` 都会被判为失败并给出对应提示。
|
|
17
|
+
- 目录型卸载失败(路径不存在 / 被占用 / 权限不足)同样给提示,不再静默。
|
|
18
|
+
- **所有行都能多选**:`非会话新增` 里每一行都有勾选框(含 🔴 danger、含 <50 MB 的小项);单行的「清理」按钮仍保留门槛(≥50 MB 且非 danger),但**批量删除对所有勾选项开放**。
|
|
19
|
+
- **批量结果条**:删完显示 `✅ 删除 N 项 · 释放 X`;若其中有被拒(不在临时目录白名单、被占用等),变红并逐条列出 `路径 — 原因`,一眼知道哪些没删掉、为什么。
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
- **风险筛选**:`非会话新增` 顶部三个彩色按钮 `🟢 N / 🟡 N / 🔴 N`,点一下只看该档,再点取消;右侧显示 `当前显示 / 总数`。
|
|
23
|
+
- **一键卸载**:会话条目多了 `一键卸载` 按钮(两步确认,先显示将执行的命令)。按生态真正执行:`winget uninstall --id … --silent`、`python -m pip uninstall -y …`、`npm rm -g …`(项目内依赖 `npm rm …` 用项目 cwd)、`conda env remove -n … -y`;目录型(git clone / venv / 安装包)走删除。结果回显退出码与命令输出尾部,并写 `uninstall-log.jsonl`。
|
|
24
|
+
- **首次扫描进度**:面板在扫描期间每秒轮询 `/api/install-radar/progress`,显示阶段文字(扫描安装根目录 → 比对基线 → 读注册表 → 解析会话日志 → 解析条目 → 整理报告)与百分比进度条。
|
|
25
|
+
- **临时目录根可勾选**:`%LOCALAPPDATA%\Temp` 这类根行现在也能勾,语义是「清空里面的子项」——服务端会把根展开成它的一级子项逐个判定(仍受白名单、50 MB 门槛、danger 排除约束),不会整根删除。
|
|
26
|
+
|
|
14
27
|
|
|
15
28
|
- **条目分卡**:展开一个会话后,每条安装记录都是一张独立小卡(交替底色 + 左侧 3px 色条 + 8px 间距),相邻两行不再糊成一片。
|
|
16
29
|
- **「为什么装」**:每条记录多一行 `🎯 为什么装:…`。来源优先级:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-install-radar",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.1",
|
|
4
4
|
"description": "Per-session install radar for DeepSeek Harness: what each conversation installed (Python / npm / git / winget), with size, path, created time, purpose, why it was installed and what removing it costs — plus a sidebar panel and process→disk causal audit.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
package/src/client.js
CHANGED
|
@@ -49,6 +49,13 @@ window.__ModuleLoader__.load({
|
|
|
49
49
|
copy: '复制路径',
|
|
50
50
|
copied: '已复制',
|
|
51
51
|
uninstall: '卸载命令',
|
|
52
|
+
uninstallNow: '一键卸载',
|
|
53
|
+
confirmUninstall: '确认卸载',
|
|
54
|
+
doUninstall: '执行卸载',
|
|
55
|
+
gotIt: '知道了',
|
|
56
|
+
uninstallOk: '卸载成功',
|
|
57
|
+
uninstallFail: '卸载失败',
|
|
58
|
+
howToFix: '解决办法',
|
|
52
59
|
openFolder: '打开文件夹',
|
|
53
60
|
tabSessions: '按会话',
|
|
54
61
|
tabEco: '按类型',
|
|
@@ -88,6 +95,8 @@ window.__ModuleLoader__.load({
|
|
|
88
95
|
freed: '已释放',
|
|
89
96
|
noTime: '未知时间',
|
|
90
97
|
recentHead: '今天零点以来新增/写入的东西(不看基线,直接按时间戳扫)',
|
|
98
|
+
riskFilter: '按风险筛选',
|
|
99
|
+
scanningDetail: '首次扫描要遍历安装根目录、会话日志和注册表,通常 20-60 秒。',
|
|
91
100
|
recentEmpty: '今天没有扫到新增文件。',
|
|
92
101
|
error: '出错了',
|
|
93
102
|
},
|
|
@@ -116,6 +125,13 @@ window.__ModuleLoader__.load({
|
|
|
116
125
|
copy: 'Copy path',
|
|
117
126
|
copied: 'Copied',
|
|
118
127
|
uninstall: 'Uninstall',
|
|
128
|
+
uninstallNow: 'Uninstall now',
|
|
129
|
+
confirmUninstall: 'Confirm uninstall',
|
|
130
|
+
doUninstall: 'Run uninstall',
|
|
131
|
+
gotIt: 'Got it',
|
|
132
|
+
uninstallOk: 'Uninstalled',
|
|
133
|
+
uninstallFail: 'Uninstall failed',
|
|
134
|
+
howToFix: 'How to fix',
|
|
119
135
|
openFolder: 'Open folder',
|
|
120
136
|
tabSessions: 'Sessions',
|
|
121
137
|
tabEco: 'By kind',
|
|
@@ -155,6 +171,8 @@ window.__ModuleLoader__.load({
|
|
|
155
171
|
freed: 'freed',
|
|
156
172
|
noTime: 'no date',
|
|
157
173
|
recentHead: 'Written or created since midnight (timestamps, not baseline)',
|
|
174
|
+
riskFilter: 'Filter by risk',
|
|
175
|
+
scanningDetail: 'First scan walks install roots, session logs and the registry — usually 20-60s.',
|
|
158
176
|
recentEmpty: 'Nothing new today.',
|
|
159
177
|
error: 'Failed',
|
|
160
178
|
},
|
|
@@ -257,6 +275,11 @@ window.__ModuleLoader__.load({
|
|
|
257
275
|
.dsh-ir-itemCard--alt{border-left-color:rgba(148,163,184,.7);background:rgba(148,163,184,.085)}
|
|
258
276
|
.dsh-ir-itemCard .dsh-ir-item{background:transparent;border-left:none;margin-left:0}
|
|
259
277
|
.dsh-ir-why{padding:2px 12px 2px;font-size:12px;color:var(--dsw-alias-state-business-primary,#6ea8ff);opacity:.95}
|
|
278
|
+
.dsh-ir-prog{padding:18px 22px}
|
|
279
|
+
.dsh-ir-progHead{font-size:12px;color:var(--dsw-alias-label-secondary,#666);margin-bottom:8px;display:flex;gap:8px;align-items:center}
|
|
280
|
+
.dsh-ir-progBar{height:6px;border-radius:3px;background:var(--dsw-alias-bg-l2,rgba(0,0,0,.1));overflow:hidden}
|
|
281
|
+
.dsh-ir-progFill{height:100%;border-radius:3px;background:linear-gradient(90deg,#4d90f0,#6ea8ff);transition:width .4s ease}
|
|
282
|
+
.dsh-ir-progTip{font-size:11px;color:var(--dsw-alias-label-tertiary,#8a8a8a);margin-top:8px}
|
|
260
283
|
.dsh-ir-risk{display:inline-flex;align-items:center;gap:5px;font-size:11px;margin-top:3px}
|
|
261
284
|
.dsh-ir-risk::before{content:'';width:6px;height:6px;border-radius:50%;background:currentColor;flex:none}
|
|
262
285
|
.dsh-ir-risk--safe{color:#2f9e5f}
|
|
@@ -412,6 +435,26 @@ window.__ModuleLoader__.load({
|
|
|
412
435
|
if (item.confidence === 'none') tags.push(t('notFound'))
|
|
413
436
|
if (item.confidence === 'low') tags.push(t('guessed'))
|
|
414
437
|
const alt = typeof index === 'number' && index % 2 === 1
|
|
438
|
+
const [confirming, setConfirming] = useState(false)
|
|
439
|
+
const [running, setRunning] = useState(false)
|
|
440
|
+
const [result, setResult] = useState(null)
|
|
441
|
+
|
|
442
|
+
const runUninstall = () => {
|
|
443
|
+
if (!rpc) return
|
|
444
|
+
setRunning(true)
|
|
445
|
+
callRpc(rpc, 'uninstall', { eco: item.eco, name: item.name, path: item.path, confirm: true })
|
|
446
|
+
.then((value) => {
|
|
447
|
+
setResult(value)
|
|
448
|
+
setRunning(false)
|
|
449
|
+
setConfirming(false)
|
|
450
|
+
})
|
|
451
|
+
.catch((err) => {
|
|
452
|
+
setResult({ error: err && err.message ? err.message : String(err) })
|
|
453
|
+
setRunning(false)
|
|
454
|
+
setConfirming(false)
|
|
455
|
+
})
|
|
456
|
+
}
|
|
457
|
+
|
|
415
458
|
return h(
|
|
416
459
|
'div',
|
|
417
460
|
{ className: `dsh-ir-itemCard${alt ? ' dsh-ir-itemCard--alt' : ''}` },
|
|
@@ -449,7 +492,65 @@ window.__ModuleLoader__.load({
|
|
|
449
492
|
)
|
|
450
493
|
: null,
|
|
451
494
|
item.uninstall ? h(CopyChip, { key: 'un', text: item.uninstall, label: t('uninstall'), title: item.uninstall }) : null,
|
|
495
|
+
item.eco || item.path
|
|
496
|
+
? h(
|
|
497
|
+
'button',
|
|
498
|
+
{
|
|
499
|
+
key: 'run',
|
|
500
|
+
type: 'button',
|
|
501
|
+
className: 'dsh-ir-chip dsh-ir-chip--danger',
|
|
502
|
+
disabled: running,
|
|
503
|
+
onClick: () => setConfirming(true),
|
|
504
|
+
title: item.uninstall || '',
|
|
505
|
+
},
|
|
506
|
+
running ? '…' : t('uninstallNow'),
|
|
507
|
+
)
|
|
508
|
+
: null,
|
|
452
509
|
]),
|
|
510
|
+
confirming
|
|
511
|
+
? h(
|
|
512
|
+
'div',
|
|
513
|
+
{ className: 'dsh-ir-confirm' },
|
|
514
|
+
h('span', null, `🔴 ${t('confirmUninstall')} ${item.name}(${item.eco || 'path'})`),
|
|
515
|
+
item.uninstall ? h('code', { className: 'dsh-ir-path' }, item.uninstall) : null,
|
|
516
|
+
h('button', { type: 'button', className: 'dsh-ir-chip dsh-ir-chip--danger', disabled: running, onClick: runUninstall }, t('doUninstall')),
|
|
517
|
+
h('button', { type: 'button', className: 'dsh-ir-chip', disabled: running, onClick: () => setConfirming(false) }, t('cancelClean')),
|
|
518
|
+
)
|
|
519
|
+
: null,
|
|
520
|
+
result
|
|
521
|
+
? h(
|
|
522
|
+
'div',
|
|
523
|
+
{
|
|
524
|
+
className: 'dsh-ir-confirm',
|
|
525
|
+
style: {
|
|
526
|
+
background: result.ok === false ? 'rgba(221,51,51,.10)' : 'rgba(47,158,95,.12)',
|
|
527
|
+
borderColor: result.ok === false ? 'rgba(221,51,51,.35)' : 'rgba(47,158,95,.4)',
|
|
528
|
+
flexWrap: 'wrap',
|
|
529
|
+
},
|
|
530
|
+
},
|
|
531
|
+
h(
|
|
532
|
+
'span',
|
|
533
|
+
null,
|
|
534
|
+
result.ok === false
|
|
535
|
+
? `❌ ${t('uninstallFail')}${result.error ? `:${result.error}` : ''}`
|
|
536
|
+
: result.mode === 'delete'
|
|
537
|
+
? `✅ ${t('uninstallOk')} · 释放 ${formatSize(result.freed || 0)}`
|
|
538
|
+
: `✅ ${t('uninstallOk')}(退出码 0)`,
|
|
539
|
+
),
|
|
540
|
+
result.ok !== false && result.results && result.results[0] && result.results[0].tail
|
|
541
|
+
? h('div', { className: 'dsh-ir-evidence', style: { width: '100%', padding: 0 } }, String(result.results[0].tail).slice(0, 300))
|
|
542
|
+
: null,
|
|
543
|
+
result.ok === false && result.hints && result.hints.length > 0
|
|
544
|
+
? h(
|
|
545
|
+
'div',
|
|
546
|
+
{ style: { width: '100%' } },
|
|
547
|
+
h('div', { className: 'dsh-ir-eco' }, `🛠 ${t('howToFix')}:`),
|
|
548
|
+
...result.hints.map((hint, index) => h('div', { key: `hint-${index}`, className: 'dsh-ir-eco' }, `${index + 1}. ${hint}`)),
|
|
549
|
+
)
|
|
550
|
+
: null,
|
|
551
|
+
h('button', { type: 'button', className: 'dsh-ir-chip', onClick: () => setResult(null) }, t('gotIt')),
|
|
552
|
+
)
|
|
553
|
+
: null,
|
|
453
554
|
item.why && item.why.text
|
|
454
555
|
? h('div', { className: 'dsh-ir-why' }, `🎯 ${t('whyInstalled')}:${item.why.text}`)
|
|
455
556
|
: null,
|
|
@@ -489,7 +590,8 @@ window.__ModuleLoader__.load({
|
|
|
489
590
|
|
|
490
591
|
/** 「今天」的一行:一个归属目录(可勾选批量清理 / 单条两段式清理) */
|
|
491
592
|
function RecentRow({ item, rpc, pending, cleanBusy, onAsk, onCancel, onConfirm, selected, onToggle }) {
|
|
492
|
-
const checkable = item.
|
|
593
|
+
const checkable = item.selectable !== false
|
|
594
|
+
const perRowClean = item.cleanable !== false
|
|
493
595
|
return h(
|
|
494
596
|
'div',
|
|
495
597
|
{ className: `dsh-ir-recent${selected ? ' dsh-ir-recent--picked' : ''}` },
|
|
@@ -507,7 +609,8 @@ window.__ModuleLoader__.load({
|
|
|
507
609
|
onChange: () => onToggle && onToggle(item.path),
|
|
508
610
|
title: item.path,
|
|
509
611
|
})
|
|
510
|
-
:
|
|
612
|
+
: null,
|
|
613
|
+
h('span', { title: item.risk ? `风险:${item.risk}` : '' }, item.risk === 'danger' ? '🔴' : item.risk === 'safe' ? '🟢' : '🟡'),
|
|
511
614
|
h('span', { className: 'dsh-ir-size' }, item.size),
|
|
512
615
|
),
|
|
513
616
|
h(
|
|
@@ -522,7 +625,7 @@ window.__ModuleLoader__.load({
|
|
|
522
625
|
{ style: { display: 'flex', gap: 6, flexWrap: 'wrap' } },
|
|
523
626
|
h('button', { type: 'button', className: 'dsh-ir-chip', onClick: () => openTarget(rpc, item.path) }, t('openFolder')),
|
|
524
627
|
h(CopyChip, { text: item.path, label: t('copy') }),
|
|
525
|
-
onAsk &&
|
|
628
|
+
onAsk && perRowClean
|
|
526
629
|
? h(
|
|
527
630
|
'button',
|
|
528
631
|
{ type: 'button', className: 'dsh-ir-chip dsh-ir-chip--danger', onClick: onAsk, disabled: cleanBusy },
|
|
@@ -805,6 +908,9 @@ window.__ModuleLoader__.load({
|
|
|
805
908
|
const [cleanBusy, setCleanBusy] = useState(false)
|
|
806
909
|
const [selected, setSelected] = useState([])
|
|
807
910
|
const [batchPending, setBatchPending] = useState(false)
|
|
911
|
+
const [riskFilter, setRiskFilter] = useState('all')
|
|
912
|
+
const [progress, setProgress] = useState(null)
|
|
913
|
+
const [batchResult, setBatchResult] = useState(null)
|
|
808
914
|
const [trend, setTrend] = useState(null)
|
|
809
915
|
const [trendBusy, setTrendBusy] = useState(false)
|
|
810
916
|
const [autoClean, setAutoClean] = useState(null)
|
|
@@ -848,6 +954,27 @@ window.__ModuleLoader__.load({
|
|
|
848
954
|
}
|
|
849
955
|
}, [load])
|
|
850
956
|
|
|
957
|
+
/** 扫描进度:报告还在跑时每秒问一次宿主 */
|
|
958
|
+
useEffect(() => {
|
|
959
|
+
if (!busy || !rpc) {
|
|
960
|
+
setProgress(null)
|
|
961
|
+
return undefined
|
|
962
|
+
}
|
|
963
|
+
let alive = true
|
|
964
|
+
const tick = () =>
|
|
965
|
+
callRpc(rpc, 'progress', {})
|
|
966
|
+
.then((value) => {
|
|
967
|
+
if (alive) setProgress(value)
|
|
968
|
+
})
|
|
969
|
+
.catch(() => {})
|
|
970
|
+
tick()
|
|
971
|
+
const timer = setInterval(tick, 1000)
|
|
972
|
+
return () => {
|
|
973
|
+
alive = false
|
|
974
|
+
clearInterval(timer)
|
|
975
|
+
}
|
|
976
|
+
}, [busy, rpc])
|
|
977
|
+
|
|
851
978
|
const loadRecent = useCallback(
|
|
852
979
|
(days) => {
|
|
853
980
|
if (!rpc) return
|
|
@@ -985,6 +1112,13 @@ window.__ModuleLoader__.load({
|
|
|
985
1112
|
setCleanBusy(false)
|
|
986
1113
|
setBatchPending(false)
|
|
987
1114
|
applyRemoved(value)
|
|
1115
|
+
const failed = (value && value.items ? value.items : []).filter((item) => item.ok === false)
|
|
1116
|
+
setBatchResult({
|
|
1117
|
+
freed: (value && value.freed) || 0,
|
|
1118
|
+
removed: (value && value.removed) || 0,
|
|
1119
|
+
rejected: failed.slice(0, 12),
|
|
1120
|
+
rejectedCount: failed.length,
|
|
1121
|
+
})
|
|
988
1122
|
})
|
|
989
1123
|
.catch((err) => {
|
|
990
1124
|
setCleanBusy(false)
|
|
@@ -1074,7 +1208,14 @@ window.__ModuleLoader__.load({
|
|
|
1074
1208
|
]
|
|
1075
1209
|
|
|
1076
1210
|
// 多选:只有「可一键清理」的行能勾(>=50 MB 且不是 danger)
|
|
1077
|
-
const
|
|
1211
|
+
const allRecent = (recent && recent.items) || []
|
|
1212
|
+
const riskCount = {
|
|
1213
|
+
safe: allRecent.filter((item) => item.risk === 'safe').length,
|
|
1214
|
+
caution: allRecent.filter((item) => item.risk === 'caution').length,
|
|
1215
|
+
danger: allRecent.filter((item) => item.risk === 'danger').length,
|
|
1216
|
+
}
|
|
1217
|
+
const visibleItems = riskFilter === 'all' ? allRecent : allRecent.filter((item) => item.risk === riskFilter)
|
|
1218
|
+
const cleanableItems = ((recent && recent.items) || []).filter((item) => item.selectable !== false)
|
|
1078
1219
|
const safeItems = cleanableItems.filter((item) => item.risk === 'safe')
|
|
1079
1220
|
const selectedSet = new Set(selected)
|
|
1080
1221
|
const selectedBytes = cleanableItems
|
|
@@ -1121,7 +1262,19 @@ window.__ModuleLoader__.load({
|
|
|
1121
1262
|
data.generatedAt ? h('span', { style: { marginLeft: 'auto' } }, formatWhen(data.generatedAt)) : null,
|
|
1122
1263
|
)
|
|
1123
1264
|
: null,
|
|
1124
|
-
busy && !data
|
|
1265
|
+
busy && !data
|
|
1266
|
+
? h(
|
|
1267
|
+
'div',
|
|
1268
|
+
{ className: 'dsh-ir-prog' },
|
|
1269
|
+
h(
|
|
1270
|
+
'div',
|
|
1271
|
+
{ className: 'dsh-ir-progHead' },
|
|
1272
|
+
h('span', null, `${progress && progress.phase ? progress.phase : t('scanning')} · ${progress && progress.pct ? progress.pct : 0}%`),
|
|
1273
|
+
),
|
|
1274
|
+
h('div', { className: 'dsh-ir-progBar' }, h('div', { className: 'dsh-ir-progFill', style: { width: `${(progress && progress.pct) || 2}%` } })),
|
|
1275
|
+
h('div', { className: 'dsh-ir-progTip' }, t('scanningDetail')),
|
|
1276
|
+
)
|
|
1277
|
+
: null,
|
|
1125
1278
|
error ? h('div', { className: 'dsh-ir-empty' }, `${t('error')}: ${error}`) : null,
|
|
1126
1279
|
h(
|
|
1127
1280
|
'div',
|
|
@@ -1198,6 +1351,31 @@ window.__ModuleLoader__.load({
|
|
|
1198
1351
|
? h('div', { key: 'head', className: 'dsh-ir-recentHead' }, `${t('recentHead')} · 起点 ${recent.sinceLabel} · 合计 ${formatSize(recent.totalBytes ?? 0)}`)
|
|
1199
1352
|
: null,
|
|
1200
1353
|
recent && recent.items.length === 0 ? h('div', { key: 'empty2', className: 'dsh-ir-empty' }, t('recentEmpty')) : null,
|
|
1354
|
+
batchResult
|
|
1355
|
+
? h(
|
|
1356
|
+
'div',
|
|
1357
|
+
{
|
|
1358
|
+
key: 'batchResult',
|
|
1359
|
+
className: 'dsh-ir-confirm',
|
|
1360
|
+
style: {
|
|
1361
|
+
background: batchResult.rejectedCount > 0 ? 'rgba(221,51,51,.10)' : 'rgba(47,158,95,.12)',
|
|
1362
|
+
borderColor: batchResult.rejectedCount > 0 ? 'rgba(221,51,51,.35)' : 'rgba(47,158,95,.4)',
|
|
1363
|
+
flexWrap: 'wrap',
|
|
1364
|
+
},
|
|
1365
|
+
},
|
|
1366
|
+
h(
|
|
1367
|
+
'span',
|
|
1368
|
+
null,
|
|
1369
|
+
batchResult.rejectedCount > 0
|
|
1370
|
+
? `⚠️ 删除 ${batchResult.removed} 项 · 释放 ${formatSize(batchResult.freed)} · 被拒 ${batchResult.rejectedCount} 项`
|
|
1371
|
+
: `✅ 删除 ${batchResult.removed} 项 · 释放 ${formatSize(batchResult.freed)}`,
|
|
1372
|
+
),
|
|
1373
|
+
...batchResult.rejected.map((item, index) =>
|
|
1374
|
+
h('div', { key: `rj-${index}`, className: 'dsh-ir-eco', style: { width: '100%' } }, `${item.path} — ${item.reason || '被拒绝'}`),
|
|
1375
|
+
),
|
|
1376
|
+
h('button', { key: 'close', type: 'button', className: 'dsh-ir-chip', onClick: () => setBatchResult(null) }, t('gotIt')),
|
|
1377
|
+
)
|
|
1378
|
+
: null,
|
|
1201
1379
|
recent && recent.items.length > 0
|
|
1202
1380
|
? h(SelBar, {
|
|
1203
1381
|
key: 'selbar',
|
|
@@ -1216,8 +1394,36 @@ window.__ModuleLoader__.load({
|
|
|
1216
1394
|
onCancel: () => setBatchPending(false),
|
|
1217
1395
|
})
|
|
1218
1396
|
: null,
|
|
1397
|
+
recent && recent.items.length > 0
|
|
1398
|
+
? h(
|
|
1399
|
+
'div',
|
|
1400
|
+
{ key: 'riskbar', className: 'dsh-ir-selbarWrap', style: { position: 'static', background: 'transparent', boxShadow: 'none', borderBottom: 'none' } },
|
|
1401
|
+
h('span', { className: 'dsh-ir-eco' }, t('riskFilter')),
|
|
1402
|
+
...[
|
|
1403
|
+
['safe', '🟢'],
|
|
1404
|
+
['caution', '🟡'],
|
|
1405
|
+
['danger', '🔴'],
|
|
1406
|
+
].map(([level, emoji]) =>
|
|
1407
|
+
h(
|
|
1408
|
+
'button',
|
|
1409
|
+
{
|
|
1410
|
+
key: level,
|
|
1411
|
+
type: 'button',
|
|
1412
|
+
className: `dsh-ir-subtab${riskFilter === level ? ' dsh-ir-subtab--on' : ''}`,
|
|
1413
|
+
onClick: () => setRiskFilter((prev) => (prev === level ? 'all' : level)),
|
|
1414
|
+
},
|
|
1415
|
+
`${emoji} ${riskCount[level]}`,
|
|
1416
|
+
),
|
|
1417
|
+
),
|
|
1418
|
+
riskFilter !== 'all'
|
|
1419
|
+
? h('button', { key: 'all', type: 'button', className: 'dsh-ir-chip', onClick: () => setRiskFilter('all') }, t('clearSel'))
|
|
1420
|
+
: null,
|
|
1421
|
+
h('span', { key: 'sp', className: 'dsh-ir-selbarSpacer' }),
|
|
1422
|
+
h('span', { className: 'dsh-ir-eco' }, `${visibleItems.length} / ${allRecent.length}`),
|
|
1423
|
+
)
|
|
1424
|
+
: null,
|
|
1219
1425
|
recent
|
|
1220
|
-
?
|
|
1426
|
+
? visibleItems.map((item, index) =>
|
|
1221
1427
|
h(RecentRow, {
|
|
1222
1428
|
key: `${item.path}-${index}`,
|
|
1223
1429
|
item,
|
package/src/engine/clean.js
CHANGED
|
@@ -58,7 +58,19 @@ export async function cleanTargets(paths, opts = {}) {
|
|
|
58
58
|
const list = Array.isArray(paths) ? paths : []
|
|
59
59
|
const items = []
|
|
60
60
|
for (const raw of list) {
|
|
61
|
-
|
|
61
|
+
let expanded = await expand(raw)
|
|
62
|
+
// 临时目录根本身不允许整根删:自动展开成里面的子项,逐个判定
|
|
63
|
+
if (expanded.length === 1 && !checkAllowed(expanded[0]).ok) {
|
|
64
|
+
const root = resolve(expanded[0])
|
|
65
|
+
if (root.toLowerCase() === String(expanded[0]).toLowerCase() && allowedRoots().includes(root.toLowerCase())) {
|
|
66
|
+
try {
|
|
67
|
+
const children = await readdir(root, { withFileTypes: true })
|
|
68
|
+
expanded = children.map((child) => join(root, child.name))
|
|
69
|
+
} catch {
|
|
70
|
+
expanded = []
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
62
74
|
if (expanded.length === 0) {
|
|
63
75
|
items.push({ path: raw, ok: false, reason: '没有匹配到目录', bytes: 0 })
|
|
64
76
|
continue
|
package/src/engine/radar.js
CHANGED
|
@@ -148,13 +148,18 @@ export function diffSnapshot(baseline, snapshot) {
|
|
|
148
148
|
*/
|
|
149
149
|
export async function buildReport(opts = {}) {
|
|
150
150
|
const home = opts.home || dshHome()
|
|
151
|
+
const progress = typeof opts.onProgress === 'function' ? opts.onProgress : () => {}
|
|
152
|
+
progress('扫描安装根目录', 5)
|
|
151
153
|
await initSizeCache(home)
|
|
152
154
|
const snapshot = await currentSnapshot({ ...opts, home })
|
|
155
|
+
progress('比对基线', 40)
|
|
153
156
|
const ensured = await ensureBaseline({ ...opts, home, snapshot })
|
|
154
157
|
const baseline = ensured.baseline
|
|
155
158
|
const delta = diffSnapshot(baseline, snapshot)
|
|
159
|
+
progress('读取已安装程序注册表', 45)
|
|
156
160
|
const env = await probeEnvironment()
|
|
157
161
|
const programs = await loadInstalledPrograms()
|
|
162
|
+
progress('解析会话日志', 55)
|
|
158
163
|
|
|
159
164
|
let sessions = []
|
|
160
165
|
let scannedSessions = 0
|
|
@@ -163,6 +168,7 @@ export async function buildReport(opts = {}) {
|
|
|
163
168
|
sessions = collected.sessions
|
|
164
169
|
scannedSessions = collected.scannedSessions
|
|
165
170
|
}
|
|
171
|
+
progress(`解析安装条目(${sessions.length} 个会话)`, 65)
|
|
166
172
|
|
|
167
173
|
let audit = null
|
|
168
174
|
try {
|
|
@@ -227,6 +233,7 @@ export async function buildReport(opts = {}) {
|
|
|
227
233
|
})
|
|
228
234
|
}
|
|
229
235
|
perSession.sort((a, b) => (b.lastActive ?? 0) - (a.lastActive ?? 0))
|
|
236
|
+
progress('整理报告', 95)
|
|
230
237
|
|
|
231
238
|
const unattributed = delta.newEntries.slice(0, 60).map((entry) => {
|
|
232
239
|
const info = classify(entry.name, entry.path)
|
|
@@ -303,7 +310,11 @@ export async function buildRecent(opts = {}) {
|
|
|
303
310
|
const risk = assessRisk(item.path, { bytes: item.bytes })
|
|
304
311
|
item.risk = risk.level
|
|
305
312
|
item.consequence = risk.consequence
|
|
306
|
-
|
|
313
|
+
// 临时目录根本身也可以勾:服务端会把它展开成里面的子项逐个处理
|
|
314
|
+
item.isRoot = risk.isRoot === true
|
|
315
|
+
// 勾选不设门槛:所有行都能多选(danger 也能勾,提交后服务端会逐条拒绝并给原因)
|
|
316
|
+
item.selectable = true
|
|
317
|
+
item.cleanable = item.bytes >= CLEAN_MIN_BYTES && risk.level !== 'danger'
|
|
307
318
|
}
|
|
308
319
|
raw.sinceLabel = new Date(raw.since).toLocaleString('sv-SE')
|
|
309
320
|
raw.totalSize = formatBytes(raw.totalBytes)
|
package/src/index.js
CHANGED
|
@@ -49,7 +49,7 @@ export const name = 'install-radar'
|
|
|
49
49
|
export const inject = ['tools']
|
|
50
50
|
|
|
51
51
|
const ROUTE_PREFIX = '/api/install-radar/'
|
|
52
|
-
const METHODS = ['report', 'sessions', 'baseline', 'diff', 'refresh', 'recent', 'open', 'procs', 'clean', 'history', 'repro', 'trend', 'autoclean']
|
|
52
|
+
const METHODS = ['report', 'sessions', 'baseline', 'diff', 'refresh', 'recent', 'open', 'procs', 'clean', 'history', 'repro', 'trend', 'autoclean', 'uninstall', 'progress']
|
|
53
53
|
|
|
54
54
|
const OUTPUT = {
|
|
55
55
|
type: 'object',
|
|
@@ -141,6 +141,160 @@ function renderSessionTable(report) {
|
|
|
141
141
|
export function apply(ctx, config = {}) {
|
|
142
142
|
const base = sessionConfig(config)
|
|
143
143
|
|
|
144
|
+
/** 首次扫描进度:report 路由在跑的时候,面板轮询这里画进度条 */
|
|
145
|
+
let progressState = null
|
|
146
|
+
|
|
147
|
+
/** 有些工具「没装」也返回 0(pip / winget 都有这种),必须看输出才能判失败 */
|
|
148
|
+
function saysNotInstalled(text) {
|
|
149
|
+
return /not installed|no installed package|找不到与输入条件匹配|未找到已安装|is not installed/i.test(String(text || ''))
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** 卸载失败时给「怎么办」,按工具 + 报错内容挑 */
|
|
153
|
+
function hintsFor(step, outcome) {
|
|
154
|
+
const tail = `${outcome.err || ''}\n${outcome.out || ''}`.toLowerCase()
|
|
155
|
+
if (outcome.code === -1 || /enoent|不是内部或外部命令|not recognized/.test(tail)) {
|
|
156
|
+
return [`没找到 ${step.cmd} 命令:确认它已安装并在 PATH 里`, '或者复制「卸载命令」到终端里手动执行']
|
|
157
|
+
}
|
|
158
|
+
if (/elevat|admin|access is denied|拒绝访问|0x80070005/.test(tail)) {
|
|
159
|
+
return ['需要管理员权限:用「以管理员身份运行」的终端重试这条命令']
|
|
160
|
+
}
|
|
161
|
+
if (step.cmd === 'winget') {
|
|
162
|
+
return [
|
|
163
|
+
'winget 卸载最常见的原因是程序还在运行——先完全退出(含托盘图标)再试',
|
|
164
|
+
'包 id 可能不准确:先跑 winget list 查确切 id',
|
|
165
|
+
'源没更新:winget source update 后再试',
|
|
166
|
+
'仍失败就把「卸载命令」复制到管理员终端手动执行,看完整报错',
|
|
167
|
+
]
|
|
168
|
+
}
|
|
169
|
+
if (step.cmd === 'npm') {
|
|
170
|
+
return ['全局卸载偶尔因权限失败:用管理员终端重试', '确认用的是 npm 包名(不是 import 名)', '可先跑 npm ls -g --depth=0 核对']
|
|
171
|
+
}
|
|
172
|
+
if (step.cmd === 'python') {
|
|
173
|
+
return ['确认这个包装在当前 python 下:python -m pip show <包名>', '多个 Python 并存时指定解释器:py -3.14 -m pip uninstall <包名>', '权限问题可试 --user']
|
|
174
|
+
}
|
|
175
|
+
if (step.cmd === 'conda') {
|
|
176
|
+
return ['确认环境名:conda env list', '环境正在被使用时无法删除,先退出该环境里的程序']
|
|
177
|
+
}
|
|
178
|
+
return ['查看下方原始输出定位原因', '或复制命令到终端手动执行']
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** 删除型卸载失败的提示 */
|
|
182
|
+
function deleteHints(items) {
|
|
183
|
+
if (!items.some((item) => item.ok === false)) return []
|
|
184
|
+
return [
|
|
185
|
+
'文件被占用:先退出正在使用它的程序(浏览器 / 编辑器 / 正在跑的脚本)',
|
|
186
|
+
'权限不足:用管理员终端重试',
|
|
187
|
+
'路径可能变了:点「重扫」刷新后再看',
|
|
188
|
+
]
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* 一键卸载:按生态执行真正的卸载命令(面板里两步确认后才调到这里)。
|
|
193
|
+
* 只接受报告里出现过的条目,避免被当成任意命令执行器。
|
|
194
|
+
*/
|
|
195
|
+
async function runUninstall(payload) {
|
|
196
|
+
const eco = String(payload?.eco || '')
|
|
197
|
+
const name = String(payload?.name || '')
|
|
198
|
+
const target = String(payload?.path || '')
|
|
199
|
+
const confirmed = payload?.confirm === true
|
|
200
|
+
if (!confirmed) return { confirm: false, eco, name, target }
|
|
201
|
+
if (!name && !target) throw new Error('缺少 name / path')
|
|
202
|
+
|
|
203
|
+
const plan = []
|
|
204
|
+
if (eco === 'winget') plan.push({ cmd: 'winget', args: ['uninstall', '--id', name, '-e', '--silent', '--accept-source-agreements'] })
|
|
205
|
+
else if (eco === 'choco') plan.push({ cmd: 'choco', args: ['uninstall', name, '-y'] })
|
|
206
|
+
else if (eco === 'python') plan.push({ cmd: 'python', args: ['-m', 'pip', 'uninstall', '-y', name] })
|
|
207
|
+
else if (eco === 'node') {
|
|
208
|
+
if (/[\\/]node_modules[\\/]/i.test(target)) {
|
|
209
|
+
const project = target.split(/[\\/]node_modules[\\/]/i)[0]
|
|
210
|
+
plan.push({ cmd: 'npm', args: ['rm', name], cwd: project })
|
|
211
|
+
} else plan.push({ cmd: 'npm', args: ['rm', '-g', name] })
|
|
212
|
+
} else if (eco === 'conda') plan.push({ cmd: 'conda', args: ['env', 'remove', '-n', name, '-y'] })
|
|
213
|
+
|
|
214
|
+
// 目录型(git clone / venv / 安装包 / 其它):走删除
|
|
215
|
+
if (plan.length === 0) {
|
|
216
|
+
if (!target || !existsSync(target)) {
|
|
217
|
+
return {
|
|
218
|
+
confirm: true,
|
|
219
|
+
mode: 'delete',
|
|
220
|
+
ok: false,
|
|
221
|
+
error: `路径不存在:${target || '(空)'}`,
|
|
222
|
+
hints: ['这个东西可能已经被手动删掉了', '点「重扫」刷新后再看当前状态'],
|
|
223
|
+
freed: 0,
|
|
224
|
+
removed: 0,
|
|
225
|
+
items: [],
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
const result = await cleanTargets([target], { confirm: true, home: base.home, note: 'uninstall' })
|
|
229
|
+
const failed = result.items.filter((item) => item.ok === false)
|
|
230
|
+
return {
|
|
231
|
+
confirm: true,
|
|
232
|
+
mode: 'delete',
|
|
233
|
+
ok: failed.length === 0,
|
|
234
|
+
freed: result.freed,
|
|
235
|
+
removed: result.removed,
|
|
236
|
+
items: result.items,
|
|
237
|
+
hints: deleteHints(result.items),
|
|
238
|
+
error: failed.length > 0 ? failed.map((item) => `${item.path}:${item.reason}`).join(';') : '',
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const results = []
|
|
243
|
+
for (const step of plan) {
|
|
244
|
+
const started = Date.now()
|
|
245
|
+
const outcome = await new Promise((done) => {
|
|
246
|
+
let out = ''
|
|
247
|
+
let err = ''
|
|
248
|
+
const child = spawn(step.cmd, step.args, { cwd: step.cwd, windowsHide: true, shell: process.platform === 'win32' })
|
|
249
|
+
child.stdout?.on('data', (chunk) => (out += chunk))
|
|
250
|
+
child.stderr?.on('data', (chunk) => (err += chunk))
|
|
251
|
+
child.on('error', (error) => done({ ok: false, code: -1, out, err: String(error.message) }))
|
|
252
|
+
child.on('close', (code) => done({ ok: code === 0, code, out, err }))
|
|
253
|
+
})
|
|
254
|
+
const tail = (outcome.err || outcome.out || '').slice(-400)
|
|
255
|
+
const notInstalled = saysNotInstalled(tail)
|
|
256
|
+
const ok = outcome.ok && !notInstalled
|
|
257
|
+
results.push({
|
|
258
|
+
...step,
|
|
259
|
+
...outcome,
|
|
260
|
+
ok,
|
|
261
|
+
notInstalled,
|
|
262
|
+
ms: Date.now() - started,
|
|
263
|
+
tail,
|
|
264
|
+
hints: ok
|
|
265
|
+
? []
|
|
266
|
+
: notInstalled
|
|
267
|
+
? [
|
|
268
|
+
`这条在 ${step.cmd} 里没找到,说明它可能装在别的解释器/别的用户下`,
|
|
269
|
+
step.cmd === 'python' ? '先确认解释器:py -0p,再用对应版本重试 python -m pip uninstall <包名>' : '先跑一次 list 命令(winget list / npm ls -g)核对确切名字',
|
|
270
|
+
'也可能已经被卸掉了——点「重扫」看当前状态',
|
|
271
|
+
]
|
|
272
|
+
: hintsFor(step, outcome),
|
|
273
|
+
})
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const ok = results.every((row) => row.ok)
|
|
277
|
+
try {
|
|
278
|
+
const dir = stateDir(base.home)
|
|
279
|
+
await mkdir(dir, { recursive: true })
|
|
280
|
+
await writeFile(
|
|
281
|
+
join(dir, 'uninstall-log.jsonl'),
|
|
282
|
+
`${JSON.stringify({ at: Date.now(), eco, name, target, ok, results: results.map((r) => ({ cmd: r.cmd, code: r.code })) })}\n`,
|
|
283
|
+
{ encoding: 'utf8', flag: 'a' },
|
|
284
|
+
)
|
|
285
|
+
} catch {
|
|
286
|
+
/* 日志写失败不影响结果 */
|
|
287
|
+
}
|
|
288
|
+
return {
|
|
289
|
+
confirm: true,
|
|
290
|
+
mode: 'command',
|
|
291
|
+
ok,
|
|
292
|
+
results,
|
|
293
|
+
hints: results.flatMap((row) => row.hints || []),
|
|
294
|
+
error: ok ? '' : results.filter((row) => !row.ok).map((row) => `${row.cmd} 退出码 ${row.code}`).join(';'),
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
144
298
|
/**
|
|
145
299
|
* 复现线索要挑「哪一轮」:优先用历史侧写的第一名;
|
|
146
300
|
* 如果临时目录已经被清掉了,就退回到「浏览器命令最多的那一轮」。
|
|
@@ -485,7 +639,20 @@ export function apply(ctx, config = {}) {
|
|
|
485
639
|
async function reportPayload(force) {
|
|
486
640
|
const ttl = 45_000
|
|
487
641
|
if (!force && cached.value && Date.now() - cached.at < ttl) return cached.value
|
|
488
|
-
|
|
642
|
+
progressState = { phase: '开始扫描', pct: 2, startedAt: Date.now() }
|
|
643
|
+
let report
|
|
644
|
+
try {
|
|
645
|
+
report = await buildReport({
|
|
646
|
+
...base,
|
|
647
|
+
includeSessions: true,
|
|
648
|
+
force,
|
|
649
|
+
onProgress: (phase, pct) => {
|
|
650
|
+
progressState = { phase, pct, startedAt: progressState?.startedAt || Date.now() }
|
|
651
|
+
},
|
|
652
|
+
})
|
|
653
|
+
} finally {
|
|
654
|
+
progressState = null
|
|
655
|
+
}
|
|
489
656
|
const value = panelPayload(report)
|
|
490
657
|
const config = await readConfig(base.home)
|
|
491
658
|
value.autoClean = { enabled: false, minAgeDays: 7, ...(config.autoClean || {}) }
|
|
@@ -603,6 +770,12 @@ export function apply(ctx, config = {}) {
|
|
|
603
770
|
}
|
|
604
771
|
return { autoClean: settings, result }
|
|
605
772
|
},
|
|
773
|
+
async uninstall(body) {
|
|
774
|
+
return runUninstall(body?.payload || {})
|
|
775
|
+
},
|
|
776
|
+
async progress() {
|
|
777
|
+
return progressState || { phase: '', pct: 0, idle: true }
|
|
778
|
+
},
|
|
606
779
|
async repro(body) {
|
|
607
780
|
const payload = body?.payload || {}
|
|
608
781
|
const picked = await pickSessionForRepro(payload.sessionId)
|