init biliup-next
This commit is contained in:
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