Skip to content

Commit 213c7d5

Browse files
JihaoXinclaude
andcommitted
Merge refactor/dashboard-at-idea2paper: unified idea2paper.org/dashboard
Cutover to single-domain architecture with CF Tunnel path routing: - idea2paper.org/ -> static homepage (new ark-homepage.service) - idea2paper.org/dashboard/* -> webapp (ark-webapp.service, port 9527) protected by CF Access (KAUST + invited allowlist, Google SSO) - dev.idea2paper.org / webapp.idea2paper.org -> deprecated after 48h See /home/xinj/.claude/plans/bright-wobbling-thimble.md for the full plan. Commits included: - Rename Python module ark.webapp -> ark.dashboard - Merge webpage submodule into website/homepage - StripPathPrefixMiddleware + APP_BASE templating - Systemd env vars (ARK_ROOT_PATH, BASE_URL) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2 parents 5b37948 + d587d22 commit 213c7d5

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

66 files changed

+4967
-111
lines changed

.gitmodules

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,6 @@
11
[submodule "materials/references/openclaw"]
22
path = materials/references/openclaw
33
url = https://github.com/openclaw/openclaw.git
4-
[submodule "submodules/webpage"]
5-
path = submodules/webpage
6-
url = git@github.com:kaust-ark/kaust-ark.github.io.git
74
[submodule "submodules/PaperBanana"]
85
path = submodules/PaperBanana
96
url = https://github.com/dwzhu-pku/PaperBanana.git

ark/cli.py

Lines changed: 26 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -816,7 +816,7 @@ def _setup_latex_template(code_dir: str, config: dict):
816816
print(f"{_c('LaTeX Template Setup', Colors.BOLD)}")
817817

818818
# Try bundled venue_templates/ first (same as webapp)
819-
from ark.webapp.templates import has_venue_template, copy_venue_template
819+
from ark.dashboard.templates import has_venue_template, copy_venue_template
820820
downloaded = False
821821

822822
_, required = _resolve_template_urls(venue_format)
@@ -1594,7 +1594,7 @@ def _finalize_project(name: str, project_dir: Path, config: dict,
15941594
# ── Register project in webapp DB ──────────────────────────
15951595
project_id = None
15961596
try:
1597-
from ark.webapp.db import (resolve_db_path, get_session,
1597+
from ark.dashboard.db import (resolve_db_path, get_session,
15981598
get_or_create_user_by_email, create_project as db_create_project)
15991599
import getpass
16001600
db_path = resolve_db_path()
@@ -1876,13 +1876,13 @@ def cmd_run(args):
18761876
project_id = config.get("_project_id", "")
18771877
if not db_path:
18781878
try:
1879-
from ark.webapp.db import resolve_db_path
1879+
from ark.dashboard.db import resolve_db_path
18801880
db_path = resolve_db_path()
18811881
except Exception:
18821882
pass
18831883
if not project_id and db_path and Path(db_path).exists():
18841884
try:
1885-
from ark.webapp.db import get_session, get_project_by_name
1885+
from ark.dashboard.db import get_session, get_project_by_name
18861886
with get_session(db_path) as session:
18871887
p = get_project_by_name(session, name)
18881888
if p:
@@ -1892,7 +1892,7 @@ def cmd_run(args):
18921892

18931893
# Launch orchestrator in background, preferring per-project conda env
18941894
try:
1895-
from ark.webapp.jobs import (
1895+
from ark.dashboard.jobs import (
18961896
find_conda_binary, project_env_ready, project_env_prefix,
18971897
)
18981898
conda_bin = find_conda_binary()
@@ -3112,14 +3112,26 @@ def _cmd_webapp_install(host: str, port: int, dev: bool = False):
31123112
prod_env_link.symlink_to(main_env)
31133113

31143114
# Environment variables for systemd service
3115+
# ARK_ROOT_PATH mounts the webapp under a URL path prefix behind the CF
3116+
# Tunnel (idea2paper.org/dashboard/*). The strip-prefix middleware makes
3117+
# this transparent to the app, so BASE_URL stays a bare origin.
31153118
env_vars = {
31163119
"ARK_WEBAPP_DB_PATH": str(db_path),
31173120
"PROJECTS_ROOT": str(data_dir / "projects"),
3121+
"ARK_ROOT_PATH": "/dashboard",
31183122
}
31193123

31203124
if dev:
31213125
env_vars["ARK_SESSION_COOKIE"] = "session_dev"
3126+
# Dev BASE_URL stays as the internal IP — dev is only reachable from
3127+
# KAUST internal network, and magic-link emails from dev must be
3128+
# clickable inside KAUST. No CF Tunnel routes dev.
31223129
env_vars["BASE_URL"] = f"http://{get_primary_ip()}:{port}"
3130+
# Dev does NOT support Google OAuth: Google rejects private IPs as
3131+
# OAuth redirect URIs. Clear the creds so /auth/google/enabled
3132+
# returns false and the UI hides the Google button.
3133+
env_vars["GOOGLE_CLIENT_ID"] = ""
3134+
env_vars["GOOGLE_CLIENT_SECRET"] = ""
31233135
# Load dev-only env overrides
31243136
dev_env_file = get_config_dir() / "webapp-dev.env"
31253137
if not dev_env_file.exists():
@@ -3135,6 +3147,11 @@ def _cmd_webapp_install(host: str, port: int, dev: bool = False):
31353147
continue
31363148
k, _, v = line.partition("=")
31373149
env_vars[k.strip()] = v.strip()
3150+
else:
3151+
# Prod BASE_URL is the public origin; /dashboard prefix is added by
3152+
# ARK_ROOT_PATH, so anything building a full URL uses BASE_URL +
3153+
# ARK_ROOT_PATH + /path.
3154+
env_vars["BASE_URL"] = "https://idea2paper.org"
31383155

31393156
svc_path = _service_file_path(svc_name)
31403157
svc_path.parent.mkdir(parents=True, exist_ok=True)
@@ -3357,8 +3374,8 @@ def cmd_webapp(args):
33573374

33583375
try:
33593376
import uvicorn
3360-
from ark.webapp import create_app
3361-
from ark.webapp.config import get_settings, _env_file
3377+
from ark.dashboard import create_app
3378+
from ark.dashboard.config import get_settings, _env_file
33623379
except ImportError:
33633380
print(f"{_c('Error:', Colors.RED)} Webapp dependencies not installed.")
33643381
print(f" Install with: {_c('pip install ark-research[webapp]', Colors.BOLD)}")
@@ -3428,8 +3445,8 @@ def _scancel(job_id):
34283445
def _submit(pid, mode, max_iter, user_id, con):
34293446
"""Try SLURM submit; fallback to 'local'. Returns new job_id."""
34303447
if _sh.which("sbatch"):
3431-
from ark.webapp.config import get_settings
3432-
from ark.webapp.jobs import submit_job
3448+
from ark.dashboard.config import get_settings
3449+
from ark.dashboard.jobs import submit_job
34333450
settings = get_settings()
34343451
pdir = settings.projects_root / user_id / pid
34353452
log_dir = pdir / "logs"
Lines changed: 62 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
from .notify import send_completion_email, send_telegram_notify
1818
from .routes import router
1919

20-
logger = logging.getLogger("ark.webapp")
20+
logger = logging.getLogger("ark.dashboard")
2121

2222
_log_mtimes: dict[str, float] = {} # project_id → last log mtime
2323

@@ -78,7 +78,11 @@ async def _poll_jobs(app: FastAPI):
7878
continue
7979

8080
pdir = settings.projects_root / p.user_id / p.id
81-
url = f"{settings.base_url}/#project/{p.id}"
81+
# Background task has no request context, so reconstruct the
82+
# public URL from BASE_URL + ARK_ROOT_PATH. BASE_URL is the
83+
# origin (https://idea2paper.org) and ARK_ROOT_PATH is the
84+
# path prefix ("/dashboard" in prod, "" for bare access).
85+
url = f"{settings.base_url}{os.environ.get('ARK_ROOT_PATH', '')}/#project/{p.id}"
8286

8387
# ── Local subprocess job ──────────────────────────────
8488
if p.slurm_job_id.startswith("local:"):
@@ -445,7 +449,7 @@ async def lifespan(app: FastAPI):
445449
get_engine(settings.db_path)
446450

447451
# Migrate existing project data: populate new DB columns from YAML state files
448-
from ark.webapp.db import migrate_project_data
452+
from ark.dashboard.db import migrate_project_data
449453
try:
450454
migrate_project_data(settings.db_path, str(settings.projects_root))
451455
logger.info("Project data migration completed.")
@@ -465,8 +469,58 @@ async def lifespan(app: FastAPI):
465469
logger.info("ARK Webapp stopped.")
466470

467471

468-
def create_app() -> FastAPI:
472+
class StripPathPrefixMiddleware:
473+
"""ASGI middleware that strips a path prefix from incoming requests.
474+
475+
When ARK_ROOT_PATH=/dashboard, requests to /dashboard/api/projects arrive
476+
with that full path. This middleware rewrites scope['path'] to
477+
/api/projects and sets scope['root_path']=/dashboard so Starlette's
478+
request.url_for() builds correct absolute URLs.
479+
480+
Non-prefixed requests pass through unchanged, which means the same
481+
process transparently serves both http://host/ and http://host/dashboard/.
482+
This is the 'dual access' property: useful for local dev (direct port)
483+
while the same app is served at /dashboard in production via tunnel.
484+
"""
485+
486+
def __init__(self, app, prefix: str):
487+
self.app = app
488+
self.prefix = prefix
489+
490+
async def __call__(self, scope, receive, send):
491+
if scope["type"] in ("http", "websocket"):
492+
path = scope.get("path", "")
493+
if path == self.prefix or path.startswith(self.prefix + "/"):
494+
new_path = path[len(self.prefix):] or "/"
495+
scope = dict(scope)
496+
scope["path"] = new_path
497+
# Stash prefix in a custom key. We intentionally do NOT
498+
# set scope["root_path"] because Starlette's StaticFiles
499+
# mount misroutes when root_path is non-empty. Handlers
500+
# read this via request.scope.get("ark_root_path", "").
501+
scope["ark_root_path"] = self.prefix
502+
# raw_path is used by Starlette routing; must update it too
503+
# or mounts (e.g., /static) won't match after prefix strip.
504+
raw_path = scope.get("raw_path")
505+
if raw_path is not None:
506+
prefix_bytes = self.prefix.encode("latin-1")
507+
if raw_path == prefix_bytes or raw_path.startswith(prefix_bytes + b"/"):
508+
scope["raw_path"] = raw_path[len(prefix_bytes):] or b"/"
509+
await self.app(scope, receive, send)
510+
511+
512+
def create_app():
513+
"""Create the FastAPI app. Returns an ASGI callable.
514+
515+
When ARK_ROOT_PATH env var is set (e.g. /dashboard), the app is wrapped
516+
with StripPathPrefixMiddleware so it serves at both / and /<prefix>.
517+
518+
Note: root_path is NOT passed to FastAPI() — otherwise FastAPI would
519+
set scope["root_path"] on every request, breaking dual-access. The
520+
middleware sets it only for requests that actually carry the prefix.
521+
"""
469522
settings = get_settings()
523+
root_path = os.environ.get("ARK_ROOT_PATH", "")
470524

471525
app = FastAPI(
472526
title="ARK Research Portal",
@@ -491,4 +545,8 @@ def create_app() -> FastAPI:
491545
static_dir = Path(__file__).parent / "static"
492546
app.mount("/static", StaticFiles(directory=str(static_dir)), name="static")
493547

548+
# Wrap as outermost ASGI layer so it sees raw incoming paths before any
549+
# FastAPI middleware/routing. Without a prefix, return the app unwrapped.
550+
if root_path:
551+
return StripPathPrefixMiddleware(app, prefix=root_path)
494552
return app
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99

1010
def _get_fernet(user_id: str) -> Fernet:
1111
"""Derive a user-specific Fernet key using PBKDF2HMAC."""
12-
from ark.webapp.config import get_settings
12+
from ark.dashboard.config import get_settings
1313
settings = get_settings()
1414

1515
# PBKDF2HMAC is a high-security key derivation function.
File renamed without changes.
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -367,7 +367,7 @@ def submit_job(
367367

368368
safe_api_keys = {k: shlex.quote(v) for k, v in (api_keys or {}).items()}
369369

370-
from ark.webapp.db import resolve_db_path
370+
from ark.dashboard.db import resolve_db_path
371371
db_path = resolve_db_path()
372372

373373
template_text = _SLURM_TEMPLATE.read_text()
@@ -489,7 +489,7 @@ def launch_local_job(
489489
python_prefix = [sys.executable]
490490

491491
# Resolve DB path so orchestrator can sync status
492-
from ark.webapp.db import resolve_db_path
492+
from ark.dashboard.db import resolve_db_path
493493
db_path = resolve_db_path()
494494

495495
cmd = python_prefix + [
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
from email.mime.text import MIMEText
1515
from pathlib import Path
1616

17-
logger = logging.getLogger("ark.webapp.notify")
17+
logger = logging.getLogger("ark.dashboard.notify")
1818

1919
# ── Telegram ──────────────────────────────────────────────────────────────────
2020

0 commit comments

Comments
 (0)