Spaces:
Sleeping
Sleeping
sachin1801 commited on
Commit ·
923926c
1
Parent(s): b7ec4a2
added api routes, changes to input pages, db creationg + token mapping, history page created, batch support for csv + fasta files
Browse files- .claude/skills/agent-log.md +247 -0
- =0.8.0 +27 -0
- webapp/app/api/routes.py +354 -10
- webapp/app/api/schemas.py +133 -26
- webapp/app/main.py +38 -3
- webapp/app/models/job.py +19 -5
- webapp/app/services/predictor.py +72 -0
- webapp/app/shiny_apps/__init__.py +1 -0
- webapp/app/shiny_apps/heatmap_app.py +306 -0
- webapp/requirements.txt +1 -0
- webapp/static/js/batch-result.js +574 -0
- webapp/static/js/file-parser.js +327 -0
- webapp/static/js/history.js +351 -0
- webapp/static/js/token.js +216 -0
- webapp/templates/about.html +0 -25
- webapp/templates/base.html +12 -40
- webapp/templates/batch_result.html +178 -0
- webapp/templates/history.html +147 -0
- webapp/templates/index.html +384 -112
- webapp/templates/result.html +20 -0
.claude/skills/agent-log.md
CHANGED
|
@@ -403,6 +403,253 @@ python -m uvicorn webapp.app.main:app --port 8000
|
|
| 403 |
|
| 404 |
---
|
| 405 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 406 |
## Future Sessions
|
| 407 |
|
| 408 |
_Sessions will be logged here as work progresses._
|
|
|
|
| 403 |
|
| 404 |
---
|
| 405 |
|
| 406 |
+
## Session 4 - 2026-01-15
|
| 407 |
+
|
| 408 |
+
### Session Start
|
| 409 |
+
- **Task**: Plan and implement multi-sequence input, file upload, token-based history, enhanced batch results
|
| 410 |
+
- **Status**: COMPLETE
|
| 411 |
+
|
| 412 |
+
### Requirements Gathered Through Q&A
|
| 413 |
+
|
| 414 |
+
| Requirement | Decision |
|
| 415 |
+
|-------------|----------|
|
| 416 |
+
| Input text format | Support both FASTA and plain sequences (auto-detect) |
|
| 417 |
+
| User identification | Token-based: auto-generate + allow user edit (NAR compliant) |
|
| 418 |
+
| Auto job title format | `2026-01-15_abc12` (date + 5-char random ID) |
|
| 419 |
+
| Token change behavior | Old jobs keep old token (no migration) |
|
| 420 |
+
| CSV delimiter | Auto-detect (comma, semicolon, tab) |
|
| 421 |
+
| Email notifications | Not for now |
|
| 422 |
+
| History search | Job title + date range filter |
|
| 423 |
+
| Results pagination | 25 per page |
|
| 424 |
+
| Invalid sequences | Mark with "Invalid" badge (no details shown) |
|
| 425 |
+
| Visualizations in dropdown | All (force plot, RNA structure, PSI gauge, sequence logo) |
|
| 426 |
+
| Max batch size | 100 sequences (kept same) |
|
| 427 |
+
|
| 428 |
+
### NAR Compliance Analysis
|
| 429 |
+
|
| 430 |
+
User asked about NAR guidelines for user identification without authentication. Researched and confirmed:
|
| 431 |
+
|
| 432 |
+
**NAR Requirements:**
|
| 433 |
+
- No login/registration required (token is auto-generated)
|
| 434 |
+
- Bookmarkable result URLs (`/result/{job_id}`)
|
| 435 |
+
- User data private (only accessible via token/URL)
|
| 436 |
+
- No tracking cookies (localStorage is allowed)
|
| 437 |
+
|
| 438 |
+
**Token System Solution:**
|
| 439 |
+
- Token format: `tok_xxxxxxxxxxxx` (12 random alphanumeric)
|
| 440 |
+
- Auto-generated on first visit, stored in localStorage
|
| 441 |
+
- User can edit/update token anytime
|
| 442 |
+
- Old jobs keep old token when user changes it
|
| 443 |
+
- Jobs searchable by token on `/history` page
|
| 444 |
+
|
| 445 |
+
### Work Completed
|
| 446 |
+
|
| 447 |
+
#### 1. Database Schema Changes (`webapp/app/models/job.py`)
|
| 448 |
+
- Added `access_token` column (String(64), indexed)
|
| 449 |
+
- Added `job_title` column (String(255))
|
| 450 |
+
- Added new indexes: `idx_jobs_access_token`, `idx_jobs_created_at`
|
| 451 |
+
- Updated `batch_sequences` to store named sequences: `[{name, sequence}, ...]`
|
| 452 |
+
|
| 453 |
+
#### 2. API Schema Updates (`webapp/app/api/schemas.py`)
|
| 454 |
+
- Added `SequenceItem` schema (name + sequence)
|
| 455 |
+
- Updated `BatchSequenceInput` to accept `List[SequenceItem]`
|
| 456 |
+
- Added `access_token`, `job_title`, `name` to `SequenceInput`
|
| 457 |
+
- Added `JobSummary` and `JobHistoryResponse` for history endpoint
|
| 458 |
+
- Added `PaginatedBatchResultsResponse` with stats (total, successful_count, invalid_count, average_psi)
|
| 459 |
+
- Added `SequenceDetailResponse` for single sequence details
|
| 460 |
+
- Added `BatchResultItem` with `index` field for pagination
|
| 461 |
+
- Added `validate_single_sequence()` function for graceful validation
|
| 462 |
+
|
| 463 |
+
#### 3. New API Endpoints (`webapp/app/api/routes.py`)
|
| 464 |
+
- `GET /api/history` - Paginated job history by token with search & date filters
|
| 465 |
+
- `DELETE /api/jobs/{job_id}` - Delete job (token verified)
|
| 466 |
+
- `GET /api/batch/{job_id}/results` - Paginated batch results with search
|
| 467 |
+
- `GET /api/batch/{job_id}/sequence/{index}` - Single sequence detail with force plot
|
| 468 |
+
- Added `generate_job_title()` helper for auto job title generation
|
| 469 |
+
- Modified `/api/predict` and `/api/batch` to handle access_token and job_title
|
| 470 |
+
- Batch processing validates each sequence individually, marks invalid ones
|
| 471 |
+
|
| 472 |
+
#### 4. New JavaScript Files (`webapp/static/js/`)
|
| 473 |
+
|
| 474 |
+
| File | Purpose | Key Functions |
|
| 475 |
+
|------|---------|---------------|
|
| 476 |
+
| `token.js` | Token management | `generateToken()`, `getOrCreateToken()`, `setToken()`, `copyTokenToClipboard()`, `initTokenDisplay()` |
|
| 477 |
+
| `file-parser.js` | CSV/FASTA parsing | `parseFasta()`, `parseCSV()`, `parseFile()`, `detectDelimiter()`, `detectHeader()`, `validateSequence()` |
|
| 478 |
+
| `history.js` | History page | `loadJobs()`, `renderJobs()`, `renderPagination()`, `deleteJob()`, `applyFilters()` |
|
| 479 |
+
| `batch-result.js` | Batch results | `loadResults()`, `renderResults()`, `showDetail()`, `createForcePlot()`, `updateStats()` |
|
| 480 |
+
|
| 481 |
+
#### 5. New Templates (`webapp/templates/`)
|
| 482 |
+
|
| 483 |
+
| File | Description |
|
| 484 |
+
|------|-------------|
|
| 485 |
+
| `history.html` | Job history page with search, date filters, paginated table |
|
| 486 |
+
| `batch_result.html` | Batch results with summary stats, search, pagination, detail modal |
|
| 487 |
+
|
| 488 |
+
#### 6. Template Updates
|
| 489 |
+
|
| 490 |
+
- **`index.html`**: Redesigned with token display, job title field, multi-sequence textarea, file upload button, sequence count display
|
| 491 |
+
- **`base.html`**: Added "History" link to desktop and mobile navigation
|
| 492 |
+
|
| 493 |
+
#### 7. Route Updates (`webapp/app/main.py`)
|
| 494 |
+
- Added `/history` route
|
| 495 |
+
- Updated `/result/{job_id}` to detect batch jobs and render `batch_result.html`
|
| 496 |
+
|
| 497 |
+
### Files Created/Modified
|
| 498 |
+
|
| 499 |
+
```
|
| 500 |
+
webapp/
|
| 501 |
+
├── app/
|
| 502 |
+
│ ├── main.py # Modified: Added history route, batch detection
|
| 503 |
+
│ ├── api/
|
| 504 |
+
│ │ ├── routes.py # Modified: New endpoints, batch validation
|
| 505 |
+
│ │ └── schemas.py # Modified: New schemas for history/batch
|
| 506 |
+
│ └── models/
|
| 507 |
+
│ └── job.py # Modified: access_token, job_title fields
|
| 508 |
+
├── static/js/
|
| 509 |
+
│ ├── token.js # NEW: Token management
|
| 510 |
+
│ ├── file-parser.js # NEW: CSV/FASTA parsing
|
| 511 |
+
│ ├── history.js # NEW: History page logic
|
| 512 |
+
│ └── batch-result.js # NEW: Batch results page logic
|
| 513 |
+
└── templates/
|
| 514 |
+
├── base.html # Modified: Added History nav link
|
| 515 |
+
├── index.html # Modified: Multi-sequence input, file upload
|
| 516 |
+
├── history.html # NEW: Job history page
|
| 517 |
+
└── batch_result.html # NEW: Batch results page
|
| 518 |
+
```
|
| 519 |
+
|
| 520 |
+
### API Endpoints Summary
|
| 521 |
+
|
| 522 |
+
| Method | Endpoint | Description |
|
| 523 |
+
|--------|----------|-------------|
|
| 524 |
+
| POST | `/api/predict` | Single sequence (now with token/title) |
|
| 525 |
+
| POST | `/api/batch` | Batch sequences with named items |
|
| 526 |
+
| GET | `/api/history` | Paginated job history by token |
|
| 527 |
+
| DELETE | `/api/jobs/{job_id}` | Delete job (token verified) |
|
| 528 |
+
| GET | `/api/batch/{job_id}/results` | Paginated batch results |
|
| 529 |
+
| GET | `/api/batch/{job_id}/sequence/{index}` | Single sequence detail |
|
| 530 |
+
|
| 531 |
+
### Plan File
|
| 532 |
+
See: `/Users/sachin/.claude/plans/structured-crafting-mitten.md`
|
| 533 |
+
|
| 534 |
+
### How to Run
|
| 535 |
+
```bash
|
| 536 |
+
source venv310/bin/activate
|
| 537 |
+
python -m uvicorn webapp.app.main:app --port 8000
|
| 538 |
+
# Open http://localhost:8000
|
| 539 |
+
```
|
| 540 |
+
|
| 541 |
+
### Testing Checklist
|
| 542 |
+
See testing instructions below in Session Notes.
|
| 543 |
+
|
| 544 |
+
---
|
| 545 |
+
|
| 546 |
+
## Session 5 - 2026-01-15
|
| 547 |
+
|
| 548 |
+
### Session Start
|
| 549 |
+
- **Task**: Implement PyShiny Filter × Position Heatmap Visualization
|
| 550 |
+
- **Status**: COMPLETE
|
| 551 |
+
|
| 552 |
+
### User Request
|
| 553 |
+
User wanted to understand what "filters/features" mean in the model and implement a heatmap visualization (like the screenshot provided) showing filter activations across sequence positions using PyShiny.
|
| 554 |
+
|
| 555 |
+
### What "Filters" Mean - Explanation Provided
|
| 556 |
+
|
| 557 |
+
The CNN model has **56 convolutional filters** that act as pattern detectors:
|
| 558 |
+
|
| 559 |
+
| Filter Type | Count | Purpose |
|
| 560 |
+
|-------------|-------|---------|
|
| 561 |
+
| `qc_incl` (incl_1 to incl_20) | 20 | Detect sequence patterns promoting **inclusion** |
|
| 562 |
+
| `qc_skip` (skip_1 to skip_20) | 20 | Detect sequence patterns promoting **skipping** |
|
| 563 |
+
| `c_incl_struct` (incl_struct_1-8) | 8 | Detect structure patterns for inclusion |
|
| 564 |
+
| `c_skip_struct` (skip_struct_1-8) | 8 | Detect structure patterns for skipping |
|
| 565 |
+
|
| 566 |
+
**Heatmap interpretation**:
|
| 567 |
+
- Rows = Filter names
|
| 568 |
+
- Columns = Positions in sequence (1-90)
|
| 569 |
+
- Color intensity = Activation strength (brighter = stronger pattern detection)
|
| 570 |
+
- Bright spots indicate where a filter detected its learned pattern
|
| 571 |
+
|
| 572 |
+
### Work Completed
|
| 573 |
+
|
| 574 |
+
#### 1. Added PyShiny Dependency
|
| 575 |
+
**File**: `webapp/requirements.txt`
|
| 576 |
+
```
|
| 577 |
+
shiny>=0.8.0 # PyShiny for interactive visualizations
|
| 578 |
+
```
|
| 579 |
+
|
| 580 |
+
#### 2. Backend - Heatmap Data Extraction
|
| 581 |
+
**File**: `webapp/app/services/predictor.py`
|
| 582 |
+
|
| 583 |
+
Added `get_heatmap_data()` method (~65 lines) that:
|
| 584 |
+
- Extracts activations from all 4 convolutional layers
|
| 585 |
+
- Applies ReLU activation
|
| 586 |
+
- Pads activations to align with 90 positions
|
| 587 |
+
- Returns structured data: positions, nucleotides, filter_names, activations (56×90 matrix)
|
| 588 |
+
|
| 589 |
+
#### 3. API Endpoint
|
| 590 |
+
**File**: `webapp/app/api/routes.py`
|
| 591 |
+
|
| 592 |
+
Added: `GET /api/heatmap/{job_id}`
|
| 593 |
+
- Returns filter activation data for a completed job
|
| 594 |
+
- Response includes 56 filters × 90 positions
|
| 595 |
+
|
| 596 |
+
#### 4. PyShiny Heatmap App
|
| 597 |
+
**New file**: `webapp/app/shiny_apps/heatmap_app.py` (~250 lines)
|
| 598 |
+
|
| 599 |
+
Created interactive PyShiny app with:
|
| 600 |
+
- Left panel: Filter checkboxes (grouped by inclusion/skipping/structure)
|
| 601 |
+
- Main area: Plotly heatmap with viridis colorscale
|
| 602 |
+
- Hover info: Position, nucleotide, filter name, activation value
|
| 603 |
+
- Select All / Deselect All buttons
|
| 604 |
+
|
| 605 |
+
#### 5. Mount PyShiny in FastAPI
|
| 606 |
+
**File**: `webapp/app/main.py`
|
| 607 |
+
|
| 608 |
+
- Added PyShiny import with graceful fallback
|
| 609 |
+
- Mounted heatmap app at `/shiny/heatmap/`
|
| 610 |
+
|
| 611 |
+
#### 6. Embed in Result Page
|
| 612 |
+
**File**: `webapp/templates/result.html`
|
| 613 |
+
|
| 614 |
+
Added heatmap section below force plot with iframe.
|
| 615 |
+
|
| 616 |
+
### Files Modified
|
| 617 |
+
|
| 618 |
+
| File | Changes |
|
| 619 |
+
|------|---------|
|
| 620 |
+
| `webapp/requirements.txt` | Added `shiny>=0.8.0` |
|
| 621 |
+
| `webapp/app/services/predictor.py` | Added `get_heatmap_data()` method |
|
| 622 |
+
| `webapp/app/api/routes.py` | Added `/api/heatmap/{job_id}` endpoint |
|
| 623 |
+
| `webapp/app/main.py` | PyShiny import + mount at `/shiny/heatmap/` |
|
| 624 |
+
| `webapp/templates/result.html` | Added heatmap iframe section |
|
| 625 |
+
|
| 626 |
+
### Files Created
|
| 627 |
+
|
| 628 |
+
| File | Description |
|
| 629 |
+
|------|-------------|
|
| 630 |
+
| `webapp/app/shiny_apps/__init__.py` | Package init |
|
| 631 |
+
| `webapp/app/shiny_apps/heatmap_app.py` | PyShiny heatmap application |
|
| 632 |
+
|
| 633 |
+
### Testing Results
|
| 634 |
+
|
| 635 |
+
```
|
| 636 |
+
Heatmap API Response:
|
| 637 |
+
Positions: 90
|
| 638 |
+
Filter names: 56
|
| 639 |
+
Activations matrix: 56 filters x 90 positions
|
| 640 |
+
SUCCESS: Heatmap API is working!
|
| 641 |
+
```
|
| 642 |
+
|
| 643 |
+
### How to Test (See detailed instructions below)
|
| 644 |
+
|
| 645 |
+
```bash
|
| 646 |
+
source venv310/bin/activate
|
| 647 |
+
python -m uvicorn webapp.app.main:app --port 8000
|
| 648 |
+
# Open http://localhost:8000
|
| 649 |
+
```
|
| 650 |
+
|
| 651 |
+
---
|
| 652 |
+
|
| 653 |
## Future Sessions
|
| 654 |
|
| 655 |
_Sessions will be logged here as work progresses._
|
=0.8.0
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Requirement already satisfied: shiny in ./venv310/lib/python3.10/site-packages (1.5.1)
|
| 2 |
+
Requirement already satisfied: typing-extensions>=4.10.0 in ./venv310/lib/python3.10/site-packages (from shiny) (4.15.0)
|
| 3 |
+
Requirement already satisfied: uvicorn>=0.16.0 in ./venv310/lib/python3.10/site-packages (from shiny) (0.40.0)
|
| 4 |
+
Requirement already satisfied: starlette in ./venv310/lib/python3.10/site-packages (from shiny) (0.50.0)
|
| 5 |
+
Requirement already satisfied: websockets>=13.0 in ./venv310/lib/python3.10/site-packages (from shiny) (16.0)
|
| 6 |
+
Requirement already satisfied: htmltools>=0.6.0 in ./venv310/lib/python3.10/site-packages (from shiny) (0.6.0)
|
| 7 |
+
Requirement already satisfied: click>=8.1.4 in ./venv310/lib/python3.10/site-packages (from shiny) (8.3.1)
|
| 8 |
+
Requirement already satisfied: markdown-it-py>=1.1.0 in ./venv310/lib/python3.10/site-packages (from shiny) (4.0.0)
|
| 9 |
+
Requirement already satisfied: mdit-py-plugins>=0.3.0 in ./venv310/lib/python3.10/site-packages (from shiny) (0.5.0)
|
| 10 |
+
Requirement already satisfied: linkify-it-py>=1.0 in ./venv310/lib/python3.10/site-packages (from shiny) (2.0.3)
|
| 11 |
+
Requirement already satisfied: platformdirs>=2.1.0 in ./venv310/lib/python3.10/site-packages (from shiny) (4.5.1)
|
| 12 |
+
Requirement already satisfied: asgiref>=3.5.2 in ./venv310/lib/python3.10/site-packages (from shiny) (3.11.0)
|
| 13 |
+
Requirement already satisfied: packaging>=20.9 in ./venv310/lib/python3.10/site-packages (from shiny) (25.0)
|
| 14 |
+
Requirement already satisfied: watchfiles>=0.18.0 in ./venv310/lib/python3.10/site-packages (from shiny) (1.1.1)
|
| 15 |
+
Requirement already satisfied: questionary>=2.0.0 in ./venv310/lib/python3.10/site-packages (from shiny) (2.1.1)
|
| 16 |
+
Requirement already satisfied: prompt-toolkit in ./venv310/lib/python3.10/site-packages (from shiny) (3.0.52)
|
| 17 |
+
Requirement already satisfied: python-multipart>=0.0.7 in ./venv310/lib/python3.10/site-packages (from shiny) (0.0.21)
|
| 18 |
+
Requirement already satisfied: narwhals>=1.10.0 in ./venv310/lib/python3.10/site-packages (from shiny) (2.15.0)
|
| 19 |
+
Requirement already satisfied: orjson>=3.10.7 in ./venv310/lib/python3.10/site-packages (from shiny) (3.11.5)
|
| 20 |
+
Requirement already satisfied: shinychat>=0.1.0 in ./venv310/lib/python3.10/site-packages (from shiny) (0.2.8)
|
| 21 |
+
Requirement already satisfied: uc-micro-py in ./venv310/lib/python3.10/site-packages (from linkify-it-py>=1.0->shiny) (1.0.3)
|
| 22 |
+
Requirement already satisfied: mdurl~=0.1 in ./venv310/lib/python3.10/site-packages (from markdown-it-py>=1.1.0->shiny) (0.1.2)
|
| 23 |
+
Requirement already satisfied: wcwidth in ./venv310/lib/python3.10/site-packages (from prompt-toolkit->shiny) (0.2.14)
|
| 24 |
+
Requirement already satisfied: h11>=0.8 in ./venv310/lib/python3.10/site-packages (from uvicorn>=0.16.0->shiny) (0.16.0)
|
| 25 |
+
Requirement already satisfied: anyio>=3.0.0 in ./venv310/lib/python3.10/site-packages (from watchfiles>=0.18.0->shiny) (4.12.1)
|
| 26 |
+
Requirement already satisfied: exceptiongroup>=1.0.2 in ./venv310/lib/python3.10/site-packages (from anyio>=3.0.0->watchfiles>=0.18.0->shiny) (1.3.1)
|
| 27 |
+
Requirement already satisfied: idna>=2.8 in ./venv310/lib/python3.10/site-packages (from anyio>=3.0.0->watchfiles>=0.18.0->shiny) (3.11)
|
webapp/app/api/routes.py
CHANGED
|
@@ -2,12 +2,15 @@
|
|
| 2 |
|
| 3 |
import uuid
|
| 4 |
import json
|
|
|
|
|
|
|
| 5 |
from datetime import datetime, timedelta
|
| 6 |
-
from typing import Optional
|
|
|
|
| 7 |
from fastapi import APIRouter, Depends, HTTPException, Query, Path
|
| 8 |
from fastapi.responses import Response
|
| 9 |
from sqlalchemy.orm import Session
|
| 10 |
-
from sqlalchemy import text
|
| 11 |
|
| 12 |
from webapp.app.database import get_db
|
| 13 |
from webapp.app.models.job import Job
|
|
@@ -16,6 +19,7 @@ from webapp.app.config import settings
|
|
| 16 |
from webapp.app.api.schemas import (
|
| 17 |
SequenceInput,
|
| 18 |
BatchSequenceInput,
|
|
|
|
| 19 |
PredictionResponse,
|
| 20 |
JobStatusResponse,
|
| 21 |
SingleResultResponse,
|
|
@@ -25,8 +29,20 @@ from webapp.app.api.schemas import (
|
|
| 25 |
ExampleSequencesResponse,
|
| 26 |
HealthResponse,
|
| 27 |
ErrorResponse,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
)
|
| 29 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
router = APIRouter()
|
| 31 |
|
| 32 |
|
|
@@ -67,6 +83,9 @@ async def submit_prediction(
|
|
| 67 |
"""
|
| 68 |
job_id = str(uuid.uuid4())
|
| 69 |
|
|
|
|
|
|
|
|
|
|
| 70 |
# Create job in database
|
| 71 |
job = Job(
|
| 72 |
id=job_id,
|
|
@@ -74,6 +93,8 @@ async def submit_prediction(
|
|
| 74 |
sequence=request.sequence,
|
| 75 |
email=request.email,
|
| 76 |
is_batch=False,
|
|
|
|
|
|
|
| 77 |
)
|
| 78 |
db.add(job)
|
| 79 |
db.commit()
|
|
@@ -124,19 +145,31 @@ async def submit_batch_prediction(
|
|
| 124 |
Submit multiple sequences for batch PSI prediction.
|
| 125 |
|
| 126 |
Each sequence must be exactly 70 nucleotides long and contain only A, C, G, T.
|
|
|
|
| 127 |
Maximum batch size is 100 sequences.
|
| 128 |
"""
|
| 129 |
job_id = str(uuid.uuid4())
|
| 130 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 131 |
# Create job in database
|
| 132 |
job = Job(
|
| 133 |
id=job_id,
|
| 134 |
status="queued",
|
| 135 |
-
sequence=request.sequences[0], # Store first sequence as reference
|
| 136 |
email=request.email,
|
| 137 |
is_batch=True,
|
|
|
|
|
|
|
| 138 |
)
|
| 139 |
-
job.set_batch_sequences(
|
| 140 |
db.add(job)
|
| 141 |
db.commit()
|
| 142 |
|
|
@@ -146,7 +179,51 @@ async def submit_batch_prediction(
|
|
| 146 |
db.commit()
|
| 147 |
|
| 148 |
predictor = get_predictor()
|
| 149 |
-
results =
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 150 |
|
| 151 |
# Update job with results
|
| 152 |
job.status = "finished"
|
|
@@ -159,12 +236,17 @@ async def submit_batch_prediction(
|
|
| 159 |
db.commit()
|
| 160 |
raise HTTPException(status_code=500, detail=str(e))
|
| 161 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 162 |
return PredictionResponse(
|
| 163 |
job_id=job_id,
|
| 164 |
status=job.status,
|
| 165 |
status_url=f"/api/status/{job_id}",
|
| 166 |
result_url=f"/result/{job_id}",
|
| 167 |
-
message=f"Batch
|
| 168 |
)
|
| 169 |
|
| 170 |
|
|
@@ -223,26 +305,32 @@ async def get_job_result(
|
|
| 223 |
# Return batch results
|
| 224 |
results = job.get_batch_results()
|
| 225 |
successful = sum(1 for r in results if r.get("status") == "success")
|
| 226 |
-
|
|
|
|
| 227 |
|
| 228 |
return BatchResultResponse(
|
| 229 |
job_id=job.id,
|
|
|
|
| 230 |
status=job.status,
|
| 231 |
total_sequences=len(results),
|
| 232 |
successful=successful,
|
|
|
|
| 233 |
failed=failed,
|
| 234 |
results=[
|
| 235 |
BatchResultItem(
|
|
|
|
| 236 |
sequence=r.get("sequence", ""),
|
| 237 |
status=r.get("status", "unknown"),
|
| 238 |
psi=r.get("psi"),
|
| 239 |
interpretation=r.get("interpretation"),
|
| 240 |
structure=r.get("structure"),
|
| 241 |
mfe=r.get("mfe"),
|
|
|
|
|
|
|
| 242 |
error=r.get("error"),
|
| 243 |
warnings=r.get("warnings"),
|
| 244 |
)
|
| 245 |
-
for r in results
|
| 246 |
],
|
| 247 |
created_at=job.created_at,
|
| 248 |
expires_at=job.expires_at,
|
|
@@ -267,6 +355,34 @@ async def get_job_result(
|
|
| 267 |
)
|
| 268 |
|
| 269 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 270 |
@router.get("/example", response_model=ExampleSequencesResponse, tags=["examples"])
|
| 271 |
async def get_example_sequences():
|
| 272 |
"""
|
|
@@ -349,16 +465,18 @@ async def export_results(
|
|
| 349 |
|
| 350 |
if job.is_batch:
|
| 351 |
results = job.get_batch_results()
|
| 352 |
-
header = ["sequence", "psi", "interpretation", "structure", "mfe", "status", "error"]
|
| 353 |
rows = [delimiter.join(header)]
|
| 354 |
-
for r in results:
|
| 355 |
row = [
|
|
|
|
| 356 |
r.get("sequence", ""),
|
| 357 |
str(r.get("psi", "")),
|
| 358 |
r.get("interpretation", ""),
|
| 359 |
r.get("structure", ""),
|
| 360 |
str(r.get("mfe", "")),
|
| 361 |
r.get("status", ""),
|
|
|
|
| 362 |
r.get("error", ""),
|
| 363 |
]
|
| 364 |
rows.append(delimiter.join(row))
|
|
@@ -382,3 +500,229 @@ async def export_results(
|
|
| 382 |
)
|
| 383 |
|
| 384 |
raise HTTPException(status_code=400, detail=f"Unsupported format: {format}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
import uuid
|
| 4 |
import json
|
| 5 |
+
import string
|
| 6 |
+
import random
|
| 7 |
from datetime import datetime, timedelta
|
| 8 |
+
from typing import Optional, List
|
| 9 |
+
from math import ceil
|
| 10 |
from fastapi import APIRouter, Depends, HTTPException, Query, Path
|
| 11 |
from fastapi.responses import Response
|
| 12 |
from sqlalchemy.orm import Session
|
| 13 |
+
from sqlalchemy import text, and_, or_
|
| 14 |
|
| 15 |
from webapp.app.database import get_db
|
| 16 |
from webapp.app.models.job import Job
|
|
|
|
| 19 |
from webapp.app.api.schemas import (
|
| 20 |
SequenceInput,
|
| 21 |
BatchSequenceInput,
|
| 22 |
+
SequenceItem,
|
| 23 |
PredictionResponse,
|
| 24 |
JobStatusResponse,
|
| 25 |
SingleResultResponse,
|
|
|
|
| 29 |
ExampleSequencesResponse,
|
| 30 |
HealthResponse,
|
| 31 |
ErrorResponse,
|
| 32 |
+
JobSummary,
|
| 33 |
+
JobHistoryResponse,
|
| 34 |
+
PaginatedBatchResultsResponse,
|
| 35 |
+
SequenceDetailResponse,
|
| 36 |
+
validate_single_sequence,
|
| 37 |
)
|
| 38 |
|
| 39 |
+
|
| 40 |
+
def generate_job_title() -> str:
|
| 41 |
+
"""Generate an auto job title in format: 2026-01-15_abc12"""
|
| 42 |
+
date_part = datetime.utcnow().strftime("%Y-%m-%d")
|
| 43 |
+
random_part = ''.join(random.choices(string.ascii_lowercase + string.digits, k=5))
|
| 44 |
+
return f"{date_part}_{random_part}"
|
| 45 |
+
|
| 46 |
router = APIRouter()
|
| 47 |
|
| 48 |
|
|
|
|
| 83 |
"""
|
| 84 |
job_id = str(uuid.uuid4())
|
| 85 |
|
| 86 |
+
# Generate job title if not provided
|
| 87 |
+
job_title = request.job_title if request.job_title else generate_job_title()
|
| 88 |
+
|
| 89 |
# Create job in database
|
| 90 |
job = Job(
|
| 91 |
id=job_id,
|
|
|
|
| 93 |
sequence=request.sequence,
|
| 94 |
email=request.email,
|
| 95 |
is_batch=False,
|
| 96 |
+
access_token=request.access_token,
|
| 97 |
+
job_title=job_title,
|
| 98 |
)
|
| 99 |
db.add(job)
|
| 100 |
db.commit()
|
|
|
|
| 145 |
Submit multiple sequences for batch PSI prediction.
|
| 146 |
|
| 147 |
Each sequence must be exactly 70 nucleotides long and contain only A, C, G, T.
|
| 148 |
+
Invalid sequences will be marked in results but won't block processing of valid ones.
|
| 149 |
Maximum batch size is 100 sequences.
|
| 150 |
"""
|
| 151 |
job_id = str(uuid.uuid4())
|
| 152 |
|
| 153 |
+
# Generate job title if not provided
|
| 154 |
+
job_title = request.job_title if request.job_title else generate_job_title()
|
| 155 |
+
|
| 156 |
+
# Convert sequences to dict format for storage
|
| 157 |
+
sequences_for_storage = [
|
| 158 |
+
{"name": seq.name, "sequence": seq.sequence}
|
| 159 |
+
for seq in request.sequences
|
| 160 |
+
]
|
| 161 |
+
|
| 162 |
# Create job in database
|
| 163 |
job = Job(
|
| 164 |
id=job_id,
|
| 165 |
status="queued",
|
| 166 |
+
sequence=request.sequences[0].sequence, # Store first sequence as reference
|
| 167 |
email=request.email,
|
| 168 |
is_batch=True,
|
| 169 |
+
access_token=request.access_token,
|
| 170 |
+
job_title=job_title,
|
| 171 |
)
|
| 172 |
+
job.set_batch_sequences(sequences_for_storage)
|
| 173 |
db.add(job)
|
| 174 |
db.commit()
|
| 175 |
|
|
|
|
| 179 |
db.commit()
|
| 180 |
|
| 181 |
predictor = get_predictor()
|
| 182 |
+
results = []
|
| 183 |
+
|
| 184 |
+
for seq_item in request.sequences:
|
| 185 |
+
# Validate each sequence
|
| 186 |
+
is_valid, validation_error = validate_single_sequence(seq_item.sequence)
|
| 187 |
+
|
| 188 |
+
if not is_valid:
|
| 189 |
+
# Mark as invalid, don't process
|
| 190 |
+
results.append({
|
| 191 |
+
"name": seq_item.name,
|
| 192 |
+
"sequence": seq_item.sequence,
|
| 193 |
+
"status": "invalid",
|
| 194 |
+
"validation_error": validation_error,
|
| 195 |
+
"psi": None,
|
| 196 |
+
"interpretation": None,
|
| 197 |
+
"structure": None,
|
| 198 |
+
"mfe": None,
|
| 199 |
+
})
|
| 200 |
+
else:
|
| 201 |
+
# Process valid sequence
|
| 202 |
+
try:
|
| 203 |
+
result = predictor.predict_single(seq_item.sequence)
|
| 204 |
+
force_plot_data = predictor.get_force_plot_data(seq_item.sequence)
|
| 205 |
+
results.append({
|
| 206 |
+
"name": seq_item.name,
|
| 207 |
+
"sequence": seq_item.sequence,
|
| 208 |
+
"status": "success",
|
| 209 |
+
"psi": result["psi"],
|
| 210 |
+
"interpretation": result["interpretation"],
|
| 211 |
+
"structure": result["structure"],
|
| 212 |
+
"mfe": result["mfe"],
|
| 213 |
+
"force_plot_data": force_plot_data,
|
| 214 |
+
"warnings": result.get("warnings"),
|
| 215 |
+
})
|
| 216 |
+
except Exception as e:
|
| 217 |
+
results.append({
|
| 218 |
+
"name": seq_item.name,
|
| 219 |
+
"sequence": seq_item.sequence,
|
| 220 |
+
"status": "error",
|
| 221 |
+
"error": str(e),
|
| 222 |
+
"psi": None,
|
| 223 |
+
"interpretation": None,
|
| 224 |
+
"structure": None,
|
| 225 |
+
"mfe": None,
|
| 226 |
+
})
|
| 227 |
|
| 228 |
# Update job with results
|
| 229 |
job.status = "finished"
|
|
|
|
| 236 |
db.commit()
|
| 237 |
raise HTTPException(status_code=500, detail=str(e))
|
| 238 |
|
| 239 |
+
# Count results
|
| 240 |
+
successful = sum(1 for r in results if r.get("status") == "success")
|
| 241 |
+
invalid = sum(1 for r in results if r.get("status") == "invalid")
|
| 242 |
+
errored = sum(1 for r in results if r.get("status") == "error")
|
| 243 |
+
|
| 244 |
return PredictionResponse(
|
| 245 |
job_id=job_id,
|
| 246 |
status=job.status,
|
| 247 |
status_url=f"/api/status/{job_id}",
|
| 248 |
result_url=f"/result/{job_id}",
|
| 249 |
+
message=f"Batch completed: {successful} successful, {invalid} invalid, {errored} errors",
|
| 250 |
)
|
| 251 |
|
| 252 |
|
|
|
|
| 305 |
# Return batch results
|
| 306 |
results = job.get_batch_results()
|
| 307 |
successful = sum(1 for r in results if r.get("status") == "success")
|
| 308 |
+
invalid = sum(1 for r in results if r.get("status") == "invalid")
|
| 309 |
+
failed = sum(1 for r in results if r.get("status") == "error")
|
| 310 |
|
| 311 |
return BatchResultResponse(
|
| 312 |
job_id=job.id,
|
| 313 |
+
job_title=job.job_title,
|
| 314 |
status=job.status,
|
| 315 |
total_sequences=len(results),
|
| 316 |
successful=successful,
|
| 317 |
+
invalid=invalid,
|
| 318 |
failed=failed,
|
| 319 |
results=[
|
| 320 |
BatchResultItem(
|
| 321 |
+
name=r.get("name", f"Seq_{i+1}"),
|
| 322 |
sequence=r.get("sequence", ""),
|
| 323 |
status=r.get("status", "unknown"),
|
| 324 |
psi=r.get("psi"),
|
| 325 |
interpretation=r.get("interpretation"),
|
| 326 |
structure=r.get("structure"),
|
| 327 |
mfe=r.get("mfe"),
|
| 328 |
+
force_plot_data=r.get("force_plot_data"),
|
| 329 |
+
validation_error=r.get("validation_error"),
|
| 330 |
error=r.get("error"),
|
| 331 |
warnings=r.get("warnings"),
|
| 332 |
)
|
| 333 |
+
for i, r in enumerate(results)
|
| 334 |
],
|
| 335 |
created_at=job.created_at,
|
| 336 |
expires_at=job.expires_at,
|
|
|
|
| 355 |
)
|
| 356 |
|
| 357 |
|
| 358 |
+
@router.get("/heatmap/{job_id}", tags=["visualization"])
|
| 359 |
+
async def get_heatmap_data(
|
| 360 |
+
job_id: str,
|
| 361 |
+
db: Session = Depends(get_db),
|
| 362 |
+
):
|
| 363 |
+
"""
|
| 364 |
+
Get filter activation heatmap data for a prediction job.
|
| 365 |
+
|
| 366 |
+
Returns position-wise filter activations for heatmap visualization.
|
| 367 |
+
"""
|
| 368 |
+
job = db.query(Job).filter(Job.id == job_id).first()
|
| 369 |
+
if not job:
|
| 370 |
+
raise HTTPException(status_code=404, detail="Job not found")
|
| 371 |
+
|
| 372 |
+
if job.status != "finished":
|
| 373 |
+
raise HTTPException(status_code=400, detail="Job not yet complete")
|
| 374 |
+
|
| 375 |
+
# For batch jobs, use the first sequence
|
| 376 |
+
sequence = job.sequence
|
| 377 |
+
|
| 378 |
+
try:
|
| 379 |
+
predictor = get_predictor()
|
| 380 |
+
heatmap_data = predictor.get_heatmap_data(sequence)
|
| 381 |
+
return heatmap_data
|
| 382 |
+
except Exception as e:
|
| 383 |
+
raise HTTPException(status_code=500, detail=f"Error generating heatmap data: {str(e)}")
|
| 384 |
+
|
| 385 |
+
|
| 386 |
@router.get("/example", response_model=ExampleSequencesResponse, tags=["examples"])
|
| 387 |
async def get_example_sequences():
|
| 388 |
"""
|
|
|
|
| 465 |
|
| 466 |
if job.is_batch:
|
| 467 |
results = job.get_batch_results()
|
| 468 |
+
header = ["name", "sequence", "psi", "interpretation", "structure", "mfe", "status", "validation_error", "error"]
|
| 469 |
rows = [delimiter.join(header)]
|
| 470 |
+
for i, r in enumerate(results):
|
| 471 |
row = [
|
| 472 |
+
r.get("name", f"Seq_{i+1}"),
|
| 473 |
r.get("sequence", ""),
|
| 474 |
str(r.get("psi", "")),
|
| 475 |
r.get("interpretation", ""),
|
| 476 |
r.get("structure", ""),
|
| 477 |
str(r.get("mfe", "")),
|
| 478 |
r.get("status", ""),
|
| 479 |
+
r.get("validation_error", ""),
|
| 480 |
r.get("error", ""),
|
| 481 |
]
|
| 482 |
rows.append(delimiter.join(row))
|
|
|
|
| 500 |
)
|
| 501 |
|
| 502 |
raise HTTPException(status_code=400, detail=f"Unsupported format: {format}")
|
| 503 |
+
|
| 504 |
+
|
| 505 |
+
# ============================================================================
|
| 506 |
+
# History and Job Management Endpoints
|
| 507 |
+
# ============================================================================
|
| 508 |
+
|
| 509 |
+
|
| 510 |
+
@router.get("/history", response_model=JobHistoryResponse, tags=["history"])
|
| 511 |
+
async def get_job_history(
|
| 512 |
+
access_token: str = Query(..., description="User access token"),
|
| 513 |
+
search: Optional[str] = Query(None, description="Search job titles"),
|
| 514 |
+
date_from: Optional[datetime] = Query(None, description="Filter by start date"),
|
| 515 |
+
date_to: Optional[datetime] = Query(None, description="Filter by end date"),
|
| 516 |
+
page: int = Query(1, ge=1, description="Page number"),
|
| 517 |
+
page_size: int = Query(25, ge=1, le=100, description="Results per page"),
|
| 518 |
+
db: Session = Depends(get_db),
|
| 519 |
+
):
|
| 520 |
+
"""
|
| 521 |
+
Get paginated job history for a user token.
|
| 522 |
+
|
| 523 |
+
Jobs are filtered by access_token and optionally by job title search and date range.
|
| 524 |
+
"""
|
| 525 |
+
# Build query
|
| 526 |
+
query = db.query(Job).filter(Job.access_token == access_token)
|
| 527 |
+
|
| 528 |
+
# Apply search filter
|
| 529 |
+
if search:
|
| 530 |
+
query = query.filter(Job.job_title.ilike(f"%{search}%"))
|
| 531 |
+
|
| 532 |
+
# Apply date filters
|
| 533 |
+
if date_from:
|
| 534 |
+
query = query.filter(Job.created_at >= date_from)
|
| 535 |
+
if date_to:
|
| 536 |
+
query = query.filter(Job.created_at <= date_to)
|
| 537 |
+
|
| 538 |
+
# Get total count
|
| 539 |
+
total = query.count()
|
| 540 |
+
|
| 541 |
+
# Apply pagination and ordering
|
| 542 |
+
query = query.order_by(Job.created_at.desc())
|
| 543 |
+
query = query.offset((page - 1) * page_size).limit(page_size)
|
| 544 |
+
|
| 545 |
+
jobs = query.all()
|
| 546 |
+
|
| 547 |
+
# Build response
|
| 548 |
+
job_summaries = [
|
| 549 |
+
JobSummary(
|
| 550 |
+
id=job.id,
|
| 551 |
+
job_title=job.job_title,
|
| 552 |
+
created_at=job.created_at,
|
| 553 |
+
status=job.status,
|
| 554 |
+
is_batch=job.is_batch,
|
| 555 |
+
sequence_count=job.get_sequence_count(),
|
| 556 |
+
)
|
| 557 |
+
for job in jobs
|
| 558 |
+
]
|
| 559 |
+
|
| 560 |
+
total_pages = ceil(total / page_size) if total > 0 else 1
|
| 561 |
+
|
| 562 |
+
return JobHistoryResponse(
|
| 563 |
+
jobs=job_summaries,
|
| 564 |
+
total=total,
|
| 565 |
+
page=page,
|
| 566 |
+
page_size=page_size,
|
| 567 |
+
total_pages=total_pages,
|
| 568 |
+
)
|
| 569 |
+
|
| 570 |
+
|
| 571 |
+
@router.delete("/jobs/{job_id}", tags=["history"])
|
| 572 |
+
async def delete_job(
|
| 573 |
+
job_id: str,
|
| 574 |
+
access_token: str = Query(..., description="User access token"),
|
| 575 |
+
db: Session = Depends(get_db),
|
| 576 |
+
):
|
| 577 |
+
"""
|
| 578 |
+
Delete a job.
|
| 579 |
+
|
| 580 |
+
Only the owner (matching access_token) can delete a job.
|
| 581 |
+
"""
|
| 582 |
+
job = db.query(Job).filter(Job.id == job_id).first()
|
| 583 |
+
if not job:
|
| 584 |
+
raise HTTPException(status_code=404, detail="Job not found")
|
| 585 |
+
|
| 586 |
+
if job.access_token != access_token:
|
| 587 |
+
raise HTTPException(status_code=403, detail="Access denied - token does not match")
|
| 588 |
+
|
| 589 |
+
db.delete(job)
|
| 590 |
+
db.commit()
|
| 591 |
+
|
| 592 |
+
return {"status": "deleted", "job_id": job_id}
|
| 593 |
+
|
| 594 |
+
|
| 595 |
+
@router.get("/batch/{job_id}/results", response_model=PaginatedBatchResultsResponse, tags=["results"])
|
| 596 |
+
async def get_batch_results_paginated(
|
| 597 |
+
job_id: str,
|
| 598 |
+
page: int = Query(1, ge=1, description="Page number"),
|
| 599 |
+
page_size: int = Query(25, ge=1, le=100, description="Results per page"),
|
| 600 |
+
search: Optional[str] = Query(None, description="Search by name or sequence"),
|
| 601 |
+
db: Session = Depends(get_db),
|
| 602 |
+
):
|
| 603 |
+
"""
|
| 604 |
+
Get paginated batch results with optional search.
|
| 605 |
+
|
| 606 |
+
Search filters results by sequence name or sequence content.
|
| 607 |
+
"""
|
| 608 |
+
job = db.query(Job).filter(Job.id == job_id).first()
|
| 609 |
+
if not job:
|
| 610 |
+
raise HTTPException(status_code=404, detail="Job not found")
|
| 611 |
+
|
| 612 |
+
if not job.is_batch:
|
| 613 |
+
raise HTTPException(status_code=400, detail="This is not a batch job")
|
| 614 |
+
|
| 615 |
+
if job.status != "finished":
|
| 616 |
+
raise HTTPException(status_code=400, detail="Job not yet complete")
|
| 617 |
+
|
| 618 |
+
all_results = job.get_batch_results()
|
| 619 |
+
|
| 620 |
+
# Calculate statistics from all results (before filtering)
|
| 621 |
+
total_sequences = len(all_results)
|
| 622 |
+
successful_count = sum(1 for r in all_results if r.get("status") == "success")
|
| 623 |
+
invalid_count = sum(1 for r in all_results if r.get("status") == "invalid")
|
| 624 |
+
failed_count = sum(1 for r in all_results if r.get("status") == "error")
|
| 625 |
+
|
| 626 |
+
# Calculate average PSI from successful sequences
|
| 627 |
+
successful_psis = [r.get("psi") for r in all_results if r.get("status") == "success" and r.get("psi") is not None]
|
| 628 |
+
average_psi = sum(successful_psis) / len(successful_psis) if successful_psis else None
|
| 629 |
+
|
| 630 |
+
# Add original index to each result for detail lookup
|
| 631 |
+
indexed_results = [(i, r) for i, r in enumerate(all_results)]
|
| 632 |
+
|
| 633 |
+
# Apply search filter
|
| 634 |
+
if search:
|
| 635 |
+
search_lower = search.lower()
|
| 636 |
+
indexed_results = [
|
| 637 |
+
(i, r) for i, r in indexed_results
|
| 638 |
+
if search_lower in r.get("name", "").lower()
|
| 639 |
+
or search_lower in r.get("sequence", "").lower()
|
| 640 |
+
]
|
| 641 |
+
|
| 642 |
+
# Total after filtering (for pagination)
|
| 643 |
+
total = len(indexed_results)
|
| 644 |
+
|
| 645 |
+
# Apply pagination
|
| 646 |
+
start_idx = (page - 1) * page_size
|
| 647 |
+
end_idx = start_idx + page_size
|
| 648 |
+
paginated_results = indexed_results[start_idx:end_idx]
|
| 649 |
+
|
| 650 |
+
total_pages = ceil(total / page_size) if total > 0 else 1
|
| 651 |
+
|
| 652 |
+
return PaginatedBatchResultsResponse(
|
| 653 |
+
job_id=job.id,
|
| 654 |
+
job_title=job.job_title,
|
| 655 |
+
status=job.status,
|
| 656 |
+
total_sequences=total_sequences,
|
| 657 |
+
successful_count=successful_count,
|
| 658 |
+
invalid_count=invalid_count,
|
| 659 |
+
failed_count=failed_count,
|
| 660 |
+
average_psi=average_psi,
|
| 661 |
+
results=[
|
| 662 |
+
BatchResultItem(
|
| 663 |
+
index=orig_idx,
|
| 664 |
+
name=r.get("name", f"Seq_{orig_idx+1}"),
|
| 665 |
+
sequence=r.get("sequence", ""),
|
| 666 |
+
status=r.get("status", "unknown"),
|
| 667 |
+
psi=r.get("psi"),
|
| 668 |
+
interpretation=r.get("interpretation"),
|
| 669 |
+
structure=r.get("structure"),
|
| 670 |
+
mfe=r.get("mfe"),
|
| 671 |
+
force_plot_data=r.get("force_plot_data"),
|
| 672 |
+
validation_error=r.get("validation_error"),
|
| 673 |
+
error=r.get("error"),
|
| 674 |
+
warnings=r.get("warnings"),
|
| 675 |
+
)
|
| 676 |
+
for orig_idx, r in paginated_results
|
| 677 |
+
],
|
| 678 |
+
total=total,
|
| 679 |
+
page=page,
|
| 680 |
+
page_size=page_size,
|
| 681 |
+
total_pages=total_pages,
|
| 682 |
+
created_at=job.created_at,
|
| 683 |
+
expires_at=job.expires_at,
|
| 684 |
+
)
|
| 685 |
+
|
| 686 |
+
|
| 687 |
+
@router.get("/batch/{job_id}/sequence/{index}", response_model=SequenceDetailResponse, tags=["results"])
|
| 688 |
+
async def get_sequence_detail(
|
| 689 |
+
job_id: str,
|
| 690 |
+
index: int = Path(..., ge=0, description="Sequence index (0-based)"),
|
| 691 |
+
db: Session = Depends(get_db),
|
| 692 |
+
):
|
| 693 |
+
"""
|
| 694 |
+
Get detailed results for a single sequence in a batch job.
|
| 695 |
+
|
| 696 |
+
Returns full details including force plot data for visualization.
|
| 697 |
+
"""
|
| 698 |
+
job = db.query(Job).filter(Job.id == job_id).first()
|
| 699 |
+
if not job:
|
| 700 |
+
raise HTTPException(status_code=404, detail="Job not found")
|
| 701 |
+
|
| 702 |
+
if not job.is_batch:
|
| 703 |
+
raise HTTPException(status_code=400, detail="This is not a batch job")
|
| 704 |
+
|
| 705 |
+
if job.status != "finished":
|
| 706 |
+
raise HTTPException(status_code=400, detail="Job not yet complete")
|
| 707 |
+
|
| 708 |
+
results = job.get_batch_results()
|
| 709 |
+
if index >= len(results):
|
| 710 |
+
raise HTTPException(status_code=404, detail=f"Sequence index {index} not found")
|
| 711 |
+
|
| 712 |
+
r = results[index]
|
| 713 |
+
|
| 714 |
+
return SequenceDetailResponse(
|
| 715 |
+
job_id=job.id,
|
| 716 |
+
index=index,
|
| 717 |
+
name=r.get("name", f"Seq_{index+1}"),
|
| 718 |
+
sequence=r.get("sequence", ""),
|
| 719 |
+
status=r.get("status", "unknown"),
|
| 720 |
+
psi=r.get("psi"),
|
| 721 |
+
interpretation=r.get("interpretation"),
|
| 722 |
+
structure=r.get("structure"),
|
| 723 |
+
mfe=r.get("mfe"),
|
| 724 |
+
force_plot_data=r.get("force_plot_data"),
|
| 725 |
+
validation_error=r.get("validation_error"),
|
| 726 |
+
error=r.get("error"),
|
| 727 |
+
warnings=r.get("warnings"),
|
| 728 |
+
)
|
webapp/app/api/schemas.py
CHANGED
|
@@ -17,10 +17,25 @@ class SequenceInput(BaseModel):
|
|
| 17 |
max_length=settings.exon_length,
|
| 18 |
description=f"The {settings.exon_length}-nucleotide exon sequence (A, C, G, T only)",
|
| 19 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
email: Optional[EmailStr] = Field(
|
| 21 |
None,
|
| 22 |
description="Optional email address for job completion notification",
|
| 23 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
|
| 25 |
@field_validator("sequence")
|
| 26 |
@classmethod
|
|
@@ -42,49 +57,71 @@ class SequenceInput(BaseModel):
|
|
| 42 |
return v
|
| 43 |
|
| 44 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
class BatchSequenceInput(BaseModel):
|
| 46 |
"""Schema for batch sequence prediction request."""
|
| 47 |
|
| 48 |
-
sequences: List[
|
| 49 |
...,
|
| 50 |
min_length=1,
|
| 51 |
max_length=settings.max_batch_size,
|
| 52 |
-
description=f"List of {settings.exon_length}-nucleotide exon sequences",
|
| 53 |
)
|
| 54 |
email: Optional[EmailStr] = Field(
|
| 55 |
None,
|
| 56 |
description="Optional email address for job completion notification",
|
| 57 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
|
| 59 |
@field_validator("sequences")
|
| 60 |
@classmethod
|
| 61 |
-
def validate_sequences(cls, v: List[
|
| 62 |
-
"""
|
| 63 |
-
|
| 64 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 65 |
|
| 66 |
-
for i, seq in enumerate(v):
|
| 67 |
-
seq = seq.upper().replace("U", "T").strip()
|
| 68 |
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
|
|
|
| 74 |
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
errors.append(
|
| 78 |
-
f"Sequence {i + 1}: contains invalid characters: {invalid_chars}"
|
| 79 |
-
)
|
| 80 |
-
continue
|
| 81 |
|
| 82 |
-
|
|
|
|
|
|
|
| 83 |
|
| 84 |
-
|
| 85 |
-
raise ValueError("; ".join(errors))
|
| 86 |
-
|
| 87 |
-
return validated
|
| 88 |
|
| 89 |
|
| 90 |
class PredictionResponse(BaseModel):
|
|
@@ -127,13 +164,17 @@ class SingleResultResponse(BaseModel):
|
|
| 127 |
class BatchResultItem(BaseModel):
|
| 128 |
"""Schema for a single item in batch results."""
|
| 129 |
|
|
|
|
|
|
|
| 130 |
sequence: str
|
| 131 |
-
status: str
|
| 132 |
psi: Optional[float] = None
|
| 133 |
interpretation: Optional[str] = None
|
| 134 |
structure: Optional[str] = None
|
| 135 |
mfe: Optional[float] = None
|
| 136 |
-
|
|
|
|
|
|
|
| 137 |
warnings: Optional[List[str]] = None
|
| 138 |
|
| 139 |
|
|
@@ -141,9 +182,11 @@ class BatchResultResponse(BaseModel):
|
|
| 141 |
"""Schema for batch prediction results."""
|
| 142 |
|
| 143 |
job_id: str
|
|
|
|
| 144 |
status: str
|
| 145 |
total_sequences: int
|
| 146 |
successful: int
|
|
|
|
| 147 |
failed: int
|
| 148 |
results: List[BatchResultItem]
|
| 149 |
created_at: datetime
|
|
@@ -179,3 +222,67 @@ class HealthResponse(BaseModel):
|
|
| 179 |
version: str
|
| 180 |
model_loaded: bool
|
| 181 |
database_connected: bool
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
max_length=settings.exon_length,
|
| 18 |
description=f"The {settings.exon_length}-nucleotide exon sequence (A, C, G, T only)",
|
| 19 |
)
|
| 20 |
+
name: Optional[str] = Field(
|
| 21 |
+
None,
|
| 22 |
+
max_length=255,
|
| 23 |
+
description="Optional name for the sequence",
|
| 24 |
+
)
|
| 25 |
email: Optional[EmailStr] = Field(
|
| 26 |
None,
|
| 27 |
description="Optional email address for job completion notification",
|
| 28 |
)
|
| 29 |
+
access_token: Optional[str] = Field(
|
| 30 |
+
None,
|
| 31 |
+
max_length=64,
|
| 32 |
+
description="User access token for job history tracking",
|
| 33 |
+
)
|
| 34 |
+
job_title: Optional[str] = Field(
|
| 35 |
+
None,
|
| 36 |
+
max_length=255,
|
| 37 |
+
description="Optional job title (auto-generated if not provided)",
|
| 38 |
+
)
|
| 39 |
|
| 40 |
@field_validator("sequence")
|
| 41 |
@classmethod
|
|
|
|
| 57 |
return v
|
| 58 |
|
| 59 |
|
| 60 |
+
class SequenceItem(BaseModel):
|
| 61 |
+
"""Schema for a named sequence in batch requests."""
|
| 62 |
+
|
| 63 |
+
name: str = Field(
|
| 64 |
+
...,
|
| 65 |
+
max_length=255,
|
| 66 |
+
description="Name/identifier for this sequence",
|
| 67 |
+
)
|
| 68 |
+
sequence: str = Field(
|
| 69 |
+
...,
|
| 70 |
+
description=f"The {settings.exon_length}-nucleotide exon sequence",
|
| 71 |
+
)
|
| 72 |
+
|
| 73 |
+
|
| 74 |
class BatchSequenceInput(BaseModel):
|
| 75 |
"""Schema for batch sequence prediction request."""
|
| 76 |
|
| 77 |
+
sequences: List[SequenceItem] = Field(
|
| 78 |
...,
|
| 79 |
min_length=1,
|
| 80 |
max_length=settings.max_batch_size,
|
| 81 |
+
description=f"List of named sequences with {settings.exon_length}-nucleotide exon sequences",
|
| 82 |
)
|
| 83 |
email: Optional[EmailStr] = Field(
|
| 84 |
None,
|
| 85 |
description="Optional email address for job completion notification",
|
| 86 |
)
|
| 87 |
+
access_token: Optional[str] = Field(
|
| 88 |
+
None,
|
| 89 |
+
max_length=64,
|
| 90 |
+
description="User access token for job history tracking",
|
| 91 |
+
)
|
| 92 |
+
job_title: Optional[str] = Field(
|
| 93 |
+
None,
|
| 94 |
+
max_length=255,
|
| 95 |
+
description="Optional job title (auto-generated if not provided)",
|
| 96 |
+
)
|
| 97 |
|
| 98 |
@field_validator("sequences")
|
| 99 |
@classmethod
|
| 100 |
+
def validate_sequences(cls, v: List[SequenceItem]) -> List[SequenceItem]:
|
| 101 |
+
"""Normalize sequences (don't reject invalid ones - they'll be marked in results)."""
|
| 102 |
+
normalized = []
|
| 103 |
+
for item in v:
|
| 104 |
+
# Normalize the sequence (uppercase, U->T)
|
| 105 |
+
normalized_seq = item.sequence.upper().replace("U", "T").strip()
|
| 106 |
+
normalized.append(SequenceItem(name=item.name, sequence=normalized_seq))
|
| 107 |
+
return normalized
|
| 108 |
|
|
|
|
|
|
|
| 109 |
|
| 110 |
+
def validate_single_sequence(sequence: str) -> tuple[bool, str]:
|
| 111 |
+
"""
|
| 112 |
+
Validate a single sequence and return (is_valid, error_message).
|
| 113 |
+
Used by batch processing to mark invalid sequences without rejecting.
|
| 114 |
+
"""
|
| 115 |
+
seq = sequence.upper().replace("U", "T").strip()
|
| 116 |
|
| 117 |
+
if len(seq) != settings.exon_length:
|
| 118 |
+
return False, f"Must be exactly {settings.exon_length} nucleotides (got {len(seq)})"
|
|
|
|
|
|
|
|
|
|
|
|
|
| 119 |
|
| 120 |
+
if not re.match(f"^[ACGT]{{{settings.exon_length}}}$", seq):
|
| 121 |
+
invalid_chars = set(seq) - set("ACGT")
|
| 122 |
+
return False, f"Contains invalid characters: {invalid_chars}"
|
| 123 |
|
| 124 |
+
return True, ""
|
|
|
|
|
|
|
|
|
|
| 125 |
|
| 126 |
|
| 127 |
class PredictionResponse(BaseModel):
|
|
|
|
| 164 |
class BatchResultItem(BaseModel):
|
| 165 |
"""Schema for a single item in batch results."""
|
| 166 |
|
| 167 |
+
index: Optional[int] = None # Original index in batch (for detail lookup)
|
| 168 |
+
name: str
|
| 169 |
sequence: str
|
| 170 |
+
status: str # "success", "invalid", "error"
|
| 171 |
psi: Optional[float] = None
|
| 172 |
interpretation: Optional[str] = None
|
| 173 |
structure: Optional[str] = None
|
| 174 |
mfe: Optional[float] = None
|
| 175 |
+
force_plot_data: Optional[Dict[str, Any]] = None
|
| 176 |
+
validation_error: Optional[str] = None # For invalid sequences
|
| 177 |
+
error: Optional[str] = None # For processing errors
|
| 178 |
warnings: Optional[List[str]] = None
|
| 179 |
|
| 180 |
|
|
|
|
| 182 |
"""Schema for batch prediction results."""
|
| 183 |
|
| 184 |
job_id: str
|
| 185 |
+
job_title: Optional[str] = None
|
| 186 |
status: str
|
| 187 |
total_sequences: int
|
| 188 |
successful: int
|
| 189 |
+
invalid: int
|
| 190 |
failed: int
|
| 191 |
results: List[BatchResultItem]
|
| 192 |
created_at: datetime
|
|
|
|
| 222 |
version: str
|
| 223 |
model_loaded: bool
|
| 224 |
database_connected: bool
|
| 225 |
+
|
| 226 |
+
|
| 227 |
+
# ============================================================================
|
| 228 |
+
# History and Job Management Schemas
|
| 229 |
+
# ============================================================================
|
| 230 |
+
|
| 231 |
+
|
| 232 |
+
class JobSummary(BaseModel):
|
| 233 |
+
"""Schema for job summary in history list."""
|
| 234 |
+
|
| 235 |
+
id: str
|
| 236 |
+
job_title: Optional[str] = None
|
| 237 |
+
created_at: datetime
|
| 238 |
+
status: str
|
| 239 |
+
is_batch: bool
|
| 240 |
+
sequence_count: int
|
| 241 |
+
|
| 242 |
+
|
| 243 |
+
class JobHistoryResponse(BaseModel):
|
| 244 |
+
"""Schema for paginated job history response."""
|
| 245 |
+
|
| 246 |
+
jobs: List[JobSummary]
|
| 247 |
+
total: int
|
| 248 |
+
page: int
|
| 249 |
+
page_size: int
|
| 250 |
+
total_pages: int
|
| 251 |
+
|
| 252 |
+
|
| 253 |
+
class PaginatedBatchResultsResponse(BaseModel):
|
| 254 |
+
"""Schema for paginated batch results."""
|
| 255 |
+
|
| 256 |
+
job_id: str
|
| 257 |
+
job_title: Optional[str] = None
|
| 258 |
+
status: str
|
| 259 |
+
total_sequences: int
|
| 260 |
+
successful_count: int
|
| 261 |
+
invalid_count: int
|
| 262 |
+
failed_count: int
|
| 263 |
+
average_psi: Optional[float] = None # Average PSI of successful sequences
|
| 264 |
+
results: List[BatchResultItem]
|
| 265 |
+
total: int # Total results after filtering (for pagination)
|
| 266 |
+
page: int
|
| 267 |
+
page_size: int
|
| 268 |
+
total_pages: int
|
| 269 |
+
created_at: datetime
|
| 270 |
+
expires_at: datetime
|
| 271 |
+
|
| 272 |
+
|
| 273 |
+
class SequenceDetailResponse(BaseModel):
|
| 274 |
+
"""Schema for detailed single sequence from batch."""
|
| 275 |
+
|
| 276 |
+
job_id: str
|
| 277 |
+
index: int
|
| 278 |
+
name: str
|
| 279 |
+
sequence: str
|
| 280 |
+
status: str
|
| 281 |
+
psi: Optional[float] = None
|
| 282 |
+
interpretation: Optional[str] = None
|
| 283 |
+
structure: Optional[str] = None
|
| 284 |
+
mfe: Optional[float] = None
|
| 285 |
+
force_plot_data: Optional[Dict[str, Any]] = None
|
| 286 |
+
validation_error: Optional[str] = None
|
| 287 |
+
error: Optional[str] = None
|
| 288 |
+
warnings: Optional[List[str]] = None
|
webapp/app/main.py
CHANGED
|
@@ -10,8 +10,19 @@ from fastapi.middleware.cors import CORSMiddleware
|
|
| 10 |
from pathlib import Path
|
| 11 |
|
| 12 |
from webapp.app.config import settings
|
| 13 |
-
from webapp.app.database import init_db
|
| 14 |
from webapp.app.api.routes import router as api_router
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
|
| 16 |
# Configure logging
|
| 17 |
logging.basicConfig(
|
|
@@ -91,6 +102,15 @@ static_path = Path(__file__).parent.parent / "static"
|
|
| 91 |
if static_path.exists():
|
| 92 |
app.mount("/static", StaticFiles(directory=str(static_path)), name="static")
|
| 93 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 94 |
# Set up templates
|
| 95 |
templates_path = Path(__file__).parent.parent / "templates"
|
| 96 |
templates = Jinja2Templates(directory=str(templates_path)) if templates_path.exists() else None
|
|
@@ -108,9 +128,18 @@ async def home(request: Request):
|
|
| 108 |
|
| 109 |
@app.get("/result/{job_id}", response_class=HTMLResponse, include_in_schema=False)
|
| 110 |
async def result_page(request: Request, job_id: str):
|
| 111 |
-
"""Render the result page for a job."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 112 |
return templates.TemplateResponse(
|
| 113 |
-
|
| 114 |
{"request": request, "job_id": job_id, "settings": settings}
|
| 115 |
)
|
| 116 |
|
|
@@ -139,6 +168,12 @@ async def methodology_page(request: Request):
|
|
| 139 |
return templates.TemplateResponse("methodology.html", {"request": request, "settings": settings})
|
| 140 |
|
| 141 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 142 |
if __name__ == "__main__":
|
| 143 |
import uvicorn
|
| 144 |
uvicorn.run(app, host="0.0.0.0", port=8000)
|
|
|
|
| 10 |
from pathlib import Path
|
| 11 |
|
| 12 |
from webapp.app.config import settings
|
| 13 |
+
from webapp.app.database import init_db, get_db
|
| 14 |
from webapp.app.api.routes import router as api_router
|
| 15 |
+
from webapp.app.models.job import Job
|
| 16 |
+
|
| 17 |
+
# PyShiny imports for heatmap visualization
|
| 18 |
+
try:
|
| 19 |
+
from shiny import App
|
| 20 |
+
from webapp.app.shiny_apps.heatmap_app import create_app as create_heatmap_app
|
| 21 |
+
SHINY_AVAILABLE = True
|
| 22 |
+
except ImportError:
|
| 23 |
+
SHINY_AVAILABLE = False
|
| 24 |
+
logger = logging.getLogger(__name__)
|
| 25 |
+
logger.warning("PyShiny not available - heatmap visualization will be disabled")
|
| 26 |
|
| 27 |
# Configure logging
|
| 28 |
logging.basicConfig(
|
|
|
|
| 102 |
if static_path.exists():
|
| 103 |
app.mount("/static", StaticFiles(directory=str(static_path)), name="static")
|
| 104 |
|
| 105 |
+
# Mount PyShiny heatmap app
|
| 106 |
+
if SHINY_AVAILABLE:
|
| 107 |
+
try:
|
| 108 |
+
heatmap_shiny_app = create_heatmap_app(api_base_url="http://localhost:8000")
|
| 109 |
+
app.mount("/shiny/heatmap", heatmap_shiny_app, name="shiny_heatmap")
|
| 110 |
+
logger.info("PyShiny heatmap app mounted at /shiny/heatmap")
|
| 111 |
+
except Exception as e:
|
| 112 |
+
logger.error(f"Failed to mount PyShiny heatmap app: {e}")
|
| 113 |
+
|
| 114 |
# Set up templates
|
| 115 |
templates_path = Path(__file__).parent.parent / "templates"
|
| 116 |
templates = Jinja2Templates(directory=str(templates_path)) if templates_path.exists() else None
|
|
|
|
| 128 |
|
| 129 |
@app.get("/result/{job_id}", response_class=HTMLResponse, include_in_schema=False)
|
| 130 |
async def result_page(request: Request, job_id: str):
|
| 131 |
+
"""Render the result page for a job. Uses batch_result.html for batch jobs."""
|
| 132 |
+
# Check if job is a batch job
|
| 133 |
+
db = next(get_db())
|
| 134 |
+
try:
|
| 135 |
+
job = db.query(Job).filter(Job.id == job_id).first()
|
| 136 |
+
is_batch = job.is_batch if job else False
|
| 137 |
+
finally:
|
| 138 |
+
db.close()
|
| 139 |
+
|
| 140 |
+
template_name = "batch_result.html" if is_batch else "result.html"
|
| 141 |
return templates.TemplateResponse(
|
| 142 |
+
template_name,
|
| 143 |
{"request": request, "job_id": job_id, "settings": settings}
|
| 144 |
)
|
| 145 |
|
|
|
|
| 168 |
return templates.TemplateResponse("methodology.html", {"request": request, "settings": settings})
|
| 169 |
|
| 170 |
|
| 171 |
+
@app.get("/history", response_class=HTMLResponse, include_in_schema=False)
|
| 172 |
+
async def history_page(request: Request):
|
| 173 |
+
"""Render the job history page."""
|
| 174 |
+
return templates.TemplateResponse("history.html", {"request": request, "settings": settings})
|
| 175 |
+
|
| 176 |
+
|
| 177 |
if __name__ == "__main__":
|
| 178 |
import uvicorn
|
| 179 |
uvicorn.run(app, host="0.0.0.0", port=8000)
|
webapp/app/models/job.py
CHANGED
|
@@ -29,10 +29,14 @@ class Job(Base):
|
|
| 29 |
default=lambda: datetime.utcnow() + timedelta(days=settings.job_retention_days),
|
| 30 |
)
|
| 31 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
# Input data
|
| 33 |
sequence = Column(Text, nullable=False)
|
| 34 |
is_batch = Column(Boolean, default=False)
|
| 35 |
-
batch_sequences = Column(Text, nullable=True) # JSON array
|
| 36 |
email = Column(String(255), nullable=True)
|
| 37 |
|
| 38 |
# Results (nullable until job finishes)
|
|
@@ -53,6 +57,8 @@ class Job(Base):
|
|
| 53 |
__table_args__ = (
|
| 54 |
Index("idx_jobs_status", "status"),
|
| 55 |
Index("idx_jobs_expires", "expires_at"),
|
|
|
|
|
|
|
| 56 |
)
|
| 57 |
|
| 58 |
def to_dict(self) -> dict:
|
|
@@ -63,6 +69,7 @@ class Job(Base):
|
|
| 63 |
"created_at": self.created_at.isoformat() if self.created_at else None,
|
| 64 |
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
|
| 65 |
"expires_at": self.expires_at.isoformat() if self.expires_at else None,
|
|
|
|
| 66 |
"sequence": self.sequence,
|
| 67 |
"is_batch": self.is_batch,
|
| 68 |
"email": self.email,
|
|
@@ -89,14 +96,21 @@ class Job(Base):
|
|
| 89 |
|
| 90 |
return result
|
| 91 |
|
| 92 |
-
def set_batch_sequences(self, sequences: List[
|
| 93 |
-
"""Set batch sequences as JSON."""
|
| 94 |
self.batch_sequences = json.dumps(sequences)
|
| 95 |
|
| 96 |
-
def get_batch_sequences(self) -> List[
|
| 97 |
-
"""Get batch sequences from JSON."""
|
| 98 |
return json.loads(self.batch_sequences) if self.batch_sequences else []
|
| 99 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 100 |
def set_batch_results(self, results: List[dict]):
|
| 101 |
"""Set batch results as JSON."""
|
| 102 |
self.batch_results = json.dumps(results)
|
|
|
|
| 29 |
default=lambda: datetime.utcnow() + timedelta(days=settings.job_retention_days),
|
| 30 |
)
|
| 31 |
|
| 32 |
+
# User identification (NAR compliant - no login required)
|
| 33 |
+
access_token = Column(String(64), nullable=True)
|
| 34 |
+
job_title = Column(String(255), nullable=True)
|
| 35 |
+
|
| 36 |
# Input data
|
| 37 |
sequence = Column(Text, nullable=False)
|
| 38 |
is_batch = Column(Boolean, default=False)
|
| 39 |
+
batch_sequences = Column(Text, nullable=True) # JSON array: [{name, sequence}, ...]
|
| 40 |
email = Column(String(255), nullable=True)
|
| 41 |
|
| 42 |
# Results (nullable until job finishes)
|
|
|
|
| 57 |
__table_args__ = (
|
| 58 |
Index("idx_jobs_status", "status"),
|
| 59 |
Index("idx_jobs_expires", "expires_at"),
|
| 60 |
+
Index("idx_jobs_access_token", "access_token"),
|
| 61 |
+
Index("idx_jobs_created_at", "created_at"),
|
| 62 |
)
|
| 63 |
|
| 64 |
def to_dict(self) -> dict:
|
|
|
|
| 69 |
"created_at": self.created_at.isoformat() if self.created_at else None,
|
| 70 |
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
|
| 71 |
"expires_at": self.expires_at.isoformat() if self.expires_at else None,
|
| 72 |
+
"job_title": self.job_title,
|
| 73 |
"sequence": self.sequence,
|
| 74 |
"is_batch": self.is_batch,
|
| 75 |
"email": self.email,
|
|
|
|
| 96 |
|
| 97 |
return result
|
| 98 |
|
| 99 |
+
def set_batch_sequences(self, sequences: List[dict]):
|
| 100 |
+
"""Set batch sequences as JSON. Format: [{name: str, sequence: str}, ...]"""
|
| 101 |
self.batch_sequences = json.dumps(sequences)
|
| 102 |
|
| 103 |
+
def get_batch_sequences(self) -> List[dict]:
|
| 104 |
+
"""Get batch sequences from JSON. Returns [{name: str, sequence: str}, ...]"""
|
| 105 |
return json.loads(self.batch_sequences) if self.batch_sequences else []
|
| 106 |
|
| 107 |
+
def get_sequence_count(self) -> int:
|
| 108 |
+
"""Get the number of sequences in this job."""
|
| 109 |
+
if not self.is_batch:
|
| 110 |
+
return 1
|
| 111 |
+
sequences = self.get_batch_sequences()
|
| 112 |
+
return len(sequences) if sequences else 1
|
| 113 |
+
|
| 114 |
def set_batch_results(self, results: List[dict]):
|
| 115 |
"""Set batch results as JSON."""
|
| 116 |
self.batch_results = json.dumps(results)
|
webapp/app/services/predictor.py
CHANGED
|
@@ -331,6 +331,78 @@ class SplicingPredictor:
|
|
| 331 |
"mfe": mfe,
|
| 332 |
}
|
| 333 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 334 |
@staticmethod
|
| 335 |
def _get_interpretation(psi: float) -> str:
|
| 336 |
"""Get human-readable interpretation of PSI value."""
|
|
|
|
| 331 |
"mfe": mfe,
|
| 332 |
}
|
| 333 |
|
| 334 |
+
def get_heatmap_data(self, exon_sequence: str) -> Dict[str, Any]:
|
| 335 |
+
"""
|
| 336 |
+
Extract filter activations for heatmap visualization.
|
| 337 |
+
|
| 338 |
+
Args:
|
| 339 |
+
exon_sequence: The 70nt exon sequence
|
| 340 |
+
|
| 341 |
+
Returns:
|
| 342 |
+
Dictionary with heatmap data:
|
| 343 |
+
- positions: list of position numbers (1-90)
|
| 344 |
+
- nucleotides: list of nucleotide characters
|
| 345 |
+
- filter_names: list of filter names
|
| 346 |
+
- activations: 2D matrix (filters × positions)
|
| 347 |
+
"""
|
| 348 |
+
# Prepare input
|
| 349 |
+
inputs, structure, mfe, _ = self.prepare_input(exon_sequence)
|
| 350 |
+
|
| 351 |
+
# Get full sequence for nucleotide labels
|
| 352 |
+
full_sequence = self.add_flanking(exon_sequence.upper())
|
| 353 |
+
|
| 354 |
+
# Layer configurations: (layer_name, prefix, num_filters, kernel_width)
|
| 355 |
+
layer_configs = [
|
| 356 |
+
("qc_incl", "incl", 20, 6),
|
| 357 |
+
("qc_skip", "skip", 20, 6),
|
| 358 |
+
("c_incl_struct", "incl_struct", 8, 30),
|
| 359 |
+
("c_skip_struct", "skip_struct", 8, 30),
|
| 360 |
+
]
|
| 361 |
+
|
| 362 |
+
filter_names = []
|
| 363 |
+
all_activations = []
|
| 364 |
+
|
| 365 |
+
for layer_name, prefix, num_filters, kernel_width in layer_configs:
|
| 366 |
+
try:
|
| 367 |
+
layer = self.model.get_layer(layer_name)
|
| 368 |
+
intermediate_model = tf.keras.Model(
|
| 369 |
+
inputs=self.model.inputs,
|
| 370 |
+
outputs=layer.output,
|
| 371 |
+
)
|
| 372 |
+
# Get activations: shape (1, output_len, num_filters)
|
| 373 |
+
raw_activations = intermediate_model.predict(inputs, verbose=0)[0]
|
| 374 |
+
output_len = raw_activations.shape[0]
|
| 375 |
+
|
| 376 |
+
for i in range(num_filters):
|
| 377 |
+
filter_name = f"{prefix}_{i+1}"
|
| 378 |
+
filter_names.append(filter_name)
|
| 379 |
+
|
| 380 |
+
# Get this filter's activations and apply ReLU
|
| 381 |
+
filter_activations = np.maximum(0, raw_activations[:, i])
|
| 382 |
+
|
| 383 |
+
# Pad to 90 positions if needed
|
| 384 |
+
if output_len < settings.total_length:
|
| 385 |
+
# Center the activations with padding
|
| 386 |
+
pad_left = (settings.total_length - output_len) // 2
|
| 387 |
+
pad_right = settings.total_length - output_len - pad_left
|
| 388 |
+
padded = np.pad(filter_activations, (pad_left, pad_right), mode='constant')
|
| 389 |
+
else:
|
| 390 |
+
# Already correct length
|
| 391 |
+
padded = filter_activations
|
| 392 |
+
|
| 393 |
+
all_activations.append(padded.tolist())
|
| 394 |
+
|
| 395 |
+
except Exception as e:
|
| 396 |
+
logger.warning(f"Could not extract layer {layer_name}: {e}")
|
| 397 |
+
|
| 398 |
+
return {
|
| 399 |
+
"positions": list(range(1, settings.total_length + 1)),
|
| 400 |
+
"nucleotides": list(full_sequence),
|
| 401 |
+
"filter_names": filter_names,
|
| 402 |
+
"activations": all_activations,
|
| 403 |
+
"structure": structure,
|
| 404 |
+
}
|
| 405 |
+
|
| 406 |
@staticmethod
|
| 407 |
def _get_interpretation(psi: float) -> str:
|
| 408 |
"""Get human-readable interpretation of PSI value."""
|
webapp/app/shiny_apps/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""PyShiny applications for visualization."""
|
webapp/app/shiny_apps/heatmap_app.py
ADDED
|
@@ -0,0 +1,306 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""PyShiny app for Filter × Position heatmap visualization."""
|
| 2 |
+
|
| 3 |
+
from shiny import App, ui, render, reactive
|
| 4 |
+
import plotly.graph_objects as go
|
| 5 |
+
from plotly.subplots import make_subplots
|
| 6 |
+
import httpx
|
| 7 |
+
import numpy as np
|
| 8 |
+
from urllib.parse import parse_qs
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def create_app(api_base_url: str = "http://localhost:8000"):
|
| 12 |
+
"""Create the PyShiny heatmap app.
|
| 13 |
+
|
| 14 |
+
Args:
|
| 15 |
+
api_base_url: Base URL for the API to fetch heatmap data
|
| 16 |
+
"""
|
| 17 |
+
|
| 18 |
+
app_ui = ui.page_fluid(
|
| 19 |
+
ui.head_content(
|
| 20 |
+
ui.tags.style("""
|
| 21 |
+
body {
|
| 22 |
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
| 23 |
+
background: #f9fafb;
|
| 24 |
+
margin: 0;
|
| 25 |
+
padding: 16px;
|
| 26 |
+
}
|
| 27 |
+
.filter-panel {
|
| 28 |
+
background: white;
|
| 29 |
+
border-radius: 8px;
|
| 30 |
+
padding: 16px;
|
| 31 |
+
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
| 32 |
+
max-height: 600px;
|
| 33 |
+
overflow-y: auto;
|
| 34 |
+
}
|
| 35 |
+
.filter-section h4 {
|
| 36 |
+
margin: 0 0 8px 0;
|
| 37 |
+
font-size: 14px;
|
| 38 |
+
color: #374151;
|
| 39 |
+
}
|
| 40 |
+
.filter-section {
|
| 41 |
+
margin-bottom: 16px;
|
| 42 |
+
}
|
| 43 |
+
.heatmap-container {
|
| 44 |
+
background: white;
|
| 45 |
+
border-radius: 8px;
|
| 46 |
+
padding: 16px;
|
| 47 |
+
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
| 48 |
+
}
|
| 49 |
+
.error-message {
|
| 50 |
+
color: #dc2626;
|
| 51 |
+
padding: 20px;
|
| 52 |
+
text-align: center;
|
| 53 |
+
}
|
| 54 |
+
.loading {
|
| 55 |
+
padding: 40px;
|
| 56 |
+
text-align: center;
|
| 57 |
+
color: #6b7280;
|
| 58 |
+
}
|
| 59 |
+
""")
|
| 60 |
+
),
|
| 61 |
+
ui.row(
|
| 62 |
+
ui.column(
|
| 63 |
+
3,
|
| 64 |
+
ui.div(
|
| 65 |
+
{"class": "filter-panel"},
|
| 66 |
+
ui.h4("Filter × Position heatmap"),
|
| 67 |
+
ui.div(
|
| 68 |
+
{"class": "filter-section"},
|
| 69 |
+
ui.h4("Inclusion Filters", style="color: #22c55e;"),
|
| 70 |
+
ui.output_ui("inclusion_checkboxes"),
|
| 71 |
+
),
|
| 72 |
+
ui.div(
|
| 73 |
+
{"class": "filter-section"},
|
| 74 |
+
ui.h4("Skipping Filters", style="color: #ef4444;"),
|
| 75 |
+
ui.output_ui("skipping_checkboxes"),
|
| 76 |
+
),
|
| 77 |
+
ui.div(
|
| 78 |
+
{"class": "filter-section"},
|
| 79 |
+
ui.h4("Structure Filters"),
|
| 80 |
+
ui.output_ui("structure_checkboxes"),
|
| 81 |
+
),
|
| 82 |
+
ui.hr(),
|
| 83 |
+
ui.input_action_button("select_all", "Select All", class_="btn-sm"),
|
| 84 |
+
ui.input_action_button("deselect_all", "Deselect All", class_="btn-sm"),
|
| 85 |
+
),
|
| 86 |
+
),
|
| 87 |
+
ui.column(
|
| 88 |
+
9,
|
| 89 |
+
ui.div(
|
| 90 |
+
{"class": "heatmap-container"},
|
| 91 |
+
ui.output_ui("heatmap_plot"),
|
| 92 |
+
),
|
| 93 |
+
),
|
| 94 |
+
),
|
| 95 |
+
)
|
| 96 |
+
|
| 97 |
+
def server(input, output, session):
|
| 98 |
+
# Reactive value to store heatmap data
|
| 99 |
+
heatmap_data = reactive.Value(None)
|
| 100 |
+
error_message = reactive.Value(None)
|
| 101 |
+
selected_filters = reactive.Value(set())
|
| 102 |
+
|
| 103 |
+
def get_job_id_from_url():
|
| 104 |
+
"""Extract job_id from URL query parameters."""
|
| 105 |
+
try:
|
| 106 |
+
# Access the ASGI scope to get query string
|
| 107 |
+
scope = session.http_conn.scope if hasattr(session, 'http_conn') else None
|
| 108 |
+
if scope and 'query_string' in scope:
|
| 109 |
+
query_string = scope['query_string'].decode('utf-8')
|
| 110 |
+
params = parse_qs(query_string)
|
| 111 |
+
if 'job_id' in params:
|
| 112 |
+
return params['job_id'][0]
|
| 113 |
+
except Exception:
|
| 114 |
+
pass
|
| 115 |
+
return None
|
| 116 |
+
|
| 117 |
+
@reactive.Effect
|
| 118 |
+
def fetch_data():
|
| 119 |
+
"""Fetch heatmap data from API on app load."""
|
| 120 |
+
job_id = get_job_id_from_url()
|
| 121 |
+
|
| 122 |
+
if not job_id:
|
| 123 |
+
error_message.set("No job_id provided in URL. Please access this page from a result page.")
|
| 124 |
+
return
|
| 125 |
+
|
| 126 |
+
try:
|
| 127 |
+
# Fetch heatmap data from API
|
| 128 |
+
with httpx.Client(timeout=30.0) as client:
|
| 129 |
+
response = client.get(f"{api_base_url}/api/heatmap/{job_id}")
|
| 130 |
+
response.raise_for_status()
|
| 131 |
+
data = response.json()
|
| 132 |
+
heatmap_data.set(data)
|
| 133 |
+
|
| 134 |
+
# Initialize all filters as selected
|
| 135 |
+
if data and "filter_names" in data:
|
| 136 |
+
selected_filters.set(set(data["filter_names"]))
|
| 137 |
+
|
| 138 |
+
except httpx.HTTPError as e:
|
| 139 |
+
error_message.set(f"Error fetching data: {str(e)}")
|
| 140 |
+
except Exception as e:
|
| 141 |
+
error_message.set(f"Unexpected error: {str(e)}")
|
| 142 |
+
|
| 143 |
+
@output
|
| 144 |
+
@render.ui
|
| 145 |
+
def inclusion_checkboxes():
|
| 146 |
+
data = heatmap_data.get()
|
| 147 |
+
if not data or "filter_names" not in data:
|
| 148 |
+
return ui.p("Loading...")
|
| 149 |
+
|
| 150 |
+
incl_filters = [f for f in data["filter_names"] if f.startswith("incl_") and not f.startswith("incl_struct")]
|
| 151 |
+
|
| 152 |
+
return ui.input_checkbox_group(
|
| 153 |
+
"incl_filters",
|
| 154 |
+
None,
|
| 155 |
+
choices={f: f for f in incl_filters},
|
| 156 |
+
selected=incl_filters,
|
| 157 |
+
)
|
| 158 |
+
|
| 159 |
+
@output
|
| 160 |
+
@render.ui
|
| 161 |
+
def skipping_checkboxes():
|
| 162 |
+
data = heatmap_data.get()
|
| 163 |
+
if not data or "filter_names" not in data:
|
| 164 |
+
return ui.p("Loading...")
|
| 165 |
+
|
| 166 |
+
skip_filters = [f for f in data["filter_names"] if f.startswith("skip_") and not f.startswith("skip_struct")]
|
| 167 |
+
|
| 168 |
+
return ui.input_checkbox_group(
|
| 169 |
+
"skip_filters",
|
| 170 |
+
None,
|
| 171 |
+
choices={f: f for f in skip_filters},
|
| 172 |
+
selected=skip_filters,
|
| 173 |
+
)
|
| 174 |
+
|
| 175 |
+
@output
|
| 176 |
+
@render.ui
|
| 177 |
+
def structure_checkboxes():
|
| 178 |
+
data = heatmap_data.get()
|
| 179 |
+
if not data or "filter_names" not in data:
|
| 180 |
+
return ui.p("Loading...")
|
| 181 |
+
|
| 182 |
+
struct_filters = [f for f in data["filter_names"] if "struct" in f]
|
| 183 |
+
|
| 184 |
+
return ui.input_checkbox_group(
|
| 185 |
+
"struct_filters",
|
| 186 |
+
None,
|
| 187 |
+
choices={f: f for f in struct_filters},
|
| 188 |
+
selected=struct_filters,
|
| 189 |
+
)
|
| 190 |
+
|
| 191 |
+
@reactive.Effect
|
| 192 |
+
@reactive.event(input.select_all)
|
| 193 |
+
def select_all_filters():
|
| 194 |
+
data = heatmap_data.get()
|
| 195 |
+
if data and "filter_names" in data:
|
| 196 |
+
# Update all checkbox groups
|
| 197 |
+
incl_filters = [f for f in data["filter_names"] if f.startswith("incl_") and not f.startswith("incl_struct")]
|
| 198 |
+
skip_filters = [f for f in data["filter_names"] if f.startswith("skip_") and not f.startswith("skip_struct")]
|
| 199 |
+
struct_filters = [f for f in data["filter_names"] if "struct" in f]
|
| 200 |
+
|
| 201 |
+
ui.update_checkbox_group("incl_filters", selected=incl_filters)
|
| 202 |
+
ui.update_checkbox_group("skip_filters", selected=skip_filters)
|
| 203 |
+
ui.update_checkbox_group("struct_filters", selected=struct_filters)
|
| 204 |
+
|
| 205 |
+
@reactive.Effect
|
| 206 |
+
@reactive.event(input.deselect_all)
|
| 207 |
+
def deselect_all_filters():
|
| 208 |
+
ui.update_checkbox_group("incl_filters", selected=[])
|
| 209 |
+
ui.update_checkbox_group("skip_filters", selected=[])
|
| 210 |
+
ui.update_checkbox_group("struct_filters", selected=[])
|
| 211 |
+
|
| 212 |
+
@output
|
| 213 |
+
@render.ui
|
| 214 |
+
def heatmap_plot():
|
| 215 |
+
err = error_message.get()
|
| 216 |
+
if err:
|
| 217 |
+
return ui.div({"class": "error-message"}, err)
|
| 218 |
+
|
| 219 |
+
data = heatmap_data.get()
|
| 220 |
+
if not data:
|
| 221 |
+
return ui.div({"class": "loading"}, "Loading heatmap data...")
|
| 222 |
+
|
| 223 |
+
# Get selected filters from all checkbox groups
|
| 224 |
+
incl_selected = list(input.incl_filters()) if input.incl_filters() else []
|
| 225 |
+
skip_selected = list(input.skip_filters()) if input.skip_filters() else []
|
| 226 |
+
struct_selected = list(input.struct_filters()) if input.struct_filters() else []
|
| 227 |
+
|
| 228 |
+
all_selected = incl_selected + skip_selected + struct_selected
|
| 229 |
+
|
| 230 |
+
if not all_selected:
|
| 231 |
+
return ui.div(
|
| 232 |
+
{"class": "loading"},
|
| 233 |
+
"Select at least one filter to display the heatmap."
|
| 234 |
+
)
|
| 235 |
+
|
| 236 |
+
# Filter data based on selected filters
|
| 237 |
+
filter_names = data["filter_names"]
|
| 238 |
+
activations = data["activations"]
|
| 239 |
+
nucleotides = data["nucleotides"]
|
| 240 |
+
positions = data["positions"]
|
| 241 |
+
|
| 242 |
+
# Get indices of selected filters
|
| 243 |
+
selected_indices = [i for i, name in enumerate(filter_names) if name in all_selected]
|
| 244 |
+
|
| 245 |
+
if not selected_indices:
|
| 246 |
+
return ui.div({"class": "loading"}, "No filters selected.")
|
| 247 |
+
|
| 248 |
+
# Create filtered activation matrix
|
| 249 |
+
filtered_names = [filter_names[i] for i in selected_indices]
|
| 250 |
+
filtered_activations = [activations[i] for i in selected_indices]
|
| 251 |
+
|
| 252 |
+
# Create x-axis labels (nucleotides)
|
| 253 |
+
x_labels = [f"{nucleotides[i]}" for i in range(len(nucleotides))]
|
| 254 |
+
|
| 255 |
+
# Create heatmap
|
| 256 |
+
fig = go.Figure(data=go.Heatmap(
|
| 257 |
+
z=filtered_activations,
|
| 258 |
+
x=x_labels,
|
| 259 |
+
y=filtered_names,
|
| 260 |
+
colorscale="Viridis",
|
| 261 |
+
colorbar=dict(
|
| 262 |
+
title="Strength",
|
| 263 |
+
titleside="right",
|
| 264 |
+
),
|
| 265 |
+
hovertemplate=(
|
| 266 |
+
"Position: %{x}<br>"
|
| 267 |
+
"Filter: %{y}<br>"
|
| 268 |
+
"Activation: %{z:.3f}<br>"
|
| 269 |
+
"<extra></extra>"
|
| 270 |
+
),
|
| 271 |
+
))
|
| 272 |
+
|
| 273 |
+
fig.update_layout(
|
| 274 |
+
title=dict(
|
| 275 |
+
text="Filter Activations by Position",
|
| 276 |
+
x=0.5,
|
| 277 |
+
font=dict(size=16),
|
| 278 |
+
),
|
| 279 |
+
xaxis=dict(
|
| 280 |
+
title="Sequence Position",
|
| 281 |
+
tickangle=0,
|
| 282 |
+
tickfont=dict(size=8),
|
| 283 |
+
dtick=5, # Show every 5th tick
|
| 284 |
+
),
|
| 285 |
+
yaxis=dict(
|
| 286 |
+
title="Filter",
|
| 287 |
+
tickfont=dict(size=10),
|
| 288 |
+
),
|
| 289 |
+
height=max(400, len(filtered_names) * 20 + 100),
|
| 290 |
+
margin=dict(l=100, r=50, t=50, b=100),
|
| 291 |
+
)
|
| 292 |
+
|
| 293 |
+
# Convert to HTML
|
| 294 |
+
html_content = fig.to_html(
|
| 295 |
+
full_html=False,
|
| 296 |
+
include_plotlyjs="cdn",
|
| 297 |
+
config={"responsive": True},
|
| 298 |
+
)
|
| 299 |
+
|
| 300 |
+
return ui.HTML(html_content)
|
| 301 |
+
|
| 302 |
+
return App(app_ui, server)
|
| 303 |
+
|
| 304 |
+
|
| 305 |
+
# Create the app instance
|
| 306 |
+
app = create_app()
|
webapp/requirements.txt
CHANGED
|
@@ -18,6 +18,7 @@ scipy # Required by figutils
|
|
| 18 |
# Visualization
|
| 19 |
plotly>=5.18.0
|
| 20 |
kaleido>=0.2.1
|
|
|
|
| 21 |
|
| 22 |
# API Documentation
|
| 23 |
scalar-fastapi>=1.0.0
|
|
|
|
| 18 |
# Visualization
|
| 19 |
plotly>=5.18.0
|
| 20 |
kaleido>=0.2.1
|
| 21 |
+
shiny>=0.8.0 # PyShiny for interactive visualizations
|
| 22 |
|
| 23 |
# API Documentation
|
| 24 |
scalar-fastapi>=1.0.0
|
webapp/static/js/batch-result.js
ADDED
|
@@ -0,0 +1,574 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Batch result page JavaScript - Pagination, search, and detail view
|
| 3 |
+
*/
|
| 4 |
+
|
| 5 |
+
// State
|
| 6 |
+
let currentPage = 1;
|
| 7 |
+
let pageSize = 25;
|
| 8 |
+
let totalResults = 0;
|
| 9 |
+
let totalPages = 1;
|
| 10 |
+
let searchQuery = '';
|
| 11 |
+
let jobInfo = null;
|
| 12 |
+
|
| 13 |
+
// DOM Elements
|
| 14 |
+
const loadingState = document.getElementById('loading-state');
|
| 15 |
+
const loadingText = document.getElementById('loading-text');
|
| 16 |
+
const resultsContainer = document.getElementById('results-container');
|
| 17 |
+
const errorState = document.getElementById('error-state');
|
| 18 |
+
const errorMessageEl = document.getElementById('error-message');
|
| 19 |
+
const jobTitleEl = document.getElementById('job-title');
|
| 20 |
+
const resultsTableBody = document.getElementById('results-table-body');
|
| 21 |
+
const searchInput = document.getElementById('search-results');
|
| 22 |
+
|
| 23 |
+
// Stats elements
|
| 24 |
+
const statTotal = document.getElementById('stat-total');
|
| 25 |
+
const statSuccess = document.getElementById('stat-success');
|
| 26 |
+
const statInvalid = document.getElementById('stat-invalid');
|
| 27 |
+
const statAvgPsi = document.getElementById('stat-avg-psi');
|
| 28 |
+
|
| 29 |
+
// Pagination elements
|
| 30 |
+
const pageStart = document.getElementById('page-start');
|
| 31 |
+
const pageEnd = document.getElementById('page-end');
|
| 32 |
+
const totalResultsEl = document.getElementById('total-results');
|
| 33 |
+
const pageButtons = document.getElementById('page-buttons');
|
| 34 |
+
|
| 35 |
+
// Export links
|
| 36 |
+
const exportCsv = document.getElementById('export-csv');
|
| 37 |
+
const exportJson = document.getElementById('export-json');
|
| 38 |
+
|
| 39 |
+
// Polling configuration
|
| 40 |
+
const POLL_INTERVAL = 2000; // 2 seconds
|
| 41 |
+
const MAX_POLLS = 120; // 4 minutes max
|
| 42 |
+
let pollCount = 0;
|
| 43 |
+
|
| 44 |
+
/**
|
| 45 |
+
* Initialize the page
|
| 46 |
+
*/
|
| 47 |
+
document.addEventListener('DOMContentLoaded', () => {
|
| 48 |
+
if (typeof jobId === 'undefined' || !jobId) {
|
| 49 |
+
showError('No job ID provided');
|
| 50 |
+
return;
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
// Set export links
|
| 54 |
+
exportCsv.href = `/api/export/${jobId}/csv`;
|
| 55 |
+
exportJson.href = `/api/export/${jobId}/json`;
|
| 56 |
+
|
| 57 |
+
// Event listeners
|
| 58 |
+
if (searchInput) {
|
| 59 |
+
let searchTimeout;
|
| 60 |
+
searchInput.addEventListener('input', (e) => {
|
| 61 |
+
clearTimeout(searchTimeout);
|
| 62 |
+
searchTimeout = setTimeout(() => {
|
| 63 |
+
searchQuery = e.target.value.trim();
|
| 64 |
+
currentPage = 1;
|
| 65 |
+
loadResults();
|
| 66 |
+
}, 300);
|
| 67 |
+
});
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
// Start loading
|
| 71 |
+
fetchJobInfo();
|
| 72 |
+
});
|
| 73 |
+
|
| 74 |
+
/**
|
| 75 |
+
* Fetch job information first
|
| 76 |
+
*/
|
| 77 |
+
async function fetchJobInfo() {
|
| 78 |
+
try {
|
| 79 |
+
const response = await fetch(`/api/result/${jobId}`);
|
| 80 |
+
|
| 81 |
+
if (response.status === 404) {
|
| 82 |
+
showError('Job not found. It may have expired.');
|
| 83 |
+
return;
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
if (!response.ok) {
|
| 87 |
+
throw new Error('Failed to fetch job info');
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
const data = await response.json();
|
| 91 |
+
jobInfo = data;
|
| 92 |
+
|
| 93 |
+
// Check status
|
| 94 |
+
if (data.status === 'finished' || data.status === 'completed') {
|
| 95 |
+
// Job is done, load results
|
| 96 |
+
updateJobHeader();
|
| 97 |
+
loadResults();
|
| 98 |
+
} else if (data.status === 'failed') {
|
| 99 |
+
showError(data.error || 'Batch processing failed');
|
| 100 |
+
} else if (data.status === 'running' || data.status === 'queued') {
|
| 101 |
+
// Still processing
|
| 102 |
+
loadingText.textContent = `Processing batch... (${pollCount * 2}s)`;
|
| 103 |
+
pollCount++;
|
| 104 |
+
if (pollCount < MAX_POLLS) {
|
| 105 |
+
setTimeout(fetchJobInfo, POLL_INTERVAL);
|
| 106 |
+
} else {
|
| 107 |
+
showError('Request timed out. Please try again later.');
|
| 108 |
+
}
|
| 109 |
+
} else {
|
| 110 |
+
// Try to show results anyway
|
| 111 |
+
updateJobHeader();
|
| 112 |
+
loadResults();
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
} catch (error) {
|
| 116 |
+
console.error('Error fetching job info:', error);
|
| 117 |
+
showError('Failed to load job information');
|
| 118 |
+
}
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
/**
|
| 122 |
+
* Update job header with title
|
| 123 |
+
*/
|
| 124 |
+
function updateJobHeader() {
|
| 125 |
+
if (jobInfo && jobInfo.job_title) {
|
| 126 |
+
jobTitleEl.textContent = jobInfo.job_title;
|
| 127 |
+
}
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
/**
|
| 131 |
+
* Load paginated results
|
| 132 |
+
*/
|
| 133 |
+
async function loadResults() {
|
| 134 |
+
try {
|
| 135 |
+
const params = new URLSearchParams({
|
| 136 |
+
page: currentPage,
|
| 137 |
+
page_size: pageSize
|
| 138 |
+
});
|
| 139 |
+
|
| 140 |
+
if (searchQuery) {
|
| 141 |
+
params.append('search', searchQuery);
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
const response = await fetch(`/api/batch/${jobId}/results?${params.toString()}`);
|
| 145 |
+
|
| 146 |
+
if (!response.ok) {
|
| 147 |
+
const error = await response.json();
|
| 148 |
+
throw new Error(error.detail || 'Failed to load results');
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
const data = await response.json();
|
| 152 |
+
|
| 153 |
+
// Update stats
|
| 154 |
+
updateStats(data);
|
| 155 |
+
|
| 156 |
+
// Update table
|
| 157 |
+
totalResults = data.total;
|
| 158 |
+
totalPages = data.total_pages;
|
| 159 |
+
renderResults(data.results);
|
| 160 |
+
renderPagination();
|
| 161 |
+
|
| 162 |
+
// Show results container
|
| 163 |
+
loadingState.classList.add('hidden');
|
| 164 |
+
resultsContainer.classList.remove('hidden');
|
| 165 |
+
|
| 166 |
+
} catch (error) {
|
| 167 |
+
console.error('Error loading results:', error);
|
| 168 |
+
showError(error.message);
|
| 169 |
+
}
|
| 170 |
+
}
|
| 171 |
+
|
| 172 |
+
/**
|
| 173 |
+
* Update stats display
|
| 174 |
+
*/
|
| 175 |
+
function updateStats(data) {
|
| 176 |
+
statTotal.textContent = data.total_sequences || 0;
|
| 177 |
+
statSuccess.textContent = data.successful_count || 0;
|
| 178 |
+
statInvalid.textContent = data.invalid_count || 0;
|
| 179 |
+
|
| 180 |
+
if (data.average_psi !== null && data.average_psi !== undefined) {
|
| 181 |
+
statAvgPsi.textContent = data.average_psi.toFixed(3);
|
| 182 |
+
} else {
|
| 183 |
+
statAvgPsi.textContent = '-';
|
| 184 |
+
}
|
| 185 |
+
}
|
| 186 |
+
|
| 187 |
+
/**
|
| 188 |
+
* Render results table
|
| 189 |
+
*/
|
| 190 |
+
function renderResults(results) {
|
| 191 |
+
resultsTableBody.innerHTML = '';
|
| 192 |
+
|
| 193 |
+
for (const result of results) {
|
| 194 |
+
const row = document.createElement('tr');
|
| 195 |
+
row.className = 'hover:bg-gray-50 cursor-pointer';
|
| 196 |
+
row.onclick = () => showDetail(result.index);
|
| 197 |
+
|
| 198 |
+
const statusBadge = getStatusBadge(result.status);
|
| 199 |
+
const psiDisplay = result.status === 'success' && result.psi !== null
|
| 200 |
+
? result.psi.toFixed(3)
|
| 201 |
+
: '-';
|
| 202 |
+
const psiClass = result.status === 'success' ? getPsiColorClass(result.psi) : 'text-gray-400';
|
| 203 |
+
|
| 204 |
+
// Truncate sequence
|
| 205 |
+
const truncatedSeq = result.sequence.length > 30
|
| 206 |
+
? result.sequence.substring(0, 30) + '...'
|
| 207 |
+
: result.sequence;
|
| 208 |
+
|
| 209 |
+
row.innerHTML = `
|
| 210 |
+
<td class="px-4 py-3 text-gray-400">
|
| 211 |
+
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
| 212 |
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
| 213 |
+
</svg>
|
| 214 |
+
</td>
|
| 215 |
+
<td class="px-4 py-3 whitespace-nowrap text-sm font-medium text-gray-900">
|
| 216 |
+
${escapeHtml(result.name)}
|
| 217 |
+
</td>
|
| 218 |
+
<td class="px-4 py-3 whitespace-nowrap text-sm font-mono text-gray-500">
|
| 219 |
+
${escapeHtml(truncatedSeq)}
|
| 220 |
+
</td>
|
| 221 |
+
<td class="px-4 py-3 whitespace-nowrap text-sm font-bold ${psiClass}">
|
| 222 |
+
${psiDisplay}
|
| 223 |
+
</td>
|
| 224 |
+
<td class="px-4 py-3 whitespace-nowrap">
|
| 225 |
+
${statusBadge}
|
| 226 |
+
</td>
|
| 227 |
+
`;
|
| 228 |
+
|
| 229 |
+
resultsTableBody.appendChild(row);
|
| 230 |
+
}
|
| 231 |
+
}
|
| 232 |
+
|
| 233 |
+
/**
|
| 234 |
+
* Get status badge HTML
|
| 235 |
+
*/
|
| 236 |
+
function getStatusBadge(status) {
|
| 237 |
+
if (status === 'success') {
|
| 238 |
+
return '<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">Success</span>';
|
| 239 |
+
} else if (status === 'invalid') {
|
| 240 |
+
return '<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800">Invalid</span>';
|
| 241 |
+
} else {
|
| 242 |
+
return `<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800">${status}</span>`;
|
| 243 |
+
}
|
| 244 |
+
}
|
| 245 |
+
|
| 246 |
+
/**
|
| 247 |
+
* Get PSI color class
|
| 248 |
+
*/
|
| 249 |
+
function getPsiColorClass(psi) {
|
| 250 |
+
if (psi >= 0.8) return 'text-green-600';
|
| 251 |
+
if (psi >= 0.3) return 'text-yellow-600';
|
| 252 |
+
return 'text-red-600';
|
| 253 |
+
}
|
| 254 |
+
|
| 255 |
+
/**
|
| 256 |
+
* Render pagination controls
|
| 257 |
+
*/
|
| 258 |
+
function renderPagination() {
|
| 259 |
+
const start = (currentPage - 1) * pageSize + 1;
|
| 260 |
+
const end = Math.min(currentPage * pageSize, totalResults);
|
| 261 |
+
|
| 262 |
+
pageStart.textContent = totalResults > 0 ? start : 0;
|
| 263 |
+
pageEnd.textContent = end;
|
| 264 |
+
totalResultsEl.textContent = totalResults;
|
| 265 |
+
|
| 266 |
+
// Clear existing buttons
|
| 267 |
+
pageButtons.innerHTML = '';
|
| 268 |
+
|
| 269 |
+
if (totalPages <= 1) return;
|
| 270 |
+
|
| 271 |
+
// Previous button
|
| 272 |
+
const prevBtn = document.createElement('button');
|
| 273 |
+
prevBtn.className = `relative inline-flex items-center px-2 py-2 rounded-l-md border border-gray-300 bg-white text-sm font-medium ${currentPage === 1 ? 'text-gray-300 cursor-not-allowed' : 'text-gray-500 hover:bg-gray-50'}`;
|
| 274 |
+
prevBtn.innerHTML = '<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" /></svg>';
|
| 275 |
+
prevBtn.disabled = currentPage === 1;
|
| 276 |
+
prevBtn.addEventListener('click', () => {
|
| 277 |
+
if (currentPage > 1) {
|
| 278 |
+
currentPage--;
|
| 279 |
+
loadResults();
|
| 280 |
+
}
|
| 281 |
+
});
|
| 282 |
+
pageButtons.appendChild(prevBtn);
|
| 283 |
+
|
| 284 |
+
// Page numbers
|
| 285 |
+
const maxButtons = 5;
|
| 286 |
+
let startPage = Math.max(1, currentPage - Math.floor(maxButtons / 2));
|
| 287 |
+
let endPage = Math.min(totalPages, startPage + maxButtons - 1);
|
| 288 |
+
|
| 289 |
+
if (endPage - startPage + 1 < maxButtons) {
|
| 290 |
+
startPage = Math.max(1, endPage - maxButtons + 1);
|
| 291 |
+
}
|
| 292 |
+
|
| 293 |
+
for (let i = startPage; i <= endPage; i++) {
|
| 294 |
+
const btn = document.createElement('button');
|
| 295 |
+
btn.className = `relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium ${i === currentPage ? 'bg-primary-50 border-primary-500 text-primary-600 z-10' : 'bg-white text-gray-500 hover:bg-gray-50'}`;
|
| 296 |
+
btn.textContent = i;
|
| 297 |
+
btn.addEventListener('click', () => {
|
| 298 |
+
currentPage = i;
|
| 299 |
+
loadResults();
|
| 300 |
+
});
|
| 301 |
+
pageButtons.appendChild(btn);
|
| 302 |
+
}
|
| 303 |
+
|
| 304 |
+
// Next button
|
| 305 |
+
const nextBtn = document.createElement('button');
|
| 306 |
+
nextBtn.className = `relative inline-flex items-center px-2 py-2 rounded-r-md border border-gray-300 bg-white text-sm font-medium ${currentPage === totalPages ? 'text-gray-300 cursor-not-allowed' : 'text-gray-500 hover:bg-gray-50'}`;
|
| 307 |
+
nextBtn.innerHTML = '<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" /></svg>';
|
| 308 |
+
nextBtn.disabled = currentPage === totalPages;
|
| 309 |
+
nextBtn.addEventListener('click', () => {
|
| 310 |
+
if (currentPage < totalPages) {
|
| 311 |
+
currentPage++;
|
| 312 |
+
loadResults();
|
| 313 |
+
}
|
| 314 |
+
});
|
| 315 |
+
pageButtons.appendChild(nextBtn);
|
| 316 |
+
}
|
| 317 |
+
|
| 318 |
+
/**
|
| 319 |
+
* Show sequence detail modal
|
| 320 |
+
*/
|
| 321 |
+
async function showDetail(index) {
|
| 322 |
+
const modal = document.getElementById('detail-modal');
|
| 323 |
+
const detailTitle = document.getElementById('detail-title');
|
| 324 |
+
const detailContent = document.getElementById('detail-content');
|
| 325 |
+
const detailLoading = document.getElementById('detail-loading');
|
| 326 |
+
|
| 327 |
+
// Show modal with loading
|
| 328 |
+
modal.classList.remove('hidden');
|
| 329 |
+
detailLoading.classList.remove('hidden');
|
| 330 |
+
detailContent.innerHTML = '';
|
| 331 |
+
detailContent.appendChild(detailLoading);
|
| 332 |
+
|
| 333 |
+
try {
|
| 334 |
+
const response = await fetch(`/api/batch/${jobId}/sequence/${index}`);
|
| 335 |
+
|
| 336 |
+
if (!response.ok) {
|
| 337 |
+
const error = await response.json();
|
| 338 |
+
throw new Error(error.detail || 'Failed to load sequence details');
|
| 339 |
+
}
|
| 340 |
+
|
| 341 |
+
const data = await response.json();
|
| 342 |
+
detailLoading.classList.add('hidden');
|
| 343 |
+
|
| 344 |
+
// Update title
|
| 345 |
+
detailTitle.textContent = data.name || `Sequence ${index + 1}`;
|
| 346 |
+
|
| 347 |
+
// Build detail content
|
| 348 |
+
let html = '';
|
| 349 |
+
|
| 350 |
+
if (data.status === 'invalid') {
|
| 351 |
+
// Show invalid sequence info
|
| 352 |
+
html = `
|
| 353 |
+
<div class="bg-red-50 border border-red-200 rounded-lg p-4 text-center">
|
| 354 |
+
<svg class="h-12 w-12 text-red-400 mx-auto" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
| 355 |
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
| 356 |
+
</svg>
|
| 357 |
+
<h4 class="mt-4 text-lg font-medium text-red-800">Invalid Sequence</h4>
|
| 358 |
+
<p class="mt-2 text-red-700">${escapeHtml(data.validation_error || 'Sequence validation failed')}</p>
|
| 359 |
+
</div>
|
| 360 |
+
<div class="bg-gray-50 rounded-lg p-4">
|
| 361 |
+
<h4 class="text-sm font-medium text-gray-500 uppercase mb-2">Sequence</h4>
|
| 362 |
+
<p class="font-mono text-sm text-gray-900 break-all">${escapeHtml(data.sequence)}</p>
|
| 363 |
+
</div>
|
| 364 |
+
`;
|
| 365 |
+
} else {
|
| 366 |
+
// Show successful prediction details
|
| 367 |
+
const psi = data.psi;
|
| 368 |
+
const interp = interpretPsi(psi);
|
| 369 |
+
|
| 370 |
+
html = `
|
| 371 |
+
<!-- PSI Value -->
|
| 372 |
+
<div class="rounded-lg p-6 text-center ${interp.colorClass}">
|
| 373 |
+
<p class="text-sm font-medium text-gray-500 uppercase tracking-wide">Predicted PSI</p>
|
| 374 |
+
<p class="mt-2 text-4xl font-bold ${interp.textClass}">${psi.toFixed(3)}</p>
|
| 375 |
+
<p class="mt-2 text-sm ${interp.textClass}">${interp.text}: ${interp.description}</p>
|
| 376 |
+
</div>
|
| 377 |
+
|
| 378 |
+
<!-- Info Grid -->
|
| 379 |
+
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
| 380 |
+
<div class="bg-gray-50 rounded-lg p-4">
|
| 381 |
+
<h4 class="text-sm font-medium text-gray-500 uppercase mb-2">RNA Secondary Structure</h4>
|
| 382 |
+
<p class="font-mono text-sm text-gray-900 break-all">${escapeHtml(data.structure || 'N/A')}</p>
|
| 383 |
+
</div>
|
| 384 |
+
<div class="bg-gray-50 rounded-lg p-4">
|
| 385 |
+
<h4 class="text-sm font-medium text-gray-500 uppercase mb-2">Minimum Free Energy</h4>
|
| 386 |
+
<p class="text-2xl font-bold text-gray-900">${data.mfe ? data.mfe.toFixed(2) : 'N/A'} <span class="text-base font-normal text-gray-500">kcal/mol</span></p>
|
| 387 |
+
</div>
|
| 388 |
+
</div>
|
| 389 |
+
|
| 390 |
+
<!-- Sequence -->
|
| 391 |
+
<div class="bg-gray-50 rounded-lg p-4">
|
| 392 |
+
<h4 class="text-sm font-medium text-gray-500 uppercase mb-2">Sequence</h4>
|
| 393 |
+
<p class="font-mono text-sm text-gray-900 break-all">${escapeHtml(data.sequence)}</p>
|
| 394 |
+
</div>
|
| 395 |
+
|
| 396 |
+
<!-- Force Plot -->
|
| 397 |
+
<div class="bg-white rounded-lg border border-gray-200 p-4">
|
| 398 |
+
<h4 class="text-sm font-medium text-gray-500 uppercase mb-4">
|
| 399 |
+
Position-wise Contribution to PSI
|
| 400 |
+
<span class="ml-2 text-gray-400 font-normal normal-case">
|
| 401 |
+
(green = promotes inclusion, red = promotes skipping)
|
| 402 |
+
</span>
|
| 403 |
+
</h4>
|
| 404 |
+
<div id="detail-force-plot" class="w-full" style="height: 350px;"></div>
|
| 405 |
+
</div>
|
| 406 |
+
`;
|
| 407 |
+
}
|
| 408 |
+
|
| 409 |
+
detailContent.innerHTML = html;
|
| 410 |
+
|
| 411 |
+
// Create force plot if we have data
|
| 412 |
+
if (data.status === 'success' && data.force_plot_data && data.force_plot_data.length > 0) {
|
| 413 |
+
setTimeout(() => createForcePlot('detail-force-plot', data.force_plot_data), 100);
|
| 414 |
+
}
|
| 415 |
+
|
| 416 |
+
} catch (error) {
|
| 417 |
+
console.error('Error loading detail:', error);
|
| 418 |
+
detailLoading.classList.add('hidden');
|
| 419 |
+
detailContent.innerHTML = `
|
| 420 |
+
<div class="bg-red-50 border border-red-200 rounded-lg p-4 text-center">
|
| 421 |
+
<p class="text-red-700">${escapeHtml(error.message)}</p>
|
| 422 |
+
</div>
|
| 423 |
+
`;
|
| 424 |
+
}
|
| 425 |
+
}
|
| 426 |
+
|
| 427 |
+
/**
|
| 428 |
+
* Close detail modal
|
| 429 |
+
*/
|
| 430 |
+
function closeDetailModal() {
|
| 431 |
+
document.getElementById('detail-modal').classList.add('hidden');
|
| 432 |
+
}
|
| 433 |
+
|
| 434 |
+
// Close modal on escape key
|
| 435 |
+
document.addEventListener('keydown', (e) => {
|
| 436 |
+
if (e.key === 'Escape') {
|
| 437 |
+
closeDetailModal();
|
| 438 |
+
}
|
| 439 |
+
});
|
| 440 |
+
|
| 441 |
+
// Close modal on backdrop click
|
| 442 |
+
document.getElementById('detail-modal').addEventListener('click', (e) => {
|
| 443 |
+
if (e.target === e.currentTarget) {
|
| 444 |
+
closeDetailModal();
|
| 445 |
+
}
|
| 446 |
+
});
|
| 447 |
+
|
| 448 |
+
/**
|
| 449 |
+
* Interpret PSI value
|
| 450 |
+
*/
|
| 451 |
+
function interpretPsi(psi) {
|
| 452 |
+
if (psi >= 0.8) {
|
| 453 |
+
return {
|
| 454 |
+
text: 'High Inclusion',
|
| 455 |
+
description: 'This exon is predicted to be included in most transcripts.',
|
| 456 |
+
colorClass: 'bg-green-50 border border-green-200',
|
| 457 |
+
textClass: 'text-green-600'
|
| 458 |
+
};
|
| 459 |
+
} else if (psi >= 0.3) {
|
| 460 |
+
return {
|
| 461 |
+
text: 'Variable/Regulated',
|
| 462 |
+
description: 'This exon shows intermediate inclusion, suggesting regulation.',
|
| 463 |
+
colorClass: 'bg-yellow-50 border border-yellow-200',
|
| 464 |
+
textClass: 'text-yellow-600'
|
| 465 |
+
};
|
| 466 |
+
} else {
|
| 467 |
+
return {
|
| 468 |
+
text: 'High Skipping',
|
| 469 |
+
description: 'This exon is predicted to be skipped in most transcripts.',
|
| 470 |
+
colorClass: 'bg-red-50 border border-red-200',
|
| 471 |
+
textClass: 'text-red-600'
|
| 472 |
+
};
|
| 473 |
+
}
|
| 474 |
+
}
|
| 475 |
+
|
| 476 |
+
/**
|
| 477 |
+
* Create force plot using Plotly
|
| 478 |
+
*/
|
| 479 |
+
function createForcePlot(elementId, forceData) {
|
| 480 |
+
const element = document.getElementById(elementId);
|
| 481 |
+
if (!element) return;
|
| 482 |
+
|
| 483 |
+
const positions = Array.from({ length: forceData.length }, (_, i) => i + 1);
|
| 484 |
+
const colors = forceData.map(v => v >= 0 ? 'rgba(34, 197, 94, 0.8)' : 'rgba(239, 68, 68, 0.8)');
|
| 485 |
+
|
| 486 |
+
const trace = {
|
| 487 |
+
x: positions,
|
| 488 |
+
y: forceData,
|
| 489 |
+
type: 'bar',
|
| 490 |
+
marker: { color: colors },
|
| 491 |
+
hovertemplate: 'Position %{x}<br>Contribution: %{y:.4f}<extra></extra>'
|
| 492 |
+
};
|
| 493 |
+
|
| 494 |
+
const layout = {
|
| 495 |
+
margin: { t: 20, r: 20, b: 50, l: 50 },
|
| 496 |
+
xaxis: {
|
| 497 |
+
title: { text: 'Position', font: { size: 11 } },
|
| 498 |
+
tickmode: 'linear',
|
| 499 |
+
dtick: 10,
|
| 500 |
+
range: [0, 91]
|
| 501 |
+
},
|
| 502 |
+
yaxis: {
|
| 503 |
+
title: { text: 'Contribution to PSI', font: { size: 11 } },
|
| 504 |
+
zeroline: true,
|
| 505 |
+
zerolinecolor: '#888',
|
| 506 |
+
zerolinewidth: 1
|
| 507 |
+
},
|
| 508 |
+
shapes: [{
|
| 509 |
+
type: 'rect',
|
| 510 |
+
xref: 'x',
|
| 511 |
+
yref: 'paper',
|
| 512 |
+
x0: 10.5,
|
| 513 |
+
x1: 80.5,
|
| 514 |
+
y0: 0,
|
| 515 |
+
y1: 1,
|
| 516 |
+
fillcolor: 'rgba(59, 130, 246, 0.05)',
|
| 517 |
+
line: { width: 0 }
|
| 518 |
+
}],
|
| 519 |
+
annotations: [
|
| 520 |
+
{ x: 5, y: 1.05, xref: 'x', yref: 'paper', text: "5' flank", showarrow: false, font: { size: 9, color: '#888' } },
|
| 521 |
+
{ x: 45, y: 1.05, xref: 'x', yref: 'paper', text: 'Exon', showarrow: false, font: { size: 9, color: '#3b82f6' } },
|
| 522 |
+
{ x: 85, y: 1.05, xref: 'x', yref: 'paper', text: "3' flank", showarrow: false, font: { size: 9, color: '#888' } }
|
| 523 |
+
],
|
| 524 |
+
paper_bgcolor: 'rgba(0,0,0,0)',
|
| 525 |
+
plot_bgcolor: 'rgba(0,0,0,0)',
|
| 526 |
+
font: { family: 'system-ui, -apple-system, sans-serif' }
|
| 527 |
+
};
|
| 528 |
+
|
| 529 |
+
const config = {
|
| 530 |
+
responsive: true,
|
| 531 |
+
displayModeBar: true,
|
| 532 |
+
modeBarButtonsToRemove: ['lasso2d', 'select2d'],
|
| 533 |
+
displaylogo: false
|
| 534 |
+
};
|
| 535 |
+
|
| 536 |
+
Plotly.newPlot(element, [trace], layout, config);
|
| 537 |
+
}
|
| 538 |
+
|
| 539 |
+
/**
|
| 540 |
+
* Show error state
|
| 541 |
+
*/
|
| 542 |
+
function showError(message) {
|
| 543 |
+
loadingState.classList.add('hidden');
|
| 544 |
+
resultsContainer.classList.add('hidden');
|
| 545 |
+
errorState.classList.remove('hidden');
|
| 546 |
+
errorMessageEl.textContent = message;
|
| 547 |
+
}
|
| 548 |
+
|
| 549 |
+
/**
|
| 550 |
+
* Copy result link to clipboard
|
| 551 |
+
*/
|
| 552 |
+
function copyLink() {
|
| 553 |
+
const url = window.location.href;
|
| 554 |
+
navigator.clipboard.writeText(url).then(() => {
|
| 555 |
+
alert('Link copied to clipboard!');
|
| 556 |
+
}).catch(err => {
|
| 557 |
+
console.error('Failed to copy:', err);
|
| 558 |
+
prompt('Copy this link:', url);
|
| 559 |
+
});
|
| 560 |
+
}
|
| 561 |
+
|
| 562 |
+
/**
|
| 563 |
+
* Escape HTML to prevent XSS
|
| 564 |
+
*/
|
| 565 |
+
function escapeHtml(text) {
|
| 566 |
+
const div = document.createElement('div');
|
| 567 |
+
div.textContent = text;
|
| 568 |
+
return div.innerHTML;
|
| 569 |
+
}
|
| 570 |
+
|
| 571 |
+
// Make functions available globally
|
| 572 |
+
window.copyLink = copyLink;
|
| 573 |
+
window.closeDetailModal = closeDetailModal;
|
| 574 |
+
window.showDetail = showDetail;
|
webapp/static/js/file-parser.js
ADDED
|
@@ -0,0 +1,327 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* File parsing utilities for CSV and FASTA files.
|
| 3 |
+
* Also handles multi-sequence text input parsing.
|
| 4 |
+
*/
|
| 5 |
+
|
| 6 |
+
/**
|
| 7 |
+
* Parse FASTA format text.
|
| 8 |
+
* Format: >Name\nSEQUENCE\n>Name2\nSEQUENCE2
|
| 9 |
+
* @param {string} text - FASTA formatted text
|
| 10 |
+
* @returns {Array<{name: string, sequence: string}>}
|
| 11 |
+
*/
|
| 12 |
+
function parseFasta(text) {
|
| 13 |
+
const sequences = [];
|
| 14 |
+
const lines = text.trim().split('\n');
|
| 15 |
+
let currentName = '';
|
| 16 |
+
let currentSeq = '';
|
| 17 |
+
let seqCount = 0;
|
| 18 |
+
|
| 19 |
+
for (const line of lines) {
|
| 20 |
+
const trimmedLine = line.trim();
|
| 21 |
+
if (trimmedLine.startsWith('>')) {
|
| 22 |
+
// Save previous sequence if exists
|
| 23 |
+
if (currentSeq) {
|
| 24 |
+
sequences.push({
|
| 25 |
+
name: currentName || `Seq_${seqCount}`,
|
| 26 |
+
sequence: currentSeq.toUpperCase()
|
| 27 |
+
});
|
| 28 |
+
}
|
| 29 |
+
seqCount++;
|
| 30 |
+
// Extract name from header (remove > and trim)
|
| 31 |
+
currentName = trimmedLine.substring(1).trim() || `Seq_${seqCount}`;
|
| 32 |
+
currentSeq = '';
|
| 33 |
+
} else if (trimmedLine) {
|
| 34 |
+
// Append to current sequence (remove whitespace)
|
| 35 |
+
currentSeq += trimmedLine.replace(/\s/g, '');
|
| 36 |
+
}
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
// Don't forget the last sequence
|
| 40 |
+
if (currentSeq) {
|
| 41 |
+
sequences.push({
|
| 42 |
+
name: currentName || `Seq_${seqCount}`,
|
| 43 |
+
sequence: currentSeq.toUpperCase()
|
| 44 |
+
});
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
return sequences;
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
/**
|
| 51 |
+
* Parse plain sequences (one per line, no headers).
|
| 52 |
+
* @param {string} text - Plain text with one sequence per line
|
| 53 |
+
* @returns {Array<{name: string, sequence: string}>}
|
| 54 |
+
*/
|
| 55 |
+
function parsePlainSequences(text) {
|
| 56 |
+
const lines = text.trim().split('\n');
|
| 57 |
+
const sequences = [];
|
| 58 |
+
|
| 59 |
+
for (let i = 0; i < lines.length; i++) {
|
| 60 |
+
const seq = lines[i].trim().toUpperCase().replace(/\s/g, '');
|
| 61 |
+
if (seq.length > 0) {
|
| 62 |
+
sequences.push({
|
| 63 |
+
name: `Seq_${sequences.length + 1}`,
|
| 64 |
+
sequence: seq
|
| 65 |
+
});
|
| 66 |
+
}
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
return sequences;
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
/**
|
| 73 |
+
* Auto-detect format and parse multi-sequence text.
|
| 74 |
+
* If any line starts with '>', treat as FASTA, otherwise plain sequences.
|
| 75 |
+
* @param {string} text - Text to parse
|
| 76 |
+
* @returns {Array<{name: string, sequence: string}>}
|
| 77 |
+
*/
|
| 78 |
+
function parseMultiSequenceText(text) {
|
| 79 |
+
const trimmed = text.trim();
|
| 80 |
+
|
| 81 |
+
if (!trimmed) {
|
| 82 |
+
return [];
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
// Check if it looks like FASTA (any line starts with >)
|
| 86 |
+
if (trimmed.includes('>')) {
|
| 87 |
+
return parseFasta(trimmed);
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
// Otherwise treat as plain sequences
|
| 91 |
+
return parsePlainSequences(trimmed);
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
/**
|
| 95 |
+
* Detect the delimiter used in a CSV file.
|
| 96 |
+
* @param {string} text - CSV text content
|
| 97 |
+
* @returns {string} Detected delimiter: ',', ';', or '\t'
|
| 98 |
+
*/
|
| 99 |
+
function detectDelimiter(text) {
|
| 100 |
+
const firstLine = text.split('\n')[0] || '';
|
| 101 |
+
|
| 102 |
+
// Count occurrences of each potential delimiter in first line
|
| 103 |
+
const tabCount = (firstLine.match(/\t/g) || []).length;
|
| 104 |
+
const semicolonCount = (firstLine.match(/;/g) || []).length;
|
| 105 |
+
const commaCount = (firstLine.match(/,/g) || []).length;
|
| 106 |
+
|
| 107 |
+
// Return the most common delimiter
|
| 108 |
+
if (tabCount > 0 && tabCount >= semicolonCount && tabCount >= commaCount) {
|
| 109 |
+
return '\t';
|
| 110 |
+
}
|
| 111 |
+
if (semicolonCount > 0 && semicolonCount >= commaCount) {
|
| 112 |
+
return ';';
|
| 113 |
+
}
|
| 114 |
+
return ',';
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
/**
|
| 118 |
+
* Detect if the first row is a header row.
|
| 119 |
+
* @param {Array<string>} row - First row of CSV
|
| 120 |
+
* @returns {boolean} True if row looks like a header
|
| 121 |
+
*/
|
| 122 |
+
function detectHeader(row) {
|
| 123 |
+
if (!row || row.length === 0) return false;
|
| 124 |
+
|
| 125 |
+
// Common header patterns (case insensitive)
|
| 126 |
+
const headerPatterns = ['name', 'sequence', 'seq', 'id', 'exon', 'header'];
|
| 127 |
+
|
| 128 |
+
// Check if any cell matches header patterns
|
| 129 |
+
for (const cell of row) {
|
| 130 |
+
const lowerCell = cell.toLowerCase().trim();
|
| 131 |
+
if (headerPatterns.some(pattern => lowerCell.includes(pattern))) {
|
| 132 |
+
return true;
|
| 133 |
+
}
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
// If first cell looks like a valid sequence (all ACGTU), probably not a header
|
| 137 |
+
const firstCell = row[0].toUpperCase().trim();
|
| 138 |
+
if (/^[ACGTU]+$/.test(firstCell) && firstCell.length > 20) {
|
| 139 |
+
return false;
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
return false;
|
| 143 |
+
}
|
| 144 |
+
|
| 145 |
+
/**
|
| 146 |
+
* Parse CSV content.
|
| 147 |
+
* Supports:
|
| 148 |
+
* - 2 columns: name, sequence (with or without header)
|
| 149 |
+
* - 1 column: sequence only (with or without header)
|
| 150 |
+
* - Auto-detects delimiter (comma, semicolon, tab)
|
| 151 |
+
* @param {string} text - CSV file content
|
| 152 |
+
* @returns {Array<{name: string, sequence: string}>}
|
| 153 |
+
*/
|
| 154 |
+
function parseCSV(text) {
|
| 155 |
+
const delimiter = detectDelimiter(text);
|
| 156 |
+
const lines = text.trim().split('\n');
|
| 157 |
+
|
| 158 |
+
if (lines.length === 0) {
|
| 159 |
+
return [];
|
| 160 |
+
}
|
| 161 |
+
|
| 162 |
+
// Parse all rows
|
| 163 |
+
const rows = lines.map(line => {
|
| 164 |
+
// Handle quoted fields properly
|
| 165 |
+
const cells = [];
|
| 166 |
+
let current = '';
|
| 167 |
+
let inQuotes = false;
|
| 168 |
+
|
| 169 |
+
for (let i = 0; i < line.length; i++) {
|
| 170 |
+
const char = line[i];
|
| 171 |
+
if (char === '"') {
|
| 172 |
+
inQuotes = !inQuotes;
|
| 173 |
+
} else if (char === delimiter && !inQuotes) {
|
| 174 |
+
cells.push(current.trim());
|
| 175 |
+
current = '';
|
| 176 |
+
} else {
|
| 177 |
+
current += char;
|
| 178 |
+
}
|
| 179 |
+
}
|
| 180 |
+
cells.push(current.trim());
|
| 181 |
+
return cells;
|
| 182 |
+
});
|
| 183 |
+
|
| 184 |
+
// Detect if first row is header
|
| 185 |
+
const hasHeader = detectHeader(rows[0]);
|
| 186 |
+
const dataRows = hasHeader ? rows.slice(1) : rows;
|
| 187 |
+
|
| 188 |
+
// Determine format based on number of columns
|
| 189 |
+
const numCols = rows[0].length;
|
| 190 |
+
|
| 191 |
+
const sequences = [];
|
| 192 |
+
for (let i = 0; i < dataRows.length; i++) {
|
| 193 |
+
const row = dataRows[i];
|
| 194 |
+
if (!row || row.length === 0 || (row.length === 1 && !row[0].trim())) {
|
| 195 |
+
continue; // Skip empty rows
|
| 196 |
+
}
|
| 197 |
+
|
| 198 |
+
if (numCols >= 2 && row.length >= 2) {
|
| 199 |
+
// Two or more columns: assume name, sequence
|
| 200 |
+
const name = row[0].trim() || `Seq_${sequences.length + 1}`;
|
| 201 |
+
const seq = row[1].trim().toUpperCase().replace(/\s/g, '');
|
| 202 |
+
if (seq) {
|
| 203 |
+
sequences.push({ name, sequence: seq });
|
| 204 |
+
}
|
| 205 |
+
} else {
|
| 206 |
+
// Single column: sequence only
|
| 207 |
+
const seq = row[0].trim().toUpperCase().replace(/\s/g, '');
|
| 208 |
+
if (seq) {
|
| 209 |
+
sequences.push({
|
| 210 |
+
name: `Seq_${sequences.length + 1}`,
|
| 211 |
+
sequence: seq
|
| 212 |
+
});
|
| 213 |
+
}
|
| 214 |
+
}
|
| 215 |
+
}
|
| 216 |
+
|
| 217 |
+
return sequences;
|
| 218 |
+
}
|
| 219 |
+
|
| 220 |
+
/**
|
| 221 |
+
* Read and parse a file (CSV or FASTA).
|
| 222 |
+
* @param {File} file - File object to parse
|
| 223 |
+
* @returns {Promise<Array<{name: string, sequence: string}>>}
|
| 224 |
+
*/
|
| 225 |
+
async function parseFile(file) {
|
| 226 |
+
return new Promise((resolve, reject) => {
|
| 227 |
+
const reader = new FileReader();
|
| 228 |
+
|
| 229 |
+
reader.onload = (event) => {
|
| 230 |
+
const text = event.target.result;
|
| 231 |
+
const fileName = file.name.toLowerCase();
|
| 232 |
+
|
| 233 |
+
let sequences;
|
| 234 |
+
if (fileName.endsWith('.fasta') || fileName.endsWith('.fa') || fileName.endsWith('.fna')) {
|
| 235 |
+
sequences = parseFasta(text);
|
| 236 |
+
} else if (fileName.endsWith('.csv') || fileName.endsWith('.tsv') || fileName.endsWith('.txt')) {
|
| 237 |
+
// Try CSV first
|
| 238 |
+
sequences = parseCSV(text);
|
| 239 |
+
// If CSV parsing resulted in weird sequences, try FASTA
|
| 240 |
+
if (sequences.length === 0 || (sequences.length === 1 && text.includes('>'))) {
|
| 241 |
+
sequences = parseFasta(text);
|
| 242 |
+
}
|
| 243 |
+
} else {
|
| 244 |
+
// Unknown extension - try to auto-detect
|
| 245 |
+
if (text.trim().startsWith('>')) {
|
| 246 |
+
sequences = parseFasta(text);
|
| 247 |
+
} else {
|
| 248 |
+
sequences = parseCSV(text);
|
| 249 |
+
}
|
| 250 |
+
}
|
| 251 |
+
|
| 252 |
+
resolve(sequences);
|
| 253 |
+
};
|
| 254 |
+
|
| 255 |
+
reader.onerror = () => {
|
| 256 |
+
reject(new Error('Failed to read file'));
|
| 257 |
+
};
|
| 258 |
+
|
| 259 |
+
reader.readAsText(file);
|
| 260 |
+
});
|
| 261 |
+
}
|
| 262 |
+
|
| 263 |
+
/**
|
| 264 |
+
* Validate a single sequence.
|
| 265 |
+
* @param {string} sequence - Sequence to validate
|
| 266 |
+
* @param {number} expectedLength - Expected length (default 70)
|
| 267 |
+
* @returns {{valid: boolean, error: string}}
|
| 268 |
+
*/
|
| 269 |
+
function validateSequence(sequence, expectedLength = 70) {
|
| 270 |
+
const cleaned = sequence.toUpperCase().replace(/U/g, 'T').replace(/\s/g, '');
|
| 271 |
+
|
| 272 |
+
if (cleaned.length !== expectedLength) {
|
| 273 |
+
return {
|
| 274 |
+
valid: false,
|
| 275 |
+
error: `Must be exactly ${expectedLength} nucleotides (got ${cleaned.length})`
|
| 276 |
+
};
|
| 277 |
+
}
|
| 278 |
+
|
| 279 |
+
const invalidChars = cleaned.match(/[^ACGT]/g);
|
| 280 |
+
if (invalidChars) {
|
| 281 |
+
const unique = [...new Set(invalidChars)];
|
| 282 |
+
return {
|
| 283 |
+
valid: false,
|
| 284 |
+
error: `Contains invalid characters: ${unique.join(', ')}`
|
| 285 |
+
};
|
| 286 |
+
}
|
| 287 |
+
|
| 288 |
+
return { valid: true, error: '' };
|
| 289 |
+
}
|
| 290 |
+
|
| 291 |
+
/**
|
| 292 |
+
* Parse and validate sequences from text input or file.
|
| 293 |
+
* @param {string} text - Text input to parse
|
| 294 |
+
* @param {number} expectedLength - Expected sequence length
|
| 295 |
+
* @returns {{sequences: Array<{name: string, sequence: string}>, validCount: number, invalidCount: number}}
|
| 296 |
+
*/
|
| 297 |
+
function parseAndValidate(text, expectedLength = 70) {
|
| 298 |
+
const sequences = parseMultiSequenceText(text);
|
| 299 |
+
let validCount = 0;
|
| 300 |
+
let invalidCount = 0;
|
| 301 |
+
|
| 302 |
+
for (const seq of sequences) {
|
| 303 |
+
const validation = validateSequence(seq.sequence, expectedLength);
|
| 304 |
+
seq.valid = validation.valid;
|
| 305 |
+
seq.error = validation.error;
|
| 306 |
+
if (validation.valid) {
|
| 307 |
+
validCount++;
|
| 308 |
+
} else {
|
| 309 |
+
invalidCount++;
|
| 310 |
+
}
|
| 311 |
+
}
|
| 312 |
+
|
| 313 |
+
return { sequences, validCount, invalidCount };
|
| 314 |
+
}
|
| 315 |
+
|
| 316 |
+
// Export functions for use in other scripts
|
| 317 |
+
window.FileParser = {
|
| 318 |
+
parseFasta,
|
| 319 |
+
parsePlainSequences,
|
| 320 |
+
parseMultiSequenceText,
|
| 321 |
+
parseCSV,
|
| 322 |
+
parseFile,
|
| 323 |
+
detectDelimiter,
|
| 324 |
+
detectHeader,
|
| 325 |
+
validateSequence,
|
| 326 |
+
parseAndValidate,
|
| 327 |
+
};
|
webapp/static/js/history.js
ADDED
|
@@ -0,0 +1,351 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* History page JavaScript
|
| 3 |
+
*/
|
| 4 |
+
|
| 5 |
+
// State
|
| 6 |
+
let currentPage = 1;
|
| 7 |
+
let pageSize = 25;
|
| 8 |
+
let totalJobs = 0;
|
| 9 |
+
let totalPages = 1;
|
| 10 |
+
let currentFilters = {
|
| 11 |
+
search: '',
|
| 12 |
+
dateFrom: null,
|
| 13 |
+
dateTo: null
|
| 14 |
+
};
|
| 15 |
+
|
| 16 |
+
// DOM Elements
|
| 17 |
+
const tokenDisplay = document.getElementById('token-display');
|
| 18 |
+
const loadingState = document.getElementById('loading-state');
|
| 19 |
+
const noJobsState = document.getElementById('no-jobs-state');
|
| 20 |
+
const jobsTableContainer = document.getElementById('jobs-table-container');
|
| 21 |
+
const jobsTableBody = document.getElementById('jobs-table-body');
|
| 22 |
+
const pagination = document.getElementById('pagination');
|
| 23 |
+
const pageStart = document.getElementById('page-start');
|
| 24 |
+
const pageEnd = document.getElementById('page-end');
|
| 25 |
+
const totalJobsEl = document.getElementById('total-jobs');
|
| 26 |
+
const pageButtons = document.getElementById('page-buttons');
|
| 27 |
+
const searchInput = document.getElementById('search-title');
|
| 28 |
+
const dateFromInput = document.getElementById('date-from');
|
| 29 |
+
const dateToInput = document.getElementById('date-to');
|
| 30 |
+
const applyFiltersBtn = document.getElementById('apply-filters-btn');
|
| 31 |
+
const clearFiltersBtn = document.getElementById('clear-filters-btn');
|
| 32 |
+
const refreshBtn = document.getElementById('refresh-btn');
|
| 33 |
+
|
| 34 |
+
/**
|
| 35 |
+
* Initialize the page
|
| 36 |
+
*/
|
| 37 |
+
document.addEventListener('DOMContentLoaded', () => {
|
| 38 |
+
TokenManager.initTokenDisplay();
|
| 39 |
+
loadJobs();
|
| 40 |
+
|
| 41 |
+
// Event listeners
|
| 42 |
+
if (applyFiltersBtn) {
|
| 43 |
+
applyFiltersBtn.addEventListener('click', applyFilters);
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
if (clearFiltersBtn) {
|
| 47 |
+
clearFiltersBtn.addEventListener('click', clearFilters);
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
if (refreshBtn) {
|
| 51 |
+
refreshBtn.addEventListener('click', () => loadJobs());
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
// Search on Enter
|
| 55 |
+
if (searchInput) {
|
| 56 |
+
searchInput.addEventListener('keypress', (e) => {
|
| 57 |
+
if (e.key === 'Enter') {
|
| 58 |
+
applyFilters();
|
| 59 |
+
}
|
| 60 |
+
});
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
// Setup custom token save handler for history page
|
| 64 |
+
const tokenSaveBtn = document.getElementById('token-save-btn');
|
| 65 |
+
if (tokenSaveBtn) {
|
| 66 |
+
tokenSaveBtn.addEventListener('click', () => {
|
| 67 |
+
const editInput = document.getElementById('token-edit-input');
|
| 68 |
+
const newToken = editInput.value.trim();
|
| 69 |
+
if (TokenManager.setToken(newToken)) {
|
| 70 |
+
if (tokenDisplay) {
|
| 71 |
+
tokenDisplay.textContent = newToken;
|
| 72 |
+
}
|
| 73 |
+
document.getElementById('token-edit-modal').classList.add('hidden');
|
| 74 |
+
loadJobs(); // Reload jobs with new token
|
| 75 |
+
} else {
|
| 76 |
+
alert('Invalid token format. Token must be in format: tok_xxxxxxxxxxxx');
|
| 77 |
+
}
|
| 78 |
+
});
|
| 79 |
+
}
|
| 80 |
+
});
|
| 81 |
+
|
| 82 |
+
/**
|
| 83 |
+
* Apply current filters and load jobs
|
| 84 |
+
*/
|
| 85 |
+
function applyFilters() {
|
| 86 |
+
currentFilters.search = searchInput.value.trim();
|
| 87 |
+
currentFilters.dateFrom = dateFromInput.value || null;
|
| 88 |
+
currentFilters.dateTo = dateToInput.value || null;
|
| 89 |
+
currentPage = 1;
|
| 90 |
+
loadJobs();
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
/**
|
| 94 |
+
* Clear all filters
|
| 95 |
+
*/
|
| 96 |
+
function clearFilters() {
|
| 97 |
+
searchInput.value = '';
|
| 98 |
+
dateFromInput.value = '';
|
| 99 |
+
dateToInput.value = '';
|
| 100 |
+
currentFilters = {
|
| 101 |
+
search: '',
|
| 102 |
+
dateFrom: null,
|
| 103 |
+
dateTo: null
|
| 104 |
+
};
|
| 105 |
+
currentPage = 1;
|
| 106 |
+
loadJobs();
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
/**
|
| 110 |
+
* Load jobs from API
|
| 111 |
+
*/
|
| 112 |
+
async function loadJobs() {
|
| 113 |
+
const token = TokenManager.getOrCreateToken();
|
| 114 |
+
if (!token) {
|
| 115 |
+
showNoJobs();
|
| 116 |
+
return;
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
showLoading();
|
| 120 |
+
|
| 121 |
+
try {
|
| 122 |
+
// Build query parameters
|
| 123 |
+
const params = new URLSearchParams({
|
| 124 |
+
access_token: token,
|
| 125 |
+
page: currentPage,
|
| 126 |
+
page_size: pageSize
|
| 127 |
+
});
|
| 128 |
+
|
| 129 |
+
if (currentFilters.search) {
|
| 130 |
+
params.append('search', currentFilters.search);
|
| 131 |
+
}
|
| 132 |
+
if (currentFilters.dateFrom) {
|
| 133 |
+
params.append('date_from', currentFilters.dateFrom);
|
| 134 |
+
}
|
| 135 |
+
if (currentFilters.dateTo) {
|
| 136 |
+
params.append('date_to', currentFilters.dateTo);
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
const response = await fetch(`/api/history?${params.toString()}`);
|
| 140 |
+
|
| 141 |
+
if (!response.ok) {
|
| 142 |
+
const error = await response.json();
|
| 143 |
+
throw new Error(error.detail || 'Failed to load jobs');
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
const data = await response.json();
|
| 147 |
+
totalJobs = data.total;
|
| 148 |
+
totalPages = data.total_pages;
|
| 149 |
+
|
| 150 |
+
if (data.jobs.length === 0) {
|
| 151 |
+
showNoJobs();
|
| 152 |
+
} else {
|
| 153 |
+
renderJobs(data.jobs);
|
| 154 |
+
renderPagination();
|
| 155 |
+
showTable();
|
| 156 |
+
}
|
| 157 |
+
|
| 158 |
+
} catch (error) {
|
| 159 |
+
console.error('Error loading jobs:', error);
|
| 160 |
+
showNoJobs();
|
| 161 |
+
}
|
| 162 |
+
}
|
| 163 |
+
|
| 164 |
+
/**
|
| 165 |
+
* Render jobs in the table
|
| 166 |
+
*/
|
| 167 |
+
function renderJobs(jobs) {
|
| 168 |
+
jobsTableBody.innerHTML = '';
|
| 169 |
+
|
| 170 |
+
for (const job of jobs) {
|
| 171 |
+
const row = document.createElement('tr');
|
| 172 |
+
row.className = 'hover:bg-gray-50';
|
| 173 |
+
|
| 174 |
+
const statusBadge = getStatusBadge(job.status);
|
| 175 |
+
const date = new Date(job.created_at).toLocaleDateString('en-US', {
|
| 176 |
+
year: 'numeric',
|
| 177 |
+
month: 'short',
|
| 178 |
+
day: 'numeric',
|
| 179 |
+
hour: '2-digit',
|
| 180 |
+
minute: '2-digit'
|
| 181 |
+
});
|
| 182 |
+
|
| 183 |
+
row.innerHTML = `
|
| 184 |
+
<td class="px-6 py-4 whitespace-nowrap">
|
| 185 |
+
<a href="/result/${job.id}" class="text-primary-600 hover:text-primary-800 font-medium">
|
| 186 |
+
${escapeHtml(job.job_title || job.id.substring(0, 8))}
|
| 187 |
+
</a>
|
| 188 |
+
</td>
|
| 189 |
+
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">${date}</td>
|
| 190 |
+
<td class="px-6 py-4 whitespace-nowrap">${statusBadge}</td>
|
| 191 |
+
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
| 192 |
+
${job.sequence_count} ${job.is_batch ? 'sequences' : 'sequence'}
|
| 193 |
+
</td>
|
| 194 |
+
<td class="px-6 py-4 whitespace-nowrap text-sm">
|
| 195 |
+
<a href="/result/${job.id}" class="text-primary-600 hover:text-primary-800 mr-3">View</a>
|
| 196 |
+
<button onclick="deleteJob('${job.id}')" class="text-red-600 hover:text-red-800">Delete</button>
|
| 197 |
+
</td>
|
| 198 |
+
`;
|
| 199 |
+
|
| 200 |
+
jobsTableBody.appendChild(row);
|
| 201 |
+
}
|
| 202 |
+
}
|
| 203 |
+
|
| 204 |
+
/**
|
| 205 |
+
* Get status badge HTML
|
| 206 |
+
*/
|
| 207 |
+
function getStatusBadge(status) {
|
| 208 |
+
const badges = {
|
| 209 |
+
'finished': '<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">Completed</span>',
|
| 210 |
+
'running': '<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">Running</span>',
|
| 211 |
+
'queued': '<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800">Queued</span>',
|
| 212 |
+
'failed': '<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800">Failed</span>'
|
| 213 |
+
};
|
| 214 |
+
return badges[status] || `<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800">${status}</span>`;
|
| 215 |
+
}
|
| 216 |
+
|
| 217 |
+
/**
|
| 218 |
+
* Render pagination controls
|
| 219 |
+
*/
|
| 220 |
+
function renderPagination() {
|
| 221 |
+
const start = (currentPage - 1) * pageSize + 1;
|
| 222 |
+
const end = Math.min(currentPage * pageSize, totalJobs);
|
| 223 |
+
|
| 224 |
+
pageStart.textContent = start;
|
| 225 |
+
pageEnd.textContent = end;
|
| 226 |
+
totalJobsEl.textContent = totalJobs;
|
| 227 |
+
|
| 228 |
+
// Clear existing buttons
|
| 229 |
+
pageButtons.innerHTML = '';
|
| 230 |
+
|
| 231 |
+
// Previous button
|
| 232 |
+
const prevBtn = document.createElement('button');
|
| 233 |
+
prevBtn.className = `relative inline-flex items-center px-2 py-2 rounded-l-md border border-gray-300 bg-white text-sm font-medium ${currentPage === 1 ? 'text-gray-300 cursor-not-allowed' : 'text-gray-500 hover:bg-gray-50'}`;
|
| 234 |
+
prevBtn.innerHTML = '<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" /></svg>';
|
| 235 |
+
prevBtn.disabled = currentPage === 1;
|
| 236 |
+
prevBtn.addEventListener('click', () => {
|
| 237 |
+
if (currentPage > 1) {
|
| 238 |
+
currentPage--;
|
| 239 |
+
loadJobs();
|
| 240 |
+
}
|
| 241 |
+
});
|
| 242 |
+
pageButtons.appendChild(prevBtn);
|
| 243 |
+
|
| 244 |
+
// Page numbers
|
| 245 |
+
const maxButtons = 5;
|
| 246 |
+
let startPage = Math.max(1, currentPage - Math.floor(maxButtons / 2));
|
| 247 |
+
let endPage = Math.min(totalPages, startPage + maxButtons - 1);
|
| 248 |
+
|
| 249 |
+
if (endPage - startPage + 1 < maxButtons) {
|
| 250 |
+
startPage = Math.max(1, endPage - maxButtons + 1);
|
| 251 |
+
}
|
| 252 |
+
|
| 253 |
+
for (let i = startPage; i <= endPage; i++) {
|
| 254 |
+
const btn = document.createElement('button');
|
| 255 |
+
btn.className = `relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium ${i === currentPage ? 'bg-primary-50 border-primary-500 text-primary-600 z-10' : 'bg-white text-gray-500 hover:bg-gray-50'}`;
|
| 256 |
+
btn.textContent = i;
|
| 257 |
+
btn.addEventListener('click', () => {
|
| 258 |
+
currentPage = i;
|
| 259 |
+
loadJobs();
|
| 260 |
+
});
|
| 261 |
+
pageButtons.appendChild(btn);
|
| 262 |
+
}
|
| 263 |
+
|
| 264 |
+
// Next button
|
| 265 |
+
const nextBtn = document.createElement('button');
|
| 266 |
+
nextBtn.className = `relative inline-flex items-center px-2 py-2 rounded-r-md border border-gray-300 bg-white text-sm font-medium ${currentPage === totalPages ? 'text-gray-300 cursor-not-allowed' : 'text-gray-500 hover:bg-gray-50'}`;
|
| 267 |
+
nextBtn.innerHTML = '<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" /></svg>';
|
| 268 |
+
nextBtn.disabled = currentPage === totalPages;
|
| 269 |
+
nextBtn.addEventListener('click', () => {
|
| 270 |
+
if (currentPage < totalPages) {
|
| 271 |
+
currentPage++;
|
| 272 |
+
loadJobs();
|
| 273 |
+
}
|
| 274 |
+
});
|
| 275 |
+
pageButtons.appendChild(nextBtn);
|
| 276 |
+
}
|
| 277 |
+
|
| 278 |
+
/**
|
| 279 |
+
* Delete a job
|
| 280 |
+
*/
|
| 281 |
+
async function deleteJob(jobId) {
|
| 282 |
+
if (!confirm('Are you sure you want to delete this job? This action cannot be undone.')) {
|
| 283 |
+
return;
|
| 284 |
+
}
|
| 285 |
+
|
| 286 |
+
const token = TokenManager.getToken();
|
| 287 |
+
if (!token) {
|
| 288 |
+
alert('No access token found');
|
| 289 |
+
return;
|
| 290 |
+
}
|
| 291 |
+
|
| 292 |
+
try {
|
| 293 |
+
const response = await fetch(`/api/jobs/${jobId}?access_token=${encodeURIComponent(token)}`, {
|
| 294 |
+
method: 'DELETE'
|
| 295 |
+
});
|
| 296 |
+
|
| 297 |
+
if (!response.ok) {
|
| 298 |
+
const error = await response.json();
|
| 299 |
+
throw new Error(error.detail || 'Failed to delete job');
|
| 300 |
+
}
|
| 301 |
+
|
| 302 |
+
// Reload jobs
|
| 303 |
+
loadJobs();
|
| 304 |
+
|
| 305 |
+
} catch (error) {
|
| 306 |
+
console.error('Error deleting job:', error);
|
| 307 |
+
alert('Failed to delete job: ' + error.message);
|
| 308 |
+
}
|
| 309 |
+
}
|
| 310 |
+
|
| 311 |
+
/**
|
| 312 |
+
* Show loading state
|
| 313 |
+
*/
|
| 314 |
+
function showLoading() {
|
| 315 |
+
loadingState.classList.remove('hidden');
|
| 316 |
+
noJobsState.classList.add('hidden');
|
| 317 |
+
jobsTableContainer.classList.add('hidden');
|
| 318 |
+
pagination.classList.add('hidden');
|
| 319 |
+
}
|
| 320 |
+
|
| 321 |
+
/**
|
| 322 |
+
* Show no jobs state
|
| 323 |
+
*/
|
| 324 |
+
function showNoJobs() {
|
| 325 |
+
loadingState.classList.add('hidden');
|
| 326 |
+
noJobsState.classList.remove('hidden');
|
| 327 |
+
jobsTableContainer.classList.add('hidden');
|
| 328 |
+
pagination.classList.add('hidden');
|
| 329 |
+
}
|
| 330 |
+
|
| 331 |
+
/**
|
| 332 |
+
* Show table
|
| 333 |
+
*/
|
| 334 |
+
function showTable() {
|
| 335 |
+
loadingState.classList.add('hidden');
|
| 336 |
+
noJobsState.classList.add('hidden');
|
| 337 |
+
jobsTableContainer.classList.remove('hidden');
|
| 338 |
+
pagination.classList.remove('hidden');
|
| 339 |
+
}
|
| 340 |
+
|
| 341 |
+
/**
|
| 342 |
+
* Escape HTML to prevent XSS
|
| 343 |
+
*/
|
| 344 |
+
function escapeHtml(text) {
|
| 345 |
+
const div = document.createElement('div');
|
| 346 |
+
div.textContent = text;
|
| 347 |
+
return div.innerHTML;
|
| 348 |
+
}
|
| 349 |
+
|
| 350 |
+
// Make deleteJob available globally
|
| 351 |
+
window.deleteJob = deleteJob;
|
webapp/static/js/token.js
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Token management utilities for user identification.
|
| 3 |
+
* Tokens are stored in localStorage and used to track job history.
|
| 4 |
+
* Format: tok_xxxxxxxxxxxx (12 random alphanumeric characters)
|
| 5 |
+
*/
|
| 6 |
+
|
| 7 |
+
const TOKEN_KEY = 'splicing_access_token';
|
| 8 |
+
const TOKEN_PREFIX = 'tok_';
|
| 9 |
+
const TOKEN_RANDOM_LENGTH = 12;
|
| 10 |
+
|
| 11 |
+
/**
|
| 12 |
+
* Generate a random token.
|
| 13 |
+
* @returns {string} Token in format tok_xxxxxxxxxxxx
|
| 14 |
+
*/
|
| 15 |
+
function generateToken() {
|
| 16 |
+
const chars = 'abcdefghijklmnopqrstuvwxyz0123456789';
|
| 17 |
+
let result = TOKEN_PREFIX;
|
| 18 |
+
for (let i = 0; i < TOKEN_RANDOM_LENGTH; i++) {
|
| 19 |
+
result += chars.charAt(Math.floor(Math.random() * chars.length));
|
| 20 |
+
}
|
| 21 |
+
return result;
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
/**
|
| 25 |
+
* Validate token format.
|
| 26 |
+
* @param {string} token - Token to validate
|
| 27 |
+
* @returns {boolean} True if valid format
|
| 28 |
+
*/
|
| 29 |
+
function isValidToken(token) {
|
| 30 |
+
const pattern = new RegExp(`^${TOKEN_PREFIX}[a-z0-9]{${TOKEN_RANDOM_LENGTH}}$`);
|
| 31 |
+
return pattern.test(token);
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
/**
|
| 35 |
+
* Get current token from localStorage, or generate new one.
|
| 36 |
+
* @returns {string} The access token
|
| 37 |
+
*/
|
| 38 |
+
function getOrCreateToken() {
|
| 39 |
+
let token = localStorage.getItem(TOKEN_KEY);
|
| 40 |
+
if (!token || !isValidToken(token)) {
|
| 41 |
+
token = generateToken();
|
| 42 |
+
localStorage.setItem(TOKEN_KEY, token);
|
| 43 |
+
}
|
| 44 |
+
return token;
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
/**
|
| 48 |
+
* Get current token from localStorage (may be null).
|
| 49 |
+
* @returns {string|null} The access token or null
|
| 50 |
+
*/
|
| 51 |
+
function getToken() {
|
| 52 |
+
return localStorage.getItem(TOKEN_KEY);
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
/**
|
| 56 |
+
* Set a new token in localStorage.
|
| 57 |
+
* @param {string} newToken - The new token to set
|
| 58 |
+
* @returns {boolean} True if successfully set
|
| 59 |
+
*/
|
| 60 |
+
function setToken(newToken) {
|
| 61 |
+
if (!isValidToken(newToken)) {
|
| 62 |
+
return false;
|
| 63 |
+
}
|
| 64 |
+
localStorage.setItem(TOKEN_KEY, newToken);
|
| 65 |
+
return true;
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
/**
|
| 69 |
+
* Clear the token from localStorage.
|
| 70 |
+
*/
|
| 71 |
+
function clearToken() {
|
| 72 |
+
localStorage.removeItem(TOKEN_KEY);
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
/**
|
| 76 |
+
* Copy token to clipboard.
|
| 77 |
+
* @returns {Promise<boolean>} True if successfully copied
|
| 78 |
+
*/
|
| 79 |
+
async function copyTokenToClipboard() {
|
| 80 |
+
const token = getToken();
|
| 81 |
+
if (!token) return false;
|
| 82 |
+
|
| 83 |
+
try {
|
| 84 |
+
await navigator.clipboard.writeText(token);
|
| 85 |
+
return true;
|
| 86 |
+
} catch (err) {
|
| 87 |
+
console.error('Failed to copy token:', err);
|
| 88 |
+
return false;
|
| 89 |
+
}
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
/**
|
| 93 |
+
* Initialize token display in the UI.
|
| 94 |
+
* Call this on page load to show current token.
|
| 95 |
+
*/
|
| 96 |
+
function initTokenDisplay() {
|
| 97 |
+
const token = getOrCreateToken();
|
| 98 |
+
|
| 99 |
+
// Update token display element if it exists
|
| 100 |
+
const displayEl = document.getElementById('token-display');
|
| 101 |
+
if (displayEl) {
|
| 102 |
+
displayEl.textContent = token;
|
| 103 |
+
displayEl.title = 'Double-click to edit';
|
| 104 |
+
displayEl.style.cursor = 'pointer';
|
| 105 |
+
|
| 106 |
+
// Double-click to edit inline
|
| 107 |
+
displayEl.addEventListener('dblclick', () => {
|
| 108 |
+
startInlineEdit(displayEl);
|
| 109 |
+
});
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
// Setup copy button
|
| 113 |
+
const copyBtn = document.getElementById('token-copy-btn');
|
| 114 |
+
if (copyBtn) {
|
| 115 |
+
copyBtn.addEventListener('click', async () => {
|
| 116 |
+
const success = await copyTokenToClipboard();
|
| 117 |
+
if (success) {
|
| 118 |
+
const originalText = copyBtn.textContent;
|
| 119 |
+
copyBtn.textContent = 'Copied!';
|
| 120 |
+
setTimeout(() => {
|
| 121 |
+
copyBtn.textContent = originalText;
|
| 122 |
+
}, 2000);
|
| 123 |
+
}
|
| 124 |
+
});
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
return token;
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
/**
|
| 131 |
+
* Start inline editing of the token display.
|
| 132 |
+
* @param {HTMLElement} displayEl - The token display element
|
| 133 |
+
*/
|
| 134 |
+
function startInlineEdit(displayEl) {
|
| 135 |
+
const currentToken = displayEl.textContent;
|
| 136 |
+
const originalBg = displayEl.style.backgroundColor;
|
| 137 |
+
|
| 138 |
+
// Make editable
|
| 139 |
+
displayEl.contentEditable = true;
|
| 140 |
+
displayEl.style.backgroundColor = '#fff';
|
| 141 |
+
displayEl.style.outline = '2px solid #3b82f6';
|
| 142 |
+
displayEl.style.outlineOffset = '1px';
|
| 143 |
+
displayEl.focus();
|
| 144 |
+
|
| 145 |
+
// Select all text
|
| 146 |
+
const range = document.createRange();
|
| 147 |
+
range.selectNodeContents(displayEl);
|
| 148 |
+
const selection = window.getSelection();
|
| 149 |
+
selection.removeAllRanges();
|
| 150 |
+
selection.addRange(range);
|
| 151 |
+
|
| 152 |
+
// Handle blur (save on click away)
|
| 153 |
+
const handleBlur = () => {
|
| 154 |
+
finishInlineEdit(displayEl, currentToken, originalBg);
|
| 155 |
+
};
|
| 156 |
+
|
| 157 |
+
// Handle keydown (Enter to save, Escape to cancel)
|
| 158 |
+
const handleKeydown = (e) => {
|
| 159 |
+
if (e.key === 'Enter') {
|
| 160 |
+
e.preventDefault();
|
| 161 |
+
displayEl.blur();
|
| 162 |
+
} else if (e.key === 'Escape') {
|
| 163 |
+
displayEl.textContent = currentToken;
|
| 164 |
+
displayEl.blur();
|
| 165 |
+
}
|
| 166 |
+
};
|
| 167 |
+
|
| 168 |
+
displayEl.addEventListener('blur', handleBlur, { once: true });
|
| 169 |
+
displayEl.addEventListener('keydown', handleKeydown);
|
| 170 |
+
|
| 171 |
+
// Remove keydown listener after blur
|
| 172 |
+
displayEl.addEventListener('blur', () => {
|
| 173 |
+
displayEl.removeEventListener('keydown', handleKeydown);
|
| 174 |
+
}, { once: true });
|
| 175 |
+
}
|
| 176 |
+
|
| 177 |
+
/**
|
| 178 |
+
* Finish inline editing and save if valid.
|
| 179 |
+
* @param {HTMLElement} displayEl - The token display element
|
| 180 |
+
* @param {string} originalToken - The original token before editing
|
| 181 |
+
* @param {string} originalBg - The original background color
|
| 182 |
+
*/
|
| 183 |
+
function finishInlineEdit(displayEl, originalToken, originalBg) {
|
| 184 |
+
displayEl.contentEditable = false;
|
| 185 |
+
displayEl.style.backgroundColor = originalBg;
|
| 186 |
+
displayEl.style.outline = '';
|
| 187 |
+
displayEl.style.outlineOffset = '';
|
| 188 |
+
|
| 189 |
+
const newToken = displayEl.textContent.trim();
|
| 190 |
+
|
| 191 |
+
if (newToken === originalToken) {
|
| 192 |
+
// No change
|
| 193 |
+
return;
|
| 194 |
+
}
|
| 195 |
+
|
| 196 |
+
if (setToken(newToken)) {
|
| 197 |
+
displayEl.textContent = newToken;
|
| 198 |
+
} else {
|
| 199 |
+
// Invalid token, revert
|
| 200 |
+
displayEl.textContent = originalToken;
|
| 201 |
+
alert('Invalid token format. Token must be: tok_ followed by 12 lowercase letters/numbers');
|
| 202 |
+
}
|
| 203 |
+
}
|
| 204 |
+
|
| 205 |
+
// Export functions for use in other scripts
|
| 206 |
+
window.TokenManager = {
|
| 207 |
+
generateToken,
|
| 208 |
+
isValidToken,
|
| 209 |
+
getOrCreateToken,
|
| 210 |
+
getToken,
|
| 211 |
+
setToken,
|
| 212 |
+
clearToken,
|
| 213 |
+
copyTokenToClipboard,
|
| 214 |
+
initTokenDisplay,
|
| 215 |
+
TOKEN_KEY,
|
| 216 |
+
};
|
webapp/templates/about.html
CHANGED
|
@@ -198,31 +198,6 @@
|
|
| 198 |
</div>
|
| 199 |
</section>
|
| 200 |
|
| 201 |
-
<!-- Citation Section -->
|
| 202 |
-
<section class="mb-12">
|
| 203 |
-
<h2 class="text-2xl font-bold text-gray-900 mb-4">Citation</h2>
|
| 204 |
-
<div class="bg-gray-50 rounded-lg border border-gray-200 p-6">
|
| 205 |
-
<p class="text-gray-700 mb-4">If you use this tool in your research, please cite:</p>
|
| 206 |
-
<blockquote class="border-l-4 border-primary-500 pl-4 italic text-gray-600">
|
| 207 |
-
Liao SE, Sudarshan M, and Regev O. "Machine learning for discovery: deciphering RNA splicing logic." <em>bioRxiv</em> (2022).
|
| 208 |
-
</blockquote>
|
| 209 |
-
<div class="mt-4 flex space-x-4">
|
| 210 |
-
<a href="https://www.biorxiv.org/content/10.1101/2022.10.01.510472v1" target="_blank" class="inline-flex items-center text-primary-600 hover:text-primary-700">
|
| 211 |
-
<svg class="mr-2 h-4 w-4" fill="currentColor" viewBox="0 0 24 24">
|
| 212 |
-
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8l-6-6zm-1 2l5 5h-5V4zM6 20V4h6v6h6v10H6z"/>
|
| 213 |
-
</svg>
|
| 214 |
-
View Paper
|
| 215 |
-
</a>
|
| 216 |
-
<a href="https://github.com/Sachin1801/interpretable-splicing-model" target="_blank" class="inline-flex items-center text-primary-600 hover:text-primary-700">
|
| 217 |
-
<svg class="mr-2 h-4 w-4" fill="currentColor" viewBox="0 0 24 24">
|
| 218 |
-
<path fill-rule="evenodd" d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z" clip-rule="evenodd" />
|
| 219 |
-
</svg>
|
| 220 |
-
View Code
|
| 221 |
-
</a>
|
| 222 |
-
</div>
|
| 223 |
-
</div>
|
| 224 |
-
</section>
|
| 225 |
-
|
| 226 |
<!-- CTA -->
|
| 227 |
<div class="text-center">
|
| 228 |
<a href="/" class="inline-flex items-center px-6 py-3 border border-transparent text-base font-medium rounded-md shadow-sm text-white bg-primary-600 hover:bg-primary-700">
|
|
|
|
| 198 |
</div>
|
| 199 |
</section>
|
| 200 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 201 |
<!-- CTA -->
|
| 202 |
<div class="text-center">
|
| 203 |
<a href="/" class="inline-flex items-center px-6 py-3 border border-transparent text-base font-medium rounded-md shadow-sm text-white bg-primary-600 hover:bg-primary-700">
|
webapp/templates/base.html
CHANGED
|
@@ -72,8 +72,8 @@
|
|
| 72 |
<a href="/tutorial" class="px-3 py-2 text-sm font-medium text-gray-700 hover:text-primary-600 hover:bg-gray-50 rounded-md {% if request.url.path == '/tutorial' %}text-primary-600 bg-primary-50{% endif %}">
|
| 73 |
Tutorial
|
| 74 |
</a>
|
| 75 |
-
<a href="/
|
| 76 |
-
|
| 77 |
</a>
|
| 78 |
</div>
|
| 79 |
|
|
@@ -99,23 +99,11 @@
|
|
| 99 |
<a href="/methodology" class="block px-3 py-2 text-base font-medium text-gray-700 hover:text-primary-600 hover:bg-gray-50 rounded-md">Methodology</a>
|
| 100 |
<a href="/help" class="block px-3 py-2 text-base font-medium text-gray-700 hover:text-primary-600 hover:bg-gray-50 rounded-md">Help</a>
|
| 101 |
<a href="/tutorial" class="block px-3 py-2 text-base font-medium text-gray-700 hover:text-primary-600 hover:bg-gray-50 rounded-md">Tutorial</a>
|
| 102 |
-
<a href="/
|
| 103 |
</div>
|
| 104 |
</div>
|
| 105 |
</nav>
|
| 106 |
|
| 107 |
-
<!-- Free access banner -->
|
| 108 |
-
<div class="bg-green-50 border-b border-green-200">
|
| 109 |
-
<div class="max-w-7xl mx-auto py-2 px-4 sm:px-6 lg:px-8">
|
| 110 |
-
<p class="text-sm text-center text-green-800">
|
| 111 |
-
<svg class="inline h-4 w-4 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
| 112 |
-
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
| 113 |
-
</svg>
|
| 114 |
-
This website is <strong>free and open</strong> to all users. No login required.
|
| 115 |
-
</p>
|
| 116 |
-
</div>
|
| 117 |
-
</div>
|
| 118 |
-
|
| 119 |
<!-- Main content -->
|
| 120 |
<main class="flex-grow">
|
| 121 |
{% block content %}{% endblock %}
|
|
@@ -124,34 +112,18 @@
|
|
| 124 |
<!-- Footer -->
|
| 125 |
<footer class="bg-white border-t border-gray-200 mt-auto">
|
| 126 |
<div class="max-w-7xl mx-auto py-8 px-4 sm:px-6 lg:px-8">
|
| 127 |
-
<div class="
|
| 128 |
-
<
|
| 129 |
-
<
|
| 130 |
-
|
| 131 |
-
<
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
</a>
|
| 135 |
-
<a href="https://github.com/Sachin1801/interpretable-splicing-model" target="_blank" class="text-gray-400 hover:text-gray-500">
|
| 136 |
-
<span class="sr-only">GitHub</span>
|
| 137 |
-
<svg class="h-6 w-6" fill="currentColor" viewBox="0 0 24 24">
|
| 138 |
-
<path fill-rule="evenodd" d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z" clip-rule="evenodd" />
|
| 139 |
-
</svg>
|
| 140 |
-
</a>
|
| 141 |
-
</div>
|
| 142 |
-
<div class="mt-8 md:mt-0 md:order-1">
|
| 143 |
-
<p class="text-center text-sm text-gray-500">
|
| 144 |
-
<strong>Citation:</strong> Liao SE, Sudarshan M, and Regev O.
|
| 145 |
-
"Machine learning for discovery: deciphering RNA splicing logic."
|
| 146 |
-
<em>bioRxiv</em> (2022).
|
| 147 |
-
</p>
|
| 148 |
-
</div>
|
| 149 |
</div>
|
| 150 |
-
<div class="mt-4
|
| 151 |
<p class="text-center text-xs text-gray-400">
|
| 152 |
Version {{ settings.app_version }} |
|
| 153 |
-
<a href="/help" class="hover:text-gray-500">Help</a>
|
| 154 |
-
<a href="/docs" class="hover:text-gray-500">API Documentation</a>
|
| 155 |
</p>
|
| 156 |
</div>
|
| 157 |
</div>
|
|
|
|
| 72 |
<a href="/tutorial" class="px-3 py-2 text-sm font-medium text-gray-700 hover:text-primary-600 hover:bg-gray-50 rounded-md {% if request.url.path == '/tutorial' %}text-primary-600 bg-primary-50{% endif %}">
|
| 73 |
Tutorial
|
| 74 |
</a>
|
| 75 |
+
<a href="/history" class="px-3 py-2 text-sm font-medium text-gray-700 hover:text-primary-600 hover:bg-gray-50 rounded-md {% if request.url.path == '/history' %}text-primary-600 bg-primary-50{% endif %}">
|
| 76 |
+
History
|
| 77 |
</a>
|
| 78 |
</div>
|
| 79 |
|
|
|
|
| 99 |
<a href="/methodology" class="block px-3 py-2 text-base font-medium text-gray-700 hover:text-primary-600 hover:bg-gray-50 rounded-md">Methodology</a>
|
| 100 |
<a href="/help" class="block px-3 py-2 text-base font-medium text-gray-700 hover:text-primary-600 hover:bg-gray-50 rounded-md">Help</a>
|
| 101 |
<a href="/tutorial" class="block px-3 py-2 text-base font-medium text-gray-700 hover:text-primary-600 hover:bg-gray-50 rounded-md">Tutorial</a>
|
| 102 |
+
<a href="/history" class="block px-3 py-2 text-base font-medium text-gray-700 hover:text-primary-600 hover:bg-gray-50 rounded-md">History</a>
|
| 103 |
</div>
|
| 104 |
</div>
|
| 105 |
</nav>
|
| 106 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 107 |
<!-- Main content -->
|
| 108 |
<main class="flex-grow">
|
| 109 |
{% block content %}{% endblock %}
|
|
|
|
| 112 |
<!-- Footer -->
|
| 113 |
<footer class="bg-white border-t border-gray-200 mt-auto">
|
| 114 |
<div class="max-w-7xl mx-auto py-8 px-4 sm:px-6 lg:px-8">
|
| 115 |
+
<div class="flex justify-center">
|
| 116 |
+
<a href="https://github.com/Sachin1801/interpretable-splicing-model" target="_blank" class="text-gray-400 hover:text-gray-500">
|
| 117 |
+
<span class="sr-only">GitHub</span>
|
| 118 |
+
<svg class="h-6 w-6" fill="currentColor" viewBox="0 0 24 24">
|
| 119 |
+
<path fill-rule="evenodd" d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z" clip-rule="evenodd" />
|
| 120 |
+
</svg>
|
| 121 |
+
</a>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 122 |
</div>
|
| 123 |
+
<div class="mt-4">
|
| 124 |
<p class="text-center text-xs text-gray-400">
|
| 125 |
Version {{ settings.app_version }} |
|
| 126 |
+
<a href="/help" class="hover:text-gray-500">Help</a>
|
|
|
|
| 127 |
</p>
|
| 128 |
</div>
|
| 129 |
</div>
|
webapp/templates/batch_result.html
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{% extends "base.html" %}
|
| 2 |
+
|
| 3 |
+
{% block title %}Batch Results - {{ settings.app_name }}{% endblock %}
|
| 4 |
+
|
| 5 |
+
{% block content %}
|
| 6 |
+
<div class="max-w-7xl mx-auto py-8 px-4 sm:px-6 lg:px-8">
|
| 7 |
+
<!-- Header -->
|
| 8 |
+
<div class="mb-6">
|
| 9 |
+
<nav class="flex" aria-label="Breadcrumb">
|
| 10 |
+
<ol class="flex items-center space-x-2">
|
| 11 |
+
<li>
|
| 12 |
+
<a href="/" class="text-gray-400 hover:text-gray-500">
|
| 13 |
+
<svg class="h-5 w-5" fill="currentColor" viewBox="0 0 20 20">
|
| 14 |
+
<path d="M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" />
|
| 15 |
+
</svg>
|
| 16 |
+
</a>
|
| 17 |
+
</li>
|
| 18 |
+
<li>
|
| 19 |
+
<svg class="h-5 w-5 text-gray-300" fill="currentColor" viewBox="0 0 20 20">
|
| 20 |
+
<path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd" />
|
| 21 |
+
</svg>
|
| 22 |
+
</li>
|
| 23 |
+
<li>
|
| 24 |
+
<a href="/history" class="text-sm font-medium text-gray-500 hover:text-gray-700">History</a>
|
| 25 |
+
</li>
|
| 26 |
+
<li>
|
| 27 |
+
<svg class="h-5 w-5 text-gray-300" fill="currentColor" viewBox="0 0 20 20">
|
| 28 |
+
<path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd" />
|
| 29 |
+
</svg>
|
| 30 |
+
</li>
|
| 31 |
+
<li>
|
| 32 |
+
<span class="text-sm font-medium text-gray-500">Batch Results</span>
|
| 33 |
+
</li>
|
| 34 |
+
</ol>
|
| 35 |
+
</nav>
|
| 36 |
+
<h1 class="mt-2 text-2xl font-bold text-gray-900" id="job-title">Batch Results</h1>
|
| 37 |
+
<p class="text-sm text-gray-500">Job ID: <code class="bg-gray-100 px-2 py-1 rounded text-xs">{{ job_id }}</code></p>
|
| 38 |
+
</div>
|
| 39 |
+
|
| 40 |
+
<!-- Loading State -->
|
| 41 |
+
<div id="loading-state" class="text-center py-12">
|
| 42 |
+
<svg class="animate-spin h-10 w-10 text-primary-600 mx-auto" fill="none" viewBox="0 0 24 24">
|
| 43 |
+
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
| 44 |
+
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
| 45 |
+
</svg>
|
| 46 |
+
<p class="mt-4 text-gray-500" id="loading-text">Loading results...</p>
|
| 47 |
+
</div>
|
| 48 |
+
|
| 49 |
+
<!-- Results Container (hidden initially) -->
|
| 50 |
+
<div id="results-container" class="hidden space-y-6">
|
| 51 |
+
<!-- Summary Stats -->
|
| 52 |
+
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
| 53 |
+
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-4 text-center">
|
| 54 |
+
<p class="text-sm font-medium text-gray-500">Total Sequences</p>
|
| 55 |
+
<p id="stat-total" class="mt-1 text-2xl font-bold text-gray-900">0</p>
|
| 56 |
+
</div>
|
| 57 |
+
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-4 text-center">
|
| 58 |
+
<p class="text-sm font-medium text-gray-500">Successful</p>
|
| 59 |
+
<p id="stat-success" class="mt-1 text-2xl font-bold text-green-600">0</p>
|
| 60 |
+
</div>
|
| 61 |
+
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-4 text-center">
|
| 62 |
+
<p class="text-sm font-medium text-gray-500">Invalid</p>
|
| 63 |
+
<p id="stat-invalid" class="mt-1 text-2xl font-bold text-red-600">0</p>
|
| 64 |
+
</div>
|
| 65 |
+
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-4 text-center">
|
| 66 |
+
<p class="text-sm font-medium text-gray-500">Average PSI</p>
|
| 67 |
+
<p id="stat-avg-psi" class="mt-1 text-2xl font-bold text-primary-600">-</p>
|
| 68 |
+
</div>
|
| 69 |
+
</div>
|
| 70 |
+
|
| 71 |
+
<!-- Search and Export -->
|
| 72 |
+
<div class="flex flex-wrap gap-4 items-center justify-between">
|
| 73 |
+
<div class="flex-1 min-w-[200px] max-w-md">
|
| 74 |
+
<input type="text" id="search-results"
|
| 75 |
+
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-primary-500 focus:ring-primary-500 text-sm"
|
| 76 |
+
placeholder="Search by name or sequence...">
|
| 77 |
+
</div>
|
| 78 |
+
<div class="flex gap-2">
|
| 79 |
+
<a id="export-csv" href="#" class="inline-flex items-center px-3 py-1.5 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50">
|
| 80 |
+
<svg class="mr-1.5 h-4 w-4 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
| 81 |
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
| 82 |
+
</svg>
|
| 83 |
+
CSV
|
| 84 |
+
</a>
|
| 85 |
+
<a id="export-json" href="#" class="inline-flex items-center px-3 py-1.5 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50">
|
| 86 |
+
<svg class="mr-1.5 h-4 w-4 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
| 87 |
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
| 88 |
+
</svg>
|
| 89 |
+
JSON
|
| 90 |
+
</a>
|
| 91 |
+
<button onclick="copyLink()" class="inline-flex items-center px-3 py-1.5 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50">
|
| 92 |
+
<svg class="mr-1.5 h-4 w-4 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
| 93 |
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 002 2h2a2 2 0 002-2M8 5a2 2 0 012-2h2a2 2 0 012 2m0 0h2a2 2 0 012 2v3m2 4H10m0 0l3-3m-3 3l3 3" />
|
| 94 |
+
</svg>
|
| 95 |
+
Copy Link
|
| 96 |
+
</button>
|
| 97 |
+
</div>
|
| 98 |
+
</div>
|
| 99 |
+
|
| 100 |
+
<!-- Results Table -->
|
| 101 |
+
<div class="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden">
|
| 102 |
+
<table class="min-w-full divide-y divide-gray-200">
|
| 103 |
+
<thead class="bg-gray-50">
|
| 104 |
+
<tr>
|
| 105 |
+
<th scope="col" class="w-10 px-4 py-3"></th>
|
| 106 |
+
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Name</th>
|
| 107 |
+
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Sequence</th>
|
| 108 |
+
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">PSI</th>
|
| 109 |
+
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
|
| 110 |
+
</tr>
|
| 111 |
+
</thead>
|
| 112 |
+
<tbody id="results-table-body" class="bg-white divide-y divide-gray-200">
|
| 113 |
+
<!-- Populated by JavaScript -->
|
| 114 |
+
</tbody>
|
| 115 |
+
</table>
|
| 116 |
+
</div>
|
| 117 |
+
|
| 118 |
+
<!-- Pagination -->
|
| 119 |
+
<div id="pagination" class="flex items-center justify-between">
|
| 120 |
+
<div>
|
| 121 |
+
<p class="text-sm text-gray-700">
|
| 122 |
+
Showing <span id="page-start">1</span> to <span id="page-end">25</span> of <span id="total-results">0</span> results
|
| 123 |
+
</p>
|
| 124 |
+
</div>
|
| 125 |
+
<div>
|
| 126 |
+
<nav class="relative z-0 inline-flex rounded-md shadow-sm -space-x-px" aria-label="Pagination" id="page-buttons">
|
| 127 |
+
<!-- Populated by JavaScript -->
|
| 128 |
+
</nav>
|
| 129 |
+
</div>
|
| 130 |
+
</div>
|
| 131 |
+
</div>
|
| 132 |
+
|
| 133 |
+
<!-- Error State -->
|
| 134 |
+
<div id="error-state" class="hidden">
|
| 135 |
+
<div class="rounded-lg bg-red-50 border border-red-200 p-6 text-center">
|
| 136 |
+
<svg class="h-12 w-12 text-red-400 mx-auto" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
| 137 |
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
| 138 |
+
</svg>
|
| 139 |
+
<h3 class="mt-4 text-lg font-medium text-red-800">Error Loading Results</h3>
|
| 140 |
+
<p id="error-message" class="mt-2 text-red-700"></p>
|
| 141 |
+
<a href="/" class="mt-4 inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-red-700 bg-red-100 hover:bg-red-200">
|
| 142 |
+
Try Again
|
| 143 |
+
</a>
|
| 144 |
+
</div>
|
| 145 |
+
</div>
|
| 146 |
+
</div>
|
| 147 |
+
|
| 148 |
+
<!-- Detail Modal -->
|
| 149 |
+
<div id="detail-modal" class="hidden fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
| 150 |
+
<div class="bg-white rounded-lg max-w-4xl w-full max-h-[90vh] overflow-y-auto">
|
| 151 |
+
<div class="sticky top-0 bg-white border-b border-gray-200 px-6 py-4 flex justify-between items-center">
|
| 152 |
+
<h3 id="detail-title" class="text-lg font-medium text-gray-900">Sequence Details</h3>
|
| 153 |
+
<button onclick="closeDetailModal()" class="text-gray-400 hover:text-gray-500">
|
| 154 |
+
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
| 155 |
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
| 156 |
+
</svg>
|
| 157 |
+
</button>
|
| 158 |
+
</div>
|
| 159 |
+
<div id="detail-content" class="p-6 space-y-6">
|
| 160 |
+
<!-- Loading spinner for detail -->
|
| 161 |
+
<div id="detail-loading" class="text-center py-8">
|
| 162 |
+
<svg class="animate-spin h-8 w-8 text-primary-600 mx-auto" fill="none" viewBox="0 0 24 24">
|
| 163 |
+
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
| 164 |
+
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
| 165 |
+
</svg>
|
| 166 |
+
</div>
|
| 167 |
+
<!-- Detail will be populated here -->
|
| 168 |
+
</div>
|
| 169 |
+
</div>
|
| 170 |
+
</div>
|
| 171 |
+
{% endblock %}
|
| 172 |
+
|
| 173 |
+
{% block scripts %}
|
| 174 |
+
<script>
|
| 175 |
+
const jobId = "{{ job_id }}";
|
| 176 |
+
</script>
|
| 177 |
+
<script src="/static/js/batch-result.js"></script>
|
| 178 |
+
{% endblock %}
|
webapp/templates/history.html
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{% extends "base.html" %}
|
| 2 |
+
|
| 3 |
+
{% block title %}Job History - {{ settings.app_name }}{% endblock %}
|
| 4 |
+
|
| 5 |
+
{% block content %}
|
| 6 |
+
<div class="max-w-6xl mx-auto py-8 px-4 sm:px-6 lg:px-8">
|
| 7 |
+
<div class="mb-6">
|
| 8 |
+
<h1 class="text-2xl font-bold text-gray-900">Job History</h1>
|
| 9 |
+
<p class="mt-2 text-sm text-gray-600">
|
| 10 |
+
View and manage your previous prediction jobs. Jobs are identified by your access token.
|
| 11 |
+
</p>
|
| 12 |
+
</div>
|
| 13 |
+
|
| 14 |
+
<!-- Token Entry Section -->
|
| 15 |
+
<div id="token-section" class="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6">
|
| 16 |
+
<div class="flex items-center justify-between flex-wrap gap-4">
|
| 17 |
+
<div class="flex items-center gap-2 flex-wrap">
|
| 18 |
+
<span class="text-sm font-medium text-blue-900">Access Token:</span>
|
| 19 |
+
<code id="token-display" class="bg-white px-3 py-1 rounded border border-blue-200 text-sm font-mono text-blue-800"></code>
|
| 20 |
+
<button type="button" id="token-copy-btn" class="text-blue-600 hover:text-blue-800 text-sm font-medium">
|
| 21 |
+
Copy
|
| 22 |
+
</button>
|
| 23 |
+
<button type="button" id="token-edit-btn" class="text-gray-600 hover:text-gray-800 text-sm font-medium">
|
| 24 |
+
Edit
|
| 25 |
+
</button>
|
| 26 |
+
</div>
|
| 27 |
+
<button type="button" id="refresh-btn" class="inline-flex items-center px-3 py-1.5 border border-blue-300 text-sm font-medium rounded-md text-blue-700 bg-white hover:bg-blue-50">
|
| 28 |
+
<svg class="w-4 h-4 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
| 29 |
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
| 30 |
+
</svg>
|
| 31 |
+
Refresh
|
| 32 |
+
</button>
|
| 33 |
+
</div>
|
| 34 |
+
</div>
|
| 35 |
+
|
| 36 |
+
<!-- Token Edit Modal -->
|
| 37 |
+
<div id="token-edit-modal" class="hidden fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
| 38 |
+
<div class="bg-white rounded-lg p-6 max-w-md w-full mx-4">
|
| 39 |
+
<h3 class="text-lg font-medium text-gray-900 mb-4">Edit Access Token</h3>
|
| 40 |
+
<p class="text-sm text-gray-600 mb-4">
|
| 41 |
+
Enter a different token to view jobs from another device or session.
|
| 42 |
+
</p>
|
| 43 |
+
<input type="text" id="token-edit-input" class="w-full rounded-md border-gray-300 shadow-sm focus:border-primary-500 focus:ring-primary-500 font-mono text-sm mb-4" placeholder="tok_xxxxxxxxxxxx">
|
| 44 |
+
<div class="flex justify-end gap-3">
|
| 45 |
+
<button type="button" id="token-cancel-btn" class="px-4 py-2 text-sm font-medium text-gray-700 hover:text-gray-900">Cancel</button>
|
| 46 |
+
<button type="button" id="token-save-btn" class="px-4 py-2 text-sm font-medium text-white bg-primary-600 rounded-md hover:bg-primary-700">Load Jobs</button>
|
| 47 |
+
</div>
|
| 48 |
+
</div>
|
| 49 |
+
</div>
|
| 50 |
+
|
| 51 |
+
<!-- Filters -->
|
| 52 |
+
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-4 mb-6">
|
| 53 |
+
<div class="flex flex-wrap gap-4 items-end">
|
| 54 |
+
<div class="flex-1 min-w-[200px]">
|
| 55 |
+
<label for="search-title" class="block text-sm font-medium text-gray-700 mb-1">Search Job Titles</label>
|
| 56 |
+
<input type="text" id="search-title"
|
| 57 |
+
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-primary-500 focus:ring-primary-500 text-sm"
|
| 58 |
+
placeholder="Search...">
|
| 59 |
+
</div>
|
| 60 |
+
<div>
|
| 61 |
+
<label for="date-from" class="block text-sm font-medium text-gray-700 mb-1">From Date</label>
|
| 62 |
+
<input type="date" id="date-from"
|
| 63 |
+
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-primary-500 focus:ring-primary-500 text-sm">
|
| 64 |
+
</div>
|
| 65 |
+
<div>
|
| 66 |
+
<label for="date-to" class="block text-sm font-medium text-gray-700 mb-1">To Date</label>
|
| 67 |
+
<input type="date" id="date-to"
|
| 68 |
+
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-primary-500 focus:ring-primary-500 text-sm">
|
| 69 |
+
</div>
|
| 70 |
+
<button type="button" id="apply-filters-btn"
|
| 71 |
+
class="px-4 py-2 text-sm font-medium text-white bg-primary-600 rounded-md hover:bg-primary-700">
|
| 72 |
+
Apply
|
| 73 |
+
</button>
|
| 74 |
+
<button type="button" id="clear-filters-btn"
|
| 75 |
+
class="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 rounded-md hover:bg-gray-200">
|
| 76 |
+
Clear
|
| 77 |
+
</button>
|
| 78 |
+
</div>
|
| 79 |
+
</div>
|
| 80 |
+
|
| 81 |
+
<!-- Loading State -->
|
| 82 |
+
<div id="loading-state" class="hidden text-center py-8">
|
| 83 |
+
<svg class="animate-spin h-8 w-8 text-primary-600 mx-auto" fill="none" viewBox="0 0 24 24">
|
| 84 |
+
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
| 85 |
+
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
| 86 |
+
</svg>
|
| 87 |
+
<p class="mt-2 text-sm text-gray-500">Loading jobs...</p>
|
| 88 |
+
</div>
|
| 89 |
+
|
| 90 |
+
<!-- No Jobs State -->
|
| 91 |
+
<div id="no-jobs-state" class="hidden text-center py-12">
|
| 92 |
+
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
| 93 |
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
|
| 94 |
+
</svg>
|
| 95 |
+
<h3 class="mt-2 text-sm font-medium text-gray-900">No jobs found</h3>
|
| 96 |
+
<p class="mt-1 text-sm text-gray-500">No prediction jobs found for this token.</p>
|
| 97 |
+
<div class="mt-6">
|
| 98 |
+
<a href="/" class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-primary-600 hover:bg-primary-700">
|
| 99 |
+
Create New Prediction
|
| 100 |
+
</a>
|
| 101 |
+
</div>
|
| 102 |
+
</div>
|
| 103 |
+
|
| 104 |
+
<!-- Jobs Table -->
|
| 105 |
+
<div id="jobs-table-container" class="hidden bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden">
|
| 106 |
+
<table class="min-w-full divide-y divide-gray-200">
|
| 107 |
+
<thead class="bg-gray-50">
|
| 108 |
+
<tr>
|
| 109 |
+
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Job Title</th>
|
| 110 |
+
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Date</th>
|
| 111 |
+
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
|
| 112 |
+
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Sequences</th>
|
| 113 |
+
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
|
| 114 |
+
</tr>
|
| 115 |
+
</thead>
|
| 116 |
+
<tbody id="jobs-table-body" class="bg-white divide-y divide-gray-200">
|
| 117 |
+
<!-- Populated by JavaScript -->
|
| 118 |
+
</tbody>
|
| 119 |
+
</table>
|
| 120 |
+
</div>
|
| 121 |
+
|
| 122 |
+
<!-- Pagination -->
|
| 123 |
+
<div id="pagination" class="hidden mt-4 flex items-center justify-between">
|
| 124 |
+
<div class="flex-1 flex justify-between sm:hidden">
|
| 125 |
+
<button id="prev-page-mobile" class="relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50">Previous</button>
|
| 126 |
+
<button id="next-page-mobile" class="ml-3 relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50">Next</button>
|
| 127 |
+
</div>
|
| 128 |
+
<div class="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
|
| 129 |
+
<div>
|
| 130 |
+
<p class="text-sm text-gray-700">
|
| 131 |
+
Showing <span id="page-start">1</span> to <span id="page-end">25</span> of <span id="total-jobs">0</span> jobs
|
| 132 |
+
</p>
|
| 133 |
+
</div>
|
| 134 |
+
<div>
|
| 135 |
+
<nav class="relative z-0 inline-flex rounded-md shadow-sm -space-x-px" aria-label="Pagination" id="page-buttons">
|
| 136 |
+
<!-- Populated by JavaScript -->
|
| 137 |
+
</nav>
|
| 138 |
+
</div>
|
| 139 |
+
</div>
|
| 140 |
+
</div>
|
| 141 |
+
</div>
|
| 142 |
+
{% endblock %}
|
| 143 |
+
|
| 144 |
+
{% block scripts %}
|
| 145 |
+
<script src="/static/js/token.js"></script>
|
| 146 |
+
<script src="/static/js/history.js"></script>
|
| 147 |
+
{% endblock %}
|
webapp/templates/index.html
CHANGED
|
@@ -9,106 +9,135 @@
|
|
| 9 |
<h1 class="text-3xl font-bold text-gray-900 sm:text-4xl">
|
| 10 |
RNA Splicing Predictor
|
| 11 |
</h1>
|
| 12 |
-
<p class="mt-4 text-lg text-gray-600 max-w-2xl mx-auto">
|
| 13 |
-
Predict alternative splicing outcomes using an interpretable deep learning model.
|
| 14 |
-
Enter a 70-nucleotide exon sequence to get a PSI (Percent Spliced In) prediction.
|
| 15 |
-
</p>
|
| 16 |
</div>
|
| 17 |
|
| 18 |
-
<!--
|
| 19 |
-
<div class="
|
| 20 |
-
<div class="
|
| 21 |
-
<
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
</
|
| 26 |
-
|
| 27 |
-
<div class="ml-4">
|
| 28 |
-
<h3 class="text-sm font-medium text-gray-900">Input</h3>
|
| 29 |
-
<p class="text-sm text-gray-500">70nt exon sequence</p>
|
| 30 |
-
</div>
|
| 31 |
-
</div>
|
| 32 |
-
</div>
|
| 33 |
-
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
|
| 34 |
-
<div class="flex items-center">
|
| 35 |
-
<div class="flex-shrink-0">
|
| 36 |
-
<svg class="h-8 w-8 text-primary-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
| 37 |
-
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
| 38 |
-
</svg>
|
| 39 |
-
</div>
|
| 40 |
-
<div class="ml-4">
|
| 41 |
-
<h3 class="text-sm font-medium text-gray-900">Output</h3>
|
| 42 |
-
<p class="text-sm text-gray-500">PSI value (0-1)</p>
|
| 43 |
-
</div>
|
| 44 |
-
</div>
|
| 45 |
-
</div>
|
| 46 |
-
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
|
| 47 |
-
<div class="flex items-center">
|
| 48 |
-
<div class="flex-shrink-0">
|
| 49 |
-
<svg class="h-8 w-8 text-primary-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
| 50 |
-
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
| 51 |
-
</svg>
|
| 52 |
-
</div>
|
| 53 |
-
<div class="ml-4">
|
| 54 |
-
<h3 class="text-sm font-medium text-gray-900">Speed</h3>
|
| 55 |
-
<p class="text-sm text-gray-500">Results in seconds</p>
|
| 56 |
</div>
|
| 57 |
-
</
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
</div>
|
| 59 |
</div>
|
| 60 |
|
| 61 |
<!-- Prediction Form -->
|
| 62 |
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
| 63 |
<form id="predict-form">
|
|
|
|
| 64 |
<div class="mb-4">
|
| 65 |
-
<label for="
|
| 66 |
-
|
| 67 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
</label>
|
| 69 |
-
<
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 79 |
<div class="mt-2 flex justify-between items-center">
|
| 80 |
-
<span id="
|
| 81 |
<span id="validation-message" class="text-sm text-red-500 hidden"></span>
|
| 82 |
</div>
|
| 83 |
</div>
|
| 84 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 85 |
<div class="flex flex-wrap gap-3">
|
| 86 |
-
<button
|
| 87 |
-
|
| 88 |
-
id="submit-btn"
|
| 89 |
-
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
| 90 |
-
>
|
| 91 |
<svg class="hidden animate-spin -ml-1 mr-2 h-4 w-4 text-white" id="loading-spinner" fill="none" viewBox="0 0 24 24">
|
| 92 |
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
| 93 |
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
| 94 |
</svg>
|
| 95 |
<span id="submit-text">Predict PSI</span>
|
| 96 |
</button>
|
| 97 |
-
<button
|
| 98 |
-
|
| 99 |
-
id="example-btn"
|
| 100 |
-
class="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
|
| 101 |
-
>
|
| 102 |
<svg class="mr-2 h-4 w-4 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
| 103 |
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
| 104 |
</svg>
|
| 105 |
Try Example
|
| 106 |
</button>
|
| 107 |
-
<button
|
| 108 |
-
|
| 109 |
-
id="clear-btn"
|
| 110 |
-
class="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
|
| 111 |
-
>
|
| 112 |
<svg class="mr-2 h-4 w-4 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
| 113 |
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
| 114 |
</svg>
|
|
@@ -128,48 +157,291 @@ Example: GGTAGTACGCCAATTCGCCGGTGCCGCGAGCCAGAGGCTACCAAAACTTGACAAGCCTACATATACTACT"
|
|
| 128 |
</div>
|
| 129 |
</div>
|
| 130 |
|
| 131 |
-
<!-- Example Sequences -->
|
| 132 |
-
<div class="mt-8">
|
| 133 |
-
<h2 class="text-lg font-medium text-gray-900 mb-4">Example Sequences</h2>
|
| 134 |
-
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
| 135 |
-
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-4 cursor-pointer hover:border-primary-300 transition-colors" onclick="loadExample(0)">
|
| 136 |
-
<div class="flex items-center justify-between mb-2">
|
| 137 |
-
<span class="text-sm font-medium text-gray-900">High Inclusion</span>
|
| 138 |
-
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">PSI ~0.98</span>
|
| 139 |
-
</div>
|
| 140 |
-
<p class="text-xs text-gray-500 font-mono truncate">GGTAGTACGCCAATTCGCCG...</p>
|
| 141 |
-
</div>
|
| 142 |
-
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-4 cursor-pointer hover:border-primary-300 transition-colors" onclick="loadExample(1)">
|
| 143 |
-
<div class="flex items-center justify-between mb-2">
|
| 144 |
-
<span class="text-sm font-medium text-gray-900">Balanced</span>
|
| 145 |
-
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800">PSI ~0.49</span>
|
| 146 |
-
</div>
|
| 147 |
-
<p class="text-xs text-gray-500 font-mono truncate">CTACCACCTCCCAAGCTTAC...</p>
|
| 148 |
-
</div>
|
| 149 |
-
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-4 cursor-pointer hover:border-primary-300 transition-colors" onclick="loadExample(2)">
|
| 150 |
-
<div class="flex items-center justify-between mb-2">
|
| 151 |
-
<span class="text-sm font-medium text-gray-900">High Skipping</span>
|
| 152 |
-
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800">PSI ~0.00</span>
|
| 153 |
-
</div>
|
| 154 |
-
<p class="text-xs text-gray-500 font-mono truncate">ACACTCCGCAGCACACTCGG...</p>
|
| 155 |
-
</div>
|
| 156 |
-
</div>
|
| 157 |
-
</div>
|
| 158 |
-
|
| 159 |
-
<!-- Learn More -->
|
| 160 |
-
<div class="mt-8 text-center">
|
| 161 |
-
<p class="text-sm text-gray-500">
|
| 162 |
-
Want to learn more?
|
| 163 |
-
<a href="/about" class="text-primary-600 hover:text-primary-700 font-medium">About the model</a>
|
| 164 |
-
<span class="mx-2">|</span>
|
| 165 |
-
<a href="/methodology" class="text-primary-600 hover:text-primary-700 font-medium">How it works</a>
|
| 166 |
-
<span class="mx-2">|</span>
|
| 167 |
-
<a href="/tutorial" class="text-primary-600 hover:text-primary-700 font-medium">Tutorial</a>
|
| 168 |
-
</p>
|
| 169 |
-
</div>
|
| 170 |
</div>
|
| 171 |
{% endblock %}
|
| 172 |
|
| 173 |
{% block scripts %}
|
| 174 |
-
<script src="/static/js/
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 175 |
{% endblock %}
|
|
|
|
| 9 |
<h1 class="text-3xl font-bold text-gray-900 sm:text-4xl">
|
| 10 |
RNA Splicing Predictor
|
| 11 |
</h1>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
</div>
|
| 13 |
|
| 14 |
+
<!-- Token Display Section -->
|
| 15 |
+
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6">
|
| 16 |
+
<div class="flex items-center justify-center gap-2">
|
| 17 |
+
<span class="relative group text-sm font-medium text-blue-900 cursor-help underline decoration-dotted underline-offset-2">
|
| 18 |
+
Your Access Token:
|
| 19 |
+
<div class="absolute left-full top-1/2 -translate-y-1/2 ml-2 hidden group-hover:block w-64 p-3 bg-gray-900 text-white text-xs rounded-lg shadow-lg z-50 font-normal no-underline">
|
| 20 |
+
<p>This unique token identifies your jobs. Save it to access your job history from any device or browser.</p>
|
| 21 |
+
<p class="mt-2 text-gray-300">Double-click the token to edit it.</p>
|
| 22 |
+
<div class="absolute right-full top-1/2 -translate-y-1/2 w-0 h-0 border-t-8 border-b-8 border-r-8 border-transparent border-r-gray-900"></div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
</div>
|
| 24 |
+
</span>
|
| 25 |
+
<code id="token-display" class="bg-white px-3 py-1 rounded border border-blue-200 text-sm font-mono text-blue-800"></code>
|
| 26 |
+
<button type="button" id="token-copy-btn" class="text-blue-600 hover:text-blue-800 text-sm font-medium">
|
| 27 |
+
Copy
|
| 28 |
+
</button>
|
| 29 |
</div>
|
| 30 |
</div>
|
| 31 |
|
| 32 |
<!-- Prediction Form -->
|
| 33 |
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
| 34 |
<form id="predict-form">
|
| 35 |
+
<!-- Job Title -->
|
| 36 |
<div class="mb-4">
|
| 37 |
+
<label for="job-title" class="block text-sm text-gray-700 mb-2">
|
| 38 |
+
<span class="relative group font-medium cursor-help underline decoration-dotted underline-offset-2">
|
| 39 |
+
Job Title
|
| 40 |
+
<div class="absolute left-full top-1/2 -translate-y-1/2 ml-2 hidden group-hover:block w-64 p-3 bg-gray-900 text-white text-xs rounded-lg shadow-lg z-50 font-normal no-underline">
|
| 41 |
+
<p>Give your prediction job a memorable name to easily find it later in your job history.</p>
|
| 42 |
+
<p class="mt-2 text-gray-300">If left empty, a name will be auto-generated (e.g., "2026-01-15_abc12").</p>
|
| 43 |
+
<div class="absolute right-full top-1/2 -translate-y-1/2 w-0 h-0 border-t-8 border-b-8 border-r-8 border-transparent border-r-gray-900"></div>
|
| 44 |
+
</div>
|
| 45 |
+
</span>
|
| 46 |
+
<span class="text-gray-400 font-normal">(optional)</span>
|
| 47 |
</label>
|
| 48 |
+
<input type="text" id="job-title" name="job_title"
|
| 49 |
+
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-primary-500 focus:ring-primary-500 text-sm p-2 border"
|
| 50 |
+
placeholder="e.g., BRCA1 Exon Analysis">
|
| 51 |
+
</div>
|
| 52 |
+
|
| 53 |
+
<!-- Sequence Input -->
|
| 54 |
+
<div class="mb-4">
|
| 55 |
+
<label for="sequences" class="block text-sm text-gray-700 mb-2">
|
| 56 |
+
<span class="relative group font-medium cursor-help underline decoration-dotted underline-offset-2">
|
| 57 |
+
Sequence(s)
|
| 58 |
+
<div class="absolute left-full top-1/2 -translate-y-1/2 ml-2 hidden group-hover:block w-72 p-3 bg-gray-900 text-white text-xs rounded-lg shadow-lg z-50 font-normal no-underline">
|
| 59 |
+
<p class="font-semibold mb-1">How to enter sequences:</p>
|
| 60 |
+
<ul class="list-disc list-inside space-y-1">
|
| 61 |
+
<li><strong>Single sequence:</strong> Paste a 70-nucleotide sequence directly</li>
|
| 62 |
+
<li><strong>Multiple (FASTA):</strong> Use >name on one line, sequence on next</li>
|
| 63 |
+
<li><strong>Multiple (plain):</strong> One sequence per line</li>
|
| 64 |
+
</ul>
|
| 65 |
+
<p class="mt-2 text-gray-300">Only A, C, G, T, U characters allowed. Each sequence must be exactly 70 nucleotides.</p>
|
| 66 |
+
<div class="absolute right-full top-1/2 -translate-y-1/2 w-0 h-0 border-t-8 border-b-8 border-r-8 border-transparent border-r-gray-900"></div>
|
| 67 |
+
</div>
|
| 68 |
+
</span>
|
| 69 |
+
<span class="text-gray-400 font-normal">(70nt each, A/C/G/T/U, max 100)</span>
|
| 70 |
+
</label>
|
| 71 |
+
<textarea id="sequences" name="sequences" rows="8"
|
| 72 |
+
class="block w-full rounded-md border-gray-300 shadow-sm font-mono text-sm p-3 border focus:border-primary-500 focus:ring-primary-500"
|
| 73 |
+
placeholder="Paste one or more sequences:
|
| 74 |
+
|
| 75 |
+
Single sequence:
|
| 76 |
+
GGTAGTACGCCAATTCGCCGGTGCCGCGAGCCAGAGGCTACCAAAACTTGACAAGCCTACATATACTACT
|
| 77 |
+
|
| 78 |
+
Multiple sequences (FASTA format):
|
| 79 |
+
>Gene1_Exon5
|
| 80 |
+
GGTAGTACGCCAATTCGCCGGTGCCGCGAGCCAGAGGCTACCAAAACTTGACAAGCCTACATATACTACT
|
| 81 |
+
>Gene2_Exon3
|
| 82 |
+
CTACCACCTCCCAAGCTTACACACTGTTTGATGAAAGGTCGCCACAACGTTCCCTCACCCCTAGTCTCGC
|
| 83 |
+
|
| 84 |
+
Or plain sequences (one per line):
|
| 85 |
+
GGTAGTACGCCAATTCGCCGGTGCCGCGAGCCAGAGGCTACCAAAACTTGACAAGCCTACATATACTACT
|
| 86 |
+
CTACCACCTCCCAAGCTTACACACTGTTTGATGAAAGGTCGCCACAACGTTCCCTCACCCCTAGTCTCGC"></textarea>
|
| 87 |
<div class="mt-2 flex justify-between items-center">
|
| 88 |
+
<span id="sequence-count" class="text-sm text-gray-500">0 sequences detected</span>
|
| 89 |
<span id="validation-message" class="text-sm text-red-500 hidden"></span>
|
| 90 |
</div>
|
| 91 |
</div>
|
| 92 |
|
| 93 |
+
<!-- File Upload -->
|
| 94 |
+
<div class="mb-6">
|
| 95 |
+
<label class="block text-sm text-gray-700 mb-2">
|
| 96 |
+
<span class="relative group font-medium cursor-help underline decoration-dotted underline-offset-2">
|
| 97 |
+
Or Upload File
|
| 98 |
+
<div class="absolute left-full top-1/2 -translate-y-1/2 ml-2 hidden group-hover:block w-72 p-3 bg-gray-900 text-white text-xs rounded-lg shadow-lg z-50 font-normal no-underline">
|
| 99 |
+
<p class="font-semibold mb-1">Supported file formats:</p>
|
| 100 |
+
<ul class="list-disc list-inside space-y-1">
|
| 101 |
+
<li><strong>CSV:</strong> 1 column (sequences only) or 2 columns (name, sequence)</li>
|
| 102 |
+
<li><strong>FASTA:</strong> Standard format with >header lines</li>
|
| 103 |
+
</ul>
|
| 104 |
+
<p class="mt-2 text-gray-300">Delimiters (comma, semicolon, tab) and headers are auto-detected.</p>
|
| 105 |
+
<div class="absolute right-full top-1/2 -translate-y-1/2 w-0 h-0 border-t-8 border-b-8 border-r-8 border-transparent border-r-gray-900"></div>
|
| 106 |
+
</div>
|
| 107 |
+
</span>
|
| 108 |
+
<span class="text-gray-400 font-normal">(CSV or FASTA)</span>
|
| 109 |
+
</label>
|
| 110 |
+
<div class="flex items-center gap-4">
|
| 111 |
+
<input type="file" id="file-upload" accept=".csv,.fasta,.fa,.fna,.txt,.tsv"
|
| 112 |
+
class="block w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-md file:border-0 file:text-sm file:font-medium file:bg-primary-50 file:text-primary-700 hover:file:bg-primary-100 cursor-pointer">
|
| 113 |
+
<button type="button" id="clear-file-btn" class="text-gray-400 hover:text-gray-600 hidden">
|
| 114 |
+
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
| 115 |
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
| 116 |
+
</svg>
|
| 117 |
+
</button>
|
| 118 |
+
</div>
|
| 119 |
+
<p id="file-status" class="mt-1 text-sm text-gray-500"></p>
|
| 120 |
+
</div>
|
| 121 |
+
|
| 122 |
+
<!-- Action Buttons -->
|
| 123 |
<div class="flex flex-wrap gap-3">
|
| 124 |
+
<button type="submit" id="submit-btn"
|
| 125 |
+
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 disabled:opacity-50 disabled:cursor-not-allowed">
|
|
|
|
|
|
|
|
|
|
| 126 |
<svg class="hidden animate-spin -ml-1 mr-2 h-4 w-4 text-white" id="loading-spinner" fill="none" viewBox="0 0 24 24">
|
| 127 |
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
| 128 |
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
| 129 |
</svg>
|
| 130 |
<span id="submit-text">Predict PSI</span>
|
| 131 |
</button>
|
| 132 |
+
<button type="button" id="example-btn"
|
| 133 |
+
class="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500">
|
|
|
|
|
|
|
|
|
|
| 134 |
<svg class="mr-2 h-4 w-4 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
| 135 |
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
| 136 |
</svg>
|
| 137 |
Try Example
|
| 138 |
</button>
|
| 139 |
+
<button type="button" id="clear-btn"
|
| 140 |
+
class="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500">
|
|
|
|
|
|
|
|
|
|
| 141 |
<svg class="mr-2 h-4 w-4 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
| 142 |
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
| 143 |
</svg>
|
|
|
|
| 157 |
</div>
|
| 158 |
</div>
|
| 159 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 160 |
</div>
|
| 161 |
{% endblock %}
|
| 162 |
|
| 163 |
{% block scripts %}
|
| 164 |
+
<script src="/static/js/token.js"></script>
|
| 165 |
+
<script src="/static/js/file-parser.js"></script>
|
| 166 |
+
<script>
|
| 167 |
+
// Example sequences
|
| 168 |
+
const EXAMPLES = [
|
| 169 |
+
{
|
| 170 |
+
name: "High Inclusion",
|
| 171 |
+
sequence: "GGTAGTACGCCAATTCGCCGGTGCCGCGAGCCAGAGGCTACCAAAACTTGACAAGCCTACATATACTACT",
|
| 172 |
+
expectedPsi: "~0.98"
|
| 173 |
+
},
|
| 174 |
+
{
|
| 175 |
+
name: "Balanced",
|
| 176 |
+
sequence: "CTACCACCTCCCAAGCTTACACACTGTTTGATGAAAGGTCGCCACAACGTTCCCTCACCCCTAGTCTCGC",
|
| 177 |
+
expectedPsi: "~0.49"
|
| 178 |
+
},
|
| 179 |
+
{
|
| 180 |
+
name: "High Skipping",
|
| 181 |
+
sequence: "ACACTCCGCAGCACACTCGGCAAAGAAGTTAGGCCCCGCTCTTACAAACATCTAGCATTTTGTATGGTCT",
|
| 182 |
+
expectedPsi: "~0.00"
|
| 183 |
+
}
|
| 184 |
+
];
|
| 185 |
+
|
| 186 |
+
// DOM Elements
|
| 187 |
+
const form = document.getElementById('predict-form');
|
| 188 |
+
const sequencesInput = document.getElementById('sequences');
|
| 189 |
+
const jobTitleInput = document.getElementById('job-title');
|
| 190 |
+
const sequenceCount = document.getElementById('sequence-count');
|
| 191 |
+
const validationMessage = document.getElementById('validation-message');
|
| 192 |
+
const submitBtn = document.getElementById('submit-btn');
|
| 193 |
+
const submitText = document.getElementById('submit-text');
|
| 194 |
+
const loadingSpinner = document.getElementById('loading-spinner');
|
| 195 |
+
const errorMessage = document.getElementById('error-message');
|
| 196 |
+
const errorText = document.getElementById('error-text');
|
| 197 |
+
const exampleBtn = document.getElementById('example-btn');
|
| 198 |
+
const clearBtn = document.getElementById('clear-btn');
|
| 199 |
+
const fileUpload = document.getElementById('file-upload');
|
| 200 |
+
const clearFileBtn = document.getElementById('clear-file-btn');
|
| 201 |
+
const fileStatus = document.getElementById('file-status');
|
| 202 |
+
|
| 203 |
+
let currentExampleIndex = 0;
|
| 204 |
+
let parsedSequences = [];
|
| 205 |
+
|
| 206 |
+
// Initialize token display
|
| 207 |
+
document.addEventListener('DOMContentLoaded', () => {
|
| 208 |
+
TokenManager.initTokenDisplay();
|
| 209 |
+
updateSequenceCount();
|
| 210 |
+
});
|
| 211 |
+
|
| 212 |
+
/**
|
| 213 |
+
* Update sequence count and validation
|
| 214 |
+
*/
|
| 215 |
+
function updateSequenceCount() {
|
| 216 |
+
const text = sequencesInput.value;
|
| 217 |
+
if (!text.trim()) {
|
| 218 |
+
sequenceCount.textContent = '0 sequences detected';
|
| 219 |
+
sequenceCount.className = 'text-sm text-gray-500';
|
| 220 |
+
parsedSequences = [];
|
| 221 |
+
updateSubmitButton();
|
| 222 |
+
return;
|
| 223 |
+
}
|
| 224 |
+
|
| 225 |
+
const result = FileParser.parseAndValidate(text, 70);
|
| 226 |
+
parsedSequences = result.sequences;
|
| 227 |
+
|
| 228 |
+
if (parsedSequences.length === 0) {
|
| 229 |
+
sequenceCount.textContent = '0 sequences detected';
|
| 230 |
+
sequenceCount.className = 'text-sm text-gray-500';
|
| 231 |
+
} else if (parsedSequences.length === 1) {
|
| 232 |
+
if (result.validCount === 1) {
|
| 233 |
+
sequenceCount.textContent = '1 valid sequence';
|
| 234 |
+
sequenceCount.className = 'text-sm text-green-600';
|
| 235 |
+
} else {
|
| 236 |
+
sequenceCount.textContent = '1 sequence (invalid)';
|
| 237 |
+
sequenceCount.className = 'text-sm text-red-600';
|
| 238 |
+
}
|
| 239 |
+
} else {
|
| 240 |
+
if (result.invalidCount === 0) {
|
| 241 |
+
sequenceCount.textContent = `${parsedSequences.length} valid sequences`;
|
| 242 |
+
sequenceCount.className = 'text-sm text-green-600';
|
| 243 |
+
} else {
|
| 244 |
+
sequenceCount.textContent = `${parsedSequences.length} sequences (${result.validCount} valid, ${result.invalidCount} invalid)`;
|
| 245 |
+
sequenceCount.className = 'text-sm text-yellow-600';
|
| 246 |
+
}
|
| 247 |
+
}
|
| 248 |
+
|
| 249 |
+
updateSubmitButton();
|
| 250 |
+
}
|
| 251 |
+
|
| 252 |
+
/**
|
| 253 |
+
* Update submit button state
|
| 254 |
+
*/
|
| 255 |
+
function updateSubmitButton() {
|
| 256 |
+
// Enable if we have at least one sequence (valid or not - backend will handle)
|
| 257 |
+
submitBtn.disabled = parsedSequences.length === 0;
|
| 258 |
+
}
|
| 259 |
+
|
| 260 |
+
/**
|
| 261 |
+
* Show loading state
|
| 262 |
+
*/
|
| 263 |
+
function setLoading(loading) {
|
| 264 |
+
if (loading) {
|
| 265 |
+
submitBtn.disabled = true;
|
| 266 |
+
submitText.textContent = 'Processing...';
|
| 267 |
+
loadingSpinner.classList.remove('hidden');
|
| 268 |
+
} else {
|
| 269 |
+
updateSubmitButton();
|
| 270 |
+
submitText.textContent = 'Predict PSI';
|
| 271 |
+
loadingSpinner.classList.add('hidden');
|
| 272 |
+
}
|
| 273 |
+
}
|
| 274 |
+
|
| 275 |
+
/**
|
| 276 |
+
* Show error message
|
| 277 |
+
*/
|
| 278 |
+
function showError(message) {
|
| 279 |
+
errorText.textContent = message;
|
| 280 |
+
errorMessage.classList.remove('hidden');
|
| 281 |
+
}
|
| 282 |
+
|
| 283 |
+
/**
|
| 284 |
+
* Hide error message
|
| 285 |
+
*/
|
| 286 |
+
function hideError() {
|
| 287 |
+
errorMessage.classList.add('hidden');
|
| 288 |
+
}
|
| 289 |
+
|
| 290 |
+
/**
|
| 291 |
+
* Load an example sequence
|
| 292 |
+
*/
|
| 293 |
+
function loadExample(index) {
|
| 294 |
+
const example = EXAMPLES[index];
|
| 295 |
+
sequencesInput.value = `>${example.name}\n${example.sequence}`;
|
| 296 |
+
updateSequenceCount();
|
| 297 |
+
hideError();
|
| 298 |
+
}
|
| 299 |
+
|
| 300 |
+
/**
|
| 301 |
+
* Handle file upload
|
| 302 |
+
*/
|
| 303 |
+
async function handleFileUpload(event) {
|
| 304 |
+
const file = event.target.files[0];
|
| 305 |
+
if (!file) return;
|
| 306 |
+
|
| 307 |
+
try {
|
| 308 |
+
fileStatus.textContent = 'Parsing file...';
|
| 309 |
+
const sequences = await FileParser.parseFile(file);
|
| 310 |
+
|
| 311 |
+
if (sequences.length === 0) {
|
| 312 |
+
fileStatus.textContent = 'No sequences found in file';
|
| 313 |
+
fileStatus.className = 'mt-1 text-sm text-red-500';
|
| 314 |
+
return;
|
| 315 |
+
}
|
| 316 |
+
|
| 317 |
+
// Convert to FASTA format and put in textarea
|
| 318 |
+
const fastaText = sequences.map(s => `>${s.name}\n${s.sequence}`).join('\n');
|
| 319 |
+
sequencesInput.value = fastaText;
|
| 320 |
+
updateSequenceCount();
|
| 321 |
+
|
| 322 |
+
fileStatus.textContent = `Loaded ${sequences.length} sequences from ${file.name}`;
|
| 323 |
+
fileStatus.className = 'mt-1 text-sm text-green-600';
|
| 324 |
+
clearFileBtn.classList.remove('hidden');
|
| 325 |
+
hideError();
|
| 326 |
+
|
| 327 |
+
} catch (error) {
|
| 328 |
+
fileStatus.textContent = `Error: ${error.message}`;
|
| 329 |
+
fileStatus.className = 'mt-1 text-sm text-red-500';
|
| 330 |
+
}
|
| 331 |
+
}
|
| 332 |
+
|
| 333 |
+
/**
|
| 334 |
+
* Clear file input
|
| 335 |
+
*/
|
| 336 |
+
function clearFileInput() {
|
| 337 |
+
fileUpload.value = '';
|
| 338 |
+
fileStatus.textContent = '';
|
| 339 |
+
clearFileBtn.classList.add('hidden');
|
| 340 |
+
}
|
| 341 |
+
|
| 342 |
+
/**
|
| 343 |
+
* Submit the prediction
|
| 344 |
+
*/
|
| 345 |
+
async function submitPrediction(event) {
|
| 346 |
+
event.preventDefault();
|
| 347 |
+
|
| 348 |
+
if (parsedSequences.length === 0) {
|
| 349 |
+
showError('Please enter at least one sequence');
|
| 350 |
+
return;
|
| 351 |
+
}
|
| 352 |
+
|
| 353 |
+
setLoading(true);
|
| 354 |
+
hideError();
|
| 355 |
+
|
| 356 |
+
const token = TokenManager.getOrCreateToken();
|
| 357 |
+
const jobTitle = jobTitleInput.value.trim() || null;
|
| 358 |
+
|
| 359 |
+
try {
|
| 360 |
+
let response;
|
| 361 |
+
let result;
|
| 362 |
+
|
| 363 |
+
if (parsedSequences.length === 1) {
|
| 364 |
+
// Single sequence prediction
|
| 365 |
+
const seq = parsedSequences[0];
|
| 366 |
+
response = await fetch('/api/predict', {
|
| 367 |
+
method: 'POST',
|
| 368 |
+
headers: { 'Content-Type': 'application/json' },
|
| 369 |
+
body: JSON.stringify({
|
| 370 |
+
sequence: seq.sequence,
|
| 371 |
+
name: seq.name,
|
| 372 |
+
access_token: token,
|
| 373 |
+
job_title: jobTitle
|
| 374 |
+
})
|
| 375 |
+
});
|
| 376 |
+
} else {
|
| 377 |
+
// Batch prediction
|
| 378 |
+
const sequences = parsedSequences.map(s => ({
|
| 379 |
+
name: s.name,
|
| 380 |
+
sequence: s.sequence
|
| 381 |
+
}));
|
| 382 |
+
response = await fetch('/api/batch', {
|
| 383 |
+
method: 'POST',
|
| 384 |
+
headers: { 'Content-Type': 'application/json' },
|
| 385 |
+
body: JSON.stringify({
|
| 386 |
+
sequences: sequences,
|
| 387 |
+
access_token: token,
|
| 388 |
+
job_title: jobTitle
|
| 389 |
+
})
|
| 390 |
+
});
|
| 391 |
+
}
|
| 392 |
+
|
| 393 |
+
if (!response.ok) {
|
| 394 |
+
const error = await response.json();
|
| 395 |
+
throw new Error(error.detail || 'Prediction failed');
|
| 396 |
+
}
|
| 397 |
+
|
| 398 |
+
result = await response.json();
|
| 399 |
+
|
| 400 |
+
// Redirect to result page
|
| 401 |
+
window.location.href = `/result/${result.job_id}`;
|
| 402 |
+
|
| 403 |
+
} catch (error) {
|
| 404 |
+
console.error('Prediction error:', error);
|
| 405 |
+
showError(error.message || 'An error occurred. Please try again.');
|
| 406 |
+
setLoading(false);
|
| 407 |
+
}
|
| 408 |
+
}
|
| 409 |
+
|
| 410 |
+
// Event listeners
|
| 411 |
+
if (form) {
|
| 412 |
+
form.addEventListener('submit', submitPrediction);
|
| 413 |
+
}
|
| 414 |
+
|
| 415 |
+
if (sequencesInput) {
|
| 416 |
+
sequencesInput.addEventListener('input', updateSequenceCount);
|
| 417 |
+
}
|
| 418 |
+
|
| 419 |
+
if (exampleBtn) {
|
| 420 |
+
exampleBtn.addEventListener('click', () => {
|
| 421 |
+
loadExample(currentExampleIndex);
|
| 422 |
+
currentExampleIndex = (currentExampleIndex + 1) % EXAMPLES.length;
|
| 423 |
+
});
|
| 424 |
+
}
|
| 425 |
+
|
| 426 |
+
if (clearBtn) {
|
| 427 |
+
clearBtn.addEventListener('click', () => {
|
| 428 |
+
sequencesInput.value = '';
|
| 429 |
+
jobTitleInput.value = '';
|
| 430 |
+
updateSequenceCount();
|
| 431 |
+
clearFileInput();
|
| 432 |
+
hideError();
|
| 433 |
+
});
|
| 434 |
+
}
|
| 435 |
+
|
| 436 |
+
if (fileUpload) {
|
| 437 |
+
fileUpload.addEventListener('change', handleFileUpload);
|
| 438 |
+
}
|
| 439 |
+
|
| 440 |
+
if (clearFileBtn) {
|
| 441 |
+
clearFileBtn.addEventListener('click', clearFileInput);
|
| 442 |
+
}
|
| 443 |
+
|
| 444 |
+
// Make loadExample available globally for onclick handlers
|
| 445 |
+
window.loadExample = loadExample;
|
| 446 |
+
</script>
|
| 447 |
{% endblock %}
|
webapp/templates/result.html
CHANGED
|
@@ -81,6 +81,26 @@
|
|
| 81 |
<div id="force-plot" class="w-full" style="height: 400px;"></div>
|
| 82 |
</div>
|
| 83 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 84 |
<!-- Actions -->
|
| 85 |
<div class="flex flex-wrap gap-3">
|
| 86 |
<a href="/" class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-primary-600 hover:bg-primary-700">
|
|
|
|
| 81 |
<div id="force-plot" class="w-full" style="height: 400px;"></div>
|
| 82 |
</div>
|
| 83 |
|
| 84 |
+
<!-- Filter Activation Heatmap -->
|
| 85 |
+
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
| 86 |
+
<h3 class="text-sm font-medium text-gray-500 uppercase tracking-wide mb-4">
|
| 87 |
+
Filter Activation Heatmap
|
| 88 |
+
<span class="ml-2 text-gray-400 font-normal normal-case">
|
| 89 |
+
(shows where each neural network filter activates across the sequence)
|
| 90 |
+
</span>
|
| 91 |
+
</h3>
|
| 92 |
+
<div id="heatmap-container" class="w-full" style="min-height: 500px;">
|
| 93 |
+
<iframe
|
| 94 |
+
id="heatmap-iframe"
|
| 95 |
+
src="/shiny/heatmap/?job_id={{ job_id }}"
|
| 96 |
+
class="w-full border-0"
|
| 97 |
+
style="height: 600px; min-height: 500px;"
|
| 98 |
+
loading="lazy"
|
| 99 |
+
title="Filter Activation Heatmap"
|
| 100 |
+
></iframe>
|
| 101 |
+
</div>
|
| 102 |
+
</div>
|
| 103 |
+
|
| 104 |
<!-- Actions -->
|
| 105 |
<div class="flex flex-wrap gap-3">
|
| 106 |
<a href="/" class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-primary-600 hover:bg-primary-700">
|