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("<", "<") .replaceAll(">", ">") .replaceAll('"', """) .replaceAll("'", "'"); } 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 `${escapeHtml(value)}`; } 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 = ` NUPACK Admin `; return; } const items = [ ["home", "/", "概览"], ["workspace", "/workspace", "计算工作台"], ["cloud", "/cloud", "云端记录"], ["account", "/account", "账号与用量"], ["settings", "/settings", "设置"], ]; nav.innerHTML = ` NUPACK `; } 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 ` ${escapeHtml(job.job_id.slice(0, 12))} ${escapeHtml(job.workflow)} / ${escapeHtml(job.mode)} ${statusBadge(job.status)} ${escapeHtml(formatDate(job.created_at))} ${escapeHtml(formatDuration(job.elapsed_seconds))} `; } 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("") : '还没有任务记录。'; } 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 ` ${escapeHtml(job.job_id.slice(0, 12))} ${escapeHtml(job.workflow)} / ${escapeHtml(job.mode)}
${escapeHtml(job.material)} · max ${escapeHtml(job.max_size)} ${statusBadge(job.status)} ${escapeHtml(formatDate(job.created_at))} ${escapeHtml(formatDuration(job.elapsed_seconds))}
查看 ${active ? `` : ``}
`; } 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("") : '没有符合条件的任务。'; 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]) => `
${escapeHtml(term)}
${escapeHtml(value ?? "-")}
`).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 ? ` ` : `${active ? `` : ""} ${active ? "" : ``}`; return ` ${escapeHtml(job.display_name || job.username || job.email || job.user_id)}
${escapeHtml(job.user_id)} ${escapeHtml(job.job_id)} ${escapeHtml(job.workflow)} / ${escapeHtml(job.mode)}
${escapeHtml(job.material)} · max ${escapeHtml(job.max_size)} ${statusBadge(job.status)} ${escapeHtml(formatDate(job.created_at))}${deleted ? `
删除 ${escapeHtml(formatDate(job.deleted_at))}
清理 ${escapeHtml(formatDate(job.purge_after))}
` : ""} ${escapeHtml(formatDuration(job.elapsed_seconds))}
${actions}
${escapeHtml(formatBytes(job.stored_bytes))} `; } 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("") : '没有符合条件的任务。'; 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); });