feat: Web-UI als zweiter Ausgabeweg neben Avalonia-Shell (#490)
Build & Test / build (ubuntu-latest) (push) Canceled after 0s
Build & Test / build (windows-latest) (push) Canceled after 0s

- Einheitlicher WebserverWorker in IBKRTrader.Hosting auf Kestrel-Basis
- Token-Authentifizierung (AuthService) mit zeitkonstantem Vergleich und HMAC-Session-Tokens
- OrderManager im Core fuer Bestands- und Reservierungsabgleich offener Broker-Orders
- SettingsSchema fuer reflexionsbasiertes Auslesen und sichere Aktualisierung ohne Klartext-Passwoerter
- SSE-Streaming (LogStreamService) fuer Echtzeit-Logs ueber isolierte Channels
- Statische Single-Page-Application in wwwroot (Avalonia Fluent / Polytrader Theme) fuer alle 9 Ansichten
- Unit-Tests fuer OrderManager, AuthService, SettingsSchema, LogStreamService und WebserverWorker (498 Tests gruen)
This commit is contained in:
Richard
2026-09-06 19:55:40 +02:00
parent 7299148dc4
commit f923721784
35 changed files with 4856 additions and 370 deletions
@@ -0,0 +1,304 @@
// ─── Supervisor View ───
import { api } from '../api.js';
export class SupervisorView {
constructor(container, setStatus) {
this.container = container;
this.setStatus = setStatus;
this.currentSubTab = 'analysis';
this.profiles = [];
this.signals = [];
this.selectedSignal = null;
}
async render() {
this.container.innerHTML = `
<!-- Sub-Nav Tabs -->
<div style="background: #ffffff; border-bottom: 1px solid var(--border-color); display: flex; gap: 4px; padding: 0 12px;">
<button class="nav-tab active" data-subtab="analysis" id="sup-tab-analysis">Analyse</button>
<button class="nav-tab" data-subtab="dossier" id="sup-tab-dossier">Dossier-Browser</button>
<button class="nav-tab" data-subtab="reports" id="sup-tab-reports">Berichte</button>
<button class="nav-tab" data-subtab="info" id="sup-tab-info">Hinweise</button>
</div>
<div id="sup-subcontent" style="flex: 1; display: flex; flex-direction: column; overflow: hidden;">
<!-- Subtab content injected here -->
</div>
`;
const subtabs = this.container.querySelectorAll('[data-subtab]');
subtabs.forEach(tab => {
tab.addEventListener('click', () => {
subtabs.forEach(t => t.classList.remove('active'));
tab.classList.add('active');
this.currentSubTab = tab.getAttribute('data-subtab');
this.renderSubTab();
});
});
await this.loadProfiles();
await this.renderSubTab();
}
async loadProfiles() {
try {
const data = await api.get('/api/modules/supervisor/profiles');
if (Array.isArray(data)) {
this.profiles = data;
}
} catch (e) {
console.warn('Konnte Supervisor-Profile nicht laden:', e);
}
}
async renderSubTab() {
const sub = document.getElementById('sup-subcontent');
if (!sub) return;
if (this.currentSubTab === 'analysis') {
sub.innerHTML = `
<div class="toolbar">
<div class="toolbar-group">
<span style="font-weight: 500;">Profil:</span>
<select id="sup-profile-select" class="select" style="min-width: 180px;">
${this.profiles.map(p => `<option value="${p}">${p}</option>`).join('')}
</select>
<button id="sup-ask-btn" class="btn btn-primary">⚡ Fragen</button>
</div>
<div id="sup-ask-status" style="color: var(--text-muted); font-size: 11px;"></div>
</div>
<div style="padding: 10px 14px; background: #ffffff; border-bottom: 1px solid var(--border-color);">
<textarea id="sup-question-input" class="input" style="width: 100%; height: 72px; font-family: var(--font-family); resize: vertical;" placeholder="Frage an den Supervisor eingeben …"></textarea>
</div>
<div id="sup-answer-container" style="flex: 1; background: #14141e; color: #d2d2d2; font-family: var(--font-mono); font-size: 12px; line-height: 1.6; padding: 14px; overflow-y: auto; white-space: pre-wrap; word-break: break-word;">
Wählen Sie ein Profil, geben Sie eine Frage ein und klicken Sie auf 'Fragen'.
</div>
`;
document.getElementById('sup-ask-btn').addEventListener('click', () => this.ask());
document.getElementById('sup-question-input').addEventListener('keydown', (e) => {
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
this.ask();
}
});
} else if (this.currentSubTab === 'dossier') {
sub.innerHTML = `
<div style="flex: 1; display: flex; overflow: hidden;">
<!-- Left: Signals list -->
<div style="width: 420px; border-right: 1px solid var(--border-color); display: flex; flex-direction: column; background: #ffffff;">
<div class="toolbar" style="border-top: none;">
<button id="sup-load-signals-btn" class="btn">🔄 Signale laden</button>
</div>
<div class="table-container" style="flex: 1;">
<table class="datagrid">
<thead>
<tr>
<th style="width: 100px;">Zeit</th>
<th style="width: 80px;">Symbol</th>
<th style="width: 100px;">Modul</th>
<th>Entscheidung</th>
</tr>
</thead>
<tbody id="sup-signals-tbody">
<tr><td colspan="4" style="text-align:center; color: var(--text-muted);">Signale laden …</td></tr>
</tbody>
</table>
</div>
</div>
<!-- Right: Dossier detail markdown -->
<div style="flex: 1; display: flex; flex-direction: column; background: #14141e;">
<div style="padding: 8px 12px; background: #1e1e2d; border-bottom: 1px solid #2d2d3f; color: #a1a1aa; font-size: 11px; display: flex; justify-content: space-between;">
<span id="sup-dossier-header">Dossier-Details</span>
</div>
<div id="sup-dossier-body" style="flex: 1; overflow-y: auto; padding: 14px; color: #d2d2d2; font-family: var(--font-mono); font-size: 12px; line-height: 1.6; white-space: pre-wrap;">
Signal in der linken Tabelle auswählen.
</div>
</div>
</div>
`;
document.getElementById('sup-load-signals-btn').addEventListener('click', () => this.loadSignals());
await this.loadSignals();
} else if (this.currentSubTab === 'reports') {
sub.innerHTML = `
<div class="toolbar">
<button id="sup-load-reports-btn" class="btn">🔄 Berichte laden</button>
<div id="sup-reports-status" style="color: var(--text-muted); font-size: 11px;"></div>
</div>
<div class="table-container">
<table class="datagrid">
<thead>
<tr>
<th style="width: 140px;">Erstellt</th>
<th style="width: 120px;">Profil</th>
<th style="width: 160px;">Modell</th>
<th>Frage</th>
<th style="width: 60px; text-align: center;">Tools</th>
<th style="width: 110px;">Tokens</th>
</tr>
</thead>
<tbody id="sup-reports-tbody">
<tr><td colspan="6" style="text-align:center; color: var(--text-muted);">Lade Berichte …</td></tr>
</tbody>
</table>
</div>
`;
document.getElementById('sup-load-reports-btn').addEventListener('click', () => this.loadReports());
await this.loadReports();
} else if (this.currentSubTab === 'info') {
sub.innerHTML = `
<div style="padding: 24px; max-width: 800px; color: var(--text-main); line-height: 1.6;">
<h3 style="margin-bottom: 12px; font-size: 16px;">Supervisor Modul-Hinweise</h3>
<div id="sup-info-content">Lade Modul-Informationen …</div>
</div>
`;
await this.loadInfo();
}
}
async ask() {
const qEl = document.getElementById('sup-question-input');
const question = qEl?.value?.trim();
if (!question) {
alert('Bitte eine Frage eingeben.');
return;
}
const pEl = document.getElementById('sup-profile-select');
const profile = pEl?.value;
const btn = document.getElementById('sup-ask-btn');
const ansEl = document.getElementById('sup-answer-container');
const statusEl = document.getElementById('sup-ask-status');
btn.disabled = true;
btn.textContent = '⏳ Analysiere …';
if (statusEl) statusEl.textContent = 'Agent denkt nach …';
ansEl.textContent = 'Agent führt Tool-Aufrufe und Analysen durch … Bitte warten.';
try {
const res = await api.post('/api/modules/supervisor/ask', { question, profile });
if (res && res.success) {
let output = res.answer || '(Keine Antwort erhalten)';
if (res.toolCalls && res.toolCalls.length > 0) {
output += '\n\n── Ausgeführte Tool-Calls ──\n' +
res.toolCalls.map(tc => `• ${tc.tool}: ${JSON.stringify(tc.arguments)}`).join('\n');
}
ansEl.textContent = output;
if (statusEl) statusEl.textContent = `Fertig (${res.toolCallCount || 0} Tools genutzt).`;
} else {
ansEl.textContent = `Fehler: ${res?.message || 'Unbekannter Fehler'}`;
}
} catch (e) {
ansEl.textContent = `Fehler bei der Abfrage: ${e.message}`;
} finally {
btn.disabled = false;
btn.textContent = '⚡ Fragen';
}
}
async loadSignals() {
this.setStatus('Lade Signale …');
try {
const signals = await api.get('/api/modules/supervisor/signals?limit=100');
this.signals = Array.isArray(signals) ? signals : [];
const tbody = document.getElementById('sup-signals-tbody');
if (!tbody) return;
if (this.signals.length === 0) {
tbody.innerHTML = `<tr><td colspan="4" style="text-align:center; color: var(--text-muted);">Keine Signale gefunden.</td></tr>`;
} else {
tbody.innerHTML = this.signals.map((s, idx) => `
<tr data-idx="${idx}" class="${this.selectedSignal && this.selectedSignal.signalId === s.signalId ? 'selected' : ''}">
<td>${s.firstSeen ? s.firstSeen.substring(5, 16) : '–'}</td>
<td style="font-weight: 600;">${s.symbol || '–'}</td>
<td>${s.module || '–'}</td>
<td>${s.lastDecision || '–'}</td>
</tr>
`).join('');
tbody.querySelectorAll('tr').forEach(tr => {
tr.addEventListener('click', () => {
const idx = parseInt(tr.getAttribute('data-idx'), 10);
this.selectSignal(this.signals[idx]);
});
});
}
this.setStatus(`Signale geladen (${this.signals.length}).`);
} catch (e) {
this.setStatus(`Fehler: ${e.message}`);
}
}
async selectSignal(s) {
this.selectedSignal = s;
const headerEl = document.getElementById('sup-dossier-header');
const bodyEl = document.getElementById('sup-dossier-body');
if (!bodyEl) return;
if (headerEl) headerEl.textContent = `Dossier: ${s.symbol} (${s.signalId})`;
bodyEl.textContent = 'Lade Dossier …';
try {
const data = await api.get(`/api/modules/supervisor/dossier/${encodeURIComponent(s.signalId)}`);
if (data && data.markdown) {
bodyEl.textContent = data.markdown;
} else {
bodyEl.textContent = 'Kein Dossier für dieses Signal verfügbar.';
}
} catch (e) {
bodyEl.textContent = `Fehler beim Laden des Dossiers: ${e.message}`;
}
}
async loadReports() {
this.setStatus('Lade Berichte …');
try {
const reports = await api.get('/api/modules/supervisor/reports?limit=100');
const tbody = document.getElementById('sup-reports-tbody');
if (!tbody) return;
if (!Array.isArray(reports) || reports.length === 0) {
tbody.innerHTML = `<tr><td colspan="6" style="text-align:center; color: var(--text-muted);">Keine Berichte vorhanden.</td></tr>`;
} else {
tbody.innerHTML = reports.map(r => `
<tr title="${(r.answer || '').replace(/"/g, '&quot;')}">
<td>${r.createdAt}</td>
<td><b>${r.profile}</b></td>
<td style="font-family: var(--font-mono); font-size: 11px;">${r.model}</td>
<td>${r.question}</td>
<td class="text-center">${r.toolCallCount}</td>
<td style="font-size: 11px;">${r.tokens}</td>
</tr>
`).join('');
}
this.setStatus(`Berichte geladen (${reports ? reports.length : 0}).`);
} catch (e) {
this.setStatus(`Fehler: ${e.message}`);
}
}
async loadInfo() {
try {
const info = await api.get('/api/modules/supervisor/info');
const el = document.getElementById('sup-info-content');
if (!el || !info) return;
el.innerHTML = `
<div style="margin-bottom: 16px; padding: 12px; border-radius: 4px; border: 1px solid ${info.openRouterKeyConfigured ? '#86efac' : '#fde68a'}; background: ${info.openRouterKeyConfigured ? '#f0fdf4' : '#fffbeb'}; color: ${info.openRouterKeyConfigured ? '#15803d' : '#b45309'};">
<b>OpenRouter API-Key:</b> ${info.openRouterKeyConfigured ? '✅ Konfiguriert' : '⚠️ Nicht gesetzt (in settings.json oder OPENROUTER_API_KEY hinterlegen)'}
</div>
<p style="margin-bottom: 8px;"><b>Zeitzone:</b> ${info.timeZone || '–'}</p>
<p style="margin-bottom: 8px;"><b>Beschreibung:</b> ${info.description || '–'}</p>
`;
} catch (e) {
console.error(e);
}
}
}