gdc_cmod/node_spooler/admin.html
mahdahar 2843ddd392 Migrate PDF generation from legacy spooler_db to CI4 + node_spooler
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
2026-02-03 11:33:55 +07:00

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>