Files
aitrade/pkg/app/web/static/app.js
T
kaedwen fe16cb55cb
Build and Push Docker Image / build-and-push (push) Successful in 3m43s
fix news page
Signed-off-by: kaedwen <kaedwen@heinrich.blue>
2026-07-09 22:54:40 +02:00

668 lines
22 KiB
JavaScript

// ===== CLIENT-SIDE ROUTER =====
let currentRoute = null;
let pageIntervals = {}; // Track intervals per page for cleanup
const routes = {
'/': 'overview',
'/overview': 'overview',
'/trades': 'trades',
'/whitelist': 'whitelist',
'/news': 'news'
};
async function navigateTo(path, skipHistory = false) {
const page = routes[path];
if (!page) {
console.warn('Unknown route:', path);
return;
}
// Skip if already on this page
if (currentRoute === page) return;
// Update browser history
if (!skipHistory) {
history.pushState({ page }, '', path);
}
// Clean up previous page
cleanupPage(currentRoute);
// Update active tab
updateActiveTab(page);
// Load new content
await loadContent(page);
// Initialize new page
initPage(page);
currentRoute = page;
}
async function loadContent(page) {
const contentDiv = document.getElementById('content');
if (!contentDiv) return;
contentDiv.innerHTML = '<p style="text-align: center; padding: 40px;">Loading...</p>';
try {
const resp = await fetch(`/content/${page}`);
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const html = await resp.text();
contentDiv.innerHTML = html;
} catch (err) {
console.error('Failed to load content:', err);
contentDiv.innerHTML = '<p style="text-align: center; padding: 40px; color: red;">Failed to load content</p>';
}
}
function updateActiveTab(page) {
document.querySelectorAll('.tab').forEach(tab => {
tab.classList.remove('active');
});
const activeTab = document.querySelector(`.tab[data-page="${page}"]`);
if (activeTab) {
activeTab.classList.add('active');
}
}
function initPage(page) {
switch(page) {
case 'overview':
loadBalance();
loadTrades();
break;
case 'trades':
loadTrades();
break;
case 'whitelist':
loadWhitelist();
break;
case 'news':
const newsInterval = initNewsPage();
pageIntervals.newsRefresh = newsInterval;
break;
}
// Initialize sticky headers for the new page
setTimeout(() => initStickyHeaders(), 100);
}
function cleanupPage(page) {
// Clear any intervals for previous page
if (pageIntervals[page]) {
clearInterval(pageIntervals[page]);
delete pageIntervals[page];
}
}
// Intercept tab link clicks
document.addEventListener('click', (e) => {
const link = e.target.closest('a.tab');
if (link && link.origin === location.origin) {
e.preventDefault();
const path = link.getAttribute('href');
navigateTo(path);
}
});
// Handle browser back/forward
window.addEventListener('popstate', (e) => {
const path = location.pathname;
navigateTo(path, true); // true = skip pushState
});
// ===== NEWS PAGE FUNCTIONS =====
let newsCurrentFilter = 'all';
let allNewsArticles = [];
let newsCurrentOffset = 0;
let newsIsLoading = false;
let newsHasMore = true;
const NEWS_BATCH_SIZE = 50;
function initNewsPage() {
// Reset state
newsCurrentOffset = 0;
newsIsLoading = false;
newsHasMore = true;
allNewsArticles = [];
// Initial load
loadNewsCount();
loadMoreNews();
// Set up filter buttons
document.querySelectorAll('.filter-btn').forEach(btn => {
btn.addEventListener('click', function() {
document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active'));
this.classList.add('active');
newsCurrentFilter = this.dataset.filter;
renderNews(allNewsArticles);
});
});
// Auto-refresh every 30 seconds
return setInterval(() => {
loadNewsCount();
loadMoreNews();
}, 30000);
}
function loadNewsCount() {
fetch('/api/news/count?hours=1')
.then(response => response.json())
.then(data => {
const countEl = document.getElementById('news-count-hour');
if (countEl) {
countEl.textContent = data.count + ' in last hour';
}
})
.catch(error => console.error('Failed to load news count:', error));
}
function loadMoreNews() {
if (newsIsLoading || !newsHasMore) return;
newsIsLoading = true;
const container = document.getElementById('news-list');
fetch(`/api/news/recent?limit=${NEWS_BATCH_SIZE}&offset=${newsCurrentOffset}`)
.then(response => response.json())
.then(data => {
const newArticles = data || [];
if (newArticles.length < NEWS_BATCH_SIZE) {
newsHasMore = false;
}
allNewsArticles = allNewsArticles.concat(newArticles);
appendNews(newArticles);
newsCurrentOffset += newArticles.length;
newsIsLoading = false;
})
.catch(error => {
console.error('Failed to load news:', error);
if (container) {
container.innerHTML = '<p class="no-news">Failed to load news. Please try again later.</p>';
}
newsIsLoading = false;
});
}
function appendNews(articles) {
const container = document.getElementById('news-list');
if (!container) return;
// Clear loading message on first load
if (newsCurrentOffset === 0) {
container.innerHTML = '';
}
const filtered = newsCurrentFilter === 'all'
? articles
: articles.filter(a => getSentimentLabel(a) === newsCurrentFilter);
filtered.forEach(article => {
const sentiment = getSentimentLabel(article);
const score = article.SentimentScore;
const symbols = article.Symbols ? article.Symbols.split(',').filter(s => s.trim()) : [];
const method = article.SentimentMethod || 'keyword';
const articleEl = document.createElement('div');
articleEl.className = `news-article ${sentiment}`;
articleEl.innerHTML = `
<div class="news-article-header">
<h3 class="news-title">
<a href="${article.URL}" target="_blank" rel="noopener">${article.Title}</a>
</h3>
<div class="news-sentiment">
${score !== null && score !== undefined ?
`<span class="sentiment-score">${formatScore(score)}</span>` : ''}
<span class="sentiment-badge ${sentiment}">${sentiment}</span>
</div>
</div>
<div class="news-meta">
<span class="news-source">${article.Source}</span>
<span class="news-time" title="${new Date(article.PublishedAt).toLocaleString()}">${formatTimeAgo(article.PublishedAt)}</span>
<span class="news-fetched" title="Fetched: ${new Date(article.FetchedAt).toLocaleString()}">📥 ${formatTimeAgo(article.FetchedAt)}</span>
</div>
${article.Content ? `<div class="news-content">${article.Content}</div>` : ''}
${symbols.length > 0 ? `
<div class="news-symbols">
${symbols.map(s => `<span class="symbol-tag">${s.trim()}</span>`).join('')}
</div>
` : ''}
<div class="news-method">Analysis: ${method}</div>
`;
container.appendChild(articleEl);
});
updateNewsStats();
}
function updateNewsStats() {
const countEl = document.getElementById('news-count');
const updateEl = document.getElementById('last-update');
const filtered = newsCurrentFilter === 'all'
? allNewsArticles
: allNewsArticles.filter(a => getSentimentLabel(a) === newsCurrentFilter);
if (countEl) {
countEl.textContent = filtered.length + ' article' + (filtered.length !== 1 ? 's' : '');
}
if (updateEl) {
updateEl.textContent = 'Last update: ' + new Date().toLocaleTimeString();
}
}
function loadNews() {
fetch('/api/news/recent?limit=50')
.then(response => response.json())
.then(data => {
allNewsArticles = data || [];
renderNews(allNewsArticles);
})
.catch(error => {
console.error('Failed to load news:', error);
const container = document.getElementById('news-list');
if (container) {
container.innerHTML = '<p class="no-news">Failed to load news. Please try again later.</p>';
}
});
}
function renderNews(articles) {
// Deprecated - now using appendNews for infinite scroll
const container = document.getElementById('news-list');
if (!container) return;
if (!articles || articles.length === 0) {
container.innerHTML = '<p class="no-news">No news articles available</p>';
return;
}
const filtered = newsCurrentFilter === 'all'
? articles
: articles.filter(a => getSentimentLabel(a) === newsCurrentFilter);
if (filtered.length === 0) {
container.innerHTML = '<p class="no-news">No ' + newsCurrentFilter + ' articles</p>';
return;
}
container.innerHTML = '';
filtered.forEach(article => {
const sentiment = getSentimentLabel(article);
const score = article.SentimentScore;
const symbols = article.Symbols ? article.Symbols.split(',').filter(s => s.trim()) : [];
const method = article.SentimentMethod || 'keyword';
const articleEl = document.createElement('div');
articleEl.className = `news-article ${sentiment}`;
articleEl.innerHTML = `
<div class="news-article-header">
<h3 class="news-title">
<a href="${article.URL}" target="_blank" rel="noopener">${article.Title}</a>
</h3>
<div class="news-sentiment">
${score !== null && score !== undefined ?
`<span class="sentiment-score">${formatScore(score)}</span>` : ''}
<span class="sentiment-badge ${sentiment}">${sentiment}</span>
</div>
</div>
<div class="news-meta">
<span class="news-source">${article.Source}</span>
<span class="news-time" title="${new Date(article.PublishedAt).toLocaleString()}">${formatTimeAgo(article.PublishedAt)}</span>
<span class="news-fetched" title="Fetched: ${new Date(article.FetchedAt).toLocaleString()}">📥 ${formatTimeAgo(article.FetchedAt)}</span>
</div>
${article.Content ? `<div class="news-content">${article.Content}</div>` : ''}
${symbols.length > 0 ? `
<div class="news-symbols">
${symbols.map(s => `<span class="symbol-tag">${s.trim()}</span>`).join('')}
</div>
` : ''}
<div class="news-method">Analysis: ${method}</div>
`;
container.appendChild(articleEl);
});
updateNewsStats();
}
function formatTimeAgo(dateString) {
const date = new Date(dateString);
const now = new Date();
const seconds = Math.floor((now - date) / 1000);
if (seconds < 60) return 'just now';
if (seconds < 3600) return Math.floor(seconds / 60) + 'm ago';
if (seconds < 86400) return Math.floor(seconds / 3600) + 'h ago';
return Math.floor(seconds / 86400) + 'd ago';
}
function formatScore(score) {
if (score === null || score === undefined) return '';
return (score > 0 ? '+' : '') + score.toFixed(2);
}
function getSentimentLabel(article) {
return article.SentimentLabel || 'neutral';
}
// ===== SSE CONNECTION =====
// SSE connection
const evtSource = new EventSource('/events');
evtSource.onmessage = function(event) {
console.log('SSE:', event.data);
// Update relevant data based on current page and event type
if (event.data.includes('whitelist')) {
if (currentRoute === 'whitelist') loadWhitelist();
}
if (event.data.includes('trade')) {
if (currentRoute === 'overview' || currentRoute === 'trades') {
loadTrades();
}
}
if (event.data.includes('news')) {
if (currentRoute === 'news') {
console.log('News updated, reloading...');
loadNews();
}
}
// Always update balance (shown in multiple places)
loadBalance();
};
// ===== INITIALIZATION =====
// Initial data and config (always load these)
loadBalance();
loadConfigOnce();
// Initial route dispatch
if (window.initialPage) {
// Server-rendered initial page, content already in DOM
currentRoute = window.initialPage;
updateActiveTab(currentRoute);
initPage(currentRoute);
} else {
// Client-side navigation (e.g., page refresh)
navigateTo(location.pathname, true);
}
setInterval(() => { loadBalance(); loadTrades(); }, 10000);
async function loadConfigOnce() {
// Load fresh config from API (no caching to ensure we always get latest state)
try {
const resp = await fetch('/api/config');
const config = await resp.json();
updateBanner(config);
} catch (err) {
console.error('Failed to load config:', err);
}
}
function updateBanner(config) {
const banner = document.getElementById('mode-banner');
if (banner) {
if (config.dry_run) {
banner.textContent = '⚠️ DRY RUN MODE';
banner.className = 'mode-banner mode-dryrun loaded';
} else {
banner.textContent = '🔴 LIVE MODE';
banner.className = 'mode-banner mode-live loaded';
}
}
}
async function loadConfig() {
try {
const resp = await fetch('/api/config');
const config = await resp.json();
const banner = document.getElementById('mode-banner');
if (banner) {
if (config.dry_run) {
banner.textContent = '⚠️ DRY RUN MODE';
banner.className = 'mode-banner mode-dryrun loaded';
} else {
banner.textContent = '🔴 LIVE MODE';
banner.className = 'mode-banner mode-live loaded';
}
}
} catch (err) {
console.error('Failed to load config:', err);
}
}
async function loadBalance() {
const resp = await fetch('/api/balance');
const data = await resp.json();
const balanceEl = document.getElementById('balance');
if (balanceEl) {
balanceEl.textContent = '$' + (data.TotalValue || 0).toLocaleString('en-US', {
minimumFractionDigits: 2,
maximumFractionDigits: 2
});
}
}
async function loadTrades() {
const resp = await fetch('/api/trades');
const trades = await resp.json();
let pending = 0, active = 0;
// Overview table (last 10)
const overviewBody = document.getElementById('trades-body-overview');
if (overviewBody) {
overviewBody.innerHTML = '';
trades.slice(0, 10).forEach(trade => {
if (trade.status === 'PENDING') pending++;
if (trade.status === 'SUBMITTED' || trade.status === 'FILLED') active++;
overviewBody.innerHTML += buildTradeRow(trade, false);
});
}
// Full trades table
const tradesBody = document.getElementById('trades-body');
if (tradesBody) {
tradesBody.innerHTML = '';
trades.forEach(trade => {
tradesBody.innerHTML += buildTradeRow(trade, true);
});
}
const pendingEl = document.getElementById('pending-trades');
const activeEl = document.getElementById('active-trades');
if (pendingEl) pendingEl.textContent = pending;
if (activeEl) activeEl.textContent = active;
}
function buildTradeRow(trade, detailed) {
const dryBadge = trade.is_dry_run ? '<span class="dry-run-badge">DRY</span>' : '';
const price = trade.executed_price ? '$' + trade.executed_price.toFixed(2) : '-';
const pnl = formatPnL(trade.dry_run_pnl);
const actions = trade.status === 'PENDING' ?
'<button class="action-btn approve-btn" onclick="approveTrade(' + trade.id + ', false)">✓</button>' +
'<button class="action-btn force-btn" onclick="approveTrade(' + trade.id + ', true)">⚡</button>' +
'<button class="action-btn reject-btn" onclick="rejectTrade(' + trade.id + ')">✕</button>' : '-';
let row = '<tr>' +
'<td>' + trade.id + dryBadge + '</td>' +
'<td>' + trade.symbol + '</td>' +
'<td>' + trade.action + '</td>' +
'<td>' + trade.quantity + '</td>' +
'<td><span class="status status-' + trade.status.toLowerCase() + '">' + trade.status + '</span></td>' +
'<td>' + price + '</td>' +
'<td>' + pnl + '</td>' +
'<td>' + (trade.confidence * 100).toFixed(0) + '%</td>';
if (detailed) {
row += '<td style="max-width: 200px; font-size: 11px;">' + (trade.reasoning || '-') + '</td>';
}
row += '<td>' + new Date(trade.created_at).toLocaleString() + '</td>' +
'<td>' + actions + '</td></tr>';
return row;
}
function formatPnL(pnl) {
if (!pnl) return '-';
const formatted = '$' + Math.abs(pnl).toFixed(2);
const cssClass = pnl >= 0 ? 'pnl-positive' : 'pnl-negative';
const sign = pnl >= 0 ? '+' : '-';
return '<span class="' + cssClass + '">' + sign + formatted + '</span>';
}
async function approveTrade(id, forceNow) {
await fetch('/api/trades/' + id + '/approve?force=' + forceNow, { method: 'POST' });
loadTrades();
}
async function rejectTrade(id) {
const reason = prompt('Rejection reason:') || 'User rejected';
await fetch('/api/trades/' + id + '/reject', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ reason })
});
loadTrades();
}
// Whitelist functions
async function loadWhitelist() {
const resp = await fetch('/api/whitelist');
const entries = await resp.json();
const tbody = document.getElementById('whitelist-body');
tbody.innerHTML = '';
entries.forEach(entry => {
const statusClass = entry.Enabled ? 'enabled' : 'disabled';
const statusText = entry.Enabled ? '✓ Enabled' : '✕ Disabled';
tbody.innerHTML += '<tr>' +
'<td><strong>' + entry.Symbol + '</strong></td>' +
'<td>' + (entry.Name || '-') + '</td>' +
'<td>' + (entry.WKN || '-') + '</td>' +
'<td>' + (entry.ISIN || '-') + '</td>' +
'<td class="' + statusClass + '">' + statusText + '</td>' +
'<td style="max-width: 200px; font-size: 11px;">' + (entry.Notes || '-') + '</td>' +
'<td>' +
'<button class="action-btn edit-btn" onclick="editWhitelist(' + entry.ID + ')">✎</button>' +
'<button class="action-btn delete-btn" onclick="deleteWhitelist(' + entry.ID + ')">🗑</button>' +
'</td></tr>';
});
}
function showAddModal() {
document.getElementById('modal-title').textContent = 'Add Symbol';
document.getElementById('whitelist-form').reset();
document.getElementById('entry-id').value = '';
document.getElementById('entry-enabled').checked = true;
document.getElementById('whitelist-modal').classList.add('active');
}
async function editWhitelist(id) {
const resp = await fetch('/api/whitelist/' + id);
const entry = await resp.json();
document.getElementById('modal-title').textContent = 'Edit Symbol';
document.getElementById('entry-id').value = entry.ID;
document.getElementById('entry-symbol').value = entry.Symbol;
document.getElementById('entry-name').value = entry.Name || '';
document.getElementById('entry-wkn').value = entry.WKN || '';
document.getElementById('entry-isin').value = entry.ISIN || '';
document.getElementById('entry-enabled').checked = entry.Enabled;
document.getElementById('entry-notes').value = entry.Notes || '';
document.getElementById('whitelist-modal').classList.add('active');
}
async function saveWhitelist(event) {
event.preventDefault();
const id = document.getElementById('entry-id').value;
const data = {
symbol: document.getElementById('entry-symbol').value,
name: document.getElementById('entry-name').value,
wkn: document.getElementById('entry-wkn').value,
isin: document.getElementById('entry-isin').value,
enabled: document.getElementById('entry-enabled').checked,
notes: document.getElementById('entry-notes').value
};
const url = id ? '/api/whitelist/' + id : '/api/whitelist';
const method = id ? 'PUT' : 'POST';
await fetch(url, {
method: method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
closeModal();
loadWhitelist();
}
async function deleteWhitelist(id) {
if (!confirm('Delete this symbol from whitelist?')) return;
await fetch('/api/whitelist/' + id, { method: 'DELETE' });
loadWhitelist();
}
function closeModal() {
document.getElementById('whitelist-modal').classList.remove('active');
}
// ===== STICKY HEADER HANDLER =====
let stickyScrollHandler = null;
function initStickyHeaders() {
// Remove old scroll handler if exists
if (stickyScrollHandler) {
window.removeEventListener('scroll', stickyScrollHandler);
}
const stickyHeaders = document.querySelectorAll('.sticky-header');
if (stickyHeaders.length === 0) return;
stickyScrollHandler = () => {
stickyHeaders.forEach(header => {
if (window.scrollY > 50) {
header.classList.add('scrolled');
} else {
header.classList.remove('scrolled');
}
});
// Infinite scroll for news page
if (currentRoute === 'news') {
if ((window.innerHeight + window.scrollY) >= document.body.offsetHeight - 500) {
loadMoreNews();
}
}
};
window.addEventListener('scroll', stickyScrollHandler);
}
// Initialize sticky headers after page load
document.addEventListener('DOMContentLoaded', () => {
initStickyHeaders();
});