diff --git a/.dockerignore b/.dockerignore index 56a001e..26ff4a1 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,5 +1,8 @@ .git .gitignore +.env +.sisyphus/ +ltest.sh __pycache__ *.pyc *.pyo @@ -12,9 +15,9 @@ nupack-history-*.json NUPACK.html NUPACK_files 1.png -nupack/vendor/nupack-4.0.2.0/source -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 +nupack/vendor/nupack-4.1.0.1/source +nupack/vendor/nupack-4.1.0.1/package/*.whl +!nupack/vendor/nupack-4.1.0.1/package/nupack-4.1.0.1-cp312-cp312-linux_x86_64.whl rna/* !rna/ViennaRNA-2.7.2.tar.gz runtime diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..0429268 --- /dev/null +++ b/.env.example @@ -0,0 +1,4 @@ +# Admin password. Generate with: openssl rand -hex 24 +NP_ADMIN_TOKEN=replace-with-a-random-password +NP_ADMIN_SESSION_TTL_SECONDS=28800 +NP_ADMIN_COOKIE_SECURE=1 diff --git a/.gitignore b/.gitignore index 013ae97..116e2b2 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,9 @@ __pycache__/ .pytest_cache/ .mypy_cache/ .DS_Store +.env +.sisyphus/ +ltest.sh # Local exports and captured reference site assets are not needed to rebuild the service. nupack-history-*.json @@ -12,10 +15,9 @@ NUPACK_files/ # The Docker build uses the vendored cp312 Linux x86_64 wheel plus the ViennaRNA tarball. rna/ViennaRNA-2.7.2/ -rna/nupack-4.0.2.0.zip -nupack/vendor/nupack-4.0.2.0/source/ -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/nupack-*.zip +nupack/vendor/nupack-4.1.0.1/source/ +nupack/vendor/nupack-4.1.0.1/package/*.whl # Runtime state mounted from Docker containers. runtime/redis/ runtime/account/ diff --git a/Dockerfile b/Dockerfile index 0fbd9cc..9eef2eb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -18,7 +18,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ perl \ && rm -rf /var/lib/apt/lists/* -COPY nupack/vendor/nupack-4.0.2.0/package /tmp/nupack-package +COPY nupack/vendor/nupack-4.1.0.1/package /tmp/nupack-package COPY rna/ViennaRNA-2.7.2.tar.gz /tmp/ViennaRNA-2.7.2.tar.gz RUN cd /tmp \ @@ -36,10 +36,15 @@ RUN python -m pip install --no-cache-dir \ 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 + /tmp/nupack-package/nupack-4.1.0.1-cp312-cp312-linux_x86_64.whl RUN python -m pip install --no-cache-dir redis==7.4.0 +ARG NP_APP_VERSION=v5.4-nupack4.1.0.1 +LABEL org.opencontainers.image.title="NP Replica" \ + org.opencontainers.image.version="${NP_APP_VERSION}" \ + org.opencontainers.image.description="NUPACK 4.1 mixed-material analysis and design service" + WORKDIR /app/service COPY service /app/service diff --git a/README.md b/README.md index 3c5042c..0967b0d 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,28 @@ # NP Replica -This project provides a local Chinese web replica of the core NUPACK `analysis/input` workflow. +This project provides a local Chinese web interface for the official NUPACK Analysis, Design, and Utilities workflows. + +## v5.4 · NUPACK 4.1 mixed-material Design + +The `v5.4-nupack4.1.0.1` image extends mixed-material support through the complete Design workflow: + +- RNA/DNA and RNA/2′OMe-RNA Domain constraints using explicit `r`, `d`, `m`, and wildcard `w` prefixes; +- material-aware IUPAC parsing, fixed-domain reverse complements, and true nucleotide lengths; +- mixed-material Pattern, Similarity, Window, and Library sequence inputs; +- a mixed-segment preset, dynamic syntax guidance, and client-side validation in the Design editor; +- material prefixes preserved in designed Domains, strands, reproducibility exports, and Analysis handoff; +- Docker version metadata moved after dependency layers so version-only rebuilds reuse the expensive NUPACK/ViennaRNA layers. + +## v5.3 · NUPACK 4.1 mixed-material analysis + +The `v5.3-nupack4.1.0.1` image adds: + +- material-aware salt defaults and server-side physical-range validation; +- RNA/DNA (`rna-dna06`) and RNA/2′OMe-RNA (`rna-merna06`) Analysis and Utilities inputs using `r`, `d`, and `m` prefixes; +- NUPACK version and resolved parameter-set provenance in results; +- `indistinguishable_search` and `max_subopt_count` controls for MFE/Subopt; +- per-complex analysis bonus support. + ## Run @@ -10,12 +32,109 @@ docker compose up --build -d Open `http://127.0.0.1:18765`. +Application pages: + +- `/`: operational overview +- `/workspace`: calculation workspace +- `/cloud`: account jobs and shares +- `/account`: identity and usage + +The system design and deployment boundaries are documented in +[`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md). + ## Reverse Proxy 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. +## Deploying the independent np instance + +`np.lihato.icu` runs independently on `100.64.0.11`. Do not copy the `npt` +OIDC values unchanged. In the `np-replica` service of the +`docker-compose.yml` deployed on `.11`, use: + +```yaml +ports: + - "100.64.0.11:18765:18765" +environment: + NP_AUTH_REQUIRED: 1 + 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 + NP_OIDC_POST_LOGOUT_URI: https://np.lihato.icu/ +``` + +The `NP_OIDC_*` variables belong on the web service (`np-replica`); the +worker does not perform browser login. Preserve the calculation settings on +both application containers. The recommended portable defaults are +`NP_WORKER_CONCURRENCY=auto`, `NP_PER_JOB_THREAD_LIMIT=4`, +`NP_WORKER_MEMORY_RESERVE_GB=4.0`, `NP_ESTIMATED_JOB_MEMORY_GB=9.0`, +and `NP_NUPACK_CACHE_GB=8.0`. + +After editing the Compose file on `.11`, recreate the web container: + +```bash +docker compose up -d --force-recreate np-replica +``` + +If the deployed image predates the account/OIDC changes, rebuild both +application containers instead: + +```bash +docker compose up -d --build --force-recreate np-replica np-worker +``` + +Verify the result: + +```bash +curl -I https://np.lihato.icu/ +curl -I 'https://np.lihato.icu/auth/login?next=%2F' +``` + +The first response should redirect to `/auth/login?next=%2F`. The second +`Location` header must contain all of the following: + +- `client_id=np-replica-web` +- `redirect_uri=https%3A%2F%2Fnp.lihato.icu%2Fauth%2Fcallback` +- `code_challenge_method=S256` + +If it contains `npt-replica-web` or `npt.lihato.icu`, the `.11` container +is still running with the wrong environment and must be recreated. + +## 容器资源配置 + +当前 `docker-compose.yml` 可随 CPU 和容器内存限制自动调整 worker 并发,服务拆成三个容器: + +- `redis`:只保存实时队列、运行状态和会话缓存,限制为 `mem_limit: 3g`。Redis 开启 AOF,数据写入 `./runtime/redis`。 +- `np-replica`:Web/API 容器,限制为 `mem_limit: 4g`,负责页面、登录、历史记录、任务提交和状态查询。它不应该承担大规模计算。 +- `np-worker`:后台计算容器,限制为 `mem_limit: 56g`,负责实际 NUPACK 计算。大任务应该由这个容器消耗 CPU 和内存。 + +计算相关环境变量需要在 `np-replica` 和 `np-worker` 中保持一致: + +- `NP_WORKER_CONCURRENCY=auto`:根据容器可用 CPU、每任务线程数、内存限制、预留内存和估算的单任务内存,自动选择同时运行的任务数。也可填正整数进行手动覆盖。 +- `NP_PER_JOB_THREAD_LIMIT=4`:单个任务最多使用 4 个 native 计算线程,同时写入 `OMP_NUM_THREADS`、`OPENBLAS_NUM_THREADS`、`MKL_NUM_THREADS`、`NUMEXPR_NUM_THREADS`、`VECLIB_MAXIMUM_THREADS`、`GOTO_NUM_THREADS`,并设置 `nupack.config.threads`。 +- `NP_NUPACK_CACHE_GB=8.0`:单个 NUPACK 进程可使用的缓存上限。 +- `NP_WORKER_MEMORY_GB=0`:为 0 时自动读取 cgroup 内存限制或系统内存;在特殊运行环境中可填实际可用 GB 数覆盖检测值。 +- `NP_WORKER_MEMORY_RESERVE_GB=4.0`:不给计算任务使用的内存,留给系统、Redis、Web 服务和运行时开销。 +- `NP_ESTIMATED_JOB_MEMORY_GB=9.0`:调度器用于计算并发的单任务内存预算。根据其他设备上观测到的峰值灵活调整;它不限制单任务实际内存。 +- `NP_JOB_TTL_SECONDS=3600`:Redis 中实时任务状态的保留时间;长期历史记录写入 SQLite。 + +`auto` 取 CPU 容量 `有效 CPU 数 / 单任务线程数` 与内存容量 +`(worker 内存 - 预留内存) / 单任务内存估算` 中较小者。CPU 检测会考虑 +进程 affinity 和 cgroup quota,内存检测会考虑 cgroup limit,因此同一份配置可迁移到 +不同设备。若出现 OOM 或交换分区抖动,提高 `NP_ESTIMATED_JOB_MEMORY_GB` 或直接降低 +`NP_WORKER_CONCURRENCY`;空闲资源很多时则可谨慎反向调整。`/health` 会返回 worker +最终采用的资源计划。 + +增加线程不会保证单个 design 等比例提速:NUPACK 的部分设计阶段实际上主要使用一个 +核心,线程更适合底层可并行计算;同时运行多个独立任务通常更容易利用多核。增加内存也 +不会直接缩短搜索,只能减少 OOM、换页和缓存压力。当前 NUPACK 4.1.0.1 wheel 只暴露 CPU +线程配置,不能通过环境变量直接调用 GPU;CUDA 加速需要重新构建或替换 NUPACK 核心 +wheel,并重新做数值一致性验证。 + +设计任务默认使用官方行为:固定序列 target 也参与 `tube_design` / `complex_design`。如果确认某些 target 完全固定、只需要展示结果、不需要参与优化,可在页面中把 “固定目标策略” 改为 “剥离固定目标以提速”,这样会减少 off-target 集合和优化搜索量。 + ## Endpoints - `GET /` @@ -25,6 +144,35 @@ Traefik only performs TLS termination, compression, and reverse proxying for `np - `POST /api/jobs` - `GET /api/jobs/` +Submitting an identical active payload for the same account returns +`409 duplicate`. The Workspace asks the user whether to cancel or explicitly +submit another calculation through `POST /api/jobs?force=1`. Fingerprints are +account-scoped, so different users are never deduplicated against one another. + +## Admin Login + +Admin uses a dedicated password and does not require OIDC. Generate a strong +password and store it in the ignored `.env`: + +```bash +openssl rand -hex 24 +``` + +```dotenv +NP_ADMIN_TOKEN=replace-with-the-generated-password +``` + +After deployment, open `https:///admin` from the Account page or directly. +The password is submitted in a JSON `POST /api/admin/login` request and never +appears in the URL. A successful login sets a random, short-lived HttpOnly +session cookie and redirects to `/admin/panel`. + +Admin can query all task metadata, stop active jobs, move terminal jobs to the +trash, restore them, permanently delete trashed jobs, and configure trash +retention (two days by default). Normal account deletion is a soft delete and +also disables that job's public shares. Set `NP_ADMIN_SESSION_TTL_SECONDS` to +control session duration and keep `NP_ADMIN_COOKIE_SECURE=1` on HTTPS deployments. + ## Persistent Shares @@ -32,16 +180,23 @@ Live jobs and sessions are stored in Redis when `NP_REDIS_URL` is enabled. Accou ## Notes -- `tube` mode supports strand concentrations and `SetSpec(max_size=...)`. +- `tube` mode supports strand concentrations and the official `SetSpec(max_size=..., include=..., exclude=...)` semantics. - `complex` mode supports explicit complex definitions such as `A+B`. - Pair matrices are returned as a preview block to keep responses manageable. +- Tube `pairs` now runs inside the same official `tube_analysis` call as the other selected calculations, including `ensemble_pair_fractions` and `fraction_bases_unpaired`. +- `result_limit` limits display and structure plotting only; it does not silently reduce the Tube calculation ensemble. +- Analysis and design results include phase timings and workload diagnostics to explain long-running jobs without changing thermodynamic results. +- Design target tubes support per-tube `SetSpec(max_size/include/exclude)`, target-complex bonuses, weighted-objective trial selection, and multi-tube result tables. +- Utilities directly exposes the official `pfunc`, `structure_energy`, `structure_probability`, `sample`, `pairs`, `mfe`, `subopt`, `ensemble_size`, `des`, `defect`, `seq_distance`, and `struc_distance` calls. +- Active calculations write only small stage-transition records. The result panel performs one status request per manual refresh and does not poll or serialize NUPACK intermediate results. +- Identical active submissions from the same account reuse the existing job instead of consuming another worker slot. - 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 supports Chinese and English switching, account-scoped cloud history, result export, share controls, and async polling for submitted jobs. +- The web UI supports Chinese and English switching, account-scoped cloud history, result export, share controls, and manually refreshed async job status. - 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. -- Worker concurrency is controlled by `NP_WORKER_CONCURRENCY` and defaults to `2`. +- Worker concurrency is controlled by `NP_WORKER_CONCURRENCY` and defaults to `auto`; a positive integer remains available as an explicit override. - Per-job native thread usage is capped by `NP_PER_JOB_THREAD_LIMIT` and defaults to `1` to prevent a single analysis from monopolizing the machine. - `NP_STRUCTURE_PLOT_MODE` supports `auto` (default), `split`, and `rnaplot`. - Static pages are served with ETag-based browser caching; JSON API responses remain uncached. @@ -52,18 +207,3 @@ Live jobs and sessions are stored in Redis when `NP_REDIS_URL` is enabled. Accou - 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. - - - - 现在 `docker-compose.yml` 面向 64 核 / 64G WSL 服务器的并发策略是: - - - 最多同时跑 16 个任务 - - 每个任务最多用 4 个计算线程 - - 后续任务进入队列等待 - - 每个任务的 NUPACK 缓存上限为 8 GB - - 理论上最多占用约 64 个 native 计算线程 - - 如果后面你发现机器还会被压满,最直接的调法就是在 docker-compose.yml 里继续压: - - - 把 NP_WORKER_CONCURRENCY 改小 - - 或保持并发不变,把 NP_PER_JOB_THREAD_LIMIT 改成 2 或 1 diff --git a/docker-compose.yml b/docker-compose.yml index b1bd892..5496061 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,13 +3,13 @@ services: image: redis:7.4.8-bookworm container_name: np-redis restart: always - mem_limit: 2g + mem_limit: 3g command: ["redis-server", "--appendonly", "yes", "--appendfsync", "everysec", "--save", "60", "1"] volumes: - ./runtime/redis:/data np-replica: - image: np-app:v4.7 + image: 100.64.0.2:13000/lihato/np_app:v5.4-nupack4.1.0.1 build: context: . dockerfile: Dockerfile @@ -33,10 +33,15 @@ services: 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_ADMIN_TOKEN: ${NP_ADMIN_TOKEN:-} + NP_ADMIN_SESSION_TTL_SECONDS: ${NP_ADMIN_SESSION_TTL_SECONDS:-28800} + NP_ADMIN_COOKIE_SECURE: ${NP_ADMIN_COOKIE_SECURE:-1} NP_JOB_TTL_SECONDS: 3600 - NP_WORKER_CONCURRENCY: 16 + NP_WORKER_CONCURRENCY: auto NP_PER_JOB_THREAD_LIMIT: 4 NP_NUPACK_CACHE_GB: 8.0 + NP_WORKER_MEMORY_RESERVE_GB: 4.0 + NP_ESTIMATED_JOB_MEMORY_GB: 9.0 OMP_NUM_THREADS: 4 OPENBLAS_NUM_THREADS: 4 MKL_NUM_THREADS: 4 @@ -45,7 +50,7 @@ services: GOTO_NUM_THREADS: 4 np-worker: - image: np-app:v4.7 + image: 100.64.0.2:13000/lihato/np_app:v5.4-nupack4.1.0.1 container_name: np-worker restart: always mem_limit: 56g @@ -58,9 +63,11 @@ services: 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_WORKER_CONCURRENCY: auto NP_PER_JOB_THREAD_LIMIT: 4 NP_NUPACK_CACHE_GB: 8.0 + NP_WORKER_MEMORY_RESERVE_GB: 4.0 + NP_ESTIMATED_JOB_MEMORY_GB: 9.0 OMP_NUM_THREADS: 4 OPENBLAS_NUM_THREADS: 4 MKL_NUM_THREADS: 4 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..ddf07f1 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,159 @@ +# NP Replica Architecture + +## Goals + +NP Replica separates user navigation, durable account data, live queue state, +and NUPACK compute processes. Scientific calculation code remains isolated from +page rendering and account administration so UI changes do not alter numerical +semantics. + +## Runtime Topology + +```text +Browser + | + | HTTPS / OIDC session + v +np-replica (Web/API, 4 GB) + | | + | live state | durable metadata/results + v v +Redis SQLite + | + | job queue + v +np-worker (NUPACK compute, 56 GB) +``` + +- **Web/API** serves the multi-page UI, validates ownership, accepts jobs, and + serializes API responses. It does not run Redis-backed NUPACK jobs. +- **Redis** owns queue order, active job state, heartbeats, duplicate claims, + sessions, and temporary result state. +- **SQLite** owns users, durable job inputs/results, usage totals, and shares. +- **Worker** consumes Redis jobs and runs NUPACK in isolated child processes. + +## Frontend Structure + +The UI uses a small multi-page application structure. Each page has one primary +operational purpose. + +| Route | File | Responsibility | +| --- | --- | --- | +| `/` | `service/home.html` | Queue overview, usage summary, recent jobs | +| `/workspace` | `service/index.html` | Analysis, Design, Utilities, result inspection | +| `/cloud` | `service/cloud.html` | Job search, JSON import/export, result links, cancellation, deletion, shares | +| `/account` | `service/account.html` | Identity, account usage, worker resource information | +| `/admin` | `service/admin-login.html` | Dedicated Admin password login | +| `/admin/panel` | `service/admin.html` | Cross-account task and trash management | + +`service/static/app-shell.css` provides the shared application shell and +responsive operational layout. `service/static/portal.js` contains shared +request, formatting, navigation, table, Cloud, Account, and Admin controllers. +The calculation workspace retains its self-contained renderer because it +contains specialized structure, heatmap, force-layout, and result-export code. + +## Backend Boundaries + +- `service/server.py` + - HTTP routes and authentication gates + - Redis live-job repository and queue + - resource planning and worker orchestration + - NUPACK request validation and calculation adapters +- `service/account.py` + - SQLite schema and durable repository + - account-scoped job/share queries + - cross-account Admin queries and trash lifecycle +- `service/split_strand_svg.py` + - structure rendering adapter + +The next safe backend extraction point is moving Redis job coordination into a +`job_repository.py` module. NUPACK adapters can then move into separate +`analysis_service.py`, `design_service.py`, and `utilities_service.py` +modules without changing HTTP contracts. + +## Job Submission And Duplicate Control + +Duplicate detection is scoped by `user_id` and a canonical SHA-256 hash of the +JSON payload. Different users never share duplicate state. + +```text +POST /api/jobs + | + +-- no active match --> 202 accepted + | + +-- active match ----> 409 duplicate + existing job metadata + | + +-- user cancels: no write + | + +-- user confirms: + POST /api/jobs?force=1 --> 202 accepted +``` + +Redis uses an atomic claim key. Terminal updates delete a claim only when it +still belongs to that job, preventing an older job from deleting a newer forced +submission's claim. The in-memory development backend mirrors this behavior +under `JOB_LOCK`. + +## Resource Planning + +`NP_WORKER_CONCURRENCY=auto` selects the smaller of: + +- effective CPU capacity divided by `NP_PER_JOB_THREAD_LIMIT`; +- usable worker memory divided by `NP_ESTIMATED_JOB_MEMORY_GB`. + +CPU detection considers process affinity and cgroup quota. Memory detection +uses an explicit override, cgroup memory limit, or host memory in that order. +The published plan is visible through `/health`. + +These controls affect scheduling only. They do not change models, sequence +constraints, complexes, thermodynamic parameters, or result serialization. + +## Authentication And Authorization + +- Normal pages and account APIs require an OIDC-backed session. +- Job, history, and share APIs enforce ownership by stable OIDC `user_id`. +- Public share routes expose only the selected shared record. +- Admin uses a dedicated password configured by `NP_ADMIN_TOKEN` and bypasses + OIDC. The password is accepted only in the JSON body of + `POST /api/admin/login`; it is never embedded in a route, redirect, cookie, or + local storage value. +- Successful login creates a random session in Redis (or process memory in + development) and sets a short-lived HttpOnly, SameSite=Strict cookie. +- Admin may list all durable job metadata, stop active jobs, move terminal jobs + to trash, restore jobs, permanently delete trashed jobs, and configure trash + retention. APIs do not return stored payload or result blobs. + +The Admin password must be random, kept in the ignored `.env`, and rotated after +exposure. Keep `NP_ADMIN_COOKIE_SECURE=1` in HTTPS deployments. + +## Trash Lifecycle + +Normal user deletion sets `deleted_at` and `purge_after` rather than deleting a +job row. Deleted jobs disappear from account history, usage, and result APIs; +all share records for that job are deleted. Admin can inspect the trash, restore a +job, or permanently delete it. Expired rows are purged lazily during job-list +reads. Retention is stored in SQLite `app_settings`, defaults to two days, and +can be changed from one hour to 365 days. + +## Persistence And Recovery + +- Redis AOF persists live queue state under `runtime/redis`. +- SQLite WAL persists account data under `runtime/account`. +- Worker recovery requeues jobs marked running after an interrupted worker. +- Active Redis job records do not expire while heartbeat updates continue; + terminal records use `NP_JOB_TTL_SECONDS`. + +Back up both runtime directories. SQLite is the durable source for the Cloud and +Admin pages; Redis is the source for current queue and heartbeat state. + +## Deployment + +1. Set `NP_ADMIN_TOKEN` in `.env`. +2. Validate with `docker compose config --quiet`. +3. Wait for active calculations to finish before recreating `np-worker`. +4. Build and recreate Web and Worker from the same source revision. +5. Verify `/health`, normal OIDC pages, duplicate confirmation, Admin password + login, task controls, and the trash lifecycle. + +The Web container may be recreated independently for page-only changes, but +backend contract changes should deploy Web and Worker from the same image. diff --git a/docs/FRONTEND_OPTIONS.md b/docs/FRONTEND_OPTIONS.md new file mode 100644 index 0000000..822fe93 --- /dev/null +++ b/docs/FRONTEND_OPTIONS.md @@ -0,0 +1,28 @@ +# Workbench Frontend Options + +## Decision + +The current service is a server-rendered, dependency-light HTML/CSS/JavaScript application. The first UI pass keeps that boundary: `service/index.html` still owns the existing calculation payload and result rendering, while `service/static/workspace-refresh.css` owns the workbench presentation layer. + +This keeps the account/OIDC flow and calculation API stable, avoids a frontend build pipeline, and makes it possible to replace the shell incrementally later. + +## Open-source shells that fit + +| Option | License | Fit for this service | Trade-off | +| --- | --- | --- | --- | +| [Tabler](https://tabler.io/) | MIT | Best direct fit. Static CSS/components, strong tables, forms, navigation, and responsive layout. It can be vendored and mounted without changing the Python service. | The existing large result renderer would still need custom components. | +| [CoreUI Free](https://coreui.io/) | MIT | Good if the application grows into a full operations console with user, storage, and admin areas. | More opinionated and heavier than the current single-page workbench. | +| [Apache ECharts](https://echarts.apache.org/) | Apache-2.0 | Good for the result dashboards and concentration/defect charts. | It is a visualization layer, not a complete application shell. | +| [shadcn/ui](https://ui.shadcn.com/) | MIT components | Excellent visual quality for a future React/Next.js rewrite. | Requires introducing a Node build, React, and a new frontend boundary. | + +## Recommended migration path + +1. Keep `/api/*`, OIDC, duplicate detection, job polling, cancel, and exports unchanged. +2. Vendor Tabler CSS and icons locally; use its shell, tabs, cards, tables, alerts, and progress components. +3. Split the current workbench into three route-level views: `Setup`, `Run`, and `Results`. Each view reads and writes one shared draft object. +4. Move the existing result renderers into isolated modules only after the new shell is stable. The computational payload stays a versioned JSON contract. +5. Add Playwright smoke coverage for authenticated navigation, draft persistence, submit/duplicate confirmation, polling, cancel, and result export. + +## Why not replace it in one step? + +The current `index.html` contains the complete NUPACK result viewer, structure graph, pair heatmap, design preflight, history, and export behavior. A template can replace the shell quickly, but replacing the whole file at once would risk changing calculation semantics and result interpretation. An incremental shell migration gives the visual improvement immediately and keeps the scientific behavior testable. diff --git a/design(2).txt b/docs/design(2).txt similarity index 100% rename from design(2).txt rename to docs/design(2).txt diff --git a/need.md b/docs/need.md similarity index 100% rename from need.md rename to docs/need.md diff --git a/绘图自动分割.txt b/docs/绘图自动分割.txt similarity index 100% rename from 绘图自动分割.txt rename to docs/绘图自动分割.txt diff --git a/nupack/vendor/nupack-4.0.2.0/README.txt b/nupack/vendor/nupack-4.0.2.0/README.txt deleted file mode 100644 index fdc97d9..0000000 --- a/nupack/vendor/nupack-4.0.2.0/README.txt +++ /dev/null @@ -1,9 +0,0 @@ -README file for NUPACK 4.0 -Copyright (c) 2003-2022. California Institute of Technology. All Rights Reserved. - -See LICENSE.txt file - -See NUPACK User Guide for installation instructions: -https://docs.nupack.org - -Technical support: support@nupack.org \ No newline at end of file diff --git a/nupack/vendor/nupack-4.0.2.0/LICENSE.txt b/nupack/vendor/nupack-4.1.0.1/LICENSE.txt similarity index 58% rename from nupack/vendor/nupack-4.0.2.0/LICENSE.txt rename to nupack/vendor/nupack-4.1.0.1/LICENSE.txt index ac3c03d..e5024cf 100644 --- a/nupack/vendor/nupack-4.0.2.0/LICENSE.txt +++ b/nupack/vendor/nupack-4.1.0.1/LICENSE.txt @@ -1,14 +1,15 @@ NUPACK Software License Agreement for Non-Commercial Academic Use -Copyright (c) 2003–2022. California Institute of Technology. All rights reserved. +Copyright (c) 2003-2026. California Institute of Technology. All rights reserved. Use of the NUPACK Python module and/or source code (“Software”) in source form and/or binary form, with or without modification, is permitted for non-commercial academic purposes only, subject to the conditions and disclaimer stated below. Conditions -1. Redistribution of the Software in source form and/or binary form is not permitted. -2. Web applications that use the Software in source form and/or binary form are not permitted. -3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote derivative works without specific prior written permission. +1. Redistribution of the Software or derivative works in source form and/or binary form is not permitted. +2. Web applications and/or graphical user interfaces that use the Software or derivative works in source form and/or binary form are not permitted. +3. Licensee is prohibited from using the Software to feed, train, validate, or prompt an artificial intelligence, machine learning, or automated system to create new code, translate the code into a different programming language, refactor the code, or produce derivative work. +4. Licensee maintains an active paid NUPACK subscription; License terminates and Software and all derivative works must be deleted if an active paid NUPACK subscription is not maintained. Disclaimer @@ -16,4 +17,5 @@ THE SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND A Contact -For any questions about this Software License Agreement please contact info@nupack.org \ No newline at end of file +For any questions about this Software License Agreement please contact info@nupack.org + diff --git a/nupack/vendor/nupack-4.1.0.1/README.txt b/nupack/vendor/nupack-4.1.0.1/README.txt new file mode 100644 index 0000000..dec7e75 --- /dev/null +++ b/nupack/vendor/nupack-4.1.0.1/README.txt @@ -0,0 +1,11 @@ +README file for NUPACK 4.1 Python Module +Copyright (c) 2003-2026. California Institute of Technology. All Rights Reserved. + +See LICENSE.txt file + +See NUPACK User Guide for installation instructions: +https://docs.nupack.org + +Technical support: support@nupack.org + +Source code: available upon request \ No newline at end of file diff --git a/nupack/vendor/nupack-4.0.2.0/package/LICENSE.txt b/nupack/vendor/nupack-4.1.0.1/package/LICENSE.txt similarity index 58% rename from nupack/vendor/nupack-4.0.2.0/package/LICENSE.txt rename to nupack/vendor/nupack-4.1.0.1/package/LICENSE.txt index ac3c03d..e5024cf 100644 --- a/nupack/vendor/nupack-4.0.2.0/package/LICENSE.txt +++ b/nupack/vendor/nupack-4.1.0.1/package/LICENSE.txt @@ -1,14 +1,15 @@ NUPACK Software License Agreement for Non-Commercial Academic Use -Copyright (c) 2003–2022. California Institute of Technology. All rights reserved. +Copyright (c) 2003-2026. California Institute of Technology. All rights reserved. Use of the NUPACK Python module and/or source code (“Software”) in source form and/or binary form, with or without modification, is permitted for non-commercial academic purposes only, subject to the conditions and disclaimer stated below. Conditions -1. Redistribution of the Software in source form and/or binary form is not permitted. -2. Web applications that use the Software in source form and/or binary form are not permitted. -3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote derivative works without specific prior written permission. +1. Redistribution of the Software or derivative works in source form and/or binary form is not permitted. +2. Web applications and/or graphical user interfaces that use the Software or derivative works in source form and/or binary form are not permitted. +3. Licensee is prohibited from using the Software to feed, train, validate, or prompt an artificial intelligence, machine learning, or automated system to create new code, translate the code into a different programming language, refactor the code, or produce derivative work. +4. Licensee maintains an active paid NUPACK subscription; License terminates and Software and all derivative works must be deleted if an active paid NUPACK subscription is not maintained. Disclaimer @@ -16,4 +17,5 @@ THE SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND A Contact -For any questions about this Software License Agreement please contact info@nupack.org \ No newline at end of file +For any questions about this Software License Agreement please contact info@nupack.org + diff --git a/nupack/vendor/nupack-4.0.2.0/package/nupack-4.0.2.0-cp312-cp312-linux_x86_64.whl b/nupack/vendor/nupack-4.1.0.1/package/nupack-4.1.0.1-cp312-cp312-linux_x86_64.whl similarity index 52% rename from nupack/vendor/nupack-4.0.2.0/package/nupack-4.0.2.0-cp312-cp312-linux_x86_64.whl rename to nupack/vendor/nupack-4.1.0.1/package/nupack-4.1.0.1-cp312-cp312-linux_x86_64.whl index 91b519b..03202d5 100644 Binary files a/nupack/vendor/nupack-4.0.2.0/package/nupack-4.0.2.0-cp312-cp312-linux_x86_64.whl and b/nupack/vendor/nupack-4.1.0.1/package/nupack-4.1.0.1-cp312-cp312-linux_x86_64.whl differ diff --git a/rna/操作流程.txt b/rna/操作流程.txt index f6731be..cc4ca38 100644 --- a/rna/操作流程.txt +++ b/rna/操作流程.txt @@ -1,44 +1,45 @@ -准备工作: -在 Windows 界面中搜索 控制面板 打开 程序-程序和功能-启动或关闭 Windows 功能-勾选‘适用于 Linux 的 Windows 子系统’与‘虚拟机平台’后点击确定后重启电脑 -在 Microsoft Store 中下载 Ubunut -按 Win+X 键 点击 ‘命令提示符(管理员)’ 输入 wsl --install 后回车。下载完毕后关闭即可。 如果遇到链接失败就是网络问题,链接手机热点后重新输入+回车即可 - -安装:打开 Ubunut -将群内的所有压缩包直接保存在D盘 -nupack 安装 -mkdir nupack-latest -cd nupack-latest -cp /mnt/d/nupack-4.0.2.0.zip ./ -sudo apt install unzip -unzip nupack-4.0.2.0.zip -cd .. -wget https://repo.anaconda.com/miniconda/Miniconda3-latest-Linux-x86_64.sh -/bin/bash Miniconda3-latest-Linux-x86_64.sh -b -miniconda3/bin/conda update -n base -c defaults conda -rm Miniconda3-latest-Linux-x86_64.sh -export PATH=$HOME/miniconda3/bin:$PATH -echo 'export PATH=$HOME/miniconda3/bin:$PATH' >> ~/.bashrc -conda install numpy scipy pip matplotlib pandas jupyterlab -pip install -U nupack -f ./nupack-latest/nupack-4.0.2.0/package -jupyter lab - -RNAplot 安装 -mkdir rna -cd rna -cp /mnt/d/ViennaRNA-2.7.2.tar.gz ./ -tar -zxvf ViennaRNA-2.7.2.tar.gz -cd ViennaRNA-2.7.2 -(可能出现报错的解决方法 -sudo apt update -sudo apt install build-essentia -sudo apt install pkg-config) -./configure -make -sudo make install - -图片美化方法 - -将生成后的svg图片保存在一个文件夹内,并将该文件夹与群内的 py 文件保存在一个文件夹。后 Shift+鼠标右键 点击在此处打开 Powershell 窗口后 -输入命令 -python TrnaStructureBeautifier.py -i 文件夹名称 用于指定一个文件夹 +准备工作: +在 Windows 界面中搜索 控制面板 打开 程序-程序和功能-启动或关闭 Windows 功能-勾选‘适用于 Linux 的 Windows 子系统’与‘虚拟机平台’后点击确定后重启电脑 +在 Microsoft Store 中下载 Ubunut +按 Win+X 键 点击 ‘命令提示符(管理员)’ 输入 wsl --install 后回车。下载完毕后关闭即可。 如果遇到链接失败就是网络问题,链接手机热点后重新输入+回车即可 + +安装:打开 Ubunut +将群内的所有压缩包直接保存在D盘 +nupack 安装 +mkdir nupack-latest +cd nupack-latest +cp /mnt/d/nupack-4.1.0.1.zip ./nupack-outer.zip +sudo apt install unzip +unzip nupack-outer.zip +unzip nupack-4.1.0.1.zip +cd .. +wget https://repo.anaconda.com/miniconda/Miniconda3-latest-Linux-x86_64.sh +/bin/bash Miniconda3-latest-Linux-x86_64.sh -b +miniconda3/bin/conda update -n base -c defaults conda +rm Miniconda3-latest-Linux-x86_64.sh +export PATH=$HOME/miniconda3/bin:$PATH +echo 'export PATH=$HOME/miniconda3/bin:$PATH' >> ~/.bashrc +conda install numpy scipy pip matplotlib pandas jupyterlab +pip install -U nupack -f ./nupack-latest/nupack-4.1.0.1/package +jupyter lab + +RNAplot 安装 +mkdir rna +cd rna +cp /mnt/d/ViennaRNA-2.7.2.tar.gz ./ +tar -zxvf ViennaRNA-2.7.2.tar.gz +cd ViennaRNA-2.7.2 +(可能出现报错的解决方法 +sudo apt update +sudo apt install build-essentia +sudo apt install pkg-config) +./configure +make +sudo make install + +图片美化方法 + +将生成后的svg图片保存在一个文件夹内,并将该文件夹与群内的 py 文件保存在一个文件夹。后 Shift+鼠标右键 点击在此处打开 Powershell 窗口后 +输入命令 +python TrnaStructureBeautifier.py -i 文件夹名称 用于指定一个文件夹 python TrnaStructureBeautifier.py -i 文件名称 用于指定一个文件 \ No newline at end of file diff --git a/rna/调用RNAplot的代码.txt b/rna/调用RNAplot的代码.txt index 73187a5..07024a0 100644 --- a/rna/调用RNAplot的代码.txt +++ b/rna/调用RNAplot的代码.txt @@ -1,46 +1,46 @@ -from nupack import * -import subprocess -import os - -def huatu(Complex,result):#Complex是你想查看的复合物,eg: '(StrandA+StrandB)' 引号和括号都要有; - #result是nupack计算(tube_analysis)的结果,eg: nupack指南里用过my_result、my_results和tube_results等, 或者你自己有其他命名 - def jiegou(Complex,result): - m=str(result[Complex].mfe[0].structure) - mm="" - for i in m: - if i != "+": - mm=mm+i - if i=="": - continue - return mm - def xulie(Complex): - inp='' - for i in Complex: - if i != '(' and i != ')': - inp=inp+i - else: - continue - l=inp.split('+',-1) - xulie='' - for i in l: - xulie=xulie+str(eval(i)) - return xulie - sequence=xulie(Complex) - best_structure=jiegou(Complex,result) - seq_name =Complex - output_folder="/home/liusiye/桌面/nupack-4.0.2.0"#指定图片保存在哪个文件夹,需自己更改 - temp_file_path = os.path.join(output_folder, f"{seq_name}.seq") - with open(temp_file_path, "w", newline='\n') as f: - f.write(f">{seq_name}\n") - f.write(f"{sequence}\n") - f.write(f"{best_structure}\n") - try: - input_filename = f"{seq_name}.seq" - process = subprocess.run(["RNAplot", "--o", "svg", input_filename], cwd=output_folder, capture_output=True, text=True) - if process.returncode == 0: - print(f"🎉 绘图成功!请前往 {output_folder} 文件夹查看 {seq_name}_ss.svg") - os.remove(temp_file_path) - else: - print("❌ RNAplot 运行失败,报错信息:\n", process.stderr) - except FileNotFoundError: +from nupack import * +import subprocess +import os + +def huatu(Complex,result):#Complex是你想查看的复合物,eg: '(StrandA+StrandB)' 引号和括号都要有; + #result是nupack计算(tube_analysis)的结果,eg: nupack指南里用过my_result、my_results和tube_results等, 或者你自己有其他命名 + def jiegou(Complex,result): + m=str(result[Complex].mfe[0].structure) + mm="" + for i in m: + if i != "+": + mm=mm+i + if i=="": + continue + return mm + def xulie(Complex): + inp='' + for i in Complex: + if i != '(' and i != ')': + inp=inp+i + else: + continue + l=inp.split('+',-1) + xulie='' + for i in l: + xulie=xulie+str(eval(i)) + return xulie + sequence=xulie(Complex) + best_structure=jiegou(Complex,result) + seq_name =Complex + output_folder="/home/liusiye/桌面/nupack-4.1.0.1"#指定图片保存在哪个文件夹,需自己更改 + temp_file_path = os.path.join(output_folder, f"{seq_name}.seq") + with open(temp_file_path, "w", newline='\n') as f: + f.write(f">{seq_name}\n") + f.write(f"{sequence}\n") + f.write(f"{best_structure}\n") + try: + input_filename = f"{seq_name}.seq" + process = subprocess.run(["RNAplot", "--o", "svg", input_filename], cwd=output_folder, capture_output=True, text=True) + if process.returncode == 0: + print(f"🎉 绘图成功!请前往 {output_folder} 文件夹查看 {seq_name}_ss.svg") + os.remove(temp_file_path) + else: + print("❌ RNAplot 运行失败,报错信息:\n", process.stderr) + except FileNotFoundError: print("❌ 找不到 RNAplot 命令,请确认环境变量。") \ No newline at end of file diff --git a/service/account.html b/service/account.html new file mode 100644 index 0000000..f53ecab --- /dev/null +++ b/service/account.html @@ -0,0 +1,45 @@ + + + + + + + + NUPACK · 账号 + + +
+ +
+ +
+
+
任务总数-
+
成功任务-
+
活跃任务-
+
存储占用-
+
+
+

身份信息

+
状态
正在载入...
+
+
+

计算资源

+
状态
正在载入...
+
+
+

系统管理

+
+
Admin 后台

需要单独的管理密码,不使用账号密码代替。

+ 进入 Admin +
+
+
+
+
+ + + diff --git a/service/account.py b/service/account.py new file mode 100644 index 0000000..337ca7b --- /dev/null +++ b/service/account.py @@ -0,0 +1,701 @@ +import base64 +import hashlib +import json +import os +import secrets +import sqlite3 +import threading +import time +import zlib +from http.cookies import SimpleCookie +from pathlib import Path +from urllib.parse import urlencode +from urllib.request import Request, urlopen + + +DEFAULT_TRASH_RETENTION_SECONDS = 2 * 86400 + + +def _json_blob(value): + if value is None: + return None + raw = json.dumps(value, ensure_ascii=False, separators=(",", ":")).encode("utf-8") + return sqlite3.Binary(zlib.compress(raw, level=6)) + + +def _blob_json(value): + if value is None: + return None + return json.loads(zlib.decompress(value).decode("utf-8")) + + +def _payload_summary(payload): + payload = payload or {} + workflow = payload.get("workflow", "analysis") + mode = payload.get("mode", "tube") + model = payload.get("model") or {} + strands = payload.get("strands") or [] + if workflow == "design": + complexes = payload.get("design_complexes") or payload.get("design_targets") or [] + tube_sizes = [int(row.get("max_size", 0) or 0) for row in (payload.get("design_tubes") or [])] + max_size = max(tube_sizes, default=int((payload.get("design") or {}).get("off_target_max_size", 0) or 0)) + else: + complexes = [line for line in str(payload.get("complexes_text") or "").splitlines() if line.strip()] + max_size = int((payload.get("tube") or {}).get("max_size", 0) or 0) + return { + "workflow": workflow, + "mode": mode, + "material": model.get("material", "rna"), + "celsius": float(model.get("celsius", 37) or 37), + "sodium": float(model.get("sodium", 0) or 0), + "magnesium": float(model.get("magnesium", 0) or 0), + "max_size": max_size, + "strand_count": len(strands), + "complex_count": len(complexes), + "compute": list(payload.get("compute") or (["design"] if workflow == "design" else [])), + "trials": int((payload.get("design") or {}).get("trials", 0) or 0), + "stop_condition": float((payload.get("design") or {}).get("stop_condition", 0) or 0), + "max_time_seconds": int((payload.get("design") or {}).get("max_time_seconds", 0) or 0), + } + + +class AccountStore: + def __init__(self, path): + self.path = Path(path) + self._init_lock = threading.Lock() + self._initialized = False + + def _connect(self): + self.path.parent.mkdir(parents=True, exist_ok=True) + connection = sqlite3.connect(self.path, timeout=30) + connection.row_factory = sqlite3.Row + connection.execute("PRAGMA busy_timeout = 30000") + connection.execute("PRAGMA foreign_keys = ON") + return connection + + def initialize(self): + with self._init_lock: + if self._initialized: + return + with self._connect() as connection: + connection.execute("PRAGMA journal_mode = WAL") + connection.execute("PRAGMA synchronous = NORMAL") + connection.executescript( + """ + CREATE TABLE IF NOT EXISTS users ( + user_id TEXT PRIMARY KEY, + username TEXT NOT NULL, + email TEXT, + display_name TEXT, + groups_json TEXT NOT NULL DEFAULT '[]', + created_at REAL NOT NULL, + last_seen_at REAL NOT NULL + ); + CREATE TABLE IF NOT EXISTS jobs ( + job_id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(user_id) ON DELETE CASCADE, + status TEXT NOT NULL, + source TEXT NOT NULL DEFAULT 'job', + created_at REAL NOT NULL, + updated_at REAL NOT NULL, + elapsed_seconds REAL, + workflow TEXT NOT NULL, + mode TEXT NOT NULL, + material TEXT NOT NULL, + celsius REAL NOT NULL, + sodium REAL NOT NULL, + magnesium REAL NOT NULL, + max_size INTEGER NOT NULL DEFAULT 0, + strand_count INTEGER NOT NULL DEFAULT 0, + complex_count INTEGER NOT NULL DEFAULT 0, + compute_json TEXT NOT NULL DEFAULT '[]', + trials INTEGER NOT NULL DEFAULT 0, + stop_condition REAL NOT NULL DEFAULT 0, + max_time_seconds INTEGER NOT NULL DEFAULT 0, + payload_blob BLOB NOT NULL, + result_blob BLOB, + error_blob BLOB, + stored_bytes INTEGER NOT NULL DEFAULT 0 + ); + CREATE INDEX IF NOT EXISTS jobs_user_created_idx ON jobs(user_id, created_at DESC); + CREATE INDEX IF NOT EXISTS jobs_user_status_idx ON jobs(user_id, status, created_at DESC); + CREATE TABLE IF NOT EXISTS shares ( + share_id TEXT PRIMARY KEY, + job_id TEXT NOT NULL REFERENCES jobs(job_id) ON DELETE CASCADE, + user_id TEXT NOT NULL REFERENCES users(user_id) ON DELETE CASCADE, + active INTEGER NOT NULL DEFAULT 1, + created_at REAL NOT NULL, + expires_at REAL, + last_access_at REAL, + access_count INTEGER NOT NULL DEFAULT 0 + ); + CREATE INDEX IF NOT EXISTS shares_user_created_idx ON shares(user_id, created_at DESC); + CREATE TABLE IF NOT EXISTS app_settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at REAL NOT NULL + ); + """ + ) + job_columns = { + row["name"] for row in connection.execute("PRAGMA table_info(jobs)").fetchall() + } + if "deleted_at" not in job_columns: + connection.execute("ALTER TABLE jobs ADD COLUMN deleted_at REAL") + if "purge_after" not in job_columns: + connection.execute("ALTER TABLE jobs ADD COLUMN purge_after REAL") + connection.execute( + "CREATE INDEX IF NOT EXISTS jobs_deleted_idx ON jobs(deleted_at, purge_after)" + ) + connection.execute( + "DELETE FROM shares WHERE job_id IN (SELECT job_id FROM jobs WHERE deleted_at IS NOT NULL)" + ) + self._initialized = True + + def upsert_user(self, user): + self.initialize() + now = time.time() + with self._connect() as connection: + connection.execute( + """ + INSERT INTO users(user_id, username, email, display_name, groups_json, created_at, last_seen_at) + VALUES(?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(user_id) DO UPDATE SET + username=excluded.username, + email=excluded.email, + display_name=excluded.display_name, + groups_json=excluded.groups_json, + last_seen_at=excluded.last_seen_at + """, + ( + user["user_id"], user.get("username") or user["user_id"], user.get("email"), + user.get("display_name"), json.dumps(user.get("groups") or [], ensure_ascii=False), now, now, + ), + ) + + def create_job(self, job_id, user, payload, status="queued", source="job", created_at=None, result=None, error=None): + self.upsert_user(user) + created_at = float(created_at or time.time()) + summary = _payload_summary(payload) + payload_blob = _json_blob(payload) + result_blob = _json_blob(result) + error_blob = _json_blob(error) + stored_bytes = sum(len(item) for item in (payload_blob, result_blob, error_blob) if item is not None) + with self._connect() as connection: + connection.execute( + """ + INSERT OR IGNORE INTO jobs( + job_id, user_id, status, source, created_at, updated_at, workflow, mode, material, + celsius, sodium, magnesium, max_size, strand_count, complex_count, compute_json, + trials, stop_condition, max_time_seconds, payload_blob, result_blob, error_blob, stored_bytes + ) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + job_id, user["user_id"], status, source, created_at, created_at, + summary["workflow"], summary["mode"], summary["material"], summary["celsius"], + summary["sodium"], summary["magnesium"], summary["max_size"], summary["strand_count"], + summary["complex_count"], json.dumps(summary["compute"]), summary["trials"], + summary["stop_condition"], summary["max_time_seconds"], payload_blob, result_blob, + error_blob, stored_bytes, + ), + ) + + def update_job(self, job_id, status, *, result=None, error=None, elapsed_seconds=None): + self.initialize() + fields = ["status = ?", "updated_at = ?"] + values = [status, time.time()] + if result is not None: + fields.append("result_blob = ?") + values.append(_json_blob(result)) + if error is not None: + fields.append("error_blob = ?") + values.append(_json_blob(error)) + if elapsed_seconds is not None: + fields.append("elapsed_seconds = ?") + values.append(float(elapsed_seconds)) + values.append(job_id) + with self._connect() as connection: + connection.execute(f"UPDATE jobs SET {', '.join(fields)} WHERE job_id = ?", values) + connection.execute( + "UPDATE jobs SET stored_bytes = length(payload_blob) + coalesce(length(result_blob), 0) + coalesce(length(error_blob), 0) WHERE job_id = ?", + (job_id,), + ) + + def owner_id(self, job_id): + self.initialize() + with self._connect() as connection: + row = connection.execute("SELECT user_id FROM jobs WHERE job_id = ?", (job_id,)).fetchone() + return row["user_id"] if row else None + + def get_job(self, user_id, job_id, include_content=True, include_deleted=False): + self.initialize() + columns = "*" if include_content else "job_id,user_id,status,source,created_at,updated_at,elapsed_seconds,workflow,mode,material,celsius,sodium,magnesium,max_size,strand_count,complex_count,compute_json,trials,stop_condition,max_time_seconds,stored_bytes,deleted_at,purge_after" + deleted_clause = "" if include_deleted else " AND deleted_at IS NULL" + with self._connect() as connection: + row = connection.execute( + f"SELECT {columns} FROM jobs WHERE job_id = ? AND user_id = ?{deleted_clause}", + (job_id, user_id), + ).fetchone() + return self._job_row(row, include_content=include_content) if row else None + + def list_jobs(self, user_id, filters=None): + self.initialize() + filters = filters or {} + limit = min(100, max(1, int(filters.get("limit", 30)))) + offset = max(0, int(filters.get("offset", 0))) + self.purge_expired_jobs() + clauses = ["user_id = ?", "deleted_at IS NULL"] + values = [user_id] + for field in ("status", "workflow", "mode", "material"): + value = str(filters.get(field) or "").strip() + if value: + clauses.append(f"{field} = ?") + values.append(value) + search = str(filters.get("q") or "").strip() + if search: + clauses.append("(job_id LIKE ? OR workflow LIKE ? OR mode LIKE ? OR material LIKE ?)") + values.extend([f"%{search}%"] * 4) + where = " AND ".join(clauses) + columns = "job_id,user_id,status,source,created_at,updated_at,elapsed_seconds,workflow,mode,material,celsius,sodium,magnesium,max_size,strand_count,complex_count,compute_json,trials,stop_condition,max_time_seconds,stored_bytes,deleted_at,purge_after" + with self._connect() as connection: + total = connection.execute(f"SELECT count(*) AS count FROM jobs WHERE {where}", values).fetchone()["count"] + rows = connection.execute( + f"SELECT {columns} FROM jobs WHERE {where} ORDER BY created_at DESC LIMIT ? OFFSET ?", + [*values, limit, offset], + ).fetchall() + return {"items": [self._job_row(row, include_content=False) for row in rows], "total": total, "limit": limit, "offset": offset} + + def list_all_jobs(self, filters=None): + self.initialize() + filters = filters or {} + limit = min(200, max(1, int(filters.get("limit", 50)))) + offset = max(0, int(filters.get("offset", 0))) + self.purge_expired_jobs() + deleted = str(filters.get("deleted") or "0").strip() == "1" + clauses = ["j.deleted_at IS NOT NULL" if deleted else "j.deleted_at IS NULL"] + values = [] + for field in ("status", "workflow", "mode", "material"): + value = str(filters.get(field) or "").strip() + if value: + clauses.append(f"j.{field} = ?") + values.append(value) + search = str(filters.get("q") or "").strip() + if search: + clauses.append( + "(j.job_id LIKE ? OR j.user_id LIKE ? OR u.username LIKE ? OR " + "coalesce(u.email, '') LIKE ? OR j.workflow LIKE ? OR j.mode LIKE ?)" + ) + values.extend([f"%{search}%"] * 6) + where = f"WHERE {' AND '.join(clauses)}" if clauses else "" + columns = ( + "j.job_id,j.user_id,j.status,j.source,j.created_at,j.updated_at,j.elapsed_seconds," + "j.workflow,j.mode,j.material,j.celsius,j.sodium,j.magnesium,j.max_size," + "j.strand_count,j.complex_count,j.compute_json,j.trials,j.stop_condition," + "j.max_time_seconds,j.stored_bytes,j.deleted_at,j.purge_after,u.username,u.email,u.display_name" + ) + with self._connect() as connection: + total = connection.execute( + f"SELECT count(*) AS count FROM jobs j JOIN users u ON u.user_id=j.user_id {where}", + values, + ).fetchone()["count"] + rows = connection.execute( + f"SELECT {columns} FROM jobs j JOIN users u ON u.user_id=j.user_id " + f"{where} ORDER BY j.created_at DESC LIMIT ? OFFSET ?", + [*values, limit, offset], + ).fetchall() + return { + "items": [self._job_row(row, include_content=False) for row in rows], + "total": total, + "limit": limit, + "offset": offset, + } + + def usage(self, user_id): + self.initialize() + with self._connect() as connection: + totals = connection.execute( + """SELECT count(*) AS total_jobs, + coalesce(sum(CASE WHEN status='success' THEN 1 ELSE 0 END),0) AS success_jobs, + coalesce(sum(CASE WHEN status='error' THEN 1 ELSE 0 END),0) AS error_jobs, + coalesce(sum(CASE WHEN status IN ('queued','running','cancel_requested') THEN 1 ELSE 0 END),0) AS active_jobs, + coalesce(sum(elapsed_seconds),0) AS compute_seconds, + coalesce(sum(stored_bytes),0) AS stored_bytes FROM jobs WHERE user_id=? AND deleted_at IS NULL""", + (user_id,), + ).fetchone() + shares = connection.execute("SELECT count(*) AS count FROM shares WHERE user_id=? AND active=1", (user_id,)).fetchone()["count"] + result = dict(totals) + result["active_shares"] = shares + return result + + def import_history(self, user, entries): + imported = 0 + for entry in entries: + payload = entry.get("payload") if isinstance(entry, dict) else None + if not isinstance(payload, dict): + continue + result = entry.get("result") if isinstance(entry.get("result"), dict) else None + raw_id = str(entry.get("id") or entry.get("job_id") or uuid_token()) + job_id = f"import-{hashlib.sha256((user['user_id'] + ':' + raw_id).encode()).hexdigest()[:24]}" + created = entry.get("created_at") or entry.get("created_at_iso") + try: + created_at = float(created) + except (TypeError, ValueError): + try: + created_at = time.mktime(time.strptime(str(created).split(".")[0].replace("Z", ""), "%Y-%m-%dT%H:%M:%S")) + except (TypeError, ValueError): + created_at = time.time() + before = self.get_job(user["user_id"], job_id, include_content=False) + self.create_job(job_id, user, payload, status="success" if result else "input_only", source="import", created_at=created_at, result=result) + if before is None: + imported += 1 + return imported + + def trash_retention_seconds(self): + self.initialize() + with self._connect() as connection: + row = connection.execute( + "SELECT value FROM app_settings WHERE key='trash_retention_seconds'" + ).fetchone() + try: + return max(3600, int(row["value"])) if row else DEFAULT_TRASH_RETENTION_SECONDS + except (TypeError, ValueError): + return DEFAULT_TRASH_RETENTION_SECONDS + + def set_trash_retention_days(self, days): + days = float(days) + if not 1 / 24 <= days <= 365: + raise ValueError("Trash retention must be between 1 hour and 365 days.") + seconds = int(days * 86400) + self.initialize() + with self._connect() as connection: + connection.execute( + """INSERT INTO app_settings(key,value,updated_at) VALUES('trash_retention_seconds',?,?) + ON CONFLICT(key) DO UPDATE SET value=excluded.value,updated_at=excluded.updated_at""", + (str(seconds), time.time()), + ) + return seconds + + def purge_expired_jobs(self): + self.initialize() + with self._connect() as connection: + cursor = connection.execute( + "DELETE FROM jobs WHERE deleted_at IS NOT NULL AND purge_after IS NOT NULL AND purge_after <= ?", + (time.time(),), + ) + return cursor.rowcount + + def delete_job(self, user_id, job_id): + self.initialize() + now = time.time() + retention_seconds = self.trash_retention_seconds() + with self._connect() as connection: + row = connection.execute( + "SELECT status,deleted_at FROM jobs WHERE job_id=? AND user_id=?", + (job_id, user_id), + ).fetchone() + if row is None: + return False + if row["status"] in {"queued", "running", "cancel_requested"}: + raise ValueError("Active jobs cannot be deleted.") + if row["deleted_at"] is not None: + return False + connection.execute( + "UPDATE jobs SET deleted_at=?,purge_after=?,updated_at=? WHERE job_id=? AND user_id=?", + (now, now + retention_seconds, now, job_id, user_id), + ) + connection.execute("DELETE FROM shares WHERE job_id=?", (job_id,)) + return True + + def admin_trash_job(self, job_id): + self.initialize() + with self._connect() as connection: + row = connection.execute("SELECT user_id FROM jobs WHERE job_id=?", (job_id,)).fetchone() + return self.delete_job(row["user_id"], job_id) if row else False + + def restore_job(self, job_id): + self.initialize() + with self._connect() as connection: + cursor = connection.execute( + "UPDATE jobs SET deleted_at=NULL,purge_after=NULL,updated_at=? WHERE job_id=? AND deleted_at IS NOT NULL", + (time.time(), job_id), + ) + return cursor.rowcount > 0 + + def permanently_delete_job(self, job_id): + self.initialize() + with self._connect() as connection: + cursor = connection.execute( + "DELETE FROM jobs WHERE job_id=? AND deleted_at IS NOT NULL", + (job_id,), + ) + return cursor.rowcount > 0 + + def create_share(self, user_id, job_id, expires_in=None): + job = self.get_job(user_id, job_id, include_content=False) + if job is None: + raise ValueError("Job not found.") + now = time.time() + expires_at = now + int(expires_in) if expires_in else None + share_id = uuid_token(18) + with self._connect() as connection: + existing = connection.execute( + """SELECT * FROM shares + WHERE user_id=? AND job_id=? AND active=1 + AND (expires_at IS NULL OR expires_at>?) + ORDER BY created_at DESC LIMIT 1""", + (user_id, job_id, now), + ).fetchone() + if existing is not None: + return dict(existing) + connection.execute( + "DELETE FROM shares WHERE user_id=? AND job_id=?", + (user_id, job_id), + ) + connection.execute( + "INSERT INTO shares(share_id,job_id,user_id,active,created_at,expires_at) VALUES(?,?,?,?,?,?)", + (share_id, job_id, user_id, 1, now, expires_at), + ) + return self.get_share_for_owner(user_id, share_id) + + def share_metadata(self, share_id): + self.initialize() + now = time.time() + with self._connect() as connection: + row = connection.execute( + """SELECT s.share_id, s.job_id, s.user_id, s.created_at, s.expires_at, s.active + FROM shares s WHERE s.share_id=? AND s.active=1 AND (s.expires_at IS NULL OR s.expires_at>?)""", + (share_id, now), + ).fetchone() + return dict(row) if row else None + + def record_share_access(self, share_id): + self.initialize() + now = time.time() + with self._connect() as connection: + connection.execute( + "UPDATE shares SET access_count=access_count+1,last_access_at=? WHERE share_id=?", + (now, share_id), + ) + + def list_shares(self, user_id): + self.initialize() + with self._connect() as connection: + rows = connection.execute( + """SELECT s.*,j.status,j.workflow,j.mode,j.material,j.created_at AS job_created_at + FROM shares s JOIN jobs j ON j.job_id=s.job_id + WHERE s.user_id=? AND j.deleted_at IS NULL ORDER BY s.created_at DESC""", + (user_id,), + ).fetchall() + return [dict(row) for row in rows] + + def get_share_for_owner(self, user_id, share_id): + self.initialize() + with self._connect() as connection: + row = connection.execute("SELECT * FROM shares WHERE share_id=? AND user_id=?", (share_id, user_id)).fetchone() + return dict(row) if row else None + + def update_share(self, user_id, share_id, *, active=None, expires_in="unchanged"): + share = self.get_share_for_owner(user_id, share_id) + if share is None: + return None + if active is False: + with self._connect() as connection: + connection.execute( + "DELETE FROM shares WHERE share_id=? AND user_id=?", + (share_id, user_id), + ) + share["active"] = 0 + return share + fields = [] + values = [] + if active is not None: + fields.append("active=?") + values.append(1 if active else 0) + if expires_in != "unchanged": + fields.append("expires_at=?") + values.append(time.time() + int(expires_in) if expires_in else None) + if fields: + values.extend([share_id, user_id]) + with self._connect() as connection: + connection.execute(f"UPDATE shares SET {', '.join(fields)} WHERE share_id=? AND user_id=?", values) + return self.get_share_for_owner(user_id, share_id) + + def resolve_share(self, share_id, *, record_access=True): + self.initialize() + now = time.time() + with self._connect() as connection: + row = connection.execute( + """SELECT + s.share_id, s.created_at AS share_created_at, + j.job_id, j.user_id, j.status, j.source, j.created_at, j.updated_at, + j.elapsed_seconds, j.workflow, j.mode, j.material, j.celsius, j.sodium, + j.magnesium, j.max_size, j.strand_count, j.complex_count, j.compute_json, + j.trials, j.stop_condition, j.max_time_seconds, j.payload_blob, + j.result_blob, j.error_blob, j.stored_bytes + FROM shares s JOIN jobs j ON j.job_id=s.job_id + WHERE s.share_id=? AND s.active=1 AND (s.expires_at IS NULL OR s.expires_at>?)""", + (share_id, now), + ).fetchone() + if row is None: + return None + if record_access: + connection.execute("UPDATE shares SET access_count=access_count+1,last_access_at=? WHERE share_id=?", (now, share_id)) + job = self._job_row(row, include_content=True) + return { + "id": share_id, + "share_id": share_id, + "job_id": job["job_id"], + "created_at": row["share_created_at"], + "status": job["status"], + "updated_at": job["updated_at"], + "elapsed_seconds": job["elapsed_seconds"], + "payload": job["payload"], + "result": job["result"], + "error": job["error"], + "result_summary": job["result_summary"], + } + + @staticmethod + def _job_row(row, include_content): + item = dict(row) + item["compute"] = json.loads(item.pop("compute_json") or "[]") + item["result_summary"] = { + key: item[key] for key in ( + "workflow", "mode", "material", "celsius", "sodium", "magnesium", "max_size", + "strand_count", "complex_count", "compute", "trials", "stop_condition", "max_time_seconds", + ) + } + if include_content: + item["payload"] = _blob_json(item.pop("payload_blob")) + item["result"] = _blob_json(item.pop("result_blob")) + item["error"] = _blob_json(item.pop("error_blob")) + return item + + +def uuid_token(size=24): + return secrets.token_urlsafe(size) + + +class OIDCAuth: + def __init__(self, redis_factory): + self.redis_factory = redis_factory + self.issuer = os.environ.get("NP_OIDC_ISSUER", "https://auth.lihato.icu/application/o/nupack-account/").rstrip("/") + "/" + self.client_id = os.environ.get("NP_OIDC_CLIENT_ID", "np-replica-web") + self.redirect_uri = os.environ.get("NP_OIDC_REDIRECT_URI", "https://np.lihato.icu/auth/callback") + self.post_logout_uri = os.environ.get("NP_OIDC_POST_LOGOUT_URI", "https://np.lihato.icu/") + self.scopes = os.environ.get("NP_OIDC_SCOPES", "openid profile email").strip() + self.cookie_name = os.environ.get("NP_AUTH_COOKIE_NAME", "np_session") + self.session_ttl = int(os.environ.get("NP_AUTH_SESSION_TTL_SECONDS", str(7 * 86400))) + self.required = os.environ.get("NP_AUTH_REQUIRED", "1") != "0" + self._configuration = None + self._config_lock = threading.Lock() + + def configuration(self): + with self._config_lock: + if self._configuration is None: + self._configuration = self._request_json(self.issuer + ".well-known/openid-configuration") + return self._configuration + + @staticmethod + def _request_json(url, *, data=None, headers=None): + body = urlencode(data).encode("utf-8") if data is not None else None + request = Request(url, data=body, headers=headers or {}) + with urlopen(request, timeout=15) as response: + return json.loads(response.read().decode("utf-8")) + + def begin_login(self, next_path="/"): + if not next_path.startswith("/") or next_path.startswith("//"): + next_path = "/" + state = uuid_token(24) + verifier = uuid_token(48) + challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).rstrip(b"=").decode() + self.redis_factory().setex( + f"np_replica:oidc_state:{state}", 600, + json.dumps({"verifier": verifier, "next": next_path}), + ) + config = self.configuration() + query = urlencode({ + "client_id": self.client_id, + "response_type": "code", + "redirect_uri": self.redirect_uri, + "scope": self.scopes, + "state": state, + "code_challenge": challenge, + "code_challenge_method": "S256", + }) + return f"{config['authorization_endpoint']}?{query}" + + def complete_login(self, params): + state = (params.get("state") or [""])[0] + code = (params.get("code") or [""])[0] + if not state or not code: + raise ValueError("OIDC callback is missing code or state.") + key = f"np_replica:oidc_state:{state}" + client = self.redis_factory() + raw = client.get(key) + client.delete(key) + if raw is None: + raise ValueError("OIDC login state has expired.") + pending = json.loads(raw) + config = self.configuration() + tokens = self._request_json(config["token_endpoint"], data={ + "grant_type": "authorization_code", + "client_id": self.client_id, + "code": code, + "redirect_uri": self.redirect_uri, + "code_verifier": pending["verifier"], + }, headers={"Content-Type": "application/x-www-form-urlencoded"}) + claims = self._request_json(config["userinfo_endpoint"], headers={"Authorization": f"Bearer {tokens['access_token']}"}) + subject = str(claims.get("sub") or "").strip() + if not subject: + raise ValueError("OIDC userinfo did not return a subject.") + user = { + "user_id": subject, + "username": claims.get("preferred_username") or claims.get("nickname") or claims.get("email") or subject, + "email": claims.get("email"), + "display_name": claims.get("name") or claims.get("preferred_username") or subject, + "groups": claims.get("groups") or [], + } + session_id = uuid_token(32) + client.setex( + f"np_replica:session:{session_id}", self.session_ttl, + json.dumps({"user": user, "id_token": tokens.get("id_token")}, ensure_ascii=False), + ) + return session_id, user, pending.get("next") or "/" + + def current_session(self, headers): + if not self.required: + user = {"user_id": "development", "username": "development", "email": None, "display_name": "Development", "groups": []} + return {"user": user, "session_id": None} + cookie = SimpleCookie() + cookie.load(headers.get("Cookie", "")) + morsel = cookie.get(self.cookie_name) + if morsel is None: + return None + session_id = morsel.value + raw = self.redis_factory().get(f"np_replica:session:{session_id}") + if raw is None: + return None + session = json.loads(raw) + session["session_id"] = session_id + return session + + def logout_url(self, session): + config = self.configuration() + endpoint = config.get("end_session_endpoint") + if not endpoint: + return "/" + query = {"post_logout_redirect_uri": self.post_logout_uri} + if session and session.get("id_token"): + query["id_token_hint"] = session["id_token"] + return f"{endpoint}?{urlencode(query)}" + + def delete_session(self, session): + if session and session.get("session_id"): + self.redis_factory().delete(f"np_replica:session:{session['session_id']}") + + def cookie_header(self, session_id): + return f"{self.cookie_name}={session_id}; Path=/; Max-Age={self.session_ttl}; HttpOnly; Secure; SameSite=Lax" + + def clear_cookie_header(self): + return f"{self.cookie_name}=; Path=/; Max-Age=0; HttpOnly; Secure; SameSite=Lax" diff --git a/service/admin-login.html b/service/admin-login.html new file mode 100644 index 0000000..e6e7af3 --- /dev/null +++ b/service/admin-login.html @@ -0,0 +1,28 @@ + + + + + + + + NUPACK · Admin 登录 + + +
+ +
+ + + diff --git a/service/admin.html b/service/admin.html new file mode 100644 index 0000000..584ef2a --- /dev/null +++ b/service/admin.html @@ -0,0 +1,57 @@ + + + + + + + + NUPACK · Admin + + +
+ +
+ +
+
+
任务总数-
+
正在运行-
+
等待队列-
+
Worker 并发-
+
+
+
+
+ + +
+
+ + +
+
+
+ + + + + +
+
+ + + +
用户任务配置状态创建/删除耗时操作
正在载入...
+
+
-
+
+ +
+
+
+ + + diff --git a/service/cloud.html b/service/cloud.html new file mode 100644 index 0000000..8c75925 --- /dev/null +++ b/service/cloud.html @@ -0,0 +1,52 @@ + + + + + + + + NUPACK · 云端记录 + + +
+ +
+ +
+
+
+

任务记录

+ +
+
+
+ + + + + +
+
+ + + +
任务配置状态创建时间耗时操作
正在载入...
+
+
-
+
+
+ +
+
+
+ + + diff --git a/service/design-guide.html b/service/design-guide.html index d850a25..48b25d7 100644 --- a/service/design-guide.html +++ b/service/design-guide.html @@ -107,13 +107,15 @@
@@ -151,8 +153,18 @@ target tube:
+
+

3. 混合材料 Design

+

选择 rna-dna06rna-merna06 后,Domain 约束必须显式标注小写材料前缀。点击“插入混合片段模板”可生成可直接编辑的起点:

+
RNA/DNA:      mixed1 = rN6dN6
+RNA/2′OMe:    mixed1 = rN6mN6
+材料也可设计: mixed1 = wN12
+

rdm 分别表示 RNA、DNA、2′OMe-RNA;w 表示 wildcard material。材料前缀不计入碱基长度,IUPAC 约束与计数仍可组合,例如 rS4dN8

+

链输入仍推荐填写 Domain composition,例如 A = mixed1B = ~mixed1。反向互补由所选模型的 material alphabet 计算,结果和“载入为 Analysis”都会保留材料前缀。

+
+
-

3. Hard constraints 怎么用

+

4. Hard constraints 怎么用

Hard constraint 是“必须满足”的约束,写错会让设计空间为空,或直接报错。

@@ -170,13 +182,29 @@ target tube:
-

4. Soft constraints 和 weights

+

5. Soft constraints 和 weights

Soft constraint 不会替代 ensemble defect,只是给优化目标增加加权惩罚。权重越大,设计器越偏向满足它,但也可能变慢。

Defect weights 用来告诉设计器哪些 domain、strand、complex 或 tube 更重要。初学时建议先不填,确认 target 能跑通后再逐步添加。

+
+

6. 推荐的分阶段流程

+

复杂体系不要一开始就把搜索条件设到最终验证强度。推荐把“找候选序列”和“验证候选序列”分开:

+
+ + + + + + + + +
阶段建议设置目的
输入检查1 trial,max size 2,f_stop=0.1–0.2,填写明确的最大时间先确认 targets、结构和约束可行,并快速暴露填写错误。
正式设计从已验证输入逐步收紧 f_stop;确有需要时再增加 trials获得候选序列。每个 trial 都是一次完整随机搜索,不是免费的重复采样。
最终验证把设计结果载入 Analysis,以 max size 3–4 检查更大的复合物集合保留严格的最终热力学验证,同时避免在每一步优化迭代中反复枚举大集合。
+

分阶段流程会改变“在哪个阶段计算哪些集合”,但不会偷减最终验证。若研究目标明确要求在优化目标中直接包含 size 3–4 off-target,应保留该设置并给任务填写最大时间。

+
+
-

5. 为什么 Design 会慢

+

7. 为什么 Design 会慢

NUPACK design 是优化问题,不是一次性的结构分析。影响耗时的主要因素:

  • off-target max size 越大,需要考虑的非目标复合物越多。
  • @@ -184,7 +212,9 @@ target tube:
  • trials 大于 1 时会跑多个随机种子,NUPACK 源码里会并行提交多个 trial。
  • hard constraints 太多或互相矛盾,会反复搜索甚至失败。
-

当前服务已显式设置 NUPACK 的 config.threads,不再只依赖 OMP_NUM_THREADS。本地 NUPACK 源码里有 NUPACK_CUDA 编译选项,但当前 wheel/镜像不是 CUDA 构建;直接“打开 GPU”不能生效,除非重新编译 NUPACK 的 CUDA 版本并替换镜像里的 wheel。

+

页面的“设计预检”会实时显示可变碱基数、固定目标数和复合物上限,并提醒无限时长、严格停止阈值、多 trial 与大 off-target 集合。它只做估算和提示,不会改写提交参数。

+

当前服务会按 CPU 和内存自动限制同时运行的任务数,并已显式设置 NUPACK 的 config.threads。这些调度参数不改变模型、约束或结果目标;它们主要用于防止多个 8–9 GB 任务把机器内存耗尽。增加线程不保证单个 design 等比例加速,因为部分优化阶段主要使用单核;多任务通常比单任务更容易占满多核。增加内存本身也不会让搜索更快,只会减少 OOM、换页和缓存压力。

+

当前 NUPACK 4.1.0.1 wheel/镜像不是 CUDA 构建,且官方分发包中没有附带 source/ 源码树;直接填写环境变量或安装显卡不能生效。GPU 路线需要重新编译或替换核心 wheel,并对结果一致性与稳定性重新测试。

diff --git a/service/home.html b/service/home.html new file mode 100644 index 0000000..2daa187 --- /dev/null +++ b/service/home.html @@ -0,0 +1,46 @@ + + + + + + + + NUPACK · 概览 + + +
+ +
+ +
+
+
正在运行-
+
等待队列-
+
我的任务-
+
累计计算-
+
+
+ +
+
+

最近任务

查看全部
+
+ + + +
任务工作流状态创建时间耗时
正在载入...
+
+
+
+
+
+ + + diff --git a/service/index.html b/service/index.html index da3e825..0619d85 100644 --- a/service/index.html +++ b/service/index.html @@ -217,6 +217,77 @@ --bg-end: #221f29; } + [data-theme="clinical"] { + --bg: #edf1f3; + --panel: rgba(250, 252, 253, 0.94); + --panel-strong: #ffffff; + --card: rgba(255, 255, 255, 0.82); + --card-strong: #ffffff; + --button-secondary: #f8fafb; + --input-bg: #ffffff; + --ink: #17242b; + --muted: #5d6e76; + --line: rgba(23, 36, 43, 0.13); + --accent: #176b73; + --accent-soft: rgba(23, 107, 115, 0.11); + --accent-strong: #0f555c; + --warn: #a44d2e; + --chart-bar: #177c86; + --chart-bar-soft: #abd9dd; + --chart-name: #28548b; + --bg-start: #f7f9fa; + --bg-end: #e8edef; + } + + [data-theme="orchard"] { + --bg: #f0f3ed; + --panel: rgba(252, 253, 249, 0.94); + --panel-strong: #ffffff; + --card: rgba(255, 255, 255, 0.82); + --card-strong: #ffffff; + --button-secondary: #fafbf8; + --input-bg: #ffffff; + --ink: #222a23; + --muted: #667067; + --line: rgba(34, 42, 35, 0.13); + --accent: #477348; + --accent-soft: rgba(71, 115, 72, 0.12); + --accent-strong: #315b34; + --warn: #a3463c; + --chart-bar: #a84e44; + --chart-bar-soft: #e8b8ae; + --chart-name: #315f72; + --bg-start: #f7f9f4; + --bg-end: #e7ece3; + } + + [data-theme="midnight"] { + --bg: #141719; + --panel: rgba(25, 29, 31, 0.94); + --panel-strong: #191d1f; + --card: rgba(33, 38, 40, 0.92); + --card-strong: #202628; + --button-secondary: #242a2c; + --input-bg: #1d2224; + --ink: #edf2ef; + --muted: #aab6b0; + --line: rgba(226, 238, 232, 0.14); + --accent: #51b6aa; + --accent-soft: rgba(81, 182, 170, 0.15); + --accent-strong: #86ddd2; + --warn: #f1aa62; + --chart-surface: #1b2022; + --chart-grid: rgba(226, 238, 232, 0.13); + --chart-axis: #edf2ef; + --chart-label: #b0bdb7; + --chart-bar: #d8a447; + --chart-bar-soft: #685632; + --chart-text-strong: #151819; + --chart-name: #8fc9ff; + --bg-start: #111416; + --bg-end: #1a1f21; + } + * { box-sizing: border-box; } @@ -225,22 +296,19 @@ margin: 0; font-family: "IBM Plex Sans", "PingFang SC", "Noto Sans SC", sans-serif; color: var(--ink); - background: - radial-gradient(circle at top left, var(--bg-glow-a), transparent 28%), - radial-gradient(circle at bottom right, var(--bg-glow-b), transparent 22%), - linear-gradient(135deg, var(--bg-start) 0%, var(--bg-end) 100%); + background: var(--bg); } .shell { - max-width: 1360px; + max-width: 1600px; margin: 0 auto; - padding: 28px 18px 44px; + padding: 16px 18px 32px; } .hero { display: grid; - gap: 12px; - margin-bottom: 20px; + gap: 8px; + margin-bottom: 14px; } .hero-top { @@ -253,28 +321,32 @@ .hero h1 { margin: 0; - font-size: clamp(32px, 4.2vw, 64px); - line-height: 0.95; - letter-spacing: -0.04em; + font-size: 26px; + line-height: 1.2; + letter-spacing: 0; + } + + .hero > p { + display: none; } .hero p { margin: 0; max-width: 820px; color: var(--muted); - font-size: 15px; - line-height: 1.6; + font-size: 13px; + line-height: 1.5; } .queue-panel { display: grid; - grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); - gap: 10px; + grid-template-columns: repeat(4, minmax(110px, 1fr)); + gap: 8px; } .queue-panel .metric { - padding: 12px 14px; - border-radius: 16px; + padding: 8px 12px; + border-radius: 6px; background: var(--card); border: 1px solid rgba(21, 32, 27, 0.08); } @@ -289,8 +361,8 @@ .layout { display: grid; - grid-template-columns: minmax(360px, 520px) minmax(0, 1fr); - gap: 18px; + grid-template-columns: minmax(390px, 500px) minmax(0, 1fr); + gap: 14px; align-items: start; min-height: calc(100vh - 72px); } @@ -325,6 +397,24 @@ position: static; } + [data-layout="input-focus"] .shell { + max-width: 1460px; + } + + [data-layout="input-focus"] .layout { + grid-template-columns: minmax(520px, 0.95fr) minmax(0, 1.05fr); + gap: 18px; + } + + [data-layout="result-focus"] .shell { + max-width: 1720px; + } + + [data-layout="result-focus"] .layout { + grid-template-columns: minmax(340px, 390px) minmax(0, 1fr); + gap: 18px; + } + .panel { background: var(--panel); backdrop-filter: blur(14px); @@ -418,6 +508,10 @@ resize: vertical; } + textarea.compact-textarea { + min-height: 82px; + } + button { border: 0; border-radius: 999px; @@ -1248,6 +1342,64 @@ .compact-actions button { padding: 8px 12px; } + .control-panel > .panel-inner > .actions { + position: sticky; + bottom: -18px; + z-index: 8; + margin: 0 -18px 18px; + padding: 12px 18px; + border-top: 1px solid var(--line); + border-bottom: 1px solid var(--line); + background: color-mix(in srgb, var(--panel-strong) 94%, transparent); + backdrop-filter: blur(16px); + } + + .performance-card { + overflow: hidden; + } + + .performance-layout { + display: grid; + grid-template-columns: minmax(250px, 1.35fr) minmax(220px, 1fr); + gap: 16px; + } + + .timing-list, + .diagnostic-notes { + display: grid; + gap: 9px; + } + + .timing-row { + display: grid; + grid-template-columns: minmax(120px, 0.8fr) minmax(120px, 1fr) auto; + gap: 10px; + align-items: center; + font-size: 12px; + } + + .timing-track { + height: 9px; + overflow: hidden; + border-radius: 999px; + background: color-mix(in srgb, var(--line) 70%, transparent); + } + + .timing-fill { + height: 100%; + min-width: 3px; + border-radius: inherit; + background: linear-gradient(90deg, var(--accent), var(--accent-strong)); + } + + .diagnostic-notes { + margin: 12px 0 0; + padding-left: 18px; + color: var(--muted); + font-size: 12px; + line-height: 1.5; + } + .toggle-row { display: flex; @@ -1271,6 +1423,119 @@ display: none; } + .design-preflight { + margin: 12px 0; + padding: 12px 14px; + border-left: 4px solid var(--accent); + border-radius: 6px; + background: color-mix(in srgb, var(--accent) 8%, var(--card-strong)); + } + + .design-preflight.warning { + border-left-color: var(--warn); + background: color-mix(in srgb, var(--warn) 9%, var(--card-strong)); + } + + .design-preflight h3 { + margin: 0 0 8px; + font-size: 14px; + } + + .design-preflight .metric-row { + display: flex; + flex-wrap: wrap; + gap: 8px 18px; + font-size: 13px; + } + + .design-preflight ul { + margin: 8px 0 0; + padding-left: 18px; + color: var(--muted); + font-size: 12px; + line-height: 1.55; + } + + .workspace-nav { + position: sticky; + top: 0; + z-index: 50; + display: flex; + align-items: center; + gap: 6px; + min-height: 52px; + padding: 8px max(16px, calc((100vw - 1500px) / 2)); + border-bottom: 1px solid var(--line); + background: color-mix(in srgb, var(--panel-strong) 94%, transparent); + backdrop-filter: blur(14px); + } + + .workspace-nav a { + min-height: 34px; + border-radius: 6px; + padding: 8px 11px; + color: var(--muted); + text-decoration: none; + } + + .workspace-nav a:first-child { + margin-right: 10px; + color: var(--ink); + font-weight: 800; + } + + .workspace-nav a.active, + .workspace-nav a:hover { + background: var(--accent-soft); + color: var(--accent-strong); + } + + #historySection, + #usagePanel { + display: none !important; + } + + body.share-mode .workspace-nav { + display: none; + } + + .duplicate-dialog { + width: min(520px, calc(100vw - 28px)); + border: 1px solid var(--line); + border-radius: 8px; + padding: 0; + background: var(--panel-strong); + color: var(--ink); + box-shadow: var(--shadow); + } + + .duplicate-dialog::backdrop { + background: rgba(12, 18, 15, 0.48); + } + + .duplicate-dialog-body { + padding: 20px; + } + + .duplicate-dialog h2 { + margin: 0 0 10px; + font-size: 18px; + } + + .duplicate-dialog p { + margin: 8px 0; + color: var(--muted); + line-height: 1.55; + } + + .duplicate-dialog-actions { + display: flex; + justify-content: flex-end; + gap: 8px; + border-top: 1px solid var(--line); + padding: 12px 20px; + } + .subsection { padding: 14px; border-radius: 18px; @@ -1399,7 +1664,7 @@ @media (min-width: 1025px) { .control-panel, .results-panel { - max-height: calc(100vh - 64px); + max-height: calc(100vh - 170px); overflow: auto; scrollbar-gutter: stable; } @@ -1417,6 +1682,10 @@ .result-grid { grid-template-columns: 1fr; } + .performance-layout { + grid-template-columns: 1fr; + } + .pie-shell { grid-template-columns: 1fr; @@ -1432,6 +1701,10 @@ } @media (max-width: 600px) { + .queue-panel { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .history-filters, .share-row { grid-template-columns: 1fr; @@ -1442,8 +1715,17 @@ } } + - + +
@@ -1470,6 +1752,9 @@ + + + -
+

@@ -1588,32 +1889,41 @@

-
-

+

+

@@ -1654,9 +1964,23 @@
+
+ +

+
+ + +
+
+ + +
@@ -1715,9 +2093,11 @@ A+B

+
+
@@ -1808,9 +2188,9 @@ A+B
- + - +
+ +
+

+

+

: -

+
+
+ + +
+
+