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:
parent
761032a280
commit
1a1fdf69c8
3 changed files with 2980 additions and 2774 deletions
|
|
@ -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);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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}`}>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue