升级 NUPACK 4.1 并支持混合材料设计

This commit is contained in:
Lihatoo 2026-08-20 16:33:16 +08:00
parent 5daa60a464
commit c6189d857d
33 changed files with 5830 additions and 466 deletions

45
service/account.html Normal file
View file

@ -0,0 +1,45 @@
<!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>

View file

@ -13,6 +13,9 @@ 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
@ -127,8 +130,26 @@ class AccountStore:
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):
@ -206,11 +227,15 @@ class AccountStore:
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):
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"
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 = ?", (job_id, user_id)).fetchone()
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):
@ -218,7 +243,8 @@ class AccountStore:
filters = filters or {}
limit = min(100, max(1, int(filters.get("limit", 30))))
offset = max(0, int(filters.get("offset", 0)))
clauses = ["user_id = ?"]
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()
@ -230,7 +256,7 @@ class AccountStore:
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"
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(
@ -239,6 +265,51 @@ class AccountStore:
).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:
@ -248,7 +319,7 @@ class AccountStore:
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=?""",
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"]
@ -279,17 +350,86 @@ class AccountStore:
imported += 1
return imported
def delete_job(self, user_id, job_id):
def trash_retention_seconds(self):
self.initialize()
with self._connect() as connection:
row = connection.execute("SELECT status FROM jobs WHERE job_id=? AND user_id=?", (job_id, user_id)).fetchone()
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.")
connection.execute("DELETE FROM jobs WHERE job_id=? AND user_id=?", (job_id, user_id))
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:
@ -298,6 +438,19 @@ class AccountStore:
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),
@ -329,7 +482,8 @@ class AccountStore:
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=? ORDER BY s.created_at DESC""",
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]
@ -344,6 +498,14 @@ class AccountStore:
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:

28
service/admin-login.html Normal file
View file

@ -0,0 +1,28 @@
<!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>

57
service/admin.html Normal file
View file

@ -0,0 +1,57 @@
<!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>

52
service/cloud.html Normal file
View file

@ -0,0 +1,52 @@
<!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>

View file

@ -107,13 +107,15 @@
<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="/#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>
<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>
</nav>
<section id="quick-start" class="card">
@ -151,8 +153,18 @@ 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/2OMe: mixed1 = rN6mN6
材料也可设计: mixed1 = wN12</code></pre>
<p><code>r</code><code>d</code><code>m</code> 分别表示 RNA、DNA、2OMe-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>3. Hard constraints 怎么用</h2>
<h2>4. Hard constraints 怎么用</h2>
<p>Hard constraint 是“必须满足”的约束,写错会让设计空间为空,或直接报错。</p>
<table>
<thead>
@ -170,13 +182,29 @@ target tube:
</section>
<section id="soft" class="card">
<h2>4. Soft constraints 和 weights</h2>
<h2>5. 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 trialmax size 2<code>f_stop=0.10.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 34 检查更大的复合物集合</td><td>保留严格的最终热力学验证,同时避免在每一步优化迭代中反复枚举大集合。</td></tr>
</tbody>
</table>
<p class="warn">分阶段流程会改变“在哪个阶段计算哪些集合”,但不会偷减最终验证。若研究目标明确要求在优化目标中直接包含 size 34 off-target应保留该设置并给任务填写最大时间。</p>
</section>
<section id="speed" class="card">
<h2>5. 为什么 Design 会慢</h2>
<h2>7. 为什么 Design 会慢</h2>
<p>NUPACK design 是优化问题,不是一次性的结构分析。影响耗时的主要因素:</p>
<ul>
<li><strong>off-target max size</strong> 越大,需要考虑的非目标复合物越多。</li>
@ -184,7 +212,9 @@ target tube:
<li><strong>trials</strong> 大于 1 时会跑多个随机种子NUPACK 源码里会并行提交多个 trial。</li>
<li><strong>hard constraints</strong> 太多或互相矛盾,会反复搜索甚至失败。</li>
</ul>
<p>当前服务已显式设置 NUPACK 的 <code>config.threads</code>,不再只依赖 <code>OMP_NUM_THREADS</code>。本地 NUPACK 源码里有 <code>NUPACK_CUDA</code> 编译选项,但当前 wheel/镜像不是 CUDA 构建;直接“打开 GPU”不能生效除非重新编译 NUPACK 的 CUDA 版本并替换镜像里的 wheel。</p>
<p>页面的“设计预检”会实时显示可变碱基数、固定目标数和复合物上限,并提醒无限时长、严格停止阈值、多 trial 与大 off-target 集合。它只做估算和提示,不会改写提交参数。</p>
<p>当前服务会按 CPU 和内存自动限制同时运行的任务数,并已显式设置 NUPACK 的 <code>config.threads</code>。这些调度参数不改变模型、约束或结果目标;它们主要用于防止多个 89 GB 任务把机器内存耗尽。增加线程不保证单个 design 等比例加速,因为部分优化阶段主要使用单核;多任务通常比单任务更容易占满多核。增加内存本身也不会让搜索更快,只会减少 OOM、换页和缓存压力。</p>
<p>当前 NUPACK 4.1.0.1 wheel/镜像不是 CUDA 构建,且官方分发包中没有附带 <code>source/</code> 源码树直接填写环境变量或安装显卡不能生效。GPU 路线需要重新编译或替换核心 wheel并对结果一致性与稳定性重新测试。</p>
</section>
</main>
</body>

46
service/home.html Normal file
View file

@ -0,0 +1,46 @@
<!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>

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

78
service/settings.html Normal file
View file

@ -0,0 +1,78 @@
<!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>

View file

@ -0,0 +1,582 @@
: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;
}
}

553
service/static/portal.js Normal file
View file

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

View file

@ -0,0 +1,378 @@
/* 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;
}
}

View file

@ -3,7 +3,7 @@ import time
import unittest
from pathlib import Path
from service.account import AccountStore
from service.account import AccountStore, DEFAULT_TRASH_RETENTION_SECONDS
def user(user_id):
@ -48,7 +48,7 @@ class AccountStoreTest(unittest.TestCase):
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_disabled(self):
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"])
@ -57,6 +57,7 @@ class AccountStoreTest(unittest.TestCase):
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)
@ -67,6 +68,13 @@ class AccountStoreTest(unittest.TestCase):
)
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")
@ -93,6 +101,86 @@ class AccountStoreTest(unittest.TestCase):
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()

View file

@ -0,0 +1,525 @@
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()