first commit
This commit is contained in:
@@ -0,0 +1,449 @@
|
||||
<!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
|
||||
<select id="persistBackend">
|
||||
<option value="sqlite">sqlite</option>
|
||||
<option value="sqlalchemy">sqlalchemy</option>
|
||||
<option value="custom">custom</option>
|
||||
</select>
|
||||
</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 readForm() {
|
||||
const mode = el.mode.value;
|
||||
const handlerConfigs = safeJsonParse(el.handlerConfigs.value, {});
|
||||
const localDs = {
|
||||
type: "jsonl",
|
||||
jsonl_dir: el.localJsonlDir.value,
|
||||
read_only: false,
|
||||
handler_configs: {},
|
||||
};
|
||||
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,
|
||||
},
|
||||
target_project_ids: fromCsv(el.targetProjectIds.value),
|
||||
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(cfg.target_project_ids);
|
||||
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>
|
||||
Reference in New Issue
Block a user