init biliup-next
This commit is contained in:
215
src/biliup_next/app/static/app/actions.js
Normal file
215
src/biliup_next/app/static/app/actions.js
Normal file
@ -0,0 +1,215 @@
|
||||
import { fetchJson } from "./api.js";
|
||||
import { navigate } from "./router.js";
|
||||
import {
|
||||
clearSettingsFieldState,
|
||||
resetTaskPage,
|
||||
setLogAutoRefreshTimer,
|
||||
setSelectedTask,
|
||||
setTaskPage,
|
||||
setTaskPageSize,
|
||||
state,
|
||||
} from "./state.js";
|
||||
import { showBanner, syncSettingsEditorFromState } from "./utils.js";
|
||||
import { renderSettingsForm } from "./views/settings.js";
|
||||
import { renderTasks } from "./views/tasks.js";
|
||||
|
||||
export function bindActions({
|
||||
loadOverview,
|
||||
loadTaskDetail,
|
||||
refreshLog,
|
||||
handleSettingsFieldChange,
|
||||
}) {
|
||||
document.querySelectorAll(".nav-btn").forEach((button) => {
|
||||
button.onclick = () => navigate(button.dataset.view);
|
||||
});
|
||||
|
||||
document.getElementById("refreshBtn").onclick = async () => {
|
||||
await loadOverview();
|
||||
showBanner("视图已刷新", "ok");
|
||||
};
|
||||
|
||||
document.getElementById("runOnceBtn").onclick = async () => {
|
||||
try {
|
||||
const result = await fetchJson("/worker/run-once", { method: "POST" });
|
||||
await loadOverview();
|
||||
showBanner(`Worker 已执行一轮,processed=${result.processed.length}`, "ok");
|
||||
} catch (err) {
|
||||
showBanner(String(err), "err");
|
||||
}
|
||||
};
|
||||
|
||||
document.getElementById("saveSettingsBtn").onclick = async () => {
|
||||
try {
|
||||
const payload = JSON.parse(document.getElementById("settingsEditor").value);
|
||||
await fetchJson("/settings", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
clearSettingsFieldState();
|
||||
await loadOverview();
|
||||
showBanner("Settings 已保存", "ok");
|
||||
} catch (err) {
|
||||
showBanner(`保存失败: ${err}`, "err");
|
||||
}
|
||||
};
|
||||
|
||||
document.getElementById("syncFormToJsonBtn").onclick = () => {
|
||||
syncSettingsEditorFromState();
|
||||
showBanner("表单已同步到 JSON", "ok");
|
||||
};
|
||||
|
||||
document.getElementById("syncJsonToFormBtn").onclick = () => {
|
||||
try {
|
||||
state.currentSettings = JSON.parse(document.getElementById("settingsEditor").value);
|
||||
clearSettingsFieldState();
|
||||
renderSettingsForm(handleSettingsFieldChange);
|
||||
showBanner("JSON 已重绘到表单", "ok");
|
||||
} catch (err) {
|
||||
showBanner(`JSON 解析失败: ${err}`, "err");
|
||||
}
|
||||
};
|
||||
|
||||
document.getElementById("settingsSearch").oninput = () => renderSettingsForm(handleSettingsFieldChange);
|
||||
document.getElementById("settingsForm").onclick = (event) => {
|
||||
const button = event.target.closest("button[data-revert-group]");
|
||||
if (!button) return;
|
||||
const group = button.dataset.revertGroup;
|
||||
const field = button.dataset.revertField;
|
||||
const originalValue = state.originalSettings[group]?.[field];
|
||||
state.currentSettings[group] ??= {};
|
||||
if (originalValue === undefined) delete state.currentSettings[group][field];
|
||||
else state.currentSettings[group][field] = JSON.parse(JSON.stringify(originalValue));
|
||||
clearSettingsFieldState();
|
||||
renderSettingsForm(handleSettingsFieldChange);
|
||||
showBanner(`已撤销 ${group}.${field}`, "ok");
|
||||
};
|
||||
const rerenderTasks = () => renderTasks(async (taskId) => {
|
||||
setSelectedTask(taskId);
|
||||
await loadTaskDetail(taskId);
|
||||
});
|
||||
document.getElementById("taskSearchInput").oninput = () => { resetTaskPage(); rerenderTasks(); };
|
||||
document.getElementById("taskStatusFilter").onchange = () => { resetTaskPage(); rerenderTasks(); };
|
||||
document.getElementById("taskSortSelect").onchange = () => { resetTaskPage(); rerenderTasks(); };
|
||||
document.getElementById("taskDeliveryFilter").onchange = () => { resetTaskPage(); rerenderTasks(); };
|
||||
document.getElementById("taskAttentionFilter").onchange = () => { resetTaskPage(); rerenderTasks(); };
|
||||
document.getElementById("taskPageSizeSelect").onchange = () => {
|
||||
setTaskPageSize(Number(document.getElementById("taskPageSizeSelect").value) || 24);
|
||||
resetTaskPage();
|
||||
rerenderTasks();
|
||||
};
|
||||
document.getElementById("taskPrevPageBtn").onclick = () => {
|
||||
setTaskPage(Math.max(1, state.taskPage - 1));
|
||||
rerenderTasks();
|
||||
};
|
||||
document.getElementById("taskNextPageBtn").onclick = () => {
|
||||
setTaskPage(state.taskPage + 1);
|
||||
rerenderTasks();
|
||||
};
|
||||
|
||||
document.getElementById("importStageBtn").onclick = async () => {
|
||||
const sourcePath = document.getElementById("stageSourcePath").value.trim();
|
||||
if (!sourcePath) return showBanner("请先输入本地文件绝对路径", "warn");
|
||||
try {
|
||||
const result = await fetchJson("/stage/import", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ source_path: sourcePath }),
|
||||
});
|
||||
document.getElementById("stageSourcePath").value = "";
|
||||
showBanner(`已导入到 stage: ${result.target_path}`, "ok");
|
||||
} catch (err) {
|
||||
showBanner(`导入失败: ${err}`, "err");
|
||||
}
|
||||
};
|
||||
|
||||
document.getElementById("uploadStageBtn").onclick = async () => {
|
||||
const input = document.getElementById("stageFileInput");
|
||||
if (!input.files?.length) return showBanner("请先选择一个本地文件", "warn");
|
||||
try {
|
||||
const form = new FormData();
|
||||
form.append("file", input.files[0]);
|
||||
const res = await fetch("/stage/upload", { method: "POST", body: form });
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || JSON.stringify(data));
|
||||
input.value = "";
|
||||
showBanner(`已上传到 stage: ${data.target_path}`, "ok");
|
||||
} catch (err) {
|
||||
showBanner(`上传失败: ${err}`, "err");
|
||||
}
|
||||
};
|
||||
|
||||
document.getElementById("refreshLogBtn").onclick = () => refreshLog().then(() => showBanner("日志已刷新", "ok")).catch((err) => showBanner(`日志刷新失败: ${err}`, "err"));
|
||||
document.getElementById("logSearchInput").oninput = () => refreshLog().catch((err) => showBanner(`日志刷新失败: ${err}`, "err"));
|
||||
document.getElementById("logLineFilter").oninput = () => refreshLog().catch((err) => showBanner(`日志刷新失败: ${err}`, "err"));
|
||||
document.getElementById("logAutoRefresh").onchange = () => {
|
||||
if (state.logAutoRefreshTimer) {
|
||||
clearInterval(state.logAutoRefreshTimer);
|
||||
setLogAutoRefreshTimer(null);
|
||||
}
|
||||
if (document.getElementById("logAutoRefresh").checked) {
|
||||
const timer = window.setInterval(() => {
|
||||
refreshLog().catch(() => {});
|
||||
}, 5000);
|
||||
setLogAutoRefreshTimer(timer);
|
||||
}
|
||||
};
|
||||
document.getElementById("refreshHistoryBtn").onclick = () => loadOverview().then(() => showBanner("动作流已刷新", "ok")).catch((err) => showBanner(`动作流刷新失败: ${err}`, "err"));
|
||||
document.getElementById("refreshSchedulerBtn").onclick = () => loadOverview().then(() => showBanner("调度队列已刷新", "ok")).catch((err) => showBanner(`调度队列刷新失败: ${err}`, "err"));
|
||||
|
||||
document.getElementById("saveTokenBtn").onclick = async () => {
|
||||
const token = document.getElementById("tokenInput").value.trim();
|
||||
localStorage.setItem("biliup_next_token", token);
|
||||
try {
|
||||
await loadOverview();
|
||||
showBanner("Token 已保存并生效", "ok");
|
||||
} catch (err) {
|
||||
showBanner(`Token 验证失败: ${err}`, "err");
|
||||
}
|
||||
};
|
||||
|
||||
document.getElementById("runTaskBtn").onclick = async () => {
|
||||
if (!state.selectedTaskId) return showBanner("当前没有选中的任务", "warn");
|
||||
try {
|
||||
const result = await fetchJson(`/tasks/${state.selectedTaskId}/actions/run`, { method: "POST" });
|
||||
await loadOverview();
|
||||
showBanner(`任务已推进,processed=${result.processed.length}`, "ok");
|
||||
} catch (err) {
|
||||
showBanner(`任务执行失败: ${err}`, "err");
|
||||
}
|
||||
};
|
||||
|
||||
document.getElementById("retryStepBtn").onclick = async () => {
|
||||
if (!state.selectedTaskId) return showBanner("当前没有选中的任务", "warn");
|
||||
if (!state.selectedStepName) return showBanner("请先在 Steps 区域选中一个 step", "warn");
|
||||
try {
|
||||
const result = await fetchJson(`/tasks/${state.selectedTaskId}/actions/retry-step`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ step_name: state.selectedStepName }),
|
||||
});
|
||||
await loadOverview();
|
||||
showBanner(`已重试 step=${state.selectedStepName},processed=${result.processed.length}`, "ok");
|
||||
} catch (err) {
|
||||
showBanner(`重试失败: ${err}`, "err");
|
||||
}
|
||||
};
|
||||
|
||||
document.getElementById("resetStepBtn").onclick = async () => {
|
||||
if (!state.selectedTaskId) return showBanner("当前没有选中的任务", "warn");
|
||||
if (!state.selectedStepName) return showBanner("请先在 Steps 区域选中一个 step", "warn");
|
||||
const ok = window.confirm(`确认重置到 step=${state.selectedStepName} 并清理其后的产物吗?`);
|
||||
if (!ok) return;
|
||||
try {
|
||||
const result = await fetchJson(`/tasks/${state.selectedTaskId}/actions/reset-to-step`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ step_name: state.selectedStepName }),
|
||||
});
|
||||
await loadOverview();
|
||||
showBanner(`已重置并重跑 step=${state.selectedStepName},processed=${result.run.processed.length}`, "ok");
|
||||
} catch (err) {
|
||||
showBanner(`重置失败: ${err}`, "err");
|
||||
}
|
||||
};
|
||||
}
|
||||
52
src/biliup_next/app/static/app/api.js
Normal file
52
src/biliup_next/app/static/app/api.js
Normal file
@ -0,0 +1,52 @@
|
||||
import { state } from "./state.js";
|
||||
|
||||
export async function fetchJson(url, options) {
|
||||
const token = localStorage.getItem("biliup_next_token") || "";
|
||||
const opts = options ? { ...options } : {};
|
||||
opts.headers = { ...(opts.headers || {}) };
|
||||
if (token) opts.headers["X-Biliup-Token"] = token;
|
||||
const res = await fetch(url, opts);
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.message || data.error || JSON.stringify(data));
|
||||
return data;
|
||||
}
|
||||
|
||||
export function buildHistoryUrl() {
|
||||
const params = new URLSearchParams();
|
||||
params.set("limit", "20");
|
||||
const status = document.getElementById("historyStatusFilter")?.value || "";
|
||||
const actionName = document.getElementById("historyActionFilter")?.value.trim() || "";
|
||||
const currentOnly = document.getElementById("historyCurrentTask")?.checked;
|
||||
if (status) params.set("status", status);
|
||||
if (actionName) params.set("action_name", actionName);
|
||||
if (currentOnly && state.selectedTaskId) params.set("task_id", state.selectedTaskId);
|
||||
return `/history?${params.toString()}`;
|
||||
}
|
||||
|
||||
export async function loadOverviewPayload() {
|
||||
const historyUrl = buildHistoryUrl();
|
||||
const [health, doctor, tasks, modules, settings, settingsSchema, services, logs, history, scheduler] = await Promise.all([
|
||||
fetchJson("/health"),
|
||||
fetchJson("/doctor"),
|
||||
fetchJson("/tasks?limit=100"),
|
||||
fetchJson("/modules"),
|
||||
fetchJson("/settings"),
|
||||
fetchJson("/settings/schema"),
|
||||
fetchJson("/runtime/services"),
|
||||
fetchJson("/logs"),
|
||||
fetchJson(historyUrl),
|
||||
fetchJson("/scheduler/preview"),
|
||||
]);
|
||||
return { health, doctor, tasks, modules, settings, settingsSchema, services, logs, history, scheduler };
|
||||
}
|
||||
|
||||
export async function loadTaskPayload(taskId) {
|
||||
const [task, steps, artifacts, history, timeline] = await Promise.all([
|
||||
fetchJson(`/tasks/${taskId}`),
|
||||
fetchJson(`/tasks/${taskId}/steps`),
|
||||
fetchJson(`/tasks/${taskId}/artifacts`),
|
||||
fetchJson(`/tasks/${taskId}/history`),
|
||||
fetchJson(`/tasks/${taskId}/timeline`),
|
||||
]);
|
||||
return { task, steps, artifacts, history, timeline };
|
||||
}
|
||||
16
src/biliup_next/app/static/app/components/artifact-list.js
Normal file
16
src/biliup_next/app/static/app/components/artifact-list.js
Normal file
@ -0,0 +1,16 @@
|
||||
import { escapeHtml, formatDate } from "../utils.js";
|
||||
|
||||
export function renderArtifactList(artifacts) {
|
||||
const artifactWrap = document.getElementById("artifactList");
|
||||
artifactWrap.innerHTML = "";
|
||||
artifacts.items.forEach((artifact) => {
|
||||
const row = document.createElement("div");
|
||||
row.className = "row-card";
|
||||
row.innerHTML = `
|
||||
<div class="step-card-title"><strong>${escapeHtml(artifact.artifact_type)}</strong></div>
|
||||
<div class="artifact-path">${escapeHtml(artifact.path)}</div>
|
||||
<div class="muted-note">${escapeHtml(formatDate(artifact.created_at))}</div>
|
||||
`;
|
||||
artifactWrap.appendChild(row);
|
||||
});
|
||||
}
|
||||
@ -0,0 +1,15 @@
|
||||
import { escapeHtml } from "../utils.js";
|
||||
|
||||
export function renderDoctor(checks) {
|
||||
const wrap = document.getElementById("doctorChecks");
|
||||
wrap.innerHTML = "";
|
||||
for (const check of checks) {
|
||||
const row = document.createElement("div");
|
||||
row.className = "row-card";
|
||||
row.innerHTML = `
|
||||
<div class="step-card-title"><strong>${escapeHtml(check.name)}</strong><span class="pill ${check.ok ? "good" : "hot"}">${check.ok ? "ok" : "fail"}</span></div>
|
||||
<div class="muted-note">${escapeHtml(check.detail)}</div>
|
||||
`;
|
||||
wrap.appendChild(row);
|
||||
}
|
||||
}
|
||||
23
src/biliup_next/app/static/app/components/history-list.js
Normal file
23
src/biliup_next/app/static/app/components/history-list.js
Normal file
@ -0,0 +1,23 @@
|
||||
import { escapeHtml, formatDate, statusClass } from "../utils.js";
|
||||
|
||||
export function renderHistoryList(history) {
|
||||
const historyWrap = document.getElementById("historyList");
|
||||
historyWrap.innerHTML = "";
|
||||
history.items.forEach((item) => {
|
||||
let details = "";
|
||||
try {
|
||||
details = JSON.stringify(JSON.parse(item.details_json || "{}"), null, 2);
|
||||
} catch {
|
||||
details = item.details_json || "";
|
||||
}
|
||||
const row = document.createElement("div");
|
||||
row.className = "row-card";
|
||||
row.innerHTML = `
|
||||
<div class="step-card-title"><strong>${escapeHtml(item.action_name)}</strong><span class="pill ${statusClass(item.status)}">${escapeHtml(item.status)}</span></div>
|
||||
<div class="muted-note">${escapeHtml(item.summary)}</div>
|
||||
<div class="muted-note">${escapeHtml(formatDate(item.created_at))}</div>
|
||||
<pre>${escapeHtml(details)}</pre>
|
||||
`;
|
||||
historyWrap.appendChild(row);
|
||||
});
|
||||
}
|
||||
15
src/biliup_next/app/static/app/components/modules-list.js
Normal file
15
src/biliup_next/app/static/app/components/modules-list.js
Normal file
@ -0,0 +1,15 @@
|
||||
import { escapeHtml } from "../utils.js";
|
||||
|
||||
export function renderModules(items) {
|
||||
const wrap = document.getElementById("moduleList");
|
||||
wrap.innerHTML = "";
|
||||
for (const item of items) {
|
||||
const row = document.createElement("div");
|
||||
row.className = "row-card";
|
||||
row.innerHTML = `
|
||||
<div class="step-card-title"><strong>${escapeHtml(item.id)}</strong><span class="pill">${escapeHtml(item.provider_type)}</span></div>
|
||||
<div class="muted-note">${escapeHtml(item.entrypoint)}</div>
|
||||
`;
|
||||
wrap.appendChild(row);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,11 @@
|
||||
export function renderRuntimeSnapshot({ health, doctor, tasks }) {
|
||||
const healthText = health.ok ? "OK" : "FAIL";
|
||||
const doctorText = doctor.ok ? "OK" : "FAIL";
|
||||
document.getElementById("tokenInput").value = localStorage.getItem("biliup_next_token") || "";
|
||||
document.getElementById("healthValue").textContent = healthText;
|
||||
document.getElementById("doctorValue").textContent = doctorText;
|
||||
document.getElementById("tasksValue").textContent = tasks.items.length;
|
||||
document.getElementById("overviewHealthValue").textContent = healthText;
|
||||
document.getElementById("overviewDoctorValue").textContent = doctorText;
|
||||
document.getElementById("overviewTasksValue").textContent = tasks.items.length;
|
||||
}
|
||||
@ -0,0 +1,46 @@
|
||||
import { statusClass } from "../utils.js";
|
||||
|
||||
export function renderOverviewTaskSummary(tasks) {
|
||||
const wrap = document.getElementById("overviewTaskSummary");
|
||||
if (!wrap) return;
|
||||
const counts = new Map();
|
||||
tasks.forEach((task) => counts.set(task.status, (counts.get(task.status) || 0) + 1));
|
||||
const ordered = ["running", "failed_retryable", "failed_manual", "published", "collection_synced", "created", "transcribed", "songs_detected", "split_done"];
|
||||
wrap.innerHTML = "";
|
||||
ordered.forEach((status) => {
|
||||
const count = counts.get(status);
|
||||
if (!count) return;
|
||||
const pill = document.createElement("div");
|
||||
pill.className = `pill ${statusClass(status)}`;
|
||||
pill.textContent = `${status} ${count}`;
|
||||
wrap.appendChild(pill);
|
||||
});
|
||||
if (!wrap.children.length) {
|
||||
const pill = document.createElement("div");
|
||||
pill.className = "pill";
|
||||
pill.textContent = "no tasks";
|
||||
wrap.appendChild(pill);
|
||||
}
|
||||
}
|
||||
|
||||
export function renderOverviewRetrySummary(tasks) {
|
||||
const wrap = document.getElementById("overviewRetrySummary");
|
||||
if (!wrap) return;
|
||||
const waitingRetry = tasks.filter((task) => task.retry_state?.next_retry_at && !task.retry_state?.retry_due);
|
||||
const dueRetry = tasks.filter((task) => task.retry_state?.retry_due);
|
||||
const failedManual = tasks.filter((task) => task.status === "failed_manual");
|
||||
wrap.innerHTML = `
|
||||
<div class="row-card">
|
||||
<strong>Waiting Retry</strong>
|
||||
<div class="muted-note">${waitingRetry.length} 个任务正在等待下一次重试</div>
|
||||
</div>
|
||||
<div class="row-card">
|
||||
<strong>Retry Due</strong>
|
||||
<div class="muted-note">${dueRetry.length} 个任务已到重试时间</div>
|
||||
</div>
|
||||
<div class="row-card">
|
||||
<strong>Manual Attention</strong>
|
||||
<div class="muted-note">${failedManual.length} 个任务需要人工处理</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@ -0,0 +1,19 @@
|
||||
import { escapeHtml, formatDate, statusClass } from "../utils.js";
|
||||
|
||||
export function renderRecentActions(items) {
|
||||
const wrap = document.getElementById("recentActionList");
|
||||
wrap.innerHTML = "";
|
||||
for (const item of items) {
|
||||
const row = document.createElement("div");
|
||||
row.className = "row-card";
|
||||
row.innerHTML = `
|
||||
<div class="step-card-title">
|
||||
<strong>${escapeHtml(item.action_name)}</strong>
|
||||
<span class="pill ${statusClass(item.status)}">${escapeHtml(item.status)}</span>
|
||||
</div>
|
||||
<div class="muted-note">${escapeHtml(item.task_id || "global")} / ${escapeHtml(item.summary)}</div>
|
||||
<div class="muted-note">${escapeHtml(formatDate(item.created_at))}</div>
|
||||
`;
|
||||
wrap.appendChild(row);
|
||||
}
|
||||
}
|
||||
19
src/biliup_next/app/static/app/components/retry-banner.js
Normal file
19
src/biliup_next/app/static/app/components/retry-banner.js
Normal file
@ -0,0 +1,19 @@
|
||||
import { escapeHtml, formatDate, formatDuration } from "../utils.js";
|
||||
|
||||
export function renderRetryPanel(task) {
|
||||
const wrap = document.getElementById("taskRetryPanel");
|
||||
const retry = task.retry_state;
|
||||
if (!retry || !retry.next_retry_at) {
|
||||
wrap.className = "retry-banner";
|
||||
wrap.style.display = "none";
|
||||
wrap.textContent = "";
|
||||
return;
|
||||
}
|
||||
wrap.style.display = "block";
|
||||
wrap.className = `retry-banner show ${retry.retry_due ? "good" : "warn"}`;
|
||||
wrap.innerHTML = `
|
||||
<strong>${escapeHtml(retry.step_name)}</strong>
|
||||
${retry.retry_due ? " 已到重试时间" : " 正在等待下一次重试"}
|
||||
<div class="muted-note">next retry at ${escapeHtml(formatDate(retry.next_retry_at))} · remaining ${escapeHtml(formatDuration(retry.retry_remaining_seconds))} · wait ${escapeHtml(formatDuration(retry.retry_wait_seconds))}</div>
|
||||
`;
|
||||
}
|
||||
27
src/biliup_next/app/static/app/components/service-list.js
Normal file
27
src/biliup_next/app/static/app/components/service-list.js
Normal file
@ -0,0 +1,27 @@
|
||||
import { escapeHtml, statusClass } from "../utils.js";
|
||||
|
||||
export function renderServices(items, onServiceAction) {
|
||||
const wrap = document.getElementById("serviceList");
|
||||
wrap.innerHTML = "";
|
||||
for (const item of items) {
|
||||
const row = document.createElement("div");
|
||||
row.className = "service-card";
|
||||
row.innerHTML = `
|
||||
<div class="step-card-title">
|
||||
<strong>${escapeHtml(item.id)}</strong>
|
||||
<span class="pill ${statusClass(item.active_state)}">${escapeHtml(item.active_state)}</span>
|
||||
<span class="pill">${escapeHtml(item.sub_state)}</span>
|
||||
</div>
|
||||
<div class="muted-note">${escapeHtml(item.fragment_path || item.description || "")}</div>
|
||||
<div class="button-row" style="margin-top:12px;">
|
||||
<button class="secondary compact" data-service="${item.id}" data-action="start">start</button>
|
||||
<button class="secondary compact" data-service="${item.id}" data-action="restart">restart</button>
|
||||
<button class="secondary compact" data-service="${item.id}" data-action="stop">stop</button>
|
||||
</div>
|
||||
`;
|
||||
wrap.appendChild(row);
|
||||
}
|
||||
wrap.querySelectorAll("button[data-service]").forEach((btn) => {
|
||||
btn.onclick = () => onServiceAction(btn.dataset.service, btn.dataset.action);
|
||||
});
|
||||
}
|
||||
34
src/biliup_next/app/static/app/components/step-list.js
Normal file
34
src/biliup_next/app/static/app/components/step-list.js
Normal file
@ -0,0 +1,34 @@
|
||||
import { state } from "../state.js";
|
||||
import { escapeHtml, formatDate, formatDuration, statusClass } from "../utils.js";
|
||||
|
||||
export function renderStepList(steps, onStepSelect) {
|
||||
const stepWrap = document.getElementById("stepList");
|
||||
stepWrap.innerHTML = "";
|
||||
steps.items.forEach((step) => {
|
||||
const row = document.createElement("div");
|
||||
row.className = `row-card ${state.selectedStepName === step.step_name ? "active" : ""}`;
|
||||
row.style.cursor = "pointer";
|
||||
const retryBlock = step.next_retry_at ? `
|
||||
<div class="step-card-metrics">
|
||||
<div class="step-metric"><strong>Next Retry</strong> ${escapeHtml(formatDate(step.next_retry_at))}</div>
|
||||
<div class="step-metric"><strong>Remaining</strong> ${escapeHtml(formatDuration(step.retry_remaining_seconds))}</div>
|
||||
<div class="step-metric"><strong>Wait Policy</strong> ${escapeHtml(formatDuration(step.retry_wait_seconds))}</div>
|
||||
</div>
|
||||
` : "";
|
||||
row.innerHTML = `
|
||||
<div class="step-card-title">
|
||||
<strong>${escapeHtml(step.step_name)}</strong>
|
||||
<span class="pill ${statusClass(step.status)}">${escapeHtml(step.status)}</span>
|
||||
<span class="pill">retry ${step.retry_count}</span>
|
||||
</div>
|
||||
<div class="muted-note">${escapeHtml(step.error_code || "")} ${escapeHtml(step.error_message || "")}</div>
|
||||
<div class="step-card-metrics">
|
||||
<div class="step-metric"><strong>Started</strong> ${escapeHtml(formatDate(step.started_at))}</div>
|
||||
<div class="step-metric"><strong>Finished</strong> ${escapeHtml(formatDate(step.finished_at))}</div>
|
||||
</div>
|
||||
${retryBlock}
|
||||
`;
|
||||
row.onclick = () => onStepSelect(step.step_name);
|
||||
stepWrap.appendChild(row);
|
||||
});
|
||||
}
|
||||
22
src/biliup_next/app/static/app/components/task-hero.js
Normal file
22
src/biliup_next/app/static/app/components/task-hero.js
Normal file
@ -0,0 +1,22 @@
|
||||
import { escapeHtml, statusClass } from "../utils.js";
|
||||
|
||||
export function renderTaskHero(task, steps) {
|
||||
const wrap = document.getElementById("taskHero");
|
||||
const succeeded = steps.items.filter((step) => step.status === "succeeded").length;
|
||||
const running = steps.items.filter((step) => step.status === "running").length;
|
||||
const failed = steps.items.filter((step) => step.status.startsWith("failed")).length;
|
||||
const delivery = task.delivery_state || {};
|
||||
wrap.className = "task-hero";
|
||||
wrap.innerHTML = `
|
||||
<div class="task-hero-title">${escapeHtml(task.title)}</div>
|
||||
<div class="task-hero-subtitle">${escapeHtml(task.id)} · ${escapeHtml(task.source_path)}</div>
|
||||
<div class="hero-meta-grid">
|
||||
<div class="mini-stat"><div class="mini-stat-label">Task Status</div><div class="mini-stat-value"><span class="pill ${statusClass(task.status)}">${escapeHtml(task.status)}</span></div></div>
|
||||
<div class="mini-stat"><div class="mini-stat-label">Succeeded Steps</div><div class="mini-stat-value">${succeeded}/${steps.items.length}</div></div>
|
||||
<div class="mini-stat"><div class="mini-stat-label">Running / Failed</div><div class="mini-stat-value">${running} / ${failed}</div></div>
|
||||
</div>
|
||||
<div class="task-hero-delivery muted-note">
|
||||
split comment=${escapeHtml(delivery.split_comment || "-")} · full timeline=${escapeHtml(delivery.full_video_timeline_comment || "-")} · source=${delivery.source_video_present ? "present" : "removed"} · split videos=${delivery.split_videos_present ? "present" : "removed"}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
22
src/biliup_next/app/static/app/components/timeline-list.js
Normal file
22
src/biliup_next/app/static/app/components/timeline-list.js
Normal file
@ -0,0 +1,22 @@
|
||||
import { escapeHtml, formatDate, formatDuration, statusClass } from "../utils.js";
|
||||
|
||||
export function renderTimelineList(timeline) {
|
||||
const timelineWrap = document.getElementById("timelineList");
|
||||
timelineWrap.innerHTML = "";
|
||||
timeline.items.forEach((item) => {
|
||||
const retryNote = item.retry_state?.next_retry_at
|
||||
? `<div class="timeline-meta-line"><strong>Next Retry</strong> ${escapeHtml(formatDate(item.retry_state.next_retry_at))} · remaining ${escapeHtml(formatDuration(item.retry_state.retry_remaining_seconds))}</div>`
|
||||
: "";
|
||||
const row = document.createElement("div");
|
||||
row.className = "timeline-card";
|
||||
row.innerHTML = `
|
||||
<div class="timeline-title"><strong>${escapeHtml(item.title)}</strong><span class="pill ${statusClass(item.status)}">${escapeHtml(item.status)}</span><span class="pill">${escapeHtml(item.kind)}</span></div>
|
||||
<div class="timeline-meta">
|
||||
<div class="timeline-meta-line">${escapeHtml(item.summary || "-")}</div>
|
||||
<div class="timeline-meta-line"><strong>Time</strong> ${escapeHtml(formatDate(item.time))}</div>
|
||||
${retryNote}
|
||||
</div>
|
||||
`;
|
||||
timelineWrap.appendChild(row);
|
||||
});
|
||||
}
|
||||
210
src/biliup_next/app/static/app/main.js
Normal file
210
src/biliup_next/app/static/app/main.js
Normal file
@ -0,0 +1,210 @@
|
||||
import { fetchJson, loadOverviewPayload, loadTaskPayload } from "./api.js";
|
||||
import { bindActions } from "./actions.js";
|
||||
import { currentRoute, initRouter, navigate } from "./router.js";
|
||||
import {
|
||||
clearSettingsFieldState,
|
||||
markSettingsFieldDirty,
|
||||
setOverviewData,
|
||||
setSettingsFieldError,
|
||||
setLogs,
|
||||
setLogListLoading,
|
||||
setSelectedLog,
|
||||
setSelectedStep,
|
||||
setSelectedTask,
|
||||
setTaskDetailStatus,
|
||||
setTaskListLoading,
|
||||
state,
|
||||
} from "./state.js";
|
||||
import { settingsFieldKey, showBanner } from "./utils.js";
|
||||
import {
|
||||
renderDoctor,
|
||||
renderModules,
|
||||
renderRecentActions,
|
||||
renderSchedulerQueue,
|
||||
renderServices,
|
||||
renderShellStats,
|
||||
} from "./views/overview.js";
|
||||
import { renderLogContent, renderLogsList } from "./views/logs.js";
|
||||
import { renderSettingsForm } from "./views/settings.js";
|
||||
import { renderTaskDetail, renderTasks, renderTaskWorkspaceState } from "./views/tasks.js";
|
||||
|
||||
async function refreshLog() {
|
||||
const name = state.selectedLogName;
|
||||
if (!name) return;
|
||||
let url = `/logs?name=${encodeURIComponent(name)}&lines=200`;
|
||||
if (document.getElementById("filterCurrentTask").checked && state.selectedTaskId) {
|
||||
const currentTask = state.currentTasks.find((item) => item.id === state.selectedTaskId);
|
||||
if (currentTask?.title) {
|
||||
url += `&contains=${encodeURIComponent(currentTask.title)}`;
|
||||
}
|
||||
}
|
||||
const payload = await fetchJson(url);
|
||||
renderLogContent(payload);
|
||||
}
|
||||
|
||||
async function selectLog(name) {
|
||||
setSelectedLog(name);
|
||||
renderLogsList(state.currentLogs, refreshLog, selectLog);
|
||||
await refreshLog();
|
||||
}
|
||||
|
||||
async function loadTaskDetail(taskId) {
|
||||
setTaskDetailStatus("loading");
|
||||
renderTaskWorkspaceState("loading");
|
||||
try {
|
||||
const payload = await loadTaskPayload(taskId);
|
||||
renderTaskDetail(payload, async (stepName) => {
|
||||
setSelectedStep(stepName);
|
||||
await loadTaskDetail(taskId);
|
||||
});
|
||||
setTaskDetailStatus("ready");
|
||||
renderTaskWorkspaceState("ready");
|
||||
} catch (err) {
|
||||
const message = `任务详情加载失败: ${err}`;
|
||||
setTaskDetailStatus("error", message);
|
||||
renderTaskWorkspaceState("error", message);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
function taskSelectHandler(taskId) {
|
||||
setSelectedTask(taskId);
|
||||
setSelectedStep(null);
|
||||
navigate("tasks", taskId);
|
||||
renderTasks(taskSelectHandler, taskRowActionHandler);
|
||||
return loadTaskDetail(taskId);
|
||||
}
|
||||
|
||||
async function taskRowActionHandler(action, taskId) {
|
||||
if (action !== "run") return;
|
||||
try {
|
||||
const result = await fetchJson(`/tasks/${taskId}/actions/run`, { method: "POST" });
|
||||
await loadOverview();
|
||||
showBanner(`任务已推进: ${taskId} / processed=${result.processed.length}`, "ok");
|
||||
} catch (err) {
|
||||
showBanner(`任务执行失败: ${err}`, "err");
|
||||
}
|
||||
}
|
||||
|
||||
function handleSettingsFieldChange(event) {
|
||||
const input = event.target;
|
||||
const group = input.dataset.group;
|
||||
const field = input.dataset.field;
|
||||
const fieldSchema = state.currentSettingsSchema.groups[group][field];
|
||||
const key = settingsFieldKey(group, field);
|
||||
let value;
|
||||
if (fieldSchema.type === "boolean") value = input.checked;
|
||||
else if (fieldSchema.type === "integer") {
|
||||
value = Number(input.value);
|
||||
if (input.value === "" || Number.isNaN(value)) {
|
||||
state.currentSettings[group] ??= {};
|
||||
state.currentSettings[group][field] = input.value;
|
||||
markSettingsFieldDirty(key, true);
|
||||
setSettingsFieldError(key, "必须填写整数");
|
||||
renderSettingsForm(handleSettingsFieldChange);
|
||||
return;
|
||||
}
|
||||
}
|
||||
else if (fieldSchema.type === "array") {
|
||||
try {
|
||||
value = JSON.parse(input.value || "[]");
|
||||
if (!Array.isArray(value)) throw new Error("not array");
|
||||
} catch {
|
||||
markSettingsFieldDirty(key, true);
|
||||
setSettingsFieldError(key, `${group}.${field} 必须是 JSON 数组`);
|
||||
renderSettingsForm(handleSettingsFieldChange);
|
||||
return;
|
||||
}
|
||||
} else value = input.value;
|
||||
if (fieldSchema.type === "integer" && typeof fieldSchema.minimum === "number" && value < fieldSchema.minimum) {
|
||||
markSettingsFieldDirty(key, true);
|
||||
setSettingsFieldError(key, `最小值为 ${fieldSchema.minimum}`);
|
||||
renderSettingsForm(handleSettingsFieldChange);
|
||||
return;
|
||||
}
|
||||
markSettingsFieldDirty(key, true);
|
||||
setSettingsFieldError(key, "");
|
||||
if (!state.currentSettings[group]) state.currentSettings[group] = {};
|
||||
state.currentSettings[group][field] = value;
|
||||
document.getElementById("settingsEditor").value = JSON.stringify(state.currentSettings, null, 2);
|
||||
renderSettingsForm(handleSettingsFieldChange);
|
||||
}
|
||||
|
||||
async function loadOverview() {
|
||||
setTaskListLoading(true);
|
||||
setLogListLoading(true);
|
||||
renderTasks(taskSelectHandler, taskRowActionHandler);
|
||||
const payload = await loadOverviewPayload();
|
||||
setOverviewData({
|
||||
tasks: payload.tasks.items,
|
||||
settings: payload.settings,
|
||||
settingsSchema: payload.settingsSchema,
|
||||
});
|
||||
clearSettingsFieldState();
|
||||
setTaskListLoading(false);
|
||||
renderShellStats(payload);
|
||||
renderSettingsForm(handleSettingsFieldChange);
|
||||
renderTasks(taskSelectHandler, taskRowActionHandler);
|
||||
renderModules(payload.modules.items);
|
||||
renderDoctor(payload.doctor.checks);
|
||||
renderSchedulerQueue(payload.scheduler);
|
||||
renderServices(payload.services.items, async (serviceId, action) => {
|
||||
if (["stop", "restart"].includes(action)) {
|
||||
const ok = window.confirm(`确认执行 ${action} ${serviceId} ?`);
|
||||
if (!ok) return;
|
||||
}
|
||||
try {
|
||||
const result = await fetchJson(`/runtime/services/${serviceId}/${action}`, { method: "POST" });
|
||||
await loadOverview();
|
||||
showBanner(`${result.id} ${result.action} 完成`, result.command_ok ? "ok" : "warn");
|
||||
} catch (err) {
|
||||
showBanner(`service 操作失败: ${err}`, "err");
|
||||
}
|
||||
});
|
||||
setLogs(payload.logs.items);
|
||||
setLogListLoading(false);
|
||||
if (!state.selectedLogName && payload.logs.items.length) {
|
||||
setSelectedLog(payload.logs.items[0].name);
|
||||
}
|
||||
renderLogsList(payload.logs.items, refreshLog, selectLog);
|
||||
renderRecentActions(payload.history.items);
|
||||
const route = currentRoute();
|
||||
const routeTaskExists = route.taskId && state.currentTasks.some((item) => item.id === route.taskId);
|
||||
if (route.view === "tasks" && routeTaskExists) {
|
||||
setSelectedTask(route.taskId);
|
||||
} else if (!state.selectedTaskId && state.currentTasks.length) {
|
||||
setSelectedTask(state.currentTasks[0].id);
|
||||
}
|
||||
if (state.selectedTaskId) await loadTaskDetail(state.selectedTaskId);
|
||||
else {
|
||||
setTaskDetailStatus("idle");
|
||||
renderTaskWorkspaceState("idle");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRouteChange(route) {
|
||||
if (route.view !== "tasks") return;
|
||||
if (!route.taskId) {
|
||||
if (state.selectedTaskId) navigate("tasks", state.selectedTaskId);
|
||||
return;
|
||||
}
|
||||
if (!state.currentTasks.length) return;
|
||||
if (!state.currentTasks.some((item) => item.id === route.taskId)) return;
|
||||
if (state.selectedTaskId !== route.taskId) {
|
||||
setSelectedTask(route.taskId);
|
||||
setSelectedStep(null);
|
||||
renderTasks(taskSelectHandler, taskRowActionHandler);
|
||||
await loadTaskDetail(route.taskId);
|
||||
}
|
||||
}
|
||||
|
||||
bindActions({
|
||||
loadOverview,
|
||||
loadTaskDetail,
|
||||
refreshLog,
|
||||
handleSettingsFieldChange,
|
||||
});
|
||||
initRouter((route) => {
|
||||
handleRouteChange(route).catch((err) => showBanner(`路由切换失败: ${err}`, "err"));
|
||||
});
|
||||
loadOverview().catch((err) => showBanner(`初始化失败: ${err}`, "err"));
|
||||
18
src/biliup_next/app/static/app/render.js
Normal file
18
src/biliup_next/app/static/app/render.js
Normal file
@ -0,0 +1,18 @@
|
||||
import { setView } from "./state.js";
|
||||
|
||||
export function renderView(view) {
|
||||
setView(view);
|
||||
document.querySelectorAll(".nav-btn").forEach((button) => {
|
||||
button.classList.toggle("active", button.dataset.view === view);
|
||||
});
|
||||
document.querySelectorAll(".view").forEach((section) => {
|
||||
section.classList.toggle("active", section.dataset.view === view);
|
||||
});
|
||||
const titleMap = {
|
||||
overview: "Overview",
|
||||
tasks: "Tasks",
|
||||
settings: "Settings",
|
||||
logs: "Logs",
|
||||
};
|
||||
document.getElementById("viewTitle").textContent = titleMap[view] || "Control";
|
||||
}
|
||||
22
src/biliup_next/app/static/app/router.js
Normal file
22
src/biliup_next/app/static/app/router.js
Normal file
@ -0,0 +1,22 @@
|
||||
import { renderView } from "./render.js";
|
||||
|
||||
export function currentRoute() {
|
||||
const raw = window.location.hash.replace(/^#/, "") || "overview";
|
||||
const [view = "overview", ...rest] = raw.split("/");
|
||||
const taskId = rest.length ? decodeURIComponent(rest.join("/")) : null;
|
||||
return { view: view || "overview", taskId };
|
||||
}
|
||||
|
||||
export function navigate(view, taskId = null) {
|
||||
window.location.hash = taskId ? `${view}/${encodeURIComponent(taskId)}` : view;
|
||||
}
|
||||
|
||||
export function initRouter(onRouteChange) {
|
||||
const sync = () => {
|
||||
const route = currentRoute();
|
||||
renderView(route.view);
|
||||
if (onRouteChange) onRouteChange(route);
|
||||
};
|
||||
window.addEventListener("hashchange", sync);
|
||||
sync();
|
||||
}
|
||||
91
src/biliup_next/app/static/app/state.js
Normal file
91
src/biliup_next/app/static/app/state.js
Normal file
@ -0,0 +1,91 @@
|
||||
export const state = {
|
||||
selectedTaskId: null,
|
||||
selectedStepName: null,
|
||||
currentTasks: [],
|
||||
currentSettings: {},
|
||||
originalSettings: {},
|
||||
currentSettingsSchema: null,
|
||||
settingsDirtyFields: {},
|
||||
settingsFieldErrors: {},
|
||||
currentView: "overview",
|
||||
taskPage: 1,
|
||||
taskPageSize: 24,
|
||||
taskListLoading: true,
|
||||
taskDetailStatus: "idle",
|
||||
taskDetailError: "",
|
||||
currentLogs: [],
|
||||
selectedLogName: null,
|
||||
logListLoading: true,
|
||||
logAutoRefreshTimer: null,
|
||||
};
|
||||
|
||||
export function setView(view) {
|
||||
state.currentView = view;
|
||||
}
|
||||
|
||||
export function setSelectedTask(taskId) {
|
||||
state.selectedTaskId = taskId;
|
||||
}
|
||||
|
||||
export function setSelectedStep(stepName) {
|
||||
state.selectedStepName = stepName;
|
||||
}
|
||||
|
||||
export function setOverviewData({ tasks, settings, settingsSchema }) {
|
||||
state.currentTasks = tasks;
|
||||
state.currentSettings = settings;
|
||||
state.originalSettings = JSON.parse(JSON.stringify(settings || {}));
|
||||
state.currentSettingsSchema = settingsSchema;
|
||||
}
|
||||
|
||||
export function markSettingsFieldDirty(key, dirty = true) {
|
||||
if (dirty) state.settingsDirtyFields[key] = true;
|
||||
else delete state.settingsDirtyFields[key];
|
||||
}
|
||||
|
||||
export function setSettingsFieldError(key, message = "") {
|
||||
if (message) state.settingsFieldErrors[key] = message;
|
||||
else delete state.settingsFieldErrors[key];
|
||||
}
|
||||
|
||||
export function clearSettingsFieldState() {
|
||||
state.settingsDirtyFields = {};
|
||||
state.settingsFieldErrors = {};
|
||||
}
|
||||
|
||||
export function setTaskPage(page) {
|
||||
state.taskPage = page;
|
||||
}
|
||||
|
||||
export function setTaskPageSize(size) {
|
||||
state.taskPageSize = size;
|
||||
}
|
||||
|
||||
export function resetTaskPage() {
|
||||
state.taskPage = 1;
|
||||
}
|
||||
|
||||
export function setTaskListLoading(loading) {
|
||||
state.taskListLoading = loading;
|
||||
}
|
||||
|
||||
export function setTaskDetailStatus(status, error = "") {
|
||||
state.taskDetailStatus = status;
|
||||
state.taskDetailError = error;
|
||||
}
|
||||
|
||||
export function setLogs(logs) {
|
||||
state.currentLogs = logs;
|
||||
}
|
||||
|
||||
export function setSelectedLog(name) {
|
||||
state.selectedLogName = name;
|
||||
}
|
||||
|
||||
export function setLogListLoading(loading) {
|
||||
state.logListLoading = loading;
|
||||
}
|
||||
|
||||
export function setLogAutoRefreshTimer(timerId) {
|
||||
state.logAutoRefreshTimer = timerId;
|
||||
}
|
||||
61
src/biliup_next/app/static/app/utils.js
Normal file
61
src/biliup_next/app/static/app/utils.js
Normal file
@ -0,0 +1,61 @@
|
||||
import { state } from "./state.js";
|
||||
|
||||
export function statusClass(status) {
|
||||
if (["collection_synced", "published", "commented", "succeeded", "active"].includes(status)) return "good";
|
||||
if (["done", "resolved", "present"].includes(status)) return "good";
|
||||
if (["legacy_untracked", "pending", "unresolved"].includes(status)) return "warn";
|
||||
if (["removed", "disabled"].includes(status)) return "";
|
||||
if (["failed_manual", "failed_retryable", "inactive"].includes(status)) return "hot";
|
||||
if (["running", "activating", "songs_detected", "split_done", "transcribed", "created", "pending"].includes(status)) return "warn";
|
||||
return "";
|
||||
}
|
||||
|
||||
export function showBanner(message, kind) {
|
||||
const el = document.getElementById("banner");
|
||||
el.textContent = message;
|
||||
el.className = `banner show ${kind}`;
|
||||
}
|
||||
|
||||
export function escapeHtml(text) {
|
||||
return String(text)
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">");
|
||||
}
|
||||
|
||||
export function formatDate(value) {
|
||||
if (!value) return "-";
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return value;
|
||||
return date.toLocaleString("zh-CN", { hour12: false });
|
||||
}
|
||||
|
||||
export function formatDuration(seconds) {
|
||||
if (seconds == null || Number.isNaN(Number(seconds))) return "-";
|
||||
const total = Math.max(0, Number(seconds));
|
||||
const h = Math.floor(total / 3600);
|
||||
const m = Math.floor((total % 3600) / 60);
|
||||
const s = total % 60;
|
||||
if (h > 0) return `${h}h ${m}m ${s}s`;
|
||||
if (m > 0) return `${m}m ${s}s`;
|
||||
return `${s}s`;
|
||||
}
|
||||
|
||||
export function syncSettingsEditorFromState() {
|
||||
document.getElementById("settingsEditor").value = JSON.stringify(state.currentSettings, null, 2);
|
||||
}
|
||||
|
||||
export function getGroupOrder(groupName) {
|
||||
return Number(state.currentSettingsSchema?.group_ui?.[groupName]?.order || 9999);
|
||||
}
|
||||
|
||||
export function compareFieldEntries(a, b) {
|
||||
const orderA = Number(a[1].ui_order || 9999);
|
||||
const orderB = Number(b[1].ui_order || 9999);
|
||||
if (orderA !== orderB) return orderA - orderB;
|
||||
return String(a[0]).localeCompare(String(b[0]));
|
||||
}
|
||||
|
||||
export function settingsFieldKey(group, field) {
|
||||
return `${group}.${field}`;
|
||||
}
|
||||
52
src/biliup_next/app/static/app/views/logs.js
Normal file
52
src/biliup_next/app/static/app/views/logs.js
Normal file
@ -0,0 +1,52 @@
|
||||
import { state } from "../state.js";
|
||||
import { escapeHtml, formatDate, showBanner } from "../utils.js";
|
||||
|
||||
export function filteredLogs() {
|
||||
const search = (document.getElementById("logSearchInput")?.value || "").trim().toLowerCase();
|
||||
return state.currentLogs.filter((item) => !search || item.name.toLowerCase().includes(search));
|
||||
}
|
||||
|
||||
export function renderLogsList(items, onRefreshLog, onSelectLog) {
|
||||
const wrap = document.getElementById("logList");
|
||||
const stateEl = document.getElementById("logListState");
|
||||
wrap.innerHTML = "";
|
||||
const visible = filteredLogs();
|
||||
if (state.logListLoading) {
|
||||
stateEl.textContent = "正在加载日志索引…";
|
||||
stateEl.classList.add("show");
|
||||
return;
|
||||
}
|
||||
if (!visible.length) {
|
||||
stateEl.textContent = "没有匹配日志文件。";
|
||||
stateEl.classList.add("show");
|
||||
return;
|
||||
}
|
||||
stateEl.classList.remove("show");
|
||||
visible.forEach((item) => {
|
||||
const row = document.createElement("div");
|
||||
row.className = `task-card log-card ${state.selectedLogName === item.name ? "active" : ""}`;
|
||||
row.innerHTML = `
|
||||
<div class="task-title">${escapeHtml(item.name)}</div>
|
||||
<div class="muted-note">${escapeHtml(item.path || "")}</div>
|
||||
`;
|
||||
row.onclick = () => onSelectLog(item.name);
|
||||
wrap.appendChild(row);
|
||||
});
|
||||
if (!state.selectedLogName && visible[0]) onSelectLog(visible[0].name);
|
||||
}
|
||||
|
||||
export function renderLogContent(payload) {
|
||||
document.getElementById("logPath").textContent = payload.path || "-";
|
||||
document.getElementById("logMeta").textContent = `updated ${formatDate(new Date().toISOString())}`;
|
||||
const filter = (document.getElementById("logLineFilter")?.value || "").trim().toLowerCase();
|
||||
const content = payload.content || "";
|
||||
if (!filter) {
|
||||
document.getElementById("logContent").textContent = content;
|
||||
return;
|
||||
}
|
||||
const filtered = content
|
||||
.split("\n")
|
||||
.filter((line) => line.toLowerCase().includes(filter))
|
||||
.join("\n");
|
||||
document.getElementById("logContent").textContent = filtered;
|
||||
}
|
||||
98
src/biliup_next/app/static/app/views/overview.js
Normal file
98
src/biliup_next/app/static/app/views/overview.js
Normal file
@ -0,0 +1,98 @@
|
||||
import { renderDoctor } from "../components/doctor-check-list.js";
|
||||
import { renderModules } from "../components/modules-list.js";
|
||||
import { renderRecentActions } from "../components/recent-actions-list.js";
|
||||
import { renderRuntimeSnapshot } from "../components/overview-runtime.js";
|
||||
import {
|
||||
renderOverviewRetrySummary,
|
||||
renderOverviewTaskSummary,
|
||||
} from "../components/overview-task-summary.js";
|
||||
import { renderServices } from "../components/service-list.js";
|
||||
import { escapeHtml } from "../utils.js";
|
||||
|
||||
export function renderShellStats({ health, doctor, tasks }) {
|
||||
renderRuntimeSnapshot({ health, doctor, tasks });
|
||||
renderOverviewTaskSummary(tasks.items);
|
||||
renderOverviewRetrySummary(tasks.items);
|
||||
}
|
||||
|
||||
export function renderSchedulerQueue(scheduler) {
|
||||
const summary = document.getElementById("schedulerSummary");
|
||||
const list = document.getElementById("schedulerList");
|
||||
const stageScan = document.getElementById("stageScanSummary");
|
||||
if (!summary || !list || !stageScan) return;
|
||||
summary.innerHTML = "";
|
||||
list.innerHTML = "";
|
||||
stageScan.innerHTML = "";
|
||||
|
||||
const scheduledCount = scheduler?.scheduled?.length || 0;
|
||||
const deferredCount = scheduler?.deferred?.length || 0;
|
||||
const summaryData = scheduler?.summary || {};
|
||||
const strategy = scheduler?.strategy || {};
|
||||
[
|
||||
["scheduled", scheduledCount, scheduledCount ? "warn" : ""],
|
||||
["deferred", deferredCount, deferredCount ? "hot" : ""],
|
||||
["scanned", summaryData.scanned_count || 0, ""],
|
||||
["truncated", summaryData.truncated_count || 0, (summaryData.truncated_count || 0) ? "warn" : ""],
|
||||
].forEach(([label, value, klass]) => {
|
||||
const pill = document.createElement("div");
|
||||
pill.className = `pill ${klass}`.trim();
|
||||
pill.textContent = `${label} ${value}`;
|
||||
summary.appendChild(pill);
|
||||
});
|
||||
|
||||
const strategyRow = document.createElement("div");
|
||||
strategyRow.className = "row-card";
|
||||
strategyRow.innerHTML = `
|
||||
<strong>Scheduler Strategy</strong>
|
||||
<div class="muted-note">max_tasks_per_cycle=${escapeHtml(String(strategy.max_tasks_per_cycle ?? "-"))}, candidate_scan_limit=${escapeHtml(String(strategy.candidate_scan_limit ?? "-"))}</div>
|
||||
<div class="muted-note">prioritize_retry_due=${escapeHtml(String(strategy.prioritize_retry_due ?? "-"))}, oldest_first=${escapeHtml(String(strategy.oldest_first ?? "-"))}</div>
|
||||
<div class="muted-note">status_priority=${escapeHtml((strategy.status_priority || []).join(" > ") || "-")}</div>
|
||||
`;
|
||||
list.appendChild(strategyRow);
|
||||
|
||||
const skipped = summaryData.skipped_counts || {};
|
||||
const skippedRow = document.createElement("div");
|
||||
skippedRow.className = "row-card";
|
||||
skippedRow.innerHTML = `
|
||||
<strong>Unscheduled Reasons</strong>
|
||||
<div class="muted-note">failed_manual=${escapeHtml(String(skipped.failed_manual || 0))}</div>
|
||||
<div class="muted-note">no_runnable_step=${escapeHtml(String(skipped.no_runnable_step || 0))}</div>
|
||||
`;
|
||||
list.appendChild(skippedRow);
|
||||
|
||||
const items = [...(scheduler?.scheduled || []).map((item) => ({ ...item, queue: "scheduled" })), ...(scheduler?.deferred || []).map((item) => ({ ...item, queue: "deferred" }))];
|
||||
if (!items.length) {
|
||||
const empty = document.createElement("div");
|
||||
empty.className = "row-card";
|
||||
empty.innerHTML = `<strong>当前无排队任务</strong><div class="muted-note">scheduler 本轮没有挑出需要执行或等待重试的任务。</div>`;
|
||||
list.appendChild(empty);
|
||||
} else {
|
||||
items.slice(0, 12).forEach((item) => {
|
||||
const row = document.createElement("div");
|
||||
row.className = "row-card";
|
||||
row.innerHTML = `
|
||||
<div class="step-card-title">
|
||||
<strong>${escapeHtml(item.task_id)}</strong>
|
||||
<span class="pill ${item.queue === "deferred" ? "hot" : "warn"}">${escapeHtml(item.queue)}</span>
|
||||
${item.step_name ? `<span class="pill">${escapeHtml(item.step_name)}</span>` : ""}
|
||||
${item.task_status ? `<span class="pill">${escapeHtml(item.task_status)}</span>` : ""}
|
||||
</div>
|
||||
<div class="muted-note">${escapeHtml(item.reason || (item.waiting_for_retry ? "waiting_for_retry" : "-"))}</div>
|
||||
${item.remaining_seconds != null ? `<div class="muted-note">remaining ${escapeHtml(String(item.remaining_seconds))}s</div>` : ""}
|
||||
`;
|
||||
list.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
const scan = scheduler?.stage_scan || { accepted: [], rejected: [], skipped: [] };
|
||||
[
|
||||
["accepted", scan.accepted?.length || 0],
|
||||
["rejected", scan.rejected?.length || 0],
|
||||
["skipped", scan.skipped?.length || 0],
|
||||
].forEach(([label, value]) => {
|
||||
const row = document.createElement("div");
|
||||
row.className = "row-card";
|
||||
row.innerHTML = `<strong>${escapeHtml(label)}</strong><div class="muted-note">${escapeHtml(String(value))}</div>`;
|
||||
stageScan.appendChild(row);
|
||||
});
|
||||
}
|
||||
162
src/biliup_next/app/static/app/views/settings.js
Normal file
162
src/biliup_next/app/static/app/views/settings.js
Normal file
@ -0,0 +1,162 @@
|
||||
import { state } from "../state.js";
|
||||
import {
|
||||
compareFieldEntries,
|
||||
escapeHtml,
|
||||
getGroupOrder,
|
||||
settingsFieldKey,
|
||||
syncSettingsEditorFromState,
|
||||
} from "../utils.js";
|
||||
|
||||
export function renderSettingsForm(onFieldChange) {
|
||||
const wrap = document.getElementById("settingsForm");
|
||||
wrap.innerHTML = "";
|
||||
if (!state.currentSettingsSchema?.groups) return;
|
||||
const search = (document.getElementById("settingsSearch")?.value || "").trim().toLowerCase();
|
||||
|
||||
const featuredContainer = document.createElement("div");
|
||||
featuredContainer.className = "settings-groups";
|
||||
const advancedDetails = document.createElement("details");
|
||||
advancedDetails.className = "settings-advanced";
|
||||
advancedDetails.innerHTML = "<summary>Advanced Settings</summary>";
|
||||
const advancedContainer = document.createElement("div");
|
||||
advancedContainer.className = "settings-groups";
|
||||
|
||||
const createSettingsField = (groupName, fieldName, fieldSchema) => {
|
||||
const key = settingsFieldKey(groupName, fieldName);
|
||||
const row = document.createElement("div");
|
||||
row.className = "settings-field";
|
||||
if (state.settingsDirtyFields[key]) row.classList.add("dirty");
|
||||
if (state.settingsFieldErrors[key]) row.classList.add("error");
|
||||
const label = document.createElement("label");
|
||||
label.className = "settings-label";
|
||||
label.textContent = fieldSchema.title || `${groupName}.${fieldName}`;
|
||||
if (fieldSchema.ui_widget) {
|
||||
const badge = document.createElement("span");
|
||||
badge.className = "settings-badge";
|
||||
badge.textContent = fieldSchema.ui_widget;
|
||||
label.appendChild(badge);
|
||||
}
|
||||
if (fieldSchema.ui_featured === true) {
|
||||
const badge = document.createElement("span");
|
||||
badge.className = "settings-badge";
|
||||
badge.textContent = "featured";
|
||||
label.appendChild(badge);
|
||||
}
|
||||
row.appendChild(label);
|
||||
|
||||
const value = state.currentSettings[groupName]?.[fieldName];
|
||||
let input;
|
||||
if (fieldSchema.type === "boolean") {
|
||||
input = document.createElement("input");
|
||||
input.type = "checkbox";
|
||||
input.checked = Boolean(value);
|
||||
} else if (Array.isArray(fieldSchema.enum)) {
|
||||
input = document.createElement("select");
|
||||
fieldSchema.enum.forEach((optionValue) => {
|
||||
const option = document.createElement("option");
|
||||
option.value = String(optionValue);
|
||||
option.textContent = String(optionValue);
|
||||
if (value === optionValue) option.selected = true;
|
||||
input.appendChild(option);
|
||||
});
|
||||
} else if (fieldSchema.type === "array") {
|
||||
input = document.createElement("textarea");
|
||||
input.style.minHeight = "96px";
|
||||
input.value = JSON.stringify(value ?? [], null, 2);
|
||||
} else {
|
||||
input = document.createElement("input");
|
||||
input.type = fieldSchema.sensitive ? "password" : (fieldSchema.type === "integer" ? "number" : "text");
|
||||
input.value = value ?? "";
|
||||
if (fieldSchema.type === "integer") {
|
||||
if (typeof fieldSchema.minimum === "number") input.min = String(fieldSchema.minimum);
|
||||
input.step = "1";
|
||||
}
|
||||
if (fieldSchema.ui_placeholder) input.placeholder = fieldSchema.ui_placeholder;
|
||||
}
|
||||
input.dataset.group = groupName;
|
||||
input.dataset.field = fieldName;
|
||||
input.onchange = onFieldChange;
|
||||
row.appendChild(input);
|
||||
|
||||
const originalValue = state.originalSettings[groupName]?.[fieldName];
|
||||
const currentValue = state.currentSettings[groupName]?.[fieldName];
|
||||
const changed = JSON.stringify(originalValue ?? null) !== JSON.stringify(currentValue ?? null);
|
||||
if (changed) {
|
||||
const controls = document.createElement("div");
|
||||
controls.className = "button-row";
|
||||
const revert = document.createElement("button");
|
||||
revert.className = "secondary compact";
|
||||
revert.type = "button";
|
||||
revert.textContent = "撤销本字段";
|
||||
revert.dataset.revertGroup = groupName;
|
||||
revert.dataset.revertField = fieldName;
|
||||
controls.appendChild(revert);
|
||||
row.appendChild(controls);
|
||||
}
|
||||
|
||||
if (fieldSchema.description || fieldSchema.sensitive) {
|
||||
const hint = document.createElement("div");
|
||||
hint.className = "hint";
|
||||
let text = fieldSchema.description || "";
|
||||
if (fieldSchema.sensitive) text = `${text ? `${text} ` : ""}Sensitive`;
|
||||
hint.textContent = text;
|
||||
row.appendChild(hint);
|
||||
}
|
||||
if (state.settingsFieldErrors[key]) {
|
||||
const error = document.createElement("div");
|
||||
error.className = "field-error";
|
||||
error.textContent = state.settingsFieldErrors[key];
|
||||
row.appendChild(error);
|
||||
}
|
||||
return row;
|
||||
};
|
||||
|
||||
const createSettingsGroup = (groupName, fields, featured) => {
|
||||
const entries = Object.entries(fields);
|
||||
if (!entries.length) return null;
|
||||
const group = document.createElement("div");
|
||||
group.className = `settings-group ${featured ? "featured" : ""}`.trim();
|
||||
group.innerHTML = `<h3>${escapeHtml(state.currentSettingsSchema.group_ui?.[groupName]?.title || groupName)}</h3>`;
|
||||
const descText = state.currentSettingsSchema.group_ui?.[groupName]?.description;
|
||||
if (descText) {
|
||||
const desc = document.createElement("div");
|
||||
desc.className = "group-desc";
|
||||
desc.textContent = descText;
|
||||
group.appendChild(desc);
|
||||
}
|
||||
const fieldWrap = document.createElement("div");
|
||||
fieldWrap.className = "settings-fields";
|
||||
entries.forEach(([fieldName, fieldSchema]) => fieldWrap.appendChild(createSettingsField(groupName, fieldName, fieldSchema)));
|
||||
group.appendChild(fieldWrap);
|
||||
return group;
|
||||
};
|
||||
|
||||
Object.entries(state.currentSettingsSchema.groups)
|
||||
.sort((a, b) => getGroupOrder(a[0]) - getGroupOrder(b[0]))
|
||||
.forEach(([groupName, fields]) => {
|
||||
const featuredFields = {};
|
||||
const advancedFields = {};
|
||||
Object.entries(fields).sort((a, b) => compareFieldEntries(a, b)).forEach(([fieldName, fieldSchema]) => {
|
||||
const key = `${groupName}.${fieldName}`.toLowerCase();
|
||||
if (search && !key.includes(search) && !(fieldSchema.description || "").toLowerCase().includes(search)) return;
|
||||
if (fieldSchema.ui_featured === true) featuredFields[fieldName] = fieldSchema;
|
||||
else advancedFields[fieldName] = fieldSchema;
|
||||
});
|
||||
const featuredGroup = createSettingsGroup(groupName, featuredFields, true);
|
||||
const advancedGroup = createSettingsGroup(groupName, advancedFields, false);
|
||||
if (featuredGroup) featuredContainer.appendChild(featuredGroup);
|
||||
if (advancedGroup) advancedContainer.appendChild(advancedGroup);
|
||||
});
|
||||
|
||||
if (!featuredContainer.children.length && !advancedContainer.children.length) {
|
||||
wrap.innerHTML = `<div class="row-card"><strong>没有匹配的配置项</strong><div class="muted-note">调整搜索关键字后重试。</div></div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
if (featuredContainer.children.length) wrap.appendChild(featuredContainer);
|
||||
if (advancedContainer.children.length) {
|
||||
advancedDetails.appendChild(advancedContainer);
|
||||
wrap.appendChild(advancedDetails);
|
||||
}
|
||||
syncSettingsEditorFromState();
|
||||
}
|
||||
462
src/biliup_next/app/static/app/views/tasks.js
Normal file
462
src/biliup_next/app/static/app/views/tasks.js
Normal file
@ -0,0 +1,462 @@
|
||||
import { state, setTaskPage } from "../state.js";
|
||||
import { escapeHtml, formatDate, formatDuration, statusClass } from "../utils.js";
|
||||
import { renderArtifactList } from "../components/artifact-list.js";
|
||||
import { renderHistoryList } from "../components/history-list.js";
|
||||
import { renderRetryPanel } from "../components/retry-banner.js";
|
||||
import { renderStepList } from "../components/step-list.js";
|
||||
import { renderTaskHero } from "../components/task-hero.js";
|
||||
import { renderTimelineList } from "../components/timeline-list.js";
|
||||
|
||||
const STATUS_LABELS = {
|
||||
created: "待转录",
|
||||
transcribed: "待识歌",
|
||||
songs_detected: "待切歌",
|
||||
split_done: "待上传",
|
||||
published: "待收尾",
|
||||
collection_synced: "已完成",
|
||||
failed_retryable: "待重试",
|
||||
failed_manual: "待人工",
|
||||
running: "处理中",
|
||||
};
|
||||
|
||||
const DELIVERY_LABELS = {
|
||||
done: "已发送",
|
||||
pending: "待处理",
|
||||
legacy_untracked: "历史未追踪",
|
||||
resolved: "已定位",
|
||||
unresolved: "未定位",
|
||||
present: "保留",
|
||||
removed: "已清理",
|
||||
};
|
||||
|
||||
function displayStatus(status) {
|
||||
return STATUS_LABELS[status] || status || "-";
|
||||
}
|
||||
|
||||
function displayDelivery(status) {
|
||||
return DELIVERY_LABELS[status] || status || "-";
|
||||
}
|
||||
|
||||
function cleanupState(deliveryState = {}) {
|
||||
return deliveryState.source_video_present === false || deliveryState.split_videos_present === false
|
||||
? "removed"
|
||||
: "present";
|
||||
}
|
||||
|
||||
function attentionState(task) {
|
||||
if (task.status === "failed_manual") return "manual_now";
|
||||
if (task.retry_state?.retry_due) return "retry_now";
|
||||
if (task.status === "failed_retryable" && task.retry_state?.next_retry_at) return "waiting_retry";
|
||||
if (task.status === "running") return "in_progress";
|
||||
return "stable";
|
||||
}
|
||||
|
||||
function displayAttention(status) {
|
||||
return {
|
||||
manual_now: "需人工",
|
||||
retry_now: "立即重试",
|
||||
waiting_retry: "等待重试",
|
||||
in_progress: "处理中",
|
||||
stable: "正常",
|
||||
}[status] || status;
|
||||
}
|
||||
|
||||
function attentionClass(status) {
|
||||
if (status === "manual_now") return "hot";
|
||||
if (["retry_now", "waiting_retry", "in_progress"].includes(status)) return "warn";
|
||||
return "good";
|
||||
}
|
||||
|
||||
function compareText(a, b) {
|
||||
return String(a || "").localeCompare(String(b || ""), "zh-CN");
|
||||
}
|
||||
|
||||
function compareBySort(sort, a, b) {
|
||||
const deliveryA = a.delivery_state || {};
|
||||
const deliveryB = b.delivery_state || {};
|
||||
if (sort === "updated_asc") return compareText(a.updated_at, b.updated_at);
|
||||
if (sort === "title_asc") return compareText(a.title, b.title);
|
||||
if (sort === "title_desc") return compareText(b.title, a.title);
|
||||
if (sort === "next_retry_asc") {
|
||||
const diff = compareText(a.retry_state?.next_retry_at || "9999", b.retry_state?.next_retry_at || "9999");
|
||||
return diff || compareText(b.updated_at, a.updated_at);
|
||||
}
|
||||
if (sort === "attention_state") {
|
||||
const diff = compareText(attentionState(a), attentionState(b));
|
||||
return diff || compareText(b.updated_at, a.updated_at);
|
||||
}
|
||||
if (sort === "split_comment_status") {
|
||||
const diff = compareText(deliveryA.split_comment, deliveryB.split_comment);
|
||||
return diff || compareText(b.updated_at, a.updated_at);
|
||||
}
|
||||
if (sort === "full_comment_status") {
|
||||
const diff = compareText(deliveryA.full_video_timeline_comment, deliveryB.full_video_timeline_comment);
|
||||
return diff || compareText(b.updated_at, a.updated_at);
|
||||
}
|
||||
if (sort === "cleanup_state") {
|
||||
const diff = compareText(cleanupState(deliveryA), cleanupState(deliveryB));
|
||||
return diff || compareText(b.updated_at, a.updated_at);
|
||||
}
|
||||
return compareText(b.updated_at, a.updated_at);
|
||||
}
|
||||
|
||||
function headerSortValue(field, currentSort) {
|
||||
const fieldMap = {
|
||||
title: ["title_asc", "title_desc"],
|
||||
status: ["status_group", "updated_desc"],
|
||||
attention: ["attention_state", "updated_desc"],
|
||||
split_comment: ["split_comment_status", "updated_desc"],
|
||||
full_comment: ["full_comment_status", "updated_desc"],
|
||||
cleanup: ["cleanup_state", "updated_desc"],
|
||||
next_retry: ["next_retry_asc", "updated_desc"],
|
||||
updated: ["updated_desc", "updated_asc"],
|
||||
};
|
||||
const [primary, secondary] = fieldMap[field] || ["updated_desc", "updated_asc"];
|
||||
return currentSort === primary ? secondary : primary;
|
||||
}
|
||||
|
||||
function headerLabel(text, field, currentSort) {
|
||||
const activeSorts = {
|
||||
title: ["title_asc", "title_desc"],
|
||||
status: ["status_group"],
|
||||
attention: ["attention_state"],
|
||||
split_comment: ["split_comment_status"],
|
||||
full_comment: ["full_comment_status"],
|
||||
cleanup: ["cleanup_state"],
|
||||
next_retry: ["next_retry_asc"],
|
||||
updated: ["updated_desc", "updated_asc"],
|
||||
};
|
||||
const active = activeSorts[field]?.includes(currentSort) ? " active" : "";
|
||||
const direction = currentSort === "updated_desc" && field === "updated"
|
||||
? "↓"
|
||||
: currentSort === "updated_asc" && field === "updated"
|
||||
? "↑"
|
||||
: currentSort === "title_asc" && field === "title"
|
||||
? "↑"
|
||||
: currentSort === "title_desc" && field === "title"
|
||||
? "↓"
|
||||
: currentSort === "status_group" && field === "status"
|
||||
? "•"
|
||||
: currentSort === "attention_state" && field === "attention"
|
||||
? "•"
|
||||
: currentSort === "split_comment_status" && field === "split_comment"
|
||||
? "•"
|
||||
: currentSort === "full_comment_status" && field === "full_comment"
|
||||
? "•"
|
||||
: currentSort === "cleanup_state" && field === "cleanup"
|
||||
? "•"
|
||||
: currentSort === "next_retry_asc" && field === "next_retry"
|
||||
? "↑"
|
||||
: "";
|
||||
return `<button class="table-sort-btn${active}" data-sort-field="${field}">${escapeHtml(text)}${direction ? `<span>${direction}</span>` : ""}</button>`;
|
||||
}
|
||||
|
||||
export function filteredTasks() {
|
||||
const search = (document.getElementById("taskSearchInput")?.value || "").trim().toLowerCase();
|
||||
const status = document.getElementById("taskStatusFilter")?.value || "";
|
||||
const sort = document.getElementById("taskSortSelect")?.value || "updated_desc";
|
||||
const delivery = document.getElementById("taskDeliveryFilter")?.value || "";
|
||||
const attention = document.getElementById("taskAttentionFilter")?.value || "";
|
||||
let items = state.currentTasks.filter((task) => {
|
||||
const haystack = `${task.id} ${task.title}`.toLowerCase();
|
||||
if (search && !haystack.includes(search)) return false;
|
||||
if (status && task.status !== status) return false;
|
||||
const deliveryState = task.delivery_state || {};
|
||||
if (delivery === "legacy_untracked" && deliveryState.full_video_timeline_comment !== "legacy_untracked") return false;
|
||||
if (delivery === "pending_comment" && deliveryState.split_comment !== "pending" && deliveryState.full_video_timeline_comment !== "pending") return false;
|
||||
if (delivery === "cleanup_removed" && deliveryState.source_video_present !== false && deliveryState.split_videos_present !== false) return false;
|
||||
if (attention && attentionState(task) !== attention) return false;
|
||||
return true;
|
||||
});
|
||||
const statusRank = {
|
||||
failed_manual: 0,
|
||||
failed_retryable: 1,
|
||||
running: 2,
|
||||
created: 3,
|
||||
transcribed: 4,
|
||||
songs_detected: 5,
|
||||
split_done: 6,
|
||||
published: 7,
|
||||
collection_synced: 8,
|
||||
};
|
||||
items = [...items].sort((a, b) => {
|
||||
if (sort === "status_group") {
|
||||
const diff = (statusRank[a.status] ?? 99) - (statusRank[b.status] ?? 99);
|
||||
if (diff !== 0) return diff;
|
||||
return compareText(b.updated_at, a.updated_at);
|
||||
}
|
||||
return compareBySort(sort, a, b);
|
||||
});
|
||||
return items;
|
||||
}
|
||||
|
||||
export function pagedTasks(items = filteredTasks()) {
|
||||
const total = items.length;
|
||||
const totalPages = Math.max(1, Math.ceil(total / state.taskPageSize));
|
||||
const safePage = Math.min(Math.max(1, state.taskPage), totalPages);
|
||||
if (safePage !== state.taskPage) setTaskPage(safePage);
|
||||
const start = (safePage - 1) * state.taskPageSize;
|
||||
const end = start + state.taskPageSize;
|
||||
return {
|
||||
items: items.slice(start, end),
|
||||
total,
|
||||
totalPages,
|
||||
page: safePage,
|
||||
pageSize: state.taskPageSize,
|
||||
start: total ? start + 1 : 0,
|
||||
end: Math.min(end, total),
|
||||
};
|
||||
}
|
||||
|
||||
export function renderTaskStatusSummary(items = filteredTasks()) {
|
||||
const wrap = document.getElementById("taskStatusSummary");
|
||||
if (!wrap) return;
|
||||
const counts = new Map();
|
||||
items.forEach((item) => counts.set(item.status, (counts.get(item.status) || 0) + 1));
|
||||
const orderedStatuses = ["running", "failed_retryable", "failed_manual", "created", "transcribed", "songs_detected", "split_done", "published", "collection_synced"];
|
||||
wrap.innerHTML = "";
|
||||
const totalPill = document.createElement("div");
|
||||
totalPill.className = "pill";
|
||||
totalPill.textContent = `filtered ${items.length}`;
|
||||
wrap.appendChild(totalPill);
|
||||
orderedStatuses.forEach((status) => {
|
||||
const count = counts.get(status);
|
||||
if (!count) return;
|
||||
const pill = document.createElement("div");
|
||||
pill.className = `pill ${statusClass(status)}`;
|
||||
pill.textContent = `${status} ${count}`;
|
||||
wrap.appendChild(pill);
|
||||
});
|
||||
}
|
||||
|
||||
export function renderTaskPagination(items = filteredTasks()) {
|
||||
const meta = pagedTasks(items);
|
||||
const summary = document.getElementById("taskPaginationSummary");
|
||||
const prevBtn = document.getElementById("taskPrevPageBtn");
|
||||
const nextBtn = document.getElementById("taskNextPageBtn");
|
||||
const sizeSelect = document.getElementById("taskPageSizeSelect");
|
||||
if (summary) {
|
||||
summary.textContent = meta.total
|
||||
? `showing ${meta.start}-${meta.end} of ${meta.total} · page ${meta.page}/${meta.totalPages}`
|
||||
: "没有可显示的任务";
|
||||
}
|
||||
if (prevBtn) prevBtn.disabled = meta.page <= 1;
|
||||
if (nextBtn) nextBtn.disabled = meta.page >= meta.totalPages;
|
||||
if (sizeSelect) sizeSelect.value = String(state.taskPageSize);
|
||||
return meta;
|
||||
}
|
||||
|
||||
export function renderTaskListState(items = filteredTasks()) {
|
||||
const stateEl = document.getElementById("taskListState");
|
||||
if (!stateEl) return;
|
||||
if (state.taskListLoading) {
|
||||
stateEl.textContent = "正在加载任务列表…";
|
||||
stateEl.classList.add("show");
|
||||
return;
|
||||
}
|
||||
if (!items.length) {
|
||||
stateEl.textContent = "没有匹配任务,调整筛选条件后重试。";
|
||||
stateEl.classList.add("show");
|
||||
return;
|
||||
}
|
||||
stateEl.classList.remove("show");
|
||||
}
|
||||
|
||||
export function renderTasks(onSelect, onRowAction = null) {
|
||||
const wrap = document.getElementById("taskList");
|
||||
wrap.innerHTML = "";
|
||||
const items = filteredTasks();
|
||||
renderTaskStatusSummary(items);
|
||||
renderTaskListState(items);
|
||||
const meta = renderTaskPagination(items);
|
||||
if (state.taskListLoading) {
|
||||
wrap.innerHTML = `<div class="task-table-loading">正在加载任务表…</div>`;
|
||||
return;
|
||||
}
|
||||
if (!meta.items.length) {
|
||||
return;
|
||||
}
|
||||
const table = document.createElement("table");
|
||||
table.className = "task-table";
|
||||
const sort = document.getElementById("taskSortSelect")?.value || "updated_desc";
|
||||
table.innerHTML = `
|
||||
<thead>
|
||||
<tr>
|
||||
<th>${headerLabel("任务", "title", sort)}</th>
|
||||
<th>${headerLabel("状态", "status", sort)}</th>
|
||||
<th>${headerLabel("关注", "attention", sort)}</th>
|
||||
<th>${headerLabel("纯享评论", "split_comment", sort)}</th>
|
||||
<th>${headerLabel("主视频评论", "full_comment", sort)}</th>
|
||||
<th>${headerLabel("清理", "cleanup", sort)}</th>
|
||||
<th>${headerLabel("下次重试", "next_retry", sort)}</th>
|
||||
<th>${headerLabel("更新时间", "updated", sort)}</th>
|
||||
<th>快捷操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
`;
|
||||
const tbody = table.querySelector("tbody");
|
||||
for (const item of meta.items) {
|
||||
const delivery = item.delivery_state || {};
|
||||
const attention = attentionState(item);
|
||||
const row = document.createElement("tr");
|
||||
row.className = item.id === state.selectedTaskId ? "active" : "";
|
||||
row.innerHTML = `
|
||||
<td>
|
||||
<div class="task-cell-title">${escapeHtml(item.title)}</div>
|
||||
<div class="task-cell-subtitle">${escapeHtml(item.id)}</div>
|
||||
</td>
|
||||
<td><span class="pill ${statusClass(item.status)}">${escapeHtml(displayStatus(item.status))}</span></td>
|
||||
<td><span class="pill ${attentionClass(attention)}">${escapeHtml(displayAttention(attention))}</span></td>
|
||||
<td><span class="pill ${statusClass(delivery.split_comment || "")}">${escapeHtml(displayDelivery(delivery.split_comment || "-"))}</span></td>
|
||||
<td><span class="pill ${statusClass(delivery.full_video_timeline_comment || "")}">${escapeHtml(displayDelivery(delivery.full_video_timeline_comment || "-"))}</span></td>
|
||||
<td><span class="pill ${statusClass(cleanupState(delivery))}">${escapeHtml(displayDelivery(cleanupState(delivery)))}</span></td>
|
||||
<td>
|
||||
${item.retry_state?.next_retry_at ? `<div>${escapeHtml(formatDate(item.retry_state.next_retry_at))}</div>` : `<span class="muted-note">-</span>`}
|
||||
${item.retry_state?.retry_remaining_seconds != null ? `<div class="muted-note">${escapeHtml(formatDuration(item.retry_state.retry_remaining_seconds))}</div>` : ""}
|
||||
</td>
|
||||
<td>
|
||||
<div>${escapeHtml(formatDate(item.updated_at))}</div>
|
||||
${item.retry_state?.next_retry_at ? `<div class="muted-note">retry ${escapeHtml(formatDate(item.retry_state.next_retry_at))}</div>` : ""}
|
||||
</td>
|
||||
<td class="task-table-actions">
|
||||
<button class="secondary compact inline-action-btn" data-task-action="open">打开</button>
|
||||
<button class="compact inline-action-btn" data-task-action="run">${attention === "manual_now" || attention === "retry_now" ? "重跑" : "执行"}</button>
|
||||
</td>
|
||||
`;
|
||||
row.onclick = () => onSelect(item.id);
|
||||
row.querySelectorAll("[data-task-action]").forEach((button) => {
|
||||
button.onclick = (event) => {
|
||||
event.stopPropagation();
|
||||
if (button.dataset.taskAction === "open") return onSelect(item.id);
|
||||
if (button.dataset.taskAction === "run" && onRowAction) return onRowAction("run", item.id);
|
||||
};
|
||||
});
|
||||
tbody.appendChild(row);
|
||||
}
|
||||
table.querySelectorAll("[data-sort-field]").forEach((button) => {
|
||||
button.onclick = (event) => {
|
||||
event.stopPropagation();
|
||||
const select = document.getElementById("taskSortSelect");
|
||||
if (!select) return;
|
||||
select.value = headerSortValue(button.dataset.sortField, select.value);
|
||||
select.dispatchEvent(new Event("change"));
|
||||
};
|
||||
});
|
||||
wrap.appendChild(table);
|
||||
}
|
||||
|
||||
export function renderTaskDetail(payload, onStepSelect) {
|
||||
const { task, steps, artifacts, history, timeline } = payload;
|
||||
renderTaskHero(task, steps);
|
||||
renderRetryPanel(task);
|
||||
|
||||
const detail = document.getElementById("taskDetail");
|
||||
detail.innerHTML = "";
|
||||
[
|
||||
["Task ID", task.id],
|
||||
["Status", task.status],
|
||||
["Created", formatDate(task.created_at)],
|
||||
["Updated", formatDate(task.updated_at)],
|
||||
["Source", task.source_path],
|
||||
["Next Retry", task.retry_state?.next_retry_at ? formatDate(task.retry_state.next_retry_at) : "-"],
|
||||
].forEach(([key, value]) => {
|
||||
const k = document.createElement("div");
|
||||
k.className = "detail-key";
|
||||
k.textContent = key;
|
||||
const v = document.createElement("div");
|
||||
v.textContent = value || "-";
|
||||
detail.appendChild(k);
|
||||
detail.appendChild(v);
|
||||
});
|
||||
|
||||
let summaryText = "暂无最近结果";
|
||||
const latestAction = history.items[0];
|
||||
if (latestAction) {
|
||||
summaryText = `最近动作: ${latestAction.action_name} / ${latestAction.status} / ${latestAction.summary}`;
|
||||
} else {
|
||||
const priority = ["failed_manual", "failed_retryable", "running", "succeeded", "pending"];
|
||||
const sortedSteps = [...steps.items].sort((a, b) => priority.indexOf(a.status) - priority.indexOf(b.status));
|
||||
const summaryStep = sortedSteps.find((step) => step.status !== "pending") || steps.items[0];
|
||||
if (summaryStep) {
|
||||
summaryText = summaryStep.error_message
|
||||
? `最近异常: ${summaryStep.step_name} / ${summaryStep.error_code || "ERROR"} / ${summaryStep.error_message}`
|
||||
: `最近结果: ${summaryStep.step_name} / ${summaryStep.status}`;
|
||||
}
|
||||
}
|
||||
const delivery = task.delivery_state || {};
|
||||
const summaryEl = document.getElementById("taskSummary");
|
||||
summaryEl.innerHTML = `
|
||||
<div class="summary-title">Recent Result</div>
|
||||
<div class="summary-text">${escapeHtml(summaryText)}</div>
|
||||
<div class="summary-title" style="margin-top:14px;">Delivery State</div>
|
||||
<div class="delivery-grid">
|
||||
${renderDeliveryState("Split Comment", delivery.split_comment || "-")}
|
||||
${renderDeliveryState("Full Timeline", delivery.full_video_timeline_comment || "-")}
|
||||
${renderDeliveryState("Full Video BV", delivery.full_video_bvid_resolved ? "resolved" : "unresolved")}
|
||||
${renderDeliveryState("Source Video", delivery.source_video_present ? "present" : "removed")}
|
||||
${renderDeliveryState("Split Videos", delivery.split_videos_present ? "present" : "removed")}
|
||||
${renderDeliveryState(
|
||||
"Cleanup Policy",
|
||||
`source=${delivery.cleanup_enabled?.delete_source_video_after_collection_synced ? "on" : "off"} / split=${delivery.cleanup_enabled?.delete_split_videos_after_collection_synced ? "on" : "off"}`,
|
||||
""
|
||||
)}
|
||||
</div>
|
||||
`;
|
||||
|
||||
renderStepList(steps, onStepSelect);
|
||||
renderArtifactList(artifacts);
|
||||
renderHistoryList(history);
|
||||
renderTimelineList(timeline);
|
||||
}
|
||||
|
||||
function renderDeliveryState(label, value, forcedClass = null) {
|
||||
const klass = forcedClass === null ? statusClass(value) : forcedClass;
|
||||
return `
|
||||
<div class="delivery-card">
|
||||
<div class="delivery-label">${escapeHtml(label)}</div>
|
||||
<div class="delivery-value"><span class="pill ${klass}">${escapeHtml(String(value))}</span></div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
export function renderTaskWorkspaceState(mode, message = "") {
|
||||
const stateEl = document.getElementById("taskWorkspaceState");
|
||||
const hero = document.getElementById("taskHero");
|
||||
const retry = document.getElementById("taskRetryPanel");
|
||||
const detail = document.getElementById("taskDetail");
|
||||
const summary = document.getElementById("taskSummary");
|
||||
const stepList = document.getElementById("stepList");
|
||||
const artifactList = document.getElementById("artifactList");
|
||||
const historyList = document.getElementById("historyList");
|
||||
const timelineList = document.getElementById("timelineList");
|
||||
if (!stateEl) return;
|
||||
|
||||
stateEl.className = "task-workspace-state show";
|
||||
if (mode === "loading") stateEl.classList.add("loading");
|
||||
if (mode === "error") stateEl.classList.add("error");
|
||||
stateEl.textContent =
|
||||
message ||
|
||||
(mode === "loading"
|
||||
? "正在加载任务详情…"
|
||||
: mode === "error"
|
||||
? "任务详情加载失败。"
|
||||
: "选择一个任务后,这里会显示当前链路、重试状态和最近动作。");
|
||||
|
||||
if (mode === "ready") {
|
||||
stateEl.className = "task-workspace-state";
|
||||
return;
|
||||
}
|
||||
|
||||
hero.className = "task-hero empty";
|
||||
hero.textContent = stateEl.textContent;
|
||||
retry.className = "retry-banner";
|
||||
retry.style.display = "none";
|
||||
retry.textContent = "";
|
||||
detail.innerHTML = "";
|
||||
summary.textContent = mode === "error" ? stateEl.textContent : "暂无最近结果";
|
||||
stepList.innerHTML = "";
|
||||
artifactList.innerHTML = "";
|
||||
historyList.innerHTML = "";
|
||||
timelineList.innerHTML = "";
|
||||
}
|
||||
815
src/biliup_next/app/static/dashboard.css
Normal file
815
src/biliup_next/app/static/dashboard.css
Normal file
@ -0,0 +1,815 @@
|
||||
:root {
|
||||
--bg: #f3efe8;
|
||||
--paper: rgba(255, 252, 247, 0.92);
|
||||
--paper-strong: rgba(255, 255, 255, 0.98);
|
||||
--ink: #1d1a16;
|
||||
--muted: #6b6159;
|
||||
--line: rgba(29, 26, 22, 0.12);
|
||||
--line-strong: rgba(29, 26, 22, 0.2);
|
||||
--accent: #b24b1a;
|
||||
--accent-2: #0e6c62;
|
||||
--warn: #9a690f;
|
||||
--good-bg: rgba(14, 108, 98, 0.12);
|
||||
--warn-bg: rgba(154, 105, 15, 0.12);
|
||||
--hot-bg: rgba(178, 75, 26, 0.12);
|
||||
--shadow: 0 24px 70px rgba(57, 37, 16, 0.08);
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
color: var(--ink);
|
||||
font-family: "IBM Plex Sans", "Noto Sans SC", sans-serif;
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(178, 75, 26, 0.14), transparent 30%),
|
||||
radial-gradient(circle at top right, rgba(14, 108, 98, 0.14), transparent 28%),
|
||||
linear-gradient(180deg, #f7f2ea 0%, #efe7dc 100%);
|
||||
}
|
||||
|
||||
button, input, select, textarea { font: inherit; }
|
||||
|
||||
.app-shell {
|
||||
width: min(1680px, calc(100vw - 28px));
|
||||
margin: 18px auto 32px;
|
||||
display: grid;
|
||||
grid-template-columns: 280px minmax(0, 1fr);
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.sidebar,
|
||||
.panel,
|
||||
.topbar {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 26px;
|
||||
background: var(--paper);
|
||||
box-shadow: var(--shadow);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
padding: 22px;
|
||||
position: sticky;
|
||||
top: 18px;
|
||||
align-self: start;
|
||||
}
|
||||
|
||||
.sidebar-brand h1 {
|
||||
margin: 0;
|
||||
font-size: 42px;
|
||||
line-height: 0.92;
|
||||
letter-spacing: -0.05em;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0 0 8px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
}
|
||||
|
||||
.sidebar-copy {
|
||||
margin: 12px 0 0;
|
||||
color: var(--muted);
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.sidebar-nav,
|
||||
.button-stack {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.sidebar-section {
|
||||
margin-top: 18px;
|
||||
padding-top: 18px;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.sidebar-label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.sidebar-token {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.nav-btn,
|
||||
button {
|
||||
border: 0;
|
||||
border-radius: 16px;
|
||||
padding: 11px 14px;
|
||||
cursor: pointer;
|
||||
background: var(--ink);
|
||||
color: #fff;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.nav-btn {
|
||||
background: rgba(255,255,255,0.84);
|
||||
color: var(--ink);
|
||||
border: 1px solid var(--line);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.nav-btn.active {
|
||||
background: linear-gradient(135deg, rgba(178, 75, 26, 0.12), rgba(255,255,255,0.95));
|
||||
border-color: rgba(178, 75, 26, 0.28);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
button.secondary {
|
||||
background: rgba(255,255,255,0.82);
|
||||
color: var(--ink);
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
|
||||
button.compact {
|
||||
padding: 8px 12px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
padding: 18px 22px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.topbar h2 {
|
||||
margin: 0;
|
||||
font-size: clamp(24px, 3vw, 38px);
|
||||
letter-spacing: -0.04em;
|
||||
}
|
||||
|
||||
.topbar-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.status-chip,
|
||||
.pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
border-radius: 999px;
|
||||
padding: 6px 10px;
|
||||
background: rgba(29, 26, 22, 0.07);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.banner,
|
||||
.retry-banner {
|
||||
padding: 14px 16px;
|
||||
border-radius: 18px;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.banner.show,
|
||||
.retry-banner.show { display: block; }
|
||||
.banner.ok { background: var(--good-bg); color: var(--accent-2); }
|
||||
.banner.warn,
|
||||
.retry-banner.warn { background: var(--warn-bg); color: var(--warn); }
|
||||
.banner.err,
|
||||
.retry-banner.hot { background: var(--hot-bg); color: var(--accent); }
|
||||
.retry-banner.good { background: var(--good-bg); color: var(--accent-2); }
|
||||
|
||||
.view {
|
||||
display: none;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.view.active { display: grid; }
|
||||
|
||||
.panel {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.panel-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.panel-head h3 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.panel-grid {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.panel-grid.two-up {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.stat-card,
|
||||
.row-card,
|
||||
.task-card,
|
||||
.service-card,
|
||||
.summary-card,
|
||||
.timeline-card {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 18px;
|
||||
background: rgba(255,255,255,0.74);
|
||||
}
|
||||
|
||||
.stat-card,
|
||||
.row-card,
|
||||
.task-card,
|
||||
.service-card,
|
||||
.summary-card,
|
||||
.timeline-card {
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.summary-title {
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.summary-text {
|
||||
margin-top: 8px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.delivery-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.delivery-card {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 14px;
|
||||
padding: 10px 12px;
|
||||
background: rgba(255,255,255,0.72);
|
||||
}
|
||||
|
||||
.delivery-label {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.delivery-value {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
font-size: 30px;
|
||||
}
|
||||
|
||||
.filter-grid,
|
||||
.field-grid,
|
||||
.task-filters {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.filter-grid,
|
||||
.field-grid {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.task-filters {
|
||||
grid-template-columns: 1.2fr .8fr .8fr;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.task-index-summary {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.summary-strip {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.task-pagination-toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 16px;
|
||||
background: rgba(255,255,255,0.78);
|
||||
}
|
||||
|
||||
.task-list-state {
|
||||
display: none;
|
||||
margin-bottom: 12px;
|
||||
padding: 12px 14px;
|
||||
border: 1px dashed var(--line-strong);
|
||||
border-radius: 16px;
|
||||
background: rgba(255,255,255,0.6);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.task-list-state.show {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.upload-grid { margin-top: 10px; }
|
||||
|
||||
input,
|
||||
select,
|
||||
textarea,
|
||||
pre {
|
||||
width: 100%;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 16px;
|
||||
padding: 12px 14px;
|
||||
background: rgba(255,255,255,0.85);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
textarea,
|
||||
pre {
|
||||
font: 13px/1.55 "IBM Plex Mono", "SFMono-Regular", monospace;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
textarea { min-height: 320px; resize: vertical; }
|
||||
pre { margin: 0; min-height: 240px; overflow: auto; }
|
||||
|
||||
.muted-note {
|
||||
margin: 10px 0 0;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.checkbox-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.checkbox-row input {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.stack-list,
|
||||
.timeline-list {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.row-card.active {
|
||||
border-color: rgba(178, 75, 26, 0.34);
|
||||
box-shadow: inset 0 0 0 1px rgba(178, 75, 26, 0.16);
|
||||
}
|
||||
|
||||
.service-title {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.task-table-wrap {
|
||||
max-height: calc(100vh - 320px);
|
||||
overflow: auto;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 16px;
|
||||
background: rgba(255,255,255,0.78);
|
||||
}
|
||||
|
||||
.task-table {
|
||||
width: 100%;
|
||||
min-width: 860px;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.task-table th,
|
||||
.task-table td {
|
||||
padding: 12px 14px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.task-table th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
background: rgba(243, 239, 232, 0.96);
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.table-sort-btn {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 12px 14px;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.table-sort-btn.active {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.table-sort-btn:hover {
|
||||
background: rgba(178, 75, 26, 0.06);
|
||||
}
|
||||
|
||||
.task-table tbody tr {
|
||||
cursor: pointer;
|
||||
transition: background 140ms ease;
|
||||
}
|
||||
|
||||
.task-table tbody tr:hover {
|
||||
background: rgba(178, 75, 26, 0.06);
|
||||
}
|
||||
|
||||
.task-table tbody tr.active {
|
||||
background: linear-gradient(135deg, rgba(255, 248, 240, 0.98), rgba(249, 242, 234, 0.95));
|
||||
}
|
||||
|
||||
.task-table-loading {
|
||||
padding: 16px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.task-cell-title {
|
||||
font-weight: 600;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.task-table .pill {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.task-table-actions {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.inline-action-btn {
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
.inline-action-btn:last-child {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.task-cell-subtitle {
|
||||
margin-top: 6px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.meta-row,
|
||||
.button-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.pill.good { background: var(--good-bg); color: var(--accent-2); }
|
||||
.pill.warn { background: var(--warn-bg); color: var(--warn); }
|
||||
.pill.hot { background: var(--hot-bg); color: var(--accent); }
|
||||
|
||||
.tasks-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 360px minmax(0, 1fr);
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.task-workspace {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.task-workspace-state {
|
||||
display: none;
|
||||
padding: 14px 16px;
|
||||
border: 1px dashed var(--line-strong);
|
||||
border-radius: 18px;
|
||||
background: rgba(255,255,255,0.62);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.task-workspace-state.show {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.task-workspace-state.loading {
|
||||
color: var(--warn);
|
||||
background: var(--warn-bg);
|
||||
border-style: solid;
|
||||
}
|
||||
|
||||
.task-workspace-state.error {
|
||||
color: var(--accent);
|
||||
background: var(--hot-bg);
|
||||
border-style: solid;
|
||||
}
|
||||
|
||||
.task-hero {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 22px;
|
||||
padding: 18px;
|
||||
background: linear-gradient(135deg, rgba(255,255,255,0.98), rgba(249,242,234,0.92));
|
||||
}
|
||||
|
||||
.task-hero.empty { color: var(--muted); }
|
||||
|
||||
.task-hero-title {
|
||||
margin: 0;
|
||||
font-size: 26px;
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
|
||||
.task-hero-subtitle {
|
||||
margin: 8px 0 0;
|
||||
color: var(--muted);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.task-hero-delivery {
|
||||
margin-top: 12px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--line);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.hero-meta-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.mini-stat {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 16px;
|
||||
padding: 12px;
|
||||
background: rgba(255,255,255,0.8);
|
||||
}
|
||||
|
||||
.mini-stat-label {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.mini-stat-value {
|
||||
margin-top: 6px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.detail-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.15fr) minmax(260px, .85fr);
|
||||
gap: 12px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.detail-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 140px 1fr;
|
||||
gap: 10px;
|
||||
padding: 14px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 18px;
|
||||
background: rgba(255,255,255,0.78);
|
||||
}
|
||||
|
||||
.detail-key {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.summary-card {
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.step-card-title,
|
||||
.timeline-title {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.step-card-metrics,
|
||||
.timeline-meta {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.step-metric,
|
||||
.timeline-meta-line {
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.step-metric strong,
|
||||
.timeline-meta-line strong {
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.artifact-path {
|
||||
margin-top: 8px;
|
||||
color: var(--muted);
|
||||
font-family: "IBM Plex Mono", "SFMono-Regular", monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.settings-toolbar,
|
||||
.settings-groups,
|
||||
.settings-fields {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.settings-group {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 18px;
|
||||
padding: 14px;
|
||||
background: rgba(255,255,255,0.72);
|
||||
}
|
||||
|
||||
.settings-group.featured {
|
||||
border-color: rgba(178, 75, 26, 0.24);
|
||||
background: linear-gradient(180deg, rgba(255,249,243,0.96), rgba(255,255,255,0.76));
|
||||
}
|
||||
|
||||
.settings-group h3 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.group-desc,
|
||||
.hint {
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.settings-field {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 10px;
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
.settings-field.dirty {
|
||||
background: rgba(178, 75, 26, 0.08);
|
||||
box-shadow: inset 0 0 0 1px rgba(178, 75, 26, 0.16);
|
||||
}
|
||||
|
||||
.settings-field.error {
|
||||
background: rgba(178, 75, 26, 0.1);
|
||||
box-shadow: inset 0 0 0 1px rgba(178, 75, 26, 0.3);
|
||||
}
|
||||
|
||||
.field-error {
|
||||
color: var(--accent);
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.settings-field .button-row {
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.settings-advanced {
|
||||
border: 1px dashed var(--line-strong);
|
||||
border-radius: 18px;
|
||||
padding: 12px;
|
||||
background: rgba(255,255,255,0.56);
|
||||
}
|
||||
|
||||
.settings-label {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.settings-badge {
|
||||
border-radius: 999px;
|
||||
padding: 2px 8px;
|
||||
font-size: 11px;
|
||||
background: rgba(29, 26, 22, 0.08);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.logs-layout {
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.logs-workspace {
|
||||
display: grid;
|
||||
grid-template-columns: 360px minmax(0, 1fr);
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.logs-index-panel {
|
||||
align-self: start;
|
||||
}
|
||||
|
||||
.log-content-stack {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.log-card.active {
|
||||
border-color: rgba(14, 108, 98, 0.34);
|
||||
box-shadow: inset 0 0 0 1px rgba(14, 108, 98, 0.16);
|
||||
}
|
||||
|
||||
@media (max-width: 1320px) {
|
||||
.app-shell,
|
||||
.tasks-layout,
|
||||
.logs-workspace,
|
||||
.panel-grid.two-up,
|
||||
.detail-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.sidebar {
|
||||
position: static;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.app-shell {
|
||||
width: min(100vw - 20px, 100%);
|
||||
margin: 10px;
|
||||
}
|
||||
.topbar,
|
||||
.sidebar,
|
||||
.panel {
|
||||
border-radius: 22px;
|
||||
}
|
||||
.stats,
|
||||
.hero-meta-grid,
|
||||
.filter-grid,
|
||||
.field-grid,
|
||||
.task-filters,
|
||||
.task-pagination-toolbar {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.task-pagination-toolbar {
|
||||
display: grid;
|
||||
}
|
||||
.topbar {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
805
src/biliup_next/app/static/dashboard.js
Normal file
805
src/biliup_next/app/static/dashboard.js
Normal file
@ -0,0 +1,805 @@
|
||||
let selectedTaskId = null;
|
||||
let selectedStepName = null;
|
||||
let currentTasks = [];
|
||||
let currentSettings = {};
|
||||
let currentSettingsSchema = null;
|
||||
let currentView = "overview";
|
||||
|
||||
function statusClass(status) {
|
||||
if (["collection_synced", "published", "commented", "succeeded", "active"].includes(status)) return "good";
|
||||
if (["failed_manual", "failed_retryable", "inactive"].includes(status)) return "hot";
|
||||
if (["running", "activating", "songs_detected", "split_done", "transcribed", "created", "pending"].includes(status)) return "warn";
|
||||
return "";
|
||||
}
|
||||
|
||||
function showBanner(message, kind) {
|
||||
const el = document.getElementById("banner");
|
||||
el.textContent = message;
|
||||
el.className = `banner show ${kind}`;
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
return String(text)
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">");
|
||||
}
|
||||
|
||||
function formatDate(value) {
|
||||
if (!value) return "-";
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return value;
|
||||
return date.toLocaleString("zh-CN", { hour12: false });
|
||||
}
|
||||
|
||||
function formatDuration(seconds) {
|
||||
if (seconds == null || Number.isNaN(Number(seconds))) return "-";
|
||||
const total = Math.max(0, Number(seconds));
|
||||
const h = Math.floor(total / 3600);
|
||||
const m = Math.floor((total % 3600) / 60);
|
||||
const s = total % 60;
|
||||
if (h > 0) return `${h}h ${m}m ${s}s`;
|
||||
if (m > 0) return `${m}m ${s}s`;
|
||||
return `${s}s`;
|
||||
}
|
||||
|
||||
function setView(view) {
|
||||
currentView = view;
|
||||
document.querySelectorAll(".nav-btn").forEach((button) => {
|
||||
button.classList.toggle("active", button.dataset.view === view);
|
||||
});
|
||||
document.querySelectorAll(".view").forEach((section) => {
|
||||
section.classList.toggle("active", section.dataset.view === view);
|
||||
});
|
||||
const titleMap = {
|
||||
overview: "Overview",
|
||||
tasks: "Tasks",
|
||||
settings: "Settings",
|
||||
logs: "Logs",
|
||||
};
|
||||
document.getElementById("viewTitle").textContent = titleMap[view] || "Control";
|
||||
}
|
||||
|
||||
async function fetchJson(url, options) {
|
||||
const token = localStorage.getItem("biliup_next_token") || "";
|
||||
const opts = options ? { ...options } : {};
|
||||
opts.headers = { ...(opts.headers || {}) };
|
||||
if (token) opts.headers["X-Biliup-Token"] = token;
|
||||
const res = await fetch(url, opts);
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.message || data.error || JSON.stringify(data));
|
||||
return data;
|
||||
}
|
||||
|
||||
function buildHistoryUrl() {
|
||||
const params = new URLSearchParams();
|
||||
params.set("limit", "20");
|
||||
const status = document.getElementById("historyStatusFilter")?.value || "";
|
||||
const actionName = document.getElementById("historyActionFilter")?.value.trim() || "";
|
||||
const currentOnly = document.getElementById("historyCurrentTask")?.checked;
|
||||
if (status) params.set("status", status);
|
||||
if (actionName) params.set("action_name", actionName);
|
||||
if (currentOnly && selectedTaskId) params.set("task_id", selectedTaskId);
|
||||
return `/history?${params.toString()}`;
|
||||
}
|
||||
|
||||
function filteredTasks() {
|
||||
const search = (document.getElementById("taskSearchInput")?.value || "").trim().toLowerCase();
|
||||
const status = document.getElementById("taskStatusFilter")?.value || "";
|
||||
const sort = document.getElementById("taskSortSelect")?.value || "updated_desc";
|
||||
|
||||
let items = currentTasks.filter((task) => {
|
||||
const haystack = `${task.id} ${task.title}`.toLowerCase();
|
||||
if (search && !haystack.includes(search)) return false;
|
||||
if (status && task.status !== status) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
const statusRank = {
|
||||
failed_manual: 0,
|
||||
failed_retryable: 1,
|
||||
running: 2,
|
||||
created: 3,
|
||||
transcribed: 4,
|
||||
songs_detected: 5,
|
||||
split_done: 6,
|
||||
published: 7,
|
||||
collection_synced: 8,
|
||||
};
|
||||
|
||||
items = [...items].sort((a, b) => {
|
||||
if (sort === "updated_asc") return String(a.updated_at).localeCompare(String(b.updated_at));
|
||||
if (sort === "title_asc") return String(a.title).localeCompare(String(b.title));
|
||||
if (sort === "status_group") {
|
||||
const diff = (statusRank[a.status] ?? 99) - (statusRank[b.status] ?? 99);
|
||||
if (diff !== 0) return diff;
|
||||
return String(b.updated_at).localeCompare(String(a.updated_at));
|
||||
}
|
||||
return String(b.updated_at).localeCompare(String(a.updated_at));
|
||||
});
|
||||
return items;
|
||||
}
|
||||
|
||||
async function loadOverview() {
|
||||
const historyUrl = buildHistoryUrl();
|
||||
const [health, doctor, tasks, modules, settings, settingsSchema, services, logs, history] = await Promise.all([
|
||||
fetchJson("/health"),
|
||||
fetchJson("/doctor"),
|
||||
fetchJson("/tasks?limit=100"),
|
||||
fetchJson("/modules"),
|
||||
fetchJson("/settings"),
|
||||
fetchJson("/settings/schema"),
|
||||
fetchJson("/runtime/services"),
|
||||
fetchJson("/logs"),
|
||||
fetchJson(historyUrl),
|
||||
]);
|
||||
|
||||
currentTasks = tasks.items;
|
||||
currentSettings = settings;
|
||||
currentSettingsSchema = settingsSchema;
|
||||
|
||||
document.getElementById("tokenInput").value = localStorage.getItem("biliup_next_token") || "";
|
||||
document.getElementById("healthValue").textContent = health.ok ? "OK" : "FAIL";
|
||||
document.getElementById("doctorValue").textContent = doctor.ok ? "OK" : "FAIL";
|
||||
document.getElementById("tasksValue").textContent = tasks.items.length;
|
||||
document.getElementById("overviewHealthValue").textContent = health.ok ? "OK" : "FAIL";
|
||||
document.getElementById("overviewDoctorValue").textContent = doctor.ok ? "OK" : "FAIL";
|
||||
document.getElementById("overviewTasksValue").textContent = tasks.items.length;
|
||||
|
||||
renderSettingsForm();
|
||||
syncSettingsEditorFromState();
|
||||
renderTasks();
|
||||
renderModules(modules.items);
|
||||
renderDoctor(doctor.checks);
|
||||
renderServices(services.items);
|
||||
renderLogsList(logs.items);
|
||||
renderRecentActions(history.items);
|
||||
|
||||
if (!selectedTaskId && currentTasks.length) selectedTaskId = currentTasks[0].id;
|
||||
if (selectedTaskId) await loadTaskDetail(selectedTaskId);
|
||||
}
|
||||
|
||||
function renderTasks() {
|
||||
const wrap = document.getElementById("taskList");
|
||||
wrap.innerHTML = "";
|
||||
const items = filteredTasks();
|
||||
if (!items.length) {
|
||||
wrap.innerHTML = `<div class="row-card"><strong>没有匹配任务</strong><div class="muted-note">调整搜索、状态或排序条件后重试。</div></div>`;
|
||||
return;
|
||||
}
|
||||
for (const item of items) {
|
||||
const el = document.createElement("div");
|
||||
el.className = `task-card ${item.id === selectedTaskId ? "active" : ""}`;
|
||||
const retryText = item.retry_state?.next_retry_at ? `next retry ${formatDate(item.retry_state.next_retry_at)}` : "";
|
||||
el.innerHTML = `
|
||||
<div class="task-title">${escapeHtml(item.title)}</div>
|
||||
<div class="meta-row">
|
||||
<span class="pill ${statusClass(item.status)}">${escapeHtml(item.status)}</span>
|
||||
<span class="pill">${escapeHtml(item.id)}</span>
|
||||
</div>
|
||||
<div class="muted-note">${escapeHtml(formatDate(item.updated_at))}</div>
|
||||
${retryText ? `<div class="muted-note">${escapeHtml(retryText)}</div>` : ""}
|
||||
`;
|
||||
el.onclick = async () => {
|
||||
selectedTaskId = item.id;
|
||||
setView("tasks");
|
||||
renderTasks();
|
||||
await loadTaskDetail(item.id);
|
||||
};
|
||||
wrap.appendChild(el);
|
||||
}
|
||||
}
|
||||
|
||||
function renderTaskHero(task, steps) {
|
||||
const wrap = document.getElementById("taskHero");
|
||||
const succeeded = steps.items.filter((step) => step.status === "succeeded").length;
|
||||
const running = steps.items.filter((step) => step.status === "running").length;
|
||||
const failed = steps.items.filter((step) => step.status.startsWith("failed")).length;
|
||||
wrap.className = "task-hero";
|
||||
wrap.innerHTML = `
|
||||
<div class="task-hero-title">${escapeHtml(task.title)}</div>
|
||||
<div class="task-hero-subtitle">${escapeHtml(task.id)} · ${escapeHtml(task.source_path)}</div>
|
||||
<div class="hero-meta-grid">
|
||||
<div class="mini-stat">
|
||||
<div class="mini-stat-label">Task Status</div>
|
||||
<div class="mini-stat-value"><span class="pill ${statusClass(task.status)}">${escapeHtml(task.status)}</span></div>
|
||||
</div>
|
||||
<div class="mini-stat">
|
||||
<div class="mini-stat-label">Succeeded Steps</div>
|
||||
<div class="mini-stat-value">${succeeded}/${steps.items.length}</div>
|
||||
</div>
|
||||
<div class="mini-stat">
|
||||
<div class="mini-stat-label">Running / Failed</div>
|
||||
<div class="mini-stat-value">${running} / ${failed}</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderRetryPanel(task) {
|
||||
const wrap = document.getElementById("taskRetryPanel");
|
||||
const retry = task.retry_state;
|
||||
if (!retry || !retry.next_retry_at) {
|
||||
wrap.className = "retry-banner";
|
||||
wrap.style.display = "none";
|
||||
wrap.textContent = "";
|
||||
return;
|
||||
}
|
||||
wrap.style.display = "block";
|
||||
wrap.className = `retry-banner show ${retry.retry_due ? "good" : "warn"}`;
|
||||
wrap.innerHTML = `
|
||||
<strong>${escapeHtml(retry.step_name)}</strong>
|
||||
${retry.retry_due ? " 已到重试时间" : " 正在等待下一次重试"}
|
||||
<div class="muted-note">next retry at ${escapeHtml(formatDate(retry.next_retry_at))} · remaining ${escapeHtml(formatDuration(retry.retry_remaining_seconds))} · wait ${escapeHtml(formatDuration(retry.retry_wait_seconds))}</div>
|
||||
`;
|
||||
}
|
||||
|
||||
async function loadTaskDetail(taskId) {
|
||||
const [task, steps, artifacts, history, timeline] = await Promise.all([
|
||||
fetchJson(`/tasks/${taskId}`),
|
||||
fetchJson(`/tasks/${taskId}/steps`),
|
||||
fetchJson(`/tasks/${taskId}/artifacts`),
|
||||
fetchJson(`/tasks/${taskId}/history`),
|
||||
fetchJson(`/tasks/${taskId}/timeline`),
|
||||
]);
|
||||
|
||||
renderTaskHero(task, steps);
|
||||
renderRetryPanel(task);
|
||||
|
||||
const detail = document.getElementById("taskDetail");
|
||||
detail.innerHTML = "";
|
||||
const pairs = [
|
||||
["Task ID", task.id],
|
||||
["Status", task.status],
|
||||
["Created", formatDate(task.created_at)],
|
||||
["Updated", formatDate(task.updated_at)],
|
||||
["Source", task.source_path],
|
||||
["Next Retry", task.retry_state?.next_retry_at ? formatDate(task.retry_state.next_retry_at) : "-"],
|
||||
];
|
||||
for (const [key, value] of pairs) {
|
||||
const k = document.createElement("div");
|
||||
k.className = "detail-key";
|
||||
k.textContent = key;
|
||||
const v = document.createElement("div");
|
||||
v.textContent = value || "-";
|
||||
detail.appendChild(k);
|
||||
detail.appendChild(v);
|
||||
}
|
||||
|
||||
let summaryText = "暂无最近结果";
|
||||
const latestAction = history.items[0];
|
||||
if (latestAction) {
|
||||
summaryText = `最近动作: ${latestAction.action_name} / ${latestAction.status} / ${latestAction.summary}`;
|
||||
} else {
|
||||
const priority = ["failed_manual", "failed_retryable", "running", "succeeded", "pending"];
|
||||
const sortedSteps = [...steps.items].sort((a, b) => priority.indexOf(a.status) - priority.indexOf(b.status));
|
||||
const summaryStep = sortedSteps.find((step) => step.status !== "pending") || steps.items[0];
|
||||
if (summaryStep) {
|
||||
summaryText = summaryStep.error_message
|
||||
? `最近异常: ${summaryStep.step_name} / ${summaryStep.error_code || "ERROR"} / ${summaryStep.error_message}`
|
||||
: `最近结果: ${summaryStep.step_name} / ${summaryStep.status}`;
|
||||
}
|
||||
}
|
||||
document.getElementById("taskSummary").textContent = summaryText;
|
||||
|
||||
const stepWrap = document.getElementById("stepList");
|
||||
stepWrap.innerHTML = "";
|
||||
for (const step of steps.items) {
|
||||
const row = document.createElement("div");
|
||||
row.className = `row-card ${selectedStepName === step.step_name ? "active" : ""}`;
|
||||
row.style.cursor = "pointer";
|
||||
const retryBlock = step.next_retry_at ? `
|
||||
<div class="step-card-metrics">
|
||||
<div class="step-metric"><strong>Next Retry</strong> ${escapeHtml(formatDate(step.next_retry_at))}</div>
|
||||
<div class="step-metric"><strong>Remaining</strong> ${escapeHtml(formatDuration(step.retry_remaining_seconds))}</div>
|
||||
<div class="step-metric"><strong>Wait Policy</strong> ${escapeHtml(formatDuration(step.retry_wait_seconds))}</div>
|
||||
</div>
|
||||
` : "";
|
||||
row.innerHTML = `
|
||||
<div class="step-card-title">
|
||||
<strong>${escapeHtml(step.step_name)}</strong>
|
||||
<span class="pill ${statusClass(step.status)}">${escapeHtml(step.status)}</span>
|
||||
<span class="pill">retry ${step.retry_count}</span>
|
||||
</div>
|
||||
<div class="muted-note">${escapeHtml(step.error_code || "")} ${escapeHtml(step.error_message || "")}</div>
|
||||
<div class="step-card-metrics">
|
||||
<div class="step-metric"><strong>Started</strong> ${escapeHtml(formatDate(step.started_at))}</div>
|
||||
<div class="step-metric"><strong>Finished</strong> ${escapeHtml(formatDate(step.finished_at))}</div>
|
||||
</div>
|
||||
${retryBlock}
|
||||
`;
|
||||
row.onclick = () => {
|
||||
selectedStepName = step.step_name;
|
||||
loadTaskDetail(taskId).catch((err) => showBanner(`任务详情刷新失败: ${err}`, "err"));
|
||||
};
|
||||
stepWrap.appendChild(row);
|
||||
}
|
||||
|
||||
const artifactWrap = document.getElementById("artifactList");
|
||||
artifactWrap.innerHTML = "";
|
||||
for (const artifact of artifacts.items) {
|
||||
const row = document.createElement("div");
|
||||
row.className = "row-card";
|
||||
row.innerHTML = `
|
||||
<div class="step-card-title"><strong>${escapeHtml(artifact.artifact_type)}</strong></div>
|
||||
<div class="artifact-path">${escapeHtml(artifact.path)}</div>
|
||||
<div class="muted-note">${escapeHtml(formatDate(artifact.created_at))}</div>
|
||||
`;
|
||||
artifactWrap.appendChild(row);
|
||||
}
|
||||
|
||||
const historyWrap = document.getElementById("historyList");
|
||||
historyWrap.innerHTML = "";
|
||||
for (const item of history.items) {
|
||||
let details = "";
|
||||
try {
|
||||
details = JSON.stringify(JSON.parse(item.details_json || "{}"), null, 2);
|
||||
} catch {
|
||||
details = item.details_json || "";
|
||||
}
|
||||
const row = document.createElement("div");
|
||||
row.className = "row-card";
|
||||
row.innerHTML = `
|
||||
<div class="step-card-title">
|
||||
<strong>${escapeHtml(item.action_name)}</strong>
|
||||
<span class="pill ${statusClass(item.status)}">${escapeHtml(item.status)}</span>
|
||||
</div>
|
||||
<div class="muted-note">${escapeHtml(item.summary)}</div>
|
||||
<div class="muted-note">${escapeHtml(formatDate(item.created_at))}</div>
|
||||
<pre>${escapeHtml(details)}</pre>
|
||||
`;
|
||||
historyWrap.appendChild(row);
|
||||
}
|
||||
|
||||
const timelineWrap = document.getElementById("timelineList");
|
||||
timelineWrap.innerHTML = "";
|
||||
for (const item of timeline.items) {
|
||||
const retryNote = item.retry_state?.next_retry_at
|
||||
? `<div class="timeline-meta-line"><strong>Next Retry</strong> ${escapeHtml(formatDate(item.retry_state.next_retry_at))} · remaining ${escapeHtml(formatDuration(item.retry_state.retry_remaining_seconds))}</div>`
|
||||
: "";
|
||||
const row = document.createElement("div");
|
||||
row.className = "timeline-card";
|
||||
row.innerHTML = `
|
||||
<div class="timeline-title">
|
||||
<strong>${escapeHtml(item.title)}</strong>
|
||||
<span class="pill ${statusClass(item.status)}">${escapeHtml(item.status)}</span>
|
||||
<span class="pill">${escapeHtml(item.kind)}</span>
|
||||
</div>
|
||||
<div class="timeline-meta">
|
||||
<div class="timeline-meta-line">${escapeHtml(item.summary || "-")}</div>
|
||||
<div class="timeline-meta-line"><strong>Time</strong> ${escapeHtml(formatDate(item.time))}</div>
|
||||
${retryNote}
|
||||
</div>
|
||||
`;
|
||||
timelineWrap.appendChild(row);
|
||||
}
|
||||
}
|
||||
|
||||
function syncSettingsEditorFromState() {
|
||||
document.getElementById("settingsEditor").value = JSON.stringify(currentSettings, null, 2);
|
||||
}
|
||||
|
||||
function getGroupOrder(groupName) {
|
||||
return Number(currentSettingsSchema.group_ui?.[groupName]?.order || 9999);
|
||||
}
|
||||
|
||||
function compareFieldEntries(a, b) {
|
||||
const orderA = Number(a[1].ui_order || 9999);
|
||||
const orderB = Number(b[1].ui_order || 9999);
|
||||
if (orderA !== orderB) return orderA - orderB;
|
||||
return String(a[0]).localeCompare(String(b[0]));
|
||||
}
|
||||
|
||||
function createSettingsField(groupName, fieldName, fieldSchema) {
|
||||
const row = document.createElement("div");
|
||||
row.className = "settings-field";
|
||||
const label = document.createElement("label");
|
||||
label.className = "settings-label";
|
||||
label.textContent = fieldSchema.title || `${groupName}.${fieldName}`;
|
||||
if (fieldSchema.ui_widget) {
|
||||
const badge = document.createElement("span");
|
||||
badge.className = "settings-badge";
|
||||
badge.textContent = fieldSchema.ui_widget;
|
||||
label.appendChild(badge);
|
||||
}
|
||||
if (fieldSchema.ui_featured === true) {
|
||||
const badge = document.createElement("span");
|
||||
badge.className = "settings-badge";
|
||||
badge.textContent = "featured";
|
||||
label.appendChild(badge);
|
||||
}
|
||||
row.appendChild(label);
|
||||
|
||||
const value = currentSettings[groupName]?.[fieldName];
|
||||
let input;
|
||||
if (fieldSchema.type === "boolean") {
|
||||
input = document.createElement("input");
|
||||
input.type = "checkbox";
|
||||
input.checked = Boolean(value);
|
||||
} else if (Array.isArray(fieldSchema.enum)) {
|
||||
input = document.createElement("select");
|
||||
for (const optionValue of fieldSchema.enum) {
|
||||
const option = document.createElement("option");
|
||||
option.value = String(optionValue);
|
||||
option.textContent = String(optionValue);
|
||||
if (value === optionValue) option.selected = true;
|
||||
input.appendChild(option);
|
||||
}
|
||||
} else if (fieldSchema.type === "array") {
|
||||
input = document.createElement("textarea");
|
||||
input.style.minHeight = "96px";
|
||||
input.value = JSON.stringify(value ?? [], null, 2);
|
||||
} else {
|
||||
input = document.createElement("input");
|
||||
input.type = fieldSchema.sensitive ? "password" : (fieldSchema.type === "integer" ? "number" : "text");
|
||||
input.value = value ?? "";
|
||||
if (fieldSchema.type === "integer") {
|
||||
if (typeof fieldSchema.minimum === "number") input.min = String(fieldSchema.minimum);
|
||||
input.step = "1";
|
||||
}
|
||||
if (fieldSchema.ui_placeholder) input.placeholder = fieldSchema.ui_placeholder;
|
||||
}
|
||||
input.dataset.group = groupName;
|
||||
input.dataset.field = fieldName;
|
||||
input.onchange = handleSettingsFieldChange;
|
||||
row.appendChild(input);
|
||||
|
||||
if (fieldSchema.description || fieldSchema.sensitive) {
|
||||
const hint = document.createElement("div");
|
||||
hint.className = "hint";
|
||||
let text = fieldSchema.description || "";
|
||||
if (fieldSchema.sensitive) text = `${text ? `${text} ` : ""}Sensitive`;
|
||||
hint.textContent = text;
|
||||
row.appendChild(hint);
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
function createSettingsGroup(groupName, fields, featured) {
|
||||
const entries = Object.entries(fields);
|
||||
if (!entries.length) return null;
|
||||
const group = document.createElement("div");
|
||||
group.className = `settings-group ${featured ? "featured" : ""}`.trim();
|
||||
const title = document.createElement("h3");
|
||||
title.textContent = currentSettingsSchema.group_ui?.[groupName]?.title || groupName;
|
||||
group.appendChild(title);
|
||||
const descText = currentSettingsSchema.group_ui?.[groupName]?.description;
|
||||
if (descText) {
|
||||
const desc = document.createElement("div");
|
||||
desc.className = "group-desc";
|
||||
desc.textContent = descText;
|
||||
group.appendChild(desc);
|
||||
}
|
||||
const fieldWrap = document.createElement("div");
|
||||
fieldWrap.className = "settings-fields";
|
||||
for (const [fieldName, fieldSchema] of entries) {
|
||||
fieldWrap.appendChild(createSettingsField(groupName, fieldName, fieldSchema));
|
||||
}
|
||||
group.appendChild(fieldWrap);
|
||||
return group;
|
||||
}
|
||||
|
||||
function renderSettingsForm() {
|
||||
const wrap = document.getElementById("settingsForm");
|
||||
wrap.innerHTML = "";
|
||||
if (!currentSettingsSchema?.groups) return;
|
||||
const search = (document.getElementById("settingsSearch")?.value || "").trim().toLowerCase();
|
||||
|
||||
const featuredContainer = document.createElement("div");
|
||||
featuredContainer.className = "settings-groups";
|
||||
const advancedDetails = document.createElement("details");
|
||||
advancedDetails.className = "settings-advanced";
|
||||
const advancedSummary = document.createElement("summary");
|
||||
advancedSummary.textContent = "Advanced Settings";
|
||||
advancedDetails.appendChild(advancedSummary);
|
||||
const advancedContainer = document.createElement("div");
|
||||
advancedContainer.className = "settings-groups";
|
||||
|
||||
const groupEntries = Object.entries(currentSettingsSchema.groups).sort((a, b) => getGroupOrder(a[0]) - getGroupOrder(b[0]));
|
||||
for (const [groupName, fields] of groupEntries) {
|
||||
const featuredFields = {};
|
||||
const advancedFields = {};
|
||||
const fieldEntries = Object.entries(fields).sort((a, b) => compareFieldEntries(a, b));
|
||||
for (const [fieldName, fieldSchema] of fieldEntries) {
|
||||
const key = `${groupName}.${fieldName}`.toLowerCase();
|
||||
if (search && !key.includes(search) && !(fieldSchema.description || "").toLowerCase().includes(search)) continue;
|
||||
if (fieldSchema.ui_featured === true) featuredFields[fieldName] = fieldSchema;
|
||||
else advancedFields[fieldName] = fieldSchema;
|
||||
}
|
||||
const featuredGroup = createSettingsGroup(groupName, featuredFields, true);
|
||||
if (featuredGroup) featuredContainer.appendChild(featuredGroup);
|
||||
const advancedGroup = createSettingsGroup(groupName, advancedFields, false);
|
||||
if (advancedGroup) advancedContainer.appendChild(advancedGroup);
|
||||
}
|
||||
if (!featuredContainer.children.length && !advancedContainer.children.length) {
|
||||
wrap.innerHTML = `<div class="row-card"><strong>没有匹配的配置项</strong><div class="muted-note">调整搜索关键字后重试。</div></div>`;
|
||||
return;
|
||||
}
|
||||
if (featuredContainer.children.length) wrap.appendChild(featuredContainer);
|
||||
if (advancedContainer.children.length) {
|
||||
advancedDetails.appendChild(advancedContainer);
|
||||
wrap.appendChild(advancedDetails);
|
||||
}
|
||||
}
|
||||
|
||||
function handleSettingsFieldChange(event) {
|
||||
const input = event.target;
|
||||
const group = input.dataset.group;
|
||||
const field = input.dataset.field;
|
||||
const fieldSchema = currentSettingsSchema.groups[group][field];
|
||||
let value;
|
||||
if (fieldSchema.type === "boolean") value = input.checked;
|
||||
else if (fieldSchema.type === "integer") value = Number(input.value);
|
||||
else if (fieldSchema.type === "array") {
|
||||
try {
|
||||
value = JSON.parse(input.value || "[]");
|
||||
if (!Array.isArray(value)) throw new Error("not array");
|
||||
} catch {
|
||||
showBanner(`${group}.${field} 必须是 JSON 数组`, "warn");
|
||||
return;
|
||||
}
|
||||
} else value = input.value;
|
||||
if (!currentSettings[group]) currentSettings[group] = {};
|
||||
currentSettings[group][field] = value;
|
||||
syncSettingsEditorFromState();
|
||||
}
|
||||
|
||||
function renderRecentActions(items) {
|
||||
const wrap = document.getElementById("recentActionList");
|
||||
wrap.innerHTML = "";
|
||||
for (const item of items) {
|
||||
const row = document.createElement("div");
|
||||
row.className = "row-card";
|
||||
row.innerHTML = `
|
||||
<div class="step-card-title">
|
||||
<strong>${escapeHtml(item.action_name)}</strong>
|
||||
<span class="pill ${statusClass(item.status)}">${escapeHtml(item.status)}</span>
|
||||
</div>
|
||||
<div class="muted-note">${escapeHtml(item.task_id || "global")} / ${escapeHtml(item.summary)}</div>
|
||||
<div class="muted-note">${escapeHtml(formatDate(item.created_at))}</div>
|
||||
`;
|
||||
wrap.appendChild(row);
|
||||
}
|
||||
}
|
||||
|
||||
function renderModules(items) {
|
||||
const wrap = document.getElementById("moduleList");
|
||||
wrap.innerHTML = "";
|
||||
for (const item of items) {
|
||||
const row = document.createElement("div");
|
||||
row.className = "row-card";
|
||||
row.innerHTML = `
|
||||
<div class="step-card-title"><strong>${escapeHtml(item.id)}</strong><span class="pill">${escapeHtml(item.provider_type)}</span></div>
|
||||
<div class="muted-note">${escapeHtml(item.entrypoint)}</div>
|
||||
`;
|
||||
wrap.appendChild(row);
|
||||
}
|
||||
}
|
||||
|
||||
function renderDoctor(checks) {
|
||||
const wrap = document.getElementById("doctorChecks");
|
||||
wrap.innerHTML = "";
|
||||
for (const check of checks) {
|
||||
const row = document.createElement("div");
|
||||
row.className = "row-card";
|
||||
row.innerHTML = `
|
||||
<div class="step-card-title"><strong>${escapeHtml(check.name)}</strong><span class="pill ${check.ok ? "good" : "hot"}">${check.ok ? "ok" : "fail"}</span></div>
|
||||
<div class="muted-note">${escapeHtml(check.detail)}</div>
|
||||
`;
|
||||
wrap.appendChild(row);
|
||||
}
|
||||
}
|
||||
|
||||
function renderServices(items) {
|
||||
const wrap = document.getElementById("serviceList");
|
||||
wrap.innerHTML = "";
|
||||
for (const item of items) {
|
||||
const row = document.createElement("div");
|
||||
row.className = "service-card";
|
||||
row.innerHTML = `
|
||||
<div class="step-card-title">
|
||||
<strong>${escapeHtml(item.id)}</strong>
|
||||
<span class="pill ${statusClass(item.active_state)}">${escapeHtml(item.active_state)}</span>
|
||||
<span class="pill">${escapeHtml(item.sub_state)}</span>
|
||||
</div>
|
||||
<div class="muted-note">${escapeHtml(item.fragment_path || item.description || "")}</div>
|
||||
<div class="button-row" style="margin-top:12px;">
|
||||
<button class="secondary compact" data-service="${item.id}" data-action="start">start</button>
|
||||
<button class="secondary compact" data-service="${item.id}" data-action="restart">restart</button>
|
||||
<button class="secondary compact" data-service="${item.id}" data-action="stop">stop</button>
|
||||
</div>
|
||||
`;
|
||||
wrap.appendChild(row);
|
||||
}
|
||||
wrap.querySelectorAll("button[data-service]").forEach((btn) => {
|
||||
btn.onclick = async () => {
|
||||
if (["stop", "restart"].includes(btn.dataset.action)) {
|
||||
const ok = window.confirm(`确认执行 ${btn.dataset.action} ${btn.dataset.service} ?`);
|
||||
if (!ok) return;
|
||||
}
|
||||
try {
|
||||
const payload = await fetchJson(`/runtime/services/${btn.dataset.service}/${btn.dataset.action}`, { method: "POST" });
|
||||
await loadOverview();
|
||||
showBanner(`${payload.id} ${payload.action} 完成`, payload.command_ok ? "ok" : "warn");
|
||||
} catch (err) {
|
||||
showBanner(`service 操作失败: ${err}`, "err");
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function renderLogsList(items) {
|
||||
const select = document.getElementById("logSelect");
|
||||
if (!select.options.length) {
|
||||
for (const item of items) {
|
||||
const option = document.createElement("option");
|
||||
option.value = item.name;
|
||||
option.textContent = item.name;
|
||||
select.appendChild(option);
|
||||
}
|
||||
}
|
||||
refreshLog().catch((err) => showBanner(`日志刷新失败: ${err}`, "err"));
|
||||
}
|
||||
|
||||
async function refreshLog() {
|
||||
const name = document.getElementById("logSelect").value;
|
||||
if (!name) return;
|
||||
let url = `/logs?name=${encodeURIComponent(name)}&lines=200`;
|
||||
if (document.getElementById("filterCurrentTask").checked && selectedTaskId) {
|
||||
const currentTask = currentTasks.find((item) => item.id === selectedTaskId);
|
||||
if (currentTask?.title) url += `&contains=${encodeURIComponent(currentTask.title)}`;
|
||||
}
|
||||
const payload = await fetchJson(url);
|
||||
document.getElementById("logPath").textContent = payload.path;
|
||||
document.getElementById("logContent").textContent = payload.content || "";
|
||||
}
|
||||
|
||||
document.querySelectorAll(".nav-btn").forEach((button) => {
|
||||
button.onclick = () => setView(button.dataset.view);
|
||||
});
|
||||
|
||||
document.getElementById("refreshBtn").onclick = async () => {
|
||||
await loadOverview();
|
||||
showBanner("视图已刷新", "ok");
|
||||
};
|
||||
|
||||
document.getElementById("runOnceBtn").onclick = async () => {
|
||||
try {
|
||||
const result = await fetchJson("/worker/run-once", { method: "POST" });
|
||||
await loadOverview();
|
||||
showBanner(`Worker 已执行一轮,processed=${result.processed.length}`, "ok");
|
||||
} catch (err) {
|
||||
showBanner(String(err), "err");
|
||||
}
|
||||
};
|
||||
|
||||
document.getElementById("saveSettingsBtn").onclick = async () => {
|
||||
try {
|
||||
const payload = JSON.parse(document.getElementById("settingsEditor").value);
|
||||
await fetchJson("/settings", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
await loadOverview();
|
||||
showBanner("Settings 已保存", "ok");
|
||||
} catch (err) {
|
||||
showBanner(`保存失败: ${err}`, "err");
|
||||
}
|
||||
};
|
||||
|
||||
document.getElementById("syncFormToJsonBtn").onclick = () => {
|
||||
syncSettingsEditorFromState();
|
||||
showBanner("表单已同步到 JSON", "ok");
|
||||
};
|
||||
|
||||
document.getElementById("syncJsonToFormBtn").onclick = () => {
|
||||
try {
|
||||
currentSettings = JSON.parse(document.getElementById("settingsEditor").value);
|
||||
renderSettingsForm();
|
||||
syncSettingsEditorFromState();
|
||||
showBanner("JSON 已重绘到表单", "ok");
|
||||
} catch (err) {
|
||||
showBanner(`JSON 解析失败: ${err}`, "err");
|
||||
}
|
||||
};
|
||||
|
||||
document.getElementById("settingsSearch").oninput = () => renderSettingsForm();
|
||||
document.getElementById("taskSearchInput").oninput = () => renderTasks();
|
||||
document.getElementById("taskStatusFilter").onchange = () => renderTasks();
|
||||
document.getElementById("taskSortSelect").onchange = () => renderTasks();
|
||||
|
||||
document.getElementById("importStageBtn").onclick = async () => {
|
||||
const sourcePath = document.getElementById("stageSourcePath").value.trim();
|
||||
if (!sourcePath) return showBanner("请先输入本地文件绝对路径", "warn");
|
||||
try {
|
||||
const result = await fetchJson("/stage/import", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ source_path: sourcePath }),
|
||||
});
|
||||
document.getElementById("stageSourcePath").value = "";
|
||||
showBanner(`已导入到 stage: ${result.target_path}`, "ok");
|
||||
} catch (err) {
|
||||
showBanner(`导入失败: ${err}`, "err");
|
||||
}
|
||||
};
|
||||
|
||||
document.getElementById("uploadStageBtn").onclick = async () => {
|
||||
const input = document.getElementById("stageFileInput");
|
||||
if (!input.files?.length) return showBanner("请先选择一个本地文件", "warn");
|
||||
try {
|
||||
const form = new FormData();
|
||||
form.append("file", input.files[0]);
|
||||
const res = await fetch("/stage/upload", { method: "POST", body: form });
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || JSON.stringify(data));
|
||||
input.value = "";
|
||||
showBanner(`已上传到 stage: ${data.target_path}`, "ok");
|
||||
} catch (err) {
|
||||
showBanner(`上传失败: ${err}`, "err");
|
||||
}
|
||||
};
|
||||
|
||||
document.getElementById("refreshLogBtn").onclick = () => refreshLog().then(() => showBanner("日志已刷新", "ok")).catch((err) => showBanner(`日志刷新失败: ${err}`, "err"));
|
||||
document.getElementById("logSelect").onchange = () => refreshLog().catch((err) => showBanner(`日志刷新失败: ${err}`, "err"));
|
||||
document.getElementById("refreshHistoryBtn").onclick = () => loadOverview().then(() => showBanner("动作流已刷新", "ok")).catch((err) => showBanner(`动作流刷新失败: ${err}`, "err"));
|
||||
|
||||
document.getElementById("saveTokenBtn").onclick = async () => {
|
||||
const token = document.getElementById("tokenInput").value.trim();
|
||||
localStorage.setItem("biliup_next_token", token);
|
||||
try {
|
||||
await loadOverview();
|
||||
showBanner("Token 已保存并生效", "ok");
|
||||
} catch (err) {
|
||||
showBanner(`Token 验证失败: ${err}`, "err");
|
||||
}
|
||||
};
|
||||
|
||||
document.getElementById("runTaskBtn").onclick = async () => {
|
||||
if (!selectedTaskId) return showBanner("当前没有选中的任务", "warn");
|
||||
try {
|
||||
const result = await fetchJson(`/tasks/${selectedTaskId}/actions/run`, { method: "POST" });
|
||||
await loadOverview();
|
||||
showBanner(`任务已推进,processed=${result.processed.length}`, "ok");
|
||||
} catch (err) {
|
||||
showBanner(`任务执行失败: ${err}`, "err");
|
||||
}
|
||||
};
|
||||
|
||||
document.getElementById("retryStepBtn").onclick = async () => {
|
||||
if (!selectedTaskId) return showBanner("当前没有选中的任务", "warn");
|
||||
if (!selectedStepName) return showBanner("请先在 Steps 区域选中一个 step", "warn");
|
||||
try {
|
||||
const result = await fetchJson(`/tasks/${selectedTaskId}/actions/retry-step`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ step_name: selectedStepName }),
|
||||
});
|
||||
await loadOverview();
|
||||
showBanner(`已重试 step=${selectedStepName},processed=${result.processed.length}`, "ok");
|
||||
} catch (err) {
|
||||
showBanner(`重试失败: ${err}`, "err");
|
||||
}
|
||||
};
|
||||
|
||||
document.getElementById("resetStepBtn").onclick = async () => {
|
||||
if (!selectedTaskId) return showBanner("当前没有选中的任务", "warn");
|
||||
if (!selectedStepName) return showBanner("请先在 Steps 区域选中一个 step", "warn");
|
||||
const ok = window.confirm(`确认重置到 step=${selectedStepName} 并清理其后的产物吗?`);
|
||||
if (!ok) return;
|
||||
try {
|
||||
const result = await fetchJson(`/tasks/${selectedTaskId}/actions/reset-to-step`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ step_name: selectedStepName }),
|
||||
});
|
||||
await loadOverview();
|
||||
showBanner(`已重置并重跑 step=${selectedStepName},processed=${result.run.processed.length}`, "ok");
|
||||
} catch (err) {
|
||||
showBanner(`重置失败: ${err}`, "err");
|
||||
}
|
||||
};
|
||||
|
||||
setView("overview");
|
||||
loadOverview().catch((err) => showBanner(`初始化失败: ${err}`, "err"));
|
||||
Reference in New Issue
Block a user