Add pagination to history page with page size selector (10, 20, 50, 100)

- Modified history route in web.py to support pagination parameters
- Added pagination controls to history.html template
- Added JavaScript functions for page navigation and page size changes
- Pagination shows current page info and total job count
- Page size selector allows 10, 20, 50, or 100 jobs per page
- Navigation includes Previous/Next buttons and numbered page buttons
parent 82840a6e
...@@ -8,7 +8,7 @@ ...@@ -8,7 +8,7 @@
.history-table { background: white; border-radius: 12px; box-shadow: 0 2px 10px rgba(0,0,0,0.05); overflow: hidden; } .history-table { background: white; border-radius: 12px; box-shadow: 0 2px 10px rgba(0,0,0,0.05); overflow: hidden; }
.table-header { padding: 2rem; border-bottom: 1px solid #e5e7eb; } .table-header { padding: 2rem; border-bottom: 1px solid #e5e7eb; }
.table-header h2 { margin: 0; color: #1e293b; } .table-header h2 { margin: 0; color: #1e293b; }
.job-row { display: grid; grid-template-columns: 1fr 2fr 1fr 100px 100px 120px; gap: 1rem; padding: 1rem 2rem; border-bottom: 1px solid #f1f5f9; align-items: center; } .job-row { display: grid; grid-template-columns: 0.3fr 1.6fr 1fr 2fr 1fr 1fr 1fr 1fr 1.5fr 1fr; gap: 0.2rem; padding: 0.5rem 0.75rem; border-bottom: 1px solid #e5e7eb; align-items: center; }
.job-row:last-child { border-bottom: none; } .job-row:last-child { border-bottom: none; }
.job-type { font-weight: 600; color: #1e293b; } .job-type { font-weight: 600; color: #1e293b; }
.job-data { color: #64748b; font-size: 0.9rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .job-data { color: #64748b; font-size: 0.9rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
...@@ -260,6 +260,120 @@ ...@@ -260,6 +260,120 @@
}); });
} }
// Track pending actions to prevent multiple clicks
const pendingActions = new Set();
function performJobAction(jobId, action, buttonElement) {
const actionKey = `${jobId}-${action}`;
// Prevent multiple clicks on the same action
if (pendingActions.has(actionKey)) {
return;
}
// Disable the button and mark as pending
buttonElement.disabled = true;
buttonElement.textContent = 'Processing...';
pendingActions.add(actionKey);
// Set a timeout to re-enable the button after 10 seconds (in case of network issues)
const timeoutId = setTimeout(() => {
if (pendingActions.has(actionKey)) {
pendingActions.delete(actionKey);
buttonElement.disabled = false;
buttonElement.textContent = action.charAt(0).toUpperCase() + action.slice(1);
}
}, 10000);
fetch(`/job/${jobId}/${action}`, {
method: 'POST',
headers: {
'X-Requested-With': 'XMLHttpRequest'
},
body: new FormData(),
redirect: 'manual' // Don't follow redirects
})
.then(response => {
clearTimeout(timeoutId); // Clear the timeout since we got a response
if (response.status >= 200 && response.status < 300) {
// Success status
return response.json().then(data => {
if (data.success !== false) {
showActionNotification(jobId, action, 'success');
} else {
showActionNotification(jobId, action, 'error');
}
});
} else if (response.status >= 300 && response.status < 400) {
// Redirect - treat as success for our purposes
showActionNotification(jobId, action, 'success');
} else {
// Error status
showActionNotification(jobId, action, 'error');
}
})
.catch(error => {
clearTimeout(timeoutId); // Clear the timeout on error
console.log(`Error performing ${action} on job ${jobId}:`, error);
showActionNotification(jobId, action, 'error');
})
.finally(() => {
// Re-enable the button and remove from pending actions
pendingActions.delete(actionKey);
buttonElement.disabled = false;
buttonElement.textContent = action.charAt(0).toUpperCase() + action.slice(1);
});
}
function showActionNotification(jobId, action, status) {
const notificationContainer = document.getElementById('notificationContainer');
if (!notificationContainer) return;
const actionText = action.charAt(0).toUpperCase() + action.slice(1);
const statusText = status === 'success' ? 'successful' : 'failed';
const notification = document.createElement('div');
notification.className = `notification ${status}`;
notification.innerHTML = `
<span class="notification-close" onclick="closeNotification(this)">&times;</span>
<strong>Job ${actionText} ${statusText}!</strong><br>
Job ${jobId} has been ${actionText.toLowerCase()}ed ${statusText}ly.
`;
notificationContainer.appendChild(notification);
// Auto-hide after 5 seconds
setTimeout(() => {
notification.classList.add('fade-out');
setTimeout(() => {
notification.remove();
}, 300);
}, 5000);
}
function closeNotification(closeBtn) {
const notification = closeBtn.parentElement;
notification.classList.add('fade-out');
setTimeout(() => {
notification.remove();
}, 300);
}
// Pagination functions
function changePage(page) {
const url = new URL(window.location);
url.searchParams.set('page', page);
window.location.href = url.toString();
}
function changePageSize(perPage) {
const url = new URL(window.location);
url.searchParams.set('per_page', perPage);
url.searchParams.set('page', '1'); // Reset to first page when changing page size
window.location.href = url.toString();
}
// Update every 5 seconds // Update every 5 seconds
setInterval(updateJobStatuses, 5000); setInterval(updateJobStatuses, 5000);
...@@ -279,44 +393,68 @@ ...@@ -279,44 +393,68 @@
</div> </div>
{% for job in queue_items %} {% for job in queue_items %}
<div class="job-row" data-job-id="{{ job.id }}"> <div class="job-row" data-job-id="{{ job.id }}">
<div class="queue-id" style="font-family: monospace; font-size: 0.8rem; color: #6b7280;">{{ job.id }}</div>
<div class="job-id" style="font-family: monospace; font-size: 0.8rem; color: #6b7280;" title="{{ job.job_id or 'N/A' }}">{{ job.job_id or 'N/A' }}</div>
<div class="job-type">{{ job.request_type.title() }}</div> <div class="job-type">{{ job.request_type.title() }}</div>
<div class="job-data" title="{{ job.data.get('prompt', job.data.get('description', 'N/A')) }}"> <div class="job-data" title="{{ job.data.get('prompt', job.data.get('description', 'N/A')) }}">
{{ job.data.get('prompt', job.data.get('description', 'N/A'))[:50] }}{% if job.data.get('prompt', job.data.get('description', 'N/A'))|length > 50 %}...{% endif %} {{ job.data.get('prompt', job.data.get('description', 'N/A'))[:50] }}{% if job.data.get('prompt', job.data.get('description', 'N/A'))|length > 50 %}...{% endif %}
</div> </div>
<div class="job-time">{{ job.created_at[:19] }}</div> <div class="job-time">{{ job.created_at[:19] }}</div>
<div class="worker-details" style="font-size: 0.8rem; color: #6b7280;">
{% if job.status == 'processing' %}
{% if job.job_id %}
{% if job.result and job.result.get('worker') %}
Worker: {{ job.result.worker }}
{% else %}
Assigned to cluster
{% endif %}
{% else %}
Local processing
{% endif %}
{% elif job.status == 'queued' %}
Pending assignment
{% elif job.status == 'completed' %}
Completed
{% elif job.status == 'failed' %}
Failed
{% elif job.status == 'cancelled' %}
Cancelled
{% else %}
-
{% endif %}
</div>
<span class="job-status status-{{ job.status }}"> <span class="job-status status-{{ job.status }}">
{% if job.status == 'processing' %}<div class="spinner"></div>{% endif %} {% if job.status == 'processing' %}<div class="spinner"></div>{% endif %}
{{ job.status.title() }} {{ job.status.title() }}
</span> </span>
<div class="job-tokens">{{ job.used_tokens or 0 }}</div> <div class="job-tokens" style="font-size: 0.8rem; color: #6b7280;">Tokens: {{ job.used_tokens or 0 }}</div>
<div class="job-progress" style="font-size: 0.8rem; color: #6b7280;">
{% if job.status == 'processing' and job.result %}
{{ job.result.get('status', 'Processing...') }}
{% elif job.status == 'completed' %}
Completed
{% elif job.status == 'failed' %}
Failed
{% elif job.status == 'cancelled' %}
Cancelled
{% else %}
-
{% endif %}
</div>
<div class="job-actions"> <div class="job-actions">
{% if job.status == 'completed' %} {% if job.status == 'completed' %}
<a href="/job_result/{{ job.id }}" class="view-result-link" style="margin-right: 0.5rem;">View Result</a> <a href="/job_result/{{ job.id }}" class="view-result-link">View Result</a>
<form method="post" action="/job/{{ job.id }}/delete" style="display: inline;" onsubmit="return confirm('Are you sure you want to delete this completed job?')">
<button type="submit" class="delete-btn" style="background: #dc2626; color: white; border: none; padding: 0.25rem 0.5rem; border-radius: 4px; font-size: 0.8rem; cursor: pointer;">Delete</button>
</form>
{% elif job.status == 'processing' %} {% elif job.status == 'processing' %}
<form method="post" action="/job/{{ job.id }}/cancel" style="display: inline; margin-right: 0.5rem;" onsubmit="return confirm('Are you sure you want to cancel this running job?')"> <button class="cancel-btn" style="background: #f59e0b; color: white; border: none; padding: 0.25rem 0.5rem; border-radius: 4px; font-size: 0.8rem; cursor: pointer; margin-right: 0.5rem;" onclick="performJobAction({{ job.id }}, 'cancel', this)">Cancel</button>
<button type="submit" class="cancel-btn" style="background: #f59e0b; color: white; border: none; padding: 0.25rem 0.5rem; border-radius: 4px; font-size: 0.8rem; cursor: pointer;">Cancel</button>
</form>
{% if job.result %}
<div class="job-progress">{{ job.result.get('status', 'Processing...') }}</div>
{% endif %}
{% elif job.status == 'cancelled' %} {% elif job.status == 'cancelled' %}
<form method="post" action="/job/{{ job.id }}/restart" style="display: inline; margin-right: 0.5rem;" onsubmit="return confirm('Are you sure you want to restart this cancelled job?')"> <div style="display: flex; gap: 0.5rem;">
<button type="submit" class="restart-btn" style="background: #10b981; color: white; border: none; padding: 0.25rem 0.5rem; border-radius: 4px; font-size: 0.8rem; cursor: pointer;">Restart</button> <button class="restart-btn" style="background: #10b981; color: white; border: none; padding: 0.25rem 0.5rem; border-radius: 4px; font-size: 0.8rem; cursor: pointer;" onclick="performJobAction({{ job.id }}, 'restart', this)">Restart</button>
</form> <button class="delete-btn" style="background: #dc2626; color: white; border: none; padding: 0.25rem 0.5rem; border-radius: 4px; font-size: 0.8rem; cursor: pointer;" onclick="if(confirm('Are you sure you want to delete this job? This action cannot be undone.')) performJobAction({{ job.id }}, 'delete', this)">Delete</button>
<form method="post" action="/job/{{ job.id }}/delete" style="display: inline;" onsubmit="return confirm('Are you sure you want to delete this cancelled job?')"> </div>
<button type="submit" class="delete-btn" style="background: #dc2626; color: white; border: none; padding: 0.25rem 0.5rem; border-radius: 4px; font-size: 0.8rem; cursor: pointer;">Delete</button>
</form>
{% elif job.status == 'queued' %} {% elif job.status == 'queued' %}
<form method="post" action="/job/{{ job.id }}/delete" style="display: inline;" onsubmit="return confirm('Are you sure you want to delete this queued job?')"> <button class="delete-btn" style="background: #dc2626; color: white; border: none; padding: 0.25rem 0.5rem; border-radius: 4px; font-size: 0.8rem; cursor: pointer;" onclick="if(confirm('Are you sure you want to delete this job? This action cannot be undone.')) performJobAction({{ job.id }}, 'delete', this)">Delete</button>
<button type="submit" class="delete-btn" style="background: #dc2626; color: white; border: none; padding: 0.25rem 0.5rem; border-radius: 4px; font-size: 0.8rem; cursor: pointer;">Delete</button>
</form>
{% elif job.status == 'failed' %} {% elif job.status == 'failed' %}
<form method="post" action="/job/{{ job.id }}/delete" style="display: inline;" onsubmit="return confirm('Are you sure you want to delete this failed job?')"> <button class="delete-btn" style="background: #dc2626; color: white; border: none; padding: 0.25rem 0.5rem; border-radius: 4px; font-size: 0.8rem; cursor: pointer;" onclick="if(confirm('Are you sure you want to delete this job? This action cannot be undone.')) performJobAction({{ job.id }}, 'delete', this)">Delete</button>
<button type="submit" class="delete-btn" style="background: #dc2626; color: white; border: none; padding: 0.25rem 0.5rem; border-radius: 4px; font-size: 0.8rem; cursor: pointer;">Delete</button>
</form>
{% endif %} {% endif %}
</div> </div>
</div> </div>
...@@ -325,6 +463,63 @@ ...@@ -325,6 +463,63 @@
<div class="no-jobs">No jobs found. Start by analyzing some media!</div> <div class="no-jobs">No jobs found. Start by analyzing some media!</div>
{% endif %} {% endif %}
</div> </div>
<!-- Pagination Controls -->
{% if total_pages > 1 %}
<div class="pagination-container" style="display: flex; justify-content: space-between; align-items: center; margin-top: 2rem; padding: 1rem; background: white; border-radius: 12px; box-shadow: 0 2px 10px rgba(0,0,0,0.05);">
<!-- Page Size Selector -->
<div style="display: flex; align-items: center; gap: 0.5rem;">
<label for="perPageSelector" style="font-size: 0.9rem; color: #374151; font-weight: 500;">Show:</label>
<select id="perPageSelector" onchange="changePageSize(this.value)" style="border: 1px solid #d1d5db; border-radius: 6px; padding: 0.375rem 0.75rem; font-size: 0.875rem; background: white;">
<option value="10" {% if per_page == 10 %}selected{% endif %}>10</option>
<option value="20" {% if per_page == 20 %}selected{% endif %}>20</option>
<option value="50" {% if per_page == 50 %}selected{% endif %}>50</option>
<option value="100" {% if per_page == 100 %}selected{% endif %}>100</option>
</select>
<span style="font-size: 0.9rem; color: #6b7280;">per page</span>
</div>
<!-- Pagination Info -->
<div style="font-size: 0.9rem; color: #6b7280;">
Showing {{ ((page - 1) * per_page) + 1 }} to {{ min(page * per_page, total_jobs) }} of {{ total_jobs }} jobs
</div>
<!-- Page Navigation -->
<div style="display: flex; align-items: center; gap: 0.5rem;">
{% if page > 1 %}
<button onclick="changePage({{ page - 1 }})" style="border: 1px solid #d1d5db; border-radius: 6px; padding: 0.375rem 0.75rem; font-size: 0.875rem; background: white; cursor: pointer;">Previous</button>
{% endif %}
{% set start_page = max(1, page - 2) %}
{% set end_page = min(total_pages, page + 2) %}
{% if start_page > 1 %}
<button onclick="changePage(1)" style="border: 1px solid #d1d5db; border-radius: 6px; padding: 0.375rem 0.75rem; font-size: 0.875rem; background: white; cursor: pointer;">1</button>
{% if start_page > 2 %}
<span style="padding: 0.375rem 0.5rem; color: #6b7280;">...</span>
{% endif %}
{% endif %}
{% for page_num in range(start_page, end_page + 1) %}
<button onclick="changePage({{ page_num }})"
style="border: 1px solid {% if page_num == page %}#3b82f6{% else %}#d1d5db{% endif %}; border-radius: 6px; padding: 0.375rem 0.75rem; font-size: 0.875rem; background: {% if page_num == page %}#3b82f6{% else %}white{% endif %}; color: {% if page_num == page %}white{% else %}#374151{% endif %}; cursor: pointer;">
{{ page_num }}
</button>
{% endfor %}
{% if end_page < total_pages %}
{% if end_page < total_pages - 1 %}
<span style="padding: 0.375rem 0.5rem; color: #6b7280;">...</span>
{% endif %}
<button onclick="changePage({{ total_pages }})" style="border: 1px solid #d1d5db; border-radius: 6px; padding: 0.375rem 0.75rem; font-size: 0.875rem; background: white; cursor: pointer;">{{ total_pages }}</button>
{% endif %}
{% if page < total_pages %}
<button onclick="changePage({{ page + 1 }})" style="border: 1px solid #d1d5db; border-radius: 6px; padding: 0.375rem 0.75rem; font-size: 0.875rem; background: white; cursor: pointer;">Next</button>
{% endif %}
</div>
</div>
{% endif %}
</div> </div>
</div> </div>
{% endblock %} {% endblock %}
\ No newline at end of file
...@@ -127,6 +127,92 @@ def handle_web_message(message: Message, client_sock=None) -> Message: ...@@ -127,6 +127,92 @@ def handle_web_message(message: Message, client_sock=None) -> Message:
return result return result
else: else:
return Message('result_pending', message.msg_id, {'status': 'pending'}) return Message('result_pending', message.msg_id, {'status': 'pending'})
elif message.msg_type == 'get_stats':
# Get system stats including GPU information
import psutil
import torch
stats = {'status': 'Idle'}
# GPU stats (local machine)
stats['gpu_count'] = 0
stats['gpus'] = []
# Try to get actual GPU stats using pynvml (NVIDIA management library)
try:
import nvidia_ml_py as pynvml
pynvml.nvmlInit()
device_count = pynvml.nvmlDeviceGetCount()
stats['gpu_count'] = device_count
stats['gpus'] = []
for i in range(device_count):
handle = pynvml.nvmlDeviceGetHandleByIndex(i)
name = pynvml.nvmlDeviceGetName(handle)
memory_info = pynvml.nvmlDeviceGetMemoryInfo(handle)
utilization = pynvml.nvmlDeviceGetUtilizationRates(handle)
gpu = {
'name': name.decode('utf-8') if isinstance(name, bytes) else str(name),
'memory_used': memory_info.used / 1024**3, # Convert bytes to GB
'memory_total': memory_info.total / 1024**3,
'utilization': utilization.gpu,
'backend': 'cuda'
}
stats['gpus'].append(gpu)
pynvml.nvmlShutdown()
except ImportError:
# Fallback to PyTorch-only stats if pynvml not available
log_message("pynvml not available, falling back to PyTorch GPU stats")
if torch.cuda.is_available():
stats['gpu_count'] = torch.cuda.device_count()
stats['gpus'] = []
for i in range(torch.cuda.device_count()):
gpu = {
'name': torch.cuda.get_device_name(i),
'memory_used': torch.cuda.memory_allocated(i) / 1024**3, # GB
'memory_total': torch.cuda.get_device_properties(i).total_memory / 1024**3,
'utilization': 0, # pynvml required for actual utilization
'backend': 'cuda'
}
stats['gpus'].append(gpu)
except Exception as e:
log_message(f"Error getting GPU stats with pynvml: {e}")
# Fallback to PyTorch if pynvml fails
if torch.cuda.is_available():
stats['gpu_count'] = torch.cuda.device_count()
stats['gpus'] = []
for i in range(torch.cuda.device_count()):
gpu = {
'name': torch.cuda.get_device_name(i),
'memory_used': torch.cuda.memory_allocated(i) / 1024**3, # GB
'memory_total': torch.cuda.get_device_properties(i).total_memory / 1024**3,
'utilization': 0,
'backend': 'cuda'
}
stats['gpus'].append(gpu)
# CPU and RAM (local machine)
stats['cpu_percent'] = psutil.cpu_percent()
ram = psutil.virtual_memory()
stats['ram_used'] = ram.used / 1024**3
stats['ram_total'] = ram.total / 1024**3
# Add GPU info summary
from .compat import detect_gpu_backends
gpu_info = detect_gpu_backends()
stats['gpu_info'] = {
'cuda_available': gpu_info['cuda'],
'rocm_available': gpu_info['rocm'],
'cuda_devices': gpu_info['cuda_devices'],
'rocm_devices': gpu_info['rocm_devices'],
'available_backends': [k for k, v in gpu_info.items() if k.endswith('_available') and v]
}
return Message('stats_response', message.msg_id, stats)
return Message('error', message.msg_id, {'error': 'Unknown message type'}) return Message('error', message.msg_id, {'error': 'Unknown message type'})
......
...@@ -417,8 +417,18 @@ def jobs(): ...@@ -417,8 +417,18 @@ def jobs():
@app.route('/history') @app.route('/history')
@login_required @login_required
def history(): def history():
"""Job history page - shows completed and failed jobs.""" """Job history page - shows completed and failed jobs with pagination."""
user = get_current_user_session() user = get_current_user_session()
# Get pagination parameters
page = int(request.args.get('page', 1))
per_page = int(request.args.get('per_page', 20))
# Validate per_page options
if per_page not in [10, 20, 50, 100]:
per_page = 20
# Get all queue items for the user
all_queue_items = get_user_queue_items(user['id']) all_queue_items = get_user_queue_items(user['id'])
# Filter for historical jobs: completed, failed, and cancelled jobs older than 24 hours # Filter for historical jobs: completed, failed, and cancelled jobs older than 24 hours
...@@ -446,11 +456,33 @@ def history(): ...@@ -446,11 +456,33 @@ def history():
# If no timestamp, exclude it # If no timestamp, exclude it
# Exclude queued and processing jobs from history # Exclude queued and processing jobs from history
# Sort by creation time (newest first)
historical_jobs.sort(key=lambda x: x.get('created_at', ''), reverse=True)
# Calculate pagination
total_jobs = len(historical_jobs)
total_pages = (total_jobs + per_page - 1) // per_page # Ceiling division
# Ensure page is within bounds
if page < 1:
page = 1
if page > total_pages and total_pages > 0:
page = total_pages
# Get jobs for current page
start_idx = (page - 1) * per_page
end_idx = start_idx + per_page
page_jobs = historical_jobs[start_idx:end_idx]
return render_template('history.html', return render_template('history.html',
user=user, user=user,
tokens=get_user_tokens(user["id"]), tokens=get_user_tokens(user["id"]),
queue_items=historical_jobs, queue_items=page_jobs,
active_page='history') active_page='history',
page=page,
per_page=per_page,
total_pages=total_pages,
total_jobs=total_jobs)
@app.route('/job/<int:job_id>/delete', methods=['POST']) @app.route('/job/<int:job_id>/delete', methods=['POST'])
...@@ -655,6 +687,32 @@ def api_job_progress(job_id): ...@@ -655,6 +687,32 @@ def api_job_progress(job_id):
return {'status': 'no_progress'} return {'status': 'no_progress'}
@app.route('/api/stats')
@login_required
def api_stats():
"""Get system stats from backend."""
user = get_current_user_session()
# Send get_stats request to backend
import uuid
msg_id = str(uuid.uuid4())
message = Message('get_stats', msg_id, {})
try:
comm.connect()
comm.send_message(message)
# Wait for response
response = comm.receive_message(timeout=5)
if response and response.msg_type == 'stats_response':
return response.data
else:
return {'error': 'No response from backend'}, 500
except Exception as e:
log_message(f"Error getting stats from backend: {e}")
return {'error': str(e)}, 500
@app.route('/update_settings', methods=['POST']) @app.route('/update_settings', methods=['POST'])
@login_required @login_required
def update_settings(): def update_settings():
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment