1472 lines
60 KiB
JavaScript
1472 lines
60 KiB
JavaScript
const state = {
|
|
currentSection: "overview",
|
|
health: null,
|
|
agents: [],
|
|
tasks: [],
|
|
metrics: {},
|
|
modelConfig: { value: {}, version: 1 },
|
|
siteScan: {
|
|
status: "idle",
|
|
host: "",
|
|
ports: "80,443,3000,5173,8000-8010,8080,8081,9000",
|
|
sites: [],
|
|
message: "",
|
|
},
|
|
systemTest: {
|
|
targetUrl: "",
|
|
assignedAgentId: "",
|
|
maxDepth: 2,
|
|
status: "idle",
|
|
message: "",
|
|
},
|
|
discovery: {
|
|
status: "idle",
|
|
targetUrl: "",
|
|
planName: "智能探索 P0",
|
|
candidates: [],
|
|
summary: null,
|
|
message: "",
|
|
},
|
|
toolConfig: { tools: [], config: {}, version: 1 },
|
|
report: null,
|
|
selectedTaskId: "",
|
|
selectedToolAgent: "",
|
|
selectedToolPlan: "",
|
|
};
|
|
|
|
const titles = {
|
|
overview: "总览",
|
|
plans: "系统测试",
|
|
console: "运行控制台",
|
|
devices: "设备监控",
|
|
models: "模型配置",
|
|
tools: "Tool 配置",
|
|
metrics: "指标看板",
|
|
reports: "报告中心",
|
|
};
|
|
|
|
const $ = (selector, root = document) => root.querySelector(selector);
|
|
const $$ = (selector, root = document) => Array.from(root.querySelectorAll(selector));
|
|
|
|
function icon(name) {
|
|
return `<svg aria-hidden="true"><use href="#i-${name}"></use></svg>`;
|
|
}
|
|
|
|
function escapeHtml(value) {
|
|
return String(value ?? "")
|
|
.replaceAll("&", "&")
|
|
.replaceAll("<", "<")
|
|
.replaceAll(">", ">")
|
|
.replaceAll('"', """)
|
|
.replaceAll("'", "'");
|
|
}
|
|
|
|
function formatDate(value) {
|
|
if (!value) return "-";
|
|
const date = new Date(value);
|
|
if (Number.isNaN(date.getTime())) return escapeHtml(value);
|
|
return date.toLocaleString();
|
|
}
|
|
|
|
function percent(value) {
|
|
const n = Number(value || 0);
|
|
return `${n.toFixed(n % 1 ? 1 : 0)}%`;
|
|
}
|
|
|
|
function duration(value) {
|
|
if (value == null || value === "") return "-";
|
|
const n = Number(value);
|
|
if (Number.isNaN(n)) return "-";
|
|
return n >= 60 ? `${(n / 60).toFixed(1)} min` : `${n.toFixed(1)} s`;
|
|
}
|
|
|
|
function statusBadge(status) {
|
|
const normalized = String(status || "-");
|
|
const ok = ["online", "idle", "succeeded", "healthy"].includes(normalized);
|
|
const warn = ["ready", "routed", "running", "registered", "claimed"].includes(normalized);
|
|
const fail = ["failed", "cancelled", "offline"].includes(normalized);
|
|
const cls = ok ? "ok" : fail ? "fail" : warn ? "warn" : "info";
|
|
return `<span class="badge ${cls}">${escapeHtml(normalized)}</span>`;
|
|
}
|
|
|
|
function showToast(message) {
|
|
const toast = $("#toast");
|
|
toast.textContent = message;
|
|
toast.hidden = false;
|
|
clearTimeout(showToast.timer);
|
|
showToast.timer = setTimeout(() => {
|
|
toast.hidden = true;
|
|
}, 3200);
|
|
}
|
|
|
|
async function fetchJson(url, options = {}) {
|
|
const response = await fetch(url, {
|
|
...options,
|
|
headers: {
|
|
Accept: "application/json",
|
|
...(options.body ? { "Content-Type": "application/json; charset=utf-8" } : {}),
|
|
...(options.headers || {}),
|
|
},
|
|
});
|
|
const data = await response.json().catch(() => ({}));
|
|
if (!response.ok || data.ok === false) {
|
|
throw new Error(data.error || `${response.status} ${response.statusText}`);
|
|
}
|
|
return data;
|
|
}
|
|
|
|
async function postJson(url, payload = {}) {
|
|
return fetchJson(url, { method: "POST", body: JSON.stringify(payload) });
|
|
}
|
|
|
|
async function refreshAll() {
|
|
$("#refreshButton").disabled = true;
|
|
try {
|
|
const [health, dashboard, report] = await Promise.all([
|
|
fetchJson("/health"),
|
|
fetchJson("/api/dashboard"),
|
|
fetchJson("/api/reports/summary"),
|
|
]);
|
|
state.health = health;
|
|
state.agents = dashboard.agents || [];
|
|
state.tasks = dashboard.tasks || [];
|
|
state.metrics = dashboard.metrics || {};
|
|
state.modelConfig = dashboard.model_config || { value: {}, version: 1 };
|
|
state.toolConfig = dashboard.tool_config || { tools: [], config: {}, version: 1 };
|
|
state.report = report.report;
|
|
renderAll();
|
|
} catch (error) {
|
|
renderHealthError(error);
|
|
showToast(error.message);
|
|
} finally {
|
|
$("#refreshButton").disabled = false;
|
|
}
|
|
}
|
|
|
|
function renderHealthError(error) {
|
|
const pill = $("#healthPill");
|
|
pill.classList.remove("ok");
|
|
pill.classList.add("fail");
|
|
pill.textContent = "offline";
|
|
$("#serviceMeta").textContent = error.message;
|
|
}
|
|
|
|
function renderChrome() {
|
|
const health = state.health;
|
|
const pill = $("#healthPill");
|
|
pill.classList.toggle("ok", Boolean(health?.ok));
|
|
pill.classList.toggle("fail", !health?.ok);
|
|
pill.textContent = health?.ok ? "healthy" : "attention";
|
|
$("#sectionTitle").textContent = titles[state.currentSection];
|
|
$("#serviceMeta").textContent = health
|
|
? `${health.name} · ${health.public_base_url} · ${health.deployment_mode}`
|
|
: "Loading...";
|
|
}
|
|
|
|
function renderAll() {
|
|
renderChrome();
|
|
renderCurrentView();
|
|
}
|
|
|
|
function renderCurrentView() {
|
|
if (state.currentSection === "overview") renderOverview();
|
|
if (state.currentSection === "plans") renderPlans();
|
|
if (state.currentSection === "console") renderConsole();
|
|
if (state.currentSection === "devices") renderDevices();
|
|
if (state.currentSection === "models") renderModels();
|
|
if (state.currentSection === "tools") renderTools();
|
|
if (state.currentSection === "metrics") renderMetrics();
|
|
if (state.currentSection === "reports") renderReports();
|
|
}
|
|
|
|
function kpi(label, value, hint) {
|
|
return `<div class="kpi"><label>${escapeHtml(label)}</label><strong>${escapeHtml(value)}</strong><span>${escapeHtml(hint)}</span></div>`;
|
|
}
|
|
|
|
function renderOverview() {
|
|
const m = state.metrics;
|
|
const health = state.health;
|
|
$("#view-overview").innerHTML = `
|
|
<div class="view-grid">
|
|
<div class="kpi-grid">
|
|
${kpi("在线被控端", `${m.agent_active || 0}/${m.agent_total || 0}`, "盒子物理机 / 虚拟机")}
|
|
${kpi("待执行任务", m.ready_tasks || 0, "ready + routed")}
|
|
${kpi("运行中", m.running_tasks || 0, "Agent 占用")}
|
|
${kpi("通过率", percent(m.pass_rate), `${m.succeeded_tasks || 0} passed / ${m.failed_tasks || 0} failed`)}
|
|
</div>
|
|
|
|
<div class="view-grid two-col">
|
|
<section class="surface">
|
|
<div class="surface-header">
|
|
<h2>主控健康</h2>
|
|
${statusBadge(health?.ok ? "healthy" : "attention")}
|
|
</div>
|
|
<div class="surface-body">
|
|
<dl class="kv">
|
|
<dt>数据库</dt><dd>${health?.checks?.database?.ok ? "OK" : "FAIL"}</dd>
|
|
<dt>任务数</dt><dd>${escapeHtml(health?.checks?.database?.task_count ?? 0)}</dd>
|
|
<dt>端口</dt><dd>${escapeHtml(`${health?.checks?.port?.host || "-"}:${health?.checks?.port?.port || "-"}`)}</dd>
|
|
<dt>静态面板</dt><dd>${health?.checks?.static_panel?.ok ? "OK" : "MISSING"}</dd>
|
|
<dt>启动时间</dt><dd>${formatDate(health?.started_at)}</dd>
|
|
</dl>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="surface">
|
|
<div class="surface-header">
|
|
<h2>最近被控端</h2>
|
|
<span class="muted">${state.agents.length} devices</span>
|
|
</div>
|
|
<div class="surface-body">
|
|
${renderAgentMiniList()}
|
|
</div>
|
|
</section>
|
|
</div>
|
|
|
|
<section class="surface">
|
|
<div class="surface-header">
|
|
<h2>最近任务</h2>
|
|
<button class="button" type="button" data-section-jump="console">${icon("play")}打开控制台</button>
|
|
</div>
|
|
<div class="table-wrap">${renderTaskTable(state.tasks.slice(0, 8), { actions: false })}</div>
|
|
</section>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function renderAgentMiniList() {
|
|
if (!state.agents.length) return `<p class="empty">暂无被控端接入</p>`;
|
|
return `
|
|
<div class="device-grid">
|
|
${state.agents.slice(0, 4).map((agent) => `
|
|
<article class="device-card">
|
|
<header>
|
|
<div>
|
|
<strong>${escapeHtml(agent.display_name || agent.public_id)}</strong>
|
|
<span class="muted">${escapeHtml(agent.deployment_type || "-")}</span>
|
|
</div>
|
|
${statusBadge(agent.status)}
|
|
</header>
|
|
<dl class="kv">
|
|
<dt>IP</dt><dd>${escapeHtml(agent.reported_ip || agent.reported_host || "-")}</dd>
|
|
<dt>日志端口</dt><dd>${escapeHtml(agent.reported_log_web_port || "-")}</dd>
|
|
<dt>心跳</dt><dd>${formatDate(agent.last_seen_at)}</dd>
|
|
</dl>
|
|
</article>
|
|
`).join("")}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function renderDiscoveryPanel() {
|
|
const discovery = state.discovery;
|
|
const cfg = state.modelConfig.value || {};
|
|
const targetUrl = discovery.targetUrl || cfg.target_system_url || "";
|
|
const selectedCount = (discovery.candidates || []).filter((candidate) => candidate.selected !== false).length;
|
|
return `
|
|
<section class="surface discovery-panel" style="grid-column:1/-1">
|
|
<div class="surface-header">
|
|
<h2>智能探索 P0</h2>
|
|
<div class="toolbar">
|
|
<button class="button" type="button" data-action="discover-site" ${discovery.status === "running" ? "disabled" : ""}>${icon("activity")}发现任务</button>
|
|
<button class="button primary" type="button" data-action="import-discovery-tasks" ${selectedCount ? "" : "disabled"}>${icon("upload")}确认入队</button>
|
|
</div>
|
|
</div>
|
|
<div class="surface-body">
|
|
<div class="discovery-fields">
|
|
<div class="field">
|
|
<label for="discoveryTargetUrl">待测系统 URL</label>
|
|
<input id="discoveryTargetUrl" type="url" value="${escapeHtml(targetUrl)}" placeholder="http://host:port" />
|
|
</div>
|
|
<div class="field">
|
|
<label for="discoveryPlanName">计划名称</label>
|
|
<input id="discoveryPlanName" value="${escapeHtml(discovery.planName)}" />
|
|
</div>
|
|
</div>
|
|
<div id="discoveryResult">${renderDiscoveryResult()}</div>
|
|
</div>
|
|
</section>
|
|
`;
|
|
}
|
|
|
|
function renderDiscoveryResult() {
|
|
const discovery = state.discovery;
|
|
if (discovery.status === "idle") {
|
|
return `<div class="discovery-empty">输入待测系统地址后点击“发现任务”,Agent 会先基于首页结构拆出 P0 子任务。</div>`;
|
|
}
|
|
if (discovery.status === "running") {
|
|
return `<div class="discovery-empty">正在读取页面结构并生成候选任务...</div>`;
|
|
}
|
|
if (discovery.status === "error") {
|
|
return `<div class="discovery-empty fail">${escapeHtml(discovery.message || "发现任务失败")}</div>`;
|
|
}
|
|
|
|
const candidates = discovery.candidates || [];
|
|
if (!candidates.length) {
|
|
return `<div class="discovery-empty">未发现可入队任务。</div>`;
|
|
}
|
|
|
|
const summary = discovery.summary || {};
|
|
const rows = candidates.map((candidate) => {
|
|
const depth = Math.max(0, Number(candidate.depth || 0));
|
|
const risk = candidate.risk_level || "low";
|
|
const selected = candidate.selected !== false;
|
|
const preconditions = Array.isArray(candidate.preconditions) && candidate.preconditions.length
|
|
? `<span class="discovery-note">前置:${escapeHtml(candidate.preconditions.join("、"))}</span>`
|
|
: "";
|
|
return `
|
|
<label class="discovery-row" style="--depth:${depth}">
|
|
<input class="discovery-checkbox" type="checkbox" data-task-id="${escapeHtml(candidate.id)}" ${selected ? "checked" : ""} />
|
|
<div class="discovery-main">
|
|
<strong>${escapeHtml(candidate.title || candidate.id)}</strong>
|
|
<span>${escapeHtml(candidate.objective || "")}</span>
|
|
<span class="mono site-url">${escapeHtml(candidate.target_url || "")}</span>
|
|
<div class="discovery-meta">
|
|
<span class="badge info">${escapeHtml(candidate.level || "case")}</span>
|
|
<span class="badge ${risk === "high" ? "fail" : risk === "medium" ? "warn" : "ok"}">${escapeHtml(risk)}</span>
|
|
<span>${escapeHtml(candidate.source || "discovery")}</span>
|
|
${preconditions}
|
|
</div>
|
|
</div>
|
|
</label>
|
|
`;
|
|
}).join("");
|
|
|
|
return `
|
|
<div class="discovery-summary">
|
|
<span>页面:${escapeHtml(summary.page_title || summary.final_url || summary.target_url || "-")}</span>
|
|
<span>候选 ${escapeHtml(summary.candidate_count || candidates.length)} 个</span>
|
|
<span>链接 ${escapeHtml(summary.link_count || 0)} / 表单 ${escapeHtml(summary.form_count || 0)} / 入口 ${escapeHtml(summary.button_count || 0)}</span>
|
|
<button class="button small" type="button" data-action="toggle-discovery-all">全选/反选</button>
|
|
</div>
|
|
<div class="discovery-tree">${rows}</div>
|
|
`;
|
|
}
|
|
|
|
function activeAgents() {
|
|
return state.agents.filter((agent) => ["online", "idle", "running", "registered"].includes(String(agent.status || "")));
|
|
}
|
|
|
|
function systemTestTargetUrl() {
|
|
const cfg = state.modelConfig.value || {};
|
|
return state.systemTest.targetUrl || cfg.target_system_url || "";
|
|
}
|
|
|
|
function renderSystemTestAgentOptions() {
|
|
const selected = String(state.systemTest.assignedAgentId || "");
|
|
return [`<option value="">自动分配空闲被控端</option>`]
|
|
.concat(state.agents.map((agent) => {
|
|
const label = `${agent.display_name || agent.public_id || agent.id} ${agent.reported_ip || ""} ${agent.status || ""}`.trim();
|
|
return `<option value="${escapeHtml(agent.id)}" ${String(agent.id) === selected ? "selected" : ""}>${escapeHtml(label)}</option>`;
|
|
}))
|
|
.join("");
|
|
}
|
|
|
|
function renderSystemTestStatus() {
|
|
const activeCount = activeAgents().length;
|
|
const status = state.systemTest.status;
|
|
const message = state.systemTest.message || (activeCount ? `${activeCount} 个被控端可接收任务` : "暂无在线被控端,任务会先进入队列");
|
|
const cls = status === "error" ? "fail" : status === "starting" ? "warn" : activeCount ? "ok" : "info";
|
|
return `<div class="system-test-status"><span class="badge ${cls}">${escapeHtml(status)}</span><span>${escapeHtml(message)}</span></div>`;
|
|
}
|
|
|
|
function readSystemTestForm() {
|
|
const targetUrl = $("#systemTestTargetUrl")?.value.trim() || "";
|
|
const assignedAgentId = $("#systemTestAgent")?.value || "";
|
|
const maxDepth = Math.max(1, Math.min(5, Number($("#systemTestMaxDepth")?.value || 2)));
|
|
state.systemTest = {
|
|
...state.systemTest,
|
|
targetUrl,
|
|
assignedAgentId,
|
|
maxDepth,
|
|
};
|
|
return { target_url: targetUrl, assigned_agent_id: assignedAgentId || null, max_depth: maxDepth };
|
|
}
|
|
|
|
function renderPlans() {
|
|
const targetUrl = systemTestTargetUrl();
|
|
$("#view-plans").innerHTML = `
|
|
<div class="view-grid">
|
|
<section class="surface">
|
|
<div class="surface-header">
|
|
<h2>系统测试</h2>
|
|
<button class="button primary" type="button" data-action="start-system-test" ${state.systemTest.status === "starting" ? "disabled" : ""}>${icon("play")}启动系统测试</button>
|
|
</div>
|
|
<div class="surface-body field-grid">
|
|
<div class="field full">
|
|
<label for="systemTestTargetUrl">待测系统 URL</label>
|
|
<input id="systemTestTargetUrl" type="url" value="${escapeHtml(targetUrl)}" placeholder="http://host:port" />
|
|
</div>
|
|
<div class="field">
|
|
<label for="systemTestAgent">被控端</label>
|
|
<select id="systemTestAgent">${renderSystemTestAgentOptions()}</select>
|
|
</div>
|
|
<div class="field">
|
|
<label for="systemTestMaxDepth">探索深度</label>
|
|
<input id="systemTestMaxDepth" type="number" min="1" max="5" value="${escapeHtml(state.systemTest.maxDepth || 2)}" />
|
|
</div>
|
|
<div class="field full">${renderSystemTestStatus()}</div>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="surface">
|
|
<div class="surface-header">
|
|
<h2>网站扫描</h2>
|
|
<span class="muted">IP + Port</span>
|
|
</div>
|
|
<div class="surface-body">${renderSiteScanner()}</div>
|
|
</section>
|
|
|
|
<section class="surface">
|
|
<div class="surface-header">
|
|
<h2>被控端</h2>
|
|
<span class="muted">${state.agents.length} devices</span>
|
|
</div>
|
|
<div class="surface-body">${renderAgentMiniList()}</div>
|
|
</section>
|
|
|
|
<section class="surface">
|
|
<div class="surface-header">
|
|
<h2>任务队列</h2>
|
|
<button class="button" type="button" data-section-jump="console">${icon("play")}运行控制台</button>
|
|
</div>
|
|
<div class="table-wrap">${renderTaskTable(state.tasks, { actions: false })}</div>
|
|
</section>
|
|
|
|
${renderTaskDetail()}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function renderConsole() {
|
|
$("#view-console").innerHTML = `
|
|
<div class="view-grid">
|
|
<div class="kpi-grid">
|
|
${kpi("队列", state.metrics.ready_tasks || 0, "ready / routed")}
|
|
${kpi("运行", state.metrics.running_tasks || 0, "running")}
|
|
${kpi("完成", state.metrics.completed_tasks || 0, "succeeded / failed / cancelled")}
|
|
${kpi("并发利用", percent(state.metrics.concurrency_utilization), "running / online agents")}
|
|
</div>
|
|
<section class="surface">
|
|
<div class="surface-header">
|
|
<h2>任务队列</h2>
|
|
<div class="toolbar">
|
|
<select id="taskStatusFilter" class="mini-select">
|
|
<option value="">全部状态</option>
|
|
${["ready", "routed", "running", "succeeded", "failed", "cancelled"].map((s) => `<option value="${s}">${s}</option>`).join("")}
|
|
</select>
|
|
<button class="button" type="button" data-action="reload-tasks">${icon("refresh")}刷新任务</button>
|
|
</div>
|
|
</div>
|
|
<div class="table-wrap">${renderTaskTable(state.tasks, { actions: true })}</div>
|
|
</section>
|
|
${renderTaskDetail()}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function renderTaskMeta(task) {
|
|
const level = task.task_level || task.payload?.level || "";
|
|
const risk = task.risk_level || task.payload?.risk_level || "";
|
|
const depth = Number(task.depth || task.payload?.depth || 0);
|
|
const parts = [];
|
|
if (level) parts.push(`层级 ${depth} · ${level}`);
|
|
if (risk) parts.push(`风险 ${risk}`);
|
|
if (task.parent_external_id) parts.push(`父任务 ${task.parent_external_id}`);
|
|
return parts.length ? `<span class="task-meta">${escapeHtml(parts.join(" / "))}</span>` : "";
|
|
}
|
|
|
|
function renderTaskTable(tasks, options = { actions: true }) {
|
|
if (!tasks.length) return `<table><tbody><tr><td class="empty">暂无任务</td></tr></tbody></table>`;
|
|
const showActions = options.actions !== false;
|
|
const showDetails = options.details !== false;
|
|
const agentOptions = [`<option value="">自动</option>`]
|
|
.concat(state.agents.map((a) => `<option value="${a.id}">${escapeHtml(a.display_name || a.public_id)}</option>`))
|
|
.join("");
|
|
return `
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Priority</th><th>Task</th><th>Plan</th><th>Status</th><th>Agent</th><th>Duration</th>${showDetails || showActions ? "<th>Actions</th>" : ""}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
${tasks.map((task) => `
|
|
<tr>
|
|
<td class="mono">${escapeHtml(task.priority)}</td>
|
|
<td>
|
|
<strong>${escapeHtml(task.title || task.external_id || task.id)}</strong>
|
|
<br><span class="muted mono">${escapeHtml(task.external_id || `#${task.id}`)}</span>
|
|
${renderTaskMeta(task)}
|
|
</td>
|
|
<td>${escapeHtml(task.plan_name || "-")}</td>
|
|
<td>${statusBadge(task.status)}</td>
|
|
<td>${escapeHtml(task.assigned_agent_name || task.assigned_agent_id || "-")}</td>
|
|
<td>${duration(task.duration_seconds)}</td>
|
|
${showDetails || showActions ? `
|
|
<td class="actions">
|
|
<div class="split-actions">
|
|
${showDetails ? `<button class="icon-button" type="button" title="详情" data-action="show-task-detail" data-task-id="${task.id}">${icon("file")}</button>` : ""}
|
|
${showActions ? `
|
|
<select class="mini-select" id="assign-${task.id}">${agentOptions}</select>
|
|
<button class="icon-button" type="button" title="分派" data-action="assign-task" data-task-id="${task.id}">${icon("users")}</button>
|
|
<button class="icon-button" type="button" title="重试" data-action="retry-task" data-task-id="${task.id}">${icon("refresh")}</button>
|
|
<button class="icon-button" type="button" title="中止" data-action="abort-task" data-task-id="${task.id}">${icon("square")}</button>
|
|
` : ""}
|
|
</div>
|
|
</td>` : ""}
|
|
</tr>
|
|
`).join("")}
|
|
</tbody>
|
|
</table>
|
|
`;
|
|
}
|
|
|
|
function selectedTask() {
|
|
const selectedId = String(state.selectedTaskId || "");
|
|
if (selectedId) {
|
|
const task = state.tasks.find((item) => String(item.id) === selectedId);
|
|
if (task) return task;
|
|
}
|
|
return state.tasks.find((item) => item.result) || state.tasks[0] || null;
|
|
}
|
|
|
|
function taskAgent(task) {
|
|
if (!task?.assigned_agent_id) return null;
|
|
return state.agents.find((agent) => String(agent.id) === String(task.assigned_agent_id)) || null;
|
|
}
|
|
|
|
function screenshotHref(task, imagePath) {
|
|
const agent = taskAgent(task);
|
|
const host = agent?.reported_ip || agent?.reported_host || "";
|
|
const port = agent?.reported_log_web_port || "";
|
|
if (!host || !port || !imagePath) return "";
|
|
return `http://${host}:${port}/agent/screenshot?path=${encodeURIComponent(imagePath)}`;
|
|
}
|
|
|
|
function renderTaskDetail() {
|
|
const task = selectedTask();
|
|
if (!task) {
|
|
return `<section class="surface"><div class="surface-body"><p class="empty">暂无任务详情</p></div></section>`;
|
|
}
|
|
if (!task.result) {
|
|
const payload = task.payload || {};
|
|
const agent = taskAgent(task);
|
|
const agentStatus = String(agent?.status || "").toLowerCase();
|
|
const likelyInterrupted = task.status === "running" && ["idle", "offline", "registered"].includes(agentStatus);
|
|
const progressText = task.status === "running"
|
|
? likelyInterrupted
|
|
? "被控端已恢复为空闲但任务没有结果回传,可能执行中断;请重试该任务。"
|
|
: "被控端已领取任务,正在等待执行结果回传。"
|
|
: task.status === "ready"
|
|
? "任务已进入队列,等待空闲被控端领取。"
|
|
: task.status === "cancelled"
|
|
? "任务已取消,未产生执行结果。"
|
|
: "任务尚未产生执行结果。";
|
|
return `
|
|
<section class="surface task-detail-panel">
|
|
<div class="surface-header">
|
|
<h2>任务详情</h2>
|
|
${statusBadge(task.status)}
|
|
</div>
|
|
<div class="surface-body task-detail">
|
|
<div class="task-detail-title">
|
|
<strong>${escapeHtml(task.title || task.external_id || task.id)}</strong>
|
|
<span class="muted mono">${escapeHtml(task.external_id || `#${task.id}`)}</span>
|
|
</div>
|
|
<dl class="kv">
|
|
<dt>目标 URL</dt><dd class="mono">${escapeHtml(payload.target_url || payload.url || "-")}</dd>
|
|
<dt>被控端</dt><dd>${escapeHtml(agent?.display_name || task.assigned_agent_name || "-")}</dd>
|
|
<dt>执行状态</dt><dd>${escapeHtml(progressText)}</dd>
|
|
<dt>创建时间</dt><dd>${formatDate(task.created_at)}</dd>
|
|
<dt>领取时间</dt><dd>${formatDate(task.claimed_at)}</dd>
|
|
<dt>更新时间</dt><dd>${formatDate(task.updated_at)}</dd>
|
|
<dt>执行结论</dt><dd>${escapeHtml(task.last_error || "-")}</dd>
|
|
</dl>
|
|
<div>
|
|
<h3>模型分析</h3>
|
|
<p class="muted">任务结果尚未回传,暂时没有模型调用记录。</p>
|
|
</div>
|
|
<div>
|
|
<h3>日志</h3>
|
|
<p class="muted">任务结果尚未回传,暂无执行日志。</p>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
`;
|
|
}
|
|
const result = task.result || {};
|
|
const observation = result.observation || {};
|
|
const browserOpen = result.browser_open || {};
|
|
const modelAnalysis = result.model_analysis || {};
|
|
const modelUsed = Boolean(modelAnalysis.model_used || result.host_decision?.model_used || result.app_decision?.model_used);
|
|
const modelErrors = [...new Set([
|
|
...(Array.isArray(result.model_errors) ? result.model_errors : []),
|
|
modelAnalysis.error,
|
|
result.host_decision?.model_error,
|
|
result.app_decision?.model_error,
|
|
].filter(Boolean).map((item) => String(item)))];
|
|
const modelCallAttempted = Boolean(
|
|
modelUsed
|
|
|| modelErrors.length
|
|
|| modelAnalysis.model_called
|
|
|| modelAnalysis.model_duration_seconds
|
|
|| result.host_decision?.model_duration_seconds
|
|
|| result.app_decision?.model_duration_seconds
|
|
);
|
|
const modelState = !result.model_available
|
|
? "未配置"
|
|
: modelUsed
|
|
? "已调用"
|
|
: modelErrors.length
|
|
? "调用失败"
|
|
: modelCallAttempted
|
|
? "已调用但无有效结果"
|
|
: "未调用";
|
|
const discoverySource = result.discovery_source === "llm" ? "大模型生成" : result.discovery_source === "rule" ? "规则回退" : "-";
|
|
const screenshots = Array.isArray(result.screenshots) ? result.screenshots : [];
|
|
const submitted = Array.isArray(result.submitted_child_tasks) ? result.submitted_child_tasks : [];
|
|
const discovered = Array.isArray(result.discovered_child_tasks) ? result.discovered_child_tasks : [];
|
|
const logs = Array.isArray(result.logs) ? result.logs : [];
|
|
const needsUserInput = result.needs_user_input || task.status === "failed";
|
|
|
|
return `
|
|
<section class="surface task-detail-panel">
|
|
<div class="surface-header">
|
|
<h2>任务详情</h2>
|
|
${statusBadge(task.status)}
|
|
</div>
|
|
<div class="surface-body task-detail">
|
|
<div class="task-detail-title">
|
|
<strong>${escapeHtml(task.title || task.external_id || task.id)}</strong>
|
|
<span class="muted mono">${escapeHtml(task.external_id || `#${task.id}`)}</span>
|
|
</div>
|
|
<dl class="kv">
|
|
<dt>目标 URL</dt><dd class="mono">${escapeHtml(result.target_url || observation.url || (task.payload || {}).target_url || "-")}</dd>
|
|
<dt>最终 URL</dt><dd class="mono">${escapeHtml(result.final_url || observation.final_url || "-")}</dd>
|
|
<dt>页面标题</dt><dd>${escapeHtml(result.page_title || observation.page_title || "-")}</dd>
|
|
<dt>HTTP 状态</dt><dd>${escapeHtml(result.http_status || observation.http_status || "-")}</dd>
|
|
<dt>浏览器打开</dt><dd>${browserOpen.opened ? "已打开" : result.browser_open ? "未打开" : "-"}</dd>
|
|
<dt>大模型</dt><dd>${escapeHtml(modelState)}${result.model_duration_seconds ? ` · ${duration(result.model_duration_seconds)}` : ""}</dd>
|
|
<dt>任务来源</dt><dd>${escapeHtml(discoverySource)}</dd>
|
|
<dt>耗时</dt><dd>${duration(result.duration_seconds || task.duration_seconds)}</dd>
|
|
<dt>人工介入</dt><dd>${needsUserInput ? '<span class="badge warn">需要确认</span>' : '<span class="badge ok">无需</span>'}</dd>
|
|
<dt>执行结论</dt><dd>${escapeHtml(result.message || task.last_error || "-")}</dd>
|
|
</dl>
|
|
<div>
|
|
<h3>模型分析</h3>
|
|
${modelUsed ? `
|
|
<ul class="detail-list">
|
|
<li>${escapeHtml(modelAnalysis.summary || "模型已参与分析,但未返回摘要。")}</li>
|
|
${(Array.isArray(modelAnalysis.coverage_notes) ? modelAnalysis.coverage_notes : []).slice(0, 5).map((note) => `<li>${escapeHtml(note)}</li>`).join("")}
|
|
${modelAnalysis.user_input_reason ? `<li>需要用户补充:${escapeHtml(modelAnalysis.user_input_reason)}</li>` : ""}
|
|
</ul>
|
|
` : modelErrors.length ? `
|
|
<ul class="detail-list">
|
|
${modelErrors.map((error) => `<li>调用失败:${escapeHtml(error)}</li>`).join("")}
|
|
</ul>
|
|
<p class="muted">本轮已回退到规则探索。</p>
|
|
` : `<p class="muted">${result.model_available ? "本轮未使用模型生成有效分析,已回退到规则探索。" : "未配置可用模型,本轮使用规则探索。"}</p>`}
|
|
</div>
|
|
<div class="task-detail-grid">
|
|
<div>
|
|
<h3>页面发现</h3>
|
|
<ul class="detail-list">
|
|
<li>链接:${escapeHtml(observation.link_count ?? "-")}</li>
|
|
<li>表单:${escapeHtml(observation.form_count ?? "-")}</li>
|
|
<li>按钮:${escapeHtml(observation.button_count ?? "-")}</li>
|
|
<li>登录表单:${observation.has_login_form ? "是" : "否"}</li>
|
|
</ul>
|
|
</div>
|
|
<div>
|
|
<h3>证据截图</h3>
|
|
${screenshots.length ? `
|
|
<ul class="detail-list">
|
|
${screenshots.map((shot) => {
|
|
const imagePath = shot.image_path || shot.path || "";
|
|
const href = screenshotHref(task, imagePath);
|
|
const label = imagePath || JSON.stringify(shot);
|
|
return `<li class="mono">${href ? `<a href="${escapeHtml(href)}" target="_blank" rel="noreferrer">${escapeHtml(label)}</a>` : escapeHtml(label)}</li>`;
|
|
}).join("")}
|
|
</ul>
|
|
` : `<p class="muted">暂无截图</p>`}
|
|
</div>
|
|
</div>
|
|
<div class="task-detail-grid">
|
|
<div>
|
|
<h3>已加入队列的子任务</h3>
|
|
${submitted.length ? `
|
|
<ul class="detail-list">
|
|
${submitted.map((child) => `<li><strong>${escapeHtml(child.title || child.external_id)}</strong><span class="muted">层级 ${escapeHtml(child.depth ?? "-")} · ${escapeHtml(child.task_level || "-")} · ${escapeHtml(child.source || discoverySource || "-")}</span>${child.llm_reason ? `<span>${escapeHtml(child.llm_reason)}</span>` : ""}</li>`).join("")}
|
|
</ul>
|
|
` : `<p class="muted">暂无子任务</p>`}
|
|
</div>
|
|
<div>
|
|
<h3>发现候选</h3>
|
|
${discovered.length ? `
|
|
<ul class="detail-list">
|
|
${discovered.slice(0, 8).map((child) => `<li><strong>${escapeHtml(child.title || child.external_id)}</strong><span class="muted mono">${escapeHtml(child.target_url || "-")}</span></li>`).join("")}
|
|
</ul>
|
|
` : `<p class="muted">暂无候选</p>`}
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<h3>日志</h3>
|
|
${logs.length ? `<ul class="detail-list logs">${logs.slice(-8).map((line) => `<li>${escapeHtml(line)}</li>`).join("")}</ul>` : `<p class="muted">暂无日志</p>`}
|
|
</div>
|
|
</div>
|
|
</section>
|
|
`;
|
|
}
|
|
|
|
function renderDevices() {
|
|
$("#view-devices").innerHTML = `
|
|
<div class="view-grid">
|
|
<section class="surface">
|
|
<div class="surface-header">
|
|
<h2>监控墙</h2>
|
|
<span class="muted">${state.agents.length} agents</span>
|
|
</div>
|
|
<div class="surface-body">
|
|
${state.agents.length ? `
|
|
<div class="device-grid">
|
|
${state.agents.map((agent) => `
|
|
<article class="device-card">
|
|
<header>
|
|
<div>
|
|
<strong>${escapeHtml(agent.display_name || agent.public_id)}</strong>
|
|
<span class="muted mono">${escapeHtml(agent.public_id)}</span>
|
|
</div>
|
|
${statusBadge(agent.status)}
|
|
</header>
|
|
<dl class="kv">
|
|
<dt>部署形态</dt><dd>${escapeHtml(agent.deployment_type || "-")}</dd>
|
|
<dt>Agent 版本</dt><dd>${escapeHtml(agent.agent_version || "-")}</dd>
|
|
<dt>Host</dt><dd>${escapeHtml(agent.reported_host || "-")}</dd>
|
|
<dt>IP</dt><dd>${escapeHtml(agent.reported_ip || "-")}</dd>
|
|
<dt>配置版本</dt><dd>v${escapeHtml(agent.config_revision || 0)}${agent.config_applied_at ? ` · ${formatDate(agent.config_applied_at)}` : ""}</dd>
|
|
<dt>日志页</dt><dd>${agent.reported_ip && agent.reported_log_web_port ? `<a href="http://${escapeHtml(agent.reported_ip)}:${escapeHtml(agent.reported_log_web_port)}/" target="_blank" rel="noreferrer">打开 ${escapeHtml(agent.reported_log_web_port)}</a>` : "-"}</dd>
|
|
<dt>最后心跳</dt><dd>${formatDate(agent.last_seen_at)}</dd>
|
|
</dl>
|
|
</article>
|
|
`).join("")}
|
|
</div>` : `<p class="empty">暂无被控端接入</p>`}
|
|
</div>
|
|
</section>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function renderModels() {
|
|
const cfg = state.modelConfig.value || {};
|
|
const targetVersion = Number(state.modelConfig.version || 1);
|
|
const syncedAgents = state.agents.filter((agent) => Number(agent.config_revision || 0) >= targetVersion).length;
|
|
const syncRows = state.agents.length
|
|
? state.agents.map((agent) => {
|
|
const revision = Number(agent.config_revision || 0);
|
|
const isSynced = revision >= targetVersion;
|
|
return `
|
|
<tr>
|
|
<td>
|
|
<strong>${escapeHtml(agent.display_name || agent.public_id)}</strong>
|
|
<div class="muted mono">${escapeHtml(agent.reported_ip || agent.public_id)}</div>
|
|
</td>
|
|
<td>v${escapeHtml(revision)}</td>
|
|
<td>${isSynced ? '<span class="badge ok">synced</span>' : '<span class="badge warn">pending</span>'}</td>
|
|
<td>${formatDate(agent.config_applied_at)}</td>
|
|
</tr>
|
|
`;
|
|
}).join("")
|
|
: `<tr><td colspan="4" class="empty-cell">暂无被控端接入</td></tr>`;
|
|
$("#view-models").innerHTML = `
|
|
<div class="view-grid two-col">
|
|
<section class="surface">
|
|
<div class="surface-header">
|
|
<h2>模型与执行参数</h2>
|
|
<span class="badge info">v${escapeHtml(state.modelConfig.version || 1)}</span>
|
|
</div>
|
|
<div class="surface-body field-grid">
|
|
${modelInput("target_system_url", "待测系统 URL", cfg.target_system_url, "url", { placeholder: "http://host:port" })}
|
|
${renderSiteScanner()}
|
|
${modelInput("vllm_base_url", "vLLM / OpenAI Base URL", cfg.vllm_base_url, "url", { placeholder: "http://host:8000/v1" })}
|
|
${modelInput("vllm_model", "模型名称", cfg.vllm_model, "text", { placeholder: "qwen2.5-vl / local-model" })}
|
|
<div class="field">
|
|
<label for="api_key">API Key <span class="muted">${cfg.api_key_set ? "已保存" : "未保存"}</span></label>
|
|
<input id="api_key" type="password" value="" placeholder="${cfg.api_key_set ? "留空保持已保存密钥" : "输入 Bearer Token / API Key"}" autocomplete="off" />
|
|
</div>
|
|
${modelInput("grounding_url", "Grounding URL", cfg.grounding_url, "url", { placeholder: "http://host:port" })}
|
|
${modelInput("grounding_model", "Grounding 模型", cfg.grounding_model, "text", { placeholder: "grounding-model" })}
|
|
${modelSelect("ground_coordinate_mode", "坐标模式", cfg.ground_coordinate_mode || "absolute", [["absolute", "absolute"], ["normalized", "normalized"]])}
|
|
${modelInput("resolution", "分辨率", cfg.resolution, "text", { placeholder: "1920x1080" })}
|
|
${modelInput("screenshot_max_width", "截图最大宽", cfg.screenshot_max_width, "number", { min: 1, max: 7680 })}
|
|
${modelInput("screenshot_max_height", "截图最大高", cfg.screenshot_max_height, "number", { min: 1, max: 7680 })}
|
|
${modelInput("max_steps", "最大步数", cfg.max_steps, "number", { min: 1, max: 500 })}
|
|
${modelInput("request_timeout_seconds", "请求超时秒", cfg.request_timeout_seconds, "number", { min: 1, max: 300 })}
|
|
${modelInput("temperature", "Temperature", cfg.temperature, "number", { min: 0, max: 2, step: "0.1" })}
|
|
<div class="field checkbox-field">
|
|
<label><input id="clear_api_key" type="checkbox" /> 清除已保存 API Key</label>
|
|
</div>
|
|
<div class="field full">
|
|
<div class="toolbar">
|
|
<button class="button primary" type="button" data-action="save-model">${icon("save")}保存配置</button>
|
|
<button class="button" type="button" data-action="test-model">${icon("activity")}连通性测试</button>
|
|
</div>
|
|
<p class="form-hint">保存后版本递增,并通过被控端心跳下发;被控端应用新配置后会重启运行时。</p>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
<div class="view-grid">
|
|
<section class="surface">
|
|
<div class="surface-header">
|
|
<h2>配置同步</h2>
|
|
<span class="muted">${syncedAgents}/${state.agents.length} agents</span>
|
|
</div>
|
|
<div class="table-wrap compact-table">
|
|
<table>
|
|
<thead><tr><th>Agent</th><th>版本</th><th>状态</th><th>应用时间</th></tr></thead>
|
|
<tbody>${syncRows}</tbody>
|
|
</table>
|
|
</div>
|
|
</section>
|
|
<section class="surface">
|
|
<div class="surface-header"><h2>测试结果</h2></div>
|
|
<div class="surface-body">
|
|
<div id="modelTestResult">${renderModelTestResult(null)}</div>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function renderSiteScanner() {
|
|
const scan = state.siteScan;
|
|
return `
|
|
<div class="field full site-scan-panel">
|
|
<div class="site-scan-fields">
|
|
<div class="field">
|
|
<label for="site_scan_host">扫描主机</label>
|
|
<input id="site_scan_host" type="text" value="${escapeHtml(scan.host)}" placeholder="local / 127.0.0.1 / 10.168.1.20" />
|
|
</div>
|
|
<div class="field">
|
|
<label for="site_scan_ports">扫描端口</label>
|
|
<input id="site_scan_ports" type="text" value="${escapeHtml(scan.ports)}" placeholder="80,443,8000-8010" />
|
|
</div>
|
|
<div class="field site-scan-action">
|
|
<button class="button" type="button" data-action="scan-sites" ${scan.status === "running" ? "disabled" : ""}>${icon("refresh")}扫描网站</button>
|
|
</div>
|
|
</div>
|
|
<div id="siteScanResult">${renderSiteScanResult()}</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function renderSiteScanResult() {
|
|
const scan = state.siteScan;
|
|
if (scan.status === "idle") {
|
|
return `<div class="site-scan-empty">尚未扫描</div>`;
|
|
}
|
|
if (scan.status === "running") {
|
|
return `<div class="site-scan-empty">正在扫描可访问网站...</div>`;
|
|
}
|
|
if (scan.status === "error") {
|
|
return `<div class="site-scan-empty fail">${escapeHtml(scan.message || "扫描失败")}</div>`;
|
|
}
|
|
|
|
const sites = scan.sites || [];
|
|
if (!sites.length) {
|
|
return `<div class="site-scan-empty">未发现可访问网站</div>`;
|
|
}
|
|
|
|
const rows = sites.map((site) => {
|
|
const address = site.address || `${site.ip || site.host}:${site.port}`;
|
|
const statusText = site.status ? `HTTP ${site.status}` : "HTTP";
|
|
const elapsed = site.elapsed_seconds != null ? `${Number(site.elapsed_seconds).toFixed(2)}s` : "-";
|
|
return `
|
|
<div class="site-result-row">
|
|
<div class="site-result-main">
|
|
<strong>${escapeHtml(site.name || address)}</strong>
|
|
<span class="site-url mono">${escapeHtml(site.url || "")}</span>
|
|
<span class="site-meta">${escapeHtml(address)} · ${escapeHtml(statusText)} · ${escapeHtml(elapsed)}</span>
|
|
</div>
|
|
<button class="button small" type="button" data-action="select-site" data-site-url="${escapeHtml(site.url || "")}">${icon("check")}选择</button>
|
|
</div>
|
|
`;
|
|
}).join("");
|
|
|
|
return `
|
|
<div class="site-scan-summary">发现 ${sites.length} 个可访问网站</div>
|
|
<div class="site-result-list">${rows}</div>
|
|
`;
|
|
}
|
|
|
|
function renderModelTestResult(test) {
|
|
if (!test) {
|
|
return `
|
|
<div class="test-result neutral">
|
|
<div class="test-result-icon">${icon("activity")}</div>
|
|
<div>
|
|
<h3>尚未测试</h3>
|
|
<p>点击“连通性测试”后,这里会显示模型服务是否可访问、响应耗时和发现的模型。</p>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
if (test.status === "running") {
|
|
return `
|
|
<div class="test-result neutral">
|
|
<div class="test-result-icon">${icon("activity")}</div>
|
|
<div>
|
|
<h3>正在测试模型服务</h3>
|
|
<p>正在访问模型服务,请稍候。</p>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
if (!test.ok) {
|
|
return `
|
|
<div class="test-result fail">
|
|
<div class="test-result-icon">${icon("x")}</div>
|
|
<div>
|
|
<h3>连接失败</h3>
|
|
<p>${escapeHtml(test.message || "模型服务暂时不可访问。")}</p>
|
|
${test.url ? `<dl class="kv compact-kv"><dt>测试地址</dt><dd>${escapeHtml(test.url)}</dd></dl>` : ""}
|
|
<ul class="hint-list">
|
|
<li>确认模型服务已启动,并且主控主机可以访问该地址。</li>
|
|
<li>检查 Base URL 是否包含正确端口,例如 http://host:8000/v1。</li>
|
|
<li>如果服务需要鉴权,请确认 API Key 已保存且未过期。</li>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
const models = normalizeModelList(test.models);
|
|
const modelList = models.length
|
|
? `<div class="model-chip-list">${models.map((name) => `<span class="model-chip">${escapeHtml(name)}</span>`).join("")}</div>`
|
|
: `<p class="muted">接口可访问,但没有返回可展示的模型名称。</p>`;
|
|
return `
|
|
<div class="test-result ok">
|
|
<div class="test-result-icon">${icon("check")}</div>
|
|
<div>
|
|
<h3>连接成功</h3>
|
|
<p>模型服务已响应,可以用于后续测试执行。</p>
|
|
<dl class="kv compact-kv">
|
|
<dt>测试地址</dt><dd>${escapeHtml(test.url || "-")}</dd>
|
|
<dt>响应耗时</dt><dd>${duration(test.elapsed_seconds)}</dd>
|
|
</dl>
|
|
<h3 class="subheading">发现的模型</h3>
|
|
${modelList}
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function normalizeModelList(models) {
|
|
if (!Array.isArray(models)) return [];
|
|
return models
|
|
.map((item) => {
|
|
if (typeof item === "string") return item;
|
|
if (!item || typeof item !== "object") return "";
|
|
return item.id || item.name || item.model || item.object || "";
|
|
})
|
|
.filter(Boolean)
|
|
.slice(0, 12);
|
|
}
|
|
|
|
function modelInput(id, label, value, type = "text", attrs = {}) {
|
|
const attrText = Object.entries(attrs)
|
|
.filter(([key]) => key !== "full")
|
|
.map(([key, val]) => `${key}="${escapeHtml(val)}"`)
|
|
.join(" ");
|
|
return `<div class="field${attrs.full ? " full" : ""}"><label for="${id}">${escapeHtml(label)}</label><input id="${id}" type="${type}" value="${escapeHtml(value ?? "")}" ${attrText} /></div>`;
|
|
}
|
|
|
|
function modelSelect(id, label, value, options) {
|
|
return `
|
|
<div class="field">
|
|
<label for="${id}">${escapeHtml(label)}</label>
|
|
<select id="${id}">
|
|
${options.map(([optionValue, labelText]) => `<option value="${escapeHtml(optionValue)}" ${optionValue === value ? "selected" : ""}>${escapeHtml(labelText)}</option>`).join("")}
|
|
</select>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function readModelForm() {
|
|
const fields = [
|
|
"target_system_url",
|
|
"vllm_base_url",
|
|
"vllm_model",
|
|
"api_key",
|
|
"grounding_url",
|
|
"grounding_model",
|
|
"ground_coordinate_mode",
|
|
"resolution",
|
|
"screenshot_max_width",
|
|
"screenshot_max_height",
|
|
"max_steps",
|
|
"request_timeout_seconds",
|
|
"temperature",
|
|
];
|
|
const payload = {};
|
|
fields.forEach((field) => {
|
|
const node = $(`#${field}`);
|
|
payload[field] = node.type === "number" ? Number(node.value || 0) : node.value.trim();
|
|
});
|
|
payload.clear_api_key = Boolean($("#clear_api_key")?.checked);
|
|
return payload;
|
|
}
|
|
|
|
function readSiteScanForm() {
|
|
const host = $("#site_scan_host")?.value.trim() || "";
|
|
const ports = $("#site_scan_ports")?.value.trim() || "";
|
|
state.siteScan.host = host;
|
|
state.siteScan.ports = ports || state.siteScan.ports;
|
|
return { host, ports, timeout_seconds: 0.8 };
|
|
}
|
|
|
|
function readDiscoveryForm() {
|
|
const targetUrl = $("#discoveryTargetUrl")?.value.trim() || "";
|
|
const planName = $("#discoveryPlanName")?.value.trim() || "智能探索 P0";
|
|
state.discovery.targetUrl = targetUrl;
|
|
state.discovery.planName = planName;
|
|
return { target_url: targetUrl, plan_name: planName, timeout_seconds: 8 };
|
|
}
|
|
|
|
function selectedDiscoveryCandidates() {
|
|
const checked = new Set($$(".discovery-checkbox:checked").map((node) => node.dataset.taskId));
|
|
state.discovery.candidates = (state.discovery.candidates || []).map((candidate) => ({
|
|
...candidate,
|
|
selected: checked.has(String(candidate.id)),
|
|
}));
|
|
return state.discovery.candidates.filter((candidate) => candidate.selected);
|
|
}
|
|
|
|
function renderTools() {
|
|
const tools = state.toolConfig.tools || [];
|
|
const config = state.toolConfig.config || {};
|
|
const globalEnabled = config.global_enabled || {};
|
|
const agentOptions = [`<option value="">选择 Agent</option>`]
|
|
.concat(state.agents.map((a) => `<option value="${a.id}" ${String(a.id) === String(state.selectedToolAgent) ? "selected" : ""}>${escapeHtml(a.display_name || a.public_id)}</option>`))
|
|
.join("");
|
|
const agentOverride = (config.agent_overrides || {})[state.selectedToolAgent] || {};
|
|
const planOverride = (config.plan_overrides || {})[state.selectedToolPlan] || {};
|
|
$("#view-tools").innerHTML = `
|
|
<div class="view-grid two-col">
|
|
<section class="surface">
|
|
<div class="surface-header">
|
|
<h2>全局默认</h2>
|
|
<span class="badge info">v${escapeHtml(state.toolConfig.version || 1)}</span>
|
|
</div>
|
|
<div class="table-wrap">
|
|
<table>
|
|
<thead><tr><th>Enabled</th><th>Tool</th><th>Type</th><th>Source</th><th>Scenario</th></tr></thead>
|
|
<tbody>
|
|
${tools.map((tool) => `
|
|
<tr>
|
|
<td><input type="checkbox" data-tool-global="${escapeHtml(tool.id)}" ${globalEnabled[tool.id] !== false ? "checked" : ""} /></td>
|
|
<td><strong>${escapeHtml(tool.name)}</strong><br><span class="muted">${escapeHtml(tool.capability)}</span></td>
|
|
<td>${escapeHtml(tool.type)}</td>
|
|
<td>${escapeHtml(tool.source)} · ${escapeHtml(tool.version)}</td>
|
|
<td>${escapeHtml(tool.scenario)}</td>
|
|
</tr>
|
|
`).join("")}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="surface">
|
|
<div class="surface-header">
|
|
<h2>覆盖策略</h2>
|
|
<button class="button primary" type="button" data-action="save-tools">${icon("save")}保存策略</button>
|
|
</div>
|
|
<div class="surface-body view-grid">
|
|
<div class="field">
|
|
<label for="toolAgentSelect">Agent 级覆盖</label>
|
|
<select id="toolAgentSelect">${agentOptions}</select>
|
|
</div>
|
|
<div class="toolbar">
|
|
${tools.map((tool) => `<label class="badge"><input type="checkbox" data-tool-agent="${escapeHtml(tool.id)}" ${agentOverride[tool.id] !== false ? "checked" : ""} /> ${escapeHtml(tool.name)}</label>`).join("")}
|
|
</div>
|
|
<div class="field">
|
|
<label for="toolPlanName">计划级覆盖</label>
|
|
<input id="toolPlanName" value="${escapeHtml(state.selectedToolPlan)}" placeholder="Plan name" />
|
|
</div>
|
|
<div class="toolbar">
|
|
${tools.map((tool) => `<label class="badge"><input type="checkbox" data-tool-plan="${escapeHtml(tool.id)}" ${planOverride[tool.id] !== false ? "checked" : ""} /> ${escapeHtml(tool.name)}</label>`).join("")}
|
|
</div>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function renderMetrics() {
|
|
const m = state.metrics;
|
|
const rows = [
|
|
["通过率", m.pass_rate],
|
|
["失败率", m.fail_rate],
|
|
["覆盖率", m.coverage_rate],
|
|
["并发利用率", m.concurrency_utilization],
|
|
["人工介入率", m.manual_intervention_rate],
|
|
["异常可恢复率", m.recovery_rate],
|
|
["Tool 配置一致率", m.tool_consistency_rate],
|
|
];
|
|
$("#view-metrics").innerHTML = `
|
|
<div class="view-grid">
|
|
<div class="kpi-grid">
|
|
${kpi("任务总数", m.total_tasks || 0, "all plans")}
|
|
${kpi("平均耗时", duration(m.avg_duration_seconds), "completed tasks")}
|
|
${kpi("通过", m.succeeded_tasks || 0, "succeeded")}
|
|
${kpi("失败", m.failed_tasks || 0, "failed")}
|
|
</div>
|
|
<section class="surface">
|
|
<div class="surface-header">
|
|
<h2>核心指标</h2>
|
|
<span class="muted">${(m.plans || []).join(", ") || "No plans"}</span>
|
|
</div>
|
|
<div class="surface-body metric-list">
|
|
${rows.map(([label, value]) => `
|
|
<div class="metric-row">
|
|
<span>${escapeHtml(label)}</span>
|
|
<div class="progress-bar"><span style="width:${Math.max(0, Math.min(100, Number(value || 0)))}%"></span></div>
|
|
<strong>${percent(value)}</strong>
|
|
</div>
|
|
`).join("")}
|
|
</div>
|
|
</section>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function renderReports() {
|
|
const report = state.report || { metrics: {}, plans: [], failed_tasks: [] };
|
|
$("#view-reports").innerHTML = `
|
|
<div class="view-grid two-col">
|
|
<section class="surface">
|
|
<div class="surface-header">
|
|
<h2>报告摘要</h2>
|
|
<button class="button" type="button" data-action="download-report">${icon("download")}下载 JSON</button>
|
|
</div>
|
|
<div class="surface-body">
|
|
<dl class="kv">
|
|
<dt>生成时间</dt><dd>${formatDate(report.generated_at)}</dd>
|
|
<dt>总任务</dt><dd>${escapeHtml(report.metrics?.total_tasks || 0)}</dd>
|
|
<dt>通过率</dt><dd>${percent(report.metrics?.pass_rate)}</dd>
|
|
<dt>覆盖率</dt><dd>${percent(report.metrics?.coverage_rate)}</dd>
|
|
</dl>
|
|
</div>
|
|
</section>
|
|
<section class="surface">
|
|
<div class="surface-header"><h2>失败清单</h2></div>
|
|
<div class="table-wrap">
|
|
<table>
|
|
<thead><tr><th>Task</th><th>Plan</th><th>Reason</th></tr></thead>
|
|
<tbody>
|
|
${(report.failed_tasks || []).length ? report.failed_tasks.map((task) => `
|
|
<tr><td>${escapeHtml(task.title || task.external_id || task.id)}</td><td>${escapeHtml(task.plan_name || "-")}</td><td>${escapeHtml(task.reason || "-")}</td></tr>
|
|
`).join("") : `<tr><td colspan="3" class="empty">暂无失败任务</td></tr>`}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function estimateTasks(raw) {
|
|
if (Array.isArray(raw)) return raw.reduce((sum, item) => sum + (item && Array.isArray(item.tasks) ? item.tasks.length : 1), 0);
|
|
if (raw && Array.isArray(raw.plans)) return raw.plans.reduce((sum, plan) => sum + (Array.isArray(plan.tasks) ? plan.tasks.length : 0), 0);
|
|
if (raw && Array.isArray(raw.tasks)) return raw.tasks.length;
|
|
if (raw && raw.tasks && typeof raw.tasks === "object") return Object.keys(raw.tasks).length;
|
|
return raw && typeof raw === "object" ? 1 : 0;
|
|
}
|
|
|
|
function samplePlan() {
|
|
return JSON.stringify(
|
|
{
|
|
plan_name: "ERP Smoke Plan",
|
|
tasks: [
|
|
{
|
|
task_id: "ERP-LOGIN-001",
|
|
title: "登录系统",
|
|
priority: 10,
|
|
tags: ["login", "smoke"],
|
|
instruction: "打开待测系统,使用测试账号登录,确认首页可访问。",
|
|
timeout: 300,
|
|
task_failure_policy: "terminate",
|
|
},
|
|
{
|
|
task_id: "ERP-QUERY-001",
|
|
title: "查询采购单",
|
|
priority: 20,
|
|
tags: ["purchase"],
|
|
instruction: "进入采购模块,按单号查询并确认列表展示结果。",
|
|
timeout: 300,
|
|
task_failure_policy: "skip",
|
|
},
|
|
],
|
|
},
|
|
null,
|
|
2
|
|
);
|
|
}
|
|
|
|
function downloadJson(filename, value) {
|
|
const blob = new Blob([JSON.stringify(value, null, 2)], { type: "application/json" });
|
|
const link = document.createElement("a");
|
|
link.href = URL.createObjectURL(blob);
|
|
link.download = filename;
|
|
link.click();
|
|
URL.revokeObjectURL(link.href);
|
|
}
|
|
|
|
document.addEventListener("click", async (event) => {
|
|
const nav = event.target.closest(".nav-item");
|
|
if (nav) {
|
|
setSection(nav.dataset.section);
|
|
return;
|
|
}
|
|
|
|
const jump = event.target.closest("[data-section-jump]");
|
|
if (jump) {
|
|
setSection(jump.dataset.sectionJump);
|
|
return;
|
|
}
|
|
|
|
const button = event.target.closest("[data-action]");
|
|
if (!button) return;
|
|
const action = button.dataset.action;
|
|
try {
|
|
if (action === "start-system-test") {
|
|
const form = readSystemTestForm();
|
|
if (!form.target_url) {
|
|
state.systemTest.status = "error";
|
|
state.systemTest.message = "请先选择或填写待测系统 URL";
|
|
renderPlans();
|
|
return;
|
|
}
|
|
state.systemTest.status = "starting";
|
|
state.systemTest.message = "正在创建根任务";
|
|
renderPlans();
|
|
const result = await postJson("/api/system-tests/start", form);
|
|
state.systemTest.status = "started";
|
|
state.systemTest.message = `已创建根任务:${result.task?.external_id || result.task?.id || "-"}`;
|
|
showToast("系统测试已启动,等待被控端领取任务");
|
|
setSection("console");
|
|
await refreshAll();
|
|
} else if (action === "sample-plan") {
|
|
$("#planJson").value = samplePlan();
|
|
$("#planValidation").textContent = "已填入样例";
|
|
} else if (action === "validate-plan") {
|
|
const raw = JSON.parse($("#planJson").value || "{}");
|
|
$("#planValidation").textContent = `JSON 有效,可导入 ${estimateTasks(raw)} 条任务`;
|
|
} else if (action === "import-plan") {
|
|
const content = $("#planJson").value;
|
|
const result = await postJson("/api/tasks/import", { content });
|
|
showToast(`已导入 ${result.count} 条任务`);
|
|
await refreshAll();
|
|
} else if (action === "discover-site") {
|
|
const form = readDiscoveryForm();
|
|
const resultContainer = $("#discoveryResult");
|
|
state.discovery.status = "running";
|
|
state.discovery.message = "";
|
|
state.discovery.candidates = [];
|
|
state.discovery.summary = null;
|
|
if (resultContainer) resultContainer.innerHTML = renderDiscoveryResult();
|
|
button.disabled = true;
|
|
try {
|
|
const result = await postJson("/api/discovery/preview", form);
|
|
state.discovery.status = "done";
|
|
state.discovery.summary = result.summary || null;
|
|
state.discovery.candidates = result.candidates || [];
|
|
state.discovery.message = "";
|
|
renderPlans();
|
|
showToast(`发现 ${state.discovery.candidates.length} 个候选任务`);
|
|
} catch (error) {
|
|
state.discovery.status = "error";
|
|
state.discovery.message = error.message;
|
|
if (resultContainer) resultContainer.innerHTML = renderDiscoveryResult();
|
|
} finally {
|
|
button.disabled = false;
|
|
}
|
|
} else if (action === "toggle-discovery-all") {
|
|
const boxes = $$(".discovery-checkbox");
|
|
const shouldCheck = boxes.some((box) => !box.checked);
|
|
boxes.forEach((box) => {
|
|
box.checked = shouldCheck;
|
|
});
|
|
} else if (action === "import-discovery-tasks") {
|
|
const form = readDiscoveryForm();
|
|
const tasks = selectedDiscoveryCandidates();
|
|
if (!tasks.length) {
|
|
showToast("请先选择要入队的任务");
|
|
return;
|
|
}
|
|
const result = await postJson("/api/discovery/import", {
|
|
target_url: form.target_url,
|
|
plan_name: form.plan_name,
|
|
tasks,
|
|
});
|
|
showToast(`已入队 ${result.count} 个智能探索任务`);
|
|
await refreshAll();
|
|
} else if (action === "export-tasks") {
|
|
downloadJson("system-test-tasks.json", state.tasks);
|
|
} else if (action === "create-task") {
|
|
await postJson("/api/tasks", {
|
|
task_id: $("#manualTaskId").value.trim(),
|
|
title: $("#manualTitle").value.trim(),
|
|
plan_name: $("#manualPlan").value.trim(),
|
|
priority: Number($("#manualPriority").value || 100),
|
|
failure_policy: $("#manualPolicy").value,
|
|
instruction: $("#manualInstruction").value.trim(),
|
|
});
|
|
showToast("任务已保存");
|
|
await refreshAll();
|
|
} else if (action === "reload-tasks") {
|
|
const status = $("#taskStatusFilter")?.value;
|
|
const data = await fetchJson(status ? `/api/tasks?status=${encodeURIComponent(status)}` : "/api/tasks");
|
|
state.tasks = data.tasks || [];
|
|
renderConsole();
|
|
} else if (action === "show-task-detail") {
|
|
state.selectedTaskId = button.dataset.taskId || "";
|
|
if (state.currentSection === "console") {
|
|
renderConsole();
|
|
} else {
|
|
renderPlans();
|
|
}
|
|
} else if (action === "assign-task") {
|
|
const taskId = button.dataset.taskId;
|
|
const agentId = $(`#assign-${taskId}`).value;
|
|
await postJson(`/api/tasks/${taskId}/assign`, { agent_id: agentId || null, reason: "manual dispatch from console" });
|
|
showToast("分派已保存");
|
|
await refreshAll();
|
|
} else if (action === "retry-task") {
|
|
await postJson(`/api/tasks/${button.dataset.taskId}/retry`, { reason: "retry from console" });
|
|
showToast("任务已回到队列");
|
|
await refreshAll();
|
|
} else if (action === "abort-task") {
|
|
await postJson(`/api/tasks/${button.dataset.taskId}/abort`, { reason: "aborted from console" });
|
|
showToast("任务已中止");
|
|
await refreshAll();
|
|
} else if (action === "save-model") {
|
|
const saved = await postJson("/api/config/model", readModelForm());
|
|
state.modelConfig = saved;
|
|
showToast(`模型配置已保存 v${saved.version}`);
|
|
await refreshAll();
|
|
} else if (action === "test-model") {
|
|
const container = $("#modelTestResult");
|
|
container.innerHTML = renderModelTestResult({ status: "running" });
|
|
try {
|
|
const result = await postJson("/api/models/test", readModelForm());
|
|
container.innerHTML = renderModelTestResult(result.test);
|
|
} catch (error) {
|
|
container.innerHTML = renderModelTestResult({ ok: false, message: error.message });
|
|
}
|
|
} else if (action === "scan-sites") {
|
|
const resultContainer = $("#siteScanResult");
|
|
button.disabled = true;
|
|
state.siteScan.status = "running";
|
|
state.siteScan.message = "";
|
|
state.siteScan.sites = [];
|
|
resultContainer.innerHTML = renderSiteScanResult();
|
|
try {
|
|
const result = await postJson("/api/sites/scan", readSiteScanForm());
|
|
state.siteScan.status = "done";
|
|
state.siteScan.sites = result.sites || [];
|
|
state.siteScan.message = "";
|
|
resultContainer.innerHTML = renderSiteScanResult();
|
|
showToast(`发现 ${state.siteScan.sites.length} 个可访问网站`);
|
|
} catch (error) {
|
|
state.siteScan.status = "error";
|
|
state.siteScan.message = error.message;
|
|
resultContainer.innerHTML = renderSiteScanResult();
|
|
} finally {
|
|
button.disabled = false;
|
|
}
|
|
} else if (action === "select-site") {
|
|
const url = button.dataset.siteUrl || "";
|
|
state.systemTest.targetUrl = url;
|
|
const systemTestInput = $("#systemTestTargetUrl");
|
|
if (systemTestInput && url) {
|
|
systemTestInput.value = url;
|
|
}
|
|
const targetInput = $("#target_system_url");
|
|
if (targetInput && url) {
|
|
targetInput.value = url;
|
|
}
|
|
if (url) showToast("已填入待测系统 URL");
|
|
} else if (action === "save-tools") {
|
|
await saveTools();
|
|
showToast("Tool 策略已保存");
|
|
await refreshAll();
|
|
} else if (action === "download-report") {
|
|
downloadJson("system-test-report-summary.json", state.report || {});
|
|
}
|
|
} catch (error) {
|
|
showToast(error.message);
|
|
}
|
|
});
|
|
|
|
document.addEventListener("change", (event) => {
|
|
if (event.target.id === "toolAgentSelect") {
|
|
state.selectedToolAgent = event.target.value;
|
|
renderTools();
|
|
}
|
|
});
|
|
|
|
document.addEventListener("input", (event) => {
|
|
if (event.target.id === "toolPlanName") {
|
|
state.selectedToolPlan = event.target.value.trim();
|
|
}
|
|
});
|
|
|
|
$("#refreshButton").addEventListener("click", refreshAll);
|
|
|
|
function setSection(section) {
|
|
state.currentSection = section;
|
|
$$(".nav-item").forEach((item) => item.classList.toggle("is-active", item.dataset.section === section));
|
|
$$(".view").forEach((view) => view.classList.toggle("is-active", view.id === `view-${section}`));
|
|
renderChrome();
|
|
renderCurrentView();
|
|
}
|
|
|
|
async function saveTools() {
|
|
const current = state.toolConfig.config || {};
|
|
const config = {
|
|
global_enabled: { ...(current.global_enabled || {}) },
|
|
agent_overrides: { ...(current.agent_overrides || {}) },
|
|
plan_overrides: { ...(current.plan_overrides || {}) },
|
|
};
|
|
$$("[data-tool-global]").forEach((node) => {
|
|
config.global_enabled[node.dataset.toolGlobal] = node.checked;
|
|
});
|
|
if (state.selectedToolAgent) {
|
|
config.agent_overrides[state.selectedToolAgent] = {};
|
|
$$("[data-tool-agent]").forEach((node) => {
|
|
config.agent_overrides[state.selectedToolAgent][node.dataset.toolAgent] = node.checked;
|
|
});
|
|
}
|
|
const planName = $("#toolPlanName")?.value.trim();
|
|
if (planName) {
|
|
config.plan_overrides[planName] = {};
|
|
$$("[data-tool-plan]").forEach((node) => {
|
|
config.plan_overrides[planName][node.dataset.toolPlan] = node.checked;
|
|
});
|
|
state.selectedToolPlan = planName;
|
|
}
|
|
const saved = await postJson("/api/tools", { tools: state.toolConfig.tools, config });
|
|
state.toolConfig = saved;
|
|
}
|
|
|
|
refreshAll();
|
|
setInterval(() => {
|
|
if (!["plans", "models", "tools"].includes(state.currentSection)) {
|
|
refreshAll();
|
|
}
|
|
}, 5000);
|