Files
Debian 3c421cf7b8
All checks were successful
Build and Push Frontend Docker Image / build (push) Successful in 39s
Build and Push Docker Image / build (push) Successful in 31m7s
Add job logging and increase timeout to 20 minutes
- Add JobLogger class to handler.py for structured timestamped logging
- Increase MAX_TIMEOUT from 600s to 1200s (20 minutes)
- Add logs column to generated_content table via migration
- Store and display job execution logs in gallery UI
- Add Logs button to gallery items with modal display

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 02:10:55 +00:00

840 lines
28 KiB
JavaScript

// State
let currentUser = null;
let mfaTypes = [];
let base64Image = '';
let currentPage = 1;
let adminCurrentPage = 1;
// DOM Elements
const pages = {
login: document.getElementById('login-page'),
mfa: document.getElementById('mfa-page'),
main: document.getElementById('main-page'),
};
const sections = {
generate: document.getElementById('generate-section'),
gallery: document.getElementById('gallery-section'),
admin: document.getElementById('admin-section'),
};
// API Helper
async function api(path, options = {}) {
const response = await fetch(`/api${path}`, {
...options,
headers: {
'Content-Type': 'application/json',
...options.headers,
},
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || 'Request failed');
}
return data;
}
// Page Navigation
function showPage(pageName) {
Object.values(pages).forEach(p => p.classList.add('hidden'));
pages[pageName]?.classList.remove('hidden');
}
function showSection(sectionName) {
Object.values(sections).forEach(s => s.classList.add('hidden'));
sections[sectionName]?.classList.remove('hidden');
document.querySelectorAll('.nav-link').forEach(link => {
link.classList.toggle('active', link.dataset.page === sectionName);
});
if (sectionName === 'gallery') loadGallery();
if (sectionName === 'admin') loadUsers();
}
// Auth
async function checkAuth() {
try {
const data = await api('/auth/me');
currentUser = data.user;
showMainApp();
} catch {
showPage('login');
}
}
function showMainApp() {
showPage('main');
document.getElementById('current-user').textContent = currentUser.username;
if (currentUser.isAdmin) {
document.querySelectorAll('.admin-only').forEach(el => el.classList.remove('hidden'));
}
showSection('generate');
checkPendingJobs();
}
// Pending Jobs
async function checkPendingJobs() {
try {
const data = await api('/generate/pending');
if (data.jobs && data.jobs.length > 0) {
showPendingJobsNotification(data.jobs);
}
} catch (error) {
console.error('Failed to check pending jobs:', error);
}
}
function showPendingJobsNotification(jobs) {
// Remove existing banner if any
const existingBanner = document.querySelector('.pending-jobs-banner');
if (existingBanner) existingBanner.remove();
const banner = document.createElement('div');
banner.className = 'pending-jobs-banner';
banner.innerHTML = `
<span>You have ${jobs.length} video${jobs.length > 1 ? 's' : ''} generating in the background</span>
<div class="pending-jobs-actions">
<button class="btn btn-sm view-progress-btn" data-content-id="${jobs[0].id}" data-job-id="${jobs[0].runpod_job_id}">View Progress</button>
<button class="btn btn-sm dismiss-btn">Dismiss</button>
</div>
`;
// Attach event listeners properly instead of inline onclick
banner.querySelector('.view-progress-btn').addEventListener('click', function() {
const contentId = parseInt(this.dataset.contentId, 10);
const jobId = this.dataset.jobId;
resumeLatestJob(contentId, jobId);
});
banner.querySelector('.dismiss-btn').addEventListener('click', function() {
banner.remove();
});
document.querySelector('.main-content').prepend(banner);
}
async function resumeLatestJob(contentId, jobId) {
const statusEl = document.getElementById('generation-status');
const videoEl = document.getElementById('output-video');
const btn = document.getElementById('generate-btn');
try {
// Switch to generate tab and show progress
showSection('generate');
btn.disabled = true;
btn.textContent = 'Generating...';
statusEl.className = 'status-message info';
statusEl.textContent = 'Resuming job...';
statusEl.classList.remove('hidden');
videoEl.classList.add('hidden');
// Remove the banner
const banner = document.querySelector('.pending-jobs-banner');
if (banner) banner.remove();
// Poll for completion
await pollJob(jobId, contentId, statusEl, videoEl);
} catch (error) {
console.error('Error resuming job:', error);
statusEl.className = 'status-message error';
statusEl.textContent = error.message || 'Failed to resume job';
statusEl.classList.remove('hidden');
} finally {
btn.disabled = false;
btn.textContent = 'Generate Video';
}
}
// Login
document.getElementById('login-form').addEventListener('submit', async (e) => {
e.preventDefault();
const errorEl = document.getElementById('login-error');
errorEl.textContent = '';
const username = document.getElementById('username').value;
const password = document.getElementById('password').value;
try {
const data = await api('/auth/login', {
method: 'POST',
body: JSON.stringify({ username, password }),
});
if (data.requiresMfa) {
mfaTypes = data.mfaTypes;
showMfaPage();
} else {
currentUser = data.user;
showMainApp();
}
} catch (error) {
errorEl.textContent = error.message;
}
});
function showMfaPage() {
showPage('mfa');
const totpSection = document.getElementById('totp-section');
const webauthnSection = document.getElementById('webauthn-section');
totpSection.classList.toggle('hidden', !mfaTypes.includes('totp'));
webauthnSection.classList.toggle('hidden', !mfaTypes.includes('webauthn'));
}
// TOTP Verification
document.getElementById('totp-form').addEventListener('submit', async (e) => {
e.preventDefault();
const errorEl = document.getElementById('mfa-error');
errorEl.textContent = '';
const code = document.getElementById('totp-code').value;
try {
const data = await api('/auth/mfa/totp', {
method: 'POST',
body: JSON.stringify({ code }),
});
currentUser = data.user;
showMainApp();
} catch (error) {
errorEl.textContent = error.message;
}
});
// WebAuthn
document.getElementById('webauthn-btn').addEventListener('click', async () => {
const errorEl = document.getElementById('mfa-error');
errorEl.textContent = '';
try {
const options = await api('/auth/mfa/webauthn/challenge', { method: 'POST' });
const credential = await navigator.credentials.get({
publicKey: {
...options,
challenge: base64UrlToBuffer(options.challenge),
allowCredentials: options.allowCredentials?.map(c => ({
...c,
id: base64UrlToBuffer(c.id),
})),
},
});
const response = {
id: credential.id,
rawId: bufferToBase64Url(credential.rawId),
type: credential.type,
response: {
clientDataJSON: bufferToBase64Url(credential.response.clientDataJSON),
authenticatorData: bufferToBase64Url(credential.response.authenticatorData),
signature: bufferToBase64Url(credential.response.signature),
},
};
const data = await api('/auth/mfa/webauthn/verify', {
method: 'POST',
body: JSON.stringify(response),
});
currentUser = data.user;
showMainApp();
} catch (error) {
errorEl.textContent = error.message || 'WebAuthn verification failed';
}
});
// Back to login from MFA
document.getElementById('mfa-back').addEventListener('click', async () => {
await api('/auth/logout', { method: 'POST' });
showPage('login');
});
// Logout
document.getElementById('logout-btn').addEventListener('click', async () => {
await api('/auth/logout', { method: 'POST' });
currentUser = null;
showPage('login');
});
// Navigation
document.querySelectorAll('.nav-link').forEach(link => {
link.addEventListener('click', (e) => {
e.preventDefault();
showSection(link.dataset.page);
});
});
// Image Upload
const imageInput = document.getElementById('image-input');
const imageUploadArea = document.getElementById('image-upload-area');
const previewImage = document.getElementById('preview-image');
const previewContainer = document.getElementById('preview-container');
const clearImageBtn = document.getElementById('clear-image-btn');
imageInput.addEventListener('change', (e) => {
const file = e.target.files[0];
if (file) {
const reader = new FileReader();
reader.onload = (event) => {
previewImage.src = event.target.result;
previewContainer.classList.remove('hidden');
imageUploadArea.classList.add('has-file');
base64Image = event.target.result.split(',')[1];
};
reader.readAsDataURL(file);
}
});
clearImageBtn.addEventListener('click', () => {
previewImage.src = '';
previewContainer.classList.add('hidden');
imageUploadArea.classList.remove('has-file');
imageInput.value = '';
base64Image = '';
});
// Generation
document.getElementById('generate-form').addEventListener('submit', async (e) => {
e.preventDefault();
if (!base64Image) {
showStatus('Please upload an image first', 'error');
return;
}
const btn = document.getElementById('generate-btn');
btn.disabled = true;
btn.textContent = 'Generating...';
const statusEl = document.getElementById('generation-status');
const videoEl = document.getElementById('output-video');
statusEl.className = 'status-message info';
statusEl.textContent = 'Submitting job...';
statusEl.classList.remove('hidden');
videoEl.classList.add('hidden');
try {
const formData = {
image: base64Image,
prompt: document.getElementById('prompt').value,
negativePrompt: document.getElementById('negative-prompt').value,
resolution: parseInt(document.getElementById('resolution').value),
steps: parseInt(document.getElementById('steps').value),
};
const submitData = await api('/generate', {
method: 'POST',
body: JSON.stringify(formData),
});
const { jobId, contentId } = submitData;
statusEl.textContent = `Job submitted. ID: ${jobId}. Waiting for completion...`;
// Poll for completion
await pollJob(jobId, contentId, statusEl, videoEl);
} catch (error) {
showStatus(error.message, 'error');
} finally {
btn.disabled = false;
btn.textContent = 'Generate Video';
}
});
async function pollJob(jobId, contentId, statusEl, videoEl) {
const startTime = Date.now();
const maxTime = 25 * 60 * 1000; // 25 minutes
while (Date.now() - startTime < maxTime) {
const elapsed = Math.floor((Date.now() - startTime) / 1000);
statusEl.textContent = `Generating... (${elapsed}s elapsed)`;
try {
const status = await api(`/generate/${jobId}/status`);
if (status.status === 'COMPLETED') {
statusEl.className = 'status-message success';
statusEl.textContent = 'Generation complete!';
// Load video
videoEl.src = `/api/content/${contentId}/stream`;
videoEl.classList.remove('hidden');
videoEl.play();
return;
}
if (status.status === 'FAILED') {
throw new Error(status.error || 'Generation failed');
}
await new Promise(r => setTimeout(r, 5000));
} catch (error) {
showStatus(error.message, 'error');
return;
}
}
showStatus('Generation timed out', 'error');
}
function showStatus(message, type) {
const statusEl = document.getElementById('generation-status');
statusEl.className = `status-message ${type}`;
statusEl.textContent = message;
statusEl.classList.remove('hidden');
}
// Gallery
async function loadGallery(page = 1) {
currentPage = page;
const grid = document.getElementById('gallery-grid');
const pagination = document.getElementById('gallery-pagination');
const status = document.getElementById('status-filter').value;
grid.innerHTML = '<div class="spinner"></div>';
try {
const params = new URLSearchParams({ page, limit: 12 });
if (status) params.append('status', status);
const data = await api(`/content?${params}`);
renderGallery(grid, data.content);
renderPagination(pagination, data.pagination, loadGallery);
} catch (error) {
grid.innerHTML = `<p class="error-message">${error.message}</p>`;
}
}
document.getElementById('status-filter').addEventListener('change', () => loadGallery(1));
function renderGallery(container, items) {
if (items.length === 0) {
container.innerHTML = '<p style="text-align:center;color:var(--gray-500);grid-column:1/-1;">No content found</p>';
return;
}
container.innerHTML = items.map(item => `
<div class="gallery-item">
<div class="gallery-item-media">
${item.status === 'completed'
? `<video src="/api/content/${item.id}/stream" muted loop data-content-id="${item.id}"></video>`
: '<div style="display:flex;align-items:center;justify-content:center;height:100%;color:var(--gray-500)">' + item.status + '</div>'
}
<span class="gallery-item-status ${item.status}">${item.status}</span>
</div>
<div class="gallery-item-info">
<p class="gallery-item-prompt">${escapeHtml(item.prompt || 'No prompt')}</p>
<div class="gallery-item-meta">
<span>${formatDate(item.createdAt)}</span>
<div class="gallery-item-actions">
<button class="btn btn-sm btn-secondary view-logs-btn" data-content-id="${item.id}">Logs</button>
${item.status === 'completed' ? `<a href="/api/content/${item.id}/download" class="btn btn-sm btn-secondary">Download</a>` : ''}
<button class="btn btn-sm btn-danger delete-content-btn" data-content-id="${item.id}">Delete</button>
</div>
</div>
</div>
</div>
`).join('');
// Video hover play/pause and click to view
container.querySelectorAll('.gallery-item-media video').forEach(video => {
video.addEventListener('mouseenter', () => video.play());
video.addEventListener('mouseleave', () => {
video.pause();
video.currentTime = 0;
});
video.addEventListener('click', () => {
const contentId = video.dataset.contentId;
openVideoViewer(`/api/content/${contentId}/stream`);
});
});
container.querySelectorAll('.delete-content-btn').forEach(btn => {
btn.addEventListener('click', async function() {
const contentId = parseInt(this.dataset.contentId, 10);
if (!confirm('Are you sure you want to delete this content?')) return;
try {
await api(`/content/${contentId}`, { method: 'DELETE' });
loadGallery(currentPage);
} catch (error) {
alert(error.message);
}
});
});
container.querySelectorAll('.view-logs-btn').forEach(btn => {
btn.addEventListener('click', async function() {
const contentId = parseInt(this.dataset.contentId, 10);
await viewContentLogs(contentId);
});
});
}
async function viewContentLogs(contentId) {
try {
const data = await api(`/content/${contentId}/logs`);
const logs = data.logs || [];
const errorMessage = data.errorMessage;
let logsHtml = '';
if (logs.length === 0) {
logsHtml = '<p style="color:var(--gray-500);text-align:center;">No logs available</p>';
} else {
logsHtml = `<div class="logs-content">${logs.map(log => escapeHtml(log)).join('\n')}</div>`;
}
if (errorMessage) {
logsHtml = `<div class="logs-error"><strong>Error:</strong> ${escapeHtml(errorMessage)}</div>` + logsHtml;
}
showModal(`
<div class="modal-header">
<h3>Job Logs</h3>
<button class="modal-close">&times;</button>
</div>
<div class="logs-container">
<div class="logs-status">Status: <span class="badge badge-${data.status}">${data.status}</span></div>
${logsHtml}
</div>
<div class="modal-footer">
<button class="btn btn-secondary modal-cancel-btn">Close</button>
</div>
`);
} catch (error) {
alert('Failed to load logs: ' + error.message);
}
}
// Admin
document.querySelectorAll('.tab-btn').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
document.querySelectorAll('.admin-tab').forEach(t => t.classList.add('hidden'));
btn.classList.add('active');
document.getElementById(`${btn.dataset.tab}-tab`).classList.remove('hidden');
if (btn.dataset.tab === 'all-content') loadAdminGallery();
});
});
async function loadUsers() {
const container = document.getElementById('users-list');
container.innerHTML = '<div class="spinner"></div>';
try {
const data = await api('/users');
container.innerHTML = data.users.map(user => `
<div class="user-item">
<div class="user-info">
<h4>${escapeHtml(user.username)}</h4>
<p>${escapeHtml(user.email || 'No email')}</p>
<div class="user-badges">
${user.isAdmin ? '<span class="badge badge-admin">Admin</span>' : ''}
${!user.isActive ? '<span class="badge badge-inactive">Inactive</span>' : ''}
</div>
</div>
<div class="user-actions">
<button class="btn btn-sm btn-secondary edit-user-btn" data-user-id="${user.id}">Edit</button>
${user.id !== currentUser.id ? `<button class="btn btn-sm btn-danger delete-user-btn" data-user-id="${user.id}">Delete</button>` : ''}
</div>
</div>
`).join('');
container.querySelectorAll('.edit-user-btn').forEach(btn => {
btn.addEventListener('click', () => editUser(parseInt(btn.dataset.userId, 10)));
});
container.querySelectorAll('.delete-user-btn').forEach(btn => {
btn.addEventListener('click', async () => {
const userId = parseInt(btn.dataset.userId, 10);
if (!confirm('Are you sure you want to delete this user?')) return;
try {
await api(`/users/${userId}`, { method: 'DELETE' });
loadUsers();
} catch (error) {
alert(error.message);
}
});
});
} catch (error) {
container.innerHTML = `<p class="error-message">${error.message}</p>`;
}
}
document.getElementById('add-user-btn').addEventListener('click', () => {
showModal(`
<div class="modal-header">
<h3>Add User</h3>
<button class="modal-close">&times;</button>
</div>
<form id="add-user-form">
<div class="form-group">
<label>Username</label>
<input type="text" name="username" required minlength="3">
</div>
<div class="form-group">
<label>Password</label>
<input type="password" name="password" required minlength="12">
</div>
<div class="form-group">
<label>Email (optional)</label>
<input type="email" name="email">
</div>
<div class="form-group">
<label><input type="checkbox" name="isAdmin"> Admin</label>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary modal-cancel-btn">Cancel</button>
<button type="submit" class="btn btn-primary">Create</button>
</div>
</form>
`);
document.getElementById('add-user-form').addEventListener('submit', async (e) => {
e.preventDefault();
const form = e.target;
try {
await api('/users', {
method: 'POST',
body: JSON.stringify({
username: form.username.value,
password: form.password.value,
email: form.email.value || null,
isAdmin: form.isAdmin.checked,
}),
});
hideModal();
loadUsers();
} catch (error) {
alert(error.message);
}
});
});
async function editUser(id) {
try {
const data = await api(`/users/${id}`);
const user = data.user;
showModal(`
<div class="modal-header">
<h3>Edit User: ${escapeHtml(user.username)}</h3>
<button class="modal-close">&times;</button>
</div>
<form id="edit-user-form">
<div class="form-group">
<label>Username</label>
<input type="text" name="username" value="${escapeHtml(user.username)}" required>
</div>
<div class="form-group">
<label>Email</label>
<input type="email" name="email" value="${escapeHtml(user.email || '')}">
</div>
<div class="form-group">
<label><input type="checkbox" name="isAdmin" ${user.isAdmin ? 'checked' : ''}> Admin</label>
</div>
<div class="form-group">
<label><input type="checkbox" name="isActive" ${user.isActive ? 'checked' : ''}> Active</label>
</div>
<hr>
<div class="form-group">
<label>Reset Password (leave blank to keep)</label>
<input type="password" name="newPassword" minlength="12" placeholder="New password">
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary modal-cancel-btn">Cancel</button>
<button type="submit" class="btn btn-primary">Save</button>
</div>
</form>
`);
document.getElementById('edit-user-form').addEventListener('submit', async (e) => {
e.preventDefault();
const form = e.target;
try {
await api(`/users/${id}`, {
method: 'PUT',
body: JSON.stringify({
username: form.username.value,
email: form.email.value || null,
isAdmin: form.isAdmin.checked,
isActive: form.isActive.checked,
}),
});
if (form.newPassword.value) {
await api(`/users/${id}/reset-password`, {
method: 'POST',
body: JSON.stringify({ newPassword: form.newPassword.value }),
});
}
hideModal();
loadUsers();
} catch (error) {
alert(error.message);
}
});
} catch (error) {
alert(error.message);
}
}
async function loadAdminGallery(page = 1) {
adminCurrentPage = page;
const grid = document.getElementById('admin-gallery-grid');
const pagination = document.getElementById('admin-gallery-pagination');
grid.innerHTML = '<div class="spinner"></div>';
try {
const data = await api(`/content?page=${page}&limit=12`);
renderGallery(grid, data.content);
renderPagination(pagination, data.pagination, loadAdminGallery);
} catch (error) {
grid.innerHTML = `<p class="error-message">${error.message}</p>`;
}
}
// Modal
function showModal(content) {
const modalContent = document.getElementById('modal-content');
modalContent.innerHTML = content;
document.getElementById('modal-overlay').classList.remove('hidden');
// Attach close handlers to modal-close buttons and cancel buttons
modalContent.querySelectorAll('.modal-close, .modal-cancel-btn').forEach(btn => {
btn.addEventListener('click', hideModal);
});
}
function hideModal() {
document.getElementById('modal-overlay').classList.add('hidden');
}
document.getElementById('modal-overlay').addEventListener('click', (e) => {
if (e.target === e.currentTarget) hideModal();
});
// Video Viewer
const videoViewer = document.getElementById('video-viewer');
const videoViewerPlayer = document.getElementById('video-viewer-player');
const videoViewerClose = document.getElementById('video-viewer-close');
function openVideoViewer(src) {
videoViewerPlayer.src = src;
videoViewer.classList.remove('hidden');
videoViewerPlayer.play();
}
function closeVideoViewer() {
videoViewerPlayer.pause();
videoViewerPlayer.src = '';
videoViewer.classList.add('hidden');
}
videoViewerClose.addEventListener('click', closeVideoViewer);
videoViewer.addEventListener('click', (e) => {
if (e.target === videoViewer) closeVideoViewer();
});
// Pagination
function renderPagination(container, pagination, loadFn) {
const { page, totalPages } = pagination;
if (totalPages <= 1) {
container.innerHTML = '';
return;
}
let html = '';
html += `<button class="pagination-btn" data-page="${page - 1}" ${page === 1 ? 'disabled' : ''}>Prev</button>`;
for (let i = 1; i <= totalPages; i++) {
if (i === 1 || i === totalPages || (i >= page - 1 && i <= page + 1)) {
html += `<button class="pagination-btn ${i === page ? 'active' : ''}" data-page="${i}">${i}</button>`;
} else if (i === page - 2 || i === page + 2) {
html += '<span>...</span>';
}
}
html += `<button class="pagination-btn" data-page="${page + 1}" ${page === totalPages ? 'disabled' : ''}>Next</button>`;
container.innerHTML = html;
container.querySelectorAll('.pagination-btn').forEach(btn => {
btn.addEventListener('click', () => {
if (!btn.disabled) {
loadFn(parseInt(btn.dataset.page, 10));
}
});
});
}
// Utilities
function escapeHtml(str) {
if (!str) return '';
return str.replace(/[&<>"']/g, c => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
}[c]));
}
function formatDate(dateStr) {
return new Date(dateStr).toLocaleDateString('en-US', {
month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit'
});
}
function base64UrlToBuffer(base64url) {
const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/');
const padding = '='.repeat((4 - base64.length % 4) % 4);
const binary = atob(base64 + padding);
return Uint8Array.from(binary, c => c.charCodeAt(0)).buffer;
}
function bufferToBase64Url(buffer) {
const bytes = new Uint8Array(buffer);
let binary = '';
for (const byte of bytes) binary += String.fromCharCode(byte);
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
// Privacy Toggle
function setupPrivacyToggle(buttonId, sectionId) {
const btn = document.getElementById(buttonId);
const section = document.getElementById(sectionId);
if (!btn || !section) return;
// Restore state from localStorage
const storageKey = `privacy-${sectionId}`;
if (localStorage.getItem(storageKey) === 'true') {
section.classList.add('privacy-mode');
btn.classList.add('active');
btn.textContent = 'Show Media';
}
btn.addEventListener('click', () => {
const isPrivate = section.classList.toggle('privacy-mode');
btn.classList.toggle('active', isPrivate);
btn.textContent = isPrivate ? 'Show Media' : 'Hide Media';
localStorage.setItem(storageKey, isPrivate);
});
}
setupPrivacyToggle('generate-privacy-toggle', 'generate-section');
setupPrivacyToggle('gallery-privacy-toggle', 'gallery-section');
// Init
checkAuth();