feat: Verbindungsdetails Modal mit Live-Ping

- Backend: Voice-Stats (Ping, Gateway, Status, Uptime) via SSE alle 5s
- Frontend: Klick auf Verbunden oeffnet Modal mit allen Verbindungsdetails
- Ping-Anzeige direkt im Header neben Verbunden
- Farbcodierte Ping-Dots (gruen <80ms, gelb <150ms, rot >=150ms)
- Uptime-Zaehler seit letztem VoiceConnection Ready
- connectedSince Tracking pro Guild
This commit is contained in:
Claude Code 2026-03-05 16:28:35 +01:00
parent 761032a280
commit 1a1fdf69c8
3 changed files with 2980 additions and 2774 deletions

View file

@ -320,6 +320,8 @@ const partyTimers = new Map<string, NodeJS.Timeout>();
const partyActive = new Set<string>(); const partyActive = new Set<string>();
// Now-Playing: aktuell gespielter Sound pro Guild // Now-Playing: aktuell gespielter Sound pro Guild
const nowPlaying = new Map<string, string>(); const nowPlaying = new Map<string, string>();
// Verbindungszeitpunkt pro Guild (fuer Uptime-Anzeige im Frontend)
const connectedSince = new Map<string, string>();
// SSE-Klienten für Broadcasts (z.B. Partymode Status) // SSE-Klienten für Broadcasts (z.B. Partymode Status)
const sseClients = new Set<Response>(); const sseClients = new Set<Response>();
function sseBroadcast(payload: any) { function sseBroadcast(payload: any) {
@ -625,6 +627,7 @@ function attachVoiceLifecycle(state: GuildAudioState, guild: any) {
if (newS.status === VoiceConnectionStatus.Ready) { if (newS.status === VoiceConnectionStatus.Ready) {
reconnectAttempts = 0; reconnectAttempts = 0;
isReconnecting = false; isReconnecting = false;
if (!connectedSince.has(state.guildId)) connectedSince.set(state.guildId, new Date().toISOString());
return; return;
} }
@ -661,6 +664,7 @@ function attachVoiceLifecycle(state: GuildAudioState, guild: any) {
} }
} }
} else if (newS.status === VoiceConnectionStatus.Destroyed) { } else if (newS.status === VoiceConnectionStatus.Destroyed) {
connectedSince.delete(state.guildId);
// Komplett neu beitreten // Komplett neu beitreten
const newConn = joinVoiceChannel({ const newConn = joinVoiceChannel({
channelId: state.channelId, channelId: state.channelId,
@ -1520,7 +1524,18 @@ app.get('/api/events', (req: Request, res: Response) => {
// Snapshot senden // Snapshot senden
try { try {
res.write(`data: ${JSON.stringify({ type: 'snapshot', party: Array.from(partyActive), selected: persistedState.selectedChannels ?? {}, volumes: persistedState.volumes ?? {}, nowplaying: Object.fromEntries(nowPlaying) })}\n\n`); const statsSnap: Record<string, any> = {};
for (const [gId, st] of guildAudioState) {
const ch = client.channels.cache.get(st.channelId);
statsSnap[gId] = {
voicePing: (st.connection.ping as any)?.ws ?? null,
gatewayPing: client.ws.ping,
status: st.connection.state?.status ?? 'unknown',
channelName: ch && 'name' in ch ? (ch as any).name : null,
connectedSince: connectedSince.get(gId) ?? null,
};
}
res.write(`data: ${JSON.stringify({ type: 'snapshot', party: Array.from(partyActive), selected: persistedState.selectedChannels ?? {}, volumes: persistedState.volumes ?? {}, nowplaying: Object.fromEntries(nowPlaying), voicestats: statsSnap })}\n\n`);
} catch {} } catch {}
// Ping, damit Proxies die Verbindung offen halten // Ping, damit Proxies die Verbindung offen halten
@ -1600,6 +1615,23 @@ app.listen(PORT, () => {
// Vollständige Cache-Synchronisation beim Start (Hintergrund) // Vollständige Cache-Synchronisation beim Start (Hintergrund)
syncNormCache(); syncNormCache();
// Voice-Stats alle 5 Sekunden an alle SSE-Clients broadcasten
setInterval(() => {
if (sseClients.size === 0 || guildAudioState.size === 0) return;
for (const [gId, st] of guildAudioState) {
const ch = client.channels.cache.get(st.channelId);
sseBroadcast({
type: 'voicestats',
guildId: gId,
voicePing: (st.connection.ping as any)?.ws ?? null,
gatewayPing: client.ws.ping,
status: st.connection.state?.status ?? 'unknown',
channelName: ch && 'name' in ch ? (ch as any).name : null,
connectedSince: connectedSince.get(gId) ?? null,
});
}
}, 5_000);
}); });

View file

@ -91,6 +91,17 @@ export default function App() {
const dragCounterRef = useRef(0); const dragCounterRef = useRef(0);
const uploadDismissRef = useRef<ReturnType<typeof setTimeout>>(); const uploadDismissRef = useRef<ReturnType<typeof setTimeout>>();
/* ── Voice Stats ── */
interface VoiceStats {
voicePing: number | null;
gatewayPing: number;
status: string;
channelName: string | null;
connectedSince: string | null;
}
const [voiceStats, setVoiceStats] = useState<VoiceStats | null>(null);
const [showConnModal, setShowConnModal] = useState(false);
/* ── UI ── */ /* ── UI ── */
const [notification, setNotification] = useState<{ msg: string; type: 'info' | 'error' } | null>(null); const [notification, setNotification] = useState<{ msg: string; type: 'info' | 'error' } | null>(null);
const [clock, setClock] = useState(''); const [clock, setClock] = useState('');
@ -233,6 +244,11 @@ export default function App() {
const g = selectedRef.current?.split(':')[0]; const g = selectedRef.current?.split(':')[0];
if (g && typeof np[g] === 'string') setLastPlayed(np[g]); if (g && typeof np[g] === 'string') setLastPlayed(np[g]);
} catch { } } catch { }
try {
const vs = msg?.voicestats || {};
const g = selectedRef.current?.split(':')[0];
if (g && vs[g]) setVoiceStats(vs[g]);
} catch { }
} else if (msg?.type === 'channel') { } else if (msg?.type === 'channel') {
const g = selectedRef.current?.split(':')[0]; const g = selectedRef.current?.split(':')[0];
if (msg.guildId === g) setSelected(`${msg.guildId}:${msg.channelId}`); if (msg.guildId === g) setSelected(`${msg.guildId}:${msg.channelId}`);
@ -242,6 +258,17 @@ export default function App() {
} else if (msg?.type === 'nowplaying') { } else if (msg?.type === 'nowplaying') {
const g = selectedRef.current?.split(':')[0]; const g = selectedRef.current?.split(':')[0];
if (msg.guildId === g) setLastPlayed(msg.name || ''); if (msg.guildId === g) setLastPlayed(msg.name || '');
} else if (msg?.type === 'voicestats') {
const g = selectedRef.current?.split(':')[0];
if (msg.guildId === g) {
setVoiceStats({
voicePing: msg.voicePing,
gatewayPing: msg.gatewayPing,
status: msg.status,
channelName: msg.channelName,
connectedSince: msg.connectedSince,
});
}
} }
}); });
return () => { try { unsub(); } catch { } }; return () => { try { unsub(); } catch { } };
@ -628,9 +655,12 @@ export default function App() {
</div> </div>
)} )}
{selected && ( {selected && (
<div className="connection"> <div className="connection" onClick={() => setShowConnModal(true)} style={{cursor:'pointer'}} title="Verbindungsdetails">
<span className="conn-dot" /> <span className="conn-dot" />
Verbunden Verbunden
{voiceStats?.voicePing != null && (
<span className="conn-ping">{voiceStats.voicePing}ms</span>
)}
</div> </div>
)} )}
<button <button
@ -948,6 +978,66 @@ export default function App() {
</div> </div>
)} )}
{/* ═══ CONNECTION MODAL ═══ */}
{showConnModal && voiceStats && (() => {
const uptimeSec = voiceStats.connectedSince
? Math.floor((Date.now() - new Date(voiceStats.connectedSince).getTime()) / 1000)
: 0;
const h = Math.floor(uptimeSec / 3600);
const m = Math.floor((uptimeSec % 3600) / 60);
const s = uptimeSec % 60;
const uptimeStr = h > 0
? `${h}h ${String(m).padStart(2,'0')}m ${String(s).padStart(2,'0')}s`
: m > 0
? `${m}m ${String(s).padStart(2,'0')}s`
: `${s}s`;
const pingColor = (ms: number | null) =>
ms == null ? 'var(--muted)' : ms < 80 ? 'var(--green)' : ms < 150 ? '#f0a830' : '#e04040';
return (
<div className="conn-modal-overlay" onClick={() => setShowConnModal(false)}>
<div className="conn-modal" onClick={e => e.stopPropagation()}>
<div className="conn-modal-header">
<span className="material-icons" style={{fontSize:20,color:'var(--green)'}}>cell_tower</span>
<span>Verbindungsdetails</span>
<button className="conn-modal-close" onClick={() => setShowConnModal(false)}>
<span className="material-icons">close</span>
</button>
</div>
<div className="conn-modal-body">
<div className="conn-stat">
<span className="conn-stat-label">Voice Ping</span>
<span className="conn-stat-value">
<span className="conn-ping-dot" style={{background: pingColor(voiceStats.voicePing)}} />
{voiceStats.voicePing != null ? `${voiceStats.voicePing} ms` : '---'}
</span>
</div>
<div className="conn-stat">
<span className="conn-stat-label">Gateway Ping</span>
<span className="conn-stat-value">
<span className="conn-ping-dot" style={{background: pingColor(voiceStats.gatewayPing)}} />
{voiceStats.gatewayPing >= 0 ? `${voiceStats.gatewayPing} ms` : '---'}
</span>
</div>
<div className="conn-stat">
<span className="conn-stat-label">Status</span>
<span className="conn-stat-value" style={{color: voiceStats.status === 'ready' ? 'var(--green)' : '#f0a830'}}>
{voiceStats.status === 'ready' ? 'Verbunden' : voiceStats.status}
</span>
</div>
<div className="conn-stat">
<span className="conn-stat-label">Kanal</span>
<span className="conn-stat-value">{voiceStats.channelName || '---'}</span>
</div>
<div className="conn-stat">
<span className="conn-stat-label">Verbunden seit</span>
<span className="conn-stat-value">{uptimeStr}</span>
</div>
</div>
</div>
</div>
);
})()}
{/* ═══ TOAST ═══ */} {/* ═══ TOAST ═══ */}
{notification && ( {notification && (
<div className={`toast ${notification.type}`}> <div className={`toast ${notification.type}`}>

View file

@ -353,6 +353,90 @@ input, select {
50% { box-shadow: 0 0 12px rgba(35, 165, 90, .8); } 50% { box-shadow: 0 0 12px rgba(35, 165, 90, .8); }
} }
.conn-ping {
font-size: 10px;
opacity: .7;
margin-left: 2px;
}
/* ── Connection Details Modal ── */
.conn-modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, .55);
z-index: 9000;
display: flex;
align-items: center;
justify-content: center;
backdrop-filter: blur(4px);
animation: fadeIn .15s ease;
}
.conn-modal {
background: var(--card);
border: 1px solid var(--border);
border-radius: 16px;
width: 340px;
box-shadow: 0 20px 60px rgba(0,0,0,.4);
overflow: hidden;
animation: slideUp .2s ease;
}
@keyframes slideUp {
from { transform: translateY(20px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
.conn-modal-header {
display: flex;
align-items: center;
gap: 8px;
padding: 14px 16px;
border-bottom: 1px solid var(--border);
font-weight: 700;
font-size: 14px;
}
.conn-modal-close {
margin-left: auto;
background: none;
border: none;
color: var(--muted);
cursor: pointer;
padding: 4px;
border-radius: 6px;
display: flex;
transition: all .15s;
}
.conn-modal-close:hover {
background: rgba(255,255,255,.08);
color: var(--fg);
}
.conn-modal-body {
padding: 16px;
display: flex;
flex-direction: column;
gap: 12px;
}
.conn-stat {
display: flex;
justify-content: space-between;
align-items: center;
}
.conn-stat-label {
color: var(--muted);
font-size: 13px;
}
.conn-stat-value {
font-weight: 600;
font-size: 13px;
display: flex;
align-items: center;
gap: 6px;
}
.conn-ping-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
/* ── Admin Icon Button ── */ /* ── Admin Icon Button ── */
.admin-btn-icon { .admin-btn-icon {
width: 32px; width: 32px;