redesign of logger frontend to streamline and unify all logger views

This commit is contained in:
Michael Beck 2025-06-13 12:30:44 +02:00
parent 8f064cda34
commit 4a10052eae
9 changed files with 1045 additions and 233 deletions

View File

@ -2,7 +2,7 @@
import csv
import io
import datetime
from flask import Blueprint, render_template, request, send_file
from flask import Blueprint, render_template, request, send_file, jsonify
from ..db import db
from ..models import ActivityLog, ActivityCategory
@ -11,10 +11,10 @@ bp = Blueprint("logger", __name__, url_prefix="/logs")
@bp.route("/")
def list_logs():
page = request.args.get("page", 1, type=int)
per_page = 50
# Filters
# For the new modern view, we only need to provide initial filter values and categories
# The actual data loading will be handled by JavaScript via the API endpoint
# Get filter parameters for initial state
category = request.args.get("category")
start_date = request.args.get("start_date")
end_date = request.args.get("end_date")
@ -23,31 +23,10 @@ def list_logs():
if search_term == "None":
search_term = None
query = ActivityLog.query
if category:
query = query.filter(ActivityLog.category == category)
if start_date:
start_date_dt = datetime.datetime.strptime(start_date, "%Y-%m-%d")
query = query.filter(ActivityLog.timestamp >= start_date_dt)
if end_date:
end_date_dt = datetime.datetime.strptime(end_date, "%Y-%m-%d") + datetime.timedelta(days=1)
query = query.filter(ActivityLog.timestamp <= end_date_dt)
if search_term:
query = query.filter(db.or_(
ActivityLog.action.contains(search_term),
ActivityLog.description.contains(search_term)
))
pagination = query.order_by(ActivityLog.timestamp.desc()).paginate(page=page, per_page=per_page, error_out=False)
categories = [e.value for e in ActivityCategory]
return render_template(
"logger.html.jinja",
logs=pagination.items,
pagination=pagination,
"logs.html.jinja",
categories=categories,
category=category,
start_date=start_date,
@ -99,8 +78,12 @@ def download_logs():
# Create response
filename = f"logs_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}.csv"
csv_data.seek(0)
output = io.BytesIO(csv_data.getvalue().encode('utf-8'))
output.seek(0)
return send_file(
io.StringIO(csv_data.getvalue()),
output,
mimetype="text/csv",
as_attachment=True,
download_name=filename
@ -109,4 +92,132 @@ def download_logs():
@bp.route("/<int:log_id>/detail")
def log_detail(log_id):
log = ActivityLog.query.get_or_404(log_id)
return render_template("partials/log_detail_modal.html.jinja", log=log)
return render_template("partials/log_detail_modal.html.jinja", log=log)
@bp.route("/api")
def get_logs_api():
"""Unified API endpoint for getting activity logs with filtering and pagination support."""
try:
# Pagination parameters
page = request.args.get('page', 1, type=int)
per_page = request.args.get('per_page', 50, type=int)
# Legacy limit parameter for backward compatibility
limit = request.args.get('limit', type=int)
if limit and not request.args.get('page'):
# Legacy mode: use limit without pagination
query = ActivityLog.query
# Apply filters
categories = request.args.getlist('category')
if categories:
query = query.filter(ActivityLog.category.in_(categories))
status = request.args.get('status')
if status:
query = query.filter(ActivityLog.status == status)
start_date = request.args.get('start_date')
if start_date:
start_date_dt = datetime.datetime.strptime(start_date, "%Y-%m-%d")
query = query.filter(ActivityLog.timestamp >= start_date_dt)
end_date = request.args.get('end_date')
if end_date:
end_date_dt = datetime.datetime.strptime(end_date, "%Y-%m-%d") + datetime.timedelta(days=1)
query = query.filter(ActivityLog.timestamp <= end_date_dt)
search_term = request.args.get('search_term')
if search_term and search_term != "None":
query = query.filter(db.or_(
ActivityLog.action.contains(search_term),
ActivityLog.description.contains(search_term)
))
logs = query.order_by(ActivityLog.timestamp.desc()).limit(limit).all()
return jsonify({
"success": True,
"logs": [{
"id": log.id,
"timestamp": log.timestamp.isoformat(),
"action": log.action,
"status": log.status,
"description": log.description,
"category": log.category,
"paper_id": log.paper_id,
"extra_data": log.extra_data
} for log in logs]
})
# Ensure reasonable per_page limits
per_page = min(per_page, 100) # Cap at 100 items per page
# Build query with filtering
query = ActivityLog.query
# Filter by categories if specified
categories = request.args.getlist('category')
if categories:
query = query.filter(ActivityLog.category.in_(categories))
# Filter by status if specified
status = request.args.get('status')
if status:
query = query.filter(ActivityLog.status == status)
# Date filters
start_date = request.args.get('start_date')
if start_date:
start_date_dt = datetime.datetime.strptime(start_date, "%Y-%m-%d")
query = query.filter(ActivityLog.timestamp >= start_date_dt)
end_date = request.args.get('end_date')
if end_date:
end_date_dt = datetime.datetime.strptime(end_date, "%Y-%m-%d") + datetime.timedelta(days=1)
query = query.filter(ActivityLog.timestamp <= end_date_dt)
# Search term filter
search_term = request.args.get('search_term')
if search_term and search_term != "None":
query = query.filter(db.or_(
ActivityLog.action.contains(search_term),
ActivityLog.description.contains(search_term)
))
# Order by most recent first and paginate
pagination = query.order_by(ActivityLog.timestamp.desc()).paginate(
page=page,
per_page=per_page,
error_out=False
)
return jsonify({
"success": True,
"logs": [{
"id": log.id,
"timestamp": log.timestamp.isoformat(),
"action": log.action,
"status": log.status,
"description": log.description,
"category": log.category,
"paper_id": log.paper_id,
"extra_data": log.extra_data
} for log in pagination.items],
"pagination": {
"page": pagination.page,
"pages": pagination.pages,
"per_page": pagination.per_page,
"total": pagination.total,
"has_next": pagination.has_next,
"has_prev": pagination.has_prev,
"next_num": pagination.next_num if pagination.has_next else None,
"prev_num": pagination.prev_num if pagination.has_prev else None
}
})
except Exception as e:
return jsonify({
"success": False,
"message": f"Error getting logs: {str(e)}"
}), 500

View File

@ -255,78 +255,15 @@ def get_status():
@bp.route("/logs")
def get_logs():
"""Get recent activity logs with pagination support."""
try:
# Pagination parameters
page = request.args.get('page', 1, type=int)
per_page = request.args.get('per_page', 20, type=int)
# Legacy limit parameter for backward compatibility
limit = request.args.get('limit', type=int)
if limit and not request.args.get('page'):
# Legacy mode: use limit without pagination
logs = ActivityLog.query.order_by(ActivityLog.timestamp.desc()).limit(limit).all()
return jsonify({
"success": True,
"logs": [{
"id": log.id,
"timestamp": log.timestamp.isoformat(),
"action": log.action,
"status": log.status,
"description": log.description,
"category": log.category
} for log in logs]
})
# Ensure reasonable per_page limits
per_page = min(per_page, 100) # Cap at 100 items per page
# Build query with optional filtering
query = ActivityLog.query
# Filter by categories if specified
categories = request.args.getlist('category')
if categories:
query = query.filter(ActivityLog.category.in_(categories))
# Filter by status if specified
status = request.args.get('status')
if status:
query = query.filter(ActivityLog.status == status)
# Order by most recent first and paginate
pagination = query.order_by(ActivityLog.timestamp.desc()).paginate(
page=page,
per_page=per_page,
error_out=False
)
return jsonify({
"success": True,
"logs": [{
"id": log.id,
"timestamp": log.timestamp.isoformat(),
"action": log.action,
"status": log.status,
"description": log.description,
"category": log.category
} for log in pagination.items],
"pagination": {
"page": pagination.page,
"pages": pagination.pages,
"per_page": pagination.per_page,
"total": pagination.total,
"has_next": pagination.has_next,
"has_prev": pagination.has_prev,
"next_num": pagination.next_num if pagination.has_next else None,
"prev_num": pagination.prev_num if pagination.has_prev else None
}
})
except Exception as e:
return jsonify({
"success": False,
"message": f"Error getting logs: {str(e)}"
}), 500
# Redirect to the unified logs API endpoint
from flask import redirect, url_for
# Forward all query parameters to the unified endpoint
query_string = request.query_string.decode('utf-8')
if query_string:
return redirect(f"{url_for('logger.get_logs_api')}?{query_string}")
else:
return redirect(url_for('logger.get_logs_api'))
@bp.route("/scrapers")
def get_scrapers():

View File

@ -116,7 +116,7 @@ class ActivityMonitor {
params.append("status", this.statusFilter);
}
const data = await apiRequest(`/scraper/logs?${params.toString()}`);
const data = await apiRequest(`/logs/api?${params.toString()}`);
if (data.success) {
this.renderActivityLog(data.logs);
@ -261,7 +261,7 @@ class ActivityMonitor {
try {
// Use the API endpoint for checking new papers, with limit for efficiency
const data = await apiRequest(
`/api/activity_logs?category=scraper_activity&category=scraper_command&action=scrape_paper&after=${this.lastPaperTimestamp}&limit=5`
`/logs/api?category=scraper_activity&category=scraper_command&action=scrape_paper&after=${this.lastPaperTimestamp}&limit=5`
);
if (data && data.length > 0) {

View File

@ -0,0 +1,405 @@
/**
* Logger Manager - Modern activity log management for the unified logger view
*/
class LoggerManager {
constructor(options = {}) {
this.categories = options.categories || [];
this.initialFilters = options.initialFilters || {};
// Pagination state
this.currentPage = 1;
this.perPage = 50;
this.totalPages = 1;
this.totalEntries = 0;
// Current filter state
this.filters = { ...this.initialFilters };
// DOM elements
this.initElements();
this.initEventListeners();
// Apply initial filters and load data
this.applyInitialFilters();
this.loadLogs();
}
initElements() {
// Form elements
this.filtersForm = document.getElementById("logFiltersForm");
this.categorySelect = document.getElementById("category");
this.statusSelect = document.getElementById("status");
this.startDateInput = document.getElementById("start_date");
this.endDateInput = document.getElementById("end_date");
this.searchTermInput = document.getElementById("search_term");
this.clearFiltersBtn = document.getElementById("clearFilters");
this.downloadLogsBtn = document.getElementById("downloadLogs");
this.refreshLogsBtn = document.getElementById("refreshLogs");
// Logs display elements
this.logsTableBody = document.getElementById("logsTableBody");
this.pageSizeSelect = document.getElementById("logPageSize");
// Pagination elements
this.paginationContainer = document.getElementById("logsPagination");
this.paginationInfo = document.getElementById("logsPaginationInfo");
this.prevPageBtn = document.getElementById("logsPrevPage");
this.nextPageBtn = document.getElementById("logsNextPage");
this.currentPageSpan = document.getElementById("logsCurrentPage");
// Modal
this.logModal = new ModalHandler("logDetailModal", "log-detail-content");
}
initEventListeners() {
// Filter form submission
if (this.filtersForm) {
this.filtersForm.addEventListener("submit", (e) => {
e.preventDefault();
this.applyFilters();
});
}
// Individual filter changes for immediate application
[
this.categorySelect,
this.statusSelect,
this.startDateInput,
this.endDateInput,
].forEach((element) => {
if (element) {
element.addEventListener("change", () => {
this.applyFilters();
});
}
});
// Search term with debounce
if (this.searchTermInput) {
let searchTimeout;
this.searchTermInput.addEventListener("input", () => {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
this.applyFilters();
}, 500);
});
}
// Clear filters
if (this.clearFiltersBtn) {
this.clearFiltersBtn.addEventListener("click", () => {
this.clearAllFilters();
});
}
// Download logs
if (this.downloadLogsBtn) {
this.downloadLogsBtn.addEventListener("click", (e) => {
e.preventDefault();
this.downloadLogs();
});
}
// Refresh logs
if (this.refreshLogsBtn) {
this.refreshLogsBtn.addEventListener("click", () => {
this.loadLogs();
});
}
// Page size change
if (this.pageSizeSelect) {
this.pageSizeSelect.addEventListener("change", () => {
this.perPage = parseInt(this.pageSizeSelect.value);
this.currentPage = 1; // Reset to first page
this.loadLogs();
});
}
// Pagination buttons
if (this.prevPageBtn) {
this.prevPageBtn.addEventListener("click", (e) => {
e.preventDefault();
if (this.currentPage > 1) {
this.currentPage--;
this.loadLogs();
}
});
}
if (this.nextPageBtn) {
this.nextPageBtn.addEventListener("click", (e) => {
e.preventDefault();
if (this.currentPage < this.totalPages) {
this.currentPage++;
this.loadLogs();
}
});
}
}
applyInitialFilters() {
// Set form values from initial filters
if (this.categorySelect && this.initialFilters.category) {
this.categorySelect.value = this.initialFilters.category;
}
if (this.startDateInput && this.initialFilters.start_date) {
this.startDateInput.value = this.initialFilters.start_date;
}
if (this.endDateInput && this.initialFilters.end_date) {
this.endDateInput.value = this.initialFilters.end_date;
}
if (this.searchTermInput && this.initialFilters.search_term) {
this.searchTermInput.value = this.initialFilters.search_term;
}
}
applyFilters() {
// Collect current filter values
this.filters = {
category: this.categorySelect?.value || "",
status: this.statusSelect?.value || "",
start_date: this.startDateInput?.value || "",
end_date: this.endDateInput?.value || "",
search_term: this.searchTermInput?.value || "",
};
// Reset to first page when filters change
this.currentPage = 1;
// Load logs with new filters
this.loadLogs();
// Update URL to reflect current filters (for bookmarking/sharing)
this.updateUrl();
}
clearAllFilters() {
// Clear all form fields
if (this.categorySelect) this.categorySelect.value = "";
if (this.statusSelect) this.statusSelect.value = "";
if (this.startDateInput) this.startDateInput.value = "";
if (this.endDateInput) this.endDateInput.value = "";
if (this.searchTermInput) this.searchTermInput.value = "";
// Apply empty filters
this.applyFilters();
}
async loadLogs() {
if (!this.logsTableBody) return;
try {
// Show loading state
this.logsTableBody.innerHTML =
'<tr><td colspan="5" class="text-center">Loading logs...</td></tr>';
// Build query parameters
const params = new URLSearchParams({
page: this.currentPage,
per_page: this.perPage,
});
// Add filters to query
Object.entries(this.filters).forEach(([key, value]) => {
if (value) {
params.append(key, value);
}
});
// Fetch logs from unified API
const data = await apiRequest(`/logs/api?${params.toString()}`);
if (data.success) {
this.renderLogs(data.logs);
this.updatePagination(data.pagination);
console.log("Logs loaded successfully");
} else {
throw new Error(data.message || "Failed to load logs");
}
} catch (error) {
console.error("Failed to load logs:", error);
this.logsTableBody.innerHTML =
'<tr><td colspan="5" class="text-center text-danger">Error loading logs. Please try again.</td></tr>';
this.hidePagination();
}
}
renderLogs(logs) {
if (!this.logsTableBody) return;
this.logsTableBody.innerHTML = "";
if (!logs || logs.length === 0) {
this.logsTableBody.innerHTML =
'<tr><td colspan="5" class="text-center">No logs found matching the current filters.</td></tr>';
return;
}
logs.forEach((log) => {
const row = document.createElement("tr");
row.className = "log-item";
row.setAttribute("data-log-id", log.id);
// Format timestamp
const timeStr = formatTimestamp(log.timestamp);
// Create status badge
const statusBadge = createStatusBadge(log.status);
// Create category badge
const categoryBadge = this.createCategoryBadge(log.category);
row.innerHTML = `
<td>${timeStr}</td>
<td>${categoryBadge}</td>
<td>${log.action}</td>
<td>${statusBadge}</td>
<td>${log.description || ""}</td>
`;
// Add click handler for details modal
row.addEventListener("click", () => {
const url = `/logs/${log.id}/detail`;
this.logModal.loadAndShow(url, "Error loading log details.");
});
this.logsTableBody.appendChild(row);
});
}
createCategoryBadge(category) {
const categoryColors = {
gui_interaction: "bg-primary",
config_change: "bg-warning",
scraper_command: "bg-info",
scraper_activity: "bg-success",
system: "bg-danger",
data_import: "bg-secondary",
};
const colorClass = categoryColors[category] || "bg-secondary";
const displayName = category
.replace(/_/g, " ")
.replace(/\b\w/g, (l) => l.toUpperCase());
return `<span class="badge ${colorClass}">${displayName}</span>`;
}
updatePagination(pagination) {
if (!pagination || !this.paginationContainer) return;
this.currentPage = pagination.page;
this.totalPages = pagination.pages;
this.totalEntries = pagination.total;
// Show pagination container
this.paginationContainer.classList.remove("d-none");
// Update pagination info
const startEntry = (pagination.page - 1) * pagination.per_page + 1;
const endEntry = Math.min(
pagination.page * pagination.per_page,
pagination.total
);
if (this.paginationInfo) {
this.paginationInfo.textContent = `Showing ${startEntry} - ${endEntry} of ${pagination.total} entries`;
}
// Update current page display
if (this.currentPageSpan) {
this.currentPageSpan.innerHTML = `<span class="page-link">${pagination.page} of ${pagination.pages}</span>`;
}
// Update previous button
if (this.prevPageBtn) {
if (pagination.has_prev) {
this.prevPageBtn.classList.remove("disabled");
this.prevPageBtn.querySelector("a").removeAttribute("tabindex");
this.prevPageBtn
.querySelector("a")
.setAttribute("aria-disabled", "false");
} else {
this.prevPageBtn.classList.add("disabled");
this.prevPageBtn.querySelector("a").setAttribute("tabindex", "-1");
this.prevPageBtn
.querySelector("a")
.setAttribute("aria-disabled", "true");
}
}
// Update next button
if (this.nextPageBtn) {
if (pagination.has_next) {
this.nextPageBtn.classList.remove("disabled");
this.nextPageBtn.querySelector("a").removeAttribute("tabindex");
this.nextPageBtn
.querySelector("a")
.setAttribute("aria-disabled", "false");
} else {
this.nextPageBtn.classList.add("disabled");
this.nextPageBtn.querySelector("a").setAttribute("tabindex", "-1");
this.nextPageBtn
.querySelector("a")
.setAttribute("aria-disabled", "true");
}
}
}
hidePagination() {
if (this.paginationContainer) {
this.paginationContainer.classList.add("d-none");
}
}
updateUrl() {
// Update URL with current filters for bookmarking
const params = new URLSearchParams();
Object.entries(this.filters).forEach(([key, value]) => {
if (value) {
params.append(key, value);
}
});
const newUrl = `${window.location.pathname}${
params.toString() ? "?" + params.toString() : ""
}`;
window.history.replaceState({}, "", newUrl);
}
downloadLogs() {
// Build download URL with current filters
const params = new URLSearchParams();
Object.entries(this.filters).forEach(([key, value]) => {
if (value) {
params.append(key, value);
}
});
const downloadUrl = `/logs/download${
params.toString() ? "?" + params.toString() : ""
}`;
window.location.href = downloadUrl;
}
refresh() {
this.loadLogs();
}
/**
* Set modal handler for log details
* @param {ModalHandler} modalHandler - Modal handler instance
*/
setModalHandler(modalHandler) {
this.logModal = modalHandler;
}
}
// Export for use in other modules
if (typeof window !== "undefined") {
window.LoggerManager = LoggerManager;
}

View File

@ -10,6 +10,9 @@ class ModalHandler {
if (this.modalElement && typeof bootstrap !== "undefined") {
this.modal = new bootstrap.Modal(this.modalElement);
// Set up global event delegation for modal close buttons
this.setupGlobalCloseHandlers();
}
}
@ -28,6 +31,13 @@ class ModalHandler {
const response = await fetch(url);
const html = await response.text();
this.contentElement.innerHTML = html;
// Set up close button handlers after content is loaded
this.setupCloseHandlers();
// Format any JSON content in the modal
this.formatJsonContent();
this.modal.show();
} catch (error) {
console.error("Error loading modal content:", error);
@ -63,9 +73,143 @@ class ModalHandler {
if (!this.modal || !this.contentElement) return;
this.contentElement.innerHTML = content;
// Set up close button handlers after content is loaded
this.setupCloseHandlers();
this.modal.show();
}
/**
* Set up global event delegation for modal close buttons
*/
setupGlobalCloseHandlers() {
// Use event delegation to handle dynamically loaded close buttons
this.modalElement.addEventListener("click", (e) => {
if (
e.target.matches('[data-bs-dismiss="modal"]') ||
e.target.closest('[data-bs-dismiss="modal"]') ||
e.target.matches(".btn-close") ||
e.target.closest(".btn-close")
) {
e.preventDefault();
this.hide();
}
});
// Handle ESC key press
document.addEventListener("keydown", (e) => {
if (
e.key === "Escape" &&
this.modal &&
this.modalElement.classList.contains("show")
) {
this.hide();
}
});
}
/**
* Set up close button event handlers for dynamically loaded content
*/
setupCloseHandlers() {
// This method is now mostly redundant due to global event delegation
// but we'll keep it for backward compatibility
// Handle close buttons with data-bs-dismiss="modal"
const closeButtons = this.contentElement.querySelectorAll(
'[data-bs-dismiss="modal"]'
);
closeButtons.forEach((button) => {
button.addEventListener("click", (e) => {
e.preventDefault();
this.hide();
});
});
// Handle close buttons with .btn-close class
const closeButtonsClass =
this.contentElement.querySelectorAll(".btn-close");
closeButtonsClass.forEach((button) => {
button.addEventListener("click", (e) => {
e.preventDefault();
this.hide();
});
});
// Also handle ESC key press
document.addEventListener("keydown", (e) => {
if (
e.key === "Escape" &&
this.modal &&
this.modalElement.classList.contains("show")
) {
this.hide();
}
});
}
/**
* Format JSON content in the modal after it's loaded
*/
formatJsonContent() {
// Format JSON in extra data if present
const extraDataElement = this.contentElement.querySelector(
"#extra-data-content"
);
if (extraDataElement && extraDataElement.textContent.trim()) {
try {
const jsonData = JSON.parse(extraDataElement.textContent);
// Pretty-format the JSON with proper indentation
const formattedJson = JSON.stringify(jsonData, null, 2);
extraDataElement.textContent = formattedJson;
// Add syntax highlighting classes if the JSON is complex
if (typeof jsonData === "object" && jsonData !== null) {
extraDataElement.parentElement.classList.add("json-formatted");
}
} catch (e) {
// If it's not valid JSON, leave it as is but still format if it looks like JSON
const text = extraDataElement.textContent.trim();
if (text.startsWith("{") || text.startsWith("[")) {
// Try to fix common JSON issues and reformat
try {
const fixedJson = text
.replace(/'/g, '"')
.replace(/None/g, "null")
.replace(/True/g, "true")
.replace(/False/g, "false");
const parsed = JSON.parse(fixedJson);
extraDataElement.textContent = JSON.stringify(parsed, null, 2);
} catch (fixError) {
// If still can't parse, just leave as is
console.debug("Extra data is not valid JSON:", e);
}
}
}
}
// Also format old_value and new_value if they contain JSON
const preElements = this.contentElement.querySelectorAll("pre code");
preElements.forEach(function (codeElement) {
if (codeElement && codeElement.textContent.trim()) {
const text = codeElement.textContent.trim();
if (
(text.startsWith("{") && text.endsWith("}")) ||
(text.startsWith("[") && text.endsWith("]"))
) {
try {
const jsonData = JSON.parse(text);
codeElement.textContent = JSON.stringify(jsonData, null, 2);
} catch (e) {
// Not JSON, leave as is
}
}
}
});
}
/**
* Hide the modal
*/

View File

@ -7,3 +7,34 @@
.progress-bar {
width: 0%;
}
/* JSON formatting styles */
.json-formatted {
background-color: #f8f9fa;
border: 1px solid #dee2e6;
border-radius: 0.375rem;
font-family: "Monaco", "Menlo", "Ubuntu Mono", monospace;
font-size: 0.875rem;
line-height: 1.4;
}
.json-formatted code {
color: #212529;
background-color: transparent;
padding: 0;
}
/* Improve readability of JSON in modals */
#extra-data-content {
white-space: pre-wrap;
word-break: break-word;
font-family: "Monaco", "Menlo", "Ubuntu Mono", monospace;
font-size: 0.875rem;
line-height: 1.4;
}
/* Style for old/new value code blocks */
pre code {
white-space: pre-wrap;
word-break: break-word;
}

View File

@ -1,120 +0,0 @@
{% extends "base.html.jinja" %}
{% block title %}Logs{% endblock title %}
{% block content %}
<h1>Activity Logs</h1>
<!-- Include flash messages template -->
{% include "partials/flash_messages.html.jinja" %}
<form method="get" class="mb-3">
<div class="row g-2">
<div class="col-md-3">
<label for="category" class="form-label">Category:</label>
<select name="category" id="category" class="form-select">
<option value="">All</option>
{% for cat in categories %}
<option value="{{ cat }}" {% if category==cat %}selected{% endif %}>{{ cat }}</option>
{% endfor %}
</select>
</div>
<div class="col-md-3">
<label for="start_date" class="form-label">Start Date:</label>
<input type="date" name="start_date" id="start_date" value="{{ start_date }}" class="form-control">
</div>
<div class="col-md-3">
<label for="end_date" class="form-label">End Date:</label>
<input type="date" name="end_date" id="end_date" value="{{ end_date }}" class="form-control">
</div>
<div class="col-md-3">
<label for="search_term" class="form-label">Search:</label>
<input type="text" name="search_term" id="search_term" value="{{ search_term }}" class="form-control">
</div>
</div>
<div class="mt-3">
<button type="submit" class="btn btn-primary">Filter</button>
<a href="{{ url_for('logger.download_logs', category=category, start_date=start_date, end_date=end_date, search_term=search_term) }}"
class="btn btn-secondary">Download CSV</a>
</div>
</form>
<ul class="list-group">
{% for log in logs %}
<li class="list-group-item log-item" data-log-id="{{ log.id }}">
<div class="d-flex justify-content-between align-items-center">
<div class="ms-2 me-auto">
<div class="fw-bold">{{ log.timestamp }}</div>
{{ log.action }} - {{ log.description }}
</div>
<span class="badge bg-primary rounded-pill">{{ log.category }}</span>
</div>
</li>
{% endfor %}
</ul>
{% if pagination %}
<nav aria-label="Page navigation" class="mt-4">
<ul class="pagination justify-content-center">
{% if pagination.has_prev %}
<li class="page-item">
<a class="page-link"
href="{{ url_for('logger.list_logs', page=pagination.prev_num, category=category, start_date=start_date, end_date=end_date, search_term=search_term) }}">Previous</a>
</li>
{% else %}
<li class="page-item disabled">
<span class="page-link">Previous</span>
</li>
{% endif %}
<li class="page-item disabled">
<span class="page-link">Page {{ pagination.page }} of {{ pagination.pages }}</span>
</li>
{% if pagination.has_next %}
<li class="page-item">
<a class="page-link"
href="{{ url_for('logger.list_logs', page=pagination.next_num, category=category, start_date=start_date, end_date=end_date, search_term=search_term) }}">Next</a>
</li>
{% else %}
<li class="page-item disabled">
<span class="page-link">Next</span>
</li>
{% endif %}
</ul>
</nav>
{% endif %}
<!-- Modal for log details -->
<div class="modal fade" id="logDetailModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-scrollable">
<div class="modal-content" id="log-detail-content">
<!-- Log details will be loaded here via AJAX -->
</div>
</div>
</div>
{% endblock content %}
{% block scripts %}
{{ super() }}
<script src="{{ url_for('static', filename='js/modal-handler.js') }}"></script>
<script>
document.addEventListener('DOMContentLoaded', function () {
// Use the reusable ModalHandler for log details
const logModal = new ModalHandler('logDetailModal', 'log-detail-content');
// Set up click handlers for log items with custom URL construction
document.querySelectorAll('.log-item').forEach(item => {
item.addEventListener('click', function () {
const logId = this.getAttribute('data-log-id');
const url = `/logs/${logId}/detail`;
logModal.loadAndShow(url, 'Error loading log details.');
});
});
});
</script>
{% endblock scripts %}

View File

@ -0,0 +1,240 @@
{% extends "base.html.jinja" %}
{% block title %}Activity Logs{% endblock title %}
{% block styles %}
{{ super() }}
<style>
.logs-container {
background: white;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.filter-panel {
background: #f8f9fa;
border-bottom: 1px solid #dee2e6;
padding: 1rem;
}
.log-entry {
cursor: pointer;
transition: background-color 0.2s ease;
}
.log-entry:hover {
background-color: #f8f9fa;
}
.category-badge {
font-size: 0.75rem;
padding: 0.25rem 0.5rem;
}
.activity-controls {
width: auto;
display: inline-block;
}
.logs-table th {
background-color: #f8f9fa;
font-weight: 600;
}
.pagination-info {
font-size: 0.875rem;
color: #6c757d;
}
.search-results-container {
max-height: 600px;
overflow-y: auto;
}
/* JSON formatting styles */
.json-formatted {
background-color: #f8f9fa;
border: 1px solid #e9ecef;
border-radius: 0.375rem;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-size: 0.875rem;
line-height: 1.4;
}
.json-formatted code {
color: #495057;
background: transparent;
}
</style>
{% endblock styles %}
{% block content %}
<div class="container-fluid mt-4">
<h1><i class="fas fa-list-alt"></i> Activity Logs</h1>
<!-- Include standardized flash messages -->
{% include "partials/flash_messages.html.jinja" %}
<div class="logs-container">
<!-- Filter Panel -->
<div class="filter-panel">
<form id="filterForm" class="row g-3">
<div class="col-md-2">
<label for="categoryFilter" class="form-label">Category:</label>
<select id="categoryFilter" class="form-select form-select-sm">
<option value="">All Categories</option>
{% for cat in categories %}
<option value="{{ cat }}" {% if category==cat %}selected{% endif %}>{{ cat.replace('_', '
').title() }}</option>
{% endfor %}
</select>
</div>
<div class="col-md-2">
<label for="statusFilter" class="form-label">Status:</label>
<select id="statusFilter" class="form-select form-select-sm">
<option value="">All Statuses</option>
<option value="success">Success</option>
<option value="error">Error</option>
<option value="warning">Warning</option>
<option value="info">Info</option>
<option value="pending">Pending</option>
</select>
</div>
<div class="col-md-2">
<label for="startDate" class="form-label">Start Date:</label>
<input type="date" id="startDate" class="form-control form-control-sm"
value="{{ start_date or '' }}">
</div>
<div class="col-md-2">
<label for="endDate" class="form-label">End Date:</label>
<input type="date" id="endDate" class="form-control form-control-sm" value="{{ end_date or '' }}">
</div>
<div class="col-md-3">
<label for="searchTerm" class="form-label">Search:</label>
<input type="text" id="searchTerm" class="form-control form-control-sm"
placeholder="Search action or description..." value="{{ search_term or '' }}">
</div>
<div class="col-md-1">
<label class="form-label">&nbsp;</label>
<div class="d-flex gap-2">
<button type="button" id="applyFilters" class="btn btn-primary btn-sm">
<i class="fas fa-filter"></i> Filter
</button>
<button type="button" id="clearFilters" class="btn btn-outline-secondary btn-sm">
<i class="fas fa-times"></i>
</button>
</div>
</div>
</form>
</div>
<!-- Controls Panel -->
<div class="d-flex justify-content-between align-items-center p-3 border-bottom">
<div class="d-flex align-items-center gap-3">
<div class="form-group mb-0">
<label for="pageSize" class="form-label mb-0 me-2">Show:</label>
<select id="pageSize" class="form-select form-select-sm activity-controls">
<option value="20">20</option>
<option value="50" selected>50</option>
<option value="100">100</option>
</select>
</div>
<span id="paginationInfo" class="pagination-info">Loading...</span>
</div>
<div class="d-flex gap-2">
<button type="button" id="refreshLogs" class="btn btn-outline-primary btn-sm">
<i class="fas fa-sync-alt"></i> Refresh
</button>
<button type="button" id="downloadLogs" class="btn btn-outline-success btn-sm">
<i class="fas fa-download"></i> Download CSV
</button>
</div>
</div>
<!-- Logs Table -->
<div class="search-results-container">
<table class="table table-hover logs-table mb-0">
<thead class="sticky-top">
<tr>
<th style="width: 150px;">Timestamp</th>
<th style="width: 120px;">Category</th>
<th style="width: 180px;">Action</th>
<th style="width: 100px;">Status</th>
<th>Description</th>
<th style="width: 60px;">Details</th>
</tr>
</thead>
<tbody id="logsTableBody">
<tr>
<td colspan="6" class="text-center py-4">
<i class="fas fa-spinner fa-spin"></i> Loading logs...
</td>
</tr>
</tbody>
</table>
</div>
<!-- Pagination Controls -->
<nav id="logsPagination" aria-label="Logs pagination" class="p-3 border-top d-none">
<div class="d-flex justify-content-between align-items-center">
<div class="pagination-info">
<span id="paginationDetails">Showing 0 - 0 of 0 entries</span>
</div>
<ul class="pagination pagination-sm mb-0">
<li class="page-item" id="prevPage">
<a class="page-link" href="#" aria-label="Previous">
<span aria-hidden="true">«</span>
</a>
</li>
<li class="page-item active" id="currentPageItem">
<span class="page-link" id="currentPageSpan">1</span>
</li>
<li class="page-item" id="nextPage">
<a class="page-link" href="#" aria-label="Next">
<span aria-hidden="true">»</span>
</a>
</li>
</ul>
</div>
</nav>
</div>
</div>
<!-- Modal for log details -->
<div class="modal fade" id="logDetailModal" tabindex="-1" aria-hidden="true" data-bs-backdrop="true"
data-bs-keyboard="true">
<div class="modal-dialog modal-lg modal-dialog-scrollable">
<div class="modal-content" id="log-detail-content">
<!-- Log details will be loaded here via AJAX -->
</div>
</div>
</div>
{% endblock content %}
{% block scripts %}
{{ super() }}
<script src="{{ url_for('static', filename='js/modal-handler.js') }}"></script>
<script src="{{ url_for('static', filename='js/logger-manager.js') }}"></script>
<script>
document.addEventListener('DOMContentLoaded', function () {
// Initialize the logger manager
window.loggerManager = new LoggerManager({
initialCategory: "{{ category or '' }}",
initialStartDate: "{{ start_date or '' }}",
initialEndDate: "{{ end_date or '' }}",
initialSearchTerm: "{{ search_term or '' }}"
});
// Set up modal handler for log details
const logModal = new ModalHandler('logDetailModal', 'log-detail-content');
window.loggerManager.setModalHandler(logModal);
});
</script>
{% endblock scripts %}

View File

@ -1,18 +1,82 @@
<div class="modal-header">
<h5 class="modal-title">Log Details</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
<h5 class="modal-title"><i class="fas fa-info-circle"></i> Log Details</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<p><strong>Timestamp:</strong> {{ log.timestamp }}</p>
<p><strong>Category:</strong> {{ log.category }}</p>
<p><strong>Action:</strong> {{ log.action }}</p>
<p><strong>Description:</strong> {{ log.description }}</p>
<div class="row">
<div class="col-md-6">
<p><strong>Timestamp:</strong> <span class="text-muted">{{ log.timestamp }}</span></p>
<p><strong>Category:</strong>
<span class="badge bg-secondary">{{ log.category.replace('_', ' ').title() }}</span>
</p>
<p><strong>Action:</strong> <code>{{ log.action }}</code></p>
{% if log.status %}
<p><strong>Status:</strong>
{% if log.status == 'success' %}
<span class="badge bg-success">{{ log.status.title() }}</span>
{% elif log.status == 'error' %}
<span class="badge bg-danger">{{ log.status.title() }}</span>
{% elif log.status == 'warning' %}
<span class="badge bg-warning">{{ log.status.title() }}</span>
{% else %}
<span class="badge bg-info">{{ log.status.title() }}</span>
{% endif %}
</p>
{% endif %}
</div>
<div class="col-md-6">
{% if log.paper_id %}
<p><strong>Paper ID:</strong> <a href="/papers/{{ log.paper_id }}" target="_blank">{{ log.paper_id }}</a></p>
{% endif %}
{% if log.user_id %}
<p><strong>User ID:</strong> {{ log.user_id }}</p>
{% endif %}
{% if log.config_key %}
<p><strong>Config Key:</strong> <code>{{ log.config_key }}</code></p>
{% endif %}
{% if log.source_ip %}
<p><strong>Source IP:</strong> {{ log.source_ip }}</p>
{% endif %}
</div>
</div>
{% if log.description %}
<div class="mt-3">
<p><strong>Description:</strong></p>
<div class="alert alert-light">{{ log.description }}</div>
</div>
{% endif %}
{% if log.old_value or log.new_value %}
<div class="mt-3">
<p><strong>Configuration Changes:</strong></p>
<div class="row">
{% if log.old_value %}
<div class="col-md-6">
<label class="form-label"><strong>Old Value:</strong></label>
<pre class="bg-light p-2"><code>{{ log.old_value }}</code></pre>
</div>
{% endif %}
{% if log.new_value %}
<div class="col-md-6">
<label class="form-label"><strong>New Value:</strong></label>
<pre class="bg-light p-2"><code>{{ log.new_value }}</code></pre>
</div>
{% endif %}
</div>
</div>
{% endif %}
{% if log.extra_data %}
<p><strong>Extra Data:</strong>
<pre><code>{{ log.extra_data }}</code></pre>
</p>
<div class="mt-3">
<p><strong>Additional Data:</strong></p>
<pre class="bg-light p-3"
style="max-height: 300px; overflow-y: auto;"><code id="extra-data-content">{{ log.extra_data }}</code></pre>
</div>
{% endif %}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
<i class="fas fa-times"></i> Close
</button>
</div>