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>
This commit is contained in:
@@ -766,3 +766,65 @@ body {
|
||||
text-shadow: 0 1px 3px rgba(0,0,0,0.5);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Logs Modal */
|
||||
.logs-container {
|
||||
background: var(--gray-100);
|
||||
border-radius: var(--radius);
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.logs-status {
|
||||
margin-bottom: 12px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.logs-status .badge {
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.badge-completed { background: var(--success); color: white; }
|
||||
.badge-processing { background: var(--warning); color: var(--gray-900); }
|
||||
.badge-pending { background: var(--gray-500); color: white; }
|
||||
.badge-failed { background: var(--danger); color: white; }
|
||||
|
||||
.logs-error {
|
||||
background: #f8d7da;
|
||||
color: #721c24;
|
||||
padding: 12px;
|
||||
border-radius: var(--radius);
|
||||
margin-bottom: 12px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.logs-content {
|
||||
font-family: 'SF Mono', Monaco, 'Andale Mono', monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
background: var(--gray-900);
|
||||
color: #e0e0e0;
|
||||
padding: 15px;
|
||||
border-radius: var(--radius);
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.logs-content::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.logs-content::-webkit-scrollbar-track {
|
||||
background: var(--gray-800);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.logs-content::-webkit-scrollbar-thumb {
|
||||
background: var(--gray-600);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.logs-content::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--gray-500);
|
||||
}
|
||||
|
||||
@@ -437,6 +437,7 @@ function renderGallery(container, items) {
|
||||
<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>
|
||||
@@ -470,6 +471,48 @@ function renderGallery(container, items) {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
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">×</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
|
||||
|
||||
@@ -50,6 +50,7 @@ function runMigrations(database: Database.Database): void {
|
||||
// Migration files in order
|
||||
const migrations = [
|
||||
{ version: 1, file: '001_initial.sql' },
|
||||
{ version: 2, file: '002_add_logs.sql' },
|
||||
];
|
||||
|
||||
for (const migration of migrations) {
|
||||
@@ -131,5 +132,6 @@ export interface GeneratedContentRow {
|
||||
mime_type: string;
|
||||
status: 'pending' | 'processing' | 'completed' | 'failed';
|
||||
error_message: string | null;
|
||||
logs: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
2
frontend/src/db/migrations/002_add_logs.sql
Normal file
2
frontend/src/db/migrations/002_add_logs.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
-- Add logs column to store job execution logs
|
||||
ALTER TABLE generated_content ADD COLUMN logs TEXT;
|
||||
@@ -103,6 +103,35 @@ router.get('/:id', (req, res) => {
|
||||
});
|
||||
});
|
||||
|
||||
// Get content logs
|
||||
router.get('/:id/logs', (req, res) => {
|
||||
const authReq = req as AuthenticatedRequest;
|
||||
const contentId = parseInt(req.params.id, 10);
|
||||
|
||||
if (isNaN(contentId)) {
|
||||
res.status(400).json({ error: 'Invalid content ID' });
|
||||
return;
|
||||
}
|
||||
|
||||
const content = getContentById(contentId);
|
||||
if (!content) {
|
||||
res.status(404).json({ error: 'Content not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!canAccessResource(authReq.user, content.userId)) {
|
||||
res.status(403).json({ error: 'Access denied' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({
|
||||
contentId: content.id,
|
||||
status: content.status,
|
||||
logs: content.logs || [],
|
||||
errorMessage: content.errorMessage,
|
||||
});
|
||||
});
|
||||
|
||||
// Download content file
|
||||
router.get('/:id/download', (req, res) => {
|
||||
const authReq = req as AuthenticatedRequest;
|
||||
|
||||
@@ -123,9 +123,16 @@ router.get('/:jobId/status', asyncHandler(async (req, res) => {
|
||||
if (output.data) {
|
||||
// Save base64 data to file
|
||||
saveContentFile(row.id, output.data);
|
||||
// Also save logs if present
|
||||
if (status.output.logs) {
|
||||
updateContentStatus(row.id, 'completed', { logs: status.output.logs });
|
||||
}
|
||||
} else if (output.path) {
|
||||
// File was saved to volume - update status
|
||||
updateContentStatus(row.id, 'completed', { fileSize: output.size });
|
||||
// File was saved to volume - update status with logs
|
||||
updateContentStatus(row.id, 'completed', {
|
||||
fileSize: output.size,
|
||||
logs: status.output.logs,
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (status.status === 'FAILED') {
|
||||
@@ -138,6 +145,7 @@ router.get('/:jobId/status', asyncHandler(async (req, res) => {
|
||||
if (row) {
|
||||
updateContentStatus(row.id, 'failed', {
|
||||
errorMessage: status.error || status.output?.error || 'Unknown error',
|
||||
logs: status.output?.logs,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -182,6 +190,7 @@ router.get('/content/:contentId/status', (req, res) => {
|
||||
status: content.status,
|
||||
runpodJobId: content.runpodJobId,
|
||||
errorMessage: content.errorMessage,
|
||||
logs: content.logs,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ function rowToContent(row: GeneratedContentRow): GeneratedContent {
|
||||
mimeType: row.mime_type,
|
||||
status: row.status,
|
||||
errorMessage: row.error_message,
|
||||
logs: row.logs ? JSON.parse(row.logs) : null,
|
||||
createdAt: new Date(row.created_at),
|
||||
};
|
||||
}
|
||||
@@ -68,6 +69,7 @@ export function updateContentStatus(
|
||||
runpodJobId?: string;
|
||||
fileSize?: number;
|
||||
errorMessage?: string;
|
||||
logs?: string[];
|
||||
}
|
||||
): GeneratedContent | null {
|
||||
const db = getDb();
|
||||
@@ -87,6 +89,10 @@ export function updateContentStatus(
|
||||
setParts.push('error_message = ?');
|
||||
values.push(updates.errorMessage);
|
||||
}
|
||||
if (updates?.logs !== undefined) {
|
||||
setParts.push('logs = ?');
|
||||
values.push(JSON.stringify(updates.logs));
|
||||
}
|
||||
|
||||
values.push(id);
|
||||
|
||||
|
||||
@@ -58,13 +58,21 @@ async function processStuckJobs(): Promise<void> {
|
||||
const output = status.output.outputs[0];
|
||||
if (output.data) {
|
||||
saveContentFile(job.id, output.data);
|
||||
// Also save logs if present
|
||||
if (status.output.logs) {
|
||||
updateContentStatus(job.id, 'completed', { logs: status.output.logs });
|
||||
}
|
||||
} else {
|
||||
updateContentStatus(job.id, 'completed', { fileSize: output.size });
|
||||
updateContentStatus(job.id, 'completed', {
|
||||
fileSize: output.size,
|
||||
logs: status.output.logs,
|
||||
});
|
||||
}
|
||||
logger.info({ contentId: job.id }, 'Background processor completed job');
|
||||
} else if (status.status === 'FAILED') {
|
||||
updateContentStatus(job.id, 'failed', {
|
||||
errorMessage: status.error || status.output?.error || 'Job failed',
|
||||
logs: status.output?.logs,
|
||||
});
|
||||
logger.info({ contentId: job.id }, 'Background processor marked job as failed');
|
||||
}
|
||||
|
||||
@@ -68,6 +68,7 @@ export interface GeneratedContent {
|
||||
mimeType: string;
|
||||
status: 'pending' | 'processing' | 'completed' | 'failed';
|
||||
errorMessage: string | null;
|
||||
logs: string[] | null;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
@@ -99,6 +100,7 @@ export interface RunPodJobStatus extends RunPodJob {
|
||||
size?: number;
|
||||
}>;
|
||||
error?: string;
|
||||
logs?: string[];
|
||||
};
|
||||
error?: string;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user