first commit

This commit is contained in:
strepsiades
2026-03-09 16:31:42 +08:00
commit 4a9c444b10
486 changed files with 52479 additions and 0 deletions
+449
View File
@@ -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>