diff --git a/.dockerignore b/.dockerignore index 0cd8183..56a001e 100644 --- a/.dockerignore +++ b/.dockerignore @@ -17,3 +17,4 @@ nupack/vendor/nupack-4.0.2.0/package/*.whl !nupack/vendor/nupack-4.0.2.0/package/nupack-4.0.2.0-cp312-cp312-linux_x86_64.whl rna/* !rna/ViennaRNA-2.7.2.tar.gz +runtime diff --git a/.gitignore b/.gitignore index 7a7f664..013ae97 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,4 @@ nupack/vendor/nupack-4.0.2.0/package/*.whl !nupack/vendor/nupack-4.0.2.0/package/nupack-4.0.2.0-cp312-cp312-linux_x86_64.whl # Runtime state mounted from Docker containers. runtime/redis/ +runtime/account/ diff --git a/Dockerfile b/Dockerfile index 21ad0d6..0fbd9cc 100644 --- a/Dockerfile +++ b/Dockerfile @@ -31,14 +31,14 @@ RUN cd /tmp \ && rm -rf /tmp/ViennaRNA-2.7.2 /tmp/ViennaRNA-2.7.2.tar.gz RUN python -m pip install --no-cache-dir \ - numpy \ - scipy \ - pandas \ - pyyaml \ - jinja2 \ + numpy==2.4.6 \ + scipy==1.17.1 \ + pandas==3.0.3 \ + pyyaml==6.0.3 \ + jinja2==3.1.6 \ /tmp/nupack-package/nupack-4.0.2.0-cp312-cp312-linux_x86_64.whl -RUN python -m pip install --no-cache-dir redis +RUN python -m pip install --no-cache-dir redis==7.4.0 WORKDIR /app/service COPY service /app/service diff --git a/README.md b/README.md index 4f61554..3c5042c 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,9 @@ Open `http://127.0.0.1:18765`. ## Reverse Proxy -The service listens on port `18765`. You can later reverse proxy `np.lihato.icu` to this port from your public server. +The service listens on port `18765`. The current local deployment is reverse proxied as `npt.lihato.icu`. + +Traefik only performs TLS termination, compression, and reverse proxying for `npt`; authentication is enforced by this application. ## Endpoints @@ -26,7 +28,7 @@ The service listens on port `18765`. You can later reverse proxy `np.lihato.icu` ## Persistent Shares -Shared result links are stored in Redis when `NP_REDIS_URL` is enabled. The compose file maps Redis data to `./runtime/redis` and enables AOF/RDB persistence, so shares survive container recreation and service restarts. Back up or migrate `./runtime/redis` if you move the deployment. +Live jobs and sessions are stored in Redis when `NP_REDIS_URL` is enabled. Account history and share links are stored in `./runtime/account/np-replica.sqlite3`; back up both runtime directories when moving the deployment. ## Notes @@ -35,7 +37,7 @@ Shared result links are stored in Redis when `NP_REDIS_URL` is enabled. The comp - Pair matrices are returned as a preview block to keep responses manageable. - The Docker image compiles ViennaRNA `RNAplot` from `rna/ViennaRNA-2.7.2.tar.gz` and embeds SVG structure plots into MFE results. - Multistrand MFE plots now use a split-strand layout by default when a structure contains `+`, with `RNAplot` retained as the fallback path. -- The web UI now supports Chinese and English switching, browser-side history, result export, and async polling for submitted jobs. +- The web UI supports Chinese and English switching, account-scoped cloud history, result export, share controls, and async polling for submitted jobs. - Docker now starts three services: `np-replica`, `np-worker`, and `redis`. - Async jobs use Redis-backed storage and queueing when `NP_REDIS_URL` is configured; otherwise the app falls back to the older in-memory mode. - The browser-facing API and page behavior stay the same after enabling Redis-backed jobs. @@ -45,6 +47,11 @@ Shared result links are stored in Redis when `NP_REDIS_URL` is enabled. The comp - Static pages are served with ETag-based browser caching; JSON API responses remain uncached. - The force-directed structure viewer lazy-loads D3 only when that view is opened, so the initial page load is not blocked by the external CDN. - `/health` uses Redis queue length plus a running-job set instead of scanning every historical job on each poll. +- Application login uses the public Authentik issuer at `https://auth.lihato.icu/` with Authorization Code + PKCE. +- The current `npt` instance uses issuer `/application/o/nupack-account-npt/`, client `npt-replica-web`, callback `https://npt.lihato.icu/auth/callback`, and logout return `https://npt.lihato.icu/`. +- For the independent `np` deployment on `100.64.0.11`, set `NP_OIDC_ISSUER=https://auth.lihato.icu/application/o/nupack-account/`, `NP_OIDC_CLIENT_ID=np-replica-web`, `NP_OIDC_REDIRECT_URI=https://np.lihato.icu/auth/callback`, and `NP_OIDC_POST_LOGOUT_URI=https://np.lihato.icu/`. +- Redis stores the live queue and login sessions. SQLite WAL at `/data/np-replica.sqlite3` stores users, owned jobs, compressed inputs/results/errors, usage totals, and durable share links. +- History and job APIs enforce ownership by the Authentik OIDC subject. Public share links expose only the selected record and can be disabled or given an expiry by its owner. @@ -53,7 +60,8 @@ Shared result links are stored in Redis when `NP_REDIS_URL` is enabled. The comp - 最多同时跑 16 个任务 - 每个任务最多用 4 个计算线程 - 后续任务进入队列等待 - - 理论上最多占用约 64 个 native 计算线程,避免 16 × 8 这类过度超卖 + - 每个任务的 NUPACK 缓存上限为 8 GB + - 理论上最多占用约 64 个 native 计算线程 如果后面你发现机器还会被压满,最直接的调法就是在 docker-compose.yml 里继续压: diff --git a/docker-compose.yml b/docker-compose.yml index aef86f8..b1bd892 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,7 +9,7 @@ services: - ./runtime/redis:/data np-replica: - image: np-app:v4.5 + image: np-app:v4.7 build: context: . dockerfile: Dockerfile @@ -18,6 +18,8 @@ services: mem_limit: 4g depends_on: - redis + volumes: + - ./runtime/account:/data ports: - "100.64.0.2:18765:18765" environment: @@ -25,6 +27,12 @@ services: NP_PORT: 18765 NP_RUN_MODE: server NP_REDIS_URL: redis://redis:6379/0 + NP_ACCOUNT_DB_PATH: /data/np-replica.sqlite3 + NP_AUTH_REQUIRED: 1 + NP_OIDC_ISSUER: https://auth.lihato.icu/application/o/nupack-account-npt/ + NP_OIDC_CLIENT_ID: npt-replica-web + NP_OIDC_REDIRECT_URI: https://npt.lihato.icu/auth/callback + NP_OIDC_POST_LOGOUT_URI: https://npt.lihato.icu/ NP_JOB_TTL_SECONDS: 3600 NP_WORKER_CONCURRENCY: 16 NP_PER_JOB_THREAD_LIMIT: 4 @@ -37,15 +45,18 @@ services: GOTO_NUM_THREADS: 4 np-worker: - image: np-app:v4.5 + image: np-app:v4.7 container_name: np-worker restart: always mem_limit: 56g depends_on: - redis + volumes: + - ./runtime/account:/data environment: NP_RUN_MODE: worker NP_REDIS_URL: redis://redis:6379/0 + NP_ACCOUNT_DB_PATH: /data/np-replica.sqlite3 NP_JOB_TTL_SECONDS: 3600 NP_WORKER_CONCURRENCY: 16 NP_PER_JOB_THREAD_LIMIT: 4 diff --git a/service/index.html b/service/index.html index 3d3de0e..da3e825 100644 --- a/service/index.html +++ b/service/index.html @@ -1094,6 +1094,151 @@ font-size: 13px; } + .account-chip { + display: flex; + align-items: center; + gap: 10px; + min-width: 0; + padding: 7px 8px 7px 12px; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--card); + } + + .account-chip strong, + .account-chip span { + display: block; + max-width: 220px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .account-chip span { + margin-top: 2px; + color: var(--muted); + font-size: 11px; + } + + .account-chip a { + padding: 7px 10px; + border-left: 1px solid var(--line); + color: var(--accent-strong); + font-size: 12px; + text-decoration: none; + } + + .usage-panel { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(110px, 1fr)); + border: 1px solid var(--line); + border-radius: 8px; + overflow: hidden; + background: var(--card); + } + + .usage-metric { + min-width: 0; + padding: 10px 12px; + border-right: 1px solid var(--line); + } + + .usage-metric:last-child { + border-right: 0; + } + + .usage-metric strong, + .usage-metric span { + display: block; + } + + .usage-metric strong { + color: var(--muted); + font-size: 11px; + font-weight: 500; + } + + .usage-metric span { + margin-top: 4px; + font-size: 18px; + font-weight: 700; + } + + .history-filters { + display: grid; + grid-template-columns: minmax(130px, 1.5fr) repeat(4, minmax(90px, 1fr)); + gap: 8px; + margin: 10px 0; + } + + .history-filters input, + .history-filters select { + padding: 8px 9px; + border-radius: 8px; + } + + .history-meta { + display: flex; + flex-wrap: wrap; + gap: 5px 12px; + margin-bottom: 9px; + color: var(--muted); + font-size: 12px; + } + + .history-meta strong { + color: var(--ink); + } + + .history-pagination, + .share-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + margin-top: 10px; + } + + .share-heading { + margin: 18px 0 10px; + padding-top: 14px; + border-top: 1px solid var(--line); + } + + .share-heading h3 { + margin: 0; + font-size: 14px; + } + + .share-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 10px; + align-items: center; + } + + .share-row select { + width: auto; + min-width: 110px; + padding: 7px 8px; + border-radius: 8px; + } + + body.share-mode #historySection, + body.share-mode .control-panel > .panel-inner > .actions, + body.share-mode #usagePanel, + body.share-mode #accountChip { + display: none !important; + } + + body.share-mode .control-panel input:disabled, + body.share-mode .control-panel select:disabled, + body.share-mode .control-panel textarea:disabled, + body.share-mode .control-panel button:disabled { + cursor: default; + opacity: 0.72; + } + .compact-actions { display: flex; flex-wrap: wrap; @@ -1280,6 +1425,21 @@ .strand-meta-grid { grid-template-columns: 1fr 1fr; } + + .history-filters { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + } + + @media (max-width: 600px) { + .history-filters, + .share-row { + grid-template-columns: 1fr; + } + + .usage-panel { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } } @@ -1288,6 +1448,10 @@
+
+
@@ -1622,17 +1794,36 @@ A+B
+

- +
+
+ + + + + +
+
+
+ + + +
+ +
@@ -2105,7 +2296,43 @@ A+B cancel_job_failed: "终止任务失败", submission_locked: "已有任务在等待结果返回,请终止或等待。", example_btn: "载入示例", - history_title: "最近任务", + export_failed_examples: "导出错误 JSON", + no_failed_examples: "暂无可导出的错误样本。", + account_logout: "退出", + usage_total: "任务总数", + usage_success: "成功", + usage_error: "失败", + usage_active: "进行中", + usage_compute: "累计计算", + usage_storage: "云端存储", + history_title: "账号工作与云端记录", + history_refresh: "刷新", + history_search: "搜索任务 ID / 配置", + history_all_status: "全部状态", + history_all_workflows: "全部工作流", + history_all_modes: "全部模式", + history_all_materials: "全部材料", + history_prev: "上一页", + history_next: "下一页", + share_duration: "新分享有效期", + share_1h: "1 小时", + share_1d: "1 天", + share_7d: "7 天", + share_30d: "30 天", + share_forever: "永久", + share_management: "分享管理", + share_copy: "复制链接", + share_disable: "关闭", + share_enable: "开启", + share_expired: "已过期", + share_permanent: "永久有效", + share_accesses: "访问", + history_page: "第 {page} / {pages} 页,共 {total} 条", + history_status: "状态", + history_config: "配置", + history_job_id: "任务", + history_elapsed: "耗时", + history_import_migrated: "本地历史已迁移到账号", clear_history: "清空历史", history_import: "导入历史", history_export_all: "全部导出", @@ -2416,7 +2643,43 @@ A+B cancel_job_failed: "Cancel job failed", submission_locked: "A job is already waiting for results. Cancel or wait.", example_btn: "Load Example", - history_title: "Recent Jobs", + export_failed_examples: "Export Error JSON", + no_failed_examples: "No failed examples are available to export.", + account_logout: "Sign out", + usage_total: "Total jobs", + usage_success: "Succeeded", + usage_error: "Failed", + usage_active: "Active", + usage_compute: "Compute time", + usage_storage: "Cloud storage", + history_title: "Account Work and Cloud Records", + history_refresh: "Refresh", + history_search: "Search job ID or configuration", + history_all_status: "All statuses", + history_all_workflows: "All workflows", + history_all_modes: "All modes", + history_all_materials: "All materials", + history_prev: "Previous", + history_next: "Next", + share_duration: "New share duration", + share_1h: "1 hour", + share_1d: "1 day", + share_7d: "7 days", + share_30d: "30 days", + share_forever: "Permanent", + share_management: "Share management", + share_copy: "Copy link", + share_disable: "Disable", + share_enable: "Enable", + share_expired: "Expired", + share_permanent: "No expiry", + share_accesses: "Accesses", + history_page: "Page {page} of {pages}, {total} records", + history_status: "Status", + history_config: "Configuration", + history_job_id: "Job", + history_elapsed: "Elapsed", + history_import_migrated: "Local history migrated to your account", clear_history: "Clear History", history_import: "Import History", history_export_all: "Export All", @@ -2602,11 +2865,27 @@ A+B const defectWeightList = document.getElementById("defectWeightList"); const historyList = document.getElementById("historyList"); const historyImportInput = document.getElementById("historyImportInput"); + const accountChip = document.getElementById("accountChip"); + const accountName = document.getElementById("accountName"); + const accountEmail = document.getElementById("accountEmail"); + const usagePanel = document.getElementById("usagePanel"); + const historySearch = document.getElementById("historySearch"); + const historyStatus = document.getElementById("historyStatus"); + const historyWorkflow = document.getElementById("historyWorkflow"); + const historyMode = document.getElementById("historyMode"); + const historyMaterial = document.getElementById("historyMaterial"); + const historyPrev = document.getElementById("historyPrev"); + const historyNext = document.getElementById("historyNext"); + const historyPageInfo = document.getElementById("historyPageInfo"); + const shareDuration = document.getElementById("shareDuration"); + const shareList = document.getElementById("shareList"); + const shareCount = document.getElementById("shareCount"); const colorLowInput = document.getElementById("colorLow"); const colorHighInput = document.getElementById("colorHigh"); const graphFontSizeSelect = document.getElementById("graphFontSize"); const colorPreview = document.getElementById("colorPreview"); const runBtn = document.getElementById("runBtn"); + const exportFailedExamplesBtn = document.getElementById("exportFailedExamplesBtn"); const viewer = document.getElementById("viewer"); const viewerTitle = document.getElementById("viewerTitle"); const viewerHint = document.getElementById("viewerHint"); @@ -2621,6 +2900,8 @@ A+B const queueThreads = document.getElementById("queueThreads"); const HISTORY_KEY = "np_replica_history_v1"; const HISTORY_MAX_ITEMS = 32; + const FAILED_EXAMPLES_KEY = "np_replica_failed_examples_v1"; + const FAILED_EXAMPLES_MAX_ITEMS = 32; const designDomainTemplate = document.getElementById("designDomainTemplate"); const designDomainList = document.getElementById("designDomainList"); const designComplexTemplate = document.getElementById("designComplexTemplate"); @@ -2681,6 +2962,13 @@ A+B let viewerDragState = null; let healthPollTimer = null; let historyRenderTimer = null; + let accountUser = null; + let accountUsage = null; + let cloudHistory = []; + let cloudHistoryTotal = 0; + let cloudShares = []; + let historyOffset = 0; + const HISTORY_PAGE_SIZE = 20; let d3LoadPromise = null; const HEALTH_POLL_INTERVAL_MS = 5000; const D3_URL = "https://d3js.org/d3.v7.min.js"; @@ -2747,6 +3035,10 @@ A+B async function fetchJsonOrThrow(url, options = {}) { const response = await fetch(url, options); + if (response.status === 401 && !url.startsWith("/api/shares/")) { + const next = `${window.location.pathname}${window.location.search}${window.location.hash}`; + window.location.assign(`/auth/login?next=${encodeURIComponent(next)}`); + } const rawText = await response.text(); const contentType = response.headers.get("content-type") || ""; const hasBody = rawText.trim().length > 0; @@ -2951,6 +3243,10 @@ A+B const key = node.dataset.i18n; node.textContent = t(key); }); + document.querySelectorAll("[data-i18n-placeholder]").forEach((node) => { + node.placeholder = t(node.dataset.i18nPlaceholder); + }); + updateFailedExamplesButtonState(); if (lastResult) { renderResult(lastResult); setStatus(formatStatus(t("status_done"))); @@ -3384,6 +3680,82 @@ A+B downloadText(filename, svgMarkup, "image/svg+xml;charset=utf-8"); } + function cloneJson(value) { + try { + return JSON.parse(JSON.stringify(value)); + } catch { + return value; + } + } + + function getFailureExamples() { + try { + const failures = JSON.parse(localStorage.getItem(FAILED_EXAMPLES_KEY) || "[]"); + return Array.isArray(failures) ? failures : []; + } catch { + return []; + } + } + + function setFailureExamples(failures) { + let trimmed = failures.slice(0, FAILED_EXAMPLES_MAX_ITEMS); + while (trimmed.length) { + try { + localStorage.setItem(FAILED_EXAMPLES_KEY, JSON.stringify(trimmed)); + return; + } catch { + trimmed = trimmed.slice(0, -1); + } + } + localStorage.removeItem(FAILED_EXAMPLES_KEY); + } + + function updateFailedExamplesButtonState() { + const count = getFailureExamples().length; + exportFailedExamplesBtn.disabled = count === 0; + exportFailedExamplesBtn.title = count ? `${t("export_failed_examples")} (${count})` : t("no_failed_examples"); + } + + function normalizeErrorForExport(error) { + const normalized = { + message: error?.message || String(error || "Unknown error"), + name: error?.name || "Error", + }; + if (error?.stack) normalized.stack = String(error.stack); + return normalized; + } + + function saveFailedExample(payload, error, context = {}) { + const safePayload = payload ? cloneJson(payload) : null; + const failure = { + id: `${Date.now()}-${Math.random().toString(16).slice(2)}`, + created_at: new Date().toISOString(), + workflow: context.workflow || safePayload?.workflow || workflowSelect.value, + mode: context.mode || safePayload?.mode || modeSelect.value, + stage: context.stage || "job", + job_id: context.jobId || currentJobId || null, + error: normalizeErrorForExport(error), + payload: safePayload, + }; + setFailureExamples([failure, ...getFailureExamples()]); + updateFailedExamplesButtonState(); + return failure; + } + + function exportFailedExamples() { + const failures = getFailureExamples(); + if (!failures.length) { + alert(t("no_failed_examples")); + updateFailedExamplesButtonState(); + return; + } + downloadText("nupack-failed-examples.json", JSON.stringify({ + version: 1, + exported_at: new Date().toISOString(), + failures, + }, null, 2)); + } + function getHistory() { try { const history = JSON.parse(localStorage.getItem(HISTORY_KEY) || "[]"); @@ -3430,7 +3802,7 @@ A+B if (!payload || !payload.workflow || !payload.mode) return null; const result = entry?.result && typeof entry.result === "object" ? entry.result : null; return { - id: String(entry.id || `${Date.now()}-${index}-${Math.random().toString(16).slice(2)}`), + id: String(entry.id || entry.job_id || `${Date.now()}-${index}-${Math.random().toString(16).slice(2)}`), created_at: entry.created_at_iso || entry.created_at || new Date().toISOString(), payload, result, @@ -3444,70 +3816,186 @@ A+B } function saveHistory(payload, result) { - const history = getHistory(); - history.unshift({ - id: `${Date.now()}`, - created_at: new Date().toISOString(), - payload, - result, - result_summary: buildHistorySummary(payload, { - workflow: result.workflow, - mode: result.mode, - complex_count: result.complexes.length, - compute: result.compute, - }), - }); - setHistory(history); + void refreshAccountWorkspace(); } - function deleteHistory(id) { - setHistory(getHistory().filter((item) => item.id !== id)); + function formatBytes(value) { + let bytes = Number(value) || 0; + const units = ["B", "KB", "MB", "GB"]; + let unit = 0; + while (bytes >= 1024 && unit < units.length - 1) { + bytes /= 1024; + unit += 1; + } + return `${bytes.toFixed(unit ? 1 : 0)} ${units[unit]}`; + } + + function formatDuration(value) { + const seconds = Number(value) || 0; + if (seconds < 60) return `${seconds.toFixed(1)}s`; + if (seconds < 3600) return `${(seconds / 60).toFixed(1)}m`; + return `${(seconds / 3600).toFixed(1)}h`; + } + + function renderUsage() { + if (!accountUsage) return; + document.getElementById("usageTotal").textContent = accountUsage.total_jobs || 0; + document.getElementById("usageSuccess").textContent = accountUsage.success_jobs || 0; + document.getElementById("usageError").textContent = accountUsage.error_jobs || 0; + document.getElementById("usageActive").textContent = accountUsage.active_jobs || 0; + document.getElementById("usageCompute").textContent = formatDuration(accountUsage.compute_seconds); + document.getElementById("usageStorage").textContent = formatBytes(accountUsage.stored_bytes); + } + + function historyQuery() { + const query = new URLSearchParams({ limit: String(HISTORY_PAGE_SIZE), offset: String(historyOffset) }); + const filters = { + q: historySearch.value.trim(), + status: historyStatus.value, + workflow: historyWorkflow.value, + mode: historyMode.value, + material: historyMaterial.value, + }; + Object.entries(filters).forEach(([key, value]) => { + if (value) query.set(key, value); + }); + return query; + } + + async function loadCloudHistory() { + const { response, data } = await fetchJsonOrThrow(`/api/history?${historyQuery()}`); + if (!response.ok || data.status !== "success") { + throw new Error(data?.error || `History request failed with HTTP ${response.status}`); + } + cloudHistory = data.items || []; + cloudHistoryTotal = Number(data.total) || 0; + if (historyOffset && historyOffset >= cloudHistoryTotal) { + historyOffset = Math.max(0, Math.floor(Math.max(0, cloudHistoryTotal - 1) / HISTORY_PAGE_SIZE) * HISTORY_PAGE_SIZE); + return loadCloudHistory(); + } renderHistory(); } - function exportHistory() { - downloadText("nupack-history.json", JSON.stringify({ - version: 1, - exported_at: new Date().toISOString(), - history: getHistory(), - }, null, 2)); + async function loadCloudShares() { + const { response, data } = await fetchJsonOrThrow("/api/account/shares"); + if (!response.ok || data.status !== "success") { + throw new Error(data?.error || `Share request failed with HTTP ${response.status}`); + } + cloudShares = data.items || []; + renderShares(); } - function exportHistoryItem(id) { - const item = getHistory().find((entry) => entry.id === id); - if (!item) return; - downloadText(`nupack-history-${id}.json`, JSON.stringify(item, null, 2)); + async function refreshAccountWorkspace() { + if (!accountUser) return; + try { + const [me] = await Promise.all([ + fetchJsonOrThrow("/api/me"), + loadCloudHistory(), + loadCloudShares(), + ]); + if (me.response.ok) { + accountUsage = me.data.usage; + renderUsage(); + } + } catch (error) { + setStatus(`${t("status_error")}: ${error.message}`, true); + } + } + + async function migrateLocalHistory() { + const entries = getHistory(); + const migrationKey = `np_replica_history_migrated_${accountUser.user_id}`; + if (!entries.length || localStorage.getItem(migrationKey) === "1") return; + const { response, data } = await fetchJsonOrThrow("/api/history/import", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ history: entries }), + }); + if (!response.ok || data.status !== "success") { + throw new Error(data?.error || `History migration failed with HTTP ${response.status}`); + } + localStorage.setItem(migrationKey, "1"); + setStatus(formatStatus(`${t("history_import_migrated")}: ${data.imported}`)); + } + + async function initializeAccount() { + try { + const { response, data } = await fetchJsonOrThrow("/api/me"); + if (!response.ok || data.status !== "success") return; + accountUser = data.user; + accountUsage = data.usage; + accountName.textContent = accountUser.display_name || accountUser.username; + accountEmail.textContent = accountUser.email || accountUser.username; + accountChip.classList.remove("hidden"); + usagePanel.classList.remove("hidden"); + renderUsage(); + await migrateLocalHistory(); + await refreshAccountWorkspace(); + } catch (error) { + setStatus(`${t("status_error")}: ${error.message}`, true); + } + } + + async function deleteHistory(id) { + const { response, data } = await fetchJsonOrThrow(`/api/history/${encodeURIComponent(id)}`, { method: "DELETE" }); + if (!response.ok || data.status !== "success") { + throw new Error(data?.error || `Delete failed with HTTP ${response.status}`); + } + await refreshAccountWorkspace(); + } + + async function fetchAllHistory() { + const items = []; + let offset = 0; + while (true) { + const query = new URLSearchParams({ limit: "100", offset: String(offset) }); + const { response, data } = await fetchJsonOrThrow(`/api/history?${query}`); + if (!response.ok || data.status !== "success") throw new Error(data?.error || "History export failed."); + const page = data.items || []; + for (const summary of page) { + const detail = await fetchJsonOrThrow(`/api/history/${encodeURIComponent(summary.job_id)}`); + if (detail.response.ok) items.push(detail.data.item); + } + offset += page.length; + if (!page.length || offset >= data.total) break; + } + return items; + } + + async function exportHistory() { + const history = await fetchAllHistory(); + downloadText("nupack-history.json", JSON.stringify({ version: 2, exported_at: new Date().toISOString(), history }, null, 2)); + } + + async function exportHistoryItem(id) { + const { response, data } = await fetchJsonOrThrow(`/api/history/${encodeURIComponent(id)}`); + if (!response.ok || data.status !== "success") throw new Error(data?.error || "History export failed."); + downloadText(`nupack-history-${id}.json`, JSON.stringify(data.item, null, 2)); } async function importHistoryFiles(files) { const imported = []; for (const file of Array.from(files || [])) { - const text = await file.text(); - imported.push(...extractHistoryEntries(JSON.parse(text))); + imported.push(...extractHistoryEntries(JSON.parse(await file.text()))); } - if (!imported.length) { - throw new Error("No valid history entries found."); - } - const existing = getHistory(); - const byId = new Map(); - [...imported, ...existing].forEach((item) => byId.set(item.id, item)); - const merged = Array.from(byId.values()).sort((a, b) => new Date(b.created_at) - new Date(a.created_at)); - setHistory(merged); - renderHistory(); - setStatus(formatStatus(`${t("history_import_success")}: ${imported.length}`)); + if (!imported.length) throw new Error("No valid history entries found."); + const { response, data } = await fetchJsonOrThrow("/api/history/import", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ history: imported }), + }); + if (!response.ok || data.status !== "success") throw new Error(data?.error || "History import failed."); + historyOffset = 0; + await refreshAccountWorkspace(); + setStatus(formatStatus(`${t("history_import_success")}: ${data.imported}`)); } async function copyTextToClipboard(text) { - if (navigator.clipboard?.writeText) { - await navigator.clipboard.writeText(text); - return; - } + if (navigator.clipboard?.writeText) return navigator.clipboard.writeText(text); const textarea = document.createElement("textarea"); textarea.value = text; - textarea.setAttribute("readonly", ""); textarea.style.position = "fixed"; textarea.style.left = "-9999px"; - textarea.style.top = "0"; document.body.appendChild(textarea); textarea.select(); const copied = document.execCommand("copy"); @@ -3515,30 +4003,31 @@ A+B if (!copied) throw new Error("Clipboard copy failed."); } - async function shareHistoryItem(id) { - const item = getHistory().find((entry) => entry.id === id); - if (!item?.result) return; - const { response, data } = await fetchJsonOrThrow("/api/shares", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - payload: item.payload, - result: item.result, - result_summary: item.result_summary, - }), - }); - if (!response.ok || data.status !== "success") { - throw new Error(data?.error || `Share failed with HTTP ${response.status}`); - } - const shareUrl = `${window.location.origin}${window.location.pathname}?share=${encodeURIComponent(data.share_id)}`; - await copyTextToClipboard(shareUrl); - setStatus(formatStatus(`${t("history_share_success")}: ${shareUrl}`)); - window.alert(`${t("history_share_copied")}\n${shareUrl}`); + function shareUrl(shareId) { + return `${window.location.origin}/share/${encodeURIComponent(shareId)}`; } - function loadHistoryItem(id) { - const item = getHistory().find((entry) => entry.id === id); - if (!item) return; + async function shareHistoryItem(id) { + const expires = shareDuration.value ? Number(shareDuration.value) : null; + const { response, data } = await fetchJsonOrThrow(`/api/jobs/${encodeURIComponent(id)}/shares`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ expires_in: expires }), + }); + if (!response.ok || data.status !== "success") throw new Error(data?.error || "Share failed."); + const url = shareUrl(data.share.share_id); + await copyTextToClipboard(url); + await refreshAccountWorkspace(); + setStatus(formatStatus(`${t("history_share_success")}: ${url}`)); + } + + async function loadHistoryItem(id) { + const { response, data } = await fetchJsonOrThrow(`/api/history/${encodeURIComponent(id)}`); + if (!response.ok || data.status !== "success") throw new Error(data?.error || "History record not found."); + applyHistoryItem(data.item); + } + + function applyHistoryItem(item) { const payload = item.payload; currentJobId = null; workflowSelect.value = payload.workflow || "analysis"; @@ -3596,6 +4085,12 @@ A+B lastResult = item.result; renderResult(item.result); setStatus(formatStatus(t("status_done"))); + } else if (item.error) { + lastPayload = payload; + lastResult = null; + const message = item.error.message || JSON.stringify(item.error); + results.innerHTML = `

${t("request_failed")}

${escapeHtml(message)}
`; + setStatus(formatStatus(t("status_error")), true); } else { lastPayload = payload; lastResult = null; @@ -3604,8 +4099,10 @@ A+B } async function loadSharedHistoryFromUrl() { - const shareId = new URLSearchParams(window.location.search).get("share"); + const pathMatch = window.location.pathname.match(/^\/share\/([A-Za-z0-9_-]{8,128})$/); + const shareId = pathMatch?.[1] || new URLSearchParams(window.location.search).get("share"); if (!shareId) return; + document.body.classList.add("share-mode"); try { const { response, data } = await fetchJsonOrThrow(`/api/shares/${encodeURIComponent(shareId)}`); if (!response.ok || data.status !== "success") { @@ -3613,11 +4110,11 @@ A+B } const item = normalizeHistoryEntry(data.share, 0); if (!item) throw new Error("Shared record is invalid."); - const merged = [item, ...getHistory().filter((entry) => entry.id !== item.id)] - .sort((a, b) => new Date(b.created_at) - new Date(a.created_at)); - setHistory(merged); - renderHistory(); - loadHistoryItem(item.id); + item.error = data.share.error || null; + applyHistoryItem(item); + document.querySelectorAll(".control-panel input, .control-panel select, .control-panel textarea, .control-panel button").forEach((node) => { + if (!node.closest(".toolbar") && !node.matches("[data-design-tab]")) node.disabled = true; + }); setStatus(formatStatus(t("history_shared_loaded"))); } catch (error) { setStatus(`${t("history_share_failed")}: ${error.message}`, true); @@ -3625,30 +4122,36 @@ A+B } function renderHistory() { - const history = getHistory(); - if (!history.length) { + if (!cloudHistory.length) { historyList.innerHTML = `

${t("history_empty")}

`; - return; - } - historyList.innerHTML = history.map((item) => ` -
-

${new Date(item.created_at).toLocaleString()} · ${(item.result_summary.workflow || item.payload?.workflow || "analysis")} · ${t(`history_mode_${item.result_summary.mode}`)} · ${item.result_summary.complex_count} ${t("count_complexes")} · ${item.result ? t("history_has_result") : t("history_no_result")}

+ } else { + historyList.innerHTML = cloudHistory.map((item) => ` +
+
${escapeHtml(item.workflow)} / ${escapeHtml(item.mode)}${new Date(item.created_at * 1000).toLocaleString()}${t("history_status")}: ${escapeHtml(item.status)}
+
${escapeHtml(item.material)} · ${item.celsius} C · Na ${item.sodium} M · Mg ${item.magnesium} Mmax size ${item.max_size} · ${item.strand_count} strands · ${item.complex_count} complexes${t("history_elapsed")}: ${item.elapsed_seconds == null ? "-" : formatDuration(item.elapsed_seconds)}
+

${t("history_job_id")}: ${escapeHtml(item.job_id)} · ${(item.compute || []).map(escapeHtml).join(", ")}

- - - ${item.result ? `` : ""} - + + + ${["success", "error", "input_only", "canceled"].includes(item.status) ? `` : ""} + ${["queued", "running", "cancel_requested"].includes(item.status) ? "" : ``}
- `).join(""); + `).join(""); + } + const pages = Math.max(1, Math.ceil(cloudHistoryTotal / HISTORY_PAGE_SIZE)); + const page = Math.floor(historyOffset / HISTORY_PAGE_SIZE) + 1; + historyPageInfo.textContent = t("history_page").replace("{page}", page).replace("{pages}", pages).replace("{total}", cloudHistoryTotal); + historyPrev.disabled = historyOffset === 0; + historyNext.disabled = historyOffset + HISTORY_PAGE_SIZE >= cloudHistoryTotal; historyList.querySelectorAll("[data-history-load]").forEach((button) => { - button.addEventListener("click", () => loadHistoryItem(button.dataset.historyLoad)); + button.addEventListener("click", () => loadHistoryItem(button.dataset.historyLoad).catch((error) => setStatus(error.message, true))); }); historyList.querySelectorAll("[data-history-delete]").forEach((button) => { - button.addEventListener("click", () => deleteHistory(button.dataset.historyDelete)); + button.addEventListener("click", () => deleteHistory(button.dataset.historyDelete).catch((error) => setStatus(error.message, true))); }); historyList.querySelectorAll("[data-history-export]").forEach((button) => { - button.addEventListener("click", () => exportHistoryItem(button.dataset.historyExport)); + button.addEventListener("click", () => exportHistoryItem(button.dataset.historyExport).catch((error) => setStatus(error.message, true))); }); historyList.querySelectorAll("[data-history-share]").forEach((button) => { button.addEventListener("click", async () => { @@ -3661,6 +4164,47 @@ A+B }); } + function renderShares() { + shareCount.textContent = String(cloudShares.length); + if (!cloudShares.length) { + shareList.innerHTML = `

${t("history_empty")}

`; + return; + } + const now = Date.now() / 1000; + shareList.innerHTML = cloudShares.map((share) => { + const expired = share.expires_at != null && share.expires_at <= now; + const enabled = Boolean(share.active) && !expired; + const expiry = share.expires_at == null ? t("share_permanent") : (expired ? t("share_expired") : new Date(share.expires_at * 1000).toLocaleString()); + return ``; + }).join(""); + shareList.querySelectorAll("[data-share-copy]").forEach((button) => button.addEventListener("click", async () => { + await copyTextToClipboard(shareUrl(button.dataset.shareCopy)); + setStatus(formatStatus(t("history_share_copied"))); + })); + shareList.querySelectorAll("[data-share-toggle]").forEach((button) => button.addEventListener("click", async () => { + const enabling = button.dataset.active !== "1"; + const change = { active: enabling }; + if (enabling && button.dataset.expired === "1") change.expires_in = null; + await updateShare(button.dataset.shareToggle, change); + })); + shareList.querySelectorAll("[data-share-expiry]").forEach((select) => select.addEventListener("change", async () => { + await updateShare(select.dataset.shareExpiry, { active: true, expires_in: select.value ? Number(select.value) : null }); + })); + } + + async function updateShare(id, change) { + const { response, data } = await fetchJsonOrThrow(`/api/account/shares/${encodeURIComponent(id)}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(change), + }); + if (!response.ok || data.status !== "success") throw new Error(data?.error || "Share update failed."); + await loadCloudShares(); + } + function isBlank(value) { return String(value ?? "").trim() === ""; } @@ -5446,6 +5990,7 @@ A+B const validationError = validatePayload(payload); if (validationError) throw new Error(validationError); } catch (error) { + saveFailedExample(payload || lastPayload, error, { stage: "designed_analysis_validation", workflow: "analysis" }); setStatus(`${t("status_error")}: ${error.message}`, true); return; } @@ -5482,6 +6027,7 @@ A+B results.innerHTML = `

${t("status_canceled")}

${String(error)}
`; setStatus(formatStatus(t("status_canceled"))); } else { + saveFailedExample(payload, error, { stage: "designed_analysis_job", jobId: currentJobId }); results.innerHTML = `

${t("request_failed")}

${String(error)}
`; setStatus(t("status_error"), true); } @@ -5491,6 +6037,7 @@ A+B currentJobId = null; cancellationRequested = false; updateRunButtonState(); + void refreshAccountWorkspace(); } } @@ -5502,6 +6049,7 @@ A+B const payload = buildPayload(); const validationError = validatePayload(payload); if (validationError) { + saveFailedExample(payload, new Error(validationError), { stage: "validation" }); results.innerHTML = `

${t("request_failed")}

${validationError}
`; setStatus(t("status_error"), true); return; @@ -5539,6 +6087,7 @@ A+B results.innerHTML = `

${t("status_canceled")}

${String(error)}
`; setStatus(formatStatus(t("status_canceled"))); } else { + saveFailedExample(payload, error, { stage: "job", jobId: currentJobId }); results.innerHTML = `

${t("request_failed")}

${String(error)}
`; setStatus(t("status_error"), true); } @@ -5548,6 +6097,7 @@ A+B currentJobId = null; cancellationRequested = false; updateRunButtonState(); + void refreshAccountWorkspace(); } } @@ -5771,10 +6321,11 @@ A+B window.open("/design-guide.html", "_blank", "noopener"); }); document.getElementById("clearHistoryBtn").addEventListener("click", () => { - localStorage.removeItem(HISTORY_KEY); - renderHistory(); + refreshAccountWorkspace(); + }); + document.getElementById("exportHistoryBtn").addEventListener("click", () => { + exportHistory().catch((error) => setStatus(`${t("status_error")}: ${error.message}`, true)); }); - document.getElementById("exportHistoryBtn").addEventListener("click", exportHistory); document.getElementById("importHistoryBtn").addEventListener("click", () => historyImportInput.click()); historyImportInput.addEventListener("change", async () => { try { @@ -5785,6 +6336,24 @@ A+B historyImportInput.value = ""; } }); + let historyFilterTimer = null; + const applyHistoryFilters = () => { + clearTimeout(historyFilterTimer); + historyFilterTimer = setTimeout(() => { + historyOffset = 0; + loadCloudHistory().catch((error) => setStatus(error.message, true)); + }, 180); + }; + historySearch.addEventListener("input", applyHistoryFilters); + [historyStatus, historyWorkflow, historyMode, historyMaterial].forEach((node) => node.addEventListener("change", applyHistoryFilters)); + historyPrev.addEventListener("click", () => { + historyOffset = Math.max(0, historyOffset - HISTORY_PAGE_SIZE); + loadCloudHistory().catch((error) => setStatus(error.message, true)); + }); + historyNext.addEventListener("click", () => { + historyOffset += HISTORY_PAGE_SIZE; + loadCloudHistory().catch((error) => setStatus(error.message, true)); + }); runBtn.addEventListener("click", () => { if (activeSubmission) { cancelCurrentJob(); @@ -5793,6 +6362,7 @@ A+B runAnalysis(); }); document.getElementById("exampleBtn").addEventListener("click", loadExample); + exportFailedExamplesBtn.addEventListener("click", exportFailedExamples); workflowSelect.addEventListener("change", () => { syncWorkflow(); syncMode(); @@ -5804,7 +6374,10 @@ A+B results.addEventListener("click", handleResultAction); window.addEventListener("hashchange", applyNavigationHash); document.addEventListener("visibilitychange", () => { - if (document.visibilityState === "visible") refreshHealth(); + if (document.visibilityState === "visible") { + refreshHealth(); + refreshAccountWorkspace(); + } }); addStrandRow({ name: "A", sequence: "AGUCUAGGAU", concentration: 1.0, unit: "uM" }); @@ -5827,7 +6400,10 @@ A+B startHealthPolling(); applyTranslations(); applyNavigationHash(); - loadSharedHistoryFromUrl(); + const isShareView = /^\/share\/[A-Za-z0-9_-]{8,128}$/.test(window.location.pathname) + || new URLSearchParams(window.location.search).has("share"); + if (isShareView) loadSharedHistoryFromUrl(); + else initializeAccount(); diff --git a/service/server.py b/service/server.py index 771724e..f939cf5 100644 --- a/service/server.py +++ b/service/server.py @@ -14,7 +14,7 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path import subprocess import tempfile -from urllib.parse import parse_qs, urlparse +from urllib.parse import parse_qs, urlencode, urlparse from uuid import uuid4 import numpy @@ -45,6 +45,7 @@ from nupack import ( tube_design, ) from nupack import config as nupack_config +from account import AccountStore, OIDCAuth from split_strand_svg import render_split_strands_svg try: @@ -70,6 +71,7 @@ JOB_RUNNING_KEY = os.environ.get("NP_JOB_RUNNING_KEY", "np_replica:jobs:running" WORKER_CONCURRENCY = int(os.environ.get("NP_WORKER_CONCURRENCY", "2")) PER_JOB_THREAD_LIMIT = int(os.environ.get("NP_PER_JOB_THREAD_LIMIT", "1")) NUPACK_CACHE_GB = float(os.environ.get("NP_NUPACK_CACHE_GB", "2.0")) +ACCOUNT_DB_PATH = os.environ.get("NP_ACCOUNT_DB_PATH", "/data/np-replica.sqlite3") UNIT_SCALE = { "M": 1.0, @@ -88,6 +90,7 @@ TERMINAL_JOB_STATUSES = {"success", "error", CANCELED_STATUS} JOB_STORE = {} JOB_LOCK = threading.Lock() JOB_TTL_SECONDS = int(os.environ.get("NP_JOB_TTL_SECONDS", "3600")) +JOB_HEARTBEAT_SECONDS = max(1, int(os.environ.get("NP_JOB_HEARTBEAT_SECONDS", "30"))) JOB_MAX_COUNT = int(os.environ.get("NP_JOB_MAX_COUNT", "64")) SHARE_STORE = {} SHARE_LOCK = threading.Lock() @@ -96,6 +99,8 @@ SHARE_MAX_BYTES = int(os.environ.get("NP_SHARE_MAX_BYTES", str(12 * 1024 * 1024) SHARE_KEY_PREFIX = os.environ.get("NP_SHARE_KEY_PREFIX", "np_replica:shares") SHARE_INDEX_KEY = f"{SHARE_KEY_PREFIX}:index" REDIS_CLIENT = None +ACCOUNT_STORE = AccountStore(ACCOUNT_DB_PATH) +OIDC_AUTH = None def json_bytes(payload, status=HTTPStatus.OK): @@ -151,6 +156,9 @@ def redis_client(): return REDIS_CLIENT +OIDC_AUTH = OIDCAuth(redis_client) + + def queue_size(): if not redis_enabled(): with JOB_LOCK: @@ -1562,7 +1570,14 @@ def prune_jobs(now=None): def set_job_data(job): if redis_enabled(): - redis_client().setex(job_key(job["job_id"]), JOB_TTL_SECONDS, json.dumps(job, ensure_ascii=False)) + client = redis_client() + key = job_key(job["job_id"]) + encoded = json.dumps(job, ensure_ascii=False) + if job.get("status") in TERMINAL_JOB_STATUSES: + client.setex(key, JOB_TTL_SECONDS, encoded) + else: + # Active jobs may legitimately outlive the result-retention TTL. + client.set(key, encoded) return with JOB_LOCK: prune_jobs(job.get("updated_at")) @@ -1613,7 +1628,11 @@ def update_job_data(job_id, **updates): current.update(updates) current["updated_at"] = time.time() pipe.multi() - pipe.setex(key, JOB_TTL_SECONDS, json.dumps(current, ensure_ascii=False)) + encoded = json.dumps(current, ensure_ascii=False) + if current.get("status") in TERMINAL_JOB_STATUSES: + pipe.setex(key, JOB_TTL_SECONDS, encoded) + else: + pipe.set(key, encoded) pipe.execute() return current except redis.WatchError: @@ -1634,11 +1653,12 @@ def update_job_data(job_id, **updates): return dict(current) -def create_job(payload): +def create_job(payload, owner): job_id = uuid4().hex now = time.time() job = { "job_id": job_id, + "user_id": owner["user_id"], "status": "queued", "error": None, "result": None, @@ -1646,8 +1666,9 @@ def create_job(payload): "updated_at": now, "payload": payload, } + ACCOUNT_STORE.create_job(job_id, owner, payload, status="queued", created_at=now) set_job_data(job) - log_event(f"accepted job_id={job_id} mode={payload.get('mode', 'tube')}") + log_event(f"accepted job_id={job_id} user_id={owner['user_id']} mode={payload.get('mode', 'tube')}") if redis_enabled(): redis_client().lpush(JOB_QUEUE_KEY, job_id) @@ -1688,14 +1709,17 @@ def terminate_process(process): def _run_job(job_id, payload): started_at = time.time() + last_heartbeat_at = started_at current = get_job_data(job_id) if current and current.get("status") in {CANCEL_REQUESTED_STATUS, CANCELED_STATUS}: elapsed = round(time.time() - started_at, 3) update_job_data(job_id, status=CANCELED_STATUS, payload=None, elapsed_seconds=elapsed) + ACCOUNT_STORE.update_job(job_id, CANCELED_STATUS, error={"message": "Job canceled by user."}, elapsed_seconds=elapsed) return log_event(f"running job_id={job_id}") update_job_data(job_id, status="running") + ACCOUNT_STORE.update_job(job_id, "running") if redis_enabled(): redis_client().sadd(JOB_RUNNING_KEY, job_id) @@ -1719,8 +1743,15 @@ def _run_job(job_id, payload): payload=None, elapsed_seconds=elapsed, ) + ACCOUNT_STORE.update_job( + job_id, CANCELED_STATUS, error={"message": "Job canceled by user."}, elapsed_seconds=elapsed + ) log_event(f"canceled job_id={job_id} elapsed={elapsed}s") return + now = time.time() + if now - last_heartbeat_at >= JOB_HEARTBEAT_SECONDS: + update_job_data(job_id, heartbeat_at=now) + last_heartbeat_at = now try: message = result_queue.get_nowait() break @@ -1745,30 +1776,38 @@ def _run_job(job_id, payload): payload=None, elapsed_seconds=elapsed, ) + ACCOUNT_STORE.update_job( + job_id, CANCELED_STATUS, error={"message": "Job canceled by user."}, elapsed_seconds=elapsed + ) log_event(f"canceled job_id={job_id} elapsed={elapsed}s") return elapsed = round(time.time() - started_at, 3) if message and message.get("status") == "success": update_job_data(job_id, status="success", result=message["result"], payload=None, elapsed_seconds=elapsed) + ACCOUNT_STORE.update_job(job_id, "success", result=message["result"], elapsed_seconds=elapsed) log_event(f"success job_id={job_id} elapsed={elapsed}s") elif message and message.get("status") == "error": + error = message.get("error") or {"message": "Job failed."} update_job_data( job_id, status="error", - error=message.get("error") or {"message": "Job failed."}, + error=error, payload=None, elapsed_seconds=elapsed, ) + ACCOUNT_STORE.update_job(job_id, "error", error=error, elapsed_seconds=elapsed) log_event(f"error job_id={job_id} elapsed={elapsed}s message={message.get('error', {}).get('message')}") else: + error = {"message": f"Job process exited with code {process.exitcode}."} update_job_data( job_id, status="error", - error={"message": f"Job process exited with code {process.exitcode}."}, + error=error, payload=None, elapsed_seconds=elapsed, ) + ACCOUNT_STORE.update_job(job_id, "error", error=error, elapsed_seconds=elapsed) log_event(f"error job_id={job_id} elapsed={elapsed}s exitcode={process.exitcode}") finally: terminate_process(process) @@ -1799,12 +1838,14 @@ def cancel_job(job_id): result=None, payload=None, ) + ACCOUNT_STORE.update_job(job_id, CANCELED_STATUS, error={"message": "Job canceled by user."}) else: updated = update_job_data( job_id, status=CANCEL_REQUESTED_STATUS, error={"message": "Cancellation requested."}, ) + ACCOUNT_STORE.update_job(job_id, CANCEL_REQUESTED_STATUS, error={"message": "Cancellation requested."}) updated.pop("payload", None) return updated @@ -1882,11 +1923,53 @@ def get_share(share_id): return dict(item) if item else None +def recover_interrupted_jobs(): + client = redis_client() + recovered = 0 + canceled = 0 + for job_id in client.smembers(JOB_RUNNING_KEY): + raw = client.get(job_key(job_id)) + if raw is None: + client.srem(JOB_RUNNING_KEY, job_id) + continue + job = json.loads(raw) + status = job.get("status") + if status == "running": + client.lrem(JOB_QUEUE_KEY, 0, job_id) + update_job_data( + job_id, + status="queued", + recovered_at=time.time(), + recovery_count=int(job.get("recovery_count", 0)) + 1, + ) + client.lpush(JOB_QUEUE_KEY, job_id) + ACCOUNT_STORE.update_job(job_id, "queued") + recovered += 1 + elif status == CANCEL_REQUESTED_STATUS: + update_job_data( + job_id, + status=CANCELED_STATUS, + error={"message": "Job canceled while the worker was restarting."}, + result=None, + payload=None, + ) + ACCOUNT_STORE.update_job( + job_id, + CANCELED_STATUS, + error={"message": "Job canceled while the worker was restarting."}, + ) + canceled += 1 + client.srem(JOB_RUNNING_KEY, job_id) + if recovered or canceled: + log_event(f"recovered jobs queued={recovered} canceled={canceled}") + + def run_worker_loop(): if not redis_enabled(): raise RuntimeError("Worker mode requires NP_REDIS_URL and the redis package.") client = redis_client() + recover_interrupted_jobs() worker_count = max(1, WORKER_CONCURRENCY) log_event( f"Starting worker loop on Redis queue {JOB_QUEUE_KEY} " @@ -2072,18 +2155,101 @@ def get_example_payload(query): class AppHandler(BaseHTTPRequestHandler): server_version = "NPReplica/0.1" + def _session(self): + if not hasattr(self, "_cached_session"): + self._cached_session = OIDC_AUTH.current_session(self.headers) + if self._cached_session: + ACCOUNT_STORE.upsert_user(self._cached_session["user"]) + return self._cached_session + + def _require_user(self): + session = self._session() + if session is None: + self._respond(*json_bytes({"error": "Authentication required", "login_url": "/auth/login"}, status=HTTPStatus.UNAUTHORIZED)) + return None + return session["user"] + + def _read_json(self, max_bytes=16 * 1024 * 1024): + length = int(self.headers.get("Content-Length", "0")) + if length < 0 or length > max_bytes: + raise ValueError(f"Request payload is too large. Limit is {max_bytes} bytes.") + return json.loads(self.rfile.read(length).decode("utf-8")) + + def _redirect(self, location, *, cookie=None): + headers = {"Location": location} + if cookie: + headers["Set-Cookie"] = cookie + self._respond(HTTPStatus.FOUND, "text/plain; charset=utf-8", b"Redirecting", extra_headers=headers) + + def _require_page_user(self, next_path): + if self._session() is not None: + return True + self._redirect(f"/auth/login?{urlencode({'next': next_path})}") + return False + + def _owned_job(self, user, job_id, include_content=True): + if ACCOUNT_STORE.owner_id(job_id) != user["user_id"]: + return None + live = get_job(job_id) + if live is not None: + live.pop("user_id", None) + return live + return ACCOUNT_STORE.get_job(user["user_id"], job_id, include_content=include_content) + def do_GET(self): parsed = urlparse(self.path) - if parsed.path == "/": + if parsed.path == "/auth/login": + next_path = (parse_qs(parsed.query).get("next") or ["/"])[0] + try: + self._redirect(OIDC_AUTH.begin_login(next_path)) + except Exception as exc: + self._respond(*json_bytes({"error": f"Unable to start login: {exc}"}, status=HTTPStatus.BAD_GATEWAY)) + return + + if parsed.path == "/auth/callback": + try: + session_id, user, next_path = OIDC_AUTH.complete_login(parse_qs(parsed.query)) + ACCOUNT_STORE.upsert_user(user) + self._redirect(next_path, cookie=OIDC_AUTH.cookie_header(session_id)) + except Exception as exc: + log_event(f"OIDC callback failed: {exc}") + self._respond(*json_bytes({"error": f"Login failed: {exc}"}, status=HTTPStatus.BAD_REQUEST)) + return + + if parsed.path == "/auth/logout": + session = self._session() + try: + location = OIDC_AUTH.logout_url(session) + except Exception: + location = "/" + OIDC_AUTH.delete_session(session) + self._redirect(location, cookie=OIDC_AUTH.clear_cookie_header()) + return + + share_page = re.fullmatch(r"/share/([A-Za-z0-9_-]{8,128})", parsed.path) + if share_page: self._respond_file(INDEX_PATH, cache_control="public, max-age=60, stale-while-revalidate=86400") return + legacy_share = (parse_qs(parsed.query).get("share") or [""])[0] + if parsed.path == "/" and re.fullmatch(r"[A-Za-z0-9_-]{8,128}", legacy_share): + self._respond_file(INDEX_PATH, cache_control="public, max-age=60, stale-while-revalidate=86400") + return + + if parsed.path == "/": + if not self._require_page_user(self.path): + return + self._respond_file(INDEX_PATH, cache_control="private, no-cache") + return + if parsed.path in {"/favicon.svg", "/favicon.ico"}: self._respond_file(FAVICON_PATH, cache_control="public, max-age=86400") return if parsed.path == "/design-guide.html": + if not self._require_page_user(self.path): + return self._respond_file(GUIDE_PATH, cache_control="public, max-age=3600") return @@ -2108,13 +2274,53 @@ class AppHandler(BaseHTTPRequestHandler): ) return + if parsed.path == "/api/me": + user = self._require_user() + if user is None: + return + self._respond(*json_bytes({"status": "success", "user": user, "usage": ACCOUNT_STORE.usage(user["user_id"])})) + return + + if parsed.path == "/api/history": + user = self._require_user() + if user is None: + return + params = {key: values[0] for key, values in parse_qs(parsed.query).items() if values} + self._respond(*json_bytes({"status": "success", **ACCOUNT_STORE.list_jobs(user["user_id"], params)})) + return + + if parsed.path.startswith("/api/history/"): + user = self._require_user() + if user is None: + return + job_id = parsed.path.rsplit("/", 1)[-1] + job = ACCOUNT_STORE.get_job(user["user_id"], job_id, include_content=True) + if job is None: + self._respond(*json_bytes({"error": "History record not found"}, status=HTTPStatus.NOT_FOUND)) + return + self._respond(*json_bytes({"status": "success", "item": job})) + return + + if parsed.path == "/api/account/shares": + user = self._require_user() + if user is None: + return + self._respond(*json_bytes({"status": "success", "items": ACCOUNT_STORE.list_shares(user["user_id"])})) + return + if parsed.path == "/api/example": + user = self._require_user() + if user is None: + return self._respond(*json_bytes(get_example_payload(parsed.query))) return if parsed.path.startswith("/api/jobs/"): + user = self._require_user() + if user is None: + return job_id = parsed.path.rsplit("/", 1)[-1] - job = get_job(job_id) + job = self._owned_job(user, job_id) if job is None: self._respond(*json_bytes({"error": "Job not found"}, status=HTTPStatus.NOT_FOUND)) return @@ -2123,7 +2329,7 @@ class AppHandler(BaseHTTPRequestHandler): if parsed.path.startswith("/api/shares/"): share_id = parsed.path.rsplit("/", 1)[-1] - share = get_share(share_id) + share = ACCOUNT_STORE.resolve_share(share_id) or get_share(share_id) if share is None: self._respond(*json_bytes({"error": "Share not found"}, status=HTTPStatus.NOT_FOUND)) return @@ -2134,75 +2340,78 @@ class AppHandler(BaseHTTPRequestHandler): def do_POST(self): parsed = urlparse(self.path) - - if parsed.path != "/api/analyze": + user = self._require_user() + if user is None: + return + try: if parsed.path.startswith("/api/jobs/") and parsed.path.endswith("/cancel"): job_id = parsed.path.split("/")[-2] + if ACCOUNT_STORE.owner_id(job_id) != user["user_id"]: + self._respond(*json_bytes({"error": "Job not found"}, status=HTTPStatus.NOT_FOUND)) + return job = cancel_job(job_id) if job is None: self._respond(*json_bytes({"error": "Job not found"}, status=HTTPStatus.NOT_FOUND)) return + job.pop("user_id", None) self._respond(*json_bytes({"status": "success", "job": job})) return - if parsed.path == "/api/jobs": - try: - length = int(self.headers.get("Content-Length", "0")) - raw = self.rfile.read(length) - payload = json.loads(raw.decode("utf-8")) - job_id = create_job(payload) - self._respond(*json_bytes({"status": "accepted", "job_id": job_id}, status=HTTPStatus.ACCEPTED)) - except Exception as exc: - self._respond( - *json_bytes( - { - "status": "error", - "error": str(exc), - "traceback": traceback.format_exc(), - }, - status=HTTPStatus.BAD_REQUEST, - ) - ) - return - if parsed.path == "/api/shares": - try: - length = int(self.headers.get("Content-Length", "0")) - if length > SHARE_MAX_BYTES: - raise ValueError(f"Share payload is too large. Limit is {SHARE_MAX_BYTES} bytes.") - raw = self.rfile.read(length) - record = json.loads(raw.decode("utf-8")) - share = create_share(record) - self._respond( - *json_bytes( - { - "status": "success", - "share_id": share["id"], - "url": f"/?share={share['id']}", - "max_count": SHARE_MAX_COUNT, - }, - status=HTTPStatus.CREATED, - ) - ) - except Exception as exc: - self._respond( - *json_bytes( - { - "status": "error", - "error": str(exc), - "traceback": traceback.format_exc(), - }, - status=HTTPStatus.BAD_REQUEST, - ) - ) - return - self._respond(*json_bytes({"error": "Not found"}, status=HTTPStatus.NOT_FOUND)) - return - try: - length = int(self.headers.get("Content-Length", "0")) - raw = self.rfile.read(length) - payload = json.loads(raw.decode("utf-8")) - result = run_job_payload(payload) - self._respond(*json_bytes({"status": "success", "result": result})) + if parsed.path.startswith("/api/jobs/") and parsed.path.endswith("/shares"): + job_id = parsed.path.split("/")[-2] + body = self._read_json() + expires_in = body.get("expires_in") + if expires_in not in {None, "", 0}: + expires_in = int(expires_in) + if expires_in < 60: + raise ValueError("Share duration must be at least 60 seconds.") + else: + expires_in = None + share = ACCOUNT_STORE.create_share(user["user_id"], job_id, expires_in) + self._respond(*json_bytes({"status": "success", "share": share, "url": f"/share/{share['share_id']}"}, status=HTTPStatus.CREATED)) + return + + if parsed.path == "/api/jobs": + payload = self._read_json() + job_id = create_job(payload, user) + self._respond(*json_bytes({"status": "accepted", "job_id": job_id}, status=HTTPStatus.ACCEPTED)) + return + + if parsed.path == "/api/history/import": + body = self._read_json(max_bytes=64 * 1024 * 1024) + entries = body.get("history") if isinstance(body, dict) else None + if not isinstance(entries, list): + raise ValueError("History import requires a history array.") + imported = ACCOUNT_STORE.import_history(user, entries) + self._respond(*json_bytes({"status": "success", "imported": imported})) + return + + if parsed.path == "/api/shares": + body = self._read_json() + job_id = str(body.get("job_id") or "") + if not job_id: + raise ValueError("Cloud shares require a job_id.") + share = ACCOUNT_STORE.create_share(user["user_id"], job_id, body.get("expires_in")) + self._respond(*json_bytes({"status": "success", "share_id": share["share_id"], "url": f"/share/{share['share_id']}"}, status=HTTPStatus.CREATED)) + return + + if parsed.path == "/api/analyze": + payload = self._read_json() + job_id = uuid4().hex + started = time.time() + ACCOUNT_STORE.create_job(job_id, user, payload, status="running", created_at=started) + try: + result = run_job_payload(payload) + except Exception as exc: + elapsed = round(time.time() - started, 3) + ACCOUNT_STORE.update_job(job_id, "error", error={"message": str(exc), "traceback": traceback.format_exc()}, elapsed_seconds=elapsed) + raise + elapsed = round(time.time() - started, 3) + ACCOUNT_STORE.update_job(job_id, "success", result=result, elapsed_seconds=elapsed) + self._respond(*json_bytes({"status": "success", "job_id": job_id, "result": result})) + return + + self._respond(*json_bytes({"error": "Not found"}, status=HTTPStatus.NOT_FOUND)) except Exception as exc: self._respond( *json_bytes( @@ -2215,6 +2424,53 @@ class AppHandler(BaseHTTPRequestHandler): ) ) + def do_PATCH(self): + parsed = urlparse(self.path) + user = self._require_user() + if user is None: + return + try: + if parsed.path.startswith("/api/account/shares/"): + share_id = parsed.path.rsplit("/", 1)[-1] + body = self._read_json() + expires_in = body["expires_in"] if "expires_in" in body else "unchanged" + share = ACCOUNT_STORE.update_share( + user["user_id"], share_id, active=body.get("active"), expires_in=expires_in + ) + if share is None: + self._respond(*json_bytes({"error": "Share not found"}, status=HTTPStatus.NOT_FOUND)) + return + self._respond(*json_bytes({"status": "success", "share": share})) + return + self._respond(*json_bytes({"error": "Not found"}, status=HTTPStatus.NOT_FOUND)) + except Exception as exc: + self._respond(*json_bytes({"status": "error", "error": str(exc)}, status=HTTPStatus.BAD_REQUEST)) + + def do_DELETE(self): + parsed = urlparse(self.path) + user = self._require_user() + if user is None: + return + try: + if parsed.path.startswith("/api/history/"): + job_id = parsed.path.rsplit("/", 1)[-1] + if not ACCOUNT_STORE.delete_job(user["user_id"], job_id): + self._respond(*json_bytes({"error": "History record not found"}, status=HTTPStatus.NOT_FOUND)) + return + self._respond(*json_bytes({"status": "success"})) + return + if parsed.path.startswith("/api/account/shares/"): + share_id = parsed.path.rsplit("/", 1)[-1] + share = ACCOUNT_STORE.update_share(user["user_id"], share_id, active=False) + if share is None: + self._respond(*json_bytes({"error": "Share not found"}, status=HTTPStatus.NOT_FOUND)) + return + self._respond(*json_bytes({"status": "success", "share": share})) + return + self._respond(*json_bytes({"error": "Not found"}, status=HTTPStatus.NOT_FOUND)) + except Exception as exc: + self._respond(*json_bytes({"status": "error", "error": str(exc)}, status=HTTPStatus.BAD_REQUEST)) + def log_message(self, format_, *args): print(f"{self.address_string()} - {format_ % args}") @@ -2245,6 +2501,7 @@ class AppHandler(BaseHTTPRequestHandler): def main(): apply_thread_limits() + ACCOUNT_STORE.initialize() if RUN_MODE == "worker": run_worker_loop() return