Add Jobs page for active job management

- Create /jobs route showing queued, processing, and recently cancelled jobs
- Modify /history to exclude active jobs, show only completed/failed/old cancelled
- Add Jobs link to navbar between Analyze and History
- Jobs page has real-time status updates like history page
- Proper job filtering by status and time for each page
parent 5014c7f8
......@@ -90,6 +90,7 @@
<nav class="nav">
<a href="/dashboard" {% if active_page == 'dashboard' %}class="active"{% endif %}>Dashboard</a>
<a href="/analyze" {% if active_page == 'analyze' %}class="active"{% endif %}>Analyze</a>
<a href="/jobs" {% if active_page == 'jobs' %}class="active"{% endif %}>Jobs</a>
<a href="/history" {% if active_page == 'history' %}class="active"{% endif %}>History</a>
<div class="docs-menu">
<div class="docs-icon" onclick="toggleDocsMenu()">Documentations</div>
......
{% extends "base.html" %}
{% block title %}Active Jobs - VidAI{% endblock %}
{% block head %}
<style>
.container { max-width: 1200px; margin: 2rem auto; padding: 0 2rem; }
.jobs-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 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:last-child { border-bottom: none; }
.job-type { font-weight: 600; color: #1e293b; }
.job-data { color: #64748b; font-size: 0.9rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.job-time { color: #64748b; font-size: 0.9rem; }
.job-status { padding: 0.25rem 0.75rem; border-radius: 20px; font-size: 0.8rem; font-weight: 500; text-align: center; }
.status-queued { background: #fef3c7; color: #d97706; }
.status-processing { background: #dbeafe; color: #2563eb; }
.status-completed { background: #d1fae5; color: #065f46; }
.status-failed { background: #fee2e2; color: #dc2626; }
.status-cancelled { background: #f3f4f6; color: #6b7280; }
.job-tokens { font-weight: 600; color: #667eea; text-align: center; }
.no-jobs { text-align: center; padding: 3rem; color: #6b7280; }
.job-progress { color: #64748b; font-size: 0.8rem; }
.spinner { display: inline-block; width: 12px; height: 12px; border: 2px solid #f3f3f3; border-top: 2px solid #667eea; border-radius: 50%; animation: spin 1s linear infinite; margin-right: 0.5rem; }
.view-result-link { color: #667eea; text-decoration: none; font-size: 0.9rem; font-weight: 500; }
.view-result-link:hover { text-decoration: underline; }
@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
</style>
<script>
let lastUpdate = Date.now();
let lastCompletedJobs = new Set(); // Track jobs that were already completed
function updateJobStatuses() {
fetch('/api/job_status_updates?since=' + lastUpdate)
.then(response => response.json())
.then(data => {
if (data.updates && data.updates.length > 0) {
data.updates.forEach(update => {
updateJobRow(update);
// Check if this is a newly completed job
if (update.status === 'completed' && !lastCompletedJobs.has(update.id)) {
showJobCompletionNotification(update.id);
lastCompletedJobs.add(update.id);
}
});
lastUpdate = Date.now();
}
})
.catch(error => {
console.log('Error updating job statuses:', error);
});
}
function showJobCompletionNotification(jobId) {
const notificationContainer = document.getElementById('notificationContainer');
if (!notificationContainer) return;
const notification = document.createElement('div');
notification.className = 'notification success';
notification.innerHTML = `
<span class="notification-close" onclick="closeNotification(this)">&times;</span>
<strong>Job Completed!</strong><br>
Your analysis job has finished. <a href="/job_result/${jobId}" style="color: #065f46; text-decoration: underline;">View Results</a>
`;
notificationContainer.appendChild(notification);
// Auto-hide after 10 seconds
setTimeout(() => {
notification.classList.add('fade-out');
setTimeout(() => {
notification.remove();
}, 300);
}, 10000);
}
function updateJobRow(jobUpdate) {
const jobRow = document.querySelector(`[data-job-id="${jobUpdate.id}"]`);
if (jobRow) {
// Update status
const statusElement = jobRow.querySelector('.job-status');
if (statusElement) {
statusElement.className = `job-status status-${jobUpdate.status}`;
statusElement.textContent = jobUpdate.status.charAt(0).toUpperCase() + jobUpdate.status.slice(1);
// Add spinner for processing jobs
if (jobUpdate.status === 'processing') {
if (!statusElement.querySelector('.spinner')) {
statusElement.innerHTML = '<div class="spinner"></div>' + statusElement.textContent;
}
} else {
const spinner = statusElement.querySelector('.spinner');
if (spinner) {
spinner.remove();
}
}
// Update actions based on status change
updateJobActions(jobUpdate.id, jobUpdate.status);
}
// Update tokens if completed
if (jobUpdate.status === 'completed' && jobUpdate.used_tokens) {
const tokensElement = jobRow.querySelector('.job-tokens');
if (tokensElement) {
tokensElement.textContent = jobUpdate.used_tokens;
}
}
// Add progress info for processing jobs
if (jobUpdate.status === 'processing' && jobUpdate.result) {
let progressElement = jobRow.querySelector('.job-progress');
if (!progressElement) {
progressElement = document.createElement('div');
progressElement.className = 'job-progress';
jobRow.appendChild(progressElement);
}
progressElement.textContent = jobUpdate.result.status || 'Processing...';
}
}
}
function updateJobActions(jobId, status) {
const jobRow = document.querySelector(`[data-job-id="${jobId}"]`);
if (!jobRow) return;
const actionsElement = jobRow.querySelector('.job-actions');
if (!actionsElement) return;
// Clear existing actions
actionsElement.innerHTML = '';
if (status === 'completed') {
const viewLink = document.createElement('a');
viewLink.href = `/job_result/${jobId}`;
viewLink.className = 'view-result-link';
viewLink.textContent = 'View Result';
actionsElement.appendChild(viewLink);
} else if (status === 'processing') {
const cancelForm = document.createElement('form');
cancelForm.method = 'post';
cancelForm.action = `/job/${jobId}/cancel`;
cancelForm.style.display = 'inline';
cancelForm.style.marginRight = '0.5rem';
cancelForm.onsubmit = () => confirm('Are you sure you want to cancel this running job?');
const cancelBtn = document.createElement('button');
cancelBtn.type = 'submit';
cancelBtn.className = 'cancel-btn';
cancelBtn.style.background = '#f59e0b';
cancelBtn.style.color = 'white';
cancelBtn.style.border = 'none';
cancelBtn.style.padding = '0.25rem 0.5rem';
cancelBtn.style.borderRadius = '4px';
cancelBtn.style.fontSize = '0.8rem';
cancelBtn.style.cursor = 'pointer';
cancelBtn.textContent = 'Cancel';
cancelForm.appendChild(cancelBtn);
actionsElement.appendChild(cancelForm);
} else if (status === 'cancelled') {
const restartForm = document.createElement('form');
restartForm.method = 'post';
restartForm.action = `/job/${jobId}/restart`;
restartForm.style.display = 'inline';
restartForm.style.marginRight = '0.5rem';
restartForm.onsubmit = () => confirm('Are you sure you want to restart this cancelled job?');
const restartBtn = document.createElement('button');
restartBtn.type = 'submit';
restartBtn.className = 'restart-btn';
restartBtn.style.background = '#10b981';
restartBtn.style.color = 'white';
restartBtn.style.border = 'none';
restartBtn.style.padding = '0.25rem 0.5rem';
restartBtn.style.borderRadius = '4px';
restartBtn.style.fontSize = '0.8rem';
restartBtn.style.cursor = 'pointer';
restartBtn.textContent = 'Restart';
restartForm.appendChild(restartBtn);
actionsElement.appendChild(restartForm);
const deleteForm = document.createElement('form');
deleteForm.method = 'post';
deleteForm.action = `/job/${jobId}/delete`;
deleteForm.style.display = 'inline';
deleteForm.onsubmit = () => confirm('Are you sure you want to delete this cancelled job?');
const deleteBtn = document.createElement('button');
deleteBtn.type = 'submit';
deleteBtn.className = 'delete-btn';
deleteBtn.style.background = '#dc2626';
deleteBtn.style.color = 'white';
deleteBtn.style.border = 'none';
deleteBtn.style.padding = '0.25rem 0.5rem';
deleteBtn.style.borderRadius = '4px';
deleteBtn.style.fontSize = '0.8rem';
deleteBtn.style.cursor = 'pointer';
deleteBtn.textContent = 'Delete';
deleteForm.appendChild(deleteBtn);
actionsElement.appendChild(deleteForm);
} else if (status === 'queued') {
const deleteForm = document.createElement('form');
deleteForm.method = 'post';
deleteForm.action = `/job/${jobId}/delete`;
deleteForm.style.display = 'inline';
deleteForm.onsubmit = () => confirm('Are you sure you want to delete this queued job?');
const deleteBtn = document.createElement('button');
deleteBtn.type = 'submit';
deleteBtn.className = 'delete-btn';
deleteBtn.style.background = '#dc2626';
deleteBtn.style.color = 'white';
deleteBtn.style.border = 'none';
deleteBtn.style.padding = '0.25rem 0.5rem';
deleteBtn.style.borderRadius = '4px';
deleteBtn.style.fontSize = '0.8rem';
deleteBtn.style.cursor = 'pointer';
deleteBtn.textContent = 'Delete';
deleteForm.appendChild(deleteBtn);
actionsElement.appendChild(deleteForm);
} else if (status === 'failed') {
const deleteForm = document.createElement('form');
deleteForm.method = 'post';
deleteForm.action = `/job/${jobId}/delete`;
deleteForm.style.display = 'inline';
deleteForm.onsubmit = () => confirm('Are you sure you want to delete this failed job?');
const deleteBtn = document.createElement('button');
deleteBtn.type = 'submit';
deleteBtn.className = 'delete-btn';
deleteBtn.style.background = '#dc2626';
deleteBtn.style.color = 'white';
deleteBtn.style.border = 'none';
deleteBtn.style.padding = '0.25rem 0.5rem';
deleteBtn.style.borderRadius = '4px';
deleteBtn.style.fontSize = '0.8rem';
deleteBtn.style.cursor = 'pointer';
deleteBtn.textContent = 'Delete';
deleteForm.appendChild(deleteBtn);
actionsElement.appendChild(deleteForm);
}
}
// Initialize completed jobs tracking
function initializeCompletedJobs() {
const jobRows = document.querySelectorAll('[data-job-id]');
jobRows.forEach(row => {
const jobId = parseInt(row.getAttribute('data-job-id'));
const statusElement = row.querySelector('.job-status');
if (statusElement && statusElement.classList.contains('status-completed')) {
lastCompletedJobs.add(jobId);
}
});
}
// Update every 5 seconds
setInterval(updateJobStatuses, 5000);
// Initial update
document.addEventListener('DOMContentLoaded', function() {
initializeCompletedJobs();
updateJobStatuses();
});
</script>
{% endblock %}
{% block content %}
<div class="container">
<div class="jobs-table">
<div class="table-header">
<h2><i class="fas fa-tasks"></i> Active Jobs</h2>
<p style="margin: 0.5rem 0 0 0; color: #64748b; font-size: 0.9rem;">View and manage your queued, processing, and recently cancelled jobs</p>
</div>
{% for job in queue_items %}
<div class="job-row" data-job-id="{{ job.id }}">
<div class="job-type">{{ job.request_type.title() }}</div>
<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 %}
</div>
<div class="job-time">{{ job.created_at[:19] }}</div>
<span class="job-status status-{{ job.status }}">
{% if job.status == 'processing' %}<div class="spinner"></div>{% endif %}
{{ job.status.title() }}
</span>
<div class="job-tokens">{{ job.used_tokens or 0 }}</div>
<div class="job-actions">
{% if job.status == 'completed' %}
<a href="/job_result/{{ job.id }}" class="view-result-link" style="margin-right: 0.5rem;">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' %}
<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 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' %}
<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?')">
<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>
</form>
<form method="post" action="/job/{{ job.id }}/delete" style="display: inline;" onsubmit="return confirm('Are you sure you want to delete this cancelled 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 == '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 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' %}
<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 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 %}
</div>
</div>
{% endfor %}
{% if not queue_items %}
<div class="no-jobs">No active jobs. Start by analyzing some media!</div>
{% endif %}
</div>
</div>
</div>
{% endblock %}
\ No newline at end of file
......@@ -315,17 +315,82 @@ def analyze():
@app.route('/jobs')
@login_required
def jobs():
"""Active jobs page - shows queued, processing, and recently cancelled jobs."""
user = get_current_user_session()
all_queue_items = get_user_queue_items(user['id'])
# Filter for active jobs: queued, processing, and cancelled jobs from last 24 hours
import time
current_time = time.time()
twenty_four_hours_ago = current_time - (24 * 60 * 60)
active_jobs = []
for job in all_queue_items:
# Include queued and processing jobs
if job['status'] in ['queued', 'processing']:
active_jobs.append(job)
# Include cancelled jobs from last 24 hours
elif job['status'] == 'cancelled':
job_time = job.get('created_at')
if isinstance(job_time, str):
from datetime import datetime
try:
job_timestamp = datetime.fromisoformat(job_time.replace('Z', '+00:00')).timestamp()
if job_timestamp > twenty_four_hours_ago:
active_jobs.append(job)
except:
# If we can't parse the timestamp, include it anyway
active_jobs.append(job)
else:
# If no timestamp, include it
active_jobs.append(job)
return render_template('jobs.html',
user=user,
tokens=get_user_tokens(user["id"]),
queue_items=active_jobs,
active_page='jobs')
@app.route('/history')
@login_required
def history():
"""Job history page."""
"""Job history page - shows completed and failed jobs."""
user = get_current_user_session()
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
import time
current_time = time.time()
twenty_four_hours_ago = current_time - (24 * 60 * 60)
historical_jobs = []
for job in all_queue_items:
# Include completed and failed jobs
if job['status'] in ['completed', 'failed']:
historical_jobs.append(job)
# Include cancelled jobs older than 24 hours
elif job['status'] == 'cancelled':
job_time = job.get('created_at')
if isinstance(job_time, str):
from datetime import datetime
try:
job_timestamp = datetime.fromisoformat(job_time.replace('Z', '+00:00')).timestamp()
if job_timestamp <= twenty_four_hours_ago:
historical_jobs.append(job)
except:
# If we can't parse the timestamp, exclude it (better safe than sorry)
pass
# If no timestamp, exclude it
# Exclude queued and processing jobs from history
return render_template('history.html',
user=user,
tokens=get_user_tokens(user["id"]),
queue_items=queue_items,
queue_items=historical_jobs,
active_page='history')
......
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