Spaces:
Sleeping
Sleeping
Sasha commited on
Commit ·
100b373
1
Parent(s): c22c35b
feat: add rate limit warning banner (HTTP 429) to UI
Browse files- client/src/App.jsx +86 -13
client/src/App.jsx
CHANGED
|
@@ -90,15 +90,54 @@ export default function App() {
|
|
| 90 |
const [editCategory, setEditCategory] = useState('');
|
| 91 |
const [savingMetadata, setSavingMetadata] = useState(false);
|
| 92 |
const [deletingStream, setDeletingStream] = useState(false);
|
|
|
|
| 93 |
|
| 94 |
// Auto-refresh timer for live stream
|
| 95 |
const pollIntervalRef = useRef(null);
|
| 96 |
const raceIntervalRef = useRef(null);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 97 |
|
| 98 |
const fetchAdminStats = async () => {
|
| 99 |
setLoadingAdminStats(true);
|
| 100 |
try {
|
| 101 |
-
const res = await
|
| 102 |
if (res.status === 200) {
|
| 103 |
const data = await res.json();
|
| 104 |
if (data.success) {
|
|
@@ -144,7 +183,7 @@ export default function App() {
|
|
| 144 |
const controller = new AbortController();
|
| 145 |
const timeoutId = setTimeout(() => controller.abort(), 6000);
|
| 146 |
|
| 147 |
-
const res = await
|
| 148 |
clearTimeout(timeoutId);
|
| 149 |
|
| 150 |
if (res.ok) {
|
|
@@ -254,7 +293,7 @@ export default function App() {
|
|
| 254 |
|
| 255 |
const fetchAuthStatus = async () => {
|
| 256 |
try {
|
| 257 |
-
const res = await
|
| 258 |
const data = await res.json();
|
| 259 |
setTwitchConfigured(data.twitchConfigured !== false);
|
| 260 |
if (data.loggedIn) {
|
|
@@ -269,7 +308,7 @@ export default function App() {
|
|
| 269 |
|
| 270 |
const fetchStreams = async () => {
|
| 271 |
try {
|
| 272 |
-
const res = await
|
| 273 |
const data = await res.json();
|
| 274 |
setStreams(Array.isArray(data) ? data : []);
|
| 275 |
} catch (e) {
|
|
@@ -290,13 +329,13 @@ export default function App() {
|
|
| 290 |
|
| 291 |
try {
|
| 292 |
// 1. Top Chatters
|
| 293 |
-
const chattersRes = await
|
| 294 |
const chattersData = await chattersRes.json();
|
| 295 |
setChatters(Array.isArray(chattersData) ? chattersData : []);
|
| 296 |
|
| 297 |
// 1.5. Overall stream stats summary (true totals)
|
| 298 |
try {
|
| 299 |
-
const statsSummaryRes = await
|
| 300 |
const statsSummaryData = await statsSummaryRes.json();
|
| 301 |
if (statsSummaryData && !statsSummaryData.error) {
|
| 302 |
setStatsSummary(statsSummaryData);
|
|
@@ -306,18 +345,18 @@ export default function App() {
|
|
| 306 |
}
|
| 307 |
|
| 308 |
// 2. Spoken voice words
|
| 309 |
-
const voiceRes = await
|
| 310 |
const voiceData = await voiceRes.json();
|
| 311 |
setVoiceWords(Array.isArray(voiceData) ? voiceData : []);
|
| 312 |
|
| 313 |
// 3. Written chat words by streamer
|
| 314 |
-
const chatWordsRes = await
|
| 315 |
const chatWordsData = await chatWordsRes.json();
|
| 316 |
setChatWords(Array.isArray(chatWordsData) ? chatWordsData : []);
|
| 317 |
|
| 318 |
// 4. Activity chart (only if stream is selected)
|
| 319 |
if (selectedStreamId !== 'all') {
|
| 320 |
-
const activityRes = await
|
| 321 |
const activityData = await activityRes.json();
|
| 322 |
setActivityData(Array.isArray(activityData) ? activityData : []);
|
| 323 |
} else {
|
|
@@ -326,15 +365,15 @@ export default function App() {
|
|
| 326 |
|
| 327 |
// 5. Fetch moderator actions if authorized
|
| 328 |
if (!twitchConfigured || (auth.loggedIn && (auth.user?.role === 'streamer' || auth.user?.role === 'moderator' || auth.user?.role === 'admin'))) {
|
| 329 |
-
const modRes = await
|
| 330 |
const modData = await modRes.json();
|
| 331 |
setModActions(Array.isArray(modData) ? modData : []);
|
| 332 |
|
| 333 |
-
const summaryRes = await
|
| 334 |
const summaryData = await summaryRes.json();
|
| 335 |
setModSummary(Array.isArray(summaryData) ? summaryData : []);
|
| 336 |
|
| 337 |
-
const profilesRes = await
|
| 338 |
const profilesData = await profilesRes.json();
|
| 339 |
setModProfiles(Array.isArray(profilesData) ? profilesData : []);
|
| 340 |
if (profilesData && profilesData.length > 0) {
|
|
@@ -374,7 +413,7 @@ export default function App() {
|
|
| 374 |
|
| 375 |
const handleLogout = async () => {
|
| 376 |
try {
|
| 377 |
-
await
|
| 378 |
setAuth({ loggedIn: false, user: null });
|
| 379 |
setModActions([]);
|
| 380 |
setModSummary([]);
|
|
@@ -578,6 +617,40 @@ export default function App() {
|
|
| 578 |
{/* Main Dashboard Panel */}
|
| 579 |
<main className="dashboard-content">
|
| 580 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 581 |
{serverStatus !== 'online' ? (
|
| 582 |
<div className="connection-overlay-container" style={{
|
| 583 |
display: 'flex',
|
|
|
|
| 90 |
const [editCategory, setEditCategory] = useState('');
|
| 91 |
const [savingMetadata, setSavingMetadata] = useState(false);
|
| 92 |
const [deletingStream, setDeletingStream] = useState(false);
|
| 93 |
+
const [rateLimitInfo, setRateLimitInfo] = useState(null);
|
| 94 |
|
| 95 |
// Auto-refresh timer for live stream
|
| 96 |
const pollIntervalRef = useRef(null);
|
| 97 |
const raceIntervalRef = useRef(null);
|
| 98 |
+
const rateLimitTimerRef = useRef(null);
|
| 99 |
+
|
| 100 |
+
const safeFetch = async (url, options = {}) => {
|
| 101 |
+
const res = await safeFetch(url, options);
|
| 102 |
+
if (res.status === 429) {
|
| 103 |
+
try {
|
| 104 |
+
const data = await res.json();
|
| 105 |
+
setRateLimitInfo({
|
| 106 |
+
message: data.message || 'Превышен лимит запросов к серверу.',
|
| 107 |
+
retryAfter: data.retryAfter || 60
|
| 108 |
+
});
|
| 109 |
+
} catch (e) {
|
| 110 |
+
setRateLimitInfo({ message: 'Слишком много запросов к серверу.', retryAfter: 60 });
|
| 111 |
+
}
|
| 112 |
+
return res;
|
| 113 |
+
} else if (res.ok) {
|
| 114 |
+
setRateLimitInfo(null);
|
| 115 |
+
}
|
| 116 |
+
return res;
|
| 117 |
+
};
|
| 118 |
+
|
| 119 |
+
useEffect(() => {
|
| 120 |
+
if (rateLimitInfo && rateLimitInfo.retryAfter > 0) {
|
| 121 |
+
if (rateLimitTimerRef.current) clearInterval(rateLimitTimerRef.current);
|
| 122 |
+
rateLimitTimerRef.current = setInterval(() => {
|
| 123 |
+
setRateLimitInfo(prev => {
|
| 124 |
+
if (!prev || prev.retryAfter <= 1) {
|
| 125 |
+
clearInterval(rateLimitTimerRef.current);
|
| 126 |
+
return null;
|
| 127 |
+
}
|
| 128 |
+
return { ...prev, retryAfter: prev.retryAfter - 1 };
|
| 129 |
+
});
|
| 130 |
+
}, 1000);
|
| 131 |
+
} else if (!rateLimitInfo && rateLimitTimerRef.current) {
|
| 132 |
+
clearInterval(rateLimitTimerRef.current);
|
| 133 |
+
}
|
| 134 |
+
return () => clearInterval(rateLimitTimerRef.current);
|
| 135 |
+
}, [rateLimitInfo]);
|
| 136 |
|
| 137 |
const fetchAdminStats = async () => {
|
| 138 |
setLoadingAdminStats(true);
|
| 139 |
try {
|
| 140 |
+
const res = await safeFetch(`${API_BASE}/api/admin/stats`, { credentials: 'include' });
|
| 141 |
if (res.status === 200) {
|
| 142 |
const data = await res.json();
|
| 143 |
if (data.success) {
|
|
|
|
| 183 |
const controller = new AbortController();
|
| 184 |
const timeoutId = setTimeout(() => controller.abort(), 6000);
|
| 185 |
|
| 186 |
+
const res = await safeFetch(`${API_BASE}/api/health`, { signal: controller.signal });
|
| 187 |
clearTimeout(timeoutId);
|
| 188 |
|
| 189 |
if (res.ok) {
|
|
|
|
| 293 |
|
| 294 |
const fetchAuthStatus = async () => {
|
| 295 |
try {
|
| 296 |
+
const res = await safeFetch(`${API_BASE}/api/auth/status`, { credentials: 'include' });
|
| 297 |
const data = await res.json();
|
| 298 |
setTwitchConfigured(data.twitchConfigured !== false);
|
| 299 |
if (data.loggedIn) {
|
|
|
|
| 308 |
|
| 309 |
const fetchStreams = async () => {
|
| 310 |
try {
|
| 311 |
+
const res = await safeFetch(`${API_BASE}/api/streams`);
|
| 312 |
const data = await res.json();
|
| 313 |
setStreams(Array.isArray(data) ? data : []);
|
| 314 |
} catch (e) {
|
|
|
|
| 329 |
|
| 330 |
try {
|
| 331 |
// 1. Top Chatters
|
| 332 |
+
const chattersRes = await safeFetch(getUrl('/api/stats/chatters'));
|
| 333 |
const chattersData = await chattersRes.json();
|
| 334 |
setChatters(Array.isArray(chattersData) ? chattersData : []);
|
| 335 |
|
| 336 |
// 1.5. Overall stream stats summary (true totals)
|
| 337 |
try {
|
| 338 |
+
const statsSummaryRes = await safeFetch(getUrl('/api/stats/summary'));
|
| 339 |
const statsSummaryData = await statsSummaryRes.json();
|
| 340 |
if (statsSummaryData && !statsSummaryData.error) {
|
| 341 |
setStatsSummary(statsSummaryData);
|
|
|
|
| 345 |
}
|
| 346 |
|
| 347 |
// 2. Spoken voice words
|
| 348 |
+
const voiceRes = await safeFetch(getUrl('/api/stats/words', { type: 'voice', limit: 60 }));
|
| 349 |
const voiceData = await voiceRes.json();
|
| 350 |
setVoiceWords(Array.isArray(voiceData) ? voiceData : []);
|
| 351 |
|
| 352 |
// 3. Written chat words by streamer
|
| 353 |
+
const chatWordsRes = await safeFetch(getUrl('/api/stats/words', { type: 'chat', limit: 60 }));
|
| 354 |
const chatWordsData = await chatWordsRes.json();
|
| 355 |
setChatWords(Array.isArray(chatWordsData) ? chatWordsData : []);
|
| 356 |
|
| 357 |
// 4. Activity chart (only if stream is selected)
|
| 358 |
if (selectedStreamId !== 'all') {
|
| 359 |
+
const activityRes = await safeFetch(getUrl('/api/stats/activity'));
|
| 360 |
const activityData = await activityRes.json();
|
| 361 |
setActivityData(Array.isArray(activityData) ? activityData : []);
|
| 362 |
} else {
|
|
|
|
| 365 |
|
| 366 |
// 5. Fetch moderator actions if authorized
|
| 367 |
if (!twitchConfigured || (auth.loggedIn && (auth.user?.role === 'streamer' || auth.user?.role === 'moderator' || auth.user?.role === 'admin'))) {
|
| 368 |
+
const modRes = await safeFetch(getUrl('/api/stats/moderators', { limit: 100 }), { credentials: 'include' });
|
| 369 |
const modData = await modRes.json();
|
| 370 |
setModActions(Array.isArray(modData) ? modData : []);
|
| 371 |
|
| 372 |
+
const summaryRes = await safeFetch(getUrl('/api/stats/moderators/summary'), { credentials: 'include' });
|
| 373 |
const summaryData = await summaryRes.json();
|
| 374 |
setModSummary(Array.isArray(summaryData) ? summaryData : []);
|
| 375 |
|
| 376 |
+
const profilesRes = await safeFetch(getUrl('/api/stats/moderators/profiles'), { credentials: 'include' });
|
| 377 |
const profilesData = await profilesRes.json();
|
| 378 |
setModProfiles(Array.isArray(profilesData) ? profilesData : []);
|
| 379 |
if (profilesData && profilesData.length > 0) {
|
|
|
|
| 413 |
|
| 414 |
const handleLogout = async () => {
|
| 415 |
try {
|
| 416 |
+
await safeFetch(`${API_BASE}/api/auth/logout`, { credentials: 'include' });
|
| 417 |
setAuth({ loggedIn: false, user: null });
|
| 418 |
setModActions([]);
|
| 419 |
setModSummary([]);
|
|
|
|
| 617 |
{/* Main Dashboard Panel */}
|
| 618 |
<main className="dashboard-content">
|
| 619 |
|
| 620 |
+
{rateLimitInfo && (
|
| 621 |
+
<div className="rate-limit-banner" style={{
|
| 622 |
+
background: 'linear-gradient(90deg, rgba(255, 153, 0, 0.15) 0%, rgba(255, 102, 0, 0.15) 100%)',
|
| 623 |
+
backdropFilter: 'blur(8px)',
|
| 624 |
+
WebkitBackdropFilter: 'blur(8px)',
|
| 625 |
+
border: '1px solid rgba(255, 153, 0, 0.3)',
|
| 626 |
+
borderRadius: 'var(--radius-md)',
|
| 627 |
+
padding: '1rem',
|
| 628 |
+
margin: '0 0 1rem 0',
|
| 629 |
+
display: 'flex',
|
| 630 |
+
alignItems: 'center',
|
| 631 |
+
gap: '1rem',
|
| 632 |
+
color: '#FF9900',
|
| 633 |
+
animation: 'fadeIn 0.3s ease-out'
|
| 634 |
+
}}>
|
| 635 |
+
<ShieldAlert size={24} style={{ animation: 'pulse 2s infinite' }} />
|
| 636 |
+
<div style={{ flex: 1 }}>
|
| 637 |
+
<h3 style={{ margin: 0, fontSize: '1rem', fontWeight: 600 }}>Внимание: Лимит запросов</h3>
|
| 638 |
+
<p style={{ margin: '0.2rem 0 0', fontSize: '0.85rem', color: 'rgba(255, 255, 255, 0.8)' }}>
|
| 639 |
+
{rateLimitInfo.message}
|
| 640 |
+
</p>
|
| 641 |
+
</div>
|
| 642 |
+
<div style={{
|
| 643 |
+
background: 'rgba(255, 153, 0, 0.2)',
|
| 644 |
+
padding: '0.5rem 1rem',
|
| 645 |
+
borderRadius: 'var(--radius-sm)',
|
| 646 |
+
fontWeight: 600,
|
| 647 |
+
fontVariantNumeric: 'tabular-nums'
|
| 648 |
+
}}>
|
| 649 |
+
Разблокировка через: {rateLimitInfo.retryAfter} сек.
|
| 650 |
+
</div>
|
| 651 |
+
</div>
|
| 652 |
+
)}
|
| 653 |
+
|
| 654 |
{serverStatus !== 'online' ? (
|
| 655 |
<div className="connection-overlay-container" style={{
|
| 656 |
display: 'flex',
|