Sasha commited on
Commit
100b373
·
1 Parent(s): c22c35b

feat: add rate limit warning banner (HTTP 429) to UI

Browse files
Files changed (1) hide show
  1. 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 fetch(`${API_BASE}/api/admin/stats`, { credentials: 'include' });
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 fetch(`${API_BASE}/api/health`, { signal: controller.signal });
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 fetch(`${API_BASE}/api/auth/status`, { credentials: 'include' });
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 fetch(`${API_BASE}/api/streams`);
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 fetch(getUrl('/api/stats/chatters'));
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 fetch(getUrl('/api/stats/summary'));
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 fetch(getUrl('/api/stats/words', { type: 'voice', limit: 60 }));
310
  const voiceData = await voiceRes.json();
311
  setVoiceWords(Array.isArray(voiceData) ? voiceData : []);
312
 
313
  // 3. Written chat words by streamer
314
- const chatWordsRes = await fetch(getUrl('/api/stats/words', { type: 'chat', limit: 60 }));
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 fetch(getUrl('/api/stats/activity'));
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 fetch(getUrl('/api/stats/moderators', { limit: 100 }), { credentials: 'include' });
330
  const modData = await modRes.json();
331
  setModActions(Array.isArray(modData) ? modData : []);
332
 
333
- const summaryRes = await fetch(getUrl('/api/stats/moderators/summary'), { credentials: 'include' });
334
  const summaryData = await summaryRes.json();
335
  setModSummary(Array.isArray(summaryData) ? summaryData : []);
336
 
337
- const profilesRes = await fetch(getUrl('/api/stats/moderators/profiles'), { credentials: 'include' });
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 fetch(`${API_BASE}/api/auth/logout`, { credentials: 'include' });
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',