diff --git a/backend/src/main/java/com/softspm/webinar/config/SecurityConfig.java b/backend/src/main/java/com/softspm/webinar/config/SecurityConfig.java index 94148ea..82691c7 100644 --- a/backend/src/main/java/com/softspm/webinar/config/SecurityConfig.java +++ b/backend/src/main/java/com/softspm/webinar/config/SecurityConfig.java @@ -166,6 +166,13 @@ public class SecurityConfig { * permitAll powyzej i sprawdza klucz w kontrolerze. */ auth.requestMatchers("/admin/api-keys/**").hasRole("ADMIN"); + /* + * Zasoby: podglad zadan w tle i ich zatrzymywanie. Dotycza + * calej instancji, takze cudzych webinarow, wiec wylacznie + * ADMIN. Moderator prowadzi spotkania, nie zarzadza + * infrastruktura. + */ + auth.requestMatchers("/admin/resources/**").hasRole("ADMIN"); // pozostale operacje administracyjne takze dla moderatora auth.requestMatchers("/admin/**").hasAnyRole("ADMIN", "MODERATOR"); diff --git a/backend/src/main/java/com/softspm/webinar/health/ResourcesAdminController.java b/backend/src/main/java/com/softspm/webinar/health/ResourcesAdminController.java new file mode 100644 index 0000000..e7c6f8a --- /dev/null +++ b/backend/src/main/java/com/softspm/webinar/health/ResourcesAdminController.java @@ -0,0 +1,145 @@ +package com.softspm.webinar.health; + +import com.softspm.webinar.attendance.AttendanceService; +import com.softspm.webinar.media.RecordingService; +import com.softspm.webinar.quiz.QuizAttemptService; +import com.softspm.webinar.repository.NotificationRepository; +import com.softspm.webinar.video.LiveKitEgressService; +import com.softspm.webinar.web.NotFoundException; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * Podglad i sprzatanie zadan prowadzonych w tle. + * + * WYLACZNIE DLA ADMINISTRATORA + * --------------------------------------------------------------------------- + * Sciezka `/admin/resources/**` jest w `SecurityConfig` zawezona do roli ADMIN. + * Zatrzymanie nagrania albo skasowanie wpisu z kolejki ma skutki dla calej + * instancji, takze dla cudzych webinarow: moderator prowadzi spotkania, a nie + * zarzadza infrastruktura. + * + * KAZDA AKCJA JEST ODWRACALNA ALBO NIESZKODLIWA + * --------------------------------------------------------------------------- + * Nic tu nie kasuje danych uczestnikow. Zatrzymanie nagrania konczy zadanie + * u dostawcy, domkniecie sesji obecnosci ustawia czas zakonczenia na ostatni + * sygnal, a ponowienie wiadomosci wraca ja do kolejki. Skasowanie wpisu + * z kolejki usuwa wylacznie sam wpis, nigdy zapisu uczestnika. + */ +@RestController +@RequestMapping("/admin/resources") +@Tag(name = "Zasoby", description = "Stan zadan w tle i ich sprzatanie") +public class ResourcesAdminController { + + private static final Logger log = LoggerFactory.getLogger(ResourcesAdminController.class); + + private final ResourcesService resources; + private final LiveKitEgressService egressService; + private final RecordingService recordings; + private final NotificationRepository notificationRepository; + private final QuizAttemptService quizAttempts; + private final AttendanceService attendance; + + public ResourcesAdminController(ResourcesService resources, + LiveKitEgressService egressService, + RecordingService recordings, + NotificationRepository notificationRepository, + QuizAttemptService quizAttempts, + AttendanceService attendance) { + this.resources = resources; + this.egressService = egressService; + this.recordings = recordings; + this.notificationRepository = notificationRepository; + this.quizAttempts = quizAttempts; + this.attendance = attendance; + } + + @GetMapping + @Operation(summary = "Stan zadan prowadzonych w tle") + public ResourcesService.ResourcesView snapshot() { + return resources.snapshot(); + } + + /** + * Zatrzymuje nagranie u dostawcy. + * + * Dziala takze dla zadania OSIEROCONEGO, czyli takiego, ktorego nie ma juz + * w naszej bazie: przyjmujemy identyfikator u dostawcy, a nie nasz wlasny. + * To jedyna droga zatrzymania czegos, co przestalo istniec po naszej + * stronie, a wciaz zajmuje moc serwera. + */ + @PostMapping("/recordings/{egressId}/stop") + @Operation(summary = "Zatrzymuje nagranie u dostawcy, takze osierocone") + public void stopRecording(@PathVariable String egressId) { + egressService.stopRecording(egressId); + log.info("Zasoby: administrator zatrzymal nagranie {}", egressId); + } + + /** + * Usuwa wpis nagrania, ktore utknelo. + * + * Najpierw probuje zatrzymac zadanie u dostawcy, dopiero potem kasuje wpis. + * Odwrotna kolejnosc zostawialaby dzialajace nagranie bez zadnego sladu + * w bazie, czyli dokladnie ten stan, ktoremu ten ekran ma zapobiegac. + */ + @DeleteMapping("/recordings/{id}") + @Operation(summary = "Zatrzymuje nagranie u dostawcy i usuwa wpis") + public void dropRecording(@PathVariable Long id) { + String egressId = recordings.providerIdOf(id); + if (egressId != null && !egressId.isBlank()) { + try { + egressService.stopRecording(egressId); + } catch (Exception e) { + // brak zadania u dostawcy nie moze zablokowac sprzatania wpisu: + // najczestszy powod to zadanie, ktore juz samo sie zakonczylo + log.warn("Zasoby: nie udalo sie zatrzymac {}: {}", egressId, e.getMessage()); + } + } + recordings.delete(id); + log.info("Zasoby: administrator usunal nagranie {}", id); + } + + /** Wraca wiadomosc do kolejki, zeby wysylacz sprobowal ponownie. */ + @PostMapping("/notifications/{id}/retry") + @Operation(summary = "Ponowna proba wyslania wiadomosci") + @Transactional + public void retryNotification(@PathVariable Long id) { + var notification = notificationRepository.findById(id) + .orElseThrow(() -> new NotFoundException("Nie znaleziono wpisu w kolejce")); + notification.setStatus(com.softspm.webinar.domain.NotificationStatus.QUEUED); + notification.setError(null); + notificationRepository.save(notification); + log.info("Zasoby: administrator ponowil wiadomosc {}", id); + } + + /** + * Usuwa wpis z kolejki. + * + * Kasuje WYLACZNIE wpis powiadomienia. Zapis uczestnika, webinar i cala + * reszta zostaja nietkniete: to jest sprzatanie kolejki, nie danych. + */ + @DeleteMapping("/notifications/{id}") + @Operation(summary = "Usuwa wpis z kolejki wiadomosci") + public void dropNotification(@PathVariable Long id) { + notificationRepository.deleteById(id); + log.info("Zasoby: administrator usunal wpis kolejki {}", id); + } + + /** Domyka porzucone podejscia do quizow i sesje obecnosci. */ + @PostMapping("/cleanup") + @Operation(summary = "Domyka porzucone podejscia do quizow i sesje obecnosci") + public void cleanup() { + quizAttempts.closeAbandoned(); + attendance.closeStaleSessions(); + log.info("Zasoby: administrator uruchomil sprzatanie recznie"); + } +} diff --git a/backend/src/main/java/com/softspm/webinar/health/ResourcesService.java b/backend/src/main/java/com/softspm/webinar/health/ResourcesService.java new file mode 100644 index 0000000..78521d7 --- /dev/null +++ b/backend/src/main/java/com/softspm/webinar/health/ResourcesService.java @@ -0,0 +1,238 @@ +package com.softspm.webinar.health; + +import com.softspm.webinar.domain.NotificationStatus; +import com.softspm.webinar.domain.Recording; +import com.softspm.webinar.domain.RecordingStatus; +import com.softspm.webinar.repository.AttendanceSessionRepository; +import com.softspm.webinar.repository.NotificationRepository; +import com.softspm.webinar.repository.QuizAttemptRepository; +import com.softspm.webinar.repository.RecordingRepository; +import com.softspm.webinar.settings.SettingKey; +import com.softspm.webinar.settings.SettingsService; +import com.softspm.webinar.video.LiveKitEgressService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Duration; +import java.time.OffsetDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * Stan zadan, ktore aplikacja prowadzi w tle. + * + * PO CO TO ISTNIEJE + * --------------------------------------------------------------------------- + * Kilka rzeczy w tej platformie zyje dluzej niz zadanie HTTP, ktore je + * uruchomilo: nagranie u dostawcy, wiadomosc w kolejce, podejscie do quizu, + * otwarta sesja obecnosci. Kazda z nich moze UTKNAC, a administrator nie ma + * jak sie o tym dowiedziec: w panelu widac skutek (brak nagrania, niewyslany + * mail), a nie przyczyne. + * + * Zdarzylo sie to realnie. Nagranie zostalo osierocone, bo administrator usunal + * webinar w trakcie: wpis znikl kaskadowo z bazy, a zadanie u dostawcy nagrywalo + * dalej. Przegladarka Chrome po stronie Egress krecila sie kilkanascie godzin + * i zjadala jedna piata procesora, a zaden ekran w aplikacji tego nie pokazywal. + * Wyszlo przypadkiem, przy sprawdzaniu czegos innego. + * + * CZEGO TU NIE MA I NIE BEDZIE + * --------------------------------------------------------------------------- + * **Procesow systemowych ani kontenerow Dockera.** Backend nie ma dostepu do + * gniazda Dockera i to jest bariera bezpieczenstwa, nie przeoczenie: kto + * przejmie panel administracyjny, przejalby wtedy caly serwer, razem z innymi + * aplikacjami stojacymi obok. Do zarzadzania kontenerami sluzy SSH. + * + * Ten ekran pokazuje wylacznie to, co aplikacja wie O SOBIE. + */ +@Service +public class ResourcesService { + + private static final Logger log = LoggerFactory.getLogger(ResourcesService.class); + + /** + * Po ilu godzinach nagranie w stanie DOWNLOADING uznajemy za wiszace. + * + * Najdluzszy sensowny webinar to kilka godzin, a montaz pliku po stronie + * Egress trwa minuty. Szesc godzin jest wiec bezpiecznym progiem: nie + * oznaczy trwajacego szkolenia, a zlapie zadanie, ktorego nikt nie zatrzymal. + */ + private static final Duration RECORDING_STUCK_AFTER = Duration.ofHours(6); + + /** Po ilu godzinach wiadomosc w kolejce uznajemy za zalegajaca. */ + private static final Duration NOTIFICATION_STUCK_AFTER = Duration.ofHours(2); + + private final RecordingRepository recordingRepository; + private final NotificationRepository notificationRepository; + private final QuizAttemptRepository quizAttemptRepository; + private final AttendanceSessionRepository attendanceRepository; + private final LiveKitEgressService egressService; + private final SettingsService settings; + + public ResourcesService(RecordingRepository recordingRepository, + NotificationRepository notificationRepository, + QuizAttemptRepository quizAttemptRepository, + AttendanceSessionRepository attendanceRepository, + LiveKitEgressService egressService, + SettingsService settings) { + this.recordingRepository = recordingRepository; + this.notificationRepository = notificationRepository; + this.quizAttemptRepository = quizAttemptRepository; + this.attendanceRepository = attendanceRepository; + this.egressService = egressService; + this.settings = settings; + } + + /** + * Pelny obraz stanu. + * + * Kazda sekcja liczy sie osobno i BLAD JEDNEJ NIE MOZE PRZEWROCIC CALOSCI: + * odpytanie dostawcy wideo idzie po sieci i bywa niedostepne, a wtedy + * administrator ma nadal zobaczyc kolejke maili i reszte. Ekran + * diagnostyczny, ktory sam sie nie otwiera, jest bezuzyteczny dokladnie + * wtedy, gdy jest potrzebny. + */ + @Transactional(readOnly = true) + public ResourcesView snapshot() { + return new ResourcesView( + recordings(), + notifications(), + quizAttempts(), + attendanceSessions(), + OffsetDateTime.now()); + } + + /** + * Nagrania: nasze wpisy i zadania u dostawcy. + * + * Zestawiamy obie strony, bo rozjazd miedzy nimi jest tu najgrozniejszy. + * Zadanie dzialajace u dostawcy bez wpisu w bazie nie zatrzyma sie samo: + * scheduler domykajacy nagrania szuka po naszych wpisach, wiec czegos, + * czego w bazie nie ma, nigdy nie zobaczy. + */ + private RecordingsSection recordings() { + OffsetDateTime stuckBefore = OffsetDateTime.now().minus(RECORDING_STUCK_AFTER); + List pending = recordingRepository.findByStatus(RecordingStatus.DOWNLOADING); + + List ours = new ArrayList<>(); + for (Recording recording : pending) { + ours.add(new RecordingItem( + recording.getId(), + recording.getProviderRecordingId(), + recording.getWebinar() == null ? null : recording.getWebinar().getId(), + recording.getWebinar() == null ? null : recording.getWebinar().getTitle(), + recording.getCreatedAt(), + recording.getCreatedAt() != null && recording.getCreatedAt().isBefore(stuckBefore))); + } + + // zadania dzialajace u dostawcy, ktorych nie mamy u siebie + List orphans = new ArrayList<>(); + String problem = null; + if ("LIVEKIT".equalsIgnoreCase(settings.get(SettingKey.VIDEO_PROVIDER))) { + try { + Set known = pending.stream() + .map(Recording::getProviderRecordingId) + .filter(id -> id != null && !id.isBlank()) + .collect(Collectors.toSet()); + orphans.addAll(egressService.activeEgressIds().stream() + .filter(id -> !known.contains(id)) + .toList()); + } catch (Exception e) { + // brak polaczenia z dostawca nie moze zaslonic reszty ekranu + problem = "Nie udalo sie odpytac dostawcy wideo: " + e.getMessage(); + log.warn("Zasoby: odpytanie Egress nieudane: {}", e.getMessage()); + } + } + + return new RecordingsSection(ours, orphans, problem); + } + + /** Kolejka wiadomosci: co czeka i co sie nie udalo. */ + private NotificationsSection notifications() { + OffsetDateTime stuckBefore = OffsetDateTime.now().minus(NOTIFICATION_STUCK_AFTER); + + List queued = notificationRepository.findByStatus(NotificationStatus.QUEUED).stream() + .map(n -> new NotificationItem(n.getId(), n.getTemplate(), n.getRecipient(), + n.getChannel().name(), n.getCreatedAt(), n.getScheduledAt(), n.getError(), + n.getCreatedAt() != null && n.getCreatedAt().isBefore(stuckBefore))) + .toList(); + + List failed = notificationRepository.findByStatus(NotificationStatus.FAILED).stream() + .map(n -> new NotificationItem(n.getId(), n.getTemplate(), n.getRecipient(), + n.getChannel().name(), n.getCreatedAt(), n.getScheduledAt(), n.getError(), false)) + .toList(); + + return new NotificationsSection(queued, failed); + } + + /** + * Podejscia do quizu wciaz otwarte. + * + * Kursant, ktory zamknal karte, nie przysle juz zadnego zadania, a jego + * podejscie BLOKUJE KOLEJNE: unikalny indeks czesciowy dopuszcza jedno + * otwarte na pare quiz-zapis. Zadanie w tle je domyka, ale gdy z jakiegos + * powodu tego nie zrobi, kursant nie moze podejsc ponownie i nie wie dlaczego. + */ + private List quizAttempts() { + return quizAttemptRepository.findOpenWithTimeLimit().stream() + .map(a -> new QuizAttemptItem(a.getId(), a.getStartedAt())) + .toList(); + } + + /** + * Sesje obecnosci otwarte dluzej, niz pozwala prog. + * + * Sesja bez zamkniecia ZAWYZA CZAS OBECNOSCI, a z niego wylicza sie flaga + * `attended`, ktora decyduje o certyfikacie. + */ + private List attendanceSessions() { + int timeout = Math.max(1, settings.getInt(SettingKey.ATTENDANCE_TIMEOUT_MINUTES)); + OffsetDateTime threshold = OffsetDateTime.now().minusMinutes(timeout); + + return attendanceRepository.findStale(threshold).stream() + .map(s -> new AttendanceItem(s.getId(), + s.getWebinar() == null ? null : s.getWebinar().getId(), + s.getJoinedAt(), s.getLastSeenAt())) + .toList(); + } + + // --- widoki --- + + public record ResourcesView(RecordingsSection recordings, + NotificationsSection notifications, + List openQuizAttempts, + List staleAttendance, + OffsetDateTime checkedAt) { + } + + /** + * @param orphanEgressIds zadania dzialajace u dostawcy, ktorych nie ma w bazie + * @param problem opis bledu odpytania dostawcy, null gdy wszystko poszlo + */ + public record RecordingsSection(List inProgress, + List orphanEgressIds, + String problem) { + } + + public record RecordingItem(Long id, String providerRecordingId, Long webinarId, + String webinarTitle, OffsetDateTime startedAt, boolean stuck) { + } + + public record NotificationsSection(List queued, List failed) { + } + + public record NotificationItem(Long id, String template, String recipient, String channel, + OffsetDateTime createdAt, OffsetDateTime scheduledAt, + String error, boolean stuck) { + } + + public record QuizAttemptItem(Long id, OffsetDateTime startedAt) { + } + + public record AttendanceItem(Long id, Long webinarId, OffsetDateTime joinedAt, + OffsetDateTime lastSeenAt) { + } +} diff --git a/backend/src/main/java/com/softspm/webinar/video/LiveKitEgressService.java b/backend/src/main/java/com/softspm/webinar/video/LiveKitEgressService.java index e1fb0ee..4b611dc 100644 --- a/backend/src/main/java/com/softspm/webinar/video/LiveKitEgressService.java +++ b/backend/src/main/java/com/softspm/webinar/video/LiveKitEgressService.java @@ -332,6 +332,38 @@ public class LiveKitEgressService { } } + /** + * Identyfikatory zadan DZIALAJACYCH u dostawcy. + * + * Sluzy ekranowi zasobow do wykrycia zadan osieroconych: takich, ktore + * Egress wciaz wykonuje, a ktorych nie ma juz w naszej bazie. Taki rozjazd + * powstaje, gdy administrator usunie nagranie albo caly webinar w trakcie + * nagrywania: wpis znika kaskadowo, a zadanie u dostawcy nie ma juz kto + * zatrzymac, bo scheduler szuka wylacznie po naszych wpisach. + * + * Bierzemy stany oznaczajace prace w toku. ENDING tez, bo zadanie + * konczace sie od kilku godzin jest rownie podejrzane jak aktywne. + */ + public List activeEgressIds() { + try { + var response = egressClient().listEgress().execute(); + if (!response.isSuccessful() || response.body() == null) { + throw new ValidationException("Serwer LiveKit odpowiedzial: " + describe(response.code())); + } + return response.body().stream() + .filter(info -> switch (info.getStatus()) { + case EGRESS_STARTING, EGRESS_ACTIVE, EGRESS_ENDING -> true; + default -> false; + }) + .map(LivekitEgress.EgressInfo::getEgressId) + .toList(); + } catch (ValidationException e) { + throw e; + } catch (Exception e) { + throw new ValidationException("Nie udalo sie polaczyc z serwerem LiveKit: " + e.getMessage()); + } + } + /** * Oznacza nagranie jako nieudane. * diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 33ab27b..e2476f5 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -51,6 +51,7 @@ import { CompanySettingsPage } from './pages/settings/CompanySettingsPage' import { HomePageSettingsPage } from './pages/settings/HomePageSettingsPage' import { UserListPage } from './pages/users/UserListPage' import { ApiKeyListPage } from './pages/apikeys/ApiKeyListPage' +import { ResourcesPage } from './pages/resources/ResourcesPage' import { AcademyChoicePage, AcademyPasswordResetPage, @@ -339,6 +340,20 @@ export function App() { } /> + {/* + Zasoby pokazuja stan zadan prowadzonych w tle i pozwalaja je zatrzymac, + wiec dotycza instancji jako calosci, tak samo jak ustawienia. Backend + zawezil /admin/resources/** do roli ADMIN i to on jest zrodlem prawdy; + RequireAdmin tylko oszczedza uzytkownikowi bledu 403. + */} + + + + } + /> {/* nieznana podstrona panelu zostaje w panelu, a nie wypada na korzen */} } /> diff --git a/frontend/src/lib/navigation.ts b/frontend/src/lib/navigation.ts index f90c9c0..94b8805 100644 --- a/frontend/src/lib/navigation.ts +++ b/frontend/src/lib/navigation.ts @@ -107,6 +107,7 @@ export const NAV_COMMON: NavItem[] = [ { to: panelPath('/ustawienia'), label: 'Ustawienia', icon: 'settings', roles: ['ADMIN'] }, { to: panelPath('/uzytkownicy'), label: 'Użytkownicy', icon: 'users', roles: ['ADMIN'] }, { to: panelPath('/klucze-api'), label: 'Klucze API', icon: 'key', roles: ['ADMIN'] }, + { to: panelPath('/zasoby'), label: 'Zasoby', icon: 'list-checks', roles: ['ADMIN'] }, ] /** Czy rola widzi pozycje. Brak listy rol oznacza: kazdy zalogowany. */ diff --git a/frontend/src/pages/resources/ResourcesPage.tsx b/frontend/src/pages/resources/ResourcesPage.tsx new file mode 100644 index 0000000..2b3cf7f --- /dev/null +++ b/frontend/src/pages/resources/ResourcesPage.tsx @@ -0,0 +1,647 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { client, errorMessage } from '../../api/client' +import { useConfirm } from '../../components/Confirm' +import { Icon } from '../../components/Icon' +import type { IconName } from '../../components/Icon' +import { useToasts } from '../../components/Toasts' +import { Alert, Badge, Button, Card, EmptyState, ErrorState, Spinner } from '../../components/ui' +import { formatLocal } from '../../lib/dates' + +/** + * Zasoby: stan zadan, ktore aplikacja prowadzi w tle. + * + * PO CO TO ISTNIEJE + * --------------------------------------------------------------------------- + * Nagranie u dostawcy, wiadomosc w kolejce, podejscie do quizu i sesja + * obecnosci zyja dluzej niz zadanie HTTP, ktore je uruchomilo. Kazde z nich + * moze utknac, a w pozostalych ekranach panelu widac wylacznie SKUTEK (brak + * nagrania, niewyslany mail), nigdy przyczyne. Ten ekran pokazuje przyczyne. + * + * DLACZEGO ODSWIEZA SIE SAM + * --------------------------------------------------------------------------- + * To ekran diagnostyczny, otwierany wtedy, gdy cos jest nie tak, i zwykle + * zostawiany otwarty obok. Stan zmienia sie bez udzialu uzytkownika: kolejka + * schodzi, nagranie sie domyka, sprzatanie w tle robi swoje. Lista zamrozona + * w chwili wejscia kazalaby przeladowywac strone, zeby sprawdzic, czy akcja + * poskutkowala. + */ + +const REFRESH_MS = 30_000 + +type RecordingItem = { + id: number + providerRecordingId: string | null + webinarId: number | null + webinarTitle: string | null + startedAt: string | null + stuck: boolean +} + +type NotificationItem = { + id: number + template: string + recipient: string + channel: string + createdAt: string | null + scheduledAt: string | null + error: string | null + stuck: boolean +} + +type QuizAttemptItem = { id: number; startedAt: string | null } + +type AttendanceItem = { + id: number + webinarId: number | null + joinedAt: string | null + lastSeenAt: string | null +} + +type ResourcesView = { + recordings: { + inProgress: RecordingItem[] + orphanEgressIds: string[] + problem: string | null + } + notifications: { + queued: NotificationItem[] + failed: NotificationItem[] + } + openQuizAttempts: QuizAttemptItem[] + staleAttendance: AttendanceItem[] + checkedAt: string +} + +export function ResourcesPage() { + const queryClient = useQueryClient() + const confirm = useConfirm() + const toasts = useToasts() + + const query = useQuery({ + queryKey: ['resources'], + queryFn: async () => { + const response = await client.get('/admin/resources') + return response.data + }, + refetchInterval: REFRESH_MS, + }) + + /** Po kazdej akcji stan sie zmienil, wiec pobieramy go od nowa. */ + function refresh() { + queryClient.invalidateQueries({ queryKey: ['resources'] }) + } + + const stopRecording = useMutation({ + mutationFn: (egressId: string) => + client.post(`/admin/resources/recordings/${encodeURIComponent(egressId)}/stop`), + onSuccess: () => { + toasts.success('Nagranie zostało zatrzymane') + refresh() + }, + onError: (error) => toasts.error(errorMessage(error, 'Nie udało się zatrzymać nagrania')), + }) + + const dropRecording = useMutation({ + mutationFn: (id: number) => client.delete(`/admin/resources/recordings/${id}`), + onSuccess: () => { + toasts.success('Wpis nagrania został usunięty') + refresh() + }, + onError: (error) => toasts.error(errorMessage(error, 'Nie udało się usunąć wpisu nagrania')), + }) + + const retryNotification = useMutation({ + mutationFn: (id: number) => client.post(`/admin/resources/notifications/${id}/retry`), + onSuccess: () => { + toasts.success('Wiadomość wróciła do kolejki') + refresh() + }, + onError: (error) => toasts.error(errorMessage(error, 'Nie udało się ponowić wiadomości')), + }) + + const dropNotification = useMutation({ + mutationFn: (id: number) => client.delete(`/admin/resources/notifications/${id}`), + onSuccess: () => { + toasts.success('Wpis został usunięty z kolejki') + refresh() + }, + onError: (error) => toasts.error(errorMessage(error, 'Nie udało się usunąć wpisu z kolejki')), + }) + + const cleanup = useMutation({ + mutationFn: () => client.post('/admin/resources/cleanup'), + onSuccess: () => { + toasts.success('Porzucone podejścia i sesje zostały domknięte') + refresh() + }, + onError: (error) => toasts.error(errorMessage(error, 'Nie udało się domknąć porzuconych zadań')), + }) + + async function askStop(egressId: string) { + const ok = await confirm({ + title: 'Zatrzymać nagranie?', + message: ( + <> + Zadanie {egressId} zostanie zatrzymane u dostawcy. Materiał nagrany do + tej pory może nie zostać zapisany, a wznowienie nie jest możliwe. + + ), + confirmLabel: 'Zatrzymaj nagranie', + destructive: true, + }) + if (ok) stopRecording.mutate(egressId) + } + + async function askDropRecording(item: RecordingItem) { + const ok = await confirm({ + title: 'Usunąć wpis nagrania?', + message: ( + <> + Zadanie zostanie najpierw zatrzymane u dostawcy, a potem zniknie wpis + {item.webinarTitle ? ( + <> + {' '} + webinaru {item.webinarTitle} + + ) : null} + . Operacji nie da się cofnąć. + + ), + confirmLabel: 'Zatrzymaj i usuń wpis', + destructive: true, + }) + if (ok) dropRecording.mutate(item.id) + } + + async function askDropNotification(item: NotificationItem) { + const ok = await confirm({ + title: 'Usunąć wpis z kolejki?', + message: ( + <> + Wiadomość {item.template} do {item.recipient} nie + zostanie wysłana i zniknie z kolejki. Zapis uczestnika i webinar zostają nietknięte. + + ), + confirmLabel: 'Usuń wpis', + destructive: true, + }) + if (ok) dropNotification.mutate(item.id) + } + + if (query.isPending) return + if (query.isError) { + return + } + + const data = query.data + const abandonedCount = data.openQuizAttempts.length + data.staleAttendance.length + const everythingClear = + data.recordings.inProgress.length === 0 && + data.recordings.orphanEgressIds.length === 0 && + data.notifications.queued.length === 0 && + data.notifications.failed.length === 0 && + abandonedCount === 0 && + !data.recordings.problem + + return ( +
+
+
+

Zasoby

+

+ Zadania prowadzone w tle: nagrania, kolejka wiadomości i porzucone sesje +

+
+
+

+ Sprawdzono: {formatLocal(data.checkedAt)} +

+ +
+
+ + {everythingClear ? ( + + + + ) : null} + + {/* + OSIEROCONE ZADANIA SA NAJWAZNIEJSZE, WIEC STOJA NAJWYZEJ. + ----------------------------------------------------------------------- + Zadanie dzialajace u dostawcy bez wpisu w bazie NIE ZATRZYMA SIE SAMO: + scheduler domykajacy nagrania szuka po naszych wpisach, wiec czegos, + czego w bazie nie ma, nigdy nie zobaczy. Zjada przy tym moc serwera tak + dlugo, az ktos zatrzyma je recznie. + */} + {data.recordings.orphanEgressIds.length > 0 && ( + +
+ + Zadanie działa u dostawcy, ale nie ma go w bazie. Nie zatrzyma się samo i zajmuje moc + serwera. + + +
    + {data.recordings.orphanEgressIds.map((egressId) => ( +
  • +

    + {egressId} +

    + +
  • + ))} +
+
+
+ )} + + {/* + Blad odpytania dostawcy pokazujemy, a nie chowamy sekcji. Ukrycie + sugerowaloby, ze osieroconych zadan nie ma, podczas gdy naprawde nie + wiadomo, czy sa. + */} + {data.recordings.problem && ( + + {data.recordings.problem}. Lista osieroconych zadań może być niepełna. + + )} + + + {data.recordings.inProgress.length === 0 ? ( +

Żadne nagranie nie jest w toku.

+ ) : ( + <> + {/* na telefonie karty, tabela dopiero od duzego ekranu */} +
    + {data.recordings.inProgress.map((item) => ( +
  • +
    +

    + {item.webinarTitle ?? 'Webinar usunięty'} +

    +

    + {item.providerRecordingId ?? 'brak identyfikatora u dostawcy'} +

    +
    +
    + + {formatLocal(item.startedAt)} + + {item.stuck && wisi ponad 6 godzin} +
    +
    + {item.providerRecordingId && ( + + )} + +
    +
  • + ))} +
+ +
+ + + + + + + + + + + {data.recordings.inProgress.map((item) => ( + + + + + + + ))} + +
WebinarZadanie u dostawcyRozpoczętoAkcje
+

+ {item.webinarTitle ?? 'Webinar usunięty'} +

+ {item.stuck && ( + + wisi ponad 6 godzin + + )} +
+ {item.providerRecordingId ?? '-'} + + {formatLocal(item.startedAt)} + +
+ {item.providerRecordingId && ( + + )} + +
+
+
+ + )} +
+ + +
+ retryNotification.mutate(id)} + onDrop={askDropNotification} + retryPending={retryNotification.isPending} + dropPending={dropNotification.isPending} + /> + retryNotification.mutate(id)} + onDrop={askDropNotification} + retryPending={retryNotification.isPending} + dropPending={dropNotification.isPending} + /> +
+
+ + +
+
+ + +
+ +
+ + {abandonedCount === 0 &&

Nie ma czego domykać.

} +
+
+
+
+ ) +} + +/** Kafelek z liczba pozycji czekajacych na domkniecie. */ +function CountTile({ + icon, + label, + count, + hint, +}: { + icon: IconName + label: string + count: number + hint: string +}) { + return ( +
0 ? 'border-warn-500/25 bg-warn-50' : 'border-slate-100' + }`} + > +
+ 0 ? 'text-warn-600' : 'text-slate-400'} /> +

{label}

+
+

{count}

+

{hint}

+
+ ) +} + +/** + * Jedna grupa kolejki: czekajace albo nieudane. + * + * Obie wygladaja tak samo i maja te same akcje, wiec sa jednym komponentem: + * druga kopia rozjechalaby sie przy pierwszej poprawce. + */ +function NotificationGroup({ + heading, + emptyText, + items, + onRetry, + onDrop, + retryPending, + dropPending, +}: { + heading: string + emptyText: string + items: NotificationItem[] + onRetry: (id: number) => void + onDrop: (item: NotificationItem) => void + retryPending: boolean + dropPending: boolean +}) { + return ( +
+
+

{heading}

+ 0 ? 'info' : 'neutral'}>{items.length} +
+ + {items.length === 0 ? ( +

{emptyText}

+ ) : ( + <> + {/* na telefonie karty, tabela dopiero od duzego ekranu */} +
    + {items.map((item) => ( +
  • +
    +

    {item.template}

    +

    {item.recipient}

    +
    +
    + {item.channel} + {item.stuck && zalega ponad 2 godziny} + + {formatLocal(item.createdAt)} + +
    + {item.error && ( +

    + {item.error} +

    + )} +
    + + +
    +
  • + ))} +
+ +
+ + + + + + + + + + + {items.map((item) => ( + + + + + + + ))} + +
WiadomośćKanałDodanoAkcje
+

{item.template}

+

{item.recipient}

+ {item.stuck && ( + + zalega ponad 2 godziny + + )} + {item.error && ( +

{item.error}

+ )} +
+ {item.channel} + + {formatLocal(item.createdAt)} + +
+ + +
+
+
+ + )} +
+ ) +}