475 lines
21 KiB
Django/Jinja
475 lines
21 KiB
Django/Jinja
{% extends "base.html.j2" %}
|
|
|
|
{% block title %}{{ t.crawl }} - neopig{% endblock %}
|
|
|
|
{% block extra_css %}
|
|
h1 { color: #ff6b6b; margin-bottom: 5px; }
|
|
.subtitle { color: #666; margin-bottom: 20px; }
|
|
.form-group { margin-bottom: 15px; }
|
|
label { display: block; margin-bottom: 5px; color: #aaa; font-size: 14px; }
|
|
input[type="text"], input[type="number"], select, textarea {
|
|
width: 100%; padding: 12px 16px; font-size: 16px;
|
|
border: 2px solid #333; border-radius: 8px; background: #1a1a1a; color: #fff;
|
|
}
|
|
input:focus, select:focus, textarea:focus { outline: none; border-color: #ff6b6b; }
|
|
.row { display: flex; gap: 15px; }
|
|
.row > div { flex: 1; }
|
|
button { padding: 14px 28px; font-size: 16px; background: #ff6b6b; color: #fff; border: none; border-radius: 8px; cursor: pointer; margin-top: 10px; }
|
|
button:hover { background: #ff5252; }
|
|
button:disabled { background: #444; cursor: not-allowed; }
|
|
button.secondary { background: #333; }
|
|
button.secondary:hover { background: #444; }
|
|
.checkbox-group { display: flex; align-items: center; gap: 8px; }
|
|
.checkbox-group input { width: auto; }
|
|
.jobs-section { margin-top: 30px; padding-top: 20px; border-top: 1px solid #333; }
|
|
h2 { color: #ff6b6b; font-size: 18px; margin-bottom: 15px; }
|
|
.job { background: #1a1a1a; border-radius: 8px; padding: 15px; margin-bottom: 10px; }
|
|
.job-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; }
|
|
.job-id { font-family: monospace; color: #666; }
|
|
.job-status { padding: 4px 10px; border-radius: 4px; font-size: 12px; font-weight: bold; }
|
|
.job-status.running { background: #2d5a27; color: #7bed72; }
|
|
.job-status.completed { background: #1a3a4a; color: #6bc5e8; }
|
|
.job-status.failed { background: #5a2727; color: #ed7272; }
|
|
.job-target { font-size: 14px; word-break: break-all; margin-bottom: 5px; }
|
|
.job-meta { font-size: 12px; color: #666; }
|
|
.job-stats { display: flex; gap: 15px; margin-top: 10px; font-size: 13px; flex-wrap: wrap; }
|
|
.job-stats span { background: #252525; padding: 4px 10px; border-radius: 4px; }
|
|
.progress-bar { height: 4px; background: #333; border-radius: 2px; margin-top: 10px; overflow: hidden; }
|
|
.progress-bar-fill { height: 100%; background: #ff6b6b; transition: width 0.3s; }
|
|
.progress-bar-fill.running {
|
|
width: 100%;
|
|
background: linear-gradient(90deg, #ff6b6b 0%, #4ecdc4 50%, #ff6b6b 100%);
|
|
background-size: 200% 100%;
|
|
animation: progress-wave 1.5s ease-in-out infinite;
|
|
}
|
|
.job-actions { margin-top: 8px; display: flex; gap: 8px; flex-wrap: wrap; }
|
|
.job-actions button { padding: 4px 12px; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 12px; margin-top: 0; }
|
|
.pause-btn { background: #e67e22; }
|
|
.pause-btn:hover { background: #f39c12; }
|
|
.start-btn { background: #27ae60; }
|
|
.start-btn:hover { background: #2ecc71; }
|
|
.recrawl-btn { background: #3498db; }
|
|
.recrawl-btn:hover { background: #2980b9; }
|
|
.delete-btn { background: #7f8c8d; }
|
|
.delete-btn:hover { background: #c0392b; }
|
|
.replay-btn { background: #9b59b6; }
|
|
.replay-btn:hover { background: #8e44ad; }
|
|
.job-links { margin-left: 10px; font-size: 12px; }
|
|
.job-links a { color: #4ecdc4; margin-right: 8px; }
|
|
.job-links a:hover { color: #7bed72; }
|
|
@keyframes progress-wave {
|
|
0% { background-position: 200% 0; }
|
|
100% { background-position: -200% 0; }
|
|
}
|
|
.job.running { border-color: #4ecdc4; }
|
|
.job-stats.live { color: #4ecdc4; }
|
|
.no-jobs { color: #666; text-align: center; padding: 30px; }
|
|
.console-btn { background: #2c3e50; }
|
|
.console-btn:hover { background: #34495e; }
|
|
.console-modal {
|
|
display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0;
|
|
background: rgba(0,0,0,0.8); z-index: 1000; padding: 20px;
|
|
}
|
|
.console-modal.active { display: flex; flex-direction: column; }
|
|
.console-header {
|
|
display: flex; justify-content: space-between; align-items: center;
|
|
padding: 10px 15px; background: #1a1a1a; border-radius: 8px 8px 0 0;
|
|
}
|
|
.console-header h3 { margin: 0; color: #ff6b6b; }
|
|
.console-close { background: #c0392b; border: none; color: white; padding: 5px 15px; border-radius: 4px; cursor: pointer; }
|
|
.console-body {
|
|
flex: 1; background: #0d0d0d; padding: 15px; overflow: auto;
|
|
font-family: monospace; font-size: 12px; white-space: pre-wrap;
|
|
word-break: break-all; color: #4ade80; border-radius: 0 0 8px 8px;
|
|
}
|
|
.console-body .log-line { margin: 0; line-height: 1.4; }
|
|
.console-body .log-INFO { color: #4ade80; }
|
|
.console-body .log-WARNING { color: #f39c12; }
|
|
.console-body .log-ERROR { color: #e74c3c; }
|
|
{% endblock %}
|
|
|
|
{% block content %}
|
|
<div class="container">
|
|
<h1>{{ t.crawl }}</h1>
|
|
<p class="subtitle">{{ t.hydrate_subtitle }}</p>
|
|
|
|
<form id="crawl-form" onsubmit="startCrawl(event)">
|
|
<div class="form-group">
|
|
<label>{{ t.url }}</label>
|
|
<textarea id="target" placeholder="https://example.com https://another.com" rows="2" required></textarea>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label>{{ t.keywords }}</label>
|
|
<input type="text" id="keywords" placeholder="e.g. rick and morty, adult swim">
|
|
</div>
|
|
|
|
<div class="row">
|
|
<div class="form-group">
|
|
<label>{{ t.mode }}</label>
|
|
<select id="mode">
|
|
<option value="all">{{ t.everything }}</option>
|
|
<option value="media" selected>{{ t.all_media }}</option>
|
|
<option value="images">{{ t.images_only }}</option>
|
|
<option value="videos">{{ t.videos_only }}</option>
|
|
<option value="text">{{ t.text_only }}</option>
|
|
</select>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>{{ t.depth }}</label>
|
|
<input type="number" id="depth" value="9" min="-1" max="15">
|
|
</div>
|
|
<div class="form-group">
|
|
<label>{{ t.max_pages }}</label>
|
|
<input type="number" id="max_pages" value="-1" min="-1">
|
|
</div>
|
|
</div>
|
|
|
|
<div class="form-group checkbox-group" style="display:flex; flex-wrap:wrap; gap:20px;">
|
|
<div>
|
|
<input type="checkbox" id="fresh" checked>
|
|
<label for="fresh" style="display:inline; margin:0;">{{ t.fresh_start }}</label>
|
|
</div>
|
|
<div>
|
|
<input type="checkbox" id="fast">
|
|
<label for="fast" style="display:inline; margin:0;">{{ t.fast_mode }}</label>
|
|
</div>
|
|
<div>
|
|
<input type="checkbox" id="screenshots" checked>
|
|
<label for="screenshots" style="display:inline; margin:0;">{{ t.screenshots }}</label>
|
|
</div>
|
|
<div>
|
|
<input type="checkbox" id="hydra">
|
|
<label for="hydra" style="display:inline; margin:0;" title="Parse RSS/Atom/Sitemap for fast discovery">Hydra</label>
|
|
</div>
|
|
</div>
|
|
|
|
<button type="submit" id="start-btn">{{ t.start_crawl }}</button>
|
|
</form>
|
|
|
|
<div class="jobs-section">
|
|
<h2>{{ t.recent_jobs }}</h2>
|
|
<div id="jobs">{{ t.loading }}</div>
|
|
</div>
|
|
|
|
<div id="console-modal" class="console-modal" onclick="if(event.target===this)closeConsole()">
|
|
<div class="console-header">
|
|
<h3 id="console-title">{{ t.console }}</h3>
|
|
<button class="console-close" onclick="closeConsole()">{{ t.close }}</button>
|
|
</div>
|
|
<div id="console-logs" class="console-body"></div>
|
|
</div>
|
|
</div>
|
|
{% endblock %}
|
|
|
|
{% block scripts %}
|
|
<script>
|
|
// Translation object for JavaScript
|
|
const T = {{ t_json|safe }};
|
|
const CRAWL_DISABLED = {{ 'true' if crawl_disabled else 'false' }};
|
|
|
|
// Disable form when crawling is disabled
|
|
if (CRAWL_DISABLED) {
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
const form = document.getElementById('crawl-form');
|
|
const inputs = form.querySelectorAll('input, select, textarea, button');
|
|
inputs.forEach(el => {
|
|
el.disabled = true;
|
|
el.style.opacity = '0.5';
|
|
el.style.cursor = 'not-allowed';
|
|
});
|
|
const banner = document.createElement('div');
|
|
banner.style.cssText = 'background:#442;border:1px solid #664;padding:12px 20px;border-radius:8px;margin-bottom:20px;color:#ffa;';
|
|
banner.innerHTML = 'Crawling is disabled on this server (read-only archive mode)';
|
|
form.parentNode.insertBefore(banner, form);
|
|
});
|
|
}
|
|
|
|
async function startCrawl(e) {
|
|
e.preventDefault();
|
|
if (CRAWL_DISABLED) return;
|
|
|
|
const btn = document.getElementById('start-btn');
|
|
btn.disabled = true;
|
|
btn.textContent = T.starting;
|
|
|
|
const keywordsRaw = document.getElementById('keywords').value;
|
|
const keywords = keywordsRaw.split(/[,\s]+/).map(k => k.trim()).filter(k => k.length > 0);
|
|
|
|
const targetsRaw = document.getElementById('target').value;
|
|
const targets = targetsRaw.split(/[,\s\n]+/).map(t => t.trim()).filter(t => t.length > 0 && t.startsWith('http'));
|
|
|
|
if (targets.length === 0) {
|
|
alert(T.valid_uri);
|
|
btn.disabled = false;
|
|
btn.textContent = T.start_crawl;
|
|
return;
|
|
}
|
|
|
|
const payload = {
|
|
targets: targets,
|
|
keywords: keywords,
|
|
mode: document.getElementById('mode').value,
|
|
depth: parseInt(document.getElementById('depth').value),
|
|
max_pages: parseInt(document.getElementById('max_pages').value),
|
|
fresh: document.getElementById('fresh').checked,
|
|
fast: document.getElementById('fast').checked,
|
|
screenshots: document.getElementById('screenshots').checked,
|
|
hydra: document.getElementById('hydra').checked
|
|
};
|
|
|
|
try {
|
|
const res = await fetch('/api/crawl', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(payload)
|
|
});
|
|
|
|
if (!res.ok) {
|
|
const err = await res.json();
|
|
btn.textContent = T.error + ': ' + (err.detail || T.load_error);
|
|
btn.style.background = '#c0392b';
|
|
setTimeout(() => { btn.textContent = T.start_crawl; btn.style.background = ''; }, 3000);
|
|
} else {
|
|
document.getElementById('target').value = '';
|
|
document.getElementById('keywords').value = '';
|
|
btn.textContent = T.started_ok;
|
|
btn.style.background = '#27ae60';
|
|
loadJobs();
|
|
setTimeout(() => { btn.textContent = T.start_crawl; btn.style.background = ''; }, 2000);
|
|
}
|
|
} catch (err) {
|
|
btn.textContent = T.error;
|
|
btn.style.background = '#c0392b';
|
|
setTimeout(() => { btn.textContent = T.start_crawl; btn.style.background = ''; }, 3000);
|
|
}
|
|
btn.disabled = false;
|
|
}
|
|
|
|
async function pauseJob(jobId) {
|
|
try {
|
|
const res = await fetch('/api/crawl/jobs/' + jobId + '/pause', { method: 'POST' });
|
|
if (res.ok) loadJobs();
|
|
else { const err = await res.json(); alert(T.error + ': ' + (err.detail || T.load_error)); }
|
|
} catch (err) { alert(T.error + ': ' + err.message); }
|
|
}
|
|
|
|
async function resumeJob(jobId) {
|
|
try {
|
|
const res = await fetch('/api/crawl/jobs/' + jobId + '/resume', { method: 'POST' });
|
|
if (res.ok) loadJobs();
|
|
else { const err = await res.json(); alert(T.error + ': ' + (err.detail || T.load_error)); }
|
|
} catch (err) { alert(T.error + ': ' + err.message); }
|
|
}
|
|
|
|
function loadJobSettings(job) {
|
|
document.getElementById('target').value = job.target_uri || '';
|
|
document.getElementById('keywords').value = (JSON.parse(job.keywords || '[]')).join(', ');
|
|
document.getElementById('mode').value = job.mode || 'all';
|
|
document.getElementById('depth').value = job.depth || 15;
|
|
document.getElementById('max_pages').value = job.max_pages || -1;
|
|
document.getElementById('fast').checked = !!job.fast;
|
|
document.getElementById('screenshots').checked = job.screenshots !== 0;
|
|
document.getElementById('fresh').checked = false;
|
|
document.getElementById('target').scrollIntoView({ behavior: 'smooth' });
|
|
document.getElementById('target').focus();
|
|
}
|
|
|
|
async function deleteJob(jobId) {
|
|
if (!confirm(T.delete + '?')) return;
|
|
try {
|
|
const res = await fetch('/api/crawl/jobs/' + jobId, { method: 'DELETE' });
|
|
if (res.ok) loadJobs();
|
|
else { const err = await res.json(); alert(T.error + ': ' + (err.detail || T.load_error)); }
|
|
} catch (err) { alert(T.error + ': ' + err.message); }
|
|
}
|
|
|
|
// Track active replays to prevent double-clicking
|
|
const activeReplays = new Set();
|
|
|
|
function replayJob(jobId) {
|
|
// Prevent duplicate replays of same job
|
|
if (activeReplays.has(jobId)) {
|
|
alert('Replay for job #' + jobId + ' is already running');
|
|
return;
|
|
}
|
|
|
|
activeReplays.add(jobId);
|
|
|
|
// Update button to show active state
|
|
const btn = document.querySelector(`button[onclick="replayJob(${jobId})"]`);
|
|
if (btn) {
|
|
btn.textContent = '⏳ Replaying...';
|
|
btn.disabled = true;
|
|
}
|
|
|
|
// Open live feed page with replay parameter
|
|
const replayWindow = window.open('/live?replay=' + jobId, '_blank');
|
|
|
|
// Listen for window close to reset state (works for same-origin)
|
|
const checkClosed = setInterval(() => {
|
|
if (replayWindow && replayWindow.closed) {
|
|
clearInterval(checkClosed);
|
|
activeReplays.delete(jobId);
|
|
if (btn) {
|
|
btn.textContent = '▶ Replay';
|
|
btn.disabled = false;
|
|
}
|
|
}
|
|
}, 1000);
|
|
|
|
// Auto-reset after 5 minutes as fallback
|
|
setTimeout(() => {
|
|
clearInterval(checkClosed);
|
|
activeReplays.delete(jobId);
|
|
if (btn) {
|
|
btn.textContent = '▶ Replay';
|
|
btn.disabled = false;
|
|
}
|
|
}, 300000);
|
|
}
|
|
|
|
function formatBytes(bytes) {
|
|
if (!bytes) return '';
|
|
if (bytes < 1024) return bytes + ' B';
|
|
if (bytes < 1024*1024) return (bytes/1024).toFixed(1) + ' KB';
|
|
if (bytes < 1024*1024*1024) return (bytes/1024/1024).toFixed(1) + ' MB';
|
|
return (bytes/1024/1024/1024).toFixed(1) + ' GB';
|
|
}
|
|
|
|
function formatDuration(startedAt, completedAt) {
|
|
if (!startedAt || !completedAt) return '';
|
|
const sec = Math.round((new Date(completedAt) - new Date(startedAt)) / 1000);
|
|
if (sec < 60) return sec + 's';
|
|
if (sec < 3600) return Math.floor(sec / 60) + 'm ' + (sec % 60) + 's';
|
|
return Math.floor(sec / 3600) + 'h ' + Math.floor((sec % 3600) / 60) + 'm';
|
|
}
|
|
|
|
async function loadJobs() {
|
|
try {
|
|
const res = await fetch('/api/crawl/jobs');
|
|
const jobs = await res.json();
|
|
const container = document.getElementById('jobs');
|
|
|
|
if (jobs.length === 0) {
|
|
container.innerHTML = '<div class="no-jobs">' + T.no_jobs + '</div>';
|
|
return;
|
|
}
|
|
|
|
const hasRunning = jobs.some(j => j.status === 'running');
|
|
container.innerHTML = jobs.map(job => renderJob(job)).join('');
|
|
setTimeout(loadJobs, hasRunning ? 3000 : 10000);
|
|
} catch (err) {
|
|
console.error('loadJobs error:', err);
|
|
document.getElementById('jobs').innerHTML = '<div class="no-jobs">' + T.load_jobs_error + ': ' + err.message + '</div>';
|
|
setTimeout(loadJobs, 10000);
|
|
}
|
|
}
|
|
|
|
function renderJob(job) {
|
|
let stats = {}, keywords = [];
|
|
try { stats = job.stats ? JSON.parse(job.stats) : {}; } catch(e) {}
|
|
try { keywords = job.keywords ? JSON.parse(job.keywords) : []; } catch(e) {}
|
|
|
|
const isBackfill = job.job_kind === 'backfill';
|
|
const kindLabel = isBackfill ? '🔄 ' + job.job_type : '🕷️ ' + (job.mode || 'all');
|
|
|
|
if (isBackfill) {
|
|
const progress = job.total_records > 0 ? Math.round((job.processed_records / job.total_records) * 100) : 0;
|
|
return `<div class="job ${job.status}">
|
|
<div class="job-header"><span class="job-id">${kindLabel} #${job.id}</span><span class="job-status ${job.status}">${T[job.status] || job.status}</span></div>
|
|
<div class="job-target">${job.domain_filter || T.all_domains}</div>
|
|
<div class="job-meta">${job.processed_records}/${job.total_records} | ${job.error_count} ${T.error} | ${T.started}: ${new Date(job.started_at).toLocaleString()}</div>
|
|
${job.status === 'running' ? '<div class="progress-bar"><div class="progress-bar-fill" style="width:' + progress + '%"></div></div>' : ''}
|
|
</div>`;
|
|
}
|
|
|
|
const crawled = stats.pages_crawled || 0, pending = stats.pages_pending || 0;
|
|
const crawlProgress = (crawled + pending) > 0 ? Math.round((crawled / (crawled + pending)) * 100) : 0;
|
|
|
|
const calcEta = () => {
|
|
const started = stats.crawl_started || 0;
|
|
if (crawled === 0 || started === 0 || pending === 0) return '';
|
|
const elapsed = Date.now() / 1000 - started;
|
|
if (elapsed < 5) return '';
|
|
const etaSec = pending / (crawled / elapsed);
|
|
if (etaSec < 60) return '~' + Math.round(etaSec) + 's';
|
|
if (etaSec < 3600) return '~' + Math.round(etaSec / 60) + 'm';
|
|
return '~' + (etaSec / 3600).toFixed(1) + 'h';
|
|
};
|
|
|
|
const liveStats = job.status === 'running' ? `<div class="job-stats live">
|
|
<span>📄 ${crawled}${pending ? '/' + (crawled + pending) : ''} pages${calcEta() ? ' ⏱️' + calcEta() : ''}</span>
|
|
<span>🖼️ ${stats.media_found || 0} found</span><span>💾 ${stats.media_downloaded || 0} saved</span>
|
|
<span>📸 ${stats.screenshots_taken || 0}</span><span>♻️ ${(stats.duplicates_skipped || 0) + (stats.content_exists || 0)} dupes</span>
|
|
${stats.bytes_stored ? '<span>📦 ' + formatBytes(stats.bytes_stored) + '</span>' : ''}
|
|
</div>` : '';
|
|
|
|
let jobLinks = '';
|
|
try { const h = new URL(job.target_uri).hostname; jobLinks = `<span class="job-links"><a href="/?q=${encodeURIComponent(h)}">${T.search}</a> <a href="/live?domain=${encodeURIComponent(h)}">${T.live}</a></span>`; } catch(e) {}
|
|
|
|
return `<div class="job ${job.status}">
|
|
<div class="job-header"><span class="job-id">${kindLabel} #${job.id}</span><span class="job-status ${job.status}">${T[job.status] || job.status}</span></div>
|
|
<div class="job-target"><a href="${job.target_uri}" target="_blank">${job.target_uri}</a>${jobLinks}</div>
|
|
<div class="job-meta">${keywords.length ? T.filter + ': ' + keywords.join(', ') + ' | ' : ''}${T.started}: ${new Date(job.started_at).toLocaleString()}</div>
|
|
${job.status === 'completed' ? `<div class="job-stats">
|
|
<span>⏱️ ${formatDuration(job.started_at, job.completed_at)}</span><span>📄 ${crawled} pages</span>
|
|
<span>🖼️ ${stats.media_found || 0}</span><span>💾 ${stats.media_downloaded || 0}</span>
|
|
<span>📸 ${stats.screenshots_taken || 0}</span><span>♻️ ${(stats.duplicates_skipped || 0) + (stats.content_exists || 0)}</span>
|
|
${stats.bytes_stored ? '<span>📦 ' + formatBytes(stats.bytes_stored) + '</span>' : ''}
|
|
</div>` : ''}
|
|
${liveStats}
|
|
${job.status === 'running' && job.job_kind === 'crawl' ? '<div class="progress-bar"><div class="progress-bar-fill" style="width:' + crawlProgress + '%"></div></div>' : ''}
|
|
<div class="job-actions">
|
|
${job.status === 'running' && job.job_kind === 'crawl' ? '<button class="pause-btn" onclick="pauseJob(' + job.id + ')">' + T.pause + '</button>' : ''}
|
|
${(job.status === 'paused' || job.status === 'cancelled') && job.job_kind === 'crawl' ? '<button class="start-btn" onclick="resumeJob(' + job.id + ')">' + T.resume + '</button>' : ''}
|
|
${(job.status === 'completed' || job.status === 'failed' || job.status === 'cancelled') && job.job_kind === 'crawl' ? "<button class=\"recrawl-btn\" onclick='loadJobSettings(" + JSON.stringify(job) + ")'>↻</button>" : ''}
|
|
${job.status === 'completed' && job.job_kind === 'crawl' ? '<button class="replay-btn" onclick="replayJob(' + job.id + ')">▶ Replay</button>' : ''}
|
|
<button class="console-btn" onclick="openConsole(${job.id})">${T.console}</button>
|
|
${job.status !== 'running' ? '<button class="delete-btn" onclick="deleteJob(' + job.id + ')">' + T.delete + '</button>' : ''}
|
|
</div>
|
|
</div>`;
|
|
}
|
|
|
|
loadJobs();
|
|
|
|
let consoleJobId = null, consoleInterval = null;
|
|
|
|
function openConsole(jobId) {
|
|
consoleJobId = jobId;
|
|
document.getElementById('console-modal').classList.add('active');
|
|
document.getElementById('console-title').textContent = T.console + ' #' + jobId;
|
|
document.getElementById('console-logs').textContent = T.loading;
|
|
fetchLogs();
|
|
consoleInterval = setInterval(fetchLogs, 2000);
|
|
}
|
|
|
|
function closeConsole() {
|
|
document.getElementById('console-modal').classList.remove('active');
|
|
if (consoleInterval) { clearInterval(consoleInterval); consoleInterval = null; }
|
|
consoleJobId = null;
|
|
}
|
|
|
|
async function fetchLogs() {
|
|
if (!consoleJobId) return;
|
|
try {
|
|
const res = await fetch('/api/crawl/jobs/' + consoleJobId + '/logs');
|
|
const logs = await res.text();
|
|
const el = document.getElementById('console-logs');
|
|
const wasAtBottom = el.scrollHeight - el.clientHeight <= el.scrollTop + 50;
|
|
el.innerHTML = logs ? logs.split('\n').map(line => {
|
|
let cls = 'log-line';
|
|
if (line.includes(' INFO:')) cls += ' log-INFO';
|
|
else if (line.includes(' WARNING:')) cls += ' log-WARNING';
|
|
else if (line.includes(' ERROR:')) cls += ' log-ERROR';
|
|
return '<div class="' + cls + '">' + line.replace(/</g, '<') + '</div>';
|
|
}).join('') : T.no_logs;
|
|
if (wasAtBottom) el.scrollTop = el.scrollHeight;
|
|
} catch (err) {
|
|
document.getElementById('console-logs').textContent = T.error + ': ' + err.message;
|
|
}
|
|
}
|
|
|
|
document.addEventListener('keydown', e => { if (e.key === 'Escape') closeConsole(); });
|
|
</script>
|
|
{% endblock %}
|