np_app/service/static/portal.js

553 lines
22 KiB
JavaScript

const page = document.body.dataset.page;
const state = {
me: null,
cloudOffset: 0,
cloudTotal: 0,
adminOffset: 0,
adminTotal: 0,
adminView: "active",
limit: 25,
};
function escapeHtml(value) {
return String(value ?? "")
.replaceAll("&", "&")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
function formatDate(value) {
if (!value) return "-";
return new Date(Number(value) * 1000).toLocaleString();
}
function formatDuration(value) {
const seconds = Number(value);
if (!Number.isFinite(seconds) || seconds <= 0) return "-";
if (seconds < 60) return `${seconds.toFixed(1)} s`;
if (seconds < 3600) return `${(seconds / 60).toFixed(1)} min`;
return `${(seconds / 3600).toFixed(1)} h`;
}
function formatBytes(value) {
let bytes = Number(value) || 0;
const units = ["B", "KB", "MB", "GB"];
let index = 0;
while (bytes >= 1024 && index < units.length - 1) {
bytes /= 1024;
index += 1;
}
return `${bytes.toFixed(index ? 1 : 0)} ${units[index]}`;
}
function downloadJson(filename, value) {
const blob = new Blob([JSON.stringify(value, null, 2)], { type: "application/json;charset=utf-8" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
}
function statusBadge(status) {
const value = String(status || "unknown");
return `<span class="status ${escapeHtml(value)}">${escapeHtml(value)}</span>`;
}
async function request(url, options = {}) {
const response = await fetch(url, options);
const text = await response.text();
let data;
try {
data = JSON.parse(text);
} catch {
throw new Error(`HTTP ${response.status} 返回了无法解析的响应`);
}
if (response.status === 401 && data.login_url) {
const next = `${window.location.pathname}${window.location.search}`;
window.location.assign(`${data.login_url}?next=${encodeURIComponent(next)}`);
throw new Error("需要登录");
}
if (response.status === 401 && page === "admin") {
window.location.assign("/admin");
throw new Error("Admin 会话已失效,请重新登录");
}
if (!response.ok) throw new Error(data.error || data.message || `HTTP ${response.status}`);
return data;
}
function renderNavigation() {
const nav = document.getElementById("appNav");
if (!nav) return;
if (page === "admin") {
nav.innerHTML = `
<a class="brand" href="#">NUPACK Admin</a>
<nav class="nav-list"><a class="active" href="#">全局任务</a></nav>
<div class="nav-meta">独立密码会话 · 免 OIDC</div>
`;
return;
}
const items = [
["home", "/", "概览"],
["workspace", "/workspace", "计算工作台"],
["cloud", "/cloud", "云端记录"],
["account", "/account", "账号与用量"],
["settings", "/settings", "设置"],
];
nav.innerHTML = `
<a class="brand" href="/">NUPACK</a>
<nav class="nav-list">
${items.map(([name, href, label]) => `<a class="${page === name ? "active" : ""}" href="${href}">${label}</a>`).join("")}
</nav>
<div class="nav-meta" id="navIdentity">正在读取账号...</div>
`;
}
async function loadIdentity() {
if (page === "admin") return null;
const data = await request("/api/me");
state.me = data;
const user = data.user || {};
const identity = document.getElementById("navIdentity");
if (identity) identity.textContent = user.display_name || user.username || user.email || user.user_id;
return data;
}
function recentRow(job) {
return `
<tr>
<td><a class="mono" href="/workspace?job=${encodeURIComponent(job.job_id)}">${escapeHtml(job.job_id.slice(0, 12))}</a></td>
<td>${escapeHtml(job.workflow)} / ${escapeHtml(job.mode)}</td>
<td>${statusBadge(job.status)}</td>
<td>${escapeHtml(formatDate(job.created_at))}</td>
<td>${escapeHtml(formatDuration(job.elapsed_seconds))}</td>
</tr>
`;
}
async function initHome() {
const [me, health, history] = await Promise.all([
loadIdentity(),
request("/health"),
request("/api/history?limit=6&offset=0"),
]);
document.getElementById("statRunning").textContent = health.jobs_running ?? "-";
document.getElementById("statQueued").textContent = health.jobs_queued ?? "-";
document.getElementById("statTotal").textContent = me.usage?.total_jobs ?? "-";
document.getElementById("statCompute").textContent = formatDuration(me.usage?.compute_seconds);
const body = document.getElementById("recentJobs");
body.innerHTML = history.items?.length
? history.items.map(recentRow).join("")
: '<tr><td colspan="5" class="empty">还没有任务记录。</td></tr>';
}
function cloudQuery() {
const form = new FormData(document.getElementById("cloudFilters"));
const query = new URLSearchParams({
limit: String(state.limit),
offset: String(state.cloudOffset),
});
for (const [key, value] of form.entries()) {
if (String(value).trim()) query.set(key, String(value).trim());
}
return query;
}
function cloudJobRow(job) {
const active = ["queued", "running", "cancel_requested"].includes(job.status);
return `
<tr>
<td class="mono">${escapeHtml(job.job_id.slice(0, 12))}</td>
<td>${escapeHtml(job.workflow)} / ${escapeHtml(job.mode)}<br><span class="mono">${escapeHtml(job.material)} · max ${escapeHtml(job.max_size)}</span></td>
<td>${statusBadge(job.status)}</td>
<td>${escapeHtml(formatDate(job.created_at))}</td>
<td>${escapeHtml(formatDuration(job.elapsed_seconds))}</td>
<td>
<div class="toolbar">
<a class="button" href="/workspace?job=${encodeURIComponent(job.job_id)}">查看</a>
<button type="button" data-export-job="${escapeHtml(job.job_id)}">JSON</button>
<button type="button" data-refresh-job="${escapeHtml(job.job_id)}">刷新状态</button>
<button type="button" data-share-job="${escapeHtml(job.job_id)}">分享</button>
${active
? `<button class="danger" type="button" data-cancel-job="${escapeHtml(job.job_id)}">终止</button>`
: `<button class="danger" type="button" data-delete-job="${escapeHtml(job.job_id)}">删除</button>`}
</div>
</td>
</tr>
`;
}
function showNotice(id, message, isError = false) {
const node = document.getElementById(id);
if (!node) return;
node.textContent = message;
node.classList.remove("hidden");
node.classList.toggle("error", isError);
}
async function loadCloudJobs() {
const data = await request(`/api/history?${cloudQuery()}`);
state.cloudTotal = Number(data.total) || 0;
const body = document.getElementById("cloudJobs");
body.innerHTML = data.items?.length
? data.items.map(cloudJobRow).join("")
: '<tr><td colspan="6" class="empty">没有符合条件的任务。</td></tr>';
const pageNumber = Math.floor(state.cloudOffset / state.limit) + 1;
const pageCount = Math.max(1, Math.ceil(state.cloudTotal / state.limit));
document.getElementById("cloudPage").textContent = `${pageNumber} / ${pageCount} 页,共 ${state.cloudTotal}`;
document.getElementById("cloudPrev").disabled = state.cloudOffset === 0;
document.getElementById("cloudNext").disabled = state.cloudOffset + state.limit >= state.cloudTotal;
}
async function refreshCloud() {
await loadCloudJobs();
}
function historyEntriesFromJson(raw) {
const source = Array.isArray(raw) ? raw : (Array.isArray(raw?.history) ? raw.history : [raw]);
return source.map((entry, index) => {
if (!entry || typeof entry !== "object") return null;
if (entry.payload && typeof entry.payload === "object") return entry;
if (!entry.workflow || !entry.mode) return null;
return {
id: entry.id || entry.job_id || `import-${Date.now()}-${index}`,
created_at: entry.created_at || new Date().toISOString(),
payload: entry,
result: null,
};
}).filter(Boolean);
}
async function exportCloudHistory() {
const history = [];
let offset = 0;
while (true) {
const page = await request(`/api/history?limit=100&offset=${offset}`);
const summaries = page.items || [];
const details = await Promise.all(
summaries.map((item) => request(`/api/history/${encodeURIComponent(item.job_id)}`)),
);
history.push(...details.map((detail) => detail.item));
offset += summaries.length;
if (!summaries.length || offset >= Number(page.total || 0)) break;
}
downloadJson("nupack-history.json", {
version: 2,
exported_at: new Date().toISOString(),
history,
});
showNotice("cloudNotice", `已导出 ${history.length} 条云端记录。`);
}
async function exportCloudHistoryItem(jobId) {
const detail = await request(`/api/history/${encodeURIComponent(jobId)}`);
downloadJson(`nupack-history-${jobId}.json`, detail.item);
showNotice("cloudNotice", `已导出任务 ${jobId.slice(0, 12)}`);
}
async function importCloudHistory(files) {
const history = [];
for (const file of Array.from(files || [])) {
history.push(...historyEntriesFromJson(JSON.parse(await file.text())));
}
if (!history.length) throw new Error("JSON 中没有可导入的计算记录。");
const result = await request("/api/history/import", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ history }),
});
state.cloudOffset = 0;
await loadCloudJobs();
showNotice("cloudNotice", `导入完成:新增 ${result.imported} 条记录。`);
}
async function handleCloudAction(event) {
const shareJob = event.target.closest("[data-share-job]");
const exportJob = event.target.closest("[data-export-job]");
const refreshJob = event.target.closest("[data-refresh-job]");
const cancelJob = event.target.closest("[data-cancel-job]");
const deleteJob = event.target.closest("[data-delete-job]");
try {
if (exportJob) {
await exportCloudHistoryItem(exportJob.dataset.exportJob);
} else if (refreshJob) {
const job = await request(`/api/jobs/${encodeURIComponent(refreshJob.dataset.refreshJob)}`);
await loadCloudJobs();
showNotice("cloudNotice", `任务状态已刷新:${job.status}`);
} else if (shareJob) {
const jobId = shareJob.dataset.shareJob;
const data = await request(`/api/jobs/${encodeURIComponent(jobId)}/shares`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ expires_in: 604800 }),
});
const url = new URL(data.url, window.location.origin).toString();
await navigator.clipboard?.writeText(url);
showNotice("cloudNotice", `分享链接已复制:${url}`);
} else if (cancelJob) {
if (!window.confirm("确定终止这个任务?")) return;
await request(`/api/jobs/${encodeURIComponent(cancelJob.dataset.cancelJob)}/cancel`, { method: "POST" });
await loadCloudJobs();
} else if (deleteJob) {
if (!window.confirm("确定将这个任务移入回收站?")) return;
await request(`/api/history/${encodeURIComponent(deleteJob.dataset.deleteJob)}`, { method: "DELETE" });
await refreshCloud();
}
} catch (error) {
showNotice("cloudNotice", error.message, true);
}
}
async function initCloud() {
await loadIdentity();
const importInput = document.getElementById("cloudImportInput");
document.getElementById("cloudImport").addEventListener("click", () => importInput.click());
importInput.addEventListener("change", () => {
importCloudHistory(importInput.files)
.catch((error) => showNotice("cloudNotice", error.message, true))
.finally(() => { importInput.value = ""; });
});
document.getElementById("cloudExport").addEventListener("click", (event) => {
const button = event.currentTarget;
button.disabled = true;
exportCloudHistory()
.catch((error) => showNotice("cloudNotice", error.message, true))
.finally(() => { button.disabled = false; });
});
document.getElementById("cloudFilters").addEventListener("submit", (event) => {
event.preventDefault();
state.cloudOffset = 0;
loadCloudJobs().catch((error) => showNotice("cloudNotice", error.message, true));
});
document.getElementById("cloudRefresh").addEventListener("click", () => {
refreshCloud().catch((error) => showNotice("cloudNotice", error.message, true));
});
document.getElementById("cloudPrev").addEventListener("click", () => {
state.cloudOffset = Math.max(0, state.cloudOffset - state.limit);
loadCloudJobs().catch((error) => showNotice("cloudNotice", error.message, true));
});
document.getElementById("cloudNext").addEventListener("click", () => {
state.cloudOffset += state.limit;
loadCloudJobs().catch((error) => showNotice("cloudNotice", error.message, true));
});
document.querySelector(".content").addEventListener("click", handleCloudAction);
await refreshCloud();
}
function renderDefinitionList(node, rows) {
node.innerHTML = rows.map(([term, value]) => `<dt>${escapeHtml(term)}</dt><dd>${escapeHtml(value ?? "-")}</dd>`).join("");
}
async function initAccount() {
const [me, health] = await Promise.all([loadIdentity(), request("/health")]);
const user = me.user || {};
const usage = me.usage || {};
document.getElementById("accountTotal").textContent = usage.total_jobs ?? "-";
document.getElementById("accountSuccess").textContent = usage.success_jobs ?? "-";
document.getElementById("accountActive").textContent = usage.active_jobs ?? "-";
document.getElementById("accountStorage").textContent = formatBytes(usage.stored_bytes);
renderDefinitionList(document.getElementById("identityDetails"), [
["显示名称", user.display_name],
["用户名", user.username],
["邮箱", user.email],
["用户 ID", user.user_id],
["用户组", (user.groups || []).join(", ") || "-"],
]);
const worker = health.worker_resource_plan || {};
renderDefinitionList(document.getElementById("resourceDetails"), [
["任务后端", health.job_backend],
["并发来源", health.worker_concurrency_source],
["Worker 并发", health.worker_concurrency],
["单任务线程", health.per_job_thread_limit],
["Worker 内存", worker.memory_gb == null ? "-" : `${worker.memory_gb} GB`],
["单任务内存预算", worker.estimated_job_memory_gb == null ? "-" : `${worker.estimated_job_memory_gb} GB`],
]);
}
const workspacePreferences = [
["settingsLanguage", "np_replica_lang", "zh"],
["settingsTheme", "np_replica_theme", "harbor"],
["settingsLayout", "np_replica_layout", "balanced"],
["settingsHelp", "np_replica_help_visibility", "shown"],
];
function loadWorkspacePreferences() {
for (const [id, key, fallback] of workspacePreferences) {
const control = document.getElementById(id);
if (control) control.value = localStorage.getItem(key) || fallback;
}
}
async function initSettings() {
await loadIdentity();
loadWorkspacePreferences();
const form = document.getElementById("settingsForm");
form.addEventListener("change", (event) => {
const preference = workspacePreferences.find(([id]) => id === event.target.id);
if (!preference) return;
localStorage.setItem(preference[1], event.target.value);
showNotice("settingsNotice", "设置已保存,重新打开工作台后生效。");
});
document.getElementById("settingsReset").addEventListener("click", () => {
for (const [, key] of workspacePreferences) localStorage.removeItem(key);
loadWorkspacePreferences();
showNotice("settingsNotice", "已恢复默认设置。");
});
}
function adminQuery() {
const form = new FormData(document.getElementById("adminFilters"));
const query = new URLSearchParams({
limit: String(state.limit),
offset: String(state.adminOffset),
deleted: state.adminView === "trash" ? "1" : "0",
});
for (const [key, value] of form.entries()) {
if (String(value).trim()) query.set(key, String(value).trim());
}
return query;
}
function adminJobRow(job) {
const active = ["queued", "running", "cancel_requested"].includes(job.status);
const deleted = Boolean(job.deleted_at);
const actions = deleted
? `<button type="button" data-admin-action="restore" data-job-id="${escapeHtml(job.job_id)}">恢复</button>
<button class="danger" type="button" data-admin-action="delete" data-job-id="${escapeHtml(job.job_id)}">永久删除</button>`
: `${active ? `<button class="danger" type="button" data-admin-action="cancel" data-job-id="${escapeHtml(job.job_id)}">停止</button>` : ""}
${active ? "" : `<button class="danger" type="button" data-admin-action="trash" data-job-id="${escapeHtml(job.job_id)}">移入回收站</button>`}`;
return `
<tr>
<td>${escapeHtml(job.display_name || job.username || job.email || job.user_id)}<br><span class="mono">${escapeHtml(job.user_id)}</span></td>
<td class="mono">${escapeHtml(job.job_id)}</td>
<td>${escapeHtml(job.workflow)} / ${escapeHtml(job.mode)}<br>${escapeHtml(job.material)} · max ${escapeHtml(job.max_size)}</td>
<td>${statusBadge(job.status)}</td>
<td>${escapeHtml(formatDate(job.created_at))}${deleted ? `<br><span class="mono">删除 ${escapeHtml(formatDate(job.deleted_at))}<br>清理 ${escapeHtml(formatDate(job.purge_after))}</span>` : ""}</td>
<td>${escapeHtml(formatDuration(job.elapsed_seconds))}</td>
<td><div class="toolbar">${actions}</div><span class="mono">${escapeHtml(formatBytes(job.stored_bytes))}</span></td>
</tr>
`;
}
async function loadAdminJobs() {
const data = await request(`/api/admin/jobs?${adminQuery()}`);
state.adminTotal = Number(data.total) || 0;
document.getElementById("adminTotal").textContent = state.adminTotal;
document.getElementById("adminRunning").textContent = data.live?.running ?? "-";
document.getElementById("adminQueued").textContent = data.live?.queued ?? "-";
document.getElementById("adminConcurrency").textContent = data.worker?.concurrency ?? "-";
const body = document.getElementById("adminJobs");
body.innerHTML = data.items?.length
? data.items.map(adminJobRow).join("")
: '<tr><td colspan="7" class="empty">没有符合条件的任务。</td></tr>';
const pageNumber = Math.floor(state.adminOffset / state.limit) + 1;
const pageCount = Math.max(1, Math.ceil(state.adminTotal / state.limit));
document.getElementById("adminPage").textContent = `${pageNumber} / ${pageCount} 页,共 ${state.adminTotal}`;
document.getElementById("adminPrev").disabled = state.adminOffset === 0;
document.getElementById("adminNext").disabled = state.adminOffset + state.limit >= state.adminTotal;
}
async function loadAdminSettings() {
const data = await request("/api/admin/settings");
document.getElementById("trashRetentionDays").value = Number(data.trash_retention_days).toFixed(2).replace(/\.00$/, "");
}
async function handleAdminAction(event) {
const button = event.target.closest("[data-admin-action]");
if (!button) return;
const action = button.dataset.adminAction;
const labels = { cancel: "停止", trash: "移入回收站", restore: "恢复", delete: "永久删除" };
if (!window.confirm(`确定${labels[action]}这个任务?`)) return;
try {
await request(`/api/admin/jobs/${encodeURIComponent(button.dataset.jobId)}/${action}`, { method: "POST" });
showNotice("adminNotice", `任务已${labels[action]}`);
await loadAdminJobs();
} catch (error) {
showNotice("adminNotice", error.message, true);
}
}
async function initAdminLogin() {
document.getElementById("adminLoginForm").addEventListener("submit", async (event) => {
event.preventDefault();
try {
const data = await request("/api/admin/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ password: document.getElementById("adminPassword").value }),
});
window.location.assign(data.url || "/admin/panel");
} catch (error) {
showNotice("adminLoginNotice", error.message, true);
}
});
}
async function initAdmin() {
document.getElementById("adminFilters").addEventListener("submit", (event) => {
event.preventDefault();
state.adminOffset = 0;
loadAdminJobs().catch((error) => showNotice("adminNotice", error.message, true));
});
document.getElementById("adminRefresh").addEventListener("click", () => {
loadAdminJobs().catch((error) => showNotice("adminNotice", error.message, true));
});
document.getElementById("adminPrev").addEventListener("click", () => {
state.adminOffset = Math.max(0, state.adminOffset - state.limit);
loadAdminJobs().catch((error) => showNotice("adminNotice", error.message, true));
});
document.getElementById("adminNext").addEventListener("click", () => {
state.adminOffset += state.limit;
loadAdminJobs().catch((error) => showNotice("adminNotice", error.message, true));
});
document.querySelector(".content").addEventListener("click", handleAdminAction);
document.querySelectorAll("[data-admin-view]").forEach((button) => {
button.addEventListener("click", () => {
state.adminView = button.dataset.adminView;
state.adminOffset = 0;
document.querySelectorAll("[data-admin-view]").forEach((item) => item.classList.toggle("active", item === button));
loadAdminJobs().catch((error) => showNotice("adminNotice", error.message, true));
});
});
document.getElementById("trashSettings").addEventListener("submit", async (event) => {
event.preventDefault();
try {
await request("/api/admin/settings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ trash_retention_days: Number(document.getElementById("trashRetentionDays").value) }),
});
showNotice("adminNotice", "回收站保留时间已保存。");
} catch (error) {
showNotice("adminNotice", error.message, true);
}
});
document.getElementById("adminLogout").addEventListener("click", async () => {
await request("/api/admin/logout", { method: "POST" });
window.location.assign("/admin");
});
await Promise.all([loadAdminJobs(), loadAdminSettings()]);
}
async function initialize() {
renderNavigation();
if (page === "home") await initHome();
else if (page === "cloud") await initCloud();
else if (page === "account") await initAccount();
else if (page === "settings") await initSettings();
else if (page === "admin") await initAdmin();
else if (page === "admin-login") await initAdminLogin();
}
initialize().catch((error) => {
const target = document.querySelector(".content") || document.body;
const notice = document.createElement("div");
notice.className = "notice error";
notice.textContent = error.message;
target.appendChild(notice);
});