Paralleles WebGUI mit Kestrel, Token-Sicherung und SSE-Logs implementiert
This commit is contained in:
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
Reference in New Issue
Block a user