danapantoja
api
8f58f48
/**
* History page JavaScript - Sequence-centric view
*/
// State
let currentPage = 1;
let pageSize = 25;
let totalSequences = 0;
let totalPages = 1;
let currentFilters = {
jobTitle: '',
sequenceId: '',
sequence: '',
dateFrom: null,
dateTo: null,
psiOperator: '',
psiValue: null,
psiValue2: null
};
let filterPanelOpen = false;
let allSequences = []; // Current page data
let selectedItems = new Map(); // key: "jobId:batchIndex" or "jobId", value: sequence object
let showSequenceColumn = false;
let currentSort = { column: 'created_at', order: 'desc' };
/** Base URL for Shiny iframes (HTTPS when not localhost; use localhost not 0.0.0.0 to avoid invalid response). */
function getShinyBaseUrl() {
const hostname = window.location.hostname;
const isLocal = hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '0.0.0.0';
if (!isLocal) return 'https://' + window.location.host;
if (hostname === '0.0.0.0') return window.location.protocol + '//localhost:' + window.location.port;
return window.location.origin;
}
// DOM Elements
const tokenDisplay = document.getElementById('token-display');
const loadingState = document.getElementById('loading-state');
const noJobsState = document.getElementById('no-jobs-state');
const jobsTableContainer = document.getElementById('jobs-table-container');
const sequencesTableBody = document.getElementById('sequences-table-body');
const pagination = document.getElementById('pagination');
const pageStart = document.getElementById('page-start');
const pageEnd = document.getElementById('page-end');
const totalJobsEl = document.getElementById('total-jobs');
const pageButtons = document.getElementById('page-buttons');
// Filter panel elements
const filterToggleBtn = document.getElementById('filter-toggle-btn');
const filterPanel = document.getElementById('filter-panel');
const filterChevron = document.getElementById('filter-chevron');
const activeFilterCount = document.getElementById('active-filter-count');
const filterJobTitle = document.getElementById('filter-job-title');
const filterSequenceId = document.getElementById('filter-sequence-id');
const filterSequence = document.getElementById('filter-sequence');
const filterDateFrom = document.getElementById('filter-date-from');
const filterDateTo = document.getElementById('filter-date-to');
const filterPsiOperator = document.getElementById('filter-psi-operator');
const filterPsiValue = document.getElementById('filter-psi-value');
const filterPsiValue2 = document.getElementById('filter-psi-value2');
const filterPsiValue2Container = document.getElementById('filter-psi-value2-container');
const applyFiltersBtn = document.getElementById('apply-filters-btn');
const clearFiltersBtn = document.getElementById('clear-filters-btn');
const refreshBtn = document.getElementById('refresh-btn');
const selectAllCheckbox = document.getElementById('select-all-checkbox');
const bulkActionsToolbar = document.getElementById('bulk-actions-toolbar');
const selectionCountEl = document.getElementById('selection-count');
const columnVisibility = document.getElementById('column-visibility');
const showSequenceColCheckbox = document.getElementById('show-sequence-col');
const sequenceHeader = document.getElementById('sequence-header');
// Modal elements
const exportModal = document.getElementById('export-modal');
const deleteModal = document.getElementById('delete-modal');
const deleteCountEl = document.getElementById('delete-count');
const deleteBatchWarning = document.getElementById('delete-batch-warning');
/**
* Initialize the page
*/
document.addEventListener('DOMContentLoaded', () => {
TokenManager.initTokenDisplay();
loadSequences();
// Filter event listeners
if (applyFiltersBtn) {
applyFiltersBtn.addEventListener('click', applyFilters);
}
if (clearFiltersBtn) {
clearFiltersBtn.addEventListener('click', clearFilters);
}
if (refreshBtn) {
refreshBtn.addEventListener('click', () => loadSequences());
}
// Filter panel toggle
if (filterToggleBtn) {
filterToggleBtn.addEventListener('click', toggleFilterPanel);
}
// PSI operator change handler - show/hide second value for "between"
if (filterPsiOperator) {
filterPsiOperator.addEventListener('change', () => {
const isBetween = filterPsiOperator.value === 'between';
filterPsiValue2Container.classList.toggle('hidden', !isBetween);
});
}
// Apply filters on Enter key in any filter input
const filterInputs = [filterJobTitle, filterSequenceId, filterSequence, filterPsiValue, filterPsiValue2];
filterInputs.forEach(input => {
if (input) {
input.addEventListener('keypress', (e) => {
if (e.key === 'Enter') {
applyFilters();
}
});
}
});
// Select all checkbox
if (selectAllCheckbox) {
selectAllCheckbox.addEventListener('change', (e) => {
if (e.target.checked) {
selectAllOnPage();
} else {
deselectAllOnPage();
}
});
}
// Bulk action buttons
const exportSelectedBtn = document.getElementById('export-selected-btn');
const deleteSelectedBtn = document.getElementById('delete-selected-btn');
if (exportSelectedBtn) {
exportSelectedBtn.addEventListener('click', openExportModal);
}
if (deleteSelectedBtn) {
deleteSelectedBtn.addEventListener('click', openDeleteModal);
}
// Column visibility toggle
if (showSequenceColCheckbox) {
showSequenceColCheckbox.addEventListener('change', (e) => {
toggleSequenceColumn(e.target.checked);
});
}
// Export modal buttons
const exportCancelBtn = document.getElementById('export-cancel-btn');
const exportDownloadBtn = document.getElementById('export-download-btn');
if (exportCancelBtn) {
exportCancelBtn.addEventListener('click', closeExportModal);
}
if (exportDownloadBtn) {
exportDownloadBtn.addEventListener('click', exportSelected);
}
// Delete modal buttons
const deleteCancelBtn = document.getElementById('delete-cancel-btn');
const deleteConfirmBtn = document.getElementById('delete-confirm-btn');
if (deleteCancelBtn) {
deleteCancelBtn.addEventListener('click', closeDeleteModal);
}
if (deleteConfirmBtn) {
deleteConfirmBtn.addEventListener('click', deleteSelected);
}
// Close modals on backdrop click
if (exportModal) {
exportModal.addEventListener('click', (e) => {
if (e.target === exportModal) closeExportModal();
});
}
if (deleteModal) {
deleteModal.addEventListener('click', (e) => {
if (e.target === deleteModal) closeDeleteModal();
});
}
// Sortable header click handlers
document.querySelectorAll('.sortable-header').forEach(header => {
header.addEventListener('click', () => {
const column = header.dataset.sort;
if (currentSort.column === column) {
// Toggle order if same column
currentSort.order = currentSort.order === 'asc' ? 'desc' : 'asc';
} else {
// New column, default to descending (except for text columns)
currentSort.column = column;
currentSort.order = (column === 'job_title' || column === 'sequence_id') ? 'asc' : 'desc';
}
updateSortIndicators();
currentPage = 1;
loadSequences();
});
});
// Initialize sort indicators
updateSortIndicators();
});
/**
* Update sort indicator icons in table headers
*/
function updateSortIndicators() {
document.querySelectorAll('.sortable-header').forEach(header => {
const column = header.dataset.sort;
const isActive = currentSort.column === column;
// Remove existing sort classes
header.classList.remove('sorted', 'asc', 'desc');
// Add appropriate classes if this column is active
if (isActive) {
header.classList.add('sorted', currentSort.order);
}
});
}
/**
* Toggle filter panel open/closed
*/
function toggleFilterPanel() {
filterPanelOpen = !filterPanelOpen;
filterPanel.classList.toggle('hidden', !filterPanelOpen);
filterChevron.classList.toggle('rotate-180', filterPanelOpen);
}
/**
* Update active filter count badge
*/
function updateActiveFilterCount() {
let count = 0;
if (currentFilters.jobTitle) count++;
if (currentFilters.sequenceId) count++;
if (currentFilters.sequence) count++;
if (currentFilters.dateFrom || currentFilters.dateTo) count++;
if (currentFilters.psiOperator && currentFilters.psiValue !== null) count++;
if (count > 0) {
activeFilterCount.textContent = count;
activeFilterCount.classList.remove('hidden');
} else {
activeFilterCount.classList.add('hidden');
}
}
/**
* Apply current filters and load sequences
*/
function applyFilters() {
// Collect all filter values
currentFilters.jobTitle = filterJobTitle.value.trim();
currentFilters.sequenceId = filterSequenceId.value.trim();
currentFilters.sequence = filterSequence.value.trim();
currentFilters.dateFrom = filterDateFrom.value || null;
currentFilters.dateTo = filterDateTo.value || null;
currentFilters.psiOperator = filterPsiOperator.value || '';
currentFilters.psiValue = filterPsiValue.value ? parseFloat(filterPsiValue.value) : null;
currentFilters.psiValue2 = filterPsiOperator.value === 'between' && filterPsiValue2.value
? parseFloat(filterPsiValue2.value)
: null;
updateActiveFilterCount();
currentPage = 1;
loadSequences();
}
/**
* Clear all filters
*/
function clearFilters() {
// Clear all inputs
filterJobTitle.value = '';
filterSequenceId.value = '';
filterSequence.value = '';
filterDateFrom.value = '';
filterDateTo.value = '';
filterPsiOperator.value = '';
filterPsiValue.value = '';
filterPsiValue2.value = '';
filterPsiValue2Container.classList.add('hidden');
// Reset filter state
currentFilters = {
jobTitle: '',
sequenceId: '',
sequence: '',
dateFrom: null,
dateTo: null,
psiOperator: '',
psiValue: null,
psiValue2: null
};
updateActiveFilterCount();
currentPage = 1;
loadSequences();
}
/**
* Load sequences from API
*/
async function loadSequences() {
const token = TokenManager.getOrCreateToken();
if (!token) {
showNoSequences();
return;
}
showLoading();
try {
// Build query parameters
const params = new URLSearchParams({
access_token: token,
page: currentPage,
page_size: pageSize
});
// Add text search filters
if (currentFilters.jobTitle) {
params.append('job_title', currentFilters.jobTitle);
}
if (currentFilters.sequenceId) {
params.append('sequence_id', currentFilters.sequenceId);
}
if (currentFilters.sequence) {
params.append('sequence', currentFilters.sequence);
}
// Add date filters
if (currentFilters.dateFrom) {
params.append('date_from', currentFilters.dateFrom);
}
if (currentFilters.dateTo) {
params.append('date_to', currentFilters.dateTo);
}
// Add PSI filters
if (currentFilters.psiOperator && currentFilters.psiValue !== null) {
params.append('psi_operator', currentFilters.psiOperator);
params.append('psi_value', currentFilters.psiValue);
if (currentFilters.psiOperator === 'between' && currentFilters.psiValue2 !== null) {
params.append('psi_value2', currentFilters.psiValue2);
}
}
// Add sort parameters
if (currentSort.column) {
params.append('sort_by', currentSort.column);
params.append('sort_order', currentSort.order);
}
const response = await fetch(`/api/history/sequences?${params.toString()}`);
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Failed to load sequences');
}
const data = await response.json();
totalSequences = data.total;
totalPages = data.total_pages;
allSequences = data.sequences;
if (data.sequences.length === 0) {
showNoSequences();
} else {
renderSequences(data.sequences);
renderPagination();
showTable();
}
} catch (error) {
console.error('Error loading sequences:', error);
showNoSequences();
}
}
/**
* Get selection key for a sequence
*/
function getSelectionKey(seq) {
if (seq.is_batch && seq.batch_index !== null) {
return `${seq.job_id}:${seq.batch_index}`;
}
return seq.job_id;
}
/**
* Render sequences in the table
*/
function renderSequences(sequences) {
sequencesTableBody.innerHTML = '';
for (let i = 0; i < sequences.length; i++) {
const seq = sequences[i];
const row = document.createElement('tr');
row.className = 'hover:bg-gray-50 cursor-pointer';
const key = getSelectionKey(seq);
const isSelected = selectedItems.has(key);
const statusBadge = getStatusBadge(seq.status);
const date = new Date(seq.created_at).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
// Format PSI - show as decimal or "-" if null
const psiDisplay = seq.psi !== null ? seq.psi.toFixed(3) : '-';
// Truncate sequence for display
const seqDisplay = seq.sequence.length > 20
? seq.sequence.substring(0, 20) + '...'
: seq.sequence;
const isFinished = seq.status === 'finished';
row.innerHTML = `
<td class="px-4 py-3 whitespace-nowrap" onclick="event.stopPropagation()">
<input type="checkbox"
class="seq-checkbox rounded border-gray-300 text-primary-600 focus:ring-primary-500"
data-key="${escapeHtml(key)}"
${isSelected ? 'checked' : ''}>
</td>
<td class="px-4 py-3 whitespace-nowrap text-sm text-primary-600 hover:text-primary-800">
${escapeHtml(seq.job_title || seq.job_id.substring(0, 8))}
</td>
<td class="px-4 py-3 whitespace-nowrap text-sm font-medium text-gray-900 group">
${seq.is_batch && seq.batch_index !== null
? `<span class="editable-name cursor-pointer hover:bg-gray-100 px-1 py-0.5 rounded inline-flex items-center gap-1"
onclick="event.stopPropagation(); startEditHistoryName(this, '${seq.job_id}', ${seq.batch_index}, '${escapeHtml(seq.sequence_id).replace(/'/g, "\\'")}')">
${escapeHtml(seq.sequence_id)}
<svg class="w-3 h-3 text-gray-400 opacity-0 group-hover:opacity-100 transition-opacity" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
</svg>
</span>`
: escapeHtml(seq.sequence_id)}
</td>
<td class="px-4 py-3 whitespace-nowrap text-sm font-mono ${seq.psi !== null ? 'text-gray-900' : 'text-gray-400'}">${psiDisplay}</td>
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-500">${date}</td>
<td class="px-4 py-3 whitespace-nowrap">${statusBadge}</td>
<td class="px-4 py-3 whitespace-nowrap" onclick="event.stopPropagation()">
<div class="flex items-center gap-1">
<button type="button" class="table-action-btn preview-btn" title="Preview" data-index="${i}" ${!isFinished ? 'disabled' : ''}>
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
</button>
<button type="button" class="table-action-btn copy-btn" title="Copy Job ID" data-job-id="${seq.job_id}">
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor">
<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" />
</svg>
</button>
<button type="button" class="table-action-btn download-btn" title="Download" data-index="${i}" ${!isFinished ? 'disabled' : ''}>
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor">
<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" />
</svg>
</button>
<button type="button" class="table-action-btn delete-btn" title="Delete" data-index="${i}">
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
</td>
<td class="seq-col px-4 py-3 whitespace-nowrap text-sm font-mono text-gray-500 ${showSequenceColumn ? '' : 'hidden'}">
<span title="${escapeHtml(seq.sequence)}">${escapeHtml(seqDisplay)}</span>
</td>
`;
// Row click navigates to detail
row.addEventListener('click', () => navigateToDetail(seq));
// Checkbox click
const checkbox = row.querySelector('.seq-checkbox');
checkbox.addEventListener('change', (e) => {
e.stopPropagation();
toggleSelection(key, seq);
});
// Action button event listeners
const previewBtn = row.querySelector('.preview-btn');
if (previewBtn && !previewBtn.disabled) {
previewBtn.addEventListener('click', (e) => {
e.stopPropagation();
openPreviewModal(seq);
});
}
const copyBtn = row.querySelector('.copy-btn');
if (copyBtn) {
copyBtn.addEventListener('click', (e) => {
e.stopPropagation();
copyJobId(seq.job_id);
});
}
const downloadBtn = row.querySelector('.download-btn');
if (downloadBtn && !downloadBtn.disabled) {
downloadBtn.addEventListener('click', (e) => {
e.stopPropagation();
openDownloadVizModal(seq);
});
}
const deleteBtn = row.querySelector('.delete-btn');
if (deleteBtn) {
deleteBtn.addEventListener('click', (e) => {
e.stopPropagation();
deleteSingleJob(seq);
});
}
sequencesTableBody.appendChild(row);
}
// Update select all checkbox state
updateSelectAllCheckbox();
}
/**
* Navigate to sequence detail page
*/
function navigateToDetail(seq) {
if (seq.is_batch && seq.batch_index !== null) {
window.location.href = `/batch/${seq.job_id}/sequence/${seq.batch_index}`;
} else {
window.location.href = `/result/${seq.job_id}`;
}
}
/**
* Toggle selection for a sequence
*/
function toggleSelection(key, seq) {
if (selectedItems.has(key)) {
selectedItems.delete(key);
} else {
selectedItems.set(key, seq);
}
updateBulkActionsToolbar();
updateSelectAllCheckbox();
}
/**
* Select all sequences on current page
*/
function selectAllOnPage() {
for (const seq of allSequences) {
const key = getSelectionKey(seq);
selectedItems.set(key, seq);
}
renderSequences(allSequences);
updateBulkActionsToolbar();
}
/**
* Deselect all sequences on current page
*/
function deselectAllOnPage() {
for (const seq of allSequences) {
const key = getSelectionKey(seq);
selectedItems.delete(key);
}
renderSequences(allSequences);
updateBulkActionsToolbar();
}
/**
* Deselect all sequences
*/
function deselectAll() {
selectedItems.clear();
renderSequences(allSequences);
updateBulkActionsToolbar();
}
/**
* Update the bulk actions toolbar visibility and count
*/
function updateBulkActionsToolbar() {
const count = selectedItems.size;
if (count > 0) {
bulkActionsToolbar.classList.remove('hidden');
if (columnVisibility) columnVisibility.classList.remove('hidden');
selectionCountEl.textContent = `${count} selected`;
} else {
bulkActionsToolbar.classList.add('hidden');
// Keep column visibility visible if table is shown
if (columnVisibility) {
if (!jobsTableContainer.classList.contains('hidden')) {
columnVisibility.classList.remove('hidden');
} else {
columnVisibility.classList.add('hidden');
}
}
}
}
/**
* Update select all checkbox state
*/
function updateSelectAllCheckbox() {
if (!selectAllCheckbox) return;
const allOnPageSelected = allSequences.length > 0 &&
allSequences.every(seq => selectedItems.has(getSelectionKey(seq)));
const someOnPageSelected = allSequences.some(seq => selectedItems.has(getSelectionKey(seq)));
selectAllCheckbox.checked = allOnPageSelected;
selectAllCheckbox.indeterminate = someOnPageSelected && !allOnPageSelected;
}
/**
* Toggle sequence column visibility
*/
function toggleSequenceColumn(show) {
showSequenceColumn = show;
// Update header
if (sequenceHeader) {
sequenceHeader.classList.toggle('hidden', !show);
}
// Update all cells
document.querySelectorAll('.seq-col').forEach(cell => {
cell.classList.toggle('hidden', !show);
});
}
/**
* Open export modal
*/
function openExportModal() {
if (selectedItems.size === 0) {
alert('No sequences selected');
return;
}
exportModal.classList.remove('hidden');
}
/**
* Close export modal
*/
function closeExportModal() {
exportModal.classList.add('hidden');
}
/**
* Export selected sequences
*/
async function exportSelected() {
const token = TokenManager.getToken();
if (!token) {
alert('No access token found');
return;
}
// Gather selected columns
const columns = ['sequence_id']; // Always included
const columnCheckboxes = [
'job_title', 'created_at', 'psi', 'status', 'sequence'
];
for (const col of columnCheckboxes) {
const checkbox = document.getElementById(`export-col-${col}`);
if (checkbox && checkbox.checked) {
columns.push(col);
}
}
// Build items list
const items = [];
for (const [key, seq] of selectedItems) {
items.push({
job_id: seq.job_id,
batch_index: seq.is_batch ? seq.batch_index : null
});
}
try {
const response = await fetch(`/api/sequences/export?access_token=${encodeURIComponent(token)}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ items, columns })
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Failed to export sequences');
}
// Download the file
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'sequences_export.csv';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
window.URL.revokeObjectURL(url);
closeExportModal();
} catch (error) {
console.error('Error exporting sequences:', error);
alert('Failed to export sequences: ' + error.message);
}
}
/**
* Open delete modal
*/
function openDeleteModal() {
if (selectedItems.size === 0) {
alert('No sequences selected');
return;
}
// Update count
deleteCountEl.textContent = selectedItems.size;
// Check if any batch sequences are selected
let hasBatch = false;
for (const seq of selectedItems.values()) {
if (seq.is_batch) {
hasBatch = true;
break;
}
}
if (hasBatch) {
deleteBatchWarning.classList.remove('hidden');
} else {
deleteBatchWarning.classList.add('hidden');
}
deleteModal.classList.remove('hidden');
}
/**
* Close delete modal
*/
function closeDeleteModal() {
deleteModal.classList.add('hidden');
}
// deleteSelected function is defined in the Toast Notifications section above
/**
* Get status badge HTML
*/
function getStatusBadge(status) {
const labels = {
'finished': 'Completed',
'running': 'Running',
'queued': 'Queued',
'failed': 'Failed',
'invalid': 'Invalid'
};
const label = labels[status] || status;
return `<span class="text-sm text-gray-900">${escapeHtml(label)}</span>`;
}
/**
* Render pagination controls
*/
function renderPagination() {
const start = (currentPage - 1) * pageSize + 1;
const end = Math.min(currentPage * pageSize, totalSequences);
pageStart.textContent = start;
pageEnd.textContent = end;
totalJobsEl.textContent = totalSequences;
// Clear existing buttons
pageButtons.innerHTML = '';
// Previous button
const prevBtn = document.createElement('button');
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'}`;
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>';
prevBtn.disabled = currentPage === 1;
prevBtn.addEventListener('click', () => {
if (currentPage > 1) {
currentPage--;
loadSequences();
}
});
pageButtons.appendChild(prevBtn);
// Page numbers
const maxButtons = 5;
let startPage = Math.max(1, currentPage - Math.floor(maxButtons / 2));
let endPage = Math.min(totalPages, startPage + maxButtons - 1);
if (endPage - startPage + 1 < maxButtons) {
startPage = Math.max(1, endPage - maxButtons + 1);
}
for (let i = startPage; i <= endPage; i++) {
const btn = document.createElement('button');
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'}`;
btn.textContent = i;
btn.addEventListener('click', () => {
currentPage = i;
loadSequences();
});
pageButtons.appendChild(btn);
}
// Next button
const nextBtn = document.createElement('button');
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'}`;
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>';
nextBtn.disabled = currentPage === totalPages;
nextBtn.addEventListener('click', () => {
if (currentPage < totalPages) {
currentPage++;
loadSequences();
}
});
pageButtons.appendChild(nextBtn);
}
/**
* Show loading state
*/
function showLoading() {
loadingState.classList.remove('hidden');
noJobsState.classList.add('hidden');
jobsTableContainer.classList.add('hidden');
pagination.classList.add('hidden');
if (columnVisibility) columnVisibility.classList.add('hidden');
}
/**
* Show no sequences state
*/
function showNoSequences() {
loadingState.classList.add('hidden');
noJobsState.classList.remove('hidden');
jobsTableContainer.classList.add('hidden');
pagination.classList.add('hidden');
if (columnVisibility) columnVisibility.classList.add('hidden');
bulkActionsToolbar.classList.add('hidden');
}
/**
* Show table
*/
function showTable() {
loadingState.classList.add('hidden');
noJobsState.classList.add('hidden');
jobsTableContainer.classList.remove('hidden');
pagination.classList.remove('hidden');
if (columnVisibility) columnVisibility.classList.remove('hidden');
updateBulkActionsToolbar();
}
/**
* Escape HTML to prevent XSS
*/
function escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// ============================================================================
// Toast Notifications
// ============================================================================
/**
* Show a toast notification
*/
function showToast(message, type = 'success') {
const container = document.getElementById('toast-container');
if (!container) return;
const toast = document.createElement('div');
toast.className = `toast ${type}`;
toast.innerHTML = `
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
${type === 'success'
? '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>'
: type === 'error'
? '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>'
: '<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"/>'}
</svg>
<span>${message}</span>
`;
container.appendChild(toast);
// Auto-dismiss after 3 seconds
setTimeout(() => {
toast.classList.add('hiding');
setTimeout(() => {
if (toast.parentNode) {
toast.parentNode.removeChild(toast);
}
}, 300);
}, 3000);
}
/**
* Copy job ID to clipboard
*/
async function copyJobId(jobId) {
try {
await navigator.clipboard.writeText(jobId);
showToast('Job ID copied to clipboard', 'success');
} catch (error) {
console.error('Failed to copy:', error);
showToast('Failed to copy Job ID', 'error');
}
}
/**
* Delete a single job with confirmation
*/
function deleteSingleJob(seq) {
// Check if delete modal exists (not present on download page)
if (!deleteModal) {
console.warn('Delete modal not available on this page');
return;
}
// Set this single item as the only selected item for deletion
const key = getSelectionKey(seq);
// Temporarily store for deletion
window._singleDeleteSeq = seq;
// Update count display
if (deleteCountEl) {
deleteCountEl.textContent = '1';
}
// Show batch warning if applicable
if (deleteBatchWarning) {
if (seq.is_batch) {
deleteBatchWarning.classList.remove('hidden');
} else {
deleteBatchWarning.classList.add('hidden');
}
}
// Show the delete modal
deleteModal.classList.remove('hidden');
}
// Store reference to original delete function
const originalDeleteSelected = deleteSelected;
/**
* Override deleteSelected to handle single deletion
*/
async function deleteSelected() {
const token = TokenManager.getToken();
if (!token) {
alert('No access token found');
return;
}
// Check if this is a single delete
if (window._singleDeleteSeq) {
const seq = window._singleDeleteSeq;
window._singleDeleteSeq = null;
try {
let response;
// For batch sequences, delete only the single sequence
if (seq.is_batch && seq.batch_index !== null && seq.batch_index !== undefined) {
response = await fetch(`/api/batch/${seq.job_id}/sequence/${seq.batch_index}?access_token=${encodeURIComponent(token)}`, {
method: 'DELETE'
});
} else {
// For single jobs, delete the entire job
response = await fetch(`/api/jobs/${seq.job_id}?access_token=${encodeURIComponent(token)}`, {
method: 'DELETE'
});
}
if (!response.ok) {
const error = await response.json();
console.error(`Failed to delete:`, error);
showToast('Failed to delete sequence', 'error');
} else {
showToast('Sequence deleted successfully', 'success');
}
closeDeleteModal();
loadSequences();
} catch (error) {
console.error('Error deleting:', error);
showToast('Failed to delete: ' + error.message, 'error');
}
return;
}
// Otherwise use bulk delete logic - delete individual sequences from batch jobs
let deletedCount = 0;
const errors = [];
for (const seq of selectedItems.values()) {
try {
let response;
if (seq.is_batch && seq.batch_index !== null && seq.batch_index !== undefined) {
response = await fetch(`/api/batch/${seq.job_id}/sequence/${seq.batch_index}?access_token=${encodeURIComponent(token)}`, {
method: 'DELETE'
});
} else {
response = await fetch(`/api/jobs/${seq.job_id}?access_token=${encodeURIComponent(token)}`, {
method: 'DELETE'
});
}
if (!response.ok) {
const error = await response.json();
errors.push(error.detail || 'Unknown error');
} else {
deletedCount++;
}
} catch (error) {
errors.push(error.message);
}
}
selectedItems.clear();
updateBulkActionsToolbar();
closeDeleteModal();
if (errors.length > 0) {
showToast(`Deleted ${deletedCount} sequence(s), ${errors.length} failed`, 'error');
} else {
showToast(`Deleted ${deletedCount} sequence(s) successfully`, 'success');
}
loadSequences();
}
// Export functions globally
window.showToast = showToast;
window.copyJobId = copyJobId;
window.deleteSingleJob = deleteSingleJob;
// ============================================================================
// Inline Name Editing for Batch Sequences
// ============================================================================
function startEditHistoryName(element, jobId, batchIndex, currentName) {
if (element.querySelector('input')) return;
element.innerHTML = `
<input type="text"
class="w-full px-2 py-1 text-sm border border-primary-500 rounded focus:ring-2 focus:ring-primary-500 focus:outline-none"
value="${escapeHtml(currentName)}"
maxlength="255"
onclick="event.stopPropagation()"
onkeydown="handleHistoryEditKeydown(event, this, '${jobId}', ${batchIndex}, '${escapeHtml(currentName).replace(/'/g, "\\'")}')"
onblur="handleHistoryEditBlur(this, '${jobId}', ${batchIndex}, '${escapeHtml(currentName).replace(/'/g, "\\'")}')">
`;
const input = element.querySelector('input');
input.focus();
input.select();
}
function handleHistoryEditKeydown(event, input, jobId, batchIndex, originalName) {
event.stopPropagation();
if (event.key === 'Enter') {
event.preventDefault();
saveHistoryName(input, jobId, batchIndex, originalName);
} else if (event.key === 'Escape') {
event.preventDefault();
cancelHistoryEdit(input, jobId, batchIndex, originalName);
}
}
function handleHistoryEditBlur(input, jobId, batchIndex, originalName) {
const newName = input.value.trim();
if (!newName || newName === originalName) {
cancelHistoryEdit(input, jobId, batchIndex, originalName);
} else {
saveHistoryName(input, jobId, batchIndex, originalName);
}
}
async function saveHistoryName(input, jobId, batchIndex, originalName) {
const newName = input.value.trim();
const parent = input.parentElement;
if (!newName || newName === originalName) {
cancelHistoryEdit(input, jobId, batchIndex, originalName);
return;
}
input.disabled = true;
input.classList.add('opacity-50');
try {
const response = await fetch(`/api/batch/${jobId}/sequence/${batchIndex}/name`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: newName }),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Failed to save');
}
restoreHistoryNameDisplay(parent, jobId, batchIndex, newName);
parent.classList.add('bg-green-100');
setTimeout(() => parent.classList.remove('bg-green-100'), 1000);
} catch (error) {
console.error('Error saving name:', error);
alert('Failed to save name: ' + error.message);
input.disabled = false;
input.classList.remove('opacity-50');
input.focus();
}
}
function cancelHistoryEdit(input, jobId, batchIndex, originalName) {
restoreHistoryNameDisplay(input.parentElement, jobId, batchIndex, originalName);
}
function restoreHistoryNameDisplay(parent, jobId, batchIndex, name) {
parent.innerHTML = `
<span class="editable-name cursor-pointer hover:bg-gray-100 px-1 py-0.5 rounded inline-flex items-center gap-1 group"
onclick="event.stopPropagation(); startEditHistoryName(this, '${jobId}', ${batchIndex}, '${escapeHtml(name).replace(/'/g, "\\'")}')">
${escapeHtml(name)}
<svg class="w-3 h-3 text-gray-400 opacity-0 group-hover:opacity-100 transition-opacity" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
</svg>
</span>
`;
}
// Export functions globally
window.startEditHistoryName = startEditHistoryName;
window.handleHistoryEditKeydown = handleHistoryEditKeydown;
window.handleHistoryEditBlur = handleHistoryEditBlur;
// ============================================================================
// Row Action Buttons (Preview & Download)
// ============================================================================
const previewModal = document.getElementById('preview-modal');
const downloadVizModal = document.getElementById('download-viz-modal');
let currentPreviewSeq = null;
let currentDownloadSeq = null;
let pendingDownloads = {};
let downloadResults = {};
// ============================================================================
// Preview Modal
// ============================================================================
/**
* Open preview modal for a sequence
*/
async function openPreviewModal(seq) {
// Check if preview modal exists (not present on download page)
if (!previewModal) {
console.warn('Preview modal not available on this page');
return;
}
currentPreviewSeq = seq;
// Show modal
previewModal.classList.remove('hidden');
// Update job info
const jobInfo = document.getElementById('preview-job-info');
jobInfo.textContent = `${seq.job_title || seq.job_id.substring(0, 8)} - ${seq.sequence_id}`;
// Set full result link
const fullLink = document.getElementById('preview-full-link');
if (seq.is_batch && seq.batch_index !== null) {
fullLink.href = `/batch/${seq.job_id}/sequence/${seq.batch_index}`;
} else {
fullLink.href = `/result/${seq.job_id}`;
}
// Show loading, hide content
document.getElementById('preview-loading').classList.remove('hidden');
document.getElementById('preview-content').classList.add('hidden');
try {
// Fetch result data
const url = seq.is_batch && seq.batch_index !== null
? `/api/result/${seq.job_id}?batch_index=${seq.batch_index}`
: `/api/result/${seq.job_id}`;
const response = await fetch(url);
if (!response.ok) throw new Error('Failed to load result');
const data = await response.json();
// Populate preview content
populatePreviewContent(data, seq);
// Load iframes
loadPreviewIframes(seq);
// Hide loading, show content
document.getElementById('preview-loading').classList.add('hidden');
document.getElementById('preview-content').classList.remove('hidden');
} catch (error) {
console.error('Error loading preview:', error);
document.getElementById('preview-loading').innerHTML = `
<p class="text-red-500">Failed to load preview. Please try again.</p>
`;
}
}
/**
* Populate preview content with result data
*/
function populatePreviewContent(data, seq) {
// PSI value and styling
const psiCard = document.getElementById('preview-psi-card');
const psiValue = document.getElementById('preview-psi-value');
const psiInterpretation = document.getElementById('preview-psi-interpretation');
const psi = data.psi;
// Use != null to catch both null and undefined
psiValue.textContent = psi != null ? psi.toFixed(3) : '-';
// Clear previous classes
psiCard.classList.remove('preview-psi-high', 'preview-psi-low', 'preview-psi-medium');
if (psi != null) {
if (psi >= 0.7) {
psiCard.classList.add('preview-psi-high');
psiInterpretation.textContent = 'Strong Inclusion';
} else if (psi <= 0.3) {
psiCard.classList.add('preview-psi-low');
psiInterpretation.textContent = 'Strong Skipping';
} else {
psiCard.classList.add('preview-psi-medium');
psiInterpretation.textContent = 'Variable Splicing';
}
} else {
psiInterpretation.textContent = '';
}
// Structure and MFE - use != null to catch both null and undefined
document.getElementById('preview-structure').textContent = data.structure || '-';
document.getElementById('preview-mfe').textContent = data.mfe != null ? `${data.mfe.toFixed(2)} kcal/mol` : '-';
// Sequence
document.getElementById('preview-sequence').textContent = data.sequence || seq.sequence || '-';
}
/**
* Load preview iframes with visualization
*/
function loadPreviewIframes(seq) {
const silhouetteIframe = document.getElementById('preview-silhouette-iframe');
const heatmapIframe = document.getElementById('preview-heatmap-iframe');
const baseParams = seq.is_batch && seq.batch_index !== null
? `job_id=${seq.job_id}&batch_index=${seq.batch_index}`
: `job_id=${seq.job_id}`;
const base = getShinyBaseUrl();
silhouetteIframe.src = `${base}/shiny/silhouette/?${baseParams}`;
heatmapIframe.src = `${base}/shiny/heatmap/?${baseParams}`;
// Set up onload handlers
silhouetteIframe.onload = function() {
setTimeout(() => {
silhouetteIframe.contentWindow.postMessage({
type: 'setParams',
job_id: seq.job_id,
batch_index: seq.is_batch ? seq.batch_index : null
}, '*');
}, 500);
};
heatmapIframe.onload = function() {
setTimeout(() => {
heatmapIframe.contentWindow.postMessage({
type: 'setParams',
job_id: seq.job_id,
batch_index: seq.is_batch ? seq.batch_index : null
}, '*');
}, 500);
};
}
/**
* Close preview modal
*/
function closePreviewModal() {
previewModal.classList.add('hidden');
currentPreviewSeq = null;
// Clear iframes
document.getElementById('preview-silhouette-iframe').src = '';
document.getElementById('preview-heatmap-iframe').src = '';
}
// Preview modal close handlers
document.getElementById('preview-close-btn')?.addEventListener('click', closePreviewModal);
document.getElementById('preview-close-btn-footer')?.addEventListener('click', closePreviewModal);
previewModal?.addEventListener('click', (e) => {
if (e.target === previewModal) closePreviewModal();
});
// ============================================================================
// Download Visualizations Modal
// ============================================================================
/**
* Open download visualizations modal
*/
function openDownloadVizModal(seq) {
// Check if download viz modal exists (not present on download page)
if (!downloadVizModal) {
console.warn('Download viz modal not available on this page');
return;
}
currentDownloadSeq = seq;
// Update job info
const jobInfo = document.getElementById('download-viz-job-info');
if (jobInfo) {
jobInfo.textContent = `${seq.job_title || seq.job_id.substring(0, 8)} - ${seq.sequence_id}`;
}
// Reset checkboxes
document.getElementById('download-viz-silhouette').checked = true;
document.getElementById('download-viz-heatmap').checked = true;
// Hide status
document.getElementById('download-viz-status').classList.add('hidden');
// Reset button
const confirmBtn = document.getElementById('download-viz-confirm-btn');
confirmBtn.disabled = false;
confirmBtn.innerHTML = `
<svg class="mr-2 h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<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"></path>
</svg>
Download SVG
`;
downloadVizModal.classList.remove('hidden');
}
/**
* Close download visualizations modal
*/
function closeDownloadVizModal() {
downloadVizModal.classList.add('hidden');
currentDownloadSeq = null;
}
/**
* Download selected visualizations
*/
async function downloadSelectedViz() {
const seq = currentDownloadSeq;
if (!seq) return;
const downloadSilhouette = document.getElementById('download-viz-silhouette').checked;
const downloadHeatmap = document.getElementById('download-viz-heatmap').checked;
if (!downloadSilhouette && !downloadHeatmap) {
alert('Please select at least one visualization to download.');
return;
}
const confirmBtn = document.getElementById('download-viz-confirm-btn');
const statusEl = document.getElementById('download-viz-status');
// Update UI
confirmBtn.disabled = true;
confirmBtn.innerHTML = `
<svg class="animate-spin mr-2 h-4 w-4" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
Preparing...
`;
statusEl.textContent = 'Loading visualizations...';
statusEl.classList.remove('hidden');
// We need to load the iframes temporarily and then capture them
const baseParams = seq.is_batch && seq.batch_index !== null
? `job_id=${seq.job_id}&batch_index=${seq.batch_index}`
: `job_id=${seq.job_id}`;
// Reset download state
pendingDownloads = {};
downloadResults = {};
// Create hidden iframes for download
const tempContainer = document.createElement('div');
tempContainer.style.cssText = 'position: fixed; left: -9999px; top: 0; width: 1200px; height: 800px;';
document.body.appendChild(tempContainer);
const downloads = [];
const base = getShinyBaseUrl();
if (downloadSilhouette) {
const iframe = document.createElement('iframe');
iframe.id = 'temp-silhouette-iframe';
iframe.src = `${base}/shiny/silhouette/?${baseParams}`;
iframe.style.cssText = 'width: 1200px; height: 600px; border: none;';
tempContainer.appendChild(iframe);
downloads.push({ type: 'silhouette', iframe });
}
if (downloadHeatmap) {
const iframe = document.createElement('iframe');
iframe.id = 'temp-heatmap-iframe';
iframe.src = `${base}/shiny/heatmap/?${baseParams}`;
iframe.style.cssText = 'width: 1200px; height: 700px; border: none;';
tempContainer.appendChild(iframe);
downloads.push({ type: 'heatmap', iframe });
}
// Wait for iframes to load and then request downloads
let loadedCount = 0;
const totalToLoad = downloads.length;
const onIframeLoad = (type, iframe) => {
setTimeout(() => {
// Send params first
iframe.contentWindow.postMessage({
type: 'setParams',
job_id: seq.job_id,
batch_index: seq.is_batch ? seq.batch_index : null
}, '*');
// Then request download after a delay
setTimeout(() => {
pendingDownloads[type] = true;
iframe.contentWindow.postMessage({ type: 'downloadRequest' }, '*');
}, 1500);
}, 500);
};
downloads.forEach(({ type, iframe }) => {
iframe.onload = () => onIframeLoad(type, iframe);
});
// Listen for responses
const messageHandler = (event) => {
if (event.data && event.data.type === 'downloadResponse') {
const source = event.data.source;
downloadResults[source] = event.data;
// Check if all downloads complete
const pendingKeys = Object.keys(pendingDownloads);
const completedKeys = Object.keys(downloadResults);
if (pendingKeys.every(key => completedKeys.includes(key))) {
window.removeEventListener('message', messageHandler);
// Save files
const files = [];
for (const [source, result] of Object.entries(downloadResults)) {
if (result.dataUrl && !result.error) {
files.push({
name: `${source}_${seq.sequence_id}_${seq.job_id.substring(0, 8)}.svg`,
dataUrl: result.dataUrl
});
}
}
// Clean up
document.body.removeChild(tempContainer);
if (files.length === 0) {
statusEl.textContent = 'Failed to download visualizations. Please try again.';
confirmBtn.disabled = false;
confirmBtn.innerHTML = `
<svg class="mr-2 h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<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"></path>
</svg>
Download SVG
`;
return;
}
// Download files
files.forEach((file, index) => {
setTimeout(() => {
const link = document.createElement('a');
link.href = file.dataUrl;
link.download = file.name;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}, index * 300);
});
statusEl.textContent = `Downloaded ${files.length} file(s).`;
setTimeout(() => closeDownloadVizModal(), 1500);
}
}
};
window.addEventListener('message', messageHandler);
// Timeout after 15 seconds
setTimeout(() => {
window.removeEventListener('message', messageHandler);
if (document.body.contains(tempContainer)) {
document.body.removeChild(tempContainer);
}
const pendingKeys = Object.keys(pendingDownloads);
const completedKeys = Object.keys(downloadResults);
if (!pendingKeys.every(key => completedKeys.includes(key))) {
statusEl.textContent = 'Download timed out. Please try again.';
confirmBtn.disabled = false;
confirmBtn.innerHTML = `
<svg class="mr-2 h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<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"></path>
</svg>
Download SVG
`;
}
}, 15000);
}
// Download modal handlers
document.getElementById('download-viz-cancel-btn')?.addEventListener('click', closeDownloadVizModal);
document.getElementById('download-viz-confirm-btn')?.addEventListener('click', downloadSelectedViz);
downloadVizModal?.addEventListener('click', (e) => {
if (e.target === downloadVizModal) closeDownloadVizModal();
});
// Keyboard event for closing modals
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
if (!previewModal.classList.contains('hidden')) {
closePreviewModal();
}
if (!downloadVizModal.classList.contains('hidden')) {
closeDownloadVizModal();
}
}
});
// Export functions
window.openPreviewModal = openPreviewModal;
window.openDownloadVizModal = openDownloadVizModal;