Add server-side pagination, filtering, sorting, and file detail view

- Updated /api/files endpoint with pagination, filtering, and sorting support
- Added /api/files/{file_id} endpoint for detailed file information
- Updated /files view to support server-side operations
- Added /files/{file_id}/detail route for file detail page
- Created new files.html with filters, status column, and pagination
- Created file_detail.html for viewing processing history
- Status computed from ProcessingLog entries (pending, processing, completed, failed)

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2026-02-07 15:12:49 +00:00
parent bbd7286c0b
commit 530c63ad14
4 changed files with 959 additions and 194 deletions
+195 -21
View File
@@ -1,15 +1,17 @@
"""
File-related API endpoints
"""
from fastapi import APIRouter, Request, HTTPException, Depends, UploadFile, File
from fastapi import APIRouter, Request, HTTPException, Depends, UploadFile, File, Query
from sqlalchemy.orm import Session
from sqlalchemy import desc, asc, or_, func
from typing import Optional, List
import logging
import os
import uuid
import mimetypes
from app.auth import require_login
from app.models import FileRecord
from app.models import FileRecord, ProcessingLog
from app.config import settings
from app.api.common import get_db
from app.tasks.process_document import process_document
@@ -22,29 +24,79 @@ router = APIRouter()
@router.get("/files")
@require_login
def list_files_api(request: Request, db: Session = Depends(get_db)):
def list_files_api(
request: Request,
db: Session = Depends(get_db),
page: int = Query(1, ge=1, description="Page number"),
per_page: int = Query(50, ge=1, le=200, description="Items per page"),
sort_by: str = Query("created_at", description="Sort field: id, original_filename, file_size, mime_type, created_at, status"),
sort_order: str = Query("desc", description="Sort order: asc or desc"),
search: Optional[str] = Query(None, description="Search in filename"),
mime_type: Optional[str] = Query(None, description="Filter by MIME type"),
status: Optional[str] = Query(None, description="Filter by processing status")
):
"""
Returns a JSON list of all FileRecord entries.
Protected by `@require_login`, so only logged-in sessions can access.
Returns a paginated JSON list of FileRecord entries with processing status.
Supports server-side sorting, filtering, and searching.
Query Parameters:
- page: Page number (default: 1)
- per_page: Items per page (default: 50, max: 200)
- sort_by: Field to sort by (default: created_at)
- sort_order: asc or desc (default: desc)
- search: Search in filename
- mime_type: Filter by MIME type
- status: Filter by processing status (pending, processing, completed, failed)
Example response:
[
{
"id": 123,
"filehash": "abc123...",
"original_filename": "example.pdf",
"local_filename": "/workdir/tmp/<uuid>.pdf",
"file_size": 1048576,
"mime_type": "application/pdf",
"created_at": "2025-05-01T12:34:56.789000"
},
...
]
{
"files": [...],
"pagination": {
"page": 1,
"per_page": 50,
"total_items": 150,
"total_pages": 3
}
}
"""
files = db.query(FileRecord).order_by(FileRecord.created_at.desc()).all()
# Return a simple list of dicts
# Start with base query
query = db.query(FileRecord)
# Apply search filter
if search:
query = query.filter(FileRecord.original_filename.ilike(f"%{search}%"))
# Apply MIME type filter
if mime_type:
query = query.filter(FileRecord.mime_type == mime_type)
# Get total count before pagination
total_items = query.count()
# Apply sorting
sort_column = {
"id": FileRecord.id,
"original_filename": FileRecord.original_filename,
"file_size": FileRecord.file_size,
"mime_type": FileRecord.mime_type,
"created_at": FileRecord.created_at
}.get(sort_by, FileRecord.created_at)
if sort_order == "asc":
query = query.order_by(asc(sort_column))
else:
query = query.order_by(desc(sort_column))
# Apply pagination
offset = (page - 1) * per_page
files = query.offset(offset).limit(per_page).all()
# Build result with processing status
result = []
for f in files:
# Get processing status for this file
processing_status = _get_file_processing_status(db, f.id)
result.append({
"id": f.id,
"filehash": f.filehash,
@@ -52,9 +104,131 @@ def list_files_api(request: Request, db: Session = Depends(get_db)):
"local_filename": f.local_filename,
"file_size": f.file_size,
"mime_type": f.mime_type,
"created_at": f.created_at.isoformat() if f.created_at else None
"created_at": f.created_at.isoformat() if f.created_at else None,
"processing_status": processing_status
})
return result
# Filter by status if requested (after computing statuses)
if status:
result = [f for f in result if f["processing_status"]["status"] == status]
total_items = len(result) # Update total for filtered results
# Calculate pagination info
total_pages = (total_items + per_page - 1) // per_page
return {
"files": result,
"pagination": {
"page": page,
"per_page": per_page,
"total_items": total_items,
"total_pages": total_pages
}
}
def _get_file_processing_status(db: Session, file_id: int) -> dict:
"""
Get the processing status for a file by checking its processing logs.
Returns:
dict with status, last_step, and has_errors
"""
# Get all logs for this file
logs = db.query(ProcessingLog).filter(
ProcessingLog.file_id == file_id
).order_by(ProcessingLog.timestamp.desc()).all()
if not logs:
return {
"status": "pending",
"last_step": None,
"has_errors": False,
"total_steps": 0
}
# Check for failures
has_errors = any(log.status == "failure" for log in logs)
# Check if any in progress
in_progress = any(log.status == "in_progress" for log in logs)
# Get the latest log
latest_log = logs[0]
# Determine overall status
if has_errors:
status = "failed"
elif in_progress:
status = "processing"
elif latest_log.status == "success":
status = "completed"
else:
status = "pending"
return {
"status": status,
"last_step": latest_log.step_name,
"has_errors": has_errors,
"total_steps": len(logs)
}
@router.get("/files/{file_id}")
@require_login
def get_file_details(request: Request, file_id: int, db: Session = Depends(get_db)):
"""
Get detailed information about a specific file including processing history.
"""
# Find the file record
file_record = db.query(FileRecord).filter(FileRecord.id == file_id).first()
if not file_record:
raise HTTPException(
status_code=404,
detail=f"File record with ID {file_id} not found"
)
# Get processing logs
logs = db.query(ProcessingLog).filter(
ProcessingLog.file_id == file_id
).order_by(ProcessingLog.timestamp.desc()).all()
# Build log list
log_list = []
for log in logs:
log_list.append({
"id": log.id,
"task_id": log.task_id,
"step_name": log.step_name,
"status": log.status,
"message": log.message,
"timestamp": log.timestamp.isoformat() if log.timestamp else None
})
# Get processing status
processing_status = _get_file_processing_status(db, file_id)
# Check if files exist on disk
files_on_disk = {
"original": os.path.exists(file_record.local_filename) if file_record.local_filename else False
}
return {
"file": {
"id": file_record.id,
"filehash": file_record.filehash,
"original_filename": file_record.original_filename,
"local_filename": file_record.local_filename,
"file_size": file_record.file_size,
"mime_type": file_record.mime_type,
"created_at": file_record.created_at.isoformat() if file_record.created_at else None
},
"processing_status": processing_status,
"logs": log_list,
"files_on_disk": files_on_disk
}
@router.delete("/files/{file_id}")
@require_login
+146 -8
View File
@@ -1,8 +1,9 @@
"""
File management views for displaying and managing files.
"""
from fastapi import Request, Depends
from fastapi import Request, Depends, Query
from sqlalchemy.orm import Session
from typing import Optional
from app.views.base import APIRouter, templates, require_login, get_db, logger
@@ -10,23 +11,113 @@ router = APIRouter()
@router.get("/files")
@require_login
def files_page(request: Request, db: Session = Depends(get_db)):
def files_page(
request: Request,
db: Session = Depends(get_db),
page: int = Query(1, ge=1),
per_page: int = Query(50, ge=1, le=200),
sort_by: str = Query("created_at"),
sort_order: str = Query("desc"),
search: Optional[str] = Query(None),
mime_type: Optional[str] = Query(None),
status: Optional[str] = Query(None)
):
"""
Return the 'files.html' template with files from the database
Return the 'files.html' template with server-side pagination, sorting, and filtering
"""
try:
# Import the model here to avoid circular imports
from app.models import FileRecord
from app.models import FileRecord, ProcessingLog
from sqlalchemy import desc, asc
# Fetch all files from the database
files = db.query(FileRecord).order_by(FileRecord.created_at.desc()).all()
# Start with base query
query = db.query(FileRecord)
# Apply search filter
if search:
query = query.filter(FileRecord.original_filename.ilike(f"%{search}%"))
# Apply MIME type filter
if mime_type:
query = query.filter(FileRecord.mime_type == mime_type)
# Get total count before pagination
total_items = query.count()
# Apply sorting
sort_column = {
"id": FileRecord.id,
"original_filename": FileRecord.original_filename,
"file_size": FileRecord.file_size,
"mime_type": FileRecord.mime_type,
"created_at": FileRecord.created_at
}.get(sort_by, FileRecord.created_at)
if sort_order == "asc":
query = query.order_by(asc(sort_column))
else:
query = query.order_by(desc(sort_column))
# Apply pagination
offset = (page - 1) * per_page
files = query.offset(offset).limit(per_page).all()
# Add processing status to each file
files_with_status = []
for file in files:
# Get processing logs for this file
logs = db.query(ProcessingLog).filter(
ProcessingLog.file_id == file.id
).order_by(ProcessingLog.timestamp.desc()).all()
# Determine status
if not logs:
processing_status = "pending"
else:
has_errors = any(log.status == "failure" for log in logs)
in_progress = any(log.status == "in_progress" for log in logs)
latest_log = logs[0]
if has_errors:
processing_status = "failed"
elif in_progress:
processing_status = "processing"
elif latest_log.status == "success":
processing_status = "completed"
else:
processing_status = "pending"
# Add status to file object as an attribute
file.processing_status = processing_status
files_with_status.append(file)
# Calculate pagination info
total_pages = (total_items + per_page - 1) // per_page
# Get unique MIME types for filter dropdown
mime_types = db.query(FileRecord.mime_type).distinct().filter(
FileRecord.mime_type.isnot(None)
).all()
mime_types = [mt[0] for mt in mime_types if mt[0]]
# Debug output
logger.info(f"Retrieved {len(files)} files from database")
logger.info(f"Retrieved {len(files_with_status)} files from database (page {page}/{total_pages})")
return templates.TemplateResponse("files.html", {
"request": request,
"files": files
"files": files_with_status,
"pagination": {
"page": page,
"per_page": per_page,
"total_items": total_items,
"total_pages": total_pages
},
"sort_by": sort_by,
"sort_order": sort_order,
"search": search or "",
"mime_type": mime_type or "",
"status": status or "",
"mime_types": mime_types
})
except Exception as e:
# Log any errors
@@ -35,5 +126,52 @@ def files_page(request: Request, db: Session = Depends(get_db)):
return templates.TemplateResponse("files.html", {
"request": request,
"files": [],
"pagination": {
"page": 1,
"per_page": per_page,
"total_items": 0,
"total_pages": 0
},
"error": str(e)
})
@router.get("/files/{file_id}/detail")
@require_login
def file_detail_page(request: Request, file_id: int, db: Session = Depends(get_db)):
"""
Return the file detail page showing processing history and file information
"""
try:
from app.models import FileRecord, ProcessingLog
import os
# Find the file record
file_record = db.query(FileRecord).filter(FileRecord.id == file_id).first()
if not file_record:
return templates.TemplateResponse("file_detail.html", {
"request": request,
"error": f"File with ID {file_id} not found"
})
# Get processing logs
logs = db.query(ProcessingLog).filter(
ProcessingLog.file_id == file_id
).order_by(ProcessingLog.timestamp.asc()).all()
# Check if file exists on disk
file_exists = os.path.exists(file_record.local_filename) if file_record.local_filename else False
return templates.TemplateResponse("file_detail.html", {
"request": request,
"file": file_record,
"logs": logs,
"file_exists": file_exists
})
except Exception as e:
logger.error(f"Error retrieving file details: {str(e)}")
return templates.TemplateResponse("file_detail.html", {
"request": request,
"error": str(e)
})
+310
View File
@@ -0,0 +1,310 @@
{% extends "base.html" %}
{% block title %}File Details{% endblock %}
{% block head_extra %}
<style>
.detail-container {
max-width: 1200px;
margin: 0 auto;
}
.back-button {
display: inline-flex;
align-items: center;
color: #3182ce;
text-decoration: none;
margin-bottom: 1.5rem;
font-weight: 600;
}
.back-button:hover {
color: #2c5aa0;
}
.back-button i {
margin-right: 0.5rem;
}
.detail-card {
background-color: white;
border-radius: 0.5rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
padding: 2rem;
margin-bottom: 1.5rem;
}
.detail-card h3 {
font-size: 1.5rem;
font-weight: bold;
margin-bottom: 1rem;
color: #2d3748;
}
.detail-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 1.5rem;
}
.detail-item {
display: flex;
flex-direction: column;
}
.detail-label {
font-weight: 600;
color: #4a5568;
margin-bottom: 0.25rem;
font-size: 0.875rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.detail-value {
color: #2d3748;
font-size: 1rem;
word-break: break-word;
}
/* Status badges */
.status-badge {
display: inline-block;
padding: 0.5rem 1rem;
border-radius: 9999px;
font-size: 1rem;
font-weight: 600;
text-align: center;
}
.status-pending {
background-color: #FEF3C7;
color: #92400E;
}
.status-processing {
background-color: #DBEAFE;
color: #1E3A8A;
}
.status-completed {
background-color: #D1FAE5;
color: #065F46;
}
.status-failed {
background-color: #FEE2E2;
color: #991B1B;
}
/* Processing logs */
.timeline {
position: relative;
padding-left: 2rem;
}
.timeline::before {
content: '';
position: absolute;
left: 0.5rem;
top: 0;
bottom: 0;
width: 2px;
background-color: #e2e8f0;
}
.timeline-item {
position: relative;
padding-bottom: 2rem;
}
.timeline-item:last-child {
padding-bottom: 0;
}
.timeline-marker {
position: absolute;
left: -1.5rem;
width: 1rem;
height: 1rem;
border-radius: 50%;
background-color: #e2e8f0;
border: 2px solid white;
}
.timeline-marker.success {
background-color: #48bb78;
}
.timeline-marker.failure {
background-color: #f56565;
}
.timeline-marker.in_progress {
background-color: #4299e1;
}
.timeline-marker.pending {
background-color: #ecc94b;
}
.timeline-content {
background-color: #f7fafc;
border-radius: 0.5rem;
padding: 1rem;
border-left: 3px solid #e2e8f0;
}
.timeline-content.success {
border-left-color: #48bb78;
background-color: #f0fff4;
}
.timeline-content.failure {
border-left-color: #f56565;
background-color: #fff5f5;
}
.timeline-content.in_progress {
border-left-color: #4299e1;
background-color: #ebf8ff;
}
.timeline-content.pending {
border-left-color: #ecc94b;
background-color: #fffff0;
}
.timeline-title {
font-weight: 600;
color: #2d3748;
margin-bottom: 0.5rem;
}
.timeline-message {
color: #4a5568;
margin-bottom: 0.5rem;
}
.timeline-meta {
display: flex;
justify-content: space-between;
font-size: 0.875rem;
color: #718096;
}
.timeline-task-id {
font-family: monospace;
font-size: 0.75rem;
color: #a0aec0;
}
.no-logs {
text-align: center;
padding: 3rem;
color: #718096;
}
.no-logs i {
font-size: 3rem;
margin-bottom: 1rem;
opacity: 0.5;
}
.error-message {
background-color: #FEE2E2;
border: 1px solid #F87171;
color: #B91C1C;
padding: 1rem;
border-radius: 0.25rem;
margin-bottom: 1rem;
}
.file-status-indicator {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
border-radius: 0.25rem;
font-size: 0.875rem;
}
.file-status-indicator.exists {
background-color: #D1FAE5;
color: #065F46;
}
.file-status-indicator.missing {
background-color: #FEE2E2;
color: #991B1B;
}
</style>
{% endblock %}
{% block content %}
<div class="detail-container">
<a href="/files" class="back-button">
<i class="fas fa-arrow-left"></i>
Back to File List
</a>
{% if error %}
<div class="error-message">
<p><strong>Error:</strong> {{ error }}</p>
</div>
{% else %}
<!-- File Information Card -->
<div class="detail-card">
<h3>File Information</h3>
<div class="detail-grid">
<div class="detail-item">
<span class="detail-label">File ID</span>
<span class="detail-value">{{ file.id }}</span>
</div>
<div class="detail-item">
<span class="detail-label">Original Filename</span>
<span class="detail-value">{{ file.original_filename }}</span>
</div>
<div class="detail-item">
<span class="detail-label">File Hash</span>
<span class="detail-value" style="font-family: monospace; font-size: 0.875rem;">{{ file.filehash }}</span>
</div>
<div class="detail-item">
<span class="detail-label">File Size</span>
<span class="detail-value">{{ (file.file_size / 1024) | round(2) }} KB ({{ file.file_size }} bytes)</span>
</div>
<div class="detail-item">
<span class="detail-label">MIME Type</span>
<span class="detail-value">{{ file.mime_type or 'N/A' }}</span>
</div>
<div class="detail-item">
<span class="detail-label">Created At</span>
<span class="detail-value">{{ file.created_at.strftime('%Y-%m-%d %H:%M:%S') if file.created_at else 'N/A' }}</span>
</div>
<div class="detail-item">
<span class="detail-label">Local Path</span>
<span class="detail-value" style="font-family: monospace; font-size: 0.875rem;">{{ file.local_filename }}</span>
</div>
<div class="detail-item">
<span class="detail-label">File on Disk</span>
<span class="detail-value">
{% if file_exists %}
<span class="file-status-indicator exists">
<i class="fas fa-check-circle"></i> File exists
</span>
{% else %}
<span class="file-status-indicator missing">
<i class="fas fa-times-circle"></i> File not found
</span>
{% endif %}
</span>
</div>
</div>
</div>
<!-- Processing History Card -->
<div class="detail-card">
<h3>Processing History</h3>
{% if logs %}
<div class="timeline">
{% for log in logs %}
<div class="timeline-item">
<div class="timeline-marker {{ log.status.lower().replace(' ', '_') }}"></div>
<div class="timeline-content {{ log.status.lower().replace(' ', '_') }}">
<div class="timeline-title">
{{ log.step_name }} - <span style="text-transform: capitalize;">{{ log.status }}</span>
</div>
{% if log.message %}
<div class="timeline-message">{{ log.message }}</div>
{% endif %}
<div class="timeline-meta">
<span>{{ log.timestamp.strftime('%Y-%m-%d %H:%M:%S') if log.timestamp else 'N/A' }}</span>
{% if log.task_id %}
<span class="timeline-task-id">Task: {{ log.task_id[:16] }}...</span>
{% endif %}
</div>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="no-logs">
<i class="fas fa-clipboard-list"></i>
<p>No processing logs found for this file.</p>
<p style="margin-top: 0.5rem; font-size: 0.875rem;">The file may not have been processed yet.</p>
</div>
{% endif %}
</div>
{% endif %}
</div>
{% endblock %}
+308 -165
View File
@@ -3,10 +3,6 @@
{% block head_extra %}
<script src="/static/js/common.js"></script>
<!-- Add TableSorter library -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.6.0/jquery.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery.tablesorter/2.31.3/js/jquery.tablesorter.min.js"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/jquery.tablesorter/2.31.3/css/theme.bootstrap_4.min.css">
<style>
.file-table {
width: 100%;
@@ -22,55 +18,161 @@
.file-table th {
background-color: #f7fafc;
font-weight: 600;
cursor: pointer;
position: relative;
}
.file-table th:hover {
.file-table th.sortable {
cursor: pointer;
user-select: none;
}
.file-table th.sortable:hover {
background-color: #edf2f7;
}
.file-table tbody tr:hover {
background-color: #f7fafc;
}
.delete-btn {
color: #e53e3e;
cursor: pointer;
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
background: none;
border: none;
display: flex;
align-items: center;
}
.delete-btn:hover {
background-color: #fed7d7;
.sort-indicator {
display: inline-block;
margin-left: 0.5rem;
opacity: 0.5;
}
.view-logs-btn {
.sort-indicator.active {
opacity: 1;
font-weight: bold;
}
/* Status badges */
.status-badge {
display: inline-block;
padding: 0.25rem 0.75rem;
border-radius: 9999px;
font-size: 0.875rem;
font-weight: 600;
text-align: center;
}
.status-pending {
background-color: #FEF3C7;
color: #92400E;
}
.status-processing {
background-color: #DBEAFE;
color: #1E3A8A;
}
.status-completed {
background-color: #D1FAE5;
color: #065F46;
}
.status-failed {
background-color: #FEE2E2;
color: #991B1B;
}
/* Action buttons */
.action-btn {
color: #3182ce;
cursor: pointer;
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
background: none;
border: none;
display: flex;
display: inline-flex;
align-items: center;
margin-right: 0.5rem;
}
.view-logs-btn:hover {
.action-btn:hover {
background-color: #bee3f8;
}
.error-message {
background-color: #FEE2E2;
border: 1px solid #F87171;
color: #B91C1C;
padding: 1rem;
border-radius: 0.25rem;
margin-bottom: 1rem;
.action-btn.delete {
color: #e53e3e;
}
.action-btn.delete:hover {
background-color: #fed7d7;
}
/* TableSorter specific styles */
.tablesorter-header-inner {
/* Filters section */
.filters-section {
background-color: #f7fafc;
padding: 1.5rem;
border-radius: 0.5rem;
margin-bottom: 1.5rem;
}
.filter-group {
display: flex;
gap: 1rem;
flex-wrap: wrap;
align-items: flex-end;
}
.filter-item {
flex: 1;
min-width: 200px;
}
.filter-item label {
display: block;
font-weight: 600;
margin-bottom: 0.5rem;
color: #2d3748;
}
.filter-item input,
.filter-item select {
width: 100%;
padding: 0.5rem;
border: 1px solid #cbd5e0;
border-radius: 0.25rem;
}
.filter-item button {
padding: 0.5rem 1rem;
background-color: #3182ce;
color: white;
border: none;
border-radius: 0.25rem;
cursor: pointer;
font-weight: 600;
}
.filter-item button:hover {
background-color: #2c5aa0;
}
.filter-item button.clear {
background-color: #718096;
}
.filter-item button.clear:hover {
background-color: #4a5568;
}
/* Pagination */
.pagination {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 1.5rem;
padding-top: 1rem;
border-top: 1px solid #e2e8f0;
}
.pagination-info {
color: #4a5568;
}
.pagination-buttons {
display: flex;
gap: 0.5rem;
}
.pagination-button {
padding: 0.5rem 1rem;
border: 1px solid #cbd5e0;
background-color: white;
border-radius: 0.25rem;
cursor: pointer;
color: #2d3748;
}
.pagination-button:hover:not(:disabled) {
background-color: #edf2f7;
}
.pagination-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.pagination-button.active {
background-color: #3182ce;
color: white;
border-color: #3182ce;
}
/* Modal styles */
@@ -127,46 +229,13 @@
background-color: #c53030;
}
/* Logs styles */
.logs-container {
margin-top: 1rem;
}
.log-entry {
padding: 0.75rem;
border-left: 3px solid #e2e8f0;
margin-bottom: 0.5rem;
background-color: #f7fafc;
.error-message {
background-color: #FEE2E2;
border: 1px solid #F87171;
color: #B91C1C;
padding: 1rem;
border-radius: 0.25rem;
}
.log-entry.success {
border-left-color: #48bb78;
background-color: #f0fff4;
}
.log-entry.failure {
border-left-color: #f56565;
background-color: #fff5f5;
}
.log-entry.in_progress {
border-left-color: #4299e1;
background-color: #ebf8ff;
}
.log-step {
font-weight: 600;
color: #2d3748;
}
.log-message {
color: #4a5568;
margin-top: 0.25rem;
}
.log-timestamp {
font-size: 0.875rem;
color: #718096;
margin-top: 0.25rem;
}
.no-logs {
text-align: center;
padding: 2rem;
color: #718096;
margin-bottom: 1rem;
}
</style>
{% endblock %}
@@ -182,34 +251,130 @@
</div>
{% endif %}
<!-- Filters Section -->
<div class="filters-section">
<form method="get" action="/files" class="filter-group">
<div class="filter-item">
<label for="search">Search Filename</label>
<input type="text" id="search" name="search" value="{{ search }}" placeholder="Enter filename...">
</div>
<div class="filter-item">
<label for="mime_type">MIME Type</label>
<select id="mime_type" name="mime_type">
<option value="">All Types</option>
{% for mt in mime_types %}
<option value="{{ mt }}" {% if mt == mime_type %}selected{% endif %}>{{ mt }}</option>
{% endfor %}
</select>
</div>
<div class="filter-item">
<label for="status">Status</label>
<select id="status" name="status">
<option value="">All Statuses</option>
<option value="pending" {% if status == "pending" %}selected{% endif %}>Pending</option>
<option value="processing" {% if status == "processing" %}selected{% endif %}>Processing</option>
<option value="completed" {% if status == "completed" %}selected{% endif %}>Completed</option>
<option value="failed" {% if status == "failed" %}selected{% endif %}>Failed</option>
</select>
</div>
<div class="filter-item">
<label>&nbsp;</label>
<button type="submit">Apply Filters</button>
</div>
<div class="filter-item">
<label>&nbsp;</label>
<button type="button" class="clear" onclick="clearFilters()">Clear</button>
</div>
<!-- Hidden fields to preserve sort order -->
<input type="hidden" name="sort_by" value="{{ sort_by }}">
<input type="hidden" name="sort_order" value="{{ sort_order }}">
<input type="hidden" name="per_page" value="{{ pagination.per_page }}">
</form>
</div>
<!-- File table -->
<div class="overflow-x-auto">
<table class="file-table" id="fileTable">
<thead>
<tr>
<th>ID</th>
<th>Original Filename</th>
<th>File Size</th>
<th>Mime Type</th>
<th>Created At</th>
<th class="sorter-false">Actions</th>
<th class="sortable" onclick="sortTable('id')">
ID
<span class="sort-indicator {% if sort_by == 'id' %}active{% endif %}">
{% if sort_by == 'id' %}
{% if sort_order == 'asc' %}▲{% else %}▼{% endif %}
{% else %}
{% endif %}
</span>
</th>
<th class="sortable" onclick="sortTable('original_filename')">
Original Filename
<span class="sort-indicator {% if sort_by == 'original_filename' %}active{% endif %}">
{% if sort_by == 'original_filename' %}
{% if sort_order == 'asc' %}▲{% else %}▼{% endif %}
{% else %}
{% endif %}
</span>
</th>
<th class="sortable" onclick="sortTable('file_size')">
File Size
<span class="sort-indicator {% if sort_by == 'file_size' %}active{% endif %}">
{% if sort_by == 'file_size' %}
{% if sort_order == 'asc' %}▲{% else %}▼{% endif %}
{% else %}
{% endif %}
</span>
</th>
<th class="sortable" onclick="sortTable('mime_type')">
MIME Type
<span class="sort-indicator {% if sort_by == 'mime_type' %}active{% endif %}">
{% if sort_by == 'mime_type' %}
{% if sort_order == 'asc' %}▲{% else %}▼{% endif %}
{% else %}
{% endif %}
</span>
</th>
<th>Status</th>
<th class="sortable" onclick="sortTable('created_at')">
Created At
<span class="sort-indicator {% if sort_by == 'created_at' %}active{% endif %}">
{% if sort_by == 'created_at' %}
{% if sort_order == 'asc' %}▲{% else %}▼{% endif %}
{% else %}
{% endif %}
</span>
</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for file in files %}
<tr>
<tr onclick="viewFileDetail({{ file.id }}, event)">
<td>{{ file.id }}</td>
<td>{{ file.original_filename }}</td>
<td data-sort-value="{{ file.file_size }}">{{ (file.file_size / 1024) | round(2) }} KB</td>
<td>{{ (file.file_size / 1024) | round(2) }} KB</td>
<td>{{ file.mime_type }}</td>
<td>{{ file.created_at }}</td>
<td>
<span class="status-badge status-{{ file.processing_status }}">
{{ file.processing_status | title }}
</span>
</td>
<td>{{ file.created_at.strftime('%Y-%m-%d %H:%M:%S') if file.created_at else 'N/A' }}</td>
<td>
<div style="display: flex; align-items: center;">
<button onclick="showLogs('{{ file.id }}')" class="view-logs-btn" title="View processing logs">
<i class="fas fa-list"></i>
<button onclick="viewFileDetail({{ file.id }}, event)" class="action-btn" title="View details">
<i class="fas fa-info-circle"></i>
</button>
<button onclick="showDeleteModal('{{ file.id }}')" class="delete-btn" title="Delete file">
<button onclick="showDeleteModal({{ file.id }}, event)" class="action-btn delete" title="Delete file">
<i class="fas fa-trash"></i>
</button>
</div>
@@ -217,13 +382,41 @@
</tr>
{% else %}
<tr>
<td colspan="6" class="text-center py-4">No files found</td>
<td colspan="7" class="text-center py-4">No files found</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<!-- Pagination -->
{% if pagination.total_pages > 1 %}
<div class="pagination">
<div class="pagination-info">
Showing {{ ((pagination.page - 1) * pagination.per_page + 1) }} -
{{ min(pagination.page * pagination.per_page, pagination.total_items) }}
of {{ pagination.total_items }} files
</div>
<div class="pagination-buttons">
{% if pagination.page > 1 %}
<button class="pagination-button" onclick="goToPage(1)">First</button>
<button class="pagination-button" onclick="goToPage({{ pagination.page - 1 }})">Previous</button>
{% endif %}
{% for p in range(max(1, pagination.page - 2), min(pagination.total_pages + 1, pagination.page + 3)) %}
<button class="pagination-button {% if p == pagination.page %}active{% endif %}" onclick="goToPage({{ p }})">
{{ p }}
</button>
{% endfor %}
{% if pagination.page < pagination.total_pages %}
<button class="pagination-button" onclick="goToPage({{ pagination.page + 1 }})">Next</button>
<button class="pagination-button" onclick="goToPage({{ pagination.total_pages }})">Last</button>
{% endif %}
</div>
</div>
{% endif %}
<!-- Delete confirmation modal -->
<div id="deleteModal" class="modal">
<div class="modal-content">
@@ -236,82 +429,25 @@
</div>
</div>
<!-- Processing logs modal -->
<div id="logsModal" class="modal">
<div class="modal-content">
<div class="modal-title">Processing Logs</div>
<div id="logsContent">
<p>Loading logs...</p>
</div>
<div class="modal-buttons">
<button id="closeLogsModal" class="modal-btn modal-btn-cancel">Close</button>
</div>
</div>
</div>
<!-- Add JavaScript for handling DELETE requests -->
<script>
// Modal functionality
const deleteModal = document.getElementById('deleteModal');
const logsModal = document.getElementById('logsModal');
const cancelDelete = document.getElementById('cancelDelete');
const confirmDelete = document.getElementById('confirmDelete');
const closeLogsModal = document.getElementById('closeLogsModal');
let currentFileId = null;
function showDeleteModal(fileId) {
function showDeleteModal(fileId, event) {
if (event) event.stopPropagation();
currentFileId = fileId;
deleteModal.style.display = 'flex';
}
function showLogs(fileId) {
logsModal.style.display = 'flex';
document.getElementById('logsContent').innerHTML = '<p>Loading logs...</p>';
// Fetch logs from API
fetch(`/api/logs/file/${fileId}`)
.then(response => {
if (!response.ok) {
throw new Error('Failed to fetch logs');
}
return response.json();
})
.then(data => {
displayLogs(data);
})
.catch(error => {
console.error('Error:', error);
document.getElementById('logsContent').innerHTML =
`<div class="error-message">Error loading logs: ${error.message}</div>`;
});
}
function displayLogs(data) {
const logsContent = document.getElementById('logsContent');
if (!data.logs || data.logs.length === 0) {
logsContent.innerHTML = '<div class="no-logs">No processing logs found for this file.</div>';
return;
function viewFileDetail(fileId, event) {
if (event && event.target.closest('.action-btn')) {
return; // Don't navigate if clicking action button
}
let html = '<div class="logs-container">';
html += `<h3 style="margin-bottom: 1rem;">File: ${data.file.original_filename}</h3>`;
data.logs.forEach(log => {
const statusClass = log.status.toLowerCase().replace(' ', '_');
const timestamp = new Date(log.timestamp).toLocaleString();
html += `
<div class="log-entry ${statusClass}">
<div class="log-step">${log.step_name} - ${log.status}</div>
${log.message ? `<div class="log-message">${log.message}</div>` : ''}
<div class="log-timestamp">${timestamp}</div>
</div>
`;
});
html += '</div>';
logsContent.innerHTML = html;
if (event) event.stopPropagation();
window.location.href = `/files/${fileId}/detail`;
}
cancelDelete.addEventListener('click', () => {
@@ -323,18 +459,11 @@
deleteModal.style.display = 'none';
});
closeLogsModal.addEventListener('click', () => {
logsModal.style.display = 'none';
});
// Close modal if clicking outside of it
window.addEventListener('click', (event) => {
if (event.target === deleteModal) {
deleteModal.style.display = 'none';
}
if (event.target === logsModal) {
logsModal.style.display = 'none';
}
});
function deleteFile(fileId) {
@@ -357,19 +486,33 @@
});
}
// Initialize TableSorter
$(document).ready(function() {
$("#fileTable").tablesorter({
theme: 'bootstrap',
widthFixed: true,
headerTemplate: '{content} {icon}',
widgets: ['zebra', 'stickyHeaders'],
sortList: [[0, 0]], // Default sort on the first column ascending
headers: {
5: { sorter: false } // Disable sorting on the Actions column
}
});
});
function sortTable(column) {
const urlParams = new URLSearchParams(window.location.search);
const currentSortBy = urlParams.get('sort_by') || 'created_at';
const currentSortOrder = urlParams.get('sort_order') || 'desc';
// Toggle sort order if clicking the same column
let newSortOrder = 'asc';
if (column === currentSortBy) {
newSortOrder = currentSortOrder === 'asc' ? 'desc' : 'asc';
}
urlParams.set('sort_by', column);
urlParams.set('sort_order', newSortOrder);
urlParams.set('page', '1'); // Reset to first page on sort
window.location.search = urlParams.toString();
}
function goToPage(page) {
const urlParams = new URLSearchParams(window.location.search);
urlParams.set('page', page);
window.location.search = urlParams.toString();
}
function clearFilters() {
window.location.href = '/files';
}
</script>
</div>
{% endblock %}