- core.js: isKeepableUserOverride重排序, markOverrideCustomized不写hash(explicitSave), commitOverrideOnApply读盘, patch89 SYSTEM_FIELD_EXTRACT - index.js: system_field_extracted状态字段(status/apply/override端点) - default-prompt-inject.en.md: 优化英文版提示词 - default-prompt-inject.zh.md: 优化中文版提示词 - docs/修改文档.md: 完整修改文档+还原说明 - pi-ai anthropic-messages.js: system从顶层字段改为messages里role:system消息
8788 lines
490 KiB
JavaScript
8788 lines
490 KiB
JavaScript
window.__ModuleLoader__.load({ id: "dsh-purge", factory: (require) => {
|
||
|
||
var module = { exports: {} };
|
||
var exports = module.exports;
|
||
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
||
let react = require("react");
|
||
const h = react.createElement;
|
||
const { useState, useEffect, useCallback, useRef, Component } = react;
|
||
|
||
const name = "dsh-purge";
|
||
const inject = ["slots", "locale"];
|
||
const NS = "settings.dsh-purge";
|
||
let translate = (key) => key;
|
||
function useT() {
|
||
return translate;
|
||
}
|
||
function clientGuessSurface() {
|
||
try {
|
||
if (typeof navigator !== "undefined" && /electron/i.test(navigator.userAgent || "")) return "desktop";
|
||
if (typeof location !== "undefined") {
|
||
const port = String(location.port || "");
|
||
if (port === "43120") return "desktop";
|
||
const host = String(location.hostname || "");
|
||
if (host && host !== "127.0.0.1" && host !== "localhost" && /dsh desktop/i.test(String(location.href || ""))) return "desktop";
|
||
}
|
||
} catch { /* ignore */ }
|
||
return "";
|
||
}
|
||
function hostSurfaceOf(s) {
|
||
return (s && s.surface) || clientGuessSurface() || "web";
|
||
}
|
||
function hostText(t, key, surface) {
|
||
if (surface && surface !== "web") {
|
||
const specific = key + "." + surface;
|
||
const hit = t(specific);
|
||
if (hit && hit !== specific) return hit;
|
||
}
|
||
return t(key);
|
||
}
|
||
function apiUrl(p) {
|
||
try { return new URL(p, window.location.origin).toString(); } catch { return p; }
|
||
}
|
||
function apiTimeoutMs(p, init) {
|
||
const path = String(p || "");
|
||
const method = String((init && init.method) || "GET").toUpperCase();
|
||
if (path.indexOf("/dsh-purge/update") !== -1) return method === "POST" ? 180000 : 45000;
|
||
if (path.indexOf("/dsh-purge/uninstall") !== -1) return 90000;
|
||
if (path.indexOf("/dsh-purge/skill") !== -1) return method === "POST" ? 120000 : 20000;
|
||
// status 会在"应用"期间被后端的同步落盘阻塞,默认 20s 必然被 abort,界面只会显示"读取失败: timeout"。
|
||
if (path.indexOf("/dsh-purge/status") !== -1) return 60000;
|
||
if (path.indexOf("/dsh-purge/apply") !== -1) return 240000;
|
||
if (path.indexOf("/dsh-purge/revert") !== -1) return 120000;
|
||
if (path.indexOf("/dsh-purge/restart") !== -1) return 60000;
|
||
return 20000;
|
||
}
|
||
function isAbortError(e) {
|
||
const msg = String((e && e.message) || e || "");
|
||
return (e && e.name === "AbortError") || /aborted|abort/i.test(msg);
|
||
}
|
||
async function apiJson(p, init) {
|
||
const ctrl = new AbortController();
|
||
const timer = setTimeout(() => ctrl.abort(), apiTimeoutMs(p, init));
|
||
try {
|
||
const r = await fetch(apiUrl(p), Object.assign({ cache: "no-store", credentials: "same-origin", signal: ctrl.signal }, init || {}));
|
||
const text = await r.text();
|
||
let data;
|
||
try { data = text ? JSON.parse(text) : null; } catch {
|
||
throw new Error(r.status + " non-json: " + text.slice(0, 120));
|
||
}
|
||
if (!r.ok) throw new Error((data && (data.error || data.message)) || (r.status + " " + r.statusText));
|
||
return data;
|
||
} catch (e) {
|
||
if (isAbortError(e)) throw new Error("timeout");
|
||
const msg = String((e && e.message) || e || "");
|
||
if (/failed to fetch|networkerror|load failed/i.test(msg)) {
|
||
throw new Error("连不上接口,请确认 dsh 还在跑后 Ctrl+F5");
|
||
}
|
||
throw e;
|
||
} finally { clearTimeout(timer); }
|
||
}
|
||
|
||
function promptBoxEmpty(text) {
|
||
return !String(text || "").trim();
|
||
}
|
||
|
||
function responseNeedsPrompt(d) {
|
||
return Boolean(d && (d.needPrompt || d.injectSource === "none"));
|
||
}
|
||
|
||
function warnNeedPrompt(d, tr) {
|
||
if (!responseNeedsPrompt(d)) return false;
|
||
window.alert(tr("need.prompt"));
|
||
return true;
|
||
}
|
||
|
||
async function bothInjectEmpty(overrideText, defaultText) {
|
||
if (!promptBoxEmpty(overrideText)) return false;
|
||
if (!promptBoxEmpty(defaultText)) return false;
|
||
return true;
|
||
}
|
||
|
||
function rulesApi(op, extra) {
|
||
return apiJson("/dsh-purge/rules", {
|
||
method: "POST",
|
||
headers: { "content-type": "application/json" },
|
||
body: JSON.stringify(Object.assign({ op: op }, extra || {})),
|
||
});
|
||
}
|
||
|
||
function skillsRouteMissing(e) {
|
||
return /\b404\b|\b405\b|non-json/i.test(String((e && e.message) || e || ""));
|
||
}
|
||
|
||
function skillsApi(op, extra) {
|
||
const init = {
|
||
method: "POST",
|
||
headers: { "content-type": "application/json" },
|
||
body: JSON.stringify(Object.assign({ op: op }, extra || {})),
|
||
};
|
||
return apiJson("/dsh-purge/skills", init).catch((e) => {
|
||
if (!skillsRouteMissing(e)) throw e;
|
||
return apiJson("/dsh-purge/skill", init);
|
||
});
|
||
}
|
||
|
||
const TARGETS = ["AGENTS.md", "CLAUDE.md"];
|
||
|
||
const zh = {
|
||
nav: "规则设定",
|
||
"dock.clean": "清洗",
|
||
"dock.drill": "演练台",
|
||
"dock.toggle": "dsh-purge",
|
||
"dock.newSession": "dsh-purge",
|
||
"dock.inSession": "dsh-purge",
|
||
"dock.redteam": "红队模式",
|
||
"dock.full": "全面浏览",
|
||
"dock.close": "收起",
|
||
"dock.unauthorized": "未授权",
|
||
"welcome.title": "用户须知",
|
||
"welcome.lead": "首次安装本插件时请阅读以下说明。确认后本窗不再弹出。",
|
||
"welcome.warn": "本插件免费开源。任何背着作者私自售卖、付费倒卖或借本项目盈利的行为,作者将依法追究法律责任。",
|
||
"welcome.body": "dsh-purge 是 DeepSeek Harness 的本机网络安全红队评测与清洗插件:在受控本机环境中调整提示词与补丁、做授权范围内的演练台操作。项目免费、非营利,源码仓库:",
|
||
"welcome.repo": "https://github.com/YuJunZhiXue/dsh-purge",
|
||
"welcome.ban1": "禁止将本项目用于未授权的网络攻击、入侵、窃取数据、破坏系统或任何违法犯罪。",
|
||
"welcome.ban2": "禁止商业售卖、付费倒卖、黑灰产牟利,以及隐瞒作者的营利性再分发。",
|
||
"welcome.ban3": "使用者须自行遵守所在地法律法规与平台规范;违法后果由行为人自行承担,与作者无关。",
|
||
"welcome.bans": "禁止事项",
|
||
"welcome.check": "我已阅读并理解上述用户须知,同意合法合规使用。",
|
||
"welcome.ok": "我已知晓并继续",
|
||
"auth.title": "演练台授权确认",
|
||
"auth.lead": "清洗不需要这一步。第一次进入演练台,或打开全面浏览,都要先完成本窗。",
|
||
"auth.warn": "安全警告:演练台只用于你有权管理的本机、离线靶标,或已书面授权的演练环境。未经授权的渗透、攻击、窃取和破坏一律禁止。",
|
||
"auth.hint.wait": "请阅读声明。{n} 秒后才能勾选。",
|
||
"auth.hint.scroll": "倒计时已结束。请把声明滚到文末,再勾选。",
|
||
"auth.hint.check": "倒计时已结束。请勾选全部三项。",
|
||
"auth.check.read": "我已读完《安全与禁止违法声明》,并认可其中的全部条款。",
|
||
"auth.check.scope": "我确认只用在本机已授权环境、离线靶标或合规演练中。",
|
||
"auth.check.ban": "我确认禁止任何违法用途。未完成本授权,不得使用演练台。",
|
||
"auth.no": "暂不进入",
|
||
"auth.ok": "确认授权",
|
||
"auth.ok.tab": "确认授权并进入演练台",
|
||
"auth.ok.full": "确认授权并全面浏览",
|
||
"auth.ok.env": "确认授权并打开环境适配",
|
||
"envGate.title": "环境还没配好",
|
||
"envGate.lead": "红队模式发送前需要本机工具。未检测到可用环境时,先去环境适配,或确认暂不配置。",
|
||
"envGate.kaliHint": "检测到 Kali 时会直接用本机工具,不必填路径。没有 Kali 时,在环境适配里填写各工具路径,或选一个整包文件夹自动分配。",
|
||
"envGate.skip": "暂不配置",
|
||
"envGate.skipConfirm": "确认后这次可以继续对话。扫描和利用类技能仍会因为缺少工具而不可用,之后可以在 dsh-purge → 环境适配里补路径。",
|
||
"envGate.skipNo": "返回",
|
||
"envGate.skipYes": "确认暂不配置",
|
||
"envGate.go": "去环境适配",
|
||
"theme.aria": "外观",
|
||
"theme.white": "白",
|
||
"theme.ink": "墨",
|
||
"purge.title": "补丁",
|
||
"override.title": "提示词",
|
||
"own.title": "自己的服务器",
|
||
"own.hint": "每行一个 IP 或完整主机名。可以写成 账号@主机,账号只用来认出这种写法。主机名会连同它当时解析出的地址一起放行。不要写密钥、密码、网段。",
|
||
"own.save": "保存名单",
|
||
"saved.own": "已保存自己的服务器",
|
||
"saved.own.dropped": "已保存。有 {n} 行不是单个 IP 或主机名,已丢掉",
|
||
"metric.purged": "已写入",
|
||
"metric.purged.skip": "已写入。跳过 {n} 项,本机没有这些文件",
|
||
"metric.pendingShort": "待处理 {n}",
|
||
"metric.unmatchedShort": "没对上 {n}",
|
||
"metric.skipShort": "跳过 {n}",
|
||
"metric.unlocated.count": "—",
|
||
"metric.shim": "shim",
|
||
"metric.shim.na": "本端不需要",
|
||
"metric.bak.yes": "有备份",
|
||
"metric.bak.no": "无备份",
|
||
"metric.bak.hint": "备份",
|
||
"table.patch": "项目",
|
||
"table.status": "状态",
|
||
"status.applied": "已应用",
|
||
"status.off": "已关闭",
|
||
"status.pending": "待应用",
|
||
"status.unmatched": "没对上",
|
||
"status.skipped": "跳过",
|
||
"apply.hint": "没改提示词框不用点保存,默认就是这份,启动和应用会写进 prompt-inject.md。只有自己改过再点保存才会覆盖。点应用打补丁。待应用=原文还在。跳过=当前版本不需要或组件未安装。",
|
||
"warn.noRoot": "未定位到当前宿主的 @deepseek-ai,清洗不会生效。请完全退出后再打开本宿主,在本页点「应用」。桌面端安装目录可以是任意盘符;目录名带 DeepseekHarness / DeepSeek Harness 均可。",
|
||
"warn.noRoot.desktop": "官方客户端的代码在 app.asar 里。点「应用」会解开并自动重启,不用另跑脚本。若一直显示还没定位,先完全退出客户端再开,再点「应用」。第三方 DSH Desktop 直接点「应用」。",
|
||
"unpack.restarting": "补丁已写入。点「重启」后客户端会自己重新打开。",
|
||
"restart.fullQuit": "正在重启,客户端会自己重新打开。",
|
||
"warn.noInject": "官方始终用默认提示词。红队开了规则集就只用规则集;没开时用去掉 CTF 的默认提示词,再接红队操作。两边都空会提示必须添加。",
|
||
"need.prompt": "提示词和规则集都是空的,必须先添加提示词,或启用一条有内容的规则集。",
|
||
"btn.restoreInject": "恢复默认",
|
||
"saved.restoreInject": "已恢复默认提示词",
|
||
"skip": "跳过",
|
||
"unknown": "未知",
|
||
"delete": "删除",
|
||
"btn.apply": "应用",
|
||
"btn.apply.busy": "处理中…",
|
||
"btn.revert": "还原",
|
||
"btn.uninstall": "卸载",
|
||
"btn.uninstall.busy": "卸载中…",
|
||
"uninstall.title": "卸载 dsh-purge",
|
||
"uninstall.body": "是否卸载?卸载将还原回原版,并清除本插件的全部文件与补丁。",
|
||
"uninstall.confirm": "确认卸载",
|
||
"uninstall.cancel": "取消",
|
||
"uninstall.applying": "正在卸载并还原…",
|
||
"uninstall.done": "已卸载并还原,正在重启…",
|
||
"uninstall.done.desktop": "已卸载并还原,正在重启桌面应用…",
|
||
"uninstall.fail": "卸载失败: {error}",
|
||
"btn.checkUpdate": "检测更新",
|
||
"btn.checkUpdate.busy": "检测中…",
|
||
"btn.doUpdate": "更新",
|
||
"btn.doUpdate.busy": "更新中…",
|
||
"channel.title": "通道",
|
||
"channel.stable": "正式版",
|
||
"channel.beta": "测试版",
|
||
"channel.useStable": "切换到正式版",
|
||
"channel.useBeta": "切换到测试版",
|
||
"channel.local": "本机",
|
||
"update.checking": "正在检测更新…",
|
||
"update.applying": "正在更新…",
|
||
"update.switching": "正在切换版本…",
|
||
"update.latest": "已是最新({version})",
|
||
"update.available": "有新版本 {remote},当前 {local}",
|
||
"update.pinned": "已固定在 {version},通道最新是 {remote}",
|
||
"update.done": "已更新到 {version}",
|
||
"update.restarting": "已还原并写入新补丁,正在重启…",
|
||
"update.fullQuit": "补丁已写入。点「重启」后客户端会自己重新打开。",
|
||
"restart.official.title": "需要重启",
|
||
"restart.official.body": "补丁已写入。点重启后客户端会自己重新打开。",
|
||
"update.switched": "已切换到 {version}",
|
||
"update.autoDone": "已自动更新到 {version}",
|
||
"update.reloading": "正在刷新设置页…",
|
||
"update.dirty": "有新版本,本地有改动未自动覆盖",
|
||
"update.fail": "更新失败: {error}",
|
||
"update.timeout": "检测超时,请再点一次检测更新",
|
||
"update.needRestart": "检测接口未加载,请先重启 dsh 再点检测更新",
|
||
"update.noBeta": "还没有测试版",
|
||
"update.switch": "切换到此版本",
|
||
"update.pick": "选择版本",
|
||
"update.tip": "最新",
|
||
"update.current": "当前",
|
||
"update.confirmSwitch": "切换到 {version}?之后可再切回另一通道或其它版本。本地未提交的插件改动不会保留。",
|
||
"update.confirmChannel": "切换到{version}?会按该通道重新安装插件,本地未提交的插件改动不会保留。",
|
||
"metric.version": "版本",
|
||
"action.apply": "应用",
|
||
"action.revert": "还原",
|
||
"action.uninstall": "卸载",
|
||
"ok.done": "已完成",
|
||
"err.action": "失败: {error}",
|
||
"err.status": "读取失败: {error}",
|
||
"err.override": "读取失败: {error}",
|
||
"err.rules": "读取失败: {error}",
|
||
"err.read": "读取失败: {error}",
|
||
"err.save": "保存失败: {error}",
|
||
"btn.saveInject": "保存",
|
||
"saved.override": "已保存",
|
||
"rules.title": "规则集",
|
||
"rules.none": "无",
|
||
"rules.reset": "还原",
|
||
"rules.reset.confirm": "还原全局指令文件?规则库会保留。",
|
||
"rules.empty": "暂无规则",
|
||
"rules.pick": "选择规则",
|
||
"policy.title": "工作区策略",
|
||
"policy.strict": "严格",
|
||
"policy.fallback": "空位兜底",
|
||
"policy.always": "总是允许",
|
||
"policy.hint": "严格:永远剥 AGENTS.md / CLAUDE.md;空位兜底:提示词框为空时才用;总是允许:保留 DSH 原生行为",
|
||
"cwd.title": "本地规则(当前目录)",
|
||
"cwd.empty": "当前目录没有 AGENTS.md / CLAUDE.md",
|
||
"cwd.rescan": "重新扫描",
|
||
"cwd.hint": "只有提示词框为空时,被勾选的本地文件才作为兜底注入;提示词框一旦非空就不会用这些文件。",
|
||
"cwd.scope.cwd": "当前目录",
|
||
"cwd.scope.ancestor": "上级",
|
||
"cwd.scope.root": "项目根",
|
||
"cwd.no.workspace": "DSH 尚未选过工作目录,请先在 DSH 里开始一次任务再回来。",
|
||
"pill.current": "当前",
|
||
"btn.use": "启用",
|
||
"btn.inUse": "当前",
|
||
"confirm.delete": "删除 {id}?",
|
||
"ph.id": "id",
|
||
"ph.alias": "名称",
|
||
"ph.alias.short": "名称",
|
||
"btn.create": "新建",
|
||
"need.id": "需要 id",
|
||
"ph.content": "",
|
||
"btn.saveRule": "保存",
|
||
"skills.title": "Skill",
|
||
"skills.hint": "导入到官方目录后,由 DSH 调用:聊天输入 /名称 立即加载;任务对上 description 或 whenToUse 时模型会调 skill 工具。正文写「激活」不会触发。",
|
||
"skills.hint.desktop": "当前桌面宿主的官方 Skill 目录。聊天输入 /名称 立即加载;任务对上 description 时模型会调 skill 工具。",
|
||
"skills.empty": "暂无用户 Skill",
|
||
"btn.importZip": "导入压缩包",
|
||
"btn.importFolder": "导入文件夹",
|
||
"skills.importing": "正在导入…",
|
||
"saved.import": "已导入 {count} 个 Skill",
|
||
"need.import": "请选择压缩包或文件夹",
|
||
"err.import.folder": "没读到文件夹里的文件,请直接选 Skill 目录(里面要有 SKILL.md)",
|
||
"skills.pick": "选择 Skill",
|
||
"skills.invalid": "官方会忽略:格式不对",
|
||
"skills.call.slash": "聊天输入 {cmd}",
|
||
"skills.call.model": "模型可调",
|
||
"skills.call.modelOff": "模型不可调",
|
||
"skills.call.userOff": "不可 /名称",
|
||
"ph.skill.desc": "description(何时用)",
|
||
"btn.saveSkill": "保存",
|
||
"need.skill.id": "id 必须是 kebab-case,例如 code-review",
|
||
"confirm.delete.skill": "从官方 $DSH_HOME/skills 删除 {id}?宿主会自动从目录拿掉。",
|
||
"err.skills": "读取失败: {error}",
|
||
"err.skills.needRestart": "Skill 接口未加载,请先重启当前宿主",
|
||
"btn.restart": "重启",
|
||
"btn.restart.busy": "重启中…",
|
||
"restarting": "重启中…",
|
||
"restarting.desktop": "正在重启桌面应用…",
|
||
"restart.timeout": "超时,请刷新",
|
||
"restart.fail": "失败: {error}",
|
||
"restart.notStarted": "重启没有开始,客户端还在。不会自动再试。",
|
||
"restart.already": "这一次重启已经开始,不会再重启一次。",
|
||
"restart.confirm": "清洗已完成,重启后生效。",
|
||
"restart.confirm.desktop": "清洗已完成,重启桌面应用后生效。",
|
||
"restart.ask.title": "要现在重启吗",
|
||
"restart.ask.body": "已经写入。重启后才会在当前客户端里生效。",
|
||
"restart.now": "现在重启",
|
||
"restart.notNow": "稍后",
|
||
"restart.asarSwap": "补丁已写入磁盘。正在结束桌面进程并挪开 app.asar,完成后会自动重新打开;若仍无效,请先完全退出 Harness(托盘也关),再点一次应用。",
|
||
"surface.web": "Web",
|
||
"surface.desktop": "桌面端",
|
||
"surface.gui": "GUI",
|
||
"surface.tui": "TUI",
|
||
"restart.incomplete": "提示词已写入。{detail}",
|
||
"restart.incomplete.patches": "还有 {failed} 项代码补丁没对上当前版本(#{ids}),不影响这段提示词",
|
||
"restart.incomplete.flash": "CMD 无感未钉入({entry})",
|
||
"restart.later": "以后",
|
||
"group.prompt": "提示词",
|
||
"group.code": "代码",
|
||
"group.engine": "引擎",
|
||
"group.tools": "工具",
|
||
"group.compat": "0.1.5 兼容",
|
||
"group.dsh20": "0.2.0",
|
||
"patch.1": "全局免责 → 强指令",
|
||
"patch.2": "替换式免责 → 强指令",
|
||
"patch.3": "作用域免责 → 强指令",
|
||
"patch.4": "persona 会话强指令",
|
||
"patch.5": "审批提示句 → 全权限",
|
||
"patch.6": "沙箱 deny → allow 写",
|
||
"patch.7": "沙箱默认全权限",
|
||
"patch.8": "审批默认 never",
|
||
"patch.9": "审批门自动放行",
|
||
"patch.10": "审批配置默认 never",
|
||
"patch.11": "never 提示句 → 自动放行",
|
||
"patch.12": "ask 提示句 → 自动放行",
|
||
"patch.13": "豁免升级阶梯",
|
||
"patch.14": "升级无条件授信",
|
||
"patch.15": "沙箱 confine 直通",
|
||
"patch.16": "文件系统围栏取消",
|
||
"patch.17": "观察策略读写放行",
|
||
"patch.18": "重复调用守卫禁用",
|
||
"patch.19": "工具结果修剪禁用",
|
||
"patch.21": "恢复插件延长的 bash 等待",
|
||
"patch.22": "read 上限放宽",
|
||
"patch.23": "子代理深度 3 → 10",
|
||
"patch.24": "preset fetch 启用",
|
||
"patch.25": "去掉官方默认身份(功能提示词不动)",
|
||
"patch.26": "minimal:去掉默认身份并打开 inject",
|
||
"patch.28": "dsh-liangshen(梁神):phase-1 保留 dsh-purge / dshpg 段",
|
||
"patch.70": "极简模式终端不再禁止网络",
|
||
"patch.71": "梁神去掉官方身份句",
|
||
"patch.29": "dsh-tool-web:外部 untrusted 拦截→可执行",
|
||
"patch.30": "dsh-hooks-claude-code:deny→allow",
|
||
"patch.31": "dsh-hooks-codex:deny→allow",
|
||
"patch.32": "Web 表面身份中性化",
|
||
"patch.33": "Shell 拒绝提示 → 可执行",
|
||
"patch.34": "沙箱提示中性化",
|
||
"patch.35": "升级 never 不拒",
|
||
"patch.36": "文件系统升级 schema 放行",
|
||
"patch.37": "子代理 scope lock 中性化",
|
||
"patch.38": "persona text→prefix(0.1.2 预设)",
|
||
"patch.39": "会话 v0 plugin summary(mnemon)",
|
||
"patch.40": "complete 预设仍保留注入",
|
||
"patch.42": "设置页旧接口",
|
||
"patch.43": "网页预设去掉默认身份",
|
||
"patch.45": "官方桌面更新交接",
|
||
"patch.46": "官方更新脚本只用英文",
|
||
"patch.47": "OAuth 不再插入 Claude 身份",
|
||
"patch.48": "shell 去掉删除前的额外确认",
|
||
"patch.49": "OAuth 不再伪装 claude-cli",
|
||
"patch.50": "跨会话引用不再标不可信",
|
||
"patch.51": "新会话保持完全权限",
|
||
"patch.52": "会话日志默认不上传",
|
||
"patch.53": "网页提示去掉禁止另开服务器",
|
||
"patch.54": "交付物提示去掉多余禁止",
|
||
"patch.55": "每一步替换第一条系统提示",
|
||
"patch.56": "回退只藏被撤掉的那一轮",
|
||
"patch.57": "developer role 仅限 openai/azure,第三方代理回退 system",
|
||
"patch.58": "回退空标记不进模型请求",
|
||
"patch.59": "官方 Auto Review 不再拦工具",
|
||
"patch.60": "官方 run_code 去掉审批教学",
|
||
"patch.61": "官方沙箱策略不再教拒绝",
|
||
"patch.62": "无审批通道时放行(用户拒绝仍有效)",
|
||
"patch.63": "计划模式不再软拦执行",
|
||
"patch.64": "Stop hook 不再注入假用户话",
|
||
"patch.65": "UserPromptSubmit deny 不再整轮 reject",
|
||
"patch.66": "空的系统段不再把这一轮打成内部错误",
|
||
"patch.67": "缺 messages 时这一轮不中断",
|
||
"patch.68": "回退后缺 content 不中断",
|
||
"patch.69": "deepseek-flash 保持官方 in-history",
|
||
"patch.72": "Messages 的 system 用当前系统提示",
|
||
"patch.73": "极简 bash 去掉误留的镜像说明",
|
||
"patch.74": "启动/应用把默认提示词写入 prompt-inject.md",
|
||
"patch.75": "回退把已完成任务收回成未开始",
|
||
"patch.76": "unrestricted 预设去掉身份句",
|
||
"patch.77": "回退标记到达时丢弃已发送消息",
|
||
"patch.78": "补丁文件缺失时从 .bak 恢复",
|
||
"patch.80": "恢复官方极简工具表",
|
||
"patch.82": "主进程兜底 DSH_PROFILE=desktop",
|
||
"patch.83": "host 信号杀时不报错不弹对话框",
|
||
"patch.84": "host code=0 正常退出不报错 (消除 stopped 对话框)",
|
||
"patch.85": "prompt() 的 steer 仍走插话,不压成 followup",
|
||
"patch.86": "队列插话走 steer;公共 steer() 保持下一步",
|
||
"patch.87": "首次 assemble 用 prepare 返回的当前模式 systemPrompt",
|
||
"patch.88": "去掉 renderPrompt 后的挂针,注入只走 assemble",
|
||
"status.unlocated": "未定位",
|
||
"status.na": "本端不需要",
|
||
"metric.unlocated": "还没定位到本机 Harness",
|
||
"metric.sealed": "已找到官方客户端,点应用解开",
|
||
"metric.sealed.count": "待解开",
|
||
"metric.lost": "补丁已丢失,请重新应用",
|
||
"metric.lost.count": "已丢失",
|
||
"rewind.label": "回退",
|
||
"rewind.aria": "回退",
|
||
"rewind.busy": "回退中…",
|
||
"rewind.empty": "没有可回退的上一句",
|
||
"rewind.fail": "回退失败: {error}",
|
||
"rewind.once": "回退上一步",
|
||
"rewind.once.hint": "撤回最后一步已进行的对话,新会话接上之前的上下文;那句回到输入框再发送",
|
||
"rewind.round": "回退上一轮",
|
||
"rewind.round.hint": "撤回上一整轮(含子代理)已进行的对话,新会话接上之前的上下文;那句回到输入框再发送",
|
||
"hooksDeny.title": "Hooks 拦截",
|
||
"hooksDeny.bypass": "放行 hooks 的 deny / ask(补丁 30 / 31 / 64 / 65)",
|
||
"hooksDeny.hint": "关掉后,用户自己的 PreToolUse / UserPromptSubmit / Stop hook 的 deny 会按官方逻辑生效。官方 ask 分支本身是空操作,关开关不会弹出审批框。改完后点「应用」并重启才写进宿主。",
|
||
"saved.hooksDeny": "已保存",
|
||
"continue.title": "失败重试 / 继续",
|
||
"continue.hint": "请求失败会自动重试;异常停止或中断可点「继续」或自动续跑。自己点停止不会自动继续。次数用完后需新开一轮。",
|
||
"continue.autoRetry": "失败自动重试",
|
||
"continue.retryMax": "重试次数",
|
||
"continue.autoContinue": "中断后自动继续",
|
||
"continue.continueMax": "继续次数",
|
||
"continue.text": "继续用语",
|
||
"saved.continue": "已保存",
|
||
"continue.label": "继续",
|
||
"continue.aria": "异常停止后继续",
|
||
"continue.busy": "继续中…",
|
||
"continue.ready": "继续(还可 {left} 次)",
|
||
"continue.limit": "已达继续上限",
|
||
"continue.fail": "继续失败: {error}",
|
||
};
|
||
|
||
const en = {
|
||
nav: "Rules",
|
||
"dock.clean": "Clean",
|
||
"dock.drill": "Drill",
|
||
"dock.toggle": "dsh-purge",
|
||
"dock.newSession": "dsh-purge",
|
||
"dock.inSession": "dsh-purge",
|
||
"dock.redteam": "Red team",
|
||
"dock.full": "Full view",
|
||
"dock.close": "Collapse",
|
||
"dock.unauthorized": "Unauthorized",
|
||
"welcome.title": "User notice",
|
||
"welcome.lead": "Please read this on first install. After you confirm, it will not appear again.",
|
||
"welcome.warn": "This plugin is free and open source. Anyone who sells, resells, or profits from it behind the author's back may face legal action.",
|
||
"welcome.body": "dsh-purge is a local DeepSeek Harness plugin for security evaluation and cleaning: adjust prompts and patches on a machine you control, and use the drill console only within authorized scope. It is free and non-profit. Repository:",
|
||
"welcome.repo": "https://github.com/YuJunZhiXue/dsh-purge",
|
||
"welcome.ban1": "Do not use this project for unauthorized attacks, intrusion, data theft, sabotage, or any illegal activity.",
|
||
"welcome.ban2": "Do not commercially sell, resell, or profit from gray-market use, or redistribute for profit without the author.",
|
||
"welcome.ban3": "You must follow local law and platform rules; illegal use is solely your responsibility.",
|
||
"welcome.bans": "Prohibitions",
|
||
"welcome.check": "I have read and understand this notice and will use the plugin lawfully.",
|
||
"welcome.ok": "I understand — continue",
|
||
"auth.title": "Drill console authorization",
|
||
"auth.lead": "Cleaning does not need this step. Opening Drill or Full view the first time requires this dialog.",
|
||
"auth.warn": "Warning: the drill console is only for hosts you manage, offline targets, or written authorized exercises. Unauthorized intrusion, attack, theft, or sabotage is forbidden.",
|
||
"auth.hint.wait": "Read the notice. You can check the boxes in {n}s.",
|
||
"auth.hint.scroll": "Countdown finished. Scroll to the end, then check the boxes.",
|
||
"auth.hint.check": "Countdown finished. Check all three boxes.",
|
||
"auth.check.read": "I have read the Safety and Anti-Abuse Notice and accept every clause.",
|
||
"auth.check.scope": "I will only use authorized local hosts, offline targets, or compliant exercises.",
|
||
"auth.check.ban": "I will not use this for any illegal purpose. Without this authorization the drill console stays closed.",
|
||
"auth.no": "Not now",
|
||
"auth.ok": "Authorize",
|
||
"auth.ok.tab": "Authorize and open Drill",
|
||
"auth.ok.full": "Authorize and open Full view",
|
||
"auth.ok.env": "Authorize and open Env adapt",
|
||
"envGate.title": "Environment is not ready",
|
||
"envGate.lead": "Red team mode needs local tools before you send. If none are available, open Env adapt or confirm you will continue without them.",
|
||
"envGate.kaliHint": "When Kali is detected, local tools are used directly and paths are optional. Otherwise set each tool path in Env adapt, or pick one toolkit folder to assign them.",
|
||
"envGate.skip": "Skip for now",
|
||
"envGate.skipConfirm": "You can keep chatting after this. Scan and exploit skills stay unavailable until the tools are installed. You can add paths later under dsh-purge → Env adapt.",
|
||
"envGate.skipNo": "Back",
|
||
"envGate.skipYes": "Continue without tools",
|
||
"envGate.go": "Open Env adapt",
|
||
"theme.aria": "Appearance",
|
||
"theme.white": "Light",
|
||
"theme.ink": "Ink",
|
||
"purge.title": "Patches",
|
||
"override.title": "Prompt",
|
||
"own.title": "Own servers",
|
||
"own.hint": "One IP or exact hostname per line. account@host is accepted; the account only identifies that host. A hostname also allows the addresses it resolves to. Do not enter a key, a password, or a range.",
|
||
"own.save": "Save list",
|
||
"saved.own": "Own servers saved",
|
||
"saved.own.dropped": "Saved. {n} lines were not a single IP or hostname and were dropped",
|
||
"metric.purged": "Written",
|
||
"metric.purged.skip": "Written. Skipped {n}; those files are not on this install",
|
||
"metric.pendingShort": "Pending {n}",
|
||
"metric.unmatchedShort": "No match {n}",
|
||
"metric.skipShort": "Skipped {n}",
|
||
"metric.unlocated.count": "—",
|
||
"metric.shim": "shim",
|
||
"metric.shim.na": "Not needed here",
|
||
"metric.bak.yes": "Backup",
|
||
"metric.bak.no": "No backup",
|
||
"metric.bak.hint": "Backup",
|
||
"table.patch": "Item",
|
||
"table.status": "Status",
|
||
"status.applied": "Applied",
|
||
"status.off": "Off",
|
||
"status.pending": "Pending",
|
||
"status.unmatched": "No match",
|
||
"status.skipped": "Skipped",
|
||
"apply.hint": "If you did not edit the prompt box, do not save. The default is already used and is written to prompt-inject.md on start/apply. Save only after you change it. Apply writes host patches. Pending = original text still present. Skipped = not needed for this version or the component is not installed.",
|
||
"warn.noRoot": "Could not find this host’s @deepseek-ai tree, so Apply will not patch anything. Fully quit and reopen this host, then Apply here. Desktop may live on any drive; folder names like DeepseekHarnessDesktop or DeepSeek Harness are fine.",
|
||
"warn.noRoot.desktop": "Official Harness keeps its code in app.asar. Apply unpacks it and restarts the app. If this page stays “not found”, fully quit the client, reopen it, then Apply again. Third-party DSH Desktop: just Apply.",
|
||
"unpack.restarting": "Patches are written. Click Restart and the client opens again by itself.",
|
||
"restart.fullQuit": "Restarting. The client will open again by itself.",
|
||
"warn.noInject": "Official mode always uses the default prompt. Red team uses only the active rule set; otherwise it uses the default prompt without the CTF section, then the red team steps. If both are empty you will be asked to add a prompt.",
|
||
"need.prompt": "Both the prompt and the rule set are empty. Add a prompt, or enable a rule that has content.",
|
||
"btn.restoreInject": "Reset default",
|
||
"saved.restoreInject": "Default prompt restored",
|
||
"skip": "Skipped",
|
||
"unknown": "Unknown",
|
||
"delete": "Delete",
|
||
"btn.apply": "Apply",
|
||
"btn.apply.busy": "Working…",
|
||
"btn.revert": "Restore",
|
||
"btn.uninstall": "Uninstall",
|
||
"btn.uninstall.busy": "Uninstalling…",
|
||
"uninstall.title": "Uninstall dsh-purge",
|
||
"uninstall.body": "Uninstall? This restores the original Harness and removes this plugin completely.",
|
||
"uninstall.confirm": "Uninstall",
|
||
"uninstall.cancel": "Cancel",
|
||
"uninstall.applying": "Uninstalling and restoring…",
|
||
"uninstall.done": "Uninstalled and restored. Restarting…",
|
||
"uninstall.done.desktop": "Uninstalled and restored. Restarting the desktop app…",
|
||
"uninstall.fail": "Uninstall failed: {error}",
|
||
"btn.checkUpdate": "Check update",
|
||
"btn.checkUpdate.busy": "Checking…",
|
||
"btn.doUpdate": "Update",
|
||
"btn.doUpdate.busy": "Updating…",
|
||
"channel.title": "Channel",
|
||
"channel.stable": "Stable",
|
||
"channel.beta": "Beta",
|
||
"channel.useStable": "Switch to stable",
|
||
"channel.useBeta": "Switch to beta",
|
||
"channel.local": "This copy",
|
||
"update.checking": "Checking for updates…",
|
||
"update.applying": "Updating…",
|
||
"update.switching": "Switching version…",
|
||
"update.latest": "Up to date ({version})",
|
||
"update.available": "Update {remote} available (now {local})",
|
||
"update.pinned": "Pinned at {version}; channel latest is {remote}",
|
||
"update.done": "Updated to {version}.",
|
||
"update.restarting": "Restored the old patches, wrote the new ones, and restarting…",
|
||
"update.fullQuit": "Patches are written. Click Restart and the client opens again by itself.",
|
||
"restart.official.title": "Restart required",
|
||
"restart.official.body": "Patches are written. Click Restart and the client opens again by itself.",
|
||
"update.switched": "Switched to {version}.",
|
||
"update.autoDone": "Auto-updated to {version}.",
|
||
"update.reloading": "Refreshing settings…",
|
||
"update.dirty": "Update available; local edits were not overwritten.",
|
||
"update.fail": "Update failed: {error}",
|
||
"update.timeout": "Check timed out. Click Check update again.",
|
||
"update.needRestart": "Update API is not loaded. Restart dsh, then check again.",
|
||
"update.noBeta": "No beta build yet",
|
||
"update.switch": "Switch to this version",
|
||
"update.pick": "Select version",
|
||
"update.tip": "latest",
|
||
"update.current": "current",
|
||
"update.confirmSwitch": "Switch to {version}? You can switch back later. Uncommitted plugin edits will not be kept.",
|
||
"update.confirmChannel": "Switch to {version}? The plugin will be reinstalled from that channel. Uncommitted plugin edits will not be kept.",
|
||
"metric.version": "Version",
|
||
"action.apply": "Apply",
|
||
"action.revert": "Restore",
|
||
"action.uninstall": "Uninstall",
|
||
"ok.done": "Done",
|
||
"err.action": "Failed: {error}",
|
||
"err.status": "Read failed: {error}",
|
||
"err.override": "Read failed: {error}",
|
||
"err.rules": "Read failed: {error}",
|
||
"err.read": "Read failed: {error}",
|
||
"err.save": "Save failed: {error}",
|
||
"btn.saveInject": "Save",
|
||
"saved.override": "Saved",
|
||
"rules.title": "Rule sets",
|
||
"rules.none": "None",
|
||
"rules.reset": "Restore",
|
||
"rules.reset.confirm": "Restore global instruction files? The rule library is kept.",
|
||
"rules.empty": "No rules",
|
||
"rules.pick": "Select a rule",
|
||
"policy.title": "Workspace policy",
|
||
"policy.strict": "Strict",
|
||
"policy.fallback": "Fallback",
|
||
"policy.always": "Always on",
|
||
"policy.hint": "Strict: always strip AGENTS.md / CLAUDE.md. Fallback: use workspace files only when the prompt box is empty. Always on: keep DSH default behavior.",
|
||
"cwd.title": "Local rules (current folder)",
|
||
"cwd.empty": "No AGENTS.md / CLAUDE.md in this folder",
|
||
"cwd.rescan": "Rescan",
|
||
"cwd.hint": "Checked files are injected only when the prompt box is empty. A non-empty prompt box always wins.",
|
||
"cwd.scope.cwd": "cwd",
|
||
"cwd.scope.ancestor": "ancestor",
|
||
"cwd.scope.root": "project root",
|
||
"cwd.no.workspace": "DSH has not opened a workspace yet. Start a task in DSH first, then come back.",
|
||
"pill.current": "On",
|
||
"btn.use": "Enable",
|
||
"btn.inUse": "On",
|
||
"confirm.delete": "Delete {id}?",
|
||
"ph.id": "id",
|
||
"ph.alias": "Name",
|
||
"ph.alias.short": "Name",
|
||
"btn.create": "New",
|
||
"need.id": "id required",
|
||
"ph.content": "",
|
||
"btn.saveRule": "Save",
|
||
"skills.title": "Skills",
|
||
"skills.hint": "After import, DSH calls it: type /name in chat to load now; the model calls the skill tool when the task matches description or whenToUse. Writing「激活」in the body does nothing.",
|
||
"skills.hint.desktop": "This desktop host’s official skill catalog. Type /name in chat to load; the model calls the skill tool when the task matches the description.",
|
||
"skills.empty": "No user skills",
|
||
"btn.importZip": "Import zip",
|
||
"btn.importFolder": "Import folder",
|
||
"skills.importing": "Importing…",
|
||
"saved.import": "Imported {count} skill(s)",
|
||
"need.import": "Choose a zip or folder",
|
||
"err.import.folder": "No files were read. Select the skill folder itself (it must contain SKILL.md).",
|
||
"skills.pick": "Select a skill",
|
||
"skills.invalid": "Official catalog will ignore this: invalid format",
|
||
"skills.call.slash": "Type {cmd} in chat",
|
||
"skills.call.model": "Model can load",
|
||
"skills.call.modelOff": "Hidden from model",
|
||
"skills.call.userOff": "/name disabled",
|
||
"ph.skill.desc": "description (when to use)",
|
||
"btn.saveSkill": "Save",
|
||
"need.skill.id": "id must be kebab-case, e.g. code-review",
|
||
"confirm.delete.skill": "Delete {id} from official $DSH_HOME/skills? The host will drop it from the catalog.",
|
||
"err.skills": "Read failed: {error}",
|
||
"err.skills.needRestart": "Skill API is not loaded. Restart this host first.",
|
||
"btn.restart": "Restart",
|
||
"btn.restart.busy": "Restarting…",
|
||
"restarting": "Restarting…",
|
||
"restarting.desktop": "Restarting the desktop app…",
|
||
"restart.timeout": "Timed out; refresh",
|
||
"restart.fail": "Failed: {error}",
|
||
"restart.notStarted": "Restart did not start. This client is still open, and it will not try again by itself.",
|
||
"restart.already": "This restart already started. It will not restart again.",
|
||
"restart.confirm": "Apply finished. Restart to take effect.",
|
||
"restart.confirm.desktop": "Apply finished. Restart the desktop app to take effect.",
|
||
"restart.ask.title": "Restart now?",
|
||
"restart.ask.body": "It is written. It takes effect in this client after a restart.",
|
||
"restart.now": "Restart now",
|
||
"restart.notNow": "Later",
|
||
"restart.asarSwap": "Patches are on disk. Quitting the desktop app and moving app.asar aside; it will reopen when done. If behavior is unchanged, fully quit Harness (including tray) and Apply again.",
|
||
"surface.web": "Web",
|
||
"surface.desktop": "Desktop",
|
||
"surface.gui": "GUI",
|
||
"surface.tui": "TUI",
|
||
"restart.incomplete": "The prompt is in. {detail}",
|
||
"restart.incomplete.patches": "{failed} code patch(es) did not match this host (#{ids}). The prompt is unaffected",
|
||
"restart.incomplete.flash": "CMD silence not pinned ({entry})",
|
||
"restart.later": "Later",
|
||
"group.prompt": "Prompt",
|
||
"group.code": "Code",
|
||
"group.engine": "Engine",
|
||
"group.tools": "Tools",
|
||
"group.compat": "0.1.5 compat",
|
||
"group.dsh20": "0.2.0",
|
||
"patch.1": "Global disclaimer → mandate",
|
||
"patch.2": "Replacement disclaimer → mandate",
|
||
"patch.3": "Scope disclaimer → mandate",
|
||
"patch.4": "Persona session mandate",
|
||
"patch.5": "Approval copy → full access",
|
||
"patch.6": "Sandbox deny → allow write",
|
||
"patch.7": "Sandbox default full access",
|
||
"patch.8": "Approval default never",
|
||
"patch.9": "Approval gate auto-grant",
|
||
"patch.10": "Approval config default never",
|
||
"patch.11": "never copy → auto-grant",
|
||
"patch.12": "ask copy → auto-grant",
|
||
"patch.13": "Escalation ladder exempt",
|
||
"patch.14": "Escalation unconditional grant",
|
||
"patch.15": "Sandbox confine passthrough",
|
||
"patch.16": "Filesystem fence off",
|
||
"patch.17": "Observation policy allow R/W",
|
||
"patch.18": "Repeat-call guard off",
|
||
"patch.19": "Tool-result pruner off",
|
||
"patch.21": "Restore plugin-raised bash wait",
|
||
"patch.22": "read cap raised",
|
||
"patch.23": "subagent depth 3 → 10",
|
||
"patch.24": "preset fetch on",
|
||
"patch.25": "Strip official default identity (keep feature prompts)",
|
||
"patch.26": "minimal: strip identity, open inject",
|
||
"patch.28": "dsh-liangshen: keep dsh-purge / dshpg in phase-1",
|
||
"patch.70": "Minimal shell drops the no-network line",
|
||
"patch.71": "Liangshen drops the stock identity line",
|
||
"patch.29": "dsh-tool-web: untrusted framing → usable data",
|
||
"patch.30": "dsh-hooks-claude-code: deny→allow",
|
||
"patch.31": "dsh-hooks-codex: deny→allow",
|
||
"patch.32": "Web surface identity neutral",
|
||
"patch.33": "Shell denial prompt → executable",
|
||
"patch.34": "Sandbox hint neutralized",
|
||
"patch.35": "Escalation never-reject",
|
||
"patch.36": "FS escalation schema opened",
|
||
"patch.37": "Subagent scope lock neutralized",
|
||
"patch.38": "persona text→prefix (0.1.2 presets)",
|
||
"patch.39": "session v0 plugin summary (mnemon)",
|
||
"patch.40": "Keep inject when a complete prompt is set",
|
||
"patch.42": "Settings legacy API",
|
||
"patch.43": "Strip identity from web presets",
|
||
"patch.45": "Official desktop update handoff",
|
||
"patch.46": "Official update script stays ASCII",
|
||
"patch.47": "OAuth no longer inserts Claude identity",
|
||
"patch.48": "Shell drops extra delete caution",
|
||
"patch.49": "OAuth no longer mimics claude-cli",
|
||
"patch.50": "Session references are not marked untrusted",
|
||
"patch.51": "Fresh session stays on full access",
|
||
"patch.52": "Session log upload off by default",
|
||
"patch.53": "Web prompt drops do-not-start",
|
||
"patch.54": "Deliverable prompt drops extra prohibition",
|
||
"patch.55": "Replace the first system prompt every step",
|
||
"patch.56": "Rewind hides only the cut range",
|
||
"patch.57": "developer role for openai/azure only, third-party gateways use system",
|
||
"patch.58": "Rewind markers stay off the model request",
|
||
"patch.59": "Official Auto Review no longer denies tools",
|
||
"patch.60": "Official run_code drops approval teaching",
|
||
"patch.61": "Official sandbox policy no longer teaches refusal",
|
||
"patch.62": "Fail-open without approval channel (user reject kept)",
|
||
"patch.63": "Plan mode no longer soft-blocks execution",
|
||
"patch.64": "Stop hook no longer steers fake user text",
|
||
"patch.65": "UserPromptSubmit deny no longer rejects turn",
|
||
"patch.66": "Empty system section no longer ends the turn as an internal error",
|
||
"patch.67": "A missing messages list no longer ends the turn",
|
||
"patch.68": "Missing content after rewind no longer ends the turn",
|
||
"patch.69": "deepseek-flash keeps the official in-history flag",
|
||
"patch.72": "Messages system field uses the current system prompt",
|
||
"patch.73": "Minimal preset drops stray mirror sentence",
|
||
"patch.74": "Start/apply writes the default prompt to prompt-inject.md",
|
||
"patch.75": "Rewind resets completed todos to pending",
|
||
"patch.76": "Unrestricted preset: strip identity sentence",
|
||
"patch.77": "Rewind marker drops already-sent messages",
|
||
"patch.78": "Restore missing overlay from .bak backup",
|
||
"patch.80": "Restore official minimal tool plugins",
|
||
"patch.82": "Fallback DSH_PROFILE=desktop in main process",
|
||
"patch.83": "Don't fail on signal kill (no crash dialog)",
|
||
"patch.84": "Don't fail on code=0 clean exit (no stopped dialog)",
|
||
"patch.85": "prompt() steer stays interrupt, not followup",
|
||
"patch.86": "Queue steer stays steer; public steer() stays next-step",
|
||
"patch.87": "First assemble uses the systemPrompt prepare returned",
|
||
"patch.88": "Drop the hang after renderPrompt; inject is assemble only",
|
||
"status.na": "Not for this host",
|
||
"metric.sealed": "Official client found. Apply to unpack",
|
||
"metric.sealed.count": "Sealed",
|
||
"metric.lost": "Patches lost — Apply again",
|
||
"metric.lost.count": "Lost",
|
||
"status.unlocated": "Not located",
|
||
"metric.unlocated": "This install was not found yet",
|
||
"rewind.label": "Undo",
|
||
"rewind.aria": "Undo",
|
||
"rewind.busy": "Undoing…",
|
||
"rewind.empty": "Nothing to undo",
|
||
"rewind.fail": "Undo failed: {error}",
|
||
"rewind.once": "Undo last step",
|
||
"rewind.once.hint": "Withdraw the last step into a new session that keeps earlier context; the line returns to the box",
|
||
"rewind.round": "Undo last turn",
|
||
"rewind.round.hint": "Withdraw the last turn (and its subagents) into a new session that keeps earlier context; the line returns to the box",
|
||
"hooksDeny.title": "Hook interception",
|
||
"hooksDeny.bypass": "Bypass hook deny / ask (patches 30 / 31 / 64 / 65)",
|
||
"hooksDeny.hint": "When off, your own PreToolUse / UserPromptSubmit / Stop hook deny runs as the official host wrote it. The official ask branch is a no-op; turning this off will not show an approval dialog. Click Apply and restart to write the host files.",
|
||
"saved.hooksDeny": "Saved",
|
||
"continue.title": "Retry / Continue",
|
||
"continue.hint": "Failed requests auto-retry. After an abnormal stop or interrupt, use Continue or auto-resume. A manual stop never auto-continues. Counts reset after a completed turn.",
|
||
"continue.autoRetry": "Auto-retry on failure",
|
||
"continue.retryMax": "Retry count",
|
||
"continue.autoContinue": "Auto-continue after interrupt",
|
||
"continue.continueMax": "Continue count",
|
||
"continue.text": "Continue text",
|
||
"saved.continue": "Saved",
|
||
"continue.label": "Continue",
|
||
"continue.aria": "Continue after an abnormal stop",
|
||
"continue.busy": "Continuing…",
|
||
"continue.ready": "Continue ({left} left)",
|
||
"continue.limit": "Continue limit reached",
|
||
"continue.fail": "Continue failed: {error}",
|
||
};
|
||
|
||
const PURGE_CSS = `
|
||
.dshp-root{--dshp-display:"Songti SC","Noto Serif SC","Iowan Old Style",Palatino,"Palatino Linotype",Georgia,serif;--dshp-sans:"Yu Gothic UI","Hiragino Sans GB","Source Han Sans SC",system-ui,sans-serif;--dshp-mono:"Cascadia Mono","Sarasa Mono SC",ui-monospace,monospace;--dshp-ease:cubic-bezier(.16,1,.3,1);max-width:920px;display:flex;flex-direction:column;gap:18px;padding:16px;border-radius:12px;background:var(--dshp-bg);color:var(--dshp-ink);font-family:var(--dshp-sans)}
|
||
.dshp-root{--dshp-bg:transparent;--dshp-paper:color-mix(in srgb,var(--dsw-alias-bg-layer-1,#f6f4ef) 74%,transparent);--dshp-ink:var(--dsw-alias-label-primary,#1a1916);--dshp-mute:color-mix(in srgb,var(--dsw-alias-label-primary,#1a1916) 74%,var(--dsw-alias-label-secondary,#5c564c));--dshp-line:color-mix(in srgb,var(--dsw-alias-border-l1,#c8c2b6) 78%,transparent);--dshp-fill:color-mix(in srgb,var(--dsw-alias-brand-primary,#6d8f78) 14%,transparent);--dshp-accent:var(--dsw-alias-brand-primary,#3f6b52);--dshp-accent-soft:color-mix(in srgb,var(--dsw-alias-brand-primary,#6d8f78) 18%,transparent);--dshp-ok:#1f7a45;--dshp-warn:#8a5a12;--dshp-bad:#9a3434}
|
||
.dshp-root[data-theme="white"]{color-scheme:light}
|
||
.dshp-root[data-theme="dusk"]{color-scheme:dark;--dshp-ok:#8fd0a8;--dshp-warn:#e4c48a;--dshp-bad:#f0b4b4}
|
||
.dshp-toolbar{display:flex;align-items:center;justify-content:flex-end;gap:8px}
|
||
.dshp-switch{display:inline-flex;border:1px solid var(--dshp-line);border-radius:999px;overflow:hidden;background:var(--dshp-paper)}
|
||
.dshp-switch button{appearance:none;border:0;background:transparent;color:var(--dshp-mute);font:12px/1 var(--dshp-sans);padding:6px 14px;cursor:pointer}
|
||
.dshp-switch button.is-on{background:var(--dshp-accent-soft);color:var(--dshp-ink)}
|
||
.dshp-switch button:focus-visible{outline:2px solid var(--dshp-accent);outline-offset:-2px}
|
||
.dshp-panel{border:1px solid var(--dshp-line);background:var(--dshp-paper);border-radius:10px;padding:18px 18px 16px}
|
||
.dshp-kicker{font-family:var(--dshp-mono);font-size:11px;letter-spacing:.14em;text-transform:uppercase;color:var(--dshp-mute);margin:0 0 4px}
|
||
.dshp-title{font-family:var(--dshp-display);font-size:20px;font-weight:500;letter-spacing:.02em;line-height:1.3;margin:0;color:var(--dshp-ink)}
|
||
.dshp-head{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:14px;flex-wrap:wrap}
|
||
.dshp-sub{display:flex;align-items:center;justify-content:space-between;gap:8px;margin:20px 0 10px}
|
||
.dshp-sub h4{margin:0;font-family:var(--dshp-display);font-size:15px;font-weight:500}
|
||
.dshp-metrics{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px;margin-bottom:14px}
|
||
.dshp-metric{border:1px solid var(--dshp-line);background:var(--dshp-bg);padding:12px;min-height:68px;display:flex;flex-direction:column;gap:4px;border-radius:8px}
|
||
.dshp-metric b{font-family:var(--dshp-display);font-size:18px;font-weight:500;line-height:1.2}
|
||
.dshp-metric span{font-size:11px;color:var(--dshp-mute)}
|
||
.dshp-bar{height:3px;border-radius:99px;background:var(--dshp-line);overflow:hidden;margin:8px 0 14px}
|
||
.dshp-bar>i{display:block;height:100%;background:var(--dshp-accent);width:0;transition:width .4s var(--dshp-ease)}
|
||
.dshp-group{border:1px solid var(--dshp-line);margin:0 0 8px;background:var(--dshp-bg);border-radius:8px;overflow:hidden}
|
||
.dshp-group-h{width:100%;display:flex;align-items:center;gap:10px;padding:9px 12px;border:0;background:transparent;color:inherit;cursor:pointer;font:inherit;text-align:left}
|
||
.dshp-group-h:hover{background:var(--dshp-fill)}
|
||
.dshp-group-h:focus-visible{outline:2px solid var(--dshp-accent);outline-offset:-2px}
|
||
.dshp-group-h strong{font-family:var(--dshp-display);font-size:14px;font-weight:500}
|
||
.dshp-group-h em{font-style:normal;font-size:12px;color:var(--dshp-mute);flex:1}
|
||
.dshp-count{font-family:var(--dshp-mono);font-size:12px;color:var(--dshp-mute)}
|
||
.dshp-table{width:100%;border-collapse:collapse;font-size:12.5px}
|
||
.dshp-table th{text-align:left;font-weight:500;font-size:11px;letter-spacing:.06em;color:var(--dshp-mute);padding:7px 12px;border-bottom:1px solid var(--dshp-line)}
|
||
.dshp-table td{padding:8px 12px;border-bottom:1px solid var(--dshp-line);vertical-align:middle}
|
||
.dshp-table tr:last-child td{border-bottom:0}
|
||
.dshp-table tr.is-on td{background:var(--dshp-accent-soft)}
|
||
.dshp-id{font-family:var(--dshp-mono);opacity:.75;width:42px}
|
||
.dshp-actions{text-align:right;white-space:nowrap}
|
||
.dshp-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
|
||
.dshp-row+.dshp-row{margin-top:10px}
|
||
.dshp-pill{display:inline-flex;align-items:center;gap:6px;font-family:var(--dshp-mono);font-size:11px;padding:2px 8px;border:1px solid var(--dshp-line);border-radius:99px;line-height:1.6;background:var(--dshp-bg)}
|
||
.dshp-pill::before{content:"";width:6px;height:6px;border-radius:50%;background:currentColor}
|
||
.dshp-pill.is-ok{color:var(--dshp-ok);border-color:color-mix(in srgb,var(--dshp-ok) 30%,var(--dshp-line))}
|
||
.dshp-pill.is-wait{color:var(--dshp-warn);border-color:color-mix(in srgb,var(--dshp-warn) 30%,var(--dshp-line))}
|
||
.dshp-pill.is-miss{color:var(--dshp-mute)}
|
||
.dshp-pill.is-bad{color:var(--dshp-bad)}
|
||
.dshp-btn{appearance:none;font-family:var(--dshp-sans);font-size:13px;font-weight:500;min-height:34px;padding:0 14px;border-radius:8px;border:1px solid transparent;cursor:pointer;transition:background .15s var(--dshp-ease),opacity .15s var(--dshp-ease)}
|
||
.dshp-btn:disabled{opacity:.45;cursor:not-allowed}
|
||
.dshp-btn:focus-visible{outline:2px solid var(--dshp-accent);outline-offset:2px}
|
||
.dshp-btn-primary{background:var(--dshp-accent-soft);color:var(--dshp-ink);border-color:color-mix(in srgb,var(--dshp-accent) 35%,var(--dshp-line))}
|
||
.dshp-btn-primary:hover:not(:disabled){background:color-mix(in srgb,var(--dshp-accent) 22%,var(--dshp-bg))}
|
||
.dshp-btn-ghost{background:transparent;color:var(--dshp-ink);border-color:var(--dshp-line)}
|
||
.dshp-btn-danger{background:transparent;color:var(--dshp-bad);border-color:color-mix(in srgb,var(--dshp-bad) 40%,var(--dshp-line))}
|
||
.dshp-btn-solid-danger{background:color-mix(in srgb,var(--dshp-bad) 16%,var(--dshp-bg));color:var(--dshp-bad);border-color:color-mix(in srgb,var(--dshp-bad) 30%,var(--dshp-line))}
|
||
.dshp-btn-tiny{min-height:28px;padding:0 10px;font-size:12px}
|
||
.dshp-field,.dshp-area{width:100%;box-sizing:border-box;font:13px/1.55 var(--dshp-mono);padding:8px 10px;background:var(--dshp-bg);color:var(--dshp-ink);border:1px solid var(--dshp-line);border-radius:8px}
|
||
.dshp-field{width:auto;min-width:140px}
|
||
.dshp-cr-row{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin:8px 0 0;font-size:13px}
|
||
.dshp-cr-row label{display:inline-flex;align-items:center;gap:6px;color:var(--dshp-ink)}
|
||
.dshp-cr-num{width:64px;min-width:64px;height:28px;padding:0 8px}
|
||
.dshp-cr-text{width:120px;min-width:88px;height:28px;padding:0 8px}
|
||
.dshp-ver{width:100%;min-width:0;max-width:none;height:32px;padding:0 10px;font-size:12px}
|
||
.dshp-rel{margin:0 0 14px}
|
||
.dshp-now{display:flex;align-items:baseline;gap:8px;flex-wrap:wrap;margin:0 0 4px}
|
||
.dshp-now-label{font-family:var(--dshp-mono);font-size:11px;letter-spacing:.12em;text-transform:uppercase;color:var(--dshp-mute)}
|
||
.dshp-now-ver{font-family:var(--dshp-display);font-size:20px;font-weight:500;letter-spacing:.01em}
|
||
.dshp-now-mark{display:inline-flex;align-items:center;margin-left:6px;padding:1px 7px;border-radius:99px;font-family:var(--dshp-mono);font-size:11px;font-style:normal;font-weight:500;line-height:1.5;color:var(--dshp-ok);border:1px solid color-mix(in srgb,var(--dshp-ok) 38%,var(--dshp-line));background:color-mix(in srgb,var(--dshp-ok) 14%,var(--dshp-bg))}
|
||
.dshp-editions{display:flex;flex-direction:column;margin:10px 0 0;border-top:1px solid var(--dshp-line)}
|
||
.dshp-edition{display:grid;grid-template-columns:auto minmax(0,1fr) auto;align-items:center;gap:10px;padding:10px 0;border-bottom:1px solid var(--dshp-line)}
|
||
.dshp-edition-name{display:inline-flex;align-items:center;gap:0;font-family:var(--dshp-display);font-size:15px;font-weight:500;white-space:nowrap}
|
||
.dshp-edition.is-on .dshp-edition-name{color:var(--dshp-ink)}
|
||
.dshp-edition:not(.is-on) .dshp-edition-name{color:var(--dshp-mute)}
|
||
.dshp-edition .dshp-btn{min-width:118px}
|
||
.dshp-rel .dshp-notice{display:block;margin-top:8px;min-height:16px}
|
||
@media (max-width:560px){.dshp-edition{grid-template-columns:1fr auto;grid-template-areas:"name btn" "sel sel"}.dshp-edition-name{grid-area:name}.dshp-edition .dshp-ver{grid-area:sel}.dshp-edition .dshp-btn{grid-area:btn}}
|
||
.dshp-area{min-height:220px;resize:vertical}
|
||
.dshp-field:focus,.dshp-area:focus{outline:none;border-color:var(--dshp-accent);box-shadow:0 0 0 3px var(--dshp-accent-soft)}
|
||
.dshp-field:disabled,.dshp-area:disabled{opacity:.5}
|
||
.dshp-ask{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-top:10px;padding:10px 12px;border:1px solid var(--dshp-line);border-radius:8px;background:var(--dshp-bg);font-size:13px}
|
||
.dshp-modal-bg{position:fixed;inset:0;z-index:80;display:flex;align-items:center;justify-content:center;padding:20px;background:color-mix(in srgb,var(--dshp-ink) 28%,transparent)}
|
||
.dshp-modal{width:min(420px,100%);background:var(--dshp-paper);color:var(--dshp-ink);border:1px solid var(--dshp-line);border-radius:12px;padding:20px 20px 16px;box-shadow:0 16px 40px color-mix(in srgb,var(--dshp-ink) 18%,transparent)}
|
||
.dshp-modal h4{margin:0 0 8px;font-family:var(--dshp-display);font-size:18px;font-weight:500}
|
||
.dshp-modal p{margin:0 0 16px;font-size:13.5px;line-height:1.55}
|
||
.dshp-modal-ops{display:flex;justify-content:flex-end;gap:8px}
|
||
.dshp-restart-mask{position:fixed;inset:0;z-index:12000;display:flex;align-items:center;justify-content:center;padding:24px;background:rgba(0,0,0,.62);font-family:"Yu Gothic UI","Hiragino Sans GB","Source Han Sans SC","Microsoft YaHei UI",system-ui,sans-serif}
|
||
.dshp-restart-card{width:min(420px,100%);box-sizing:border-box;padding:22px 22px 18px;border-radius:14px;background:var(--dsw-alias-bg-layer-1,#fffcf7);color:var(--dsw-alias-label-primary,#16140f);border:1px solid var(--dsw-alias-border-l1,#c9c3b6);box-shadow:0 18px 50px rgba(0,0,0,.35)}
|
||
.dshp-restart-card h4{margin:0 0 10px;font-family:inherit;font-size:20px;font-weight:650;line-height:1.35;color:inherit}
|
||
.dshp-restart-card p{margin:0 0 18px;font-size:16px;font-weight:500;line-height:1.65;color:inherit}
|
||
.dshp-restart-card .dshp-modal-ops{display:flex;justify-content:flex-end;gap:10px}
|
||
.dshp-restart-card .dshp-btn{min-height:38px;padding:0 16px;font-size:15px;font-weight:650;color:var(--dsw-alias-label-primary,#16140f);background:var(--dsw-alias-bg-layer-2,#fff);border:1px solid var(--dsw-alias-border-l1,#c9c3b6)}
|
||
.dshp-restart-card .dshp-btn-primary,.dshp-restart-card .dshp-btn-primary:hover:not(:disabled){color:#fff;background:#2f6b4f;border-color:#2f6b4f}
|
||
body[data-ds-dark-theme] .dshp-restart-card,.dshp-restart-mask[data-theme="dusk"] .dshp-restart-card{background:#1c1e24;color:#f4f1ea;border-color:#5a5e68}
|
||
body[data-ds-dark-theme] .dshp-restart-card .dshp-btn,.dshp-restart-mask[data-theme="dusk"] .dshp-restart-card .dshp-btn{color:#f4f1ea;background:#2a2d34;border-color:#5a5e68}
|
||
body[data-ds-dark-theme] .dshp-restart-card .dshp-btn-primary,body[data-ds-dark-theme] .dshp-restart-card .dshp-btn-primary:hover:not(:disabled),.dshp-restart-mask[data-theme="dusk"] .dshp-restart-card .dshp-btn-primary,.dshp-restart-mask[data-theme="dusk"] .dshp-restart-card .dshp-btn-primary:hover:not(:disabled){color:#fff;background:#3d8f68;border-color:#3d8f68}
|
||
.dshp-notice{font-size:12.5px;line-height:1.4}
|
||
.dshp-notice.is-ok{color:var(--dshp-ok)}
|
||
.dshp-notice.is-bad{color:var(--dshp-bad)}
|
||
.dshp-skel{height:12px;border-radius:6px;background:linear-gradient(90deg,var(--dshp-fill),var(--dshp-line),var(--dshp-fill));background-size:200% 100%;animation:dshp-pulse 1.2s ease-in-out infinite}
|
||
.dshp-active{display:flex;align-items:center;gap:10px;flex-wrap:wrap;padding:10px 12px;margin-bottom:12px;border:1px solid var(--dshp-line);background:var(--dshp-bg);border-radius:8px}
|
||
.dshp-active b{font-family:var(--dshp-display);font-weight:500}
|
||
.dshp-empty{padding:28px 12px;font-size:13px;color:var(--dshp-mute);text-align:center}
|
||
.dshp-split{display:flex;flex-direction:column;gap:12px}
|
||
.dshp-rulelist{display:flex;flex-direction:column;border:1px solid var(--dshp-line);border-radius:8px;background:var(--dshp-bg);overflow:hidden}
|
||
.dshp-rulebody{max-height:240px;overflow:auto}
|
||
.dshp-ruleitem{display:flex;align-items:center;gap:12px;width:100%;padding:10px 12px;border:0;border-bottom:1px solid var(--dshp-line);background:transparent;color:inherit;font:inherit;text-align:left;cursor:pointer}
|
||
.dshp-ruleitem:hover{background:var(--dshp-fill)}
|
||
.dshp-ruleitem.is-edit{background:var(--dshp-accent-soft)}
|
||
.dshp-rule-main{flex:1;min-width:0}
|
||
.dshp-rule-name{display:flex;align-items:center;gap:8px;font-family:var(--dshp-display);font-size:14px;line-height:1.35}
|
||
.dshp-rule-meta{display:block;margin-top:3px;font-family:var(--dshp-mono);font-size:11px;color:var(--dshp-mute)}
|
||
.dshp-rule-ops{display:flex;flex-direction:row;align-items:center;gap:6px;flex-shrink:0}
|
||
.dshp-create{display:flex;flex-direction:column;gap:6px;padding:10px 12px;border-top:1px solid var(--dshp-line);background:var(--dshp-paper)}
|
||
.dshp-create-row{display:flex;gap:6px;align-items:center}
|
||
.dshp-create .dshp-field{flex:1;min-width:0;width:auto}
|
||
.dshp-editor{display:flex;flex-direction:column;gap:10px;min-width:0;border:1px solid var(--dshp-line);border-radius:8px;background:var(--dshp-bg);padding:12px;min-height:320px}
|
||
.dshp-editor .dshp-area{flex:1;min-height:240px}
|
||
.dshp-editor-empty{flex:1;display:flex;align-items:center;justify-content:center;color:var(--dshp-mute);font-size:13px;min-height:160px}
|
||
.dshp-block{padding:14px 0;border-top:1px solid var(--dshp-line)}
|
||
.dshp-block-head{display:flex;align-items:center;gap:10px;margin:0 0 8px}
|
||
.dshp-block-head h4{margin:0;font-family:var(--dshp-display);font-size:14px;font-weight:500;flex:1;color:var(--dshp-ink)}
|
||
.dshp-block-hint{margin:8px 0 0;font-size:12px;line-height:1.55;color:var(--dshp-mute)}
|
||
.dshp-policy-row{display:flex;align-items:center;gap:10px}
|
||
.dshp-policy-row>label{font-size:12px;color:var(--dshp-mute);flex:0 0 auto}
|
||
.dshp-cwd-path{margin:0 0 8px;font-family:var(--dshp-mono);font-size:11px;color:var(--dshp-mute);word-break:break-all;line-height:1.5}
|
||
.dshp-cwd-list{display:flex;flex-direction:column;border:1px solid var(--dshp-line);border-radius:8px;background:var(--dshp-bg);overflow:hidden}
|
||
.dshp-cwd-item{display:flex;align-items:flex-start;gap:10px;padding:10px 12px;border-bottom:1px solid var(--dshp-line);cursor:pointer;margin:0}
|
||
.dshp-cwd-item:last-child{border-bottom:0}
|
||
.dshp-cwd-item:hover{background:var(--dshp-fill)}
|
||
.dshp-cwd-item.is-on{background:var(--dshp-accent-soft)}
|
||
.dshp-cwd-item input{margin:3px 0 0;flex:0 0 auto}
|
||
.dshp-cwd-main{flex:1;min-width:0}
|
||
.dshp-cwd-name{display:flex;align-items:center;gap:8px;flex-wrap:wrap;font-family:var(--dshp-display);font-size:13.5px;line-height:1.35;color:var(--dshp-ink)}
|
||
.dshp-cwd-badge{font-family:var(--dshp-mono);font-size:10px;letter-spacing:.04em;text-transform:uppercase;padding:1px 7px;border-radius:99px;border:1px solid var(--dshp-line);color:var(--dshp-mute);line-height:1.6}
|
||
.dshp-cwd-size{font-family:var(--dshp-mono);font-size:11px;color:var(--dshp-mute)}
|
||
.dshp-cwd-sub{margin-top:3px;font-family:var(--dshp-mono);font-size:11px;color:var(--dshp-mute);word-break:break-all}
|
||
.dshp-cwd-prev{margin-top:4px;font-size:11px;line-height:1.5;color:var(--dshp-mute);max-height:34px;overflow:hidden}
|
||
@keyframes dshp-pulse{0%{background-position:200% 0}100%{background-position:-200% 0}}
|
||
@media (max-width:640px){.dshp-metrics{grid-template-columns:1fr}.dshp-ruleitem{flex-wrap:wrap}.dshp-rule-ops{width:100%;justify-content:flex-end}}
|
||
@media (prefers-reduced-motion:reduce){.dshp-btn,.dshp-bar>i{transition:none}.dshp-skel{animation:none}}
|
||
|
||
.dshp-dock{position:fixed;top:12px;right:12px;z-index:10050;display:flex;flex-direction:column;width:var(--dshp-dock-w,620px);height:var(--dshp-dock-h,72vh);min-width:380px;min-height:320px;max-width:calc(100vw - 16px);max-height:calc(100vh - 16px);box-sizing:border-box;background:color-mix(in srgb,var(--dsw-alias-bg-layer-1,var(--dshp-paper,#1c1c1c)) 52%,transparent);backdrop-filter:blur(22px) saturate(1.35);-webkit-backdrop-filter:blur(22px) saturate(1.35);border:1px solid color-mix(in srgb,var(--dsw-alias-border-l1,var(--dshp-line,#555)) 65%,transparent);border-radius:12px;box-shadow:0 18px 48px color-mix(in srgb,#000 30%,transparent),0 0 0 1px color-mix(in srgb,#fff 6%,transparent);pointer-events:auto;color:var(--dsw-alias-label-primary,var(--dshp-ink,#f2f2f2));overflow:visible}
|
||
.dshp-dock[data-open="0"]{display:none}
|
||
.dshp-launch{position:fixed;right:16px;bottom:16px;z-index:10040;appearance:none;display:inline-flex;align-items:center;gap:6px;height:36px;padding:0 14px;border:1px solid color-mix(in srgb,var(--dsw-alias-brand-primary,#6dbf8c) 55%,transparent);border-radius:999px;background:color-mix(in srgb,var(--dsw-alias-bg-layer-1,#1c1c1c) 72%,transparent);color:var(--dsw-alias-label-primary,#f2f2f2);cursor:pointer;font:13px/1 inherit;box-shadow:0 8px 22px color-mix(in srgb,#000 28%,transparent);pointer-events:auto}
|
||
.dshp-launch:hover{border-color:var(--dsw-alias-brand-primary,#6dbf8c);background:color-mix(in srgb,var(--dsw-alias-brand-primary,#6dbf8c) 18%,transparent)}
|
||
.dshp-launch[data-open="1"],.dshp-launch[data-hide="1"]{display:none}
|
||
.dshp-dock-head{display:flex;align-items:center;gap:8px;padding:10px 12px;border-bottom:1px solid color-mix(in srgb,var(--dsw-alias-border-l1,var(--dshp-line,#555)) 55%,transparent);background:transparent;cursor:grab;user-select:none;touch-action:none;color:var(--dsw-alias-label-primary,var(--dshp-ink,#f2f2f2));border-radius:12px 12px 0 0}
|
||
.dshp-dock-head:active{cursor:grabbing}
|
||
.dshp-dock-head b{font-size:14px;font-weight:600;color:inherit;text-shadow:0 1px 0 color-mix(in srgb,#000 18%,transparent)}
|
||
.dshp-dock-head button{cursor:pointer;color:inherit;background:color-mix(in srgb,var(--dsw-alias-bg-layer-2,rgba(127,127,127,.18)) 55%,transparent)}
|
||
.dshp-dock-tabs{display:flex;gap:4px;padding:0 12px;border-bottom:1px solid color-mix(in srgb,var(--dsw-alias-border-l1,var(--dshp-line,#555)) 55%,transparent);background:transparent}
|
||
.dshp-dock-tab{appearance:none;border:0;border-bottom:2px solid transparent;background:transparent;color:var(--dsw-alias-label-secondary,var(--dshp-mute,#b8b4ac));border-radius:0;padding:10px 14px 8px;margin-bottom:-1px;cursor:pointer;font:13px/1.2 inherit;text-shadow:0 1px 0 color-mix(in srgb,#000 12%,transparent)}
|
||
.dshp-dock-tab:hover{color:var(--dsw-alias-label-primary,var(--dshp-ink,#f2f2f2))}
|
||
.dshp-dock-tab.on{color:var(--dsw-alias-label-primary,var(--dshp-ink,#f2f2f2));font-weight:600;border-bottom-color:var(--dsw-alias-brand-primary,#6dbf8c);background:transparent}
|
||
.dshp-dock-body{flex:1;min-height:0;overflow:hidden;display:flex;flex-direction:column;background:transparent;border-radius:0 0 12px 12px}
|
||
.dshp-dock-body .dshp-root{max-width:none;height:100%;flex:1;min-height:0;overflow:auto;background:transparent!important;--dshp-bg:transparent;--dshp-paper:color-mix(in srgb,var(--dshp-ink) 7%,transparent);--dshp-fill:color-mix(in srgb,var(--dshp-ink) 6%,transparent);--dshp-line:color-mix(in srgb,var(--dshp-ink) 20%,transparent);--dshp-accent-soft:color-mix(in srgb,var(--dshp-accent) 22%,transparent)}
|
||
.dshp-dock-body .dshp-panel,.dshp-dock-body .dshp-metric,.dshp-dock-body .dshp-group,.dshp-dock-body .dshp-editor,.dshp-dock-body .dshp-active,.dshp-dock-body .dshp-ask,.dshp-dock-body .dshp-create,.dshp-dock-body .dshp-rulelist,.dshp-dock-body .dshp-table,.dshp-dock-body .dshp-ruleitem,.dshp-dock-body .dshp-cwd-list,.dshp-dock-body .dshp-cwd-item{background:color-mix(in srgb,var(--dsw-alias-bg-layer-1,#f7f4ee) 74%,transparent)!important;border-color:color-mix(in srgb,var(--dsw-alias-brand-primary,#6d8f78) 32%,transparent)!important;color:var(--dshp-ink)!important;box-shadow:inset 0 1px 0 color-mix(in srgb,#fff 32%,transparent);backdrop-filter:blur(16px) saturate(1.3);-webkit-backdrop-filter:blur(16px) saturate(1.3)}
|
||
.dshp-dock-body .dshp-field,.dshp-dock-body .dshp-area{background:color-mix(in srgb,var(--dsw-alias-bg-layer-1,#fff) 88%,transparent)!important;color:var(--dshp-ink)!important;border-color:color-mix(in srgb,var(--dsw-alias-border-l1,#8a8478) 72%,transparent)!important}
|
||
.dshp-dock-body select.dshp-field,.dshp-dock-body .dshp-field.dshp-ver{background:var(--dsw-alias-bg-layer-1,#fff)!important;color:var(--dshp-ink)!important;color-scheme:inherit}
|
||
.dshp-dock[data-theme="white"] select.dshp-field option,.dshp-dock[data-theme="white"] .dshp-field.dshp-ver option{background:#fff;color:#16140f}
|
||
.dshp-dock[data-theme="dusk"] select.dshp-field option,.dshp-dock[data-theme="dusk"] .dshp-field.dshp-ver option{background:#1b1d22;color:#f4f1ea}
|
||
.dshp-dock-body .dshp-title,.dshp-dock-body .dshp-sub h4,.dshp-dock-body .dshp-group-h strong,.dshp-dock-body .dshp-metric b,.dshp-dock-body .dshp-rule-name{font-family:inherit;font-weight:650;letter-spacing:0;color:var(--dshp-ink)!important;text-shadow:none}
|
||
.dshp-dock-body .dshp-mute,.dshp-dock-body .dshp-kicker,.dshp-dock-body .dshp-metric span,.dshp-dock-body .dshp-group-h em,.dshp-dock-body .dshp-count,.dshp-dock-body .dshp-rule-meta,.dshp-dock-body .dshp-hint{color:var(--dshp-mute)!important;font-weight:500;text-shadow:none}
|
||
.dshp-dock-body .rt-dock{position:relative;inset:auto;width:100%!important;height:100%;max-width:none!important;flex:1;min-height:0;min-width:0;display:flex!important;flex-direction:column;box-shadow:none;border:0;transform:none!important;opacity:1!important;pointer-events:auto!important;background:transparent!important}
|
||
.dshp-dock-body .rt-grip{display:none!important}
|
||
.dshp-dock-body .rt-embedded>.rt-head,.dshp-dock-body .rt-tabs,.dshp-dock-body .rt-foot{flex:none}
|
||
.dshp-dock-body .rt-body{flex:1;min-height:0;overflow:hidden!important;background:transparent!important}
|
||
.dshp-dock-body .rt-main{min-height:0;flex:1;overflow:hidden}
|
||
.dshp-dock-body .rt-pane{min-height:0;overflow:auto!important}
|
||
.dshp-dock-body .rt-embedded>.rt-head{padding:8px 12px}
|
||
.dshp-dock-body .rt-embedded>.rt-head .rt-title{font-size:14px;letter-spacing:0}
|
||
.dshp-dock-body .rt-tabs{gap:2px;padding:6px 10px 0;flex-wrap:wrap}
|
||
.dshp-dock-body .rt-tab{border-radius:0;border-bottom:2px solid transparent;margin-bottom:-1px;padding:8px 9px 7px;background:transparent;font-size:14px}
|
||
.dshp-dock-body .rt-tab.on{border-bottom-color:var(--dsw-alias-brand-primary,#6d8f78);background:transparent;font-weight:650}
|
||
.dshp-dock-body .rt-row:hover,.dshp-dock-body .rt-item:hover,.dshp-dock-body .rt-item.on,.dshp-dock-body .rt-seg:hover,.dshp-dock-body .rt-seg.on,.dshp-dock-body .rt-vrow:hover,.dshp-dock-body .rt-score-row:hover{background:var(--rt-surf-2)!important}
|
||
body:not([data-ds-dark-theme]) .dshp-auth-modal{background:color-mix(in srgb,var(--dsw-alias-bg-layer-1,#fffcf7) 92%,transparent);color:var(--dsw-alias-label-primary,#1a1916);border-color:color-mix(in srgb,var(--dsw-alias-border-l1,#c9c3b6) 80%,transparent);backdrop-filter:blur(18px) saturate(1.3);-webkit-backdrop-filter:blur(18px) saturate(1.3)}
|
||
body:not([data-ds-dark-theme]) .dshp-auth-legal{background:color-mix(in srgb,var(--dsw-alias-bg-layer-2,#f3f0e8) 90%,transparent);color:var(--dsw-alias-label-primary,#1a1916);border-color:color-mix(in srgb,var(--dsw-alias-border-l1,#c9c3b6) 80%,transparent)}
|
||
body:not([data-ds-dark-theme]) .dshp-auth-warn{background:color-mix(in srgb,#f5e6c8 88%,transparent);color:#6b4a10}
|
||
body:not([data-ds-dark-theme]) .dshp-auth-ops button{border-color:var(--dsw-alias-border-l1,#c9c3b6);color:var(--dsw-alias-label-primary,#1a1916);background:color-mix(in srgb,var(--dsw-alias-bg-layer-1,#fff) 88%,transparent)}
|
||
body:not([data-ds-dark-theme]) .dshp-auth-ops button.primary{background:var(--dsw-alias-brand-primary,#3f6b52);border-color:var(--dsw-alias-brand-primary,#3f6b52);color:#fff}
|
||
.dshp-dock-resize,.dshp-dock-resize-l,.dshp-dock-resize-r,.dshp-dock-resize-b{position:absolute;z-index:60;pointer-events:auto;touch-action:none}
|
||
.dshp-dock-resize{right:0;bottom:0;width:22px;height:22px;cursor:nwse-resize;background:linear-gradient(135deg,transparent 46%,color-mix(in srgb,var(--dsw-alias-brand-primary,#6dbf8c) 75%,transparent) 46%);border-radius:0 0 12px 0;opacity:.95}
|
||
.dshp-dock-resize-l{left:-2px;top:0;bottom:0;width:10px;cursor:ew-resize;background:color-mix(in srgb,var(--dsw-alias-brand-primary,#6dbf8c) 18%,transparent)}
|
||
.dshp-dock-resize-l:hover,.dshp-dock-resize-r:hover{background:color-mix(in srgb,var(--dsw-alias-brand-primary,#6dbf8c) 42%,transparent)}
|
||
.dshp-dock-resize-r{right:-2px;top:0;bottom:22px;width:10px;cursor:ew-resize;background:transparent}
|
||
.dshp-dock-resize-b{left:10px;right:22px;bottom:-2px;height:10px;cursor:ns-resize;background:transparent}
|
||
.dshp-dock-resize-b:hover{background:color-mix(in srgb,var(--dsw-alias-brand-primary,#6dbf8c) 35%,transparent)}
|
||
.dshp-auth-mask{position:fixed;inset:0;z-index:10100;background:rgba(0,0,0,.62);display:flex;align-items:center;justify-content:center;padding:20px;pointer-events:auto}
|
||
.dshp-env-gate{z-index:10200}
|
||
.dshp-auth-modal{width:min(680px,100%);max-height:min(88vh,820px);display:flex;flex-direction:column;gap:10px;padding:16px;border:1px solid var(--dsw-alias-border-l1,#333);border-radius:12px;background:var(--dsw-alias-bg-layer-1,#1a1a1a);color:var(--dsw-alias-label-primary,#eee)}
|
||
.dshp-auth-modal h2{margin:0;font-size:16px;font-weight:600}
|
||
.dshp-auth-warn{padding:8px 10px;border-radius:8px;background:#3a2e18;color:#f0d48a;font-size:13px}
|
||
.dshp-auth-legal{overflow:auto;height:300px;margin:0;padding:10px 12px;border:1px solid var(--dsw-alias-border-l1,#333);border-radius:8px;background:#121212;font-size:13px;line-height:1.55}
|
||
.dshp-auth-legal h3{margin:14px 0 4px;font-size:13px}
|
||
.dshp-auth-legal h3:first-child{margin-top:0}
|
||
.dshp-auth-legal p{margin:0 0 8px}
|
||
.dshp-auth-checks{display:flex;flex-direction:column;gap:8px;font-size:13px}
|
||
.dshp-auth-checks label{display:flex;gap:8px;align-items:flex-start}
|
||
.dshp-auth-checks label.locked{opacity:.45}
|
||
.dshp-auth-ops{display:flex;justify-content:flex-end;gap:8px}
|
||
.dshp-auth-ops button,.dshp-dock-head button{appearance:none;border:1px solid var(--dsw-alias-border-l1,#444);background:transparent;color:inherit;border-radius:8px;padding:6px 12px;cursor:pointer;font:inherit}
|
||
.dshp-auth-ops button.primary{background:#2a4033;border-color:#8fbf9a}
|
||
.dshp-auth-ops button:disabled{opacity:.45;cursor:default}
|
||
.dshp-icon-btn{appearance:none;display:inline-flex;align-items:center;gap:6px;height:32px;padding:0 10px;border:1px solid color-mix(in srgb,var(--dsw-alias-brand-primary,#6dbf8c) 45%,transparent);border-radius:8px;background:transparent;color:var(--dsw-alias-label-secondary,currentColor);cursor:pointer;font:13px/1 inherit;white-space:nowrap;flex:0 0 auto}
|
||
.dshp-icon-btn:hover{border-color:var(--dsw-alias-brand-primary,#6dbf8c);background:color-mix(in srgb,var(--dsw-alias-brand-primary,#6dbf8c) 12%,transparent);color:var(--dsw-alias-label-primary,currentColor)}
|
||
.dshp-icon-btn.on{border-color:var(--dsw-alias-brand-primary,#6dbf8c);color:var(--dsw-alias-brand-primary,#6dbf8c);background:color-mix(in srgb,var(--dsw-alias-brand-primary,#6dbf8c) 16%,transparent);box-shadow:0 0 0 1px color-mix(in srgb,var(--dsw-alias-brand-primary,#6dbf8c) 28%,transparent)}
|
||
.dshp-hbtn{appearance:none;display:inline-flex;flex:0 0 auto;align-items:center;gap:6px;border:1px solid color-mix(in srgb,var(--dsw-alias-brand-primary,#6dbf8c) 55%,transparent);background:transparent;color:var(--dsw-alias-label-secondary,currentColor);cursor:pointer;font:12px/1 inherit;padding:5px 10px;border-radius:8px;white-space:nowrap}
|
||
.dshp-hbtn:hover{border-color:var(--dsw-alias-brand-primary,#6dbf8c);background:color-mix(in srgb,var(--dsw-alias-brand-primary,#6dbf8c) 12%,transparent);color:var(--dsw-alias-label-primary,currentColor)}
|
||
.dshp-hbtn.on{border-color:var(--dsw-alias-brand-primary,#6dbf8c);color:var(--dsw-alias-brand-primary,#6dbf8c);background:color-mix(in srgb,var(--dsw-alias-brand-primary,#6dbf8c) 18%,transparent);box-shadow:0 0 0 1px color-mix(in srgb,var(--dsw-alias-brand-primary,#6dbf8c) 32%,transparent)}
|
||
.dshp-live-dot{width:7px;height:7px;border-radius:50%;flex:none;background:var(--dsw-alias-brand-primary,#6dbf8c);box-shadow:0 0 0 3px color-mix(in srgb,var(--dsw-alias-brand-primary,#6dbf8c) 28%,transparent)}
|
||
.dshp-hero-chip{display:inline-flex;align-items:center;margin-left:6px;flex:0 0 auto}
|
||
.dshp-hero-chip-btn{appearance:none;display:inline-flex;align-items:center;gap:6px;height:28px;padding:0 12px;border:1px solid color-mix(in srgb,var(--dsw-alias-brand-primary,#6dbf8c) 50%,transparent);border-radius:999px;background:color-mix(in srgb,var(--dsw-alias-brand-primary,#6dbf8c) 10%,transparent);color:var(--dsw-alias-label-primary,currentColor);cursor:pointer;font:12.5px/1 inherit;white-space:nowrap}
|
||
.dshp-hero-chip-btn:hover{border-color:var(--dsw-alias-brand-primary,#6dbf8c);background:color-mix(in srgb,var(--dsw-alias-brand-primary,#6dbf8c) 18%,transparent)}
|
||
.dshp-hero-chip-btn.on{border-color:var(--dsw-alias-brand-primary,#6dbf8c);color:var(--dsw-alias-brand-primary,#6dbf8c);background:color-mix(in srgb,var(--dsw-alias-brand-primary,#6dbf8c) 18%,transparent);box-shadow:0 0 0 1px color-mix(in srgb,var(--dsw-alias-brand-primary,#6dbf8c) 32%,transparent)}
|
||
.dshp-tab-chip{display:inline-flex;align-items:flex-end;flex:0 0 auto}
|
||
.dshp-tab-btn{appearance:none;position:relative;border:none;background:transparent;cursor:pointer;padding:0 0 9px;font:500 13px/16px inherit;color:var(--dsw-alias-label-tertiary,currentColor);white-space:nowrap}
|
||
.dshp-tab-btn:hover{color:var(--dsw-alias-label-primary,currentColor)}
|
||
.dshp-tab-btn.on{color:var(--dsw-alias-brand-primary,#6dbf8c)}
|
||
.dshp-tab-btn.on:after{content:"";position:absolute;left:0;right:0;bottom:-1px;height:2px;border-radius:2px;background:var(--dsw-alias-brand-primary,#6dbf8c)}
|
||
/* 玻璃跟随宿主颜色。字重和底的不透明度分开:壳透,字坐在更实的一层上。 */
|
||
.dshp-dock{font-family:"Segoe UI","Microsoft YaHei UI","PingFang SC","Noto Sans SC",system-ui,sans-serif;font-size:14px;line-height:1.6;font-weight:500;-webkit-font-smoothing:antialiased;text-shadow:none}
|
||
.dshp-dock[data-theme="white"],.dshp-dock[data-theme="dusk"]{background:linear-gradient(165deg,color-mix(in srgb,var(--dsw-alias-bg-layer-1,#fff) 50%,transparent),color-mix(in srgb,var(--dsw-alias-brand-primary,#6d8f78) 18%,transparent) 46%,color-mix(in srgb,var(--dsw-alias-bg-layer-2,#fff) 28%,transparent))!important;backdrop-filter:blur(26px) saturate(1.45)!important;-webkit-backdrop-filter:blur(26px) saturate(1.45)!important;color:var(--dshp-ink)!important;border:1px solid color-mix(in srgb,var(--dsw-alias-brand-primary,#6d8f78) 36%,transparent)!important;box-shadow:inset 0 1px 0 color-mix(in srgb,#fff 42%,transparent),0 18px 44px color-mix(in srgb,#000 18%,transparent)!important}
|
||
.dshp-dock[data-theme="dusk"]{--dshp-ink:var(--dsw-alias-label-primary,#f4f1ea);--dshp-mute:color-mix(in srgb,var(--dsw-alias-label-primary,#f4f1ea) 84%,var(--dsw-alias-label-secondary,#c9c3b8))}
|
||
.dshp-dock[data-theme="white"]{--dshp-ink:var(--dsw-alias-label-primary,#16140f);--dshp-mute:color-mix(in srgb,var(--dsw-alias-label-primary,#16140f) 76%,var(--dsw-alias-label-secondary,#3c362e))}
|
||
.dshp-dock .dshp-dock-head,.dshp-dock .dshp-dock-tabs{background:color-mix(in srgb,var(--dsw-alias-bg-layer-1,transparent) 28%,transparent)!important;color:var(--dshp-ink)!important}
|
||
.dshp-dock .dshp-dock-head,.dshp-dock .dshp-dock-head b,.dshp-dock .dshp-dock-head button,.dshp-dock .dshp-dock-tab,.dshp-dock .dshp-dock-tab.on{color:var(--dshp-ink)!important;text-shadow:none!important;font-weight:600}
|
||
.dshp-dock .dshp-dock-tab{font-size:14px}
|
||
.dshp-dock .dshp-dock-tab.on{border-bottom-color:var(--dsw-alias-brand-primary,#6d8f78)!important}
|
||
.dshp-dock-body .rt-dock{font-size:14px!important;line-height:1.6!important;font-weight:500!important;color:var(--dshp-ink)!important;--rt-ink:var(--dshp-ink);--rt-mute:var(--dshp-mute);--rt-surf:color-mix(in srgb,var(--dsw-alias-bg-layer-2,var(--dsw-alias-bg-layer-1,#fff)) 40%,transparent);--rt-surf-2:color-mix(in srgb,var(--dsw-alias-bg-layer-1,#fff) 62%,transparent);--rt-line:color-mix(in srgb,var(--dsw-alias-brand-primary,#6d8f78) 30%,transparent)}
|
||
.dshp-dock-body .rt-title,.dshp-dock-body .rt-tab,.dshp-dock-body .rt-tab.on,.dshp-dock-body .rt-card,.dshp-dock-body .rt-card h4,.dshp-dock-body .rt-row,.dshp-dock-body .rt-vrow,.dshp-dock-body .rt-kv,.dshp-dock-body .rt-kv b,.dshp-dock-body .rt-kv span,.dshp-dock-body .rt-mono,.dshp-dock-body .rt-empty,.dshp-dock-body .rt-foot,.dshp-dock-body .rt-tag,.dshp-dock-body .rt-item,.dshp-dock-body .rt-item-name,.dshp-dock-body .rt-item-desc,.dshp-dock-body .rt-sec-sub,.dshp-dock-body .rt-kb-sub,.dshp-dock-body .rt-seg-cidr,.dshp-dock-body .rt-seg-meta,.dshp-dock-body .rt-section,.dshp-dock-body .rt-note,.dshp-dock-body .rt-hint{color:var(--dshp-ink)!important;text-shadow:none!important;opacity:1!important}
|
||
.dshp-dock-body .rt-tab,.dshp-dock-body .rt-foot,.dshp-dock-body .rt-empty,.dshp-dock-body .rt-item-desc,.dshp-dock-body .rt-kb-sub,.dshp-dock-body .rt-kv span,.dshp-dock-body .rt-tag,.dshp-dock-body .rt-sec-sub{color:var(--dshp-mute)!important}
|
||
.dshp-dock-body .rt-card,.dshp-dock-body .rt-pane,.dshp-dock-body .rt-side,.dshp-dock-body .rt-table,.dshp-dock-body .rt-toolbar,.dshp-dock-body .rt-embedded>.rt-head,.dshp-dock-body .rt-tabs{background:color-mix(in srgb,var(--dsw-alias-bg-layer-1,#fff) 72%,transparent)!important;border-color:color-mix(in srgb,var(--dsw-alias-brand-primary,#6d8f78) 28%,transparent)!important;color:var(--dshp-ink)!important;box-shadow:inset 0 1px 0 color-mix(in srgb,#fff 26%,transparent)!important;backdrop-filter:blur(16px) saturate(1.3);-webkit-backdrop-filter:blur(16px) saturate(1.3)}
|
||
.dshp-dock-body .rt-btn,.dshp-dock-body .rt-input,.dshp-dock-body .rt-eng-select,.dshp-dock-body textarea.rt-input{background:color-mix(in srgb,var(--dsw-alias-bg-layer-1,#fff) 90%,transparent)!important;color:var(--dshp-ink)!important;border-color:color-mix(in srgb,var(--dsw-alias-border-l1,#888) 70%,transparent)!important}
|
||
.dshp-dock-body .rt-btn-primary{background:var(--dsw-alias-brand-primary,#3f6b52)!important;border-color:var(--dsw-alias-brand-primary,#3f6b52)!important;color:#fff!important}
|
||
.dshp-dock[data-theme="white"] .dshp-dock-body .rt-eng-select option,.dshp-dock[data-theme="white"] .dshp-dock-body .rt-input option{background:#fff;color:#16140f}
|
||
.dshp-dock[data-theme="dusk"] .dshp-dock-body .rt-eng-select option,.dshp-dock[data-theme="dusk"] .dshp-dock-body .rt-input option{background:#1b1d22;color:#f4f1ea}
|
||
.dshp-dock-body .rt-err{font-weight:650}
|
||
.dshp-dock[data-theme="white"] .dshp-dock-body .rt-err{color:#9a3434!important}
|
||
.dshp-dock[data-theme="dusk"] .dshp-dock-body .rt-err{color:#f0b4b4!important}
|
||
.dshp-dock-body .rt-rep-http,.dshp-dock-body pre{background:color-mix(in srgb,var(--dsw-alias-bg-layer-1,#fff) 84%,transparent)!important;color:var(--dshp-ink)!important;border:1px solid color-mix(in srgb,var(--dsw-alias-border-l1,#888) 60%,transparent)!important}
|
||
`;
|
||
|
||
function formatSize(bytes) {
|
||
if (typeof bytes !== "number" || !isFinite(bytes) || bytes < 0) return "0 B";
|
||
if (bytes < 1024) return bytes + " B";
|
||
const kb = bytes / 1024;
|
||
return (kb >= 100 ? Math.round(kb) : kb.toFixed(1)) + " KB";
|
||
}
|
||
|
||
function statusKind(st) {
|
||
if (st === "applied" || st === "already") return "ok";
|
||
if (st === "pending") return "wait";
|
||
if (st === "unmatched") return "bad";
|
||
if (st === "na") return "ok";
|
||
if (st === "missing_file" || st === "skipped" || st === "unlocated" || st === "off") return "miss";
|
||
return "bad";
|
||
}
|
||
|
||
function statusLabel(st, t) {
|
||
if (st === "applied" || st === "already") return t("status.applied");
|
||
if (st === "off") return t("status.skipped");
|
||
if (st === "pending") return t("status.pending");
|
||
if (st === "unmatched") return t("status.unmatched");
|
||
if (st === "skipped") return t("status.skipped");
|
||
if (st === "missing_file") return t("skip");
|
||
if (st === "unlocated") return t("status.unlocated");
|
||
if (st === "na") return t("status.na");
|
||
return st || t("unknown");
|
||
}
|
||
|
||
function shimKind(v) {
|
||
if (v === "patched" || v === "n/a") return "ok";
|
||
if (v === "original") return "wait";
|
||
if (v === "missing" || !v) return "miss";
|
||
return "bad";
|
||
}
|
||
|
||
function noticeNode(notice) {
|
||
if (!notice || notice.kind === "idle" || !notice.text) return null;
|
||
return h("span", { className: "dshp-notice " + (notice.kind === "error" ? "is-bad" : "is-ok") }, notice.text);
|
||
}
|
||
|
||
function Btn(props) {
|
||
const { kind, tiny, children, className, type, ...rest } = props;
|
||
const cls = ["dshp-btn", kind ? "dshp-btn-" + kind : "dshp-btn-ghost", tiny ? "dshp-btn-tiny" : "", className || ""].filter(Boolean).join(" ");
|
||
return h("button", Object.assign({ type: type || "button", className: cls }, rest), children);
|
||
}
|
||
|
||
function Pill({ value, label }) {
|
||
const t = useT();
|
||
return h("span", { className: "dshp-pill is-" + statusKind(value) }, label || statusLabel(value, t));
|
||
}
|
||
|
||
const LAYER_GROUP = {
|
||
"提示词": "prompt",
|
||
"代码": "code",
|
||
"工具": "tools",
|
||
"0.1.5 兼容": "compat",
|
||
"0.2.0 兼容": "dsh20",
|
||
};
|
||
const GROUP_ORDER = ["prompt", "code", "tools", "compat", "dsh20"];
|
||
|
||
function patchRowsOf(state) {
|
||
if (!state || !state.ai_base) return [];
|
||
const fromList = Array.isArray(state.patches) ? state.patches : null;
|
||
const rows = fromList
|
||
? fromList.map((p) => ({
|
||
id: Number(p.id),
|
||
ids: Array.isArray(p.ids) && p.ids.length ? p.ids.map(Number) : [Number(p.id)],
|
||
st: p.status || "missing_file",
|
||
layer: p.layer || "",
|
||
title: p.title || "",
|
||
}))
|
||
: Object.keys(state.patch_status || {}).map((id) => ({
|
||
id: Number(id),
|
||
ids: [Number(id)],
|
||
st: state.patch_status[id],
|
||
layer: "",
|
||
title: "",
|
||
}));
|
||
return rows.filter((r) => r.st && r.st !== "na" && Number.isFinite(r.id));
|
||
}
|
||
|
||
function patchIdLabel(r) {
|
||
const ids = Array.isArray(r.ids) && r.ids.length ? r.ids : [r.id];
|
||
return ids.map((id) => "#" + id).join("/");
|
||
}
|
||
|
||
function patchTitle(t, state, r) {
|
||
if (r && r.title) return r.title;
|
||
const id = r && typeof r === "object" ? r.id : r;
|
||
const key = "patch." + id;
|
||
const hit = t(key);
|
||
if (hit && hit !== key) return hit;
|
||
const names = state && state.patch_names;
|
||
const name = names && (names[id] || names[String(id)]);
|
||
return name || key;
|
||
}
|
||
|
||
function tallyPatches(rows) {
|
||
const out = { applied: 0, pending: 0, unmatched: 0, skipped: 0, total: 0 };
|
||
for (const row of rows || []) {
|
||
out.total += 1;
|
||
if (row.st === "applied" || row.st === "already") out.applied += 1;
|
||
else if (row.st === "pending") out.pending += 1;
|
||
else if (row.st === "unmatched") out.unmatched += 1;
|
||
else out.skipped += 1;
|
||
}
|
||
return out;
|
||
}
|
||
|
||
function PatchGroups({ state }) {
|
||
const t = useT();
|
||
const [open, setOpen] = useState({});
|
||
if (!state || !state.patch_status) return h("div", { className: "dshp-skel", style: { height: 120 } });
|
||
const located = !!state.ai_base;
|
||
if (!located) return null;
|
||
const grouped = new Map();
|
||
for (const row of patchRowsOf(state)) {
|
||
const key = LAYER_GROUP[row.layer] || "code";
|
||
if (!grouped.has(key)) grouped.set(key, []);
|
||
grouped.get(key).push(row);
|
||
}
|
||
return GROUP_ORDER.filter((key) => grouped.has(key)).map((key) => {
|
||
const rows = grouped.get(key).slice().sort((a, b) => a.id - b.id);
|
||
if (!rows.length) return null;
|
||
const tally = tallyPatches(rows);
|
||
const notes = [];
|
||
if (tally.pending) notes.push(t("metric.pendingShort", { n: tally.pending }));
|
||
if (tally.unmatched) notes.push(t("metric.unmatchedShort", { n: tally.unmatched }));
|
||
if (tally.skipped) notes.push(t("metric.skipShort", { n: tally.skipped }));
|
||
const expanded = !!open[key];
|
||
return h("div", { key: key, className: "dshp-group" },
|
||
h("button", {
|
||
type: "button",
|
||
className: "dshp-group-h",
|
||
"aria-expanded": expanded ? "true" : "false",
|
||
onClick: () => setOpen((prev) => Object.assign({}, prev, { [key]: !prev[key] })),
|
||
},
|
||
h("strong", null, t("group." + key)),
|
||
h("span", { className: "dshp-count" }, tally.applied + "/" + tally.total + (notes.length ? " · " + notes.join(" · ") : "")),
|
||
),
|
||
expanded ? h("table", { className: "dshp-table" },
|
||
h("thead", null, h("tr", null,
|
||
h("th", null, "#"),
|
||
h("th", null, t("table.patch")),
|
||
h("th", null, t("table.status")),
|
||
)),
|
||
h("tbody", null, rows.map((r) => h("tr", { key: r.ids ? r.ids.join("-") : r.id },
|
||
h("td", { className: "dshp-id" }, patchIdLabel(r)),
|
||
h("td", null, patchTitle(t, state, r)),
|
||
h("td", null, h(Pill, { value: r.st })),
|
||
))),
|
||
) : null,
|
||
);
|
||
});
|
||
}
|
||
|
||
function HooksDenySection({ onSaved }) {
|
||
const t = useT();
|
||
const [cfg, setCfg] = useState(null);
|
||
const [notice, setNotice] = useState({ kind: "idle", text: "" });
|
||
const [busy, setBusy] = useState(false);
|
||
|
||
useEffect(() => {
|
||
apiJson("/dsh-purge/hooks-deny")
|
||
.then((d) => { if (d && d.ok) setCfg({ hooksDenyBypass: d.hooksDenyBypass !== false }); })
|
||
.catch(() => {});
|
||
}, []);
|
||
|
||
const save = (hooksDenyBypass) => {
|
||
if (!cfg || busy) return;
|
||
setCfg({ hooksDenyBypass });
|
||
setBusy(true);
|
||
apiJson("/dsh-purge/hooks-deny", {
|
||
method: "POST",
|
||
headers: { "content-type": "application/json" },
|
||
body: JSON.stringify({ hooksDenyBypass }),
|
||
})
|
||
.then((d) => {
|
||
if (d && d.ok) {
|
||
setCfg({ hooksDenyBypass: d.hooksDenyBypass === true });
|
||
setNotice({ kind: "ok", text: t("saved.hooksDeny") });
|
||
if (typeof onSaved === "function") onSaved();
|
||
} else {
|
||
setNotice({ kind: "error", text: t("err.save", { error: (d && d.error) || "" }) });
|
||
}
|
||
})
|
||
.catch((e) => setNotice({ kind: "error", text: t("err.save", { error: e.message }) }))
|
||
.finally(() => setBusy(false));
|
||
};
|
||
|
||
if (!cfg) return null;
|
||
return h("div", { className: "dshp-cr", style: { marginTop: 12 } },
|
||
h("div", { className: "dshp-sub" },
|
||
h("h4", null, t("hooksDeny.title")),
|
||
noticeNode(notice),
|
||
),
|
||
h("p", { className: "dshp-hint", style: { margin: "0 0 4px", color: "var(--dshp-mute)", fontSize: 12 } }, t("hooksDeny.hint")),
|
||
h("div", { className: "dshp-cr-row" },
|
||
h("label", null,
|
||
h("input", {
|
||
type: "checkbox",
|
||
checked: cfg.hooksDenyBypass === true,
|
||
disabled: busy,
|
||
onChange: (e) => save(e.target.checked),
|
||
}),
|
||
t("hooksDeny.bypass"),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
function ContinueRetrySection() {
|
||
const t = useT();
|
||
const [cfg, setCfg] = useState(null);
|
||
const [notice, setNotice] = useState({ kind: "idle", text: "" });
|
||
const [busy, setBusy] = useState(false);
|
||
|
||
useEffect(() => {
|
||
apiJson("/dsh-purge/continue/settings")
|
||
.then((d) => { if (d && d.ok) setCfg(d); })
|
||
.catch(() => {});
|
||
}, []);
|
||
|
||
const save = (patch) => {
|
||
if (!cfg || busy) return;
|
||
const next = { ...cfg, ...patch };
|
||
if (next.autoRetry && !(Number(next.retryMax) > 0)) next.retryMax = 3;
|
||
if (next.autoContinue && !(Number(next.continueMax) > 0)) next.continueMax = 3;
|
||
setCfg(next);
|
||
setBusy(true);
|
||
apiJson("/dsh-purge/continue/settings", {
|
||
method: "POST",
|
||
headers: { "content-type": "application/json" },
|
||
body: JSON.stringify(next),
|
||
})
|
||
.then((d) => {
|
||
if (d && d.ok) {
|
||
setCfg(d);
|
||
setNotice({ kind: "ok", text: t("saved.continue") });
|
||
} else {
|
||
setNotice({ kind: "error", text: t("err.save", { error: (d && d.error) || "" }) });
|
||
}
|
||
})
|
||
.catch((e) => setNotice({ kind: "error", text: t("err.save", { error: e.message }) }))
|
||
.finally(() => setBusy(false));
|
||
};
|
||
|
||
if (!cfg) return null;
|
||
return h("div", { className: "dshp-cr" },
|
||
h("div", { className: "dshp-sub" },
|
||
h("h4", null, t("continue.title")),
|
||
noticeNode(notice),
|
||
),
|
||
h("p", { className: "dshp-hint", style: { margin: "0 0 4px", color: "var(--dshp-mute)", fontSize: 12 } }, t("continue.hint")),
|
||
h("div", { className: "dshp-cr-row" },
|
||
h("label", null,
|
||
h("input", {
|
||
type: "checkbox",
|
||
checked: Boolean(cfg.autoRetry),
|
||
disabled: busy,
|
||
onChange: (e) => save({ autoRetry: e.target.checked }),
|
||
}),
|
||
t("continue.autoRetry"),
|
||
),
|
||
h("label", null,
|
||
t("continue.retryMax"),
|
||
h("input", {
|
||
className: "dshp-field dshp-cr-num",
|
||
type: "number",
|
||
min: 0,
|
||
max: 20,
|
||
value: cfg.retryMax,
|
||
disabled: busy,
|
||
onChange: (e) => save({ retryMax: Number(e.target.value) }),
|
||
}),
|
||
),
|
||
),
|
||
h("div", { className: "dshp-cr-row" },
|
||
h("label", null,
|
||
h("input", {
|
||
type: "checkbox",
|
||
checked: Boolean(cfg.autoContinue),
|
||
disabled: busy,
|
||
onChange: (e) => save({ autoContinue: e.target.checked }),
|
||
}),
|
||
t("continue.autoContinue"),
|
||
),
|
||
h("label", null,
|
||
t("continue.continueMax"),
|
||
h("input", {
|
||
className: "dshp-field dshp-cr-num",
|
||
type: "number",
|
||
min: 0,
|
||
max: 20,
|
||
value: cfg.continueMax,
|
||
disabled: busy,
|
||
onChange: (e) => save({ continueMax: Number(e.target.value) }),
|
||
}),
|
||
),
|
||
h("label", null,
|
||
t("continue.text"),
|
||
h("input", {
|
||
className: "dshp-field dshp-cr-text",
|
||
type: "text",
|
||
value: cfg.continueText || "",
|
||
disabled: busy,
|
||
onChange: (e) => save({ continueText: e.target.value }),
|
||
}),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
function RestartAsk(props) {
|
||
const node = h("div", {
|
||
className: "dshp-restart-mask",
|
||
"data-theme": props.theme === "dusk" ? "dusk" : "white",
|
||
role: "dialog",
|
||
"aria-modal": "true",
|
||
"aria-labelledby": "dshp-restart-title",
|
||
onClick: (e) => { if (e.target === e.currentTarget && props.onLater) props.onLater(); },
|
||
},
|
||
h("div", { className: "dshp-restart-card" },
|
||
h("h4", { id: "dshp-restart-title" }, props.t("restart.ask.title")),
|
||
h("p", null, props.t("restart.ask.body")),
|
||
h("div", { className: "dshp-modal-ops" },
|
||
h(Btn, { onClick: props.onLater }, props.t("restart.notNow")),
|
||
h(Btn, { kind: "primary", onClick: props.onRestart }, props.t("restart.now")),
|
||
),
|
||
),
|
||
);
|
||
try {
|
||
const rd = require("react-dom");
|
||
if (rd && typeof rd.createPortal === "function" && typeof document !== "undefined" && document.body) {
|
||
return rd.createPortal(node, document.body);
|
||
}
|
||
} catch { /* host 可能没暴露 react-dom */ }
|
||
return node;
|
||
}
|
||
|
||
function PurgifySection() {
|
||
const t = useT();
|
||
const tRef = useRef(t);
|
||
tRef.current = t;
|
||
const [state, setState] = useState(null);
|
||
const [override, setOverride] = useState("");
|
||
const [defaultOverride, setDefaultOverride] = useState("");
|
||
const [overrideLoaded, setOverrideLoaded] = useState(false);
|
||
const [ownServers, setOwnServers] = useState("");
|
||
const [ownLoaded, setOwnLoaded] = useState(false);
|
||
const [patchBusy, setPatchBusy] = useState(false);
|
||
const [updateBusy, setUpdateBusy] = useState(false);
|
||
const [canApplyUpdate, setCanApplyUpdate] = useState(false);
|
||
const [updateJob, setUpdateJob] = useState(null);
|
||
const [askRestart, setAskRestart] = useState(false);
|
||
const suppressRestartAsk = useRef(false);
|
||
const [askUninstall, setAskUninstall] = useState(false);
|
||
const [uninstallBusy, setUninstallBusy] = useState(false);
|
||
const [notice, setNotice] = useState({ kind: "idle", text: "" });
|
||
const [updateNotice, setUpdateNotice] = useState({ kind: "idle", text: "" });
|
||
const [updateInfo, setUpdateInfo] = useState(null);
|
||
const [channel, setChannel] = useState("stable");
|
||
const [pick, setPick] = useState({ stable: "", beta: "" });
|
||
const actionTicket = useRef(0);
|
||
const overrideRef = useRef("");
|
||
const defaultOverrideRef = useRef("");
|
||
const promptSeededRef = useRef(false);
|
||
const promptDirtyRef = useRef(false);
|
||
const promptAreaRef = useRef(null);
|
||
overrideRef.current = override;
|
||
defaultOverrideRef.current = defaultOverride;
|
||
|
||
const readPromptBox = () => {
|
||
const node = promptAreaRef.current;
|
||
const live = node && typeof node.value === "string" ? node.value : overrideRef.current;
|
||
overrideRef.current = live || "";
|
||
return overrideRef.current;
|
||
};
|
||
|
||
const syncPicks = (d) => {
|
||
const versions = (d && d.versions) || [];
|
||
const ceiling = listedCeiling(versions, d && d.localVersion);
|
||
const next = { stable: "", beta: "" };
|
||
for (const id of ["stable", "beta"]) {
|
||
const list = versions.filter((item) => item.channel === id && keepListedVersion(item, ceiling));
|
||
const hit = preferListed(list, d && d.localVersion, d && d.pin);
|
||
next[id] = (hit && hit.ref) || "";
|
||
}
|
||
setPick(next);
|
||
};
|
||
|
||
const updateErrorText = (tr, e) => {
|
||
const msg = String((e && e.message) || e || "");
|
||
if (msg === "timeout" || /aborted|abort|超时/i.test(msg)) return tr("update.timeout");
|
||
if (/\b404\b/.test(msg) || /non-json/.test(msg)) return tr("update.needRestart");
|
||
return tr("update.fail", { error: msg });
|
||
};
|
||
|
||
const applyUpdateInfo = useCallback((d, tr, kind) => {
|
||
if (d.channel) setChannel(d.channel);
|
||
setUpdateInfo((prev) => {
|
||
const versions = (d.versions && d.versions.length) ? d.versions : ((prev && prev.versions) || []);
|
||
return Object.assign({}, prev || {}, d, { versions });
|
||
});
|
||
if (d.versions && d.versions.length) syncPicks(d);
|
||
if (kind === "check") setCanApplyUpdate(Boolean(d.hasUpdate) && !d.error);
|
||
else if (kind === "done" || kind === "switched" || kind === "channel") setCanApplyUpdate(false);
|
||
const version = d.localVersion || d.localSha || "—";
|
||
const remote = d.remoteVersion || d.remoteSha || "—";
|
||
let text = tr("update.latest", { version });
|
||
if (d.hasUpdate) text = tr("update.available", { remote, local: version });
|
||
if (d.pinned && d.hasUpdate) text = tr("update.pinned", { version, remote });
|
||
if (kind === "switched" || kind === "channel") text = tr("update.switched", { version: d.localVersion || d.remoteVersion || "—" });
|
||
if (kind === "done") text = tr("update.done", { version: d.localVersion || d.remoteVersion || "—" });
|
||
if (d.error) text = d.error;
|
||
setUpdateNotice({ kind: d.ok === false || d.error ? "error" : "ok", text });
|
||
}, []);
|
||
|
||
const checkUpdate = useCallback((id) => {
|
||
setUpdateBusy(true);
|
||
setUpdateJob({ id: id || "", kind: "check" });
|
||
const tr = tRef.current;
|
||
setUpdateNotice({ kind: "ok", text: tr("update.checking") });
|
||
apiJson("/dsh-purge/update")
|
||
.then((d) => {
|
||
if (!d || (!d.ok && !d.channel)) throw new Error((d && d.error) || "check failed");
|
||
applyUpdateInfo(d, tr, "check");
|
||
})
|
||
.catch((e) => setUpdateNotice({ kind: "error", text: updateErrorText(tr, e) }))
|
||
.finally(() => {
|
||
setUpdateBusy(false);
|
||
setUpdateJob(null);
|
||
});
|
||
}, [applyUpdateInfo]);
|
||
|
||
const postUpdate = useCallback((body, kind, id) => {
|
||
setUpdateBusy(true);
|
||
setUpdateJob({ id: id || "", kind });
|
||
const tr = tRef.current;
|
||
setUpdateNotice({ kind: "ok", text: kind === "switched" || kind === "channel" ? tr("update.switching") : tr("update.applying") });
|
||
return apiJson("/dsh-purge/update", {
|
||
method: "POST",
|
||
headers: { "content-type": "application/json" },
|
||
body: JSON.stringify(body || {}),
|
||
})
|
||
.then((d) => {
|
||
if (!d || (d.ok === false && d.error)) throw new Error((d && d.error) || "update failed");
|
||
applyUpdateInfo(d, tr, kind);
|
||
if (d.needsFullQuit) {
|
||
suppressRestartAsk.current = false;
|
||
setAskRestart(true);
|
||
setUpdateNotice({ kind: "ok", text: tr("restart.official.body") });
|
||
return d;
|
||
}
|
||
if (d.restartAfter) {
|
||
setUpdateNotice({ kind: "ok", text: tr("update.restarting") });
|
||
return d;
|
||
}
|
||
if (d.applied && d.reloadClient !== false) {
|
||
setUpdateNotice({ kind: "ok", text: tr("update.reloading") });
|
||
setTimeout(() => {
|
||
try { window.location.reload(); } catch { /* ignore */ }
|
||
}, 280);
|
||
} else if (d.needRestart) {
|
||
setAskRestart(true);
|
||
}
|
||
return d;
|
||
})
|
||
.catch((e) => {
|
||
setUpdateNotice({ kind: "error", text: updateErrorText(tr, e) });
|
||
throw e;
|
||
})
|
||
.finally(() => {
|
||
setUpdateBusy(false);
|
||
setUpdateJob(null);
|
||
});
|
||
}, [applyUpdateInfo]);
|
||
|
||
const doUpdate = useCallback((id) => {
|
||
const lane = id === "beta" ? "beta" : "stable";
|
||
postUpdate({ op: "apply", ref: lane === "beta" ? "beta" : "master", channel: lane }, "done", id).catch(() => {});
|
||
}, [postUpdate]);
|
||
|
||
const changeChannel = useCallback((next) => {
|
||
if (!next) return;
|
||
const tr = tRef.current;
|
||
if (!window.confirm(tr("update.confirmChannel", { version: tr("channel." + next) }))) return;
|
||
const prevChannel = channel;
|
||
const prevInfo = updateInfo;
|
||
setChannel(next);
|
||
setUpdateInfo((cur) => Object.assign({}, cur || {}, { channel: next }));
|
||
postUpdate({ op: "channel", channel: next }, "channel", next).catch(() => {
|
||
setChannel(prevChannel);
|
||
setUpdateInfo(prevInfo);
|
||
});
|
||
}, [channel, postUpdate, updateInfo]);
|
||
|
||
const switchSelected = useCallback((id) => {
|
||
const tr = tRef.current;
|
||
const ref = pick[id];
|
||
const versions = ((updateInfo && updateInfo.versions) || []).filter((item) => item.channel === id);
|
||
const hit = versions.find((item) => item.ref === ref);
|
||
const label = (hit && (hit.label || hit.version)) || ref;
|
||
if (!ref) return;
|
||
if (!window.confirm(tr("update.confirmSwitch", { version: label }))) return;
|
||
const prevChannel = channel;
|
||
const prevInfo = updateInfo;
|
||
if (hit && hit.channel) {
|
||
setChannel(hit.channel);
|
||
setUpdateInfo((cur) => Object.assign({}, cur || {}, { channel: hit.channel }));
|
||
}
|
||
postUpdate({ op: "switch", ref }, "switched", id).catch(() => {
|
||
setChannel(prevChannel);
|
||
setUpdateInfo(prevInfo);
|
||
});
|
||
}, [channel, pick, postUpdate, updateInfo]);
|
||
|
||
const loadAll = useCallback(() => {
|
||
const tr = tRef.current;
|
||
apiJson("/dsh-purge/status")
|
||
.then((d) => {
|
||
if (d && d.ok) {
|
||
setState(d);
|
||
if (d.channel) setChannel(d.channel);
|
||
if (d.update && d.update.ok && !d.update.error) setUpdateInfo(d.update);
|
||
if (d.boot_full_quit && !suppressRestartAsk.current) setAskRestart(true);
|
||
} else {
|
||
setState({ ok: false, patches_total: 0, patches_applied: 0, patch_status: {}, shim_cmd: "n/a", shim_ps1: "n/a", shim_bin: "n/a", has_backup: false });
|
||
setNotice({ kind: "error", text: tr("err.status", { error: (d && d.error) || "bad response" }) });
|
||
}
|
||
})
|
||
.catch((e) => {
|
||
setState({ ok: false, patches_total: 0, patches_applied: 0, patch_status: {}, shim_cmd: "n/a", shim_ps1: "n/a", shim_bin: "n/a", has_backup: false });
|
||
setNotice({ kind: "error", text: tr("err.status", { error: e.message }) });
|
||
});
|
||
apiJson("/dsh-purge/update")
|
||
.then((d) => {
|
||
if (!d) return;
|
||
if (d.channel) setChannel(d.channel);
|
||
setUpdateInfo(d);
|
||
syncPicks(d);
|
||
})
|
||
.catch(() => {});
|
||
apiJson("/dsh-purge/override")
|
||
.then((d) => {
|
||
if (d && d.ok) {
|
||
const packed = typeof d.defaultContent === "string" ? d.defaultContent : "";
|
||
const shown = typeof d.content === "string" ? d.content : packed;
|
||
if (packed) setDefaultOverride(packed);
|
||
if (!promptSeededRef.current || !promptDirtyRef.current) {
|
||
promptSeededRef.current = true;
|
||
setOverride(shown || packed);
|
||
}
|
||
setOverrideLoaded(true);
|
||
}
|
||
else setNotice({ kind: "error", text: tr("err.override", { error: (d && d.error) || "" }) });
|
||
})
|
||
.catch((e) => setNotice({ kind: "error", text: tr("err.override", { error: e.message }) }));
|
||
apiJson("/dsh-purge/own-servers")
|
||
.then((d) => {
|
||
if (d && d.ok) {
|
||
setOwnServers(typeof d.content === "string" ? d.content : "");
|
||
setOwnLoaded(true);
|
||
}
|
||
})
|
||
.catch(() => {});
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
loadAll();
|
||
const timer = setTimeout(() => { loadAll(); }, 5000);
|
||
return () => clearTimeout(timer);
|
||
}, [loadAll]);
|
||
|
||
const rejectNeedPrompt = useCallback((tr) => {
|
||
setAskRestart(false);
|
||
setNotice({ kind: "error", text: tr("need.prompt") });
|
||
setTimeout(() => window.alert(tr("need.prompt")), 0);
|
||
}, []);
|
||
|
||
const doAction = useCallback((action, actionKey) => {
|
||
const tr = tRef.current;
|
||
const label = tr(actionKey);
|
||
const ticket = ++actionTicket.current;
|
||
const run = () => {
|
||
setPatchBusy(true);
|
||
setAskRestart(false);
|
||
setNotice({ kind: "idle", text: "" });
|
||
const applyContent = action === "apply" ? readPromptBox() : "";
|
||
fetch("/dsh-purge/" + action, {
|
||
method: "POST",
|
||
headers: { "content-type": "application/json" },
|
||
body: JSON.stringify(action === "apply" ? { content: applyContent } : {}),
|
||
})
|
||
.then((r) => r.json())
|
||
.then((d) => {
|
||
if (ticket !== actionTicket.current) return;
|
||
if (responseNeedsPrompt(d)) {
|
||
rejectNeedPrompt(tr);
|
||
return;
|
||
}
|
||
if (!d.ok) {
|
||
setNotice({ kind: "error", text: tr("err.action", { action: label, error: d.error || "" }) });
|
||
return;
|
||
}
|
||
if (action === "apply") {
|
||
if (typeof d.defaultContent === "string") setDefaultOverride(d.defaultContent);
|
||
if (typeof d.override_content === "string") {
|
||
setOverride(d.override_content);
|
||
setOverrideLoaded(true);
|
||
}
|
||
}
|
||
if (action === "apply" && d.complete === false) {
|
||
const parts = [];
|
||
if (d.failed > 0) {
|
||
const ids = (d.failed_items || [])
|
||
.map((x) => x.id)
|
||
.filter((id) => id != null)
|
||
.join(",");
|
||
parts.push(
|
||
tr("restart.incomplete.patches", {
|
||
failed: String(d.failed || 0),
|
||
ids: ids || "?",
|
||
}),
|
||
);
|
||
}
|
||
if (d.cmd_flash && d.cmd_flash.ok === false) {
|
||
parts.push(
|
||
tr("restart.incomplete.flash", {
|
||
entry: String(d.cmd_flash.entry || "fail"),
|
||
}),
|
||
);
|
||
}
|
||
if (parts.length === 0) parts.push(tr("restart.incomplete.flash", { entry: "unknown" }));
|
||
setNotice({
|
||
kind: "error",
|
||
text: tr("restart.incomplete", { detail: parts.join(";") }),
|
||
});
|
||
loadAll();
|
||
if (d.restarting || d.needs_full_quit) {
|
||
suppressRestartAsk.current = false;
|
||
setAskRestart(true);
|
||
}
|
||
return;
|
||
}
|
||
if (action === "apply") {
|
||
setNotice({ kind: "ok", text: tr("ok.done") });
|
||
loadAll();
|
||
suppressRestartAsk.current = false;
|
||
setAskRestart(true);
|
||
return;
|
||
}
|
||
setNotice({ kind: "ok", text: tr("ok.done") });
|
||
loadAll();
|
||
})
|
||
.catch((e) => {
|
||
if (ticket !== actionTicket.current) return;
|
||
setNotice({ kind: "error", text: tr("err.action", { action: label, error: e.message }) });
|
||
})
|
||
.finally(() => {
|
||
if (ticket === actionTicket.current) setPatchBusy(false);
|
||
});
|
||
};
|
||
run();
|
||
}, [loadAll, rejectNeedPrompt, state]);
|
||
|
||
const saveOverride = useCallback(() => {
|
||
const tr = t;
|
||
const ticket = ++actionTicket.current;
|
||
const go = () => {
|
||
setPatchBusy(true);
|
||
setAskRestart(false);
|
||
setNotice({ kind: "idle", text: "" });
|
||
const content = readPromptBox();
|
||
fetch("/dsh-purge/override", {
|
||
method: "POST",
|
||
headers: { "content-type": "application/json" },
|
||
body: JSON.stringify({ content: content }),
|
||
})
|
||
.then((r) => r.json())
|
||
.then((d) => {
|
||
if (ticket !== actionTicket.current) return;
|
||
if (!d.ok || responseNeedsPrompt(d)) {
|
||
rejectNeedPrompt(tr);
|
||
return;
|
||
}
|
||
if (typeof d.content === "string") {
|
||
overrideRef.current = d.content;
|
||
setOverride(d.content);
|
||
}
|
||
promptDirtyRef.current = false;
|
||
setNotice({ kind: "ok", text: t("saved.override") });
|
||
})
|
||
.catch((e) => {
|
||
if (ticket !== actionTicket.current) return;
|
||
setNotice({ kind: "error", text: t("err.save", { error: e.message }) });
|
||
})
|
||
.finally(() => {
|
||
if (ticket === actionTicket.current) setPatchBusy(false);
|
||
});
|
||
};
|
||
bothInjectEmpty(readPromptBox(), defaultOverrideRef.current).then((empty) => {
|
||
if (ticket !== actionTicket.current) return;
|
||
if (empty) {
|
||
rejectNeedPrompt(tr);
|
||
return;
|
||
}
|
||
go();
|
||
});
|
||
}, [t, rejectNeedPrompt]);
|
||
|
||
const restoreOverride = useCallback(() => {
|
||
if (!defaultOverride) return;
|
||
const tr = t;
|
||
const ticket = ++actionTicket.current;
|
||
overrideRef.current = defaultOverride;
|
||
setOverride(defaultOverride);
|
||
setPatchBusy(true);
|
||
setNotice({ kind: "idle", text: "" });
|
||
apiJson("/dsh-purge/override", {
|
||
method: "POST",
|
||
headers: { "content-type": "application/json" },
|
||
body: JSON.stringify({ content: defaultOverride }),
|
||
})
|
||
.then((d) => {
|
||
if (ticket !== actionTicket.current) return;
|
||
if (!d || !d.ok) {
|
||
setNotice({ kind: "error", text: tr("err.save", { error: (d && d.error) || "" }) });
|
||
return;
|
||
}
|
||
if (typeof d.content === "string") {
|
||
overrideRef.current = d.content;
|
||
setOverride(d.content);
|
||
}
|
||
promptDirtyRef.current = false;
|
||
setNotice({ kind: "ok", text: tr("saved.restoreInject") });
|
||
})
|
||
.catch((e) => {
|
||
if (ticket !== actionTicket.current) return;
|
||
setNotice({ kind: "error", text: tr("err.save", { error: e.message }) });
|
||
})
|
||
.finally(() => {
|
||
if (ticket === actionTicket.current) setPatchBusy(false);
|
||
});
|
||
}, [defaultOverride, t]);
|
||
|
||
const waitHostAfterUninstall = useCallback((tr) => {
|
||
const started = Date.now();
|
||
const ping = () => {
|
||
fetch("/", { cache: "no-store", credentials: "same-origin" })
|
||
.then((r) => {
|
||
if (r.ok) {
|
||
try {
|
||
window.localStorage.removeItem("dshp-theme");
|
||
window.localStorage.removeItem("dshp-theme-mode");
|
||
} catch { /* ignore */ }
|
||
window.location.reload();
|
||
return;
|
||
}
|
||
retry();
|
||
})
|
||
.catch(retry);
|
||
};
|
||
const retry = () => {
|
||
if (Date.now() - started > 90000) {
|
||
setNotice({ kind: "error", text: tr("restart.timeout") });
|
||
setUninstallBusy(false);
|
||
return;
|
||
}
|
||
setTimeout(ping, 500);
|
||
};
|
||
setTimeout(ping, 800);
|
||
}, []);
|
||
|
||
const doUninstall = useCallback(() => {
|
||
setUninstallBusy(true);
|
||
setAskUninstall(false);
|
||
setAskRestart(false);
|
||
const tr = tRef.current;
|
||
setNotice({ kind: "ok", text: tr("uninstall.applying") });
|
||
apiJson("/dsh-purge/uninstall", { method: "POST", headers: { "content-type": "application/json" }, body: "{}" })
|
||
.then((d) => {
|
||
if (!d || !d.ok) throw new Error((d && d.error) || "uninstall failed");
|
||
setNotice({ kind: "ok", text: (d && d.note) || hostText(tr, "uninstall.done", d.surface || hostSurfaceOf(state)) });
|
||
if ((d && d.surface) === "desktop" || (d && d.fullApp) || hostSurfaceOf(state) === "desktop") return;
|
||
waitHostAfterUninstall(tr);
|
||
})
|
||
.catch((e) => {
|
||
const msg = String((e && e.message) || e || "");
|
||
if (/failed to fetch|networkerror|load failed/i.test(msg)) {
|
||
setNotice({ kind: "ok", text: hostText(tr, "uninstall.done", hostSurfaceOf(state)) });
|
||
if (hostSurfaceOf(state) === "desktop" || clientGuessSurface() === "desktop") return;
|
||
waitHostAfterUninstall(tr);
|
||
return;
|
||
}
|
||
setNotice({ kind: "error", text: tr("uninstall.fail", { error: msg }) });
|
||
setUninstallBusy(false);
|
||
});
|
||
}, [state, waitHostAfterUninstall]);
|
||
|
||
const s = state;
|
||
const located = !!(s && s.ai_base);
|
||
const hostSurface = hostSurfaceOf(s);
|
||
const desktopLikely = hostSurface === "desktop" || clientGuessSurface() === "desktop";
|
||
const sealedReady = !located && desktopLikely && !!(s && (s.asar_still_sealed || s.desktop_exe || s.desktop_install));
|
||
const patchesLost = !located && !!(s && s.patches_lost);
|
||
const tally = located ? tallyPatches(patchRowsOf(s)) : { applied: 0, pending: 0, unmatched: 0, skipped: 0, total: 0 };
|
||
const total = tally.total;
|
||
const applied = tally.applied;
|
||
const pct = located && total ? Math.round((applied / total) * 100) : 0;
|
||
const metricNotes = [];
|
||
if (tally.pending) metricNotes.push(t("metric.pendingShort", { n: tally.pending }));
|
||
if (tally.unmatched) metricNotes.push(t("metric.unmatchedShort", { n: tally.unmatched }));
|
||
if (tally.skipped) metricNotes.push(t("metric.skipShort", { n: tally.skipped }));
|
||
|
||
const versions = (updateInfo && updateInfo.versions) || [];
|
||
const channelNow = (updateInfo && updateInfo.channel) || channel || "stable";
|
||
const localVer = (s && s.plugin_version) || (updateInfo && updateInfo.localVersion) || "";
|
||
const renderEdition = (id) => {
|
||
const lane = (updateInfo && updateInfo.lanes && updateInfo.lanes[id]) || {};
|
||
const ceiling = listedCeiling(versions, localVer || (updateInfo && updateInfo.localVersion));
|
||
const list = versions.filter((item) => item.channel === id && keepListedVersion(item, ceiling));
|
||
const selectedRef = pick[id] || "";
|
||
const hit = list.find((item) => item.ref === selectedRef) || preferListed(list, localVer, updateInfo && updateInfo.pin);
|
||
const onLane = channelNow === id;
|
||
const noBeta = id === "beta" && Array.isArray(updateInfo && updateInfo.versions) && !list.length;
|
||
const pickedOther = Boolean(hit && selectedRef && !hit.current);
|
||
const thisJob = updateJob && updateJob.id === id ? updateJob : null;
|
||
let actionLabel = t("btn.checkUpdate");
|
||
let actionKind;
|
||
let actionClick = () => checkUpdate(id);
|
||
let actionDisabled = updateBusy || Boolean(noBeta);
|
||
if (noBeta) {
|
||
actionLabel = t("update.noBeta");
|
||
actionClick = () => {};
|
||
} else if (pickedOther) {
|
||
actionLabel = t("update.switch");
|
||
actionKind = "primary";
|
||
actionClick = () => switchSelected(id);
|
||
} else if (!onLane) {
|
||
actionLabel = id === "beta" ? t("channel.useBeta") : t("channel.useStable");
|
||
actionKind = "primary";
|
||
actionClick = () => changeChannel(id);
|
||
} else if (canApplyUpdate && lane.hasUpdate) {
|
||
actionLabel = t("btn.doUpdate");
|
||
actionKind = "primary";
|
||
actionClick = () => doUpdate(id);
|
||
}
|
||
if (thisJob) {
|
||
if (thisJob.kind === "check") actionLabel = t("btn.checkUpdate.busy");
|
||
else if (thisJob.kind === "done") actionLabel = t("btn.doUpdate.busy");
|
||
else actionLabel = t("update.switching");
|
||
}
|
||
return h("div", {
|
||
className: "dshp-edition" + (onLane ? " is-on" : ""),
|
||
"aria-label": t("channel." + id),
|
||
},
|
||
h("span", { className: "dshp-edition-name" },
|
||
t("channel." + id),
|
||
onLane ? h("em", { className: "dshp-now-mark" }, t("update.current")) : null,
|
||
),
|
||
h("select", {
|
||
className: "dshp-field dshp-ver",
|
||
value: list.length ? ((hit && hit.ref) || "") : "",
|
||
disabled: updateBusy || !list.length,
|
||
onChange: (e) => setPick((prev) => Object.assign({}, prev, { [id]: e.target.value })),
|
||
"aria-label": t("update.pick"),
|
||
},
|
||
list.length
|
||
? list.map((item) => h("option", {
|
||
key: item.ref + (item.sha || ""),
|
||
value: item.ref,
|
||
}, versionChoiceLabel(item, t)))
|
||
: h("option", { value: "" }, noBeta ? t("update.noBeta") : t("update.pick")),
|
||
),
|
||
h(Btn, { tiny: true, kind: actionKind, disabled: actionDisabled, onClick: actionClick }, actionLabel),
|
||
);
|
||
};
|
||
return h("section", { className: "dshp-panel", "aria-label": t("purge.title") },
|
||
h("div", { className: "dshp-head" },
|
||
h("h3", { className: "dshp-title" }, t("purge.title")),
|
||
h("span", { className: "dshp-pill" }, t("surface." + hostSurface)),
|
||
),
|
||
h("div", { className: "dshp-rel" },
|
||
h("div", { className: "dshp-now" },
|
||
h("span", { className: "dshp-now-label" }, t("channel.local")),
|
||
h("b", { className: "dshp-now-ver" }, localVer ? "v" + localVer : "—"),
|
||
h("em", { className: "dshp-now-mark" }, t("update.current")),
|
||
),
|
||
h("div", { className: "dshp-editions" },
|
||
renderEdition("stable"),
|
||
renderEdition("beta"),
|
||
),
|
||
noticeNode(updateNotice),
|
||
),
|
||
s ? h("div", { className: "dshp-metrics" },
|
||
h("div", { className: "dshp-metric" },
|
||
h("b", null, located ? (applied + " / " + total) : (patchesLost ? t("metric.lost.count") : (sealedReady ? t("metric.sealed.count") : t("metric.unlocated.count")))),
|
||
h("span", null, !located ? (patchesLost ? t("metric.lost") : (sealedReady ? t("metric.sealed") : t("metric.unlocated"))) : (metricNotes.length ? metricNotes.join(" · ") : t("metric.purged"))),
|
||
),
|
||
h("div", { className: "dshp-metric" },
|
||
h("b", { style: { fontSize: 13, fontFamily: "var(--dshp-mono)", fontWeight: 500 } },
|
||
h("span", { className: "dshp-pill is-" + shimKind(s.shim_cmd) }, "cmd"),
|
||
" ",
|
||
h("span", { className: "dshp-pill is-" + shimKind(s.shim_ps1) }, "ps1"),
|
||
" ",
|
||
h("span", { className: "dshp-pill is-" + shimKind(s.shim_bin || "missing") }, "unix"),
|
||
),
|
||
h("span", null, (s.shim_cmd === "n/a" && s.shim_ps1 === "n/a" && (s.shim_bin === "n/a" || !s.shim_bin)) ? t("metric.shim.na") : t("metric.shim")),
|
||
),
|
||
h("div", { className: "dshp-metric" },
|
||
h("b", null, s.has_backup ? t("metric.bak.yes") : t("metric.bak.no")),
|
||
h("span", null, t("metric.bak.hint")),
|
||
),
|
||
) : h("div", { className: "dshp-metrics" },
|
||
h("div", { className: "dshp-metric" }, h("div", { className: "dshp-skel" }), h("div", { className: "dshp-skel", style: { width: "40%" } })),
|
||
h("div", { className: "dshp-metric" }, h("div", { className: "dshp-skel" }), h("div", { className: "dshp-skel", style: { width: "40%" } })),
|
||
h("div", { className: "dshp-metric" }, h("div", { className: "dshp-skel" }), h("div", { className: "dshp-skel", style: { width: "40%" } })),
|
||
),
|
||
h("div", { className: "dshp-bar", "aria-hidden": "true" }, h("i", { style: { width: pct + "%" } })),
|
||
h(PatchGroups, { state: s }),
|
||
h(HooksDenySection, { onSaved: loadAll }),
|
||
h("div", { className: "dshp-row", style: { marginTop: 14 } },
|
||
h(Btn, { kind: "primary", disabled: patchBusy || uninstallBusy || !overrideLoaded, onClick: () => doAction("apply", "action.apply") }, patchBusy ? t("btn.apply.busy") : t("btn.apply")),
|
||
h(Btn, { kind: "danger", disabled: patchBusy || uninstallBusy, onClick: () => doAction("revert", "action.revert") }, t("btn.revert")),
|
||
h(Btn, { kind: "solid-danger", disabled: patchBusy || uninstallBusy, onClick: () => setAskUninstall(true) }, uninstallBusy ? t("btn.uninstall.busy") : t("btn.uninstall")),
|
||
noticeNode(notice),
|
||
),
|
||
askUninstall ? h("div", {
|
||
className: "dshp-modal-bg",
|
||
role: "dialog",
|
||
"aria-modal": "true",
|
||
"aria-labelledby": "dshp-uninstall-title",
|
||
onClick: (e) => { if (e.target === e.currentTarget && !uninstallBusy) setAskUninstall(false); },
|
||
},
|
||
h("div", { className: "dshp-modal" },
|
||
h("h4", { id: "dshp-uninstall-title" }, t("uninstall.title")),
|
||
h("p", null, t("uninstall.body")),
|
||
h("div", { className: "dshp-modal-ops" },
|
||
h(Btn, { disabled: uninstallBusy, onClick: () => setAskUninstall(false) }, t("uninstall.cancel")),
|
||
h(Btn, { kind: "solid-danger", disabled: uninstallBusy, onClick: doUninstall }, t("uninstall.confirm")),
|
||
),
|
||
),
|
||
) : null,
|
||
h("p", { className: "dshp-hint", style: { margin: "8px 0 0", color: "var(--dshp-mute)", fontSize: 12 } }, t("apply.hint")),
|
||
s && !s.ai_base ? h("p", { className: "dshp-hint", style: { margin: "8px 0 0", color: "var(--dshp-danger, #c44)", fontSize: 12 } }, hostText(t, "warn.noRoot", hostSurface)) : null,
|
||
s && s.ai_base && s.override_status === "missing" ? h("p", { className: "dshp-hint", style: { margin: "8px 0 0", color: "var(--dshp-mute)", fontSize: 12 } }, t("warn.noInject")) : null,
|
||
askRestart ? h(RestartAsk, {
|
||
t: t,
|
||
theme: detectHostTheme(),
|
||
onLater: () => {
|
||
suppressRestartAsk.current = true;
|
||
setAskRestart(false);
|
||
},
|
||
onRestart: () => {
|
||
suppressRestartAsk.current = true;
|
||
setAskRestart(false);
|
||
restartDsh(setNotice, function () {}, t, hostSurface);
|
||
},
|
||
}) : null,
|
||
// h(ContinueRetrySection, null),
|
||
h("div", { className: "dshp-sub" },
|
||
h("h4", null, t("override.title")),
|
||
h("div", { className: "dshp-row", style: { margin: 0 } },
|
||
h(Btn, { kind: "primary", tiny: true, disabled: patchBusy || !overrideLoaded, onClick: saveOverride }, t("btn.saveInject")),
|
||
h(Btn, { tiny: true, disabled: patchBusy || !overrideLoaded || !defaultOverride, onClick: restoreOverride }, t("btn.restoreInject")),
|
||
),
|
||
),
|
||
h("textarea", {
|
||
className: "dshp-area",
|
||
ref: promptAreaRef,
|
||
value: override,
|
||
onChange: (e) => {
|
||
promptDirtyRef.current = true;
|
||
overrideRef.current = e.target.value;
|
||
setOverride(e.target.value);
|
||
},
|
||
spellCheck: false,
|
||
placeholder: "",
|
||
}),
|
||
promptBoxEmpty(override) ? h("p", {
|
||
className: "dshp-hint",
|
||
style: { margin: "8px 0 0", color: "var(--dshp-danger, #c44)", fontSize: 12 },
|
||
}, t("need.prompt")) : null,
|
||
h("div", { className: "dshp-sub" },
|
||
h("h4", null, t("own.title")),
|
||
h("div", { className: "dshp-row", style: { margin: 0 } },
|
||
h(Btn, {
|
||
kind: "primary",
|
||
tiny: true,
|
||
disabled: !ownLoaded,
|
||
onClick: () => {
|
||
fetch("/dsh-purge/own-servers", {
|
||
method: "POST",
|
||
headers: { "content-type": "application/json" },
|
||
body: JSON.stringify({ content: ownServers }),
|
||
}).then((r) => r.json()).then((d) => {
|
||
if (!d || !d.ok) {
|
||
setNotice({ kind: "error", text: (d && d.error) || t("err.override", { error: "" }) });
|
||
return;
|
||
}
|
||
if (typeof d.content === "string") setOwnServers(d.content);
|
||
setNotice({
|
||
kind: "ok",
|
||
text: d.dropped > 0 ? t("saved.own.dropped", { n: d.dropped }) : t("saved.own"),
|
||
});
|
||
}).catch((e) => setNotice({ kind: "error", text: e.message }));
|
||
},
|
||
}, t("own.save")),
|
||
),
|
||
),
|
||
h("textarea", {
|
||
className: "dshp-area",
|
||
value: ownServers,
|
||
onChange: (e) => setOwnServers(e.target.value),
|
||
spellCheck: false,
|
||
rows: 4,
|
||
style: { minHeight: 96 },
|
||
}),
|
||
h("p", { className: "dshp-hint", style: { margin: "8px 0 0", color: "var(--dshp-mute)", fontSize: 12 } }, t("own.hint")),
|
||
);
|
||
}
|
||
|
||
function ruleLabel(r) {
|
||
if (!r) return "";
|
||
if (r.name !== r.id) return r.name + " (" + r.id + ")";
|
||
return r.id;
|
||
}
|
||
|
||
function RulesSection() {
|
||
const t = useT();
|
||
const tRef = useRef(t);
|
||
tRef.current = t;
|
||
const [st, setSt] = useState(null);
|
||
const [editId, setEditId] = useState(null);
|
||
const [editName, setEditName] = useState("");
|
||
const [editTarget, setEditTarget] = useState("AGENTS.md");
|
||
const [content, setContent] = useState("");
|
||
const [newId, setNewId] = useState("");
|
||
const [newName, setNewName] = useState("");
|
||
const [newTarget, setNewTarget] = useState("AGENTS.md");
|
||
const [busy, setBusy] = useState(false);
|
||
const [notice, setNotice] = useState({ kind: "idle", text: "" });
|
||
|
||
const loadStatus = useCallback(() => {
|
||
const tr = tRef.current;
|
||
let cwd = "";
|
||
try { cwd = clientCurrentCwd(); } catch { /* ignore */ }
|
||
rulesApi("status", cwd ? { cwd: cwd } : undefined)
|
||
.then((d) => {
|
||
if (d && d.ok) setSt(d);
|
||
else {
|
||
setSt({ ok: false, rules: [], active: null });
|
||
setNotice({ kind: "error", text: tr("err.rules", { error: (d && d.error) || "bad response" }) });
|
||
}
|
||
})
|
||
.catch((e) => {
|
||
setSt({ ok: false, rules: [], active: null });
|
||
setNotice({ kind: "error", text: tr("err.rules", { error: e.message }) });
|
||
});
|
||
}, []);
|
||
|
||
useEffect(() => { loadStatus(); }, [loadStatus]);
|
||
|
||
const doPost = useCallback((action, payload, after) => {
|
||
setBusy(true);
|
||
setNotice({ kind: "idle", text: "" });
|
||
rulesApi(action, payload)
|
||
.then((d) => {
|
||
if (d && d.ok) {
|
||
setNotice({ kind: "ok", text: t("ok.done") });
|
||
if (action === "save" || action === "activate") warnNeedPrompt(d, t);
|
||
loadStatus();
|
||
if (after) after();
|
||
} else {
|
||
warnNeedPrompt(d, t);
|
||
setNotice({ kind: "error", text: t("err.action", { error: (d && d.error) || "" }) });
|
||
}
|
||
})
|
||
.catch((e) => setNotice({ kind: "error", text: t("err.action", { error: e.message }) }))
|
||
.finally(() => setBusy(false));
|
||
}, [loadStatus, t]);
|
||
|
||
const openRule = useCallback((id) => {
|
||
setBusy(true);
|
||
rulesApi("read", { id: id })
|
||
.then((d) => {
|
||
if (d.ok) {
|
||
setEditId(id);
|
||
setEditName(d.name || id);
|
||
setEditTarget(d.target || "AGENTS.md");
|
||
setContent(typeof d.content === "string" ? d.content : "");
|
||
} else setNotice({ kind: "error", text: t("err.read", { error: d.error || "" }) });
|
||
})
|
||
.catch((e) => setNotice({ kind: "error", text: t("err.read", { error: e.message }) }))
|
||
.finally(() => setBusy(false));
|
||
}, [t]);
|
||
|
||
const clearEditor = () => {
|
||
setEditId(null);
|
||
setEditName("");
|
||
setEditTarget("AGENTS.md");
|
||
setContent("");
|
||
};
|
||
|
||
let list;
|
||
if (!st) {
|
||
list = h("div", { className: "dshp-skel", style: { height: 80, margin: 12 } });
|
||
} else if (!st.rules || st.rules.length === 0) {
|
||
list = h("p", { className: "dshp-empty" }, t("rules.empty"));
|
||
} else {
|
||
list = st.rules.map((r) => {
|
||
const isActive = r.id === st.active;
|
||
const isEdit = r.id === editId;
|
||
return h("div", {
|
||
key: r.id,
|
||
className: "dshp-ruleitem" + (isEdit ? " is-edit" : ""),
|
||
role: "button",
|
||
tabIndex: 0,
|
||
onClick: () => openRule(r.id),
|
||
onKeyDown: (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); openRule(r.id); } },
|
||
},
|
||
h("div", { className: "dshp-rule-main" },
|
||
h("span", { className: "dshp-rule-name" },
|
||
ruleLabel(r),
|
||
isActive ? h("span", { className: "dshp-pill is-ok" }, t("pill.current")) : null,
|
||
),
|
||
h("span", { className: "dshp-rule-meta" }, r.target + " · " + formatSize(r.size)),
|
||
),
|
||
h("div", { className: "dshp-rule-ops" },
|
||
h(Btn, {
|
||
tiny: true,
|
||
kind: isActive ? undefined : "primary",
|
||
disabled: busy || isActive,
|
||
onClick: (e) => {
|
||
e.stopPropagation();
|
||
if (!isActive) doPost("activate", { id: r.id });
|
||
},
|
||
}, isActive ? t("btn.inUse") : t("btn.use")),
|
||
h(Btn, {
|
||
tiny: true,
|
||
kind: "danger",
|
||
disabled: busy,
|
||
onClick: (e) => {
|
||
e.stopPropagation();
|
||
if (!window.confirm(t("confirm.delete", { id: r.id }))) return;
|
||
if (editId === r.id) clearEditor();
|
||
doPost("delete", { id: r.id });
|
||
},
|
||
}, t("delete")),
|
||
),
|
||
);
|
||
});
|
||
}
|
||
|
||
const policyValue = (st && typeof st.workspace_policy === "string") ? st.workspace_policy : "fallback";
|
||
const policyOptions = (st && Array.isArray(st.workspace_policies) && st.workspace_policies.length) ? st.workspace_policies : ["fallback", "strict", "always"];
|
||
const cwdCandidates = (st && Array.isArray(st.cwd_candidates)) ? st.cwd_candidates : [];
|
||
const cwdValue = (st && typeof st.cwd === "string") ? st.cwd : "";
|
||
return h("section", { className: "dshp-panel", "aria-label": t("rules.title") },
|
||
h("div", { className: "dshp-head" },
|
||
h("h3", { className: "dshp-title" }, t("rules.title")),
|
||
h("div", { className: "dshp-row", style: { margin: 0 } },
|
||
noticeNode(notice),
|
||
h(Btn, {
|
||
tiny: true,
|
||
kind: "danger",
|
||
disabled: busy,
|
||
onClick: () => {
|
||
if (!window.confirm(t("rules.reset.confirm"))) return;
|
||
clearEditor();
|
||
doPost("reset", {});
|
||
},
|
||
}, t("rules.reset")),
|
||
),
|
||
),
|
||
h("div", { className: "dshp-block" },
|
||
h("div", { className: "dshp-policy-row" },
|
||
h("label", { htmlFor: "dshp-policy-select" }, t("policy.title")),
|
||
h("select", {
|
||
id: "dshp-policy-select",
|
||
className: "dshp-field",
|
||
value: policyValue,
|
||
disabled: busy || !st,
|
||
onChange: (e) => doPost("set-policy", { policy: e.target.value }),
|
||
}, policyOptions.map((p) => h("option", { key: p, value: p }, t("policy." + p)))),
|
||
),
|
||
h("p", { className: "dshp-block-hint" }, t("policy.hint")),
|
||
),
|
||
h("div", { className: "dshp-block" },
|
||
h("div", { className: "dshp-block-head" },
|
||
h("h4", null, t("cwd.title")),
|
||
h(Btn, {
|
||
tiny: true,
|
||
disabled: busy,
|
||
onClick: () => {
|
||
let cwd = cwdValue;
|
||
if (!cwd) { try { cwd = clientCurrentCwd(); } catch { /* ignore */ } }
|
||
doPost("scan-cwd", cwd ? { cwd: cwd } : {});
|
||
},
|
||
}, t("cwd.rescan")),
|
||
),
|
||
cwdValue ? h("p", { className: "dshp-cwd-path" }, cwdValue) : null,
|
||
(!st || !cwdValue)
|
||
? h("p", { className: "dshp-empty" }, t("cwd.no.workspace"))
|
||
: (cwdCandidates.length === 0
|
||
? h("p", { className: "dshp-empty" }, t("cwd.empty"))
|
||
: h("div", { className: "dshp-cwd-list" }, cwdCandidates.map((item) => h("label", {
|
||
key: item.path,
|
||
className: "dshp-cwd-item" + (item.selected ? " is-on" : ""),
|
||
},
|
||
h("input", {
|
||
type: "checkbox",
|
||
checked: Boolean(item.selected),
|
||
disabled: busy,
|
||
onChange: (e) => doPost("toggle-cwd", { path: item.path, enabled: e.target.checked }),
|
||
}),
|
||
h("div", { className: "dshp-cwd-main" },
|
||
h("div", { className: "dshp-cwd-name" },
|
||
h("span", null, item.name),
|
||
h("span", { className: "dshp-cwd-badge" }, t("cwd.scope." + (item.scope || "cwd"))),
|
||
h("span", { className: "dshp-cwd-size" }, formatSize(item.size || 0)),
|
||
),
|
||
h("div", { className: "dshp-cwd-sub" }, item.path),
|
||
item.preview ? h("div", { className: "dshp-cwd-prev" }, item.preview) : null,
|
||
),
|
||
)))),
|
||
h("p", { className: "dshp-block-hint" }, t("cwd.hint")),
|
||
),
|
||
h("div", { className: "dshp-block" },
|
||
h("div", { className: "dshp-split" },
|
||
h("div", { className: "dshp-rulelist" },
|
||
h("div", { className: "dshp-rulebody" }, list),
|
||
h("div", { className: "dshp-create" },
|
||
h("div", { className: "dshp-create-row" },
|
||
h("input", { className: "dshp-field", placeholder: t("ph.id"), value: newId, onChange: (e) => setNewId(e.target.value) }),
|
||
h("input", { className: "dshp-field", placeholder: t("ph.alias"), value: newName, onChange: (e) => setNewName(e.target.value) }),
|
||
h("select", { className: "dshp-field", style: { minWidth: 120, flex: "0 0 auto" }, value: newTarget, onChange: (e) => setNewTarget(e.target.value) },
|
||
TARGETS.map((x) => h("option", { key: x, value: x }, x))),
|
||
h(Btn, {
|
||
kind: "primary",
|
||
tiny: true,
|
||
disabled: busy,
|
||
onClick: () => {
|
||
const id = newId.trim();
|
||
if (!id) { setNotice({ kind: "error", text: t("need.id") }); return; }
|
||
const name = newName.trim() || id;
|
||
const target = newTarget;
|
||
doPost("save", { id, content: "", name, target }, () => {
|
||
setEditId(id);
|
||
setEditName(name);
|
||
setEditTarget(target);
|
||
setContent("");
|
||
});
|
||
setNewId(""); setNewName(""); setNewTarget("AGENTS.md");
|
||
},
|
||
}, t("btn.create")),
|
||
),
|
||
),
|
||
),
|
||
h("div", { className: "dshp-editor" },
|
||
editId ? [
|
||
h("div", { key: "meta", className: "dshp-row" },
|
||
h("input", {
|
||
className: "dshp-field", placeholder: t("ph.alias.short"), value: editName,
|
||
onChange: (e) => setEditName(e.target.value),
|
||
}),
|
||
h("select", {
|
||
className: "dshp-field", style: { minWidth: 120 }, value: editTarget,
|
||
onChange: (e) => setEditTarget(e.target.value),
|
||
}, TARGETS.map((x) => h("option", { key: x, value: x }, x))),
|
||
),
|
||
h("textarea", {
|
||
key: "body",
|
||
className: "dshp-area", value: content,
|
||
onChange: (e) => setContent(e.target.value),
|
||
placeholder: "",
|
||
}),
|
||
h("div", { key: "save", className: "dshp-row" },
|
||
h(Btn, {
|
||
kind: "primary",
|
||
disabled: busy,
|
||
onClick: () => doPost("save", { id: editId, content, name: editName, target: editTarget }),
|
||
}, t("btn.saveRule")),
|
||
noticeNode(notice),
|
||
),
|
||
] : h("div", { className: "dshp-editor-empty" }, t("rules.pick")),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
function skillUiAllowed(d) {
|
||
if (!d) return false;
|
||
const ver = String(d.plugin_version || d.localVersion || "").replace(/^v/i, "");
|
||
if (/^1\.1\.11(?:-|$)/.test(ver)) return false;
|
||
const parts = ver.match(/^(\d+)\.(\d+)\.(\d+)/);
|
||
if (!parts) return false;
|
||
if (+parts[1] !== 1) return +parts[1] > 1;
|
||
if (+parts[2] !== 1) return +parts[2] > 1;
|
||
return +parts[3] >= 12;
|
||
}
|
||
|
||
function splitListedVersion(raw) {
|
||
const text = String(raw || "").trim().replace(/^v/i, "");
|
||
const cut = text.split("-");
|
||
const core = String(cut[0] || "").split(".").map((n) => parseInt(n, 10) || 0);
|
||
return { core: [core[0] || 0, core[1] || 0, core[2] || 0], pre: cut.slice(1).join("-") };
|
||
}
|
||
|
||
function listedVersionNewer(remote, local) {
|
||
if (!remote || !local || String(remote) === String(local)) return false;
|
||
const a = splitListedVersion(remote);
|
||
const b = splitListedVersion(local);
|
||
for (let i = 0; i < 3; i += 1) {
|
||
if (a.core[i] !== b.core[i]) return a.core[i] > b.core[i];
|
||
}
|
||
if (a.pre && !b.pre) return false;
|
||
if (!a.pre && b.pre) return true;
|
||
return a.pre > b.pre;
|
||
}
|
||
|
||
function listedCeiling(versions, localVer) {
|
||
let best = "";
|
||
const rows = (versions || []).slice();
|
||
const local = String(localVer || "").replace(/^v/i, "");
|
||
if (local && !/(?:^|[-._])(beta|rc|pre|preview|test)(?:\d|$|[-._])/i.test(local)) {
|
||
rows.push({ channel: "stable", version: local });
|
||
}
|
||
for (const item of rows) {
|
||
if (!item || item.channel === "beta") continue;
|
||
const ver = String(item.version || "").replace(/^v/i, "");
|
||
if (!/^\d+\.\d+\.\d+/.test(ver) || /(?:^|[-._])(beta|rc|pre|preview|test)(?:\d|$|[-._])/i.test(ver)) continue;
|
||
if (!best || listedVersionNewer(ver, best)) best = ver;
|
||
}
|
||
return best;
|
||
}
|
||
|
||
function keepListedVersion(item, _ceiling) {
|
||
const ver = String((item && (item.version || item.ref || item.id)) || "");
|
||
if (!item) return false;
|
||
if (item.ref === "beta" || item.id === "beta") return true;
|
||
if (item.channel === "beta") return false;
|
||
if (/(?:^|[-._])(beta|rc|pre|preview|test)(?:\d|$|[-._])/i.test(ver)) return false;
|
||
return true;
|
||
}
|
||
|
||
function preferListed(list, localVer, pin) {
|
||
const pinned = pin ? (list || []).find((item) => item.ref === pin) : null;
|
||
if (pinned) return pinned;
|
||
const ver = String(localVer || "").replace(/^v/i, "");
|
||
const same = (list || []).filter((item) => ver && String(item.version || "").replace(/^v/i, "") === ver);
|
||
if (same.length) {
|
||
return same.find((item) => item.current)
|
||
|| same.find((item) => item.latest)
|
||
|| same[0];
|
||
}
|
||
return (list || []).find((item) => item.current)
|
||
|| (list || []).find((item) => item.latest)
|
||
|| (list || [])[0]
|
||
|| null;
|
||
}
|
||
|
||
function versionChoiceLabel(item, tr) {
|
||
const ver = String((item && item.version) || "").replace(/^v/i, "");
|
||
const base = ver ? ("v" + ver) : ((item && (item.label || item.ref)) || "—");
|
||
if (item && item.current) return base + " · " + tr("update.current");
|
||
if (item && item.latest) return base + " · " + tr("update.tip");
|
||
return base;
|
||
}
|
||
|
||
function SkillsSection() {
|
||
const t = useT();
|
||
const tRef = useRef(t);
|
||
tRef.current = t;
|
||
const [onBeta, setOnBeta] = useState(false);
|
||
const [st, setSt] = useState(null);
|
||
const [editId, setEditId] = useState(null);
|
||
const [editDesc, setEditDesc] = useState("");
|
||
const [content, setContent] = useState("");
|
||
const [busy, setBusy] = useState(false);
|
||
const [notice, setNotice] = useState({ kind: "idle", text: "" });
|
||
const zipRef = useRef(null);
|
||
const folderRef = useRef(null);
|
||
|
||
const loadStatus = useCallback(() => {
|
||
const tr = tRef.current;
|
||
const fail = (e) => {
|
||
setSt({ ok: false, skills: [] });
|
||
setNotice({
|
||
kind: "error",
|
||
text: skillsRouteMissing(e) ? tr("err.skills.needRestart") : tr("err.skills", { error: (e && e.message) || e || "bad response" }),
|
||
});
|
||
};
|
||
const apply = (d) => {
|
||
if (d && d.ok) setSt({ ok: true, skills: d.skills || [] });
|
||
else fail({ message: (d && d.error) || "bad response" });
|
||
};
|
||
apiJson("/dsh-purge/status")
|
||
.then((d) => {
|
||
const allowed = skillUiAllowed(d);
|
||
setOnBeta(allowed);
|
||
if (!allowed) {
|
||
setSt(null);
|
||
return;
|
||
}
|
||
if (d.skills) {
|
||
apply({ ok: true, skills: d.skills });
|
||
return;
|
||
}
|
||
skillsApi("status").then(apply).catch(fail);
|
||
})
|
||
.catch(() => {
|
||
setOnBeta(false);
|
||
setSt(null);
|
||
});
|
||
}, []);
|
||
|
||
useEffect(() => { loadStatus(); }, [loadStatus]);
|
||
|
||
const doPost = useCallback((action, payload, after) => {
|
||
setBusy(true);
|
||
setNotice({ kind: "idle", text: "" });
|
||
skillsApi(action, payload)
|
||
.then((d) => {
|
||
if (d && d.ok) {
|
||
setNotice({ kind: "ok", text: t("ok.done") });
|
||
loadStatus();
|
||
if (after) after(d);
|
||
} else {
|
||
setNotice({ kind: "error", text: t("err.action", { error: (d && d.error) || "" }) });
|
||
}
|
||
})
|
||
.catch((e) => setNotice({
|
||
kind: "error",
|
||
text: skillsRouteMissing(e) ? t("err.skills.needRestart") : t("err.action", { error: e.message }),
|
||
}))
|
||
.finally(() => setBusy(false));
|
||
}, [loadStatus, t]);
|
||
|
||
const openSkill = useCallback((id) => {
|
||
setBusy(true);
|
||
skillsApi("read", { id: id })
|
||
.then((d) => {
|
||
if (d.ok) {
|
||
setEditId(id);
|
||
setEditDesc(d.description || "");
|
||
setContent(typeof d.content === "string" ? d.content : "");
|
||
} else setNotice({ kind: "error", text: t("err.read", { error: d.error || "" }) });
|
||
})
|
||
.catch((e) => setNotice({ kind: "error", text: t("err.read", { error: e.message }) }))
|
||
.finally(() => setBusy(false));
|
||
}, [t]);
|
||
|
||
const clearEditor = () => {
|
||
setEditId(null);
|
||
setEditDesc("");
|
||
setContent("");
|
||
};
|
||
|
||
const readAsBase64 = (file) => new Promise((resolve, reject) => {
|
||
const reader = new FileReader();
|
||
reader.onload = () => {
|
||
const text = String(reader.result || "");
|
||
const at = text.indexOf(",");
|
||
resolve(at >= 0 ? text.slice(at + 1) : text);
|
||
};
|
||
reader.onerror = () => reject(new Error(t("need.import")));
|
||
reader.readAsDataURL(file);
|
||
});
|
||
|
||
const importZip = (file) => {
|
||
if (!file) {
|
||
setNotice({ kind: "error", text: t("need.import") });
|
||
return;
|
||
}
|
||
setBusy(true);
|
||
setNotice({ kind: "ok", text: t("skills.importing") });
|
||
readAsBase64(file)
|
||
.then((data) => skillsApi("import", { data, name: file.name }))
|
||
.then((d) => {
|
||
if (!d || !d.ok) throw new Error((d && d.error) || "");
|
||
setNotice({ kind: "ok", text: t("saved.import", { count: (d.imported || []).length || 1 }) });
|
||
loadStatus();
|
||
})
|
||
.catch((e) => setNotice({
|
||
kind: "error",
|
||
text: skillsRouteMissing(e) ? t("err.skills.needRestart") : t("err.action", { error: e.message }),
|
||
}))
|
||
.finally(() => setBusy(false));
|
||
};
|
||
|
||
const keepFolderFile = (file) => {
|
||
const rel = String(file.webkitRelativePath || file.name || "").replace(/\\/g, "/");
|
||
if (!rel || rel.includes("..")) return false;
|
||
if (/(^|\/)(node_modules|\.git|__MACOSX|\.system)(\/|$)/i.test(rel)) return false;
|
||
if (/(^|\/)\._/.test(rel) || /\/\.DS_Store$/i.test(rel)) return false;
|
||
return true;
|
||
};
|
||
|
||
const importFolder = (list) => {
|
||
const files = Array.from(list || []).filter(keepFolderFile);
|
||
if (!files.length) {
|
||
setNotice({ kind: "error", text: t("err.import.folder") });
|
||
return;
|
||
}
|
||
setBusy(true);
|
||
setNotice({ kind: "ok", text: t("skills.importing") });
|
||
Promise.all(files.map((file) => readAsBase64(file).then((content) => ({
|
||
path: file.webkitRelativePath || file.name,
|
||
content,
|
||
encoding: "base64",
|
||
}))))
|
||
.then((payload) => skillsApi("import", {
|
||
files: payload,
|
||
name: String(payload[0] && payload[0].path || "").split("/")[0],
|
||
}))
|
||
.then((d) => {
|
||
if (!d || !d.ok) throw new Error((d && d.error) || "");
|
||
setNotice({ kind: "ok", text: t("saved.import", { count: (d.imported || []).length || 1 }) });
|
||
loadStatus();
|
||
})
|
||
.catch((e) => setNotice({
|
||
kind: "error",
|
||
text: skillsRouteMissing(e) ? t("err.skills.needRestart") : t("err.action", { error: e.message }),
|
||
}))
|
||
.finally(() => setBusy(false));
|
||
};
|
||
|
||
if (!onBeta) return null;
|
||
|
||
let list;
|
||
if (!st) {
|
||
list = h("div", { className: "dshp-skel", style: { height: 80, margin: 12 } });
|
||
} else if (!st.skills || st.skills.length === 0) {
|
||
list = h("p", { className: "dshp-empty" }, t("skills.empty"));
|
||
} else {
|
||
list = st.skills.map((item) => {
|
||
const isEdit = item.id === editId;
|
||
return h("div", {
|
||
key: item.id,
|
||
className: "dshp-ruleitem" + (isEdit ? " is-edit" : ""),
|
||
role: "button",
|
||
tabIndex: 0,
|
||
onClick: () => openSkill(item.id),
|
||
onKeyDown: (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); openSkill(item.id); } },
|
||
},
|
||
h("div", { className: "dshp-rule-main" },
|
||
h("span", { className: "dshp-rule-name" }, item.id),
|
||
h("span", { className: "dshp-rule-meta" },
|
||
(item.valid ? item.description : t("skills.invalid")) + " · " + formatSize(item.size),
|
||
),
|
||
item.valid ? h("span", { className: "dshp-rule-meta" },
|
||
[
|
||
item.userInvocable ? t("skills.call.slash", { cmd: "/" + item.id }) : t("skills.call.userOff"),
|
||
item.modelInvocable ? t("skills.call.model") : t("skills.call.modelOff"),
|
||
].join(" · "),
|
||
) : null,
|
||
),
|
||
h("div", { className: "dshp-rule-ops" },
|
||
h(Btn, {
|
||
tiny: true,
|
||
kind: "danger",
|
||
disabled: busy,
|
||
onClick: (e) => {
|
||
e.stopPropagation();
|
||
if (!window.confirm(t("confirm.delete.skill", { id: item.id }))) return;
|
||
if (editId === item.id) clearEditor();
|
||
doPost("delete", { id: item.id });
|
||
},
|
||
}, t("delete")),
|
||
),
|
||
);
|
||
});
|
||
}
|
||
|
||
return h("section", { className: "dshp-panel", "aria-label": t("skills.title") },
|
||
h("div", { className: "dshp-head" },
|
||
h("h3", { className: "dshp-title" }, t("skills.title")),
|
||
h("div", { className: "dshp-row", style: { margin: 0 } }, noticeNode(notice)),
|
||
),
|
||
h("p", { className: "dshp-hint", style: { margin: "0 0 10px", color: "var(--dshp-mute)", fontSize: 12 } }, hostText(t, "skills.hint", hostSurfaceOf())),
|
||
h("div", { className: "dshp-split" },
|
||
h("div", { className: "dshp-rulelist" },
|
||
h("div", { className: "dshp-rulebody" }, list),
|
||
h("div", { className: "dshp-create" },
|
||
h("div", { className: "dshp-create-row" },
|
||
h("input", {
|
||
ref: zipRef,
|
||
type: "file",
|
||
accept: ".zip,.tgz,.tar.gz,.tar,.skill",
|
||
style: { display: "none" },
|
||
onChange: (e) => {
|
||
const file = e.target.files && e.target.files[0];
|
||
e.target.value = "";
|
||
importZip(file);
|
||
},
|
||
}),
|
||
h("input", {
|
||
ref: (el) => {
|
||
folderRef.current = el;
|
||
if (!el) return;
|
||
el.setAttribute("webkitdirectory", "");
|
||
el.setAttribute("directory", "");
|
||
el.multiple = true;
|
||
},
|
||
type: "file",
|
||
multiple: true,
|
||
style: { display: "none" },
|
||
onChange: (e) => {
|
||
const files = Array.from((e.target && e.target.files) || []);
|
||
e.target.value = "";
|
||
importFolder(files);
|
||
},
|
||
}),
|
||
h(Btn, {
|
||
kind: "primary",
|
||
tiny: true,
|
||
disabled: busy,
|
||
onClick: () => zipRef.current && zipRef.current.click(),
|
||
}, t("btn.importZip")),
|
||
h(Btn, {
|
||
tiny: true,
|
||
disabled: busy,
|
||
onClick: () => folderRef.current && folderRef.current.click(),
|
||
}, t("btn.importFolder")),
|
||
),
|
||
),
|
||
),
|
||
h("div", { className: "dshp-editor" },
|
||
editId ? [
|
||
h("div", { key: "meta", className: "dshp-row" },
|
||
h("input", {
|
||
className: "dshp-field",
|
||
placeholder: t("ph.skill.desc"),
|
||
value: editDesc,
|
||
onChange: (e) => setEditDesc(e.target.value),
|
||
}),
|
||
),
|
||
h("textarea", {
|
||
key: "body",
|
||
className: "dshp-area",
|
||
value: content,
|
||
onChange: (e) => setContent(e.target.value),
|
||
spellCheck: false,
|
||
placeholder: "",
|
||
}),
|
||
h("div", { key: "save", className: "dshp-row" },
|
||
h(Btn, {
|
||
kind: "primary",
|
||
disabled: busy,
|
||
onClick: () => doPost("save", { id: editId, content, description: editDesc }),
|
||
}, t("btn.saveSkill")),
|
||
noticeNode(notice),
|
||
),
|
||
] : h("div", { className: "dshp-editor-empty" }, t("skills.pick")),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
function reopenAfterRestart() {
|
||
window.location.reload();
|
||
}
|
||
|
||
function waitForRestart(setNotice, setBusy, t) {
|
||
const started = Date.now();
|
||
// 必须先看到旧进程掉线,再等新进程就绪;否则同进程立刻 200 会被当成「重启成功」又弹重启。
|
||
let sawDown = false;
|
||
const ping = () => {
|
||
fetch("/dsh-purge/status", { cache: "no-store", credentials: "same-origin" })
|
||
.then((r) => (r.ok ? r.json() : Promise.reject()))
|
||
.then((d) => {
|
||
if (!sawDown) {
|
||
retry();
|
||
return;
|
||
}
|
||
if (d && d.ok && d.ready !== false) reopenAfterRestart();
|
||
else retry();
|
||
})
|
||
.catch(() => {
|
||
sawDown = true;
|
||
retry();
|
||
});
|
||
};
|
||
const retry = () => {
|
||
if (Date.now() - started > 90000) {
|
||
setNotice({ kind: "error", text: t("restart.timeout") });
|
||
setBusy(false);
|
||
return;
|
||
}
|
||
setTimeout(ping, 500);
|
||
};
|
||
setTimeout(ping, 800);
|
||
}
|
||
|
||
let restartOnce = false;
|
||
|
||
function restartDsh(setNotice, setBusy, t, surface) {
|
||
if (restartOnce) return;
|
||
const surf = surface || clientGuessSurface() || "desktop";
|
||
const desktop = surf === "desktop" || clientGuessSurface() === "desktop";
|
||
restartOnce = true;
|
||
setBusy(true);
|
||
setNotice({ kind: "ok", text: hostText(t, "restarting", surf) });
|
||
const stop = (error) => {
|
||
restartOnce = false;
|
||
setNotice({ kind: "error", text: error ? t("restart.fail", { error }) : t("restart.notStarted") });
|
||
setBusy(false);
|
||
};
|
||
let sawDrop = false;
|
||
const watch = setTimeout(() => {
|
||
if (sawDrop) return;
|
||
stop("");
|
||
}, 25000);
|
||
fetch("/dsh-purge/restart", { method: "POST", headers: { "content-type": "application/json" }, body: "{}" })
|
||
.then((r) => r.json())
|
||
.then((d) => {
|
||
if (!d.ok) throw new Error(d.error || "restart failed");
|
||
const next = (d && d.surface) || surf;
|
||
const full = next === "desktop" || (d && d.fullApp) || desktop;
|
||
if (full && d.already) {
|
||
setNotice({ kind: "ok", text: hostText(t, "restarting", next) });
|
||
return;
|
||
}
|
||
if (full && d.restarting === false) {
|
||
clearTimeout(watch);
|
||
stop(d.error || "not started");
|
||
return;
|
||
}
|
||
setNotice({ kind: "ok", text: hostText(t, "restarting", next) });
|
||
if (full) return;
|
||
clearTimeout(watch);
|
||
restartOnce = false;
|
||
waitForRestart(setNotice, setBusy, t);
|
||
})
|
||
.catch((e) => {
|
||
const msg = String((e && e.message) || e || "");
|
||
const dropped = /failed to fetch|networkerror|load failed/i.test(msg) || e.name === "TypeError";
|
||
if (dropped && desktop) {
|
||
sawDrop = true;
|
||
clearTimeout(watch);
|
||
return;
|
||
}
|
||
clearTimeout(watch);
|
||
stop(msg || "restart failed");
|
||
});
|
||
}
|
||
|
||
function detectHostTheme() {
|
||
try {
|
||
if (typeof document !== "undefined") {
|
||
const root = document.documentElement;
|
||
if (document.body?.hasAttribute("data-ds-dark-theme") || root?.hasAttribute("data-ds-dark-theme")) return "dusk";
|
||
const scheme = (root.style.colorScheme || getComputedStyle(root).colorScheme || getComputedStyle(root).getPropertyValue("color-scheme") || "").toLowerCase();
|
||
if (scheme.includes("dark")) return "dusk";
|
||
if (scheme.includes("light")) return "white";
|
||
}
|
||
if (typeof window !== "undefined" && window.matchMedia?.("(prefers-color-scheme: dark)").matches) {
|
||
return "dusk";
|
||
}
|
||
} catch { /* ignore */ }
|
||
return "white";
|
||
}
|
||
|
||
function readTheme() {
|
||
return detectHostTheme();
|
||
}
|
||
|
||
function SettingsRoot(props) {
|
||
const t = typeof props.t === "function" ? props.t : ((key) => key);
|
||
const [theme, setTheme] = useState(readTheme);
|
||
useEffect(() => {
|
||
let cancelled = false;
|
||
let timer = 0;
|
||
const syncFromHost = () => {
|
||
if (!cancelled) setTheme(detectHostTheme());
|
||
};
|
||
const syncSoon = () => {
|
||
if (timer) window.clearTimeout(timer);
|
||
timer = window.setTimeout(syncFromHost, 50);
|
||
};
|
||
syncFromHost();
|
||
let mq;
|
||
try {
|
||
mq = window.matchMedia?.("(prefers-color-scheme: dark)");
|
||
mq?.addEventListener?.("change", syncFromHost);
|
||
} catch { /* ignore */ }
|
||
let obs;
|
||
try {
|
||
obs = new MutationObserver(syncSoon);
|
||
if (document.body) obs.observe(document.body, { attributes: true, attributeFilter: ["data-ds-dark-theme", "class", "style"] });
|
||
obs.observe(document.documentElement, { attributes: true, attributeFilter: ["data-ds-dark-theme", "class", "style"] });
|
||
} catch { /* ignore */ }
|
||
return () => {
|
||
cancelled = true;
|
||
if (timer) window.clearTimeout(timer);
|
||
try { mq?.removeEventListener?.("change", syncFromHost); } catch { /* ignore */ }
|
||
try { obs?.disconnect(); } catch { /* ignore */ }
|
||
};
|
||
}, []);
|
||
translate = t;
|
||
return h("div", { className: "dshp-root", "data-theme": theme },
|
||
h("style", null, PURGE_CSS),
|
||
h(PurgifySection, null),
|
||
h(RulesSection, null),
|
||
h(SkillsSection, null),
|
||
);
|
||
}
|
||
|
||
const REWIND_CSS = `
|
||
.dshp-rewind-wrap{position:relative;display:inline-flex}
|
||
.dshp-rewind{appearance:none;display:inline-flex;align-items:center;gap:4px;height:28px;padding:0 8px;border:0;border-radius:8px;background:transparent;color:var(--dsw-alias-label-secondary,currentColor);font:12px/1 var(--ds-font-sans,system-ui,sans-serif);cursor:pointer}
|
||
.dshp-rewind:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.12));color:var(--dsw-alias-label-primary,currentColor)}
|
||
.dshp-rewind:disabled{opacity:.4;cursor:default}
|
||
.dshp-rewind svg{display:block}
|
||
.dshp-rewind-menu{position:fixed;z-index:2147483000;box-sizing:border-box;min-width:220px;padding:4px;border:0;border-radius:12px;background:var(--dsw-specific-menu,#fff);color:var(--dsw-alias-label-primary,#1a1a1a);--dsw-elevation-stroke-color:var(--dsw-alias-border-l1);box-shadow:var(--dsw-elevation-prominent,0 8px 24px rgba(0,0,0,.16));transform:translateY(-100%)}
|
||
body[data-ds-dark-theme] .dshp-rewind-menu{background:var(--dsw-specific-menu,#32312d);color:var(--dsw-alias-label-primary,#e6e2db);--dsw-elevation-stroke-color:var(--dsw-alias-border-l1,#3f3d38);box-shadow:var(--dsw-elevation-prominent,0 10px 28px rgba(0,0,0,.45))}
|
||
.dshp-rewind-item{display:flex;flex-direction:column;gap:2px;width:100%;padding:8px 10px;border:0;border-radius:8px;background:transparent;color:inherit;text-align:left;cursor:pointer;font:12px/1.3 var(--ds-font-sans,system-ui,sans-serif)}
|
||
.dshp-rewind-item:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.12))}
|
||
.dshp-rewind-item b{font-size:13px;font-weight:600;color:var(--dsw-alias-label-primary,inherit)}
|
||
.dshp-rewind-item span{color:var(--dsw-alias-label-tertiary,#9a958c);font-size:11px}
|
||
body[data-ds-dark-theme] .dshp-rewind-item span{color:var(--dsw-alias-label-tertiary,#a8a39a)}
|
||
.dshp-continue{appearance:none;display:inline-flex;align-items:center;gap:4px;height:28px;padding:0 8px;border:0;border-radius:8px;background:transparent;color:var(--dsw-alias-label-secondary,currentColor);font:12px/1 var(--ds-font-sans,system-ui,sans-serif);cursor:pointer}
|
||
.dshp-continue:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.12));color:var(--dsw-alias-label-primary,currentColor)}
|
||
.dshp-continue:disabled{opacity:.4;cursor:default}
|
||
.dshp-continue.is-ready{color:var(--dsw-alias-label-primary,currentColor)}
|
||
.dshp-continue svg{display:block}
|
||
`;
|
||
const DRAFT_KEY = "dshp-rewind-draft:";
|
||
const ARM_KEY = "dshp-rewind-arm:";
|
||
const FILL_DELAYS = [0, 50, 180, 400, 800];
|
||
const ARM_TTL_MS = 45 * 1000;
|
||
let rewindSeenAt = 0;
|
||
let rewindSessions = null;
|
||
let rewindHost = null;
|
||
let rewindWorkspace = null;
|
||
let rewindConversation = null;
|
||
let pendingComposer = { sessionId: "", text: "", at: 0 };
|
||
let armedFill = { sessionId: "", text: "", at: 0 };
|
||
|
||
function rewindText(t, key, fallback) {
|
||
try {
|
||
if (typeof t === "function") {
|
||
const value = t(key);
|
||
if (value && value !== key) return value;
|
||
}
|
||
} catch { /* ignore */ }
|
||
return fallback;
|
||
}
|
||
|
||
function currentSessionId(sessions) {
|
||
try {
|
||
const selected = rewindWorkspace?.selection?.getSnapshot?.()?.sessionId;
|
||
if (typeof selected === "string" && selected) return selected;
|
||
} catch { /* ignore */ }
|
||
try {
|
||
const snap = sessions?.list?.getSnapshot?.();
|
||
if (typeof snap?.current === "string" && snap.current) return snap.current;
|
||
} catch { /* ignore */ }
|
||
return "";
|
||
}
|
||
|
||
function sessionKnown(sessions, sessionId) {
|
||
try {
|
||
const snap = sessions?.list?.getSnapshot?.();
|
||
if (!snap || !sessionId) return false;
|
||
if (snap.byId && Object.prototype.hasOwnProperty.call(snap.byId, sessionId)) return true;
|
||
if (Array.isArray(snap.ids) && snap.ids.includes(sessionId)) return true;
|
||
} catch { /* ignore */ }
|
||
return false;
|
||
}
|
||
|
||
function waitMs(ms) {
|
||
return new Promise((resolve) => window.setTimeout(resolve, ms));
|
||
}
|
||
|
||
function isPluginDraft(text) {
|
||
const value = String(text || "").trim();
|
||
if (!value) return false;
|
||
return /^\[MNEMON\]/i.test(value)
|
||
|| /MNEMON RUNTIME MEMORY SNAPSHOT/i.test(value)
|
||
|| /^MNEMON VIEW TOOLS/im.test(value);
|
||
}
|
||
|
||
function writeDraft(sessionId, text) {
|
||
if (!sessionId) return;
|
||
const value = isPluginDraft(text) ? "" : (text || "");
|
||
pendingComposer = { sessionId, text: value, at: Date.now() };
|
||
try { sessionStorage.setItem(DRAFT_KEY + sessionId, value); } catch { /* ignore */ }
|
||
}
|
||
|
||
function peekDraft(sessionId) {
|
||
if (!sessionId) return "";
|
||
if (pendingComposer.sessionId === sessionId && pendingComposer.text) return pendingComposer.text;
|
||
try { return sessionStorage.getItem(DRAFT_KEY + sessionId) || ""; } catch { return ""; }
|
||
}
|
||
|
||
function clearStoredDraft(sessionId) {
|
||
if (!sessionId) return;
|
||
if (pendingComposer.sessionId === sessionId) pendingComposer = { sessionId: "", text: "", at: 0 };
|
||
try { sessionStorage.removeItem(DRAFT_KEY + sessionId); } catch { /* ignore */ }
|
||
try { sessionStorage.removeItem(ARM_KEY + sessionId); } catch { /* ignore */ }
|
||
}
|
||
|
||
function armComposerFill(sessionId, text) {
|
||
if (!sessionId || !text || isPluginDraft(text)) return;
|
||
const at = Date.now();
|
||
armedFill = { sessionId, text, at };
|
||
writeDraft(sessionId, text);
|
||
try { sessionStorage.setItem(ARM_KEY + sessionId, String(at)); } catch { /* ignore */ }
|
||
}
|
||
|
||
function takeArmedFill(sessionId) {
|
||
if (!sessionId) return "";
|
||
let text = "";
|
||
let at = 0;
|
||
if (armedFill.sessionId === sessionId && armedFill.text) {
|
||
text = armedFill.text;
|
||
at = armedFill.at;
|
||
} else {
|
||
text = peekDraft(sessionId);
|
||
try { at = Number(sessionStorage.getItem(ARM_KEY + sessionId) || 0); } catch { at = 0; }
|
||
}
|
||
if (!text || !at || Date.now() - at > ARM_TTL_MS) {
|
||
if (text && (!at || Date.now() - at > ARM_TTL_MS)) clearStoredDraft(sessionId);
|
||
return "";
|
||
}
|
||
clearStoredDraft(sessionId);
|
||
armedFill = { sessionId: "", text: "", at: 0 };
|
||
return text;
|
||
}
|
||
|
||
function conversationFace() {
|
||
if (rewindConversation) return rewindConversation;
|
||
const host = rewindHost;
|
||
if (host?.conversation) return host.conversation;
|
||
if (typeof host?.get === "function") {
|
||
try { return host.get("conversation"); } catch { return undefined; }
|
||
}
|
||
return undefined;
|
||
}
|
||
|
||
function conversationInput(sessionId) {
|
||
try {
|
||
const input = conversationFace()?.input;
|
||
if (!input || !sessionId) return null;
|
||
if (typeof input.shell === "function") {
|
||
try { return input.shell(sessionId); } catch { /* session not retained yet */ }
|
||
}
|
||
const sessions = rewindHost?.sessions || rewindSessions;
|
||
const actx = typeof sessions?.scope === "function" ? sessions.scope(sessionId) : undefined;
|
||
if (typeof input.for === "function" && actx) return input.for(actx);
|
||
return null;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function liveDraft(sessionId, inputActions) {
|
||
try {
|
||
const fromActions = inputActions?.state?.getSnapshot?.()?.draft;
|
||
if (typeof fromActions === "string") return fromActions;
|
||
} catch { /* ignore */ }
|
||
try {
|
||
const draft = conversationInput(sessionId)?.state?.getSnapshot?.()?.draft;
|
||
if (typeof draft === "string") return draft;
|
||
} catch { /* ignore */ }
|
||
return "";
|
||
}
|
||
|
||
function hostSetDraft(sessionId, text) {
|
||
if (!sessionId) return false;
|
||
try {
|
||
const input = conversationInput(sessionId);
|
||
if (input && typeof input.setDraft === "function") {
|
||
input.setDraft(text || "");
|
||
return true;
|
||
}
|
||
} catch { /* ignore */ }
|
||
return false;
|
||
}
|
||
|
||
function fillComposer(inputActions, text) {
|
||
if (inputActions && typeof inputActions.setDraft === "function") {
|
||
try { inputActions.setDraft(text || ""); } catch { /* ignore */ }
|
||
}
|
||
}
|
||
|
||
function scheduleComposerFill(sessionId, text, inputActions) {
|
||
if (!sessionId || !text || isPluginDraft(text)) return () => {};
|
||
let stopped = false;
|
||
let filled = false;
|
||
const timers = [];
|
||
const tryFill = () => {
|
||
if (stopped) return;
|
||
const current = liveDraft(sessionId, inputActions);
|
||
if (filled && current !== text) {
|
||
stopped = true;
|
||
return;
|
||
}
|
||
if (current && current !== text) {
|
||
stopped = true;
|
||
return;
|
||
}
|
||
fillComposer(inputActions, text);
|
||
hostSetDraft(sessionId, text);
|
||
filled = true;
|
||
};
|
||
for (const ms of FILL_DELAYS) timers.push(window.setTimeout(tryFill, ms));
|
||
return () => {
|
||
stopped = true;
|
||
for (const t of timers) { try { window.clearTimeout(t); } catch { /* ignore */ } }
|
||
};
|
||
}
|
||
|
||
function scheduleEchoClear(sessionId, inputActions) {
|
||
if (!sessionId) return () => {};
|
||
let last = "";
|
||
let stopped = false;
|
||
const timers = [];
|
||
const tryClear = () => {
|
||
if (stopped || !last) return;
|
||
const current = String(liveDraft(sessionId, inputActions) || "").trim();
|
||
if (!current) {
|
||
stopped = true;
|
||
return;
|
||
}
|
||
if (current !== last) return;
|
||
fillComposer(inputActions, "");
|
||
hostSetDraft(sessionId, "");
|
||
clearStoredDraft(sessionId);
|
||
stopped = true;
|
||
};
|
||
apiJson("/dsh-purge/last-user?sessionId=" + encodeURIComponent(sessionId)).then((data) => {
|
||
if (stopped) return;
|
||
last = String(data?.text || "").trim();
|
||
if (!last || isPluginDraft(last)) return;
|
||
tryClear();
|
||
for (const ms of [0, 50, 180, 400]) timers.push(window.setTimeout(tryClear, ms));
|
||
}).catch(() => {});
|
||
return () => {
|
||
stopped = true;
|
||
for (const t of timers) { try { window.clearTimeout(t); } catch { /* ignore */ } }
|
||
};
|
||
}
|
||
|
||
async function dropInheritedQueue(sessions, sessionId) {
|
||
if (!sessions || !sessionId) return;
|
||
const drop = async () => {
|
||
try {
|
||
const actx = typeof sessions.scope === "function" ? sessions.scope(sessionId) : undefined;
|
||
const face = typeof sessions.sessionOf === "function" ? sessions.sessionOf(actx) : undefined;
|
||
const queue = face?.getSnapshot?.()?.queue || [];
|
||
for (const item of queue) {
|
||
const id = item?.id || item?.itemId;
|
||
if (!id) continue;
|
||
try { await face.updateQueue?.(id, { kind: "remove" }); } catch { /* ignore */ }
|
||
}
|
||
} catch { /* ignore */ }
|
||
};
|
||
await drop();
|
||
window.setTimeout(() => { drop(); }, 80);
|
||
}
|
||
|
||
async function openRewoundSession(sessions, sessionId) {
|
||
if (!sessionId) return;
|
||
const workspace = rewindWorkspace;
|
||
let lastError = null;
|
||
for (let attempt = 0; attempt < 25; attempt += 1) {
|
||
if (sessions && typeof sessions.refresh === "function" && !sessionKnown(sessions, sessionId)) {
|
||
try { await sessions.refresh(); } catch (error) { lastError = error; }
|
||
}
|
||
const known = sessionKnown(sessions, sessionId);
|
||
if (known || typeof sessions?.open === "function") {
|
||
try {
|
||
if (workspace && typeof workspace.openSession === "function") {
|
||
workspace.openSession(sessionId);
|
||
} else if (typeof sessions?.open === "function") {
|
||
sessions.open(sessionId);
|
||
} else {
|
||
throw new Error("无法打开回退后的会话");
|
||
}
|
||
const selected = workspace?.selection?.getSnapshot?.()?.sessionId;
|
||
if (!workspace || selected === sessionId) {
|
||
await dropInheritedQueue(sessions, sessionId);
|
||
window.setTimeout(() => { dropInheritedQueue(sessions, sessionId); }, 250);
|
||
return;
|
||
}
|
||
} catch (error) {
|
||
lastError = error;
|
||
}
|
||
}
|
||
await waitMs(120);
|
||
}
|
||
throw new Error((lastError && lastError.message) || "回退后的会话没有打开");
|
||
}
|
||
|
||
class RewindSafe extends Component {
|
||
constructor(p) { super(p); this.state = { failed: false }; }
|
||
static getDerivedStateFromError() { return { failed: true }; }
|
||
componentDidCatch(error) { try { console.error("[dsh-purge] rewind slot:", error); } catch { /* ignore */ } }
|
||
render() {
|
||
if (this.state.failed) return h(RewindFallback, this.props);
|
||
return h(RewindButton, this.props);
|
||
}
|
||
}
|
||
|
||
class ContinueSafe extends Component {
|
||
constructor(p) { super(p); this.state = { failed: false }; }
|
||
static getDerivedStateFromError() { return { failed: true }; }
|
||
componentDidCatch(error) { try { console.error("[dsh-purge] continue slot:", error); } catch { /* ignore */ } }
|
||
render() {
|
||
if (this.state.failed) {
|
||
return h("button", {
|
||
type: "button",
|
||
className: "dshp-continue",
|
||
title: rewindText(translate, "continue.label", "继续"),
|
||
onMouseDown: (e) => e.preventDefault(),
|
||
onClick: async () => {
|
||
const sessionId = currentSessionId(rewindSessions);
|
||
if (!sessionId) return;
|
||
try {
|
||
const data = await apiJson("/dsh-purge/continue", {
|
||
method: "POST",
|
||
headers: { "content-type": "application/json" },
|
||
body: JSON.stringify({ sessionId, force: true }),
|
||
});
|
||
if (!data || !data.ok) throw new Error((data && data.error) || "continue");
|
||
} catch (e) {
|
||
window.alert(rewindText(translate, "continue.fail", "继续失败: {error}").replace("{error}", String((e && e.message) || e)));
|
||
}
|
||
},
|
||
}, rewindText(translate, "continue.label", "继续"));
|
||
}
|
||
return h(ContinueButton, this.props);
|
||
}
|
||
}
|
||
|
||
function ContinueButton(props) {
|
||
const t = props.t || translate;
|
||
const sessions = rewindSessions;
|
||
const sessionId = props.sessionId || currentSessionId(sessions);
|
||
const [busy, setBusy] = useState(false);
|
||
const [notice, setNotice] = useState("");
|
||
const [info, setInfo] = useState(null);
|
||
|
||
useEffect(() => {
|
||
if (!sessionId) {
|
||
setInfo(null);
|
||
return;
|
||
}
|
||
let cancelled = false;
|
||
const tick = () => {
|
||
apiJson("/dsh-purge/continue?sessionId=" + encodeURIComponent(sessionId))
|
||
.then((data) => { if (!cancelled && data && data.ok) setInfo(data); })
|
||
.catch(() => {});
|
||
};
|
||
tick();
|
||
const timer = window.setInterval(tick, 1200);
|
||
return () => {
|
||
cancelled = true;
|
||
window.clearInterval(timer);
|
||
};
|
||
}, [sessionId]);
|
||
|
||
const canContinue = Boolean(info && info.canContinue);
|
||
const left = info ? Math.max(0, (info.continueMax || 0) - (info.continueUsed || 0)) : 0;
|
||
const runContinue = async () => {
|
||
if (!sessionId || busy || !canContinue) return;
|
||
setBusy(true);
|
||
setNotice("");
|
||
try {
|
||
const data = await apiJson("/dsh-purge/continue", {
|
||
method: "POST",
|
||
headers: { "content-type": "application/json" },
|
||
body: JSON.stringify({ sessionId }),
|
||
});
|
||
if (!data || !data.ok) throw new Error((data && data.error) || "continue");
|
||
setInfo((prev) => prev ? { ...prev, ...data, canContinue: false, continueUsed: data.used } : data);
|
||
} catch (e) {
|
||
setNotice(rewindText(t, "continue.fail", "继续失败: {error}").replace("{error}", String((e && e.message) || e)));
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
};
|
||
|
||
const label = busy
|
||
? rewindText(t, "continue.busy", "继续中…")
|
||
: rewindText(t, "continue.label", "继续");
|
||
const title = notice
|
||
|| (canContinue
|
||
? rewindText(t, "continue.ready", "继续(还可 {left} 次)").replace("{left}", String(left))
|
||
: (info && info.reason && left <= 0
|
||
? rewindText(t, "continue.limit", "已达继续上限")
|
||
: rewindText(t, "continue.aria", "异常停止后继续")));
|
||
return h("button", {
|
||
type: "button",
|
||
className: "dshp-continue" + (canContinue ? " is-ready" : ""),
|
||
title,
|
||
"aria-label": rewindText(t, "continue.aria", "异常停止后继续"),
|
||
disabled: busy || !sessionId || !canContinue,
|
||
onMouseDown: (e) => e.preventDefault(),
|
||
onClick: (e) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
runContinue();
|
||
},
|
||
},
|
||
h("svg", { viewBox: "0 0 16 16", width: "14", height: "14", "aria-hidden": true },
|
||
h("path", { fill: "currentColor", d: "M4.2 2.8v10.4L13 8 4.2 2.8z" }),
|
||
),
|
||
label,
|
||
);
|
||
}
|
||
|
||
function rewindMenuNode(t, menuPos, menuRef, runRewind) {
|
||
const menu = h("div", {
|
||
className: "dshp-rewind-menu",
|
||
role: "menu",
|
||
ref: menuRef,
|
||
style: menuPos ? { left: menuPos.left + "px", top: menuPos.top + "px" } : { left: "8px", bottom: "48px", transform: "none" },
|
||
},
|
||
h("button", {
|
||
type: "button",
|
||
className: "dshp-rewind-item",
|
||
onMouseDown: (e) => { e.preventDefault(); e.stopPropagation(); },
|
||
onClick: (e) => { e.preventDefault(); e.stopPropagation(); runRewind("once"); },
|
||
},
|
||
h("b", null, rewindText(t, "rewind.once", "回退上一步")),
|
||
h("span", null, rewindText(t, "rewind.once.hint", "撤回最后一步已进行的对话,新会话接上之前的上下文;那句回到输入框再发送")),
|
||
),
|
||
h("button", {
|
||
type: "button",
|
||
className: "dshp-rewind-item",
|
||
onMouseDown: (e) => { e.preventDefault(); e.stopPropagation(); },
|
||
onClick: (e) => { e.preventDefault(); e.stopPropagation(); runRewind("round"); },
|
||
},
|
||
h("b", null, rewindText(t, "rewind.round", "回退上一轮")),
|
||
h("span", null, rewindText(t, "rewind.round.hint", "撤回上一整轮(含子代理)已进行的对话,新会话接上之前的上下文;那句回到输入框再发送")),
|
||
),
|
||
);
|
||
try {
|
||
const rd = require("react-dom");
|
||
if (rd && typeof rd.createPortal === "function" && typeof document !== "undefined") {
|
||
return rd.createPortal(menu, document.body);
|
||
}
|
||
} catch { /* 没有 portal 时菜单仍挂在按钮旁 */ }
|
||
return menu;
|
||
}
|
||
|
||
class RewindFallback extends Component {
|
||
constructor(p) {
|
||
super(p);
|
||
this.state = { menu: false, busy: false, notice: "", menuPos: null };
|
||
this.wrap = null;
|
||
this.menuEl = null;
|
||
this.onDoc = (event) => {
|
||
if (this.wrap && this.wrap.contains(event.target)) return;
|
||
if (this.menuEl && this.menuEl.contains(event.target)) return;
|
||
this.setState({ menu: false });
|
||
};
|
||
}
|
||
componentDidMount() {
|
||
document.addEventListener("mousedown", this.onDoc);
|
||
}
|
||
componentWillUnmount() {
|
||
document.removeEventListener("mousedown", this.onDoc);
|
||
}
|
||
run(mode) {
|
||
const sessionId = this.props.sessionId || currentSessionId(rewindSessions);
|
||
if (!sessionId || this.state.busy) return;
|
||
this.setState({ busy: true, menu: false, notice: "" });
|
||
apiJson("/dsh-purge/rewind", {
|
||
method: "POST",
|
||
headers: { "content-type": "application/json" },
|
||
body: JSON.stringify({ sessionId, mode }),
|
||
}).then(async (data) => {
|
||
if (!data || !data.ok || !data.sessionId || data.cleared === false) {
|
||
throw new Error((data && data.error) || "没有清掉之前发送的内容");
|
||
}
|
||
if (data.at) rewindSeenAt = data.at;
|
||
const text = isPluginDraft(data.text) ? "" : (data.text || "");
|
||
armComposerFill(data.sessionId, text);
|
||
await openRewoundSession(rewindSessions, data.sessionId);
|
||
scheduleComposerFill(data.sessionId, text);
|
||
this.setState({ busy: false });
|
||
}).catch((e) => {
|
||
this.setState({
|
||
busy: false,
|
||
notice: "回退失败: " + String((e && e.message) || e),
|
||
});
|
||
});
|
||
}
|
||
openMenu() {
|
||
const rect = this.wrap && this.wrap.getBoundingClientRect ? this.wrap.getBoundingClientRect() : null;
|
||
this.setState({
|
||
menu: !this.state.menu,
|
||
notice: "",
|
||
menuPos: rect ? { left: Math.max(8, rect.right - 228), top: Math.max(8, rect.top - 8) } : null,
|
||
});
|
||
}
|
||
render() {
|
||
const sessionId = this.props.sessionId || currentSessionId(rewindSessions);
|
||
const t = this.props.t || translate;
|
||
const label = this.state.busy ? "回退中…" : (this.state.notice ? this.state.notice.slice(0, 18) : "回退");
|
||
return h("div", { className: "dshp-rewind-wrap", ref: (node) => { this.wrap = node; } },
|
||
h("style", null, REWIND_CSS),
|
||
h("button", {
|
||
type: "button",
|
||
className: "dshp-rewind",
|
||
disabled: this.state.busy || !sessionId,
|
||
onMouseDown: (e) => e.preventDefault(),
|
||
onClick: (e) => { e.preventDefault(); this.openMenu(); },
|
||
}, label),
|
||
this.state.menu ? rewindMenuNode(t, this.state.menuPos, (node) => { this.menuEl = node; }, (mode) => this.run(mode)) : null,
|
||
);
|
||
}
|
||
}
|
||
|
||
function RewindButton(props) {
|
||
const t = props.t || translate;
|
||
const sessions = rewindSessions;
|
||
const inputActions = props.inputActions;
|
||
const sessionId = props.sessionId || currentSessionId(sessions);
|
||
const wrapRef = useRef(null);
|
||
const menuRef = useRef(null);
|
||
const [busy, setBusy] = useState(false);
|
||
const [notice, setNotice] = useState("");
|
||
const [menu, setMenu] = useState(false);
|
||
const [menuPos, setMenuPos] = useState(null);
|
||
|
||
useEffect(() => {
|
||
if (!sessionId) return;
|
||
const armed = takeArmedFill(sessionId);
|
||
if (armed) {
|
||
scheduleComposerFill(sessionId, armed, inputActions);
|
||
return;
|
||
}
|
||
scheduleEchoClear(sessionId, inputActions);
|
||
}, [sessionId, inputActions]);
|
||
|
||
useEffect(() => {
|
||
if (!menu) return;
|
||
const onDoc = (event) => {
|
||
if (wrapRef.current && wrapRef.current.contains(event.target)) return;
|
||
if (menuRef.current && menuRef.current.contains(event.target)) return;
|
||
setMenu(false);
|
||
};
|
||
document.addEventListener("mousedown", onDoc);
|
||
return () => document.removeEventListener("mousedown", onDoc);
|
||
}, [menu]);
|
||
|
||
const runRewind = async (mode) => {
|
||
if (!sessionId || busy) return;
|
||
setBusy(true);
|
||
setMenu(false);
|
||
setNotice("");
|
||
try {
|
||
const data = await apiJson("/dsh-purge/rewind", {
|
||
method: "POST",
|
||
headers: { "content-type": "application/json" },
|
||
body: JSON.stringify({ sessionId, mode }),
|
||
});
|
||
if (!data || !data.ok || !data.sessionId || data.cleared === false) {
|
||
throw new Error((data && data.error) || "没有清掉之前发送的内容");
|
||
}
|
||
if (data.at) rewindSeenAt = data.at;
|
||
const text = isPluginDraft(data.text) ? "" : (data.text || "");
|
||
armComposerFill(data.sessionId, text);
|
||
await openRewoundSession(sessions, data.sessionId);
|
||
scheduleComposerFill(data.sessionId, text);
|
||
} catch (e) {
|
||
const message = rewindText(t, "rewind.fail", "回退失败: {error}").replace("{error}", String((e && e.message) || e));
|
||
setNotice(message);
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
};
|
||
|
||
const onClick = (event) => {
|
||
event?.preventDefault?.();
|
||
event?.stopPropagation?.();
|
||
if (!sessionId || busy) return;
|
||
if (menu) {
|
||
setMenu(false);
|
||
return;
|
||
}
|
||
const rect = wrapRef.current?.getBoundingClientRect?.();
|
||
if (rect) {
|
||
setMenuPos({
|
||
left: Math.max(8, rect.right - 228),
|
||
top: Math.max(8, rect.top - 8),
|
||
});
|
||
}
|
||
setNotice("");
|
||
setMenu(true);
|
||
};
|
||
|
||
const failLabel = notice
|
||
? (notice.length > 18 ? notice.slice(0, 18) + "…" : notice)
|
||
: "";
|
||
const label = busy
|
||
? rewindText(t, "rewind.busy", "回退中…")
|
||
: (failLabel || rewindText(t, "rewind.label", "回退"));
|
||
const title = notice || rewindText(t, "rewind.aria", "回退");
|
||
return h("div", { className: "dshp-rewind-wrap", ref: wrapRef },
|
||
h("style", null, REWIND_CSS),
|
||
h("button", {
|
||
type: "button",
|
||
className: "dshp-rewind",
|
||
title,
|
||
"aria-label": rewindText(t, "rewind.aria", "回退"),
|
||
"aria-expanded": menu ? "true" : "false",
|
||
disabled: busy || !sessionId,
|
||
onMouseDown: (e) => e.preventDefault(),
|
||
onClick,
|
||
},
|
||
h("svg", { viewBox: "0 0 16 16", width: "14", height: "14", "aria-hidden": true },
|
||
h("path", {
|
||
fill: "currentColor",
|
||
d: "M7.2 3.2 3.4 7l3.8 3.8V8.6c2.8 0 4.7.7 5.9 2.3-.2-2.8-1.9-5.2-5.9-5.6V3.2z",
|
||
}),
|
||
),
|
||
label,
|
||
),
|
||
menu ? rewindMenuNode(t, menuPos, menuRef, runRewind) : null,
|
||
);
|
||
}
|
||
|
||
function installRewindWatch(ctx) {
|
||
const sessions = ctx.sessions;
|
||
if (!sessions) return () => {};
|
||
const tick = async () => {
|
||
try {
|
||
const data = await apiJson("/dsh-purge/rewind");
|
||
if (!data || !data.sessionId || !data.at || data.at <= rewindSeenAt) return;
|
||
rewindSeenAt = data.at;
|
||
const current = currentSessionId(sessions);
|
||
if (current && current !== data.parentId) return;
|
||
const text = isPluginDraft(data.text) ? "" : (data.text || "");
|
||
armComposerFill(data.sessionId, text);
|
||
scheduleComposerFill(data.sessionId, text);
|
||
await openRewoundSession(sessions, data.sessionId);
|
||
scheduleComposerFill(data.sessionId, text);
|
||
} catch { /* ignore */ }
|
||
};
|
||
const timer = window.setInterval(tick, 1200);
|
||
return () => window.clearInterval(timer);
|
||
}
|
||
|
||
/* __DSH_PURGE_DRILL_BEGIN__ */
|
||
// 内嵌演练台(源码在 lib/redteam,已并进本插件)
|
||
const __dshPurgeDrill = (() => {
|
||
var module = { exports: {} };
|
||
var exports = module.exports;
|
||
|
||
var module = { exports: {} }
|
||
var exports = module.exports
|
||
Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' })
|
||
|
||
const React = require('react')
|
||
const h = React.createElement
|
||
|
||
/*
|
||
* 演练台自己是一块浮动窗:position:fixed,用 left/top/width/height 放。
|
||
* 不给官方 frame 加 padding,不改官方输入框的宽度。嵌在清洗面板里时走 .rt-embedded。
|
||
*/
|
||
const CSS = `
|
||
.rt-dock{position:fixed;top:12px;right:12px;z-index:20;display:flex;flex-direction:column;box-sizing:border-box;
|
||
background:color-mix(in srgb,var(--dsw-alias-bg-layer-1,#1c1c1c) 52%,transparent);
|
||
backdrop-filter:blur(20px) saturate(1.3);-webkit-backdrop-filter:blur(20px) saturate(1.3);
|
||
border:1px solid color-mix(in srgb,var(--dsw-alias-border-l1,#444) 70%,transparent);
|
||
border-radius:12px;
|
||
box-shadow:0 18px 48px color-mix(in srgb,#000 28%,transparent);pointer-events:auto;color:var(--dsw-alias-label-primary);
|
||
font-size:13px;line-height:1.5}
|
||
.rt-dock.rt-embedded{position:relative;inset:auto;width:100%;height:100%;max-width:none;border:0;border-radius:0;box-shadow:none;background:transparent!important;backdrop-filter:none;-webkit-backdrop-filter:none}
|
||
.rt-grip,.rt-resize-r,.rt-resize-b,.rt-resize-c{position:absolute;z-index:3;touch-action:none}
|
||
.rt-grip{left:-3px;top:0;bottom:0;width:8px;cursor:ew-resize;background:transparent}
|
||
.rt-resize-r{right:-3px;top:0;bottom:14px;width:8px;cursor:ew-resize}
|
||
.rt-resize-b{left:8px;right:14px;bottom:-3px;height:8px;cursor:ns-resize}
|
||
.rt-resize-c{right:0;bottom:0;width:16px;height:16px;cursor:nwse-resize}
|
||
.rt-head{display:flex;align-items:center;gap:8px;padding:10px 12px;border-bottom:1px solid var(--dsw-alias-border-l1)}
|
||
.rt-title{font-weight:600;font-size:14px;display:flex;align-items:center;gap:6px;white-space:nowrap}
|
||
.rt-dot{width:8px;height:8px;border-radius:50%;background:var(--dsw-alias-brand-primary)}
|
||
.rt-spacer{flex:1}
|
||
.rt-btn{border:1px solid var(--dsw-alias-border-l1);background:var(--dsw-alias-bg-layer-2);color:inherit;
|
||
border-radius:6px;padding:3px 9px;font-size:12px;cursor:pointer;font-family:inherit;white-space:nowrap}
|
||
.rt-btn:hover{border-color:var(--dsw-alias-border-l2)}
|
||
.rt-btn-primary{background:var(--dsw-alias-brand-primary);border-color:var(--dsw-alias-brand-primary);color:#fff}
|
||
.rt-btn-primary:hover{opacity:.9}
|
||
.rt-btn:disabled{opacity:.5;cursor:default}
|
||
.rt-tabs{display:flex;flex-wrap:wrap;gap:2px;padding:6px 12px 0;border-bottom:1px solid var(--dsw-alias-border-l1)}
|
||
.rt-tab{padding:8px 10px 7px;border-radius:0;cursor:pointer;font-size:12.5px;color:var(--dsw-alias-label-secondary);
|
||
display:inline-flex;align-items:center;gap:5px;border-bottom:2px solid transparent;margin-bottom:-1px;background:transparent}
|
||
.rt-tab:hover{color:var(--dsw-alias-label-primary)}
|
||
.rt-tab.on{color:var(--dsw-alias-label-primary);background:transparent;font-weight:600;border-bottom-color:var(--dsw-alias-brand-primary,#6dbf8c)}
|
||
/* 未读红点:该页签有新内容(新资产/新漏洞/新得分/新步骤…),点开看过就消失 */
|
||
.rt-tab-dot{width:7px;height:7px;border-radius:50%;background:#ef4444;flex:none;
|
||
box-shadow:0 0 0 2px color-mix(in srgb, #ef4444 22%, transparent)}
|
||
.rt-body{flex:1;min-height:0;display:flex;flex-direction:column}
|
||
.rt-split{flex:1;min-height:0;display:flex}
|
||
.rt-side{width:200px;flex:none;border-right:1px solid var(--dsw-alias-border-l1);overflow:auto;padding:8px}
|
||
.rt-main{flex:1;min-width:0;min-height:0;display:flex;flex-direction:column;overflow:hidden}
|
||
.rt-seg{padding:7px 8px;border-radius:6px;cursor:pointer;margin-bottom:4px;border:1px solid transparent}
|
||
.rt-seg:hover{background:var(--dsw-alias-bg-layer-2)}
|
||
.rt-seg.on{background:var(--dsw-alias-bg-layer-2);border-color:var(--dsw-alias-brand-primary)}
|
||
.rt-seg-cidr{font-family:ui-monospace,Menlo,monospace;font-size:12.5px}
|
||
.rt-seg-meta{font-size:11px;color:var(--dsw-alias-label-secondary);margin-top:2px}
|
||
.rt-toolbar{display:flex;gap:6px;padding:8px 10px;border-bottom:1px solid var(--dsw-alias-border-l1);flex-wrap:wrap;align-items:center}
|
||
.rt-input{background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l1);color:inherit;
|
||
border-radius:6px;padding:4px 8px;font-size:12px;font-family:inherit;outline:none;min-width:0}
|
||
.rt-input:focus{border-color:var(--dsw-alias-brand-primary)}
|
||
.rt-input option,.rt-eng-select option{background:var(--dsw-alias-bg-layer-1,#1c1c1c);color:var(--dsw-alias-label-primary,#f2f2f2)}
|
||
.rt-eng-select{color:var(--dsw-alias-label-primary,#f2f2f2);background:var(--dsw-alias-bg-layer-2,#2a2a2a)}
|
||
.rt-table{flex:1;overflow:auto}
|
||
.rt-row{display:grid;grid-template-columns:150px 74px 104px 1.15fr 1fr;gap:8px;padding:6px 10px;
|
||
border-bottom:1px solid var(--dsw-alias-border-l1);align-items:center;cursor:pointer;font-size:12.5px}
|
||
.rt-row:hover{background:var(--dsw-alias-bg-layer-2)}
|
||
.rt-row.head{cursor:default;color:var(--dsw-alias-label-secondary);font-size:11.5px;font-weight:600;position:sticky;top:0;
|
||
background:var(--dsw-alias-bg-layer-1);z-index:1}
|
||
.rt-row.head:hover{background:var(--dsw-alias-bg-layer-1)}
|
||
.rt-mono{font-family:ui-monospace,Menlo,monospace}
|
||
.rt-tag{display:inline-block;padding:0 5px;border-radius:4px;font-size:11px;margin-right:4px;
|
||
border:1px solid var(--dsw-alias-border-l1);color:var(--dsw-alias-label-secondary);white-space:nowrap}
|
||
.rt-tag-passive{color:#8b5cf6;border-color:#8b5cf655;background:#8b5cf61a}
|
||
.rt-tag-active{color:#f59e0b;border-color:#f59e0b55;background:#f59e0b1a}
|
||
.rt-tag-live{color:#10b981;border-color:#10b98155;background:#10b9811a}
|
||
.rt-tag-warn{color:#ef4444;border-color:#ef444455;background:#ef44441a}
|
||
/* 纪律提示条:不满足交付要求(非冰蝎/哥斯拉马、没有 suo5 隧道)时顶在区块最上方 */
|
||
.rt-hint{border-radius:6px;padding:7px 9px;margin-bottom:8px;font-size:11.5px;line-height:1.6;
|
||
border:1px dashed #ef444488;background:#ef44440f;color:var(--dsw-alias-label-primary)}
|
||
.rt-hint b{color:#ef4444}
|
||
.rt-tag-dead{color:var(--dsw-alias-label-secondary)}
|
||
.rt-expand{grid-column:1/-1;padding:8px 4px 10px;font-size:12px;color:var(--dsw-alias-label-secondary)}
|
||
.rt-kv{display:flex;gap:8px;margin-bottom:3px;align-items:baseline}
|
||
.rt-kv b{color:var(--dsw-alias-label-primary);font-weight:600;min-width:64px;flex:none}
|
||
/* 图谱视图已移除(见 AssetsTab:资产关系由「域名维度」与 redteam_attack_path 工具承担) */
|
||
.rt-pane{flex:1;min-height:0;overflow:auto;padding:12px}
|
||
.rt-card{border:1px solid color-mix(in srgb,var(--dsw-alias-border-l1,#444) 75%,transparent);border-radius:8px;padding:10px;margin-bottom:10px;
|
||
background:color-mix(in srgb,var(--dsw-alias-bg-layer-2,#2a2a2a) 48%,transparent);backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px)}
|
||
.rt-card h4{margin:0 0 6px;font-size:13px}
|
||
.rt-textarea{width:100%;min-height:260px;background:var(--dsw-alias-bg-base);border:1px solid var(--dsw-alias-border-l1);
|
||
color:inherit;border-radius:6px;padding:8px;font-size:12.5px;font-family:ui-monospace,Menlo,monospace;
|
||
line-height:1.6;resize:vertical;outline:none;box-sizing:border-box}
|
||
.rt-textarea:focus{border-color:var(--dsw-alias-brand-primary)}
|
||
.rt-list{width:210px;flex:none;border-right:1px solid var(--dsw-alias-border-l1);overflow:auto;padding:8px}
|
||
.rt-item{padding:7px 8px;border-radius:6px;cursor:pointer;margin-bottom:4px;border:1px solid transparent}
|
||
.rt-item:hover{background:var(--dsw-alias-bg-layer-2)}
|
||
.rt-item.on{background:var(--dsw-alias-bg-layer-2);border-color:var(--dsw-alias-brand-primary)}
|
||
.rt-item-name{font-weight:600;font-size:12.5px}
|
||
.rt-item-desc{font-size:11px;color:var(--dsw-alias-label-secondary);margin-top:2px;
|
||
display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
|
||
.rt-empty{padding:24px;text-align:center;color:var(--dsw-alias-label-secondary);font-size:12.5px}
|
||
.rt-err{color:var(--dsw-alias-state-error-primary);font-size:12px;padding:6px 10px}
|
||
.rt-foot{padding:8px 12px;border-top:1px solid var(--dsw-alias-border-l1);font-size:11px;
|
||
color:var(--dsw-alias-label-secondary);display:flex;gap:12px;flex-wrap:wrap;align-items:center}
|
||
.rt-icon-btn{display:flex;align-items:center;justify-content:center;gap:6px;width:100%;border:1px solid var(--dsw-alias-border-l1);
|
||
background:transparent;color:inherit;border-radius:6px;padding:6px 8px;cursor:pointer;font-family:inherit;font-size:12.5px}
|
||
.rt-icon-btn:hover{background:var(--dsw-alias-bg-layer-2)}
|
||
.rt-icon-btn.on{border-color:var(--dsw-alias-brand-primary);color:var(--dsw-alias-brand-primary)}
|
||
.rt-hbtn{border:1px solid var(--dsw-alias-border-l1);background:transparent;color:inherit;border-radius:6px;
|
||
padding:2px 8px;font-size:12px;cursor:pointer;font-family:inherit;display:flex;align-items:center;gap:5px}
|
||
.rt-hbtn:hover{background:var(--dsw-alias-bg-layer-2)}
|
||
.rt-hbtn.on{border-color:var(--dsw-alias-brand-primary);color:var(--dsw-alias-brand-primary)}
|
||
.rt-test{display:inline-block;padding:0 5px;border-radius:4px;font-size:11px;white-space:nowrap;
|
||
border:1px solid var(--dsw-alias-border-l1);color:var(--dsw-alias-label-secondary)}
|
||
.rt-test-testing{color:#f59e0b;border-color:#f59e0b55;background:#f59e0b1a}
|
||
.rt-test-tested{color:#10b981;border-color:#10b98155;background:#10b9811a}
|
||
.rt-test-blocked{color:#fff;background:#ef4444;border-color:#ef4444}
|
||
.rt-test-abandoned{color:#94a3b8;border-color:#94a3b855;background:#94a3b81a}
|
||
.rt-test-no_surface{color:#6366f1;border-color:#6366f155;background:#6366f11a}
|
||
.rt-scope-testing{color:#5b21b6;background:#ddd6fe}
|
||
.rt-pri{display:inline-block;padding:0 6px;border-radius:4px;font-size:11px;font-weight:600;white-space:nowrap}
|
||
.rt-pri-high{color:#fff;background:#ef4444}
|
||
.rt-pri-medium{color:#fff;background:#f59e0b}
|
||
.rt-pri-low{color:#fff;background:#94a3b8}
|
||
.rt-score-row{display:grid;grid-template-columns:16px 62px minmax(0,1fr) 116px 66px;gap:8px;padding:7px 10px;
|
||
border-bottom:1px solid var(--dsw-alias-border-l1);align-items:center;font-size:12.5px;cursor:pointer}
|
||
.rt-score-row:hover{background:var(--dsw-alias-bg-layer-2)}
|
||
.rt-score-row.head{cursor:default;color:var(--dsw-alias-label-secondary);font-size:11.5px;font-weight:600;
|
||
position:sticky;top:0;background:var(--dsw-alias-bg-layer-1);z-index:1}
|
||
.rt-score-detail{grid-column:1/-1;padding:8px 4px 10px;font-size:12px;color:var(--dsw-alias-label-secondary)}
|
||
/* 得分目标按合并版的 8 个类别分组:类别头 + 组内按分值升序 */
|
||
.rt-score-group{display:flex;align-items:center;gap:8px;padding:7px 10px 5px;margin-top:2px;
|
||
font-size:12px;font-weight:600;border-top:1px solid var(--dsw-alias-border-l1);background:var(--dsw-alias-bg-layer-2)}
|
||
.rt-score-group:first-child{border-top:none}
|
||
.rt-score-group .rt-sg-sub{font-weight:400;font-size:11px;color:var(--dsw-alias-label-secondary)}
|
||
/* ── 会话与入口:每张卡片分「标题行 / 关键事实 / 可用命令 / 备注」四段,避免一行糊在一起 ── */
|
||
.rt-sess-facts{display:flex;flex-direction:column;gap:2px;margin-top:5px}
|
||
.rt-sess-fact{display:flex;gap:6px;font-size:11.5px;line-height:1.5}
|
||
.rt-sess-fact>b{flex:none;min-width:62px;font-weight:600;color:var(--dsw-alias-label-secondary)}
|
||
.rt-sess-fact>span{min-width:0;overflow-wrap:anywhere}
|
||
.rt-sess-cmd{margin-top:6px}
|
||
.rt-sess-cmd>b{display:block;font-size:11px;color:var(--dsw-alias-label-secondary);margin-bottom:3px;font-weight:600}
|
||
.rt-sess-fold{margin-top:6px;font-size:11.5px}
|
||
.rt-sess-fold>summary{cursor:pointer;color:var(--dsw-alias-label-secondary);user-select:none}
|
||
.rt-sess-fold>summary:hover{color:var(--dsw-alias-label-primary)}
|
||
.rt-score-row .rt-scope{font-size:10.5px;color:var(--dsw-alias-label-secondary)}
|
||
.rt-score-form{display:grid;grid-template-columns:1fr 1fr;gap:6px;margin-bottom:6px}
|
||
.rt-score-form input,.rt-score-form select{width:100%;box-sizing:border-box}
|
||
.rt-vrow{display:grid;grid-template-columns:62px minmax(0,1fr) 132px 108px 74px 52px;gap:8px;padding:6px 10px;
|
||
border-bottom:1px solid var(--dsw-alias-border-l1);align-items:center;font-size:12.5px;cursor:pointer}
|
||
.rt-vrow:hover{background:var(--dsw-alias-bg-layer-2)}
|
||
.rt-vrow.head{cursor:default;color:var(--dsw-alias-label-secondary);font-size:11.5px;font-weight:600;
|
||
position:sticky;top:0;background:var(--dsw-alias-bg-layer-1);z-index:1}
|
||
.rt-vdetail{grid-column:1/-1;padding:8px 4px 10px;font-size:12px;color:var(--dsw-alias-label-secondary)}
|
||
.rt-vdetail .rt-kv{margin-bottom:4px}
|
||
.rt-actions{display:flex;gap:6px;margin-top:6px}
|
||
.rt-section{padding:8px 10px 2px;font-size:11.5px;font-weight:600;color:var(--dsw-alias-label-secondary)}
|
||
.rt-link{color:var(--dsw-alias-brand-primary);text-decoration:none;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||
.rt-link:hover{text-decoration:underline}
|
||
.rt-full{position:fixed;inset:0;z-index:60;display:flex;flex-direction:column;
|
||
background:var(--dsw-alias-bg-base);color:var(--dsw-alias-label-primary);font-size:13px;line-height:1.5;
|
||
border-top:3px solid var(--dsw-alias-brand-primary)}
|
||
.rt-full .rt-head{padding:12px 18px}
|
||
.rt-full .rt-tabs{padding:10px 18px 0;gap:6px;flex-wrap:wrap}
|
||
.rt-full .rt-side{width:260px}
|
||
.rt-full .rt-list{width:280px}
|
||
.rt-full .rt-row{grid-template-columns:190px 90px 130px 1.4fr 1.2fr}
|
||
.rt-full .rt-vrow{grid-template-columns:80px minmax(0,1.6fr) 200px 150px 90px 64px}
|
||
.rt-full .rt-pane{padding:18px}
|
||
.rt-full .rt-textarea{min-height:60vh}
|
||
.rt-full .rt-foot{padding:10px 18px;font-size:12px}
|
||
.rt-full .rt-body{max-width:1400px;width:100%;margin:0 auto;flex:1;min-height:0;display:flex;flex-direction:column}
|
||
.rt-chain{flex:1;overflow:auto;padding:10px 12px}
|
||
.rt-step{display:flex;gap:10px;padding:8px 6px;border-left:2px solid var(--dsw-alias-border-l1);margin-left:6px}
|
||
.rt-step:last-child{border-left-color:transparent}
|
||
.rt-step-dot{width:22px;height:22px;flex:none;border-radius:50%;display:flex;align-items:center;justify-content:center;
|
||
font-size:11px;font-weight:700;color:#fff;background:#64748b;margin-left:-13px}
|
||
.rt-step-body{min-width:0}
|
||
.rt-step-title{font-weight:600;font-size:13px}
|
||
.rt-step-meta{font-size:11.5px;color:var(--dsw-alias-label-secondary);margin-top:2px;word-break:break-word}
|
||
.rt-stage-recon{background:#6366f1}
|
||
.rt-stage-vuln{background:#f59e0b}
|
||
.rt-stage-exploit{background:#ef4444}
|
||
.rt-stage-access{background:#10b981}
|
||
.rt-stage-pivot{background:#8b5cf6}
|
||
.rt-stage-data{background:#0ea5e9}
|
||
.rt-stage-other{background:#64748b}
|
||
.rt-step-head{display:flex;align-items:baseline;gap:6px;flex-wrap:wrap}
|
||
.rt-step-time{font-size:11px;color:var(--dsw-alias-label-secondary);margin-left:auto;white-space:nowrap}
|
||
.rt-step-detail{font-size:12px;margin-top:5px;white-space:pre-wrap;word-break:break-word;
|
||
border-left:2px solid var(--dsw-alias-border-l1);padding:2px 0 2px 9px;line-height:1.6}
|
||
.rt-chip{display:inline-flex;align-items:center;gap:5px;font-size:11.5px;padding:1px 7px;border-radius:5px;
|
||
border:1px solid var(--dsw-alias-border-l1);background:var(--dsw-alias-bg-layer-2);margin:4px 5px 0 0;max-width:100%}
|
||
.rt-chip>i{font-style:normal;color:var(--dsw-alias-label-secondary);font-size:10.5px}
|
||
.rt-chip>span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:420px}
|
||
.rt-stage-tag{display:inline-block;padding:0 6px;border-radius:4px;font-size:11px;color:#fff;font-weight:600}
|
||
.rt-st-recon{background:#6366f1}.rt-st-vuln{background:#f59e0b}.rt-st-exploit{background:#ef4444}
|
||
.rt-st-access{background:#10b981}.rt-st-pivot{background:#8b5cf6}.rt-st-data{background:#0ea5e9}.rt-st-other{background:#64748b}
|
||
.rt-sev{display:inline-block;padding:0 6px;border-radius:4px;font-size:11px;font-weight:600;border:1px solid transparent}
|
||
.rt-sev-critical{color:#fff;background:#b91c1c}.rt-sev-high{color:#fff;background:#ef4444}
|
||
.rt-sev-medium{color:#7c2d12;background:#fdba74}.rt-sev-low{color:#1e3a8a;background:#bfdbfe}
|
||
.rt-sev-info{color:var(--dsw-alias-label-secondary);background:var(--dsw-alias-bg-layer-2);border-color:var(--dsw-alias-border-l1)}
|
||
.rt-dot-on{display:inline-block;width:7px;height:7px;border-radius:50%;background:#10b981;box-shadow:0 0 0 3px #10b98133}
|
||
.rt-dot-off{display:inline-block;width:7px;height:7px;border-radius:50%;background:#ef4444;box-shadow:0 0 0 3px #ef444433}
|
||
.rt-dot-unk{display:inline-block;width:7px;height:7px;border-radius:50%;background:#94a3b8;box-shadow:0 0 0 3px #94a3b833}
|
||
.rt-sess-grid{display:grid;grid-template-columns:1fr;gap:8px;padding:10px 12px}
|
||
.rt-sess{border:1px solid var(--dsw-alias-border-l1);border-radius:8px;padding:9px 10px;background:var(--dsw-alias-bg-layer-2)}
|
||
.rt-sess-head{display:flex;align-items:center;gap:7px;flex-wrap:wrap}
|
||
.rt-sess-title{font-weight:600;font-size:12.5px;font-family:ui-monospace,Menlo,monospace;word-break:break-all}
|
||
.rt-sess-sub{font-size:11.5px;color:var(--dsw-alias-label-secondary);margin-top:3px;word-break:break-word}
|
||
.rt-code{font-family:ui-monospace,Menlo,monospace;font-size:11px;background:var(--dsw-alias-bg-base);
|
||
border:1px solid var(--dsw-alias-border-l1);border-radius:4px;padding:1px 5px;cursor:pointer;word-break:break-all}
|
||
.rt-code:hover{border-color:var(--dsw-alias-brand-primary)}
|
||
.rt-evi{border:1px solid var(--dsw-alias-border-l1);border-radius:8px;overflow:hidden;margin-top:8px}
|
||
.rt-evi-head{display:flex;align-items:center;gap:8px;padding:5px 9px;background:var(--dsw-alias-bg-layer-2);
|
||
font-size:11.5px;font-weight:600;border-bottom:1px solid var(--dsw-alias-border-l1)}
|
||
.rt-evi-body{margin:0;padding:9px 11px;font-family:ui-monospace,Menlo,monospace;font-size:11.5px;line-height:1.6;
|
||
white-space:pre-wrap;word-break:break-word;max-height:340px;overflow:auto;background:var(--dsw-alias-bg-base)}
|
||
.rt-evi-body.req{max-height:220px}
|
||
.rt-hl-req{color:#10b981;font-weight:600}
|
||
.rt-hl-res{color:#0ea5e9;font-weight:600}
|
||
.rt-gain{display:inline-flex;align-items:center;gap:5px;font-size:11.5px;font-weight:600;padding:2px 8px;border-radius:12px;
|
||
color:#065f46;background:#a7f3d0;border:1px solid #10b98155}
|
||
.rt-total{font-size:20px;font-weight:700;font-family:ui-monospace,Menlo,monospace}
|
||
.rt-sidehead{padding:7px 8px 2px;font-size:11px;font-weight:600;color:var(--dsw-alias-label-secondary);
|
||
display:flex;align-items:center;gap:5px}
|
||
.rt-sidehead-btn{cursor:pointer;outline:none;padding:6px 6px 5px;border-radius:5px;user-select:none}
|
||
.rt-sidehead-btn:hover{color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-layer-1)}
|
||
.rt-sidehead-btn:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:-2px}
|
||
.rt-scope{display:inline-block;padding:0 4px;border-radius:3px;font-size:10px;font-weight:700;line-height:15px;flex:none}
|
||
.rt-scope-internal{color:#0e7490;background:#a5f3fc}
|
||
.rt-scope-external{color:#9a3412;background:#fed7aa}
|
||
.rt-hits{display:flex;flex-direction:column;gap:6px;margin-top:7px}
|
||
.rt-hit{border:1px solid var(--dsw-alias-border-l1);border-left:3px solid #10b981;border-radius:6px;
|
||
padding:7px 9px;background:var(--dsw-alias-bg-layer-2)}
|
||
.rt-hit-head{display:flex;align-items:center;gap:7px;flex-wrap:wrap}
|
||
.rt-hit-idx{width:16px;height:16px;border-radius:50%;background:#10b981;color:#fff;font-size:10.5px;font-weight:700;
|
||
display:inline-flex;align-items:center;justify-content:center;flex:none}
|
||
.rt-hit-target{font-family:ui-monospace,Menlo,monospace;font-weight:600;font-size:12px;word-break:break-all}
|
||
.rt-hit-time{margin-left:auto;font-size:11px;color:var(--dsw-alias-label-secondary);white-space:nowrap}
|
||
.rt-hit-evi{font-family:ui-monospace,Menlo,monospace;font-size:11.5px;line-height:1.65;white-space:pre-wrap;word-break:break-word;
|
||
background:var(--dsw-alias-bg-base);border:1px solid var(--dsw-alias-border-l1);border-radius:5px;padding:6px 8px;margin-top:5px}
|
||
.rt-hit-note{font-size:11.5px;color:var(--dsw-alias-label-secondary);margin-top:4px}
|
||
.rt-cred{border:1px solid var(--dsw-alias-border-l1);border-left:3px solid #f59e0b;border-radius:6px;
|
||
padding:8px 10px;background:var(--dsw-alias-bg-layer-2);margin-bottom:7px}
|
||
.rt-cred-head{display:flex;align-items:center;gap:7px;flex-wrap:wrap}
|
||
.rt-cred-host{font-family:ui-monospace,Menlo,monospace;font-weight:600;font-size:12.5px;word-break:break-all}
|
||
.rt-secret{font-family:ui-monospace,Menlo,monospace;font-size:12.5px;line-height:1.6;background:#fef3c7;color:#78350f;
|
||
border:1px solid #f59e0b66;border-radius:5px;padding:6px 9px;margin-top:6px;white-space:pre-wrap;word-break:break-all;
|
||
user-select:all;cursor:text}
|
||
.rt-secret-none{font-family:ui-monospace,Menlo,monospace;font-size:11.5px;color:var(--dsw-alias-state-error-primary);
|
||
border:1px dashed var(--dsw-alias-state-error-primary);border-radius:5px;padding:5px 9px;margin-top:6px}
|
||
.rt-cred-meta{font-size:11.5px;color:var(--dsw-alias-label-secondary);margin-top:5px;word-break:break-word}
|
||
.rt-stage-score{background:#10b981}
|
||
.rt-counted{font-family:ui-monospace,Menlo,monospace;font-size:12px;font-weight:700;color:#065f46;background:#a7f3d0;border:1px solid #10b98155;border-radius:9px;padding:0 7px}
|
||
.rt-scorepts{font-size:11.5px;font-weight:700;color:#065f46;background:#a7f3d0;border:1px solid #10b98155;
|
||
border-radius:10px;padding:0 7px;white-space:nowrap}
|
||
.rt-livebar{display:flex;align-items:center;gap:7px;padding:7px 12px;border-bottom:1px solid var(--dsw-alias-border-l1);
|
||
background:var(--dsw-alias-bg-layer-2);flex-wrap:wrap}
|
||
.rt-live-dot{width:8px;height:8px;border-radius:50%;background:#10b981;flex:none;animation:rt-pulse 1.6s ease-in-out infinite}
|
||
.rt-live-dot.idle{background:#94a3b8;animation:none}
|
||
@keyframes rt-pulse{0%,100%{opacity:1;box-shadow:0 0 0 0 #10b98166}50%{opacity:.5;box-shadow:0 0 0 5px #10b98100}}
|
||
.rt-live-body{padding:8px 12px 2px;max-height:44vh;overflow:auto}
|
||
.rt-atest{border:1px solid var(--dsw-alias-border-l1);border-left:3px solid #10b981;border-radius:6px;
|
||
padding:7px 10px;background:var(--dsw-alias-bg-layer-2);margin-bottom:6px}
|
||
.rt-atest.past{border-left-color:#94a3b8;opacity:.85}
|
||
.rt-atest-head{display:flex;align-items:center;gap:7px;flex-wrap:wrap}
|
||
.rt-atest-ip{font-family:ui-monospace,Menlo,monospace;font-weight:600;font-size:12.5px;word-break:break-all}
|
||
.rt-atest-meta{font-size:11.5px;color:var(--dsw-alias-label-secondary);margin-top:3px;word-break:break-word}
|
||
.rt-atest-notes{font-family:ui-monospace,Menlo,monospace;font-size:11px;line-height:1.6;white-space:pre-wrap;
|
||
word-break:break-word;background:var(--dsw-alias-bg-base);border-radius:4px;padding:5px 7px;margin-top:4px;max-height:76px;overflow:auto}
|
||
.rt-concl{display:flex;align-items:center;gap:4px;padding:6px 12px;border-bottom:1px solid var(--dsw-alias-border-l1);
|
||
background:var(--dsw-alias-bg-layer-2);flex-wrap:wrap}
|
||
.rt-concl-i{display:inline-flex;align-items:baseline;gap:4px;padding:2px 8px;border-radius:6px;cursor:pointer;
|
||
border:1px solid transparent}
|
||
.rt-concl-i:hover{border-color:var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-1)}
|
||
.rt-concl-i.on{border-color:var(--dsw-alias-brand-primary);background:var(--dsw-alias-bg-layer-1)}
|
||
.rt-concl-i b{font-family:ui-monospace,Menlo,monospace;font-size:14px;font-weight:700}
|
||
.rt-concl-i>span{color:var(--dsw-alias-label-secondary);font-size:11.5px}
|
||
.rt-more{color:var(--dsw-alias-brand-primary);font-size:11.5px;cursor:pointer;user-select:none;margin-top:4px;display:inline-block}
|
||
.rt-more:hover{text-decoration:underline}
|
||
.rt-subtabs{display:flex;gap:4px;padding:6px 10px 0;border-bottom:1px solid var(--dsw-alias-border-l1);align-items:center}
|
||
.rt-subtab{padding:4px 10px;border-radius:6px 6px 0 0;cursor:pointer;font-size:12px;color:var(--dsw-alias-label-secondary)}
|
||
.rt-subtab.on{color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-layer-2);font-weight:600}
|
||
.rt-sevbar{width:3px;border-radius:2px;align-self:stretch;flex:none;margin-right:2px}
|
||
/* ── 折叠层次体系 ─────────────────────────────────────────────────────────────
|
||
L1 折叠头 .rt-sec :通栏、无圆角无边框、左色条、深底 —— 永远是"扁"的
|
||
L2 内容卡 .rt-atest/.rt-hit/.rt-cred/.rt-evi:内缩、有边框、圆角 —— 立起来
|
||
L3 详情/长文本 .rt-clip-body / .rt-evi-body :无边框、最浅、等宽
|
||
L4 子项容器 .rt-sec-body:左缩进 + 竖引导线,表明"属于上面那个头"
|
||
──────────────────────────────────────────────────────────────────────────── */
|
||
.rt-sec-wrap{margin:0}
|
||
.rt-sec{display:flex;align-items:center;gap:8px;padding:7px 12px 7px 9px;cursor:pointer;
|
||
background:var(--dsw-alias-bg-layer-2);border-left:3px solid var(--dsw-alias-border-l2);
|
||
border-top:1px solid var(--dsw-alias-border-l1);user-select:none;outline:none}
|
||
.rt-sec:hover{background:var(--dsw-alias-bg-layer-1)}
|
||
.rt-sec:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:-2px}
|
||
.rt-sec.flat{cursor:default}
|
||
.rt-sec.flat:hover{background:var(--dsw-alias-bg-layer-2)}
|
||
.rt-sec-caret{flex:none;width:11px;font-size:10px;color:var(--dsw-alias-label-secondary);text-align:center}
|
||
.rt-sec-title{font-weight:600;font-size:13px;white-space:nowrap}
|
||
.rt-sec-count{font-size:11.5px;color:var(--dsw-alias-label-secondary);white-space:nowrap}
|
||
.rt-sec-sub{font-size:11.5px;color:var(--dsw-alias-label-secondary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||
.rt-sec-right{margin-left:auto;font-size:11px;color:var(--dsw-alias-label-secondary);white-space:nowrap;flex:none}
|
||
.rt-sec.t-stage{border-left-color:#8b5cf6}
|
||
.rt-sec.t-target{border-left-color:#0ea5e9}
|
||
.rt-sec.t-folder{border-left-color:#64748b}
|
||
.rt-sec.t-test{border-left-color:#10b981}
|
||
.rt-sec.t-queue{border-left-color:#f59e0b}
|
||
.rt-sec.t-past{border-left-color:#94a3b8}
|
||
.rt-sec-body{margin-left:12px;border-left:1px solid var(--dsw-alias-border-l1);padding:7px 0 3px 10px}
|
||
.rt-sec-body>.rt-atest:last-child,.rt-sec-body>.rt-hit:last-child{margin-bottom:2px}
|
||
/* 长文本折叠(L3):默认预览 2 行并渐隐,展开后限高滚动 */
|
||
.rt-clip{margin-top:6px}
|
||
.rt-clip-head{display:flex;align-items:center;gap:6px}
|
||
.rt-clip-label{font-size:11px;color:var(--dsw-alias-label-secondary);font-weight:600}
|
||
.rt-clip-body{font-family:ui-monospace,Menlo,monospace;font-size:11.5px;line-height:1.65;white-space:pre-wrap;
|
||
word-break:break-word;background:var(--dsw-alias-bg-base);border-radius:5px;padding:6px 8px;margin-top:3px}
|
||
.rt-clip:not(.open) .rt-clip-body{max-height:46px;overflow:hidden;
|
||
-webkit-mask-image:linear-gradient(180deg,#000 55%,transparent);mask-image:linear-gradient(180deg,#000 55%,transparent)}
|
||
.rt-clip.open .rt-clip-body{max-height:340px;overflow:auto}
|
||
.rt-flow{padding:10px 12px 16px}
|
||
.rt-flow-start,.rt-flow-end{font-size:11.5px;color:var(--dsw-alias-label-secondary);padding:4px 0}
|
||
.rt-flow-end{font-weight:600;color:var(--dsw-alias-label-primary)}
|
||
.rt-flow-link{display:flex;align-items:center;gap:8px;padding:3px 0 3px 10px}
|
||
.rt-flow-arrow{color:var(--dsw-alias-border-l2);font-size:11px;flex:none}
|
||
.rt-flow-action{font-size:11.5px;color:var(--dsw-alias-label-secondary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||
.rt-flow-action.inferred{opacity:.7;font-style:italic}
|
||
.rt-flow-node{border:1px solid var(--dsw-alias-border-l1);border-left:3px solid #10b981;border-radius:8px;
|
||
padding:8px 11px;background:var(--dsw-alias-bg-layer-2);cursor:pointer;outline:none}
|
||
.rt-flow-node:hover{border-color:var(--dsw-alias-border-l2)}
|
||
.rt-flow-node:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:-2px}
|
||
.rt-flow-node.overflow{border-left-color:#94a3b8;opacity:.75}
|
||
.rt-flow-head{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
|
||
.rt-flow-idx{width:18px;height:18px;border-radius:50%;background:#10b981;color:#fff;font-size:11px;font-weight:700;
|
||
display:inline-flex;align-items:center;justify-content:center;flex:none}
|
||
.rt-flow-node.overflow .rt-flow-idx{background:#94a3b8}
|
||
.rt-flow-name{font-weight:600;font-size:13px}
|
||
.rt-flow-pts{margin-left:auto;font-family:ui-monospace,Menlo,monospace;font-size:12.5px;font-weight:700;color:#065f46;
|
||
background:#a7f3d0;border:1px solid #10b98155;border-radius:10px;padding:0 8px;white-space:nowrap}
|
||
.rt-flow-pts.off{color:var(--dsw-alias-label-secondary);background:var(--dsw-alias-bg-layer-1);border-color:var(--dsw-alias-border-l1)}
|
||
.rt-flow-times{font-size:11px;color:var(--dsw-alias-label-secondary)}
|
||
.rt-flow-target{font-family:ui-monospace,Menlo,monospace;font-size:11.5px;margin-top:4px;word-break:break-all;
|
||
color:var(--dsw-alias-label-secondary)}
|
||
.rt-flow-gain{font-size:12px;margin-top:3px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||
.rt-flow-detail{margin-top:7px;padding-top:7px;border-top:1px dashed var(--dsw-alias-border-l1)}
|
||
.rt-hflow-wrap{flex:1;min-height:0;display:flex;flex-direction:column;overflow:auto}
|
||
.rt-hflow{display:flex;align-items:center;gap:0;padding:16px 12px;overflow-x:auto;flex-wrap:nowrap}
|
||
.rt-hflow-item{display:flex;align-items:center;flex:none}
|
||
.rt-hflow-start,.rt-hflow-end{font-size:11.5px;color:var(--dsw-alias-label-secondary);white-space:nowrap;padding:0 4px}
|
||
.rt-hflow-end{font-weight:600;color:var(--dsw-alias-label-primary)}
|
||
.rt-hflow-arrow{color:var(--dsw-alias-border-l2);padding:0 5px;font-size:13px;flex:none}
|
||
.rt-hflow-node{display:flex;align-items:center;gap:5px;border:1px solid var(--dsw-alias-border-l1);
|
||
border-left:3px solid #10b981;border-radius:7px;padding:5px 8px;background:var(--dsw-alias-bg-layer-2);
|
||
cursor:pointer;white-space:nowrap;outline:none}
|
||
.rt-hflow-node:hover{border-color:var(--dsw-alias-border-l2)}
|
||
.rt-hflow-node:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:-2px}
|
||
.rt-hflow-node.overflow{border-left-color:#94a3b8;opacity:.72}
|
||
.rt-hflow-node.open{border-color:var(--dsw-alias-brand-primary)}
|
||
.rt-hflow-pts{font-family:ui-monospace,Menlo,monospace;font-size:11.5px;font-weight:700;color:#065f46;background:#a7f3d0;
|
||
border-radius:8px;padding:0 6px}
|
||
.rt-hflow-node.overflow .rt-hflow-pts{color:var(--dsw-alias-label-secondary);background:var(--dsw-alias-bg-layer-1)}
|
||
.rt-hflow-name{font-size:11.5px}
|
||
.rt-hflow-n{font-size:10.5px;color:var(--dsw-alias-label-secondary)}
|
||
.rt-rep-list{padding:10px 12px 14px}
|
||
.rt-rep-tools{display:flex;gap:6px;justify-content:flex-end;margin-bottom:8px}
|
||
.rt-rep{border:1px solid var(--dsw-alias-border-l1);border-left:3px solid #10b981;border-radius:8px;
|
||
padding:9px 11px;margin-bottom:10px;background:var(--dsw-alias-bg-layer-2)}
|
||
.rt-rep-head{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
|
||
.rt-rep-idx{font-size:15px;color:#10b981;font-weight:700;flex:none}
|
||
.rt-rep-name{font-weight:600;font-size:13px}
|
||
.rt-rep-meta{font-size:12px;margin-top:4px;color:var(--dsw-alias-label-secondary);word-break:break-word}
|
||
.rt-rep-meta b{color:var(--dsw-alias-label-primary);font-weight:600;margin-right:2px}
|
||
.rt-rep-missing{font-size:11.5px;color:var(--dsw-alias-state-error-primary);margin-top:6px;
|
||
border:1px dashed var(--dsw-alias-state-error-primary);border-radius:5px;padding:5px 8px}
|
||
.rt-rep-req{margin-top:7px;border:1px solid var(--dsw-alias-border-l1);border-radius:6px;overflow:hidden}
|
||
.rt-rep-req-head{display:flex;align-items:center;gap:7px;padding:4px 8px;font-size:11.5px;font-weight:600;
|
||
background:var(--dsw-alias-bg-layer-1);border-bottom:1px solid var(--dsw-alias-border-l1)}
|
||
.rt-rep-http{margin:0;padding:8px 10px;font-family:ui-monospace,Menlo,monospace;font-size:11.5px;line-height:1.6;white-space:pre-wrap;word-break:break-word;max-height:240px;overflow:auto;background:var(--dsw-alias-bg-base)}
|
||
/* ── 报告里的「这一步怎么来的」:动作步骤 / 命令 / 凭据 / 隧道 ───────────── */
|
||
.rt-rep-trace{margin-top:8px;border:1px solid var(--dsw-alias-border-l1);border-radius:7px;padding:8px 10px;
|
||
background:var(--dsw-alias-bg-base)}
|
||
.rt-rep-trace-head{display:flex;align-items:center;gap:7px;margin-bottom:5px}
|
||
.rt-rep-trace-title{font-weight:600;font-size:12.5px}
|
||
.rt-rep-trace-how{font-size:12px;color:var(--dsw-alias-label-secondary);margin-bottom:6px}
|
||
.rt-rep-step{border-left:2px solid var(--dsw-alias-border-l1);padding:2px 0 6px 9px;margin-bottom:6px}
|
||
.rt-rep-step-head{display:flex;align-items:center;gap:6px;flex-wrap:wrap}
|
||
.rt-rep-step-no{width:17px;height:17px;border-radius:50%;background:var(--dsw-alias-brand-primary);color:#fff;
|
||
font-size:10.5px;display:flex;align-items:center;justify-content:center;flex:none}
|
||
.rt-rep-step-title{font-weight:600;font-size:12.5px}
|
||
.rt-rep-step-detail{font-size:12px;color:var(--dsw-alias-label-secondary);margin-top:2px}
|
||
.rt-rep-step-cmd{font-size:11.5px;margin-top:4px;display:flex;gap:6px;align-items:flex-start;flex-wrap:wrap}
|
||
.rt-rep-step-cmd .rt-mono{background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l1);
|
||
border-radius:5px;padding:2px 6px;word-break:break-all}
|
||
.rt-rep-step-result{font-size:11.5px;margin-top:3px;word-break:break-word}
|
||
.rt-rep-src{margin-top:6px;padding-top:6px;border-top:1px dashed var(--dsw-alias-border-l1)}
|
||
.rt-rep-src-title{font-weight:600;font-size:12px;margin-bottom:3px}
|
||
/* ── 全链路攻击路径图 ─────────────────────────────────────────────── */
|
||
.rt-ap{padding:10px 12px 18px;overflow:auto}
|
||
.rt-ap-stage{margin-bottom:2px}
|
||
.rt-ap-head{display:flex;align-items:center;gap:8px;padding:7px 10px;background:var(--dsw-alias-bg-layer-2);
|
||
border-left:4px solid #64748b;border-top:1px solid var(--dsw-alias-border-l1);border-radius:6px 6px 0 0}
|
||
.rt-ap-no{width:20px;height:20px;border-radius:5px;color:#fff;font-size:11px;font-weight:700;flex:none;
|
||
display:inline-flex;align-items:center;justify-content:center}
|
||
.rt-ap-name{font-weight:700;font-size:13.5px}
|
||
.rt-ap-en{font-size:10px;color:var(--dsw-alias-label-secondary);letter-spacing:.3px}
|
||
.rt-ap-phase{font-size:10.5px;color:var(--dsw-alias-label-secondary);white-space:nowrap}
|
||
.rt-ap-goal{display:flex;align-items:baseline;gap:8px;padding:6px 10px 6px 9px;font-size:12px;
|
||
border-left:4px solid #64748b;background:var(--dsw-alias-bg-layer-1)}
|
||
.rt-ap-goal-tag{font-size:10.5px;font-weight:700;border:1px solid;border-radius:4px;padding:0 5px;white-space:nowrap;flex:none}
|
||
.rt-ap-result{padding:7px 10px 3px;border-left:4px solid transparent}
|
||
.rt-ap-result-head{display:flex;align-items:center;gap:8px;margin-bottom:5px;flex-wrap:wrap}
|
||
.rt-ap-pts{font-family:ui-monospace,Menlo,monospace;font-size:11.5px;font-weight:700;color:#065f46;background:#a7f3d0;
|
||
border:1px solid #10b98155;border-radius:10px;padding:0 7px;white-space:nowrap}
|
||
.rt-ap-pts.off{color:var(--dsw-alias-label-secondary);background:var(--dsw-alias-bg-layer-1);border-color:var(--dsw-alias-border-l1)}
|
||
/* 按服务封顶/自建而不计分的命中:分值标灰(+0),避免看着像又加了分 */
|
||
.rt-ap-pts.uncounted{color:var(--dsw-alias-label-secondary);background:var(--dsw-alias-bg-layer-1);border-color:var(--dsw-alias-border-l1)}
|
||
.rt-ap-nth{font-size:10.5px;color:var(--dsw-alias-label-secondary);white-space:nowrap}
|
||
.rt-ap-hit{border:1px solid var(--dsw-alias-border-l1);border-left:3px solid #10b981;border-radius:6px;
|
||
padding:6px 9px;margin-bottom:5px;background:var(--dsw-alias-bg-layer-2);cursor:pointer;outline:none}
|
||
.rt-ap-hit:hover{border-color:var(--dsw-alias-border-l2)}
|
||
.rt-ap-hit:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:-2px}
|
||
.rt-ap-hit.overflow{border-left-color:#94a3b8;opacity:.75}
|
||
.rt-ap-hit-head{display:flex;align-items:center;gap:7px;flex-wrap:wrap}
|
||
.rt-ap-hit-name{font-weight:600;font-size:12.5px}
|
||
.rt-ap-hit-target{font-family:ui-monospace,Menlo,monospace;font-size:11px;color:var(--dsw-alias-label-secondary);
|
||
margin-left:auto;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:46%}
|
||
.rt-ap-act{font-size:11px;color:var(--dsw-alias-label-secondary);margin-top:3px}
|
||
.rt-ap-act.inferred{font-style:italic;opacity:.75}
|
||
.rt-ap-none{font-size:11.5px;color:var(--dsw-alias-label-secondary);padding:2px 0 4px}
|
||
.rt-ap-trans{display:flex;align-items:center;gap:7px;padding:2px 0 2px 14px}
|
||
.rt-ap-trans-t{font-size:11px;color:var(--dsw-alias-label-secondary)}
|
||
/* 横向路径图 */
|
||
.rt-ap-h{display:flex;align-items:stretch;padding:10px 12px 14px;overflow-x:auto;flex:1;min-height:0}
|
||
.rt-ap-col{display:flex;align-items:stretch;flex:none}
|
||
.rt-ap-col-arrow{align-self:center;color:var(--dsw-alias-border-l2);padding:0 6px;font-size:12px;flex:none}
|
||
.rt-hcol{width:228px;display:flex;flex-direction:column;border:1px solid var(--dsw-alias-border-l1);
|
||
border-top:3px solid #64748b;border-radius:7px;background:var(--dsw-alias-bg-layer-1);overflow:hidden}
|
||
.rt-hcol-goal{font-size:11px;color:var(--dsw-alias-label-secondary);padding:5px 8px;line-height:1.45;
|
||
border-bottom:1px solid var(--dsw-alias-border-l1);display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
|
||
.rt-hcol-body{padding:6px 8px;flex:1;min-height:0;overflow:auto}
|
||
.rt-hcol-hit{display:flex;align-items:center;gap:5px;font-size:11px;margin-bottom:3px}
|
||
.rt-hcol-hit.off{opacity:.6}
|
||
.rt-hcol-pts{font-family:ui-monospace,Menlo,monospace;font-size:10.5px;font-weight:700;color:#065f46;background:#a7f3d0;
|
||
border-radius:7px;padding:0 5px;flex:none}
|
||
.rt-hcol-hit.off .rt-hcol-pts{color:var(--dsw-alias-label-secondary);background:var(--dsw-alias-bg-layer-2)}
|
||
.rt-hcol-name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||
.rt-hcol-none{font-size:11px;color:var(--dsw-alias-label-secondary)}
|
||
.rt-hcol-secs{display:flex;flex-wrap:wrap;gap:3px;margin-top:6px}
|
||
.rt-hcol-sec{font-size:10px;color:var(--dsw-alias-label-secondary);border:1px solid var(--dsw-alias-border-l1);
|
||
border-radius:4px;padding:0 4px}
|
||
.rt-hcol-attck{font-family:ui-monospace,Menlo,monospace;font-size:9.5px;color:var(--dsw-alias-label-secondary);
|
||
padding:4px 8px;border-top:1px solid var(--dsw-alias-border-l1);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||
.rt-ap-cum{font-size:11px;color:var(--dsw-alias-label-secondary);white-space:nowrap}
|
||
.rt-ap-sub{font-size:11px;font-weight:600;color:var(--dsw-alias-label-secondary);margin:6px 0 3px}
|
||
.rt-ap-assets,.rt-ap-tunnels{display:flex;flex-direction:column;gap:3px}
|
||
.rt-ap-asset{display:flex;align-items:center;gap:6px;font-size:11.5px;padding:3px 6px;border-radius:5px;
|
||
background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l1)}
|
||
.rt-ap-tunnel{display:flex;align-items:center;gap:6px;font-size:11.5px;padding:4px 7px;border-radius:5px;
|
||
background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l1);border-left:3px solid #f59e0b}
|
||
.rt-hcol-sub{font-size:10.5px;color:var(--dsw-alias-label-secondary);margin-bottom:2px;
|
||
overflow:hidden;text-overflow:ellipsis;white-space:nowrap;display:flex;gap:5px;align-items:center}
|
||
.rt-hcol-sub.off{opacity:.6}
|
||
.rt-hit-row{display:flex;flex-wrap:wrap;align-items:center;gap:2px 7px;font-size:11.5px;padding:4px 7px;border-radius:5px;
|
||
background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l1);margin-bottom:3px}
|
||
/* 资产与内容都自适应换行:长域名/长口令/长结果一律换行显示,不截断成省略号 */
|
||
.rt-hit-asset{font-family:ui-monospace,Menlo,monospace;font-weight:600;flex:0 1 auto;max-width:100%;
|
||
overflow-wrap:anywhere;word-break:break-word}
|
||
.rt-hit-txt{flex:1 1 100%;color:var(--dsw-alias-label-primary);line-height:1.55;
|
||
white-space:pre-wrap;overflow-wrap:anywhere;word-break:break-word}
|
||
.rt-hit-txt.none{color:var(--dsw-alias-state-error-primary)}
|
||
.rt-hit-row .rt-hit-time{flex:none;margin-left:auto}
|
||
/* 自己注册/自建的账号:留痕但不计分,整行压暗 */
|
||
.rt-hit-row.self-created{opacity:.72;border-left:3px solid #ef444488}
|
||
/* 同一资产同一端口的重复账号/库权限:服务已拿满,不计分(只作留痕) */
|
||
.rt-hit-row.service-capped{opacity:.72;border-left:3px solid #f59e0b88}
|
||
.rt-hit-row.service-capped .rt-hit-idx{background:#f59e0b}
|
||
.rt-rep-group{margin-bottom:14px}
|
||
.rt-rep-stage{display:flex;align-items:center;gap:8px;padding:7px 10px;margin-bottom:7px;
|
||
background:var(--dsw-alias-bg-layer-2);border-left:4px solid #64748b;border-radius:6px;
|
||
cursor:pointer;outline:none;user-select:none}
|
||
.rt-rep-stage:hover{background:var(--dsw-alias-bg-layer-1)}
|
||
.rt-rep-stage:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:-2px}
|
||
.rt-rep-no{width:20px;height:20px;border-radius:5px;color:#fff;font-size:11px;font-weight:700;flex:none;
|
||
display:inline-flex;align-items:center;justify-content:center}
|
||
.rt-rep-stage-name{font-weight:700;font-size:13px}
|
||
.rt-rep-stage-n{font-size:11px;color:var(--dsw-alias-label-secondary)}
|
||
/* 折叠后仍要能一眼看到"这一阶段拿了多少分",所以分数留在头上 */
|
||
.rt-rep-pts{font-size:11px;font-weight:600;padding:1px 6px;border-radius:5px;border:1px solid transparent;flex:none}
|
||
.rt-rep-body{padding-left:6px}
|
||
/* ── 知识库(POC/EXP) ──────────────────────────────────────────── */
|
||
.rt-kb-filter{display:flex;align-items:center;gap:7px;padding:8px 10px;border-bottom:1px solid var(--dsw-alias-border-l1)}
|
||
/* 知识库归类总览:一行标签,点一下按该类筛选 */
|
||
.rt-kb-cats{display:flex;flex-wrap:wrap;gap:6px;padding:8px 10px;border-bottom:1px solid var(--dsw-alias-border-l1)}
|
||
/* 知识库分组标题(按归类分组时每组一条) */
|
||
.rt-kb-cat{display:flex;align-items:center;gap:7px;padding:7px 10px;background:var(--dsw-alias-bg-layer-2);
|
||
border-bottom:1px solid var(--dsw-alias-border-l1);position:sticky;top:0;z-index:1}
|
||
.rt-kb-cat-name{font-weight:600;font-size:12.5px}
|
||
/* 技能可用性徽章(技能库页签) */
|
||
.rt-avail{display:inline-block;padding:0 5px;border-radius:4px;font-size:10.5px;white-space:nowrap;
|
||
border:1px solid var(--dsw-alias-border-l1);color:var(--dsw-alias-label-secondary)}
|
||
.rt-avail-available{color:#10b981;border-color:#10b98155;background:#10b9811a}
|
||
.rt-avail-broken{color:#ef4444;border-color:#ef444455;background:#ef44441a}
|
||
.rt-avail-unknown{color:#94a3b8;border-color:#94a3b855;background:#94a3b81a}
|
||
/* 版本 / 更新弹窗 */
|
||
.rt-modal{position:fixed;inset:0;z-index:200;background:rgba(0,0,0,.45);display:flex;align-items:center;justify-content:center}
|
||
.rt-modal-box{width:min(560px,92vw);max-height:80vh;overflow:auto;background:var(--dsw-alias-bg-layer-1);
|
||
border:1px solid var(--dsw-alias-border-l1);border-radius:10px;padding:14px 16px;font-size:12.5px;line-height:1.7;
|
||
box-shadow:0 18px 48px rgba(0,0,0,.35);color:var(--dsw-alias-label-primary)}
|
||
.rt-modal-box .rt-kv b{min-width:76px}
|
||
.rt-kb-check{display:flex;align-items:center;gap:5px;font-size:11.5px;color:var(--dsw-alias-label-secondary);white-space:nowrap;cursor:pointer}
|
||
.rt-kb{border:1px solid var(--dsw-alias-border-l1);border-radius:7px;margin:0 0 8px;overflow:hidden;
|
||
background:var(--dsw-alias-bg-layer-2)}
|
||
.rt-kb.open{border-color:var(--dsw-alias-brand-primary)}
|
||
.rt-kb-head{display:flex;align-items:center;gap:7px;padding:7px 9px;cursor:pointer;outline:none;flex-wrap:wrap}
|
||
.rt-kb-head:hover{background:var(--dsw-alias-bg-layer-1)}
|
||
.rt-kb-head:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:-2px}
|
||
.rt-kb-title{font-weight:600;font-size:12.5px;overflow-wrap:anywhere}
|
||
.rt-kb-kind{font-size:10px;font-weight:700;letter-spacing:.4px;padding:1px 5px;border-radius:4px;
|
||
color:#fff;background:#64748b;flex:none}
|
||
.rt-kb-kind.k-exp{background:#ef4444}
|
||
.rt-kb-kind.k-poc{background:#f59e0b}
|
||
.rt-kb-kind.k-template{background:#8b5cf6}
|
||
.rt-kb-kind.k-script{background:#0ea5e9}
|
||
.rt-kb-kind.k-payload{background:#10b981}
|
||
.rt-kb-sub{font-size:11px;color:var(--dsw-alias-label-secondary);padding:0 9px 7px;overflow-wrap:anywhere}
|
||
.rt-kb-body{padding:0 9px 9px}
|
||
.rt-kb-actions{display:flex;gap:6px;margin:7px 0}
|
||
.rt-kb-body .rt-kv span{overflow-wrap:anywhere;word-break:break-word}
|
||
/* 本机 nuclei 模板命中:路径要能完整看到(复制成命令直接跑) */
|
||
.rt-kb-tpl{margin-top:12px;border-top:1px dashed var(--dsw-alias-border-l1);padding-top:8px}
|
||
.rt-kb-tpl-row{display:flex;align-items:center;gap:7px;font-size:11.5px;padding:3px 7px;border-radius:5px;
|
||
background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l1);margin-bottom:3px}
|
||
.rt-kb-tpl-path{flex:0 1 auto;font-weight:600;overflow-wrap:anywhere}
|
||
.rt-kb-tpl-name{flex:1 1 auto;min-width:0;color:var(--dsw-alias-label-secondary);overflow-wrap:anywhere}
|
||
.rt-md{flex:1;overflow:auto;margin:0;padding:14px 16px;font-family:ui-monospace,Menlo,monospace;font-size:12.5px;
|
||
line-height:1.65;white-space:pre-wrap;word-break:break-word;background:var(--dsw-alias-bg-base)}
|
||
.rt-weblink{display:block;font-size:11.5px;margin-top:1px;max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||
.rt-shots{margin:8px 0 4px;padding:7px 8px;border:1px solid var(--dsw-alias-border-l1);border-radius:7px;
|
||
background:var(--dsw-alias-bg-layer-1)}
|
||
.rt-shots-head{display:flex;align-items:baseline;gap:8px;font-size:11.5px;margin-bottom:6px}
|
||
.rt-shots-n{color:var(--dsw-alias-label-secondary)}
|
||
.rt-shot-row{display:flex;gap:8px;flex-wrap:wrap}
|
||
.rt-shot{display:flex;flex-direction:column;gap:3px;max-width:220px}
|
||
.rt-shot-thumb{border:1px solid var(--dsw-alias-border-l1);border-radius:5px;background:#0b1020;
|
||
object-fit:contain;display:block;width:auto;max-width:216px}
|
||
.rt-shot-loading{display:flex;align-items:center;justify-content:center;width:150px;font-size:11px;
|
||
color:var(--dsw-alias-label-secondary)}
|
||
.rt-shot-cap{display:flex;align-items:center;gap:5px;font-size:10.5px;overflow-wrap:anywhere}
|
||
.rt-shot-label{color:var(--dsw-alias-label-secondary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:170px}
|
||
.rt-shot-zoom{position:fixed;inset:0;z-index:9999;background:rgba(0,0,0,.82);display:flex;flex-direction:column;
|
||
align-items:center;justify-content:center;gap:10px;cursor:zoom-out;padding:18px}
|
||
.rt-shot-zoom img{max-width:96vw;max-height:86vh;border-radius:6px;box-shadow:0 18px 48px rgba(0,0,0,.5)}
|
||
.rt-shot-zoom-cap{color:#e5e7eb;font-size:12px;text-align:center;max-width:90vw;overflow-wrap:anywhere}` /* ---------------------------------------------------------- 桥接与状态 */
|
||
/** 是否在「全面浏览」独立窗口里(URL hash 标记,复用同一套界面代码)。 */
|
||
const isFullWindow = () => {
|
||
try { return String(window.location.hash || '') === '#redteam-full' } catch { return false }
|
||
}
|
||
|
||
const api = (req) => fetch('/redteam/api', {
|
||
method: 'POST',
|
||
headers: { 'content-type': 'application/json' },
|
||
body: JSON.stringify(req),
|
||
}).then(async (res) => {
|
||
const text = await res.text().catch(() => '')
|
||
let data = null
|
||
try { data = text ? JSON.parse(text) : null } catch {
|
||
return { ok: false, error: res.status + ' non-json: ' + String(text).slice(0, 120) }
|
||
}
|
||
if (!res.ok) {
|
||
return Object.assign({}, data || {}, {
|
||
ok: false,
|
||
error: (data && (data.error || data.message)) || (res.status + ' ' + res.statusText),
|
||
})
|
||
}
|
||
return data
|
||
})
|
||
|
||
let ui = { open: true, tab: 'assets' }
|
||
const subs = new Set()
|
||
const setUI = (patch) => {
|
||
ui = Object.assign({}, ui, patch)
|
||
const snap = Object.assign({}, ui)
|
||
for (const f of Array.from(subs)) {
|
||
try { f(snap) } catch { /* ignore subscriber errors */ }
|
||
}
|
||
}
|
||
/* 保留导出。面板尺寸只写在自己的元素上,不再改官方 frame 的宽度。 */
|
||
let dockWidthTag = null
|
||
const setDockWidth = () => {}
|
||
const useUI = () => {
|
||
const [st, setSt] = React.useState(() => Object.assign({}, ui))
|
||
React.useEffect(() => {
|
||
const f = (snap) => setSt(Object.assign({}, snap || ui))
|
||
subs.add(f)
|
||
setSt(Object.assign({}, ui))
|
||
return () => { subs.delete(f) }
|
||
}, [])
|
||
return st
|
||
}
|
||
|
||
/* 知识库归类(与 core.js 的 POC_CATEGORIES 一致;面板按它分组) */
|
||
const POC_CAT_NAME = {
|
||
rce: '远程命令执行', deserialization: '反序列化', 'file-upload': '文件上传 getshell',
|
||
sqli: 'SQL 注入', unauthorized: '未授权访问', 'auth-bypass': '认证绕过 / 越权',
|
||
'weak-password': '弱口令 / 爆破', ssrf: 'SSRF', xxe: 'XXE',
|
||
'path-traversal': '目录穿越 / 任意文件读', 'info-leak': '信息泄露',
|
||
privesc: '提权 / 横向', tunnel: '隧道 / 代理', other: '其它',
|
||
}
|
||
const POC_CAT_ORDER = Object.keys(POC_CAT_NAME)
|
||
/* 角色 code → 中文(报告/知识库都要标"谁发现的") */
|
||
const ROLE_LABEL = {
|
||
plan: '主会话(指挥)', recon: '信息收集', assess: '资产梳理',
|
||
'vuln-scan': '漏洞发现', exploit: '漏洞利用', internal: '内网渗透',
|
||
}
|
||
|
||
const fmt = (s) => (s ? String(s).replace('T', ' ').slice(0, 16) : '—')
|
||
const shotCache = new Map()
|
||
const shotLoading = new Map()
|
||
const shotSrc = (eng, id) => shotCache.get(eng + ':' + id) || null
|
||
const loadShot = (eng, id) => {
|
||
const key = eng + ':' + id
|
||
if (shotCache.has(key) || shotLoading.has(key)) return
|
||
const p = api({ op: 'shotData', engagement: eng, id: id }).then((r) => {
|
||
shotCache.set(key, r && r.base64 ? 'data:' + (r.mime || 'image/png') + ';base64,' + r.base64 : '')
|
||
shotLoading.delete(key)
|
||
const snap = Object.assign({}, ui)
|
||
for (const f of Array.from(subs)) {
|
||
try { f(snap) } catch { /* ignore subscriber errors */ }
|
||
}
|
||
}).catch(() => { shotLoading.delete(key) })
|
||
shotLoading.set(key, p)
|
||
}
|
||
const SHOT_KIND = { screenshot: '现场截图', card: '记录生成的证据摘录' }
|
||
const ShotImage = ({ eng, shot, height }) => {
|
||
const [zoom, setZoom] = React.useState(false)
|
||
const src = shotSrc(eng, shot.id)
|
||
if (src === null) { loadShot(eng, shot.id); }
|
||
const card = shot.kind === 'card'
|
||
return h('div', { className: 'rt-shot' },
|
||
src === null
|
||
? h('div', { className: 'rt-shot-thumb rt-shot-loading', style: { height: (height || 96) + 'px' } }, '载入中…')
|
||
: h('img', {
|
||
className: 'rt-shot-thumb', src: src, alt: shot.label || ('证据 ' + shot.id),
|
||
style: { height: (height || 96) + 'px', cursor: 'zoom-in' },
|
||
onClick: () => setZoom(true),
|
||
}),
|
||
h('div', { className: 'rt-shot-cap' },
|
||
h('span', { className: 'rt-tag ' + (card ? 'rt-tag-warn' : 'rt-tag-live'), title: card
|
||
? '由当日记录生成的证据摘录,不是现场截图 —— 报告里同样会标注'
|
||
: '现场截图' }, SHOT_KIND[shot.kind] || shot.kind || '证据'),
|
||
h('span', { className: 'rt-shot-label', title: shot.label || shot.note || '' },
|
||
shot.label || shot.target || ('#' + shot.id))),
|
||
zoom
|
||
? h('div', { className: 'rt-shot-zoom', onClick: () => setZoom(false) },
|
||
h('img', { src: src, alt: shot.label || '' }),
|
||
h('div', { className: 'rt-shot-zoom-cap' }, (shot.label || shot.target || '')
|
||
+ (card ? ' · 由当日记录生成(非现场截图)' : '')
|
||
+ (shot.captured_at ? ' · ' + fmt(shot.captured_at) : '')
|
||
+ ' · 点击任意处关闭'))
|
||
: null)
|
||
}
|
||
const ShotStrip = ({ eng, shots, total, title }) => {
|
||
const list = shots || []
|
||
if (list.length === 0) return null
|
||
return h('div', { className: 'rt-shots' },
|
||
h('div', { className: 'rt-shots-head' },
|
||
h('b', null, title || '证据截图'),
|
||
h('span', { className: 'rt-shots-n' }, '显示 ' + list.length + ' 张'
|
||
+ (total && total > list.length ? '(该得分点共 ' + total + ' 张,取代表图)' : ''))),
|
||
h('div', { className: 'rt-shot-row' }, list.map((sh) => h(ShotImage, { key: 'sh' + sh.id, eng: eng, shot: sh }))))
|
||
}
|
||
/** 发现时间在列表里只留「月-日 时:分」:整行宽度紧张,完整时间进悬浮提示 */
|
||
const fmtShort = (s) => {
|
||
const t = String(s || '').replace('T', ' ')
|
||
return t.length >= 16 ? t.slice(5, 16) : (t || '—')
|
||
}
|
||
const provLabel = (p) => (p === 'passive' ? '被动' : p === 'active' ? '主动' : '未知')
|
||
|
||
/**
|
||
* 给「可点击但不是 <button>」的元素补上键盘可达性,返回可直接展开进 props 的对象。
|
||
*
|
||
* 为什么需要:面板里大量用 div/span 当按钮(表格行、页签、结论条、C 段条目…),
|
||
* 它们鼠标能点、键盘完全够不着 —— 而这类元素此前有 16 处是各写各的,
|
||
* 写法还不一致(有的只有 role、有的漏了 Space 键)。
|
||
* 统一到一个工厂后,新增可点击元素只要 `...clickable(fn, { label })` 就有完整语义。
|
||
*
|
||
* 注意:**不要**用它包真正的 `<button>`(原生按钮自带全部语义)。
|
||
* @param onActivate - 激活回调(鼠标点击 / Enter / Space 都走它)。
|
||
* @param options - `{ label?, expanded? }`:label 进 aria-label,expanded 进 aria-expanded。
|
||
* @returns props 片段:role / tabIndex / onClick / onKeyDown / aria-*
|
||
*/
|
||
const clickable = (onActivate, options = {}) => {
|
||
const props = {
|
||
role: 'button',
|
||
tabIndex: 0,
|
||
onClick: onActivate,
|
||
onKeyDown: (e) => {
|
||
/* Enter 与 Space 是按钮的标准激活键;Space 还要阻止页面滚动 */
|
||
if (e.key === 'Enter' || e.key === ' ') {
|
||
e.preventDefault()
|
||
onActivate(e)
|
||
}
|
||
},
|
||
}
|
||
if (options.label !== undefined) props['aria-label'] = String(options.label)
|
||
if (options.expanded !== undefined) props['aria-expanded'] = options.expanded ? 'true' : 'false'
|
||
return props
|
||
}
|
||
|
||
/**
|
||
* 复制文本到剪贴板,**返回真实的成功与否**。
|
||
*
|
||
* clipboard API 在非安全上下文(http + 非 localhost)、页面失焦、权限被拒时都会
|
||
* 返回被拒绝的 Promise —— 同步 try/catch 抓不到,于是界面会显示"已复制"而剪贴板是空的。
|
||
* 这里 await 真实结果,并在不可用时退回 execCommand('copy')(老浏览器/非安全上下文仍可用)。
|
||
* @param text - 要复制的文本。
|
||
* @returns Promise<boolean>
|
||
*/
|
||
const copyText = async (text) => {
|
||
const value = text === undefined || text === null ? '' : String(text)
|
||
if (value === '') return false
|
||
try {
|
||
if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
|
||
await navigator.clipboard.writeText(value)
|
||
return true
|
||
}
|
||
} catch (e) { /* 落到下面的兜底方案 */ }
|
||
/* 兜底:临时 textarea + execCommand —— 非安全上下文里唯一还能用的办法 */
|
||
try {
|
||
const ta = document.createElement('textarea')
|
||
ta.value = value
|
||
ta.setAttribute('readonly', '')
|
||
ta.style.position = 'fixed'
|
||
ta.style.left = '-9999px'
|
||
document.body.appendChild(ta)
|
||
ta.select()
|
||
const okFlag = document.execCommand('copy')
|
||
document.body.removeChild(ta)
|
||
return okFlag === true
|
||
} catch (e) { return false }
|
||
}
|
||
|
||
/**
|
||
* 复制并给出**如实**的界面反馈(成功/失败文案统一)。
|
||
* @param text - 要复制的文本。
|
||
* @param label - 成功时提示里显示的名字。
|
||
* @param onResult - 可选回调:`(ok, message) => void`;不传时返回 Promise<boolean>。
|
||
*/
|
||
const copyWithFeedback = async (text, label, onResult) => {
|
||
const okFlag = await copyText(text)
|
||
const message = okFlag ? '已复制:' + label : '复制失败(浏览器未授权剪贴板)—— 请手动选中文本复制'
|
||
if (typeof onResult === 'function') onResult(okFlag, message)
|
||
return okFlag
|
||
}
|
||
|
||
function ProvTag(props) {
|
||
if (!props.p) return h('span', { className: 'rt-tag' }, '未知')
|
||
return h('span', { className: 'rt-tag rt-tag-' + props.p }, provLabel(props.p))
|
||
}
|
||
|
||
const TEST_LABEL = { untested: '未测试', testing: '测试中', tested: '已测试', blocked: '被封禁', abandoned: '已放弃', no_surface: '无攻击面' }
|
||
const PRI_LABEL = { high: '高', medium: '中', low: '低' }
|
||
|
||
/** 资产易打性徽章。 */
|
||
function PriTag(props) {
|
||
if (!props.p) return h('span', { className: 'rt-tag' }, '未评')
|
||
return h('span', { className: 'rt-pri rt-pri-' + props.p, title: props.title || '' }, PRI_LABEL[props.p] || props.p)
|
||
}
|
||
|
||
/** 资产测试状态徽章。 */
|
||
function TestTag(props) {
|
||
const st = props.s || 'untested'
|
||
return h('span', { className: 'rt-test rt-test-' + st }, TEST_LABEL[st] || st)
|
||
}
|
||
|
||
/* ---------------------------------------------------------- 资产测绘 */
|
||
function AssetsTab(props) {
|
||
const eng = props.engagement
|
||
const snapshot = props.snapshot
|
||
const refreshKey = props.refreshKey || 0
|
||
const onRefresh = props.onRefresh
|
||
const [view, setView] = React.useState('list')
|
||
const [cidr, setCidr] = React.useState(null)
|
||
const [q, setQ] = React.useState('')
|
||
const [qApplied, setQApplied] = React.useState('')
|
||
const [service, setService] = React.useState('')
|
||
const [port, setPort] = React.useState('')
|
||
const [prov, setProv] = React.useState('')
|
||
const [testStatus, setTestStatus] = React.useState('')
|
||
const [priority, setPriority] = React.useState('')
|
||
const [scope, setScope] = React.useState('')
|
||
const [assetState, setAssetState] = React.useState('')
|
||
const [sort, setSort] = React.useState('priority')
|
||
const [showAll, setShowAll] = React.useState(null)
|
||
const [state, setState] = React.useState({ loading: false, error: null, total: 0, items: [] })
|
||
const [domains, setDomains] = React.useState(null)
|
||
const [web, setWeb] = React.useState(null)
|
||
const [openId, setOpenId] = React.useState(null)
|
||
const [detail, setDetail] = React.useState(null)
|
||
/* 左侧「外网 C 段 / 内网 C 段」两组各自折叠,状态按靶标记住 */
|
||
const sideCollapse = useCollapse('assets-side:' + eng)
|
||
const seq = React.useRef(0)
|
||
const onData = props.onData
|
||
|
||
React.useEffect(() => {
|
||
if (!eng) return
|
||
const my = ++seq.current
|
||
setState((s) => Object.assign({}, s, { loading: true, error: null }))
|
||
api({
|
||
op: 'assets', engagement: eng, cidr: cidr || undefined, q: qApplied || undefined,
|
||
service: service || undefined, port: port || undefined,
|
||
provenance: prov || undefined, test_status: testStatus || undefined,
|
||
priority: priority || undefined, scope: scope || undefined,
|
||
state: assetState || undefined, sort: sort, limit: 400,
|
||
}).then((r) => {
|
||
if (my !== seq.current) return
|
||
if (!r || r.ok === false) {
|
||
setState({ loading: false, error: (r && r.error) || '查询失败', total: 0, items: [] })
|
||
return
|
||
}
|
||
setState({ loading: false, error: null, total: r.total, items: r.items || [] })
|
||
/* C 段/统计可能因本轮采集新增:让外层重新拉一次快照,左侧分类立即更新 */
|
||
if (onData) onData()
|
||
}, (e) => {
|
||
if (my === seq.current) setState({ loading: false, error: String((e && e.message) || e), total: 0, items: [] })
|
||
})
|
||
}, [eng, cidr, qApplied, service, port, prov, testStatus, priority, scope, assetState, sort, refreshKey])
|
||
|
||
React.useEffect(() => {
|
||
if (!eng || view !== 'domain') return
|
||
setDomains(null)
|
||
api({ op: 'domains', engagement: eng, cidr: cidr || undefined })
|
||
.then((r) => setDomains((r && r.items) || []), () => setDomains([]))
|
||
}, [eng, view, cidr, refreshKey])
|
||
|
||
React.useEffect(() => {
|
||
if (!eng || view !== 'web') return
|
||
setWeb(null)
|
||
api({ op: 'web', engagement: eng, cidr: cidr || undefined })
|
||
.then((r) => setWeb((r && r.items) || []), () => setWeb([]))
|
||
}, [eng, view, cidr, refreshKey])
|
||
|
||
|
||
const toggleRow = (id) => {
|
||
if (openId === id) { setOpenId(null); setDetail(null); return }
|
||
setOpenId(id)
|
||
setDetail(null)
|
||
api({ op: 'asset', engagement: eng, id: id }).then((r) => {
|
||
if (r && r.ok && r.asset) setDetail(r.asset)
|
||
}, () => {})
|
||
}
|
||
|
||
const segs = (snapshot && snapshot.segments) || []
|
||
|
||
const sideChildren = []
|
||
sideChildren.push(h('div', Object.assign({
|
||
key: 'all', className: 'rt-seg' + (cidr === null && view !== 'testing' ? ' on' : ''),
|
||
}, clickable(() => { setView('list'); setCidr(null) }, { label: '全部 C 段', expanded: cidr === null && view !== 'testing' })),
|
||
h('div', { className: 'rt-seg-cidr' }, '全部 C 段'),
|
||
h('div', { className: 'rt-seg-meta' }, segs.length + ' 个网段')))
|
||
sideChildren.push(h('div', Object.assign({
|
||
key: 'testing', className: 'rt-seg' + (view === 'testing' ? ' on' : ''),
|
||
title: '正在跑的扫描 / 探测与进度(5 秒自动刷新)',
|
||
}, clickable(() => { setView('testing'); setCidr(null) }, { label: '当前测试', expanded: view === 'testing' })),
|
||
h('div', { className: 'rt-seg-cidr', style: { display: 'flex', alignItems: 'baseline', gap: 5 } },
|
||
h('span', { className: 'rt-scope rt-scope-testing', title: '当前测试(既非内网也非外网)' }, '测'),
|
||
h('span', { style: { flex: 1 } }, '当前测试')),
|
||
h('div', { className: 'rt-seg-meta' }, '正在跑的扫描与探测')))
|
||
/* C 段按内外网分组:先外网(互联网可达,通常是入口)再内网(打进去之后才看得到) */
|
||
const segBlock = (title, list, kind) => {
|
||
/* 外网 / 内网两组各自可折叠(状态按靶标记住),默认展开 */
|
||
const open = sideCollapse.isOpen('scope:' + kind, true)
|
||
const toggle = sideCollapse.toggle('scope:' + kind, true)
|
||
const out = [h('div', {
|
||
key: 'h' + kind, className: 'rt-sidehead rt-sidehead-btn',
|
||
role: 'button', tabIndex: 0, 'aria-expanded': open ? 'true' : 'false',
|
||
title: (open ? '收起本组' : '展开本组') + '(并切回资产列表)',
|
||
onClick: () => { setView('list'); toggle() },
|
||
onKeyDown: (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setView('list'); toggle(e) } },
|
||
},
|
||
h('span', { className: 'rt-sec-caret' }, open ? '▾' : '▸'),
|
||
h('span', { className: 'rt-scope rt-scope-' + kind }, kind === 'internal' ? '内网' : '外网'),
|
||
h('span', null, title + ' · ' + list.length + ' 个 C 段'),
|
||
h('span', { className: 'rt-spacer' }),
|
||
h('span', { style: { fontWeight: 400 } }, list.reduce((n, x) => n + (x.assets || 0), 0) + ' 资产'))]
|
||
if (!open) return out
|
||
for (const s of list) {
|
||
/* 一段一行:只给 C 段 + 资产数(端口数不再占位,归属与存活放进悬浮提示) */
|
||
out.push(h('div', Object.assign({
|
||
key: s.cidr, className: 'rt-seg' + (cidr === s.cidr ? ' on' : ''),
|
||
title: (s.org || '未知归属') + ' · 存活 ' + (s.live || 0) + '/' + (s.assets || 0) + ' 台',
|
||
}, clickable(() => { setView('list'); setCidr(s.cidr) },
|
||
{ label: '筛选 C 段 ' + s.cidr, expanded: cidr === s.cidr && view !== 'testing' })),
|
||
h('div', { className: 'rt-seg-cidr', style: { display: 'flex', alignItems: 'baseline', gap: 5 } },
|
||
h('span', { className: 'rt-scope rt-scope-' + kind }, kind === 'internal' ? '内' : '外'),
|
||
h('span', { style: { flex: 1 } }, s.cidr),
|
||
h('span', { className: 'rt-seg-meta', style: { margin: 0, whiteSpace: 'nowrap' } },
|
||
s.assets + ' 台'))))
|
||
}
|
||
return out
|
||
}
|
||
const segExternal = segs.filter((x) => x.scope !== 'internal')
|
||
const segInternal = segs.filter((x) => x.scope === 'internal')
|
||
if (segExternal.length) sideChildren.push(...segBlock('外网资产', segExternal, 'external'))
|
||
if (segInternal.length) sideChildren.push(...segBlock('内网资产', segInternal, 'internal'))
|
||
const side = h('div', { className: 'rt-side' }, sideChildren)
|
||
|
||
const toolbar = h('div', { className: 'rt-toolbar' },
|
||
h('input', {
|
||
className: 'rt-input', style: { flex: '1 1 150px' }, placeholder: '搜索 IP / 域名 / 指纹(回车)',
|
||
value: q, onChange: (e) => setQ(e.target.value),
|
||
onKeyDown: (e) => { if (e.key === 'Enter') setQApplied(q) },
|
||
}),
|
||
h('button', { className: 'rt-btn', onClick: () => setQApplied(q) }, '搜索'),
|
||
h('input', {
|
||
className: 'rt-input', style: { width: '78px' }, placeholder: '服务',
|
||
value: service, onChange: (e) => setService(e.target.value),
|
||
}),
|
||
h('input', {
|
||
className: 'rt-input', style: { width: '60px' }, placeholder: '端口',
|
||
value: port, onChange: (e) => setPort(e.target.value),
|
||
}),
|
||
h('select', { className: 'rt-input', value: scope, onChange: (e) => setScope(e.target.value) },
|
||
h('option', { value: '' }, '内外网不限'),
|
||
h('option', { value: 'external' }, '仅外网资产'),
|
||
h('option', { value: 'internal' }, '仅内网资产')),
|
||
h('select', { className: 'rt-input', value: prov, onChange: (e) => setProv(e.target.value), title: '按端口来源过滤(列表已不再单列显示,详情里可见)' },
|
||
h('option', { value: '' }, '来源不限'),
|
||
h('option', { value: 'passive' }, '仅被动'),
|
||
h('option', { value: 'active' }, '仅主动')),
|
||
h('select', { className: 'rt-input', value: priority, onChange: (e) => setPriority(e.target.value) },
|
||
h('option', { value: '' }, '易打性不限'),
|
||
h('option', { value: 'high' }, '易打(高)'),
|
||
h('option', { value: 'medium' }, '一般(中)'),
|
||
h('option', { value: 'low' }, '难打(低)')),
|
||
h('select', { className: 'rt-input', value: testStatus, onChange: (e) => setTestStatus(e.target.value) },
|
||
h('option', { value: '' }, '测试状态不限'),
|
||
h('option', { value: 'untested' }, '未测试'),
|
||
h('option', { value: 'testing' }, '测试中'),
|
||
h('option', { value: 'tested' }, '已测试'),
|
||
h('option', { value: 'blocked' }, '被封禁'),
|
||
h('option', { value: 'abandoned' }, '已放弃'),
|
||
h('option', { value: 'no_surface' }, '无攻击面')),
|
||
h('div', { className: 'rt-spacer' }),
|
||
h('button', { className: 'rt-btn', title: '重新拉取快照与当前视图数据', onClick: () => { if (onRefresh) onRefresh() } }, '刷新'),
|
||
h('button', { className: 'rt-btn' + (view === 'list' ? ' rt-btn-primary' : ''), onClick: () => setView('list') }, '列表'),
|
||
h('button', { className: 'rt-btn' + (view === 'timeline' ? ' rt-btn-primary' : ''), title: '按发现时间看资产(什么时候发现、哪天收了多少)', onClick: () => setView('timeline') }, '发现时间'),
|
||
h('button', { className: 'rt-btn' + (view === 'domain' ? ' rt-btn-primary' : ''), onClick: () => setView('domain') }, '域名'),
|
||
h('button', { className: 'rt-btn' + (view === 'web' ? ' rt-btn-primary' : ''), onClick: () => setView('web') }, 'Web'))
|
||
|
||
/* ── 结论行:一屏看清家底,数字点一下就是筛选 ───────────────────── */
|
||
const tests = (snapshot && snapshot.tests) || {}
|
||
const snapStats = (snapshot && snapshot.stats) || {}
|
||
const noFilter = !testStatus && !priority && !scope && !prov && !assetState && !cidr
|
||
const conclItem = (key, label, value, active, onClick) => h('span', Object.assign({
|
||
key: key, className: 'rt-concl-i' + (active ? ' on' : ''), title: '点击筛选 / 再点取消',
|
||
}, clickable(onClick, { label: '按「' + label + '」筛选(' + (value || 0) + ')', expanded: active })),
|
||
h('b', null, String(value || 0)), h('span', null, label))
|
||
const toggleTest = (v) => { setTestStatus((cur) => (cur === v ? '' : v)); setAssetState('') }
|
||
const conclusion = h('div', { className: 'rt-concl' },
|
||
conclItem('all', '台资产', snapStats.assets, noFilter, () => {
|
||
setTestStatus(''); setPriority(''); setScope(''); setProv(''); setAssetState(''); setCidr(null)
|
||
}),
|
||
conclItem('live', '存活', snapStats.liveAssets, assetState === 'live', () => { setAssetState((v) => (v === 'live' ? '' : 'live')); setTestStatus('') }),
|
||
conclItem('untested', '待测', tests.untested, testStatus === 'untested', () => toggleTest('untested')),
|
||
conclItem('testing', '测试中', tests.testing, testStatus === 'testing', () => toggleTest('testing')),
|
||
conclItem('tested', '已测', tests.tested, testStatus === 'tested', () => toggleTest('tested')),
|
||
conclItem('giveup', '放弃', (tests.abandoned || 0) + (tests.blocked || 0), testStatus === 'abandoned,blocked', () => toggleTest('abandoned,blocked')),
|
||
h('div', { className: 'rt-spacer' }),
|
||
h('select', {
|
||
className: 'rt-input', value: sort, onChange: (e) => setSort(e.target.value),
|
||
title: '列表排序方式',
|
||
},
|
||
h('option', { value: 'priority' }, '排序:易打性优先'),
|
||
h('option', { value: 'todo' }, '排序:待测优先'),
|
||
h('option', { value: 'discovered' }, '排序:最近发现优先'),
|
||
h('option', { value: 'ports' }, '排序:端口多优先'),
|
||
h('option', { value: 'ip' }, '排序:按 IP')))
|
||
|
||
const head = h('div', { className: 'rt-row head' },
|
||
h('span', null, 'IP'), h('span', null, '易打'), h('span', null, '测试状态'),
|
||
h('span', null, '开放端口 / 服务'), h('span', null, '指纹'))
|
||
|
||
const rowNodes = []
|
||
for (const it of state.items) {
|
||
const openPorts = it.ports.filter((p) => p.state === 'open')
|
||
const portText = openPorts.map((p) => p.port + (p.service ? '/' + p.service : '')).join(', ') || '—'
|
||
const fpText = it.fingerprints.map((f) => [f.vendor, f.product, f.version].filter(Boolean).join(' ')).join(' / ') || '—'
|
||
/* 端口最多列 3 个,其余用 +N;主被动来源不再占列,进详情 */
|
||
const shownPorts = openPorts.slice(0, 3).map((p) => p.port + (p.service ? '/' + p.service : '')).join(', ')
|
||
const morePorts = openPorts.length > 3 ? ' +' + (openPorts.length - 3) : ''
|
||
rowNodes.push(h('div', Object.assign({
|
||
key: 'r' + it.id, className: 'rt-row',
|
||
}, clickable(() => toggleRow(it.id), { label: '展开资产 ' + it.ip, expanded: openId === it.id })),
|
||
h('span', { className: 'rt-mono', style: { display: 'flex', alignItems: 'baseline', gap: 4, flexWrap: 'wrap' } },
|
||
h('span', {
|
||
className: 'rt-scope rt-scope-' + (it.scope === 'internal' ? 'internal' : 'external'),
|
||
}, it.scope === 'internal' ? '内' : '外'),
|
||
h('span', {
|
||
title: it.state === 'live' ? '存活' : String(it.state),
|
||
style: {
|
||
width: 7, height: 7, borderRadius: '50%', flex: 'none', marginTop: 4,
|
||
background: it.state === 'live' ? '#10b981' : '#94a3b8',
|
||
},
|
||
}),
|
||
h('span', { style: { overflow: 'hidden', textOverflow: 'ellipsis' } }, it.ip),
|
||
/* 发现时间挂在 IP 下面一行:IP 列宽度有限,不另开列(列宽一改整表要跟着调) */
|
||
h('span', {
|
||
style: { fontSize: 10.5, color: 'var(--dsw-alias-label-secondary)', whiteSpace: 'nowrap' },
|
||
title: '发现时间:' + (it.discovered_at ? fmt(it.discovered_at) : '未记录')
|
||
+ (it.last_seen ? '\n最近采集:' + fmt(it.last_seen) : ''),
|
||
}, it.discovered_at ? fmtShort(it.discovered_at) : '发现时间未知')),
|
||
h('span', null, h(PriTag, { p: it.priority, title: it.potential || '' })),
|
||
h('span', null, h(TestTag, { s: it.test_status }),
|
||
it.blocked_count ? h('span', { className: 'rt-tag', style: { color: '#ef4444', borderColor: '#ef444455', marginLeft: 4 } }, '封' + it.blocked_count) : null),
|
||
h('span', { title: portText }, (shownPorts || '—') + morePorts),
|
||
h('span', { title: fpText }, fpText.length > 30 ? fpText.slice(0, 30) + '…' : fpText)))
|
||
|
||
if (openId !== it.id) continue
|
||
const d = detail && detail.id === it.id ? detail : null
|
||
const portRows = []
|
||
const fpRows = []
|
||
const obsRows = []
|
||
if (d) {
|
||
for (const p of (d.ports || [])) {
|
||
if (p.state !== 'open') continue
|
||
portRows.push(h('div', { key: 'p' + p.port, className: 'rt-kv' },
|
||
h('b', { className: 'rt-mono' }, p.port + '/' + p.proto),
|
||
h('span', null, [p.service, p.product, p.version].filter(Boolean).join(' ') || '未知服务'),
|
||
p.url ? h('a', { className: 'rt-link', href: p.url, target: '_blank', rel: 'noreferrer', title: p.url }, p.title ? p.title : p.url) : null,
|
||
h(ProvTag, { p: p.provenance })))
|
||
}
|
||
for (const f of (d.fingerprints || [])) {
|
||
fpRows.push(h('div', { key: 'f' + (f.product || '') + (f.version || '') + (f.evidence || ''), className: 'rt-kv' },
|
||
h('b', null, f.category || '—'),
|
||
h('span', null, [f.vendor, f.product, f.version].filter(Boolean).join(' ') + (f.evidence ? '(' + f.evidence + ')' : '')),
|
||
h(ProvTag, { p: f.provenance })))
|
||
}
|
||
for (const o of (d.observations || []).slice(0, 8)) {
|
||
obsRows.push(h('div', { key: 'o' + (o.attr || '') + (o.value || '') + (o.collected_at || ''), className: 'rt-kv' },
|
||
h('b', null, o.attr || '—'),
|
||
h('span', null, (o.value || '') + ' · ' + (o.tool || '未知工具') + ' · ' + fmt(o.collected_at)),
|
||
h(ProvTag, { p: o.provenance })))
|
||
}
|
||
}
|
||
/* 详情分三层:3 行必读 → 「展开全部」后才是溯源、原始记录与备注全文 */
|
||
const all = showAll === it.id
|
||
const noteLines = d && d.test_notes ? d.test_notes.split('\n').filter(Boolean) : []
|
||
const inner = d
|
||
? h('div', null,
|
||
/* 必读三行 */
|
||
h('div', { className: 'rt-kv' }, h('b', null, '测试'), h('span', null,
|
||
h(TestTag, { s: d.test_status }),
|
||
d.blocked_count ? h('span', { className: 'rt-tag', style: { marginLeft: 6, color: '#ef4444', borderColor: '#ef444455' } }, '被封 ' + d.blocked_count + ' 次') : null,
|
||
h('span', { style: { marginLeft: 8, color: 'var(--dsw-alias-label-secondary)' } },
|
||
(d.test_updated_at ? fmt(d.test_updated_at) : '未测过') + (d.test_updated_by ? ' · ' + d.test_updated_by : '')))),
|
||
h('div', { className: 'rt-kv' }, h('b', null, '易打性'), h('span', null,
|
||
h(PriTag, { p: d.priority }),
|
||
h('span', { style: { marginLeft: 8 } }, d.potential || '未评估'),
|
||
d.assess_reason ? h('span', { style: { marginLeft: 8, color: 'var(--dsw-alias-label-secondary)' } }, d.assess_reason) : null)),
|
||
h('div', { className: 'rt-kv' }, h('b', null, '攻击面'), h('span', null,
|
||
d.test_surface || (portRows.length ? '未记录(开放端口见下)' : '无开放端口'),
|
||
d.scope ? h('span', { className: 'rt-scope rt-scope-' + (d.scope === 'internal' ? 'internal' : 'external'), style: { marginLeft: 8 } },
|
||
d.scope === 'internal' ? '内网资产' : '外网资产') : null)),
|
||
h('span', Object.assign({ className: 'rt-more' },
|
||
clickable(() => setShowAll(all ? null : it.id), { label: all ? '收起资产详情' : '展开资产详情', expanded: all })),
|
||
all ? '收起全部 ▲' : '展开全部(端口 · 指纹 · 采集溯源 · 测试记录)▼'),
|
||
all ? h('div', null,
|
||
h('div', { className: 'rt-kv' }, h('b', null, '主机名'), h('span', null, (d.names || []).map((n) => n.name).join(', ') || '—')),
|
||
h('div', { className: 'rt-kv' }, h('b', null, '发现时间'),
|
||
h('span', { title: '本条资产第一次进入资产库的时刻(重复采集只刷新"最近采集")' }, fmt(d.discovered_at)),
|
||
h('b', { style: { minWidth: 0, marginLeft: 8 } }, '最近采集'), h('span', null, fmt(d.last_seen)),
|
||
h('b', { style: { minWidth: 0, marginLeft: 8 } }, '数据源首见'), h('span', null, fmt(d.first_seen))),
|
||
h('div', { className: 'rt-kv' }, h('b', null, 'C 段'), h('span', null, d.segment_cidr)),
|
||
h('div', { className: 'rt-kv' }, h('b', null, '易打性'), h('span', null,
|
||
(d.priority || '未评估') + (d.potential ? ' · 预期:' + d.potential : '')
|
||
+ (d.assess_reason ? ' · 依据:' + d.assess_reason : '')
|
||
+ (d.assessed_at ? '(' + fmt(d.assessed_at) + ')' : ''))),
|
||
h('div', { className: 'rt-kv' }, h('b', null, '来源'), h('span', null,
|
||
'被动端口 ' + (d.passive || 0) + ' · 主动端口 ' + (d.active || 0))),
|
||
h('div', { style: { margin: '6px 0 3px', fontWeight: 600 } }, '开放端口 / 服务'),
|
||
h('div', null, portRows.length ? portRows : '—'),
|
||
h('div', { style: { margin: '6px 0 3px', fontWeight: 600 } }, '指纹'),
|
||
h('div', null, fpRows.length ? fpRows : '—'),
|
||
noteLines.length
|
||
? h(Clip, { key: 'notes', label: '测试记录(' + noteLines.length + ' 条)', text: noteLines.join('\n') })
|
||
: null,
|
||
h('div', { style: { margin: '6px 0 3px', fontWeight: 600 } }, '采集溯源(最近 8 条)'),
|
||
h('div', null, obsRows.length ? obsRows : '—'))
|
||
: null)
|
||
: h('div', null, '加载中…')
|
||
rowNodes.push(h('div', {
|
||
key: 'd' + it.id, className: 'rt-row',
|
||
style: { cursor: 'default', gridTemplateColumns: '1fr' },
|
||
}, h('div', { className: 'rt-expand' }, inner)))
|
||
}
|
||
|
||
const listPane = h('div', { className: 'rt-table' }, head, rowNodes,
|
||
!state.loading && !state.items.length ? h('div', { className: 'rt-empty' }, '没有匹配的资产') : null)
|
||
|
||
/* 域名维度:域名 → 关联资产 */
|
||
const domainPane = h('div', { className: 'rt-table' },
|
||
domains === null ? h('div', { className: 'rt-empty' }, '加载中…')
|
||
: domains.length
|
||
? domains.map((g) => h('div', { key: g.domain },
|
||
h('div', { className: 'rt-section', style: { padding: '8px 10px 4px' } },
|
||
h('a', { className: 'rt-link', href: 'http://' + g.domain, target: '_blank', rel: 'noreferrer' }, g.domain),
|
||
h('span', { className: 'rt-tag', style: { marginLeft: 8 } }, g.count + ' 个资产')),
|
||
g.assets.map((a) => h('div', {
|
||
key: String(g.domain) + '\u0000' + a.id + '\u0000' + a.ip, className: 'rt-row',
|
||
style: { gridTemplateColumns: '150px 130px 70px 1fr', cursor: 'pointer' },
|
||
onClick: () => { setView('list'); setQ(''); setQApplied(a.ip) },
|
||
},
|
||
h('span', { className: 'rt-mono' }, a.ip),
|
||
h('span', { className: 'rt-mono' }, a.segment),
|
||
h('span', null, h('span', { className: 'rt-tag rt-tag-' + (a.state === 'live' ? 'live' : 'dead') }, a.state === 'live' ? '存活' : a.state)),
|
||
h('span', { style: { fontSize: 11, color: 'var(--dsw-alias-label-secondary)' } }, (a.names || []).join(', '))))))
|
||
: h('div', { className: 'rt-empty' }, cidr ? '该 C 段下暂无域名(切换到「全部 C 段」看全量)' : '暂无域名数据(信息收集阶段会写入域名)'))
|
||
|
||
/* Web 资产:标题 + 可直接点击的 URL */
|
||
const webPane = h('div', { className: 'rt-table' },
|
||
h('div', { className: 'rt-row head', style: { gridTemplateColumns: '1.6fr 1.2fr 130px 110px' } },
|
||
h('span', null, 'URL(可点击)'), h('span', null, '标题'), h('span', null, '资产'), h('span', null, '服务')),
|
||
(web || []).map((w) => {
|
||
const url = w.url || ('http' + (w.port === 443 || w.port === 8443 || w.port === 9443 ? 's' : '') + '://' + w.ip + (w.port === 80 || w.port === 443 ? '' : ':' + w.port))
|
||
return h('div', {
|
||
key: 'w' + w.port_id, className: 'rt-row',
|
||
style: { gridTemplateColumns: '1.6fr 1.2fr 130px 110px', cursor: 'default' },
|
||
},
|
||
h('a', { className: 'rt-link rt-mono', href: url, target: '_blank', rel: 'noreferrer', title: url }, url),
|
||
h('span', { title: w.title || '' }, w.title || '—'),
|
||
h('span', { className: 'rt-mono' }, w.ip + ' · ' + w.segment_cidr),
|
||
h('span', { style: { fontSize: 11 } }, [w.service, w.product, w.version].filter(Boolean).join(' ')))
|
||
}),
|
||
web === null ? h('div', { className: 'rt-empty' }, '加载中…')
|
||
: (!web.length ? h('div', { className: 'rt-empty' }, cidr ? '该 C 段下暂无 Web 资产' : '暂无 Web 资产(HTTP 探测后会写入 URL 与标题)') : null))
|
||
|
||
|
||
let pane = listPane
|
||
if (view === 'testing') pane = h(TestingTab, { engagement: eng, refreshKey: refreshKey })
|
||
else if (view === 'domain') pane = domainPane
|
||
else if (view === 'web') pane = webPane
|
||
/* 发现时间视图自带滚动容器,直接放进 rt-main 的 flex 里 */
|
||
else if (view === 'timeline') pane = h(DiscoveryView, { engagement: eng, refreshKey: refreshKey })
|
||
|
||
return h('div', { className: 'rt-split' }, side,
|
||
h('div', { className: 'rt-main' }, view === 'testing' ? null : toolbar,
|
||
conclusion,
|
||
state.error ? h('div', { className: 'rt-err' }, state.error) : null,
|
||
pane))
|
||
}
|
||
|
||
/* ---------------------------------------------------------- 资产发现时间线 */
|
||
/**
|
||
* 资产发现时间线:什么时候发现了什么、哪天收了多少。
|
||
* ⚠️ 这个组件曾在"移除图谱视图"时被连带删掉 —— 删除区间的结束标记选得太宽
|
||
* (图谱画布的收尾与它挨着),bundle 自检的 "client.js 有资产「发现时间」视图"
|
||
* 因此变红。恢复时把插入点固定成"资产测绘注释块之前",避免再被别的删除波及。
|
||
*/
|
||
function DiscoveryView(props) {
|
||
const eng = props.engagement
|
||
const refreshKey = props.refreshKey || 0
|
||
const [data, setData] = React.useState(null)
|
||
const [err, setErr] = React.useState(null)
|
||
const [day, setDay] = React.useState('')
|
||
React.useEffect(() => {
|
||
if (!eng) return
|
||
setData(null)
|
||
api({ op: 'discoveryTimeline', engagement: eng, limit: 200 })
|
||
.then((r) => { if (!r || r.ok === false) setErr((r && r.error) || '加载失败'); else { setErr(null); setData(r) } },
|
||
(e) => setErr(String((e && e.message) || e)))
|
||
}, [eng, refreshKey])
|
||
if (err) return h('div', { className: 'rt-pane' }, h('div', { className: 'rt-err' }, err))
|
||
if (!data) return h('div', { className: 'rt-empty' }, '加载中…')
|
||
const recent = (data.recent || []).filter((a) => day === '' || String(a.discovered_at || '').slice(0, 10) === day)
|
||
return h('div', { className: 'rt-pane', style: { flex: 1, minHeight: 0 } },
|
||
h('div', { className: 'rt-card' },
|
||
h('h4', null, '资产发现时间线'),
|
||
h('div', { style: { fontSize: 12, color: 'var(--dsw-alias-label-secondary)' } },
|
||
'共 ' + ((data.span && data.span.total) || 0) + ' 台 · 最早 '
|
||
+ (data.span && data.span.first ? fmt(data.span.first) : '—')
|
||
+ ' · 最近 ' + (data.span && data.span.last ? fmt(data.span.last) : '—')
|
||
+ '(发现时间 = 第一次进入资产库的时刻;重复采集只刷新"最近采集")')),
|
||
(data.days || []).length === 0 ? h('div', { className: 'rt-empty' }, '还没有资产。') : null,
|
||
h('div', { className: 'rt-card' },
|
||
h('h4', null, '按天统计', day
|
||
? h('span', Object.assign({ className: 'rt-tag', style: { marginLeft: 6, cursor: 'pointer' } },
|
||
clickable(() => setDay(''), { label: '清除按天筛选:' + day })), '清除筛选:' + day)
|
||
: null),
|
||
h('div', { style: { display: 'flex', flexWrap: 'wrap', gap: 8 } },
|
||
(data.days || []).map((d) => h('div', Object.assign({
|
||
key: d.day, className: 'rt-seg' + (day === d.day ? ' on' : ''), style: { marginBottom: 0, cursor: 'pointer' },
|
||
title: d.day + ':新增 ' + d.assets + ' 台(内网 ' + d.internal + ' / 外网 ' + d.external + ')',
|
||
}, clickable(() => setDay(day === d.day ? '' : d.day),
|
||
{ label: '筛选 ' + d.day + ' 发现的资产', expanded: day === d.day })),
|
||
h('div', { className: 'rt-seg-cidr' }, d.day),
|
||
h('div', { className: 'rt-seg-meta' }, d.assets + ' 台 · 内 ' + d.internal + ' / 外 ' + d.external))))),
|
||
h('div', { className: 'rt-card' },
|
||
h('h4', null, day ? day + ' 发现的资产(' + recent.length + ')' : '最近发现的资产(' + recent.length + ')'),
|
||
recent.length === 0 ? h('div', { className: 'rt-empty' }, '这一天没有新增资产。') : null,
|
||
recent.map((a) => h('div', { key: a.id, className: 'rt-kv' },
|
||
h('b', { className: 'rt-mono', style: { minWidth: 110 } },
|
||
h('span', { className: 'rt-scope rt-scope-' + (a.scope === 'internal' ? 'internal' : 'external'), style: { marginRight: 4 } },
|
||
a.scope === 'internal' ? '内' : '外'),
|
||
a.ip),
|
||
h('span', { style: { minWidth: 132, fontSize: 11.5, color: 'var(--dsw-alias-label-secondary)' } },
|
||
a.discovered_at ? fmt(a.discovered_at) : '时间未知'),
|
||
h('span', { style: { flex: 1 } }, (a.primary_name || '—') + (a.open_ports ? ' · ' + a.open_ports + ' 端口' : '')),
|
||
h(PriTag, { p: a.priority })))))
|
||
}
|
||
|
||
/* ---------------------------------------------------------- 智能体提示词 */
|
||
function PromptsTab(props) {
|
||
const eng = props.engagement
|
||
const refreshKey = props.refreshKey || 0
|
||
const [roles, setRoles] = React.useState([])
|
||
const [active, setActive] = React.useState(null)
|
||
const [draft, setDraft] = React.useState('')
|
||
const [msg, setMsg] = React.useState(null)
|
||
const [busy, setBusy] = React.useState(false)
|
||
|
||
React.useEffect(() => {
|
||
/* 无靶标也允许:服务端回内置默认,不再报 engagement required */
|
||
api(eng ? { op: 'prompts', engagement: eng } : { op: 'prompts' }).then((r) => {
|
||
if (!r || r.ok === false) { setMsg({ err: (r && r.error) || '读取失败' }); return }
|
||
setRoles(r.roles || [])
|
||
if (r.roles && r.roles.length) setActive((cur) => cur || r.roles[0].role)
|
||
if (r.builtin) setMsg({ ok: '当前为内置默认提示词(创建靶标后可按靶标保存修改)' })
|
||
}, (e) => setMsg({ err: String((e && e.message) || e) }))
|
||
}, [eng, refreshKey])
|
||
|
||
React.useEffect(() => {
|
||
const r = roles.find((x) => x.role === active)
|
||
if (r) setDraft(r.content || '')
|
||
}, [active, roles])
|
||
|
||
const save = () => {
|
||
if (!eng) { setMsg({ err: '请先创建靶标,再保存提示词' }); return }
|
||
setBusy(true)
|
||
setMsg(null)
|
||
api({ op: 'savePrompt', engagement: eng, role: active, content: draft }).then((r) => {
|
||
setBusy(false)
|
||
if (!r || r.ok === false) { setMsg({ err: (r && r.error) || '保存失败' }); return }
|
||
setRoles((list) => list.map((x) => (x.role === active
|
||
? Object.assign({}, x, { content: draft, updated_at: new Date().toISOString() })
|
||
: x)))
|
||
setMsg({ ok: '已保存' })
|
||
}, (e) => { setBusy(false); setMsg({ err: String((e && e.message) || e) }) })
|
||
}
|
||
|
||
/* 老靶标的提示词是旧版模板;这里可以把当前角色(或全部)恢复成内置最新版 */
|
||
const reset = (all) => {
|
||
if (!eng) { setMsg({ err: '请先创建靶标,再恢复默认提示词' }); return }
|
||
setBusy(true); setMsg(null)
|
||
api({ op: 'resetPrompts', engagement: eng, role: all ? undefined : active }).then((r) => {
|
||
setBusy(false)
|
||
if (!r || r.ok === false) { setMsg({ err: (r && r.error) || '恢复失败' }); return }
|
||
setMsg({ ok: '已恢复内置默认:' + (r.reset || []).join('、') })
|
||
api({ op: 'prompts', engagement: eng }).then((rr) => {
|
||
if (rr && rr.ok) {
|
||
setRoles(rr.roles || [])
|
||
const cur2 = (rr.roles || []).find((x) => x.role === active)
|
||
if (cur2) setDraft(cur2.content || '')
|
||
}
|
||
}, () => {})
|
||
}, (e) => { setBusy(false); setMsg({ err: String((e && e.message) || e) }) })
|
||
}
|
||
|
||
const cur = roles.find((x) => x.role === active)
|
||
const items = roles.map((r) => h('div', Object.assign({
|
||
key: r.role, className: 'rt-item' + (active === r.role ? ' on' : ''),
|
||
}, clickable(() => { setActive(r.role); setMsg(null) },
|
||
{ label: '查看「' + r.title + '」提示词', expanded: active === r.role })),
|
||
h('div', { className: 'rt-item-name' }, r.title),
|
||
h('div', { className: 'rt-item-desc' }, (r.content || '').replace(/[#*`]/g, '').slice(0, 60) || '(空)')))
|
||
|
||
return h('div', { className: 'rt-split' },
|
||
h('div', { className: 'rt-list' }, items),
|
||
h('div', { className: 'rt-main' },
|
||
h('div', { className: 'rt-toolbar' },
|
||
h('span', { style: { fontWeight: 600 } }, cur ? cur.title : '提示词'),
|
||
h('span', { className: 'rt-tag' }, '更新 ' + fmt(cur && cur.updated_at)),
|
||
h('div', { className: 'rt-spacer' }),
|
||
h('button', {
|
||
className: 'rt-btn', disabled: busy || !active, title: '把当前角色恢复成内置最新版提示词',
|
||
onClick: () => reset(false),
|
||
}, '恢复默认为当前'),
|
||
h('button', {
|
||
className: 'rt-btn', disabled: busy, title: '四个角色全部恢复成内置最新版提示词',
|
||
onClick: () => reset(true),
|
||
}, '全部恢复默认'),
|
||
h('button', { className: 'rt-btn rt-btn-primary', disabled: busy || !active, onClick: save }, busy ? '保存中…' : '保存')),
|
||
msg ? h('div', { className: msg.err ? 'rt-err' : 'rt-foot' }, msg.err || msg.ok) : null,
|
||
h('div', { className: 'rt-pane' },
|
||
h('textarea', {
|
||
className: 'rt-textarea', value: draft, spellCheck: false,
|
||
onChange: (e) => setDraft(e.target.value),
|
||
placeholder: '该角色的系统提示词(Markdown)',
|
||
}),
|
||
h('div', { style: { fontSize: 11, color: 'var(--dsw-alias-label-secondary)', marginTop: 6 } },
|
||
'保存后写入 agents/' + (active || 'role') + '.md。子会话任务第一行写 redteamRole: recon、assess、vuln-scan、exploit 或 internal 后,对应文件进入系统提示词。'))))
|
||
}
|
||
|
||
/* ---------------------------------------------------------- 技能库(DSH 原生) */
|
||
function SkillsTab(props) {
|
||
const refreshKey = props.refreshKey || 0
|
||
const [items, setItems] = React.useState([])
|
||
const [meta, setMeta] = React.useState({})
|
||
const [err, setErr] = React.useState(null)
|
||
const [q, setQ] = React.useState('')
|
||
const [srcOnly, setSrcOnly] = React.useState(false)
|
||
const [active, setActive] = React.useState(null)
|
||
const [detail, setDetail] = React.useState(null)
|
||
const [busy, setBusy] = React.useState(false)
|
||
const [brokenOnly, setBrokenOnly] = React.useState(false)
|
||
|
||
/* refresh=true 跳过后端 30 秒可用性缓存(技能正文/环境变量可能刚改过) */
|
||
const load = (force) => {
|
||
setBusy(true)
|
||
api({ op: 'skillCatalog', refresh: force === true }).then((r) => {
|
||
setBusy(false)
|
||
if (!r || r.ok === false) { setErr((r && r.error) || '读取失败'); return }
|
||
setErr(null)
|
||
setMeta(r || {})
|
||
setItems(r.items || [])
|
||
}, (e) => { setBusy(false); setErr(String((e && e.message) || e)) })
|
||
}
|
||
React.useEffect(load, [refreshKey])
|
||
|
||
const open = (skill) => {
|
||
const name = skill && skill.name
|
||
const key = (skill && skill.library ? skill.library : 'redteam') + ':' + name
|
||
if (active === key) { setActive(null); setDetail(null); return }
|
||
setActive(key)
|
||
setDetail(null)
|
||
api({ op: 'skillRead', name: name, library: skill.library || 'redteam' }).then((r) => {
|
||
if (r && r.ok) setDetail(r)
|
||
else setErr((r && r.error) || '读取失败')
|
||
}, (e) => setErr(String((e && e.message) || e)))
|
||
}
|
||
|
||
const needle = q.trim().toLowerCase()
|
||
let filtered = needle
|
||
? items.filter((s) => (s.name + ' ' + s.description + ' ' + s.whenToUse).toLowerCase().indexOf(needle) >= 0)
|
||
: items
|
||
if (srcOnly) filtered = filtered.filter((s) => s.library !== 'official' && s.fromPlugin === true)
|
||
if (brokenOnly) filtered = filtered.filter((s) => s.library !== 'official' && (s.availability === 'broken' || s.availability === 'unknown'))
|
||
/* 目录聚合:一眼看出"这么多技能是哪来的"(本项目/别的插件/自带根…) */
|
||
const dirs = (meta.byDir || []).filter((d) => d.n > 0).slice(0, 6)
|
||
const availSummary = meta.availability ? meta.availability.summary : null
|
||
const needRestart = err !== null && String(err).indexOf('unknown op') >= 0
|
||
|
||
const renderSkill = (s) => h('div', Object.assign({
|
||
key: (s.library || 'redteam') + ':' + s.name, className: 'rt-item' + (active === ((s.library || 'redteam') + ':' + s.name) ? ' on' : ''),
|
||
}, clickable(() => open(s), { label: '查看技能 ' + s.name, expanded: active === ((s.library || 'redteam') + ':' + s.name) })),
|
||
h('div', { className: 'rt-item-name' }, s.name,
|
||
s.modelInvocable === false ? h('span', { className: 'rt-tag', style: { marginLeft: 6 } }, '仅人工') : null,
|
||
s.library === 'official'
|
||
? h('span', { className: 'rt-tag', style: { marginLeft: 6 }, title: '官方技能目录,不按红队工具路径检查' }, '官方')
|
||
: h('span', {
|
||
className: 'rt-avail rt-avail-' + (s.availability || 'unknown'),
|
||
style: { marginLeft: 6 },
|
||
title: (s.availability === 'available'
|
||
? '可用:技能正文可以加载。缺的外部工具或密钥记在详情里,不阻止调用。'
|
||
: (s.problems || []).join('\n') || '未知'),
|
||
}, s.availability === 'available' ? '可用' : s.availability === 'broken' ? '不可用' : '未知'),
|
||
h('div', { className: 'rt-item-desc' }, s.description || '(无描述)'),
|
||
h('div', { className: 'rt-kb-sub' },
|
||
[s.source ? '来源 ' + s.source : null,
|
||
s.fromPlugin ? '本插件自带' : null,
|
||
s.provider ? s.provider : null,
|
||
s.dir ? s.dir : null].filter(Boolean).join(' · ')),
|
||
(s.problems || []).length > 0 && s.availability !== 'available' && s.library !== 'official'
|
||
? h('div', { className: 'rt-kb-sub', style: { color: 'var(--dsw-alias-state-warn-primary, #f59e0b)' } },
|
||
'⚠ ' + String(s.problems[0]).slice(0, 60))
|
||
: null))
|
||
const redteamRows = filtered.filter((s) => s.library !== 'official').map(renderSkill)
|
||
const officialRows = filtered.filter((s) => s.library === 'official').map(renderSkill)
|
||
const listItems = []
|
||
if (redteamRows.length > 0) {
|
||
listItems.push(h('div', { key: 'head-redteam', className: 'rt-kb-sub', style: { margin: '8px 0 4px', fontWeight: 600 } }, '红队技能'))
|
||
listItems.push.apply(listItems, redteamRows)
|
||
}
|
||
if (officialRows.length > 0) {
|
||
listItems.push(h('div', { key: 'head-official', className: 'rt-kb-sub', style: { margin: '8px 0 4px', fontWeight: 600 } }, '官方技能'))
|
||
listItems.push.apply(listItems, officialRows)
|
||
}
|
||
|
||
return h('div', { className: 'rt-split' },
|
||
h('div', { className: 'rt-list' },
|
||
h('input', {
|
||
className: 'rt-input', style: { width: '100%', marginBottom: 8, boxSizing: 'border-box' },
|
||
placeholder: '过滤技能', value: q, onChange: (e) => setQ(e.target.value),
|
||
}),
|
||
h('label', { className: 'rt-kb-check', style: { display: 'flex', margin: '0 0 6px' } },
|
||
h('input', { type: 'checkbox', checked: srcOnly, onChange: (e) => setSrcOnly(e.target.checked) }),
|
||
'只看本插件自带(' + (meta.fromPlugin || 0) + ' 个)'),
|
||
availSummary && (availSummary.broken > 0 || availSummary.unknown > 0)
|
||
? h('label', {
|
||
className: 'rt-kb-check', style: { display: 'flex', margin: '0 0 8px' },
|
||
title: '只看有明确缺口(缺 key / 缺本机路径 / 基础设施还是占位符)或判不了可用性的技能',
|
||
},
|
||
h('input', { type: 'checkbox', checked: brokenOnly, onChange: (e) => setBrokenOnly(e.target.checked) }),
|
||
'只看不可用/未知(' + ((availSummary.broken || 0) + (availSummary.unknown || 0)) + ' 个)')
|
||
: null,
|
||
availSummary
|
||
? h('div', { style: { display: 'flex', gap: 4, flexWrap: 'wrap', marginBottom: 6 } },
|
||
h('span', { className: 'rt-avail rt-avail-available' }, '可用 ' + (availSummary.available || 0)),
|
||
availSummary.broken > 0 ? h('span', { className: 'rt-avail rt-avail-broken' }, '不可用 ' + availSummary.broken) : null,
|
||
availSummary.unknown > 0 ? h('span', { className: 'rt-avail rt-avail-unknown' }, '未知 ' + availSummary.unknown) : null,
|
||
h('span', Object.assign({
|
||
className: 'rt-tag', style: { cursor: 'pointer' },
|
||
title: '技能正文或环境变量刚改过?点这里跳过 30 秒缓存重查',
|
||
}, clickable(() => load(true), { label: '重新检查技能可用性' })), '重查可用性'))
|
||
: null,
|
||
h('div', { style: { fontSize: 11, color: 'var(--dsw-alias-label-secondary)', marginBottom: 6 } },
|
||
'红队 ' + ((meta.libraries && meta.libraries.redteam) || 0) + ' · 官方 ' + ((meta.libraries && meta.libraries.official) || 0),
|
||
items.length > 100 ? h('div', { style: { marginTop: 3 } },
|
||
'(技能多来自其它插件注册的根或你自己的技能目录;本插件只自带 ' + (meta.fromPlugin || 0) + ' 个)') : null),
|
||
listItems),
|
||
h('div', { className: 'rt-main' },
|
||
h('div', { className: 'rt-toolbar' },
|
||
h('span', { style: { fontWeight: 600 } }, detail ? detail.name : '技能目录(DSH 原生)'),
|
||
h('div', { className: 'rt-spacer' }),
|
||
h('button', { className: 'rt-btn', disabled: busy, onClick: () => load(true) }, busy ? '刷新中…' : '刷新')),
|
||
err
|
||
? (needRestart
|
||
? h('div', { className: 'rt-empty' }, '该模块的宿主代码已更新,需重启一次当前宿主(官方 exe / 社区版 / dsh web)后生效')
|
||
: h('div', { className: 'rt-err' }, err))
|
||
: null,
|
||
detail
|
||
? h('div', { className: 'rt-pane' },
|
||
h('div', { className: 'rt-kv' }, h('b', null, '描述'), h('span', null, detail.description || '—')),
|
||
h('div', { className: 'rt-kv' }, h('b', null, '何时使用'), h('span', null, detail.whenToUse || '—')),
|
||
h('div', { className: 'rt-kv' }, h('b', null, '来源'), h('span', null, (detail.provider || '—') + ' / ' + (detail.source || '—'))),
|
||
(() => {
|
||
const s2 = items.find((x) => ((x.library || 'redteam') + ':' + x.name) === active)
|
||
if (!s2) return null
|
||
if (s2.library === 'official') {
|
||
return h('div', { className: 'rt-kv' }, h('b', null, '类型'), h('span', null, '官方技能。由宿主按任务调用,不按红队工具路径检查。'))
|
||
}
|
||
const avail = s2.availability || 'unknown'
|
||
return h('div', null,
|
||
h('div', { className: 'rt-kv' }, h('b', null, '可用性'),
|
||
h('span', { className: 'rt-avail rt-avail-' + avail },
|
||
avail === 'available' ? '可用' : avail === 'broken' ? '不可用(有明确缺口)' : '未知(正文读不到)')),
|
||
(s2.problems || []).length > 0
|
||
? h('div', { className: 'rt-kv' }, h('b', null, avail === 'available' ? '未装上的外部工具' : '缺口'),
|
||
h('span', null, s2.problems.map((x, i) => h('div', { key: 'p' + i }, '· ' + x))))
|
||
: null,
|
||
(s2.needs_user || []).length > 0
|
||
? h('div', { className: 'rt-kv' }, h('b', null, '需要你提供'),
|
||
h('span', { style: { color: 'var(--dsw-alias-state-warn-primary, #f59e0b)' } },
|
||
s2.needs_user.map((x, i) => h('div', { key: 'n' + i }, '· ' + x))))
|
||
: null)
|
||
})(),
|
||
detail.path ? h('div', { className: 'rt-kv' }, h('b', null, '文件'), h('span', { className: 'rt-mono', style: { wordBreak: 'break-all' } }, detail.path)) : null,
|
||
h('pre', { className: 'rt-md', style: { border: '1px solid var(--dsw-alias-border-l1)', borderRadius: 6, maxHeight: '52vh' } }, detail.content || '(空)'))
|
||
: h('div', { className: 'rt-pane' },
|
||
h('div', { style: { fontSize: 12, color: 'var(--dsw-alias-label-secondary)', marginBottom: 8 } },
|
||
meta.note || '技能由 DSH 原生 skill 体系管理,红队智能体通过 skill 工具调用。'),
|
||
availSummary
|
||
? h('div', { style: { fontSize: 12, marginBottom: 8, display: 'flex', gap: 6, flexWrap: 'wrap', alignItems: 'center' } },
|
||
h('span', { className: 'rt-avail rt-avail-available' }, '可用 ' + (availSummary.available || 0)),
|
||
availSummary.broken > 0 ? h('span', { className: 'rt-avail rt-avail-broken' }, '不可用 ' + availSummary.broken) : null,
|
||
availSummary.unknown > 0 ? h('span', { className: 'rt-avail rt-avail-unknown' }, '未知 ' + availSummary.unknown) : null,
|
||
h('span', { style: { color: 'var(--dsw-alias-label-secondary)' } },
|
||
'(' + (meta.availability && meta.availability.cached ? '缓存于 ' : '检查于 ')
|
||
+ (meta.availability && meta.availability.checked_at ? fmt(meta.availability.checked_at) : '—') + ')'))
|
||
: null,
|
||
meta.availability && (meta.availability.broken || []).length > 0
|
||
? h('div', { className: 'rt-hint' },
|
||
h('div', { style: { fontWeight: 600, marginBottom: 4 } }, '现在跑不起来的技能:'),
|
||
meta.availability.broken.slice(0, 12).map((b) => h('div', { key: b.name },
|
||
'· ' + b.name + (b.problems && b.problems.length ? ' — ' + b.problems[0] : ''))))
|
||
: null,
|
||
(meta.byDir || []).slice(0, 6).map((d) => h('div', { key: d.key, className: 'rt-mono', style: { fontSize: 11, marginBottom: 2, overflowWrap: 'anywhere' } },
|
||
d.n + ' 个 · ' + d.key)),
|
||
(meta.byDir || []).length > 6
|
||
? h('div', { style: { fontSize: 11, color: 'var(--dsw-alias-label-secondary)' } },
|
||
'…另有 ' + ((meta.byDir || []).length - 6) + ' 个目录(共 ' + items.length + ' 个技能)')
|
||
: null,
|
||
h('div', { className: 'rt-empty' }, '左侧选择技能查看内容')))
|
||
)
|
||
}
|
||
|
||
/* ---------------------------------------------------------- 漏洞战果 */
|
||
const SEV_LABEL = { critical: '严重', high: '高危', medium: '中危', low: '低危', info: '信息' }
|
||
const STATUS_LABEL = { candidate: '待验证', confirmed: '已确认', 'false-positive': '误报', exploited: '已利用', fixed: '已修复' }
|
||
const sevClass = (s) => 'rt-sev rt-sev-' + (SEV_LABEL[s] ? s : 'info')
|
||
|
||
|
||
/** 目标归并:按「scheme://host:port」聚合,避免带路径的 URL 把分组打散。 */
|
||
function targetKeyOf(v) {
|
||
const t = String(v.target || '').trim()
|
||
if (t !== '') {
|
||
const url = /^([a-z][a-z0-9+.-]*:\/\/[^/?#\s]+)/i.exec(t)
|
||
if (url) return url[1]
|
||
const head = /^([^\s/?#]+)/.exec(t)
|
||
if (head) return head[1]
|
||
return t
|
||
}
|
||
return v.asset_ip || '(未指定目标)'
|
||
}
|
||
|
||
/* ---------------------------------------------------------- 证据渲染 */
|
||
/** 把一段原始 HTTP 报文按「请求行/状态行 + 头 + 体」着色,便于人眼扫读。 */
|
||
function HttpBlock(props) {
|
||
const text = String(props.text || '')
|
||
if (!text) return null
|
||
const lines = text.split(/\r?\n/)
|
||
const nodes = lines.map((ln, i) => {
|
||
let cls = null
|
||
if (i === 0 && /^(GET|POST|PUT|DELETE|HEAD|OPTIONS|PATCH|TRACE)\s/.test(ln)) cls = 'rt-hl-req'
|
||
else if (i === 0 && /^HTTP\//.test(ln)) cls = 'rt-hl-res'
|
||
else if (/^[A-Za-z0-9-]+:/.test(ln)) {
|
||
const name = ln.split(':')[0].toLowerCase()
|
||
if (name === 'host' || name === 'cookie' || name === 'authorization' || name === 'content-type') cls = 'rt-hl-req'
|
||
}
|
||
return h('div', { key: 'l' + i, className: cls || undefined }, ln === '' ? '\u00a0' : ln)
|
||
})
|
||
return h('pre', { className: 'rt-evi-body' + (props.compact ? ' req' : '') }, nodes)
|
||
}
|
||
|
||
/** 漏洞详情里的证据区:结构化证据文本 + 该漏洞的原始 HTTP 请求/响应记录。 */
|
||
function detailEvidence(v) {
|
||
const evi = String(v.evidence || '').trim()
|
||
const looksHttp = /^(GET|POST|PUT|DELETE|HEAD|OPTIONS|PATCH|HTTP\/)/m.test(evi) || /\n[A-Za-z-]+: /.test(evi)
|
||
const blocks = []
|
||
if (evi) {
|
||
blocks.push(h('div', { key: 'ev', className: 'rt-evi' },
|
||
h('div', { className: 'rt-evi-head' }, '证据摘要',
|
||
h('span', { className: 'rt-tag' }, looksHttp ? '原始报文' : '文本'),
|
||
h('div', { className: 'rt-spacer' }),
|
||
h('button', {
|
||
className: 'rt-btn', style: { padding: '0 6px', fontSize: 11 },
|
||
onClick: (e) => { e.stopPropagation(); copyText(evi) },
|
||
}, '复制')),
|
||
looksHttp ? h(HttpBlock, { text: evi }) : h('pre', { className: 'rt-evi-body' }, evi)))
|
||
}
|
||
const http = v.http_evidence || []
|
||
for (const e of http) {
|
||
blocks.push(h('div', { key: 'h' + e.id, className: 'rt-evi' },
|
||
h('div', { className: 'rt-evi-head' },
|
||
e.label || 'HTTP 证据',
|
||
h('span', { className: 'rt-tag' }, (e.method || '') + ' ' + (e.status === null || e.status === undefined ? '' : e.status)),
|
||
h('div', { className: 'rt-spacer' }),
|
||
h('span', { style: { fontWeight: 400, color: 'var(--dsw-alias-label-secondary)' } }, fmt(e.captured_at || e.created_at))),
|
||
e.request ? h('div', null,
|
||
h('div', { className: 'rt-evi-head', style: { borderTop: 'none' } }, '▸ 请求(可直接粘进 Burp Repeater)'),
|
||
h(HttpBlock, { text: e.request, compact: true })) : null,
|
||
e.response ? h('div', null,
|
||
h('div', { className: 'rt-evi-head' }, '▸ 响应'),
|
||
h(HttpBlock, { text: e.response })) : null,
|
||
e.note ? h('pre', { className: 'rt-evi-body', style: { maxHeight: 80 } }, e.note) : null))
|
||
}
|
||
if (blocks.length === 0) {
|
||
blocks.push(h('div', { key: 'none', className: 'rt-kv' }, h('b', null, '证据'),
|
||
h('span', { style: { color: 'var(--dsw-alias-state-error-primary)' } }, '缺失 —— 未验证/无证据的漏洞不计入报告,请补 redteam_http_evidence_add')))
|
||
}
|
||
return h('div', { key: 'eviwrap' }, blocks)
|
||
}
|
||
|
||
function FindingsTab(props) {
|
||
const eng = props.engagement
|
||
const refreshKey = props.refreshKey || 0
|
||
const [sev, setSev] = React.useState('')
|
||
const [status, setStatus] = React.useState('')
|
||
const [q, setQ] = React.useState('')
|
||
const [qApplied, setQApplied] = React.useState('')
|
||
const [state, setState] = React.useState({ loading: false, error: null, total: 0, items: [], stats: null })
|
||
const [creds, setCreds] = React.useState([])
|
||
const [accesses, setAccesses] = React.useState([])
|
||
const [files, setFiles] = React.useState([])
|
||
const [openId, setOpenId] = React.useState(null)
|
||
const [msg, setMsg] = React.useState(null)
|
||
/* 子页签:漏洞 / 凭据 / 访问会话(凭据不再铺在漏洞页底部) */
|
||
const [subTab, setSubTab] = React.useState('vulns')
|
||
/* 默认按目标聚合:322 条平铺没法读,先看"哪台被打下什么" */
|
||
const [grouped, setGrouped] = React.useState(true)
|
||
const collapse = useCollapse('findings:' + eng)
|
||
|
||
const load = () => {
|
||
if (!eng) return
|
||
api({
|
||
op: 'vulns', engagement: eng, severity: sev || undefined,
|
||
status: status || undefined, q: qApplied || undefined, limit: 200,
|
||
}).then((r) => {
|
||
if (!r || r.ok === false) {
|
||
setState({ loading: false, error: (r && r.error) || '查询失败', total: 0, items: [], stats: null })
|
||
return
|
||
}
|
||
setState({ loading: false, error: null, total: r.total, items: r.items || [], stats: r.stats || null })
|
||
}, (e) => setState({ loading: false, error: String((e && e.message) || e), total: 0, items: [], stats: null }))
|
||
api({ op: 'credentials', engagement: eng }).then((r) => setCreds((r && r.items) || []), () => {})
|
||
api({ op: 'access', engagement: eng }).then((r) => setAccesses((r && r.items) || []), () => {})
|
||
api({ op: 'attackFiles', engagement: eng }).then((r) => setFiles((r && r.items) || []), () => {})
|
||
}
|
||
React.useEffect(load, [eng, sev, status, qApplied, refreshKey])
|
||
|
||
const setVulnStatus = (id, next) => {
|
||
setMsg(null)
|
||
api({ op: 'updateVuln', engagement: eng, id: id, patch: { status: next } }).then((r) => {
|
||
if (!r || r.ok === false) { setMsg({ err: (r && r.error) || '更新失败' }); return }
|
||
setMsg({ ok: '已更新为「' + (STATUS_LABEL[next] || next) + '」' })
|
||
load()
|
||
}, (e) => setMsg({ err: String((e && e.message) || e) }))
|
||
}
|
||
|
||
const stats = (state.stats && state.stats.bySeverity) ? state.stats : { bySeverity: {}, byStatus: {} }
|
||
const needRestart = state.error !== null && String(state.error).indexOf('unknown op') >= 0
|
||
|
||
const allFiles = (files || []).flatMap((g) => (g.files || []).map((f) => Object.assign({ target: g.target }, f)))
|
||
const filesByVuln = new Map()
|
||
const orphanFiles = []
|
||
for (const f of allFiles) {
|
||
const vid = f.vuln_id === null || f.vuln_id === undefined ? null : Number(f.vuln_id)
|
||
if (vid === null) { orphanFiles.push(f); continue }
|
||
if (!filesByVuln.has(vid)) filesByVuln.set(vid, [])
|
||
filesByVuln.get(vid).push(f)
|
||
}
|
||
|
||
/* ── 结论行:只给结论,数字点一下就是筛选 ─────────────────────── */
|
||
const concl = (key, label, value, active, onClick) => h('span', Object.assign({
|
||
key: key, className: 'rt-concl-i' + (active ? ' on' : ''), title: '点击筛选 / 再点取消',
|
||
}, clickable(onClick, { label: '按「' + label + '」筛选(' + (value || 0) + ')', expanded: active })),
|
||
h('b', null, String(value || 0)), h('span', null, label))
|
||
const conclusion = h('div', { className: 'rt-concl' },
|
||
concl('conf', '已确认', (stats.byStatus.confirmed || 0), status === 'confirmed', () => setStatus((v) => (v === 'confirmed' ? '' : 'confirmed'))),
|
||
concl('exp', '已利用', (stats.byStatus.exploited || 0), status === 'exploited', () => setStatus((v) => (v === 'exploited' ? '' : 'exploited'))),
|
||
concl('crit', '严重', (stats.bySeverity.critical || 0), sev === 'critical', () => setSev((v) => (v === 'critical' ? '' : 'critical'))),
|
||
concl('high', '高危', (stats.bySeverity.high || 0), sev === 'high', () => setSev((v) => (v === 'high' ? '' : 'high'))),
|
||
concl('med', '中危', (stats.bySeverity.medium || 0), sev === 'medium', () => setSev((v) => (v === 'medium' ? '' : 'medium'))),
|
||
concl('gain', '拿到权限', (stats.withGained || 0), false, () => {}),
|
||
h('div', { className: 'rt-spacer' }),
|
||
h('span', { style: { fontSize: 11, color: 'var(--dsw-alias-label-secondary)' } },
|
||
'默认按目标聚合 · 共 ' + (stats.targetGroups || 0) + ' 个目标'))
|
||
|
||
const head = h('div', { className: 'rt-vrow head' },
|
||
h('span', null, '等级'), h('span', null, '漏洞 / 编号'), h('span', null, '目标'),
|
||
h('span', null, '拿到什么'), h('span', null, '状态'), h('span', null, '置信'))
|
||
|
||
/* 单条漏洞(行 + 展开详情),聚合视图与平铺视图共用 */
|
||
const vulnRows = (v, compact) => {
|
||
const out = []
|
||
const gainedList = String(v.gained || '').split(/[、,;,;]/).map((x) => x.trim()).filter(Boolean)
|
||
const open = openId === v.id
|
||
out.push(h('div', {
|
||
key: 'v' + v.id, className: 'rt-vrow', role: 'button', tabIndex: 0,
|
||
style: compact ? { cursor: 'pointer', gridTemplateColumns: '58px minmax(0,1fr) 104px 68px' } : { cursor: 'pointer' },
|
||
onClick: () => setOpenId(open ? null : v.id),
|
||
onKeyDown: (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setOpenId(open ? null : v.id) } },
|
||
},
|
||
h('span', null, h('span', { className: sevClass(v.severity) }, SEV_LABEL[v.severity] || v.severity)),
|
||
h('span', { title: v.title || '' }, h('span', { className: 'rt-sec-caret' }, open ? '▾' : '▸'), (v.cve ? v.cve + ' ' : '') + (v.title || '')),
|
||
compact ? null : h('span', { className: 'rt-mono', title: v.target || '' }, v.target || v.asset_ip || '—'),
|
||
h('span', { title: v.gained || '' },
|
||
gainedList.length
|
||
? h('span', { className: 'rt-gain', style: { maxWidth: '100%', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', display: 'inline-block' } }, gainedList[0] + (gainedList.length > 1 ? ' +' + (gainedList.length - 1) : ''))
|
||
: h('span', { style: { color: 'var(--dsw-alias-label-secondary)' } }, '—')),
|
||
h('span', null, h('span', { className: 'rt-tag' }, STATUS_LABEL[v.status] || v.status)),
|
||
compact ? null : h('span', null, v.confidence === null || v.confidence === undefined ? '—' : Math.round(v.confidence * 100) + '%')))
|
||
if (openId !== v.id) return out
|
||
out.push(h('div', {
|
||
key: 'vd' + v.id, className: 'rt-vrow',
|
||
style: { cursor: 'default', gridTemplateColumns: '1fr' },
|
||
}, h('div', { className: 'rt-vdetail' },
|
||
h('div', { className: 'rt-kv' }, h('b', null, '拿到什么'),
|
||
gainedList.length
|
||
? h('span', null, gainedList.map((g, gi) => h('span', { key: 'g' + gi, className: 'rt-gain', style: { marginRight: 6 } }, g)))
|
||
: h('span', { style: { color: 'var(--dsw-alias-label-secondary)' } },
|
||
'未记录 —— 拿到权限/成果后请用 redteam_vuln_update 补 gained(例:服务器权限、内网隧道、后台管理员账号)')),
|
||
h('div', { className: 'rt-kv' }, h('b', null, '目标'), h('span', { className: 'rt-mono', style: { wordBreak: 'break-all' } }, v.target || '—')),
|
||
h('div', { className: 'rt-kv' }, h('b', null, '资产'), h('span', null, (v.asset_ip || '—') + ' · ' + (v.segment_cidr || ''))),
|
||
h('div', { className: 'rt-kv' }, h('b', null, '来源'), h('span', null, (v.source || '—') + ' · ' + (v.found_by_agent || '—') + ' · ' + fmt(v.found_at))),
|
||
detailEvidence(v),
|
||
(() => {
|
||
const mine = filesByVuln.get(Number(v.id)) || []
|
||
return h('div', { className: 'rt-kv' }, h('b', null, '攻击文件'),
|
||
mine.length
|
||
? h('span', null, mine.map((f, fi) => h('div', { key: 'af' + fi, style: { marginBottom: 3 } },
|
||
'· ' + (f.kind ? '[' + f.kind + '] ' : '') + (f.name || '(未命名)')
|
||
+ (f.description ? ' — ' + f.description : ''),
|
||
h('div', { className: 'rt-mono', style: { fontSize: 11, marginLeft: 12, color: 'var(--dsw-alias-label-secondary)', overflowWrap: 'anywhere' } }, f.path || ''))))
|
||
: h('span', { style: { color: 'var(--dsw-alias-label-secondary)' } },
|
||
'(无)用 redteam_attack_file_add 把这条漏洞打通的脚本/马/EXP 归档,并带上 vuln_id'))
|
||
})(),
|
||
h('div', { className: 'rt-actions' },
|
||
h('button', { className: 'rt-btn', onClick: (e) => { e.stopPropagation(); setVulnStatus(v.id, 'confirmed') } }, '确认'),
|
||
h('button', { className: 'rt-btn', onClick: (e) => { e.stopPropagation(); setVulnStatus(v.id, 'exploited') } }, '已利用'),
|
||
h('button', { className: 'rt-btn', onClick: (e) => { e.stopPropagation(); setVulnStatus(v.id, 'false-positive') } }, '误报'),
|
||
h('button', { className: 'rt-btn', onClick: (e) => { e.stopPropagation(); setVulnStatus(v.id, 'fixed') } }, '已修复')))))
|
||
return out
|
||
}
|
||
|
||
const rows = []
|
||
if (grouped) {
|
||
/* 按目标聚合成组:先看"哪台被打下什么",再点进去看具体漏洞 */
|
||
const groups = new Map()
|
||
for (const v of state.items) {
|
||
const key = targetKeyOf(v)
|
||
if (!groups.has(key)) groups.set(key, [])
|
||
groups.get(key).push(v)
|
||
}
|
||
const SEV_RANK = { critical: 0, high: 1, medium: 2, low: 3, info: 4 }
|
||
const list = Array.from(groups.entries()).map(([key, vs]) => ({
|
||
key,
|
||
vulns: vs,
|
||
top: vs.slice().sort((a, b) => (SEV_RANK[a.severity] ?? 9) - (SEV_RANK[b.severity] ?? 9))[0],
|
||
gained: Array.from(new Set(vs.flatMap((v) => String(v.gained || '').split(/[、,;,;]/).map((x) => x.trim()).filter(Boolean)))),
|
||
exploited: vs.filter((v) => v.status === 'exploited').length,
|
||
})).sort((a, b) => (SEV_RANK[a.top.severity] ?? 9) - (SEV_RANK[b.top.severity] ?? 9) || b.vulns.length - a.vulns.length)
|
||
for (const g of list) {
|
||
const key = 'target:' + g.key
|
||
const defOpen = false
|
||
rows.push(h(Section, {
|
||
key: 'sec:' + g.key, tone: 'target',
|
||
title: g.key,
|
||
count: g.vulns.length + ' 个漏洞',
|
||
sub: [
|
||
SEV_LABEL[g.top.severity] || g.top.severity,
|
||
g.exploited ? '已利用 ' + g.exploited : null,
|
||
g.top.asset_ip && g.top.asset_ip !== g.key ? '资产 ' + g.top.asset_ip : null,
|
||
].filter(Boolean).join(' · '),
|
||
right: g.gained.length
|
||
? g.gained.slice(0, 2).join(' / ') + (g.gained.length > 2 ? ' +' + (g.gained.length - 2) : '')
|
||
: '未记录权限',
|
||
open: collapse.isOpen(key, defOpen),
|
||
onToggle: collapse.toggle(key, defOpen),
|
||
}, g.vulns.map((v) => vulnRows(v, true))))
|
||
}
|
||
if (!list.length && !state.loading && !state.error) {
|
||
rows.push(h('div', { key: 'none', className: 'rt-empty' }, '暂无漏洞记录'))
|
||
}
|
||
} else {
|
||
for (const v of state.items) rows.push(...vulnRows(v))
|
||
}
|
||
|
||
const toolbar = h('div', { className: 'rt-toolbar' },
|
||
h('input', {
|
||
className: 'rt-input', style: { flex: '1 1 140px' }, placeholder: '搜索标题 / CVE / 目标(回车)',
|
||
value: q, onChange: (e) => setQ(e.target.value),
|
||
onKeyDown: (e) => { if (e.key === 'Enter') setQApplied(q) },
|
||
}),
|
||
h('button', { className: 'rt-btn', onClick: () => setQApplied(q) }, '搜索'),
|
||
h('select', { className: 'rt-input', value: sev, onChange: (e) => setSev(e.target.value) },
|
||
h('option', { value: '' }, '全部等级'),
|
||
h('option', { value: 'critical' }, '严重'),
|
||
h('option', { value: 'high' }, '高危'),
|
||
h('option', { value: 'medium' }, '中危'),
|
||
h('option', { value: 'low' }, '低危'),
|
||
h('option', { value: 'info' }, '信息')),
|
||
h('select', { className: 'rt-input', value: status, onChange: (e) => setStatus(e.target.value) },
|
||
h('option', { value: '' }, '全部状态'),
|
||
h('option', { value: 'candidate' }, '待验证'),
|
||
h('option', { value: 'confirmed' }, '已确认'),
|
||
h('option', { value: 'exploited' }, '已利用'),
|
||
h('option', { value: 'false-positive' }, '误报'),
|
||
h('option', { value: 'fixed' }, '已修复')))
|
||
|
||
const credSection = h('div', null,
|
||
h('div', { className: 'rt-section' }, '凭据 · ' + creds.length + (creds.length ? '(明文直显,注意屏幕分享/录屏)' : '')),
|
||
creds.length
|
||
? h('div', { style: { padding: '4px 10px 0' } }, creds.map((c) => h('div', { key: 'c' + c.id, className: 'rt-cred' },
|
||
h('div', { className: 'rt-cred-head' },
|
||
h('span', { className: 'rt-cred-host' }, c.host),
|
||
c.username ? h('span', { className: 'rt-tag' }, c.username) : null,
|
||
h('span', { className: 'rt-tag rt-tag-passive' }, c.secret_type || 'password'),
|
||
c.privilege ? h('span', { className: 'rt-tag rt-tag-active' }, c.privilege) : null,
|
||
h('div', { className: 'rt-spacer' }),
|
||
h('button', {
|
||
className: 'rt-btn', style: { padding: '0 6px', fontSize: 11 },
|
||
onClick: (e) => { e.stopPropagation(); copyText(String(c.secret_value || '')) },
|
||
}, '复制')),
|
||
c.secret_value
|
||
? h('div', { className: 'rt-secret', title: '点击可全选' }, c.secret_value)
|
||
: h('div', { className: 'rt-secret-none' }, '未记明文 —— 请用 redteam_credential_add 的 secret_value 补上,面板才能直显'),
|
||
h('div', { className: 'rt-cred-meta' },
|
||
[c.source ? '来源 ' + c.source : null,
|
||
c.tool ? '工具 ' + c.tool : null,
|
||
c.secret_ref ? '证据 ' + c.secret_ref : null,
|
||
c.found_by_agent ? 'by ' + c.found_by_agent : null,
|
||
c.found_at ? fmt(c.found_at) : null].filter(Boolean).join(' · ')),
|
||
c.note ? h('div', { className: 'rt-cred-meta' }, '备注:' + c.note) : null)))
|
||
: h('div', { className: 'rt-empty' }, '暂无凭据(拿到口令/密钥/Hash 后用 redteam_credential_add 落库,秒级可复用)'))
|
||
|
||
const accessSection = h('div', null,
|
||
h('div', { className: 'rt-section' }, '已获得访问会话 · ' + accesses.length),
|
||
accesses.length
|
||
? accesses.map((a) => h('div', { key: 'a' + a.id, className: 'rt-vrow', style: { cursor: 'default', gridTemplateColumns: '1fr 110px 80px 80px 1fr' } },
|
||
h('span', { className: 'rt-mono' }, a.host),
|
||
h('span', null, a.username || '—'),
|
||
h('span', null, h('span', { className: 'rt-tag rt-tag-active' }, a.method || '—')),
|
||
h('span', null, a.privilege || '—'),
|
||
h('span', { className: 'rt-mono', title: a.session_ref || '' }, a.session_ref || '—')))
|
||
: h('div', { className: 'rt-empty' }, '暂无'))
|
||
|
||
const subTabBtn = (key, label, n) => h('span', Object.assign({
|
||
className: 'rt-subtab' + (subTab === key ? ' on' : ''),
|
||
}, clickable(() => setSubTab(key), { label: label + '(' + n + ')', expanded: subTab === key })),
|
||
label + ' ' + n)
|
||
|
||
return h('div', { className: 'rt-main' }, toolbar, conclusion,
|
||
h('div', { className: 'rt-subtabs' },
|
||
subTabBtn('vulns', '漏洞', state.total || 0),
|
||
subTabBtn('files', '攻击文件', allFiles.length),
|
||
subTabBtn('creds', '凭据', creds.length),
|
||
subTabBtn('access', '访问会话', accesses.length),
|
||
h('div', { className: 'rt-spacer' }),
|
||
grouped ? h('button', {
|
||
className: 'rt-btn', title: '展开所有目标',
|
||
onClick: () => collapse.setAll((state.items || []).map((v) => 'target:' + targetKeyOf(v)), true),
|
||
}, '全部展开') : null,
|
||
grouped ? h('button', {
|
||
className: 'rt-btn', title: '收起所有目标',
|
||
onClick: () => collapse.setAll((state.items || []).map((v) => 'target:' + targetKeyOf(v)), false),
|
||
}, '全部收起') : null,
|
||
h('button', {
|
||
className: 'rt-btn' + (grouped ? ' rt-btn-primary' : ''),
|
||
title: grouped ? '当前:按目标聚合(先看哪台被打下什么)' : '当前:平铺每条漏洞',
|
||
onClick: () => setGrouped((g) => !g),
|
||
}, grouped ? '按目标聚合' : '平铺列表')),
|
||
msg ? h('div', { className: msg.err ? 'rt-err' : 'rt-foot' }, msg.err || msg.ok) : null,
|
||
needRestart ? h('div', { className: 'rt-empty' }, '该模块的宿主代码已更新,需重启一次当前宿主(官方 exe / 社区版 / dsh web)后生效') : null,
|
||
state.error && !needRestart ? h('div', { className: 'rt-err' }, state.error) : null,
|
||
subTab === 'files'
|
||
? h('div', { className: 'rt-body', style: { overflow: 'auto' } },
|
||
h('div', { className: 'rt-hint', style: { marginBottom: 8 } },
|
||
'攻击文件按靶标目录组织(原「攻击文件」页签);每条漏洞的关联文件也能在该漏洞详情里直接看到。'
|
||
+ (orphanFiles.length ? ' 目前有 ' + orphanFiles.length + ' 个文件没挂 vuln_id,建议补上以便与漏洞对应。' : '')),
|
||
h(AttackFilesTab, { engagement: eng, refreshKey: refreshKey }))
|
||
: subTab === 'creds'
|
||
? h('div', { className: 'rt-body', style: { overflow: 'auto' } }, credSection)
|
||
: subTab === 'access'
|
||
? h('div', { className: 'rt-body', style: { overflow: 'auto' } }, accessSection)
|
||
: h('div', { className: 'rt-table' }, grouped ? null : head, rows,
|
||
!state.loading && !state.items.length && !state.error ? h('div', { className: 'rt-empty' }, '暂无漏洞记录') : null))
|
||
}
|
||
|
||
/* ---------------------------------------------------------- 攻击链 */
|
||
const STAGE_LABEL = { recon: '信息收集', vuln: '漏洞发现', exploit: '漏洞利用', access: '获得权限', pivot: '内网突破', data: '敏感数据', other: '其他' }
|
||
|
||
/* ---------------------------------------------------------- 攻击链(五阶段) */
|
||
/**
|
||
* 按攻击面位置串成一条链:
|
||
* ① 信息收集 → ② 互联网资产权限 → ③ 边界突破 → ④ 内网资产权限 → ⑤ 靶标权限。
|
||
* 每阶段只讲两件事:这一步拿到多少分(累计多少)、涉及的资产/隧道是哪些。
|
||
* A = 竖向链(详细);B = 横向链(一屏看完)。
|
||
*/
|
||
function ChainTab(props) {
|
||
const eng = props.engagement
|
||
const refreshKey = props.refreshKey || 0
|
||
const [data, setData] = React.useState(null)
|
||
const [err, setErr] = React.useState(null)
|
||
const [loading, setLoading] = React.useState(false)
|
||
const [view, setView] = React.useState('A')
|
||
const [openId, setOpenId] = React.useState(null)
|
||
|
||
const load = () => {
|
||
if (!eng) return
|
||
setLoading(true)
|
||
api({ op: 'scoreChain', engagement: eng }).then((r) => {
|
||
setLoading(false)
|
||
if (!r || r.ok === false) { setErr((r && r.error) || '读取失败'); return }
|
||
setErr(null); setData(r)
|
||
}, (e) => { setLoading(false); setErr(String((e && e.message) || e)) })
|
||
}
|
||
React.useEffect(load, [eng, refreshKey])
|
||
|
||
const stages = (data && data.stages) || []
|
||
const summary = (data && data.summary) || null
|
||
const toggle = (id) => setOpenId((cur) => (cur === id ? null : id))
|
||
const CIRCLED = ['①', '②', '③', '④', '⑤', '⑥']
|
||
|
||
/* 单次得分的详情 */
|
||
const hitDetail = (x) => h('div', { className: 'rt-flow-detail' },
|
||
h('div', { className: 'rt-kv' }, h('b', null, '目标'), h('span', { className: 'rt-mono', style: { wordBreak: 'break-all' } }, x.target || '—')),
|
||
x.asset_ip ? h('div', { className: 'rt-kv' }, h('b', null, '资产'), h('span', { className: 'rt-mono' }, x.asset_ip)) : null,
|
||
x.vuln_title ? h('div', { className: 'rt-kv' }, h('b', null, '利用漏洞'), h('span', null, [x.vuln_cve, x.vuln_title].filter(Boolean).join(' '))) : null,
|
||
h('div', { className: 'rt-kv' }, h('b', null, '次数'), h('span', null,
|
||
'同类第 ' + x.nth_of_point + ' 次 · +' + (x.points || 0) + ' 分')),
|
||
h('div', { className: 'rt-kv' }, h('b', null, '记录'), h('span', null, fmt(x.recorded_at) + (x.recorded_by ? ' · ' + x.recorded_by : ''))),
|
||
x.evidence ? h(Clip, { label: '结果与证据', text: x.evidence }) : null)
|
||
|
||
const hitRow = (x) => h('div', {
|
||
key: 'h' + x.id,
|
||
className: 'rt-ap-hit' + (openId === x.id ? ' open' : ''),
|
||
role: 'button', tabIndex: 0,
|
||
onClick: () => toggle(x.id),
|
||
onKeyDown: (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggle(x.id) } },
|
||
},
|
||
h('div', { className: 'rt-ap-hit-head' },
|
||
h('span', { className: 'rt-ap-pts' + (x.counted ? '' : ' uncounted'), title: x.capped_reason || undefined },
|
||
'+' + (x.counted ? (x.points || 0) : 0)),
|
||
h('span', { className: 'rt-ap-hit-name' }, x.point_name),
|
||
x.capped ? h('span', { className: 'rt-tag rt-tag-warn', title: x.capped_reason || '同一资产同一端口只算分值最高的一条' }, '服务已拿满 · 不计分') : null,
|
||
x.nth_of_point > 1 ? h('span', { className: 'rt-ap-nth' }, '第 ' + x.nth_of_point + ' 次') : null,
|
||
h('span', { className: 'rt-ap-hit-target' }, x.target || x.asset_ip || '—')),
|
||
x.action
|
||
? h('div', { className: 'rt-ap-act' + (x.action_inferred ? ' inferred' : '') },
|
||
'动作:' + (x.action.title || '(未命名步骤)') + (x.action_inferred ? '(推断)' : ''))
|
||
: null,
|
||
openId === x.id ? hitDetail(x) : null)
|
||
|
||
/* 资产行(信息收集阶段 / 各阶段涉及资产) */
|
||
const assetRow = (a) => h('div', { key: 'a' + a.id, className: 'rt-ap-asset' },
|
||
h('span', { className: 'rt-scope rt-scope-' + (a.scope === 'internal' ? 'internal' : 'external') },
|
||
a.scope === 'internal' ? '内' : '外'),
|
||
h('span', { className: 'rt-mono', style: { fontWeight: 600 } }, a.ip),
|
||
a.segment_cidr ? h('span', { className: 'rt-ap-nth' }, a.segment_cidr) : null,
|
||
h('span', { className: 'rt-ap-nth' }, '端口 ' + (a.open_ports || 0)),
|
||
a.vulns ? h('span', { className: 'rt-ap-nth' }, '漏洞 ' + a.vulns) : null,
|
||
a.priority ? h('span', { className: 'rt-tag' }, '易打 ' + a.priority) : null,
|
||
h('div', { className: 'rt-spacer' }),
|
||
h('span', { className: 'rt-ap-pts' }, '+' + (a.points || 0) + ' 分'),
|
||
h('span', { className: 'rt-ap-nth' }, (a.hits || 0) + ' 次'))
|
||
|
||
/* 攻击链只讲"打到哪了、拿了多少分":不展示打法要点与工具清单,
|
||
那些是执行细节,混在链路里会淹没得分与资产信息。 */
|
||
|
||
/* ── A:竖向攻击链 ─────────────────────────────────────────── */
|
||
const stageA = (st, i) => {
|
||
const isRecon = st.code === 'recon'
|
||
const body = []
|
||
if (isRecon) {
|
||
body.push(h('div', { key: 'at', className: 'rt-ap-sub' }, '拿到分数的资产 · ' + st.assetCount + ' 台'))
|
||
body.push(h('div', { key: 'al', className: 'rt-ap-assets' },
|
||
st.assets.length ? st.assets.map(assetRow) : h('div', { className: 'rt-ap-none' }, '还没有产生得分的资产')))
|
||
} else {
|
||
body.push(h('div', { key: 'ht', className: 'rt-ap-sub' },
|
||
'本阶段命中 ' + st.hits + ' 次' + (st.points ? ' · +' + st.points + ' 分' : '')))
|
||
body.push(h('div', { key: 'hl', className: 'rt-ap-hits' },
|
||
st.items.length ? st.items.map(hitRow) : h('div', { className: 'rt-ap-none' }, '本阶段还没有得分')))
|
||
if (st.code === 'boundary') {
|
||
const selfOnly = (st.tunnels_self_only || []).length
|
||
body.push(h('div', { key: 'tt', className: 'rt-ap-sub' },
|
||
'跨越靶标边界的通道 · ' + st.tunnels.length + ' 条' + (selfOnly ? '(另有 ' + selfOnly + ' 条只在自己 VPS/自建服务器上,不算突破)' : '')))
|
||
if (selfOnly && st.tunnels.length === 0) {
|
||
body.push(h('div', { key: 'tw', className: 'rt-ap-none' },
|
||
'⚠️ 现有的通道都在自己的服务器上,没有碰到目标 —— 不计边界突破。需要目标侧发起的通道(反弹 shell 到我这 / 目标上跑 frp 客户端 / 经目标 WebShell 的 suo5)。'))
|
||
}
|
||
body.push(h('div', { key: 'tl', className: 'rt-ap-tunnels' },
|
||
st.tunnels.length
|
||
? st.tunnels.map((t) => h('div', { key: 't' + t.id, className: 'rt-ap-tunnel' },
|
||
h('span', { className: 'rt-tag rt-tag-passive' }, t.kind || 'tunnel'),
|
||
h('span', { className: 'rt-mono', style: { fontWeight: 600 } }, t.listen || '—'),
|
||
h('span', {
|
||
className: 'rt-tag ' + (t.status === 'active' ? 'rt-tag-live' : ''),
|
||
style: t.status === 'active' ? {} : { opacity: .7 },
|
||
}, t.status === 'active' ? '可用' : (t.status || '未知')),
|
||
h('span', { className: 'rt-ap-nth', style: { minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis' } },
|
||
'可达 ' + (t.reach || '—'))))
|
||
: h('div', { className: 'rt-ap-none' }, '还没有建立隧道(建好后用 redteam_tunnel_add 登记)')))
|
||
}
|
||
if (st.assets.length && st.code !== 'boundary') {
|
||
body.push(h('div', { key: 'at', className: 'rt-ap-sub' }, '涉及资产 · ' + st.assetCount + ' 台'))
|
||
body.push(h('div', { key: 'al', className: 'rt-ap-assets' }, st.assets.map(assetRow)))
|
||
}
|
||
}
|
||
|
||
return h('div', { key: 'st' + st.code, className: 'rt-ap-stage' },
|
||
h('div', { className: 'rt-ap-head', style: { borderLeftColor: st.color } },
|
||
h('span', { className: 'rt-ap-no', style: { background: st.color } }, CIRCLED[i] || ('0' + (i + 1))),
|
||
h('span', { className: 'rt-ap-name' }, st.name),
|
||
st.subtitle ? h('span', { className: 'rt-ap-en' }, st.subtitle) : null,
|
||
h('div', { className: 'rt-spacer' }),
|
||
st.points > 0 ? h('span', { className: 'rt-ap-pts', style: { background: st.color + '22', color: st.color, borderColor: st.color + '66' } }, '+' + st.points) : null,
|
||
h('span', { className: 'rt-ap-cum' }, '累计 ' + st.cumulative + ' 分')),
|
||
h('div', { className: 'rt-ap-goal', style: { borderLeftColor: st.color } },
|
||
h('span', { className: 'rt-ap-goal-tag', style: { color: st.color, borderColor: st.color + '66' } }, '阶段目标'),
|
||
h('span', null, st.goal)),
|
||
h('div', { className: 'rt-ap-result', style: { borderLeftColor: st.color } }, body),
|
||
i < stages.length - 1
|
||
? h('div', { className: 'rt-ap-trans' },
|
||
h('span', { className: 'rt-flow-arrow' }, '▼'),
|
||
st.transition ? h('span', { className: 'rt-ap-trans-t' }, st.transition) : null,
|
||
h('span', { className: 'rt-ap-trans-t', style: { marginLeft: 'auto' } }, '累计 ' + st.cumulative + ' 分'))
|
||
: h('div', { className: 'rt-ap-trans' },
|
||
h('span', { className: 'rt-flow-arrow' }, '▼'),
|
||
h('span', { className: 'rt-ap-trans-t' }, '合计 ' + ((summary && summary.points) || 0) + ' 分')))
|
||
}
|
||
|
||
/* ── B:横向攻击链(一屏看完) ─────────────────────────────── */
|
||
const stageB = (st, i) => h('div', { key: 'c' + st.code, className: 'rt-ap-col' },
|
||
i > 0 ? h('span', { className: 'rt-ap-col-arrow' }, '▶') : null,
|
||
h('div', { className: 'rt-hcol', style: { borderTopColor: st.color } },
|
||
h('div', { className: 'rt-ap-head', style: { borderLeftColor: st.color, padding: '5px 8px' } },
|
||
h('span', { className: 'rt-ap-no', style: { background: st.color, width: 16, height: 16, fontSize: 10 } }, String(i + 1)),
|
||
h('span', { className: 'rt-ap-name', style: { fontSize: 12 } }, st.name),
|
||
h('div', { className: 'rt-spacer' }),
|
||
h('span', { className: 'rt-ap-cum', style: { fontWeight: 700 } }, st.cumulative)),
|
||
h('div', { className: 'rt-hcol-goal', title: st.goal }, st.goal),
|
||
h('div', { className: 'rt-hcol-body' },
|
||
st.code === 'boundary'
|
||
? h('div', null,
|
||
h('div', { className: 'rt-hcol-hit' }, h('span', { className: 'rt-hcol-pts' }, '+' + st.points), h('span', { className: 'rt-hcol-name' }, '隧道 ' + st.tunnels.length + ' 条')),
|
||
st.tunnels.slice(0, 3).map((t) => h('div', { key: 't' + t.id, className: 'rt-hcol-sub' }, '▸ ' + (t.kind || '') + ' ' + (t.listen || ''))))
|
||
: st.code === 'recon'
|
||
? h('div', null,
|
||
h('div', { className: 'rt-hcol-hit' }, h('span', { className: 'rt-hcol-pts' }, st.assetCount), h('span', { className: 'rt-hcol-name' }, '台资产拿到分')),
|
||
st.assets.slice(0, 6).map((a) => h('div', { key: 'a' + a.id, className: 'rt-hcol-sub', title: a.ip + ' +' + a.points + ' 分' },
|
||
(a.scope === 'internal' ? '内 ' : '外 ') + a.ip + ' +' + a.points)))
|
||
: h('div', null,
|
||
h('div', { className: 'rt-hcol-hit' }, h('span', { className: 'rt-hcol-pts' }, '+' + st.points), h('span', { className: 'rt-hcol-name' }, st.hits + ' 次命中')),
|
||
st.items.slice(0, 6).map((x) => h('div', { key: 'c' + x.id, className: 'rt-hcol-sub', title: x.point_name + ' ' + (x.target || '') },
|
||
h('span', { className: 'rt-hcol-pts' }, '+' + x.points), h('span', { className: 'rt-hcol-name' }, x.point_name))),
|
||
st.items.length > 6 ? h('div', { className: 'rt-hcol-none' }, '…另有 ' + (st.items.length - 6) + ' 次') : null,
|
||
st.assetCount ? h('div', { className: 'rt-hcol-none' }, '涉及 ' + st.assetCount + ' 台资产') : null))))
|
||
|
||
return h('div', { className: 'rt-main' },
|
||
h('div', { className: 'rt-toolbar' },
|
||
h('span', { style: { fontWeight: 600 } }, '攻击链'),
|
||
h('span', { className: 'rt-tag', style: { fontSize: 10.5 } }, '信息收集 → 互联网资产权限 → 边界突破 → 内网资产权限 → 靶标权限'),
|
||
summary ? h('span', { className: 'rt-tag rt-tag-live' }, '总分 ' + summary.points + ' 分') : null,
|
||
h('div', { className: 'rt-spacer' }),
|
||
h('button', { className: 'rt-btn' + (view === 'A' ? ' rt-btn-primary' : ''), title: '竖向攻击链:逐阶段向下看细节', onClick: () => setView('A') }, '链路 A'),
|
||
h('button', { className: 'rt-btn' + (view === 'B' ? ' rt-btn-primary' : ''), title: '横向攻击链:一屏看完五个阶段', onClick: () => setView('B') }, '链路 B'),
|
||
h('button', { className: 'rt-btn', disabled: loading, onClick: load }, loading ? '加载中…' : '刷新')),
|
||
err ? h('div', { className: 'rt-err' }, err) : null,
|
||
!stages.length && !err && data !== null
|
||
? h('div', { className: 'rt-empty' }, '还没有得分记录。拿到成果后用 redteam_score_hit 记分,这条链才会长出来。')
|
||
: view === 'A'
|
||
? h('div', { className: 'rt-ap' }, stages.map(stageA))
|
||
: h('div', { className: 'rt-ap-h' }, stages.map(stageB)))
|
||
}
|
||
|
||
/* ---------------------------------------------------------- 得分复现报告 */
|
||
/**
|
||
* 报告分组兜底:host 只给平铺条目时,用 scoreChain 的阶段信息把条目按攻击链顺序分组。
|
||
* 依据是两边共同的 score_hit id —— scoreChain 的每条 item 都带 stage_code,
|
||
* scoreReport 的每条 item 带同样的 id。这样即使 host 侧版本较旧或阶段行缺失,
|
||
* 报告页也能正常显示,而不是误报"还没有可交付的成果"。
|
||
*/
|
||
function groupByStage(reportItems, chain) {
|
||
const byId = new Map((reportItems || []).map((x) => [x.id, x]))
|
||
const groups = []
|
||
const stageList = (chain && chain.stages) || []
|
||
stageList.forEach((st, si) => {
|
||
const items = (st.items || []).map((x) => byId.get(x.id)).filter(Boolean)
|
||
if (items.length === 0) return
|
||
groups.push({ code: st.code, name: st.name, color: st.color || '#64748b',
|
||
ordinal: st.ordinal || si + 1, points: st.points || 0, cumulative: st.cumulative || 0, items })
|
||
})
|
||
/* scoreChain 完全没有阶段信息时,至少把得分归到「其他」,不要让条目凭空消失 */
|
||
const grouped = new Set(groups.reduce((acc, g) => acc.concat(g.items.map((x) => x.id)), []))
|
||
const rest = (reportItems || []).filter((x) => !grouped.has(x.id))
|
||
if (rest.length > 0) {
|
||
groups.push({ code: 'other', name: '其他得分', color: '#64748b', ordinal: groups.length + 1,
|
||
points: rest.reduce((n, x) => n + (x.counted ? x.points : 0), 0),
|
||
cumulative: 0, items: rest })
|
||
}
|
||
return groups
|
||
}
|
||
|
||
function ReportTab(props) {
|
||
const eng = props.engagement
|
||
const refreshKey = props.refreshKey || 0
|
||
const [data, setData] = React.useState(null)
|
||
const [err, setErr] = React.useState(null)
|
||
const [busy, setBusy] = React.useState(false)
|
||
const [msg, setMsg] = React.useState(null)
|
||
/* 报告按阶段折叠:默认全开,折叠状态按靶标记住(阶段多时便于逐段交付) */
|
||
const collapse = useCollapse('report:' + eng)
|
||
|
||
const load = () => {
|
||
if (!eng) return
|
||
setBusy(true); setMsg(null)
|
||
api({ op: 'scoreReport', engagement: eng }).then((r) => {
|
||
if (!r || r.ok === false) { setBusy(false); setErr((r && r.error) || '生成失败'); return }
|
||
/* 老 host 只给平铺条目、不给阶段分组(或阶段行缺失):自己按攻击链分组,
|
||
否则报告页会误报"还没有可交付的成果"。分组数据取自 scoreChain。 */
|
||
if ((!r.stages || r.stages.length === 0) && (r.items || []).length > 0) {
|
||
api({ op: 'scoreChain', engagement: eng }).then((c) => {
|
||
setBusy(false); setErr(null); setData(Object.assign({}, r, { stages: groupByStage(r.items, c) }))
|
||
}, () => { setBusy(false); setErr(null); setData(r) })
|
||
return
|
||
}
|
||
setBusy(false); setErr(null); setData(r)
|
||
}, (e) => { setBusy(false); setErr(String((e && e.message) || e)) })
|
||
}
|
||
React.useEffect(load, [eng, refreshKey])
|
||
|
||
/* 复制结果必须是真实的:clipboard API 失败时(非安全上下文/失焦/被拒)要如实提示,
|
||
否则用户以为复制成功、粘到 Yakit 里是空的。 */
|
||
const copy = (text, label) => copyWithFeedback(text, label, (okFlag, message) => {
|
||
setMsg(okFlag ? { ok: message } : { err: message })
|
||
})
|
||
const download = (text, label) => {
|
||
try {
|
||
const blob = new Blob([text], { type: 'text/markdown;charset=utf-8' })
|
||
const url = URL.createObjectURL(blob)
|
||
const a = document.createElement('a')
|
||
a.href = url
|
||
a.download = 'report-' + String(label || 'score').replace(/[^\w.\-]/g, '_') + '.md'
|
||
a.click()
|
||
/* 立刻 revoke 会在部分浏览器里让 click 启动的下载断流,延后一拍 */
|
||
setTimeout(() => { try { URL.revokeObjectURL(url) } catch { /* ignore */ } }, 1000)
|
||
setMsg({ ok: '已下载:' + label })
|
||
} catch (e) { setMsg({ err: '下载失败:' + ((e && e.message) || e) }) }
|
||
}
|
||
|
||
const items = (data && data.items) || []
|
||
const stages = (data && data.stages) || []
|
||
const summary = (data && data.summary) || null
|
||
const mdText = (data && data.markdown) || ''
|
||
const CIRCLED = ['①', '②', '③', '④', '⑤', '⑥', '⑦', '⑧', '⑨', '⑩', '⑪', '⑫', '⑬', '⑭', '⑮', '⑯', '⑰', '⑱', '⑲', '⑳']
|
||
|
||
/* 每项一条,平铺不折叠:目标 → 拿到什么 → 复现请求(可直接粘进 Yakit)→ 响应 */
|
||
const card = (x) => h('div', { key: 'r' + x.id, className: 'rt-rep' },
|
||
h('div', { className: 'rt-rep-head' },
|
||
h('span', { className: 'rt-rep-idx' }, String(x.seq)),
|
||
h('span', { className: 'rt-rep-name' }, x.point_name),
|
||
h('span', { className: 'rt-flow-pts' }, '+' + x.points + ' 分'),
|
||
x.nth_of_point > 1 ? h('span', { className: 'rt-tag' }, '同类第 ' + x.nth_of_point + ' 次') : null,
|
||
h('div', { className: 'rt-spacer' }),
|
||
h('button', {
|
||
className: 'rt-btn', style: { padding: '0 6px', fontSize: 11 },
|
||
onClick: () => copy((x.requests || []).map((v) => v.request || '').filter(Boolean).join('\n\n'), '第 ' + x.seq + ' 项请求'),
|
||
}, '复制请求')),
|
||
h('div', { className: 'rt-rep-meta' },
|
||
h('span', null, h('b', null, '目标 ')), h('span', { className: 'rt-mono' }, x.target || x.asset_ip || '—')),
|
||
x.gained ? h('div', { className: 'rt-rep-meta' }, h('b', null, '拿到 '), h('span', null, x.gained)) : null,
|
||
x.vuln ? h('div', { className: 'rt-rep-meta' }, h('b', null, '利用漏洞 '),
|
||
h('span', null, [x.vuln.cve, x.vuln.title].filter(Boolean).join(' '))) : null,
|
||
x.evidence ? h('div', { className: 'rt-rep-meta' }, h('b', null, '结果 '),
|
||
h('span', null, String(x.evidence).replace(/\n+/g, ' '))) : null,
|
||
x.recorded_at ? h('div', { className: 'rt-rep-meta' }, h('b', null, '取得时间 '),
|
||
h('span', null, fmt(x.recorded_at) + (x.recorded_by ? '(' + (ROLE_LABEL[x.recorded_by] || x.recorded_by) + ')' : ''))) : null,
|
||
h(ShotStrip, { eng: eng, shots: x.shots, total: x.shot_total, title: '证据截图' }),
|
||
/* ── 复现步骤:动作记录(含实际命令与回显)+ 凭据 + 隧道 + WebShell ─────
|
||
报告的交付价值全在这块:账号密码怎么来的、隧道怎么搭的,用户照着就能复现。 */
|
||
h('details', { className: 'rt-rep-trace', open: x.incomplete === true || (x.steps || []).length === 0 },
|
||
h('summary', { className: 'rt-rep-trace-head', style: { cursor: 'pointer' } },
|
||
h('span', { className: 'rt-rep-trace-title' }, '复现步骤(点击展开)'),
|
||
(x.steps || []).length ? h('span', { className: 'rt-tag' }, (x.steps || []).length + ' 个动作') : null,
|
||
(x.credentials || []).length ? h('span', { className: 'rt-tag' }, (x.credentials || []).length + ' 条凭据') : null,
|
||
(x.tunnels || []).length ? h('span', { className: 'rt-tag' }, (x.tunnels || []).length + ' 条隧道') : null,
|
||
x.incomplete ? h('span', { className: 'rt-tag rt-tag-warn' }, '复现材料待补') : h('span', { className: 'rt-tag rt-tag-live' }, '可复现')),
|
||
h('div', { style: { paddingTop: 6 } },
|
||
x.how ? h('div', { className: 'rt-rep-trace-how' }, x.how) : null,
|
||
(x.steps || []).length === 0
|
||
? h('div', { className: 'rt-rep-missing' },
|
||
'⚠️ 没有关联的攻击步骤:说不清这一步是怎么做的。请用 redteam_chain_add 补上动作(title / detail / tool / result),记分时也可以带 point_code + evidence 一次完成。')
|
||
: (x.steps || []).map((s, si) => h('div', { key: 's' + s.id, className: 'rt-rep-step' },
|
||
h('div', { className: 'rt-rep-step-head' },
|
||
h('span', { className: 'rt-rep-step-no' }, String(si + 1)),
|
||
h('span', { className: 'rt-rep-step-title' }, s.title || '(未命名动作)'),
|
||
s.agent ? h('span', { className: 'rt-tag' }, ROLE_LABEL[s.agent] || s.agent) : null,
|
||
s.stage_code ? h('span', { className: 'rt-tag' }, s.stage_code) : null,
|
||
s.inferred ? h('span', { className: 'rt-tag' }, '按同资产推断') : null,
|
||
h('div', { className: 'rt-spacer' }),
|
||
s.recorded_at ? h('span', { style: { fontSize: 11, color: 'var(--dsw-alias-label-secondary)' } }, fmt(s.recorded_at)) : null),
|
||
s.detail ? h('div', { className: 'rt-rep-step-detail' }, s.detail) : null,
|
||
h('div', { className: 'rt-rep-step-cmd' },
|
||
h('b', null, '执行 '),
|
||
s.tool
|
||
? h('span', { className: 'rt-mono' }, s.tool)
|
||
: h('span', { style: { color: 'var(--dsw-alias-state-warn-primary, #f59e0b)' } }, '未记录实际命令(redteam_chain_add 的 tool)')),
|
||
s.tool ? h('div', { style: { marginTop: 3 } },
|
||
h('button', {
|
||
className: 'rt-btn', style: { padding: '0 6px', fontSize: 11 },
|
||
onClick: () => copy(String(s.tool), '第 ' + (si + 1) + ' 步命令'),
|
||
}, '复制命令')) : null,
|
||
s.result ? h('div', { className: 'rt-rep-step-result' }, h('b', null, '结果 '), String(s.result).replace(/\n+/g, ' ')) : null,
|
||
s.evidence_ref ? h('div', { className: 'rt-rep-step-result' }, h('b', null, '证据 '), h('span', { className: 'rt-mono' }, s.evidence_ref)) : null)),
|
||
/* 账号密码怎么来的:凭据的来源 + 取得方式 */
|
||
(x.credentials || []).length
|
||
? h('div', { className: 'rt-rep-src' },
|
||
h('div', { className: 'rt-rep-src-title' }, '拿到的凭据(来源可追溯)'),
|
||
(x.credentials || []).map((c) => h('div', { key: 'c' + c.id, className: 'rt-kv' },
|
||
h('b', { className: 'rt-mono', style: { minWidth: 120 } }, c.host || '—'),
|
||
h('span', null, (c.username || '(无用户名)') + ' / ' + (c.secret_type || 'password')
|
||
+ (c.privilege ? ' · 权限 ' + c.privilege : '')
|
||
+ ' · 来源 ' + (c.source || '未标注')
|
||
+ (c.tool ? ' · 取得方式 ' + String(c.tool).replace(/\n+/g, ' ').slice(0, 160) : '')))))
|
||
: null,
|
||
/* 隧道怎么搭的 */
|
||
(x.tunnels || []).length
|
||
? h('div', { className: 'rt-rep-src' },
|
||
h('div', { className: 'rt-rep-src-title' }, '用到的隧道 / 通道'),
|
||
(x.tunnels || []).map((t) => h('div', { key: 't' + t.id, className: 'rt-kv' },
|
||
h('b', { className: 'rt-mono', style: { minWidth: 120 } }, (t.kind || 'socks5') + ' ' + (t.listen || '')),
|
||
h('span', null, '入口 ' + (t.entry || '未登记')
|
||
+ (t.reach ? ' · 可达 ' + t.reach : '')
|
||
+ ' · 目标侧 ' + (t.entry_kind || '未声明')
|
||
+ (t.legit === false ? ' · ⚠️ 不算跨越靶标边界' : '')
|
||
+ (t.status ? ' · ' + t.status : '')
|
||
+ (t.command ? ' · 命令 ' + String(t.command).replace(/\n+/g, ' ').slice(0, 160) : '')))))
|
||
: null,
|
||
(x.webshells || []).length
|
||
? h('div', { className: 'rt-rep-src' },
|
||
h('div', { className: 'rt-rep-src-title' }, '用到的 WebShell'),
|
||
(x.webshells || []).map((w) => h('div', { key: 'w' + w.id, className: 'rt-kv' },
|
||
h('b', { className: 'rt-mono', style: { minWidth: 120 } }, w.shell_type || 'shell'),
|
||
h('span', null, (w.url || '') + (w.pass_key ? ' · 口令/密钥 ' + w.pass_key : '')
|
||
+ (w.privilege ? ' · 权限 ' + w.privilege : '') + (w.status ? ' · ' + w.status : '')))))
|
||
: null,
|
||
(x.gaps || []).length ? h('div', { className: 'rt-rep-missing' }, '⚠️ 复现缺口:' + x.gaps.join(';')) : null),
|
||
(x.requests || []).length === 0
|
||
? h('div', { className: 'rt-rep-missing' }, '⚠️ 这一项没有原始请求记录,无法直接复现 —— 请用 redteam_http_evidence_add 补上')
|
||
: (x.requests || []).map((r, ri) => h('div', { key: 'q' + ri, className: 'rt-rep-req' },
|
||
h('div', { className: 'rt-rep-req-head' },
|
||
h('span', null, '复现请求 ' + (ri + 1) + (r.source === 'auto' ? '(按目标路径自动匹配,请核对)' : '')),
|
||
h('span', { className: 'rt-tag' }, (r.method || 'GET') + ' ' + (r.status === null || r.status === undefined ? '' : r.status)),
|
||
h('div', { className: 'rt-spacer' }),
|
||
h('button', {
|
||
className: 'rt-btn', style: { padding: '0 6px', fontSize: 11 },
|
||
onClick: () => copy(String(r.request || ''), '请求 ' + (ri + 1)),
|
||
}, '复制到 Yakit')),
|
||
h('pre', { className: 'rt-rep-http' }, r.request || ((r.method || 'GET') + ' ' + (r.url || '') + ' HTTP/1.1')),
|
||
r.response ? h('div', null,
|
||
h('div', { className: 'rt-rep-req-head' }, h('span', null, '响应摘要')),
|
||
h('pre', { className: 'rt-rep-http', style: { maxHeight: 160 } }, String(r.response).slice(0, 1600))) : null)),
|
||
x.note ? h('div', { className: 'rt-rep-meta' }, h('b', null, '备注 '), h('span', null, x.note)) : null))
|
||
|
||
return h('div', { className: 'rt-main' },
|
||
h('div', { className: 'rt-toolbar' },
|
||
h('span', { style: { fontWeight: 600 } }, '攻击得分链路复现报告'),
|
||
summary ? h('span', { className: 'rt-tag rt-tag-live' }, '合计 ' + summary.points + ' 分') : null,
|
||
summary ? h('span', { className: 'rt-tag' }, summary.count + ' 项得分') : null,
|
||
summary ? h('span', { className: 'rt-tag' + (summary.missingRequests ? '' : ' rt-tag-live') },
|
||
summary.withRequests + '/' + summary.count + ' 项带原始请求') : null,
|
||
summary ? h('span', {
|
||
className: 'rt-tag' + (summary.incomplete ? ' rt-tag-warn' : ' rt-tag-live'),
|
||
title: '复现链是否完整:有攻击步骤、写了实际命令、关联了漏洞/凭据/隧道',
|
||
}, '可复现 ' + (summary.count - (summary.incomplete || 0)) + '/' + summary.count) : null,
|
||
summary && summary.serviceCappedExcluded
|
||
? h('span', {
|
||
className: 'rt-tag rt-tag-warn',
|
||
title: (summary.serviceCapped || []).map((x) => (x.service || '') + '|' + (x.point_name || '')).join('\n')
|
||
|| '同一资产同一端口的重复账号/数据库权限命中:服务已拿满,不计分、不进报告',
|
||
}, '同服务已拿满不计分 ' + summary.serviceCappedExcluded)
|
||
: null,
|
||
h('div', { className: 'rt-spacer' }),
|
||
h('button', { className: 'rt-btn', disabled: busy || !mdText, onClick: () => copy(mdText, '整份报告') }, '复制全文'),
|
||
h('button', { className: 'rt-btn', disabled: busy || !mdText, onClick: () => download(mdText, (data && data.target) || eng) }, '下载 .md'),
|
||
h('button', { className: 'rt-btn', disabled: busy, onClick: load }, busy ? '生成中…' : '重新生成')),
|
||
msg ? h('div', { className: msg.err ? 'rt-err' : 'rt-foot' }, msg.err || msg.ok) : null,
|
||
err ? h('div', { className: 'rt-err' }, err) : null,
|
||
h('div', { className: 'rt-body', style: { overflow: 'auto' } },
|
||
stages.length
|
||
? h('div', { className: 'rt-rep-list' },
|
||
h('div', { className: 'rt-rep-tools' },
|
||
h('button', { className: 'rt-btn', onClick: () => collapse.setAll(stages.map((s) => 'st:' + s.code), true) }, '全部展开'),
|
||
h('button', { className: 'rt-btn', onClick: () => collapse.setAll(stages.map((s) => 'st:' + s.code), false) }, '全部折叠')),
|
||
stages.map((st) => {
|
||
const open = collapse.isOpen('st:' + st.code, true)
|
||
const toggle = collapse.toggle('st:' + st.code, true)
|
||
return h('div', { key: 'g' + st.code, className: 'rt-rep-group' },
|
||
h('div', {
|
||
className: 'rt-rep-stage', role: 'button', tabIndex: 0,
|
||
style: { borderLeftColor: st.color },
|
||
'aria-expanded': open ? 'true' : 'false',
|
||
onClick: toggle,
|
||
onKeyDown: (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggle(e) } },
|
||
},
|
||
h('span', { className: 'rt-sec-caret' }, open ? '▾' : '▸'),
|
||
h('span', { className: 'rt-rep-no', style: { background: st.color } }, CIRCLED[st.ordinal - 1] || st.ordinal),
|
||
h('span', { className: 'rt-rep-stage-name' }, st.name),
|
||
h('span', { className: 'rt-rep-stage-n' }, st.items.length + ' 项'),
|
||
h('div', { className: 'rt-spacer' }),
|
||
h('span', { className: 'rt-rep-pts', style: { background: st.color + '22', color: st.color, borderColor: st.color + '66' } },
|
||
'+' + st.points + ' 分'),
|
||
h('span', { className: 'rt-ap-cum' }, '累计 ' + st.cumulative + ' 分')),
|
||
open ? h('div', { className: 'rt-rep-body' }, st.items.map(card)) : null)
|
||
}))
|
||
: (data === null ? h('div', { className: 'rt-empty' }, '加载中…')
|
||
: h('div', { className: 'rt-empty' },
|
||
h('div', null, '还没有可交付的成果。'),
|
||
h('div', { style: { marginTop: 6, fontSize: 12 } },
|
||
'本报告只收录"拿到了分"的成果;没有得分的漏洞不进报告。拿到成果后用 redteam_score_hit 记分(目标资产 + 拿到的东西),并补 redteam_http_evidence_add 以便复现。')))),
|
||
h('div', { className: 'rt-foot' },
|
||
h('span', null, '口径:只收录得分成果,每条附带可粘进 Yakit Repeater 的原始请求')))
|
||
}
|
||
|
||
/* ---------------------------------------------------------- 攻击文件 */
|
||
const FILE_KIND = { poc: 'POC', exp: 'EXP', script: '脚本', wordlist: '字典', other: '其他' }
|
||
|
||
function AttackFilesTab(props) {
|
||
const eng = props.engagement
|
||
const refreshKey = props.refreshKey || 0
|
||
const [folders, setFolders] = React.useState([])
|
||
const [err, setErr] = React.useState(null)
|
||
const [busy, setBusy] = React.useState(false)
|
||
const collapse = useCollapse('files:' + eng)
|
||
const [detail, setDetail] = React.useState(null)
|
||
|
||
const load = () => {
|
||
if (!eng) return
|
||
setBusy(true)
|
||
api({ op: 'attackFiles', engagement: eng }).then((r) => {
|
||
setBusy(false)
|
||
if (!r || r.ok === false) { setErr((r && r.error) || '读取失败'); return }
|
||
setErr(null)
|
||
setFolders(r.items || [])
|
||
}, (e) => { setBusy(false); setErr(String((e && e.message) || e)) })
|
||
}
|
||
React.useEffect(load, [eng, refreshKey])
|
||
|
||
const openFile = (f) => {
|
||
if (detail && detail.id === f.id) { setDetail(null); return }
|
||
setDetail(null)
|
||
api({ op: 'readAttackFile', engagement: eng, id: f.id }).then((r) => {
|
||
if (r && r.ok) setDetail(r)
|
||
else setErr((r && r.error) || '读取失败')
|
||
}, (e) => setErr(String((e && e.message) || e)))
|
||
}
|
||
|
||
const rows = []
|
||
for (const folder of folders) {
|
||
const fKey = 'folder:' + folder.folder
|
||
const fileNodes = []
|
||
for (const f of folder.files) {
|
||
fileNodes.push(h('div', {
|
||
key: 'a' + f.id, className: 'rt-vrow', style: { gridTemplateColumns: '18px 1.2fr 60px 1.6fr', cursor: 'pointer' },
|
||
role: 'button', tabIndex: 0,
|
||
onClick: () => openFile(f),
|
||
onKeyDown: (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); openFile(f) } },
|
||
},
|
||
h('span', { className: 'rt-sec-caret' }, detail && detail.id === f.id ? '▾' : '▸'),
|
||
h('span', { className: 'rt-mono' }, f.name),
|
||
h('span', null, h('span', { className: 'rt-tag rt-tag-active' }, FILE_KIND[f.kind] || f.kind || '—')),
|
||
h('span', { style: { fontSize: 11.5, color: 'var(--dsw-alias-label-secondary)' }, title: f.description || '' }, f.description || '—')))
|
||
if (!detail || detail.id !== f.id) continue
|
||
fileNodes.push(h('div', {
|
||
key: 'ad' + f.id, className: 'rt-vrow', style: { cursor: 'default', gridTemplateColumns: '1fr' },
|
||
}, h('div', { className: 'rt-vdetail' },
|
||
h('div', { className: 'rt-kv' }, h('b', null, '效果'), h('span', null, detail.evidence || '—')),
|
||
h('div', { className: 'rt-kv' }, h('b', null, '路径'), h('span', { className: 'rt-mono', style: { wordBreak: 'break-all' } }, detail.path)),
|
||
h('div', { className: 'rt-kv' }, h('b', null, '记录'), h('span', null, fmt(detail.created_at) + (detail.created_by ? ' · ' + detail.created_by : ''))),
|
||
h('pre', { className: 'rt-md', style: { border: '1px solid var(--dsw-alias-border-l1)', borderRadius: 6, maxHeight: '40vh', padding: '10px 12px' } }, detail.content || '(空)'))))
|
||
}
|
||
rows.push(h(Section, {
|
||
key: 'sec:' + folder.folder, tone: 'folder', title: folder.folder + '/',
|
||
count: folder.count + ' 个文件',
|
||
open: collapse.isOpen(fKey, true), onToggle: collapse.toggle(fKey, true),
|
||
}, fileNodes.length ? fileNodes : h('div', { className: 'rt-atest-meta' }, '(空)')))
|
||
}
|
||
|
||
const total = folders.reduce((n, f) => n + f.count, 0)
|
||
return h('div', { className: 'rt-main' },
|
||
h('div', { className: 'rt-toolbar' },
|
||
h('span', { style: { fontWeight: 600 } }, '攻击文件'),
|
||
h('span', { className: 'rt-tag' }, folders.length + ' 个目标 / ' + total + ' 个文件'),
|
||
h('div', { className: 'rt-spacer' }),
|
||
h('button', { className: 'rt-btn', disabled: busy, onClick: load }, busy ? '刷新中…' : '刷新')),
|
||
err ? h('div', { className: 'rt-err' }, err) : null,
|
||
h('div', { className: 'rt-table' },
|
||
rows,
|
||
!folders.length ? h('div', { className: 'rt-empty' }, '暂无攻击文件(打通的脚本/POC/EXP 会按目标文件夹出现在这里)') : null),
|
||
h('div', { className: 'rt-foot' }, h('span', null, '目录:attack-files/<IP|URL主机|C段>/ | 只收录实际生效的文件')))
|
||
}
|
||
|
||
/* ---------------------------------------------------------- 知识库(POC/EXP,全局共享) */
|
||
/**
|
||
* 知识库页:打 Nday/1day 之前先在这里搜。检索框支持 CVE / 组件 / 关键字 / 正文关键词;
|
||
* 命中就展开拿全文(可直接复制去用),没有就说明要去互联网找或自己搓,验证后回填。
|
||
* 这里是**全局**的:不随靶标切换,一个靶标沉淀的通用 POC 后面所有靶标都能用。
|
||
*/
|
||
const POC_KIND_LABEL = { poc: 'POC', exp: 'EXP', script: '脚本', template: '模板', payload: '载荷' }
|
||
const POC_SOURCE_LABEL = { web: '互联网', self: '手搓', manual: '人工', 'nuclei-template': 'nuclei 模板', kb: '知识库' }
|
||
|
||
function KnowledgeTab(props) {
|
||
const refreshKey = props.refreshKey || 0
|
||
const [data, setData] = React.useState(null)
|
||
const [err, setErr] = React.useState(null)
|
||
const [busy, setBusy] = React.useState(false)
|
||
const [msg, setMsg] = React.useState(null)
|
||
const [q, setQ] = React.useState('')
|
||
const [kind, setKind] = React.useState('')
|
||
const [category, setCategory] = React.useState('')
|
||
const [engagement, setEngagement] = React.useState('')
|
||
const [assetTarget, setAssetTarget] = React.useState('')
|
||
const [grouped, setGrouped] = React.useState(true)
|
||
const [verifiedOnly, setVerifiedOnly] = React.useState(false)
|
||
const [openId, setOpenId] = React.useState(null)
|
||
const [detail, setDetail] = React.useState(null)
|
||
const [detailBusy, setDetailBusy] = React.useState(false)
|
||
const [tplOffset, setTplOffset] = React.useState(0)
|
||
const tplPageSize = 40
|
||
|
||
const query = (over) => {
|
||
const extra = over || {}
|
||
const offset = Object.prototype.hasOwnProperty.call(extra, 'templateOffset') ? extra.templateOffset : 0
|
||
setTplOffset(offset)
|
||
const params = Object.assign({
|
||
q: q.trim() || undefined,
|
||
kind: kind || undefined,
|
||
category: category || undefined,
|
||
engagement: engagement || undefined,
|
||
asset_target: assetTarget.trim() || undefined,
|
||
verified: verifiedOnly || undefined,
|
||
templateOffset: offset || 0,
|
||
templateLimit: tplPageSize,
|
||
}, extra)
|
||
setBusy(true); setMsg(null)
|
||
api(Object.assign({ op: 'pocSearch' }, params)).then((r) => {
|
||
setBusy(false)
|
||
if (!r || r.ok === false) { setErr((r && r.error) || '读取失败'); return }
|
||
setErr(null); setData(r)
|
||
}, (e) => { setBusy(false); setErr(String((e && e.message) || e)) })
|
||
}
|
||
/* 首次进入与刷新键变化时拉全量(检索是显式动作,避免边打字边打接口) */
|
||
React.useEffect(() => { query({ q: undefined, kind: undefined, category: undefined, engagement: undefined, asset_target: undefined, verified: undefined }) }, [refreshKey])
|
||
|
||
const open = (id) => {
|
||
if (openId === id) { setOpenId(null); setDetail(null); return }
|
||
setOpenId(id); setDetail(null); setDetailBusy(true)
|
||
api({ op: 'pocGet', id: id }).then((r) => {
|
||
setDetailBusy(false)
|
||
if (!r || r.ok === false) { setMsg({ err: (r && r.error) || '读取失败' }); return }
|
||
setDetail(r)
|
||
}, (e) => { setDetailBusy(false); setMsg({ err: String((e && e.message) || e) }) })
|
||
}
|
||
|
||
/* 同报告页:await 真实结果再提示 */
|
||
const copy = (text, label) => copyWithFeedback(text, label, (okFlag, message) => {
|
||
setMsg(okFlag ? { ok: message } : { err: message })
|
||
})
|
||
const useIt = (row) => {
|
||
api({ op: 'pocUse', id: row.id, used_on: '控制台手动标记' }).then(() => {
|
||
setMsg({ ok: '已记一次复用:' + row.title })
|
||
query()
|
||
}, (e) => setMsg({ err: String((e && e.message) || e) }))
|
||
}
|
||
|
||
const stats = (data && data.stats) || { total: 0, verified: 0, reused: 0, byKind: [], bySource: [] }
|
||
const items = (data && data.items) || []
|
||
const tpl = (data && data.templates) || { dir: null, total: 0, matched: 0, offset: 0, items: [], byCategory: [] }
|
||
const tplByCat = new Map((tpl.byCategory || []).map((c) => [c.code, c.n || 0]))
|
||
const catShown = (c) => (c.n || 0) + (tplByCat.get(c.code) || 0)
|
||
const tplItems = tpl.items || []
|
||
const tplMatched = tpl.matched != null ? tpl.matched : tpl.total
|
||
const tplStart = Number(tpl.offset) || 0
|
||
const turnTpl = (next) => {
|
||
const offset = Math.max(0, next)
|
||
setTplOffset(offset)
|
||
query({ templateOffset: offset })
|
||
}
|
||
|
||
const card = (x) => {
|
||
const isOpen = openId === x.id
|
||
const d = isOpen && detail && detail.id === x.id ? detail : null
|
||
return h('div', { key: 'p' + x.id, className: 'rt-kb' + (isOpen ? ' open' : '') },
|
||
h('div', {
|
||
className: 'rt-kb-head', role: 'button', tabIndex: 0, 'aria-expanded': isOpen ? 'true' : 'false',
|
||
onClick: () => open(x.id),
|
||
onKeyDown: (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); open(x.id) } },
|
||
},
|
||
h('span', { className: 'rt-sec-caret' }, isOpen ? '▾' : '▸'),
|
||
h('span', { className: 'rt-kb-kind k-' + (x.kind || 'poc') }, POC_KIND_LABEL[x.kind] || x.kind || 'POC'),
|
||
h('span', { className: 'rt-kb-title' }, x.title),
|
||
x.cve ? h('span', { className: 'rt-tag rt-tag-passive' }, x.cve) : null,
|
||
x.component ? h('span', { className: 'rt-tag' }, x.component) : null,
|
||
x.verified === 1
|
||
? h('span', { className: 'rt-tag rt-tag-live' }, '已验证')
|
||
: h('span', { className: 'rt-tag rt-tag-warn' }, '未验证'),
|
||
h('div', { className: 'rt-spacer' }),
|
||
h('span', { className: 'rt-tag' }, POC_SOURCE_LABEL[x.source] || x.source || '—'),
|
||
x.hit_count ? h('span', { className: 'rt-tag' }, '复用 ' + x.hit_count) : null),
|
||
/* 来源行:归类 · 影响版本 · 建立时间 · 来源靶标 · 发现资产 —— 一条知识"什么时候、
|
||
在哪个单位的哪台资产上发现的"一眼可见 */
|
||
h('div', { className: 'rt-kb-sub' },
|
||
[x.category ? '归类 ' + (POC_CAT_NAME[x.category] || x.category) : null,
|
||
x.versions ? '影响版本 ' + x.versions : null,
|
||
x.language || null,
|
||
x.tags || null,
|
||
'建立时间 ' + fmt(x.created_at),
|
||
(x.engagement_name || x.engagement_id) ? '来源靶标 ' + (x.engagement_name || x.engagement_id) : null,
|
||
x.asset_target ? '发现资产 ' + x.asset_target : null,
|
||
x.found_by_agent ? '发现角色 ' + (ROLE_LABEL[x.found_by_agent] || x.found_by_agent) : null,
|
||
x.source_url ? '来源 ' + x.source_url : null].filter(Boolean).join(' · ')),
|
||
isOpen
|
||
? h('div', { className: 'rt-kb-body' },
|
||
detailBusy && !d ? h('div', { className: 'rt-empty' }, '读取中…') : null,
|
||
d ? h('div', null,
|
||
d.usage ? h('div', { className: 'rt-kv' }, h('b', null, '用法'), h('span', { className: 'rt-mono' }, d.usage)) : null,
|
||
d.description ? h('div', { className: 'rt-kv' }, h('b', null, '说明'), h('span', null, d.description)) : null,
|
||
d.verified_note ? h('div', { className: 'rt-kv' }, h('b', null, '验证证据'), h('span', null, d.verified_note)) : null,
|
||
d.used_on ? h('div', { className: 'rt-kv' }, h('b', null, '最近使用'), h('span', null, d.used_on)) : null,
|
||
d.path ? h('div', { className: 'rt-kv' }, h('b', null, '落盘'), h('span', { className: 'rt-mono' }, d.path)) : null,
|
||
h('div', { className: 'rt-kb-actions' },
|
||
h('button', { className: 'rt-btn', disabled: !d.content, onClick: () => copy(d.content || '', 'POC 正文') }, '复制正文'),
|
||
h('button', { className: 'rt-btn', onClick: () => useIt(x) }, '记一次复用'),
|
||
d.source_url ? h('button', { className: 'rt-btn', onClick: () => copy(d.source_url, '来源链接') }, '复制来源') : null),
|
||
d.content
|
||
? h('pre', { className: 'rt-rep-http' }, d.content.length > 12000 ? d.content.slice(0, 12000) + '\n…(已截断,完整内容见落盘文件)' : d.content)
|
||
: h('div', { className: 'rt-empty' }, '这条只有元数据,没有正文 —— 拿到正文后用 redteam_poc_update 补上'))
|
||
: null)
|
||
: null)
|
||
}
|
||
|
||
return h('div', { className: 'rt-main' },
|
||
h('div', { className: 'rt-toolbar' },
|
||
h('span', { style: { fontWeight: 600 } }, '知识库 · POC / EXP'),
|
||
h('span', { className: 'rt-tag' }, stats.total + ' 条'),
|
||
h('span', { className: 'rt-tag rt-tag-live' }, '已验证 ' + stats.verified),
|
||
h('span', { className: 'rt-tag' }, '累计复用 ' + (stats.reused || 0)),
|
||
(stats.uncategorized || 0) > 0 && (stats.uncategorized || 0) !== stats.total
|
||
? h('span', { className: 'rt-tag rt-tag-warn', title: '这些条目还没归类:智能体回填时用 redteam_poc_add 的 category 参数标一下' }, '未归类 ' + stats.uncategorized)
|
||
: null,
|
||
tpl.total ? h('span', { className: 'rt-tag rt-tag-passive' }, '本机模板 ' + tpl.total) : null,
|
||
h('div', { className: 'rt-spacer' }),
|
||
h('button', { className: 'rt-btn' + (grouped ? ' rt-btn-primary' : ''), title: '按归类分组显示 / 平铺显示', onClick: () => setGrouped(!grouped) }, grouped ? '按归类分组' : '平铺显示'),
|
||
h('button', { className: 'rt-btn', disabled: busy, onClick: () => query() }, busy ? '检索中…' : '刷新')),
|
||
/* 归类总览:点一下就是按该类筛选,一眼看清"哪类武器攒了多少、哪类还是空的" */
|
||
h('div', { className: 'rt-kb-cats' },
|
||
(stats.byCategory || []).filter((c) => catShown(c) > 0 || POC_CAT_ORDER.includes(c.code)).map((c) => h('span', {
|
||
key: c.code,
|
||
className: 'rt-concl-i' + (category === c.code ? ' on' : ''),
|
||
title: (c.hint || POC_CAT_NAME[c.code] || c.code)
|
||
+ ' · 本机模板 ' + (tplByCat.get(c.code) || 0)
|
||
+ ' · 知识库 ' + (c.n || 0)
|
||
+ (c.verified ? '(已验证 ' + c.verified + ')' : ''),
|
||
onClick: () => {
|
||
const next = category === c.code ? '' : c.code
|
||
setCategory(next)
|
||
query({ category: next || undefined })
|
||
},
|
||
},
|
||
h('b', null, String(catShown(c))),
|
||
h('span', null, (POC_CAT_NAME[c.code] || c.code) + (c.verified ? '(已验证 ' + c.verified + ')' : ''))))),
|
||
h('div', { className: 'rt-kb-filter' },
|
||
h('input', {
|
||
className: 'rt-input', style: { flex: 1, minWidth: 140 }, placeholder: '搜 CVE / 组件 / 关键字(正文也会搜)',
|
||
value: q, onChange: (e) => setQ(e.target.value),
|
||
onKeyDown: (e) => { if (e.key === 'Enter') query() },
|
||
}),
|
||
h('select', { className: 'rt-input', style: { maxWidth: 120 }, value: category, onChange: (e) => { setCategory(e.target.value); query({ category: e.target.value || undefined }) } },
|
||
h('option', { value: '' }, '全部归类'),
|
||
POC_CAT_ORDER.map((k) => h('option', { key: k, value: k }, POC_CAT_NAME[k]))),
|
||
h('select', { className: 'rt-input', style: { maxWidth: 120 }, value: engagement, onChange: (e) => { setEngagement(e.target.value); query({ engagement: e.target.value || undefined }) } },
|
||
h('option', { value: '' }, '全部来源靶标'),
|
||
(stats.byEngagement || []).map((e) => h('option', { key: e.engagement, value: e.engagement }, e.engagement + '(' + e.n + ')'))),
|
||
h('input', {
|
||
className: 'rt-input', style: { width: 120 }, placeholder: '发现资产筛选',
|
||
value: assetTarget, onChange: (e) => setAssetTarget(e.target.value),
|
||
onKeyDown: (e) => { if (e.key === 'Enter') query() },
|
||
}),
|
||
h('select', { className: 'rt-input', style: { maxWidth: 110 }, value: kind, onChange: (e) => { setKind(e.target.value); query({ kind: e.target.value || undefined }) } },
|
||
h('option', { value: '' }, '全部类型'),
|
||
Object.keys(POC_KIND_LABEL).map((k) => h('option', { key: k, value: k }, POC_KIND_LABEL[k]))),
|
||
h('label', { className: 'rt-kb-check' },
|
||
h('input', { type: 'checkbox', checked: verifiedOnly, onChange: (e) => { setVerifiedOnly(e.target.checked); query({ verified: e.target.checked || undefined }) } }),
|
||
'只看已验证'),
|
||
h('button', { className: 'rt-btn rt-btn-primary', onClick: () => query() }, '检索')),
|
||
msg ? h('div', { className: msg.err ? 'rt-err' : 'rt-foot' }, msg.err || msg.ok) : null,
|
||
err ? h('div', { className: 'rt-err' },
|
||
/engagement required|unknown op/i.test(err)
|
||
/* 老 host 还没有知识库接口:讲清怎么恢复,别让人对着 "engagement required" 发懵 */
|
||
? '知识库接口由 host 侧提供,当前宿主还是旧进程 —— 请完全退出后重新打开(官方 exe / 社区版 / dsh web 任一),再刷新页面。'
|
||
: err) : null,
|
||
!err && data && data.dbPath
|
||
? h('div', { className: 'rt-foot', style: { marginBottom: 6, fontSize: 11 } },
|
||
'知识库:' + data.dbPath
|
||
+ (data.root ? '(根目录 ' + data.root + ')' : ''))
|
||
: null,
|
||
h('div', { className: 'rt-table' },
|
||
(() => {
|
||
if (!grouped) return items.map(card)
|
||
/* 分组渲染:按内置归类顺序,未知归类挂到末尾的「其它」 */
|
||
const buckets = new Map()
|
||
for (const x of items) {
|
||
const code = POC_CAT_NAME[x.category] ? x.category : 'other'
|
||
if (!buckets.has(code)) buckets.set(code, [])
|
||
buckets.get(code).push(x)
|
||
}
|
||
const order = POC_CAT_ORDER.filter((c) => buckets.has(c))
|
||
for (const code of buckets.keys()) if (!order.includes(code)) order.push(code)
|
||
const out = []
|
||
for (const code of order) {
|
||
const list = buckets.get(code)
|
||
out.push(h('div', { key: 'g' + code, className: 'rt-kb-cat' },
|
||
h('span', { className: 'rt-kb-cat-name' }, POC_CAT_NAME[code] || code),
|
||
h('span', { className: 'rt-tag' }, list.length + ' 条'),
|
||
h('span', { className: 'rt-tag rt-tag-live' }, '已验证 ' + list.filter((x) => x.verified === 1).length),
|
||
h('span', { className: 'rt-spacer' }),
|
||
h('span', { style: { fontSize: 11, color: 'var(--dsw-alias-label-secondary)' } },
|
||
'最近建立 ' + fmt(list.map((x) => x.created_at).filter(Boolean).sort().pop()))))
|
||
for (const x of list) out.push(card(x))
|
||
}
|
||
return out
|
||
})(),
|
||
tplItems.length
|
||
? h('div', { className: 'rt-kb-tpl' },
|
||
h('div', { className: 'rt-ap-sub' },
|
||
'本机 nuclei 模板 · ' + (tplStart + 1) + '–' + (tplStart + tplItems.length) + ' / ' + tplMatched
|
||
+ '(直接 `nuclei -t <模板路径>`)'),
|
||
tplItems.map((t, i) => h('div', { key: 't' + tplStart + '-' + i, className: 'rt-kb-tpl-row' },
|
||
h('span', { className: 'rt-tag' }, t.severity || '—'),
|
||
t.category ? h('span', { className: 'rt-tag' }, POC_CAT_NAME[t.category] || t.category) : null,
|
||
h('span', { className: 'rt-mono rt-kb-tpl-path', title: t.path }, t.path),
|
||
h('span', { className: 'rt-kb-tpl-name', title: t.name }, t.name || ''),
|
||
h('button', {
|
||
className: 'rt-btn', style: { padding: '0 6px', fontSize: 10.5 },
|
||
onClick: () => copy('nuclei -t ' + t.path + ' -u <目标>', '模板命令'),
|
||
}, '复制命令'))),
|
||
h('div', { className: 'rt-kb-actions' },
|
||
h('button', {
|
||
className: 'rt-btn', disabled: busy || tplStart <= 0,
|
||
onClick: () => turnTpl(tplStart - tplPageSize),
|
||
}, '上一页'),
|
||
h('button', {
|
||
className: 'rt-btn', disabled: busy || tplStart + tplItems.length >= tplMatched,
|
||
onClick: () => turnTpl(tplStart + tplPageSize),
|
||
}, '下一页')),
|
||
tpl.dir ? h('div', { className: 'rt-foot' }, h('span', null, '模板目录:' + tpl.dir)) : null)
|
||
: null,
|
||
data === null ? h('div', { className: 'rt-empty' }, '加载中…') : null,
|
||
data !== null && !items.length && !tplItems.length
|
||
? h('div', { className: 'rt-empty' },
|
||
h('div', null,
|
||
q || kind || verifiedOnly
|
||
? '没有命中:换个关键字再试,或去互联网找 / 自己手搓后回填。'
|
||
: (tpl.total
|
||
? ('POC/EXP 库还是空的;本机已有 ' + tpl.total + ' 个 nuclei 模板,但这一页没有列出来。请完全退出后重新打开客户端。')
|
||
: (tpl.dir
|
||
? '知识库还是空的(本机模板目录存在但没有 yaml,请跑 nuclei -update-templates)。'
|
||
: '知识库还是空的。本机也还没有 nuclei 模板库 —— 在演练机上跑 setup.sh 或 `nuclei -update-templates`,装好后这里会出现「本机模板 N」。'))),
|
||
h('div', { style: { marginTop: 6, fontSize: 12 } },
|
||
'打 Nday/1day 的标准顺序:① redteam_poc_search 先查这里(顺带搜本机 nuclei 模板库)→ ② 都没有就互联网搜索(web_search / GitHub / ExploitDB / 厂商公告)或自己手搓 → ③ 在真实目标上验证有效后 redteam_poc_add 回填,后面的靶标直接就能用。'),
|
||
data.dbPath ? h('div', { style: { marginTop: 6, fontSize: 11 }, className: 'rt-mono' }, '知识库文件:' + data.dbPath) : null,
|
||
tpl.dir ? h('div', { style: { marginTop: 6, fontSize: 11 }, className: 'rt-mono' }, '模板目录:' + tpl.dir) : null)
|
||
: null),
|
||
h('div', { className: 'rt-foot' },
|
||
h('span', null, '全局共享(跨靶标)| 落盘:pocs/<code>/ | 只收录通用可复用的 POC/EXP,靶标专用脚本走「攻击文件」')))
|
||
}
|
||
|
||
/* ---------------------------------------------------------- 环境适配(Windows / 非 Kali) */
|
||
/**
|
||
* 借鉴 Z3r0 的「系统配置」思路:路径与密钥进集中配置,不靠 bash/ps1。
|
||
* 落盘 $DSH_HOME/redteam/config.json —— 用户填工具绝对路径或整包搜索目录。
|
||
*/
|
||
function EnvTab(props) {
|
||
const refreshKey = props.refreshKey || 0
|
||
const [data, setData] = React.useState(null)
|
||
const [draft, setDraft] = React.useState(null)
|
||
const [err, setErr] = React.useState(null)
|
||
const [msg, setMsg] = React.useState(null)
|
||
const [busy, setBusy] = React.useState(false)
|
||
const [folderPath, setFolderPath] = React.useState('')
|
||
|
||
const applyDraftFrom = (r) => {
|
||
const c = (r && r.config) || {}
|
||
setDraft({
|
||
platform: c.platform || 'auto',
|
||
toolkitDir: c.toolkitDir || '',
|
||
nucleiTemplatesDir: c.nucleiTemplatesDir || '',
|
||
binDirsText: (c.binDirs || []).join('\n'),
|
||
tools: Object.assign({}, c.tools || {}),
|
||
env: Object.assign({ FOFA_KEY: '', REDTEAM_VPS_HOST: '', REDTEAM_VPS_KEY: '' }, c.env || {}),
|
||
notes: c.notes || '',
|
||
})
|
||
if (c.toolkitDir) setFolderPath(c.toolkitDir)
|
||
}
|
||
|
||
const load = () => {
|
||
setBusy(true); setMsg(null)
|
||
api({ op: 'platformConfigGet' }).then((r) => {
|
||
setBusy(false)
|
||
if (!r || r.ok === false) { setErr((r && r.error) || '读取失败'); return }
|
||
setErr(null); setData(r)
|
||
applyDraftFrom(r)
|
||
}, (e) => { setBusy(false); setErr(String((e && e.message) || e)) })
|
||
}
|
||
React.useEffect(load, [refreshKey])
|
||
|
||
const save = () => {
|
||
if (!draft) return
|
||
setBusy(true); setMsg(null)
|
||
const config = {
|
||
platform: draft.platform,
|
||
toolkitDir: draft.toolkitDir,
|
||
nucleiTemplatesDir: draft.nucleiTemplatesDir,
|
||
binDirs: String(draft.binDirsText || '').split(/\r?\n/).map((x) => x.trim()).filter(Boolean),
|
||
tools: draft.tools,
|
||
env: draft.env,
|
||
notes: draft.notes,
|
||
envAdaptConfigured: true,
|
||
envAdaptSkip: false,
|
||
}
|
||
api({ op: 'platformConfigSave', config }).then((r) => {
|
||
setBusy(false)
|
||
if (!r || r.ok === false) { setMsg({ err: (r && r.error) || '保存失败' }); return }
|
||
setData(r)
|
||
setMsg({ ok: '已保存到 ' + ((r.config && r.config.path) || '$DSH_HOME/redteam/config.json') })
|
||
applyDraftFrom(r)
|
||
}, (e) => { setBusy(false); setMsg({ err: String((e && e.message) || e) }) })
|
||
}
|
||
|
||
const assignFolder = () => {
|
||
const dir = String(folderPath || (draft && draft.toolkitDir) || '').trim()
|
||
if (!dir) { setMsg({ err: '请先填写工具所在文件夹路径' }); return }
|
||
setBusy(true); setMsg(null)
|
||
api({ op: 'platformAssignToolkit', dir }).then((r) => {
|
||
setBusy(false)
|
||
if (!r || r.ok === false) { setMsg({ err: (r && r.error) || '自动分配失败' }); return }
|
||
setData(r)
|
||
applyDraftFrom(r)
|
||
setMsg({
|
||
ok: '已从文件夹分配 ' + (r.assignedCount || 0) + ' 个工具'
|
||
+ (r.nucleiTemplatesDir ? ',并识别 nuclei-templates' : ''),
|
||
})
|
||
}, (e) => { setBusy(false); setMsg({ err: String((e && e.message) || e) }) })
|
||
}
|
||
|
||
const setTool = (id, value) => setDraft((d) => d ? Object.assign({}, d, { tools: Object.assign({}, d.tools, { [id]: value }) }) : d)
|
||
const setEnv = (key, value) => setDraft((d) => d ? Object.assign({}, d, { env: Object.assign({}, d.env, { [key]: value }) }) : d)
|
||
|
||
const runtime = (data && data.runtime) || {}
|
||
const adapt = (data && data.adapt) || {}
|
||
const tools = (data && data.tools && data.tools.items) || []
|
||
const found = tools.filter((t) => t.path).length
|
||
const defaultToolkit = (data && data.toolkitDir) || '$DSH_HOME/redteam/toolkit'
|
||
const defaultNuclei = (data && data.nucleiTemplatesDir) || (defaultToolkit + '/nuclei-templates')
|
||
|
||
return h('div', { className: 'rt-main' },
|
||
h('div', { className: 'rt-toolbar' },
|
||
h('span', { style: { fontWeight: 600 } }, '环境适配'),
|
||
h('span', { className: 'rt-tag' }, runtime.effective === 'windows' ? 'Windows' : 'Linux/Kali'),
|
||
adapt.kali ? h('span', { className: 'rt-tag rt-tag-live' }, 'Kali') : null,
|
||
h('span', { className: 'rt-tag' + (adapt.ready ? ' rt-tag-live' : ' rt-tag-warn') },
|
||
adapt.ready ? ('就绪 · ' + (adapt.reason || '')) : '待配置'),
|
||
h('span', { className: 'rt-tag' + (found ? ' rt-tag-live' : ' rt-tag-warn') }, '工具 ' + found + '/' + tools.length),
|
||
h('div', { className: 'rt-spacer' }),
|
||
h('button', { className: 'rt-btn', disabled: busy, onClick: load }, busy ? '读取中…' : '刷新'),
|
||
h('button', { className: 'rt-btn rt-btn-primary', disabled: busy || !draft, onClick: save }, '保存')),
|
||
h('div', { className: 'rt-pane', style: { minHeight: 0 } },
|
||
err ? h('div', { className: 'rt-err' }, err) : null,
|
||
msg ? h('div', { className: msg.err ? 'rt-err' : 'rt-foot' }, msg.err || msg.ok) : null,
|
||
h('div', { className: 'rt-card', style: { margin: '0 0 8px', fontSize: 12, lineHeight: 1.6 } },
|
||
adapt.message
|
||
|| '均可选填:留空则用默认路径/自动查找。填了的覆盖默认。密钥类只在需要测绘/反弹时才填。'),
|
||
h('div', { className: 'rt-card', style: { margin: '0 0 8px', fontSize: 12, lineHeight: 1.6 } },
|
||
h('div', { style: { fontWeight: 600, marginBottom: 6 } }, '整包文件夹 → 自动分配工具'),
|
||
h('div', { style: { marginBottom: 8, opacity: 0.9 } },
|
||
'工具都在同一个文件夹(含子目录)时,填路径后点「自动分配」:会写入 toolkitDir,并按文件名匹配 nmap / nuclei / fscan 等填到下方。'),
|
||
h('div', { style: { display: 'flex', gap: 8, alignItems: 'center' } },
|
||
h('input', {
|
||
className: 'rt-input', style: { flex: 1 },
|
||
placeholder: '例如 D:\\pentest-tools 或 /opt/toolkit',
|
||
value: folderPath,
|
||
onChange: (e) => setFolderPath(e.target.value),
|
||
onKeyDown: (e) => { if (e.key === 'Enter') assignFolder() },
|
||
}),
|
||
h('button', {
|
||
className: 'rt-btn rt-btn-primary', disabled: busy,
|
||
onClick: assignFolder,
|
||
}, busy ? '扫描中…' : '自动分配'))),
|
||
data && data.hint ? h('div', { className: 'rt-card', style: { margin: '0 0 8px', fontSize: 12, lineHeight: 1.6 } }, data.hint) : null,
|
||
data && data.egress ? h('div', { className: 'rt-card', style: { margin: '0 0 8px', fontSize: 12, lineHeight: 1.55 } },
|
||
h('div', { style: { fontWeight: 600, marginBottom: 6 } }, '出网状态(借鉴 Z3r0 egress,只读)'),
|
||
h('div', { className: 'rt-mono', style: { fontSize: 11 } }, 'HTTP_PROXY=' + (data.egress.http_proxy || '(空)')),
|
||
h('div', { className: 'rt-mono', style: { fontSize: 11 } }, 'HTTPS_PROXY=' + (data.egress.https_proxy || '(空)')),
|
||
h('div', { className: 'rt-mono', style: { fontSize: 11 } }, 'ALL_PROXY=' + (data.egress.all_proxy || '(空)')),
|
||
h('div', { className: 'rt-mono', style: { fontSize: 11 } }, 'NO_PROXY=' + (data.egress.no_proxy || '(空)')),
|
||
h('div', { style: { marginTop: 6, opacity: 0.85 } }, data.egress.note || '')) : null,
|
||
!draft ? h('div', { className: 'rt-empty' }, '加载中…') : h('div', null,
|
||
h('div', { className: 'rt-card' },
|
||
h('h4', null, '平台与目录'),
|
||
h('div', { style: { display: 'grid', gap: 8 } },
|
||
h('label', { style: { fontSize: 12 } }, '平台模式',
|
||
h('select', {
|
||
className: 'rt-input', style: { display: 'block', width: '100%', marginTop: 4 },
|
||
value: draft.platform,
|
||
onChange: (e) => setDraft(Object.assign({}, draft, { platform: e.target.value })),
|
||
},
|
||
h('option', { value: 'auto' }, 'auto(跟随本机,默认)'),
|
||
h('option', { value: 'windows' }, 'windows'),
|
||
h('option', { value: 'linux' }, 'linux'))),
|
||
h('label', { style: { fontSize: 12 } }, '工具箱目录 toolkitDir(留空=默认)',
|
||
h('input', {
|
||
className: 'rt-input', style: { display: 'block', width: '100%', marginTop: 4 },
|
||
placeholder: '默认:' + defaultToolkit,
|
||
value: draft.toolkitDir,
|
||
onChange: (e) => setDraft(Object.assign({}, draft, { toolkitDir: e.target.value })),
|
||
})),
|
||
h('label', { style: { fontSize: 12 } }, 'nuclei 模板目录(留空=自动:toolkit/nuclei-templates)',
|
||
h('input', {
|
||
className: 'rt-input', style: { display: 'block', width: '100%', marginTop: 4 },
|
||
placeholder: '默认:' + defaultNuclei,
|
||
value: draft.nucleiTemplatesDir,
|
||
onChange: (e) => setDraft(Object.assign({}, draft, { nucleiTemplatesDir: e.target.value })),
|
||
})),
|
||
h('label', { style: { fontSize: 12 } }, '工具搜索目录 binDirs(留空=只搜 toolkit + PATH;每行一个)',
|
||
h('textarea', {
|
||
className: 'rt-input', rows: 4, style: { display: 'block', width: '100%', marginTop: 4, fontFamily: 'var(--dsw-font-mono, monospace)' },
|
||
placeholder: '可选。例如:' + String.fromCharCode(10) + 'D:\\tools' + String.fromCharCode(10) + 'D:\\pentest-bin',
|
||
value: draft.binDirsText,
|
||
onChange: (e) => setDraft(Object.assign({}, draft, { binDirsText: e.target.value })),
|
||
})))),
|
||
h('div', { className: 'rt-card' },
|
||
h('h4', null, '密钥与 VPS(留空=不覆盖;进程环境变量优先)'),
|
||
h('div', { style: { display: 'grid', gap: 8 } },
|
||
['FOFA_KEY', 'REDTEAM_VPS_HOST', 'REDTEAM_VPS_KEY'].map((k) => h('label', { key: k, style: { fontSize: 12 } }, k + '(可选)',
|
||
h('input', {
|
||
className: 'rt-input', style: { display: 'block', width: '100%', marginTop: 4 },
|
||
type: k === 'FOFA_KEY' ? 'password' : 'text',
|
||
placeholder: k === 'REDTEAM_VPS_KEY' ? '留空;需要时填私钥绝对路径' : (k === 'REDTEAM_VPS_HOST' ? '留空;需要时填 user@host' : '留空;测绘时再填'),
|
||
value: (draft.env && draft.env[k]) || '',
|
||
onChange: (e) => setEnv(k, e.target.value),
|
||
}))))),
|
||
h('div', { className: 'rt-card' },
|
||
h('h4', null, '工具可执行文件(留空=自动查找)'),
|
||
h('div', { style: { fontSize: 11.5, color: 'var(--dsw-alias-label-secondary)', marginBottom: 8 } },
|
||
'解析顺序:本表绝对路径 → toolkitDir → binDirs → PATH。留空即走默认查找。'),
|
||
tools.map((t) => h('div', { key: t.id, style: { marginBottom: 8 } },
|
||
h('div', { style: { display: 'flex', gap: 8, alignItems: 'center', marginBottom: 4 } },
|
||
h('span', { style: { fontWeight: 600, minWidth: 88 } }, t.label || t.id),
|
||
t.path
|
||
? h('span', { className: 'rt-tag rt-tag-live', title: t.path }, t.source + ' · 已找到')
|
||
: h('span', { className: 'rt-tag rt-tag-warn' }, '未找到(可留空等装好)')),
|
||
h('input', {
|
||
className: 'rt-input', style: { width: '100%' },
|
||
placeholder: '留空自动找:' + (t.names || []).slice(0, 3).join(' / '),
|
||
value: (draft.tools && draft.tools[t.id]) || '',
|
||
onChange: (e) => setTool(t.id, e.target.value),
|
||
}),
|
||
t.path ? h('div', { className: 'rt-mono', style: { fontSize: 10.5, marginTop: 2, opacity: 0.8 } }, t.path) : null))),
|
||
h('div', { className: 'rt-card' },
|
||
h('h4', null, '备注'),
|
||
h('textarea', {
|
||
className: 'rt-input', rows: 3, style: { width: '100%' },
|
||
placeholder: '可选。例如:工具来自某某绿色包',
|
||
value: draft.notes,
|
||
onChange: (e) => setDraft(Object.assign({}, draft, { notes: e.target.value })),
|
||
})),
|
||
h('div', { className: 'rt-foot' },
|
||
h('span', null,
|
||
'配置文件:' + ((data && data.config && data.config.path) || '$DSH_HOME/redteam/config.json')
|
||
+ ' | 留空字段全部走默认')))))
|
||
}
|
||
|
||
/* ---------------------------------------------------------- 得分目标 */
|
||
function ScoreTab(props) {
|
||
const eng = props.engagement
|
||
const refreshKey = props.refreshKey || 0
|
||
const [data, setData] = React.useState(null)
|
||
const [err, setErr] = React.useState(null)
|
||
const [busy, setBusy] = React.useState(false)
|
||
const [openId, setOpenId] = React.useState(null)
|
||
const [form, setForm] = React.useState(null)
|
||
const [msg, setMsg] = React.useState(null)
|
||
|
||
const load = () => {
|
||
if (!eng) return
|
||
setBusy(true)
|
||
api({ op: 'scores', engagement: eng }).then((r) => {
|
||
setBusy(false)
|
||
if (!r || r.ok === false) { setErr((r && r.error) || '读取失败'); return }
|
||
setErr(null)
|
||
setData(r)
|
||
}, (e) => { setBusy(false); setErr(String((e && e.message) || e)) })
|
||
}
|
||
React.useEffect(load, [eng, refreshKey])
|
||
|
||
/* builtin 要一路带到表单:内置得分点的分值/名称/口径由《得分规则》锁定,
|
||
界面必须把输入框置灰并说明原因 —— 以前是「能改、提示已保存、刷新后变回去」,
|
||
用户以为是 bug。 */
|
||
const startEdit = (p) => setForm({
|
||
id: p.id, name: p.name, category: p.category || '', points: p.points,
|
||
description: p.description || '', enabled: p.enabled, builtin: p.builtin === true,
|
||
src: p.src, cap: p.cap, rule: p.rule,
|
||
})
|
||
const startNew = () => { setForm({ name: '', category: '', points: 10, description: '', enabled: true, builtin: false }); setMsg(null) }
|
||
const setField = (k, v) => setForm((f) => Object.assign({}, f, { [k]: v }))
|
||
|
||
const save = () => {
|
||
if (!form || !form.name) { setMsg({ err: '名称不能为空' }); return }
|
||
setBusy(true)
|
||
setMsg(null)
|
||
api({ op: 'saveScorePoint', engagement: eng, point: form }).then((r) => {
|
||
setBusy(false)
|
||
if (!r || r.ok === false) { setMsg({ err: (r && r.error) || '保存失败' }); return }
|
||
/* overridden === false 表示后端**没有采纳**提交的分值(内置点由规则锁定)。
|
||
这时不能笼统说「已保存」 —— 那句话会让用户以为分值改成功了。 */
|
||
if (r.overridden === false) {
|
||
setMsg({ err: '已保存「启用/停用」。分值未改动:这是随《得分规则》分发的内置得分点,'
|
||
+ '分值 / 上限 / 计分口径由规则锁定(同一条规则的上限按组内所有得分点累计,'
|
||
+ '单独改分值会让一条命中吃掉整组上限)。要自定义分值时请「+ 新增得分点」。' })
|
||
} else {
|
||
setMsg({ ok: r.note || '已保存' })
|
||
}
|
||
setForm(null)
|
||
load()
|
||
}, (e) => { setBusy(false); setMsg({ err: String((e && e.message) || e) }) })
|
||
}
|
||
const remove = () => {
|
||
if (!form || !form.id) { setForm(null); return }
|
||
setBusy(true)
|
||
setMsg(null)
|
||
api({ op: 'deleteScorePoint', engagement: eng, id: form.id }).then((r) => {
|
||
setBusy(false)
|
||
if (!r || r.ok === false) { setMsg({ err: (r && r.error) || '删除失败' }); return }
|
||
setMsg({ ok: '已删除' })
|
||
setForm(null)
|
||
setOpenId(null)
|
||
load()
|
||
}, (e) => { setBusy(false); setMsg({ err: String((e && e.message) || e) }) })
|
||
}
|
||
|
||
const summary = (data && data.summary) || { achievedPoints: 0, pointCount: 0, hitPointCount: 0, hitCount: 0, selfCreatedHits: 0, serviceCappedHits: 0 }
|
||
const items = (data && data.items) || []
|
||
|
||
/* 按合并版的 8 个类别分组渲染(后端 ruleGroups 已排好序、组内按分值升序)。
|
||
兼容:后端没给 ruleGroups(老 host)时退回平铺。 */
|
||
const groups = (data && data.ruleGroups && data.ruleGroups.length)
|
||
? data.ruleGroups
|
||
: [{ key: 'all', name: '', capSum: 0, points: 0, counted: 0, tiers: items }]
|
||
const rows = []
|
||
for (const g of groups) {
|
||
if (g.name) {
|
||
rows.push(h('div', { key: 'g-' + g.key, className: 'rt-score-group' },
|
||
h('span', null, g.name),
|
||
h('span', { className: 'rt-sg-sub' },
|
||
g.tiers.length + ' 项'
|
||
+ (g.capSum > 0 ? ' · 各项上限合计 ' + g.capSum + ' 分(各项独立,不跨项累加)' : '')),
|
||
h('div', { className: 'rt-spacer' }),
|
||
g.points > 0
|
||
? h('span', { className: 'rt-tag rt-tag-active' }, '+' + g.points + ' 分')
|
||
: null))
|
||
}
|
||
for (const p of g.tiers) {
|
||
const achieved = p.hits.length > 0
|
||
const open = openId === p.id
|
||
rows.push(h('div', {
|
||
key: 'sp' + p.id, className: 'rt-score-row', role: 'button', tabIndex: 0,
|
||
onClick: () => setOpenId(open ? null : p.id),
|
||
onKeyDown: (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setOpenId(open ? null : p.id) } },
|
||
},
|
||
h('span', { className: 'rt-sec-caret' }, open ? '▾' : '▸'),
|
||
h('span', null, h('span', {
|
||
className: achieved ? 'rt-pri rt-pri-high' : 'rt-pri rt-pri-low',
|
||
style: achieved ? {} : { background: 'var(--dsw-alias-bg-layer-2)', color: 'var(--dsw-alias-label-secondary)' },
|
||
}, p.points + '分')),
|
||
h('span', { title: p.description || '' },
|
||
p.name,
|
||
p.tier ? h('span', { className: 'rt-scope' }, ' ' + p.tier) : null),
|
||
/* 命中与上限:这一条计入几次、上限用了多少(G3 各项上限独立,到顶就不再累计) */
|
||
h('span', { title: p.capped > 0
|
||
? '已计入 ' + p.counted + ' 次,另有 ' + p.capped + ' 条因「' + (p.scope_label || '计分口径') + '」或已达上限不计分'
|
||
: (p.scope_label || '') },
|
||
h('span', { className: 'rt-sec-count' }, p.hits.length > 0 ? p.counted + ' 次命中' : '未命中'),
|
||
p.cap > 0
|
||
? h('span', { className: 'rt-scope' }, ' 上限 ' + (p.cap_used === null ? 0 : p.cap_used) + '/' + p.cap)
|
||
: null),
|
||
h('span', { style: { textAlign: 'right' } }, p.earned > 0
|
||
? h('span', { className: 'rt-tag rt-tag-active' }, '+' + p.earned + ' 分')
|
||
: h('span', { style: { color: 'var(--dsw-alias-label-secondary)' } }, '—'))))
|
||
if (!open) continue
|
||
/* 命中记录:一行一条 —— 第一行给"序号 + 资产 + 时间 + 复制",内容另起一行自适应换行 */
|
||
const hitNodes = p.hits.map((hh, hi) => h('div', {
|
||
key: 'h' + hh.id,
|
||
className: 'rt-hit-row' + (hh.self_created ? ' self-created' : '') + (hh.capped ? ' service-capped' : ''),
|
||
},
|
||
h('span', { className: 'rt-hit-idx' }, String(hi + 1)),
|
||
h('span', { className: 'rt-hit-asset', title: hh.target || hh.asset_ip || '' },
|
||
hh.asset_ip || hh.target || '未指定资产'),
|
||
hh.self_created ? h('span', { className: 'rt-tag rt-tag-warn', title: '自己注册/自建的账号不算得分权限,只作过程记录' }, '自建 · 不计分') : null,
|
||
hh.capped ? h('span', {
|
||
className: 'rt-tag rt-tag-warn',
|
||
title: hh.capped_reason || '同一资产同一端口只算分值最高的一条,这条不计分',
|
||
}, '服务已拿满 · 不计分' + (hh.service ? '(' + hh.service + ')' : '')) : null,
|
||
hh.shot_count ? h('span', { className: 'rt-tag', title: '这条命中有证据截图(展开得分点即可看到代表图)' },
|
||
'图 ' + hh.shot_count) : null,
|
||
h('span', { className: 'rt-hit-time' }, fmt(hh.recorded_at)),
|
||
h('button', {
|
||
className: 'rt-btn', style: { padding: '0 5px', fontSize: 10.5 },
|
||
title: '复制这一条',
|
||
onClick: (e) => {
|
||
e.stopPropagation()
|
||
copyText((hh.asset_ip || hh.target || '') + ' ' + (hh.evidence || ''))
|
||
},
|
||
}, '复制'),
|
||
hh.evidence
|
||
? h('span', { className: 'rt-hit-txt', title: hh.evidence }, String(hh.evidence).replace(/\n+/g, ' '))
|
||
: h('span', { className: 'rt-hit-txt none' }, '未填账号密码/结果')))
|
||
rows.push(h('div', {
|
||
key: 'spd' + p.id, className: 'rt-score-row',
|
||
style: { cursor: 'default', gridTemplateColumns: '1fr' },
|
||
}, h('div', { className: 'rt-score-detail' },
|
||
p.description ? h('div', { className: 'rt-kv' }, h('b', null, '得分条件'), h('span', null, p.description)) : null,
|
||
h('div', { className: 'rt-kv' }, h('b', null, '状态'),
|
||
h('span', null, (p.enabled ? '启用' : '停用') + ' · ' + p.points + ' 分/次 · 命中 ' + p.hits.length +
|
||
' 次 = ' + p.earned + ' 分' + (p.self_created ? '(另有 ' + p.self_created + ' 次自建不计分)' : '') +
|
||
(p.capped ? '(另有 ' + p.capped + ' 次同服务重复命中不计分)' : ''))),
|
||
p.capped
|
||
? h('div', { className: 'rt-kv' }, h('b', null, '服务封顶'),
|
||
h('span', null, p.service_summary + ';账号权限与数据库权限按「同资产同端口」只算一次,拿到最高权限账号即该服务拿满'))
|
||
: null,
|
||
p.hits.length
|
||
? h('div', null,
|
||
h(ShotStrip, { eng: eng, shots: p.shots, total: p.shot_total,
|
||
title: '证据截图 · ' + (p.shot_total || (p.shots || []).length) + ' 张' }),
|
||
h('div', { className: 'rt-section', style: { padding: '6px 0 0' } },
|
||
'命中记录 · ' + p.hits.length + '(只记资产与账号密码,详细复现见报告)'),
|
||
h('div', { className: 'rt-hits' }, hitNodes))
|
||
: h('div', { className: 'rt-kv' }, h('b', null, '命中记录'),
|
||
h('span', { style: { color: 'var(--dsw-alias-label-secondary)' } },
|
||
'还没有 —— 拿下成果后用 redteam_score_hit 记分:写明目标资产 + 拿到的账号密码/权限')),
|
||
h('div', { className: 'rt-actions' },
|
||
h('button', { className: 'rt-btn', onClick: (e) => { e.stopPropagation(); startEdit(p) } }, '编辑')))))
|
||
}
|
||
}
|
||
|
||
return h('div', { className: 'rt-main' },
|
||
h('div', { className: 'rt-toolbar' },
|
||
h('span', { style: { fontWeight: 600 } }, '得分目标'),
|
||
/* 只显示已拿下的总分,不显示目标分数、不显示进度条 */
|
||
h('span', { style: { fontSize: 13, color: 'var(--dsw-alias-label-secondary)', marginLeft: 4 } }, '总分'),
|
||
h('span', { className: 'rt-total' }, String(summary.achievedPoints)),
|
||
h('span', { style: { fontSize: 13, color: 'var(--dsw-alias-label-secondary)' } }, '分'),
|
||
h('span', { className: 'rt-tag' }, summary.pointCount + ' 个得分点'),
|
||
h('span', { className: 'rt-tag' }, '命中 ' + summary.countedHits + ' 次'),
|
||
summary.selfCreatedHits
|
||
? h('span', {
|
||
className: 'rt-tag rt-tag-warn',
|
||
title: '自己注册/自己创建的账号不算得分权限,只作过程记录(不计分、不占上限、不进报告)',
|
||
}, '自建不计分 ' + summary.selfCreatedHits)
|
||
: null,
|
||
summary.serviceCappedHits
|
||
? h('span', {
|
||
className: 'rt-tag rt-tag-warn',
|
||
title: '账号权限与数据库权限按「同资产同端口」只算一次:该服务已拿满,这些重复命中不计分(只作留痕)',
|
||
}, '服务已拿满不计分 ' + summary.serviceCappedHits)
|
||
: null,
|
||
h('div', { className: 'rt-spacer' }),
|
||
h('button', { className: 'rt-btn', onClick: startNew }, '+ 新增得分点'),
|
||
h('button', { className: 'rt-btn', disabled: busy, onClick: load }, busy ? '刷新中…' : '刷新')),
|
||
msg ? h('div', { className: msg.err ? 'rt-err' : 'rt-foot' }, msg.err || msg.ok) : null,
|
||
err ? h('div', { className: 'rt-err' }, err) : null,
|
||
form ? h('div', { className: 'rt-pane', style: { flex: 'none', borderBottom: '1px solid var(--dsw-alias-border-l1)' } },
|
||
/* 内置得分点(builtin):分值 / 名称 / 分类由《得分规则》锁定,输入框置灰。
|
||
可改的只有「启用 / 停用」。这样界面上就不会再出现「改完提示已保存、刷新变回去」的困惑。 */
|
||
form.builtin
|
||
? h('div', { className: 'rt-hint', style: { marginBottom: 8 } },
|
||
h('b', null, '内置得分点(来自《突破入侵类得分规则(合并版)》)'),
|
||
h('div', { style: { marginTop: 3 } },
|
||
'分值、上限、计分口径与名称由规则锁定 —— 同一条规则的上限按组内所有得分点累计,'
|
||
+ '单独改分值会让一条命中吃掉整组上限。这里可以改「启用 / 停用」;'
|
||
+ '要自定义分值时请返回上一屏点「+ 新增得分点」。'),
|
||
form.src !== null && form.src !== undefined
|
||
? h('div', { style: { marginTop: 3, color: 'var(--dsw-alias-label-secondary)' } },
|
||
'规则原文序号 ' + form.src + (form.rule ? ' · 上限分组 rule=' + form.rule : '')
|
||
+ (form.cap > 0 ? ' · 上限 ' + form.cap + ' 分' : ' · 不设上限'))
|
||
: null)
|
||
: null,
|
||
h('div', { className: 'rt-score-form' },
|
||
h('input', {
|
||
className: 'rt-input', placeholder: '名称(必填)', value: form.name,
|
||
readOnly: form.builtin === true,
|
||
title: form.builtin ? '内置得分点的名称由规则锁定' : '',
|
||
onChange: (e) => setField('name', e.target.value),
|
||
}),
|
||
h('input', {
|
||
className: 'rt-input', placeholder: '分类,如 账号权限', value: form.category,
|
||
readOnly: form.builtin === true,
|
||
title: form.builtin ? '内置得分点的分类由规则锁定(决定它属于面板哪一组)' : '',
|
||
onChange: (e) => setField('category', e.target.value),
|
||
}),
|
||
h('input', {
|
||
className: 'rt-input', type: 'number', placeholder: '单次分值',
|
||
readOnly: form.builtin === true,
|
||
title: form.builtin
|
||
? '内置得分点的分值由《得分规则》锁定,不能在这里改'
|
||
: '这一类的单次分值;每命中一次就按这个分值累加(受该条规则上限约束)',
|
||
value: form.points, onChange: (e) => setField('points', Number(e.target.value)),
|
||
}),
|
||
h('select', { className: 'rt-input', value: form.enabled ? '1' : '0', onChange: (e) => setField('enabled', e.target.value === '1') },
|
||
h('option', { value: '1' }, '启用'),
|
||
h('option', { value: '0' }, '停用'))),
|
||
h('input', {
|
||
className: 'rt-input', style: { width: '100%', marginBottom: 6, boxSizing: 'border-box' },
|
||
placeholder: '得分条件说明', value: form.description,
|
||
readOnly: form.builtin === true,
|
||
title: form.builtin ? '内置得分点的条款正文由规则锁定' : '',
|
||
onChange: (e) => setField('description', e.target.value),
|
||
}),
|
||
h('div', { className: 'rt-actions' },
|
||
h('button', { className: 'rt-btn rt-btn-primary', disabled: busy, onClick: save }, '保存'),
|
||
form.id
|
||
? (form.builtin
|
||
? h('button', {
|
||
className: 'rt-btn', disabled: true,
|
||
title: '内置得分点不能删除:删掉会让面板缺一条规则、报告少一类成果(下次启动还会自动补回来)。要让它不参与计分请改用「停用」。',
|
||
}, '删除(内置项不可删)')
|
||
: h('button', { className: 'rt-btn', disabled: busy, onClick: remove }, '删除'))
|
||
: null,
|
||
h('button', { className: 'rt-btn', onClick: () => setForm(null) }, '取消'))) : null,
|
||
h('div', { className: 'rt-table' },
|
||
h('div', { className: 'rt-score-row head' },
|
||
h('span', null, ''), h('span', null, '分值'), h('span', null, '得分点(按分值从低到高)'),
|
||
h('span', null, '命中 / 上限'), h('span', { style: { textAlign: 'right' } }, '已得分')),
|
||
rows,
|
||
!items.length ? h('div', { className: 'rt-empty' }, '暂无得分点,点右上角「新增得分点」') : null))
|
||
}
|
||
|
||
/* ---------------------------------------------------------- 折叠底座 */
|
||
/**
|
||
* 折叠状态按「页签 + 靶标」持久化到 localStorage,切页签/刷新后保持不变。
|
||
* 只记录用户显式点过的键;没点过的用调用方给的默认值。
|
||
*/
|
||
const COLLAPSE_KEY = 'rt-collapse:'
|
||
const collapseLoad = (prefix) => {
|
||
try {
|
||
const raw = window.localStorage.getItem(COLLAPSE_KEY + prefix)
|
||
const parsed = raw ? JSON.parse(raw) : null
|
||
return parsed && typeof parsed === 'object' ? parsed : {}
|
||
} catch (e) { return {} }
|
||
}
|
||
const collapseSave = (prefix, value) => {
|
||
try { window.localStorage.setItem(COLLAPSE_KEY + prefix, JSON.stringify(value)) } catch (e) { /* 隐私模式等 */ }
|
||
}
|
||
|
||
/** 一个页签一个 hook:isOpen(key, defaultOpen) / toggle(key, defaultOpen) / setAll(keys, open) */
|
||
function useCollapse(prefix) {
|
||
const [state, setState] = React.useState(() => collapseLoad(prefix))
|
||
React.useEffect(() => { setState(collapseLoad(prefix)) }, [prefix])
|
||
const write = (next) => { collapseSave(prefix, next); setState(next) }
|
||
const isOpen = (key, defaultOpen) => {
|
||
const v = state[key]
|
||
return v === undefined ? defaultOpen !== false : v === true
|
||
}
|
||
const toggle = (key, defaultOpen) => (e) => {
|
||
if (e && e.stopPropagation) e.stopPropagation()
|
||
write(Object.assign({}, state, { [key]: !isOpen(key, defaultOpen) }))
|
||
}
|
||
const setAll = (keys, open) => {
|
||
const next = Object.assign({}, state)
|
||
for (const k of keys) next[k] = open
|
||
write(next)
|
||
}
|
||
return { isOpen: isOpen, toggle: toggle, setAll: setAll }
|
||
}
|
||
|
||
/**
|
||
* 统一折叠头(L1):▾ 固定在最左、标题加粗、计数紧跟、右侧放时间或操作,
|
||
* 内容缩进 12px 并带一条竖引导线。always=true 表示"常显"(不可折叠)。
|
||
*/
|
||
function Section(props) {
|
||
const always = props.always === true
|
||
const open = always || props.open === true
|
||
const head = h('div', {
|
||
className: 'rt-sec t-' + (props.tone || 'target') + (always ? ' flat' : ''),
|
||
role: always ? undefined : 'button',
|
||
tabIndex: always ? undefined : 0,
|
||
'aria-expanded': always ? undefined : (open ? 'true' : 'false'),
|
||
onClick: always ? undefined : props.onToggle,
|
||
onKeyDown: always ? undefined : (e) => {
|
||
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); if (props.onToggle) props.onToggle(e) }
|
||
},
|
||
},
|
||
h('span', { className: 'rt-sec-caret' }, always ? '▍' : (open ? '▾' : '▸')),
|
||
props.title ? h('span', { className: 'rt-sec-title' }, props.title) : null,
|
||
props.count !== undefined && props.count !== null ? h('span', { className: 'rt-sec-count' }, String(props.count)) : null,
|
||
props.sub ? h('span', { className: 'rt-sec-sub' }, props.sub) : null,
|
||
props.right ? h('span', { className: 'rt-sec-right' }, props.right) : null)
|
||
return h('div', { className: 'rt-sec-wrap' }, head,
|
||
open && props.children ? h('div', { className: 'rt-sec-body' }, props.children) : null)
|
||
}
|
||
|
||
/** 长文本折叠(L3):固定预览高度 + 渐隐 + 展开/复制。 */
|
||
function Clip(props) {
|
||
const [open, setOpen] = React.useState(false)
|
||
const text = String(props.text === undefined || props.text === null ? '' : props.text)
|
||
if (text.trim() === '') return null
|
||
return h('div', { className: 'rt-clip' + (open ? ' open' : '') },
|
||
h('div', { className: 'rt-clip-head' },
|
||
props.label ? h('span', { className: 'rt-clip-label' }, props.label) : null,
|
||
h('div', { className: 'rt-spacer' }),
|
||
h('button', {
|
||
className: 'rt-btn', style: { padding: '0 6px', fontSize: 11 },
|
||
onClick: (e) => { e.stopPropagation(); copyText(text) },
|
||
}, '复制'),
|
||
h('button', {
|
||
className: 'rt-btn', style: { padding: '0 6px', fontSize: 11 },
|
||
onClick: (e) => { e.stopPropagation(); setOpen((v) => !v) },
|
||
}, open ? '收起' : '展开')),
|
||
h('div', { className: 'rt-clip-body' }, text))
|
||
}
|
||
|
||
/* ---------------------------------------------------------- 当前测试(实时) */
|
||
const TEST_STATUS_LABEL = {
|
||
untested: '未测试', testing: '测试中', tested: '已测试',
|
||
blocked: '被封禁', abandoned: '已放弃', no_surface: '无攻击面',
|
||
}
|
||
|
||
/**
|
||
* 当前测试页:agent 正在打哪台、打到哪一步、还有什么在排队。
|
||
* 5 秒轮询自动刷新;三个区块可折叠,其中「正在测」始终完整展开。
|
||
*/
|
||
function TestingTab(props) {
|
||
const eng = props.engagement
|
||
const refreshKey = props.refreshKey || 0
|
||
const [data, setData] = React.useState(null)
|
||
const [err, setErr] = React.useState(null)
|
||
const [at, setAt] = React.useState(null)
|
||
const [auto, setAuto] = React.useState(true)
|
||
/* 折叠状态按「页签 + 靶标」记忆;正在测永远常显,不参与折叠 */
|
||
const collapse = useCollapse('testing:' + eng)
|
||
|
||
/* 5 秒轮询要有在途守卫:网络慢或后端卡住时,请求会越堆越多、
|
||
而且旧响应回来会覆盖新状态(界面上表现为数字来回跳)。 */
|
||
const inflight = React.useRef(false)
|
||
const seq = React.useRef(0)
|
||
const load = () => {
|
||
if (!eng || inflight.current) return
|
||
inflight.current = true
|
||
const my = ++seq.current
|
||
api({ op: 'activeTests', engagement: eng, limit: 20 }).then((r) => {
|
||
if (my !== seq.current) return /* 切了靶标:这条已经过期,丢掉 */
|
||
if (!r || r.ok === false) { setErr((r && r.error) || '读取失败'); return }
|
||
setErr(null); setData(r); setAt(new Date())
|
||
}, (e) => { if (my === seq.current) setErr(String((e && e.message) || e)) })
|
||
.finally(() => { if (my === seq.current) inflight.current = false })
|
||
}
|
||
React.useEffect(() => { seq.current += 1; load() }, [eng, refreshKey])
|
||
React.useEffect(() => {
|
||
if (!eng || !auto) return undefined
|
||
const timer = setInterval(load, 5000)
|
||
return () => clearInterval(timer)
|
||
}, [eng, auto, refreshKey])
|
||
|
||
const testing = (data && data.testing) || []
|
||
const recent = (data && data.recent) || []
|
||
const queue = (data && data.queue) || []
|
||
const stats = (data && data.stats) || {}
|
||
|
||
/* 一台资产一张卡:一眼看清"打的是谁、打到哪、拿到什么" */
|
||
const card = (a, past) => {
|
||
const chips = []
|
||
chips.push(h('span', { key: 'sc', className: 'rt-scope rt-scope-' + (a.scope === 'internal' ? 'internal' : 'external') },
|
||
a.scope === 'internal' ? '内网' : '外网'))
|
||
if (a.segment_cidr) chips.push(h('span', { key: 'seg', className: 'rt-tag' }, a.segment_cidr))
|
||
if (a.open_ports) chips.push(h('span', { key: 'p', className: 'rt-tag rt-tag-active' }, '开放 ' + a.open_ports + ' 端口'))
|
||
if (a.vulns) chips.push(h('span', { key: 'v', className: 'rt-tag rt-tag-live' }, '已确认漏洞 ' + a.vulns))
|
||
if (a.webshells) chips.push(h('span', { key: 'w', className: 'rt-tag rt-tag-passive' }, 'WebShell ' + a.webshells))
|
||
if (a.tunnels) chips.push(h('span', { key: 't', className: 'rt-tag rt-tag-passive' }, '隧道 ' + a.tunnels))
|
||
if (a.priority) chips.push(h('span', { key: 'pr', className: 'rt-tag' }, '易打 ' + a.priority))
|
||
if (a.blocked_count) chips.push(h('span', { key: 'b', className: 'rt-tag', style: { color: '#ef4444', borderColor: '#ef444455' } }, '被封 ' + a.blocked_count + ' 次'))
|
||
const noteLines = a.test_notes
|
||
? String(a.test_notes).split('\n').slice(-3).map((l) => (l.length > 240 ? l.slice(0, 240) + ' …' : l))
|
||
: []
|
||
return h('div', { key: 'at' + a.id, className: 'rt-atest' + (past ? ' past' : '') },
|
||
h('div', { className: 'rt-atest-head' },
|
||
past ? null : h('span', { className: 'rt-live-dot' }),
|
||
h('span', { className: 'rt-atest-ip' }, a.ip),
|
||
h('span', { className: 'rt-tag ' + (a.test_status === 'testing' ? 'rt-tag-live' : '') }, TEST_STATUS_LABEL[a.test_status] || a.test_status),
|
||
h('div', { className: 'rt-spacer' }),
|
||
h('span', { style: { fontSize: 11, color: 'var(--dsw-alias-label-secondary)' } },
|
||
fmt(a.test_updated_at) + (a.test_updated_by ? ' · ' + a.test_updated_by : ''))),
|
||
chips.length ? h('div', null, chips) : null,
|
||
a.test_surface ? h('div', { className: 'rt-atest-meta' }, '测试面:' + a.test_surface) : null,
|
||
a.potential ? h('div', { className: 'rt-atest-meta' }, '预期得分:' + a.potential) : null,
|
||
noteLines.length ? h(Clip, { label: '测试记录', text: noteLines.join('\n') }) : null)
|
||
}
|
||
|
||
|
||
const concl = (label, value, tone) => h('span', { key: label, className: 'rt-concl-i', style: { cursor: 'default' } },
|
||
h('b', null, String(value || 0)), h('span', null, label))
|
||
const conclusion = h('div', { className: 'rt-concl' },
|
||
concl('测试中', stats.testing, 'live'),
|
||
concl('待测', stats.untested, ''),
|
||
concl('已测', stats.tested, ''),
|
||
concl('放弃', (stats.abandoned || 0) + (stats.blocked || 0), ''),
|
||
concl('无攻击面', stats.no_surface, ''),
|
||
h('div', { className: 'rt-spacer' }),
|
||
at ? h('span', { style: { fontSize: 11, color: 'var(--dsw-alias-label-secondary)' } },
|
||
'更新于 ' + at.toLocaleTimeString('zh-CN', { hour12: false })) : null,
|
||
h('button', {
|
||
className: 'rt-btn', style: { padding: '0 7px', fontSize: 11 }, title: '展开最近动过与待测队列',
|
||
onClick: () => collapse.setAll(['testing:' + eng, 'recent:' + eng, 'queue:' + eng], true),
|
||
}, '全部展开'),
|
||
h('button', {
|
||
className: 'rt-btn', style: { padding: '0 7px', fontSize: 11 }, title: '只留正在测',
|
||
onClick: () => collapse.setAll(['testing:' + eng, 'recent:' + eng, 'queue:' + eng], false),
|
||
}, '全部收起'),
|
||
h('button', {
|
||
className: 'rt-btn' + (auto ? ' rt-btn-primary' : ''), style: { padding: '0 7px', fontSize: 11 },
|
||
title: '每 5 秒自动刷新', onClick: () => setAuto((x) => !x),
|
||
}, auto ? '实时 · 5s' : '已暂停'),
|
||
h('button', { className: 'rt-btn', style: { padding: '0 7px', fontSize: 11 }, onClick: load }, '刷新'))
|
||
|
||
const body = []
|
||
/* 正在测:常显,完整展开(不可折叠) */
|
||
body.push(h(Section, {
|
||
key: 'testing', tone: 'test', title: '正在测',
|
||
count: testing.length + ' 台',
|
||
sub: testing.length ? 'agent 正在打这些资产' : 'agent 开始测某台资产后会实时出现在这里',
|
||
open: collapse.isOpen('testing:' + eng, true),
|
||
onToggle: collapse.toggle('testing:' + eng, true),
|
||
}, testing.length
|
||
? testing.map((a) => card(a, false))
|
||
: h('div', { className: 'rt-atest-meta', style: { paddingBottom: 4 } }, '当前没有资产处于「测试中」')))
|
||
|
||
/* 最近动过(默认折叠,可记忆) */
|
||
body.push(h(Section, {
|
||
key: 'recent', tone: 'past', title: '最近动过', count: recent.length + ' 台',
|
||
sub: '已测过的资产',
|
||
open: collapse.isOpen('recent:' + eng, false),
|
||
onToggle: collapse.toggle('recent:' + eng, false),
|
||
}, recent.length ? recent.map((a) => card(a, true)) : h('div', { className: 'rt-atest-meta' }, '暂无')))
|
||
|
||
/* 待测队列(默认折叠,可记忆) */
|
||
body.push(h(Section, {
|
||
key: 'queue', tone: 'queue', title: '待测队列',
|
||
count: (data ? (data.untested || 0) : 0) + ' 台',
|
||
sub: '按易打性与端口数排出先打哪几台',
|
||
open: collapse.isOpen('queue:' + eng, false),
|
||
onToggle: collapse.toggle('queue:' + eng, false),
|
||
}, queue.length
|
||
? queue.map((a) => card(a, true)).concat([
|
||
h('div', { key: 'queueHint', className: 'rt-atest-meta', style: { paddingTop: 4 } },
|
||
'完整清单(含筛选与排序)见「资产测绘」页')])
|
||
: h('div', { className: 'rt-atest-meta' }, '没有待测资产')))
|
||
|
||
return h('div', { className: 'rt-main' },
|
||
conclusion,
|
||
err ? h('div', { className: 'rt-err' }, err) : null,
|
||
data === null && !err ? h('div', { className: 'rt-empty' }, '加载中…') : null,
|
||
h('div', { className: 'rt-body', style: { overflow: 'auto' } },
|
||
h('div', { style: { padding: '6px 12px 14px' } }, body)))
|
||
}
|
||
|
||
/* ---------------------------------------------------------- 会话与入口(WebShell / 隧道) */
|
||
/**
|
||
* 打内网最容易出的问题:拿到 WebShell 或隧道之后忘了登记,过一会儿就"忘了还有入口可用"。
|
||
* 这个页签把事实库里的 WebShell 与隧道集中展示,带连通状态,并可一键让 host 侧实测。
|
||
*/
|
||
function SessionTab(props) {
|
||
const eng = props.engagement
|
||
const refreshKey = props.refreshKey || 0
|
||
const [data, setData] = React.useState(null)
|
||
const [err, setErr] = React.useState(null)
|
||
const [busy, setBusy] = React.useState(false)
|
||
const [msg, setMsg] = React.useState(null)
|
||
const [copied, setCopied] = React.useState(null)
|
||
|
||
const load = () => {
|
||
if (!eng) return
|
||
api({ op: 'sessions', engagement: eng }).then((r) => {
|
||
if (!r || r.ok === false) { setErr((r && r.error) || '读取失败'); return }
|
||
setErr(null)
|
||
setData(r)
|
||
}, (e) => setErr(String((e && e.message) || e)))
|
||
}
|
||
React.useEffect(load, [eng, refreshKey])
|
||
|
||
const probe = () => {
|
||
setBusy(true); setMsg(null)
|
||
api({ op: 'probeSessions', engagement: eng, timeoutMs: 6000 }).then((r) => {
|
||
setBusy(false)
|
||
if (!r || r.ok === false) { setMsg({ err: (r && r.error) || '检测失败' }); load(); return }
|
||
const on = (r.webshells || []).filter((x) => x.status === 'online').length
|
||
const act = (r.tunnels || []).filter((x) => x.status === 'active').length
|
||
setMsg({ ok: '检测完成:WebShell 在线 ' + on + '/' + (r.webshells || []).length + ',隧道可用 ' + act + '/' + (r.tunnels || []).length })
|
||
load()
|
||
}, (e) => { setBusy(false); setMsg({ err: String((e && e.message) || e) }) })
|
||
}
|
||
|
||
/* 只有真的写进剪贴板才把按钮点亮成「已复制」;失败时如实报错 */
|
||
const copy = (key, text) => copyWithFeedback(text, text, (okFlag, message) => {
|
||
if (!okFlag) { setMsg({ err: message }); return }
|
||
setCopied(key)
|
||
setTimeout(() => setCopied((c) => (c === key ? null : c)), 1500)
|
||
})
|
||
|
||
const markTunnel = (t, status) => {
|
||
setMsg(null)
|
||
api({ op: 'updateTunnel', engagement: eng, id: t.id, patch: { status: status, check_note: '界面手动标记' } })
|
||
.then(() => load(), (e) => setMsg({ err: String((e && e.message) || e) }))
|
||
}
|
||
const markShell = (w, status) => {
|
||
setMsg(null)
|
||
api({ op: 'updateWebshell', engagement: eng, id: w.id, patch: { status: status, check_note: '界面手动标记' } })
|
||
.then(() => load(), (e) => setMsg({ err: String((e && e.message) || e) }))
|
||
}
|
||
|
||
const sessCollapse = useCollapse('sessions:' + eng)
|
||
const totals = (data && data.totals) || {}
|
||
const shells = (data && data.webshells) || []
|
||
const tunnels = (data && data.tunnels) || []
|
||
const statusDot = (s) => h('span', { className: s === 'online' || s === 'active' ? 'rt-dot-on' : (s === 'unknown' || !s ? 'rt-dot-unk' : 'rt-dot-off') })
|
||
/* 卡片里统一用「标签 + 值」两列,长文本自动换行 —— 比一行点分隔好扫读 */
|
||
const fact = (label, value) => h('div', { className: 'rt-sess-fact' }, h('b', null, label), h('span', null, value))
|
||
/* 交付要求:马必须是冰蝎/哥斯拉加密马(用户才连得上),内网必须走 suo5 隧道 */
|
||
const isEncryptedShell = (t) => /behinder|godzilla|冰蝎|哥斯拉/i.test(String(t || ''))
|
||
const isSuo5 = (t) => /suo5/i.test(String(t || ''))
|
||
/* 提示条:只留一句结论 + 可展开的做法(原来是把三段长解释平铺,正文全被淹掉) */
|
||
const hint = (key, title, detail) => h('div', { key, className: 'rt-hint' },
|
||
h('b', null, title),
|
||
h('details', { className: 'rt-sess-fold', style: { marginTop: 3 } },
|
||
h('summary', null, '怎么做(点击展开)'),
|
||
h('div', { style: { marginTop: 4 } }, detail)))
|
||
const hints = []
|
||
const badShells = shells.filter((w) => !isEncryptedShell(w.shell_type))
|
||
const legitTunnels = tunnels.filter((t) => t.legit === true)
|
||
if (badShells.length > 0) {
|
||
hints.push(hint('h1', '有 ' + badShells.length + ' 个入口不是冰蝎马/哥斯拉马(用户连不上,不算可交付入口)',
|
||
'用技能 webshell-toolkit 重新上传冰蝎马(behinder)或哥斯拉马(godzilla),'
|
||
+ '并把 shell_type + pass_key 写进 redteam_webshell_add。只作临时中转的可在备注里写明。'))
|
||
}
|
||
if (tunnels.length > 0 && legitTunnels.length === 0) {
|
||
hints.push(hint('h3', '现有 ' + tunnels.length + ' 条通道都不算"跨越靶标边界"(不算边界/内网突破)',
|
||
'自己的 VPS / 自建服务器上开的 socks5、frp、代理不算隧道。必须是目标侧发起的通道:'
|
||
+ '目标反弹 shell 到我方、目标上跑 frp/Stowaway 客户端、或经目标 WebShell 建的 suo5/HTTP 隧道;登记时用 entry_kind 说明。'))
|
||
}
|
||
const activeSuo5 = tunnels.filter((t) => isSuo5(t.kind) && t.status === 'active')
|
||
if (shells.length > 0 && activeSuo5.length === 0) {
|
||
hints.push(hint('h2', '还没有可用的 suo5 隧道(打进内网的标准通道)',
|
||
'用技能 suo5-tunnel 通过上面的 WebShell 建 socks5 隧道,再 redteam_tunnel_add'
|
||
+ '(kind=suo5、listen=127.0.0.1:1080、entry=WebShell URL、reach=可达网段)登记,'
|
||
+ '最后点「检测连通性」确认 status=active。'))
|
||
}
|
||
|
||
const shellCards = shells.map((w) => h('div', { key: 'w' + w.id, className: 'rt-sess' },
|
||
h('div', { className: 'rt-sess-head' },
|
||
statusDot(w.status),
|
||
h('span', { className: 'rt-sess-title' }, w.url),
|
||
h('span', { className: 'rt-tag rt-tag-active' }, w.shell_type || 'webshell'),
|
||
isEncryptedShell(w.shell_type) ? null : h('span', { className: 'rt-tag rt-tag-warn' }, '用户连不上'),
|
||
w.privilege ? h('span', { className: 'rt-tag' }, w.privilege) : null,
|
||
h('div', { className: 'rt-spacer' }),
|
||
copied === 'cmd' + w.id
|
||
? h('span', { className: 'rt-tag', style: { color: '#10b981' } }, '已复制')
|
||
: h('button', {
|
||
className: 'rt-btn', style: { padding: '0 6px', fontSize: 11 },
|
||
onClick: () => copy('cmd' + w.id, 'curl -s "' + w.url + '"'),
|
||
}, '复制 URL'),
|
||
h('button', {
|
||
className: 'rt-btn', style: { padding: '0 6px', fontSize: 11 },
|
||
onClick: () => markShell(w, 'offline'),
|
||
}, '标记失效')),
|
||
h('div', { className: 'rt-sess-facts' },
|
||
fact('类型', (isEncryptedShell(w.shell_type) ? '加密马(冰蝎/哥斯拉,用户可直连)' : '非加密马 —— 用户连不上,仅可作临时中转')),
|
||
w.pass_key ? fact('连接口令', h('span', { className: 'rt-mono' }, w.pass_key)) : null,
|
||
w.asset_ip ? fact('所在资产', w.asset_ip) : null,
|
||
w.privilege ? fact('权限', w.privilege) : null,
|
||
w.secret_ref ? fact('凭据引用', h('span', { className: 'rt-mono' }, w.secret_ref)) : null,
|
||
fact('最后检测', (w.last_check ? fmt(w.last_check) : '未检测')
|
||
+ (w.latency_ms !== null && w.latency_ms !== undefined ? '(' + w.latency_ms + 'ms)' : '')),
|
||
w.check_note ? fact('检测说明', w.check_note) : null,
|
||
w.note ? fact('备注', w.note) : null)))
|
||
|
||
const tunnelCards = tunnels.map((t) => {
|
||
const proxy = t.listen ? 'socks5://' + t.listen : ''
|
||
const gogoCmd = t.listen ? './gogo -i <内网CIDR> -m ss --ping -p top2,win,db --proxy ' + proxy : ''
|
||
const fscanCmd = t.listen ? './fscan -h <内网CIDR> -np -nobr -nopoc -socks5 ' + t.listen + ' -o intranet.txt' : ''
|
||
return h('div', { key: 't' + t.id, className: 'rt-sess' },
|
||
h('div', { className: 'rt-sess-head' },
|
||
statusDot(t.status),
|
||
h('span', { className: 'rt-sess-title' }, t.listen || '(未填监听地址)'),
|
||
h('span', { className: 'rt-tag rt-tag-passive' }, t.kind || 'tunnel'),
|
||
isSuo5(t.kind) ? null : h('span', { className: 'rt-tag rt-tag-warn' }, '非 suo5 标准通道'),
|
||
/* 只有跨越靶标边界的通道才算突破凭证(自己 VPS/自建服务器上开的不算) */
|
||
t.legit === true
|
||
? h('span', { className: 'rt-tag rt-tag-live', title: t.entry_kind_label || '' }, '目标侧通道')
|
||
: (t.legit === false
|
||
? h('span', { className: 'rt-tag rt-tag-warn', title: '只在自己 VPS/自建服务器上开的通道,没有碰到目标 —— 不算边界突破/内网突破' }, '不算突破')
|
||
: h('span', { className: 'rt-tag', title: '未声明 entry_kind:请说明目标侧的那一端是什么(target-outbound / target-http / target-agent)' }, '待确认')),
|
||
t.reach ? h('span', { className: 'rt-tag' }, '可达 ' + t.reach) : null,
|
||
h('div', { className: 'rt-spacer' }),
|
||
h('button', {
|
||
className: 'rt-btn', style: { padding: '0 6px', fontSize: 11 },
|
||
onClick: () => copy('sock' + t.id, t.listen || ''),
|
||
}, copied === 'sock' + t.id ? '已复制' : '复制地址'),
|
||
t.status === 'active'
|
||
? h('button', { className: 'rt-btn', style: { padding: '0 6px', fontSize: 11 }, onClick: () => markTunnel(t, 'down') }, '标记失效')
|
||
: h('button', { className: 'rt-btn', style: { padding: '0 6px', fontSize: 11 }, onClick: () => markTunnel(t, 'active') }, '标记可用')),
|
||
h('div', { className: 'rt-sess-facts' },
|
||
fact('目标侧入口', t.entry || '未登记'),
|
||
fact('跨越边界', t.legit === true
|
||
? h('span', { className: 'rt-tag rt-tag-live' }, t.entry_kind_label || t.entry_kind || '已确认目标侧')
|
||
: (t.legit === false
|
||
? h('span', { className: 'rt-tag rt-tag-warn' }, '不算突破(只在自己 VPS 上开代理)')
|
||
: h('span', { className: 'rt-tag' }, '未声明 entry_kind,待确认'))),
|
||
t.reach ? fact('可达网段', t.reach) : null,
|
||
t.asset_ip ? fact('所在资产', t.asset_ip) : null,
|
||
fact('最后检测', (t.last_check ? fmt(t.last_check) : '未检测')
|
||
+ (t.latency_ms !== null && t.latency_ms !== undefined ? '(' + t.latency_ms + 'ms)' : '')),
|
||
t.check_note ? fact('检测说明', t.check_note) : null,
|
||
t.note ? fact('备注', t.note) : null),
|
||
/* 命令默认折叠:卡片首要信息是"这条通道能不能用、通向哪",命令按需展开 */
|
||
t.command ? h('details', { className: 'rt-sess-fold' },
|
||
h('summary', null, '建立命令(点击展开 / 复制)'),
|
||
h('div', Object.assign({ className: 'rt-code', title: '点击复制' }, clickable(() => copy('c' + t.id, t.command), { label: '复制命令' })),
|
||
copied === 'c' + t.id ? '已复制' : t.command)) : null,
|
||
t.status === 'active' && t.listen
|
||
? h('details', { className: 'rt-sess-fold' },
|
||
h('summary', null, '走这条隧道扫描(gogo / fscan 命令)'),
|
||
h('div', { className: 'rt-sess-cmd' },
|
||
h('div', Object.assign({ className: 'rt-code', title: '点击复制' }, clickable(() => copy('g' + t.id, gogoCmd), { label: '复制 gogo 命令' })),
|
||
copied === 'g' + t.id ? '已复制' : gogoCmd),
|
||
h('div', Object.assign({ className: 'rt-code', style: { display: 'block', marginTop: 3 }, title: '点击复制' }, clickable(() => copy('f' + t.id, fscanCmd), { label: '复制 fscan 命令' })),
|
||
copied === 'f' + t.id ? '已复制' : fscanCmd)))
|
||
: null)
|
||
})
|
||
|
||
return h('div', { className: 'rt-main' },
|
||
h('div', { className: 'rt-toolbar' },
|
||
h('span', { style: { fontWeight: 600 } }, '会话与入口'),
|
||
h('span', { className: 'rt-tag' }, 'WebShell ' + (totals.webshellsOnline || 0) + '/' + (totals.webshells || 0) + ' 在线'),
|
||
h('span', { className: 'rt-tag' }, '隧道 ' + (totals.tunnelsActive || 0) + '/' + (totals.tunnels || 0) + ' 可用'),
|
||
h('span', { className: 'rt-tag' }, '凭据 ' + (totals.credentials || 0)),
|
||
h('span', { className: 'rt-tag' }, '访问会话 ' + (totals.access || 0)),
|
||
h('div', { className: 'rt-spacer' }),
|
||
h('button', { className: 'rt-btn rt-btn-primary', disabled: busy, onClick: probe }, busy ? '检测中…' : '检测连通性'),
|
||
h('button', { className: 'rt-btn', onClick: load }, '刷新')),
|
||
msg ? h('div', { className: msg.err ? 'rt-err' : 'rt-foot' }, msg.err || msg.ok) : null,
|
||
err ? h('div', { className: 'rt-err' }, err) : null,
|
||
h('div', { className: 'rt-body', style: { overflow: 'auto' } },
|
||
!shells.length && !tunnels.length
|
||
? h('div', { className: 'rt-empty' },
|
||
h('div', null, '还没有登记任何 WebShell 或隧道。'),
|
||
h('div', { style: { marginTop: 6, fontSize: 12 } },
|
||
'拿到 WebShell 用 redteam_webshell_add;建好隧道用 redteam_tunnel_add(suo5 / socks5 / ssh -R);之后智能体用 redteam_sessions 就能看到。'))
|
||
: null,
|
||
hints.length ? h('div', { style: { padding: '8px 10px 0' } }, hints) : null,
|
||
h(Section, {
|
||
key: 'webshells', tone: 'queue', title: 'WebShell',
|
||
count: (totals.webshellsOnline || 0) + '/' + (totals.webshells || 0) + ' 在线',
|
||
sub: '已上线的可控入口',
|
||
open: sessCollapse.isOpen('shells', true), onToggle: sessCollapse.toggle('shells', true),
|
||
}, shells.length
|
||
? h('div', { className: 'rt-sess-grid', style: { padding: 0 } }, shellCards)
|
||
: h('div', { className: 'rt-atest-meta' }, '暂无(拿到 WebShell 后用 redteam_webshell_add 登记)')),
|
||
h(Section, {
|
||
key: 'tunnels', tone: 'test', title: '内网隧道',
|
||
count: (totals.tunnelsActive || 0) + '/' + (totals.tunnels || 0) + ' 可用',
|
||
sub: '可直接给扫描器当代理用',
|
||
open: sessCollapse.isOpen('tunnels', true), onToggle: sessCollapse.toggle('tunnels', true),
|
||
}, tunnels.length
|
||
? h('div', { className: 'rt-sess-grid', style: { padding: 0 } }, tunnelCards)
|
||
: h('div', { className: 'rt-atest-meta' }, '暂无(建好隧道后用 redteam_tunnel_add 登记)'))))
|
||
|
||
}
|
||
|
||
/* ---------------------------------------------------------- 错误边界 */
|
||
/**
|
||
* 单个页签渲染出错时只降级该页签,不拖垮整个面板:面板与侧栏按钮保持可用,
|
||
* 用户可一键回到资产测绘。(此前面板整块消失、按钮点不开就是缺了这层保护。)
|
||
*/
|
||
class RtBoundary extends React.Component {
|
||
constructor(props) {
|
||
super(props)
|
||
this.state = { error: null }
|
||
}
|
||
static getDerivedStateFromError(error) {
|
||
return { error: error }
|
||
}
|
||
componentDidCatch(error) {
|
||
try { console.error('[redteam-ui] 页面渲染出错:', error) } catch (e) { /* ignore */ }
|
||
}
|
||
render() {
|
||
if (this.state.error) {
|
||
const msg = this.state.error && this.state.error.message ? this.state.error.message : String(this.state.error)
|
||
return h('div', { className: 'rt-pane' },
|
||
h('div', { className: 'rt-err' }, '该页面渲染出错:' + msg),
|
||
h('button', {
|
||
className: 'rt-btn',
|
||
onClick: () => { this.setState({ error: null }); setUI({ tab: 'assets' }) },
|
||
}, '回到资产测绘'))
|
||
}
|
||
return this.props.children
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 智能体页签:并发名额(最多 3 个)+ 六个角色与它们的提示词。
|
||
* 主会话派活前先看这里的名额,用户也能一眼看到"现在还能拉起几个智能体"。
|
||
*/
|
||
function AgentsTab(props) {
|
||
const eng = props.engagement
|
||
const refreshKey = props.refreshKey || 0
|
||
const [roles, setRoles] = React.useState(null)
|
||
const [err, setErr] = React.useState(null)
|
||
const [concurrency, setConcurrency] = React.useState(null)
|
||
const [busy, setBusy] = React.useState(false)
|
||
|
||
const load = () => {
|
||
setBusy(true)
|
||
api({ op: eng ? 'prompts' : 'bootstrap', engagement: eng || undefined }).then((r) => {
|
||
setBusy(false)
|
||
if (!r || r.ok === false) { setErr((r && r.error) || '读取失败'); return }
|
||
setErr(null)
|
||
if (r.roles) setRoles(r.roles)
|
||
}, (e) => { setBusy(false); setErr(String((e && e.message) || e)) })
|
||
/* 并发名额按"当前跑着的子智能体"算;host 侧读 subagents 注册表 */
|
||
api({ op: 'agentsStatus' }).then((r) => {
|
||
if (r && r.ok !== false) setConcurrency(r)
|
||
}, () => {})
|
||
}
|
||
React.useEffect(load, [eng, refreshKey])
|
||
|
||
const used = concurrency ? concurrency.used : null
|
||
const max = concurrency ? concurrency.max : 3
|
||
return h('div', { className: 'rt-main' },
|
||
h('div', { className: 'rt-toolbar' },
|
||
h('span', { style: { fontWeight: 600 } }, '作战智能体'),
|
||
h('span', { className: 'rt-tag rt-tag-passive' }, '并发上限 ' + max),
|
||
used === null
|
||
? h('span', { className: 'rt-tag' }, '占用未知')
|
||
: h('span', { className: 'rt-tag' + (used >= max ? ' rt-tag-warn' : ' rt-tag-live') }, '在跑 ' + used + ' / 剩余 ' + Math.max(max - used, 0)),
|
||
h('div', { className: 'rt-spacer' }),
|
||
h('button', { className: 'rt-btn', disabled: busy, onClick: load }, busy ? '读取中…' : '刷新')),
|
||
h('div', { className: 'rt-pane' },
|
||
err ? h('div', { className: 'rt-err' }, err) : null,
|
||
h('div', { className: 'rt-card' },
|
||
h('h4', null, '并发规则(硬约束)'),
|
||
h('div', { style: { fontSize: 12, lineHeight: 1.7 } },
|
||
'· 同一靶标**同时最多 ' + max + ' 个执行智能体**;主会话派活前用 `redteam_agent_slot` 占位,满了直接拒绝。',
|
||
h('br'),
|
||
'· **默认一个一个派、按顺序推进**;只有确实互不依赖的活才并行。',
|
||
h('br'),
|
||
'· 每个会话有**自己的靶标绑定**:多会话并行不会把报告写串(子智能体继承父会话的靶标)。')),
|
||
h('div', { className: 'rt-card' },
|
||
h('h4', null, '六个角色'),
|
||
h('div', { style: { fontSize: 12, lineHeight: 1.8 } },
|
||
'① 信息收集 `recon` —— 只收集资产,把单位资产收集完整(含边缘与未备案资产)', h('br'),
|
||
'② 资产梳理 `assess` —— 一条一条过,评易打性并全部落库', h('br'),
|
||
'③ 漏洞发现 `vuln-scan` —— 先查库去重 → Nday/1day 优先 → 接口未授权探测', h('br'),
|
||
'④ 漏洞利用 `exploit` —— 先拿服务器权限(冰蝎/哥斯拉马)+ 建 suo5 隧道,再打其它得分项', h('br'),
|
||
'⑤ 内网渗透 `internal` —— 走隧道,依次拉起 ①②③④ 做内网', h('br'),
|
||
'⑥ 主会话 `plan` —— 只做计划、派活、汇总、汇报,不动手')),
|
||
h(ConcurrencyCard, { concurrency, onSaved: load }),
|
||
h('div', { className: 'rt-card' },
|
||
h('h4', null, '角色提示词(按靶标存,可在这里编辑)'),
|
||
h('div', { style: { fontSize: 11.5, color: 'var(--dsw-alias-label-secondary)', marginBottom: 6 } },
|
||
'「智能体提示词」已并入本页;下面是完整编辑器(改完点保存,只影响当前靶标)。'),
|
||
h(PromptsTab, { engagement: eng, refreshKey: refreshKey }))))
|
||
}
|
||
|
||
function ConcurrencyCard(props) {
|
||
const info = props.concurrency || {}
|
||
const [draft, setDraft] = React.useState('')
|
||
const [busy, setBusy] = React.useState(false)
|
||
const [msg, setMsg] = React.useState(null)
|
||
React.useEffect(() => { if (info.max) setDraft(String(info.max)) }, [info.max])
|
||
|
||
const limit = info.limit || 10
|
||
const used = typeof info.used === 'number' ? info.used : null
|
||
const source = info.source === 'settings' ? '面板中设置'
|
||
: info.source === 'env' ? '环境变量 REDTEAM_MAX_AGENTS' : '默认值'
|
||
const n = Number(draft)
|
||
const invalid = !Number.isFinite(n) || Math.floor(n) !== n || n < 1 || n > limit
|
||
const save = () => {
|
||
if (invalid) return
|
||
setBusy(true); setMsg(null)
|
||
api({ op: 'setAgentsMax', max: n }).then((r) => {
|
||
setBusy(false)
|
||
if (!r || r.ok === false) { setMsg({ err: (r && r.error) || '保存失败' }); return }
|
||
setMsg({ ok: '已保存:并发上限 ' + r.max + '(立即生效,不用重启)' })
|
||
if (props.onSaved) props.onSaved()
|
||
}, (e) => { setBusy(false); setMsg({ err: String((e && e.message) || e) }) })
|
||
}
|
||
return h('div', { className: 'rt-card' },
|
||
h('h4', null, '并发数量'),
|
||
h('div', { style: { fontSize: 12, lineHeight: 1.8, marginBottom: 8 } },
|
||
'同一靶标**同时最多几个执行智能体**。当前生效:**' + (info.max || '?') + '**'
|
||
+ (used === null ? '' : '(在跑 ' + used + ',剩余 ' + Math.max((info.max || 0) - used, 0) + ')')
|
||
+ ',来源:' + source + '。改完**立即生效**,不用重启。'),
|
||
h('div', { style: { display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' } },
|
||
h('input', {
|
||
className: 'rt-input', style: { width: 90 }, type: 'number', min: 1, max: limit,
|
||
value: draft, onChange: (e) => setDraft(e.target.value),
|
||
}),
|
||
h('span', { style: { fontSize: 11.5, color: 'var(--dsw-alias-label-secondary)' } }, '范围 1 – ' + limit),
|
||
h('button', { className: 'rt-btn rt-btn-primary', disabled: busy || invalid, onClick: save },
|
||
busy ? '保存中…' : '保存'),
|
||
msg && msg.err ? h('span', { style: { fontSize: 12, color: 'var(--rt-danger, #d33)' } }, msg.err) : null,
|
||
msg && msg.ok ? h('span', { style: { fontSize: 12, color: 'var(--dsw-alias-label-secondary)' } }, msg.ok) : null),
|
||
h('div', { className: 'rt-note', style: { marginTop: 8 } },
|
||
'⚠️ 调大不等于更快:**同一个模型 API Key 的并发/速率上限是共享的**,同时派太多会互相挤占,'
|
||
+ '表现为排队等待、超时、返回被截断甚至限流报错 —— 反而让测试结果不稳定(漏测、半途而废)。'
|
||
+ '建议从 2–3 开始,只有在确认 Key 的额度足够、任务之间确实互不依赖时才调大。'))
|
||
}
|
||
|
||
/**
|
||
* 版本显示 + 自动更新按钮。
|
||
*
|
||
* 行为:挂载时静默查一次最新版本(npm registry),有新版本就把按钮点亮成
|
||
* 「有新版本 vX」;点击弹出确认框(当前版本 / 最新版本 / 安装位置 / 阻塞项),
|
||
* 确认后由 host 侧执行 安装 → 重启 dsh web(页面会断开,重启后刷新即可)。
|
||
*/
|
||
function VersionBar(props) {
|
||
const [info, setInfo] = React.useState(null)
|
||
const [err, setErr] = React.useState(null)
|
||
const [busy, setBusy] = React.useState(false)
|
||
const [open, setOpen] = React.useState(false)
|
||
const [msg, setMsg] = React.useState(null)
|
||
const [countdown, setCountdown] = React.useState(0)
|
||
const countdownRef = React.useRef(null)
|
||
const clearCountdown = React.useCallback(() => {
|
||
if (countdownRef.current) { try { clearInterval(countdownRef.current) } catch { /* ignore */ } }
|
||
countdownRef.current = null
|
||
}, [])
|
||
React.useEffect(() => () => clearCountdown(), [clearCountdown])
|
||
const closeModal = React.useCallback(() => {
|
||
clearCountdown()
|
||
setCountdown(0)
|
||
setOpen(false)
|
||
}, [clearCountdown])
|
||
|
||
const check = (silent) => {
|
||
if (!silent) setBusy(true)
|
||
api({ op: 'updateCheck' }).then((r) => {
|
||
setBusy(false)
|
||
if (!r || r.ok === false) {
|
||
/* 开发态或查不到 registry:只显示当前版本,不报错打扰用户 */
|
||
if (!silent) setErr((r && r.error) || '检查更新失败')
|
||
api({ op: 'version' }).then((v) => { if (v && v.ok !== false) setInfo(v) }, () => {})
|
||
return
|
||
}
|
||
setErr(null)
|
||
setInfo(r)
|
||
}, (e) => {
|
||
setBusy(false)
|
||
if (!silent) setErr(String((e && e.message) || e))
|
||
api({ op: 'version' }).then((v) => { if (v && v.ok !== false) setInfo(v) }, () => {})
|
||
})
|
||
}
|
||
React.useEffect(() => { check(true) }, [])
|
||
|
||
const apply = () => {
|
||
setBusy(true)
|
||
setMsg(null)
|
||
api({ op: 'updateApply', target: (info && info.latest) || undefined }).then((r) => {
|
||
setBusy(false)
|
||
if (!r || r.ok === false) { setMsg({ err: (r && r.error) || '更新失败' }); return }
|
||
setMsg({ ok: '已安装 ' + (r.installed || '') + ',正在重启当前宿主…' })
|
||
/* 重启会断开这个页面:倒计时提示用户稍后刷新 */
|
||
setCountdown(6)
|
||
clearCountdown()
|
||
countdownRef.current = setInterval(() => {
|
||
setCountdown((n) => {
|
||
if (n <= 1) { clearCountdown(); window.location.reload(); return 0 }
|
||
return n - 1
|
||
})
|
||
}, 1000)
|
||
}, (e) => { setBusy(false); setMsg({ err: String((e && e.message) || e) }) })
|
||
}
|
||
|
||
const current = info && info.current ? info.current : (info && info.plugin ? info.plugin.version : null)
|
||
const latest = info && info.latest ? info.latest : null
|
||
const hasNew = !!(info && info.updateAvailable)
|
||
const blockers = (info && info.blockers) || []
|
||
const notes = (info && info.notes) || []
|
||
const mode = info && info.install ? info.install.mode : null
|
||
|
||
return h('span', { style: { display: 'inline-flex', alignItems: 'center', gap: 6 } },
|
||
h('span', {
|
||
className: 'rt-tag' + (hasNew ? ' rt-tag-warn' : ''),
|
||
title: ('当前版本 ' + (current || '未知') + (latest ? '\n最新版本 ' + latest : '')
|
||
+ (mode === 'dev' ? '\n安装方式:开发态(源码软链)' : mode === 'package' ? '\n安装方式:包安装' : '')
|
||
+ (info && info.check_error ? '\n检查失败:' + info.check_error : '')),
|
||
}, 'v' + (current || '?')),
|
||
hasNew
|
||
? h('button', {
|
||
className: 'rt-btn rt-btn-primary', disabled: busy,
|
||
onClick: () => { setOpen(true); setMsg(null) },
|
||
title: '发现新版本 ' + latest + ':点击查看并一键更新(会重启当前宿主)',
|
||
}, busy ? '检查中…' : '有新版本 v' + latest)
|
||
: h('button', {
|
||
className: 'rt-btn', disabled: busy, onClick: () => check(false),
|
||
title: '重新检查 npm 上的最新版本',
|
||
}, busy ? '检查中…' : '检查更新'),
|
||
open
|
||
? h('div', { className: 'rt-modal', 'aria-hidden': 'true', onClick: closeModal },
|
||
h('div', { className: 'rt-modal-box', onClick: (e) => e.stopPropagation() },
|
||
h('h4', { style: { marginTop: 0 } }, '更新 RedTeam 模式'),
|
||
h('div', { className: 'rt-kv' }, h('b', null, '当前版本'), h('span', null, current || '未知')),
|
||
h('div', { className: 'rt-kv' }, h('b', null, '最新版本'), h('span', null, latest || '未知')),
|
||
h('div', { className: 'rt-kv' }, h('b', null, '安装方式'),
|
||
h('span', null, mode === 'dev' ? '开发态(源码软链)' : mode === 'package' ? '包安装(' + ((info.install && info.install.packageManager) || 'npm') + ')' : '未知')),
|
||
info && info.install && info.install.dir
|
||
? h('div', { className: 'rt-kv' }, h('b', null, '安装位置'), h('span', { className: 'rt-mono' }, info.install.dir))
|
||
: null,
|
||
blockers.length
|
||
? h('div', { className: 'rt-hint' },
|
||
h('div', { style: { fontWeight: 600, marginBottom: 4 } }, '现在不能更新:'),
|
||
blockers.map((b, i) => h('div', { key: 'b' + i }, '· ' + b)))
|
||
: null,
|
||
notes.length
|
||
? h('div', { className: 'rt-foot', style: { padding: '4px 0' } }, notes.map((n, i) => h('div', { key: 'n' + i }, n)))
|
||
: null,
|
||
h('div', { style: { fontSize: 12, color: 'var(--dsw-alias-label-secondary)', margin: '8px 0' } },
|
||
'更新过程:安装新版本 → 自动重启当前宿主(官方 exe / 社区版 / dsh web;当前页面会断开,约 5–10 秒后自动刷新)。'
|
||
+ '重启前会检查有没有智能体在跑,有就拦住不动。'),
|
||
msg ? h('div', { className: msg.err ? 'rt-err' : 'rt-foot' }, msg.err || msg.ok) : null,
|
||
countdown > 0
|
||
? h('div', { className: 'rt-foot' }, '宿主正在重启,' + countdown + ' 秒后自动刷新页面…')
|
||
: null,
|
||
h('div', { style: { display: 'flex', gap: 8, justifyContent: 'flex-end', marginTop: 10 } },
|
||
h('button', { className: 'rt-btn', onClick: closeModal }, '关闭'),
|
||
h('button', {
|
||
className: 'rt-btn rt-btn-primary',
|
||
disabled: busy || blockers.length > 0 || mode === 'dev',
|
||
title: mode === 'dev' ? '开发态安装请用仓库流程升级' : blockers.length ? '先解决上面的阻塞项' : '安装并重启',
|
||
onClick: apply,
|
||
}, busy ? '更新中…' : '一键更新并重启'))))
|
||
: null)
|
||
}
|
||
|
||
/* ---------------------------------------------------------- 常驻面板主体 */
|
||
/**
|
||
* 「未读」状态:按靶标记在 localStorage 里,每 25 秒问一次 host 的 consoleDigest
|
||
* (每个页签的条数 + 最近更新时间)。比本地记住的快照新 → 该页签点红点;
|
||
* 用户点开那个页签就把当前值记成已读,红点消失。
|
||
*/
|
||
const UNREAD_KEY = 'rt-unread:'
|
||
const loadUnread = (eng) => {
|
||
try {
|
||
const raw = window.localStorage.getItem(UNREAD_KEY + eng)
|
||
const parsed = raw ? JSON.parse(raw) : null
|
||
return parsed && typeof parsed === 'object' ? parsed : null
|
||
} catch (e) { return null }
|
||
}
|
||
const saveUnread = (eng, value) => {
|
||
try { window.localStorage.setItem(UNREAD_KEY + eng, JSON.stringify(value)) } catch (e) { /* 隐私模式等:不持久化也能用 */ }
|
||
}
|
||
/** 这个页签相对上次查看有没有新内容(条数变多,或最新一条比上次查看还新)。 */
|
||
const digestHasNew = (prev, cur) => {
|
||
if (!cur) return false
|
||
if (!prev) return true
|
||
const pc = Number(prev.count || 0)
|
||
const cc = Number(cur.count || 0)
|
||
if (cc > pc) return true
|
||
const pa = prev.at || ''
|
||
const ca = cur.at || ''
|
||
return ca !== '' && ca !== pa && ca > pa
|
||
}
|
||
|
||
const RT_GEOM_KEY = 'rt-dock-geom-v1'
|
||
function clampRtGeom(g) {
|
||
const vw = (typeof window !== 'undefined' && window.innerWidth) || 1280
|
||
const vh = (typeof window !== 'undefined' && window.innerHeight) || 800
|
||
const w = Math.min(Math.max(320, Number(g && g.w) || 620), Math.max(320, vw))
|
||
const h = Math.min(Math.max(240, Number(g && g.h) || 520), Math.max(240, vh))
|
||
const x = Math.min(Math.max(0, Number(g && g.x) || 0), Math.max(0, vw - w))
|
||
const y = Math.min(Math.max(0, Number(g && g.y) || 0), Math.max(0, vh - h))
|
||
return { x: x, y: y, w: w, h: h }
|
||
}
|
||
function defaultRtGeom() {
|
||
const vw = (typeof window !== 'undefined' && window.innerWidth) || 1280
|
||
const vh = (typeof window !== 'undefined' && window.innerHeight) || 800
|
||
let w = 620
|
||
try {
|
||
const saved = Number(window.localStorage.getItem('rt-dock-width'))
|
||
if (Number.isFinite(saved) && saved >= 320 && saved <= 1200) w = saved
|
||
} catch (e) { /* 隐私模式 */ }
|
||
const h = Math.max(240, Math.min(640, vh - 24))
|
||
return clampRtGeom({ x: Math.max(0, vw - w - 12), y: 12, w: w, h: h })
|
||
}
|
||
function loadRtGeom() {
|
||
try {
|
||
const raw = JSON.parse(window.localStorage.getItem(RT_GEOM_KEY) || 'null')
|
||
if (raw && typeof raw === 'object') return clampRtGeom(raw)
|
||
} catch (e) { /* 隐私模式或坏数据 */ }
|
||
return defaultRtGeom()
|
||
}
|
||
|
||
function Panel(props) {
|
||
/* embedded:嵌在 dsh-purge 外层 dock 里时,不再自带收起/全面浏览/拖拽条,避免与外层按钮重叠 */
|
||
const embedded = !!(props && props.embedded)
|
||
const st = useUI()
|
||
const [engagements, setEngagements] = React.useState([])
|
||
const [eng, setEng] = React.useState(null)
|
||
const [snapshot, setSnapshot] = React.useState(null)
|
||
const [err, setErr] = React.useState(null)
|
||
const [newName, setNewName] = React.useState('')
|
||
const [geom, setGeom] = React.useState(loadRtGeom)
|
||
const [creating, setCreating] = React.useState(false)
|
||
const [refreshKey, setRefreshKey] = React.useState(0)
|
||
const [digest, setDigest] = React.useState(null)
|
||
const [seen, setSeen] = React.useState(null)
|
||
|
||
React.useEffect(() => {
|
||
if (embedded) return undefined
|
||
try { window.localStorage.setItem(RT_GEOM_KEY, JSON.stringify(geom)) } catch (e) { /* 隐私模式 */ }
|
||
}, [geom, embedded])
|
||
React.useEffect(() => {
|
||
if (embedded) return undefined
|
||
const onResize = () => setGeom((g) => clampRtGeom(g))
|
||
window.addEventListener('resize', onResize)
|
||
return () => window.removeEventListener('resize', onResize)
|
||
}, [embedded])
|
||
|
||
const loadSnapshot = (id) => {
|
||
if (!id) { setSnapshot(null); return }
|
||
api({ op: 'snapshot', engagement: id }).then((r) => {
|
||
if (!r || r.ok === false) { setErr((r && r.error) || '加载失败'); return }
|
||
setErr(null)
|
||
setSnapshot(r)
|
||
}, (e) => setErr(String((e && e.message) || e)))
|
||
}
|
||
|
||
const refreshList = (selectId) => {
|
||
api({ op: 'bootstrap' }).then((b) => {
|
||
setEngagements((b && b.engagements) || [])
|
||
if (selectId) { setEng(selectId); loadSnapshot(selectId) }
|
||
}, () => {})
|
||
}
|
||
|
||
React.useEffect(() => {
|
||
api({ op: 'bootstrap' }).then((r) => {
|
||
if (!r || r.ok === false) { setErr((r && r.error) || '无法连接资产库'); return }
|
||
setEngagements(r.engagements || [])
|
||
const id = r.current || (r.engagements && r.engagements[0] && r.engagements[0].id) || null
|
||
if (id) { setEng(id); loadSnapshot(id) }
|
||
}, (e) => setErr(String((e && e.message) || e)))
|
||
}, [])
|
||
|
||
const openEngagement = (nameArg) => {
|
||
const name = String(nameArg || newName).trim()
|
||
if (!name) return
|
||
setCreating(true)
|
||
api({ op: 'openEngagement', name: name }).then((r) => {
|
||
setCreating(false)
|
||
if (!r || r.ok === false) { setErr((r && r.error) || '创建失败'); return }
|
||
setNewName('')
|
||
refreshList(r.engagement && r.engagement.id)
|
||
}, (e) => { setCreating(false); setErr(String((e && e.message) || e)) })
|
||
}
|
||
|
||
/** 刷新:重新拉取名册/快照,并让当前页签重新取数。 */
|
||
const refreshAll = () => {
|
||
setRefreshKey((k) => k + 1)
|
||
api({ op: 'bootstrap' }).then((b) => {
|
||
setEngagements((b && b.engagements) || [])
|
||
if (eng) loadSnapshot(eng)
|
||
}, () => { if (eng) loadSnapshot(eng) })
|
||
}
|
||
|
||
const trackPointer = (e, move) => {
|
||
if (e.button !== 0) return
|
||
e.preventDefault()
|
||
e.stopPropagation()
|
||
const up = () => {
|
||
window.removeEventListener('mousemove', move)
|
||
window.removeEventListener('mouseup', up)
|
||
}
|
||
window.addEventListener('mousemove', move)
|
||
window.addEventListener('mouseup', up)
|
||
}
|
||
const startDrag = (e) => {
|
||
if (e.target && e.target.closest && e.target.closest('button,a,input,select,textarea,label')) return
|
||
const sx = e.clientX
|
||
const sy = e.clientY
|
||
const ox = geom.x
|
||
const oy = geom.y
|
||
trackPointer(e, (ev) => setGeom((g) => clampRtGeom({ x: ox + (ev.clientX - sx), y: oy + (ev.clientY - sy), w: g.w, h: g.h })))
|
||
}
|
||
const startResizeLeft = (e) => {
|
||
const sx = e.clientX
|
||
const ox = geom.x
|
||
const ow = geom.w
|
||
trackPointer(e, (ev) => {
|
||
const dx = ev.clientX - sx
|
||
setGeom((g) => clampRtGeom({ x: ox + dx, y: g.y, w: ow - dx, h: g.h }))
|
||
})
|
||
}
|
||
const startResizeRight = (e) => {
|
||
const sx = e.clientX
|
||
const ow = geom.w
|
||
trackPointer(e, (ev) => setGeom((g) => clampRtGeom({ x: g.x, y: g.y, w: ow + (ev.clientX - sx), h: g.h })))
|
||
}
|
||
const startResizeBottom = (e) => {
|
||
const sy = e.clientY
|
||
const oh = geom.h
|
||
trackPointer(e, (ev) => setGeom((g) => clampRtGeom({ x: g.x, y: g.y, w: g.w, h: oh + (ev.clientY - sy) })))
|
||
}
|
||
const startResizeCorner = (e) => {
|
||
const sx = e.clientX
|
||
const sy = e.clientY
|
||
const ow = geom.w
|
||
const oh = geom.h
|
||
trackPointer(e, (ev) => setGeom((g) => clampRtGeom({ x: g.x, y: g.y, w: ow + (ev.clientX - sx), h: oh + (ev.clientY - sy) })))
|
||
}
|
||
|
||
/* 切靶标:重新读该靶标的未读快照,并立刻取一次摘要 */
|
||
React.useEffect(() => {
|
||
if (!eng) { setDigest(null); setSeen(null); return undefined }
|
||
setSeen(loadUnread(eng))
|
||
let alive = true
|
||
let digestInflight = false
|
||
const tick = () => {
|
||
if (digestInflight) return /* 上一轮还没回来,跳过这一轮,别把请求堆起来 */
|
||
digestInflight = true
|
||
api({ op: 'consoleDigest', engagement: eng }).then((r) => {
|
||
if (alive && r && r.ok !== false && r.sections) setDigest(r.sections)
|
||
}, () => { /* 网络异常:不打断,等下一轮 */ })
|
||
.finally(() => { digestInflight = false })
|
||
}
|
||
tick()
|
||
const timer = window.setInterval(tick, 25000)
|
||
return () => { alive = false; window.clearInterval(timer) }
|
||
}, [eng, refreshKey])
|
||
|
||
/**
|
||
* 打开某个页签 = 看过这个页签的内容:把当前摘要记成已读。
|
||
* 没读过(第一次打开面板)不算新内容 —— 否则一进来满屏红点,反而看不出"哪里有新东西"。
|
||
*/
|
||
const markTabSeen = React.useCallback((tab) => {
|
||
if (!eng) return
|
||
setSeen((prev) => {
|
||
const base = prev || (() => {
|
||
const fresh = {}
|
||
for (const [k, v] of Object.entries((digest || {}))) fresh[k] = { count: Number(v.count || 0), at: v.at || null }
|
||
return fresh
|
||
})()
|
||
const next = Object.assign({}, base)
|
||
const cur = digest && digest[tab]
|
||
if (cur) next[tab] = { count: Number(cur.count || 0), at: cur.at || null }
|
||
saveUnread(eng, next)
|
||
return next
|
||
})
|
||
}, [eng, digest])
|
||
|
||
const unreadOf = (tab) => {
|
||
if (!eng || !digest) return false
|
||
if (!seen) return false
|
||
return digestHasNew(seen[tab], digest[tab])
|
||
}
|
||
|
||
const stats = (snapshot && snapshot.stats) || {}
|
||
const tabs = [
|
||
['assets', '资产测绘'], ['env', '环境适配'], ['agents', '智能体'], ['sessions', '会话隧道'],
|
||
['findings', '漏洞战果'],
|
||
['chain', '攻击链'], ['scores', '得分目标'], ['report', '报告'],
|
||
['knowledge', '知识库'],
|
||
['skills', '技能库'],
|
||
]
|
||
const full = isFullWindow()
|
||
const openFull = () => {
|
||
try { window.open(window.location.href.split('#')[0] + '#redteam-full', '_blank', 'noopener') } catch (e) { setErr('无法打开新窗口:' + ((e && e.message) || e)) }
|
||
}
|
||
const exitFull = () => {
|
||
/* 优先关掉脚本打开的窗口;关不掉就退回带侧栏的普通界面 */
|
||
try { window.close() } catch (e) { /* 非脚本打开的窗口无法关闭 */ }
|
||
try {
|
||
if (window.location.hash) {
|
||
window.location.hash = ''
|
||
window.location.reload()
|
||
}
|
||
} catch (e) { /* ignore */ }
|
||
}
|
||
React.useEffect(() => {
|
||
if (!full) return undefined
|
||
const onKey = (e) => { if (e.key === 'Escape') exitFull() }
|
||
window.addEventListener('keydown', onKey)
|
||
return () => window.removeEventListener('keydown', onKey)
|
||
}, [full])
|
||
|
||
let body
|
||
if (err) body = h('div', { className: 'rt-err' }, err)
|
||
/* 全局页:没有靶标也能看(知识库 / 环境适配 / 技能库 / 智能体概览) */
|
||
else if (st.tab === 'knowledge') body = h(KnowledgeTab, { refreshKey: refreshKey })
|
||
else if (st.tab === 'env') body = h(EnvTab, { refreshKey: refreshKey })
|
||
else if (st.tab === 'skills') body = h(SkillsTab, { refreshKey: refreshKey })
|
||
else if (st.tab === 'agents' || st.tab === 'prompts') body = h(AgentsTab, { engagement: eng, refreshKey: refreshKey })
|
||
else if (!eng) {
|
||
body = h('div', { className: 'rt-pane' },
|
||
h('div', { className: 'rt-card' },
|
||
h('h4', null, '还没有靶标'),
|
||
h('div', { style: { fontSize: 12, color: 'var(--dsw-alias-label-secondary)', marginBottom: 8 } },
|
||
'输入攻防演练靶标单位名称,创建演练并开始资产测绘。'),
|
||
h('div', { style: { display: 'flex', gap: 8 } },
|
||
h('input', {
|
||
className: 'rt-input', style: { flex: 1 }, placeholder: '例如:示例科技有限公司',
|
||
value: newName, onChange: (e) => setNewName(e.target.value),
|
||
onKeyDown: (e) => { if (e.key === 'Enter') openEngagement() },
|
||
}),
|
||
h('button', { className: 'rt-btn rt-btn-primary', disabled: creating, onClick: () => openEngagement() },
|
||
creating ? '创建中…' : '创建靶标'))))
|
||
} else if (st.tab === 'assets') body = h(AssetsTab, { engagement: eng, snapshot: snapshot, refreshKey: refreshKey, onRefresh: refreshAll, onData: () => loadSnapshot(eng) })
|
||
else if (st.tab === 'testing') body = h(AssetsTab, { engagement: eng, snapshot: snapshot, refreshKey: refreshKey, onRefresh: refreshAll, onData: () => loadSnapshot(eng) })
|
||
else if (st.tab === 'sessions') body = h(SessionTab, { engagement: eng, refreshKey: refreshKey })
|
||
else if (st.tab === 'findings') body = h(FindingsTab, { engagement: eng, refreshKey: refreshKey })
|
||
else if (st.tab === 'chain') body = h(ChainTab, { engagement: eng, refreshKey: refreshKey })
|
||
else if (st.tab === 'report') body = h(ReportTab, { engagement: eng, refreshKey: refreshKey })
|
||
else if (st.tab === 'attackfiles') body = h(FindingsTab, { engagement: eng, refreshKey: refreshKey })
|
||
else if (st.tab === 'scores') body = h(ScoreTab, { engagement: eng, refreshKey: refreshKey })
|
||
else body = h('div', { className: 'rt-empty' }, '未知页签')
|
||
|
||
const shellProps = full
|
||
? { className: 'rt-full', style: { display: 'flex' } }
|
||
: embedded
|
||
? { className: 'rt-dock rt-embedded', 'data-open': '1', style: { width: '100%', display: 'flex' } }
|
||
: {
|
||
className: 'rt-dock',
|
||
'data-open': st.open ? '1' : '0',
|
||
style: {
|
||
left: geom.x + 'px',
|
||
top: geom.y + 'px',
|
||
right: 'auto',
|
||
bottom: 'auto',
|
||
width: geom.w + 'px',
|
||
height: geom.h + 'px',
|
||
display: st.open ? 'flex' : 'none',
|
||
},
|
||
}
|
||
|
||
return h('div', shellProps,
|
||
(full || embedded) ? null : h('div', { className: 'rt-grip', title: '拖动调整宽度', onMouseDown: startResizeLeft }),
|
||
(full || embedded) ? null : h('div', { className: 'rt-resize-r', title: '拖动调整宽度', onMouseDown: startResizeRight }),
|
||
(full || embedded) ? null : h('div', { className: 'rt-resize-b', title: '拖动调整高度', onMouseDown: startResizeBottom }),
|
||
(full || embedded) ? null : h('div', { className: 'rt-resize-c', title: '拖动调整大小', onMouseDown: startResizeCorner }),
|
||
h('div', { className: 'rt-head', onMouseDown: (full || embedded) ? undefined : startDrag, title: (full || embedded) ? undefined : '按住拖动面板' },
|
||
embedded ? null : h('div', { className: 'rt-title' }, h('span', { className: 'rt-dot' }),
|
||
full ? '演练台 · 全面浏览' : '演练台',
|
||
full ? h('span', { className: 'rt-tag', style: { marginLeft: 6 } }, '独立窗口') : null),
|
||
h('select', {
|
||
className: 'rt-input rt-eng-select',
|
||
style: { maxWidth: embedded ? '100%' : '170px', minWidth: embedded ? '160px' : undefined, flex: embedded ? '1 1 200px' : undefined },
|
||
value: eng || '',
|
||
onChange: (e) => {
|
||
setEng(e.target.value)
|
||
loadSnapshot(e.target.value)
|
||
/* 同步「当前靶标」:原生 skill 目录按它解析技能根 */
|
||
api({ op: 'activateEngagement', engagement: e.target.value }).catch(() => {})
|
||
},
|
||
},
|
||
h('option', { value: '', disabled: true }, engagements.length ? '选择靶标' : '暂无靶标'),
|
||
engagements.map((x) => h('option', { key: x.id, value: x.id }, x.name || x.id || '未命名')),
|
||
),
|
||
h('div', { className: 'rt-spacer' }),
|
||
embedded ? null : h(VersionBar, null),
|
||
full ? h('button', { className: 'rt-btn', title: '回到带侧栏的普通界面(或按 Esc)', onClick: exitFull }, '退出全面浏览') : null,
|
||
embedded ? null : h('button', { className: 'rt-btn', title: '刷新名册、快照与当前页面数据', onClick: refreshAll }, '刷新'),
|
||
(full || embedded) ? null : h('button', { className: 'rt-btn', title: '在新浏览器窗口打开完整控制台', onClick: openFull }, '全面浏览'),
|
||
(full || embedded) ? null : h('button', { className: 'rt-btn', title: '收起面板', onClick: () => setUI({ open: false }) }, '收起')),
|
||
/* 页签栏用标准 tablist/tab 角色:读屏软件据此播报「第几个页签、是否选中」。
|
||
键盘用户 Tab 进来后可用 Enter/Space 切换(由 clickable 提供)。 */
|
||
h('div', { className: 'rt-tabs', role: 'tablist' }, tabs.map((t) => {
|
||
const activate = () => {
|
||
markTabSeen(t[0])
|
||
setUI({ tab: t[0] })
|
||
/* 强制当前页重新取数,避免切页后仍显示上一页缓存视觉 */
|
||
setRefreshKey((k) => k + 1)
|
||
}
|
||
return h('div', Object.assign({}, clickable(activate, { label: t[1] }), {
|
||
key: t[0], className: 'rt-tab' + (st.tab === t[0] ? ' on' : ''),
|
||
role: 'tab',
|
||
'aria-selected': st.tab === t[0] ? 'true' : 'false',
|
||
title: unreadOf(t[0]) ? t[1] + ':有新内容,点开看过红点就会消失' : t[1],
|
||
}), t[1], unreadOf(t[0])
|
||
/* 红点是纯视觉信息,给读屏软件一个文字替代 */
|
||
? h('span', { className: 'rt-tab-dot', 'aria-label': '有新内容' })
|
||
: null)
|
||
})),
|
||
h('div', { className: 'rt-body', key: 'body-' + st.tab }, h(RtBoundary, { key: st.tab }, body)),
|
||
h('div', { className: 'rt-foot' },
|
||
h('span', null, 'C 段 ' + (stats.segments || 0)),
|
||
h('span', null, '资产 ' + (stats.assets || 0) + '(存活 ' + (stats.liveAssets || 0) + ')'),
|
||
h('span', null, '端口 ' + (stats.openPorts || 0)),
|
||
h('span', null, '指纹 ' + (stats.fingerprints || 0)),
|
||
h('span', null, '漏洞 ' + (stats.vulns || 0)),
|
||
h('span', null, '被动/主动 ' + (stats.passiveSignals || 0) + '/' + (stats.activeSignals || 0)),
|
||
h('div', { className: 'rt-spacer' }),
|
||
full ? h('span', null, '按 Esc 或点右上角「退出全面浏览」回到带侧栏的界面') : null,
|
||
h('span', null, 'SQLite · ' + (snapshot && snapshot.engagement ? snapshot.engagement.name : ''))))
|
||
}
|
||
|
||
/* ---------------------------------------------------------- 入口按钮 */
|
||
function SidebarButton(props) {
|
||
const st = useUI()
|
||
return h('button', {
|
||
className: 'rt-icon-btn' + (st.open ? ' on' : ''),
|
||
title: 'RedTeam 控制台(常驻右侧栏)',
|
||
onClick: () => setUI({ open: !st.open }),
|
||
}, h('span', { style: { fontSize: 14 } }, '⛨'), props.wide ? h('span', null, 'RedTeam') : null)
|
||
}
|
||
|
||
function HeaderButton() {
|
||
const st = useUI()
|
||
return h('button', {
|
||
className: 'rt-hbtn' + (st.open ? ' on' : ''),
|
||
title: 'RedTeam 控制台(常驻右侧栏)',
|
||
onClick: () => setUI({ open: !st.open }),
|
||
}, '⛨ RedTeam')
|
||
}
|
||
|
||
/** 侧栏「全面浏览」:在新浏览器窗口打开完整控制台(当前窗口不受影响)。 */
|
||
function FullButton(props) {
|
||
const open = () => {
|
||
try { window.open(window.location.href.split('#')[0] + '#redteam-full', '_blank', 'noopener') } catch { /* 被浏览器拦截 */ }
|
||
}
|
||
return h('button', {
|
||
className: 'rt-icon-btn',
|
||
title: '全面浏览:在新窗口打开完整控制台(当前窗口不受影响;新窗口内按 Esc 退出)',
|
||
onClick: open,
|
||
}, h('span', { style: { fontSize: 14 } }, '⛶'), props.wide ? h('span', null, '全面浏览') : null)
|
||
}
|
||
|
||
/* ---------------------------------------------------------- 插件入口 */
|
||
/** 唯一硬依赖:槽位注册表。 */
|
||
const inject = ['slots']
|
||
|
||
/**
|
||
* 注入样式 + 三处槽位。样式标签与宽度变量随 fiber 卸载一起移除。
|
||
* @param ctx - 客户端根上下文。
|
||
*/
|
||
function applyDrillParts() {
|
||
return { CSS: CSS, Panel: Panel, setUI: setUI, useUI: useUI, isFullWindow: isFullWindow, setDockWidth: setDockWidth };
|
||
}
|
||
|
||
exports.inject = inject
|
||
return applyDrillParts();
|
||
})();
|
||
/* __DSH_PURGE_DRILL_END__ */
|
||
|
||
const AUTH_KEY = "dsh-purge-drill-auth";
|
||
const WELCOME_KEY = "dsh-purge-user-notice-v1";
|
||
let drillGrantGen = 0;
|
||
let drillGrantPosting = 0;
|
||
let drillGrantState = { ready: false, granted: false };
|
||
let drillSyncRetryTimer = null;
|
||
const AUTH_LEGAL_HTML = "<h3>严正法律免责与合规使用声明</h3>\r\n <p>本声明在进入演练台前必须全文阅读。本项目是非营利开源项目,遵守国家法律法规及所在平台的规范,仅供学习与研究、技术参考。严禁任何主体把本项目用于商业售卖、付费倒卖或黑灰产牟利。</p>\r\n <p>开发者坚决反对并严禁任何形式的违法犯罪,绝不支持、不鼓励、不协助未授权网络攻击、漏洞利用、数据窃取、非法侵入计算机信息系统,或生成违法违禁内容。任何将本项目用于违法犯罪的行为,均与开发者无关,由行为人依法独立承担全部法律责任。不得以科研、教学、测试、演示或内部学习为由规避下列条款。</p>\r\n <h3>1. 本仓库不含违法内容</h3>\r\n <p>dsh-purge 发布的代码、文档、补丁与默认提示词不是木马、后门、未授权渗透工具、勒索软件、撞库脚本,也不是针对公网或第三方系统的攻击载荷。项目不提供违法内容,也不教唆、组织、协助实施违法犯罪。</p>\r\n <h3>2. 本机操作不构成对外授权</h3>\r\n <p>应用补丁、写入、回滚、卸载,都在本机文件和本机进程内完成。这些操作不对任何公网主机或未授权系统进行扫描、探测、入侵或攻击发包,也不得把本机当作跳板。</p>\r\n <p>插件若开启检测更新,仅可能访问本插件自己的 GitHub 仓库以核对版本。该访问与对第三方系统的网络攻击无关,不能被解释为对外渗透的授权。</p>\r\n <h3>3. 允许使用的范围</h3>\r\n <p>只允许用在你能证明有权处理的环境:本人有权管理的本机官方 Harness;离线的本地合成靶标;所有者已经出具合法书面授权的网络安全演练靶场;合规实验室里的受控环境。书面授权要能对应具体目标、时间和范围。只填写一个单位名称,或只有口头说法,不构成授权。</p>\r\n <p>未经所有者合法书面授权的目标、公网在线系统、生产业务,以及能源、交通、水利、金融、公共服务、电子政务等关键信息基础设施,都不得作为演练对象。</p>\r\n <h3>4. 必须遵守的法律,按条、款</h3>\r\n <p>《中华人民共和国刑法》第二百八十五条第一款:禁止违反国家规定,侵入国家事务、国防建设、尖端科学技术领域的计算机信息系统。</p>\r\n <p>同条第二款:禁止侵入前款规定以外的计算机信息系统,或者用其他技术手段,获取该系统中存储、处理或者传输的数据,或者对该系统实施非法控制。</p>\r\n <p>同条第三款:禁止提供专门用于侵入、非法控制计算机信息系统的程序、工具;明知他人实施侵入、非法控制而为其提供程序、工具,同样禁止。</p>\r\n <p>第二百八十六条第一款:禁止对计算机信息系统功能进行删除、修改、增加、干扰,造成系统不能正常运行。</p>\r\n <p>同条第二款:禁止对系统中存储、处理或者传输的数据和应用程序进行删除、修改、增加。</p>\r\n <p>同条第三款:禁止故意制作、传播计算机病毒等破坏性程序,影响计算机系统正常运行。</p>\r\n <p>第二百八十七条:禁止利用计算机实施金融诈骗、盗窃、贪污、挪用公款、窃取国家秘密或者其他犯罪。</p>\r\n <p>第二百八十七条之一第一款第(一)项:禁止设立用于实施诈骗、传授犯罪方法、制作或者销售违禁物品、管制物品等违法犯罪活动的网站、通讯群组。</p>\r\n <p>同款第(二)项:禁止发布有关制作或者销售毒品、枪支、淫秽物品等违禁物品、管制物品或者其他违法犯罪的信息。</p>\r\n <p>同款第(三)项:禁止为实施诈骗等违法犯罪活动发布信息。</p>\r\n <p>第二百八十七条之二:禁止明知他人利用信息网络实施犯罪,仍为其提供技术支持,或者提供广告推广、支付结算等帮助。</p>\r\n <p>第二百五十三条之一第一款、第三款:禁止向他人出售或者提供公民个人信息;禁止窃取或者以其他方法非法获取公民个人信息。</p>\r\n <p>《中华人民共和国网络安全法》第十三条第二款:使用网络不得危害网络安全,不得利用网络从事危害国家安全、荣誉和利益,煽动颠覆国家政权、推翻社会主义制度,煽动分裂国家、破坏国家统一,宣扬恐怖主义、极端主义,宣扬民族仇恨、民族歧视,传播暴力、淫秽色情信息,编造、传播虚假信息扰乱经济秩序和社会秩序,以及侵害他人名誉、隐私、知识产权和其他合法权益等活动。</p>\r\n <p>第十四条:禁止利用网络从事危害未成年人身心健康的活动。</p>\r\n <p>第二十九条:禁止非法侵入他人网络、干扰他人网络正常功能、窃取网络数据;禁止提供专门用于侵入网络、干扰网络正常功能及防护措施、窃取网络数据的程序、工具;明知他人从事危害网络安全的活动,禁止为其提供技术支持、广告推广、支付结算等帮助。</p>\r\n <p>第三十三条:公共通信和信息服务、能源、交通、水利、金融、公共服务、电子政务等关键信息基础设施实行重点保护。未获合法授权,不得侵入、干扰或破坏。</p>\r\n <p>第四十六条:禁止窃取或者以其他非法方式获取个人信息,禁止非法出售或者非法向他人提供个人信息。</p>\r\n <p>第四十八条:禁止设立用于实施诈骗,传授犯罪方法,制作或者销售违禁物品、管制物品等违法犯罪活动的网站、通讯群组;禁止利用网络发布涉及上述违法犯罪活动的信息。</p>\r\n <p>第五十条:发送的电子信息、提供的应用软件不得设置恶意程序,不得含有法律、行政法规禁止发布或者传输的信息。</p>\r\n <p>《中华人民共和国数据安全法》第八条:开展数据处理活动,不得危害国家安全、公共利益,不得损害个人、组织的合法权益。</p>\r\n <p>第三十二条第一款:收集数据应当采取合法、正当的方式,不得窃取或者以其他非法方式获取数据。</p>\r\n <p>同条第二款:法律、行政法规对收集、使用数据的目的和范围有规定的,只能在该目的和范围内收集、使用。</p>\r\n <p>第五十一条:窃取或者以其他非法方式获取数据,或者因此损害个人、组织合法权益的,依照有关法律、行政法规处罚。</p>\r\n <p>《中华人民共和国个人信息保护法》第五条:处理个人信息应当合法、正当、必要、诚信,不得通过误导、欺诈、胁迫等方式处理。</p>\r\n <p>第六条第一款、第二款:处理个人信息应当具有明确、合理的目的,并与该目的直接相关,采取对个人权益影响最小的方式;收集限于实现处理目的的最小范围,不得过度收集。</p>\r\n <p>第十条:禁止非法收集、使用、加工、传输他人个人信息,禁止非法买卖、提供或者公开他人个人信息;禁止从事危害国家安全、公共利益的个人信息处理活动。</p>\r\n <p>第十三条:没有取得个人同意,也不属于订立履行合同所必需、履行法定职责、应对突发、公共利益新闻舆论、处理本人已经合法公开的信息,以及法律行政法规规定的其他情形之一的,不得处理个人信息。</p>\r\n <p>第二十八条、第二十九条:生物识别、医疗健康、金融账户、行踪轨迹,以及不满十四周岁未成年人的个人信息,属于敏感个人信息。没有特定目的、充分必要性和严格保护措施,并且没有取得个人单独同意的,不得处理。</p>\r\n <p>此外还须遵守其他现行有效的法律、行政法规、监管规定,以及所使用模型的服务条款与滥用政策。</p>\r\n <h3>5. 据此禁止的方向</h3>\r\n <p>未授权进入。禁止把没有书面授权的单位、域名、地址、公网系统、生产业务或关键信息基础设施登记为靶标,禁止对其扫描、探测、侵入、控制或发包。对应刑法第二百八十五条第一款、第二款,网络安全法第二十九条、第三十三条。</p>\r\n <p>破坏与恶意程序。禁止删除、修改、增加、干扰他人系统功能或其中的数据、应用程序;禁止制作、传播病毒、勒索程序或其他破坏性程序;禁止在信息或软件中设置恶意程序。对应刑法第二百八十六条第一款至第三款,网络安全法第五十条。</p>\r\n <p>工具与帮助。禁止把本项目或本机提供给他人,用于侵入、非法控制或窃取数据;禁止明知对方在实施网络犯罪,仍提供程序、工具、技术支持、广告推广或支付结算。对应刑法第二百八十五条第三款、第二百八十七条之二,网络安全法第二十九条。</p>\r\n <p>数据。禁止窃取、非法收集、超范围使用、泄露、出售或向他人提供业务数据、账号、口令和其他受保护数据。对应数据安全法第八条、第三十二条、第五十一条。</p>\r\n <p>个人信息。禁止非法收集、使用、加工、传输、买卖、提供或公开他人个人信息;禁止过度收集;禁止以误导、欺诈、胁迫方式处理;禁止擅自处理生物识别、医疗健康、金融账户、行踪轨迹和儿童个人信息。对应刑法第二百五十三条之一第一款、第三款,网络安全法第四十六条,个人信息保护法第五条、第六条、第十条、第十三条、第二十八条、第二十九条。</p>\r\n <p>诈骗与违法信息。禁止设立或利用网站、群组实施诈骗、传授犯罪方法、制作或销售违禁物品、管制物品;禁止发布此类信息;禁止利用计算机实施诈骗、盗窃、贪污、挪用公款、窃取国家秘密。对应刑法第二百八十七条、第二百八十七条之一第一款第(一)项至第(三)项,网络安全法第四十八条。</p>\r\n <p>内容。禁止生成或传播危害国家安全、荣誉和利益的内容,禁止煽动颠覆、分裂,禁止恐怖主义、极端主义、民族仇恨,禁止暴力、淫秽色情、赌博,禁止编造虚假信息扰乱经济秩序和社会秩序,禁止侵害名誉、隐私、知识产权,禁止危害未成年人身心健康的内容。对应网络安全法第十三条第二款、第十四条。</p>\r\n <p>结果扩散。演练中形成的记录、资产信息和文件,只留在书面授权写明的目标和期限里,不得交给无权获知的人,也不得改作授权以外的用途。</p>\r\n <h3>6. 责任由使用者承担</h3>\r\n <p>本项目依据 MIT 协议按现状提供。开发者不就完整性、安全性与适用性作保证。使用者对自己的下载、部署、运行、修改、传播,以及全部输入与输出,承担独立、完全的民事、行政及刑事法律责任。作者与贡献者不承担因滥用产生的直接、间接或连带责任。</p>\r\n <p>勾选确认的是操作者本人,不能代替没有阅读本声明的人,也不能把别人的系统说成已经授权。</p>\r\n <h3>7. 违约即终止授权</h3>\r\n <p>一旦用于非法攻击、恶意活动或上述任一禁止方向,使用许可自该行为发生之日起自动终止,且不可撤销。必须立即停止使用,并销毁本项目的代码、脚本与衍生数据,依法承担责任。授权终止后,演练台不得继续打开或继续使用。</p>\r\n <h3>8. 与 DeepSeek 官方的关系</h3>\r\n <p>本项目是独立的开源项目,与 DeepSeek 官方或其关联主体没有隶属、商业合作、授权或官方背书。文中的「官方」只表示评测对象是使用者本机安装的官方 DeepSeek Harness 软件包,不代表 DeepSeek 官方开发、认可或担保本插件。</p>\r\n <p>文末。倒计时结束、滚到这里并勾选全部三项,才表示你以本人身份认可本声明,并完成本次演练台授权。</p>";
|
||
|
||
function readWelcomeAck() {
|
||
try { return window.localStorage.getItem(WELCOME_KEY) === "1"; } catch { return false; }
|
||
}
|
||
function writeWelcomeAck() {
|
||
try { window.localStorage.setItem(WELCOME_KEY, "1"); } catch { /* ignore */ }
|
||
}
|
||
|
||
function readDrillAuth() {
|
||
if (drillGrantState.ready) return drillGrantState.granted;
|
||
try { return window.localStorage.getItem(AUTH_KEY) === "1"; } catch { return false; }
|
||
}
|
||
function setDrillAuthLocal(ok) {
|
||
try {
|
||
if (ok) window.localStorage.setItem(AUTH_KEY, "1");
|
||
else window.localStorage.removeItem(AUTH_KEY);
|
||
} catch { /* ignore */ }
|
||
drillGrantState = { ready: true, granted: !!ok };
|
||
}
|
||
function writeDrillAuth(ok) {
|
||
drillGrantGen += 1;
|
||
const gen = drillGrantGen;
|
||
setDrillAuthLocal(ok);
|
||
if (!ok) return;
|
||
drillGrantPosting += 1;
|
||
fetch("/dsh-purge/drill-grant", {
|
||
method: "POST",
|
||
headers: { "content-type": "application/json" },
|
||
body: "{}",
|
||
}).then((r) => r.json()).then((saved) => {
|
||
if (gen !== drillGrantGen) return;
|
||
setDrillAuthLocal(!!(saved && saved.granted));
|
||
}).catch(() => { /* 页面先记住,下次同步再写入当前安装目录 */ })
|
||
.finally(() => { drillGrantPosting = Math.max(0, drillGrantPosting - 1); });
|
||
}
|
||
function syncDrillGrant(attempt) {
|
||
const n = attempt || 0;
|
||
const gen = drillGrantGen;
|
||
/* 复位上一次调度的重试 timer,避免多次 writeDrillAuth 叠加链条 */
|
||
if (drillSyncRetryTimer) { try { clearTimeout(drillSyncRetryTimer); } catch { /* ignore */ } drillSyncRetryTimer = null; }
|
||
fetch("/dsh-purge/drill-grant", { method: "GET" })
|
||
.then((r) => r.json())
|
||
.then((r) => {
|
||
if (gen !== drillGrantGen) return;
|
||
if (!r || r.ok === false) throw new Error("grant");
|
||
if (r.granted) {
|
||
setDrillAuthLocal(true);
|
||
if (dockBus.authOpen) setDock({ authOpen: false });
|
||
return;
|
||
}
|
||
if (drillGrantPosting > 0) return;
|
||
setDrillAuthLocal(false);
|
||
if (dockBus.authOpen || dockBus.tab === "drill") {
|
||
setDock({ open: true, tab: "drill", authOpen: true, pending: dockBus.pending || "tab" });
|
||
}
|
||
})
|
||
.catch(() => {
|
||
if (gen !== drillGrantGen) return;
|
||
if (n < 5) {
|
||
drillSyncRetryTimer = window.setTimeout(() => {
|
||
drillSyncRetryTimer = null;
|
||
syncDrillGrant(n + 1);
|
||
}, 400);
|
||
}
|
||
});
|
||
}
|
||
|
||
let dockBus = { open: false, tab: "clean", authOpen: false, pending: "tab", listeners: new Set() };
|
||
function getDock() { return dockBus; }
|
||
function setDock(patch) {
|
||
dockBus = Object.assign({}, dockBus, patch);
|
||
dockBus.listeners.forEach((fn) => { try { fn(dockBus); } catch { /* ignore */ } });
|
||
}
|
||
function useDock() {
|
||
const [st, setSt] = useState(dockBus);
|
||
useEffect(() => {
|
||
const fn = (next) => setSt(next);
|
||
dockBus.listeners.add(fn);
|
||
return () => { dockBus.listeners.delete(fn); };
|
||
}, []);
|
||
return st;
|
||
}
|
||
|
||
function requestDrill(kind) {
|
||
const pending = kind || "tab";
|
||
const openEnv = () => {
|
||
try {
|
||
if (__dshPurgeDrill && __dshPurgeDrill.setUI) {
|
||
__dshPurgeDrill.setUI({ open: true, tab: "env" });
|
||
}
|
||
} catch { /* ignore */ }
|
||
};
|
||
if (readDrillAuth()) {
|
||
setDock({ open: true, tab: "drill", authOpen: false, pending: "tab" });
|
||
try { if (__dshPurgeDrill && __dshPurgeDrill.setUI) __dshPurgeDrill.setUI({ open: true }); } catch { /* ignore */ }
|
||
if (pending === "env") openEnv();
|
||
if (pending === "full") {
|
||
try { window.open(window.location.href.split("#")[0] + "#redteam-full", "_blank", "noopener"); } catch { /* ignore */ }
|
||
}
|
||
return;
|
||
}
|
||
/* 未授权:直接切到演练台页签并弹出授权窗(不再停在清洗页,避免像「没触发」) */
|
||
setDock({ open: true, tab: "drill", authOpen: true, pending: pending });
|
||
}
|
||
|
||
function requestDrillEnv() {
|
||
requestDrill("env");
|
||
}
|
||
|
||
function openDockClean() {
|
||
setDock({ open: true, tab: "clean", authOpen: false, pending: "tab" });
|
||
}
|
||
function toggleDock() {
|
||
if (dockBus.open) setDock({ open: false, authOpen: false, pending: "tab" });
|
||
else openDockClean();
|
||
}
|
||
syncDrillGrant();
|
||
|
||
const DOCK_GEOM_KEY = "dsh-purge-dock-geom-v4";
|
||
/* 默认停在右上,底边留在输入框上面。拖动之后停在用户松开的位置,只保证不拖出窗口。 */
|
||
const DOCK_COMPOSER_CLEAR = 172;
|
||
function defaultDockGeom() {
|
||
const vw = typeof window !== "undefined" ? window.innerWidth : 1280;
|
||
const vh = typeof window !== "undefined" ? window.innerHeight : 800;
|
||
const w = Math.min(620, Math.max(380, vw - 16));
|
||
const y = 48;
|
||
const h = Math.max(240, Math.min(640, vh - y - DOCK_COMPOSER_CLEAR));
|
||
return { x: Math.max(8, vw - w - 8), y: y, w: w, h: h };
|
||
}
|
||
function clampDockGeom(g) {
|
||
const vw = typeof window !== "undefined" ? window.innerWidth : 1280;
|
||
const vh = typeof window !== "undefined" ? window.innerHeight : 800;
|
||
const w = Math.min(Math.max(380, Number(g && g.w) || 620), Math.max(380, vw - 16));
|
||
const h = Math.min(Math.max(240, Number(g && g.h) || 520), Math.max(240, vh - 16));
|
||
const x = Math.min(Math.max(8, Number(g && g.x) || 8), Math.max(8, vw - w - 8));
|
||
const y = Math.min(Math.max(8, Number(g && g.y) || 8), Math.max(8, vh - Math.min(h, vh - 16) - 8));
|
||
return { x: x, y: y, w: w, h: h };
|
||
}
|
||
function loadDockGeom() {
|
||
try {
|
||
const raw = JSON.parse(window.localStorage.getItem(DOCK_GEOM_KEY) || "null");
|
||
if (raw && typeof raw === "object") return clampDockGeom(raw);
|
||
} catch { /* ignore */ }
|
||
return clampDockGeom(defaultDockGeom());
|
||
}
|
||
function saveDockGeom(g) {
|
||
try { window.localStorage.setItem(DOCK_GEOM_KEY, JSON.stringify(clampDockGeom(g))); } catch { /* ignore */ }
|
||
}
|
||
|
||
function WelcomeNotice() {
|
||
const t = useT();
|
||
const [open, setOpen] = useState(() => !readWelcomeAck());
|
||
const [checked, setChecked] = useState(false);
|
||
if (!open) return null;
|
||
const repo = t("welcome.repo");
|
||
const node = h("div", { className: "dshp-auth-mask", role: "dialog", "aria-modal": "true" },
|
||
h("div", { className: "dshp-auth-modal", style: { width: "min(560px,100%)" } },
|
||
h("h2", null, t("welcome.title")),
|
||
h("div", { className: "muted", style: { color: "var(--dsw-alias-label-secondary,#999)", fontSize: 12 } }, t("welcome.lead")),
|
||
h("div", { className: "dshp-auth-warn" }, t("welcome.warn")),
|
||
h("div", { className: "dshp-auth-legal", style: { height: "auto", maxHeight: 280 } },
|
||
h("p", null, t("welcome.body")),
|
||
h("p", null,
|
||
h("a", {
|
||
href: repo,
|
||
target: "_blank",
|
||
rel: "noopener noreferrer",
|
||
style: { color: "var(--dsw-alias-label-brand, #8fbf9a)", wordBreak: "break-all" },
|
||
}, repo),
|
||
),
|
||
h("h3", null, t("welcome.bans")),
|
||
h("p", null, t("welcome.ban1")),
|
||
h("p", null, t("welcome.ban2")),
|
||
h("p", null, t("welcome.ban3")),
|
||
),
|
||
h("div", { className: "dshp-auth-checks" },
|
||
h("label", null,
|
||
h("input", {
|
||
type: "checkbox",
|
||
checked: checked,
|
||
onChange: (e) => setChecked(!!(e && e.target && e.target.checked)),
|
||
}),
|
||
h("span", null, t("welcome.check")),
|
||
),
|
||
),
|
||
h("div", { className: "dshp-auth-ops" },
|
||
h("button", {
|
||
type: "button",
|
||
className: "primary",
|
||
disabled: !checked,
|
||
onClick: () => {
|
||
if (!checked) return;
|
||
writeWelcomeAck();
|
||
setOpen(false);
|
||
},
|
||
}, t("welcome.ok")),
|
||
),
|
||
),
|
||
);
|
||
try {
|
||
const rd = require("react-dom");
|
||
if (rd && typeof rd.createPortal === "function" && typeof document !== "undefined" && document.body) {
|
||
return rd.createPortal(node, document.body);
|
||
}
|
||
} catch { /* ignore */ }
|
||
return node;
|
||
}
|
||
|
||
function AuthGate(props) {
|
||
const t = useT();
|
||
const [left, setLeft] = useState(10);
|
||
const [readEnd, setReadEnd] = useState(false);
|
||
const [checks, setChecks] = useState({ read: false, scope: false, ban: false });
|
||
const legalRef = useRef(null);
|
||
useEffect(() => {
|
||
const id = window.setInterval(() => {
|
||
setLeft((n) => {
|
||
if (n <= 1) { window.clearInterval(id); return 0; }
|
||
return n - 1;
|
||
});
|
||
}, 1000);
|
||
return () => window.clearInterval(id);
|
||
}, []);
|
||
useEffect(() => {
|
||
const el = legalRef.current;
|
||
if (!el) return;
|
||
if (el.scrollHeight <= el.clientHeight + 4) setReadEnd(true);
|
||
}, []);
|
||
const locked = left > 0 || !readEnd;
|
||
const ready = !locked && checks.read && checks.scope && checks.ban;
|
||
const hint = left > 0
|
||
? t("auth.hint.wait").replace("{n}", String(left))
|
||
: (readEnd ? t("auth.hint.check") : t("auth.hint.scroll"));
|
||
const onScroll = () => {
|
||
const el = legalRef.current;
|
||
if (!el) return;
|
||
if (el.scrollTop + el.clientHeight >= el.scrollHeight - 8) setReadEnd(true);
|
||
};
|
||
const toggle = (id) => {
|
||
if (locked) return;
|
||
setChecks((prev) => Object.assign({}, prev, { [id]: !prev[id] }));
|
||
};
|
||
const okLabel = !ready
|
||
? t("auth.ok")
|
||
: (props.pending === "full"
|
||
? t("auth.ok.full")
|
||
: (props.pending === "env" ? t("auth.ok.env") : t("auth.ok.tab")));
|
||
return h("div", { className: "dshp-auth-mask", role: "dialog", "aria-modal": "true" },
|
||
h("div", { className: "dshp-auth-modal" },
|
||
h("h2", null, t("auth.title")),
|
||
h("div", { className: "muted", style: { color: "var(--dsw-alias-label-secondary,#999)", fontSize: 12 } }, t("auth.lead")),
|
||
h("div", { className: "dshp-auth-warn" }, t("auth.warn")),
|
||
h("div", {
|
||
className: "dshp-auth-legal",
|
||
ref: legalRef,
|
||
onScroll,
|
||
dangerouslySetInnerHTML: { __html: AUTH_LEGAL_HTML },
|
||
}),
|
||
h("div", { style: { color: "var(--dsw-alias-label-secondary,#999)", fontSize: 12 } }, hint),
|
||
h("div", { className: "dshp-auth-checks" },
|
||
[["read", "auth.check.read"], ["scope", "auth.check.scope"], ["ban", "auth.check.ban"]].map((row) =>
|
||
h("label", { key: row[0], className: locked ? "locked" : "" },
|
||
h("input", {
|
||
type: "checkbox",
|
||
checked: !!checks[row[0]],
|
||
disabled: locked,
|
||
onChange: () => toggle(row[0]),
|
||
}),
|
||
h("span", null, t(row[1])),
|
||
),
|
||
),
|
||
),
|
||
h("div", { className: "dshp-auth-ops" },
|
||
h("button", { type: "button", onClick: () => setDock({ authOpen: false, tab: "clean", pending: "tab" }) }, t("auth.no")),
|
||
h("button", {
|
||
type: "button",
|
||
className: "primary",
|
||
disabled: !ready,
|
||
onClick: () => {
|
||
if (!ready) return;
|
||
writeDrillAuth(true);
|
||
const kind = props.pending || "tab";
|
||
setDock({ authOpen: false, open: true, tab: "drill", pending: "tab" });
|
||
try { if (__dshPurgeDrill && __dshPurgeDrill.setUI) __dshPurgeDrill.setUI({ open: true }); } catch { /* ignore */ }
|
||
if (kind === "env") {
|
||
try { if (__dshPurgeDrill && __dshPurgeDrill.setUI) __dshPurgeDrill.setUI({ open: true, tab: "env" }); } catch { /* ignore */ }
|
||
}
|
||
if (kind === "full") {
|
||
try { window.open(window.location.href.split("#")[0] + "#redteam-full", "_blank", "noopener"); } catch { /* ignore */ }
|
||
}
|
||
},
|
||
}, okLabel),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
function isRedteamPresetId(value) {
|
||
const text = String(value || "").trim();
|
||
if (!text) return false;
|
||
if (text === "redteam") return true;
|
||
if (/^red[\s_-]*team$/i.test(text)) return true;
|
||
const label = text.replace(/不是红队/g, "").replace(/非红队/g, "");
|
||
if (/红队/.test(label)) return true;
|
||
return false;
|
||
}
|
||
|
||
function readSessionAgentPreset(sess) {
|
||
if (!sess || typeof sess !== "object") return "";
|
||
try {
|
||
const pv = sess.projectionValues;
|
||
if (pv && typeof pv.agentPreset === "string") return pv.agentPreset;
|
||
} catch { /* ignore */ }
|
||
try {
|
||
if (typeof sess.agentPreset === "string") return sess.agentPreset;
|
||
} catch { /* ignore */ }
|
||
try {
|
||
const header = sess.header;
|
||
if (header && typeof header.agentPreset === "string") return header.agentPreset;
|
||
} catch { /* ignore */ }
|
||
return "";
|
||
}
|
||
|
||
function readSessionCwd(sess) {
|
||
if (!sess || typeof sess !== "object") return "";
|
||
try {
|
||
const pv = sess.projectionValues;
|
||
if (pv && typeof pv.cwd === "string" && pv.cwd.trim()) return pv.cwd.trim();
|
||
} catch { /* ignore */ }
|
||
try {
|
||
if (typeof sess.cwd === "string" && sess.cwd.trim()) return sess.cwd.trim();
|
||
} catch { /* ignore */ }
|
||
try {
|
||
const header = sess.header;
|
||
if (header && typeof header.cwd === "string" && header.cwd.trim()) return header.cwd.trim();
|
||
} catch { /* ignore */ }
|
||
return "";
|
||
}
|
||
|
||
function clientCurrentCwd() {
|
||
try {
|
||
const sessions = rewindSessions || (rewindHost && rewindHost.sessions);
|
||
if (!sessions) return "";
|
||
const sid = currentSessionId(sessions);
|
||
if (!sid) return "";
|
||
try {
|
||
const snap = typeof sessions.list?.getSnapshot === "function" ? sessions.list.getSnapshot() : null;
|
||
const sess = snap && snap.byId ? snap.byId[sid] : null;
|
||
const found = readSessionCwd(sess);
|
||
if (found) return found;
|
||
} catch { /* ignore */ }
|
||
try {
|
||
const actx = typeof sessions.scope === "function" ? sessions.scope(sid) : undefined;
|
||
const face = typeof sessions.sessionOf === "function" ? sessions.sessionOf(actx) : undefined;
|
||
const faceSnap = face && typeof face.getSnapshot === "function" ? face.getSnapshot() : face;
|
||
const found = readSessionCwd(faceSnap);
|
||
if (found) return found;
|
||
} catch { /* ignore */ }
|
||
} catch { /* ignore */ }
|
||
return "";
|
||
}
|
||
|
||
function isRedteamModeActive() {
|
||
try {
|
||
const sessions = rewindSessions || (rewindHost && rewindHost.sessions);
|
||
const sid = currentSessionId(sessions);
|
||
if (sid && sessions) {
|
||
try {
|
||
const snap = typeof sessions.list?.getSnapshot === "function" ? sessions.list.getSnapshot() : null;
|
||
const sess = snap && snap.byId ? snap.byId[sid] : null;
|
||
if (isRedteamPresetId(readSessionAgentPreset(sess))) return true;
|
||
} catch { /* ignore */ }
|
||
try {
|
||
const actx = typeof sessions.scope === "function" ? sessions.scope(sid) : undefined;
|
||
const face = typeof sessions.sessionOf === "function" ? sessions.sessionOf(actx) : undefined;
|
||
const faceSnap = face && typeof face.getSnapshot === "function" ? face.getSnapshot() : face;
|
||
if (isRedteamPresetId(readSessionAgentPreset(faceSnap))) return true;
|
||
} catch { /* ignore */ }
|
||
}
|
||
} catch { /* ignore */ }
|
||
/* 新会话:模式下拉里的「红队模式」chip(选好但还没写入 session)
|
||
* 优先走语义化 data-* 挂点,其次 class 子串模糊匹配 + 文本兜底;
|
||
* 不写死 CSS Module 哈希(HKgFRW_seat 这种每次构建都会变)。 */
|
||
try {
|
||
const semantic = document.querySelector(
|
||
"[data-agent-preset='redteam'], [data-preset-id='redteam'], [data-mode='redteam']"
|
||
);
|
||
if (semantic) return true;
|
||
const nodes = document.querySelectorAll(
|
||
"[class*='seat' i][class*='label' i], [class*='seatLabel' i], button[class*='seat' i], [role='button'][class*='seat' i]"
|
||
);
|
||
for (const el of nodes) {
|
||
const text = String(el.textContent || "").replace(/\s+/g, " ").trim();
|
||
if (/红队模式/.test(text) || /^RedTeam\b/i.test(text)) return true;
|
||
}
|
||
} catch { /* ignore */ }
|
||
try {
|
||
const labels = document.querySelectorAll(
|
||
"[class*='label' i][class*='preset' i], [class*='presetLabel' i], [title*='红队'], [title*='RedTeam']"
|
||
);
|
||
for (const el of labels) {
|
||
const text = String(el.textContent || "").trim();
|
||
if (/红队模式/.test(text) || /^RedTeam\b/i.test(text)) return true;
|
||
if (isRedteamPresetId(text)) return true;
|
||
}
|
||
} catch { /* ignore */ }
|
||
return false;
|
||
}
|
||
|
||
function looksLikeSendControl(el) {
|
||
if (!el || el.nodeType !== 1) return false;
|
||
const btn = el.closest("button, [role='button']");
|
||
if (!btn) return false;
|
||
if (btn.closest(".dshp-dock, .dshp-auth-mask, .rt-dock, .dshp-env-gate")) return false;
|
||
const label = [
|
||
btn.getAttribute("aria-label"),
|
||
btn.getAttribute("title"),
|
||
btn.getAttribute("data-testid"),
|
||
btn.textContent,
|
||
].filter(Boolean).join(" ");
|
||
if (/停止|Stop/i.test(label) && !/发送|Send/i.test(label)) return false;
|
||
if (/发送|Submit|Send(?!\s*feedback)/i.test(label)) return true;
|
||
/* composer 主按钮:data-composer-card 内的 primary */
|
||
try {
|
||
const card = btn.closest("[data-composer-card], [data-composer-seat]");
|
||
if (card) {
|
||
const cls = String(btn.className || "");
|
||
if (/primary/i.test(cls) && btn.querySelector("svg")) return true;
|
||
}
|
||
} catch { /* ignore */ }
|
||
if (btn.type === "submit") {
|
||
const form = btn.closest("form");
|
||
if (form && form.querySelector("textarea, [contenteditable='true']")) return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
function fetchEnvAdaptStatus() {
|
||
return fetch("/redteam/api", {
|
||
method: "POST",
|
||
headers: { "content-type": "application/json" },
|
||
body: JSON.stringify({ op: "platformEnvAdaptStatus" }),
|
||
}).then((r) => r.json()).catch(() => null);
|
||
}
|
||
|
||
/** 同步探测的占位:不再做同步 XHR(会卡 UI 主线程、Chrome 已逐步弃用)。
|
||
* 调用方 mustBlockSubmit 在 cache 过期/缺失时按"未知→保守 block"处理,
|
||
* 同时 EnvAdaptSendGate 的 useEffect 轮询会异步 refresh,下次调用即可放行。 */
|
||
function fetchEnvAdaptStatusSync() { return null; }
|
||
|
||
/** 红队发送前门禁:未就绪 → 授权并打开环境适配;可确认跳过。 */
|
||
function EnvAdaptSendGate() {
|
||
const t = useT();
|
||
const [open, setOpen] = useState(false);
|
||
const [confirmSkip, setConfirmSkip] = useState(false);
|
||
const [status, setStatus] = useState(null);
|
||
const [busy, setBusy] = useState(false);
|
||
const cacheRef = useRef({ at: 0, ready: false });
|
||
const openRef = useRef(false);
|
||
openRef.current = open;
|
||
|
||
const refresh = useCallback(async () => {
|
||
const r = await fetchEnvAdaptStatus();
|
||
const ready = !!(r && r.ready);
|
||
cacheRef.current = { at: Date.now(), ready: ready, skipped: !!(r && r.skipped) };
|
||
setStatus(r);
|
||
return r;
|
||
}, []);
|
||
|
||
const blockAndOpen = useCallback(() => {
|
||
setConfirmSkip(false);
|
||
refresh();
|
||
/* 第一次:只走完整授权窗 → 授权后进环境适配页 */
|
||
if (!readDrillAuth()) {
|
||
setOpen(false);
|
||
requestDrillEnv();
|
||
return true;
|
||
}
|
||
/* 已授权过:只弹精简环境适配窗,不再自动拉开整块演练台 */
|
||
setOpen(true);
|
||
return true;
|
||
}, [refresh]);
|
||
|
||
const mustBlockSubmit = useCallback(() => {
|
||
if (!isRedteamModeActive()) return false;
|
||
const cached = cacheRef.current;
|
||
/* cache 过期时触发异步刷新;本次调用按最后一次 cache 判断,未知保守 block */
|
||
if (!cached.at || Date.now() - cached.at > 2500) {
|
||
refresh().catch(() => {});
|
||
}
|
||
if (cached.ready) return false;
|
||
blockAndOpen();
|
||
return true;
|
||
}, [blockAndOpen, refresh]);
|
||
|
||
/* 挂住宿主 SessionInputShell.submit —— 比点按钮启发式可靠。
|
||
* patch 必须可逆:存 origSubmit 到 WeakMap,unmount / 依赖变更时恢复,
|
||
* 否则插件禁用后 shell.submit 还是我们的 wrapper,持死 mustBlockSubmit 闭包。 */
|
||
useEffect(() => {
|
||
const patched = new WeakMap();
|
||
const patchedShells = [];
|
||
const patchShell = (shell) => {
|
||
if (!shell || patched.has(shell)) return;
|
||
if (typeof shell.submit !== "function") return;
|
||
const origSubmit = shell.submit;
|
||
patched.set(shell, { origSubmit });
|
||
patchedShells.push(shell);
|
||
const bound = origSubmit.bind(shell);
|
||
shell.submit = function gatedSubmit(mode) {
|
||
try {
|
||
if (mustBlockSubmit()) return;
|
||
} catch { /* 门禁异常时不吞掉发送 */ }
|
||
return bound(mode);
|
||
};
|
||
};
|
||
const tick = () => {
|
||
try {
|
||
const sessions = rewindSessions || (rewindHost && rewindHost.sessions);
|
||
const sid = currentSessionId(sessions);
|
||
if (!sid) return;
|
||
patchShell(conversationInput(sid));
|
||
} catch { /* ignore */ }
|
||
};
|
||
tick();
|
||
const iv = window.setInterval(tick, 400);
|
||
return () => {
|
||
window.clearInterval(iv);
|
||
for (const shell of patchedShells) {
|
||
const info = patched.get(shell);
|
||
if (!info) continue;
|
||
try { shell.submit = info.origSubmit; } catch { /* ignore */ }
|
||
}
|
||
patchedShells.length = 0;
|
||
};
|
||
}, [mustBlockSubmit]);
|
||
|
||
useEffect(() => {
|
||
let alive = true;
|
||
const tick = () => {
|
||
if (!alive) return;
|
||
if (!isRedteamModeActive()) return;
|
||
refresh();
|
||
};
|
||
tick();
|
||
const iv = window.setInterval(tick, 5000);
|
||
return () => { alive = false; window.clearInterval(iv); };
|
||
}, [refresh]);
|
||
|
||
useEffect(() => {
|
||
const onPointer = (e) => {
|
||
if (!looksLikeSendControl(e.target)) return;
|
||
if (!mustBlockSubmit()) return;
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
if (typeof e.stopImmediatePropagation === "function") e.stopImmediatePropagation();
|
||
};
|
||
const onKey = (e) => {
|
||
if (e.key !== "Enter" || e.shiftKey || e.isComposing) return;
|
||
const inComposer = e.target && e.target.closest
|
||
&& e.target.closest("[data-composer-card], [data-composer-seat], [contenteditable='true'], textarea");
|
||
if (!inComposer) return;
|
||
if (e.target.closest && e.target.closest(".dshp-dock, .dshp-auth-mask, .rt-dock, .dshp-env-gate")) return;
|
||
if (!mustBlockSubmit()) return;
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
if (typeof e.stopImmediatePropagation === "function") e.stopImmediatePropagation();
|
||
};
|
||
document.addEventListener("pointerdown", onPointer, true);
|
||
document.addEventListener("click", onPointer, true);
|
||
document.addEventListener("keydown", onKey, true);
|
||
return () => {
|
||
document.removeEventListener("pointerdown", onPointer, true);
|
||
document.removeEventListener("click", onPointer, true);
|
||
document.removeEventListener("keydown", onKey, true);
|
||
};
|
||
}, [mustBlockSubmit]);
|
||
|
||
if (!open) return null;
|
||
|
||
const message = (status && status.message) || t("envGate.lead");
|
||
return h("div", { className: "dshp-auth-mask dshp-env-gate", role: "dialog", "aria-modal": "true" },
|
||
h("div", { className: "dshp-auth-modal", style: { width: "min(480px,100%)" } },
|
||
h("h2", null, t("envGate.title")),
|
||
h("div", { style: { fontSize: 13, lineHeight: 1.55 } }, message),
|
||
h("div", { style: { fontSize: 12, color: "var(--dsw-alias-label-secondary,#999)", lineHeight: 1.5 } }, t("envGate.kaliHint")),
|
||
confirmSkip
|
||
? h("div", { className: "dshp-auth-warn" }, t("envGate.skipConfirm"))
|
||
: null,
|
||
h("div", { className: "dshp-auth-ops" },
|
||
confirmSkip
|
||
? [
|
||
h("button", {
|
||
key: "no",
|
||
type: "button",
|
||
onClick: () => setConfirmSkip(false),
|
||
}, t("envGate.skipNo")),
|
||
h("button", {
|
||
key: "yes",
|
||
type: "button",
|
||
className: "primary",
|
||
disabled: busy,
|
||
onClick: () => {
|
||
setBusy(true);
|
||
fetch("/redteam/api", {
|
||
method: "POST",
|
||
headers: { "content-type": "application/json" },
|
||
body: JSON.stringify({ op: "platformEnvAdaptSkip", skip: true }),
|
||
}).then((r) => r.json()).then((r) => {
|
||
setBusy(false);
|
||
cacheRef.current = { at: Date.now(), ready: true, skipped: true };
|
||
setStatus(r);
|
||
setOpen(false);
|
||
setConfirmSkip(false);
|
||
}).catch(() => {
|
||
setBusy(false);
|
||
cacheRef.current = { at: Date.now(), ready: true, skipped: true };
|
||
setOpen(false);
|
||
});
|
||
},
|
||
}, t("envGate.skipYes")),
|
||
]
|
||
: [
|
||
h("button", {
|
||
key: "skip",
|
||
type: "button",
|
||
onClick: () => setConfirmSkip(true),
|
||
}, t("envGate.skip")),
|
||
h("button", {
|
||
key: "go",
|
||
type: "button",
|
||
className: "primary",
|
||
onClick: () => {
|
||
setOpen(false);
|
||
requestDrillEnv();
|
||
},
|
||
}, t("envGate.go")),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
function PurgeDock() {
|
||
const t = useT();
|
||
const st = useDock();
|
||
const [geom, setGeom] = useState(() => loadDockGeom());
|
||
const [dockTheme, setDockTheme] = useState(detectHostTheme);
|
||
useEffect(() => {
|
||
const sync = () => setDockTheme(detectHostTheme());
|
||
sync();
|
||
const obs = new MutationObserver(sync);
|
||
if (document.body) obs.observe(document.body, { attributes: true, attributeFilter: ["data-ds-dark-theme", "class", "style"] });
|
||
obs.observe(document.documentElement, { attributes: true, attributeFilter: ["data-ds-dark-theme", "class", "style"] });
|
||
const timer = window.setInterval(sync, 1500);
|
||
return () => { obs.disconnect(); window.clearInterval(timer); };
|
||
}, []);
|
||
useEffect(() => {
|
||
let uiTag = document.querySelector('style[data-dsh-purge-ui="1"]');
|
||
if (!uiTag) {
|
||
uiTag = document.createElement("style");
|
||
uiTag.setAttribute("data-dsh-purge-ui", "1");
|
||
uiTag.textContent = PURGE_CSS;
|
||
document.head.append(uiTag);
|
||
}
|
||
if (!__dshPurgeDrill || !__dshPurgeDrill.CSS) return undefined;
|
||
const styleTag = document.createElement("style");
|
||
styleTag.setAttribute("data-dsh-purge-drill", "1");
|
||
styleTag.textContent = __dshPurgeDrill.CSS;
|
||
document.head.append(styleTag);
|
||
const widthTag = document.createElement("style");
|
||
widthTag.setAttribute("data-dsh-purge-dock-w", "1");
|
||
widthTag.textContent = ":root{--dshp-dock-w:" + geom.w + "px;--dshp-dock-h:" + geom.h + "px}";
|
||
document.head.append(widthTag);
|
||
return () => { styleTag.remove(); widthTag.remove(); };
|
||
}, []);
|
||
useEffect(() => {
|
||
saveDockGeom(geom);
|
||
const tag = document.querySelector('style[data-dsh-purge-dock-w="1"]');
|
||
if (tag) tag.textContent = ":root{--dshp-dock-w:" + geom.w + "px;--dshp-dock-h:" + geom.h + "px}";
|
||
}, [geom]);
|
||
useEffect(() => {
|
||
const onResize = () => setGeom((g) => clampDockGeom(g));
|
||
window.addEventListener("resize", onResize);
|
||
return () => window.removeEventListener("resize", onResize);
|
||
}, []);
|
||
useEffect(() => {
|
||
if (st.tab === "drill" && readDrillAuth() && __dshPurgeDrill && __dshPurgeDrill.setUI) {
|
||
try { __dshPurgeDrill.setUI({ open: true }); } catch { /* ignore */ }
|
||
}
|
||
}, [st.tab, st.open]);
|
||
const onTab = (id) => {
|
||
if (id === "drill") { requestDrill("tab"); return; }
|
||
setDock({ tab: "clean", authOpen: false, pending: "tab" });
|
||
};
|
||
const startDrag = (e) => {
|
||
if (e.button !== 0) return;
|
||
if (e.target && e.target.closest && e.target.closest("button,a,input,select,textarea,label")) return;
|
||
e.preventDefault();
|
||
const sx = e.clientX;
|
||
const sy = e.clientY;
|
||
const ox = geom.x;
|
||
const oy = geom.y;
|
||
const move = (ev) => setGeom((g) => clampDockGeom({ x: ox + (ev.clientX - sx), y: oy + (ev.clientY - sy), w: g.w, h: g.h }));
|
||
const up = () => {
|
||
window.removeEventListener("mousemove", move);
|
||
window.removeEventListener("mouseup", up);
|
||
};
|
||
window.addEventListener("mousemove", move);
|
||
window.addEventListener("mouseup", up);
|
||
};
|
||
const startResize = (e) => {
|
||
if (e.button !== 0) return;
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
const sx = e.clientX;
|
||
const sy = e.clientY;
|
||
const ow = geom.w;
|
||
const oh = geom.h;
|
||
const move = (ev) => setGeom((g) => clampDockGeom({ x: g.x, y: g.y, w: ow + (ev.clientX - sx), h: oh + (ev.clientY - sy) }));
|
||
const up = () => {
|
||
window.removeEventListener("mousemove", move);
|
||
window.removeEventListener("mouseup", up);
|
||
};
|
||
window.addEventListener("mousemove", move);
|
||
window.addEventListener("mouseup", up);
|
||
};
|
||
const startResizeLeft = (e) => {
|
||
if (e.button !== 0) return;
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
const sx = e.clientX;
|
||
const ox = geom.x;
|
||
const ow = geom.w;
|
||
const move = (ev) => {
|
||
const dx = ev.clientX - sx;
|
||
setGeom((g) => clampDockGeom({ x: ox + dx, y: g.y, w: ow - dx, h: g.h }));
|
||
};
|
||
const up = () => {
|
||
window.removeEventListener("mousemove", move);
|
||
window.removeEventListener("mouseup", up);
|
||
};
|
||
window.addEventListener("mousemove", move);
|
||
window.addEventListener("mouseup", up);
|
||
};
|
||
const startResizeRight = (e) => {
|
||
if (e.button !== 0) return;
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
const sx = e.clientX;
|
||
const ow = geom.w;
|
||
const move = (ev) => setGeom((g) => clampDockGeom({ x: g.x, y: g.y, w: ow + (ev.clientX - sx), h: g.h }));
|
||
const up = () => {
|
||
window.removeEventListener("mousemove", move);
|
||
window.removeEventListener("mouseup", up);
|
||
};
|
||
window.addEventListener("mousemove", move);
|
||
window.addEventListener("mouseup", up);
|
||
};
|
||
const startResizeBottom = (e) => {
|
||
if (e.button !== 0) return;
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
const sy = e.clientY;
|
||
const oh = geom.h;
|
||
const move = (ev) => setGeom((g) => clampDockGeom({ x: g.x, y: g.y, w: g.w, h: oh + (ev.clientY - sy) }));
|
||
const up = () => {
|
||
window.removeEventListener("mousemove", move);
|
||
window.removeEventListener("mouseup", up);
|
||
};
|
||
window.addEventListener("mousemove", move);
|
||
window.addEventListener("mouseup", up);
|
||
};
|
||
const body = st.tab === "clean"
|
||
? h(SettingsRoot, { t })
|
||
: (readDrillAuth() && __dshPurgeDrill.Panel
|
||
? h(__dshPurgeDrill.Panel, { embedded: true })
|
||
: h("div", { style: { padding: 16, color: "var(--dsw-alias-label-secondary)" } }, t("dock.unauthorized")));
|
||
const panel = h("div", {
|
||
className: "dshp-dock",
|
||
"data-theme": dockTheme === "white" ? "white" : "dusk",
|
||
"data-open": st.open ? "1" : "0",
|
||
style: {
|
||
left: geom.x + "px",
|
||
top: geom.y + "px",
|
||
right: "auto",
|
||
bottom: "auto",
|
||
width: geom.w + "px",
|
||
height: geom.h + "px",
|
||
},
|
||
},
|
||
h("div", { className: "dshp-dock-head", onMouseDown: startDrag, title: "按住拖动面板" },
|
||
h("b", null, "dsh-purge"),
|
||
h("span", { style: { flex: 1 } }),
|
||
st.tab === "drill" && readDrillAuth()
|
||
? h("button", { type: "button", onClick: () => requestDrill("full") }, t("dock.full"))
|
||
: null,
|
||
h("button", { type: "button", onClick: () => setDock({ open: false, authOpen: false, tab: "clean", pending: "tab" }) }, t("dock.close")),
|
||
),
|
||
h("div", { className: "dshp-dock-tabs", role: "tablist" },
|
||
h("button", {
|
||
type: "button",
|
||
className: "dshp-dock-tab" + (st.tab === "clean" ? " on" : ""),
|
||
role: "tab",
|
||
"aria-selected": st.tab === "clean" ? "true" : "false",
|
||
onClick: () => onTab("clean"),
|
||
}, t("dock.clean")),
|
||
h("button", {
|
||
type: "button",
|
||
className: "dshp-dock-tab" + (st.tab === "drill" ? " on" : ""),
|
||
role: "tab",
|
||
"aria-selected": st.tab === "drill" ? "true" : "false",
|
||
onClick: () => onTab("drill"),
|
||
}, t("dock.drill") + (readDrillAuth() ? "" : " · " + t("dock.unauthorized"))),
|
||
),
|
||
h("div", { className: "dshp-dock-body" }, body),
|
||
h("div", { className: "dshp-dock-resize-l", title: "拖动调整宽度", onMouseDown: startResizeLeft }),
|
||
h("div", { className: "dshp-dock-resize-r", title: "拖动调整宽度", onMouseDown: startResizeRight }),
|
||
h("div", { className: "dshp-dock-resize-b", title: "拖动调整高度", onMouseDown: startResizeBottom }),
|
||
h("div", { className: "dshp-dock-resize", title: "拖动调整大小", onMouseDown: startResize }),
|
||
);
|
||
let portal = null;
|
||
let authPortal = null;
|
||
const authNode = st.authOpen ? h(AuthGate, { pending: st.pending }) : null;
|
||
try {
|
||
const rd = require("react-dom");
|
||
if (rd && typeof rd.createPortal === "function" && typeof document !== "undefined" && document.body) {
|
||
portal = rd.createPortal(panel, document.body);
|
||
if (authNode) authPortal = rd.createPortal(authNode, document.body);
|
||
}
|
||
} catch { /* host 可能没暴露 react-dom */ }
|
||
return h(react.Fragment, null,
|
||
portal || panel,
|
||
authPortal || authNode,
|
||
);
|
||
}
|
||
|
||
function hasInlinePurgeEntry() {
|
||
if (typeof document === "undefined") return false;
|
||
if (document.querySelector("[data-testid='dsh-purge-hero-chip'], [data-testid='dsh-purge-tab-chip']")) return true;
|
||
const nodes = document.querySelectorAll("button, [role='tab'], [role='button'], [class*='chip']");
|
||
for (let i = 0; i < nodes.length; i += 1) {
|
||
const el = nodes[i];
|
||
if (!el || el.classList.contains("dshp-launch")) continue;
|
||
if (el.closest && el.closest(".dshp-dock, .dshp-launch")) continue;
|
||
if (String(el.textContent || "").trim() === "dsh-purge") return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
function PurgeLauncher() {
|
||
const t = useT();
|
||
const st = useDock();
|
||
const [hide, setHide] = useState(hasInlinePurgeEntry);
|
||
useEffect(() => {
|
||
const sync = () => setHide(hasInlinePurgeEntry());
|
||
sync();
|
||
const obs = typeof MutationObserver !== "undefined"
|
||
? new MutationObserver(sync)
|
||
: null;
|
||
if (obs && document.body) obs.observe(document.body, { childList: true, subtree: true });
|
||
const iv = setInterval(sync, 800);
|
||
return () => {
|
||
if (obs) obs.disconnect();
|
||
clearInterval(iv);
|
||
};
|
||
}, []);
|
||
if (st.open || hide) return null;
|
||
return h("button", {
|
||
type: "button",
|
||
className: "dshp-launch",
|
||
"data-open": "0",
|
||
"data-hide": "0",
|
||
"data-testid": "dsh-purge-launch",
|
||
title: t("dock.newSession"),
|
||
onClick: () => toggleDock(),
|
||
}, t("dock.newSession"));
|
||
}
|
||
|
||
/** 首页:模式旁挂「dsh-purge」→ 打开插件。红队模式走模式下拉(registry 声明)。 */
|
||
function HeroNewSessionMount() {
|
||
const t = useT();
|
||
const st = useDock();
|
||
const [host, setHost] = useState(null);
|
||
useEffect(() => {
|
||
let dead = false;
|
||
let warnedMiss = false;
|
||
const ensure = () => {
|
||
if (dead || typeof document === "undefined") return;
|
||
/* 多路挂点兜底:语义化 data-* 优先,class 子串模糊匹配兜底(不绑定具体 CSS Module 哈希) */
|
||
const row =
|
||
document.querySelector('[data-dsh-hero-workspace-row]') ||
|
||
document.querySelector('[data-conversation-hero]') ||
|
||
document.querySelector('[class*="heroWorkspaceRow" i]') ||
|
||
document.querySelector('[class*="hero" i][class*="workspace" i][class*="row" i]');
|
||
if (!row) {
|
||
if (!warnedMiss) {
|
||
warnedMiss = true;
|
||
try { console.warn("[dsh-purge] HeroNewSessionMount: anchor row missing (host 可能改版)"); } catch { /* ignore */ }
|
||
}
|
||
setHost((prev) => (prev ? null : prev));
|
||
return;
|
||
}
|
||
warnedMiss = false;
|
||
let el = row.querySelector(":scope > .dshp-hero-chip");
|
||
if (!el) {
|
||
el = document.createElement("div");
|
||
el.className = "dshp-hero-chip";
|
||
el.setAttribute("data-testid", "dsh-purge-hero-chip");
|
||
row.appendChild(el);
|
||
}
|
||
setHost((prev) => (prev === el ? prev : el));
|
||
};
|
||
ensure();
|
||
const obs = typeof MutationObserver !== "undefined"
|
||
? new MutationObserver(() => ensure())
|
||
: null;
|
||
if (obs) obs.observe(document.body, { childList: true, subtree: true });
|
||
const iv = setInterval(ensure, 1000);
|
||
return () => {
|
||
dead = true;
|
||
if (obs) obs.disconnect();
|
||
clearInterval(iv);
|
||
};
|
||
}, []);
|
||
if (!host) return null;
|
||
const btn = h("button", {
|
||
type: "button",
|
||
className: "dshp-hero-chip-btn" + (st.open ? " on" : ""),
|
||
title: t("dock.newSession"),
|
||
onClick: () => toggleDock(),
|
||
},
|
||
st.open ? h("span", { className: "dshp-live-dot", "aria-hidden": "true" }) : null,
|
||
t("dock.newSession"),
|
||
);
|
||
try {
|
||
const rd = require("react-dom");
|
||
if (rd && typeof rd.createPortal === "function") return rd.createPortal(btn, host);
|
||
} catch { /* host 可能没暴露 react-dom */ }
|
||
return null;
|
||
}
|
||
|
||
/** 挂在「对话 / 轨迹 / 上下文」这一排,紧挨上下文。 */
|
||
function TabRowPurgeMount() {
|
||
const t = useT();
|
||
const st = useDock();
|
||
const [host, setHost] = useState(null);
|
||
useEffect(() => {
|
||
let dead = false;
|
||
let warnedMiss = false;
|
||
const ensure = () => {
|
||
if (dead || typeof document === "undefined") return;
|
||
/* 多路挂点兜底:data-* 优先,ARIA / class 子串兜底 */
|
||
const row =
|
||
document.querySelector("[data-conversation-tabs]") ||
|
||
document.querySelector("[data-conversation-tabrow]") ||
|
||
document.querySelector("[role='tablist'][aria-label*='对话' i]") ||
|
||
document.querySelector("[class*='conversation' i][class*='tab' i][class*='row' i]");
|
||
if (!row) {
|
||
if (!warnedMiss) {
|
||
warnedMiss = true;
|
||
try { console.warn("[dsh-purge] TabRowPurgeMount: tab row anchor missing"); } catch { /* ignore */ }
|
||
}
|
||
setHost((prev) => (prev ? null : prev));
|
||
return;
|
||
}
|
||
warnedMiss = false;
|
||
let el = row.querySelector(":scope > .dshp-tab-chip");
|
||
if (!el) {
|
||
el = document.createElement("div");
|
||
el.className = "dshp-tab-chip";
|
||
el.setAttribute("data-testid", "dsh-purge-tab-chip");
|
||
row.appendChild(el);
|
||
}
|
||
setHost((prev) => (prev === el ? prev : el));
|
||
};
|
||
ensure();
|
||
const obs = typeof MutationObserver !== "undefined"
|
||
? new MutationObserver(() => ensure())
|
||
: null;
|
||
if (obs) obs.observe(document.body, { childList: true, subtree: true });
|
||
const iv = setInterval(ensure, 1000);
|
||
return () => {
|
||
dead = true;
|
||
if (obs) obs.disconnect();
|
||
clearInterval(iv);
|
||
};
|
||
}, []);
|
||
if (!host) return null;
|
||
const btn = h("button", {
|
||
type: "button",
|
||
className: "dshp-tab-btn" + (st.open ? " on" : ""),
|
||
title: st.open ? (t("dock.inSession") + " · 已开启") : (t("dock.inSession") + " · 点击打开"),
|
||
onClick: () => toggleDock(),
|
||
}, t("dock.inSession"));
|
||
try {
|
||
const rd = require("react-dom");
|
||
if (rd && typeof rd.createPortal === "function") return rd.createPortal(btn, host);
|
||
} catch { /* host 可能没暴露 react-dom */ }
|
||
return null;
|
||
}
|
||
|
||
|
||
function installRewindUi(ctx) {
|
||
rewindHost = ctx;
|
||
rewindSessions = ctx.sessions;
|
||
rewindWorkspace = ctx.uiWorkspace || null;
|
||
rewindConversation = ctx.conversation || null;
|
||
ctx.slots.inject("conversation.input.right", () => ctx.slots.register({
|
||
name: "conversation.input.right",
|
||
id: "dsh-purge-rewind",
|
||
order: 20,
|
||
inject: (sessionId) => ({
|
||
sessionId,
|
||
t: ctx.locale.bind(NS),
|
||
}),
|
||
}, RewindSafe));
|
||
// ctx.slots.inject("conversation.input.right", () => ctx.slots.register({
|
||
// name: "conversation.input.right",
|
||
// id: "dsh-purge-continue",
|
||
// order: 21,
|
||
// inject: (sessionId) => ({
|
||
// sessionId,
|
||
// t: ctx.locale.bind(NS),
|
||
// }),
|
||
// }, ContinueSafe));
|
||
ctx.effect(() => installRewindWatch(ctx), "dsh-purge: rewind watch");
|
||
}
|
||
|
||
function apply(ctx) {
|
||
ctx.effect(() => ctx.locale.register(NS, { zh, en }), "dsh-purge: dictionaries");
|
||
const t = ctx.locale.bind(NS);
|
||
translate = t;
|
||
ctx.slots.inject("shell.overlay", () => ctx.slots.register({
|
||
name: "shell.overlay",
|
||
id: "dsh-purge-dock",
|
||
order: 50,
|
||
}, () => h(react.Fragment, null, h(WelcomeNotice), h(PurgeLauncher), h(PurgeDock), h(HeroNewSessionMount), h(TabRowPurgeMount), h(EnvAdaptSendGate))));
|
||
try {
|
||
if (typeof ctx.inject === "function") {
|
||
ctx.inject(["sessions", "uiWorkspace", "workspaces", "conversation"], (host) => installRewindUi(host));
|
||
} else if (ctx.sessions) {
|
||
installRewindUi(ctx);
|
||
}
|
||
} catch (e) {
|
||
try {
|
||
if (typeof ctx.inject === "function") {
|
||
ctx.inject(["sessions", "uiWorkspace", "conversation"], (host) => installRewindUi(host));
|
||
}
|
||
} catch (e2) {
|
||
try { console.warn("[dsh-purge] rewind ui skipped:", e2 || e); } catch { /* ignore */ }
|
||
}
|
||
}
|
||
}
|
||
|
||
exports.name = name;
|
||
exports.inject = inject;
|
||
exports.apply = apply;
|
||
return module.exports;
|
||
}
|
||
});
|