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>();
|
||||
// Now-Playing: aktuell gespielter Sound pro Guild
|
||||
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)
|
||||
const sseClients = new Set<Response>();
|
||||
function sseBroadcast(payload: any) {
|
||||
|
|
@ -625,6 +627,7 @@ function attachVoiceLifecycle(state: GuildAudioState, guild: any) {
|
|||
if (newS.status === VoiceConnectionStatus.Ready) {
|
||||
reconnectAttempts = 0;
|
||||
isReconnecting = false;
|
||||
if (!connectedSince.has(state.guildId)) connectedSince.set(state.guildId, new Date().toISOString());
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -661,6 +664,7 @@ function attachVoiceLifecycle(state: GuildAudioState, guild: any) {
|
|||
}
|
||||
}
|
||||
} else if (newS.status === VoiceConnectionStatus.Destroyed) {
|
||||
connectedSince.delete(state.guildId);
|
||||
// Komplett neu beitreten
|
||||
const newConn = joinVoiceChannel({
|
||||
channelId: state.channelId,
|
||||
|
|
@ -1520,7 +1524,18 @@ app.get('/api/events', (req: Request, res: Response) => {
|
|||
|
||||
// Snapshot senden
|
||||
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 {}
|
||||
|
||||
// Ping, damit Proxies die Verbindung offen halten
|
||||
|
|
@ -1600,6 +1615,23 @@ app.listen(PORT, () => {
|
|||
|
||||
// Vollständige Cache-Synchronisation beim Start (Hintergrund)
|
||||
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 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 ── */
|
||||
const [notification, setNotification] = useState<{ msg: string; type: 'info' | 'error' } | null>(null);
|
||||
const [clock, setClock] = useState('');
|
||||
|
|
@ -233,6 +244,11 @@ export default function App() {
|
|||
const g = selectedRef.current?.split(':')[0];
|
||||
if (g && typeof np[g] === 'string') setLastPlayed(np[g]);
|
||||
} 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') {
|
||||
const g = selectedRef.current?.split(':')[0];
|
||||
if (msg.guildId === g) setSelected(`${msg.guildId}:${msg.channelId}`);
|
||||
|
|
@ -242,6 +258,17 @@ export default function App() {
|
|||
} else if (msg?.type === 'nowplaying') {
|
||||
const g = selectedRef.current?.split(':')[0];
|
||||
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 { } };
|
||||
|
|
@ -628,9 +655,12 @@ export default function App() {
|
|||
</div>
|
||||
)}
|
||||
{selected && (
|
||||
<div className="connection">
|
||||
<div className="connection" onClick={() => setShowConnModal(true)} style={{cursor:'pointer'}} title="Verbindungsdetails">
|
||||
<span className="conn-dot" />
|
||||
Verbunden
|
||||
{voiceStats?.voicePing != null && (
|
||||
<span className="conn-ping">{voiceStats.voicePing}ms</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
|
|
@ -948,6 +978,66 @@ export default function App() {
|
|||
</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 ═══ */}
|
||||
{notification && (
|
||||
<div className={`toast ${notification.type}`}>
|
||||
|
|
|
|||
|
|
@ -353,6 +353,90 @@ input, select {
|
|||
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-btn-icon {
|
||||
width: 32px;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue