Paralleles WebGUI mit Kestrel, Token-Sicherung und SSE-Logs implementiert

This commit is contained in:
Richard
2026-09-07 21:55:51 +02:00
parent 28b3aedec7
commit 10f928eb4e
36 changed files with 3114 additions and 266 deletions
+359
View File
@@ -0,0 +1,359 @@
// PolyTrader Web — Client-Side Application Logic
let currentView = 'dashboard';
let sseSource = null;
let allLogs = [];
// Init on DOM ready
document.addEventListener('DOMContentLoaded', () => {
initSseLogs();
refreshDashboard();
setInterval(refreshDashboard, 2500);
});
// Navigation
function switchView(viewName) {
currentView = viewName;
document.querySelectorAll('.nav-link').forEach(btn => btn.classList.remove('active'));
document.querySelectorAll('.view-panel').forEach(p => p.classList.remove('active'));
const activeBtn = Array.from(document.querySelectorAll('.nav-link')).find(b => b.getAttribute('onclick')?.includes(viewName));
if (activeBtn) activeBtn.classList.add('active');
const panel = document.getElementById(`view-${viewName}`);
if (panel) panel.classList.add('active');
if (viewName === 'accounts') loadAccounts();
if (viewName === 'jobs') loadJobs();
if (viewName === 'modules') loadModules();
if (viewName === 'settings') loadSettings();
if (viewName === 'logs') {
const container = document.getElementById('logContainer');
if (document.getElementById('chkAutoScroll').checked) {
container.scrollTop = container.scrollHeight;
}
}
}
// Dashboard Refresh
async function refreshDashboard() {
try {
const res = await fetch('/api/dashboard/summary');
if (res.status === 401) {
window.location.href = '/login';
return;
}
if (!res.ok) return;
const data = await res.json();
// Status indicators
document.getElementById('dotAlchemy').className = data.alchemyHealthy ? 'dot online' : 'dot offline';
document.getElementById('dotVpn').className = data.vpnConnected ? 'dot online' : 'dot offline';
document.getElementById('dotWatchdog').className = data.watchdogActive ? 'dot online' : 'dot offline';
// KPIs
const pnlEl = document.getElementById('valGlobalPnl');
pnlEl.textContent = formatCurrency(data.globalPnl);
pnlEl.style.color = data.globalPnl >= 0 ? 'var(--positive)' : 'var(--negative)';
document.getElementById('valTotalUsdc').textContent = formatCurrency(data.totalUsdcBalance);
document.getElementById('valAccountCount').textContent = `${data.accountCount} Konten konfiguriert`;
document.getElementById('valPortfolioValue').textContent = formatCurrency(data.totalPortfolioValue);
// Pause Button
const pauseBtn = document.getElementById('btnPause');
if (data.globalTradingPaused) {
pauseBtn.textContent = '▶ Trading fortsetzen';
pauseBtn.className = 'btn btn-primary';
pauseBtn.style.background = 'var(--positive)';
} else {
pauseBtn.textContent = '⏸ Trading pausieren';
pauseBtn.className = 'btn btn-danger';
}
// Toggles
updateToggleGroup('groupLiveTrading', data.liveTradingMode);
updateToggleGroup('groupDemoTrading', data.demoTradingMode);
} catch (e) {
// Fehler bei Verbindung
}
}
function updateToggleGroup(groupId, currentMode) {
const group = document.getElementById(groupId);
if (!group) return;
const buttons = group.querySelectorAll('.btn-toggle');
buttons.forEach(btn => {
btn.className = 'btn-toggle';
const mode = btn.getAttribute('onclick')?.match(/'([^']+)'\)$/)?.[1];
if (mode && mode.toLowerCase() === currentMode.toLowerCase()) {
if (mode === 'Active') btn.classList.add('active-green');
else if (mode === 'SellOnly') btn.classList.add('active-yellow');
else btn.classList.add('active-red');
}
});
}
async function setTradingMode(type, mode) {
try {
const res = await fetch('/api/dashboard/trading-mode', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ modeType: type, targetMode: mode })
});
if (res.ok) refreshDashboard();
} catch (e) {}
}
async function togglePause() {
try {
const res = await fetch('/api/dashboard/trading-mode', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ modeType: 'pause', targetMode: '' })
});
if (res.ok) refreshDashboard();
} catch (e) {}
}
// Accounts View
async function loadAccounts() {
try {
const res = await fetch('/api/accounts');
if (!res.ok) return;
const accounts = await res.json();
const tbody = document.getElementById('tblAccounts');
if (accounts.length === 0) {
tbody.innerHTML = '<tr><td colspan="8" style="text-align: center; color: var(--muted);">Keine Konten gefunden.</td></tr>';
return;
}
tbody.innerHTML = accounts.map(a => `
<tr>
<td><b>${a.accountId}</b></td>
<td>${escapeHtml(a.name)}</td>
<td><span style="color: ${a.isActive ? 'var(--positive)' : 'var(--muted)'};">●</span> ${a.isActive ? 'Aktiv' : 'Inaktiv'}</td>
<td>${a.isDemo ? '<span style="color: var(--warning);">Demo</span>' : '<span style="color: var(--positive);">Live</span>'}</td>
<td>${formatCurrency(a.usdcBalance)}</td>
<td>${formatCurrency(a.portfolioValue)}</td>
<td style="font-family: var(--font-mono); font-size: 11px; color: var(--muted);">${shortenAddress(a.proxyWalletAddress)}</td>
<td style="color: var(--muted); font-size: 11px;">${a.lastPolledAt ? new Date(a.lastPolledAt).toLocaleTimeString() : '—'}</td>
</tr>
`).join('');
} catch (e) {}
}
// Jobs View
async function loadJobs() {
try {
const res = await fetch('/api/jobs');
if (!res.ok) return;
const jobs = await res.json();
const tbody = document.getElementById('tblJobs');
if (jobs.length === 0) {
tbody.innerHTML = '<tr><td colspan="7" style="text-align: center; color: var(--muted);">Keine Jobs registriert.</td></tr>';
return;
}
tbody.innerHTML = jobs.map(j => `
<tr>
<td><b>${escapeHtml(j.jobName)}</b></td>
<td style="color: var(--muted); font-size: 12px;">${escapeHtml(j.description)}</td>
<td><span style="color: ${j.isEnabled ? 'var(--positive)' : 'var(--muted)'};">●</span> ${j.isEnabled ? 'Ja' : 'Nein'}</td>
<td style="font-size: 12px; color: var(--muted);">${j.lastRun ? new Date(j.lastRun).toLocaleTimeString() : '—'}</td>
<td style="font-size: 12px; color: var(--muted);">${j.nextRun ? new Date(j.nextRun).toLocaleTimeString() : '—'}</td>
<td><span style="padding: 2px 6px; background: var(--surface-alt); border-radius: 4px; font-size: 11px;">${escapeHtml(j.statusText)}</span></td>
<td>
${j.canTriggerManually ? `<button class="btn btn-secondary" style="padding: 3px 8px; font-size: 11px;" onclick="triggerJob('${escapeHtml(j.jobName)}')">Ausführen</button>` : ''}
</td>
</tr>
`).join('');
} catch (e) {}
}
async function triggerJob(jobName) {
try {
await fetch(`/api/jobs/${encodeURIComponent(jobName)}/run`, { method: 'POST' });
setTimeout(loadJobs, 500);
} catch (e) {}
}
// Modules View
async function loadModules() {
try {
const res = await fetch('/api/modules');
if (!res.ok) return;
const modules = await res.json();
const container = document.getElementById('modulesContainer');
container.innerHTML = modules.map(m => `
<div class="card">
<div class="card-title">
<span>${escapeHtml(m.name)}</span>
<span style="font-size: 12px; color: ${m.isActive ? 'var(--positive)' : 'var(--muted)'}; font-weight: normal;">
● ${m.isActive ? 'Aktiv im Betrieb' : 'Deaktiviert'}
</span>
</div>
<div style="font-size: 13px; color: var(--muted);">
${m.isActive ? 'Modul läuft planmäßig im Hintergrund.' : 'Nicht geladen (in den Servereinstellungen oder über DisabledModules deaktiviert).'}
</div>
</div>
`).join('');
} catch (e) {}
}
// Terminal & SSE Logs
function initSseLogs() {
const sseDot = document.getElementById('sseDot');
const sseStatus = document.getElementById('sseStatus');
if (sseSource) sseSource.close();
sseSource = new EventSource('/api/logs/stream');
sseSource.onopen = () => {
sseDot.className = 'dot online';
sseStatus.textContent = 'Verbunden';
};
sseSource.onmessage = (event) => {
if (!event.data || event.data.startsWith(':')) return;
try {
const entry = JSON.parse(event.data);
appendLog(entry);
} catch (e) {}
};
sseSource.onerror = () => {
sseDot.className = 'dot offline';
sseStatus.textContent = 'Verbindung getrennt (Reconnect …)';
};
}
function appendLog(entry) {
allLogs.push(entry);
if (allLogs.length > 2000) allLogs.shift();
const filter = document.getElementById('logLevelFilter').value;
if (filter !== 'ALL' && entry.level.toUpperCase() !== filter) return;
renderLogEntry(entry);
}
function renderLogEntry(entry) {
const container = document.getElementById('logContainer');
const div = document.createElement('div');
div.className = 'log-entry';
div.innerHTML = `
<span class="log-time">[${entry.time}]</span>
<span class="log-level ${entry.level}">${entry.level}</span>
<span class="log-msg">${escapeHtml(entry.msg)}</span>
`;
container.appendChild(div);
if (document.getElementById('chkAutoScroll').checked && currentView === 'logs') {
container.scrollTop = container.scrollHeight;
}
}
function applyLogFilter() {
const container = document.getElementById('logContainer');
container.innerHTML = '';
const filter = document.getElementById('logLevelFilter').value;
allLogs.forEach(entry => {
if (filter === 'ALL' || entry.level.toUpperCase() === filter) {
renderLogEntry(entry);
}
});
}
function clearLogs() {
allLogs = [];
document.getElementById('logContainer').innerHTML = '';
}
// Settings View
async function loadSettings() {
try {
const res = await fetch('/api/settings/webui');
if (!res.ok) return;
const opt = await res.json();
document.getElementById('cfgEnabled').checked = opt.enabled;
document.getElementById('cfgPort').value = opt.port;
document.getElementById('cfgLocalOnly').checked = opt.localOnly;
document.getElementById('cfgToken').value = opt.token || '';
} catch (e) {}
}
async function saveSettings(e) {
e.preventDefault();
const enabled = document.getElementById('cfgEnabled').checked;
const port = parseInt(document.getElementById('cfgPort').value, 10);
const localOnly = document.getElementById('cfgLocalOnly').checked;
const token = document.getElementById('cfgToken').value.trim();
try {
const res = await fetch('/api/settings/webui', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled, port, localOnly, authToken: token })
});
if (res.ok) {
const msg = document.getElementById('settingsStatusMsg');
msg.style.display = 'inline';
setTimeout(() => { msg.style.display = 'none'; }, 3000);
}
} catch (e) {}
}
async function generateNewToken() {
if (!confirm('Neues Zugangs-Token generieren? Alle bestehenden Sessions müssen sich danach neu anmelden.')) return;
try {
const res = await fetch('/api/settings/webui/generate-token', { method: 'POST' });
if (res.ok) {
const data = await res.json();
document.getElementById('cfgToken').value = data.token;
alert('Neues Token generiert und in appsettings.Local.json gespeichert:\n\n' + data.token);
}
} catch (e) {}
}
function copyToken() {
const input = document.getElementById('cfgToken');
if (!input.value) return;
navigator.clipboard.writeText(input.value).then(() => {
alert('Token in Zwischenablage kopiert!');
});
}
// Auth
async function logout() {
try {
await fetch('/api/auth/logout', { method: 'POST' });
} finally {
window.location.href = '/login';
}
}
// Helpers
function formatCurrency(val) {
if (val === undefined || val === null) return '0.00 $';
return Number(val).toLocaleString('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + ' $';
}
function shortenAddress(addr) {
if (!addr) return '—';
if (addr.length <= 12) return addr;
return addr.substring(0, 6) + '…' + addr.substring(addr.length - 4);
}
function escapeHtml(str) {
if (!str) return '';
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}