Files
decypharr/pkg/web/templates/repair.html
Mukhtar Akere 07f1d0f28d - Fix symlinks % bug
- A cleaner settings page
- More bug fixes
2025-04-25 12:36:12 +01:00

575 lines
24 KiB
HTML

{{ define "repair" }}
<div class="container mt-4">
<div class="card">
<div class="card-header">
<h4 class="mb-0"><i class="bi bi-tools me-2"></i>Repair Media</h4>
</div>
<div class="card-body">
<form id="repairForm">
<div class="mb-3">
<label for="arrSelect" class="form-label">Select Arr Instance</label>
<select class="form-select" id="arrSelect">
<option value="">Select an Arr instance</option>
</select>
</div>
<div class="mb-3">
<label for="mediaIds" class="form-label">Media IDs</label>
<input type="text" class="form-control" id="mediaIds"
placeholder="Enter IDs (comma-separated)">
<small class="text-muted">Enter TV DB ids for Sonarr, TM DB ids for Radarr</small>
</div>
<div class="mb-2">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="isAsync" checked>
<label class="form-check-label" for="isAsync">
Run in background
</label>
</div>
</div>
<div class="mb-3">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="autoProcess">
<label class="form-check-label" for="autoProcess">
Auto Process(this will delete and re-search broken media)
</label>
</div>
</div>
<button type="submit" class="btn btn-primary" id="submitRepair">
<i class="bi bi-wrench me-2"></i>Start Repair
</button>
</form>
</div>
</div>
<!-- Jobs Table Section -->
<div class="card mt-4">
<div class="card-header d-flex justify-content-between align-items-center">
<h4 class="mb-0"><i class="bi bi-list-task me-2"></i>Repair Jobs</h4>
<div>
<button id="deleteSelectedJobs" class="btn btn-sm btn-danger me-2" disabled>
<i class="bi bi-trash me-1"></i>Delete Selected
</button>
<button id="refreshJobs" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-arrow-clockwise me-1"></i>Refresh
</button>
</div>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-striped table-hover" id="jobsTable">
<thead>
<tr>
<th>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="selectAllJobs">
</div>
</th>
<th>ID</th>
<th>Arr Instances</th>
<th>Started</th>
<th>Status</th>
<th>Broken Items</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="jobsTableBody">
<!-- Jobs will be loaded here -->
</tbody>
</table>
</div>
<!-- Pagination -->
<nav aria-label="Jobs pagination" class="mt-3">
<ul class="pagination justify-content-center" id="jobsPagination">
<!-- Pagination will be generated here -->
</ul>
</nav>
<div id="noJobsMessage" class="text-center py-3 d-none">
<p class="text-muted">No repair jobs found</p>
</div>
</div>
</div>
<!-- Job Details Modal -->
<div class="modal fade" id="jobDetailsModal" tabindex="-1" aria-labelledby="jobDetailsModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="jobDetailsModalLabel">Job Details</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="row mb-3">
<div class="col-md-6">
<p><strong>Job ID:</strong> <span id="modalJobId"></span></p>
<p><strong>Status:</strong> <span id="modalJobStatus"></span></p>
<p><strong>Started:</strong> <span id="modalJobStarted"></span></p>
<p><strong>Completed:</strong> <span id="modalJobCompleted"></span></p>
</div>
<div class="col-md-6">
<p><strong>Arrs:</strong> <span id="modalJobArrs"></span></p>
<p><strong>Media IDs:</strong> <span id="modalJobMediaIds"></span></p>
<p><strong>Auto Process:</strong> <span id="modalJobAutoProcess"></span></p>
</div>
</div>
<div id="errorContainer" class="alert alert-danger mb-3 d-none">
<strong>Error:</strong> <span id="modalJobError"></span>
</div>
<h6>Broken Items</h6>
<div class="table-responsive">
<table class="table table-sm table-striped">
<thead>
<tr>
<th>Arr</th>
<th>Path</th>
</tr>
</thead>
<tbody id="brokenItemsTableBody">
<!-- Broken items will be loaded here -->
</tbody>
</table>
</div>
<div id="noBrokenItemsMessage" class="text-center py-2 d-none">
<p class="text-muted">No broken items found</p>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
<button type="button" class="btn btn-primary" id="processJobBtn">Process Items</button>
</div>
</div>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', () => {
// Load Arr instances
fetcher('/api/arrs')
.then(response => response.json())
.then(arrs => {
const select = document.getElementById('arrSelect');
arrs.forEach(arr => {
const option = document.createElement('option');
option.value = arr.name;
option.textContent = arr.name;
select.appendChild(option);
});
});
// Handle form submission
document.getElementById('repairForm').addEventListener('submit', async (e) => {
e.preventDefault();
const submitBtn = document.getElementById('submitRepair');
const originalText = submitBtn.innerHTML;
submitBtn.disabled = true;
submitBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Repairing...';
let mediaIds = document.getElementById('mediaIds').value.split(',').map(id => id.trim());
let arr = document.getElementById('arrSelect').value;
try {
const response = await fetcher('/api/repair', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
arr: arr,
mediaIds: mediaIds,
async: document.getElementById('isAsync').checked,
autoProcess: document.getElementById('autoProcess').checked,
})
});
if (!response.ok) throw new Error(await response.text());
createToast('Repair process initiated successfully!');
loadJobs(1); // Refresh jobs after submission
} catch (error) {
createToast(`Error starting repair: ${error.message}`, 'error');
} finally {
submitBtn.disabled = false;
submitBtn.innerHTML = originalText;
}
});
// Jobs table pagination variables
let currentPage = 1;
const itemsPerPage = 10;
let allJobs = [];
// Load jobs function
async function loadJobs(page) {
try {
const response = await fetcher('/api/repair/jobs');
if (!response.ok) throw new Error('Failed to fetcher jobs');
allJobs = await response.json();
renderJobsTable(page);
} catch (error) {
console.error('Error loading jobs:', error);
createToast(`Error loading jobs: ${error.message}`, 'error');
}
}
// Render jobs table with pagination
function renderJobsTable(page) {
const tableBody = document.getElementById('jobsTableBody');
const paginationElement = document.getElementById('jobsPagination');
const noJobsMessage = document.getElementById('noJobsMessage');
const deleteSelectedBtn = document.getElementById('deleteSelectedJobs');
// Clear previous content
tableBody.innerHTML = '';
paginationElement.innerHTML = '';
document.getElementById('selectAllJobs').checked = false;
deleteSelectedBtn.disabled = true;
if (allJobs.length === 0) {
noJobsMessage.classList.remove('d-none');
return;
}
noJobsMessage.classList.add('d-none');
// Calculate pagination
const totalPages = Math.ceil(allJobs.length / itemsPerPage);
const startIndex = (page - 1) * itemsPerPage;
const endIndex = Math.min(startIndex + itemsPerPage, allJobs.length);
// Display jobs for current page
for (let i = startIndex; i < endIndex; i++) {
const job = allJobs[i];
const row = document.createElement('tr');
// Format date
const startedDate = new Date(job.created_at);
const formattedDate = startedDate.toLocaleString();
// Determine status
let status = 'In Progress';
let statusClass = 'text-primary';
let canDelete = job.status !== "started";
let totalItems = job.broken_items ? Object.values(job.broken_items).reduce((sum, arr) => sum + arr.length, 0) : 0;
if (job.status === 'failed') {
status = 'Failed';
statusClass = 'text-danger';
} else if (job.status === 'completed') {
status = 'Completed';
statusClass = 'text-success';
} else if (job.status === 'pending') {
status = 'Pending';
statusClass = 'text-warning';
} else if (job.status === "processing") {
status = 'Processing';
statusClass = 'text-info';
}
row.innerHTML = `
<td>
<div class="form-check">
<input class="form-check-input job-checkbox" type="checkbox" value="${job.id}"
${canDelete ? '' : 'disabled'} data-can-delete="${canDelete}">
</div>
</td>
<td><a href="#" class="text-link view-job" data-id="${job.id}"><small>${job.id.substring(0, 8)}</small></a></td>
<td>${job.arrs.join(', ')}</td>
<td><small>${formattedDate}</small></td>
<td><span class="${statusClass}">${status}</span></td>
<td>${totalItems}</td>
<td>
${job.status === "pending" ?
`<button class="btn btn-sm btn-primary process-job" data-id="${job.id}">
<i class="bi bi-play-fill"></i> Process
</button>` :
`<button class="btn btn-sm btn-primary" disabled>
<i class="bi bi-eye"></i> Process
</button>`
}
${canDelete ?
`<button class="btn btn-sm btn-danger delete-job" data-id="${job.id}">
<i class="bi bi-trash"></i>
</button>` :
`<button class="btn btn-sm btn-danger" disabled>
<i class="bi bi-trash"></i>
</button>`
}
</td>
`;
tableBody.appendChild(row);
}
// Create pagination
if (totalPages > 1) {
// Previous button
const prevLi = document.createElement('li');
prevLi.className = `page-item ${page === 1 ? 'disabled' : ''}`;
prevLi.innerHTML = `<a class="page-link" href="#" aria-label="Previous" ${page !== 1 ? `data-page="${page - 1}"` : ''}>
<span aria-hidden="true">&laquo;</span>
</a>`;
paginationElement.appendChild(prevLi);
// Page numbers
for (let i = 1; i <= totalPages; i++) {
const pageLi = document.createElement('li');
pageLi.className = `page-item ${i === page ? 'active' : ''}`;
pageLi.innerHTML = `<a class="page-link" href="#" data-page="${i}">${i}</a>`;
paginationElement.appendChild(pageLi);
}
// Next button
const nextLi = document.createElement('li');
nextLi.className = `page-item ${page === totalPages ? 'disabled' : ''}`;
nextLi.innerHTML = `<a class="page-link" href="#" aria-label="Next" ${page !== totalPages ? `data-page="${page + 1}"` : ''}>
<span aria-hidden="true">&raquo;</span>
</a>`;
paginationElement.appendChild(nextLi);
}
// Add event listeners to pagination
document.querySelectorAll('#jobsPagination a[data-page]').forEach(link => {
link.addEventListener('click', (e) => {
e.preventDefault();
const newPage = parseInt(e.currentTarget.dataset.page);
currentPage = newPage;
renderJobsTable(newPage);
});
});
document.querySelectorAll('.job-checkbox').forEach(checkbox => {
checkbox.addEventListener('change', updateDeleteButtonState);
});
document.querySelectorAll('.delete-job').forEach(button => {
button.addEventListener('click', (e) => {
const jobId = e.currentTarget.dataset.id;
deleteJob(jobId);
});
});
// Add event listeners to action buttons
document.querySelectorAll('.process-job').forEach(button => {
button.addEventListener('click', (e) => {
const jobId = e.currentTarget.dataset.id;
processJob(jobId);
});
});
document.querySelectorAll('.view-job').forEach(button => {
button.addEventListener('click', (e) => {
const jobId = e.currentTarget.dataset.id;
viewJobDetails(jobId);
});
});
}
document.getElementById('selectAllJobs').addEventListener('change', function() {
const isChecked = this.checked;
document.querySelectorAll('.job-checkbox:not(:disabled)').forEach(checkbox => {
checkbox.checked = isChecked;
});
updateDeleteButtonState();
});
// Function to update delete button state
function updateDeleteButtonState() {
const deleteBtn = document.getElementById('deleteSelectedJobs');
const selectedCheckboxes = document.querySelectorAll('.job-checkbox:checked');
deleteBtn.disabled = selectedCheckboxes.length === 0;
}
// Delete selected jobs
document.getElementById('deleteSelectedJobs').addEventListener('click', async () => {
const selectedIds = Array.from(
document.querySelectorAll('.job-checkbox:checked')
).map(checkbox => checkbox.value);
if (!selectedIds.length) return;
if (confirm(`Are you sure you want to delete ${selectedIds.length} job(s)?`)) {
await deleteMultipleJobs(selectedIds);
}
});
async function deleteJob(jobId) {
if (confirm('Are you sure you want to delete this job?')) {
try {
const response = await fetcher(`/api/repair/jobs`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ ids: [jobId] })
});
if (!response.ok) throw new Error(await response.text());
createToast('Job deleted successfully');
await loadJobs(currentPage); // Refresh the jobs list
} catch (error) {
createToast(`Error deleting job: ${error.message}`, 'error');
}
}
}
async function deleteMultipleJobs(jobIds) {
try {
const response = await fetcher(`/api/repair/jobs`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ ids: jobIds })
});
if (!response.ok) throw new Error(await response.text());
createToast(`${jobIds.length} job(s) deleted successfully`);
await loadJobs(currentPage); // Refresh the jobs list
} catch (error) {
createToast(`Error deleting jobs: ${error.message}`, 'error');
}
}
// Process job function
async function processJob(jobId) {
try {
const response = await fetcher(`/api/repair/jobs/${jobId}/process`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
});
if (!response.ok) throw new Error(await response.text());
createToast('Job processing started successfully');
await loadJobs(currentPage); // Refresh the jobs list
} catch (error) {
createToast(`Error processing job: ${error.message}`, 'error');
}
}
// View job details function
function viewJobDetails(jobId) {
// Find the job
const job = allJobs.find(j => j.id === jobId);
if (!job) return;
// Prepare modal data
document.getElementById('modalJobId').textContent = job.id.substring(0, 8);
// Format dates
const startedDate = new Date(job.created_at);
document.getElementById('modalJobStarted').textContent = startedDate.toLocaleString();
if (job.finished_at) {
const completedDate = new Date(job.finished_at);
document.getElementById('modalJobCompleted').textContent = completedDate.toLocaleString();
} else {
document.getElementById('modalJobCompleted').textContent = 'N/A';
}
// Set status with color
let status = 'In Progress';
let statusClass = 'text-primary';
if (job.status === 'failed') {
status = 'Failed';
statusClass = 'text-danger';
} else if (job.status === 'completed') {
status = 'Completed';
statusClass = 'text-success';
} else if (job.status === 'pending') {
status = 'Pending';
statusClass = 'text-warning';
} else if (job.status === "processing") {
status = 'Processing';
statusClass = 'text-info';
}
document.getElementById('modalJobStatus').innerHTML = `<span class="${statusClass}">${status}</span>`;
// Set other job details
document.getElementById('modalJobArrs').textContent = job.arrs.join(', ');
document.getElementById('modalJobMediaIds').textContent = job.media_ids && job.media_ids.length > 0 ?
job.media_ids.join(', ') : 'All';
document.getElementById('modalJobAutoProcess').textContent = job.auto_process ? 'Yes' : 'No';
// Show/hide error message
const errorContainer = document.getElementById('errorContainer');
if (job.error) {
document.getElementById('modalJobError').textContent = job.error;
errorContainer.classList.remove('d-none');
} else {
errorContainer.classList.add('d-none');
}
// Process button visibility
const processBtn = document.getElementById('processJobBtn');
if (job.status === 'pending') {
processBtn.classList.remove('d-none');
processBtn.onclick = () => {
processJob(job.id);
const modal = bootstrap.Modal.getInstance(document.getElementById('jobDetailsModal'));
modal.hide();
};
} else {
processBtn.classList.add('d-none');
}
// Populate broken items table
const brokenItemsTableBody = document.getElementById('brokenItemsTableBody');
const noBrokenItemsMessage = document.getElementById('noBrokenItemsMessage');
brokenItemsTableBody.innerHTML = '';
let hasBrokenItems = false;
// Check if broken_items exists and has entries
if (job.broken_items && Object.entries(job.broken_items).length > 0) {
hasBrokenItems = true;
// Loop through each Arr's broken items
for (const [arrName, items] of Object.entries(job.broken_items)) {
if (items && items.length > 0) {
// Add each item to the table
items.forEach(item => {
const row = document.createElement('tr');
row.innerHTML = `
<td>${arrName}</td>
<td><small class="text-muted">${item.path}</small></td>
`;
brokenItemsTableBody.appendChild(row);
});
}
}
}
// Show/hide no items message
if (hasBrokenItems) {
noBrokenItemsMessage.classList.add('d-none');
} else {
noBrokenItemsMessage.classList.remove('d-none');
}
// Show the modal
const modal = new bootstrap.Modal(document.getElementById('jobDetailsModal'));
modal.show();
}
// Add event listener for refresh button
document.getElementById('refreshJobs').addEventListener('click', () => {
loadJobs(currentPage);
});
// Load jobs on page load
loadJobs(1);
});
</script>
{{ end }}