Compare commits
No commits in common. "main" and "v4.5" have entirely different histories.
33 changed files with 608 additions and 7794 deletions
|
|
@ -1,8 +1,5 @@
|
|||
.git
|
||||
.gitignore
|
||||
.env
|
||||
.sisyphus/
|
||||
ltest.sh
|
||||
__pycache__
|
||||
*.pyc
|
||||
*.pyo
|
||||
|
|
@ -15,9 +12,8 @@ nupack-history-*.json
|
|||
NUPACK.html
|
||||
NUPACK_files
|
||||
1.png
|
||||
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
|
||||
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/*
|
||||
!rna/ViennaRNA-2.7.2.tar.gz
|
||||
runtime
|
||||
|
|
|
|||
|
|
@ -1,4 +0,0 @@
|
|||
# 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
|
||||
11
.gitignore
vendored
11
.gitignore
vendored
|
|
@ -3,9 +3,6 @@ __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
|
||||
|
|
@ -15,9 +12,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-*.zip
|
||||
nupack/vendor/nupack-4.1.0.1/source/
|
||||
nupack/vendor/nupack-4.1.0.1/package/*.whl
|
||||
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
|
||||
# Runtime state mounted from Docker containers.
|
||||
runtime/redis/
|
||||
runtime/account/
|
||||
|
|
|
|||
21
Dockerfile
21
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.1.0.1/package /tmp/nupack-package
|
||||
COPY nupack/vendor/nupack-4.0.2.0/package /tmp/nupack-package
|
||||
COPY rna/ViennaRNA-2.7.2.tar.gz /tmp/ViennaRNA-2.7.2.tar.gz
|
||||
|
||||
RUN cd /tmp \
|
||||
|
|
@ -31,19 +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==2.4.6 \
|
||||
scipy==1.17.1 \
|
||||
pandas==3.0.3 \
|
||||
pyyaml==6.0.3 \
|
||||
jinja2==3.1.6 \
|
||||
/tmp/nupack-package/nupack-4.1.0.1-cp312-cp312-linux_x86_64.whl
|
||||
numpy \
|
||||
scipy \
|
||||
pandas \
|
||||
pyyaml \
|
||||
jinja2 \
|
||||
/tmp/nupack-package/nupack-4.0.2.0-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"
|
||||
RUN python -m pip install --no-cache-dir redis
|
||||
|
||||
WORKDIR /app/service
|
||||
COPY service /app/service
|
||||
|
|
|
|||
188
README.md
188
README.md
|
|
@ -1,28 +1,6 @@
|
|||
# NP Replica
|
||||
|
||||
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.
|
||||
|
||||
This project provides a local Chinese web replica of the core NUPACK `analysis/input` workflow.
|
||||
|
||||
## Run
|
||||
|
||||
|
|
@ -32,108 +10,9 @@ 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 集合和优化搜索量。
|
||||
The service listens on port `18765`. You can later reverse proxy `np.lihato.icu` to this port from your public server.
|
||||
|
||||
## Endpoints
|
||||
|
||||
|
|
@ -144,66 +23,39 @@ wheel,并重新做数值一致性验证。
|
|||
- `POST /api/jobs`
|
||||
- `GET /api/jobs/<job_id>`
|
||||
|
||||
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://<host>/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
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
## Notes
|
||||
|
||||
- `tube` mode supports strand concentrations and the official `SetSpec(max_size=..., include=..., exclude=...)` semantics.
|
||||
- `tube` mode supports strand concentrations and `SetSpec(max_size=...)`.
|
||||
- `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 manually refreshed async job status.
|
||||
- The web UI now supports Chinese and English switching, browser-side history, result export, 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.
|
||||
- Worker concurrency is controlled by `NP_WORKER_CONCURRENCY` and defaults to `auto`; a positive integer remains available as an explicit override.
|
||||
- Worker concurrency is controlled by `NP_WORKER_CONCURRENCY` and defaults to `2`.
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
|
||||
|
||||
现在 `docker-compose.yml` 面向 64 核 / 64G WSL 服务器的并发策略是:
|
||||
|
||||
- 最多同时跑 16 个任务
|
||||
- 每个任务最多用 4 个计算线程
|
||||
- 后续任务进入队列等待
|
||||
- 理论上最多占用约 64 个 native 计算线程,避免 16 × 8 这类过度超卖
|
||||
|
||||
如果后面你发现机器还会被压满,最直接的调法就是在 docker-compose.yml 里继续压:
|
||||
|
||||
- 把 NP_WORKER_CONCURRENCY 改小
|
||||
- 或保持并发不变,把 NP_PER_JOB_THREAD_LIMIT 改成 2 或 1
|
||||
|
|
|
|||
|
|
@ -3,13 +3,13 @@ services:
|
|||
image: redis:7.4.8-bookworm
|
||||
container_name: np-redis
|
||||
restart: always
|
||||
mem_limit: 3g
|
||||
mem_limit: 2g
|
||||
command: ["redis-server", "--appendonly", "yes", "--appendfsync", "everysec", "--save", "60", "1"]
|
||||
volumes:
|
||||
- ./runtime/redis:/data
|
||||
|
||||
np-replica:
|
||||
image: 100.64.0.2:13000/lihato/np_app:v5.4-nupack4.1.0.1
|
||||
image: np-app:v4.5
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
|
|
@ -18,8 +18,6 @@ services:
|
|||
mem_limit: 4g
|
||||
depends_on:
|
||||
- redis
|
||||
volumes:
|
||||
- ./runtime/account:/data
|
||||
ports:
|
||||
- "100.64.0.2:18765:18765"
|
||||
environment:
|
||||
|
|
@ -27,21 +25,10 @@ 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_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: auto
|
||||
NP_WORKER_CONCURRENCY: 16
|
||||
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
|
||||
|
|
@ -50,24 +37,19 @@ services:
|
|||
GOTO_NUM_THREADS: 4
|
||||
|
||||
np-worker:
|
||||
image: 100.64.0.2:13000/lihato/np_app:v5.4-nupack4.1.0.1
|
||||
image: np-app:v4.5
|
||||
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: auto
|
||||
NP_WORKER_CONCURRENCY: 16
|
||||
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
|
||||
|
|
|
|||
|
|
@ -1,159 +0,0 @@
|
|||
# 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.
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
# 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.
|
||||
|
|
@ -1,15 +1,14 @@
|
|||
NUPACK Software License Agreement for Non-Commercial Academic Use
|
||||
|
||||
Copyright (c) 2003-2026. California Institute of Technology. All rights reserved.
|
||||
Copyright (c) 2003–2022. 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 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.
|
||||
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.
|
||||
|
||||
Disclaimer
|
||||
|
||||
|
|
@ -18,4 +17,3 @@ 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
|
||||
|
||||
9
nupack/vendor/nupack-4.0.2.0/README.txt
vendored
Normal file
9
nupack/vendor/nupack-4.0.2.0/README.txt
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
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
|
||||
|
|
@ -1,15 +1,14 @@
|
|||
NUPACK Software License Agreement for Non-Commercial Academic Use
|
||||
|
||||
Copyright (c) 2003-2026. California Institute of Technology. All rights reserved.
|
||||
Copyright (c) 2003–2022. 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 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.
|
||||
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.
|
||||
|
||||
Disclaimer
|
||||
|
||||
|
|
@ -18,4 +17,3 @@ 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
|
||||
|
||||
Binary file not shown.
11
nupack/vendor/nupack-4.1.0.1/README.txt
vendored
11
nupack/vendor/nupack-4.1.0.1/README.txt
vendored
|
|
@ -1,11 +0,0 @@
|
|||
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
|
||||
|
|
@ -8,10 +8,9 @@
|
|||
nupack 安装
|
||||
mkdir nupack-latest
|
||||
cd nupack-latest
|
||||
cp /mnt/d/nupack-4.1.0.1.zip ./nupack-outer.zip
|
||||
cp /mnt/d/nupack-4.0.2.0.zip ./
|
||||
sudo apt install unzip
|
||||
unzip nupack-outer.zip
|
||||
unzip nupack-4.1.0.1.zip
|
||||
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
|
||||
|
|
@ -20,7 +19,7 @@ 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
|
||||
pip install -U nupack -f ./nupack-latest/nupack-4.0.2.0/package
|
||||
jupyter lab
|
||||
|
||||
RNAplot 安装
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ def huatu(Complex,result):#Complex是你想查看的复合物,eg: '(StrandA+St
|
|||
sequence=xulie(Complex)
|
||||
best_structure=jiegou(Complex,result)
|
||||
seq_name =Complex
|
||||
output_folder="/home/liusiye/桌面/nupack-4.1.0.1"#指定图片保存在哪个文件夹,需自己更改
|
||||
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")
|
||||
|
|
|
|||
|
|
@ -1,45 +0,0 @@
|
|||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||
<link rel="stylesheet" href="/static/app-shell.css" />
|
||||
<title>NUPACK · 账号</title>
|
||||
</head>
|
||||
<body data-page="account">
|
||||
<div class="app">
|
||||
<aside class="sidebar" id="appNav"></aside>
|
||||
<main class="main">
|
||||
<header class="page-header">
|
||||
<div><h1>账号与用量</h1><p>当前登录身份、存储和计算统计。</p></div>
|
||||
<div class="header-actions"><a class="button" href="/auth/logout">退出登录</a></div>
|
||||
</header>
|
||||
<div class="content">
|
||||
<section class="stats" aria-label="账号用量">
|
||||
<div class="stat"><span>任务总数</span><strong id="accountTotal">-</strong></div>
|
||||
<div class="stat"><span>成功任务</span><strong id="accountSuccess">-</strong></div>
|
||||
<div class="stat"><span>活跃任务</span><strong id="accountActive">-</strong></div>
|
||||
<div class="stat"><span>存储占用</span><strong id="accountStorage">-</strong></div>
|
||||
</section>
|
||||
<section class="band">
|
||||
<div class="band-header"><h2>身份信息</h2></div>
|
||||
<div class="band-body"><dl class="identity" id="identityDetails"><dt>状态</dt><dd>正在载入...</dd></dl></div>
|
||||
</section>
|
||||
<section class="band">
|
||||
<div class="band-header"><h2>计算资源</h2></div>
|
||||
<div class="band-body"><dl class="identity" id="resourceDetails"><dt>状态</dt><dd>正在载入...</dd></dl></div>
|
||||
</section>
|
||||
<section class="band">
|
||||
<div class="band-header"><h2>系统管理</h2></div>
|
||||
<div class="band-body settings-actions">
|
||||
<div><strong>Admin 后台</strong><p class="muted-copy">需要单独的管理密码,不使用账号密码代替。</p></div>
|
||||
<a class="button" href="/admin">进入 Admin</a>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
<script type="module" src="/static/portal.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -1,701 +0,0 @@
|
|||
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"
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||
<link rel="stylesheet" href="/static/app-shell.css" />
|
||||
<title>NUPACK · Admin 登录</title>
|
||||
</head>
|
||||
<body data-page="admin-login">
|
||||
<main class="login-shell">
|
||||
<section class="band login-panel">
|
||||
<div class="band-header"><h1>Admin 登录</h1></div>
|
||||
<form class="band-body login-form" id="adminLoginForm">
|
||||
<p>输入服务端配置的管理密码。密码不会写入网址或浏览器本地存储。</p>
|
||||
<label class="setting-field">
|
||||
<span>管理密码</span>
|
||||
<input id="adminPassword" type="password" autocomplete="current-password" required autofocus />
|
||||
</label>
|
||||
<button class="primary" type="submit">进入管理后台</button>
|
||||
<a class="button" href="/account">返回账号</a>
|
||||
</form>
|
||||
<div class="notice hidden" id="adminLoginNotice" role="alert"></div>
|
||||
</section>
|
||||
</main>
|
||||
<script type="module" src="/static/portal.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -1,57 +0,0 @@
|
|||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||
<link rel="stylesheet" href="/static/app-shell.css" />
|
||||
<title>NUPACK · Admin</title>
|
||||
</head>
|
||||
<body data-page="admin">
|
||||
<div class="app">
|
||||
<aside class="sidebar" id="appNav"></aside>
|
||||
<main class="main">
|
||||
<header class="page-header">
|
||||
<div><h1>全局任务管理</h1><p>查看所有账号任务,并执行停止、回收和清理操作。</p></div>
|
||||
<div class="header-actions"><button id="adminRefresh" class="primary" type="button">刷新</button><button id="adminLogout" type="button">退出 Admin</button></div>
|
||||
</header>
|
||||
<div class="content">
|
||||
<section class="stats" aria-label="全局状态">
|
||||
<div class="stat"><span>任务总数</span><strong id="adminTotal">-</strong></div>
|
||||
<div class="stat"><span>正在运行</span><strong id="adminRunning">-</strong></div>
|
||||
<div class="stat"><span>等待队列</span><strong id="adminQueued">-</strong></div>
|
||||
<div class="stat"><span>Worker 并发</span><strong id="adminConcurrency">-</strong></div>
|
||||
</section>
|
||||
<section class="band">
|
||||
<div class="band-header">
|
||||
<div class="segmented">
|
||||
<button class="active" type="button" data-admin-view="active">全部任务</button>
|
||||
<button type="button" data-admin-view="trash">回收站</button>
|
||||
</div>
|
||||
<form class="toolbar" id="trashSettings">
|
||||
<label>回收站保留天数 <input id="trashRetentionDays" type="number" min="0.0417" max="365" step="0.5" value="2" /></label>
|
||||
<button type="submit">保存</button>
|
||||
</form>
|
||||
</div>
|
||||
<form class="band-body filters" id="adminFilters">
|
||||
<input name="q" type="search" placeholder="搜索用户、邮箱、任务 ID" />
|
||||
<select name="status"><option value="">全部状态</option><option>queued</option><option>running</option><option>success</option><option>error</option><option>canceled</option></select>
|
||||
<select name="workflow"><option value="">全部工作流</option><option>analysis</option><option>design</option><option>utilities</option></select>
|
||||
<select name="material"><option value="">全部材料</option><option>rna</option><option>dna04</option></select>
|
||||
<button type="submit">筛选</button>
|
||||
</form>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead><tr><th>用户</th><th>任务</th><th>配置</th><th>状态</th><th>创建/删除</th><th>耗时</th><th>操作</th></tr></thead>
|
||||
<tbody id="adminJobs"><tr><td colspan="7" class="loading">正在载入...</td></tr></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="pager"><button id="adminPrev" type="button">上一页</button><span id="adminPage">-</span><button id="adminNext" type="button">下一页</button></div>
|
||||
</section>
|
||||
<div class="notice hidden" id="adminNotice" role="status"></div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
<script type="module" src="/static/portal.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -1,52 +0,0 @@
|
|||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||
<link rel="stylesheet" href="/static/app-shell.css" />
|
||||
<title>NUPACK · 云端记录</title>
|
||||
</head>
|
||||
<body data-page="cloud">
|
||||
<div class="app">
|
||||
<aside class="sidebar" id="appNav"></aside>
|
||||
<main class="main">
|
||||
<header class="page-header">
|
||||
<div><h1>云端记录</h1><p>账号范围内的任务、结果和分享。</p></div>
|
||||
<div class="header-actions">
|
||||
<input class="hidden" id="cloudImportInput" type="file" accept="application/json,.json" multiple />
|
||||
<button id="cloudImport" type="button">导入 JSON</button>
|
||||
<button id="cloudExport" type="button">导出 JSON</button>
|
||||
<a class="button primary" href="/workspace">新建计算</a>
|
||||
</div>
|
||||
</header>
|
||||
<div class="content">
|
||||
<section class="band">
|
||||
<div class="band-header">
|
||||
<h2>任务记录</h2>
|
||||
<button id="cloudRefresh" type="button">刷新</button>
|
||||
</div>
|
||||
<div>
|
||||
<form class="band-body filters" id="cloudFilters">
|
||||
<input name="q" type="search" placeholder="搜索任务 ID、工作流或材料" />
|
||||
<select name="status"><option value="">全部状态</option><option>queued</option><option>running</option><option>success</option><option>error</option><option>canceled</option></select>
|
||||
<select name="workflow"><option value="">全部工作流</option><option>analysis</option><option>design</option><option>utilities</option></select>
|
||||
<select name="material"><option value="">全部材料</option><option>rna</option><option>dna04</option></select>
|
||||
<button type="submit">筛选</button>
|
||||
</form>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead><tr><th>任务</th><th>配置</th><th>状态</th><th>创建时间</th><th>耗时</th><th>操作</th></tr></thead>
|
||||
<tbody id="cloudJobs"><tr><td colspan="6" class="loading">正在载入...</td></tr></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="pager"><button id="cloudPrev" type="button">上一页</button><span id="cloudPage">-</span><button id="cloudNext" type="button">下一页</button></div>
|
||||
</div>
|
||||
</section>
|
||||
<div class="notice hidden" id="cloudNotice" role="status"></div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
<script type="module" src="/static/portal.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -107,15 +107,13 @@
|
|||
<nav class="toc">
|
||||
<a class="button" href="#quick-start">快速跑通</a>
|
||||
<a class="button" href="#targets">Targets 怎么填</a>
|
||||
<a class="button" href="#mixed">混合材料 Design</a>
|
||||
<a class="button" href="#hard">Hard constraints</a>
|
||||
<a class="button" href="#soft">Soft constraints</a>
|
||||
<a class="button" href="#staged">分阶段计算</a>
|
||||
<a class="button" href="#speed">为什么会慢</a>
|
||||
<a class="button" href="/workspace#design-targets">跳到工作台 Targets</a>
|
||||
<a class="button" href="/workspace#design-hard">跳到工作台 Hard</a>
|
||||
<a class="button" href="/workspace#design-soft">跳到工作台 Soft</a>
|
||||
<a class="button" href="/cloud">打开云端记录</a>
|
||||
<a class="button" href="/#design-targets">跳到主界面 Targets</a>
|
||||
<a class="button" href="/#design-hard">跳到主界面 Hard</a>
|
||||
<a class="button" href="/#design-soft">跳到主界面 Soft</a>
|
||||
<a class="button" href="/#history">跳到历史记录</a>
|
||||
</nav>
|
||||
|
||||
<section id="quick-start" class="card">
|
||||
|
|
@ -153,18 +151,8 @@ target tube:
|
|||
</table>
|
||||
</section>
|
||||
|
||||
<section id="mixed" class="card">
|
||||
<h2>3. 混合材料 Design</h2>
|
||||
<p>选择 <code>rna-dna06</code> 或 <code>rna-merna06</code> 后,Domain 约束必须显式标注小写材料前缀。点击“插入混合片段模板”可生成可直接编辑的起点:</p>
|
||||
<pre><code>RNA/DNA: mixed1 = rN6dN6
|
||||
RNA/2′OMe: mixed1 = rN6mN6
|
||||
材料也可设计: mixed1 = wN12</code></pre>
|
||||
<p><code>r</code>、<code>d</code>、<code>m</code> 分别表示 RNA、DNA、2′OMe-RNA;<code>w</code> 表示 wildcard material。材料前缀不计入碱基长度,IUPAC 约束与计数仍可组合,例如 <code>rS4dN8</code>。</p>
|
||||
<p>链输入仍推荐填写 Domain composition,例如 <code>A = mixed1</code>、<code>B = ~mixed1</code>。反向互补由所选模型的 material alphabet 计算,结果和“载入为 Analysis”都会保留材料前缀。</p>
|
||||
</section>
|
||||
|
||||
<section id="hard" class="card">
|
||||
<h2>4. Hard constraints 怎么用</h2>
|
||||
<h2>3. Hard constraints 怎么用</h2>
|
||||
<p>Hard constraint 是“必须满足”的约束,写错会让设计空间为空,或直接报错。</p>
|
||||
<table>
|
||||
<thead>
|
||||
|
|
@ -182,29 +170,13 @@ RNA/2′OMe: mixed1 = rN6mN6
|
|||
</section>
|
||||
|
||||
<section id="soft" class="card">
|
||||
<h2>5. Soft constraints 和 weights</h2>
|
||||
<h2>4. Soft constraints 和 weights</h2>
|
||||
<p>Soft constraint 不会替代 ensemble defect,只是给优化目标增加加权惩罚。权重越大,设计器越偏向满足它,但也可能变慢。</p>
|
||||
<p>Defect weights 用来告诉设计器哪些 domain、strand、complex 或 tube 更重要。初学时建议先不填,确认 target 能跑通后再逐步添加。</p>
|
||||
</section>
|
||||
|
||||
<section id="staged" class="card">
|
||||
<h2>6. 推荐的分阶段流程</h2>
|
||||
<p>复杂体系不要一开始就把搜索条件设到最终验证强度。推荐把“找候选序列”和“验证候选序列”分开:</p>
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>阶段</th><th>建议设置</th><th>目的</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td>输入检查</td><td>1 trial,max size 2,<code>f_stop=0.1–0.2</code>,填写明确的最大时间</td><td>先确认 targets、结构和约束可行,并快速暴露填写错误。</td></tr>
|
||||
<tr><td>正式设计</td><td>从已验证输入逐步收紧 <code>f_stop</code>;确有需要时再增加 trials</td><td>获得候选序列。每个 trial 都是一次完整随机搜索,不是免费的重复采样。</td></tr>
|
||||
<tr><td>最终验证</td><td>把设计结果载入 Analysis,以 max size 3–4 检查更大的复合物集合</td><td>保留严格的最终热力学验证,同时避免在每一步优化迭代中反复枚举大集合。</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="warn">分阶段流程会改变“在哪个阶段计算哪些集合”,但不会偷减最终验证。若研究目标明确要求在优化目标中直接包含 size 3–4 off-target,应保留该设置并给任务填写最大时间。</p>
|
||||
</section>
|
||||
|
||||
<section id="speed" class="card">
|
||||
<h2>7. 为什么 Design 会慢</h2>
|
||||
<h2>5. 为什么 Design 会慢</h2>
|
||||
<p>NUPACK design 是优化问题,不是一次性的结构分析。影响耗时的主要因素:</p>
|
||||
<ul>
|
||||
<li><strong>off-target max size</strong> 越大,需要考虑的非目标复合物越多。</li>
|
||||
|
|
@ -212,9 +184,7 @@ RNA/2′OMe: mixed1 = rN6mN6
|
|||
<li><strong>trials</strong> 大于 1 时会跑多个随机种子,NUPACK 源码里会并行提交多个 trial。</li>
|
||||
<li><strong>hard constraints</strong> 太多或互相矛盾,会反复搜索甚至失败。</li>
|
||||
</ul>
|
||||
<p>页面的“设计预检”会实时显示可变碱基数、固定目标数和复合物上限,并提醒无限时长、严格停止阈值、多 trial 与大 off-target 集合。它只做估算和提示,不会改写提交参数。</p>
|
||||
<p>当前服务会按 CPU 和内存自动限制同时运行的任务数,并已显式设置 NUPACK 的 <code>config.threads</code>。这些调度参数不改变模型、约束或结果目标;它们主要用于防止多个 8–9 GB 任务把机器内存耗尽。增加线程不保证单个 design 等比例加速,因为部分优化阶段主要使用单核;多任务通常比单任务更容易占满多核。增加内存本身也不会让搜索更快,只会减少 OOM、换页和缓存压力。</p>
|
||||
<p>当前 NUPACK 4.1.0.1 wheel/镜像不是 CUDA 构建,且官方分发包中没有附带 <code>source/</code> 源码树;直接填写环境变量或安装显卡不能生效。GPU 路线需要重新编译或替换核心 wheel,并对结果一致性与稳定性重新测试。</p>
|
||||
<p>当前服务已显式设置 NUPACK 的 <code>config.threads</code>,不再只依赖 <code>OMP_NUM_THREADS</code>。本地 NUPACK 源码里有 <code>NUPACK_CUDA</code> 编译选项,但当前 wheel/镜像不是 CUDA 构建;直接“打开 GPU”不能生效,除非重新编译 NUPACK 的 CUDA 版本并替换镜像里的 wheel。</p>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
|
|
|
|||
|
|
@ -1,46 +0,0 @@
|
|||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||
<link rel="stylesheet" href="/static/app-shell.css" />
|
||||
<title>NUPACK · 概览</title>
|
||||
</head>
|
||||
<body data-page="home">
|
||||
<div class="app">
|
||||
<aside class="sidebar" id="appNav"></aside>
|
||||
<main class="main">
|
||||
<header class="page-header">
|
||||
<div><h1>计算概览</h1><p>任务状态、资源占用和最近计算。</p></div>
|
||||
<div class="header-actions"><a class="button primary" href="/workspace">新建计算</a></div>
|
||||
</header>
|
||||
<div class="content">
|
||||
<section class="stats" aria-label="计算状态">
|
||||
<div class="stat"><span>正在运行</span><strong id="statRunning">-</strong></div>
|
||||
<div class="stat"><span>等待队列</span><strong id="statQueued">-</strong></div>
|
||||
<div class="stat"><span>我的任务</span><strong id="statTotal">-</strong></div>
|
||||
<div class="stat"><span>累计计算</span><strong id="statCompute">-</strong></div>
|
||||
</section>
|
||||
<section class="band">
|
||||
<div class="quick-grid">
|
||||
<a class="quick-link" href="/workspace"><strong>计算工作台</strong><span>Analysis、Design 和 Utilities 的统一输入与结果界面。</span></a>
|
||||
<a class="quick-link" href="/cloud"><strong>云端记录</strong><span>检索任务、查看状态、管理结果和分享链接。</span></a>
|
||||
<a class="quick-link" href="/account"><strong>账号与用量</strong><span>查看当前身份、存储占用和资源配置。</span></a>
|
||||
</div>
|
||||
</section>
|
||||
<section class="band">
|
||||
<div class="band-header"><h2>最近任务</h2><a class="button" href="/cloud">查看全部</a></div>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead><tr><th>任务</th><th>工作流</th><th>状态</th><th>创建时间</th><th>耗时</th></tr></thead>
|
||||
<tbody id="recentJobs"><tr><td colspan="5" class="loading">正在载入...</td></tr></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
<script type="module" src="/static/portal.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
2308
service/index.html
2308
service/index.html
File diff suppressed because it is too large
Load diff
2113
service/server.py
2113
service/server.py
File diff suppressed because it is too large
Load diff
|
|
@ -1,78 +0,0 @@
|
|||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||
<link rel="stylesheet" href="/static/app-shell.css" />
|
||||
<title>NUPACK · 设置</title>
|
||||
</head>
|
||||
<body data-page="settings">
|
||||
<div class="app">
|
||||
<aside class="sidebar" id="appNav"></aside>
|
||||
<main class="main">
|
||||
<header class="page-header">
|
||||
<div><h1>界面设置</h1><p>管理计算工作台的语言、主题、布局和填写提示。</p></div>
|
||||
<div class="header-actions"><a class="button primary" href="/workspace">返回工作台</a></div>
|
||||
</header>
|
||||
<div class="content">
|
||||
<form class="band" id="settingsForm">
|
||||
<div class="band-header"><h2>工作台显示</h2></div>
|
||||
<div class="band-body settings-grid">
|
||||
<label class="setting-field">
|
||||
<span>界面语言</span>
|
||||
<select id="settingsLanguage">
|
||||
<option value="zh">中文</option>
|
||||
<option value="en">English</option>
|
||||
</select>
|
||||
<small>控制工作台字段、提示和计算状态使用的语言。</small>
|
||||
</label>
|
||||
<label class="setting-field">
|
||||
<span>主题</span>
|
||||
<select id="settingsTheme">
|
||||
<option value="harbor">港湾</option>
|
||||
<option value="linen">亚麻</option>
|
||||
<option value="tide">潮汐</option>
|
||||
<option value="sage">鼠尾草</option>
|
||||
<option value="ember">余烬</option>
|
||||
<option value="graphite">石墨</option>
|
||||
<option value="studio">工作室</option>
|
||||
<option value="clinical">临床白</option>
|
||||
<option value="orchard">果园</option>
|
||||
<option value="midnight">午夜</option>
|
||||
</select>
|
||||
<small>只改变工作台配色,不影响结果或计算参数。</small>
|
||||
</label>
|
||||
<label class="setting-field">
|
||||
<span>工作区布局</span>
|
||||
<select id="settingsLayout">
|
||||
<option value="balanced">均衡</option>
|
||||
<option value="dashboard">宽屏</option>
|
||||
<option value="editorial">紧凑输入</option>
|
||||
<option value="stacked">上下排列</option>
|
||||
<option value="input-focus">输入优先</option>
|
||||
<option value="result-focus">结果优先</option>
|
||||
</select>
|
||||
<small>宽屏建议使用均衡或宽屏,窄屏会自动改为上下排列。</small>
|
||||
</label>
|
||||
<label class="setting-field">
|
||||
<span>填写说明</span>
|
||||
<select id="settingsHelp">
|
||||
<option value="shown">显示</option>
|
||||
<option value="hidden">隐藏</option>
|
||||
</select>
|
||||
<small>首次使用建议显示,熟悉参数后可以隐藏以提高页面密度。</small>
|
||||
</label>
|
||||
</div>
|
||||
<div class="band-body settings-actions">
|
||||
<button id="settingsReset" type="button">恢复默认</button>
|
||||
<a class="button primary" href="/workspace">完成</a>
|
||||
</div>
|
||||
</form>
|
||||
<div class="notice hidden" id="settingsNotice" role="status"></div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
<script type="module" src="/static/portal.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -1,582 +0,0 @@
|
|||
:root {
|
||||
color-scheme: light;
|
||||
--bg: #f5f7f6;
|
||||
--surface: #ffffff;
|
||||
--surface-muted: #eef2f0;
|
||||
--ink: #17201c;
|
||||
--muted: #627069;
|
||||
--line: #d9e0dc;
|
||||
--accent: #167052;
|
||||
--accent-hover: #0f5b42;
|
||||
--warning: #a35418;
|
||||
--danger: #a33a32;
|
||||
--info: #246a9b;
|
||||
--radius: 6px;
|
||||
--shadow: 0 8px 24px rgba(23, 32, 28, 0.07);
|
||||
font-family: Inter, "Noto Sans SC", "Microsoft YaHei", system-ui, sans-serif;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-width: 320px;
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
select {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
button,
|
||||
.button {
|
||||
min-height: 36px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
padding: 7px 12px;
|
||||
background: var(--surface);
|
||||
color: var(--ink);
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
button:hover,
|
||||
.button:hover {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
button.primary,
|
||||
.button.primary {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
button.primary:hover,
|
||||
.button.primary:hover {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
button.danger {
|
||||
color: var(--danger);
|
||||
border-color: color-mix(in srgb, var(--danger) 40%, var(--line));
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
input,
|
||||
select {
|
||||
width: 100%;
|
||||
min-height: 38px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
padding: 7px 10px;
|
||||
background: var(--surface);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
input:focus,
|
||||
select:focus,
|
||||
button:focus-visible,
|
||||
.button:focus-visible {
|
||||
outline: 2px solid color-mix(in srgb, var(--accent) 38%, transparent);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.app {
|
||||
display: grid;
|
||||
grid-template-columns: 220px minmax(0, 1fr);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
body[data-page="admin"] .app {
|
||||
grid-template-columns: 160px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
body[data-page="admin"] .sidebar {
|
||||
padding-right: 8px;
|
||||
padding-left: 8px;
|
||||
}
|
||||
|
||||
body[data-page="admin"] .main {
|
||||
padding-right: 20px;
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
body[data-page="admin"] .page-header,
|
||||
body[data-page="admin"] .content {
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
height: 100vh;
|
||||
padding: 20px 14px;
|
||||
border-right: 1px solid var(--line);
|
||||
background: #18231e;
|
||||
color: #f4f8f6;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: block;
|
||||
padding: 8px 10px 20px;
|
||||
color: #fff;
|
||||
font-size: 19px;
|
||||
font-weight: 750;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.nav-list {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.nav-list a {
|
||||
min-height: 40px;
|
||||
border-radius: var(--radius);
|
||||
padding: 10px 12px;
|
||||
color: #cbd8d1;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.nav-list a:hover,
|
||||
.nav-list a.active {
|
||||
background: #2b3c34;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.nav-meta {
|
||||
position: absolute;
|
||||
right: 14px;
|
||||
bottom: 18px;
|
||||
left: 14px;
|
||||
border-top: 1px solid #34463d;
|
||||
padding: 14px 10px 0;
|
||||
color: #aebdb5;
|
||||
font-size: 12px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.main {
|
||||
min-width: 0;
|
||||
padding: 24px clamp(18px, 4vw, 48px) 48px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
margin: 0 auto 22px;
|
||||
max-width: 1280px;
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
margin: 0;
|
||||
font-size: 25px;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.page-header p {
|
||||
margin: 5px 0 0;
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.header-actions,
|
||||
.toolbar,
|
||||
.pager,
|
||||
.segmented {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.band {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.band-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
min-height: 54px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.band-header h2 {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.band-body {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.stat {
|
||||
min-height: 92px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
padding: 14px;
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.stat span {
|
||||
display: block;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.stat strong {
|
||||
display: block;
|
||||
margin-top: 9px;
|
||||
font-size: 25px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.quick-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 1px;
|
||||
background: var(--line);
|
||||
}
|
||||
|
||||
.settings-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.setting-field {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.setting-field > span {
|
||||
color: var(--ink);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.setting-field small {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.muted-copy {
|
||||
margin: 5px 0 0;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.login-shell {
|
||||
display: grid;
|
||||
min-height: 100vh;
|
||||
padding: 24px;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.login-panel {
|
||||
width: min(440px, 100%);
|
||||
}
|
||||
|
||||
.login-panel h1 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.login-form {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.login-form p {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.settings-actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
border-top: 1px solid var(--line);
|
||||
padding-top: 16px;
|
||||
}
|
||||
|
||||
.quick-link {
|
||||
min-height: 112px;
|
||||
padding: 18px;
|
||||
background: var(--surface);
|
||||
color: var(--ink);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.quick-link:hover {
|
||||
background: #f8fbf9;
|
||||
}
|
||||
|
||||
.quick-link strong,
|
||||
.quick-link span {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.quick-link span {
|
||||
margin-top: 8px;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.filters {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(220px, 2fr) repeat(3, minmax(120px, 1fr)) auto;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.table-wrap {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
border-bottom: 1px solid var(--line);
|
||||
padding: 11px 12px;
|
||||
text-align: left;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
th {
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
tbody tr:hover {
|
||||
background: #f7faf8;
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: "SFMono-Regular", Consolas, monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 24px;
|
||||
border-radius: 999px;
|
||||
padding: 3px 8px;
|
||||
background: var(--surface-muted);
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.status.running,
|
||||
.status.queued,
|
||||
.status.cancel_requested {
|
||||
background: #e6f0f8;
|
||||
color: var(--info);
|
||||
}
|
||||
|
||||
.status.success {
|
||||
background: #e3f2eb;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.status.error,
|
||||
.status.canceled {
|
||||
background: #f7e8e6;
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.empty,
|
||||
.loading,
|
||||
.notice {
|
||||
padding: 30px 16px;
|
||||
color: var(--muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.notice.error {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.pager {
|
||||
justify-content: flex-end;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.pager span {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.segmented button.active {
|
||||
border-color: var(--accent);
|
||||
background: var(--surface-muted);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.identity {
|
||||
display: grid;
|
||||
grid-template-columns: 180px minmax(0, 1fr);
|
||||
gap: 10px 24px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.identity dt {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.identity dd {
|
||||
margin: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
html,
|
||||
body {
|
||||
max-width: 100%;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.app {
|
||||
grid-template-columns: 1fr;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
body[data-page="admin"] .app {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
position: static;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
padding: 10px 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
body[data-page="admin"] .sidebar {
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.brand {
|
||||
padding: 6px 8px 10px;
|
||||
}
|
||||
|
||||
.nav-list {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
overscroll-behavior-inline: contain;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.nav-list a {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.nav-meta {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.stats {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.filters {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.quick-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.main {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
padding: 18px 12px 36px;
|
||||
}
|
||||
|
||||
body[data-page="admin"] .main {
|
||||
padding: 18px 12px 36px;
|
||||
}
|
||||
|
||||
.content,
|
||||
.band,
|
||||
.band-body,
|
||||
.settings-grid,
|
||||
.setting-field {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.stats,
|
||||
.filters,
|
||||
.settings-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.identity {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.identity dd {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,553 +0,0 @@
|
|||
const page = document.body.dataset.page;
|
||||
const state = {
|
||||
me: null,
|
||||
cloudOffset: 0,
|
||||
cloudTotal: 0,
|
||||
adminOffset: 0,
|
||||
adminTotal: 0,
|
||||
adminView: "active",
|
||||
limit: 25,
|
||||
};
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value ?? "")
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
function formatDate(value) {
|
||||
if (!value) return "-";
|
||||
return new Date(Number(value) * 1000).toLocaleString();
|
||||
}
|
||||
|
||||
function formatDuration(value) {
|
||||
const seconds = Number(value);
|
||||
if (!Number.isFinite(seconds) || seconds <= 0) return "-";
|
||||
if (seconds < 60) return `${seconds.toFixed(1)} s`;
|
||||
if (seconds < 3600) return `${(seconds / 60).toFixed(1)} min`;
|
||||
return `${(seconds / 3600).toFixed(1)} h`;
|
||||
}
|
||||
|
||||
function formatBytes(value) {
|
||||
let bytes = Number(value) || 0;
|
||||
const units = ["B", "KB", "MB", "GB"];
|
||||
let index = 0;
|
||||
while (bytes >= 1024 && index < units.length - 1) {
|
||||
bytes /= 1024;
|
||||
index += 1;
|
||||
}
|
||||
return `${bytes.toFixed(index ? 1 : 0)} ${units[index]}`;
|
||||
}
|
||||
|
||||
function downloadJson(filename, value) {
|
||||
const blob = new Blob([JSON.stringify(value, null, 2)], { type: "application/json;charset=utf-8" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
function statusBadge(status) {
|
||||
const value = String(status || "unknown");
|
||||
return `<span class="status ${escapeHtml(value)}">${escapeHtml(value)}</span>`;
|
||||
}
|
||||
|
||||
async function request(url, options = {}) {
|
||||
const response = await fetch(url, options);
|
||||
const text = await response.text();
|
||||
let data;
|
||||
try {
|
||||
data = JSON.parse(text);
|
||||
} catch {
|
||||
throw new Error(`HTTP ${response.status} 返回了无法解析的响应`);
|
||||
}
|
||||
if (response.status === 401 && data.login_url) {
|
||||
const next = `${window.location.pathname}${window.location.search}`;
|
||||
window.location.assign(`${data.login_url}?next=${encodeURIComponent(next)}`);
|
||||
throw new Error("需要登录");
|
||||
}
|
||||
if (response.status === 401 && page === "admin") {
|
||||
window.location.assign("/admin");
|
||||
throw new Error("Admin 会话已失效,请重新登录");
|
||||
}
|
||||
if (!response.ok) throw new Error(data.error || data.message || `HTTP ${response.status}`);
|
||||
return data;
|
||||
}
|
||||
|
||||
function renderNavigation() {
|
||||
const nav = document.getElementById("appNav");
|
||||
if (!nav) return;
|
||||
if (page === "admin") {
|
||||
nav.innerHTML = `
|
||||
<a class="brand" href="#">NUPACK Admin</a>
|
||||
<nav class="nav-list"><a class="active" href="#">全局任务</a></nav>
|
||||
<div class="nav-meta">独立密码会话 · 免 OIDC</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
const items = [
|
||||
["home", "/", "概览"],
|
||||
["workspace", "/workspace", "计算工作台"],
|
||||
["cloud", "/cloud", "云端记录"],
|
||||
["account", "/account", "账号与用量"],
|
||||
["settings", "/settings", "设置"],
|
||||
];
|
||||
nav.innerHTML = `
|
||||
<a class="brand" href="/">NUPACK</a>
|
||||
<nav class="nav-list">
|
||||
${items.map(([name, href, label]) => `<a class="${page === name ? "active" : ""}" href="${href}">${label}</a>`).join("")}
|
||||
</nav>
|
||||
<div class="nav-meta" id="navIdentity">正在读取账号...</div>
|
||||
`;
|
||||
}
|
||||
|
||||
async function loadIdentity() {
|
||||
if (page === "admin") return null;
|
||||
const data = await request("/api/me");
|
||||
state.me = data;
|
||||
const user = data.user || {};
|
||||
const identity = document.getElementById("navIdentity");
|
||||
if (identity) identity.textContent = user.display_name || user.username || user.email || user.user_id;
|
||||
return data;
|
||||
}
|
||||
|
||||
function recentRow(job) {
|
||||
return `
|
||||
<tr>
|
||||
<td><a class="mono" href="/workspace?job=${encodeURIComponent(job.job_id)}">${escapeHtml(job.job_id.slice(0, 12))}</a></td>
|
||||
<td>${escapeHtml(job.workflow)} / ${escapeHtml(job.mode)}</td>
|
||||
<td>${statusBadge(job.status)}</td>
|
||||
<td>${escapeHtml(formatDate(job.created_at))}</td>
|
||||
<td>${escapeHtml(formatDuration(job.elapsed_seconds))}</td>
|
||||
</tr>
|
||||
`;
|
||||
}
|
||||
|
||||
async function initHome() {
|
||||
const [me, health, history] = await Promise.all([
|
||||
loadIdentity(),
|
||||
request("/health"),
|
||||
request("/api/history?limit=6&offset=0"),
|
||||
]);
|
||||
document.getElementById("statRunning").textContent = health.jobs_running ?? "-";
|
||||
document.getElementById("statQueued").textContent = health.jobs_queued ?? "-";
|
||||
document.getElementById("statTotal").textContent = me.usage?.total_jobs ?? "-";
|
||||
document.getElementById("statCompute").textContent = formatDuration(me.usage?.compute_seconds);
|
||||
const body = document.getElementById("recentJobs");
|
||||
body.innerHTML = history.items?.length
|
||||
? history.items.map(recentRow).join("")
|
||||
: '<tr><td colspan="5" class="empty">还没有任务记录。</td></tr>';
|
||||
}
|
||||
|
||||
function cloudQuery() {
|
||||
const form = new FormData(document.getElementById("cloudFilters"));
|
||||
const query = new URLSearchParams({
|
||||
limit: String(state.limit),
|
||||
offset: String(state.cloudOffset),
|
||||
});
|
||||
for (const [key, value] of form.entries()) {
|
||||
if (String(value).trim()) query.set(key, String(value).trim());
|
||||
}
|
||||
return query;
|
||||
}
|
||||
|
||||
function cloudJobRow(job) {
|
||||
const active = ["queued", "running", "cancel_requested"].includes(job.status);
|
||||
return `
|
||||
<tr>
|
||||
<td class="mono">${escapeHtml(job.job_id.slice(0, 12))}</td>
|
||||
<td>${escapeHtml(job.workflow)} / ${escapeHtml(job.mode)}<br><span class="mono">${escapeHtml(job.material)} · max ${escapeHtml(job.max_size)}</span></td>
|
||||
<td>${statusBadge(job.status)}</td>
|
||||
<td>${escapeHtml(formatDate(job.created_at))}</td>
|
||||
<td>${escapeHtml(formatDuration(job.elapsed_seconds))}</td>
|
||||
<td>
|
||||
<div class="toolbar">
|
||||
<a class="button" href="/workspace?job=${encodeURIComponent(job.job_id)}">查看</a>
|
||||
<button type="button" data-export-job="${escapeHtml(job.job_id)}">JSON</button>
|
||||
<button type="button" data-refresh-job="${escapeHtml(job.job_id)}">刷新状态</button>
|
||||
<button type="button" data-share-job="${escapeHtml(job.job_id)}">分享</button>
|
||||
${active
|
||||
? `<button class="danger" type="button" data-cancel-job="${escapeHtml(job.job_id)}">终止</button>`
|
||||
: `<button class="danger" type="button" data-delete-job="${escapeHtml(job.job_id)}">删除</button>`}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
}
|
||||
|
||||
function showNotice(id, message, isError = false) {
|
||||
const node = document.getElementById(id);
|
||||
if (!node) return;
|
||||
node.textContent = message;
|
||||
node.classList.remove("hidden");
|
||||
node.classList.toggle("error", isError);
|
||||
}
|
||||
|
||||
async function loadCloudJobs() {
|
||||
const data = await request(`/api/history?${cloudQuery()}`);
|
||||
state.cloudTotal = Number(data.total) || 0;
|
||||
const body = document.getElementById("cloudJobs");
|
||||
body.innerHTML = data.items?.length
|
||||
? data.items.map(cloudJobRow).join("")
|
||||
: '<tr><td colspan="6" class="empty">没有符合条件的任务。</td></tr>';
|
||||
const pageNumber = Math.floor(state.cloudOffset / state.limit) + 1;
|
||||
const pageCount = Math.max(1, Math.ceil(state.cloudTotal / state.limit));
|
||||
document.getElementById("cloudPage").textContent = `第 ${pageNumber} / ${pageCount} 页,共 ${state.cloudTotal} 条`;
|
||||
document.getElementById("cloudPrev").disabled = state.cloudOffset === 0;
|
||||
document.getElementById("cloudNext").disabled = state.cloudOffset + state.limit >= state.cloudTotal;
|
||||
}
|
||||
|
||||
async function refreshCloud() {
|
||||
await loadCloudJobs();
|
||||
}
|
||||
|
||||
function historyEntriesFromJson(raw) {
|
||||
const source = Array.isArray(raw) ? raw : (Array.isArray(raw?.history) ? raw.history : [raw]);
|
||||
return source.map((entry, index) => {
|
||||
if (!entry || typeof entry !== "object") return null;
|
||||
if (entry.payload && typeof entry.payload === "object") return entry;
|
||||
if (!entry.workflow || !entry.mode) return null;
|
||||
return {
|
||||
id: entry.id || entry.job_id || `import-${Date.now()}-${index}`,
|
||||
created_at: entry.created_at || new Date().toISOString(),
|
||||
payload: entry,
|
||||
result: null,
|
||||
};
|
||||
}).filter(Boolean);
|
||||
}
|
||||
|
||||
async function exportCloudHistory() {
|
||||
const history = [];
|
||||
let offset = 0;
|
||||
while (true) {
|
||||
const page = await request(`/api/history?limit=100&offset=${offset}`);
|
||||
const summaries = page.items || [];
|
||||
const details = await Promise.all(
|
||||
summaries.map((item) => request(`/api/history/${encodeURIComponent(item.job_id)}`)),
|
||||
);
|
||||
history.push(...details.map((detail) => detail.item));
|
||||
offset += summaries.length;
|
||||
if (!summaries.length || offset >= Number(page.total || 0)) break;
|
||||
}
|
||||
downloadJson("nupack-history.json", {
|
||||
version: 2,
|
||||
exported_at: new Date().toISOString(),
|
||||
history,
|
||||
});
|
||||
showNotice("cloudNotice", `已导出 ${history.length} 条云端记录。`);
|
||||
}
|
||||
|
||||
async function exportCloudHistoryItem(jobId) {
|
||||
const detail = await request(`/api/history/${encodeURIComponent(jobId)}`);
|
||||
downloadJson(`nupack-history-${jobId}.json`, detail.item);
|
||||
showNotice("cloudNotice", `已导出任务 ${jobId.slice(0, 12)}。`);
|
||||
}
|
||||
|
||||
async function importCloudHistory(files) {
|
||||
const history = [];
|
||||
for (const file of Array.from(files || [])) {
|
||||
history.push(...historyEntriesFromJson(JSON.parse(await file.text())));
|
||||
}
|
||||
if (!history.length) throw new Error("JSON 中没有可导入的计算记录。");
|
||||
const result = await request("/api/history/import", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ history }),
|
||||
});
|
||||
state.cloudOffset = 0;
|
||||
await loadCloudJobs();
|
||||
showNotice("cloudNotice", `导入完成:新增 ${result.imported} 条记录。`);
|
||||
}
|
||||
|
||||
async function handleCloudAction(event) {
|
||||
const shareJob = event.target.closest("[data-share-job]");
|
||||
const exportJob = event.target.closest("[data-export-job]");
|
||||
const refreshJob = event.target.closest("[data-refresh-job]");
|
||||
const cancelJob = event.target.closest("[data-cancel-job]");
|
||||
const deleteJob = event.target.closest("[data-delete-job]");
|
||||
try {
|
||||
if (exportJob) {
|
||||
await exportCloudHistoryItem(exportJob.dataset.exportJob);
|
||||
} else if (refreshJob) {
|
||||
const job = await request(`/api/jobs/${encodeURIComponent(refreshJob.dataset.refreshJob)}`);
|
||||
await loadCloudJobs();
|
||||
showNotice("cloudNotice", `任务状态已刷新:${job.status}`);
|
||||
} else if (shareJob) {
|
||||
const jobId = shareJob.dataset.shareJob;
|
||||
const data = await request(`/api/jobs/${encodeURIComponent(jobId)}/shares`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ expires_in: 604800 }),
|
||||
});
|
||||
const url = new URL(data.url, window.location.origin).toString();
|
||||
await navigator.clipboard?.writeText(url);
|
||||
showNotice("cloudNotice", `分享链接已复制:${url}`);
|
||||
} else if (cancelJob) {
|
||||
if (!window.confirm("确定终止这个任务?")) return;
|
||||
await request(`/api/jobs/${encodeURIComponent(cancelJob.dataset.cancelJob)}/cancel`, { method: "POST" });
|
||||
await loadCloudJobs();
|
||||
} else if (deleteJob) {
|
||||
if (!window.confirm("确定将这个任务移入回收站?")) return;
|
||||
await request(`/api/history/${encodeURIComponent(deleteJob.dataset.deleteJob)}`, { method: "DELETE" });
|
||||
await refreshCloud();
|
||||
}
|
||||
} catch (error) {
|
||||
showNotice("cloudNotice", error.message, true);
|
||||
}
|
||||
}
|
||||
|
||||
async function initCloud() {
|
||||
await loadIdentity();
|
||||
const importInput = document.getElementById("cloudImportInput");
|
||||
document.getElementById("cloudImport").addEventListener("click", () => importInput.click());
|
||||
importInput.addEventListener("change", () => {
|
||||
importCloudHistory(importInput.files)
|
||||
.catch((error) => showNotice("cloudNotice", error.message, true))
|
||||
.finally(() => { importInput.value = ""; });
|
||||
});
|
||||
document.getElementById("cloudExport").addEventListener("click", (event) => {
|
||||
const button = event.currentTarget;
|
||||
button.disabled = true;
|
||||
exportCloudHistory()
|
||||
.catch((error) => showNotice("cloudNotice", error.message, true))
|
||||
.finally(() => { button.disabled = false; });
|
||||
});
|
||||
document.getElementById("cloudFilters").addEventListener("submit", (event) => {
|
||||
event.preventDefault();
|
||||
state.cloudOffset = 0;
|
||||
loadCloudJobs().catch((error) => showNotice("cloudNotice", error.message, true));
|
||||
});
|
||||
document.getElementById("cloudRefresh").addEventListener("click", () => {
|
||||
refreshCloud().catch((error) => showNotice("cloudNotice", error.message, true));
|
||||
});
|
||||
document.getElementById("cloudPrev").addEventListener("click", () => {
|
||||
state.cloudOffset = Math.max(0, state.cloudOffset - state.limit);
|
||||
loadCloudJobs().catch((error) => showNotice("cloudNotice", error.message, true));
|
||||
});
|
||||
document.getElementById("cloudNext").addEventListener("click", () => {
|
||||
state.cloudOffset += state.limit;
|
||||
loadCloudJobs().catch((error) => showNotice("cloudNotice", error.message, true));
|
||||
});
|
||||
document.querySelector(".content").addEventListener("click", handleCloudAction);
|
||||
await refreshCloud();
|
||||
}
|
||||
|
||||
function renderDefinitionList(node, rows) {
|
||||
node.innerHTML = rows.map(([term, value]) => `<dt>${escapeHtml(term)}</dt><dd>${escapeHtml(value ?? "-")}</dd>`).join("");
|
||||
}
|
||||
|
||||
async function initAccount() {
|
||||
const [me, health] = await Promise.all([loadIdentity(), request("/health")]);
|
||||
const user = me.user || {};
|
||||
const usage = me.usage || {};
|
||||
document.getElementById("accountTotal").textContent = usage.total_jobs ?? "-";
|
||||
document.getElementById("accountSuccess").textContent = usage.success_jobs ?? "-";
|
||||
document.getElementById("accountActive").textContent = usage.active_jobs ?? "-";
|
||||
document.getElementById("accountStorage").textContent = formatBytes(usage.stored_bytes);
|
||||
renderDefinitionList(document.getElementById("identityDetails"), [
|
||||
["显示名称", user.display_name],
|
||||
["用户名", user.username],
|
||||
["邮箱", user.email],
|
||||
["用户 ID", user.user_id],
|
||||
["用户组", (user.groups || []).join(", ") || "-"],
|
||||
]);
|
||||
const worker = health.worker_resource_plan || {};
|
||||
renderDefinitionList(document.getElementById("resourceDetails"), [
|
||||
["任务后端", health.job_backend],
|
||||
["并发来源", health.worker_concurrency_source],
|
||||
["Worker 并发", health.worker_concurrency],
|
||||
["单任务线程", health.per_job_thread_limit],
|
||||
["Worker 内存", worker.memory_gb == null ? "-" : `${worker.memory_gb} GB`],
|
||||
["单任务内存预算", worker.estimated_job_memory_gb == null ? "-" : `${worker.estimated_job_memory_gb} GB`],
|
||||
]);
|
||||
}
|
||||
|
||||
const workspacePreferences = [
|
||||
["settingsLanguage", "np_replica_lang", "zh"],
|
||||
["settingsTheme", "np_replica_theme", "harbor"],
|
||||
["settingsLayout", "np_replica_layout", "balanced"],
|
||||
["settingsHelp", "np_replica_help_visibility", "shown"],
|
||||
];
|
||||
|
||||
function loadWorkspacePreferences() {
|
||||
for (const [id, key, fallback] of workspacePreferences) {
|
||||
const control = document.getElementById(id);
|
||||
if (control) control.value = localStorage.getItem(key) || fallback;
|
||||
}
|
||||
}
|
||||
|
||||
async function initSettings() {
|
||||
await loadIdentity();
|
||||
loadWorkspacePreferences();
|
||||
const form = document.getElementById("settingsForm");
|
||||
form.addEventListener("change", (event) => {
|
||||
const preference = workspacePreferences.find(([id]) => id === event.target.id);
|
||||
if (!preference) return;
|
||||
localStorage.setItem(preference[1], event.target.value);
|
||||
showNotice("settingsNotice", "设置已保存,重新打开工作台后生效。");
|
||||
});
|
||||
document.getElementById("settingsReset").addEventListener("click", () => {
|
||||
for (const [, key] of workspacePreferences) localStorage.removeItem(key);
|
||||
loadWorkspacePreferences();
|
||||
showNotice("settingsNotice", "已恢复默认设置。");
|
||||
});
|
||||
}
|
||||
|
||||
function adminQuery() {
|
||||
const form = new FormData(document.getElementById("adminFilters"));
|
||||
const query = new URLSearchParams({
|
||||
limit: String(state.limit),
|
||||
offset: String(state.adminOffset),
|
||||
deleted: state.adminView === "trash" ? "1" : "0",
|
||||
});
|
||||
for (const [key, value] of form.entries()) {
|
||||
if (String(value).trim()) query.set(key, String(value).trim());
|
||||
}
|
||||
return query;
|
||||
}
|
||||
|
||||
function adminJobRow(job) {
|
||||
const active = ["queued", "running", "cancel_requested"].includes(job.status);
|
||||
const deleted = Boolean(job.deleted_at);
|
||||
const actions = deleted
|
||||
? `<button type="button" data-admin-action="restore" data-job-id="${escapeHtml(job.job_id)}">恢复</button>
|
||||
<button class="danger" type="button" data-admin-action="delete" data-job-id="${escapeHtml(job.job_id)}">永久删除</button>`
|
||||
: `${active ? `<button class="danger" type="button" data-admin-action="cancel" data-job-id="${escapeHtml(job.job_id)}">停止</button>` : ""}
|
||||
${active ? "" : `<button class="danger" type="button" data-admin-action="trash" data-job-id="${escapeHtml(job.job_id)}">移入回收站</button>`}`;
|
||||
return `
|
||||
<tr>
|
||||
<td>${escapeHtml(job.display_name || job.username || job.email || job.user_id)}<br><span class="mono">${escapeHtml(job.user_id)}</span></td>
|
||||
<td class="mono">${escapeHtml(job.job_id)}</td>
|
||||
<td>${escapeHtml(job.workflow)} / ${escapeHtml(job.mode)}<br>${escapeHtml(job.material)} · max ${escapeHtml(job.max_size)}</td>
|
||||
<td>${statusBadge(job.status)}</td>
|
||||
<td>${escapeHtml(formatDate(job.created_at))}${deleted ? `<br><span class="mono">删除 ${escapeHtml(formatDate(job.deleted_at))}<br>清理 ${escapeHtml(formatDate(job.purge_after))}</span>` : ""}</td>
|
||||
<td>${escapeHtml(formatDuration(job.elapsed_seconds))}</td>
|
||||
<td><div class="toolbar">${actions}</div><span class="mono">${escapeHtml(formatBytes(job.stored_bytes))}</span></td>
|
||||
</tr>
|
||||
`;
|
||||
}
|
||||
|
||||
async function loadAdminJobs() {
|
||||
const data = await request(`/api/admin/jobs?${adminQuery()}`);
|
||||
state.adminTotal = Number(data.total) || 0;
|
||||
document.getElementById("adminTotal").textContent = state.adminTotal;
|
||||
document.getElementById("adminRunning").textContent = data.live?.running ?? "-";
|
||||
document.getElementById("adminQueued").textContent = data.live?.queued ?? "-";
|
||||
document.getElementById("adminConcurrency").textContent = data.worker?.concurrency ?? "-";
|
||||
const body = document.getElementById("adminJobs");
|
||||
body.innerHTML = data.items?.length
|
||||
? data.items.map(adminJobRow).join("")
|
||||
: '<tr><td colspan="7" class="empty">没有符合条件的任务。</td></tr>';
|
||||
const pageNumber = Math.floor(state.adminOffset / state.limit) + 1;
|
||||
const pageCount = Math.max(1, Math.ceil(state.adminTotal / state.limit));
|
||||
document.getElementById("adminPage").textContent = `第 ${pageNumber} / ${pageCount} 页,共 ${state.adminTotal} 条`;
|
||||
document.getElementById("adminPrev").disabled = state.adminOffset === 0;
|
||||
document.getElementById("adminNext").disabled = state.adminOffset + state.limit >= state.adminTotal;
|
||||
}
|
||||
|
||||
async function loadAdminSettings() {
|
||||
const data = await request("/api/admin/settings");
|
||||
document.getElementById("trashRetentionDays").value = Number(data.trash_retention_days).toFixed(2).replace(/\.00$/, "");
|
||||
}
|
||||
|
||||
async function handleAdminAction(event) {
|
||||
const button = event.target.closest("[data-admin-action]");
|
||||
if (!button) return;
|
||||
const action = button.dataset.adminAction;
|
||||
const labels = { cancel: "停止", trash: "移入回收站", restore: "恢复", delete: "永久删除" };
|
||||
if (!window.confirm(`确定${labels[action]}这个任务?`)) return;
|
||||
try {
|
||||
await request(`/api/admin/jobs/${encodeURIComponent(button.dataset.jobId)}/${action}`, { method: "POST" });
|
||||
showNotice("adminNotice", `任务已${labels[action]}。`);
|
||||
await loadAdminJobs();
|
||||
} catch (error) {
|
||||
showNotice("adminNotice", error.message, true);
|
||||
}
|
||||
}
|
||||
|
||||
async function initAdminLogin() {
|
||||
document.getElementById("adminLoginForm").addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
try {
|
||||
const data = await request("/api/admin/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ password: document.getElementById("adminPassword").value }),
|
||||
});
|
||||
window.location.assign(data.url || "/admin/panel");
|
||||
} catch (error) {
|
||||
showNotice("adminLoginNotice", error.message, true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function initAdmin() {
|
||||
document.getElementById("adminFilters").addEventListener("submit", (event) => {
|
||||
event.preventDefault();
|
||||
state.adminOffset = 0;
|
||||
loadAdminJobs().catch((error) => showNotice("adminNotice", error.message, true));
|
||||
});
|
||||
document.getElementById("adminRefresh").addEventListener("click", () => {
|
||||
loadAdminJobs().catch((error) => showNotice("adminNotice", error.message, true));
|
||||
});
|
||||
document.getElementById("adminPrev").addEventListener("click", () => {
|
||||
state.adminOffset = Math.max(0, state.adminOffset - state.limit);
|
||||
loadAdminJobs().catch((error) => showNotice("adminNotice", error.message, true));
|
||||
});
|
||||
document.getElementById("adminNext").addEventListener("click", () => {
|
||||
state.adminOffset += state.limit;
|
||||
loadAdminJobs().catch((error) => showNotice("adminNotice", error.message, true));
|
||||
});
|
||||
document.querySelector(".content").addEventListener("click", handleAdminAction);
|
||||
document.querySelectorAll("[data-admin-view]").forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
state.adminView = button.dataset.adminView;
|
||||
state.adminOffset = 0;
|
||||
document.querySelectorAll("[data-admin-view]").forEach((item) => item.classList.toggle("active", item === button));
|
||||
loadAdminJobs().catch((error) => showNotice("adminNotice", error.message, true));
|
||||
});
|
||||
});
|
||||
document.getElementById("trashSettings").addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
try {
|
||||
await request("/api/admin/settings", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ trash_retention_days: Number(document.getElementById("trashRetentionDays").value) }),
|
||||
});
|
||||
showNotice("adminNotice", "回收站保留时间已保存。");
|
||||
} catch (error) {
|
||||
showNotice("adminNotice", error.message, true);
|
||||
}
|
||||
});
|
||||
document.getElementById("adminLogout").addEventListener("click", async () => {
|
||||
await request("/api/admin/logout", { method: "POST" });
|
||||
window.location.assign("/admin");
|
||||
});
|
||||
await Promise.all([loadAdminJobs(), loadAdminSettings()]);
|
||||
}
|
||||
|
||||
async function initialize() {
|
||||
renderNavigation();
|
||||
if (page === "home") await initHome();
|
||||
else if (page === "cloud") await initCloud();
|
||||
else if (page === "account") await initAccount();
|
||||
else if (page === "settings") await initSettings();
|
||||
else if (page === "admin") await initAdmin();
|
||||
else if (page === "admin-login") await initAdminLogin();
|
||||
}
|
||||
|
||||
initialize().catch((error) => {
|
||||
const target = document.querySelector(".content") || document.body;
|
||||
const notice = document.createElement("div");
|
||||
notice.className = "notice error";
|
||||
notice.textContent = error.message;
|
||||
target.appendChild(notice);
|
||||
});
|
||||
|
|
@ -1,378 +0,0 @@
|
|||
/* Focused workbench layer. Calculation and account behaviour stay in index.html. */
|
||||
|
||||
body {
|
||||
background: var(--bg-end);
|
||||
}
|
||||
|
||||
.workspace-nav {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 20;
|
||||
min-height: 62px;
|
||||
padding: 0 28px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: color-mix(in srgb, var(--panel-strong) 94%, transparent);
|
||||
box-shadow: 0 6px 20px rgba(20, 35, 28, 0.06);
|
||||
backdrop-filter: blur(18px);
|
||||
}
|
||||
|
||||
.workspace-nav > a {
|
||||
position: relative;
|
||||
min-height: 62px;
|
||||
padding: 21px 14px 18px;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.workspace-nav > a:first-child {
|
||||
margin-right: 16px;
|
||||
padding-left: 0;
|
||||
color: var(--ink);
|
||||
font-size: 17px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.workspace-nav > a:hover,
|
||||
.workspace-nav > a.active {
|
||||
color: var(--accent-strong);
|
||||
}
|
||||
|
||||
.workspace-nav > a.active::after {
|
||||
position: absolute;
|
||||
right: 14px;
|
||||
bottom: 0;
|
||||
left: 14px;
|
||||
height: 3px;
|
||||
border-radius: 3px 3px 0 0;
|
||||
background: var(--accent);
|
||||
content: "";
|
||||
}
|
||||
|
||||
.shell {
|
||||
max-width: 1500px;
|
||||
padding: 28px 32px 58px;
|
||||
}
|
||||
|
||||
.hero {
|
||||
gap: 15px;
|
||||
margin-bottom: 20px;
|
||||
padding: 23px 25px 20px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
background: var(--panel-strong);
|
||||
box-shadow: 0 12px 32px rgba(20, 35, 28, 0.08);
|
||||
}
|
||||
|
||||
.hero-top {
|
||||
min-height: 34px;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.hero-top .account-chip,
|
||||
.hero-top .toolbar,
|
||||
#usagePanel {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.hero h1 {
|
||||
font-size: 30px;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.hero > p {
|
||||
display: block;
|
||||
max-width: 760px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.queue-panel {
|
||||
grid-template-columns: repeat(4, minmax(130px, 1fr));
|
||||
gap: 0;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: color-mix(in srgb, var(--panel) 82%, var(--accent-soft));
|
||||
}
|
||||
|
||||
.queue-panel .metric {
|
||||
min-height: 74px;
|
||||
padding: 14px 16px;
|
||||
border: 0;
|
||||
border-right: 1px solid var(--line);
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.queue-panel .metric:last-child {
|
||||
border-right: 0;
|
||||
}
|
||||
|
||||
.queue-panel .metric strong {
|
||||
margin-bottom: 8px;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.queue-panel .metric-value {
|
||||
color: var(--ink);
|
||||
font-size: 20px;
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.layout {
|
||||
grid-template-columns: minmax(370px, 450px) minmax(0, 1fr);
|
||||
gap: 20px;
|
||||
min-height: calc(100vh - 148px);
|
||||
}
|
||||
|
||||
.panel {
|
||||
border-radius: 10px;
|
||||
background: var(--panel-strong);
|
||||
box-shadow: 0 12px 32px rgba(20, 35, 28, 0.07);
|
||||
backdrop-filter: none;
|
||||
}
|
||||
|
||||
.panel-inner {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.control-panel {
|
||||
top: 82px;
|
||||
}
|
||||
|
||||
.control-panel .panel-inner {
|
||||
counter-reset: workbench-step;
|
||||
}
|
||||
|
||||
.section {
|
||||
padding-bottom: 22px;
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
|
||||
.section h2 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
margin-bottom: 14px;
|
||||
font-size: 16px;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
#workflowModeSection h2::before,
|
||||
#modelSection h2::before,
|
||||
#strandSection h2::before,
|
||||
#computeSection h2::before,
|
||||
#designSection > h2::before {
|
||||
display: inline-grid;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
place-items: center;
|
||||
border-radius: 50%;
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent-strong);
|
||||
content: counter(workbench-step, decimal-leading-zero);
|
||||
counter-increment: workbench-step;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
label {
|
||||
gap: 7px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
border-radius: 7px;
|
||||
border-color: var(--line);
|
||||
padding: 10px 11px;
|
||||
box-shadow: inset 0 1px 1px rgba(20, 35, 28, 0.025);
|
||||
}
|
||||
|
||||
input:focus,
|
||||
select:focus,
|
||||
textarea:focus {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 13%, transparent);
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
.strand-card,
|
||||
.result-card {
|
||||
border-radius: 8px;
|
||||
border-color: var(--line);
|
||||
background: var(--card-strong);
|
||||
box-shadow: 0 4px 14px rgba(20, 35, 28, 0.04);
|
||||
}
|
||||
|
||||
.strand-card {
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.sequence-field textarea {
|
||||
min-height: 98px;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.section-note,
|
||||
.minor-note {
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.design-preflight {
|
||||
border-radius: 8px;
|
||||
border-color: color-mix(in srgb, var(--accent) 20%, var(--line));
|
||||
background: color-mix(in srgb, var(--accent-soft) 55%, var(--card-strong));
|
||||
}
|
||||
|
||||
.control-panel > .panel-inner > .actions {
|
||||
bottom: -20px;
|
||||
margin: 0 -20px 20px;
|
||||
padding: 13px 20px;
|
||||
border-color: var(--line);
|
||||
}
|
||||
|
||||
.control-panel > .panel-inner > .actions #runBtn {
|
||||
min-width: 150px;
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.results-panel > .panel-inner {
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.results {
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.result-card h3 {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.meta-chip {
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.layout {
|
||||
grid-template-columns: minmax(330px, 410px) minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.workspace-nav {
|
||||
overflow-x: auto;
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.shell {
|
||||
padding: 20px 16px 44px;
|
||||
}
|
||||
|
||||
.layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.control-panel {
|
||||
position: static;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
body {
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.hero,
|
||||
.layout,
|
||||
.panel {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.workspace-nav {
|
||||
max-width: 100vw;
|
||||
}
|
||||
|
||||
.workspace-nav > a:first-child {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.shell {
|
||||
width: 100%;
|
||||
max-width: 100vw;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.hero {
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.hero-top {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.hero-top .pill {
|
||||
justify-self: start;
|
||||
}
|
||||
|
||||
.hero h1 {
|
||||
font-size: 25px;
|
||||
}
|
||||
|
||||
.queue-panel {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.queue-panel .metric:nth-child(2) {
|
||||
border-right: 0;
|
||||
}
|
||||
|
||||
.queue-panel .metric:nth-child(-n + 2) {
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.panel-inner {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.grid.cols-2 {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.strand-meta-grid {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.strand-meta-grid .remove-btn {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.control-panel > .panel-inner > .actions {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.control-panel > .panel-inner > .actions #runBtn {
|
||||
grid-column: 1 / -1;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.control-panel > .panel-inner > .actions button {
|
||||
min-width: 0;
|
||||
padding-right: 10px;
|
||||
padding-left: 10px;
|
||||
line-height: 1.3;
|
||||
white-space: normal;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,186 +0,0 @@
|
|||
import tempfile
|
||||
import time
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from service.account import AccountStore, DEFAULT_TRASH_RETENTION_SECONDS
|
||||
|
||||
|
||||
def user(user_id):
|
||||
return {
|
||||
"user_id": user_id,
|
||||
"username": user_id,
|
||||
"display_name": user_id.title(),
|
||||
"email": f"{user_id}@example.test",
|
||||
"groups": [],
|
||||
}
|
||||
|
||||
|
||||
class AccountStoreTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temporary_directory = tempfile.TemporaryDirectory()
|
||||
self.store = AccountStore(Path(self.temporary_directory.name) / "account.sqlite3")
|
||||
self.alice = user("alice")
|
||||
self.bob = user("bob")
|
||||
self.payload = {
|
||||
"workflow": "analysis",
|
||||
"mode": "tube",
|
||||
"model": {"material": "rna", "celsius": 37, "sodium": 1, "magnesium": 0},
|
||||
"strands": [{"name": "A", "sequence": "ACGU"}],
|
||||
"complexes_text": "A",
|
||||
"tube": {"max_size": 1},
|
||||
"compute": ["mfe"],
|
||||
}
|
||||
|
||||
def tearDown(self):
|
||||
self.temporary_directory.cleanup()
|
||||
|
||||
def test_jobs_are_private_and_content_round_trips(self):
|
||||
result = {"workflow": "analysis", "complex_results": [{"name": "A"}]}
|
||||
self.store.create_job("job-1", self.alice, self.payload)
|
||||
self.store.update_job("job-1", "success", result=result, elapsed_seconds=1.25)
|
||||
|
||||
self.assertIsNone(self.store.get_job(self.bob["user_id"], "job-1"))
|
||||
item = self.store.get_job(self.alice["user_id"], "job-1")
|
||||
self.assertEqual(item["payload"], self.payload)
|
||||
self.assertEqual(item["result"], result)
|
||||
self.assertEqual(item["elapsed_seconds"], 1.25)
|
||||
self.assertEqual(self.store.list_jobs("alice")["total"], 1)
|
||||
self.assertEqual(self.store.list_jobs("bob")["total"], 0)
|
||||
|
||||
def test_share_can_expire_and_be_deleted(self):
|
||||
self.store.create_job("job-2", self.alice, self.payload, status="success", result={"ok": True})
|
||||
share = self.store.create_share("alice", "job-2")
|
||||
resolved = self.store.resolve_share(share["share_id"])
|
||||
self.assertEqual(resolved["job_id"], "job-2")
|
||||
self.assertEqual(resolved["result"], {"ok": True})
|
||||
|
||||
self.store.update_share("alice", share["share_id"], active=False)
|
||||
self.assertIsNone(self.store.resolve_share(share["share_id"]))
|
||||
self.assertIsNone(self.store.get_share_for_owner("alice", share["share_id"]))
|
||||
self.assertIsNone(self.store.update_share("bob", share["share_id"], active=True))
|
||||
|
||||
expiring = self.store.create_share("alice", "job-2", expires_in=1)
|
||||
with self.store._connect() as connection:
|
||||
connection.execute(
|
||||
"UPDATE shares SET expires_at=? WHERE share_id=?",
|
||||
(time.time() - 1, expiring["share_id"]),
|
||||
)
|
||||
self.assertIsNone(self.store.resolve_share(expiring["share_id"]))
|
||||
|
||||
def test_create_share_reuses_existing_active_link(self):
|
||||
self.store.create_job("job-share-once", self.alice, self.payload, status="success")
|
||||
first = self.store.create_share("alice", "job-share-once", expires_in=604800)
|
||||
second = self.store.create_share("alice", "job-share-once", expires_in=604800)
|
||||
self.assertEqual(first["share_id"], second["share_id"])
|
||||
self.assertEqual(len(self.store.list_shares("alice")), 1)
|
||||
|
||||
def test_running_job_can_be_shared_and_later_exposes_result(self):
|
||||
self.store.create_job("job-running", self.alice, self.payload, status="running")
|
||||
share = self.store.create_share("alice", "job-running")
|
||||
|
||||
resolved = self.store.resolve_share(share["share_id"])
|
||||
self.assertEqual(resolved["job_id"], "job-running")
|
||||
self.assertEqual(resolved["status"], "running")
|
||||
self.assertIsNone(resolved["result"])
|
||||
|
||||
self.store.update_job("job-running", "success", result={"ok": True}, elapsed_seconds=2.5)
|
||||
resolved = self.store.resolve_share(share["share_id"])
|
||||
self.assertEqual(resolved["status"], "success")
|
||||
self.assertEqual(resolved["result"], {"ok": True})
|
||||
self.assertEqual(resolved["elapsed_seconds"], 2.5)
|
||||
|
||||
self.assertIsNone(self.store.update_share("bob", share["share_id"], active=False))
|
||||
self.store.update_share("alice", share["share_id"], active=False)
|
||||
self.assertIsNone(self.store.resolve_share(share["share_id"]))
|
||||
|
||||
def test_active_job_cannot_be_deleted(self):
|
||||
self.store.create_job("job-3", self.alice, self.payload)
|
||||
with self.assertRaises(ValueError):
|
||||
self.store.delete_job("alice", "job-3")
|
||||
self.store.update_job("job-3", "canceled", error={"message": "canceled"})
|
||||
self.assertTrue(self.store.delete_job("alice", "job-3"))
|
||||
|
||||
def test_deleted_job_moves_to_trash_and_deletes_shares(self):
|
||||
self.assertEqual(self.store.trash_retention_seconds(), DEFAULT_TRASH_RETENTION_SECONDS)
|
||||
self.store.create_job("job-trash", self.alice, self.payload, status="success", result={"ok": True})
|
||||
share = self.store.create_share("alice", "job-trash")
|
||||
|
||||
self.assertTrue(self.store.delete_job("alice", "job-trash"))
|
||||
self.assertIsNone(self.store.get_job("alice", "job-trash"))
|
||||
self.assertEqual(self.store.list_jobs("alice")["total"], 0)
|
||||
self.assertIsNone(self.store.resolve_share(share["share_id"]))
|
||||
self.assertIsNone(self.store.get_share_for_owner("alice", share["share_id"]))
|
||||
self.assertEqual(self.store.list_shares("alice"), [])
|
||||
|
||||
trash = self.store.list_all_jobs({"deleted": "1"})
|
||||
self.assertEqual(trash["total"], 1)
|
||||
self.assertEqual(trash["items"][0]["job_id"], "job-trash")
|
||||
self.assertIsNotNone(trash["items"][0]["deleted_at"])
|
||||
self.assertAlmostEqual(
|
||||
trash["items"][0]["purge_after"] - trash["items"][0]["deleted_at"],
|
||||
DEFAULT_TRASH_RETENTION_SECONDS,
|
||||
delta=1,
|
||||
)
|
||||
|
||||
def test_initialize_removes_legacy_shares_for_trashed_jobs(self):
|
||||
self.store.create_job("job-legacy", self.alice, self.payload, status="success")
|
||||
share = self.store.create_share("alice", "job-legacy")
|
||||
with self.store._connect() as connection:
|
||||
connection.execute(
|
||||
"UPDATE jobs SET deleted_at=?,purge_after=? WHERE job_id=?",
|
||||
(time.time(), time.time() + 86400, "job-legacy"),
|
||||
)
|
||||
self.store._initialized = False
|
||||
self.store.initialize()
|
||||
self.assertIsNone(self.store.get_share_for_owner("alice", share["share_id"]))
|
||||
self.assertEqual(self.store.list_shares("alice"), [])
|
||||
|
||||
def test_restore_and_permanent_delete_complete_trash_lifecycle(self):
|
||||
self.store.create_job("job-live", self.alice, self.payload, status="success")
|
||||
self.assertFalse(self.store.permanently_delete_job("job-live"))
|
||||
|
||||
self.store.create_job("job-restore", self.alice, self.payload, status="success")
|
||||
self.store.delete_job("alice", "job-restore")
|
||||
self.assertTrue(self.store.restore_job("job-restore"))
|
||||
self.assertIsNotNone(self.store.get_job("alice", "job-restore"))
|
||||
self.assertEqual(self.store.list_all_jobs({"deleted": "1"})["total"], 0)
|
||||
|
||||
self.store.delete_job("alice", "job-restore")
|
||||
self.assertTrue(self.store.permanently_delete_job("job-restore"))
|
||||
self.assertFalse(self.store.permanently_delete_job("job-restore"))
|
||||
self.assertIsNone(self.store.get_job("alice", "job-restore", include_deleted=True))
|
||||
|
||||
def test_retention_setting_and_expired_job_purge(self):
|
||||
self.assertEqual(self.store.set_trash_retention_days(3.5), int(3.5 * 86400))
|
||||
self.assertEqual(self.store.trash_retention_seconds(), int(3.5 * 86400))
|
||||
with self.assertRaises(ValueError):
|
||||
self.store.set_trash_retention_days(0)
|
||||
|
||||
self.store.create_job("job-expired", self.alice, self.payload, status="success")
|
||||
self.store.delete_job("alice", "job-expired")
|
||||
with self.store._connect() as connection:
|
||||
connection.execute(
|
||||
"UPDATE jobs SET purge_after=? WHERE job_id=?",
|
||||
(time.time() - 1, "job-expired"),
|
||||
)
|
||||
self.assertEqual(self.store.purge_expired_jobs(), 1)
|
||||
self.assertIsNone(self.store.get_job("alice", "job-expired", include_deleted=True))
|
||||
|
||||
def test_admin_listing_includes_all_users_and_supports_filters(self):
|
||||
self.store.create_job("alice-job", self.alice, self.payload, status="running")
|
||||
bob_payload = {**self.payload, "workflow": "design"}
|
||||
self.store.create_job("bob-job", self.bob, bob_payload, status="success")
|
||||
|
||||
listing = self.store.list_all_jobs({"limit": 10})
|
||||
self.assertEqual(listing["total"], 2)
|
||||
self.assertEqual({item["user_id"] for item in listing["items"]}, {"alice", "bob"})
|
||||
self.assertTrue(all("username" in item for item in listing["items"]))
|
||||
|
||||
filtered = self.store.list_all_jobs({"q": self.bob["email"], "workflow": "design"})
|
||||
self.assertEqual(filtered["total"], 1)
|
||||
self.assertEqual(filtered["items"][0]["job_id"], "bob-job")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
@ -1,525 +0,0 @@
|
|||
import copy
|
||||
import os
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
from unittest import mock
|
||||
|
||||
os.environ.setdefault("NP_ACCOUNT_DB_PATH", "/tmp/np-replica-calculation-test.sqlite3")
|
||||
os.environ.setdefault("ENABLE_RNAPLOT", "0")
|
||||
|
||||
import server
|
||||
from nupack import Complex, Model, SetSpec, Strand, Tube, pfunc, tube_analysis
|
||||
|
||||
|
||||
def analysis_payload():
|
||||
payload = copy.deepcopy(server.EXAMPLE_PAYLOAD)
|
||||
payload["model"] = {
|
||||
"material": "rna",
|
||||
"ensemble": "stacking",
|
||||
"celsius": 37,
|
||||
"sodium": 1.0,
|
||||
"magnesium": 0.0,
|
||||
}
|
||||
payload["strands"] = [
|
||||
{"name": "A", "sequence": "ACG", "concentration": 1.0, "unit": "uM"},
|
||||
{"name": "B", "sequence": "CGU", "concentration": 0.5, "unit": "uM"},
|
||||
]
|
||||
payload["compute"] = ["pfunc", "pairs"]
|
||||
payload["options"].update(
|
||||
{
|
||||
"sparsity_fraction": 1.0,
|
||||
"sparsity_threshold": 0.0,
|
||||
"result_limit": 99,
|
||||
"pairs_preview_size": 24,
|
||||
}
|
||||
)
|
||||
payload["tube"] = {
|
||||
"name": "tube-test",
|
||||
"max_size": 2,
|
||||
"include_complexes": "A+B+B",
|
||||
"exclude_complexes": "A+A",
|
||||
}
|
||||
return payload
|
||||
|
||||
|
||||
class OfficialTubePipelineTest(unittest.TestCase):
|
||||
def test_replica_matches_direct_tube_analysis(self):
|
||||
payload = analysis_payload()
|
||||
replica = server.run_job_payload(payload)
|
||||
|
||||
model = Model(**payload["model"])
|
||||
a = Strand("ACG", name="A")
|
||||
b = Strand("CGU", name="B")
|
||||
included = Complex([a, b, b], name="included")
|
||||
excluded = Complex([a, a], name="excluded")
|
||||
tube = Tube(
|
||||
strands={a: 1e-6, b: 0.5e-6},
|
||||
complexes=SetSpec(max_size=2, include=[included], exclude=[excluded]),
|
||||
name="tube-test",
|
||||
)
|
||||
direct = tube_analysis(
|
||||
[tube],
|
||||
model=model,
|
||||
compute=["pairs"],
|
||||
options={
|
||||
"num_sample": 20,
|
||||
"energy_gap": 1.0,
|
||||
"sparsity_fraction": 1.0,
|
||||
"sparsity_threshold": 0.0,
|
||||
"single_mfe": False,
|
||||
},
|
||||
)
|
||||
|
||||
expected = {
|
||||
server.stringify_complex(complex_obj): float(value)
|
||||
for complex_obj, value in direct[tube].complex_concentrations.items()
|
||||
}
|
||||
actual = {
|
||||
row["display"]: row["concentration_M"]
|
||||
for row in replica["tube"]["complex_concentrations"]
|
||||
}
|
||||
|
||||
self.assertEqual(set(actual), set(expected))
|
||||
for name, value in expected.items():
|
||||
self.assertAlmostEqual(actual[name], value, places=18)
|
||||
|
||||
self.assertEqual(replica["tube"]["include_complexes"], ["A + B + B"])
|
||||
self.assertEqual(replica["tube"]["exclude_complexes"], ["A + A"])
|
||||
self.assertIsNotNone(replica["tube"]["ensemble_pair_fractions"])
|
||||
self.assertAlmostEqual(
|
||||
replica["tube"]["fraction_bases_unpaired"],
|
||||
float(direct[tube].fraction_bases_unpaired),
|
||||
places=12,
|
||||
)
|
||||
self.assertEqual(replica["performance"]["pipeline"], "tube_analysis")
|
||||
|
||||
def test_result_limit_is_display_only(self):
|
||||
payload = analysis_payload()
|
||||
payload["options"]["result_limit"] = 1
|
||||
replica = server.run_job_payload(payload)
|
||||
|
||||
self.assertGreater(replica["total_complex_count"], 1)
|
||||
self.assertEqual(replica["displayed_complex_count"], 1)
|
||||
self.assertEqual(len(replica["tube"]["complex_concentrations"]), 1)
|
||||
self.assertIsNotNone(replica["tube"]["ensemble_pair_fractions"])
|
||||
|
||||
def test_analysis_rejects_iupac_ambiguity_codes(self):
|
||||
payload = analysis_payload()
|
||||
payload["strands"][0]["sequence"] = "ACN"
|
||||
with self.assertRaisesRegex(ValueError, "only A, C, G, T, or U"):
|
||||
server.run_job_payload(payload)
|
||||
|
||||
|
||||
class OfficialUtilitiesPipelineTest(unittest.TestCase):
|
||||
def test_pfunc_matches_direct_utility(self):
|
||||
payload = copy.deepcopy(server.UTILITIES_EXAMPLE_PAYLOAD)
|
||||
payload["utility"]["operation"] = "pfunc"
|
||||
replica = server.run_job_payload(payload)
|
||||
direct_partition, direct_free_energy = pfunc(
|
||||
[payload["strands"][0]["sequence"]],
|
||||
Model(**payload["model"]),
|
||||
)
|
||||
self.assertEqual(replica["utility"]["partition_function"], str(direct_partition))
|
||||
self.assertAlmostEqual(
|
||||
replica["utility"]["free_energy_kcal_per_mol"],
|
||||
float(direct_free_energy),
|
||||
places=12,
|
||||
)
|
||||
|
||||
def test_distance_uses_two_explicit_inputs(self):
|
||||
payload = copy.deepcopy(server.UTILITIES_EXAMPLE_PAYLOAD)
|
||||
payload["strands"] = []
|
||||
payload["utility"].update(operation="seq_distance", input_a="AAAA", input_b="AAAU")
|
||||
replica = server.run_job_payload(payload)
|
||||
self.assertEqual(replica["utility"]["distance"], 1)
|
||||
|
||||
def test_pairs_matches_direct_utility(self):
|
||||
payload = copy.deepcopy(server.UTILITIES_EXAMPLE_PAYLOAD)
|
||||
payload["utility"]["operation"] = "pairs"
|
||||
payload["options"].update(sparsity_fraction=1.0, sparsity_threshold=0.0)
|
||||
replica = server.run_job_payload(payload)
|
||||
direct = server.nupack.pairs(
|
||||
[payload["strands"][0]["sequence"]],
|
||||
Model(**payload["model"]),
|
||||
sparsity_fraction=1.0,
|
||||
sparsity_threshold=0.0,
|
||||
)
|
||||
expected = server.serialize_pairs(direct, preview_limit=payload["options"]["pairs_preview_size"])
|
||||
self.assertEqual(replica["utility"]["pairs"]["shape"], expected["shape"])
|
||||
self.assertEqual(replica["utility"]["pairs"]["preview"], expected["preview"])
|
||||
|
||||
|
||||
class OfficialInputValidationTest(unittest.TestCase):
|
||||
def test_model_rejects_non_finite_and_invalid_physical_values(self):
|
||||
for field, value, message in (
|
||||
("celsius", float("nan"), "finite"),
|
||||
("celsius", -273.15, "absolute zero"),
|
||||
("sodium", float("inf"), "finite"),
|
||||
("sodium", -0.01, "non-negative"),
|
||||
("magnesium", -0.01, "non-negative"),
|
||||
):
|
||||
payload = analysis_payload()
|
||||
payload["model"][field] = value
|
||||
with self.subTest(field=field, value=value):
|
||||
with self.assertRaisesRegex(ValueError, message):
|
||||
server.run_job_payload(payload)
|
||||
|
||||
def test_probability_options_reject_values_outside_unit_interval(self):
|
||||
for field, value in (("sparsity_fraction", -0.1), ("sparsity_threshold", 1.01)):
|
||||
payload = analysis_payload()
|
||||
payload["options"][field] = value
|
||||
with self.subTest(field=field):
|
||||
with self.assertRaisesRegex(ValueError, "between 0 and 1"):
|
||||
server.run_job_payload(payload)
|
||||
|
||||
def test_analysis_concentration_must_be_finite(self):
|
||||
payload = analysis_payload()
|
||||
payload["strands"][0]["concentration"] = float("nan")
|
||||
with self.assertRaisesRegex(ValueError, "finite"):
|
||||
server.run_job_payload(payload)
|
||||
|
||||
|
||||
class Nupack41FeatureTest(unittest.TestCase):
|
||||
def test_material_specific_salt_rules_and_provenance(self):
|
||||
merna = server.parse_model_input({"material": "merna06", "celsius": 37})
|
||||
self.assertEqual(merna["sodium"], 0.12)
|
||||
self.assertEqual(merna["magnesium"], 0.0)
|
||||
with self.assertRaisesRegex(ValueError, "must be 0.12 M"):
|
||||
server.parse_model_input({"material": "merna06", "sodium": 1.0, "magnesium": 0})
|
||||
with self.assertRaisesRegex(ValueError, "between 0.12 and 1 M"):
|
||||
server.parse_model_input({"material": "rna-dna06", "sodium": 0.1, "magnesium": 0})
|
||||
|
||||
summary = server.build_model_summary({"material": "dna", "sodium": 1, "magnesium": 0})
|
||||
self.assertEqual(summary["resolved_material"], "dna04.3")
|
||||
self.assertEqual(summary["nupack_version"], "4.1.0.1")
|
||||
|
||||
def test_mixed_material_utility_preserves_prefixes_and_counts_bases(self):
|
||||
payload = copy.deepcopy(server.UTILITIES_EXAMPLE_PAYLOAD)
|
||||
payload["model"].update(material="rna-dna06", sodium=0.5, magnesium=0.0)
|
||||
payload["strands"] = [{"name": "hybrid", "sequence": "rACGdAT"}]
|
||||
payload["utility"]["operation"] = "pfunc"
|
||||
replica = server.run_job_payload(payload)
|
||||
|
||||
direct_partition, direct_free_energy = pfunc(
|
||||
["rACGdAT"], Model(material="rna-dna06", sodium=0.5, magnesium=0.0)
|
||||
)
|
||||
self.assertEqual(replica["strands"][0]["sequence"], "rACGdAT")
|
||||
self.assertEqual(replica["performance"]["workload"]["input_nucleotides"], 5)
|
||||
self.assertEqual(replica["utility"]["partition_function"], str(direct_partition))
|
||||
self.assertAlmostEqual(replica["utility"]["free_energy_kcal_per_mol"], float(direct_free_energy), places=12)
|
||||
|
||||
def test_mixed_material_complex_analysis_uses_true_nucleotide_lengths(self):
|
||||
payload = analysis_payload()
|
||||
payload["mode"] = "complex"
|
||||
payload["model"].update(material="rna-dna06", sodium=0.5, magnesium=0.0)
|
||||
payload["strands"] = [
|
||||
{"name": "H", "sequence": "rACGdAT", "concentration": 1.0, "unit": "uM"}
|
||||
]
|
||||
payload["complexes_text"] = "H"
|
||||
payload["compute"] = ["pfunc"]
|
||||
replica = server.run_job_payload(payload)
|
||||
|
||||
self.assertEqual(replica["performance"]["workload"]["input_nucleotides"], 5)
|
||||
self.assertEqual(replica["performance"]["workload"]["largest_complex_nucleotides"], 5)
|
||||
self.assertEqual(replica["complexes"][0]["strand_lengths"], [5])
|
||||
self.assertEqual(replica["model"]["resolved_material"], "rna-dna06")
|
||||
|
||||
def test_mixed_material_requires_explicit_lowercase_prefix(self):
|
||||
payload = copy.deepcopy(server.UTILITIES_EXAMPLE_PAYLOAD)
|
||||
payload["model"].update(material="rna-dna06", sodium=1.0, magnesium=0.0)
|
||||
payload["strands"] = [{"name": "hybrid", "sequence": "ACGdAT"}]
|
||||
payload["utility"]["operation"] = "pfunc"
|
||||
with self.assertRaisesRegex(ValueError, "explicit lowercase material prefixes"):
|
||||
server.run_job_payload(payload)
|
||||
|
||||
def test_mfe_new_controls_are_forwarded(self):
|
||||
payload = copy.deepcopy(server.UTILITIES_EXAMPLE_PAYLOAD)
|
||||
payload["utility"]["operation"] = "mfe"
|
||||
payload["options"].update(max_subopt_count=4321, indistinguishable_search=True)
|
||||
with mock.patch.object(server.nupack, "mfe", return_value=[]) as mfe:
|
||||
replica = server.run_job_payload(payload)
|
||||
mfe.assert_called_once_with(
|
||||
[payload["strands"][0]["sequence"]],
|
||||
mock.ANY,
|
||||
max_subopt_count=4321,
|
||||
indistinguishable_search=True,
|
||||
)
|
||||
self.assertEqual(replica["options"]["max_subopt_count"], 4321)
|
||||
self.assertTrue(replica["options"]["indistinguishable_search"])
|
||||
|
||||
def test_max_subopt_count_has_a_server_safety_bound(self):
|
||||
payload = analysis_payload()
|
||||
payload["options"]["max_subopt_count"] = 1000001
|
||||
with self.assertRaisesRegex(ValueError, "between 1 and 1000000"):
|
||||
server.run_job_payload(payload)
|
||||
|
||||
def test_analysis_complex_bonus_shifts_free_energy(self):
|
||||
payload = analysis_payload()
|
||||
payload["mode"] = "complex"
|
||||
payload["strands"] = [payload["strands"][0]]
|
||||
payload["compute"] = ["pfunc"]
|
||||
payload["complexes_text"] = "A"
|
||||
baseline = server.run_job_payload(payload)
|
||||
|
||||
payload["complexes_text"] = "A; bonus=1.25"
|
||||
shifted = server.run_job_payload(payload)
|
||||
self.assertAlmostEqual(
|
||||
shifted["complexes"][0]["free_energy_kcal_mol"]
|
||||
- baseline["complexes"][0]["free_energy_kcal_mol"],
|
||||
1.25,
|
||||
places=6,
|
||||
)
|
||||
self.assertEqual(shifted["complexes"][0]["bonus_kcal_mol"], 1.25)
|
||||
|
||||
def test_mixed_material_design_preserves_prefixes_and_true_lengths(self):
|
||||
payload = copy.deepcopy(server.DESIGN_TUBE_EXAMPLE_PAYLOAD)
|
||||
payload["workflow"] = "design"
|
||||
payload["mode"] = "complex"
|
||||
payload["model"].update(material="rna-dna06", sodium=0.5, magnesium=0.0)
|
||||
payload["design_domains"] = [
|
||||
{"name": "rseg", "sequence": "rN4"},
|
||||
{"name": "dseg", "sequence": "dN4"},
|
||||
]
|
||||
payload["strands"] = [
|
||||
{"name": "R", "sequence": "rseg"},
|
||||
{"name": "D", "sequence": "dseg"},
|
||||
]
|
||||
payload["design_complexes"] = [
|
||||
{"name": "RD", "strands": "R+D", "structure": "(4+)4"},
|
||||
]
|
||||
payload["design"].update(stop_condition=0.5, trials=1, seed=1)
|
||||
replica = server.run_job_payload(payload)
|
||||
|
||||
self.assertEqual(replica["model"]["material"], "rna-dna06")
|
||||
self.assertEqual([row["length"] for row in replica["design"]["domains"]], [4, 4])
|
||||
self.assertEqual([row["length"] for row in replica["strands"]], [4, 4])
|
||||
self.assertTrue(replica["strands"][0]["sequence"].startswith("r"))
|
||||
self.assertTrue(replica["strands"][1]["sequence"].startswith("d"))
|
||||
|
||||
|
||||
class OfficialDesignPipelineTest(unittest.TestCase):
|
||||
def test_tube_set_bonus_and_multi_tube_results(self):
|
||||
payload = copy.deepcopy(server.DESIGN_TUBE_EXAMPLE_PAYLOAD)
|
||||
payload["design_domains"] = [{"name": "a", "sequence": "N4"}]
|
||||
payload["design_complexes"][0].update(structure="(4+)4", bonus=0.5)
|
||||
payload["design"]["stop_condition"] = 0.5
|
||||
payload["design_tubes"] = [
|
||||
{
|
||||
"name": "T1",
|
||||
"max_size": 1,
|
||||
"include_complexes": "A+A",
|
||||
"exclude_complexes": "A",
|
||||
"on_targets": [{"complex": "AB_target", "concentration": 1, "unit": "uM"}],
|
||||
},
|
||||
{
|
||||
"name": "T2",
|
||||
"max_size": 1,
|
||||
"on_targets": [{"complex": "AB_target", "concentration": 2, "unit": "uM"}],
|
||||
},
|
||||
]
|
||||
replica = server.run_job_payload(payload)
|
||||
|
||||
self.assertEqual(replica["design"]["targets"][0]["bonus"], 0.5)
|
||||
self.assertEqual([tube["name"] for tube in replica["tubes"]], ["T1", "T2"])
|
||||
self.assertEqual(replica["tubes"][0]["include_complexes"], ["A+A"])
|
||||
self.assertEqual(replica["tubes"][0]["exclude_complexes"], ["A"])
|
||||
self.assertTrue(all(row["tube_name"] == "T1" for row in replica["tubes"][0]["complex_concentrations"]))
|
||||
self.assertTrue(all(row["tube_name"] == "T2" for row in replica["tubes"][1]["complex_concentrations"]))
|
||||
self.assertAlmostEqual(
|
||||
replica["design"]["objective"],
|
||||
replica["design"]["weighted_ensemble_defect"],
|
||||
places=12,
|
||||
)
|
||||
|
||||
|
||||
class SchedulingAndWorkloadTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
server.JOB_STORE.clear()
|
||||
server.JOB_DEDUP_RESERVATIONS.clear()
|
||||
|
||||
def tearDown(self):
|
||||
server.JOB_STORE.clear()
|
||||
server.JOB_DEDUP_RESERVATIONS.clear()
|
||||
|
||||
def test_cyclic_complex_count_for_four_species_through_size_four(self):
|
||||
self.assertEqual(server.cyclic_complex_count(4, 4), 108)
|
||||
|
||||
def test_design_workload_estimates_four_species(self):
|
||||
strands = [SimpleNamespace(name=name) for name in ("S1", "S2", "S3", "S4")]
|
||||
target = SimpleNamespace(strands=strands)
|
||||
summary = server.build_design_workload_summary(
|
||||
[{"name": "x", "constraint": "N10", "mutable": True}],
|
||||
[{"name": "target", "mutable": True}, {"name": "fixed", "mutable": False}],
|
||||
[{
|
||||
"name": "tube",
|
||||
"max_size": 4,
|
||||
"on_targets": [{"complex": "target"}],
|
||||
"include_complexes": [],
|
||||
}],
|
||||
{"target": target},
|
||||
{"max_time_seconds": 3600},
|
||||
)
|
||||
self.assertEqual(summary["mutable_domain_nucleotides"], 10)
|
||||
self.assertEqual(summary["fixed_target_count"], 1)
|
||||
self.assertEqual(summary["estimated_complexes_upper_bound"], 108)
|
||||
self.assertEqual(summary["estimated_off_targets_upper_bound"], 107)
|
||||
self.assertEqual(summary["largest_tube_complexes_upper_bound"], 108)
|
||||
|
||||
def test_design_workload_includes_excluded_species_and_explicit_large_complexes(self):
|
||||
a = SimpleNamespace(name="A")
|
||||
summary = server.build_design_workload_summary(
|
||||
[],
|
||||
[{"name": "target", "mutable": True}],
|
||||
[{
|
||||
"name": "tube",
|
||||
"max_size": 1,
|
||||
"on_targets": [{"complex": "target"}],
|
||||
"include_complexes": ["A+B+A"],
|
||||
"exclude_complexes": ["B"],
|
||||
}],
|
||||
{"target": SimpleNamespace(strands=[a])},
|
||||
{"max_time_seconds": 0},
|
||||
)
|
||||
# Two monomers are generated, B is excluded, and A+B+A is explicitly included.
|
||||
self.assertEqual(summary["estimated_complexes_upper_bound"], 2)
|
||||
self.assertEqual(summary["estimated_off_targets_upper_bound"], 1)
|
||||
|
||||
def test_cyclic_identity_treats_rotations_as_the_same_complex(self):
|
||||
self.assertEqual(
|
||||
server.canonical_cyclic_identity(("A", "B", "C")),
|
||||
server.canonical_cyclic_identity(("B", "C", "A")),
|
||||
)
|
||||
|
||||
def test_fixed_domain_complement_uses_material_alphabet(self):
|
||||
rna_model = Model(material="rna")
|
||||
dna_model = Model(material="dna")
|
||||
domain_map, domains = server.build_design_domains(
|
||||
[{"name": "a", "sequence": "AC"}], model=rna_model, material="rna"
|
||||
)
|
||||
_, rna_rows = server.build_design_strands(
|
||||
[{"name": "R", "sequence": "~a"}], domain_map, domains,
|
||||
material="rna", model=rna_model,
|
||||
)
|
||||
domain_map, domains = server.build_design_domains(
|
||||
[{"name": "a", "sequence": "AC"}], model=dna_model, material="dna"
|
||||
)
|
||||
_, dna_rows = server.build_design_strands(
|
||||
[{"name": "D", "sequence": "~a"}], domain_map, domains,
|
||||
material="dna", model=dna_model,
|
||||
)
|
||||
self.assertEqual(rna_rows[0]["fixed_sequence"], "GU")
|
||||
self.assertEqual(dna_rows[0]["fixed_sequence"], "GT")
|
||||
|
||||
def test_mixed_constraint_parser_keeps_material_segments(self):
|
||||
model = Model(material="rna-dna06", sodium=0.5)
|
||||
self.assertTrue(server.is_valid_iupac_constraint("rN4dA2wS2", "rna-dna06", model))
|
||||
self.assertEqual(server.expand_iupac_constraint("rN2dA2", "rna-dna06"), "rNNdAA")
|
||||
self.assertTrue(server.is_mutable_iupac_constraint("wA4", "rna-dna06"))
|
||||
self.assertFalse(server.is_valid_iupac_constraint("N4dN4", "rna-dna06", model))
|
||||
|
||||
def test_mixed_fixed_domain_complement_uses_material_alphabet(self):
|
||||
model = Model(material="rna-dna06", sodium=0.5)
|
||||
domain_map, domains = server.build_design_domains(
|
||||
[{"name": "x", "sequence": "rACGdAT"}], model=model, material="rna-dna06"
|
||||
)
|
||||
_, rows = server.build_design_strands(
|
||||
[{"name": "Xc", "sequence": "~x"}], domain_map, domains,
|
||||
material="rna-dna06", model=model,
|
||||
)
|
||||
self.assertEqual(rows[0]["fixed_sequence"], "dATrCGU")
|
||||
|
||||
def test_fingerprint_is_canonical_and_user_scoped(self):
|
||||
first = {"model": {"rna": True, "temperature": 37}, "compute": ["pairs", "mfe"]}
|
||||
reordered = {"compute": ["pairs", "mfe"], "model": {"temperature": 37, "rna": True}}
|
||||
self.assertEqual(
|
||||
server.job_payload_fingerprint(first, "alice"),
|
||||
server.job_payload_fingerprint(reordered, "alice"),
|
||||
)
|
||||
self.assertNotEqual(
|
||||
server.job_payload_fingerprint(first, "alice"),
|
||||
server.job_payload_fingerprint(first, "bob"),
|
||||
)
|
||||
|
||||
def test_active_duplicate_requires_force_and_is_scoped_by_user(self):
|
||||
owner = {"user_id": "alice"}
|
||||
other_owner = {"user_id": "bob"}
|
||||
payload = {"workflow": "analysis", "mode": "complex", "strands": []}
|
||||
with (
|
||||
mock.patch.object(server, "redis_enabled", return_value=False),
|
||||
mock.patch.object(server.ACCOUNT_STORE, "create_job"),
|
||||
mock.patch.object(server.threading, "Thread") as thread_class,
|
||||
):
|
||||
first_id, first_reused = server.create_job(payload, owner)
|
||||
duplicate_id, duplicate_reused = server.create_job(copy.deepcopy(payload), owner)
|
||||
self.assertFalse(first_reused)
|
||||
self.assertTrue(duplicate_reused)
|
||||
self.assertEqual(duplicate_id, first_id)
|
||||
self.assertEqual(thread_class.call_count, 1)
|
||||
|
||||
forced_id, forced_reused = server.create_job(payload, owner, force_duplicate=True)
|
||||
self.assertFalse(forced_reused)
|
||||
self.assertNotEqual(forced_id, first_id)
|
||||
self.assertEqual(thread_class.call_count, 2)
|
||||
|
||||
other_id, other_reused = server.create_job(payload, other_owner)
|
||||
self.assertFalse(other_reused)
|
||||
self.assertNotIn(other_id, {first_id, forced_id})
|
||||
self.assertEqual(thread_class.call_count, 3)
|
||||
|
||||
server.update_job_data(first_id, status="success", result={"ok": True}, payload=None)
|
||||
still_duplicate_id, still_duplicate = server.create_job(payload, owner)
|
||||
self.assertTrue(still_duplicate)
|
||||
self.assertEqual(still_duplicate_id, forced_id)
|
||||
|
||||
server.update_job_data(forced_id, status="success", result={"ok": True}, payload=None)
|
||||
next_id, next_reused = server.create_job(payload, owner)
|
||||
self.assertFalse(next_reused)
|
||||
self.assertNotIn(next_id, {first_id, forced_id})
|
||||
self.assertEqual(thread_class.call_count, 4)
|
||||
|
||||
def test_auto_resource_plan_respects_cpu_and_memory(self):
|
||||
with (
|
||||
mock.patch.object(server, "WORKER_CONCURRENCY", "auto"),
|
||||
mock.patch.object(server, "PER_JOB_THREAD_LIMIT", 4),
|
||||
mock.patch.object(server, "WORKER_MEMORY_RESERVE_GB", 4.0),
|
||||
mock.patch.object(server, "ESTIMATED_JOB_MEMORY_GB", 9.0),
|
||||
mock.patch.object(server, "detected_worker_cpu_count", return_value=64),
|
||||
mock.patch.object(server, "detected_worker_memory_gb", return_value=56.0),
|
||||
):
|
||||
plan = server.worker_resource_plan()
|
||||
self.assertEqual(plan["concurrency"], 5)
|
||||
self.assertEqual(plan["source"], "auto")
|
||||
self.assertEqual(plan["cpu_count"], 64)
|
||||
|
||||
def test_manual_resource_plan_keeps_explicit_concurrency(self):
|
||||
with (
|
||||
mock.patch.object(server, "WORKER_CONCURRENCY", "3"),
|
||||
mock.patch.object(server, "detected_worker_cpu_count", return_value=2),
|
||||
mock.patch.object(server, "detected_worker_memory_gb", return_value=8.0),
|
||||
):
|
||||
plan = server.worker_resource_plan()
|
||||
self.assertEqual(plan["concurrency"], 3)
|
||||
self.assertEqual(plan["source"], "manual")
|
||||
|
||||
def test_admin_session_is_random_revocable_and_does_not_expose_password(self):
|
||||
password = "admin-password-that-must-stay-secret"
|
||||
with (
|
||||
mock.patch.object(server, "redis_enabled", return_value=False),
|
||||
mock.patch.object(server, "ADMIN_TOKEN", password),
|
||||
mock.patch.object(server, "ADMIN_COOKIE_SECURE", False),
|
||||
):
|
||||
server.ADMIN_SESSION_STORE.clear()
|
||||
token = server.create_admin_session()
|
||||
self.assertNotEqual(token, password)
|
||||
self.assertTrue(server.valid_admin_session(token))
|
||||
cookie = server.admin_cookie_header(token)
|
||||
self.assertIn(f"{server.ADMIN_COOKIE_NAME}={token}", cookie)
|
||||
self.assertIn("HttpOnly", cookie)
|
||||
self.assertIn("SameSite=Strict", cookie)
|
||||
self.assertNotIn(password, cookie)
|
||||
server.delete_admin_session(token)
|
||||
self.assertFalse(server.valid_admin_session(token))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Loading…
Add table
Add a link
Reference in a new issue