Files
ecm_sync_system/ui/web/index.html
T

468 lines
17 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Sync Pipeline UI</title>
<style>
:root { color-scheme: light dark; }
body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: #0b1020; color: #e7ecff; }
.wrap { max-width: 1200px; margin: 0 auto; padding: 20px; }
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
.grid-1 { display: grid; grid-template-columns: 1fr; gap: 16px; }
.card { background: #121a33; border: 1px solid #2a355f; border-radius: 12px; padding: 16px; }
h1 { margin: 0 0 12px 0; font-size: 20px; }
h2 { margin: 0 0 10px 0; font-size: 16px; }
h3 { margin: 0 0 8px 0; font-size: 14px; color: #bcd0ff; }
label { display: block; margin-bottom: 8px; font-size: 13px; color: #9bb0ff; }
input, textarea, select { width: 100%; box-sizing: border-box; border: 1px solid #3b4a86; border-radius: 8px; background: #0b1020; color: #e7ecff; padding: 8px; }
textarea { min-height: 96px; resize: vertical; }
.row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.btns { display: flex; gap: 8px; flex-wrap: wrap; }
button { border: 1px solid #4f64b5; background: #1d2a59; color: #fff; padding: 8px 12px; border-radius: 8px; cursor: pointer; }
button:disabled { opacity: 0.6; cursor: not-allowed; }
.logs { height: 320px; overflow: auto; white-space: pre-wrap; background: #020617; border: 1px solid #263251; border-radius: 8px; padding: 10px; font-family: Consolas, monospace; font-size: 12px; }
.panel { height: 260px; overflow: auto; white-space: pre-wrap; background: #020617; border: 1px solid #263251; border-radius: 8px; padding: 10px; font-family: Consolas, monospace; font-size: 12px; }
.status { font-size: 13px; color: #b6c5ff; margin-bottom: 10px; }
.tip { font-size: 12px; color: #90a0d8; }
.muted { font-size: 12px; color: #95a6db; }
</style>
</head>
<body>
<div class="wrap">
<h1>Sync State Machine 调试 UI</h1>
<p class="muted">覆盖流程:可视化配置 → 启动运行 → 查看日志与持久化记录(含 SQL 查询)。</p>
<div class="grid">
<div class="card">
<h2>步骤 1:配置</h2>
<div class="row">
<label>预设(与脚本一致)
<select id="presetName"></select>
</label>
<label>手动配置文件路径(相对项目根目录或绝对路径)
<input id="manualConfigPath" placeholder="run_profiles/jsonl_to_jsonl.default.json" />
</label>
</div>
<div class="row">
<label>模式
<select id="mode">
<option value="jsonl_to_jsonl">JSONL → JSONL</option>
<option value="jsonl_to_api">JSONL → API</option>
</select>
</label>
</div>
<div class="row">
<label>节点类型(逗号分隔)
<input id="nodeTypes" />
</label>
<label></label>
</div>
<div class="row">
<label>本地 JSONL 目录
<input id="localJsonlDir" />
</label>
<label>远端 JSONL 目录
<input id="remoteJsonlDir" />
</label>
</div>
<div class="row">
<label>API Base URL
<input id="apiBaseUrl" />
</label>
<label>API UID
<input id="apiUid" />
</label>
</div>
<div class="row">
<label>API Secret
<input id="apiSecret" />
</label>
<label>目标项目 ID(逗号分隔)
<input id="targetProjectIds" />
</label>
</div>
<div class="row">
<label>Persist Backend
<input id="persistBackend" list="persistBackendOptions" />
<datalist id="persistBackendOptions">
<option value="sqlite"></option>
</datalist>
</label>
<label>持久化 DB 路径
<input id="persistenceDb" />
</label>
</div>
<div class="row">
<label>日志文件路径(空=自动)
<input id="logFilePath" />
</label>
<label>persist.backend_options (JSON)
<textarea id="backendOptions"></textarea>
</label>
</div>
<div class="row">
<label>API Debug
<select id="apiDebug">
<option value="true">true</option>
<option value="false">false</option>
</select>
</label>
<label>poll_max_retries
<input id="pollMaxRetries" type="number" min="1" />
</label>
</div>
<label>datasource.handler_configs (JSON, API 端可用)
<textarea id="handlerConfigs"></textarea>
</label>
<label>strategies (JSON)
<textarea id="strategies"></textarea>
</label>
<label>可视化配置(当前表单解析结果)
<textarea id="visualizedConfig" readonly></textarea>
</label>
<div class="btns">
<button id="loadDefaultsBtn">加载默认配置</button>
<button id="loadPresetBtn">加载所选预设</button>
<button id="loadFileBtn">手动加载文件</button>
<button id="loadCurrentBtn">加载当前配置</button>
<button id="previewBtn">刷新可视化</button>
<button id="saveBtn">保存当前配置</button>
</div>
<p class="tip">说明:策略里支持 `skip_sync` + `load_mode`(如 supplier 的 persisted_only)。</p>
</div>
<div class="card">
<h2>步骤 2:运行与日志</h2>
<div class="btns" style="margin-bottom: 10px;">
<button id="runBtn">启动 Pipeline</button>
<button id="stopBtn">停止</button>
</div>
<div class="status" id="status">状态:-</div>
<div class="logs" id="logs"></div>
</div>
</div>
<div class="grid-1" style="margin-top: 16px;">
<div class="card">
<h2>步骤 3:记录与数据库</h2>
<div class="btns" style="margin-bottom: 10px;">
<button id="refreshRecordsBtn">刷新记录汇总</button>
<button id="runSqlBtn">执行 SQL</button>
</div>
<div class="row">
<div>
<h3>记录汇总</h3>
<div class="panel" id="recordSummary"></div>
</div>
<div>
<h3>SQL 查询结果</h3>
<div class="panel" id="sqlResult"></div>
</div>
</div>
<label style="margin-top: 10px;">SQL(只允许 SELECT / PRAGMA / WITH
<textarea id="sqlText">SELECT node_type, COUNT(1) AS cnt FROM nodes GROUP BY node_type ORDER BY cnt DESC LIMIT 50;</textarea>
</label>
</div>
</div>
</div>
<script>
const ids = [
"mode", "nodeTypes", "localJsonlDir", "remoteJsonlDir", "persistenceDb", "targetProjectIds",
"presetName", "manualConfigPath", "persistBackend", "backendOptions", "apiBaseUrl", "apiUid", "apiSecret", "apiDebug", "pollMaxRetries", "logFilePath",
"handlerConfigs", "strategies", "visualizedConfig", "sqlText"
];
const el = Object.fromEntries(ids.map(id => [id, document.getElementById(id)]));
const logsEl = document.getElementById("logs");
const statusEl = document.getElementById("status");
const recordSummaryEl = document.getElementById("recordSummary");
const sqlResultEl = document.getElementById("sqlResult");
let logCursor = 0;
function safeJsonParse(text, fallback) {
if (!text || !text.trim()) return fallback;
try { return JSON.parse(text); } catch (e) { throw new Error("JSON 解析失败: " + e.message); }
}
function toCsv(list) { return (list || []).join(","); }
function fromCsv(text) { return (text || "").split(",").map(s => s.trim()).filter(Boolean); }
function buildProjectFilterHandlerConfigs(baseConfigs, projectIds) {
const nextConfigs = { ...(baseConfigs || {}) };
const ids = Array.isArray(projectIds) ? projectIds : [];
if (!ids.length) return nextConfigs;
nextConfigs.project = {
...(nextConfigs.project || {}),
data_id_filter: ids,
};
return nextConfigs;
}
function readProjectFilter(cfg) {
return cfg?.handler_configs?.project?.data_id_filter || [];
}
function readForm() {
const mode = el.mode.value;
const projectIds = fromCsv(el.targetProjectIds.value);
const handlerConfigs = buildProjectFilterHandlerConfigs(safeJsonParse(el.handlerConfigs.value, {}), projectIds);
const localDs = {
type: "jsonl",
jsonl_dir: el.localJsonlDir.value,
read_only: false,
handler_configs: buildProjectFilterHandlerConfigs({}, projectIds),
};
const remoteDs = mode === "jsonl_to_api"
? {
type: "api",
api_base_url: el.apiBaseUrl.value,
api_uid: el.apiUid.value,
api_secret: el.apiSecret.value,
api_debug: el.apiDebug.value === "true",
poll_max_retries: Number(el.pollMaxRetries.value || 10),
poll_interval: 2.0,
handler_configs: handlerConfigs,
}
: {
type: "jsonl",
jsonl_dir: el.remoteJsonlDir.value,
read_only: false,
handler_configs: {},
};
return {
mode,
node_types: fromCsv(el.nodeTypes.value),
local_datasource: localDs,
remote_datasource: remoteDs,
persist: {
backend: el.persistBackend.value,
db_path: el.persistenceDb.value,
backend_options: safeJsonParse(el.backendOptions.value, {}),
enable: true,
wipe_on_start: false,
},
logging: {
initialize: true,
file_path: el.logFilePath.value || null,
level: 20,
},
strategies: safeJsonParse(el.strategies.value, {}),
};
}
function writeForm(cfg) {
el.mode.value = cfg.mode;
el.nodeTypes.value = toCsv(cfg.node_types);
el.localJsonlDir.value = cfg.local_datasource?.jsonl_dir || "";
el.remoteJsonlDir.value = cfg.remote_datasource?.type === "jsonl" ? (cfg.remote_datasource.jsonl_dir || "") : "";
el.persistBackend.value = cfg.persist?.backend || "sqlite";
el.persistenceDb.value = cfg.persist?.db_path || "";
el.backendOptions.value = JSON.stringify(cfg.persist?.backend_options || {}, null, 2);
el.targetProjectIds.value = toCsv(
readProjectFilter(cfg.remote_datasource).length
? readProjectFilter(cfg.remote_datasource)
: readProjectFilter(cfg.local_datasource)
);
if (cfg.remote_datasource?.type === "api") {
el.apiBaseUrl.value = cfg.remote_datasource.api_base_url || "";
el.apiUid.value = cfg.remote_datasource.api_uid || "";
el.apiSecret.value = cfg.remote_datasource.api_secret || "";
el.apiDebug.value = String(!!cfg.remote_datasource.api_debug);
el.pollMaxRetries.value = String(cfg.remote_datasource.poll_max_retries ?? 10);
el.handlerConfigs.value = JSON.stringify(cfg.remote_datasource.handler_configs || {}, null, 2);
} else {
el.apiBaseUrl.value = "";
el.apiUid.value = "";
el.apiSecret.value = "";
el.apiDebug.value = "true";
el.pollMaxRetries.value = "10";
el.handlerConfigs.value = "{}";
}
el.logFilePath.value = cfg.logging?.file_path || "";
el.strategies.value = JSON.stringify(cfg.strategies || {}, null, 2);
refreshPreview();
}
async function api(path, options = {}) {
const res = await fetch(path, {
headers: { "Content-Type": "application/json" },
...options,
});
if (!res.ok) {
let msg = `${res.status}`;
try { const data = await res.json(); msg = data.detail || JSON.stringify(data); } catch {}
throw new Error(msg);
}
return await res.json();
}
async function refreshStatus() {
const st = await api("/api/status");
statusEl.textContent = `状态: ${st.running ? "运行中" : "空闲"} | started=${st.started_at || "-"} | ended=${st.ended_at || "-"} | error=${st.last_error || "-"} | log=${st.current_log_file || "-"}`;
}
async function refreshLogs() {
const data = await api(`/api/logs?since=${logCursor}`);
if (data.chunks.length > 0) {
logsEl.textContent += data.chunks.join("");
logsEl.scrollTop = logsEl.scrollHeight;
}
logCursor = data.next_index;
}
async function loadDefaults() {
const cfg = await api("/api/config/default");
writeForm(cfg);
}
async function loadPresetNames() {
const data = await api("/api/config/presets");
const presets = data.presets || [];
el.presetName.innerHTML = "";
for (const item of presets) {
const opt = document.createElement("option");
opt.value = item.name;
opt.textContent = item.name;
el.presetName.appendChild(opt);
}
if (presets.some(p => p.name === "simple_sm")) {
el.presetName.value = "simple_sm";
}
}
async function loadSelectedPreset() {
const name = el.presetName.value;
if (!name) return;
const cfg = await api(`/api/config/preset/${encodeURIComponent(name)}`);
writeForm(cfg);
el.manualConfigPath.value = "";
}
async function loadManualConfigFile() {
const path = (el.manualConfigPath.value || "").trim();
if (!path) {
alert("请先输入配置文件路径");
return;
}
const cfg = await api("/api/config/load-file", {
method: "POST",
body: JSON.stringify({ path }),
});
writeForm(cfg);
}
async function loadCurrent() {
const cfg = await api("/api/config/current");
writeForm(cfg);
}
async function refreshPreview() {
try {
const cfg = readForm();
el.visualizedConfig.value = JSON.stringify(cfg, null, 2);
} catch (e) {
el.visualizedConfig.value = `配置解析失败: ${e.message}`;
}
}
async function refreshRecordsSummary() {
try {
const data = await api("/api/records/summary");
recordSummaryEl.textContent = JSON.stringify(data, null, 2);
} catch (e) {
recordSummaryEl.textContent = `加载记录失败: ${e.message}`;
}
}
async function runSql() {
try {
const data = await api("/api/records/query", {
method: "POST",
body: JSON.stringify({ sql: el.sqlText.value, limit: 200 }),
});
sqlResultEl.textContent = JSON.stringify(data, null, 2);
} catch (e) {
sqlResultEl.textContent = `SQL 执行失败: ${e.message}`;
}
}
document.getElementById("loadDefaultsBtn").onclick = async () => {
try { await loadDefaults(); } catch (e) { alert(e.message); }
};
document.getElementById("loadCurrentBtn").onclick = async () => {
try { await loadCurrent(); } catch (e) { alert(e.message); }
};
document.getElementById("loadPresetBtn").onclick = async () => {
try { await loadSelectedPreset(); } catch (e) { alert(e.message); }
};
document.getElementById("loadFileBtn").onclick = async () => {
try { await loadManualConfigFile(); } catch (e) { alert(e.message); }
};
document.getElementById("previewBtn").onclick = async () => {
try { await refreshPreview(); } catch (e) { alert(e.message); }
};
document.getElementById("saveBtn").onclick = async () => {
try {
const cfg = readForm();
await api("/api/config/current", { method: "POST", body: JSON.stringify(cfg) });
alert("配置已保存");
await refreshPreview();
} catch (e) { alert(e.message); }
};
document.getElementById("runBtn").onclick = async () => {
try {
logCursor = 0;
logsEl.textContent = "";
const cfg = readForm();
await api("/api/run", { method: "POST", body: JSON.stringify(cfg) });
} catch (e) { alert(e.message); }
};
document.getElementById("stopBtn").onclick = async () => {
try { await api("/api/stop", { method: "POST" }); } catch (e) { alert(e.message); }
};
document.getElementById("refreshRecordsBtn").onclick = async () => {
await refreshRecordsSummary();
};
document.getElementById("runSqlBtn").onclick = async () => {
await runSql();
};
setInterval(async () => {
try {
await refreshStatus();
await refreshLogs();
} catch {}
}, 1200);
loadPresetNames()
.then(loadSelectedPreset)
.then(refreshStatus)
.then(refreshPreview)
.then(refreshRecordsSummary)
.catch(console.error);
</script>
</body>
</html>