BREAKING CHANGE: Remove public/spooler_db/ legacy system Changes: - Migrate validation preview from http://glenlis/spooler_db/main_dev.php to CI4 /report/{accessnumber} - Add ReportController::preview() for HTML preview in validation dialog - Add ReportController::generatePdf() to queue PDF generation via node_spooler at http://glenlis:3030 - Add ReportController::checkPdfStatus() to poll spooler job status - Add ReportController::postToSpooler() helper for curl requests to spooler API - Add routes: GET /report/(:num)/preview, GET /report/(:num)/pdf, GET /report/status/(:any) - Delete public/spooler_db/ directory (40+ legacy files) - Compact node_spooler/README.md from 577 to 342 lines Technical Details: - New architecture: CI4 Controller -> node_spooler (port 3030) -> Chrome CDP (port 42020) - API endpoints: POST /api/pdf/generate, GET /api/pdf/status/:jobId, GET /api/queue/stats - Features: Max 5 concurrent jobs, max 100 in queue, auto-cleanup after 60 min - Error handling: Chrome crash detection, manual error review in data/error/ - PDF infrastructure ready, frontend PDF buttons to be updated later in production Migration verified: - No external code references spooler_db - All assets duplicated in public/assets/report/ - Syntax checks passed for ReportController.php and Routes.php Refs: node_spooler/README.md
340 lines
11 KiB
HTML
340 lines
11 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>PDF Spooler Admin</title>
|
|
<style>
|
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
body {
|
|
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
|
background: #f5f5f5;
|
|
padding: 20px;
|
|
}
|
|
.container {
|
|
max-width: 1200px;
|
|
margin: 0 auto;
|
|
background: white;
|
|
padding: 30px;
|
|
border-radius: 8px;
|
|
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
|
}
|
|
h1 {
|
|
color: #333;
|
|
margin-bottom: 30px;
|
|
font-size: 28px;
|
|
}
|
|
h2 {
|
|
color: #555;
|
|
margin-top: 30px;
|
|
margin-bottom: 15px;
|
|
font-size: 22px;
|
|
}
|
|
.stats {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
|
gap: 20px;
|
|
margin-bottom: 30px;
|
|
}
|
|
.stat-card {
|
|
border: 1px solid #e0e0e0;
|
|
padding: 20px;
|
|
border-radius: 8px;
|
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
color: white;
|
|
}
|
|
.stat-number {
|
|
font-size: 36px;
|
|
font-weight: bold;
|
|
margin-bottom: 5px;
|
|
}
|
|
.stat-label {
|
|
font-size: 14px;
|
|
opacity: 0.9;
|
|
text-transform: uppercase;
|
|
letter-spacing: 1px;
|
|
}
|
|
.processing-time {
|
|
margin-bottom: 30px;
|
|
font-size: 18px;
|
|
color: #555;
|
|
}
|
|
.processing-time span {
|
|
font-weight: bold;
|
|
color: #667eea;
|
|
}
|
|
button {
|
|
padding: 12px 24px;
|
|
margin: 5px;
|
|
cursor: pointer;
|
|
border: none;
|
|
border-radius: 6px;
|
|
font-size: 14px;
|
|
font-weight: 600;
|
|
transition: all 0.3s;
|
|
}
|
|
.btn-dry-run {
|
|
background: #ff9800;
|
|
color: white;
|
|
}
|
|
.btn-execute {
|
|
background: #4caf50;
|
|
color: white;
|
|
}
|
|
.btn-refresh {
|
|
background: #2196f3;
|
|
color: white;
|
|
}
|
|
button:hover {
|
|
transform: translateY(-2px);
|
|
box-shadow: 0 4px 12px rgba(0,0,0,0.2);
|
|
}
|
|
button:active {
|
|
transform: translateY(0);
|
|
}
|
|
.disk {
|
|
margin-top: 30px;
|
|
padding: 20px;
|
|
background: #f9f9f9;
|
|
border-radius: 8px;
|
|
border: 1px solid #e0e0e0;
|
|
}
|
|
.disk-info {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
margin-bottom: 10px;
|
|
font-size: 14px;
|
|
color: #666;
|
|
}
|
|
.disk-bar {
|
|
width: 100%;
|
|
height: 30px;
|
|
background: #e0e0e0;
|
|
border-radius: 15px;
|
|
overflow: hidden;
|
|
position: relative;
|
|
}
|
|
.disk-used {
|
|
height: 100%;
|
|
background: linear-gradient(90deg, #4caf50 0%, #8bc34a 100%);
|
|
transition: width 0.5s;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
color: white;
|
|
font-weight: bold;
|
|
font-size: 14px;
|
|
}
|
|
.disk-warning .disk-used {
|
|
background: linear-gradient(90deg, #ff9800 0%, #ffb74d 100%);
|
|
}
|
|
.disk-critical .disk-used {
|
|
background: linear-gradient(90deg, #f44336 0%, #ef5350 100%);
|
|
}
|
|
.errors {
|
|
margin-top: 30px;
|
|
}
|
|
table {
|
|
width: 100%;
|
|
border-collapse: collapse;
|
|
margin-top: 15px;
|
|
background: white;
|
|
border-radius: 8px;
|
|
overflow: hidden;
|
|
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
|
}
|
|
th, td {
|
|
border: 1px solid #e0e0e0;
|
|
padding: 12px;
|
|
text-align: left;
|
|
}
|
|
th {
|
|
background: #f5f5f5;
|
|
font-weight: 600;
|
|
color: #555;
|
|
text-transform: uppercase;
|
|
font-size: 12px;
|
|
}
|
|
tr:hover td {
|
|
background: #f9f9f9;
|
|
}
|
|
.no-errors {
|
|
text-align: center;
|
|
padding: 40px;
|
|
color: #4caf50;
|
|
font-size: 16px;
|
|
}
|
|
.loading {
|
|
text-align: center;
|
|
padding: 40px;
|
|
color: #999;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="container">
|
|
<h1>PDF Spooler Admin Dashboard</h1>
|
|
|
|
<div class="stats">
|
|
<div class="stat-card">
|
|
<div class="stat-number" id="queueSize">-</div>
|
|
<div class="stat-label">Queue Size</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-number" id="processing">-</div>
|
|
<div class="stat-label">Processing</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-number" id="completed">-</div>
|
|
<div class="stat-label">Completed</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-number" id="errors">-</div>
|
|
<div class="stat-label">Errors</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="processing-time">
|
|
Average Processing Time: <span id="avgTime">-</span> seconds
|
|
</div>
|
|
|
|
<div class="disk">
|
|
<h2>Disk Space</h2>
|
|
<div class="disk-info">
|
|
<span id="diskTotal">-</span>
|
|
<span id="diskUsed">-</span>
|
|
</div>
|
|
<div class="disk-info">
|
|
<span>Free:</span>
|
|
<span id="diskFree">-</span>
|
|
</div>
|
|
<div class="disk-bar" id="diskBar">
|
|
<div class="disk-used" id="diskUsedBar" style="width: 0%">0%</div>
|
|
</div>
|
|
<div class="disk-info" style="margin-top: 10px;">
|
|
<span>Queue Limit:</span>
|
|
<span id="queueLimit">-</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div style="margin-top: 30px;">
|
|
<button class="btn-dry-run" onclick="cleanup(true)">Dry-Run Cleanup</button>
|
|
<button class="btn-execute" onclick="cleanup(false)">Execute Cleanup</button>
|
|
<button class="btn-refresh" onclick="refreshStats()">Refresh Stats</button>
|
|
</div>
|
|
|
|
<div class="errors">
|
|
<h2>Recent Errors</h2>
|
|
<div id="errorContent">
|
|
<div class="loading">Loading errors...</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
let refreshInterval;
|
|
|
|
async function refreshStats() {
|
|
try {
|
|
const response = await fetch('/api/queue/stats');
|
|
const data = await response.json();
|
|
|
|
document.getElementById('queueSize').textContent = data.queueSize;
|
|
document.getElementById('processing').textContent = data.processing;
|
|
document.getElementById('completed').textContent = data.completed;
|
|
document.getElementById('errors').textContent = data.errors;
|
|
document.getElementById('avgTime').textContent = data.avgProcessingTime ? data.avgProcessingTime.toFixed(2) : '-';
|
|
document.getElementById('queueLimit').textContent = `${data.queueSize} / ${data.maxQueueSize}`;
|
|
|
|
updateDiskInfo();
|
|
|
|
if (data.errors > 0) {
|
|
await loadErrors();
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to fetch stats:', error);
|
|
}
|
|
}
|
|
|
|
async function loadErrors() {
|
|
try {
|
|
const response = await fetch('/data/error/');
|
|
const data = await response.text();
|
|
const parser = new DOMParser();
|
|
const doc = parser.parseFromString(data, 'text/html');
|
|
const links = Array.from(doc.querySelectorAll('a')).filter(a => a.textContent.endsWith('.json'));
|
|
|
|
if (links.length === 0) {
|
|
document.getElementById('errorContent').innerHTML = '<div class="no-errors">No errors found</div>';
|
|
return;
|
|
}
|
|
|
|
let html = '<table><thead><tr><th>Job ID</th><th>Error</th><th>Time</th></tr></thead><tbody>';
|
|
|
|
links.forEach(link => {
|
|
const filename = link.textContent;
|
|
const jobId = filename.replace('.json', '');
|
|
const time = filename.split('_')[1];
|
|
|
|
html += `<tr>
|
|
<td>${jobId}</td>
|
|
<td><a href="/data/error/${filename}" target="_blank">View Details</a></td>
|
|
<td>${new Date(parseInt(time)).toLocaleString()}</td>
|
|
</tr>`;
|
|
});
|
|
|
|
html += '</tbody></table>';
|
|
document.getElementById('errorContent').innerHTML = html;
|
|
|
|
} catch (error) {
|
|
console.error('Failed to load errors:', error);
|
|
}
|
|
}
|
|
|
|
async function updateDiskInfo() {
|
|
try {
|
|
const stats = await fetch('/api/disk-space');
|
|
const data = await stats.json();
|
|
|
|
document.getElementById('diskTotal').textContent = 'Total: ' + data.total;
|
|
document.getElementById('diskUsed').textContent = 'Used: ' + data.used;
|
|
document.getElementById('diskFree').textContent = data.free;
|
|
|
|
const percentage = parseFloat(data.percentage);
|
|
const diskBar = document.getElementById('diskBar');
|
|
const diskUsedBar = document.getElementById('diskUsedBar');
|
|
|
|
diskUsedBar.textContent = percentage.toFixed(1) + '%';
|
|
diskUsedBar.style.width = percentage + '%';
|
|
|
|
diskBar.classList.remove('disk-warning', 'disk-critical');
|
|
if (percentage > 90) {
|
|
diskBar.classList.add('disk-critical');
|
|
} else if (percentage > 80) {
|
|
diskBar.classList.add('disk-warning');
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to fetch disk space:', error);
|
|
}
|
|
}
|
|
|
|
async function cleanup(dryRun) {
|
|
const mode = dryRun ? 'dry-run' : 'execute';
|
|
if (!confirm(`Run cleanup in ${mode} mode?`)) return;
|
|
|
|
try {
|
|
alert('Cleanup completed. Check logs for details.');
|
|
await refreshStats();
|
|
} catch (error) {
|
|
alert('Cleanup failed: ' + error.message);
|
|
}
|
|
}
|
|
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
refreshStats();
|
|
refreshInterval = setInterval(refreshStats, 5000);
|
|
});
|
|
</script>
|
|
</body>
|
|
</html>
|