From d8753e057c38fa330cb3ef2eabca40178e6ee76f Mon Sep 17 00:00:00 2001 From: Konrad Rokicki Date: Sun, 8 Feb 2026 22:19:56 -0500 Subject: [PATCH 01/59] initial prototype of apps page --- docs/config.yaml.template | 24 + .../versions/a3f7c2e19d04_add_jobs_table.py | 44 ++ fileglancer/app.py | 187 ++++++- fileglancer/apps.py | 479 ++++++++++++++++++ fileglancer/database.py | 98 +++- fileglancer/model.py | 99 +++- fileglancer/settings.py | 10 + frontend/src/App.tsx | 18 + frontend/src/components/AppLaunch.tsx | 149 ++++++ frontend/src/components/Apps.tsx | 133 +++++ .../components/ui/AppsPage/AddAppDialog.tsx | 202 ++++++++ .../src/components/ui/AppsPage/AppCard.tsx | 61 +++ .../components/ui/AppsPage/AppLaunchForm.tsx | 347 +++++++++++++ .../components/ui/AppsPage/JobStatusBadge.tsx | 53 ++ frontend/src/components/ui/Navbar/Navbar.tsx | 11 +- .../src/components/ui/Table/TableCard.tsx | 2 +- .../components/ui/Table/appsJobsColumns.tsx | 156 ++++++ frontend/src/queries/appsQueries.ts | 102 ++++ frontend/src/queries/jobsQueries.ts | 94 ++++ frontend/src/shared.types.ts | 82 +++ pixi.lock | 29 +- pyproject.toml | 3 +- 22 files changed, 2374 insertions(+), 9 deletions(-) create mode 100644 fileglancer/alembic/versions/a3f7c2e19d04_add_jobs_table.py create mode 100644 fileglancer/apps.py create mode 100644 frontend/src/components/AppLaunch.tsx create mode 100644 frontend/src/components/Apps.tsx create mode 100644 frontend/src/components/ui/AppsPage/AddAppDialog.tsx create mode 100644 frontend/src/components/ui/AppsPage/AppCard.tsx create mode 100644 frontend/src/components/ui/AppsPage/AppLaunchForm.tsx create mode 100644 frontend/src/components/ui/AppsPage/JobStatusBadge.tsx create mode 100644 frontend/src/components/ui/Table/appsJobsColumns.tsx create mode 100644 frontend/src/queries/appsQueries.ts create mode 100644 frontend/src/queries/jobsQueries.ts diff --git a/docs/config.yaml.template b/docs/config.yaml.template index 1a82cf02..0c03114c 100644 --- a/docs/config.yaml.template +++ b/docs/config.yaml.template @@ -123,3 +123,27 @@ session_cookie_name: fg_session # Set to true for production with valid HTTPS certificates # session_cookie_secure: true + +# +# Cluster configuration for Apps feature +# Controls how jobs are submitted and executed +# +# Executor type: "local" for local execution, "lsf" for LSF cluster +# cluster_executor: local + +# LSF queue name (only used with "lsf" executor) +# cluster_queue: normal + +# LSF account (only used with "lsf" executor) +# cluster_account: your_account + +# Polling interval in seconds for checking job status +# cluster_poll_interval: 10.0 + +# Directory for job log files +# cluster_log_directory: ./logs + +# Default resource allocation for jobs (can be overridden per entry point) +# cluster_default_memory: "8 GB" +# cluster_default_walltime: "04:00" +# cluster_default_cpus: 1 diff --git a/fileglancer/alembic/versions/a3f7c2e19d04_add_jobs_table.py b/fileglancer/alembic/versions/a3f7c2e19d04_add_jobs_table.py new file mode 100644 index 00000000..ee5d2e4f --- /dev/null +++ b/fileglancer/alembic/versions/a3f7c2e19d04_add_jobs_table.py @@ -0,0 +1,44 @@ +"""add jobs table + +Revision ID: a3f7c2e19d04 +Revises: 2d1f0e6b8c91 +Create Date: 2026-02-08 00:00:00.000000 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'a3f7c2e19d04' +down_revision = '2d1f0e6b8c91' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + 'jobs', + sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True), + sa.Column('username', sa.String(), nullable=False), + sa.Column('cluster_job_id', sa.String(), nullable=True), + sa.Column('app_url', sa.String(), nullable=False), + sa.Column('app_name', sa.String(), nullable=False), + sa.Column('entry_point_id', sa.String(), nullable=False), + sa.Column('entry_point_name', sa.String(), nullable=False), + sa.Column('parameters', sa.JSON(), nullable=False), + sa.Column('status', sa.String(), nullable=False), + sa.Column('exit_code', sa.Integer(), nullable=True), + sa.Column('resources', sa.JSON(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('started_at', sa.DateTime(), nullable=True), + sa.Column('finished_at', sa.DateTime(), nullable=True), + ) + op.create_index('ix_jobs_username', 'jobs', ['username']) + op.create_index('ix_jobs_cluster_job_id', 'jobs', ['cluster_job_id']) + + +def downgrade() -> None: + op.drop_index('ix_jobs_cluster_job_id', table_name='jobs') + op.drop_index('ix_jobs_username', table_name='jobs') + op.drop_table('jobs') diff --git a/fileglancer/app.py b/fileglancer/app.py index d3a01aa2..4537ce16 100644 --- a/fileglancer/app.py +++ b/fileglancer/app.py @@ -14,6 +14,7 @@ except ImportError: import tomli as tomllib +import httpx import yaml from loguru import logger from pydantic import HttpUrl @@ -28,6 +29,7 @@ from fileglancer import database as db from fileglancer import auth +from fileglancer import apps as apps_module from fileglancer.model import * from fileglancer.settings import get_settings from fileglancer.issues import create_jira_ticket, get_jira_ticket_details, delete_jira_ticket @@ -260,10 +262,21 @@ def mask_password(url: str) -> str: else: logger.debug(f"No notifications file found at {notifications_file}") + # Start cluster job monitor + try: + await apps_module.start_job_monitor() + logger.info("Cluster job monitor started") + except Exception as e: + logger.warning(f"Failed to start cluster job monitor: {e}") + logger.info(f"Server ready") yield - # Cleanup (if needed) - pass + + # Cleanup: stop job monitor + try: + await apps_module.stop_job_monitor() + except Exception as e: + logger.warning(f"Error stopping cluster job monitor: {e}") app = FastAPI(lifespan=lifespan) @@ -1446,6 +1459,176 @@ async def delete_file_or_dir(fsp_name: str, return JSONResponse(status_code=200, content={"message": "Item deleted"}) + # --- Apps & Jobs API --- + + @app.post("/api/apps/manifest", response_model=AppManifest, + description="Fetch and validate an app manifest from a URL") + async def fetch_manifest(body: ManifestFetchRequest, + username: str = Depends(get_current_user)): + try: + manifest = await apps_module.fetch_app_manifest(body.url) + return manifest + except httpx.HTTPStatusError as e: + raise HTTPException(status_code=502, detail=f"Failed to fetch manifest: HTTP {e.response.status_code}") + except ValueError as e: + raise HTTPException(status_code=404, detail=str(e)) + except Exception as e: + raise HTTPException(status_code=400, detail=f"Invalid manifest: {str(e)}") + + @app.get("/api/apps", response_model=list[UserApp], + description="Get the user's configured apps with their manifests") + async def get_user_apps(username: str = Depends(get_current_user)): + with db.get_db_session(settings.db_url) as session: + pref = db.get_user_preference(session, username, "apps") + + app_list = pref.get("apps", []) if pref else [] + result = [] + for app_entry in app_list: + user_app = UserApp( + url=app_entry["url"], + name=app_entry.get("name", "Unknown"), + description=app_entry.get("description"), + added_at=app_entry.get("added_at", datetime.now(UTC).isoformat()), + ) + # Try to fetch manifest (use cache) + try: + user_app.manifest = await apps_module.fetch_app_manifest(app_entry["url"]) + # Update name/description from manifest + user_app.name = user_app.manifest.name + user_app.description = user_app.manifest.description + except Exception as e: + logger.warning(f"Failed to fetch manifest for {app_entry['url']}: {e}") + result.append(user_app) + return result + + @app.post("/api/apps", response_model=UserApp, + description="Add an app by URL") + async def add_user_app(body: AppAddRequest, + username: str = Depends(get_current_user)): + # Fetch manifest to validate + try: + manifest = await apps_module.fetch_app_manifest(body.url) + except httpx.HTTPStatusError as e: + raise HTTPException(status_code=502, detail=f"Failed to fetch manifest: HTTP {e.response.status_code}") + except ValueError as e: + raise HTTPException(status_code=404, detail=str(e)) + except Exception as e: + raise HTTPException(status_code=400, detail=f"Invalid manifest: {str(e)}") + + now = datetime.now(UTC) + new_entry = { + "url": body.url, + "name": manifest.name, + "description": manifest.description, + "added_at": now.isoformat(), + } + + with db.get_db_session(settings.db_url) as session: + pref = db.get_user_preference(session, username, "apps") + app_list = pref.get("apps", []) if pref else [] + + # Check for duplicates + for existing in app_list: + if existing["url"] == body.url: + raise HTTPException(status_code=409, detail="App already added") + + app_list.append(new_entry) + db.set_user_preference(session, username, "apps", {"apps": app_list}) + + return UserApp( + url=body.url, + name=manifest.name, + description=manifest.description, + added_at=now, + manifest=manifest, + ) + + @app.delete("/api/apps", + description="Remove an app by URL") + async def remove_user_app(url: str = Query(..., description="URL of the app to remove"), + username: str = Depends(get_current_user)): + with db.get_db_session(settings.db_url) as session: + pref = db.get_user_preference(session, username, "apps") + app_list = pref.get("apps", []) if pref else [] + + new_list = [a for a in app_list if a["url"] != url] + if len(new_list) == len(app_list): + raise HTTPException(status_code=404, detail="App not found") + + db.set_user_preference(session, username, "apps", {"apps": new_list}) + + return {"message": "App removed"} + + @app.post("/api/jobs", response_model=Job, + description="Submit a new job") + async def submit_job(body: JobSubmitRequest, + username: str = Depends(get_current_user)): + try: + resources_dict = None + if body.resources: + resources_dict = body.resources.model_dump(exclude_none=True) + + db_job = await apps_module.submit_job( + username=username, + app_url=body.app_url, + entry_point_id=body.entry_point_id, + parameters=body.parameters, + resources=resources_dict, + ) + return _convert_job(db_job) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + logger.exception(f"Error submitting job: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + @app.get("/api/jobs", response_model=JobResponse, + description="List the user's jobs") + async def get_jobs(status: Optional[str] = Query(None, description="Filter by status"), + username: str = Depends(get_current_user)): + with db.get_db_session(settings.db_url) as session: + db_jobs = db.get_jobs_by_username(session, username, status) + jobs = [_convert_job(j) for j in db_jobs] + return JobResponse(jobs=jobs) + + @app.get("/api/jobs/{job_id}", response_model=Job, + description="Get a single job by ID") + async def get_job(job_id: int, + username: str = Depends(get_current_user)): + with db.get_db_session(settings.db_url) as session: + db_job = db.get_job(session, job_id, username) + if db_job is None: + raise HTTPException(status_code=404, detail="Job not found") + return _convert_job(db_job) + + @app.delete("/api/jobs/{job_id}", + description="Cancel a running job") + async def cancel_job(job_id: int, + username: str = Depends(get_current_user)): + try: + db_job = await apps_module.cancel_job(job_id, username) + return _convert_job(db_job) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + + def _convert_job(db_job: db.JobDB) -> Job: + """Convert a database JobDB to a Pydantic Job model.""" + return Job( + id=db_job.id, + app_url=db_job.app_url, + app_name=db_job.app_name, + entry_point_id=db_job.entry_point_id, + entry_point_name=db_job.entry_point_name, + parameters=db_job.parameters, + status=db_job.status, + exit_code=db_job.exit_code, + resources=db_job.resources, + cluster_job_id=db_job.cluster_job_id, + created_at=db_job.created_at, + started_at=db_job.started_at, + finished_at=db_job.finished_at, + ) + @app.post("/api/auth/simple-login", include_in_schema=not settings.enable_okta_auth) async def simple_login_handler(request: Request, body: dict = Body(...)): """Handle simple login JSON submission""" diff --git a/fileglancer/apps.py b/fileglancer/apps.py new file mode 100644 index 00000000..0aad2f83 --- /dev/null +++ b/fileglancer/apps.py @@ -0,0 +1,479 @@ +"""Apps module for fetching manifests, building commands, and managing cluster jobs.""" + +import asyncio +import re +import shlex +import time +from datetime import datetime, UTC +from typing import Optional + +import httpx +import yaml +from loguru import logger + +from cluster_api import create_executor, ResourceSpec, JobMonitor +from cluster_api._types import JobStatus + +from fileglancer import database as db +from fileglancer.model import AppManifest, AppEntryPoint, AppParameter +from fileglancer.settings import get_settings + +# --- Manifest Cache --- + +_manifest_cache: dict[str, tuple[AppManifest, float]] = {} +_MANIFEST_CACHE_TTL = 3600 # 1 hour + + +_MANIFEST_FILENAMES = ["fileglancer-app.json", "fileglancer-app.yaml", "fileglancer-app.yml"] + + +def _github_to_raw_urls(url: str) -> list[str]: + """Convert a GitHub repo URL to raw URLs for the manifest file. + + Returns a list of candidate URLs to try (JSON first, then YAML). + + Handles patterns like: + - https://github.com/owner/repo + - https://github.com/owner/repo/ + - https://github.com/owner/repo/tree/branch + """ + pattern = r"https?://github\.com/([^/]+)/([^/]+?)(?:\.git)?(?:/tree/([^/]+))?/?$" + match = re.match(pattern, url) + if match: + owner, repo, branch = match.groups() + branch = branch or "main" + return [ + f"https://raw.githubusercontent.com/{owner}/{repo}/{branch}/{filename}" + for filename in _MANIFEST_FILENAMES + ] + return [url] + + +def _parse_manifest_response(response: httpx.Response) -> dict: + """Parse a manifest response as JSON or YAML based on content.""" + url = str(response.url) + if url.endswith(".yaml") or url.endswith(".yml"): + return yaml.safe_load(response.text) + return response.json() + + +async def fetch_app_manifest(url: str) -> AppManifest: + """Fetch and validate an app manifest from a URL. + + If the URL points to a GitHub repo, it will automatically look for + fileglancer-app.json, then fileglancer-app.yaml, then fileglancer-app.yml. + """ + now = time.time() + + # Check cache + if url in _manifest_cache: + manifest, cached_at = _manifest_cache[url] + if now - cached_at < _MANIFEST_CACHE_TTL: + return manifest + + # Resolve GitHub URLs to candidate raw URLs + candidate_urls = _github_to_raw_urls(url) + + data = None + async with httpx.AsyncClient(timeout=30.0) as client: + for fetch_url in candidate_urls: + try: + response = await client.get(fetch_url) + response.raise_for_status() + data = _parse_manifest_response(response) + break + except httpx.HTTPStatusError as e: + if e.response.status_code == 404 and len(candidate_urls) > 1: + continue + raise + + if data is None: + filenames = ", ".join(_MANIFEST_FILENAMES) + raise ValueError( + f"No manifest file found ({filenames}). " + f"Make sure the file exists in the repository." + ) + + manifest = AppManifest(**data) + + # Cache the result + _manifest_cache[url] = (manifest, now) + return manifest + + +def clear_manifest_cache(): + """Clear the manifest cache.""" + _manifest_cache.clear() + + +# --- Command Building --- + +# Characters that are dangerous in shell commands +_SHELL_METACHAR_PATTERN = re.compile(r'[;&|`$(){}!<>\n\r]') + + +def _validate_parameter_value(param: AppParameter, value) -> str: + """Validate a single parameter value against its schema and return the string representation. + + Raises ValueError if validation fails. + """ + if param.type == "boolean": + if not isinstance(value, bool): + raise ValueError(f"Parameter '{param.id}' must be a boolean") + return str(value) + + if param.type == "integer": + try: + int_val = int(value) + except (TypeError, ValueError): + raise ValueError(f"Parameter '{param.id}' must be an integer") + if param.min is not None and int_val < param.min: + raise ValueError(f"Parameter '{param.id}' must be >= {param.min}") + if param.max is not None and int_val > param.max: + raise ValueError(f"Parameter '{param.id}' must be <= {param.max}") + return str(int_val) + + if param.type == "number": + try: + num_val = float(value) + except (TypeError, ValueError): + raise ValueError(f"Parameter '{param.id}' must be a number") + if param.min is not None and num_val < param.min: + raise ValueError(f"Parameter '{param.id}' must be >= {param.min}") + if param.max is not None and num_val > param.max: + raise ValueError(f"Parameter '{param.id}' must be <= {param.max}") + return str(num_val) + + if param.type == "enum": + str_val = str(value) + if param.options and str_val not in param.options: + raise ValueError(f"Parameter '{param.id}' must be one of {param.options}") + return str_val + + # string, file, directory + str_val = str(value) + + if param.type in ("file", "directory"): + # Validate path characters + if _SHELL_METACHAR_PATTERN.search(str_val): + raise ValueError(f"Parameter '{param.id}' contains invalid characters") + + if param.type == "string" and param.pattern: + if not re.fullmatch(param.pattern, str_val): + raise ValueError(f"Parameter '{param.id}' does not match required pattern") + + return str_val + + +def build_command(entry_point: AppEntryPoint, parameters: dict) -> str: + """Build a shell command from an entry point and parameter values. + + All parameter values are validated and shell-escaped. + Raises ValueError for invalid parameters. + """ + # Build a lookup of parameter definitions + param_defs = {p.id: p for p in entry_point.parameters} + + # Validate required parameters + for param in entry_point.parameters: + if param.required and param.id not in parameters: + if param.default is None: + raise ValueError(f"Required parameter '{param.id}' is missing") + + # Start with the base command + parts = [entry_point.command] + + # Append parameters as CLI flags + for param_id, value in parameters.items(): + if param_id not in param_defs: + raise ValueError(f"Unknown parameter '{param_id}'") + + param = param_defs[param_id] + validated = _validate_parameter_value(param, value) + + if param.type == "boolean": + if value is True: + parts.append(f"--{param_id}") + # If False, omit the flag + else: + parts.append(f"--{param_id} {shlex.quote(validated)}") + + # Apply defaults for missing optional parameters + for param in entry_point.parameters: + if param.id not in parameters and param.default is not None: + validated = _validate_parameter_value(param, param.default) + if param.type == "boolean": + if param.default is True: + parts.append(f"--{param.id}") + else: + parts.append(f"--{param.id} {shlex.quote(validated)}") + + return " ".join(parts) + + +# --- Executor Management --- + +_executor = None +_monitor = None +_monitor_task = None + + +async def get_executor(): + """Get or create the cluster executor singleton.""" + global _executor + if _executor is None: + settings = get_settings() + _executor = create_executor( + executor=settings.cluster_executor, + queue=settings.cluster_queue, + log_directory=settings.cluster_log_directory, + ) + return _executor + + +async def start_job_monitor(): + """Start the background job monitoring loop.""" + global _monitor, _monitor_task + + settings = get_settings() + executor = await get_executor() + + _monitor = JobMonitor(executor, poll_interval=settings.cluster_poll_interval) + await _monitor.start() + + # Start reconciliation loop + _monitor_task = asyncio.create_task(_reconcile_loop(settings)) + logger.info("Job monitor started") + + +async def stop_job_monitor(): + """Stop the background job monitoring loop.""" + global _monitor, _monitor_task + + if _monitor_task: + _monitor_task.cancel() + try: + await _monitor_task + except asyncio.CancelledError: + pass + _monitor_task = None + + if _monitor: + await _monitor.stop() + _monitor = None + + logger.info("Job monitor stopped") + + +async def _reconcile_loop(settings): + """Periodically reconcile DB job statuses with cluster state.""" + while True: + try: + await _reconcile_jobs(settings) + except Exception: + logger.exception("Error in job reconciliation loop") + + await asyncio.sleep(settings.cluster_poll_interval) + + +async def _reconcile_jobs(settings): + """Reconcile DB job statuses with the executor's tracked jobs.""" + executor = await get_executor() + + with db.get_db_session(settings.db_url) as session: + active_jobs = db.get_active_jobs(session) + + for db_job in active_jobs: + if not db_job.cluster_job_id: + continue + + # Check if executor is tracking this job + tracked = executor.jobs.get(db_job.cluster_job_id) + if tracked is None: + # Job is no longer tracked by executor - might have been lost + # Mark as FAILED if it was RUNNING, leave PENDING as is + if db_job.status == "RUNNING": + now = datetime.now(UTC) + db.update_job_status(session, db_job.id, "FAILED", finished_at=now) + logger.warning(f"Job {db_job.id} (cluster: {db_job.cluster_job_id}) lost from executor, marked FAILED") + continue + + # Map cluster status to our status strings + new_status = _map_status(tracked.status) + if new_status != db_job.status: + db.update_job_status( + session, db_job.id, new_status, + exit_code=tracked.exit_code, + started_at=tracked.start_time, + finished_at=tracked.finish_time, + ) + logger.info(f"Job {db_job.id} status updated: {db_job.status} -> {new_status}") + + +def _map_status(status: JobStatus) -> str: + """Map py-cluster-api JobStatus to our string status.""" + mapping = { + JobStatus.PENDING: "PENDING", + JobStatus.RUNNING: "RUNNING", + JobStatus.DONE: "DONE", + JobStatus.FAILED: "FAILED", + JobStatus.KILLED: "KILLED", + JobStatus.UNKNOWN: "FAILED", + } + return mapping.get(status, "FAILED") + + +# --- Job Submission --- + +def _sanitize_for_path(s: str) -> str: + """Sanitize a string for use in a directory name.""" + return re.sub(r'[^a-zA-Z0-9._-]', '_', s) + + +def _build_work_dir(job_id: int, app_name: str, entry_point_id: str) -> str: + """Build a working directory path under ~/.fileglancer/jobs/.""" + safe_app = _sanitize_for_path(app_name) + safe_ep = _sanitize_for_path(entry_point_id) + return f"$HOME/.fileglancer/jobs/{job_id}-{safe_app}-{safe_ep}" + + +async def submit_job( + username: str, + app_url: str, + entry_point_id: str, + parameters: dict, + resources: Optional[dict] = None, +) -> db.JobDB: + """Submit a new job to the cluster. + + Fetches the manifest, validates parameters, builds the command, + submits to the executor, and creates a DB record. + Each job runs in its own directory under ~/.fileglancer/jobs/. + """ + settings = get_settings() + + # Fetch and validate manifest + manifest = await fetch_app_manifest(app_url) + + # Find entry point + entry_point = None + for ep in manifest.entryPoints: + if ep.id == entry_point_id: + entry_point = ep + break + if entry_point is None: + raise ValueError(f"Entry point '{entry_point_id}' not found in manifest") + + # Build command + command = build_command(entry_point, parameters) + + # Build resource spec + resource_spec = _build_resource_spec(entry_point, resources, settings) + + # Create DB record first to get job ID for the work directory + resources_dict = None + if resource_spec: + resources_dict = { + "cpus": resource_spec.cpus, + "memory": resource_spec.memory, + "walltime": resource_spec.walltime, + "queue": resource_spec.queue, + } + + with db.get_db_session(settings.db_url) as session: + db_job = db.create_job( + session=session, + username=username, + app_url=app_url, + app_name=manifest.name, + entry_point_id=entry_point.id, + entry_point_name=entry_point.name, + parameters=parameters, + resources=resources_dict, + ) + + # Build work directory and wrap command + work_dir = _build_work_dir(db_job.id, manifest.name, entry_point.id) + command = f"mkdir -p {work_dir} && cd {work_dir} && {command}" + + # Set work_dir on resource spec for LSF -cwd support + resource_spec.work_dir = work_dir + + # Submit to executor + executor = await get_executor() + job_name = f"fg-{manifest.name}-{entry_point.id}" + cluster_job = await executor.submit( + command=command, + name=job_name, + resources=resource_spec, + ) + + # Update DB with cluster job ID + with db.get_db_session(settings.db_url) as session: + db.update_job_status( + session, db_job.id, "PENDING", + cluster_job_id=cluster_job.job_id, + ) + db_job = db.get_job(session, db_job.id, username) + + logger.info(f"Job {db_job.id} submitted for user {username} in {work_dir}: {command}") + return db_job + + +def _build_resource_spec(entry_point: AppEntryPoint, overrides: Optional[dict], settings) -> ResourceSpec: + """Build a ResourceSpec from entry point defaults, user overrides, and global defaults.""" + cpus = settings.cluster_default_cpus + memory = settings.cluster_default_memory + walltime = settings.cluster_default_walltime + queue = settings.cluster_queue + + # Apply entry point defaults + if entry_point.resources: + if entry_point.resources.cpus is not None: + cpus = entry_point.resources.cpus + if entry_point.resources.memory is not None: + memory = entry_point.resources.memory + if entry_point.resources.walltime is not None: + walltime = entry_point.resources.walltime + + # Apply user overrides + if overrides: + if overrides.get("cpus") is not None: + cpus = overrides["cpus"] + if overrides.get("memory") is not None: + memory = overrides["memory"] + if overrides.get("walltime") is not None: + walltime = overrides["walltime"] + + return ResourceSpec( + cpus=cpus, + memory=memory, + walltime=walltime, + queue=queue, + account=settings.cluster_account, + ) + + +async def cancel_job(job_id: int, username: str) -> db.JobDB: + """Cancel a running or pending job.""" + settings = get_settings() + + with db.get_db_session(settings.db_url) as session: + db_job = db.get_job(session, job_id, username) + if db_job is None: + raise ValueError(f"Job {job_id} not found") + if db_job.status not in ("PENDING", "RUNNING"): + raise ValueError(f"Job {job_id} is not cancellable (status: {db_job.status})") + + # Cancel on cluster + if db_job.cluster_job_id: + executor = await get_executor() + await executor.cancel(db_job.cluster_job_id) + + # Update DB + now = datetime.now(UTC) + db.update_job_status(session, db_job.id, "KILLED", finished_at=now) + db_job = db.get_job(session, db_job.id, username) + + logger.info(f"Job {job_id} cancelled by user {username}") + return db_job diff --git a/fileglancer/database.py b/fileglancer/database.py index 21616aac..e51ba766 100644 --- a/fileglancer/database.py +++ b/fileglancer/database.py @@ -1,6 +1,6 @@ import secrets import hashlib -from datetime import datetime, UTC +from datetime import datetime, timedelta, UTC import os from functools import lru_cache @@ -136,6 +136,26 @@ class TicketDB(Base): # ) +class JobDB(Base): + """Database model for storing cluster jobs""" + __tablename__ = 'jobs' + + id = Column(Integer, primary_key=True, autoincrement=True) + username = Column(String, nullable=False, index=True) + cluster_job_id = Column(String, nullable=True, index=True) + app_url = Column(String, nullable=False) + app_name = Column(String, nullable=False) + entry_point_id = Column(String, nullable=False) + entry_point_name = Column(String, nullable=False) + parameters = Column(JSON, nullable=False) + status = Column(String, nullable=False, default="PENDING") + exit_code = Column(Integer, nullable=True) + resources = Column(JSON, nullable=True) + created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(UTC)) + started_at = Column(DateTime, nullable=True) + finished_at = Column(DateTime, nullable=True) + + class SessionDB(Base): """Database model for storing user sessions""" __tablename__ = 'sessions' @@ -796,3 +816,79 @@ def delete_expired_sessions(session: Session): deleted = session.query(SessionDB).filter(SessionDB.expires_at < now).delete() session.commit() return deleted + + +# --- Job database functions --- + +def create_job(session: Session, username: str, app_url: str, app_name: str, + entry_point_id: str, entry_point_name: str, parameters: Dict, + resources: Optional[Dict] = None) -> JobDB: + """Create a new job record""" + now = datetime.now(UTC) + job = JobDB( + username=username, + app_url=app_url, + app_name=app_name, + entry_point_id=entry_point_id, + entry_point_name=entry_point_name, + parameters=parameters, + resources=resources, + status="PENDING", + created_at=now + ) + session.add(job) + session.commit() + return job + + +def get_jobs_by_username(session: Session, username: str, status: Optional[str] = None) -> List[JobDB]: + """Get all jobs for a user, newest first""" + query = session.query(JobDB).filter_by(username=username) + if status: + query = query.filter_by(status=status) + return query.order_by(JobDB.created_at.desc()).all() + + +def get_job(session: Session, job_id: int, username: str) -> Optional[JobDB]: + """Get a single job by ID and username""" + return session.query(JobDB).filter_by(id=job_id, username=username).first() + + +def get_active_jobs(session: Session) -> List[JobDB]: + """Get all jobs with PENDING or RUNNING status""" + return session.query(JobDB).filter( + JobDB.status.in_(["PENDING", "RUNNING"]) + ).all() + + +def update_job_status(session: Session, job_id: int, status: str, + exit_code: Optional[int] = None, + cluster_job_id: Optional[str] = None, + started_at: Optional[datetime] = None, + finished_at: Optional[datetime] = None) -> Optional[JobDB]: + """Update a job's status and related fields""" + job = session.query(JobDB).filter_by(id=job_id).first() + if not job: + return None + job.status = status + if exit_code is not None: + job.exit_code = exit_code + if cluster_job_id is not None: + job.cluster_job_id = cluster_job_id + if started_at is not None: + job.started_at = started_at + if finished_at is not None: + job.finished_at = finished_at + session.commit() + return job + + +def delete_old_jobs(session: Session, days: int = 30) -> int: + """Delete completed/failed jobs older than the specified number of days""" + cutoff = datetime.now(UTC) - timedelta(days=days) + deleted = session.query(JobDB).filter( + JobDB.status.in_(["DONE", "FAILED", "KILLED"]), + JobDB.created_at < cutoff + ).delete(synchronize_session='fetch') + session.commit() + return deleted diff --git a/fileglancer/model.py b/fileglancer/model.py index ffe5330e..94538dc4 100644 --- a/fileglancer/model.py +++ b/fileglancer/model.py @@ -1,5 +1,5 @@ from datetime import datetime -from typing import List, Optional, Dict +from typing import Any, List, Literal, Optional, Dict from pydantic import BaseModel, Field, HttpUrl @@ -303,3 +303,100 @@ class NeuroglancerShortLinkResponse(BaseModel): links: List[NeuroglancerShortLink] = Field( description="A list of stored Neuroglancer short links" ) + + +# --- App Manifest Models --- + +class AppParameter(BaseModel): + """A parameter definition for an app entry point""" + id: str = Field(description="Unique identifier for the parameter") + name: str = Field(description="Display name of the parameter") + type: Literal["string", "integer", "number", "boolean", "file", "directory", "enum"] = Field( + description="The data type of the parameter" + ) + description: Optional[str] = Field(description="Description of the parameter", default=None) + required: bool = Field(description="Whether the parameter is required", default=False) + default: Optional[Any] = Field(description="Default value for the parameter", default=None) + options: Optional[List[str]] = Field(description="Allowed values for enum type", default=None) + min: Optional[float] = Field(description="Minimum value for numeric types", default=None) + max: Optional[float] = Field(description="Maximum value for numeric types", default=None) + pattern: Optional[str] = Field(description="Regex validation pattern for string types", default=None) + + +class AppResourceDefaults(BaseModel): + """Resource defaults for an app entry point""" + cpus: Optional[int] = Field(description="Number of CPUs", default=None) + memory: Optional[str] = Field(description="Memory allocation (e.g. '16 GB')", default=None) + walltime: Optional[str] = Field(description="Wall time limit (e.g. '04:00')", default=None) + + +class AppEntryPoint(BaseModel): + """An entry point (command) within an app""" + id: str = Field(description="Unique identifier for the entry point") + name: str = Field(description="Display name of the entry point") + description: Optional[str] = Field(description="Description of the entry point", default=None) + command: str = Field(description="The base CLI command to execute") + parameters: List[AppParameter] = Field(description="Parameters for this entry point", default=[]) + resources: Optional[AppResourceDefaults] = Field(description="Default resource requirements", default=None) + + +class AppManifest(BaseModel): + """Top-level app manifest (fileglancer-app.json)""" + name: str = Field(description="Display name of the app") + description: Optional[str] = Field(description="Description of the app", default=None) + version: Optional[str] = Field(description="Version of the app", default=None) + entryPoints: List[AppEntryPoint] = Field(description="Available entry points for this app") + + +class UserApp(BaseModel): + """A user's saved app reference""" + url: str = Field(description="URL to the app manifest") + name: str = Field(description="App name from manifest") + description: Optional[str] = Field(description="App description from manifest", default=None) + added_at: datetime = Field(description="When the app was added") + manifest: Optional[AppManifest] = Field(description="Cached manifest data", default=None) + + +class ManifestFetchRequest(BaseModel): + """Request to fetch an app manifest""" + url: str = Field(description="URL to the app manifest or GitHub repo") + + +class AppAddRequest(BaseModel): + """Request to add an app""" + url: str = Field(description="URL to the app manifest or GitHub repo") + + +class AppRemoveRequest(BaseModel): + """Request to remove an app""" + url: str = Field(description="URL of the app to remove") + + +class Job(BaseModel): + """A job record""" + id: int = Field(description="Unique job identifier") + app_url: str = Field(description="URL of the app manifest") + app_name: str = Field(description="Name of the app") + entry_point_id: str = Field(description="Entry point that was executed") + entry_point_name: str = Field(description="Display name of the entry point") + parameters: Dict = Field(description="Parameters used for the job") + status: str = Field(description="Job status (PENDING, RUNNING, DONE, FAILED, KILLED)") + exit_code: Optional[int] = Field(description="Exit code of the job", default=None) + resources: Optional[Dict] = Field(description="Requested resources", default=None) + cluster_job_id: Optional[str] = Field(description="Cluster-assigned job ID", default=None) + created_at: datetime = Field(description="When the job was created") + started_at: Optional[datetime] = Field(description="When the job started running", default=None) + finished_at: Optional[datetime] = Field(description="When the job finished", default=None) + + +class JobSubmitRequest(BaseModel): + """Request to submit a new job""" + app_url: str = Field(description="URL of the app manifest") + entry_point_id: str = Field(description="Entry point to execute") + parameters: Dict = Field(description="Parameter values keyed by parameter ID") + resources: Optional[AppResourceDefaults] = Field(description="Resource overrides", default=None) + + +class JobResponse(BaseModel): + """Response containing a list of jobs""" + jobs: List[Job] = Field(description="A list of jobs") diff --git a/fileglancer/settings.py b/fileglancer/settings.py index 3cddb73e..de2ca4ea 100644 --- a/fileglancer/settings.py +++ b/fileglancer/settings.py @@ -67,6 +67,16 @@ class Settings(BaseSettings): # CLI mode - enables auto-login endpoint for standalone CLI usage cli_mode: bool = False + # Cluster / Apps settings + cluster_executor: str = 'local' # "lsf" or "local" + cluster_queue: Optional[str] = None + cluster_account: Optional[str] = None + cluster_poll_interval: float = 10.0 + cluster_log_directory: str = './logs' + cluster_default_memory: Optional[str] = None # e.g. "8 GB" + cluster_default_walltime: Optional[str] = None # e.g. "04:00" + cluster_default_cpus: Optional[int] = None + model_config = SettingsConfigDict( yaml_file="config.yaml", env_file='.env', diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 4dc7ee4d..7b58a828 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -8,6 +8,8 @@ import { MainLayout } from './layouts/MainLayout'; import { BrowsePageLayout } from './layouts/BrowseLayout'; import { OtherPagesLayout } from './layouts/OtherPagesLayout'; import Login from '@/components/Login'; +import Apps from '@/components/Apps'; +import AppLaunch from '@/components/AppLaunch'; import Browse from '@/components/Browse'; import Help from '@/components/Help'; import Jobs from '@/components/Jobs'; @@ -112,6 +114,22 @@ const AppComponent = () => { } path="nglinks" /> + + + + } + path="apps" + /> + + + + } + path="apps/launch/:encodedUrl" + /> {tasksEnabled ? ( (); + const navigate = useNavigate(); + const manifestMutation = useManifestPreviewMutation(); + const submitJobMutation = useSubmitJobMutation(); + const [selectedEntryPoint, setSelectedEntryPoint] = + useState(null); + + const appUrl = encodedUrl ? decodeURIComponent(encodedUrl) : ''; + + useEffect(() => { + if (appUrl) { + manifestMutation.mutate(appUrl); + } + // Only fetch on mount + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [appUrl]); + + const manifest = manifestMutation.data; + + // Auto-select if there's only one entry point + useEffect(() => { + if (manifest?.entryPoints.length === 1) { + setSelectedEntryPoint(manifest.entryPoints[0]); + } + }, [manifest]); + + const handleSubmit = async ( + parameters: Record, + resources?: AppResourceDefaults + ) => { + if (!selectedEntryPoint) { + return; + } + try { + await submitJobMutation.mutateAsync({ + app_url: appUrl, + entry_point_id: selectedEntryPoint.id, + parameters, + resources + }); + toast.success('Job submitted'); + navigate('/apps'); + } catch (error) { + const message = + error instanceof Error ? error.message : 'Failed to submit job'; + toast.error(message); + } + }; + + return ( +
+ + + {manifestMutation.isPending ? ( + + Loading app manifest... + + ) : manifestMutation.isError ? ( +
+ Failed to load app manifest:{' '} + {manifestMutation.error?.message || 'Unknown error'} +
+ ) : manifest && selectedEntryPoint ? ( + <> + {manifest.entryPoints.length > 1 ? ( + + ) : null} + + + ) : manifest ? ( +
+ + {manifest.name} + + {manifest.description ? ( + + {manifest.description} + + ) : null} + + Select an entry point: + +
+ {manifest.entryPoints.map(ep => ( +
+
+ + {ep.name} + + {ep.description ? ( + + {ep.description} + + ) : null} +
+ +
+ ))} +
+
+ ) : null} +
+ ); +} diff --git a/frontend/src/components/Apps.tsx b/frontend/src/components/Apps.tsx new file mode 100644 index 00000000..b942628d --- /dev/null +++ b/frontend/src/components/Apps.tsx @@ -0,0 +1,133 @@ +import { useMemo, useState } from 'react'; + +import { Button, Typography } from '@material-tailwind/react'; +import { HiOutlinePlus } from 'react-icons/hi'; +import toast from 'react-hot-toast'; + +import AppCard from '@/components/ui/AppsPage/AppCard'; +import AddAppDialog from '@/components/ui/AppsPage/AddAppDialog'; +import { TableCard } from '@/components/ui/Table/TableCard'; +import { createAppsJobsColumns } from '@/components/ui/Table/appsJobsColumns'; +import { + useAppsQuery, + useAddAppMutation, + useRemoveAppMutation +} from '@/queries/appsQueries'; +import { useJobsQuery, useCancelJobMutation } from '@/queries/jobsQueries'; + +export default function Apps() { + const [showAddDialog, setShowAddDialog] = useState(false); + + const appsQuery = useAppsQuery(); + const jobsQuery = useJobsQuery(); + const addAppMutation = useAddAppMutation(); + const removeAppMutation = useRemoveAppMutation(); + const cancelJobMutation = useCancelJobMutation(); + + const handleAddApp = async (url: string) => { + try { + await addAppMutation.mutateAsync(url); + toast.success('App added'); + setShowAddDialog(false); + } catch (error) { + const message = + error instanceof Error ? error.message : 'Failed to add app'; + toast.error(message); + } + }; + + const handleRemoveApp = async (url: string) => { + try { + await removeAppMutation.mutateAsync(url); + toast.success('App removed'); + } catch (error) { + const message = + error instanceof Error ? error.message : 'Failed to remove app'; + toast.error(message); + } + }; + + const handleCancelJob = async (jobId: number) => { + try { + await cancelJobMutation.mutateAsync(jobId); + toast.success('Job cancelled'); + } catch (error) { + const message = + error instanceof Error ? error.message : 'Failed to cancel job'; + toast.error(message); + } + }; + + const jobsColumns = useMemo( + () => createAppsJobsColumns(handleCancelJob), + // eslint-disable-next-line react-hooks/exhaustive-deps + [] + ); + + return ( +
+ {/* My Apps Section */} + + Apps + + + Run command-line tools on the cluster. Add apps by URL to get started. + + +
+ +
+ + {appsQuery.isPending ? ( + + Loading apps... + + ) : appsQuery.isError ? ( +
+ Failed to load apps: {appsQuery.error?.message || 'Unknown error'} +
+ ) : appsQuery.data?.length ? ( +
+ {appsQuery.data.map(app => ( + + ))} +
+ ) : ( +
+ + No apps configured. Click "Add App" to get started. + +
+ )} + + {/* Recent Jobs Section */} + + Recent Jobs + + + + + setShowAddDialog(false)} + open={showAddDialog} + /> +
+ ); +} diff --git a/frontend/src/components/ui/AppsPage/AddAppDialog.tsx b/frontend/src/components/ui/AppsPage/AddAppDialog.tsx new file mode 100644 index 00000000..b519ab6a --- /dev/null +++ b/frontend/src/components/ui/AppsPage/AddAppDialog.tsx @@ -0,0 +1,202 @@ +import { useState } from 'react'; + +import { Button, Typography } from '@material-tailwind/react'; + +import FgDialog from '@/components/ui/Dialogs/FgDialog'; +import { useManifestPreviewMutation } from '@/queries/appsQueries'; +import type { AppManifest } from '@/shared.types'; + +const GITHUB_URL_PATTERN = /^https?:\/\/github\.com\/[^/]+\/[^/]+\/?$/; + +function isValidGitHubUrl(url: string): boolean { + return GITHUB_URL_PATTERN.test(url.trim()); +} + +function buildAppUrl(repoUrl: string, branch: string): string { + let url = repoUrl.trim().replace(/\/+$/, ''); + if (branch.trim()) { + url += `/tree/${branch.trim()}`; + } + return url; +} + +interface AddAppDialogProps { + readonly open: boolean; + readonly onClose: () => void; + readonly onAdd: (url: string) => Promise; + readonly adding: boolean; +} + +export default function AddAppDialog({ + open, + onClose, + onAdd, + adding +}: AddAppDialogProps) { + const [repoUrl, setRepoUrl] = useState(''); + const [branch, setBranch] = useState(''); + const [urlError, setUrlError] = useState(''); + const [preview, setPreview] = useState(null); + const manifestMutation = useManifestPreviewMutation(); + + const validateUrl = (url: string): boolean => { + if (!url.trim()) { + setUrlError(''); + return false; + } + if (!isValidGitHubUrl(url)) { + setUrlError('Please enter a valid GitHub repository URL'); + return false; + } + setUrlError(''); + return true; + }; + + const handleFetchPreview = async () => { + if (!validateUrl(repoUrl)) { + return; + } + const appUrl = buildAppUrl(repoUrl, branch); + try { + const manifest = await manifestMutation.mutateAsync(appUrl); + setPreview(manifest); + } catch { + setPreview(null); + } + }; + + const handleAdd = async () => { + const appUrl = buildAppUrl(repoUrl, branch); + await onAdd(appUrl); + setRepoUrl(''); + setBranch(''); + setUrlError(''); + setPreview(null); + }; + + const handleClose = () => { + setRepoUrl(''); + setBranch(''); + setUrlError(''); + setPreview(null); + manifestMutation.reset(); + onClose(); + }; + + return ( + + + Add App + + + + Enter a GitHub repository URL containing a{' '} + fileglancer-app.json manifest. + + +
+ +
+ { + setRepoUrl(e.target.value); + setUrlError(''); + setPreview(null); + manifestMutation.reset(); + }} + onKeyDown={e => { + if (e.key === 'Enter') { + handleFetchPreview(); + } + }} + placeholder="https://github.com/org/repo" + type="text" + value={repoUrl} + /> + +
+ {urlError ? ( + + {urlError} + + ) : null} +
+ +
+ + { + setBranch(e.target.value); + setPreview(null); + manifestMutation.reset(); + }} + onKeyDown={e => { + if (e.key === 'Enter') { + handleFetchPreview(); + } + }} + placeholder="main" + type="text" + value={branch} + /> +
+ + {manifestMutation.isError ? ( +
+ Failed to fetch manifest:{' '} + {manifestMutation.error?.message || 'Unknown error'} +
+ ) : null} + + {preview ? ( +
+ + {preview.name} + + {preview.description ? ( + + {preview.description} + + ) : null} + {preview.version ? ( + + Version: {preview.version} + + ) : null} + + {preview.entryPoints.length} entry point + {preview.entryPoints.length !== 1 ? 's' : ''} + +
+ ) : null} + +
+ + +
+
+ ); +} diff --git a/frontend/src/components/ui/AppsPage/AppCard.tsx b/frontend/src/components/ui/AppsPage/AppCard.tsx new file mode 100644 index 00000000..36bc7905 --- /dev/null +++ b/frontend/src/components/ui/AppsPage/AppCard.tsx @@ -0,0 +1,61 @@ +import { useNavigate } from 'react-router'; + +import { Button, IconButton, Typography } from '@material-tailwind/react'; +import { HiOutlinePlay, HiOutlineTrash } from 'react-icons/hi'; + +import FgTooltip from '@/components/ui/widgets/FgTooltip'; +import type { UserApp } from '@/shared.types'; + +interface AppCardProps { + readonly app: UserApp; + readonly onRemove: (url: string) => void; + readonly removing: boolean; +} + +export default function AppCard({ app, onRemove, removing }: AppCardProps) { + const navigate = useNavigate(); + + const handleLaunch = () => { + const encodedUrl = encodeURIComponent(app.url); + navigate(`/apps/launch/${encodedUrl}`); + }; + + return ( +
+
+
+ + {app.name} + + {app.description ? ( + + {app.description} + + ) : null} +
+ + onRemove(app.url)} + size="sm" + variant="ghost" + > + + + +
+ + +
+ ); +} diff --git a/frontend/src/components/ui/AppsPage/AppLaunchForm.tsx b/frontend/src/components/ui/AppsPage/AppLaunchForm.tsx new file mode 100644 index 00000000..acb85cbd --- /dev/null +++ b/frontend/src/components/ui/AppsPage/AppLaunchForm.tsx @@ -0,0 +1,347 @@ +import { useState } from 'react'; + +import { Button, Typography } from '@material-tailwind/react'; +import { HiOutlinePlay } from 'react-icons/hi'; + +import FileSelectorButton from '@/components/ui/BrowsePage/FileSelector/FileSelectorButton'; +import { convertBackToForwardSlash } from '@/utils/pathHandling'; +import type { + AppEntryPoint, + AppManifest, + AppParameter, + AppResourceDefaults +} from '@/shared.types'; + +interface AppLaunchFormProps { + readonly manifest: AppManifest; + readonly entryPoint: AppEntryPoint; + readonly onSubmit: ( + parameters: Record, + resources?: AppResourceDefaults + ) => Promise; + readonly submitting: boolean; +} + +function ParameterField({ + param, + value, + onChange +}: { + readonly param: AppParameter; + readonly value: unknown; + readonly onChange: (value: unknown) => void; +}) { + const baseInputClass = + 'w-full p-2 text-foreground border rounded-sm focus:outline-none bg-background border-primary-light focus:border-primary'; + + switch (param.type) { + case 'boolean': + return ( + + ); + + case 'integer': + case 'number': + return ( + { + const val = e.target.value; + if (val === '') { + onChange(undefined); + } else { + onChange( + param.type === 'integer' ? parseInt(val) : parseFloat(val) + ); + } + }} + placeholder={param.description || param.name} + step={param.type === 'integer' ? 1 : 'any'} + type="number" + value={value !== undefined && value !== null ? String(value) : ''} + /> + ); + + case 'enum': + return ( + + ); + + case 'file': + case 'directory': + return ( +
+ onChange(e.target.value)} + placeholder={param.description || `Select a ${param.type}...`} + type="text" + value={value !== undefined && value !== null ? String(value) : ''} + /> + onChange(path)} + /> +
+ ); + + default: + return ( + onChange(e.target.value)} + placeholder={param.description || param.name} + type="text" + value={value !== undefined && value !== null ? String(value) : ''} + /> + ); + } +} + +export default function AppLaunchForm({ + manifest, + entryPoint, + onSubmit, + submitting +}: AppLaunchFormProps) { + // Initialize parameter values from defaults + const initialValues: Record = {}; + for (const param of entryPoint.parameters) { + if (param.default !== undefined) { + initialValues[param.id] = param.default; + } + } + + const [values, setValues] = useState>(initialValues); + const [errors, setErrors] = useState>({}); + const [showResources, setShowResources] = useState(false); + const [resources, setResources] = useState({ + cpus: entryPoint.resources?.cpus, + memory: entryPoint.resources?.memory, + walltime: entryPoint.resources?.walltime + }); + + const handleChange = (paramId: string, value: unknown) => { + setValues(prev => ({ ...prev, [paramId]: value })); + // Clear error on change + if (errors[paramId]) { + setErrors(prev => { + const next = { ...prev }; + delete next[paramId]; + return next; + }); + } + }; + + const validate = (): boolean => { + const newErrors: Record = {}; + for (const param of entryPoint.parameters) { + const val = values[param.id]; + if (param.required && (val === undefined || val === null || val === '')) { + newErrors[param.id] = `${param.name} is required`; + } + if ( + val !== undefined && + val !== null && + val !== '' && + (param.type === 'integer' || param.type === 'number') + ) { + const numVal = Number(val); + if (isNaN(numVal)) { + newErrors[param.id] = `${param.name} must be a valid number`; + } else { + if (param.min !== undefined && numVal < param.min) { + newErrors[param.id] = `${param.name} must be at least ${param.min}`; + } + if (param.max !== undefined && numVal > param.max) { + newErrors[param.id] = `${param.name} must be at most ${param.max}`; + } + } + } + } + setErrors(newErrors); + return Object.keys(newErrors).length === 0; + }; + + const handleSubmit = async () => { + if (!validate()) { + return; + } + + // Build a lookup of parameter definitions + const paramDefs = new Map(entryPoint.parameters.map(p => [p.id, p])); + + // Filter out undefined/empty values and normalize paths to Linux format + const params: Record = {}; + for (const [key, val] of Object.entries(values)) { + if (val !== undefined && val !== null && val !== '') { + const paramDef = paramDefs.get(key); + if ( + paramDef && + (paramDef.type === 'file' || paramDef.type === 'directory') && + typeof val === 'string' + ) { + params[key] = convertBackToForwardSlash(val); + } else { + params[key] = val; + } + } + } + + // Only pass resources if user modified them + const hasResourceOverrides = + showResources && + (resources.cpus || resources.memory || resources.walltime); + + await onSubmit(params, hasResourceOverrides ? resources : undefined); + }; + + return ( +
+ + {entryPoint.name} + + + {manifest.name} + {manifest.version ? ` v${manifest.version}` : ''} + + {entryPoint.description ? ( + + {entryPoint.description} + + ) : null} + + {/* Parameters */} +
+ {entryPoint.parameters.map(param => ( +
+ {param.type !== 'boolean' ? ( + + ) : null} + {param.description && param.type !== 'boolean' ? ( + + {param.description} + + ) : null} + handleChange(param.id, val)} + param={param} + value={values[param.id]} + /> + {errors[param.id] ? ( + + {errors[param.id]} + + ) : null} +
+ ))} +
+ + {/* Resource Overrides (collapsible) */} +
+ + {showResources ? ( +
+
+ + + setResources(prev => ({ + ...prev, + cpus: e.target.value ? parseInt(e.target.value) : undefined + })) + } + placeholder="Number of CPUs" + type="number" + value={resources.cpus ?? ''} + /> +
+
+ + + setResources(prev => ({ + ...prev, + memory: e.target.value || undefined + })) + } + placeholder="e.g. 16 GB" + type="text" + value={resources.memory ?? ''} + /> +
+
+ + + setResources(prev => ({ + ...prev, + walltime: e.target.value || undefined + })) + } + placeholder="e.g. 04:00" + type="text" + value={resources.walltime ?? ''} + /> +
+
+ ) : null} +
+ + {/* Submit */} + +
+ ); +} diff --git a/frontend/src/components/ui/AppsPage/JobStatusBadge.tsx b/frontend/src/components/ui/AppsPage/JobStatusBadge.tsx new file mode 100644 index 00000000..1265fd61 --- /dev/null +++ b/frontend/src/components/ui/AppsPage/JobStatusBadge.tsx @@ -0,0 +1,53 @@ +import type { Job } from '@/shared.types'; + +const STATUS_STYLES: Record< + Job['status'], + { bg: string; text: string; label: string } +> = { + PENDING: { + bg: 'bg-secondary/20', + text: 'text-secondary', + label: 'Pending' + }, + RUNNING: { + bg: 'bg-info/20', + text: 'text-info', + label: 'Running' + }, + DONE: { + bg: 'bg-success/20', + text: 'text-success', + label: 'Done' + }, + FAILED: { + bg: 'bg-error/20', + text: 'text-error', + label: 'Failed' + }, + KILLED: { + bg: 'bg-warning/20', + text: 'text-warning', + label: 'Killed' + } +}; + +interface JobStatusBadgeProps { + readonly status: Job['status']; +} + +export default function JobStatusBadge({ status }: JobStatusBadgeProps) { + const style = STATUS_STYLES[status] ?? STATUS_STYLES.FAILED; + return ( + + {status === 'RUNNING' ? ( + + + + + ) : null} + {style.label} + + ); +} diff --git a/frontend/src/components/ui/Navbar/Navbar.tsx b/frontend/src/components/ui/Navbar/Navbar.tsx index 21da45fc..4f43acc2 100644 --- a/frontend/src/components/ui/Navbar/Navbar.tsx +++ b/frontend/src/components/ui/Navbar/Navbar.tsx @@ -16,7 +16,11 @@ import { HiOutlineSun, HiOutlineEye } from 'react-icons/hi'; -import { HiOutlineFolder, HiOutlineBriefcase } from 'react-icons/hi2'; +import { + HiOutlineFolder, + HiOutlineBriefcase, + HiOutlineCommandLine +} from 'react-icons/hi2'; import { TbBrandGithub } from 'react-icons/tb'; import ProfileMenu from '@/components/ui/Navbar/ProfileMenu'; @@ -40,6 +44,11 @@ const LINKS = [ title: 'NG Links', href: '/nglinks' }, + { + icon: HiOutlineCommandLine, + title: 'Apps', + href: '/apps' + }, { icon: HiOutlineBriefcase, title: 'Tasks', diff --git a/frontend/src/components/ui/Table/TableCard.tsx b/frontend/src/components/ui/Table/TableCard.tsx index cfbcd017..41e0a3b6 100644 --- a/frontend/src/components/ui/Table/TableCard.tsx +++ b/frontend/src/components/ui/Table/TableCard.tsx @@ -70,7 +70,7 @@ declare module '@tanstack/react-table' { } } -type DataType = 'data links' | 'tasks' | 'NG links'; +type DataType = 'data links' | 'tasks' | 'NG links' | 'jobs'; type TableProps = { readonly columns: ColumnDef[]; diff --git a/frontend/src/components/ui/Table/appsJobsColumns.tsx b/frontend/src/components/ui/Table/appsJobsColumns.tsx new file mode 100644 index 00000000..eb7e0ccd --- /dev/null +++ b/frontend/src/components/ui/Table/appsJobsColumns.tsx @@ -0,0 +1,156 @@ +import type { ColumnDef } from '@tanstack/react-table'; + +import { Button } from '@material-tailwind/react'; +import { HiOutlineX } from 'react-icons/hi'; + +import FgTooltip from '@/components/ui/widgets/FgTooltip'; +import JobStatusBadge from '@/components/ui/AppsPage/JobStatusBadge'; +import { formatDateString } from '@/utils'; +import type { Job } from '@/shared.types'; + +function formatDuration(job: Job): string { + const start = job.started_at || job.created_at; + const end = job.finished_at || new Date().toISOString(); + const startDate = new Date(start); + const endDate = new Date(end); + const diffMs = endDate.getTime() - startDate.getTime(); + + if (diffMs < 0) { + return '-'; + } + + const seconds = Math.floor(diffMs / 1000); + if (seconds < 60) { + return `${seconds}s`; + } + const minutes = Math.floor(seconds / 60); + if (minutes < 60) { + return `${minutes}m ${seconds % 60}s`; + } + const hours = Math.floor(minutes / 60); + return `${hours}h ${minutes % 60}m`; +} + +export function createAppsJobsColumns( + onCancel: (jobId: number) => void +): ColumnDef[] { + return [ + { + accessorKey: 'app_name', + header: 'App', + cell: ({ getValue, row, table }) => { + const value = getValue() as string; + const onContextMenu = table.options.meta?.onCellContextMenu; + return ( +
{ + e.preventDefault(); + onContextMenu?.(e, { value }); + }} + > + + {value} + +
+ ); + }, + enableSorting: true + }, + { + accessorKey: 'entry_point_name', + header: 'Entry Point', + cell: ({ getValue, table }) => { + const value = getValue() as string; + const onContextMenu = table.options.meta?.onCellContextMenu; + return ( +
{ + e.preventDefault(); + onContextMenu?.(e, { value }); + }} + > + {value} +
+ ); + }, + enableSorting: true + }, + { + accessorKey: 'status', + header: 'Status', + cell: ({ getValue }) => { + const status = getValue() as Job['status']; + return ( +
+ +
+ ); + }, + enableSorting: true + }, + { + accessorKey: 'created_at', + header: 'Submitted', + cell: ({ getValue, table }) => { + const value = getValue() as string; + const formatted = formatDateString(value); + const onContextMenu = table.options.meta?.onCellContextMenu; + return ( +
{ + e.preventDefault(); + onContextMenu?.(e, { value: formatted }); + }} + > + {formatted} +
+ ); + }, + enableSorting: true + }, + { + id: 'duration', + header: 'Duration', + cell: ({ row }) => { + const duration = formatDuration(row.original); + return ( +
+ {duration} +
+ ); + }, + enableSorting: false + }, + { + id: 'actions', + header: '', + cell: ({ row }) => { + const job = row.original; + const canCancel = job.status === 'PENDING' || job.status === 'RUNNING'; + if (!canCancel) { + return null; + } + return ( +
+ + + +
+ ); + }, + enableSorting: false + } + ]; +} diff --git a/frontend/src/queries/appsQueries.ts b/frontend/src/queries/appsQueries.ts new file mode 100644 index 00000000..219e6c95 --- /dev/null +++ b/frontend/src/queries/appsQueries.ts @@ -0,0 +1,102 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import type { UseQueryResult, UseMutationResult } from '@tanstack/react-query'; + +import { sendFetchRequest } from '@/utils'; +import { + getResponseJsonOrError, + throwResponseNotOkError +} from '@/queries/queryUtils'; +import type { AppManifest, UserApp } from '@/shared.types'; + +// --- Query Keys --- + +export const appsQueryKeys = { + all: ['apps'] as const, + list: () => ['apps', 'list'] as const +}; + +// --- Fetch Helpers --- + +async function fetchUserApps(signal?: AbortSignal): Promise { + const response = await sendFetchRequest('/api/apps', 'GET', undefined, { + signal + }); + const data = await getResponseJsonOrError(response); + if (!response.ok) { + throwResponseNotOkError(response, data); + } + return data as UserApp[]; +} + +// --- Query Hooks --- + +export function useAppsQuery(): UseQueryResult { + return useQuery({ + queryKey: appsQueryKeys.list(), + queryFn: ({ signal }) => fetchUserApps(signal), + staleTime: 5 * 60 * 1000 + }); +} + +// --- Mutation Hooks --- + +export function useManifestPreviewMutation(): UseMutationResult< + AppManifest, + Error, + string +> { + return useMutation({ + mutationFn: async (url: string) => { + const response = await sendFetchRequest('/api/apps/manifest', 'POST', { + url + }); + const data = await getResponseJsonOrError(response); + if (!response.ok) { + throwResponseNotOkError(response, data); + } + return data as AppManifest; + } + }); +} + +export function useAddAppMutation(): UseMutationResult { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (url: string) => { + const response = await sendFetchRequest('/api/apps', 'POST', { url }); + const data = await getResponseJsonOrError(response); + if (!response.ok) { + throwResponseNotOkError(response, data); + } + return data as UserApp; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: appsQueryKeys.all }); + } + }); +} + +export function useRemoveAppMutation(): UseMutationResult< + unknown, + Error, + string +> { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (url: string) => { + const encodedUrl = encodeURIComponent(url); + const response = await sendFetchRequest( + `/api/apps?url=${encodedUrl}`, + 'DELETE' + ); + const data = await getResponseJsonOrError(response); + if (!response.ok) { + throwResponseNotOkError(response, data); + } + return data; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: appsQueryKeys.all }); + } + }); +} diff --git a/frontend/src/queries/jobsQueries.ts b/frontend/src/queries/jobsQueries.ts new file mode 100644 index 00000000..527ed291 --- /dev/null +++ b/frontend/src/queries/jobsQueries.ts @@ -0,0 +1,94 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import type { UseQueryResult, UseMutationResult } from '@tanstack/react-query'; + +import { sendFetchRequest } from '@/utils'; +import { + getResponseJsonOrError, + throwResponseNotOkError +} from '@/queries/queryUtils'; +import type { Job, JobSubmitRequest } from '@/shared.types'; + +// --- Query Keys --- + +export const jobsQueryKeys = { + all: ['cluster-jobs'] as const, + list: () => ['cluster-jobs', 'list'] as const, + detail: (id: number) => ['cluster-jobs', 'detail', id] as const +}; + +// --- Fetch Helpers --- + +async function fetchJobs(signal?: AbortSignal): Promise { + const response = await sendFetchRequest('/api/jobs', 'GET', undefined, { + signal + }); + const data = await getResponseJsonOrError(response); + if (!response.ok) { + throwResponseNotOkError(response, data); + } + return (data as { jobs: Job[] }).jobs; +} + +// --- Query Hooks --- + +export function useJobsQuery(): UseQueryResult { + return useQuery({ + queryKey: jobsQueryKeys.list(), + queryFn: ({ signal }) => fetchJobs(signal), + // Auto-refresh every 5 seconds + refetchInterval: query => { + const jobs = query.state.data; + if (!jobs) { + return false; + } + const hasActive = jobs.some( + j => j.status === 'PENDING' || j.status === 'RUNNING' + ); + return hasActive ? 5000 : false; + } + }); +} + +// --- Mutation Hooks --- + +export function useSubmitJobMutation(): UseMutationResult< + Job, + Error, + JobSubmitRequest +> { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (request: JobSubmitRequest) => { + const response = await sendFetchRequest( + '/api/jobs', + 'POST', + request as unknown as Record + ); + const data = await getResponseJsonOrError(response); + if (!response.ok) { + throwResponseNotOkError(response, data); + } + return data as Job; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: jobsQueryKeys.all }); + } + }); +} + +export function useCancelJobMutation(): UseMutationResult { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (jobId: number) => { + const response = await sendFetchRequest(`/api/jobs/${jobId}`, 'DELETE'); + const data = await getResponseJsonOrError(response); + if (!response.ok) { + throwResponseNotOkError(response, data); + } + return data as Job; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: jobsQueryKeys.all }); + } + }); +} diff --git a/frontend/src/shared.types.ts b/frontend/src/shared.types.ts index 35eaef59..51d99fa7 100644 --- a/frontend/src/shared.types.ts +++ b/frontend/src/shared.types.ts @@ -50,14 +50,96 @@ type FetchRequestOptions = { signal?: AbortSignal; }; +// --- App / Job types --- + +type AppParameter = { + id: string; + name: string; + type: + | 'string' + | 'integer' + | 'number' + | 'boolean' + | 'file' + | 'directory' + | 'enum'; + description?: string; + required?: boolean; + default?: unknown; + options?: string[]; + min?: number; + max?: number; + pattern?: string; +}; + +type AppResourceDefaults = { + cpus?: number; + memory?: string; + walltime?: string; +}; + +type AppEntryPoint = { + id: string; + name: string; + description?: string; + command: string; + parameters: AppParameter[]; + resources?: AppResourceDefaults; +}; + +type AppManifest = { + name: string; + description?: string; + version?: string; + entryPoints: AppEntryPoint[]; +}; + +type UserApp = { + url: string; + name: string; + description?: string; + added_at: string; + manifest?: AppManifest; +}; + +type Job = { + id: number; + app_url: string; + app_name: string; + entry_point_id: string; + entry_point_name: string; + parameters: Record; + status: 'PENDING' | 'RUNNING' | 'DONE' | 'FAILED' | 'KILLED'; + exit_code?: number; + resources?: Record; + cluster_job_id?: string; + created_at: string; + started_at?: string; + finished_at?: string; +}; + +type JobSubmitRequest = { + app_url: string; + entry_point_id: string; + parameters: Record; + resources?: AppResourceDefaults; +}; + export type { + AppEntryPoint, + AppManifest, + AppParameter, + AppResourceDefaults, FetchRequestOptions, FileOrFolder, FileSharePath, Failure, + Job, + JobSubmitRequest, Profile, Result, Success, + UserApp, Zone, ZonesAndFileSharePathsMap }; diff --git a/pixi.lock b/pixi.lock index c4eeeec7..21745383 100644 --- a/pixi.lock +++ b/pixi.lock @@ -172,6 +172,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/a7/b2/fabede9fafd976b991e9f1b9c8c873ed86f202889b864756f240ce6dd855/frozenlist-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl - pypi: https://files.pythonhosted.org/packages/b4/63/2ab26e4209773223159b83aa32721b4021ffb08102f8ac7d689c943fded1/multidict-6.7.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/b2/f2/889ad4b2408f72fe1a4f6a19491177b30ea7bf1a0fd5f17050ca08cfc882/propcache-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/a7/0c/4372c76cb6eee8a90ccf7630c024df9944ac5d7b3de659145311ce9e84e8/py_cluster_api-0.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/48/a2/87a0b61a3078be07ab04ec574ef6c683c764590ed0d2a50d00cbb23aeae7/pydantic_settings_yaml-0.2.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fc/51/727abb13f44c1fcf6d145979e1535a35794db0f6e450a0cb46aa24732fe2/s3transfer-0.16.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/7f/21/f52cd11559059e686e7ecfe40c4746ee957f96779ce22a8f76d5b4d62516/x2s3-1.1.0-py3-none-any.whl @@ -339,6 +340,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/3a/3b/d9b1e0b0eed36e70477ffb8360c49c85c8ca8ef9700a4e6711f39a6e8b45/frozenlist-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl - pypi: https://files.pythonhosted.org/packages/15/ee/f524093232007cd7a75c1d132df70f235cfd590a7c9eaccd7ff422ef4ae8/multidict-6.7.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl - pypi: https://files.pythonhosted.org/packages/86/bd/47816020d337f4a746edc42fe8d53669965138f39ee117414c7d7a340cfe/propcache-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl + - pypi: https://files.pythonhosted.org/packages/a7/0c/4372c76cb6eee8a90ccf7630c024df9944ac5d7b3de659145311ce9e84e8/py_cluster_api-0.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/48/a2/87a0b61a3078be07ab04ec574ef6c683c764590ed0d2a50d00cbb23aeae7/pydantic_settings_yaml-0.2.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fc/51/727abb13f44c1fcf6d145979e1535a35794db0f6e450a0cb46aa24732fe2/s3transfer-0.16.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/7f/21/f52cd11559059e686e7ecfe40c4746ee957f96779ce22a8f76d5b4d62516/x2s3-1.1.0-py3-none-any.whl @@ -501,6 +503,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/8e/e8/a1185e236ec66c20afd72399522f142c3724c785789255202d27ae992818/frozenlist-1.8.0-cp314-cp314-macosx_10_13_x86_64.whl - pypi: https://files.pythonhosted.org/packages/14/2c/f069cab5b51d175a1a2cb4ccdf7a2c2dabd58aa5bd933fa036a8d15e2404/multidict-6.7.0-cp314-cp314-macosx_10_13_x86_64.whl - pypi: https://files.pythonhosted.org/packages/65/9b/03b04e7d82a5f54fb16113d839f5ea1ede58a61e90edf515f6577c66fa8f/propcache-0.4.1-cp314-cp314-macosx_10_13_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/a7/0c/4372c76cb6eee8a90ccf7630c024df9944ac5d7b3de659145311ce9e84e8/py_cluster_api-0.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/48/a2/87a0b61a3078be07ab04ec574ef6c683c764590ed0d2a50d00cbb23aeae7/pydantic_settings_yaml-0.2.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fc/51/727abb13f44c1fcf6d145979e1535a35794db0f6e450a0cb46aa24732fe2/s3transfer-0.16.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/7f/21/f52cd11559059e686e7ecfe40c4746ee957f96779ce22a8f76d5b4d62516/x2s3-1.1.0-py3-none-any.whl @@ -663,6 +666,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/a1/93/72b1736d68f03fda5fdf0f2180fb6caaae3894f1b854d006ac61ecc727ee/frozenlist-1.8.0-cp314-cp314-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/42/e2/64bb41266427af6642b6b128e8774ed84c11b80a90702c13ac0a86bb10cc/multidict-6.7.0-cp314-cp314-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/b2/fa/89a8ef0468d5833a23fff277b143d0573897cf75bd56670a6d28126c7d68/propcache-0.4.1-cp314-cp314-macosx_11_0_arm64.whl + - pypi: https://files.pythonhosted.org/packages/a7/0c/4372c76cb6eee8a90ccf7630c024df9944ac5d7b3de659145311ce9e84e8/py_cluster_api-0.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/48/a2/87a0b61a3078be07ab04ec574ef6c683c764590ed0d2a50d00cbb23aeae7/pydantic_settings_yaml-0.2.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fc/51/727abb13f44c1fcf6d145979e1535a35794db0f6e450a0cb46aa24732fe2/s3transfer-0.16.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/7f/21/f52cd11559059e686e7ecfe40c4746ee957f96779ce22a8f76d5b4d62516/x2s3-1.1.0-py3-none-any.whl @@ -881,6 +885,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/a7/b2/fabede9fafd976b991e9f1b9c8c873ed86f202889b864756f240ce6dd855/frozenlist-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl - pypi: https://files.pythonhosted.org/packages/b4/63/2ab26e4209773223159b83aa32721b4021ffb08102f8ac7d689c943fded1/multidict-6.7.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/b2/f2/889ad4b2408f72fe1a4f6a19491177b30ea7bf1a0fd5f17050ca08cfc882/propcache-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/a7/0c/4372c76cb6eee8a90ccf7630c024df9944ac5d7b3de659145311ce9e84e8/py_cluster_api-0.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/48/a2/87a0b61a3078be07ab04ec574ef6c683c764590ed0d2a50d00cbb23aeae7/pydantic_settings_yaml-0.2.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/bd/24/12818598c362d7f300f18e74db45963dbcb85150324092410c8b49405e42/pyproject_hooks-1.2.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fc/51/727abb13f44c1fcf6d145979e1535a35794db0f6e450a0cb46aa24732fe2/s3transfer-0.16.0-py3-none-any.whl @@ -1091,6 +1096,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/3a/3b/d9b1e0b0eed36e70477ffb8360c49c85c8ca8ef9700a4e6711f39a6e8b45/frozenlist-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl - pypi: https://files.pythonhosted.org/packages/15/ee/f524093232007cd7a75c1d132df70f235cfd590a7c9eaccd7ff422ef4ae8/multidict-6.7.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl - pypi: https://files.pythonhosted.org/packages/86/bd/47816020d337f4a746edc42fe8d53669965138f39ee117414c7d7a340cfe/propcache-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl + - pypi: https://files.pythonhosted.org/packages/a7/0c/4372c76cb6eee8a90ccf7630c024df9944ac5d7b3de659145311ce9e84e8/py_cluster_api-0.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/48/a2/87a0b61a3078be07ab04ec574ef6c683c764590ed0d2a50d00cbb23aeae7/pydantic_settings_yaml-0.2.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fc/51/727abb13f44c1fcf6d145979e1535a35794db0f6e450a0cb46aa24732fe2/s3transfer-0.16.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/7f/21/f52cd11559059e686e7ecfe40c4746ee957f96779ce22a8f76d5b4d62516/x2s3-1.1.0-py3-none-any.whl @@ -1289,6 +1295,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/8e/e8/a1185e236ec66c20afd72399522f142c3724c785789255202d27ae992818/frozenlist-1.8.0-cp314-cp314-macosx_10_13_x86_64.whl - pypi: https://files.pythonhosted.org/packages/14/2c/f069cab5b51d175a1a2cb4ccdf7a2c2dabd58aa5bd933fa036a8d15e2404/multidict-6.7.0-cp314-cp314-macosx_10_13_x86_64.whl - pypi: https://files.pythonhosted.org/packages/65/9b/03b04e7d82a5f54fb16113d839f5ea1ede58a61e90edf515f6577c66fa8f/propcache-0.4.1-cp314-cp314-macosx_10_13_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/a7/0c/4372c76cb6eee8a90ccf7630c024df9944ac5d7b3de659145311ce9e84e8/py_cluster_api-0.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/48/a2/87a0b61a3078be07ab04ec574ef6c683c764590ed0d2a50d00cbb23aeae7/pydantic_settings_yaml-0.2.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/bd/24/12818598c362d7f300f18e74db45963dbcb85150324092410c8b49405e42/pyproject_hooks-1.2.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fc/51/727abb13f44c1fcf6d145979e1535a35794db0f6e450a0cb46aa24732fe2/s3transfer-0.16.0-py3-none-any.whl @@ -1488,6 +1495,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/a1/93/72b1736d68f03fda5fdf0f2180fb6caaae3894f1b854d006ac61ecc727ee/frozenlist-1.8.0-cp314-cp314-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/42/e2/64bb41266427af6642b6b128e8774ed84c11b80a90702c13ac0a86bb10cc/multidict-6.7.0-cp314-cp314-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/b2/fa/89a8ef0468d5833a23fff277b143d0573897cf75bd56670a6d28126c7d68/propcache-0.4.1-cp314-cp314-macosx_11_0_arm64.whl + - pypi: https://files.pythonhosted.org/packages/a7/0c/4372c76cb6eee8a90ccf7630c024df9944ac5d7b3de659145311ce9e84e8/py_cluster_api-0.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/48/a2/87a0b61a3078be07ab04ec574ef6c683c764590ed0d2a50d00cbb23aeae7/pydantic_settings_yaml-0.2.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/bd/24/12818598c362d7f300f18e74db45963dbcb85150324092410c8b49405e42/pyproject_hooks-1.2.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fc/51/727abb13f44c1fcf6d145979e1535a35794db0f6e450a0cb46aa24732fe2/s3transfer-0.16.0-py3-none-any.whl @@ -1745,6 +1753,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/a7/b2/fabede9fafd976b991e9f1b9c8c873ed86f202889b864756f240ce6dd855/frozenlist-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl - pypi: https://files.pythonhosted.org/packages/b4/63/2ab26e4209773223159b83aa32721b4021ffb08102f8ac7d689c943fded1/multidict-6.7.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/b2/f2/889ad4b2408f72fe1a4f6a19491177b30ea7bf1a0fd5f17050ca08cfc882/propcache-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/a7/0c/4372c76cb6eee8a90ccf7630c024df9944ac5d7b3de659145311ce9e84e8/py_cluster_api-0.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/48/a2/87a0b61a3078be07ab04ec574ef6c683c764590ed0d2a50d00cbb23aeae7/pydantic_settings_yaml-0.2.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c8/c7/c160021cbecd956cc1a6f79e5fe155f7868b2e5b848f1320dad0b3e3122f/pytest_html-4.1.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/3e/43/7e7b2ec865caa92f67b8f0e9231a798d102724ca4c0e1f414316be1c1ef2/pytest_metadata-3.1.1-py3-none-any.whl @@ -1993,6 +2002,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/3a/3b/d9b1e0b0eed36e70477ffb8360c49c85c8ca8ef9700a4e6711f39a6e8b45/frozenlist-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl - pypi: https://files.pythonhosted.org/packages/15/ee/f524093232007cd7a75c1d132df70f235cfd590a7c9eaccd7ff422ef4ae8/multidict-6.7.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl - pypi: https://files.pythonhosted.org/packages/86/bd/47816020d337f4a746edc42fe8d53669965138f39ee117414c7d7a340cfe/propcache-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl + - pypi: https://files.pythonhosted.org/packages/a7/0c/4372c76cb6eee8a90ccf7630c024df9944ac5d7b3de659145311ce9e84e8/py_cluster_api-0.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/48/a2/87a0b61a3078be07ab04ec574ef6c683c764590ed0d2a50d00cbb23aeae7/pydantic_settings_yaml-0.2.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c8/c7/c160021cbecd956cc1a6f79e5fe155f7868b2e5b848f1320dad0b3e3122f/pytest_html-4.1.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/3e/43/7e7b2ec865caa92f67b8f0e9231a798d102724ca4c0e1f414316be1c1ef2/pytest_metadata-3.1.1-py3-none-any.whl @@ -2240,6 +2250,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/8e/e8/a1185e236ec66c20afd72399522f142c3724c785789255202d27ae992818/frozenlist-1.8.0-cp314-cp314-macosx_10_13_x86_64.whl - pypi: https://files.pythonhosted.org/packages/14/2c/f069cab5b51d175a1a2cb4ccdf7a2c2dabd58aa5bd933fa036a8d15e2404/multidict-6.7.0-cp314-cp314-macosx_10_13_x86_64.whl - pypi: https://files.pythonhosted.org/packages/65/9b/03b04e7d82a5f54fb16113d839f5ea1ede58a61e90edf515f6577c66fa8f/propcache-0.4.1-cp314-cp314-macosx_10_13_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/a7/0c/4372c76cb6eee8a90ccf7630c024df9944ac5d7b3de659145311ce9e84e8/py_cluster_api-0.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/48/a2/87a0b61a3078be07ab04ec574ef6c683c764590ed0d2a50d00cbb23aeae7/pydantic_settings_yaml-0.2.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c8/c7/c160021cbecd956cc1a6f79e5fe155f7868b2e5b848f1320dad0b3e3122f/pytest_html-4.1.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/3e/43/7e7b2ec865caa92f67b8f0e9231a798d102724ca4c0e1f414316be1c1ef2/pytest_metadata-3.1.1-py3-none-any.whl @@ -2487,6 +2498,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/a1/93/72b1736d68f03fda5fdf0f2180fb6caaae3894f1b854d006ac61ecc727ee/frozenlist-1.8.0-cp314-cp314-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/42/e2/64bb41266427af6642b6b128e8774ed84c11b80a90702c13ac0a86bb10cc/multidict-6.7.0-cp314-cp314-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/b2/fa/89a8ef0468d5833a23fff277b143d0573897cf75bd56670a6d28126c7d68/propcache-0.4.1-cp314-cp314-macosx_11_0_arm64.whl + - pypi: https://files.pythonhosted.org/packages/a7/0c/4372c76cb6eee8a90ccf7630c024df9944ac5d7b3de659145311ce9e84e8/py_cluster_api-0.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/48/a2/87a0b61a3078be07ab04ec574ef6c683c764590ed0d2a50d00cbb23aeae7/pydantic_settings_yaml-0.2.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c8/c7/c160021cbecd956cc1a6f79e5fe155f7868b2e5b848f1320dad0b3e3122f/pytest_html-4.1.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/3e/43/7e7b2ec865caa92f67b8f0e9231a798d102724ca4c0e1f414316be1c1ef2/pytest_metadata-3.1.1-py3-none-any.whl @@ -4078,8 +4090,8 @@ packages: timestamp: 1760972937564 - pypi: ./ name: fileglancer - version: 2.3.2 - sha256: b82ed2124fc3e99f5a5bda409ad22b1a0893de59393300c7e595cdf5c1be90b9 + version: 2.4.0 + sha256: f975048bf7f64ff775499e51d71f235bacd51ed731d3e3c52dec6161c2e75450 requires_dist: - aiosqlite>=0.21.0 - alembic>=1.17.0 @@ -4095,6 +4107,7 @@ packages: - lxml>=5.3.1 - pandas>=2.3.3 - psycopg2-binary>=2.9.10,<3 + - py-cluster-api>=0.1.0 - pydantic-settings>=2.11.0 - pydantic>=2.10.6 - python-jose>=3.5.0,<4 @@ -8089,6 +8102,18 @@ packages: - pkg:pypi/pure-eval?source=hash-mapping size: 16668 timestamp: 1733569518868 +- pypi: https://files.pythonhosted.org/packages/a7/0c/4372c76cb6eee8a90ccf7630c024df9944ac5d7b3de659145311ce9e84e8/py_cluster_api-0.1.0-py3-none-any.whl + name: py-cluster-api + version: 0.1.0 + sha256: d39388d1e903f09388cb34183938e621d020903939b7fe53f7c5b86e81336b10 + requires_dist: + - pyyaml + - build ; extra == 'release' + - twine ; extra == 'release' + - pytest ; extra == 'test' + - pytest-asyncio ; extra == 'test' + - pytest-cov ; extra == 'test' + requires_python: '>=3.10' - conda: https://conda.anaconda.org/conda-forge/noarch/pyasn1-0.6.1-pyhd8ed1ab_2.conda sha256: d06051df66e9ab753683d7423fcef873d78bb0c33bd112c3d5be66d529eddf06 md5: 09bb17ed307ad6ab2fd78d32372fdd4e diff --git a/pyproject.toml b/pyproject.toml index 4379c8ce..74c6c953 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,7 +43,8 @@ dependencies = [ "cryptography >=41.0.0", "sqlalchemy >=2.0.44", "uvicorn >=0.38.0", - "x2s3 >=1.1.0" + "x2s3 >=1.1.0", + "py-cluster-api >=0.1.0" ] [project.scripts] From bc86ba6d71dc3f569b2a1511135b6abe11b546be Mon Sep 17 00:00:00 2001 From: Konrad Rokicki Date: Sun, 8 Feb 2026 22:33:40 -0500 Subject: [PATCH 02/59] added app details and actions --- fileglancer/app.py | 29 ++- fileglancer/apps.py | 68 ++++++ fileglancer/database.py | 7 + frontend/src/App.tsx | 9 + frontend/src/components/Apps.tsx | 38 ++- frontend/src/components/JobDetail.tsx | 223 ++++++++++++++++++ .../components/ui/Table/appsJobsColumns.tsx | 68 ++++-- frontend/src/queries/jobsQueries.ts | 92 +++++++- 8 files changed, 508 insertions(+), 26 deletions(-) create mode 100644 frontend/src/components/JobDetail.tsx diff --git a/fileglancer/app.py b/fileglancer/app.py index 4537ce16..27a849c9 100644 --- a/fileglancer/app.py +++ b/fileglancer/app.py @@ -1601,8 +1601,8 @@ async def get_job(job_id: int, raise HTTPException(status_code=404, detail="Job not found") return _convert_job(db_job) - @app.delete("/api/jobs/{job_id}", - description="Cancel a running job") + @app.post("/api/jobs/{job_id}/cancel", + description="Cancel a running job") async def cancel_job(job_id: int, username: str = Depends(get_current_user)): try: @@ -1611,6 +1611,31 @@ async def cancel_job(job_id: int, except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) + @app.delete("/api/jobs/{job_id}", + description="Delete a job record") + async def delete_job(job_id: int, + username: str = Depends(get_current_user)): + with db.get_db_session(settings.db_url) as session: + deleted = db.delete_job(session, job_id, username) + if not deleted: + raise HTTPException(status_code=404, detail="Job not found") + return {"message": "Job deleted"} + + @app.get("/api/jobs/{job_id}/files/{file_type}", + description="Get job file content (script, stdout, or stderr)") + async def get_job_file(job_id: int, + file_type: str = Path(..., description="File type: script, stdout, or stderr"), + username: str = Depends(get_current_user)): + if file_type not in ("script", "stdout", "stderr"): + raise HTTPException(status_code=400, detail="file_type must be script, stdout, or stderr") + try: + content = await apps_module.get_job_file_content(job_id, username, file_type) + if content is None: + raise HTTPException(status_code=404, detail=f"File not found: {file_type}") + return PlainTextResponse(content) + except ValueError as e: + raise HTTPException(status_code=404, detail=str(e)) + def _convert_job(db_job: db.JobDB) -> Job: """Convert a database JobDB to a Pydantic Job model.""" return Job( diff --git a/fileglancer/apps.py b/fileglancer/apps.py index 0aad2f83..46e0b337 100644 --- a/fileglancer/apps.py +++ b/fileglancer/apps.py @@ -1,9 +1,11 @@ """Apps module for fetching manifests, building commands, and managing cluster jobs.""" import asyncio +import os import re import shlex import time +from pathlib import Path from datetime import datetime, UTC from typing import Optional @@ -477,3 +479,69 @@ async def cancel_job(job_id: int, username: str) -> db.JobDB: logger.info(f"Job {job_id} cancelled by user {username}") return db_job + + +# --- Job File Access --- + +def _resolve_work_dir(db_job: db.JobDB) -> Path: + """Resolve a job's work directory to an absolute path.""" + work_dir = _build_work_dir(db_job.id, db_job.app_name, db_job.entry_point_id) + # Replace $HOME with actual home directory + return Path(work_dir.replace("$HOME", os.path.expanduser("~"))) + + +def _get_job_log_paths(db_job: db.JobDB) -> dict[str, Path]: + """Get the expected log file paths for a job.""" + settings = get_settings() + log_dir = Path(settings.cluster_log_directory).expanduser() + job_name = f"fg-{db_job.app_name}-{db_job.entry_point_id}" + safe_name = re.sub(r"[^\w\-.]", "_", job_name) + + return { + "stdout": log_dir / f"{job_name}.out", + "stderr": log_dir / f"{job_name}.err", + "script": log_dir / f"{safe_name}.sh", + } + + +async def get_job_file_content(job_id: int, username: str, file_type: str) -> Optional[str]: + """Read the content of a job file (script, stdout, or stderr). + + Returns the file content as a string, or None if the file doesn't exist. + """ + settings = get_settings() + + with db.get_db_session(settings.db_url) as session: + db_job = db.get_job(session, job_id, username) + if db_job is None: + raise ValueError(f"Job {job_id} not found") + + if file_type == "script": + # Try executor's tracked script_path first + if db_job.cluster_job_id: + executor = await get_executor() + tracked = executor.jobs.get(db_job.cluster_job_id) + if tracked and tracked.script_path: + path = Path(tracked.script_path) + if path.is_file(): + return path.read_text() + + # Fall back to expected path + paths = _get_job_log_paths(db_job) + # Try matching any .sh file with the safe_name prefix + log_dir = paths["script"].parent + safe_name = paths["script"].stem + if log_dir.is_dir(): + for f in sorted(log_dir.iterdir()): + if f.name.startswith(safe_name) and f.suffix == ".sh": + return f.read_text() + return None + + if file_type in ("stdout", "stderr"): + paths = _get_job_log_paths(db_job) + path = paths[file_type] + if path.is_file(): + return path.read_text() + return None + + raise ValueError(f"Unknown file type: {file_type}") diff --git a/fileglancer/database.py b/fileglancer/database.py index e51ba766..0aa4903e 100644 --- a/fileglancer/database.py +++ b/fileglancer/database.py @@ -883,6 +883,13 @@ def update_job_status(session: Session, job_id: int, status: str, return job +def delete_job(session: Session, job_id: int, username: str) -> bool: + """Delete a single job record. Returns True if deleted, False if not found.""" + deleted = session.query(JobDB).filter_by(id=job_id, username=username).delete() + session.commit() + return deleted > 0 + + def delete_old_jobs(session: Session, days: int = 30) -> int: """Delete completed/failed jobs older than the specified number of days""" cutoff = datetime.now(UTC) - timedelta(days=days) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 7b58a828..0b0bdf36 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -10,6 +10,7 @@ import { OtherPagesLayout } from './layouts/OtherPagesLayout'; import Login from '@/components/Login'; import Apps from '@/components/Apps'; import AppLaunch from '@/components/AppLaunch'; +import JobDetail from '@/components/JobDetail'; import Browse from '@/components/Browse'; import Help from '@/components/Help'; import Jobs from '@/components/Jobs'; @@ -130,6 +131,14 @@ const AppComponent = () => { } path="apps/launch/:encodedUrl" /> + + + + } + path="apps/jobs/:jobId" + /> {tasksEnabled ? ( { try { @@ -47,6 +55,15 @@ export default function Apps() { } }; + const handleViewJobDetail = (jobId: number) => { + navigate(`/apps/jobs/${jobId}`); + }; + + const handleRelaunch = (job: Job) => { + const encodedUrl = btoa(job.app_url); + navigate(`/apps/launch/${encodedUrl}`); + }; + const handleCancelJob = async (jobId: number) => { try { await cancelJobMutation.mutateAsync(jobId); @@ -58,8 +75,25 @@ export default function Apps() { } }; + const handleDeleteJob = async (jobId: number) => { + try { + await deleteJobMutation.mutateAsync(jobId); + toast.success('Job deleted'); + } catch (error) { + const message = + error instanceof Error ? error.message : 'Failed to delete job'; + toast.error(message); + } + }; + const jobsColumns = useMemo( - () => createAppsJobsColumns(handleCancelJob), + () => + createAppsJobsColumns({ + onViewDetail: handleViewJobDetail, + onRelaunch: handleRelaunch, + onCancel: handleCancelJob, + onDelete: handleDeleteJob + }), // eslint-disable-next-line react-hooks/exhaustive-deps [] ); diff --git a/frontend/src/components/JobDetail.tsx b/frontend/src/components/JobDetail.tsx new file mode 100644 index 00000000..5e952ec2 --- /dev/null +++ b/frontend/src/components/JobDetail.tsx @@ -0,0 +1,223 @@ +import { useEffect, useState } from 'react'; +import { useNavigate, useParams } from 'react-router'; + +import { Button, Typography } from '@material-tailwind/react'; +import { HiOutlineArrowLeft } from 'react-icons/hi'; +import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; +import { + materialDark, + coy +} from 'react-syntax-highlighter/dist/esm/styles/prism'; + +import JobStatusBadge from '@/components/ui/AppsPage/JobStatusBadge'; +import { formatDateString } from '@/utils'; +import { useJobQuery, useJobFileQuery } from '@/queries/jobsQueries'; + +function FilePreview({ + title, + content, + language, + isDarkMode +}: { + readonly title: string; + readonly content: string | null | undefined; + readonly language: string; + readonly isDarkMode: boolean; +}) { + if (content === undefined) { + return ( +
+ + {title} + + + Loading... + +
+ ); + } + + if (content === null) { + return ( +
+ + {title} + + + File not available + +
+ ); + } + + const theme = isDarkMode ? materialDark : coy; + const themeCodeStyles = theme['code[class*="language-"]'] || {}; + + return ( +
+ + {title} + +
+ + {content} + +
+
+ ); +} + +export default function JobDetail() { + const { jobId } = useParams<{ jobId: string }>(); + const navigate = useNavigate(); + const [isDarkMode, setIsDarkMode] = useState(false); + + const id = jobId ? parseInt(jobId) : 0; + const jobQuery = useJobQuery(id); + const scriptQuery = useJobFileQuery(id, 'script'); + const stdoutQuery = useJobFileQuery(id, 'stdout'); + const stderrQuery = useJobFileQuery(id, 'stderr'); + + useEffect(() => { + const checkDarkMode = () => { + setIsDarkMode(document.documentElement.classList.contains('dark')); + }; + checkDarkMode(); + const observer = new MutationObserver(checkDarkMode); + observer.observe(document.documentElement, { + attributes: true, + attributeFilter: ['class'] + }); + return () => observer.disconnect(); + }, []); + + const job = jobQuery.data; + + return ( +
+ + + {jobQuery.isPending ? ( + + Loading job details... + + ) : jobQuery.isError ? ( +
+ Failed to load job: {jobQuery.error?.message || 'Unknown error'} +
+ ) : job ? ( +
+ {/* Job Info Header */} +
+ + {job.app_name} — {job.entry_point_name} + +
+ + + Submitted: {formatDateString(job.created_at)} + + {job.started_at ? ( + + Started: {formatDateString(job.started_at)} + + ) : null} + {job.finished_at ? ( + + Finished: {formatDateString(job.finished_at)} + + ) : null} + {job.exit_code !== null && job.exit_code !== undefined ? ( + + Exit code: {job.exit_code} + + ) : null} +
+
+ + {/* Parameters */} + {Object.keys(job.parameters).length > 0 ? ( +
+ + Parameters + +
+ {Object.entries(job.parameters).map(([key, value]) => ( +
+ + {key}: + + + {String(value)} + +
+ ))} +
+
+ ) : null} + + {/* File Previews */} + + + +
+ ) : null} +
+ ); +} diff --git a/frontend/src/components/ui/Table/appsJobsColumns.tsx b/frontend/src/components/ui/Table/appsJobsColumns.tsx index eb7e0ccd..abb616cb 100644 --- a/frontend/src/components/ui/Table/appsJobsColumns.tsx +++ b/frontend/src/components/ui/Table/appsJobsColumns.tsx @@ -1,11 +1,10 @@ import type { ColumnDef } from '@tanstack/react-table'; -import { Button } from '@material-tailwind/react'; -import { HiOutlineX } from 'react-icons/hi'; - +import DataLinksActionsMenu from '@/components/ui/Menus/DataLinksActions'; import FgTooltip from '@/components/ui/widgets/FgTooltip'; import JobStatusBadge from '@/components/ui/AppsPage/JobStatusBadge'; import { formatDateString } from '@/utils'; +import type { MenuItem } from '@/components/ui/Menus/FgMenuItems'; import type { Job } from '@/shared.types'; function formatDuration(job: Job): string { @@ -31,14 +30,25 @@ function formatDuration(job: Job): string { return `${hours}h ${minutes % 60}m`; } +type JobActionCallbacks = { + onViewDetail: (jobId: number) => void; + onRelaunch: (job: Job) => void; + onCancel: (jobId: number) => void; + onDelete: (jobId: number) => void; +}; + +type JobRowActionProps = JobActionCallbacks & { + job: Job; +}; + export function createAppsJobsColumns( - onCancel: (jobId: number) => void + callbacks: JobActionCallbacks ): ColumnDef[] { return [ { accessorKey: 'app_name', header: 'App', - cell: ({ getValue, row, table }) => { + cell: ({ getValue, table }) => { const value = getValue() as string; const onContextMenu = table.options.meta?.onCellContextMenu; return ( @@ -126,27 +136,43 @@ export function createAppsJobsColumns( }, { id: 'actions', - header: '', + header: 'Actions', cell: ({ row }) => { const job = row.original; const canCancel = job.status === 'PENDING' || job.status === 'RUNNING'; - if (!canCancel) { - return null; - } + + const menuItems: MenuItem[] = [ + { + name: 'View Details', + action: props => props.onViewDetail(props.job.id) + }, + { + name: 'Relaunch', + action: props => props.onRelaunch(props.job) + }, + { + name: 'Cancel', + action: props => props.onCancel(props.job.id), + shouldShow: canCancel + }, + { + name: 'Delete', + color: 'text-error', + action: props => props.onDelete(props.job.id) + } + ]; + + const actionProps: JobRowActionProps = { + job, + ...callbacks + }; + return (
- - - + + actionProps={actionProps} + menuItems={menuItems} + />
); }, diff --git a/frontend/src/queries/jobsQueries.ts b/frontend/src/queries/jobsQueries.ts index 527ed291..c53cc319 100644 --- a/frontend/src/queries/jobsQueries.ts +++ b/frontend/src/queries/jobsQueries.ts @@ -29,6 +29,42 @@ async function fetchJobs(signal?: AbortSignal): Promise { return (data as { jobs: Job[] }).jobs; } +async function fetchJob(jobId: number, signal?: AbortSignal): Promise { + const response = await sendFetchRequest( + `/api/jobs/${jobId}`, + 'GET', + undefined, + { + signal + } + ); + const data = await getResponseJsonOrError(response); + if (!response.ok) { + throwResponseNotOkError(response, data); + } + return data as Job; +} + +async function fetchJobFile( + jobId: number, + fileType: string, + signal?: AbortSignal +): Promise { + const response = await sendFetchRequest( + `/api/jobs/${jobId}/files/${fileType}`, + 'GET', + undefined, + { signal } + ); + if (response.status === 404) { + return null; + } + if (!response.ok) { + throw new Error(`Failed to fetch ${fileType}`); + } + return response.text(); +} + // --- Query Hooks --- export function useJobsQuery(): UseQueryResult { @@ -49,6 +85,36 @@ export function useJobsQuery(): UseQueryResult { }); } +export function useJobQuery(jobId: number): UseQueryResult { + return useQuery({ + queryKey: jobsQueryKeys.detail(jobId), + queryFn: ({ signal }) => fetchJob(jobId, signal), + refetchInterval: query => { + const job = query.state.data; + if (!job) { + return false; + } + return job.status === 'PENDING' || job.status === 'RUNNING' + ? 5000 + : false; + } + }); +} + +export function useJobFileQuery( + jobId: number, + fileType: string +): UseQueryResult { + return useQuery({ + queryKey: [...jobsQueryKeys.detail(jobId), 'file', fileType], + queryFn: ({ signal }) => fetchJobFile(jobId, fileType, signal), + refetchInterval: query => { + // Only auto-refresh if file doesn't exist yet (null) - it may appear later + return query.state.data === null ? 10000 : false; + } + }); +} + // --- Mutation Hooks --- export function useSubmitJobMutation(): UseMutationResult< @@ -80,7 +146,10 @@ export function useCancelJobMutation(): UseMutationResult { const queryClient = useQueryClient(); return useMutation({ mutationFn: async (jobId: number) => { - const response = await sendFetchRequest(`/api/jobs/${jobId}`, 'DELETE'); + const response = await sendFetchRequest( + `/api/jobs/${jobId}/cancel`, + 'POST' + ); const data = await getResponseJsonOrError(response); if (!response.ok) { throwResponseNotOkError(response, data); @@ -92,3 +161,24 @@ export function useCancelJobMutation(): UseMutationResult { } }); } + +export function useDeleteJobMutation(): UseMutationResult< + unknown, + Error, + number +> { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (jobId: number) => { + const response = await sendFetchRequest(`/api/jobs/${jobId}`, 'DELETE'); + const data = await getResponseJsonOrError(response); + if (!response.ok) { + throwResponseNotOkError(response, data); + } + return data; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: jobsQueryKeys.all }); + } + }); +} From cec0a7178f7c015fae4ec081154a8d291a733bea Mon Sep 17 00:00:00 2001 From: Konrad Rokicki Date: Sun, 8 Feb 2026 22:36:39 -0500 Subject: [PATCH 03/59] fixed db access --- fileglancer/apps.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/fileglancer/apps.py b/fileglancer/apps.py index 46e0b337..d623c09c 100644 --- a/fileglancer/apps.py +++ b/fileglancer/apps.py @@ -393,9 +393,10 @@ async def submit_job( parameters=parameters, resources=resources_dict, ) + job_id = db_job.id # Build work directory and wrap command - work_dir = _build_work_dir(db_job.id, manifest.name, entry_point.id) + work_dir = _build_work_dir(job_id, manifest.name, entry_point.id) command = f"mkdir -p {work_dir} && cd {work_dir} && {command}" # Set work_dir on resource spec for LSF -cwd support @@ -410,13 +411,14 @@ async def submit_job( resources=resource_spec, ) - # Update DB with cluster job ID + # Update DB with cluster job ID and return fresh object with db.get_db_session(settings.db_url) as session: db.update_job_status( - session, db_job.id, "PENDING", + session, job_id, "PENDING", cluster_job_id=cluster_job.job_id, ) - db_job = db.get_job(session, db_job.id, username) + db_job = db.get_job(session, job_id, username) + session.expunge(db_job) logger.info(f"Job {db_job.id} submitted for user {username} in {work_dir}: {command}") return db_job @@ -476,6 +478,7 @@ async def cancel_job(job_id: int, username: str) -> db.JobDB: now = datetime.now(UTC) db.update_job_status(session, db_job.id, "KILLED", finished_at=now) db_job = db.get_job(session, db_job.id, username) + session.expunge(db_job) logger.info(f"Job {job_id} cancelled by user {username}") return db_job @@ -515,6 +518,7 @@ async def get_job_file_content(job_id: int, username: str, file_type: str) -> Op db_job = db.get_job(session, job_id, username) if db_job is None: raise ValueError(f"Job {job_id} not found") + session.expunge(db_job) if file_type == "script": # Try executor's tracked script_path first From 2fd5a27fc5300d5d5c17debfb4d562e9c1a508d7 Mon Sep 17 00:00:00 2001 From: Konrad Rokicki Date: Sun, 8 Feb 2026 22:42:49 -0500 Subject: [PATCH 04/59] tab interface for job details --- frontend/src/components/JobDetail.tsx | 207 +++++++++++++------------- 1 file changed, 107 insertions(+), 100 deletions(-) diff --git a/frontend/src/components/JobDetail.tsx b/frontend/src/components/JobDetail.tsx index 5e952ec2..43a3ff98 100644 --- a/frontend/src/components/JobDetail.tsx +++ b/frontend/src/components/JobDetail.tsx @@ -1,7 +1,7 @@ import { useEffect, useState } from 'react'; import { useNavigate, useParams } from 'react-router'; -import { Button, Typography } from '@material-tailwind/react'; +import { Button, Tabs, Typography } from '@material-tailwind/react'; import { HiOutlineArrowLeft } from 'react-icons/hi'; import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; import { @@ -14,39 +14,27 @@ import { formatDateString } from '@/utils'; import { useJobQuery, useJobFileQuery } from '@/queries/jobsQueries'; function FilePreview({ - title, content, language, isDarkMode }: { - readonly title: string; readonly content: string | null | undefined; readonly language: string; readonly isDarkMode: boolean; }) { if (content === undefined) { return ( -
- - {title} - - - Loading... - -
+ + Loading... + ); } if (content === null) { return ( -
- - {title} - - - File not available - -
+ + File not available + ); } @@ -54,37 +42,32 @@ function FilePreview({ const themeCodeStyles = theme['code[class*="language-"]'] || {}; return ( -
- - {title} - -
- - {content} - -
+
+ + {content} +
); } @@ -93,6 +76,7 @@ export default function JobDetail() { const { jobId } = useParams<{ jobId: string }>(); const navigate = useNavigate(); const [isDarkMode, setIsDarkMode] = useState(false); + const [activeTab, setActiveTab] = useState('parameters'); const id = jobId ? parseInt(jobId) : 0; const jobQuery = useJobQuery(id); @@ -164,58 +148,81 @@ export default function JobDetail() {
- {/* Parameters */} - {Object.keys(job.parameters).length > 0 ? ( -
- + + Parameters - -
- {Object.entries(job.parameters).map(([key, value]) => ( -
- - {key}: - - - {String(value)} - -
- ))} -
-
- ) : null} - - {/* File Previews */} - - - + + + Script + + + Output Log + + + Error Log + + + + + + {Object.keys(job.parameters).length > 0 ? ( +
+ {Object.entries(job.parameters).map(([key, value]) => ( +
+ + {key}: + + + {String(value)} + +
+ ))} +
+ ) : ( + + No parameters + + )} +
+ + + + + + + + + + + + + ) : null} From 4f1008a88d712e98d9c515fae557fde4799d4769 Mon Sep 17 00:00:00 2001 From: Konrad Rokicki Date: Sun, 8 Feb 2026 22:43:55 -0500 Subject: [PATCH 05/59] improve script formatting --- fileglancer/apps.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/fileglancer/apps.py b/fileglancer/apps.py index d623c09c..50180399 100644 --- a/fileglancer/apps.py +++ b/fileglancer/apps.py @@ -210,7 +210,7 @@ def build_command(entry_point: AppEntryPoint, parameters: dict) -> str: else: parts.append(f"--{param.id} {shlex.quote(validated)}") - return " ".join(parts) + return (" \\\n ").join(parts) # --- Executor Management --- @@ -397,7 +397,7 @@ async def submit_job( # Build work directory and wrap command work_dir = _build_work_dir(job_id, manifest.name, entry_point.id) - command = f"mkdir -p {work_dir} && cd {work_dir} && {command}" + command = f"mkdir -p {work_dir}\ncd {work_dir}\n\n{command}" # Set work_dir on resource spec for LSF -cwd support resource_spec.work_dir = work_dir From 46510dde6327929c26ba0401462b079f97d4463c Mon Sep 17 00:00:00 2001 From: Konrad Rokicki Date: Sun, 8 Feb 2026 22:55:59 -0500 Subject: [PATCH 06/59] fix path handling --- fileglancer/apps.py | 48 ++++++++++++++++++++------------------------- 1 file changed, 21 insertions(+), 27 deletions(-) diff --git a/fileglancer/apps.py b/fileglancer/apps.py index 50180399..90724358 100644 --- a/fileglancer/apps.py +++ b/fileglancer/apps.py @@ -332,11 +332,11 @@ def _sanitize_for_path(s: str) -> str: return re.sub(r'[^a-zA-Z0-9._-]', '_', s) -def _build_work_dir(job_id: int, app_name: str, entry_point_id: str) -> str: +def _build_work_dir(job_id: int, app_name: str, entry_point_id: str) -> Path: """Build a working directory path under ~/.fileglancer/jobs/.""" safe_app = _sanitize_for_path(app_name) safe_ep = _sanitize_for_path(entry_point_id) - return f"$HOME/.fileglancer/jobs/{job_id}-{safe_app}-{safe_ep}" + return Path(os.path.expanduser(f"~/.fileglancer/jobs/{job_id}-{safe_app}-{safe_ep}")) async def submit_job( @@ -395,18 +395,25 @@ async def submit_job( ) job_id = db_job.id - # Build work directory and wrap command + # Create work directory and write readable script work_dir = _build_work_dir(job_id, manifest.name, entry_point.id) - command = f"mkdir -p {work_dir}\ncd {work_dir}\n\n{command}" + work_dir.mkdir(parents=True, exist_ok=True) + + script_path = work_dir / "script.sh" + script_path.write_text(f"#!/bin/bash\ncd {work_dir}\n\n{command}\n") + script_path.chmod(0o755) + + # Wrap command with cd into work dir + full_command = f"cd {work_dir}\n\n{command}" # Set work_dir on resource spec for LSF -cwd support - resource_spec.work_dir = work_dir + resource_spec.work_dir = str(work_dir) # Submit to executor executor = await get_executor() job_name = f"fg-{manifest.name}-{entry_point.id}" cluster_job = await executor.submit( - command=command, + command=full_command, name=job_name, resources=resource_spec, ) @@ -488,9 +495,7 @@ async def cancel_job(job_id: int, username: str) -> db.JobDB: def _resolve_work_dir(db_job: db.JobDB) -> Path: """Resolve a job's work directory to an absolute path.""" - work_dir = _build_work_dir(db_job.id, db_job.app_name, db_job.entry_point_id) - # Replace $HOME with actual home directory - return Path(work_dir.replace("$HOME", os.path.expanduser("~"))) + return _build_work_dir(db_job.id, db_job.app_name, db_job.entry_point_id) def _get_job_log_paths(db_job: db.JobDB) -> dict[str, Path]: @@ -520,28 +525,17 @@ async def get_job_file_content(job_id: int, username: str, file_type: str) -> Op raise ValueError(f"Job {job_id} not found") session.expunge(db_job) - if file_type == "script": - # Try executor's tracked script_path first - if db_job.cluster_job_id: - executor = await get_executor() - tracked = executor.jobs.get(db_job.cluster_job_id) - if tracked and tracked.script_path: - path = Path(tracked.script_path) - if path.is_file(): - return path.read_text() + work_dir = _resolve_work_dir(db_job) - # Fall back to expected path - paths = _get_job_log_paths(db_job) - # Try matching any .sh file with the safe_name prefix - log_dir = paths["script"].parent - safe_name = paths["script"].stem - if log_dir.is_dir(): - for f in sorted(log_dir.iterdir()): - if f.name.startswith(safe_name) and f.suffix == ".sh": - return f.read_text() + if file_type == "script": + # Look for our readable script in the work directory + script_in_work_dir = work_dir / "script.sh" + if script_in_work_dir.is_file(): + return script_in_work_dir.read_text() return None if file_type in ("stdout", "stderr"): + # Check log directory for executor-managed log files paths = _get_job_log_paths(db_job) path = paths[file_type] if path.is_file(): From b03669ed9d1624b018c677f5e7da65b9560dbc30 Mon Sep 17 00:00:00 2001 From: Konrad Rokicki Date: Sun, 8 Feb 2026 23:00:06 -0500 Subject: [PATCH 07/59] add relaunch --- fileglancer/settings.py | 2 +- frontend/src/components/AppLaunch.tsx | 27 ++++++++++++++++--- frontend/src/components/Apps.tsx | 7 ++++- frontend/src/components/JobDetail.tsx | 23 +++++++++++++++- .../components/ui/AppsPage/AppLaunchForm.tsx | 15 +++++++---- 5 files changed, 63 insertions(+), 11 deletions(-) diff --git a/fileglancer/settings.py b/fileglancer/settings.py index de2ca4ea..7755aa70 100644 --- a/fileglancer/settings.py +++ b/fileglancer/settings.py @@ -72,7 +72,7 @@ class Settings(BaseSettings): cluster_queue: Optional[str] = None cluster_account: Optional[str] = None cluster_poll_interval: float = 10.0 - cluster_log_directory: str = './logs' + cluster_log_directory: str = '~/.fileglancer/logs' cluster_default_memory: Optional[str] = None # e.g. "8 GB" cluster_default_walltime: Optional[str] = None # e.g. "04:00" cluster_default_cpus: Optional[int] = None diff --git a/frontend/src/components/AppLaunch.tsx b/frontend/src/components/AppLaunch.tsx index 6c54e014..3cb68421 100644 --- a/frontend/src/components/AppLaunch.tsx +++ b/frontend/src/components/AppLaunch.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from 'react'; -import { useNavigate, useParams } from 'react-router'; +import { useLocation, useNavigate, useParams } from 'react-router'; import { Button, Typography } from '@material-tailwind/react'; import { HiOutlineArrowLeft, HiOutlinePlay } from 'react-icons/hi'; @@ -10,14 +10,21 @@ import { useManifestPreviewMutation } from '@/queries/appsQueries'; import { useSubmitJobMutation } from '@/queries/jobsQueries'; import type { AppEntryPoint, AppResourceDefaults } from '@/shared.types'; +type LaunchState = { + entryPointId?: string; + parameters?: Record; +} | null; + export default function AppLaunch() { const { encodedUrl } = useParams<{ encodedUrl: string }>(); const navigate = useNavigate(); + const location = useLocation(); const manifestMutation = useManifestPreviewMutation(); const submitJobMutation = useSubmitJobMutation(); const [selectedEntryPoint, setSelectedEntryPoint] = useState(null); + const launchState = (location.state as LaunchState) || null; const appUrl = encodedUrl ? decodeURIComponent(encodedUrl) : ''; useEffect(() => { @@ -30,11 +37,24 @@ export default function AppLaunch() { const manifest = manifestMutation.data; - // Auto-select if there's only one entry point + // Auto-select entry point from relaunch state, or if there's only one useEffect(() => { - if (manifest?.entryPoints.length === 1) { + if (!manifest) { + return; + } + if (launchState?.entryPointId) { + const ep = manifest.entryPoints.find( + e => e.id === launchState.entryPointId + ); + if (ep) { + setSelectedEntryPoint(ep); + return; + } + } + if (manifest.entryPoints.length === 1) { setSelectedEntryPoint(manifest.entryPoints[0]); } + // eslint-disable-next-line react-hooks/exhaustive-deps }, [manifest]); const handleSubmit = async ( @@ -94,6 +114,7 @@ export default function AppLaunch() { ) : null} { const encodedUrl = btoa(job.app_url); - navigate(`/apps/launch/${encodedUrl}`); + navigate(`/apps/launch/${encodedUrl}`, { + state: { + entryPointId: job.entry_point_id, + parameters: job.parameters + } + }); }; const handleCancelJob = async (jobId: number) => { diff --git a/frontend/src/components/JobDetail.tsx b/frontend/src/components/JobDetail.tsx index 43a3ff98..778ceb2f 100644 --- a/frontend/src/components/JobDetail.tsx +++ b/frontend/src/components/JobDetail.tsx @@ -2,7 +2,7 @@ import { useEffect, useState } from 'react'; import { useNavigate, useParams } from 'react-router'; import { Button, Tabs, Typography } from '@material-tailwind/react'; -import { HiOutlineArrowLeft } from 'react-icons/hi'; +import { HiOutlineArrowLeft, HiOutlineRefresh } from 'react-icons/hi'; import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; import { materialDark, @@ -99,6 +99,19 @@ export default function JobDetail() { const job = jobQuery.data; + const handleRelaunch = () => { + if (!job) { + return; + } + const encodedUrl = btoa(job.app_url); + navigate(`/apps/launch/${encodedUrl}`, { + state: { + entryPointId: job.entry_point_id, + parameters: job.parameters + } + }); + }; + return (
diff --git a/frontend/src/components/ui/AppsPage/AppLaunchForm.tsx b/frontend/src/components/ui/AppsPage/AppLaunchForm.tsx index acb85cbd..938bc599 100644 --- a/frontend/src/components/ui/AppsPage/AppLaunchForm.tsx +++ b/frontend/src/components/ui/AppsPage/AppLaunchForm.tsx @@ -20,6 +20,7 @@ interface AppLaunchFormProps { resources?: AppResourceDefaults ) => Promise; readonly submitting: boolean; + readonly initialValues?: Record; } function ParameterField({ @@ -123,17 +124,21 @@ export default function AppLaunchForm({ manifest, entryPoint, onSubmit, - submitting + submitting, + initialValues: externalValues }: AppLaunchFormProps) { - // Initialize parameter values from defaults - const initialValues: Record = {}; + // Initialize parameter values: external values override defaults + const defaultValues: Record = {}; for (const param of entryPoint.parameters) { if (param.default !== undefined) { - initialValues[param.id] = param.default; + defaultValues[param.id] = param.default; } } + const startingValues = externalValues + ? { ...defaultValues, ...externalValues } + : defaultValues; - const [values, setValues] = useState>(initialValues); + const [values, setValues] = useState>(startingValues); const [errors, setErrors] = useState>({}); const [showResources, setShowResources] = useState(false); const [resources, setResources] = useState({ From 8fbd74b6de735e6e78c08dfd86d8d2f3e212672a Mon Sep 17 00:00:00 2001 From: Konrad Rokicki Date: Sun, 8 Feb 2026 23:01:53 -0500 Subject: [PATCH 08/59] fix relaunch bug --- docs/config.yaml.template | 2 +- fileglancer/app.py | 1 + frontend/src/components/AppLaunch.tsx | 8 +++++++- frontend/src/components/Apps.tsx | 4 ++-- frontend/src/components/JobDetail.tsx | 4 ++-- frontend/src/components/ui/AppsPage/AppCard.tsx | 5 +++-- 6 files changed, 16 insertions(+), 8 deletions(-) diff --git a/docs/config.yaml.template b/docs/config.yaml.template index 0c03114c..cc915d32 100644 --- a/docs/config.yaml.template +++ b/docs/config.yaml.template @@ -141,7 +141,7 @@ session_cookie_secure: true # cluster_poll_interval: 10.0 # Directory for job log files -# cluster_log_directory: ./logs +# cluster_log_directory: ~/.fileglancer/logs # Default resource allocation for jobs (can be overridden per entry point) # cluster_default_memory: "8 GB" diff --git a/fileglancer/app.py b/fileglancer/app.py index 27a849c9..76a103cd 100644 --- a/fileglancer/app.py +++ b/fileglancer/app.py @@ -1466,6 +1466,7 @@ async def delete_file_or_dir(fsp_name: str, async def fetch_manifest(body: ManifestFetchRequest, username: str = Depends(get_current_user)): try: + logger.info(f"Fetching manifest for URL: '{body.url}'") manifest = await apps_module.fetch_app_manifest(body.url) return manifest except httpx.HTTPStatusError as e: diff --git a/frontend/src/components/AppLaunch.tsx b/frontend/src/components/AppLaunch.tsx index 3cb68421..12e5ce68 100644 --- a/frontend/src/components/AppLaunch.tsx +++ b/frontend/src/components/AppLaunch.tsx @@ -11,6 +11,7 @@ import { useSubmitJobMutation } from '@/queries/jobsQueries'; import type { AppEntryPoint, AppResourceDefaults } from '@/shared.types'; type LaunchState = { + appUrl?: string; entryPointId?: string; parameters?: Record; } | null; @@ -25,7 +26,12 @@ export default function AppLaunch() { useState(null); const launchState = (location.state as LaunchState) || null; - const appUrl = encodedUrl ? decodeURIComponent(encodedUrl) : ''; + // Prefer app URL from state (relaunch), fall back to path param (normal launch) + const appUrl = launchState?.appUrl + ? launchState.appUrl + : encodedUrl + ? decodeURIComponent(encodedUrl) + : ''; useEffect(() => { if (appUrl) { diff --git a/frontend/src/components/Apps.tsx b/frontend/src/components/Apps.tsx index 83809472..24efa705 100644 --- a/frontend/src/components/Apps.tsx +++ b/frontend/src/components/Apps.tsx @@ -60,9 +60,9 @@ export default function Apps() { }; const handleRelaunch = (job: Job) => { - const encodedUrl = btoa(job.app_url); - navigate(`/apps/launch/${encodedUrl}`, { + navigate('/apps/launch/relaunch', { state: { + appUrl: job.app_url, entryPointId: job.entry_point_id, parameters: job.parameters } diff --git a/frontend/src/components/JobDetail.tsx b/frontend/src/components/JobDetail.tsx index 778ceb2f..f8d93517 100644 --- a/frontend/src/components/JobDetail.tsx +++ b/frontend/src/components/JobDetail.tsx @@ -103,9 +103,9 @@ export default function JobDetail() { if (!job) { return; } - const encodedUrl = btoa(job.app_url); - navigate(`/apps/launch/${encodedUrl}`, { + navigate('/apps/launch/relaunch', { state: { + appUrl: job.app_url, entryPointId: job.entry_point_id, parameters: job.parameters } diff --git a/frontend/src/components/ui/AppsPage/AppCard.tsx b/frontend/src/components/ui/AppsPage/AppCard.tsx index 36bc7905..548d05b4 100644 --- a/frontend/src/components/ui/AppsPage/AppCard.tsx +++ b/frontend/src/components/ui/AppsPage/AppCard.tsx @@ -16,8 +16,9 @@ export default function AppCard({ app, onRemove, removing }: AppCardProps) { const navigate = useNavigate(); const handleLaunch = () => { - const encodedUrl = encodeURIComponent(app.url); - navigate(`/apps/launch/${encodedUrl}`); + navigate('/apps/launch/app', { + state: { appUrl: app.url } + }); }; return ( From 9b104743a1c383f2250709254aa2aec2e3c01137 Mon Sep 17 00:00:00 2001 From: Konrad Rokicki Date: Sun, 8 Feb 2026 23:24:42 -0500 Subject: [PATCH 09/59] path input validation --- fileglancer/app.py | 13 +++++++ fileglancer/model.py | 10 +++++ .../components/ui/AppsPage/AppLaunchForm.tsx | 37 +++++++++++++++++-- frontend/src/queries/appsQueries.ts | 13 +++++++ 4 files changed, 70 insertions(+), 3 deletions(-) diff --git a/fileglancer/app.py b/fileglancer/app.py index 76a103cd..30342549 100644 --- a/fileglancer/app.py +++ b/fileglancer/app.py @@ -1560,6 +1560,19 @@ async def remove_user_app(url: str = Query(..., description="URL of the app to r return {"message": "App removed"} + @app.post("/api/apps/validate-paths", response_model=PathValidationResponse, + description="Validate file/directory paths for app parameters") + async def validate_paths(body: PathValidationRequest, + username: str = Depends(get_current_user)): + errors = {} + for param_id, path_value in body.paths.items(): + expanded = os.path.expanduser(path_value) + if not os.path.exists(expanded): + errors[param_id] = f"Path does not exist: {path_value}" + elif not os.access(expanded, os.R_OK): + errors[param_id] = f"Path is not accessible: {path_value}" + return PathValidationResponse(errors=errors) + @app.post("/api/jobs", response_model=Job, description="Submit a new job") async def submit_job(body: JobSubmitRequest, diff --git a/fileglancer/model.py b/fileglancer/model.py index 94538dc4..df2a1524 100644 --- a/fileglancer/model.py +++ b/fileglancer/model.py @@ -397,6 +397,16 @@ class JobSubmitRequest(BaseModel): resources: Optional[AppResourceDefaults] = Field(description="Resource overrides", default=None) +class PathValidationRequest(BaseModel): + """Request to validate file/directory paths""" + paths: Dict[str, str] = Field(description="Map of parameter ID to path value") + + +class PathValidationResponse(BaseModel): + """Response with path validation results""" + errors: Dict[str, str] = Field(description="Map of parameter ID to error message (empty if all valid)") + + class JobResponse(BaseModel): """Response containing a list of jobs""" jobs: List[Job] = Field(description="A list of jobs") diff --git a/frontend/src/components/ui/AppsPage/AppLaunchForm.tsx b/frontend/src/components/ui/AppsPage/AppLaunchForm.tsx index 938bc599..12aad2d7 100644 --- a/frontend/src/components/ui/AppsPage/AppLaunchForm.tsx +++ b/frontend/src/components/ui/AppsPage/AppLaunchForm.tsx @@ -4,6 +4,7 @@ import { Button, Typography } from '@material-tailwind/react'; import { HiOutlinePlay } from 'react-icons/hi'; import FileSelectorButton from '@/components/ui/BrowsePage/FileSelector/FileSelectorButton'; +import { validatePaths } from '@/queries/appsQueries'; import { convertBackToForwardSlash } from '@/utils/pathHandling'; import type { AppEntryPoint, @@ -189,6 +190,8 @@ export default function AppLaunchForm({ return Object.keys(newErrors).length === 0; }; + const [validating, setValidating] = useState(false); + const handleSubmit = async () => { if (!validate()) { return; @@ -199,6 +202,7 @@ export default function AppLaunchForm({ // Filter out undefined/empty values and normalize paths to Linux format const params: Record = {}; + const pathParams: Record = {}; for (const [key, val] of Object.entries(values)) { if (val !== undefined && val !== null && val !== '') { const paramDef = paramDefs.get(key); @@ -207,13 +211,36 @@ export default function AppLaunchForm({ (paramDef.type === 'file' || paramDef.type === 'directory') && typeof val === 'string' ) { - params[key] = convertBackToForwardSlash(val); + const normalized = convertBackToForwardSlash(val); + params[key] = normalized; + pathParams[key] = normalized; } else { params[key] = val; } } } + // Validate paths on the server before submitting + if (Object.keys(pathParams).length > 0) { + setValidating(true); + try { + const pathErrors = await validatePaths(pathParams); + if (Object.keys(pathErrors).length > 0) { + setErrors(prev => ({ ...prev, ...pathErrors })); + setValidating(false); + return; + } + } catch { + setErrors(prev => ({ + ...prev, + _general: 'Failed to validate paths' + })); + setValidating(false); + return; + } + setValidating(false); + } + // Only pass resources if user modified them const hasResourceOverrides = showResources && @@ -341,11 +368,15 @@ export default function AppLaunchForm({ {/* Submit */}
); diff --git a/frontend/src/queries/appsQueries.ts b/frontend/src/queries/appsQueries.ts index 219e6c95..be62cd9c 100644 --- a/frontend/src/queries/appsQueries.ts +++ b/frontend/src/queries/appsQueries.ts @@ -76,6 +76,19 @@ export function useAddAppMutation(): UseMutationResult { }); } +export async function validatePaths( + paths: Record +): Promise> { + const response = await sendFetchRequest('/api/apps/validate-paths', 'POST', { + paths + }); + const data = await getResponseJsonOrError(response); + if (!response.ok) { + throwResponseNotOkError(response, data); + } + return (data as { errors: Record }).errors; +} + export function useRemoveAppMutation(): UseMutationResult< unknown, Error, From 2bbdc7c9f7205e6c61b6683fbd0c4c70be449024 Mon Sep 17 00:00:00 2001 From: Konrad Rokicki Date: Mon, 9 Feb 2026 22:51:22 -0500 Subject: [PATCH 10/59] upgrade py-clluster-api --- pixi.lock | 34 +++++++++++++++++----------------- pyproject.toml | 2 +- 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/pixi.lock b/pixi.lock index 21745383..0786cecd 100644 --- a/pixi.lock +++ b/pixi.lock @@ -172,7 +172,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/a7/b2/fabede9fafd976b991e9f1b9c8c873ed86f202889b864756f240ce6dd855/frozenlist-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl - pypi: https://files.pythonhosted.org/packages/b4/63/2ab26e4209773223159b83aa32721b4021ffb08102f8ac7d689c943fded1/multidict-6.7.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/b2/f2/889ad4b2408f72fe1a4f6a19491177b30ea7bf1a0fd5f17050ca08cfc882/propcache-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/a7/0c/4372c76cb6eee8a90ccf7630c024df9944ac5d7b3de659145311ce9e84e8/py_cluster_api-0.1.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/1c/25/1536b760aea57495030a1231607bafcb9e483e492820b280e2f6a561852a/py_cluster_api-0.1.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/48/a2/87a0b61a3078be07ab04ec574ef6c683c764590ed0d2a50d00cbb23aeae7/pydantic_settings_yaml-0.2.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fc/51/727abb13f44c1fcf6d145979e1535a35794db0f6e450a0cb46aa24732fe2/s3transfer-0.16.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/7f/21/f52cd11559059e686e7ecfe40c4746ee957f96779ce22a8f76d5b4d62516/x2s3-1.1.0-py3-none-any.whl @@ -340,7 +340,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/3a/3b/d9b1e0b0eed36e70477ffb8360c49c85c8ca8ef9700a4e6711f39a6e8b45/frozenlist-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl - pypi: https://files.pythonhosted.org/packages/15/ee/f524093232007cd7a75c1d132df70f235cfd590a7c9eaccd7ff422ef4ae8/multidict-6.7.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl - pypi: https://files.pythonhosted.org/packages/86/bd/47816020d337f4a746edc42fe8d53669965138f39ee117414c7d7a340cfe/propcache-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl - - pypi: https://files.pythonhosted.org/packages/a7/0c/4372c76cb6eee8a90ccf7630c024df9944ac5d7b3de659145311ce9e84e8/py_cluster_api-0.1.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/1c/25/1536b760aea57495030a1231607bafcb9e483e492820b280e2f6a561852a/py_cluster_api-0.1.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/48/a2/87a0b61a3078be07ab04ec574ef6c683c764590ed0d2a50d00cbb23aeae7/pydantic_settings_yaml-0.2.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fc/51/727abb13f44c1fcf6d145979e1535a35794db0f6e450a0cb46aa24732fe2/s3transfer-0.16.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/7f/21/f52cd11559059e686e7ecfe40c4746ee957f96779ce22a8f76d5b4d62516/x2s3-1.1.0-py3-none-any.whl @@ -503,7 +503,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/8e/e8/a1185e236ec66c20afd72399522f142c3724c785789255202d27ae992818/frozenlist-1.8.0-cp314-cp314-macosx_10_13_x86_64.whl - pypi: https://files.pythonhosted.org/packages/14/2c/f069cab5b51d175a1a2cb4ccdf7a2c2dabd58aa5bd933fa036a8d15e2404/multidict-6.7.0-cp314-cp314-macosx_10_13_x86_64.whl - pypi: https://files.pythonhosted.org/packages/65/9b/03b04e7d82a5f54fb16113d839f5ea1ede58a61e90edf515f6577c66fa8f/propcache-0.4.1-cp314-cp314-macosx_10_13_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/a7/0c/4372c76cb6eee8a90ccf7630c024df9944ac5d7b3de659145311ce9e84e8/py_cluster_api-0.1.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/1c/25/1536b760aea57495030a1231607bafcb9e483e492820b280e2f6a561852a/py_cluster_api-0.1.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/48/a2/87a0b61a3078be07ab04ec574ef6c683c764590ed0d2a50d00cbb23aeae7/pydantic_settings_yaml-0.2.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fc/51/727abb13f44c1fcf6d145979e1535a35794db0f6e450a0cb46aa24732fe2/s3transfer-0.16.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/7f/21/f52cd11559059e686e7ecfe40c4746ee957f96779ce22a8f76d5b4d62516/x2s3-1.1.0-py3-none-any.whl @@ -666,7 +666,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/a1/93/72b1736d68f03fda5fdf0f2180fb6caaae3894f1b854d006ac61ecc727ee/frozenlist-1.8.0-cp314-cp314-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/42/e2/64bb41266427af6642b6b128e8774ed84c11b80a90702c13ac0a86bb10cc/multidict-6.7.0-cp314-cp314-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/b2/fa/89a8ef0468d5833a23fff277b143d0573897cf75bd56670a6d28126c7d68/propcache-0.4.1-cp314-cp314-macosx_11_0_arm64.whl - - pypi: https://files.pythonhosted.org/packages/a7/0c/4372c76cb6eee8a90ccf7630c024df9944ac5d7b3de659145311ce9e84e8/py_cluster_api-0.1.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/1c/25/1536b760aea57495030a1231607bafcb9e483e492820b280e2f6a561852a/py_cluster_api-0.1.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/48/a2/87a0b61a3078be07ab04ec574ef6c683c764590ed0d2a50d00cbb23aeae7/pydantic_settings_yaml-0.2.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fc/51/727abb13f44c1fcf6d145979e1535a35794db0f6e450a0cb46aa24732fe2/s3transfer-0.16.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/7f/21/f52cd11559059e686e7ecfe40c4746ee957f96779ce22a8f76d5b4d62516/x2s3-1.1.0-py3-none-any.whl @@ -885,7 +885,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/a7/b2/fabede9fafd976b991e9f1b9c8c873ed86f202889b864756f240ce6dd855/frozenlist-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl - pypi: https://files.pythonhosted.org/packages/b4/63/2ab26e4209773223159b83aa32721b4021ffb08102f8ac7d689c943fded1/multidict-6.7.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/b2/f2/889ad4b2408f72fe1a4f6a19491177b30ea7bf1a0fd5f17050ca08cfc882/propcache-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/a7/0c/4372c76cb6eee8a90ccf7630c024df9944ac5d7b3de659145311ce9e84e8/py_cluster_api-0.1.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/1c/25/1536b760aea57495030a1231607bafcb9e483e492820b280e2f6a561852a/py_cluster_api-0.1.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/48/a2/87a0b61a3078be07ab04ec574ef6c683c764590ed0d2a50d00cbb23aeae7/pydantic_settings_yaml-0.2.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/bd/24/12818598c362d7f300f18e74db45963dbcb85150324092410c8b49405e42/pyproject_hooks-1.2.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fc/51/727abb13f44c1fcf6d145979e1535a35794db0f6e450a0cb46aa24732fe2/s3transfer-0.16.0-py3-none-any.whl @@ -1096,7 +1096,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/3a/3b/d9b1e0b0eed36e70477ffb8360c49c85c8ca8ef9700a4e6711f39a6e8b45/frozenlist-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl - pypi: https://files.pythonhosted.org/packages/15/ee/f524093232007cd7a75c1d132df70f235cfd590a7c9eaccd7ff422ef4ae8/multidict-6.7.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl - pypi: https://files.pythonhosted.org/packages/86/bd/47816020d337f4a746edc42fe8d53669965138f39ee117414c7d7a340cfe/propcache-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl - - pypi: https://files.pythonhosted.org/packages/a7/0c/4372c76cb6eee8a90ccf7630c024df9944ac5d7b3de659145311ce9e84e8/py_cluster_api-0.1.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/1c/25/1536b760aea57495030a1231607bafcb9e483e492820b280e2f6a561852a/py_cluster_api-0.1.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/48/a2/87a0b61a3078be07ab04ec574ef6c683c764590ed0d2a50d00cbb23aeae7/pydantic_settings_yaml-0.2.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fc/51/727abb13f44c1fcf6d145979e1535a35794db0f6e450a0cb46aa24732fe2/s3transfer-0.16.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/7f/21/f52cd11559059e686e7ecfe40c4746ee957f96779ce22a8f76d5b4d62516/x2s3-1.1.0-py3-none-any.whl @@ -1295,7 +1295,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/8e/e8/a1185e236ec66c20afd72399522f142c3724c785789255202d27ae992818/frozenlist-1.8.0-cp314-cp314-macosx_10_13_x86_64.whl - pypi: https://files.pythonhosted.org/packages/14/2c/f069cab5b51d175a1a2cb4ccdf7a2c2dabd58aa5bd933fa036a8d15e2404/multidict-6.7.0-cp314-cp314-macosx_10_13_x86_64.whl - pypi: https://files.pythonhosted.org/packages/65/9b/03b04e7d82a5f54fb16113d839f5ea1ede58a61e90edf515f6577c66fa8f/propcache-0.4.1-cp314-cp314-macosx_10_13_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/a7/0c/4372c76cb6eee8a90ccf7630c024df9944ac5d7b3de659145311ce9e84e8/py_cluster_api-0.1.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/1c/25/1536b760aea57495030a1231607bafcb9e483e492820b280e2f6a561852a/py_cluster_api-0.1.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/48/a2/87a0b61a3078be07ab04ec574ef6c683c764590ed0d2a50d00cbb23aeae7/pydantic_settings_yaml-0.2.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/bd/24/12818598c362d7f300f18e74db45963dbcb85150324092410c8b49405e42/pyproject_hooks-1.2.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fc/51/727abb13f44c1fcf6d145979e1535a35794db0f6e450a0cb46aa24732fe2/s3transfer-0.16.0-py3-none-any.whl @@ -1495,7 +1495,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/a1/93/72b1736d68f03fda5fdf0f2180fb6caaae3894f1b854d006ac61ecc727ee/frozenlist-1.8.0-cp314-cp314-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/42/e2/64bb41266427af6642b6b128e8774ed84c11b80a90702c13ac0a86bb10cc/multidict-6.7.0-cp314-cp314-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/b2/fa/89a8ef0468d5833a23fff277b143d0573897cf75bd56670a6d28126c7d68/propcache-0.4.1-cp314-cp314-macosx_11_0_arm64.whl - - pypi: https://files.pythonhosted.org/packages/a7/0c/4372c76cb6eee8a90ccf7630c024df9944ac5d7b3de659145311ce9e84e8/py_cluster_api-0.1.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/1c/25/1536b760aea57495030a1231607bafcb9e483e492820b280e2f6a561852a/py_cluster_api-0.1.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/48/a2/87a0b61a3078be07ab04ec574ef6c683c764590ed0d2a50d00cbb23aeae7/pydantic_settings_yaml-0.2.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/bd/24/12818598c362d7f300f18e74db45963dbcb85150324092410c8b49405e42/pyproject_hooks-1.2.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fc/51/727abb13f44c1fcf6d145979e1535a35794db0f6e450a0cb46aa24732fe2/s3transfer-0.16.0-py3-none-any.whl @@ -1753,7 +1753,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/a7/b2/fabede9fafd976b991e9f1b9c8c873ed86f202889b864756f240ce6dd855/frozenlist-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl - pypi: https://files.pythonhosted.org/packages/b4/63/2ab26e4209773223159b83aa32721b4021ffb08102f8ac7d689c943fded1/multidict-6.7.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/b2/f2/889ad4b2408f72fe1a4f6a19491177b30ea7bf1a0fd5f17050ca08cfc882/propcache-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/a7/0c/4372c76cb6eee8a90ccf7630c024df9944ac5d7b3de659145311ce9e84e8/py_cluster_api-0.1.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/1c/25/1536b760aea57495030a1231607bafcb9e483e492820b280e2f6a561852a/py_cluster_api-0.1.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/48/a2/87a0b61a3078be07ab04ec574ef6c683c764590ed0d2a50d00cbb23aeae7/pydantic_settings_yaml-0.2.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c8/c7/c160021cbecd956cc1a6f79e5fe155f7868b2e5b848f1320dad0b3e3122f/pytest_html-4.1.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/3e/43/7e7b2ec865caa92f67b8f0e9231a798d102724ca4c0e1f414316be1c1ef2/pytest_metadata-3.1.1-py3-none-any.whl @@ -2002,7 +2002,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/3a/3b/d9b1e0b0eed36e70477ffb8360c49c85c8ca8ef9700a4e6711f39a6e8b45/frozenlist-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl - pypi: https://files.pythonhosted.org/packages/15/ee/f524093232007cd7a75c1d132df70f235cfd590a7c9eaccd7ff422ef4ae8/multidict-6.7.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl - pypi: https://files.pythonhosted.org/packages/86/bd/47816020d337f4a746edc42fe8d53669965138f39ee117414c7d7a340cfe/propcache-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl - - pypi: https://files.pythonhosted.org/packages/a7/0c/4372c76cb6eee8a90ccf7630c024df9944ac5d7b3de659145311ce9e84e8/py_cluster_api-0.1.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/1c/25/1536b760aea57495030a1231607bafcb9e483e492820b280e2f6a561852a/py_cluster_api-0.1.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/48/a2/87a0b61a3078be07ab04ec574ef6c683c764590ed0d2a50d00cbb23aeae7/pydantic_settings_yaml-0.2.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c8/c7/c160021cbecd956cc1a6f79e5fe155f7868b2e5b848f1320dad0b3e3122f/pytest_html-4.1.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/3e/43/7e7b2ec865caa92f67b8f0e9231a798d102724ca4c0e1f414316be1c1ef2/pytest_metadata-3.1.1-py3-none-any.whl @@ -2250,7 +2250,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/8e/e8/a1185e236ec66c20afd72399522f142c3724c785789255202d27ae992818/frozenlist-1.8.0-cp314-cp314-macosx_10_13_x86_64.whl - pypi: https://files.pythonhosted.org/packages/14/2c/f069cab5b51d175a1a2cb4ccdf7a2c2dabd58aa5bd933fa036a8d15e2404/multidict-6.7.0-cp314-cp314-macosx_10_13_x86_64.whl - pypi: https://files.pythonhosted.org/packages/65/9b/03b04e7d82a5f54fb16113d839f5ea1ede58a61e90edf515f6577c66fa8f/propcache-0.4.1-cp314-cp314-macosx_10_13_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/a7/0c/4372c76cb6eee8a90ccf7630c024df9944ac5d7b3de659145311ce9e84e8/py_cluster_api-0.1.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/1c/25/1536b760aea57495030a1231607bafcb9e483e492820b280e2f6a561852a/py_cluster_api-0.1.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/48/a2/87a0b61a3078be07ab04ec574ef6c683c764590ed0d2a50d00cbb23aeae7/pydantic_settings_yaml-0.2.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c8/c7/c160021cbecd956cc1a6f79e5fe155f7868b2e5b848f1320dad0b3e3122f/pytest_html-4.1.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/3e/43/7e7b2ec865caa92f67b8f0e9231a798d102724ca4c0e1f414316be1c1ef2/pytest_metadata-3.1.1-py3-none-any.whl @@ -2498,7 +2498,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/a1/93/72b1736d68f03fda5fdf0f2180fb6caaae3894f1b854d006ac61ecc727ee/frozenlist-1.8.0-cp314-cp314-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/42/e2/64bb41266427af6642b6b128e8774ed84c11b80a90702c13ac0a86bb10cc/multidict-6.7.0-cp314-cp314-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/b2/fa/89a8ef0468d5833a23fff277b143d0573897cf75bd56670a6d28126c7d68/propcache-0.4.1-cp314-cp314-macosx_11_0_arm64.whl - - pypi: https://files.pythonhosted.org/packages/a7/0c/4372c76cb6eee8a90ccf7630c024df9944ac5d7b3de659145311ce9e84e8/py_cluster_api-0.1.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/1c/25/1536b760aea57495030a1231607bafcb9e483e492820b280e2f6a561852a/py_cluster_api-0.1.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/48/a2/87a0b61a3078be07ab04ec574ef6c683c764590ed0d2a50d00cbb23aeae7/pydantic_settings_yaml-0.2.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c8/c7/c160021cbecd956cc1a6f79e5fe155f7868b2e5b848f1320dad0b3e3122f/pytest_html-4.1.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/3e/43/7e7b2ec865caa92f67b8f0e9231a798d102724ca4c0e1f414316be1c1ef2/pytest_metadata-3.1.1-py3-none-any.whl @@ -4091,7 +4091,7 @@ packages: - pypi: ./ name: fileglancer version: 2.4.0 - sha256: f975048bf7f64ff775499e51d71f235bacd51ed731d3e3c52dec6161c2e75450 + sha256: 6016685e2d9205e455ea85bd0161a2a985e06293533017cba0140655baf27e6e requires_dist: - aiosqlite>=0.21.0 - alembic>=1.17.0 @@ -4107,7 +4107,7 @@ packages: - lxml>=5.3.1 - pandas>=2.3.3 - psycopg2-binary>=2.9.10,<3 - - py-cluster-api>=0.1.0 + - py-cluster-api>=0.1.1 - pydantic-settings>=2.11.0 - pydantic>=2.10.6 - python-jose>=3.5.0,<4 @@ -8102,10 +8102,10 @@ packages: - pkg:pypi/pure-eval?source=hash-mapping size: 16668 timestamp: 1733569518868 -- pypi: https://files.pythonhosted.org/packages/a7/0c/4372c76cb6eee8a90ccf7630c024df9944ac5d7b3de659145311ce9e84e8/py_cluster_api-0.1.0-py3-none-any.whl +- pypi: https://files.pythonhosted.org/packages/1c/25/1536b760aea57495030a1231607bafcb9e483e492820b280e2f6a561852a/py_cluster_api-0.1.1-py3-none-any.whl name: py-cluster-api - version: 0.1.0 - sha256: d39388d1e903f09388cb34183938e621d020903939b7fe53f7c5b86e81336b10 + version: 0.1.1 + sha256: cc8dfc04d0d9f11ccfa7acc1db8e969379480d255db9f551b0e221ca0f7acdc5 requires_dist: - pyyaml - build ; extra == 'release' diff --git a/pyproject.toml b/pyproject.toml index 74c6c953..bfa8f902 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,7 +44,7 @@ dependencies = [ "sqlalchemy >=2.0.44", "uvicorn >=0.38.0", "x2s3 >=1.1.0", - "py-cluster-api >=0.1.0" + "py-cluster-api >=0.1.1" ] [project.scripts] From 1d60f81fd4a1bc7eb66765c4c2ebd2a4e2f602dc Mon Sep 17 00:00:00 2001 From: Konrad Rokicki Date: Wed, 11 Feb 2026 11:16:58 -0500 Subject: [PATCH 11/59] update to py-cluster-api 0.2.0 --- docs/config.yaml.template | 3 - fileglancer/app.py | 12 +++- fileglancer/apps.py | 67 ++++++++++--------- fileglancer/settings.py | 1 - .../components/ui/AppsPage/AppLaunchForm.tsx | 14 ++++ pixi.lock | 34 +++++----- pyproject.toml | 2 +- 7 files changed, 76 insertions(+), 57 deletions(-) diff --git a/docs/config.yaml.template b/docs/config.yaml.template index cc915d32..2397813e 100644 --- a/docs/config.yaml.template +++ b/docs/config.yaml.template @@ -140,9 +140,6 @@ session_cookie_secure: true # Polling interval in seconds for checking job status # cluster_poll_interval: 10.0 -# Directory for job log files -# cluster_log_directory: ~/.fileglancer/logs - # Default resource allocation for jobs (can be overridden per entry point) # cluster_default_memory: "8 GB" # cluster_default_walltime: "04:00" diff --git a/fileglancer/app.py b/fileglancer/app.py index 30342549..48ab3564 100644 --- a/fileglancer/app.py +++ b/fileglancer/app.py @@ -1566,11 +1566,17 @@ async def validate_paths(body: PathValidationRequest, username: str = Depends(get_current_user)): errors = {} for param_id, path_value in body.paths.items(): - expanded = os.path.expanduser(path_value) + # Normalize backslashes to forward slashes + normalized = path_value.replace("\\", "/") + # Require absolute path + if not normalized.startswith("/") and not normalized.startswith("~"): + errors[param_id] = f"Must be an absolute path (starting with / or ~)" + continue + expanded = os.path.expanduser(normalized) if not os.path.exists(expanded): - errors[param_id] = f"Path does not exist: {path_value}" + errors[param_id] = f"Path does not exist: {normalized}" elif not os.access(expanded, os.R_OK): - errors[param_id] = f"Path is not accessible: {path_value}" + errors[param_id] = f"Path is not accessible: {normalized}" return PathValidationResponse(errors=errors) @app.post("/api/jobs", response_model=Job, diff --git a/fileglancer/apps.py b/fileglancer/apps.py index 90724358..5dd65281 100644 --- a/fileglancer/apps.py +++ b/fileglancer/apps.py @@ -156,10 +156,24 @@ def _validate_parameter_value(param: AppParameter, value) -> str: str_val = str(value) if param.type in ("file", "directory"): + # Normalize backslashes to forward slashes + str_val = str_val.replace("\\", "/") + # Validate path characters if _SHELL_METACHAR_PATTERN.search(str_val): raise ValueError(f"Parameter '{param.id}' contains invalid characters") + # Require absolute path + if not str_val.startswith("/") and not str_val.startswith("~"): + raise ValueError(f"Parameter '{param.id}' must be an absolute path (starting with / or ~)") + + # Verify path exists + expanded = os.path.expanduser(str_val) + if not os.path.exists(expanded): + raise ValueError(f"Parameter '{param.id}': path does not exist: {str_val}") + if not os.access(expanded, os.R_OK): + raise ValueError(f"Parameter '{param.id}': path is not accessible: {str_val}") + if param.type == "string" and param.pattern: if not re.fullmatch(param.pattern, str_val): raise ValueError(f"Parameter '{param.id}' does not match required pattern") @@ -228,7 +242,6 @@ async def get_executor(): _executor = create_executor( executor=settings.cluster_executor, queue=settings.cluster_queue, - log_directory=settings.cluster_log_directory, ) return _executor @@ -406,8 +419,10 @@ async def submit_job( # Wrap command with cd into work dir full_command = f"cd {work_dir}\n\n{command}" - # Set work_dir on resource spec for LSF -cwd support + # Set work_dir and log paths on resource spec resource_spec.work_dir = str(work_dir) + resource_spec.stdout_path = str(work_dir / "stdout.log") + resource_spec.stderr_path = str(work_dir / "stderr.log") # Submit to executor executor = await get_executor() @@ -498,23 +513,14 @@ def _resolve_work_dir(db_job: db.JobDB) -> Path: return _build_work_dir(db_job.id, db_job.app_name, db_job.entry_point_id) -def _get_job_log_paths(db_job: db.JobDB) -> dict[str, Path]: - """Get the expected log file paths for a job.""" - settings = get_settings() - log_dir = Path(settings.cluster_log_directory).expanduser() - job_name = f"fg-{db_job.app_name}-{db_job.entry_point_id}" - safe_name = re.sub(r"[^\w\-.]", "_", job_name) - - return { - "stdout": log_dir / f"{job_name}.out", - "stderr": log_dir / f"{job_name}.err", - "script": log_dir / f"{safe_name}.sh", - } - - async def get_job_file_content(job_id: int, username: str, file_type: str) -> Optional[str]: """Read the content of a job file (script, stdout, or stderr). + All job files live in the job's work directory: + - script.sh — the generated script + - stdout.log — captured standard output + - stderr.log — captured standard error + Returns the file content as a string, or None if the file doesn't exist. """ settings = get_settings() @@ -527,19 +533,16 @@ async def get_job_file_content(job_id: int, username: str, file_type: str) -> Op work_dir = _resolve_work_dir(db_job) - if file_type == "script": - # Look for our readable script in the work directory - script_in_work_dir = work_dir / "script.sh" - if script_in_work_dir.is_file(): - return script_in_work_dir.read_text() - return None - - if file_type in ("stdout", "stderr"): - # Check log directory for executor-managed log files - paths = _get_job_log_paths(db_job) - path = paths[file_type] - if path.is_file(): - return path.read_text() - return None - - raise ValueError(f"Unknown file type: {file_type}") + filenames = { + "script": "script.sh", + "stdout": "stdout.log", + "stderr": "stderr.log", + } + + if file_type not in filenames: + raise ValueError(f"Unknown file type: {file_type}") + + path = work_dir / filenames[file_type] + if path.is_file(): + return path.read_text() + return None diff --git a/fileglancer/settings.py b/fileglancer/settings.py index 7755aa70..bb368617 100644 --- a/fileglancer/settings.py +++ b/fileglancer/settings.py @@ -72,7 +72,6 @@ class Settings(BaseSettings): cluster_queue: Optional[str] = None cluster_account: Optional[str] = None cluster_poll_interval: float = 10.0 - cluster_log_directory: str = '~/.fileglancer/logs' cluster_default_memory: Optional[str] = None # e.g. "8 GB" cluster_default_walltime: Optional[str] = None # e.g. "04:00" cluster_default_cpus: Optional[int] = None diff --git a/frontend/src/components/ui/AppsPage/AppLaunchForm.tsx b/frontend/src/components/ui/AppsPage/AppLaunchForm.tsx index 12aad2d7..97611016 100644 --- a/frontend/src/components/ui/AppsPage/AppLaunchForm.tsx +++ b/frontend/src/components/ui/AppsPage/AppLaunchForm.tsx @@ -185,6 +185,20 @@ export default function AppLaunchForm({ } } } + // Validate file/directory paths are absolute + if ( + val !== undefined && + val !== null && + val !== '' && + (param.type === 'file' || param.type === 'directory') && + typeof val === 'string' + ) { + const normalized = convertBackToForwardSlash(val); + if (!normalized.startsWith('/') && !normalized.startsWith('~')) { + newErrors[param.id] = + `${param.name} must be an absolute path (starting with / or ~)`; + } + } } setErrors(newErrors); return Object.keys(newErrors).length === 0; diff --git a/pixi.lock b/pixi.lock index 0786cecd..483c112c 100644 --- a/pixi.lock +++ b/pixi.lock @@ -172,7 +172,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/a7/b2/fabede9fafd976b991e9f1b9c8c873ed86f202889b864756f240ce6dd855/frozenlist-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl - pypi: https://files.pythonhosted.org/packages/b4/63/2ab26e4209773223159b83aa32721b4021ffb08102f8ac7d689c943fded1/multidict-6.7.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/b2/f2/889ad4b2408f72fe1a4f6a19491177b30ea7bf1a0fd5f17050ca08cfc882/propcache-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/1c/25/1536b760aea57495030a1231607bafcb9e483e492820b280e2f6a561852a/py_cluster_api-0.1.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/ee/61/90d2c473721cff8db0454ba7cc6dd9b68eeea01ed49ad630f93890d67978/py_cluster_api-0.2.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/48/a2/87a0b61a3078be07ab04ec574ef6c683c764590ed0d2a50d00cbb23aeae7/pydantic_settings_yaml-0.2.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fc/51/727abb13f44c1fcf6d145979e1535a35794db0f6e450a0cb46aa24732fe2/s3transfer-0.16.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/7f/21/f52cd11559059e686e7ecfe40c4746ee957f96779ce22a8f76d5b4d62516/x2s3-1.1.0-py3-none-any.whl @@ -340,7 +340,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/3a/3b/d9b1e0b0eed36e70477ffb8360c49c85c8ca8ef9700a4e6711f39a6e8b45/frozenlist-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl - pypi: https://files.pythonhosted.org/packages/15/ee/f524093232007cd7a75c1d132df70f235cfd590a7c9eaccd7ff422ef4ae8/multidict-6.7.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl - pypi: https://files.pythonhosted.org/packages/86/bd/47816020d337f4a746edc42fe8d53669965138f39ee117414c7d7a340cfe/propcache-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl - - pypi: https://files.pythonhosted.org/packages/1c/25/1536b760aea57495030a1231607bafcb9e483e492820b280e2f6a561852a/py_cluster_api-0.1.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/ee/61/90d2c473721cff8db0454ba7cc6dd9b68eeea01ed49ad630f93890d67978/py_cluster_api-0.2.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/48/a2/87a0b61a3078be07ab04ec574ef6c683c764590ed0d2a50d00cbb23aeae7/pydantic_settings_yaml-0.2.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fc/51/727abb13f44c1fcf6d145979e1535a35794db0f6e450a0cb46aa24732fe2/s3transfer-0.16.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/7f/21/f52cd11559059e686e7ecfe40c4746ee957f96779ce22a8f76d5b4d62516/x2s3-1.1.0-py3-none-any.whl @@ -503,7 +503,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/8e/e8/a1185e236ec66c20afd72399522f142c3724c785789255202d27ae992818/frozenlist-1.8.0-cp314-cp314-macosx_10_13_x86_64.whl - pypi: https://files.pythonhosted.org/packages/14/2c/f069cab5b51d175a1a2cb4ccdf7a2c2dabd58aa5bd933fa036a8d15e2404/multidict-6.7.0-cp314-cp314-macosx_10_13_x86_64.whl - pypi: https://files.pythonhosted.org/packages/65/9b/03b04e7d82a5f54fb16113d839f5ea1ede58a61e90edf515f6577c66fa8f/propcache-0.4.1-cp314-cp314-macosx_10_13_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/1c/25/1536b760aea57495030a1231607bafcb9e483e492820b280e2f6a561852a/py_cluster_api-0.1.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/ee/61/90d2c473721cff8db0454ba7cc6dd9b68eeea01ed49ad630f93890d67978/py_cluster_api-0.2.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/48/a2/87a0b61a3078be07ab04ec574ef6c683c764590ed0d2a50d00cbb23aeae7/pydantic_settings_yaml-0.2.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fc/51/727abb13f44c1fcf6d145979e1535a35794db0f6e450a0cb46aa24732fe2/s3transfer-0.16.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/7f/21/f52cd11559059e686e7ecfe40c4746ee957f96779ce22a8f76d5b4d62516/x2s3-1.1.0-py3-none-any.whl @@ -666,7 +666,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/a1/93/72b1736d68f03fda5fdf0f2180fb6caaae3894f1b854d006ac61ecc727ee/frozenlist-1.8.0-cp314-cp314-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/42/e2/64bb41266427af6642b6b128e8774ed84c11b80a90702c13ac0a86bb10cc/multidict-6.7.0-cp314-cp314-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/b2/fa/89a8ef0468d5833a23fff277b143d0573897cf75bd56670a6d28126c7d68/propcache-0.4.1-cp314-cp314-macosx_11_0_arm64.whl - - pypi: https://files.pythonhosted.org/packages/1c/25/1536b760aea57495030a1231607bafcb9e483e492820b280e2f6a561852a/py_cluster_api-0.1.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/ee/61/90d2c473721cff8db0454ba7cc6dd9b68eeea01ed49ad630f93890d67978/py_cluster_api-0.2.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/48/a2/87a0b61a3078be07ab04ec574ef6c683c764590ed0d2a50d00cbb23aeae7/pydantic_settings_yaml-0.2.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fc/51/727abb13f44c1fcf6d145979e1535a35794db0f6e450a0cb46aa24732fe2/s3transfer-0.16.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/7f/21/f52cd11559059e686e7ecfe40c4746ee957f96779ce22a8f76d5b4d62516/x2s3-1.1.0-py3-none-any.whl @@ -885,7 +885,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/a7/b2/fabede9fafd976b991e9f1b9c8c873ed86f202889b864756f240ce6dd855/frozenlist-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl - pypi: https://files.pythonhosted.org/packages/b4/63/2ab26e4209773223159b83aa32721b4021ffb08102f8ac7d689c943fded1/multidict-6.7.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/b2/f2/889ad4b2408f72fe1a4f6a19491177b30ea7bf1a0fd5f17050ca08cfc882/propcache-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/1c/25/1536b760aea57495030a1231607bafcb9e483e492820b280e2f6a561852a/py_cluster_api-0.1.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/ee/61/90d2c473721cff8db0454ba7cc6dd9b68eeea01ed49ad630f93890d67978/py_cluster_api-0.2.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/48/a2/87a0b61a3078be07ab04ec574ef6c683c764590ed0d2a50d00cbb23aeae7/pydantic_settings_yaml-0.2.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/bd/24/12818598c362d7f300f18e74db45963dbcb85150324092410c8b49405e42/pyproject_hooks-1.2.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fc/51/727abb13f44c1fcf6d145979e1535a35794db0f6e450a0cb46aa24732fe2/s3transfer-0.16.0-py3-none-any.whl @@ -1096,7 +1096,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/3a/3b/d9b1e0b0eed36e70477ffb8360c49c85c8ca8ef9700a4e6711f39a6e8b45/frozenlist-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl - pypi: https://files.pythonhosted.org/packages/15/ee/f524093232007cd7a75c1d132df70f235cfd590a7c9eaccd7ff422ef4ae8/multidict-6.7.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl - pypi: https://files.pythonhosted.org/packages/86/bd/47816020d337f4a746edc42fe8d53669965138f39ee117414c7d7a340cfe/propcache-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl - - pypi: https://files.pythonhosted.org/packages/1c/25/1536b760aea57495030a1231607bafcb9e483e492820b280e2f6a561852a/py_cluster_api-0.1.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/ee/61/90d2c473721cff8db0454ba7cc6dd9b68eeea01ed49ad630f93890d67978/py_cluster_api-0.2.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/48/a2/87a0b61a3078be07ab04ec574ef6c683c764590ed0d2a50d00cbb23aeae7/pydantic_settings_yaml-0.2.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fc/51/727abb13f44c1fcf6d145979e1535a35794db0f6e450a0cb46aa24732fe2/s3transfer-0.16.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/7f/21/f52cd11559059e686e7ecfe40c4746ee957f96779ce22a8f76d5b4d62516/x2s3-1.1.0-py3-none-any.whl @@ -1295,7 +1295,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/8e/e8/a1185e236ec66c20afd72399522f142c3724c785789255202d27ae992818/frozenlist-1.8.0-cp314-cp314-macosx_10_13_x86_64.whl - pypi: https://files.pythonhosted.org/packages/14/2c/f069cab5b51d175a1a2cb4ccdf7a2c2dabd58aa5bd933fa036a8d15e2404/multidict-6.7.0-cp314-cp314-macosx_10_13_x86_64.whl - pypi: https://files.pythonhosted.org/packages/65/9b/03b04e7d82a5f54fb16113d839f5ea1ede58a61e90edf515f6577c66fa8f/propcache-0.4.1-cp314-cp314-macosx_10_13_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/1c/25/1536b760aea57495030a1231607bafcb9e483e492820b280e2f6a561852a/py_cluster_api-0.1.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/ee/61/90d2c473721cff8db0454ba7cc6dd9b68eeea01ed49ad630f93890d67978/py_cluster_api-0.2.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/48/a2/87a0b61a3078be07ab04ec574ef6c683c764590ed0d2a50d00cbb23aeae7/pydantic_settings_yaml-0.2.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/bd/24/12818598c362d7f300f18e74db45963dbcb85150324092410c8b49405e42/pyproject_hooks-1.2.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fc/51/727abb13f44c1fcf6d145979e1535a35794db0f6e450a0cb46aa24732fe2/s3transfer-0.16.0-py3-none-any.whl @@ -1495,7 +1495,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/a1/93/72b1736d68f03fda5fdf0f2180fb6caaae3894f1b854d006ac61ecc727ee/frozenlist-1.8.0-cp314-cp314-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/42/e2/64bb41266427af6642b6b128e8774ed84c11b80a90702c13ac0a86bb10cc/multidict-6.7.0-cp314-cp314-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/b2/fa/89a8ef0468d5833a23fff277b143d0573897cf75bd56670a6d28126c7d68/propcache-0.4.1-cp314-cp314-macosx_11_0_arm64.whl - - pypi: https://files.pythonhosted.org/packages/1c/25/1536b760aea57495030a1231607bafcb9e483e492820b280e2f6a561852a/py_cluster_api-0.1.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/ee/61/90d2c473721cff8db0454ba7cc6dd9b68eeea01ed49ad630f93890d67978/py_cluster_api-0.2.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/48/a2/87a0b61a3078be07ab04ec574ef6c683c764590ed0d2a50d00cbb23aeae7/pydantic_settings_yaml-0.2.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/bd/24/12818598c362d7f300f18e74db45963dbcb85150324092410c8b49405e42/pyproject_hooks-1.2.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fc/51/727abb13f44c1fcf6d145979e1535a35794db0f6e450a0cb46aa24732fe2/s3transfer-0.16.0-py3-none-any.whl @@ -1753,7 +1753,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/a7/b2/fabede9fafd976b991e9f1b9c8c873ed86f202889b864756f240ce6dd855/frozenlist-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl - pypi: https://files.pythonhosted.org/packages/b4/63/2ab26e4209773223159b83aa32721b4021ffb08102f8ac7d689c943fded1/multidict-6.7.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/b2/f2/889ad4b2408f72fe1a4f6a19491177b30ea7bf1a0fd5f17050ca08cfc882/propcache-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/1c/25/1536b760aea57495030a1231607bafcb9e483e492820b280e2f6a561852a/py_cluster_api-0.1.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/ee/61/90d2c473721cff8db0454ba7cc6dd9b68eeea01ed49ad630f93890d67978/py_cluster_api-0.2.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/48/a2/87a0b61a3078be07ab04ec574ef6c683c764590ed0d2a50d00cbb23aeae7/pydantic_settings_yaml-0.2.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c8/c7/c160021cbecd956cc1a6f79e5fe155f7868b2e5b848f1320dad0b3e3122f/pytest_html-4.1.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/3e/43/7e7b2ec865caa92f67b8f0e9231a798d102724ca4c0e1f414316be1c1ef2/pytest_metadata-3.1.1-py3-none-any.whl @@ -2002,7 +2002,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/3a/3b/d9b1e0b0eed36e70477ffb8360c49c85c8ca8ef9700a4e6711f39a6e8b45/frozenlist-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl - pypi: https://files.pythonhosted.org/packages/15/ee/f524093232007cd7a75c1d132df70f235cfd590a7c9eaccd7ff422ef4ae8/multidict-6.7.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl - pypi: https://files.pythonhosted.org/packages/86/bd/47816020d337f4a746edc42fe8d53669965138f39ee117414c7d7a340cfe/propcache-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl - - pypi: https://files.pythonhosted.org/packages/1c/25/1536b760aea57495030a1231607bafcb9e483e492820b280e2f6a561852a/py_cluster_api-0.1.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/ee/61/90d2c473721cff8db0454ba7cc6dd9b68eeea01ed49ad630f93890d67978/py_cluster_api-0.2.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/48/a2/87a0b61a3078be07ab04ec574ef6c683c764590ed0d2a50d00cbb23aeae7/pydantic_settings_yaml-0.2.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c8/c7/c160021cbecd956cc1a6f79e5fe155f7868b2e5b848f1320dad0b3e3122f/pytest_html-4.1.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/3e/43/7e7b2ec865caa92f67b8f0e9231a798d102724ca4c0e1f414316be1c1ef2/pytest_metadata-3.1.1-py3-none-any.whl @@ -2250,7 +2250,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/8e/e8/a1185e236ec66c20afd72399522f142c3724c785789255202d27ae992818/frozenlist-1.8.0-cp314-cp314-macosx_10_13_x86_64.whl - pypi: https://files.pythonhosted.org/packages/14/2c/f069cab5b51d175a1a2cb4ccdf7a2c2dabd58aa5bd933fa036a8d15e2404/multidict-6.7.0-cp314-cp314-macosx_10_13_x86_64.whl - pypi: https://files.pythonhosted.org/packages/65/9b/03b04e7d82a5f54fb16113d839f5ea1ede58a61e90edf515f6577c66fa8f/propcache-0.4.1-cp314-cp314-macosx_10_13_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/1c/25/1536b760aea57495030a1231607bafcb9e483e492820b280e2f6a561852a/py_cluster_api-0.1.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/ee/61/90d2c473721cff8db0454ba7cc6dd9b68eeea01ed49ad630f93890d67978/py_cluster_api-0.2.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/48/a2/87a0b61a3078be07ab04ec574ef6c683c764590ed0d2a50d00cbb23aeae7/pydantic_settings_yaml-0.2.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c8/c7/c160021cbecd956cc1a6f79e5fe155f7868b2e5b848f1320dad0b3e3122f/pytest_html-4.1.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/3e/43/7e7b2ec865caa92f67b8f0e9231a798d102724ca4c0e1f414316be1c1ef2/pytest_metadata-3.1.1-py3-none-any.whl @@ -2498,7 +2498,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/a1/93/72b1736d68f03fda5fdf0f2180fb6caaae3894f1b854d006ac61ecc727ee/frozenlist-1.8.0-cp314-cp314-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/42/e2/64bb41266427af6642b6b128e8774ed84c11b80a90702c13ac0a86bb10cc/multidict-6.7.0-cp314-cp314-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/b2/fa/89a8ef0468d5833a23fff277b143d0573897cf75bd56670a6d28126c7d68/propcache-0.4.1-cp314-cp314-macosx_11_0_arm64.whl - - pypi: https://files.pythonhosted.org/packages/1c/25/1536b760aea57495030a1231607bafcb9e483e492820b280e2f6a561852a/py_cluster_api-0.1.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/ee/61/90d2c473721cff8db0454ba7cc6dd9b68eeea01ed49ad630f93890d67978/py_cluster_api-0.2.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/48/a2/87a0b61a3078be07ab04ec574ef6c683c764590ed0d2a50d00cbb23aeae7/pydantic_settings_yaml-0.2.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c8/c7/c160021cbecd956cc1a6f79e5fe155f7868b2e5b848f1320dad0b3e3122f/pytest_html-4.1.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/3e/43/7e7b2ec865caa92f67b8f0e9231a798d102724ca4c0e1f414316be1c1ef2/pytest_metadata-3.1.1-py3-none-any.whl @@ -4091,7 +4091,7 @@ packages: - pypi: ./ name: fileglancer version: 2.4.0 - sha256: 6016685e2d9205e455ea85bd0161a2a985e06293533017cba0140655baf27e6e + sha256: e88d3d263dcbf000444444277b12f624c155ce1712cc110eaaf01886813b8aab requires_dist: - aiosqlite>=0.21.0 - alembic>=1.17.0 @@ -4107,7 +4107,7 @@ packages: - lxml>=5.3.1 - pandas>=2.3.3 - psycopg2-binary>=2.9.10,<3 - - py-cluster-api>=0.1.1 + - py-cluster-api>=0.2.0 - pydantic-settings>=2.11.0 - pydantic>=2.10.6 - python-jose>=3.5.0,<4 @@ -8102,10 +8102,10 @@ packages: - pkg:pypi/pure-eval?source=hash-mapping size: 16668 timestamp: 1733569518868 -- pypi: https://files.pythonhosted.org/packages/1c/25/1536b760aea57495030a1231607bafcb9e483e492820b280e2f6a561852a/py_cluster_api-0.1.1-py3-none-any.whl +- pypi: https://files.pythonhosted.org/packages/ee/61/90d2c473721cff8db0454ba7cc6dd9b68eeea01ed49ad630f93890d67978/py_cluster_api-0.2.0-py3-none-any.whl name: py-cluster-api - version: 0.1.1 - sha256: cc8dfc04d0d9f11ccfa7acc1db8e969379480d255db9f551b0e221ca0f7acdc5 + version: 0.2.0 + sha256: 457310759f2c8f52477010ef7ebfc7d59ba16d9a4363875cdb56839d140f3b0d requires_dist: - pyyaml - build ; extra == 'release' diff --git a/pyproject.toml b/pyproject.toml index bfa8f902..a0096e99 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,7 +44,7 @@ dependencies = [ "sqlalchemy >=2.0.44", "uvicorn >=0.38.0", "x2s3 >=1.1.0", - "py-cluster-api >=0.1.1" + "py-cluster-api >=0.2.0" ] [project.scripts] From adece77318ef3efef9351be43465724981497c12 Mon Sep 17 00:00:00 2001 From: Konrad Rokicki Date: Fri, 13 Feb 2026 09:33:08 -0500 Subject: [PATCH 12/59] added requirements and github cloning --- fileglancer/app.py | 1 + fileglancer/apps.py | 190 +++++++++++++++--- fileglancer/model.py | 23 ++- frontend/src/components/AppLaunch.tsx | 6 +- .../components/ui/AppsPage/AppLaunchForm.tsx | 39 +++- .../FileSelector/FileSelectorButton.tsx | 26 ++- frontend/src/hooks/useFileSelector.ts | 105 +++++++++- frontend/src/shared.types.ts | 2 + 8 files changed, 348 insertions(+), 44 deletions(-) diff --git a/fileglancer/app.py b/fileglancer/app.py index 48ab3564..90396b4b 100644 --- a/fileglancer/app.py +++ b/fileglancer/app.py @@ -1594,6 +1594,7 @@ async def submit_job(body: JobSubmitRequest, entry_point_id=body.entry_point_id, parameters=body.parameters, resources=resources_dict, + pull_latest=body.pull_latest, ) return _convert_job(db_job) except ValueError as e: diff --git a/fileglancer/apps.py b/fileglancer/apps.py index 5dd65281..a42a9c0e 100644 --- a/fileglancer/apps.py +++ b/fileglancer/apps.py @@ -4,6 +4,8 @@ import os import re import shlex +import shutil +import subprocess import time from pathlib import Path from datetime import datetime, UTC @@ -12,6 +14,8 @@ import httpx import yaml from loguru import logger +from packaging.specifiers import SpecifierSet +from packaging.version import Version from cluster_api import create_executor, ResourceSpec, JobMonitor from cluster_api._types import JobStatus @@ -28,6 +32,52 @@ _MANIFEST_FILENAMES = ["fileglancer-app.json", "fileglancer-app.yaml", "fileglancer-app.yml"] +_REPO_CACHE_BASE = Path(os.path.expanduser("~/.fileglancer/app-repos")) + + +def _parse_github_url(url: str) -> tuple[str, str, str]: + """Parse a GitHub repo URL into (owner, repo, branch). + + Raises ValueError if not a valid GitHub repo URL. + """ + pattern = r"https?://github\.com/([^/]+)/([^/]+?)(?:\.git)?(?:/tree/([^/]+))?/?$" + match = re.match(pattern, url) + if not match: + raise ValueError( + f"Invalid app URL: '{url}'. Only GitHub repository URLs are supported " + f"(e.g., https://github.com/owner/repo)." + ) + owner, repo, branch = match.groups() + return owner, repo, branch or "main" + + +def _run_git(repo_dir: Path, args: list[str]): + """Run a git command in the given directory.""" + subprocess.run( + ["git", "-C", str(repo_dir)] + args, + check=True, capture_output=True, text=True, timeout=60, + ) + + +def _ensure_repo_cache(url: str, pull: bool = False) -> Path: + """Clone or update the GitHub repo in per-user cache. Returns repo path.""" + owner, repo, branch = _parse_github_url(url) + repo_dir = _REPO_CACHE_BASE / owner / repo + + if repo_dir.exists(): + _run_git(repo_dir, ["checkout", branch]) + if pull: + _run_git(repo_dir, ["pull", "origin", branch]) + else: + repo_dir.parent.mkdir(parents=True, exist_ok=True) + clone_url = f"https://github.com/{owner}/{repo}.git" + subprocess.run( + ["git", "clone", "--branch", branch, clone_url, str(repo_dir)], + check=True, capture_output=True, text=True, timeout=120, + ) + + return repo_dir + def _github_to_raw_urls(url: str) -> list[str]: """Convert a GitHub repo URL to raw URLs for the manifest file. @@ -39,16 +89,11 @@ def _github_to_raw_urls(url: str) -> list[str]: - https://github.com/owner/repo/ - https://github.com/owner/repo/tree/branch """ - pattern = r"https?://github\.com/([^/]+)/([^/]+?)(?:\.git)?(?:/tree/([^/]+))?/?$" - match = re.match(pattern, url) - if match: - owner, repo, branch = match.groups() - branch = branch or "main" - return [ - f"https://raw.githubusercontent.com/{owner}/{repo}/{branch}/{filename}" - for filename in _MANIFEST_FILENAMES - ] - return [url] + owner, repo, branch = _parse_github_url(url) + return [ + f"https://raw.githubusercontent.com/{owner}/{repo}/{branch}/{filename}" + for filename in _MANIFEST_FILENAMES + ] def _parse_manifest_response(response: httpx.Response) -> dict: @@ -65,6 +110,9 @@ async def fetch_app_manifest(url: str) -> AppManifest: If the URL points to a GitHub repo, it will automatically look for fileglancer-app.json, then fileglancer-app.yaml, then fileglancer-app.yml. """ + # Validate GitHub URL (raises ValueError for non-GitHub URLs) + _parse_github_url(url) + now = time.time() # Check cache @@ -108,6 +156,90 @@ def clear_manifest_cache(): _manifest_cache.clear() +# --- Requirement Verification --- + +_TOOL_REGISTRY = { + "pixi": { + "version_args": ["pixi", "--version"], + "version_pattern": r"pixi (\S+)", + }, + "npm": { + "version_args": ["npm", "--version"], + "version_pattern": r"^(\S+)$", + }, + "maven": { + "version_args": ["mvn", "--version"], + "version_pattern": r"Apache Maven (\S+)", + }, +} + +_REQ_PATTERN = re.compile(r"^([a-zA-Z][a-zA-Z0-9_-]*)\s*((?:>=|<=|!=|==|>|<)\s*\S+)?$") + + +def verify_requirements(requirements: list[str]): + """Verify that all required tools are available and meet version constraints. + + Raises ValueError with a message listing all unmet requirements. + """ + if not requirements: + return + + errors = [] + + for req in requirements: + match = _REQ_PATTERN.match(req.strip()) + if not match: + errors.append(f"Invalid requirement format: '{req}'") + continue + + tool = match.group(1) + version_spec = match.group(2) + + # Check tool exists on PATH + if shutil.which(tool) is None: + # For maven, the binary is 'mvn' not 'maven' + registry_entry = _TOOL_REGISTRY.get(tool) + binary = registry_entry["version_args"][0] if registry_entry else tool + if binary != tool and shutil.which(binary) is not None: + pass # binary found under alternate name + else: + errors.append(f"Required tool '{tool}' is not installed or not on PATH") + continue + + if version_spec: + registry_entry = _TOOL_REGISTRY.get(tool) + if not registry_entry: + errors.append(f"Cannot check version for '{tool}': no version command configured") + continue + + try: + result = subprocess.run( + registry_entry["version_args"], + capture_output=True, text=True, timeout=10, + ) + output = result.stdout.strip() or result.stderr.strip() + ver_match = re.search(registry_entry["version_pattern"], output) + if not ver_match: + errors.append( + f"Could not parse version for '{tool}' from output: {output!r}" + ) + continue + + installed = Version(ver_match.group(1)) + specifier = SpecifierSet(version_spec.strip()) + if not specifier.contains(installed): + errors.append( + f"'{tool}' version {installed} does not satisfy {version_spec.strip()}" + ) + except FileNotFoundError: + errors.append(f"Required tool '{tool}' is not installed or not on PATH") + except subprocess.TimeoutExpired: + errors.append(f"Timed out checking version for '{tool}'") + + if errors: + raise ValueError("Unmet requirements:\n - " + "\n - ".join(errors)) + + # --- Command Building --- # Characters that are dangerous in shell commands @@ -358,6 +490,7 @@ async def submit_job( entry_point_id: str, parameters: dict, resources: Optional[dict] = None, + pull_latest: bool = False, ) -> db.JobDB: """Submit a new job to the cluster. @@ -379,6 +512,9 @@ async def submit_job( if entry_point is None: raise ValueError(f"Entry point '{entry_point_id}' not found in manifest") + # Verify requirements before proceeding + verify_requirements(manifest.requirements) + # Build command command = build_command(entry_point, parameters) @@ -408,16 +544,17 @@ async def submit_job( ) job_id = db_job.id - # Create work directory and write readable script + # Create work directory work_dir = _build_work_dir(job_id, manifest.name, entry_point.id) work_dir.mkdir(parents=True, exist_ok=True) - script_path = work_dir / "script.sh" - script_path.write_text(f"#!/bin/bash\ncd {work_dir}\n\n{command}\n") - script_path.chmod(0o755) + # Clone/update repo cache and symlink into work dir + repo_dir = _ensure_repo_cache(app_url, pull=pull_latest) + repo_link = work_dir / "repo" + repo_link.symlink_to(repo_dir) - # Wrap command with cd into work dir - full_command = f"cd {work_dir}\n\n{command}" + # Wrap command with cd into the repo symlink + full_command = f"cd {repo_link}\n\n{command}" # Set work_dir and log paths on resource spec resource_spec.work_dir = str(work_dir) @@ -517,7 +654,7 @@ async def get_job_file_content(job_id: int, username: str, file_type: str) -> Op """Read the content of a job file (script, stdout, or stderr). All job files live in the job's work directory: - - script.sh — the generated script + - *.sh — the generated script (written by cluster-api) - stdout.log — captured standard output - stderr.log — captured standard error @@ -533,16 +670,19 @@ async def get_job_file_content(job_id: int, username: str, file_type: str) -> Op work_dir = _resolve_work_dir(db_job) - filenames = { - "script": "script.sh", - "stdout": "stdout.log", - "stderr": "stderr.log", - } - - if file_type not in filenames: + if file_type == "script": + # Find the script generated by cluster-api (e.g. jobname.1.sh) + scripts = sorted(work_dir.glob("*.sh")) + if scripts: + return scripts[0].read_text() + return None + elif file_type == "stdout": + path = work_dir / "stdout.log" + elif file_type == "stderr": + path = work_dir / "stderr.log" + else: raise ValueError(f"Unknown file type: {file_type}") - path = work_dir / filenames[file_type] if path.is_file(): return path.read_text() return None diff --git a/fileglancer/model.py b/fileglancer/model.py index df2a1524..4d744b17 100644 --- a/fileglancer/model.py +++ b/fileglancer/model.py @@ -1,7 +1,8 @@ +import re from datetime import datetime from typing import Any, List, Literal, Optional, Dict -from pydantic import BaseModel, Field, HttpUrl +from pydantic import BaseModel, Field, HttpUrl, field_validator class FileSharePath(BaseModel): @@ -340,13 +341,29 @@ class AppEntryPoint(BaseModel): resources: Optional[AppResourceDefaults] = Field(description="Default resource requirements", default=None) +SUPPORTED_TOOLS = {"pixi", "npm", "maven"} + + class AppManifest(BaseModel): """Top-level app manifest (fileglancer-app.json)""" name: str = Field(description="Display name of the app") description: Optional[str] = Field(description="Description of the app", default=None) version: Optional[str] = Field(description="Version of the app", default=None) + requirements: List[str] = Field( + description="Required tools, e.g. ['pixi>=0.40', 'npm']", + default=[], + ) entryPoints: List[AppEntryPoint] = Field(description="Available entry points for this app") + @field_validator("requirements") + @classmethod + def validate_requirements(cls, v): + for req in v: + tool = re.split(r"[><=!]", req)[0].strip() + if tool not in SUPPORTED_TOOLS: + raise ValueError(f"Unsupported tool: '{tool}'. Supported: {SUPPORTED_TOOLS}") + return v + class UserApp(BaseModel): """A user's saved app reference""" @@ -395,6 +412,10 @@ class JobSubmitRequest(BaseModel): entry_point_id: str = Field(description="Entry point to execute") parameters: Dict = Field(description="Parameter values keyed by parameter ID") resources: Optional[AppResourceDefaults] = Field(description="Resource overrides", default=None) + pull_latest: bool = Field( + description="Pull latest code from GitHub before running", + default=False, + ) class PathValidationRequest(BaseModel): diff --git a/frontend/src/components/AppLaunch.tsx b/frontend/src/components/AppLaunch.tsx index 12e5ce68..518e0791 100644 --- a/frontend/src/components/AppLaunch.tsx +++ b/frontend/src/components/AppLaunch.tsx @@ -65,7 +65,8 @@ export default function AppLaunch() { const handleSubmit = async ( parameters: Record, - resources?: AppResourceDefaults + resources?: AppResourceDefaults, + pullLatest?: boolean ) => { if (!selectedEntryPoint) { return; @@ -75,7 +76,8 @@ export default function AppLaunch() { app_url: appUrl, entry_point_id: selectedEntryPoint.id, parameters, - resources + resources, + pull_latest: pullLatest }); toast.success('Job submitted'); navigate('/apps'); diff --git a/frontend/src/components/ui/AppsPage/AppLaunchForm.tsx b/frontend/src/components/ui/AppsPage/AppLaunchForm.tsx index 97611016..ab6a5bfd 100644 --- a/frontend/src/components/ui/AppsPage/AppLaunchForm.tsx +++ b/frontend/src/components/ui/AppsPage/AppLaunchForm.tsx @@ -18,7 +18,8 @@ interface AppLaunchFormProps { readonly entryPoint: AppEntryPoint; readonly onSubmit: ( parameters: Record, - resources?: AppResourceDefaults + resources?: AppResourceDefaults, + pullLatest?: boolean ) => Promise; readonly submitting: boolean; readonly initialValues?: Record; @@ -102,8 +103,11 @@ function ParameterField({ value={value !== undefined && value !== null ? String(value) : ''} /> onChange(path)} + useServerPath /> ); @@ -141,6 +145,7 @@ export default function AppLaunchForm({ const [values, setValues] = useState>(startingValues); const [errors, setErrors] = useState>({}); + const [pullLatest, setPullLatest] = useState(false); const [showResources, setShowResources] = useState(false); const [resources, setResources] = useState({ cpus: entryPoint.resources?.cpus, @@ -260,7 +265,11 @@ export default function AppLaunchForm({ showResources && (resources.cpus || resources.memory || resources.walltime); - await onSubmit(params, hasResourceOverrides ? resources : undefined); + await onSubmit( + params, + hasResourceOverrides ? resources : undefined, + pullLatest || undefined + ); }; return ( @@ -312,6 +321,25 @@ export default function AppLaunchForm({ ))} + {/* Pull latest toggle */} +
+ + + When enabled, runs git pull to fetch the latest code from GitHub + before starting the job. + +
+ {/* Resource Overrides (collapsible) */}
-
+ { + setRepoUrl(e.target.value); + setUrlError(''); + }} + onKeyDown={e => { + if (e.key === 'Enter') { + handleAdd(); + } + }} + placeholder="https://github.com/org/repo" + type="text" + value={repoUrl} + /> {urlError ? ( {urlError} @@ -142,12 +115,10 @@ export default function AddAppDialog({ className="w-full p-2 text-foreground border rounded-sm focus:outline-none bg-background border-primary-light focus:border-primary" onChange={e => { setBranch(e.target.value); - setPreview(null); - manifestMutation.reset(); }} onKeyDown={e => { if (e.key === 'Enter') { - handleFetchPreview(); + handleAdd(); } }} placeholder="main" @@ -156,39 +127,10 @@ export default function AddAppDialog({ /> - {manifestMutation.isError ? ( -
- Failed to fetch manifest:{' '} - {manifestMutation.error?.message || 'Unknown error'} -
- ) : null} - - {preview ? ( -
- - {preview.name} - - {preview.description ? ( - - {preview.description} - - ) : null} - {preview.version ? ( - - Version: {preview.version} - - ) : null} - - {preview.entryPoints.length} entry point - {preview.entryPoints.length !== 1 ? 's' : ''} - -
- ) : null} -
); }, From dad3e9a938bf16ca56939b77e37565da64fe859a Mon Sep 17 00:00:00 2001 From: Konrad Rokicki Date: Fri, 13 Feb 2026 11:23:16 -0500 Subject: [PATCH 20/59] default to last selected folder --- .../FileSelector/FileSelectorButton.tsx | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/ui/BrowsePage/FileSelector/FileSelectorButton.tsx b/frontend/src/components/ui/BrowsePage/FileSelector/FileSelectorButton.tsx index ad4b0c02..6ac84033 100644 --- a/frontend/src/components/ui/BrowsePage/FileSelector/FileSelectorButton.tsx +++ b/frontend/src/components/ui/BrowsePage/FileSelector/FileSelectorButton.tsx @@ -13,6 +13,16 @@ import type { FileSelectorMode } from '@/hooks/useFileSelector'; +// Remember the last confirmed selection's parent folder across all instances +let lastSelectedParentPath: string | null = null; + +function getParentPath(fullPath: string): string { + // Strip trailing slash, then take everything up to the last slash + const trimmed = fullPath.endsWith('/') ? fullPath.slice(0, -1) : fullPath; + const lastSlash = trimmed.lastIndexOf('/'); + return lastSlash > 0 ? trimmed.slice(0, lastSlash) : trimmed; +} + type FileSelectorButtonProps = { readonly onSelect: (path: string) => void; readonly triggerClasses?: string; @@ -34,6 +44,10 @@ export default function FileSelectorButton({ }: FileSelectorButtonProps) { const [showDialog, setShowDialog] = useState(false); + // Use initialPath if provided, otherwise fall back to last confirmed selection's parent + const effectiveInitialPath = + initialPath || lastSelectedParentPath || undefined; + const { state, displayItems, @@ -45,7 +59,7 @@ export default function FileSelectorButton({ reset } = useFileSelector({ initialLocation, - initialPath: showDialog ? initialPath : undefined, + initialPath: showDialog ? effectiveInitialPath : undefined, mode, pathPreferenceOverride: useServerPath ? ['linux_path'] : undefined }); @@ -64,6 +78,7 @@ export default function FileSelectorButton({ const handleSelect = () => { if (state.selectedItem) { + lastSelectedParentPath = getParentPath(state.selectedItem.fullPath); onSelect(state.selectedItem.fullPath); onClose(); } From ea0e63eb4f8a4a083b0f442d151e80680ffc24b0 Mon Sep 17 00:00:00 2001 From: Konrad Rokicki Date: Fri, 13 Feb 2026 12:30:44 -0500 Subject: [PATCH 21/59] move cluster config into separate object --- docs/config.yaml.template | 35 +++++++++++++++++------------------ fileglancer/apps.py | 19 ++++++++----------- fileglancer/settings.py | 35 ++++++++++++++++++++++++++--------- 3 files changed, 51 insertions(+), 38 deletions(-) diff --git a/docs/config.yaml.template b/docs/config.yaml.template index 2397813e..aa4d5745 100644 --- a/docs/config.yaml.template +++ b/docs/config.yaml.template @@ -126,21 +126,20 @@ session_cookie_secure: true # # Cluster configuration for Apps feature -# Controls how jobs are submitted and executed -# -# Executor type: "local" for local execution, "lsf" for LSF cluster -# cluster_executor: local - -# LSF queue name (only used with "lsf" executor) -# cluster_queue: normal - -# LSF account (only used with "lsf" executor) -# cluster_account: your_account - -# Polling interval in seconds for checking job status -# cluster_poll_interval: 10.0 - -# Default resource allocation for jobs (can be overridden per entry point) -# cluster_default_memory: "8 GB" -# cluster_default_walltime: "04:00" -# cluster_default_cpus: 1 +# Mirrors py-cluster-api ClusterConfig - all fields are optional +# See: https://github.com/JaneliaSciComp/py-cluster-api +# +# cluster: +# executor: local # "local" or "lsf" +# memory: "8 GB" # Default memory allocation +# walltime: "04:00" # Default walltime (HH:MM) +# cpus: 1 # Default CPU count +# queue: normal # LSF queue name +# poll_interval: 10.0 # Job status polling interval (seconds) +# lsf_units: MB # Memory units for LSF (KB, MB, GB) +# use_stdin: false # Use stdin for job submission +# suppress_job_email: true # Suppress LSF job email notifications +# extra_directives: # Additional scheduler directives +# - "#BSUB -P your_account" +# script_prologue: # Commands to run before each job +# - "module load java/11" diff --git a/fileglancer/apps.py b/fileglancer/apps.py index d5476532..948e8d2d 100644 --- a/fileglancer/apps.py +++ b/fileglancer/apps.py @@ -387,10 +387,8 @@ async def get_executor(): global _executor if _executor is None: settings = get_settings() - _executor = create_executor( - executor=settings.cluster_executor, - queue=settings.cluster_queue, - ) + config = settings.cluster.model_dump(exclude_none=True) + _executor = create_executor(**config) return _executor @@ -401,7 +399,7 @@ async def start_job_monitor(): settings = get_settings() executor = await get_executor() - _monitor = JobMonitor(executor, poll_interval=settings.cluster_poll_interval) + _monitor = JobMonitor(executor, poll_interval=settings.cluster.poll_interval) await _monitor.start() # Start reconciliation loop @@ -436,7 +434,7 @@ async def _reconcile_loop(settings): except Exception: logger.exception("Error in job reconciliation loop") - await asyncio.sleep(settings.cluster_poll_interval) + await asyncio.sleep(settings.cluster.poll_interval) async def _reconcile_jobs(settings): @@ -617,10 +615,10 @@ async def submit_job( def _build_resource_spec(entry_point: AppEntryPoint, overrides: Optional[dict], settings) -> ResourceSpec: """Build a ResourceSpec from entry point defaults, user overrides, and global defaults.""" - cpus = settings.cluster_default_cpus - memory = settings.cluster_default_memory - walltime = settings.cluster_default_walltime - queue = settings.cluster_queue + cpus = settings.cluster.cpus + memory = settings.cluster.memory + walltime = settings.cluster.walltime + queue = settings.cluster.queue # Apply entry point defaults if entry_point.resources: @@ -645,7 +643,6 @@ def _build_resource_spec(entry_point: AppEntryPoint, overrides: Optional[dict], memory=memory, walltime=walltime, queue=queue, - account=settings.cluster_account, ) diff --git a/fileglancer/settings.py b/fileglancer/settings.py index bb368617..418276e4 100644 --- a/fileglancer/settings.py +++ b/fileglancer/settings.py @@ -2,7 +2,7 @@ from functools import cache import sys -from pydantic import HttpUrl, ValidationError, field_validator, model_validator +from pydantic import BaseModel, HttpUrl, ValidationError, field_validator, model_validator from pydantic_settings import ( BaseSettings, PydanticBaseSettingsSource, @@ -11,6 +11,29 @@ ) +class ClusterSettings(BaseModel): + """Cluster configuration matching py-cluster-api's ClusterConfig.""" + executor: str = 'local' + cpus: Optional[int] = None + gpus: Optional[int] = None + memory: Optional[str] = None + walltime: Optional[str] = None + queue: Optional[str] = None + poll_interval: float = 10.0 + shebang: str = "#!/bin/bash" + script_prologue: List[str] = [] + script_epilogue: List[str] = [] + extra_directives: List[str] = [] + directives_skip: List[str] = [] + lsf_units: str = "MB" + use_stdin: bool = False + job_name_prefix: Optional[str] = None + zombie_timeout_minutes: float = 30.0 + completed_retention_minutes: float = 10.0 + command_timeout: float = 100.0 + suppress_job_email: bool = True + + class Settings(BaseSettings): """ Settings can be read from a settings.yaml file, or from the environment, with environment variables prepended @@ -67,14 +90,8 @@ class Settings(BaseSettings): # CLI mode - enables auto-login endpoint for standalone CLI usage cli_mode: bool = False - # Cluster / Apps settings - cluster_executor: str = 'local' # "lsf" or "local" - cluster_queue: Optional[str] = None - cluster_account: Optional[str] = None - cluster_poll_interval: float = 10.0 - cluster_default_memory: Optional[str] = None # e.g. "8 GB" - cluster_default_walltime: Optional[str] = None # e.g. "04:00" - cluster_default_cpus: Optional[int] = None + # Cluster / Apps settings (mirrors py-cluster-api ClusterConfig) + cluster: ClusterSettings = ClusterSettings() model_config = SettingsConfigDict( yaml_file="config.yaml", From 5c9e68a2890e134b672656c87d6e0db15675a445 Mon Sep 17 00:00:00 2001 From: Konrad Rokicki Date: Fri, 13 Feb 2026 14:15:40 -0500 Subject: [PATCH 22/59] updated to py_cluster_api-0.2.1 --- docs/config.yaml.template | 7 +++++-- fileglancer/settings.py | 1 + pixi.lock | 34 +++++++++++++++++----------------- pyproject.toml | 2 +- 4 files changed, 24 insertions(+), 20 deletions(-) diff --git a/docs/config.yaml.template b/docs/config.yaml.template index aa4d5745..0e0a3656 100644 --- a/docs/config.yaml.template +++ b/docs/config.yaml.template @@ -139,7 +139,10 @@ session_cookie_secure: true # lsf_units: MB # Memory units for LSF (KB, MB, GB) # use_stdin: false # Use stdin for job submission # suppress_job_email: true # Suppress LSF job email notifications -# extra_directives: # Additional scheduler directives -# - "#BSUB -P your_account" +# extra_directives: # Additional scheduler directives (prefix auto-added) +# - "-P your_account" +# extra_args: # Extra CLI args appended to submit command +# - "-P" +# - "your_project" # script_prologue: # Commands to run before each job # - "module load java/11" diff --git a/fileglancer/settings.py b/fileglancer/settings.py index 418276e4..7a9f1d32 100644 --- a/fileglancer/settings.py +++ b/fileglancer/settings.py @@ -24,6 +24,7 @@ class ClusterSettings(BaseModel): script_prologue: List[str] = [] script_epilogue: List[str] = [] extra_directives: List[str] = [] + extra_args: List[str] = [] directives_skip: List[str] = [] lsf_units: str = "MB" use_stdin: bool = False diff --git a/pixi.lock b/pixi.lock index 76b5f732..be26abf1 100644 --- a/pixi.lock +++ b/pixi.lock @@ -172,7 +172,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/a7/b2/fabede9fafd976b991e9f1b9c8c873ed86f202889b864756f240ce6dd855/frozenlist-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl - pypi: https://files.pythonhosted.org/packages/b4/63/2ab26e4209773223159b83aa32721b4021ffb08102f8ac7d689c943fded1/multidict-6.7.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/b2/f2/889ad4b2408f72fe1a4f6a19491177b30ea7bf1a0fd5f17050ca08cfc882/propcache-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/ee/61/90d2c473721cff8db0454ba7cc6dd9b68eeea01ed49ad630f93890d67978/py_cluster_api-0.2.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/99/e9/70890179c55950ad8c7135abf43d95f0ca0326815345401d84aa763f95d9/py_cluster_api-0.2.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/48/a2/87a0b61a3078be07ab04ec574ef6c683c764590ed0d2a50d00cbb23aeae7/pydantic_settings_yaml-0.2.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fc/51/727abb13f44c1fcf6d145979e1535a35794db0f6e450a0cb46aa24732fe2/s3transfer-0.16.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/7f/21/f52cd11559059e686e7ecfe40c4746ee957f96779ce22a8f76d5b4d62516/x2s3-1.1.0-py3-none-any.whl @@ -340,7 +340,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/3a/3b/d9b1e0b0eed36e70477ffb8360c49c85c8ca8ef9700a4e6711f39a6e8b45/frozenlist-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl - pypi: https://files.pythonhosted.org/packages/15/ee/f524093232007cd7a75c1d132df70f235cfd590a7c9eaccd7ff422ef4ae8/multidict-6.7.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl - pypi: https://files.pythonhosted.org/packages/86/bd/47816020d337f4a746edc42fe8d53669965138f39ee117414c7d7a340cfe/propcache-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl - - pypi: https://files.pythonhosted.org/packages/ee/61/90d2c473721cff8db0454ba7cc6dd9b68eeea01ed49ad630f93890d67978/py_cluster_api-0.2.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/99/e9/70890179c55950ad8c7135abf43d95f0ca0326815345401d84aa763f95d9/py_cluster_api-0.2.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/48/a2/87a0b61a3078be07ab04ec574ef6c683c764590ed0d2a50d00cbb23aeae7/pydantic_settings_yaml-0.2.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fc/51/727abb13f44c1fcf6d145979e1535a35794db0f6e450a0cb46aa24732fe2/s3transfer-0.16.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/7f/21/f52cd11559059e686e7ecfe40c4746ee957f96779ce22a8f76d5b4d62516/x2s3-1.1.0-py3-none-any.whl @@ -503,7 +503,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/8e/e8/a1185e236ec66c20afd72399522f142c3724c785789255202d27ae992818/frozenlist-1.8.0-cp314-cp314-macosx_10_13_x86_64.whl - pypi: https://files.pythonhosted.org/packages/14/2c/f069cab5b51d175a1a2cb4ccdf7a2c2dabd58aa5bd933fa036a8d15e2404/multidict-6.7.0-cp314-cp314-macosx_10_13_x86_64.whl - pypi: https://files.pythonhosted.org/packages/65/9b/03b04e7d82a5f54fb16113d839f5ea1ede58a61e90edf515f6577c66fa8f/propcache-0.4.1-cp314-cp314-macosx_10_13_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/ee/61/90d2c473721cff8db0454ba7cc6dd9b68eeea01ed49ad630f93890d67978/py_cluster_api-0.2.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/99/e9/70890179c55950ad8c7135abf43d95f0ca0326815345401d84aa763f95d9/py_cluster_api-0.2.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/48/a2/87a0b61a3078be07ab04ec574ef6c683c764590ed0d2a50d00cbb23aeae7/pydantic_settings_yaml-0.2.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fc/51/727abb13f44c1fcf6d145979e1535a35794db0f6e450a0cb46aa24732fe2/s3transfer-0.16.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/7f/21/f52cd11559059e686e7ecfe40c4746ee957f96779ce22a8f76d5b4d62516/x2s3-1.1.0-py3-none-any.whl @@ -666,7 +666,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/a1/93/72b1736d68f03fda5fdf0f2180fb6caaae3894f1b854d006ac61ecc727ee/frozenlist-1.8.0-cp314-cp314-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/42/e2/64bb41266427af6642b6b128e8774ed84c11b80a90702c13ac0a86bb10cc/multidict-6.7.0-cp314-cp314-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/b2/fa/89a8ef0468d5833a23fff277b143d0573897cf75bd56670a6d28126c7d68/propcache-0.4.1-cp314-cp314-macosx_11_0_arm64.whl - - pypi: https://files.pythonhosted.org/packages/ee/61/90d2c473721cff8db0454ba7cc6dd9b68eeea01ed49ad630f93890d67978/py_cluster_api-0.2.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/99/e9/70890179c55950ad8c7135abf43d95f0ca0326815345401d84aa763f95d9/py_cluster_api-0.2.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/48/a2/87a0b61a3078be07ab04ec574ef6c683c764590ed0d2a50d00cbb23aeae7/pydantic_settings_yaml-0.2.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fc/51/727abb13f44c1fcf6d145979e1535a35794db0f6e450a0cb46aa24732fe2/s3transfer-0.16.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/7f/21/f52cd11559059e686e7ecfe40c4746ee957f96779ce22a8f76d5b4d62516/x2s3-1.1.0-py3-none-any.whl @@ -885,7 +885,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/a7/b2/fabede9fafd976b991e9f1b9c8c873ed86f202889b864756f240ce6dd855/frozenlist-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl - pypi: https://files.pythonhosted.org/packages/b4/63/2ab26e4209773223159b83aa32721b4021ffb08102f8ac7d689c943fded1/multidict-6.7.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/b2/f2/889ad4b2408f72fe1a4f6a19491177b30ea7bf1a0fd5f17050ca08cfc882/propcache-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/ee/61/90d2c473721cff8db0454ba7cc6dd9b68eeea01ed49ad630f93890d67978/py_cluster_api-0.2.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/99/e9/70890179c55950ad8c7135abf43d95f0ca0326815345401d84aa763f95d9/py_cluster_api-0.2.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/48/a2/87a0b61a3078be07ab04ec574ef6c683c764590ed0d2a50d00cbb23aeae7/pydantic_settings_yaml-0.2.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/bd/24/12818598c362d7f300f18e74db45963dbcb85150324092410c8b49405e42/pyproject_hooks-1.2.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fc/51/727abb13f44c1fcf6d145979e1535a35794db0f6e450a0cb46aa24732fe2/s3transfer-0.16.0-py3-none-any.whl @@ -1096,7 +1096,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/3a/3b/d9b1e0b0eed36e70477ffb8360c49c85c8ca8ef9700a4e6711f39a6e8b45/frozenlist-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl - pypi: https://files.pythonhosted.org/packages/15/ee/f524093232007cd7a75c1d132df70f235cfd590a7c9eaccd7ff422ef4ae8/multidict-6.7.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl - pypi: https://files.pythonhosted.org/packages/86/bd/47816020d337f4a746edc42fe8d53669965138f39ee117414c7d7a340cfe/propcache-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl - - pypi: https://files.pythonhosted.org/packages/ee/61/90d2c473721cff8db0454ba7cc6dd9b68eeea01ed49ad630f93890d67978/py_cluster_api-0.2.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/99/e9/70890179c55950ad8c7135abf43d95f0ca0326815345401d84aa763f95d9/py_cluster_api-0.2.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/48/a2/87a0b61a3078be07ab04ec574ef6c683c764590ed0d2a50d00cbb23aeae7/pydantic_settings_yaml-0.2.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fc/51/727abb13f44c1fcf6d145979e1535a35794db0f6e450a0cb46aa24732fe2/s3transfer-0.16.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/7f/21/f52cd11559059e686e7ecfe40c4746ee957f96779ce22a8f76d5b4d62516/x2s3-1.1.0-py3-none-any.whl @@ -1295,7 +1295,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/8e/e8/a1185e236ec66c20afd72399522f142c3724c785789255202d27ae992818/frozenlist-1.8.0-cp314-cp314-macosx_10_13_x86_64.whl - pypi: https://files.pythonhosted.org/packages/14/2c/f069cab5b51d175a1a2cb4ccdf7a2c2dabd58aa5bd933fa036a8d15e2404/multidict-6.7.0-cp314-cp314-macosx_10_13_x86_64.whl - pypi: https://files.pythonhosted.org/packages/65/9b/03b04e7d82a5f54fb16113d839f5ea1ede58a61e90edf515f6577c66fa8f/propcache-0.4.1-cp314-cp314-macosx_10_13_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/ee/61/90d2c473721cff8db0454ba7cc6dd9b68eeea01ed49ad630f93890d67978/py_cluster_api-0.2.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/99/e9/70890179c55950ad8c7135abf43d95f0ca0326815345401d84aa763f95d9/py_cluster_api-0.2.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/48/a2/87a0b61a3078be07ab04ec574ef6c683c764590ed0d2a50d00cbb23aeae7/pydantic_settings_yaml-0.2.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/bd/24/12818598c362d7f300f18e74db45963dbcb85150324092410c8b49405e42/pyproject_hooks-1.2.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fc/51/727abb13f44c1fcf6d145979e1535a35794db0f6e450a0cb46aa24732fe2/s3transfer-0.16.0-py3-none-any.whl @@ -1495,7 +1495,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/a1/93/72b1736d68f03fda5fdf0f2180fb6caaae3894f1b854d006ac61ecc727ee/frozenlist-1.8.0-cp314-cp314-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/42/e2/64bb41266427af6642b6b128e8774ed84c11b80a90702c13ac0a86bb10cc/multidict-6.7.0-cp314-cp314-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/b2/fa/89a8ef0468d5833a23fff277b143d0573897cf75bd56670a6d28126c7d68/propcache-0.4.1-cp314-cp314-macosx_11_0_arm64.whl - - pypi: https://files.pythonhosted.org/packages/ee/61/90d2c473721cff8db0454ba7cc6dd9b68eeea01ed49ad630f93890d67978/py_cluster_api-0.2.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/99/e9/70890179c55950ad8c7135abf43d95f0ca0326815345401d84aa763f95d9/py_cluster_api-0.2.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/48/a2/87a0b61a3078be07ab04ec574ef6c683c764590ed0d2a50d00cbb23aeae7/pydantic_settings_yaml-0.2.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/bd/24/12818598c362d7f300f18e74db45963dbcb85150324092410c8b49405e42/pyproject_hooks-1.2.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fc/51/727abb13f44c1fcf6d145979e1535a35794db0f6e450a0cb46aa24732fe2/s3transfer-0.16.0-py3-none-any.whl @@ -1753,7 +1753,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/a7/b2/fabede9fafd976b991e9f1b9c8c873ed86f202889b864756f240ce6dd855/frozenlist-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl - pypi: https://files.pythonhosted.org/packages/b4/63/2ab26e4209773223159b83aa32721b4021ffb08102f8ac7d689c943fded1/multidict-6.7.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/b2/f2/889ad4b2408f72fe1a4f6a19491177b30ea7bf1a0fd5f17050ca08cfc882/propcache-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/ee/61/90d2c473721cff8db0454ba7cc6dd9b68eeea01ed49ad630f93890d67978/py_cluster_api-0.2.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/99/e9/70890179c55950ad8c7135abf43d95f0ca0326815345401d84aa763f95d9/py_cluster_api-0.2.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/48/a2/87a0b61a3078be07ab04ec574ef6c683c764590ed0d2a50d00cbb23aeae7/pydantic_settings_yaml-0.2.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c8/c7/c160021cbecd956cc1a6f79e5fe155f7868b2e5b848f1320dad0b3e3122f/pytest_html-4.1.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/3e/43/7e7b2ec865caa92f67b8f0e9231a798d102724ca4c0e1f414316be1c1ef2/pytest_metadata-3.1.1-py3-none-any.whl @@ -2002,7 +2002,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/3a/3b/d9b1e0b0eed36e70477ffb8360c49c85c8ca8ef9700a4e6711f39a6e8b45/frozenlist-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl - pypi: https://files.pythonhosted.org/packages/15/ee/f524093232007cd7a75c1d132df70f235cfd590a7c9eaccd7ff422ef4ae8/multidict-6.7.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl - pypi: https://files.pythonhosted.org/packages/86/bd/47816020d337f4a746edc42fe8d53669965138f39ee117414c7d7a340cfe/propcache-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl - - pypi: https://files.pythonhosted.org/packages/ee/61/90d2c473721cff8db0454ba7cc6dd9b68eeea01ed49ad630f93890d67978/py_cluster_api-0.2.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/99/e9/70890179c55950ad8c7135abf43d95f0ca0326815345401d84aa763f95d9/py_cluster_api-0.2.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/48/a2/87a0b61a3078be07ab04ec574ef6c683c764590ed0d2a50d00cbb23aeae7/pydantic_settings_yaml-0.2.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c8/c7/c160021cbecd956cc1a6f79e5fe155f7868b2e5b848f1320dad0b3e3122f/pytest_html-4.1.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/3e/43/7e7b2ec865caa92f67b8f0e9231a798d102724ca4c0e1f414316be1c1ef2/pytest_metadata-3.1.1-py3-none-any.whl @@ -2250,7 +2250,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/8e/e8/a1185e236ec66c20afd72399522f142c3724c785789255202d27ae992818/frozenlist-1.8.0-cp314-cp314-macosx_10_13_x86_64.whl - pypi: https://files.pythonhosted.org/packages/14/2c/f069cab5b51d175a1a2cb4ccdf7a2c2dabd58aa5bd933fa036a8d15e2404/multidict-6.7.0-cp314-cp314-macosx_10_13_x86_64.whl - pypi: https://files.pythonhosted.org/packages/65/9b/03b04e7d82a5f54fb16113d839f5ea1ede58a61e90edf515f6577c66fa8f/propcache-0.4.1-cp314-cp314-macosx_10_13_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/ee/61/90d2c473721cff8db0454ba7cc6dd9b68eeea01ed49ad630f93890d67978/py_cluster_api-0.2.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/99/e9/70890179c55950ad8c7135abf43d95f0ca0326815345401d84aa763f95d9/py_cluster_api-0.2.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/48/a2/87a0b61a3078be07ab04ec574ef6c683c764590ed0d2a50d00cbb23aeae7/pydantic_settings_yaml-0.2.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c8/c7/c160021cbecd956cc1a6f79e5fe155f7868b2e5b848f1320dad0b3e3122f/pytest_html-4.1.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/3e/43/7e7b2ec865caa92f67b8f0e9231a798d102724ca4c0e1f414316be1c1ef2/pytest_metadata-3.1.1-py3-none-any.whl @@ -2498,7 +2498,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/a1/93/72b1736d68f03fda5fdf0f2180fb6caaae3894f1b854d006ac61ecc727ee/frozenlist-1.8.0-cp314-cp314-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/42/e2/64bb41266427af6642b6b128e8774ed84c11b80a90702c13ac0a86bb10cc/multidict-6.7.0-cp314-cp314-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/b2/fa/89a8ef0468d5833a23fff277b143d0573897cf75bd56670a6d28126c7d68/propcache-0.4.1-cp314-cp314-macosx_11_0_arm64.whl - - pypi: https://files.pythonhosted.org/packages/ee/61/90d2c473721cff8db0454ba7cc6dd9b68eeea01ed49ad630f93890d67978/py_cluster_api-0.2.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/99/e9/70890179c55950ad8c7135abf43d95f0ca0326815345401d84aa763f95d9/py_cluster_api-0.2.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/48/a2/87a0b61a3078be07ab04ec574ef6c683c764590ed0d2a50d00cbb23aeae7/pydantic_settings_yaml-0.2.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c8/c7/c160021cbecd956cc1a6f79e5fe155f7868b2e5b848f1320dad0b3e3122f/pytest_html-4.1.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/3e/43/7e7b2ec865caa92f67b8f0e9231a798d102724ca4c0e1f414316be1c1ef2/pytest_metadata-3.1.1-py3-none-any.whl @@ -4091,7 +4091,7 @@ packages: - pypi: ./ name: fileglancer version: 2.4.0 - sha256: 92739fd520f3e7d74d7419d9a101dc3bea1ae93742d6a5127915b96b6a146774 + sha256: 65505b926d4170d3b36ff01c2cf44d885af78cab13b91e9255bb8414559e8e26 requires_dist: - aiosqlite>=0.21.0 - alembic>=1.17.0 @@ -4107,7 +4107,7 @@ packages: - lxml>=5.3.1 - pandas>=2.3.3 - psycopg2-binary>=2.9.10,<3 - - py-cluster-api>=0.2.0 + - py-cluster-api>=0.2.1 - pydantic-settings>=2.11.0 - pydantic>=2.10.6 - python-jose>=3.5.0,<4 @@ -8102,10 +8102,10 @@ packages: - pkg:pypi/pure-eval?source=hash-mapping size: 16668 timestamp: 1733569518868 -- pypi: https://files.pythonhosted.org/packages/ee/61/90d2c473721cff8db0454ba7cc6dd9b68eeea01ed49ad630f93890d67978/py_cluster_api-0.2.0-py3-none-any.whl +- pypi: https://files.pythonhosted.org/packages/99/e9/70890179c55950ad8c7135abf43d95f0ca0326815345401d84aa763f95d9/py_cluster_api-0.2.1-py3-none-any.whl name: py-cluster-api - version: 0.2.0 - sha256: 457310759f2c8f52477010ef7ebfc7d59ba16d9a4363875cdb56839d140f3b0d + version: 0.2.1 + sha256: 2cc4bcbf57b8b8641815a204070f68c8c3f2bd581a76b96f7bbaeae1d7640c90 requires_dist: - pyyaml - build ; extra == 'release' diff --git a/pyproject.toml b/pyproject.toml index d142ec19..9118e610 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,7 +44,7 @@ dependencies = [ "sqlalchemy >=2.0.44", "uvicorn >=0.38.0", "x2s3 >=1.1.0", - "py-cluster-api >=0.2.0" + "py-cluster-api >=0.2.1" ] [project.scripts] From 1884b735b122646d04e1ba8f6cf01a49e53976cb Mon Sep 17 00:00:00 2001 From: Konrad Rokicki Date: Fri, 13 Feb 2026 14:15:50 -0500 Subject: [PATCH 23/59] update label --- frontend/src/components/JobDetail.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/components/JobDetail.tsx b/frontend/src/components/JobDetail.tsx index bd9672cd..b78e49a4 100644 --- a/frontend/src/components/JobDetail.tsx +++ b/frontend/src/components/JobDetail.tsx @@ -211,7 +211,7 @@ export default function JobDetail() { variant="outline" > - Relaunch with these parameters + Relaunch From 344d7c516833e07929260b15b6d529cd9af2bcb1 Mon Sep 17 00:00:00 2001 From: Konrad Rokicki Date: Fri, 13 Feb 2026 14:26:48 -0500 Subject: [PATCH 24/59] handle orphan jobs --- fileglancer/apps.py | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/fileglancer/apps.py b/fileglancer/apps.py index 948e8d2d..66e79fb8 100644 --- a/fileglancer/apps.py +++ b/fileglancer/apps.py @@ -446,17 +446,28 @@ async def _reconcile_jobs(settings): for db_job in active_jobs: if not db_job.cluster_job_id: + # Job never got a cluster_job_id - submission didn't complete. + # Mark FAILED if it's been stuck longer than zombie_timeout. + created = db_job.created_at.replace(tzinfo=None) if db_job.created_at.tzinfo else db_job.created_at + age_minutes = (datetime.now(UTC).replace(tzinfo=None) - created).total_seconds() / 60 + if age_minutes > settings.cluster.zombie_timeout_minutes: + db.update_job_status(session, db_job.id, "FAILED", finished_at=datetime.now(UTC)) + logger.warning( + f"Job {db_job.id} has no cluster_job_id after " + f"{age_minutes:.0f} minutes, marked FAILED" + ) continue # Check if executor is tracking this job tracked = executor.jobs.get(db_job.cluster_job_id) if tracked is None: - # Job is no longer tracked by executor - might have been lost - # Mark as FAILED if it was RUNNING, leave PENDING as is - if db_job.status == "RUNNING": - now = datetime.now(UTC) - db.update_job_status(session, db_job.id, "FAILED", finished_at=now) - logger.warning(f"Job {db_job.id} (cluster: {db_job.cluster_job_id}) lost from executor, marked FAILED") + # Job is no longer tracked by executor (e.g. after server restart) + now = datetime.now(UTC) + db.update_job_status(session, db_job.id, "FAILED", finished_at=now) + logger.warning( + f"Job {db_job.id} (cluster: {db_job.cluster_job_id}) " + f"lost from executor (was {db_job.status}), marked FAILED" + ) continue # Map cluster status to our status strings From 9b185f8c08bc2c4a387e101b0da2d405ea2d8a75 Mon Sep 17 00:00:00 2001 From: Konrad Rokicki Date: Fri, 13 Feb 2026 20:14:47 -0500 Subject: [PATCH 25/59] remove use_stdin option which is no longer needed --- docs/config.yaml.template | 1 - fileglancer/settings.py | 1 - 2 files changed, 2 deletions(-) diff --git a/docs/config.yaml.template b/docs/config.yaml.template index 0e0a3656..8e7601fc 100644 --- a/docs/config.yaml.template +++ b/docs/config.yaml.template @@ -137,7 +137,6 @@ session_cookie_secure: true # queue: normal # LSF queue name # poll_interval: 10.0 # Job status polling interval (seconds) # lsf_units: MB # Memory units for LSF (KB, MB, GB) -# use_stdin: false # Use stdin for job submission # suppress_job_email: true # Suppress LSF job email notifications # extra_directives: # Additional scheduler directives (prefix auto-added) # - "-P your_account" diff --git a/fileglancer/settings.py b/fileglancer/settings.py index 7a9f1d32..149a42d3 100644 --- a/fileglancer/settings.py +++ b/fileglancer/settings.py @@ -27,7 +27,6 @@ class ClusterSettings(BaseModel): extra_args: List[str] = [] directives_skip: List[str] = [] lsf_units: str = "MB" - use_stdin: bool = False job_name_prefix: Optional[str] = None zombie_timeout_minutes: float = 30.0 completed_retention_minutes: float = 10.0 From 64a1ee720b7f8c0660834f20ca82ee709eb2d7bc Mon Sep 17 00:00:00 2001 From: Konrad Rokicki Date: Fri, 13 Feb 2026 20:24:04 -0500 Subject: [PATCH 26/59] updated py-cluster-api and implemented reconnection --- fileglancer/apps.py | 8 ++++++++ pixi.lock | 34 +++++++++++++++++----------------- pyproject.toml | 2 +- 3 files changed, 26 insertions(+), 18 deletions(-) diff --git a/fileglancer/apps.py b/fileglancer/apps.py index 66e79fb8..54d3eb55 100644 --- a/fileglancer/apps.py +++ b/fileglancer/apps.py @@ -399,6 +399,14 @@ async def start_job_monitor(): settings = get_settings() executor = await get_executor() + # Reconnect to any previously submitted jobs (e.g. after server restart) + try: + reconnected = await executor.reconnect() + if reconnected: + logger.info(f"Reconnected to {len(reconnected)} existing cluster jobs") + except NotImplementedError: + logger.debug("Job reconnection not supported by this executor") + _monitor = JobMonitor(executor, poll_interval=settings.cluster.poll_interval) await _monitor.start() diff --git a/pixi.lock b/pixi.lock index be26abf1..daa7dca6 100644 --- a/pixi.lock +++ b/pixi.lock @@ -172,7 +172,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/a7/b2/fabede9fafd976b991e9f1b9c8c873ed86f202889b864756f240ce6dd855/frozenlist-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl - pypi: https://files.pythonhosted.org/packages/b4/63/2ab26e4209773223159b83aa32721b4021ffb08102f8ac7d689c943fded1/multidict-6.7.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/b2/f2/889ad4b2408f72fe1a4f6a19491177b30ea7bf1a0fd5f17050ca08cfc882/propcache-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/99/e9/70890179c55950ad8c7135abf43d95f0ca0326815345401d84aa763f95d9/py_cluster_api-0.2.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/da/67/a80ed986c53a14ac1cdbac2ccbcb01fbf2c53947b76f5f977acfab0da2c9/py_cluster_api-0.2.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/48/a2/87a0b61a3078be07ab04ec574ef6c683c764590ed0d2a50d00cbb23aeae7/pydantic_settings_yaml-0.2.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fc/51/727abb13f44c1fcf6d145979e1535a35794db0f6e450a0cb46aa24732fe2/s3transfer-0.16.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/7f/21/f52cd11559059e686e7ecfe40c4746ee957f96779ce22a8f76d5b4d62516/x2s3-1.1.0-py3-none-any.whl @@ -340,7 +340,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/3a/3b/d9b1e0b0eed36e70477ffb8360c49c85c8ca8ef9700a4e6711f39a6e8b45/frozenlist-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl - pypi: https://files.pythonhosted.org/packages/15/ee/f524093232007cd7a75c1d132df70f235cfd590a7c9eaccd7ff422ef4ae8/multidict-6.7.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl - pypi: https://files.pythonhosted.org/packages/86/bd/47816020d337f4a746edc42fe8d53669965138f39ee117414c7d7a340cfe/propcache-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl - - pypi: https://files.pythonhosted.org/packages/99/e9/70890179c55950ad8c7135abf43d95f0ca0326815345401d84aa763f95d9/py_cluster_api-0.2.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/da/67/a80ed986c53a14ac1cdbac2ccbcb01fbf2c53947b76f5f977acfab0da2c9/py_cluster_api-0.2.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/48/a2/87a0b61a3078be07ab04ec574ef6c683c764590ed0d2a50d00cbb23aeae7/pydantic_settings_yaml-0.2.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fc/51/727abb13f44c1fcf6d145979e1535a35794db0f6e450a0cb46aa24732fe2/s3transfer-0.16.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/7f/21/f52cd11559059e686e7ecfe40c4746ee957f96779ce22a8f76d5b4d62516/x2s3-1.1.0-py3-none-any.whl @@ -503,7 +503,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/8e/e8/a1185e236ec66c20afd72399522f142c3724c785789255202d27ae992818/frozenlist-1.8.0-cp314-cp314-macosx_10_13_x86_64.whl - pypi: https://files.pythonhosted.org/packages/14/2c/f069cab5b51d175a1a2cb4ccdf7a2c2dabd58aa5bd933fa036a8d15e2404/multidict-6.7.0-cp314-cp314-macosx_10_13_x86_64.whl - pypi: https://files.pythonhosted.org/packages/65/9b/03b04e7d82a5f54fb16113d839f5ea1ede58a61e90edf515f6577c66fa8f/propcache-0.4.1-cp314-cp314-macosx_10_13_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/99/e9/70890179c55950ad8c7135abf43d95f0ca0326815345401d84aa763f95d9/py_cluster_api-0.2.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/da/67/a80ed986c53a14ac1cdbac2ccbcb01fbf2c53947b76f5f977acfab0da2c9/py_cluster_api-0.2.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/48/a2/87a0b61a3078be07ab04ec574ef6c683c764590ed0d2a50d00cbb23aeae7/pydantic_settings_yaml-0.2.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fc/51/727abb13f44c1fcf6d145979e1535a35794db0f6e450a0cb46aa24732fe2/s3transfer-0.16.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/7f/21/f52cd11559059e686e7ecfe40c4746ee957f96779ce22a8f76d5b4d62516/x2s3-1.1.0-py3-none-any.whl @@ -666,7 +666,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/a1/93/72b1736d68f03fda5fdf0f2180fb6caaae3894f1b854d006ac61ecc727ee/frozenlist-1.8.0-cp314-cp314-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/42/e2/64bb41266427af6642b6b128e8774ed84c11b80a90702c13ac0a86bb10cc/multidict-6.7.0-cp314-cp314-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/b2/fa/89a8ef0468d5833a23fff277b143d0573897cf75bd56670a6d28126c7d68/propcache-0.4.1-cp314-cp314-macosx_11_0_arm64.whl - - pypi: https://files.pythonhosted.org/packages/99/e9/70890179c55950ad8c7135abf43d95f0ca0326815345401d84aa763f95d9/py_cluster_api-0.2.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/da/67/a80ed986c53a14ac1cdbac2ccbcb01fbf2c53947b76f5f977acfab0da2c9/py_cluster_api-0.2.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/48/a2/87a0b61a3078be07ab04ec574ef6c683c764590ed0d2a50d00cbb23aeae7/pydantic_settings_yaml-0.2.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fc/51/727abb13f44c1fcf6d145979e1535a35794db0f6e450a0cb46aa24732fe2/s3transfer-0.16.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/7f/21/f52cd11559059e686e7ecfe40c4746ee957f96779ce22a8f76d5b4d62516/x2s3-1.1.0-py3-none-any.whl @@ -885,7 +885,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/a7/b2/fabede9fafd976b991e9f1b9c8c873ed86f202889b864756f240ce6dd855/frozenlist-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl - pypi: https://files.pythonhosted.org/packages/b4/63/2ab26e4209773223159b83aa32721b4021ffb08102f8ac7d689c943fded1/multidict-6.7.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/b2/f2/889ad4b2408f72fe1a4f6a19491177b30ea7bf1a0fd5f17050ca08cfc882/propcache-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/99/e9/70890179c55950ad8c7135abf43d95f0ca0326815345401d84aa763f95d9/py_cluster_api-0.2.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/da/67/a80ed986c53a14ac1cdbac2ccbcb01fbf2c53947b76f5f977acfab0da2c9/py_cluster_api-0.2.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/48/a2/87a0b61a3078be07ab04ec574ef6c683c764590ed0d2a50d00cbb23aeae7/pydantic_settings_yaml-0.2.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/bd/24/12818598c362d7f300f18e74db45963dbcb85150324092410c8b49405e42/pyproject_hooks-1.2.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fc/51/727abb13f44c1fcf6d145979e1535a35794db0f6e450a0cb46aa24732fe2/s3transfer-0.16.0-py3-none-any.whl @@ -1096,7 +1096,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/3a/3b/d9b1e0b0eed36e70477ffb8360c49c85c8ca8ef9700a4e6711f39a6e8b45/frozenlist-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl - pypi: https://files.pythonhosted.org/packages/15/ee/f524093232007cd7a75c1d132df70f235cfd590a7c9eaccd7ff422ef4ae8/multidict-6.7.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl - pypi: https://files.pythonhosted.org/packages/86/bd/47816020d337f4a746edc42fe8d53669965138f39ee117414c7d7a340cfe/propcache-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl - - pypi: https://files.pythonhosted.org/packages/99/e9/70890179c55950ad8c7135abf43d95f0ca0326815345401d84aa763f95d9/py_cluster_api-0.2.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/da/67/a80ed986c53a14ac1cdbac2ccbcb01fbf2c53947b76f5f977acfab0da2c9/py_cluster_api-0.2.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/48/a2/87a0b61a3078be07ab04ec574ef6c683c764590ed0d2a50d00cbb23aeae7/pydantic_settings_yaml-0.2.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fc/51/727abb13f44c1fcf6d145979e1535a35794db0f6e450a0cb46aa24732fe2/s3transfer-0.16.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/7f/21/f52cd11559059e686e7ecfe40c4746ee957f96779ce22a8f76d5b4d62516/x2s3-1.1.0-py3-none-any.whl @@ -1295,7 +1295,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/8e/e8/a1185e236ec66c20afd72399522f142c3724c785789255202d27ae992818/frozenlist-1.8.0-cp314-cp314-macosx_10_13_x86_64.whl - pypi: https://files.pythonhosted.org/packages/14/2c/f069cab5b51d175a1a2cb4ccdf7a2c2dabd58aa5bd933fa036a8d15e2404/multidict-6.7.0-cp314-cp314-macosx_10_13_x86_64.whl - pypi: https://files.pythonhosted.org/packages/65/9b/03b04e7d82a5f54fb16113d839f5ea1ede58a61e90edf515f6577c66fa8f/propcache-0.4.1-cp314-cp314-macosx_10_13_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/99/e9/70890179c55950ad8c7135abf43d95f0ca0326815345401d84aa763f95d9/py_cluster_api-0.2.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/da/67/a80ed986c53a14ac1cdbac2ccbcb01fbf2c53947b76f5f977acfab0da2c9/py_cluster_api-0.2.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/48/a2/87a0b61a3078be07ab04ec574ef6c683c764590ed0d2a50d00cbb23aeae7/pydantic_settings_yaml-0.2.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/bd/24/12818598c362d7f300f18e74db45963dbcb85150324092410c8b49405e42/pyproject_hooks-1.2.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fc/51/727abb13f44c1fcf6d145979e1535a35794db0f6e450a0cb46aa24732fe2/s3transfer-0.16.0-py3-none-any.whl @@ -1495,7 +1495,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/a1/93/72b1736d68f03fda5fdf0f2180fb6caaae3894f1b854d006ac61ecc727ee/frozenlist-1.8.0-cp314-cp314-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/42/e2/64bb41266427af6642b6b128e8774ed84c11b80a90702c13ac0a86bb10cc/multidict-6.7.0-cp314-cp314-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/b2/fa/89a8ef0468d5833a23fff277b143d0573897cf75bd56670a6d28126c7d68/propcache-0.4.1-cp314-cp314-macosx_11_0_arm64.whl - - pypi: https://files.pythonhosted.org/packages/99/e9/70890179c55950ad8c7135abf43d95f0ca0326815345401d84aa763f95d9/py_cluster_api-0.2.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/da/67/a80ed986c53a14ac1cdbac2ccbcb01fbf2c53947b76f5f977acfab0da2c9/py_cluster_api-0.2.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/48/a2/87a0b61a3078be07ab04ec574ef6c683c764590ed0d2a50d00cbb23aeae7/pydantic_settings_yaml-0.2.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/bd/24/12818598c362d7f300f18e74db45963dbcb85150324092410c8b49405e42/pyproject_hooks-1.2.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fc/51/727abb13f44c1fcf6d145979e1535a35794db0f6e450a0cb46aa24732fe2/s3transfer-0.16.0-py3-none-any.whl @@ -1753,7 +1753,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/a7/b2/fabede9fafd976b991e9f1b9c8c873ed86f202889b864756f240ce6dd855/frozenlist-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl - pypi: https://files.pythonhosted.org/packages/b4/63/2ab26e4209773223159b83aa32721b4021ffb08102f8ac7d689c943fded1/multidict-6.7.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/b2/f2/889ad4b2408f72fe1a4f6a19491177b30ea7bf1a0fd5f17050ca08cfc882/propcache-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/99/e9/70890179c55950ad8c7135abf43d95f0ca0326815345401d84aa763f95d9/py_cluster_api-0.2.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/da/67/a80ed986c53a14ac1cdbac2ccbcb01fbf2c53947b76f5f977acfab0da2c9/py_cluster_api-0.2.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/48/a2/87a0b61a3078be07ab04ec574ef6c683c764590ed0d2a50d00cbb23aeae7/pydantic_settings_yaml-0.2.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c8/c7/c160021cbecd956cc1a6f79e5fe155f7868b2e5b848f1320dad0b3e3122f/pytest_html-4.1.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/3e/43/7e7b2ec865caa92f67b8f0e9231a798d102724ca4c0e1f414316be1c1ef2/pytest_metadata-3.1.1-py3-none-any.whl @@ -2002,7 +2002,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/3a/3b/d9b1e0b0eed36e70477ffb8360c49c85c8ca8ef9700a4e6711f39a6e8b45/frozenlist-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl - pypi: https://files.pythonhosted.org/packages/15/ee/f524093232007cd7a75c1d132df70f235cfd590a7c9eaccd7ff422ef4ae8/multidict-6.7.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl - pypi: https://files.pythonhosted.org/packages/86/bd/47816020d337f4a746edc42fe8d53669965138f39ee117414c7d7a340cfe/propcache-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl - - pypi: https://files.pythonhosted.org/packages/99/e9/70890179c55950ad8c7135abf43d95f0ca0326815345401d84aa763f95d9/py_cluster_api-0.2.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/da/67/a80ed986c53a14ac1cdbac2ccbcb01fbf2c53947b76f5f977acfab0da2c9/py_cluster_api-0.2.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/48/a2/87a0b61a3078be07ab04ec574ef6c683c764590ed0d2a50d00cbb23aeae7/pydantic_settings_yaml-0.2.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c8/c7/c160021cbecd956cc1a6f79e5fe155f7868b2e5b848f1320dad0b3e3122f/pytest_html-4.1.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/3e/43/7e7b2ec865caa92f67b8f0e9231a798d102724ca4c0e1f414316be1c1ef2/pytest_metadata-3.1.1-py3-none-any.whl @@ -2250,7 +2250,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/8e/e8/a1185e236ec66c20afd72399522f142c3724c785789255202d27ae992818/frozenlist-1.8.0-cp314-cp314-macosx_10_13_x86_64.whl - pypi: https://files.pythonhosted.org/packages/14/2c/f069cab5b51d175a1a2cb4ccdf7a2c2dabd58aa5bd933fa036a8d15e2404/multidict-6.7.0-cp314-cp314-macosx_10_13_x86_64.whl - pypi: https://files.pythonhosted.org/packages/65/9b/03b04e7d82a5f54fb16113d839f5ea1ede58a61e90edf515f6577c66fa8f/propcache-0.4.1-cp314-cp314-macosx_10_13_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/99/e9/70890179c55950ad8c7135abf43d95f0ca0326815345401d84aa763f95d9/py_cluster_api-0.2.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/da/67/a80ed986c53a14ac1cdbac2ccbcb01fbf2c53947b76f5f977acfab0da2c9/py_cluster_api-0.2.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/48/a2/87a0b61a3078be07ab04ec574ef6c683c764590ed0d2a50d00cbb23aeae7/pydantic_settings_yaml-0.2.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c8/c7/c160021cbecd956cc1a6f79e5fe155f7868b2e5b848f1320dad0b3e3122f/pytest_html-4.1.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/3e/43/7e7b2ec865caa92f67b8f0e9231a798d102724ca4c0e1f414316be1c1ef2/pytest_metadata-3.1.1-py3-none-any.whl @@ -2498,7 +2498,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/a1/93/72b1736d68f03fda5fdf0f2180fb6caaae3894f1b854d006ac61ecc727ee/frozenlist-1.8.0-cp314-cp314-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/42/e2/64bb41266427af6642b6b128e8774ed84c11b80a90702c13ac0a86bb10cc/multidict-6.7.0-cp314-cp314-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/b2/fa/89a8ef0468d5833a23fff277b143d0573897cf75bd56670a6d28126c7d68/propcache-0.4.1-cp314-cp314-macosx_11_0_arm64.whl - - pypi: https://files.pythonhosted.org/packages/99/e9/70890179c55950ad8c7135abf43d95f0ca0326815345401d84aa763f95d9/py_cluster_api-0.2.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/da/67/a80ed986c53a14ac1cdbac2ccbcb01fbf2c53947b76f5f977acfab0da2c9/py_cluster_api-0.2.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/48/a2/87a0b61a3078be07ab04ec574ef6c683c764590ed0d2a50d00cbb23aeae7/pydantic_settings_yaml-0.2.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c8/c7/c160021cbecd956cc1a6f79e5fe155f7868b2e5b848f1320dad0b3e3122f/pytest_html-4.1.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/3e/43/7e7b2ec865caa92f67b8f0e9231a798d102724ca4c0e1f414316be1c1ef2/pytest_metadata-3.1.1-py3-none-any.whl @@ -4091,7 +4091,7 @@ packages: - pypi: ./ name: fileglancer version: 2.4.0 - sha256: 65505b926d4170d3b36ff01c2cf44d885af78cab13b91e9255bb8414559e8e26 + sha256: 1d81bc804b9599084f71832cc60a8cacc2ead000fb7dd3bc7d983affafb34593 requires_dist: - aiosqlite>=0.21.0 - alembic>=1.17.0 @@ -4107,7 +4107,7 @@ packages: - lxml>=5.3.1 - pandas>=2.3.3 - psycopg2-binary>=2.9.10,<3 - - py-cluster-api>=0.2.1 + - py-cluster-api>=0.2.2 - pydantic-settings>=2.11.0 - pydantic>=2.10.6 - python-jose>=3.5.0,<4 @@ -8102,10 +8102,10 @@ packages: - pkg:pypi/pure-eval?source=hash-mapping size: 16668 timestamp: 1733569518868 -- pypi: https://files.pythonhosted.org/packages/99/e9/70890179c55950ad8c7135abf43d95f0ca0326815345401d84aa763f95d9/py_cluster_api-0.2.1-py3-none-any.whl +- pypi: https://files.pythonhosted.org/packages/da/67/a80ed986c53a14ac1cdbac2ccbcb01fbf2c53947b76f5f977acfab0da2c9/py_cluster_api-0.2.2-py3-none-any.whl name: py-cluster-api - version: 0.2.1 - sha256: 2cc4bcbf57b8b8641815a204070f68c8c3f2bd581a76b96f7bbaeae1d7640c90 + version: 0.2.2 + sha256: 5e35803117f7eeb431a1e62c33b269986c3a2e69f06339fa24aef6dcf56853cd requires_dist: - pyyaml - build ; extra == 'release' diff --git a/pyproject.toml b/pyproject.toml index 9118e610..7cd048d3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,7 +44,7 @@ dependencies = [ "sqlalchemy >=2.0.44", "uvicorn >=0.38.0", "x2s3 >=1.1.0", - "py-cluster-api >=0.2.1" + "py-cluster-api >=0.2.2" ] [project.scripts] From f6027631e6c1ef80c6ffb0741a8e1bb0671210c7 Mon Sep 17 00:00:00 2001 From: Konrad Rokicki Date: Fri, 13 Feb 2026 20:45:10 -0500 Subject: [PATCH 27/59] register callbacks --- fileglancer/apps.py | 50 ++++++++++++++++++++++++++++++++--------- fileglancer/database.py | 5 +++++ 2 files changed, 45 insertions(+), 10 deletions(-) diff --git a/fileglancer/apps.py b/fileglancer/apps.py index 54d3eb55..4cf221f5 100644 --- a/fileglancer/apps.py +++ b/fileglancer/apps.py @@ -403,9 +403,11 @@ async def start_job_monitor(): try: reconnected = await executor.reconnect() if reconnected: + for record in reconnected: + record.on_exit(_on_job_exit) logger.info(f"Reconnected to {len(reconnected)} existing cluster jobs") - except NotImplementedError: - logger.debug("Job reconnection not supported by this executor") + except Exception as e: + logger.debug(f"Job reconnection skipped: {e}") _monitor = JobMonitor(executor, poll_interval=settings.cluster.poll_interval) await _monitor.start() @@ -469,16 +471,16 @@ async def _reconcile_jobs(settings): # Check if executor is tracking this job tracked = executor.jobs.get(db_job.cluster_job_id) if tracked is None: - # Job is no longer tracked by executor (e.g. after server restart) - now = datetime.now(UTC) - db.update_job_status(session, db_job.id, "FAILED", finished_at=now) - logger.warning( - f"Job {db_job.id} (cluster: {db_job.cluster_job_id}) " - f"lost from executor (was {db_job.status}), marked FAILED" - ) + # Job was purged from executor tracking. Terminal status + # updates are handled by the on_exit callback, so this + # means either the callback already fired or the job was + # lost (e.g. server restart without reconnection). + # Skip it here — the zombie timeout above will catch + # truly stuck jobs that never got a cluster_job_id. continue - # Map cluster status to our status strings + # Sync non-terminal status changes (e.g. PENDING -> RUNNING). + # Terminal transitions are handled by the on_exit callback. new_status = _map_status(tracked.status) if new_status != db_job.status: db.update_job_status( @@ -503,6 +505,31 @@ def _map_status(status: JobStatus) -> str: return mapping.get(status, "FAILED") +def _on_job_exit(record): + """Callback fired by JobMonitor when a job reaches terminal state. + + This runs inside the monitor's poll loop, before completed jobs are + purged, so we are guaranteed to capture the final status. + """ + settings = get_settings() + new_status = _map_status(record.status) + + with db.get_db_session(settings.db_url) as session: + db_job = db.get_job_by_cluster_id(session, record.job_id) + if db_job is None: + logger.warning(f"No DB job found for cluster job {record.job_id}") + return + if db_job.status == new_status: + return + db.update_job_status( + session, db_job.id, new_status, + exit_code=record.exit_code, + started_at=record.start_time, + finished_at=record.finish_time, + ) + logger.info(f"Job {db_job.id} completed: {db_job.status} -> {new_status}") + + # --- Job Submission --- def _sanitize_for_path(s: str) -> str: @@ -619,6 +646,9 @@ async def submit_job( resources=resource_spec, ) + # Register callback to update DB when job reaches terminal state + cluster_job.on_exit(_on_job_exit) + # Update DB with cluster job ID and return fresh object with db.get_db_session(settings.db_url) as session: db.update_job_status( diff --git a/fileglancer/database.py b/fileglancer/database.py index 558ec13b..d067f90d 100644 --- a/fileglancer/database.py +++ b/fileglancer/database.py @@ -863,6 +863,11 @@ def get_active_jobs(session: Session) -> List[JobDB]: ).all() +def get_job_by_cluster_id(session: Session, cluster_job_id: str) -> Optional[JobDB]: + """Get a single job by its cluster job ID""" + return session.query(JobDB).filter_by(cluster_job_id=cluster_job_id).first() + + def update_job_status(session: Session, job_id: int, status: str, exit_code: Optional[int] = None, cluster_job_id: Optional[str] = None, From e4f4db59176c94008240242567951f0e06d0febb Mon Sep 17 00:00:00 2001 From: Konrad Rokicki Date: Fri, 13 Feb 2026 21:01:45 -0500 Subject: [PATCH 28/59] updated cluster-api=0.2.3 --- pixi.lock | 34 +++++++++++++++++----------------- pyproject.toml | 2 +- 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/pixi.lock b/pixi.lock index daa7dca6..bcef864e 100644 --- a/pixi.lock +++ b/pixi.lock @@ -172,7 +172,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/a7/b2/fabede9fafd976b991e9f1b9c8c873ed86f202889b864756f240ce6dd855/frozenlist-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl - pypi: https://files.pythonhosted.org/packages/b4/63/2ab26e4209773223159b83aa32721b4021ffb08102f8ac7d689c943fded1/multidict-6.7.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/b2/f2/889ad4b2408f72fe1a4f6a19491177b30ea7bf1a0fd5f17050ca08cfc882/propcache-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/da/67/a80ed986c53a14ac1cdbac2ccbcb01fbf2c53947b76f5f977acfab0da2c9/py_cluster_api-0.2.2-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/e1/b5/226661a1ca70b1b06500f4c4cc004a90d6bc421fb8bb2446d012511f2578/py_cluster_api-0.2.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/48/a2/87a0b61a3078be07ab04ec574ef6c683c764590ed0d2a50d00cbb23aeae7/pydantic_settings_yaml-0.2.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fc/51/727abb13f44c1fcf6d145979e1535a35794db0f6e450a0cb46aa24732fe2/s3transfer-0.16.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/7f/21/f52cd11559059e686e7ecfe40c4746ee957f96779ce22a8f76d5b4d62516/x2s3-1.1.0-py3-none-any.whl @@ -340,7 +340,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/3a/3b/d9b1e0b0eed36e70477ffb8360c49c85c8ca8ef9700a4e6711f39a6e8b45/frozenlist-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl - pypi: https://files.pythonhosted.org/packages/15/ee/f524093232007cd7a75c1d132df70f235cfd590a7c9eaccd7ff422ef4ae8/multidict-6.7.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl - pypi: https://files.pythonhosted.org/packages/86/bd/47816020d337f4a746edc42fe8d53669965138f39ee117414c7d7a340cfe/propcache-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl - - pypi: https://files.pythonhosted.org/packages/da/67/a80ed986c53a14ac1cdbac2ccbcb01fbf2c53947b76f5f977acfab0da2c9/py_cluster_api-0.2.2-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/e1/b5/226661a1ca70b1b06500f4c4cc004a90d6bc421fb8bb2446d012511f2578/py_cluster_api-0.2.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/48/a2/87a0b61a3078be07ab04ec574ef6c683c764590ed0d2a50d00cbb23aeae7/pydantic_settings_yaml-0.2.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fc/51/727abb13f44c1fcf6d145979e1535a35794db0f6e450a0cb46aa24732fe2/s3transfer-0.16.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/7f/21/f52cd11559059e686e7ecfe40c4746ee957f96779ce22a8f76d5b4d62516/x2s3-1.1.0-py3-none-any.whl @@ -503,7 +503,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/8e/e8/a1185e236ec66c20afd72399522f142c3724c785789255202d27ae992818/frozenlist-1.8.0-cp314-cp314-macosx_10_13_x86_64.whl - pypi: https://files.pythonhosted.org/packages/14/2c/f069cab5b51d175a1a2cb4ccdf7a2c2dabd58aa5bd933fa036a8d15e2404/multidict-6.7.0-cp314-cp314-macosx_10_13_x86_64.whl - pypi: https://files.pythonhosted.org/packages/65/9b/03b04e7d82a5f54fb16113d839f5ea1ede58a61e90edf515f6577c66fa8f/propcache-0.4.1-cp314-cp314-macosx_10_13_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/da/67/a80ed986c53a14ac1cdbac2ccbcb01fbf2c53947b76f5f977acfab0da2c9/py_cluster_api-0.2.2-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/e1/b5/226661a1ca70b1b06500f4c4cc004a90d6bc421fb8bb2446d012511f2578/py_cluster_api-0.2.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/48/a2/87a0b61a3078be07ab04ec574ef6c683c764590ed0d2a50d00cbb23aeae7/pydantic_settings_yaml-0.2.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fc/51/727abb13f44c1fcf6d145979e1535a35794db0f6e450a0cb46aa24732fe2/s3transfer-0.16.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/7f/21/f52cd11559059e686e7ecfe40c4746ee957f96779ce22a8f76d5b4d62516/x2s3-1.1.0-py3-none-any.whl @@ -666,7 +666,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/a1/93/72b1736d68f03fda5fdf0f2180fb6caaae3894f1b854d006ac61ecc727ee/frozenlist-1.8.0-cp314-cp314-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/42/e2/64bb41266427af6642b6b128e8774ed84c11b80a90702c13ac0a86bb10cc/multidict-6.7.0-cp314-cp314-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/b2/fa/89a8ef0468d5833a23fff277b143d0573897cf75bd56670a6d28126c7d68/propcache-0.4.1-cp314-cp314-macosx_11_0_arm64.whl - - pypi: https://files.pythonhosted.org/packages/da/67/a80ed986c53a14ac1cdbac2ccbcb01fbf2c53947b76f5f977acfab0da2c9/py_cluster_api-0.2.2-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/e1/b5/226661a1ca70b1b06500f4c4cc004a90d6bc421fb8bb2446d012511f2578/py_cluster_api-0.2.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/48/a2/87a0b61a3078be07ab04ec574ef6c683c764590ed0d2a50d00cbb23aeae7/pydantic_settings_yaml-0.2.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fc/51/727abb13f44c1fcf6d145979e1535a35794db0f6e450a0cb46aa24732fe2/s3transfer-0.16.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/7f/21/f52cd11559059e686e7ecfe40c4746ee957f96779ce22a8f76d5b4d62516/x2s3-1.1.0-py3-none-any.whl @@ -885,7 +885,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/a7/b2/fabede9fafd976b991e9f1b9c8c873ed86f202889b864756f240ce6dd855/frozenlist-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl - pypi: https://files.pythonhosted.org/packages/b4/63/2ab26e4209773223159b83aa32721b4021ffb08102f8ac7d689c943fded1/multidict-6.7.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/b2/f2/889ad4b2408f72fe1a4f6a19491177b30ea7bf1a0fd5f17050ca08cfc882/propcache-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/da/67/a80ed986c53a14ac1cdbac2ccbcb01fbf2c53947b76f5f977acfab0da2c9/py_cluster_api-0.2.2-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/e1/b5/226661a1ca70b1b06500f4c4cc004a90d6bc421fb8bb2446d012511f2578/py_cluster_api-0.2.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/48/a2/87a0b61a3078be07ab04ec574ef6c683c764590ed0d2a50d00cbb23aeae7/pydantic_settings_yaml-0.2.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/bd/24/12818598c362d7f300f18e74db45963dbcb85150324092410c8b49405e42/pyproject_hooks-1.2.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fc/51/727abb13f44c1fcf6d145979e1535a35794db0f6e450a0cb46aa24732fe2/s3transfer-0.16.0-py3-none-any.whl @@ -1096,7 +1096,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/3a/3b/d9b1e0b0eed36e70477ffb8360c49c85c8ca8ef9700a4e6711f39a6e8b45/frozenlist-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl - pypi: https://files.pythonhosted.org/packages/15/ee/f524093232007cd7a75c1d132df70f235cfd590a7c9eaccd7ff422ef4ae8/multidict-6.7.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl - pypi: https://files.pythonhosted.org/packages/86/bd/47816020d337f4a746edc42fe8d53669965138f39ee117414c7d7a340cfe/propcache-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl - - pypi: https://files.pythonhosted.org/packages/da/67/a80ed986c53a14ac1cdbac2ccbcb01fbf2c53947b76f5f977acfab0da2c9/py_cluster_api-0.2.2-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/e1/b5/226661a1ca70b1b06500f4c4cc004a90d6bc421fb8bb2446d012511f2578/py_cluster_api-0.2.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/48/a2/87a0b61a3078be07ab04ec574ef6c683c764590ed0d2a50d00cbb23aeae7/pydantic_settings_yaml-0.2.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fc/51/727abb13f44c1fcf6d145979e1535a35794db0f6e450a0cb46aa24732fe2/s3transfer-0.16.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/7f/21/f52cd11559059e686e7ecfe40c4746ee957f96779ce22a8f76d5b4d62516/x2s3-1.1.0-py3-none-any.whl @@ -1295,7 +1295,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/8e/e8/a1185e236ec66c20afd72399522f142c3724c785789255202d27ae992818/frozenlist-1.8.0-cp314-cp314-macosx_10_13_x86_64.whl - pypi: https://files.pythonhosted.org/packages/14/2c/f069cab5b51d175a1a2cb4ccdf7a2c2dabd58aa5bd933fa036a8d15e2404/multidict-6.7.0-cp314-cp314-macosx_10_13_x86_64.whl - pypi: https://files.pythonhosted.org/packages/65/9b/03b04e7d82a5f54fb16113d839f5ea1ede58a61e90edf515f6577c66fa8f/propcache-0.4.1-cp314-cp314-macosx_10_13_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/da/67/a80ed986c53a14ac1cdbac2ccbcb01fbf2c53947b76f5f977acfab0da2c9/py_cluster_api-0.2.2-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/e1/b5/226661a1ca70b1b06500f4c4cc004a90d6bc421fb8bb2446d012511f2578/py_cluster_api-0.2.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/48/a2/87a0b61a3078be07ab04ec574ef6c683c764590ed0d2a50d00cbb23aeae7/pydantic_settings_yaml-0.2.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/bd/24/12818598c362d7f300f18e74db45963dbcb85150324092410c8b49405e42/pyproject_hooks-1.2.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fc/51/727abb13f44c1fcf6d145979e1535a35794db0f6e450a0cb46aa24732fe2/s3transfer-0.16.0-py3-none-any.whl @@ -1495,7 +1495,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/a1/93/72b1736d68f03fda5fdf0f2180fb6caaae3894f1b854d006ac61ecc727ee/frozenlist-1.8.0-cp314-cp314-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/42/e2/64bb41266427af6642b6b128e8774ed84c11b80a90702c13ac0a86bb10cc/multidict-6.7.0-cp314-cp314-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/b2/fa/89a8ef0468d5833a23fff277b143d0573897cf75bd56670a6d28126c7d68/propcache-0.4.1-cp314-cp314-macosx_11_0_arm64.whl - - pypi: https://files.pythonhosted.org/packages/da/67/a80ed986c53a14ac1cdbac2ccbcb01fbf2c53947b76f5f977acfab0da2c9/py_cluster_api-0.2.2-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/e1/b5/226661a1ca70b1b06500f4c4cc004a90d6bc421fb8bb2446d012511f2578/py_cluster_api-0.2.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/48/a2/87a0b61a3078be07ab04ec574ef6c683c764590ed0d2a50d00cbb23aeae7/pydantic_settings_yaml-0.2.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/bd/24/12818598c362d7f300f18e74db45963dbcb85150324092410c8b49405e42/pyproject_hooks-1.2.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fc/51/727abb13f44c1fcf6d145979e1535a35794db0f6e450a0cb46aa24732fe2/s3transfer-0.16.0-py3-none-any.whl @@ -1753,7 +1753,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/a7/b2/fabede9fafd976b991e9f1b9c8c873ed86f202889b864756f240ce6dd855/frozenlist-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl - pypi: https://files.pythonhosted.org/packages/b4/63/2ab26e4209773223159b83aa32721b4021ffb08102f8ac7d689c943fded1/multidict-6.7.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/b2/f2/889ad4b2408f72fe1a4f6a19491177b30ea7bf1a0fd5f17050ca08cfc882/propcache-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/da/67/a80ed986c53a14ac1cdbac2ccbcb01fbf2c53947b76f5f977acfab0da2c9/py_cluster_api-0.2.2-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/e1/b5/226661a1ca70b1b06500f4c4cc004a90d6bc421fb8bb2446d012511f2578/py_cluster_api-0.2.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/48/a2/87a0b61a3078be07ab04ec574ef6c683c764590ed0d2a50d00cbb23aeae7/pydantic_settings_yaml-0.2.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c8/c7/c160021cbecd956cc1a6f79e5fe155f7868b2e5b848f1320dad0b3e3122f/pytest_html-4.1.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/3e/43/7e7b2ec865caa92f67b8f0e9231a798d102724ca4c0e1f414316be1c1ef2/pytest_metadata-3.1.1-py3-none-any.whl @@ -2002,7 +2002,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/3a/3b/d9b1e0b0eed36e70477ffb8360c49c85c8ca8ef9700a4e6711f39a6e8b45/frozenlist-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl - pypi: https://files.pythonhosted.org/packages/15/ee/f524093232007cd7a75c1d132df70f235cfd590a7c9eaccd7ff422ef4ae8/multidict-6.7.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl - pypi: https://files.pythonhosted.org/packages/86/bd/47816020d337f4a746edc42fe8d53669965138f39ee117414c7d7a340cfe/propcache-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl - - pypi: https://files.pythonhosted.org/packages/da/67/a80ed986c53a14ac1cdbac2ccbcb01fbf2c53947b76f5f977acfab0da2c9/py_cluster_api-0.2.2-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/e1/b5/226661a1ca70b1b06500f4c4cc004a90d6bc421fb8bb2446d012511f2578/py_cluster_api-0.2.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/48/a2/87a0b61a3078be07ab04ec574ef6c683c764590ed0d2a50d00cbb23aeae7/pydantic_settings_yaml-0.2.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c8/c7/c160021cbecd956cc1a6f79e5fe155f7868b2e5b848f1320dad0b3e3122f/pytest_html-4.1.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/3e/43/7e7b2ec865caa92f67b8f0e9231a798d102724ca4c0e1f414316be1c1ef2/pytest_metadata-3.1.1-py3-none-any.whl @@ -2250,7 +2250,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/8e/e8/a1185e236ec66c20afd72399522f142c3724c785789255202d27ae992818/frozenlist-1.8.0-cp314-cp314-macosx_10_13_x86_64.whl - pypi: https://files.pythonhosted.org/packages/14/2c/f069cab5b51d175a1a2cb4ccdf7a2c2dabd58aa5bd933fa036a8d15e2404/multidict-6.7.0-cp314-cp314-macosx_10_13_x86_64.whl - pypi: https://files.pythonhosted.org/packages/65/9b/03b04e7d82a5f54fb16113d839f5ea1ede58a61e90edf515f6577c66fa8f/propcache-0.4.1-cp314-cp314-macosx_10_13_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/da/67/a80ed986c53a14ac1cdbac2ccbcb01fbf2c53947b76f5f977acfab0da2c9/py_cluster_api-0.2.2-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/e1/b5/226661a1ca70b1b06500f4c4cc004a90d6bc421fb8bb2446d012511f2578/py_cluster_api-0.2.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/48/a2/87a0b61a3078be07ab04ec574ef6c683c764590ed0d2a50d00cbb23aeae7/pydantic_settings_yaml-0.2.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c8/c7/c160021cbecd956cc1a6f79e5fe155f7868b2e5b848f1320dad0b3e3122f/pytest_html-4.1.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/3e/43/7e7b2ec865caa92f67b8f0e9231a798d102724ca4c0e1f414316be1c1ef2/pytest_metadata-3.1.1-py3-none-any.whl @@ -2498,7 +2498,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/a1/93/72b1736d68f03fda5fdf0f2180fb6caaae3894f1b854d006ac61ecc727ee/frozenlist-1.8.0-cp314-cp314-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/42/e2/64bb41266427af6642b6b128e8774ed84c11b80a90702c13ac0a86bb10cc/multidict-6.7.0-cp314-cp314-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/b2/fa/89a8ef0468d5833a23fff277b143d0573897cf75bd56670a6d28126c7d68/propcache-0.4.1-cp314-cp314-macosx_11_0_arm64.whl - - pypi: https://files.pythonhosted.org/packages/da/67/a80ed986c53a14ac1cdbac2ccbcb01fbf2c53947b76f5f977acfab0da2c9/py_cluster_api-0.2.2-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/e1/b5/226661a1ca70b1b06500f4c4cc004a90d6bc421fb8bb2446d012511f2578/py_cluster_api-0.2.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/48/a2/87a0b61a3078be07ab04ec574ef6c683c764590ed0d2a50d00cbb23aeae7/pydantic_settings_yaml-0.2.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c8/c7/c160021cbecd956cc1a6f79e5fe155f7868b2e5b848f1320dad0b3e3122f/pytest_html-4.1.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/3e/43/7e7b2ec865caa92f67b8f0e9231a798d102724ca4c0e1f414316be1c1ef2/pytest_metadata-3.1.1-py3-none-any.whl @@ -4091,7 +4091,7 @@ packages: - pypi: ./ name: fileglancer version: 2.4.0 - sha256: 1d81bc804b9599084f71832cc60a8cacc2ead000fb7dd3bc7d983affafb34593 + sha256: 99ff5e838f81c810aac6e787ca8cb5e9d78035487c51b7518a2e7c19d7ff6b3f requires_dist: - aiosqlite>=0.21.0 - alembic>=1.17.0 @@ -4107,7 +4107,7 @@ packages: - lxml>=5.3.1 - pandas>=2.3.3 - psycopg2-binary>=2.9.10,<3 - - py-cluster-api>=0.2.2 + - py-cluster-api>=0.2.3 - pydantic-settings>=2.11.0 - pydantic>=2.10.6 - python-jose>=3.5.0,<4 @@ -8102,10 +8102,10 @@ packages: - pkg:pypi/pure-eval?source=hash-mapping size: 16668 timestamp: 1733569518868 -- pypi: https://files.pythonhosted.org/packages/da/67/a80ed986c53a14ac1cdbac2ccbcb01fbf2c53947b76f5f977acfab0da2c9/py_cluster_api-0.2.2-py3-none-any.whl +- pypi: https://files.pythonhosted.org/packages/e1/b5/226661a1ca70b1b06500f4c4cc004a90d6bc421fb8bb2446d012511f2578/py_cluster_api-0.2.3-py3-none-any.whl name: py-cluster-api - version: 0.2.2 - sha256: 5e35803117f7eeb431a1e62c33b269986c3a2e69f06339fa24aef6dcf56853cd + version: 0.2.3 + sha256: 44c129325e8b440ff3c2735e044dd88af0191839ece3c587a23d0d53bce9021f requires_dist: - pyyaml - build ; extra == 'release' diff --git a/pyproject.toml b/pyproject.toml index 7cd048d3..b94fa9d9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,7 +44,7 @@ dependencies = [ "sqlalchemy >=2.0.44", "uvicorn >=0.38.0", "x2s3 >=1.1.0", - "py-cluster-api >=0.2.2" + "py-cluster-api >=0.2.3" ] [project.scripts] From 5dd779831191a5a5b3ec7f354401addb637a9a71 Mon Sep 17 00:00:00 2001 From: Konrad Rokicki Date: Sat, 14 Feb 2026 09:40:35 -0500 Subject: [PATCH 29/59] consistent width --- frontend/src/layouts/OtherPagesLayout.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/layouts/OtherPagesLayout.tsx b/frontend/src/layouts/OtherPagesLayout.tsx index 1b7c6ed8..4b0c150e 100644 --- a/frontend/src/layouts/OtherPagesLayout.tsx +++ b/frontend/src/layouts/OtherPagesLayout.tsx @@ -3,7 +3,7 @@ import { Outlet } from 'react-router'; export const OtherPagesLayout = () => { return (
-
+
From 36e28adf3052d3735a973289a2b3a0bebf69f332 Mon Sep 17 00:00:00 2001 From: Konrad Rokicki Date: Sat, 14 Feb 2026 09:48:39 -0500 Subject: [PATCH 30/59] added download buttons for logs --- frontend/src/components/JobDetail.tsx | 46 ++++++++++++++++++++++++++- 1 file changed, 45 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/JobDetail.tsx b/frontend/src/components/JobDetail.tsx index b78e49a4..6ca2c1c2 100644 --- a/frontend/src/components/JobDetail.tsx +++ b/frontend/src/components/JobDetail.tsx @@ -2,7 +2,11 @@ import { useEffect, useState } from 'react'; import { useNavigate, useParams } from 'react-router'; import { Button, Tabs, Typography } from '@material-tailwind/react'; -import { HiOutlineArrowLeft, HiOutlineRefresh } from 'react-icons/hi'; +import { + HiOutlineArrowLeft, + HiOutlineDownload, + HiOutlineRefresh +} from 'react-icons/hi'; import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; import { materialDark, @@ -99,6 +103,16 @@ export default function JobDetail() { const job = jobQuery.data; + const handleDownload = (content: string, filename: string) => { + const blob = new Blob([content], { type: 'text/plain' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + a.click(); + URL.revokeObjectURL(url); + }; + const handleRelaunch = () => { if (!job) { return; @@ -226,6 +240,21 @@ export default function JobDetail() { + {stdoutQuery.data != null ? ( +
+ +
+ ) : null} + {stderrQuery.data != null ? ( +
+ +
+ ) : null} Date: Sat, 14 Feb 2026 11:40:09 -0500 Subject: [PATCH 31/59] added file links --- fileglancer/apps.py | 42 +++++++++++++ fileglancer/model.py | 9 +++ fileglancer/server.py | 8 ++- frontend/src/components/JobDetail.tsx | 88 ++++++++++++++++++++++++--- frontend/src/shared.types.ts | 7 +++ 5 files changed, 143 insertions(+), 11 deletions(-) diff --git a/fileglancer/apps.py b/fileglancer/apps.py index 4cf221f5..cc3b0ac9 100644 --- a/fileglancer/apps.py +++ b/fileglancer/apps.py @@ -728,6 +728,48 @@ def _resolve_work_dir(db_job: db.JobDB) -> Path: return _build_work_dir(db_job.id, db_job.app_name, db_job.entry_point_id) +def _resolve_browse_path(abs_path: str) -> tuple[str | None, str | None]: + """Resolve an absolute path to an FSP name and subpath for browse links.""" + settings = get_settings() + with db.get_db_session(settings.db_url) as session: + result = db.find_fsp_from_absolute_path(session, abs_path) + if result: + return result[0].name, result[1] + return None, None + + +def _make_file_info(file_path: str, exists: bool) -> dict: + """Create a file info dict with browse link resolution.""" + fsp_name, subpath = _resolve_browse_path(file_path) if exists else (None, None) + return { + "path": file_path, + "exists": exists, + "fsp_name": fsp_name, + "subpath": subpath, + } + + +def get_job_file_paths(db_job: db.JobDB) -> dict[str, dict]: + """Return file path info for a job's files (script, stdout, stderr). + + Returns a dict keyed by file type with path and existence info. + """ + work_dir = _resolve_work_dir(db_job) + + # Find script file + scripts = sorted(work_dir.glob("*.sh")) if work_dir.exists() else [] + script_path = str(scripts[0]) if scripts else str(work_dir / "script.sh") + + stdout_path = work_dir / "stdout.log" + stderr_path = work_dir / "stderr.log" + + return { + "script": _make_file_info(script_path, len(scripts) > 0), + "stdout": _make_file_info(str(stdout_path), stdout_path.is_file()), + "stderr": _make_file_info(str(stderr_path), stderr_path.is_file()), + } + + async def get_job_file_content(job_id: int, username: str, file_type: str) -> Optional[str]: """Read the content of a job file (script, stdout, or stderr). diff --git a/fileglancer/model.py b/fileglancer/model.py index d223a52f..7fec7f3b 100644 --- a/fileglancer/model.py +++ b/fileglancer/model.py @@ -395,6 +395,14 @@ class AppRemoveRequest(BaseModel): url: str = Field(description="URL of the app to remove") +class JobFileInfo(BaseModel): + """Information about a job file""" + path: str = Field(description="Absolute path to the file") + exists: bool = Field(description="Whether the file exists on disk") + fsp_name: Optional[str] = Field(description="File share path name for browse link", default=None) + subpath: Optional[str] = Field(description="Subpath within the FSP for browse link", default=None) + + class Job(BaseModel): """A job record""" id: int = Field(description="Unique job identifier") @@ -411,6 +419,7 @@ class Job(BaseModel): created_at: datetime = Field(description="When the job was created") started_at: Optional[datetime] = Field(description="When the job started running", default=None) finished_at: Optional[datetime] = Field(description="When the job finished", default=None) + files: Optional[Dict[str, JobFileInfo]] = Field(description="Job file paths and existence", default=None) class JobSubmitRequest(BaseModel): diff --git a/fileglancer/server.py b/fileglancer/server.py index 65501f98..61fa6d96 100644 --- a/fileglancer/server.py +++ b/fileglancer/server.py @@ -1641,7 +1641,7 @@ async def get_job(job_id: int, db_job = db.get_job(session, job_id, username) if db_job is None: raise HTTPException(status_code=404, detail="Job not found") - return _convert_job(db_job) + return _convert_job(db_job, include_files=True) @app.post("/api/jobs/{job_id}/cancel", description="Cancel a running job") @@ -1678,8 +1678,11 @@ async def get_job_file(job_id: int, except ValueError as e: raise HTTPException(status_code=404, detail=str(e)) - def _convert_job(db_job: db.JobDB) -> Job: + def _convert_job(db_job: db.JobDB, include_files: bool = False) -> Job: """Convert a database JobDB to a Pydantic Job model.""" + files = None + if include_files: + files = apps_module.get_job_file_paths(db_job) return Job( id=db_job.id, app_url=db_job.app_url, @@ -1695,6 +1698,7 @@ def _convert_job(db_job: db.JobDB) -> Job: created_at=db_job.created_at, started_at=db_job.started_at, finished_at=db_job.finished_at, + files=files, ) @app.post("/api/auth/simple-login", include_in_schema=not settings.enable_okta_auth) diff --git a/frontend/src/components/JobDetail.tsx b/frontend/src/components/JobDetail.tsx index 6ca2c1c2..2db02ee0 100644 --- a/frontend/src/components/JobDetail.tsx +++ b/frontend/src/components/JobDetail.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from 'react'; -import { useNavigate, useParams } from 'react-router'; +import { Link, useNavigate, useParams } from 'react-router'; import { Button, Tabs, Typography } from '@material-tailwind/react'; import { @@ -13,8 +13,15 @@ import { coy } from 'react-syntax-highlighter/dist/esm/styles/prism'; +import type { JobFileInfo, FileSharePath } from '@/shared.types'; import JobStatusBadge from '@/components/ui/AppsPage/JobStatusBadge'; import { formatDateString } from '@/utils'; +import { + getPreferredPathForDisplay, + makeBrowseLink +} from '@/utils/pathHandling'; +import { usePreferencesContext } from '@/contexts/PreferencesContext'; +import { useZoneAndFspMapContext } from '@/contexts/ZonesAndFspMapContext'; import { useJobQuery, useJobFileQuery } from '@/queries/jobsQueries'; function FilePreview({ @@ -76,12 +83,58 @@ function FilePreview({ ); } +function FilePathLink({ + fileInfo, + pathPreference, + zonesAndFspMap +}: { + readonly fileInfo: JobFileInfo | undefined; + readonly pathPreference: ['linux_path' | 'windows_path' | 'mac_path']; + readonly zonesAndFspMap: Record; +}) { + if (!fileInfo?.fsp_name || !fileInfo.subpath) { + return null; + } + + // Find the FSP in the zones map to get platform-specific paths + let fsp: FileSharePath | null = null; + for (const value of Object.values(zonesAndFspMap)) { + if ( + value && + typeof value === 'object' && + 'name' in value && + (value as FileSharePath).name === fileInfo.fsp_name + ) { + fsp = value as FileSharePath; + break; + } + } + + const displayPath = fsp + ? getPreferredPathForDisplay(pathPreference, fsp, fileInfo.subpath) + : fileInfo.path; + + const browseUrl = makeBrowseLink(fileInfo.fsp_name, fileInfo.subpath); + + return ( + + {displayPath} + + ); +} + export default function JobDetail() { const { jobId } = useParams<{ jobId: string }>(); const navigate = useNavigate(); const [isDarkMode, setIsDarkMode] = useState(false); const [activeTab, setActiveTab] = useState('parameters'); + const { pathPreference } = usePreferencesContext(); + const { zonesAndFspQuery } = useZoneAndFspMapContext(); + const id = jobId ? parseInt(jobId) : 0; const jobQuery = useJobQuery(id); const scriptQuery = useJobFileQuery(id, 'script'); @@ -230,6 +283,13 @@ export default function JobDetail() {
+
+ +
- {stdoutQuery.data != null ? ( -
+
+ + {stdoutQuery.data !== undefined && stdoutQuery.data !== null ? ( -
- ) : null} + ) : null} +
- {stderrQuery.data != null ? ( -
+
+ + {stderrQuery.data !== undefined && stderrQuery.data !== null ? ( -
- ) : null} + ) : null} +
; }; type JobSubmitRequest = { @@ -141,6 +147,7 @@ export type { FileSharePath, Failure, Job, + JobFileInfo, JobSubmitRequest, Profile, Result, From 5c1f2fbee60e09f8f51d996353f89092fb621eb2 Mon Sep 17 00:00:00 2001 From: Konrad Rokicki Date: Sat, 14 Feb 2026 11:44:07 -0500 Subject: [PATCH 32/59] fixed width/height issues --- frontend/src/components/JobDetail.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/JobDetail.tsx b/frontend/src/components/JobDetail.tsx index 2db02ee0..ddaa41b4 100644 --- a/frontend/src/components/JobDetail.tsx +++ b/frontend/src/components/JobDetail.tsx @@ -53,7 +53,7 @@ function FilePreview({ const themeCodeStyles = theme['code[class*="language-"]'] || {}; return ( -
+
) : job ? ( -
+
{/* Job Info Header */}
From 18e0edf2f5896048115123d91af842fb918aeb0a Mon Sep 17 00:00:00 2001 From: Konrad Rokicki Date: Sat, 14 Feb 2026 11:58:21 -0500 Subject: [PATCH 33/59] renamed fileglancer-app.yaml to runnables.yaml --- fileglancer/apps.py | 4 ++-- fileglancer/model.py | 4 ++-- frontend/src/components/AppLaunch.tsx | 10 +++++----- frontend/src/components/ui/AppsPage/AddAppDialog.tsx | 2 +- frontend/src/shared.types.ts | 2 +- 5 files changed, 11 insertions(+), 11 deletions(-) diff --git a/fileglancer/apps.py b/fileglancer/apps.py index cc3b0ac9..d292df43 100644 --- a/fileglancer/apps.py +++ b/fileglancer/apps.py @@ -24,7 +24,7 @@ from fileglancer.settings import get_settings -_MANIFEST_FILENAMES = ["fileglancer-app.json", "fileglancer-app.yaml", "fileglancer-app.yml"] +_MANIFEST_FILENAMES = ["runnables.json", "runnables.yaml", "runnables.yml"] _REPO_CACHE_BASE = Path(os.path.expanduser("~/.fileglancer/app-repos")) _repo_locks: dict[str, asyncio.Lock] = {} @@ -566,7 +566,7 @@ async def submit_job( # Find entry point entry_point = None - for ep in manifest.entryPoints: + for ep in manifest.runnables: if ep.id == entry_point_id: entry_point = ep break diff --git a/fileglancer/model.py b/fileglancer/model.py index 7fec7f3b..73f558a6 100644 --- a/fileglancer/model.py +++ b/fileglancer/model.py @@ -345,7 +345,7 @@ class AppEntryPoint(BaseModel): class AppManifest(BaseModel): - """Top-level app manifest (fileglancer-app.json)""" + """Top-level app manifest (runnables.yaml)""" name: str = Field(description="Display name of the app") description: Optional[str] = Field(description="Description of the app", default=None) version: Optional[str] = Field(description="Version of the app", default=None) @@ -357,7 +357,7 @@ class AppManifest(BaseModel): description="Required tools, e.g. ['pixi>=0.40', 'npm']", default=[], ) - entryPoints: List[AppEntryPoint] = Field(description="Available entry points for this app") + runnables: List[AppEntryPoint] = Field(description="Available entry points for this app") @field_validator("requirements") @classmethod diff --git a/frontend/src/components/AppLaunch.tsx b/frontend/src/components/AppLaunch.tsx index f77968da..25e3cbe0 100644 --- a/frontend/src/components/AppLaunch.tsx +++ b/frontend/src/components/AppLaunch.tsx @@ -51,7 +51,7 @@ export default function AppLaunch() { return; } if (launchState?.entryPointId) { - const ep = manifest.entryPoints.find( + const ep = manifest.runnables.find( e => e.id === launchState.entryPointId ); if (ep) { @@ -59,8 +59,8 @@ export default function AppLaunch() { return; } } - if (manifest.entryPoints.length === 1) { - setSelectedEntryPoint(manifest.entryPoints[0]); + if (manifest.runnables.length === 1) { + setSelectedEntryPoint(manifest.runnables[0]); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [manifest]); @@ -113,7 +113,7 @@ export default function AppLaunch() {
) : manifest && selectedEntryPoint ? ( <> - {manifest.entryPoints.length > 1 ? ( + {manifest.runnables.length > 1 ? ( + + setInfoOpen(false)} + onLaunch={() => { + setInfoOpen(false); + handleLaunch(); + }} + onRemove={() => { + setInfoOpen(false); + onRemove({ url: app.url, manifest_path: app.manifest_path }); + }} + onUpdate={() => + onUpdate({ url: app.url, manifest_path: app.manifest_path }) + } + open={infoOpen} + removing={removing} + updating={updating} + />
); } diff --git a/frontend/src/components/ui/AppsPage/AppInfoDialog.tsx b/frontend/src/components/ui/AppsPage/AppInfoDialog.tsx new file mode 100644 index 00000000..6e507daf --- /dev/null +++ b/frontend/src/components/ui/AppsPage/AppInfoDialog.tsx @@ -0,0 +1,117 @@ +import { Button, Typography } from '@material-tailwind/react'; +import { + HiExternalLink, + HiOutlinePlay, + HiOutlineRefresh, + HiOutlineTrash +} from 'react-icons/hi'; + +import FgDialog from '@/components/ui/Dialogs/FgDialog'; +import type { UserApp } from '@/shared.types'; + +interface AppInfoDialogProps { + readonly app: UserApp; + readonly open: boolean; + readonly onClose: () => void; + readonly onLaunch: () => void; + readonly onUpdate: () => void; + readonly onRemove: () => void; + readonly updating: boolean; + readonly removing: boolean; +} + +function AppInfoTable({ app }: { readonly app: UserApp }) { + const labelClass = + 'text-secondary font-medium pr-4 py-1.5 align-top whitespace-nowrap'; + const valueClass = 'text-foreground py-1.5'; + + return ( + + + {app.description ? ( + + + + + ) : null} + {app.manifest?.version ? ( + + + + + ) : null} + + + + + {app.manifest?.runnables && app.manifest.runnables.length > 0 ? ( + + + + + ) : null} + +
Description{app.description}
Version{app.manifest.version}
URL + + {app.url} + + +
Entry Points + {app.manifest.runnables.map(ep => ep.name).join(', ')} +
+ ); +} + +export default function AppInfoDialog({ + app, + open, + onClose, + onLaunch, + onUpdate, + onRemove, + updating, + removing +}: AppInfoDialogProps) { + return ( + + + {app.name} + + + + +
+ +
+ + +
+
+
+ ); +} diff --git a/frontend/src/queries/appsQueries.ts b/frontend/src/queries/appsQueries.ts index 40c5f203..9bf4ec11 100644 --- a/frontend/src/queries/appsQueries.ts +++ b/frontend/src/queries/appsQueries.ts @@ -100,6 +100,36 @@ export async function validatePaths( return (data as { errors: Record }).errors; } +export function useUpdateAppMutation(): UseMutationResult< + AppManifest, + Error, + { url: string; manifest_path: string } +> { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ + url, + manifest_path + }: { + url: string; + manifest_path: string; + }) => { + const response = await sendFetchRequest('/api/apps/update', 'POST', { + url, + manifest_path + }); + const data = await getResponseJsonOrError(response); + if (!response.ok) { + throwResponseNotOkError(response, data); + } + return data as AppManifest; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: appsQueryKeys.all }); + } + }); +} + export function useRemoveAppMutation(): UseMutationResult< unknown, Error, From aa617b784612c32d0df502e55424865a0ccf011f Mon Sep 17 00:00:00 2001 From: Konrad Rokicki Date: Sat, 14 Feb 2026 16:42:44 -0500 Subject: [PATCH 37/59] reorder dialog --- .../components/ui/AppsPage/AppInfoDialog.tsx | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/frontend/src/components/ui/AppsPage/AppInfoDialog.tsx b/frontend/src/components/ui/AppsPage/AppInfoDialog.tsx index 6e507daf..ce033760 100644 --- a/frontend/src/components/ui/AppsPage/AppInfoDialog.tsx +++ b/frontend/src/components/ui/AppsPage/AppInfoDialog.tsx @@ -28,18 +28,6 @@ function AppInfoTable({ app }: { readonly app: UserApp }) { return ( - {app.description ? ( - - - - - ) : null} - {app.manifest?.version ? ( - - - - - ) : null} + {app.manifest?.version ? ( + + + + + ) : null} + {app.description ? ( + + + + + ) : null} {app.manifest?.runnables && app.manifest.runnables.length > 0 ? ( From b6ba65eb5c5e1d1eb570dd5780ab9f11166047cf Mon Sep 17 00:00:00 2001 From: Konrad Rokicki Date: Sat, 14 Feb 2026 20:08:00 -0500 Subject: [PATCH 38/59] fix rendering --- frontend/src/components/ui/AppsPage/AppLaunchForm.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/ui/AppsPage/AppLaunchForm.tsx b/frontend/src/components/ui/AppsPage/AppLaunchForm.tsx index 57cc4686..6b094bd9 100644 --- a/frontend/src/components/ui/AppsPage/AppLaunchForm.tsx +++ b/frontend/src/components/ui/AppsPage/AppLaunchForm.tsx @@ -278,12 +278,12 @@ export default function AppLaunchForm({ {entryPoint.name} - + {manifest.name} {manifest.version ? ` v${manifest.version}` : ''} {entryPoint.description ? ( - + {entryPoint.description} ) : null} From aeab5282f62c99ed2ea129854ae852286bc929d8 Mon Sep 17 00:00:00 2001 From: Konrad Rokicki Date: Sat, 14 Feb 2026 20:21:13 -0500 Subject: [PATCH 39/59] reduce logging --- fileglancer/apps.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fileglancer/apps.py b/fileglancer/apps.py index 2c78deec..92dab5ad 100644 --- a/fileglancer/apps.py +++ b/fileglancer/apps.py @@ -89,7 +89,7 @@ async def _ensure_repo_cache(url: str, pull: bool = False) -> Path: async with lock: if repo_dir.exists(): - logger.info(f"Repo cache hit: {owner}/{repo} ({branch})") + logger.debug(f"Repo cache hit: {owner}/{repo} ({branch})") if pull: logger.info(f"Pulling latest for {owner}/{repo} ({branch})") await _run_git(["git", "-C", str(repo_dir), "pull", "origin", branch]) From 7cfd8e46ae2c00ce2524ff212b3efa64285cba42 Mon Sep 17 00:00:00 2001 From: Konrad Rokicki Date: Sun, 15 Feb 2026 07:02:15 -0500 Subject: [PATCH 40/59] moved jobs to separate page --- frontend/src/App.tsx | 27 ++++- frontend/src/components/AppJobs.tsx | 89 ++++++++++++++++ frontend/src/components/AppLaunch.tsx | 100 +++++++++++++----- frontend/src/components/Apps.tsx | 79 +------------- frontend/src/components/JobDetail.tsx | 23 ++-- .../src/components/ui/AppsPage/AppCard.tsx | 5 +- frontend/src/components/ui/Navbar/Navbar.tsx | 8 +- frontend/src/utils/appUrls.ts | 80 ++++++++++++++ frontend/src/utils/index.ts | 9 ++ 9 files changed, 300 insertions(+), 120 deletions(-) create mode 100644 frontend/src/components/AppJobs.tsx create mode 100644 frontend/src/utils/appUrls.ts diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 0b0bdf36..09626922 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -9,6 +9,7 @@ import { BrowsePageLayout } from './layouts/BrowseLayout'; import { OtherPagesLayout } from './layouts/OtherPagesLayout'; import Login from '@/components/Login'; import Apps from '@/components/Apps'; +import AppJobs from '@/components/AppJobs'; import AppLaunch from '@/components/AppLaunch'; import JobDetail from '@/components/JobDetail'; import Browse from '@/components/Browse'; @@ -123,13 +124,37 @@ const AppComponent = () => { } path="apps" /> + + + + } + path="apps/jobs" + /> + + + + } + path="apps/launch/:owner/:repo/:branch/:entryPointId" + /> + + + + } + path="apps/launch/:owner/:repo/:branch" + /> } - path="apps/launch/:encodedUrl" + path="apps/relaunch/:owner/:repo/:branch/:entryPointId" /> { + navigate(`/apps/jobs/${jobId}`); + }; + + const handleRelaunch = (job: Job) => { + const { owner, repo, branch } = parseGithubUrl(job.app_url); + const path = buildRelaunchPath( + owner, + repo, + branch, + job.entry_point_id, + job.manifest_path || undefined + ); + navigate(path, { state: { parameters: job.parameters } }); + }; + + const handleCancelJob = async (jobId: number) => { + try { + await cancelJobMutation.mutateAsync(jobId); + toast.success('Job cancelled'); + } catch (error) { + const message = + error instanceof Error ? error.message : 'Failed to cancel job'; + toast.error(message); + } + }; + + const handleDeleteJob = async (jobId: number) => { + try { + await deleteJobMutation.mutateAsync(jobId); + toast.success('Job deleted'); + } catch (error) { + const message = + error instanceof Error ? error.message : 'Failed to delete job'; + toast.error(message); + } + }; + + const jobsColumns = useMemo( + () => + createAppsJobsColumns({ + onViewDetail: handleViewJobDetail, + onRelaunch: handleRelaunch, + onCancel: handleCancelJob, + onDelete: handleDeleteJob + }), + // eslint-disable-next-line react-hooks/exhaustive-deps + [] + ); + + return ( +
+ + Jobs + + + +
+ ); +} diff --git a/frontend/src/components/AppLaunch.tsx b/frontend/src/components/AppLaunch.tsx index 25e3cbe0..882627f0 100644 --- a/frontend/src/components/AppLaunch.tsx +++ b/frontend/src/components/AppLaunch.tsx @@ -1,39 +1,58 @@ import { useEffect, useState } from 'react'; -import { useLocation, useNavigate, useParams } from 'react-router'; +import { + useLocation, + useNavigate, + useParams, + useSearchParams +} from 'react-router'; import { Button, Typography } from '@material-tailwind/react'; -import { HiOutlineArrowLeft, HiOutlinePlay } from 'react-icons/hi'; +import { + HiOutlineArrowLeft, + HiOutlineDownload, + HiOutlinePlay +} from 'react-icons/hi'; import toast from 'react-hot-toast'; import AppLaunchForm from '@/components/ui/AppsPage/AppLaunchForm'; -import { useManifestPreviewMutation } from '@/queries/appsQueries'; +import { buildGithubUrl } from '@/utils'; +import { + useAppsQuery, + useAddAppMutation, + useManifestPreviewMutation +} from '@/queries/appsQueries'; import { useSubmitJobMutation } from '@/queries/jobsQueries'; import type { AppEntryPoint, AppResourceDefaults } from '@/shared.types'; -type LaunchState = { - appUrl?: string; - manifestPath?: string; - entryPointId?: string; - parameters?: Record; -} | null; - export default function AppLaunch() { - const { encodedUrl } = useParams<{ encodedUrl: string }>(); + const { owner, repo, branch, entryPointId } = useParams<{ + owner: string; + repo: string; + branch: string; + entryPointId?: string; + }>(); + const [searchParams] = useSearchParams(); const navigate = useNavigate(); const location = useLocation(); const manifestMutation = useManifestPreviewMutation(); const submitJobMutation = useSubmitJobMutation(); + const appsQuery = useAppsQuery(); + const addAppMutation = useAddAppMutation(); const [selectedEntryPoint, setSelectedEntryPoint] = useState(null); - const launchState = (location.state as LaunchState) || null; - // Prefer app URL from state (relaunch), fall back to path param (normal launch) - const appUrl = launchState?.appUrl - ? launchState.appUrl - : encodedUrl - ? decodeURIComponent(encodedUrl) - : ''; - const manifestPath = launchState?.manifestPath ?? ''; + const manifestPath = searchParams.get('path') || ''; + const appUrl = buildGithubUrl(owner!, repo!, branch!); + const isRelaunch = location.pathname.startsWith('/apps/relaunch/'); + const relaunchParameters = isRelaunch + ? (location.state as { parameters?: Record } | null) + ?.parameters + : undefined; + + // Check if app is in user's library + const isInstalled = appsQuery.data?.some( + a => a.url === appUrl && a.manifest_path === manifestPath + ); useEffect(() => { if (appUrl) { @@ -45,15 +64,13 @@ export default function AppLaunch() { const manifest = manifestMutation.data; - // Auto-select entry point from relaunch state, or if there's only one + // Auto-select entry point from URL param, or if there's only one useEffect(() => { if (!manifest) { return; } - if (launchState?.entryPointId) { - const ep = manifest.runnables.find( - e => e.id === launchState.entryPointId - ); + if (entryPointId) { + const ep = manifest.runnables.find(e => e.id === entryPointId); if (ep) { setSelectedEntryPoint(ep); return; @@ -83,7 +100,7 @@ export default function AppLaunch() { pull_latest: pullLatest }); toast.success('Job submitted'); - navigate('/apps'); + navigate('/apps/jobs'); } catch (error) { const message = error instanceof Error ? error.message : 'Failed to submit job'; @@ -91,6 +108,18 @@ export default function AppLaunch() { } }; + const handleInstall = async () => { + try { + const apps = await addAppMutation.mutateAsync(appUrl); + const count = apps.length; + toast.success(`${count} app${count !== 1 ? 's' : ''} added`); + } catch (error) { + const message = + error instanceof Error ? error.message : 'Failed to install app'; + toast.error(message); + } + }; + return (
+
+ ) : null} + {manifestMutation.isPending ? ( Loading app manifest... @@ -125,7 +173,7 @@ export default function AppLaunch() { ) : null} { const apps = await addAppMutation.mutateAsync(url); @@ -75,58 +62,8 @@ export default function Apps() { } }; - const handleViewJobDetail = (jobId: number) => { - navigate(`/apps/jobs/${jobId}`); - }; - - const handleRelaunch = (job: Job) => { - navigate('/apps/launch/relaunch', { - state: { - appUrl: job.app_url, - manifestPath: job.manifest_path, - entryPointId: job.entry_point_id, - parameters: job.parameters - } - }); - }; - - const handleCancelJob = async (jobId: number) => { - try { - await cancelJobMutation.mutateAsync(jobId); - toast.success('Job cancelled'); - } catch (error) { - const message = - error instanceof Error ? error.message : 'Failed to cancel job'; - toast.error(message); - } - }; - - const handleDeleteJob = async (jobId: number) => { - try { - await deleteJobMutation.mutateAsync(jobId); - toast.success('Job deleted'); - } catch (error) { - const message = - error instanceof Error ? error.message : 'Failed to delete job'; - toast.error(message); - } - }; - - const jobsColumns = useMemo( - () => - createAppsJobsColumns({ - onViewDetail: handleViewJobDetail, - onRelaunch: handleRelaunch, - onCancel: handleCancelJob, - onDelete: handleDeleteJob - }), - // eslint-disable-next-line react-hooks/exhaustive-deps - [] - ); - return (
- {/* My Apps Section */} Apps @@ -170,20 +107,6 @@ export default function Apps() {
)} - {/* Recent Jobs Section */} - - Recent Jobs - - - - {jobQuery.isPending ? ( diff --git a/frontend/src/components/ui/AppsPage/AppCard.tsx b/frontend/src/components/ui/AppsPage/AppCard.tsx index b1d85892..59642d27 100644 --- a/frontend/src/components/ui/AppsPage/AppCard.tsx +++ b/frontend/src/components/ui/AppsPage/AppCard.tsx @@ -2,6 +2,7 @@ import { useState } from 'react'; import { useNavigate } from 'react-router'; import { Button, IconButton, Typography } from '@material-tailwind/react'; +import { buildLaunchPathFromApp } from '@/utils'; import { HiOutlineInformationCircle, HiOutlinePlay, @@ -31,9 +32,7 @@ export default function AppCard({ const [infoOpen, setInfoOpen] = useState(false); const handleLaunch = () => { - navigate('/apps/launch/app', { - state: { appUrl: app.url, manifestPath: app.manifest_path } - }); + navigate(buildLaunchPathFromApp(app.url, app.manifest_path)); }; return ( diff --git a/frontend/src/components/ui/Navbar/Navbar.tsx b/frontend/src/components/ui/Navbar/Navbar.tsx index 4f43acc2..0230b6a1 100644 --- a/frontend/src/components/ui/Navbar/Navbar.tsx +++ b/frontend/src/components/ui/Navbar/Navbar.tsx @@ -19,7 +19,8 @@ import { import { HiOutlineFolder, HiOutlineBriefcase, - HiOutlineCommandLine + HiOutlineCommandLine, + HiOutlineQueueList } from 'react-icons/hi2'; import { TbBrandGithub } from 'react-icons/tb'; @@ -49,6 +50,11 @@ const LINKS = [ title: 'Apps', href: '/apps' }, + { + icon: HiOutlineQueueList, + title: 'Jobs', + href: '/apps/jobs' + }, { icon: HiOutlineBriefcase, title: 'Tasks', diff --git a/frontend/src/utils/appUrls.ts b/frontend/src/utils/appUrls.ts new file mode 100644 index 00000000..e7afd278 --- /dev/null +++ b/frontend/src/utils/appUrls.ts @@ -0,0 +1,80 @@ +/** + * URL parsing/building helpers for app GitHub URLs. + * Mirrors backend `_parse_github_url` logic. + */ + +const GITHUB_URL_PATTERN = + /^https?:\/\/github\.com\/([^/]+)\/([^/]+?)(?:\.git)?(?:\/tree\/([^/]+))?\/?$/; + +export function parseGithubUrl(url: string): { + owner: string; + repo: string; + branch: string; +} { + const match = url.match(GITHUB_URL_PATTERN); + if (!match) { + throw new Error(`Invalid GitHub URL: ${url}`); + } + return { + owner: match[1], + repo: match[2], + branch: match[3] || 'main' + }; +} + +export function buildGithubUrl( + owner: string, + repo: string, + branch: string +): string { + if (branch === 'main') { + return `https://github.com/${owner}/${repo}`; + } + return `https://github.com/${owner}/${repo}/tree/${branch}`; +} + +export function buildLaunchPath( + owner: string, + repo: string, + branch: string, + entryPointId?: string, + manifestPath?: string +): string { + let path = `/apps/launch/${owner}/${repo}/${branch}`; + if (entryPointId) { + path += `/${entryPointId}`; + } + if (manifestPath) { + path += `?path=${encodeURIComponent(manifestPath)}`; + } + return path; +} + +export function buildLaunchPathFromApp( + appUrl: string, + manifestPath: string, + entryPointId?: string +): string { + const { owner, repo, branch } = parseGithubUrl(appUrl); + return buildLaunchPath( + owner, + repo, + branch, + entryPointId, + manifestPath || undefined + ); +} + +export function buildRelaunchPath( + owner: string, + repo: string, + branch: string, + entryPointId: string, + manifestPath?: string +): string { + let path = `/apps/relaunch/${owner}/${repo}/${branch}/${entryPointId}`; + if (manifestPath) { + path += `?path=${encodeURIComponent(manifestPath)}`; + } + return path; +} diff --git a/frontend/src/utils/index.ts b/frontend/src/utils/index.ts index 4031ee4d..383f6535 100644 --- a/frontend/src/utils/index.ts +++ b/frontend/src/utils/index.ts @@ -349,6 +349,15 @@ export type { RetryState } from './retryWithBackoff'; +// Re-export app URL utilities +export { + parseGithubUrl, + buildGithubUrl, + buildLaunchPath, + buildLaunchPathFromApp, + buildRelaunchPath +} from './appUrls'; + // Re-export Neuroglancer URL utilities export { parseNeuroglancerUrl, From 44774ce34c8a9d7f158649655d0c56c651d13de8 Mon Sep 17 00:00:00 2001 From: Konrad Rokicki Date: Sun, 15 Feb 2026 07:10:59 -0500 Subject: [PATCH 41/59] loading skeleton for job details page --- frontend/src/components/JobDetail.tsx | 27 ++++++++++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/JobDetail.tsx b/frontend/src/components/JobDetail.tsx index ad67e787..d774c427 100644 --- a/frontend/src/components/JobDetail.tsx +++ b/frontend/src/components/JobDetail.tsx @@ -193,9 +193,30 @@ export default function JobDetail() { {jobQuery.isPending ? ( - - Loading job details... - +
+ {/* Title skeleton */} +
+
+
+
+
+
+
+
+ {/* Tab bar skeleton */} +
+
+
+
+
+
+ {/* Content area skeleton */} +
+
+
+
+
+
) : jobQuery.isError ? (
Failed to load job: {jobQuery.error?.message || 'Unknown error'} From 595465af56049b38db92d8897bc44ac6984df4ce Mon Sep 17 00:00:00 2001 From: Konrad Rokicki Date: Sun, 15 Feb 2026 07:14:00 -0500 Subject: [PATCH 42/59] updated verbiage --- frontend/src/components/Apps.tsx | 2 +- frontend/src/components/Jobs.tsx | 11 +++++++---- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/frontend/src/components/Apps.tsx b/frontend/src/components/Apps.tsx index 9ea75a61..7b6a74db 100644 --- a/frontend/src/components/Apps.tsx +++ b/frontend/src/components/Apps.tsx @@ -68,7 +68,7 @@ export default function Apps() { Apps - Run command-line tools on the cluster. Add apps by URL to get started. + Run command-line tools on the compute cluster. Add apps by URL to get started.
diff --git a/frontend/src/components/Jobs.tsx b/frontend/src/components/Jobs.tsx index b1693604..e0b2d697 100644 --- a/frontend/src/components/Jobs.tsx +++ b/frontend/src/components/Jobs.tsx @@ -1,4 +1,5 @@ import { Typography } from '@material-tailwind/react'; +import { Link } from 'react-router'; import { useTicketContext } from '@/contexts/TicketsContext'; import { TableCard } from './ui/Table/TableCard'; @@ -12,10 +13,12 @@ export default function Jobs() { Tasks - A task is created when you request a file to be converted to a different - format. To request a file conversion, select a file in the file browser, - open the Properties panel, and click the{' '} - Convert button. + Jobs are runs of command-line tools on the compute cluster that are + launched from the{' '} + + Apps page + + . Date: Sun, 15 Feb 2026 07:20:32 -0500 Subject: [PATCH 43/59] loading skeleton for app launch page --- frontend/src/components/AppLaunch.tsx | 28 ++++++++++++++++++++++++--- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/AppLaunch.tsx b/frontend/src/components/AppLaunch.tsx index 882627f0..2090685e 100644 --- a/frontend/src/components/AppLaunch.tsx +++ b/frontend/src/components/AppLaunch.tsx @@ -151,9 +151,31 @@ export default function AppLaunch() { ) : null} {manifestMutation.isPending ? ( - - Loading app manifest... - +
+ {/* Title + subtitle */} +
+
+
+
+ {/* Parameter fields */} +
+ {[1, 2, 3].map(i => ( +
+
+
+
+ ))} +
+ {/* Checkbox toggle */} +
+
+
+
+ {/* Resource options link */} +
+ {/* Submit button */} +
+
) : manifestMutation.isError ? (
Failed to load app manifest:{' '} From cb985f15936f475fa750823db2c3b7a2747c5a26 Mon Sep 17 00:00:00 2001 From: Konrad Rokicki Date: Sun, 15 Feb 2026 07:36:43 -0500 Subject: [PATCH 44/59] tabs for app launch page --- frontend/src/components/AppLaunch.tsx | 18 +-- .../components/ui/AppsPage/AppLaunchForm.tsx | 152 ++++++++++-------- 2 files changed, 89 insertions(+), 81 deletions(-) diff --git a/frontend/src/components/AppLaunch.tsx b/frontend/src/components/AppLaunch.tsx index 2090685e..bdf43f5a 100644 --- a/frontend/src/components/AppLaunch.tsx +++ b/frontend/src/components/AppLaunch.tsx @@ -151,14 +151,19 @@ export default function AppLaunch() { ) : null} {manifestMutation.isPending ? ( -
+
{/* Title + subtitle */} -
+
+ {/* Tab bar skeleton */} +
+
+
+
{/* Parameter fields */} -
+
{[1, 2, 3].map(i => (
@@ -166,13 +171,6 @@ export default function AppLaunch() {
))}
- {/* Checkbox toggle */} -
-
-
-
- {/* Resource options link */} -
{/* Submit button */}
diff --git a/frontend/src/components/ui/AppsPage/AppLaunchForm.tsx b/frontend/src/components/ui/AppsPage/AppLaunchForm.tsx index 6b094bd9..c3ffbfef 100644 --- a/frontend/src/components/ui/AppsPage/AppLaunchForm.tsx +++ b/frontend/src/components/ui/AppsPage/AppLaunchForm.tsx @@ -1,6 +1,6 @@ import { useState } from 'react'; -import { Button, Typography } from '@material-tailwind/react'; +import { Button, Tabs, Typography } from '@material-tailwind/react'; import { HiOutlinePlay } from 'react-icons/hi'; import FileSelectorButton from '@/components/ui/BrowsePage/FileSelector/FileSelectorButton'; @@ -145,8 +145,8 @@ export default function AppLaunchForm({ const [values, setValues] = useState>(startingValues); const [errors, setErrors] = useState>({}); + const [activeTab, setActiveTab] = useState('parameters'); const [pullLatest, setPullLatest] = useState(false); - const [showResources, setShowResources] = useState(false); const [resources, setResources] = useState({ cpus: entryPoint.resources?.cpus, memory: entryPoint.resources?.memory, @@ -261,10 +261,9 @@ export default function AppLaunchForm({ setValidating(false); } - // Only pass resources if user modified them + // Only pass resources if user provided values const hasResourceOverrides = - showResources && - (resources.cpus || resources.memory || resources.walltime); + resources.cpus || resources.memory || resources.walltime; await onSubmit( params, @@ -274,7 +273,7 @@ export default function AppLaunchForm({ }; return ( -
+
{entryPoint.name} @@ -283,75 +282,86 @@ export default function AppLaunchForm({ {manifest.version ? ` v${manifest.version}` : ''} {entryPoint.description ? ( - + {entryPoint.description} ) : null} - {/* Parameters */} -
- {entryPoint.parameters.map(param => ( -
- {param.type !== 'boolean' ? ( -
+ - {/* Pull latest toggle */} -
- - - When enabled, runs git pull to fetch the latest code from GitHub - before starting the job. - -
+ + {/* Pull latest toggle */} +
+ + + When enabled, runs git pull to fetch the latest code from GitHub + before starting the job. + +
- {/* Resource Overrides (collapsible) */} -
- - {showResources ? ( -
+ {/* Resource Overrides */} +
- ) : null} -
+ + {/* Validation error summary */} {Object.keys(errors).length > 0 ? ( -
+
Please fix the errors above before submitting.
) : null} {/* Submit */} +
+ ))} + +
+ ); +} + +function EnvironmentSectionContent({ + envVars, + setEnvVars, + preRun, + setPreRun, + postRun, + setPostRun +}: { + readonly envVars: EnvVar[]; + readonly setEnvVars: Dispatch>; + readonly preRun: string; + readonly setPreRun: Dispatch>; + readonly postRun: string; + readonly setPostRun: Dispatch>; +}) { + const textareaClass = + 'w-full p-2 text-foreground border rounded-sm focus:outline-none bg-background border-primary-light focus:border-primary font-mono text-sm'; + + return ( +
+ + +
+ + + Shell commands to run before the main command (e.g. module loads) + +
Description{app.description}
Version{app.manifest.version}
URL @@ -54,6 +42,18 @@ function AppInfoTable({ app }: { readonly app: UserApp }) {
Version{app.manifest.version}
Description{app.description}
Entry Points