394 lines
18 KiB
JavaScript
394 lines
18 KiB
JavaScript
function initDashboard() {
|
|
function commaFormat(n) {
|
|
return n.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',');
|
|
}
|
|
|
|
function escapeHtml(text) {
|
|
if (text == null) return '';
|
|
const div = document.createElement('div');
|
|
div.textContent = text;
|
|
return div.innerHTML;
|
|
}
|
|
|
|
// State
|
|
let infiniteScrollState = null;
|
|
let isLoadingMore = false;
|
|
let searchQuery = '';
|
|
|
|
// Event delegation for domain-spacer clicks (toggle feeds)
|
|
document.addEventListener('click', (e) => {
|
|
const spacer = e.target.closest('.domain-spacer');
|
|
if (spacer) {
|
|
const block = spacer.closest('.domain-block');
|
|
if (block) {
|
|
const feedsDiv = block.querySelector('.domain-feeds');
|
|
if (feedsDiv) {
|
|
const isVisible = feedsDiv.style.display !== 'none';
|
|
feedsDiv.style.display = isVisible ? 'none' : 'block';
|
|
if (!isVisible) {
|
|
feedsDiv.querySelectorAll('.inline-feed-block').forEach(feedBlock => {
|
|
const itemsDiv = feedBlock.querySelector('.feed-items');
|
|
if (itemsDiv && !itemsDiv.dataset.loaded) {
|
|
itemsDiv.dataset.loaded = 'true';
|
|
loadFeedItems(feedBlock.dataset.url, itemsDiv);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
// Event delegation for feed-url-toggle clicks (toggle feed info)
|
|
document.addEventListener('click', (e) => {
|
|
const urlToggle = e.target.closest('.feed-url-toggle');
|
|
if (urlToggle) {
|
|
const feedBlock = urlToggle.closest('.inline-feed-block');
|
|
if (feedBlock) {
|
|
const infoDiv = feedBlock.querySelector('.feed-info');
|
|
if (infoDiv) {
|
|
const isVisible = infoDiv.style.display !== 'none';
|
|
infoDiv.style.display = isVisible ? 'none' : 'block';
|
|
if (!isVisible && !infoDiv.dataset.loaded) {
|
|
infoDiv.dataset.loaded = 'true';
|
|
loadFeedInfo(feedBlock.dataset.url, infoDiv);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
// Event delegation for feed-title-toggle and feed-filler-toggle clicks (toggle items)
|
|
document.addEventListener('click', (e) => {
|
|
const titleToggle = e.target.closest('.feed-title-toggle');
|
|
const fillerToggle = e.target.closest('.feed-filler-toggle');
|
|
if (titleToggle || fillerToggle) {
|
|
const feedBlock = (titleToggle || fillerToggle).closest('.inline-feed-block');
|
|
if (feedBlock) {
|
|
const itemsDiv = feedBlock.querySelector('.feed-items');
|
|
if (itemsDiv) {
|
|
const isVisible = itemsDiv.style.display !== 'none';
|
|
itemsDiv.style.display = isVisible ? 'none' : 'block';
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
// Load feed info
|
|
async function loadFeedInfo(feedUrl, infoDiv) {
|
|
infoDiv.innerHTML = '<span style="color: #666;">Loading...</span>';
|
|
try {
|
|
const resp = await fetch(`/api/feedInfo?url=${encodeURIComponent(feedUrl)}`);
|
|
if (!resp.ok) throw new Error('Failed to load');
|
|
const f = await resp.json();
|
|
|
|
let html = '<div style="display: grid; grid-template-columns: auto 1fr; gap: 4px 12px; color: #888;">';
|
|
const fields = [
|
|
['URL', f.url],
|
|
['Title', f.title],
|
|
['Description', f.description],
|
|
['Type', f.type],
|
|
['Language', f.language],
|
|
['Site URL', f.siteUrl],
|
|
['Status', f.status],
|
|
['Error Count', f.errorCount],
|
|
['Last Error', f.lastError],
|
|
['Item Count', f.itemCount],
|
|
['Avg Post Freq', f.avgPostFreqHrs ? f.avgPostFreqHrs.toFixed(1) + ' hrs' : null],
|
|
['Oldest Item', f.oldestItemDate],
|
|
['Newest Item', f.newestItemDate],
|
|
['Discovered', f.discoveredAt],
|
|
['Last Crawled', f.lastCrawledAt],
|
|
['Next Crawl', f.nextCrawlAt],
|
|
['TTL', f.ttlMinutes ? f.ttlMinutes + ' min' : null],
|
|
['Publish Status', f.publishStatus],
|
|
['Publish Account', f.publishAccount],
|
|
];
|
|
fields.forEach(([label, value]) => {
|
|
if (value != null && value !== '' && value !== 0) {
|
|
html += `<span style="color: #666;">${escapeHtml(label)}:</span><span style="color: #fff;">${escapeHtml(String(value))}</span>`;
|
|
}
|
|
});
|
|
html += '</div>';
|
|
infoDiv.innerHTML = html;
|
|
} catch (err) {
|
|
infoDiv.innerHTML = `<span style="color: #f66;">Error: ${escapeHtml(err.message)}</span>`;
|
|
}
|
|
}
|
|
|
|
// Load feed items
|
|
async function loadFeedItems(feedUrl, itemsDiv) {
|
|
itemsDiv.innerHTML = '<span style="color: #666;">Loading...</span>';
|
|
try {
|
|
const resp = await fetch(`/api/feedItems?url=${encodeURIComponent(feedUrl)}&limit=50`);
|
|
if (!resp.ok) throw new Error('Failed to load');
|
|
const items = await resp.json();
|
|
|
|
if (!items || items.length === 0) {
|
|
itemsDiv.innerHTML = '<span style="color: #666;">No items</span>';
|
|
return;
|
|
}
|
|
|
|
let html = '';
|
|
items.forEach(item => {
|
|
const date = item.pub_date ? new Date(item.pub_date).toLocaleDateString() : '';
|
|
html += `<div style="padding: 2px 0; border-bottom: 1px solid #1a1a1a;">`;
|
|
html += `<span style="color: #666; margin-right: 8px;">${escapeHtml(date)}</span>`;
|
|
if (item.link) {
|
|
html += `<a href="${escapeHtml(item.link)}" target="_blank" style="color: #0af; text-decoration: none;">${escapeHtml(item.title || item.link)}</a>`;
|
|
} else {
|
|
html += `<span style="color: #fff;">${escapeHtml(item.title || '(no title)')}</span>`;
|
|
}
|
|
html += '</div>';
|
|
});
|
|
itemsDiv.innerHTML = html;
|
|
} catch (err) {
|
|
itemsDiv.innerHTML = `<span style="color: #f66;">Error: ${escapeHtml(err.message)}</span>`;
|
|
}
|
|
}
|
|
|
|
// Status colors
|
|
const statusConfig = {
|
|
hold: { color: '#f90', bg: '#330', border: '#550' },
|
|
skip: { color: '#f66', bg: '#400', border: '#600' },
|
|
pass: { color: '#0f0', bg: '#040', border: '#060' },
|
|
fail: { color: '#f00', bg: '#400', border: '#600' }
|
|
};
|
|
|
|
// Render status buttons
|
|
function renderStatusBtns(currentStatus, type, id, errorStatus) {
|
|
const order = ['pass', 'hold', 'skip'];
|
|
const showFail = errorStatus === 'error' || errorStatus === 'dead';
|
|
let html = '<div class="status-btn-group" style="display: inline-flex; margin-right: 10px;">';
|
|
order.forEach((s, i) => {
|
|
const cfg = statusConfig[s];
|
|
const isActive = s === currentStatus;
|
|
const bg = isActive ? cfg.bg : '#111';
|
|
const border = isActive ? cfg.border : '#333';
|
|
const color = isActive ? cfg.color : '#444';
|
|
html += `<button class="status-btn" data-type="${type}" data-id="${escapeHtml(id)}" data-status="${s}"
|
|
style="padding: 2px 6px; font-family: monospace;
|
|
background: ${bg}; border: 1px solid ${border}; border-radius: 3px;
|
|
color: ${color}; cursor: pointer; margin-left: ${i > 0 ? '1px' : '0'};">${s}</button>`;
|
|
});
|
|
if (showFail) {
|
|
const cfg = statusConfig.fail;
|
|
html += `<button disabled style="padding: 2px 6px; font-family: monospace;
|
|
background: ${cfg.bg}; border: 1px solid ${cfg.border}; border-radius: 3px;
|
|
color: ${cfg.color}; cursor: default; margin-left: 1px;">fail</button>`;
|
|
}
|
|
html += '</div>';
|
|
return html;
|
|
}
|
|
|
|
// Render domain row with feeds
|
|
function renderDomainRow(d) {
|
|
const status = d.status || 'hold';
|
|
const hasError = !!d.last_error;
|
|
|
|
let html = `<div class="domain-block" data-host="${escapeHtml(d.host)}" data-status="${status}">`;
|
|
html += `<div class="domain-row" style="display: flex; align-items: center; padding: 8px 10px; border-bottom: 1px solid #202020;">`;
|
|
html += renderStatusBtns(status, 'domain', d.host, hasError ? 'error' : null);
|
|
html += `<a class="domain-name" href="https://${escapeHtml(d.host)}" target="_blank" style="color: #0af; text-decoration: none;">${escapeHtml(d.host)}</a>`;
|
|
|
|
if (d.last_error) {
|
|
html += `<span class="domain-spacer" style="color: #f66; margin-left: 10px; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; cursor: pointer;" title="${escapeHtml(d.last_error)}">${escapeHtml(d.last_error)}</span>`;
|
|
} else {
|
|
html += '<span class="domain-spacer" style="flex: 1; cursor: pointer;"> </span>';
|
|
}
|
|
html += '</div>';
|
|
|
|
// Feeds (shown by default in this view)
|
|
if (d.feeds && d.feeds.length > 0) {
|
|
html += '<div class="domain-feeds" style="display: block; margin-left: 10px; border-left: 2px solid #333; padding-left: 6px;">';
|
|
d.feeds.forEach(f => {
|
|
const feedStatus = f.publish_status || 'hold';
|
|
html += `<div class="inline-feed-block" data-url="${escapeHtml(f.url)}" data-status="${feedStatus}">`;
|
|
html += `<div class="feed-row" style="display: flex; align-items: center; padding: 4px 0;">`;
|
|
|
|
const lang = f.language || '';
|
|
html += `<span style="display: inline-block; width: 32px; margin-right: 6px; color: #666; font-family: monospace; text-align: center;">${escapeHtml(lang)}</span>`;
|
|
html += renderStatusBtns(feedStatus, 'feed', f.url, f.status);
|
|
|
|
const statusColor = f.status === 'active' ? '#484' : f.status === 'error' ? '#a66' : '#666';
|
|
html += `<span style="color: ${statusColor}; font-family: monospace; width: 50px; margin-right: 6px;">${escapeHtml(f.status || 'active')}</span>`;
|
|
|
|
if (f.item_count > 0) {
|
|
html += `<span style="color: #888; font-family: monospace; width: 55px; margin-right: 6px; text-align: right;">${commaFormat(f.item_count)}</span>`;
|
|
} else {
|
|
html += `<span style="width: 55px; margin-right: 6px;"></span>`;
|
|
}
|
|
|
|
let feedPath = f.url;
|
|
try {
|
|
const urlObj = new URL(f.url.startsWith('http') ? f.url : 'https://' + f.url);
|
|
feedPath = urlObj.pathname + urlObj.search;
|
|
} catch (e) {}
|
|
html += `<span class="feed-url-toggle" style="color: #0af; margin-right: 8px; white-space: nowrap; cursor: pointer;" title="Click to show feed info">${escapeHtml(feedPath)}</span>`;
|
|
|
|
if (f.title) {
|
|
html += `<span class="feed-title-toggle" style="color: #fff; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; cursor: pointer;">${escapeHtml(f.title)}</span>`;
|
|
}
|
|
html += '<span class="feed-filler-toggle" style="flex: 1; cursor: pointer;"> </span>';
|
|
html += '</div>';
|
|
html += '<div class="feed-info" style="display: none; padding: 6px 10px; margin-left: 10px; border-left: 2px solid #444; background: #0a0a0a;"></div>';
|
|
html += '<div class="feed-items" style="display: block; padding: 4px 10px; margin-left: 10px; border-left: 2px solid #333;"></div>';
|
|
html += '</div>';
|
|
});
|
|
html += '</div>';
|
|
}
|
|
html += '</div>';
|
|
return html;
|
|
}
|
|
|
|
// Attach status button handlers
|
|
function attachStatusHandlers(container) {
|
|
container.querySelectorAll('.status-btn:not(.btn-handled)').forEach(btn => {
|
|
btn.classList.add('btn-handled');
|
|
btn.addEventListener('click', async (e) => {
|
|
e.stopPropagation();
|
|
const type = btn.dataset.type;
|
|
const id = btn.dataset.id;
|
|
const newStatus = btn.dataset.status;
|
|
|
|
const endpoint = type === 'domain' ? '/api/setDomainStatus' : '/api/setPublishStatus';
|
|
const param = type === 'domain' ? 'host' : 'url';
|
|
|
|
try {
|
|
const resp = await fetch(`${endpoint}?${param}=${encodeURIComponent(id)}&status=${newStatus}`);
|
|
if (resp.ok) {
|
|
const group = btn.closest('.status-btn-group');
|
|
group.querySelectorAll('.status-btn').forEach(b => {
|
|
const s = b.dataset.status;
|
|
const cfg = statusConfig[s];
|
|
const isActive = s === newStatus;
|
|
b.style.background = isActive ? cfg.bg : '#111';
|
|
b.style.borderColor = isActive ? cfg.border : '#333';
|
|
b.style.color = isActive ? cfg.color : '#444';
|
|
});
|
|
const block = btn.closest(type === 'domain' ? '.domain-block' : '.inline-feed-block');
|
|
if (block) block.dataset.status = newStatus;
|
|
}
|
|
} catch (err) {
|
|
console.error('Status update failed:', err);
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
// Infinite scroll
|
|
function setupInfiniteScroll(loadMoreFn) {
|
|
infiniteScrollState = { loadMore: loadMoreFn, ended: false };
|
|
}
|
|
|
|
function clearInfiniteScroll() {
|
|
infiniteScrollState = null;
|
|
}
|
|
|
|
window.addEventListener('scroll', async () => {
|
|
if (!infiniteScrollState || infiniteScrollState.ended || isLoadingMore) return;
|
|
const scrollY = window.scrollY + window.innerHeight;
|
|
const docHeight = document.documentElement.scrollHeight;
|
|
if (scrollY > docHeight - 500) {
|
|
isLoadingMore = true;
|
|
await infiniteScrollState.loadMore();
|
|
isLoadingMore = false;
|
|
}
|
|
});
|
|
|
|
// Load and display feeds
|
|
async function loadFeeds(query = '') {
|
|
const output = document.getElementById('output');
|
|
output.innerHTML = '<div class="domain-list"></div><div id="infiniteLoader" style="text-align: center; padding: 10px; color: #666;">Loading...</div>';
|
|
|
|
let offset = 0;
|
|
const limit = 100;
|
|
|
|
async function loadMore() {
|
|
try {
|
|
let url = `/api/domains?limit=${limit}&offset=${offset}&sort=alpha&has_feeds=true`;
|
|
if (query) {
|
|
url += `&search=${encodeURIComponent(query)}`;
|
|
}
|
|
|
|
const resp = await fetch(url);
|
|
const domains = await resp.json();
|
|
|
|
if (!domains || domains.length === 0) {
|
|
if (infiniteScrollState) infiniteScrollState.ended = true;
|
|
document.getElementById('infiniteLoader').textContent = offset === 0 ? 'No feeds found' : 'End of list';
|
|
return;
|
|
}
|
|
|
|
const container = output.querySelector('.domain-list');
|
|
domains.forEach(d => {
|
|
container.insertAdjacentHTML('beforeend', renderDomainRow(d));
|
|
});
|
|
attachStatusHandlers(container);
|
|
|
|
// Load items for all feeds
|
|
container.querySelectorAll('.inline-feed-block').forEach(feedBlock => {
|
|
const itemsDiv = feedBlock.querySelector('.feed-items');
|
|
if (itemsDiv && !itemsDiv.dataset.loaded) {
|
|
itemsDiv.dataset.loaded = 'true';
|
|
loadFeedItems(feedBlock.dataset.url, itemsDiv);
|
|
}
|
|
});
|
|
|
|
offset += domains.length;
|
|
|
|
if (domains.length < limit) {
|
|
if (infiniteScrollState) infiniteScrollState.ended = true;
|
|
document.getElementById('infiniteLoader').textContent = 'End of list';
|
|
}
|
|
} catch (err) {
|
|
document.getElementById('infiniteLoader').textContent = 'Error: ' + err.message;
|
|
}
|
|
}
|
|
|
|
await loadMore();
|
|
setupInfiniteScroll(loadMore);
|
|
}
|
|
|
|
// Search handler
|
|
const searchInput = document.getElementById('searchInput');
|
|
let searchTimeout;
|
|
searchInput.addEventListener('input', () => {
|
|
clearTimeout(searchTimeout);
|
|
searchTimeout = setTimeout(() => {
|
|
searchQuery = searchInput.value.trim();
|
|
clearInfiniteScroll();
|
|
loadFeeds(searchQuery);
|
|
}, 300);
|
|
});
|
|
|
|
// Initial load
|
|
loadFeeds();
|
|
|
|
// Update stats periodically
|
|
async function updateStats() {
|
|
try {
|
|
const resp = await fetch('/api/stats');
|
|
const stats = await resp.json();
|
|
document.getElementById('totalDomains').textContent = commaFormat(stats.total_domains);
|
|
document.getElementById('holdDomains').textContent = commaFormat(stats.hold_domains);
|
|
document.getElementById('passDomains').textContent = commaFormat(stats.pass_domains);
|
|
document.getElementById('skipDomains').textContent = commaFormat(stats.skip_domains);
|
|
document.getElementById('failDomains').textContent = commaFormat(stats.fail_domains);
|
|
document.getElementById('crawlRate').textContent = commaFormat(stats.crawl_rate);
|
|
document.getElementById('checkRate').textContent = commaFormat(stats.check_rate);
|
|
document.getElementById('totalFeeds').textContent = commaFormat(stats.total_feeds);
|
|
document.getElementById('rssFeeds').textContent = commaFormat(stats.rss_feeds);
|
|
document.getElementById('atomFeeds').textContent = commaFormat(stats.atom_feeds);
|
|
document.getElementById('unknownFeeds').textContent = commaFormat(stats.unknown_feeds);
|
|
document.getElementById('updatedAt').textContent = 'Last updated: ' + new Date().toLocaleString();
|
|
} catch (err) {
|
|
console.error('Stats update failed:', err);
|
|
}
|
|
}
|
|
|
|
setInterval(updateStats, 60000);
|
|
}
|
|
|
|
document.addEventListener('DOMContentLoaded', initDashboard);
|