升级 NUPACK 4.1 并支持混合材料设计
This commit is contained in:
parent
5daa60a464
commit
c6189d857d
33 changed files with 5830 additions and 466 deletions
45
service/account.html
Normal file
45
service/account.html
Normal 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>
|
||||
|
|
@ -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
28
service/admin-login.html
Normal 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
57
service/admin.html
Normal 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
52
service/cloud.html
Normal 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>
|
||||
|
|
@ -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/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>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 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>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>。这些调度参数不改变模型、约束或结果目标;它们主要用于防止多个 8–9 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
46
service/home.html
Normal 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>
|
||||
1493
service/index.html
1493
service/index.html
File diff suppressed because it is too large
Load diff
1580
service/server.py
1580
service/server.py
File diff suppressed because it is too large
Load diff
78
service/settings.html
Normal file
78
service/settings.html
Normal 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>
|
||||
582
service/static/app-shell.css
Normal file
582
service/static/app-shell.css
Normal 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
553
service/static/portal.js
Normal 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("&", "&")
|
||||
.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);
|
||||
});
|
||||
378
service/static/workspace-refresh.css
Normal file
378
service/static/workspace-refresh.css
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
525
service/test_server_calculations.py
Normal file
525
service/test_server_calculations.py
Normal 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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue