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 moze utknac, a administrator nie mial jak sie o tym dowiedziec: w panelu widac skutek (brak nagrania, niewyslany mail), a nie przyczyne. POWSTALO PO REALNYM ZDARZENIU Nagranie zostalo osierocone, bo webinar usunieto 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 na serwerze dzielonym z dwiema innymi aplikacjami. Zaden ekran tego nie pokazywal; wyszlo przypadkiem, przy sprawdzaniu czegos innego. Zadanie osierocone jest przy tym nie do zatrzymania zwyklymi srodkami: scheduler domykajacy nagrania szuka po NASZYCH wpisach, wiec czegos, czego w bazie nie ma, nigdy nie zobaczy. CO POKAZUJE Sekcje w kolejnosci waznosci: zadania osierocone (dzialaja u dostawcy, brak wpisu w bazie), nagrania w toku z oznaczeniem wiszacych po szesciu godzinach, kolejka wiadomosci QUEUED i FAILED wraz z trescia bledu, liczniki porzuconych podejsc do quizow i nieswiezych sesji obecnosci. Kazda pozycja ma akcje: zatrzymanie, usuniecie wpisu, ponowienie wysylki, reczne domkniecie. Zatrzymanie nagrania idzie po identyfikatorze U DOSTAWCY, a nie naszym: to jedyna droga do czegos, co po naszej stronie juz nie istnieje. Usuniecie wpisu najpierw probuje zatrzymac zadanie, dopiero potem kasuje: odwrotna kolejnosc zostawialaby dzialajace nagranie bez zadnego sladu, czyli dokladnie ten stan, ktoremu ten ekran ma zapobiegac. BLAD JEDNEJ SEKCJI NIE PRZEWRACA CALOSCI Odpytanie dostawcy idzie po sieci i bywa niedostepne. Wtedy pokazujemy ostrzezenie i informacje, ze lista osieroconych moze byc niepelna, a reszta ekranu dziala. Ekran diagnostyczny, ktory sam sie nie otwiera, jest bezuzyteczny dokladnie wtedy, gdy jest potrzebny. 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, przejalby caly serwer razem z aplikacjami stojacymi obok. Do zarzadzania kontenerami sluzy SSH. Ekran pokazuje wylacznie to, co aplikacja wie o sobie. Dostep wylacznie dla ADMIN, regula w `SecurityConfig` przed ogolna `/admin/**`: zatrzymanie zadania dotyczy calej instancji, takze cudzych webinarow, a moderator prowadzi spotkania, nie zarzadza infrastruktura. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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<Recording> pending = recordingRepository.findByStatus(RecordingStatus.DOWNLOADING);
|
||||
|
||||
List<RecordingItem> 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<String> orphans = new ArrayList<>();
|
||||
String problem = null;
|
||||
if ("LIVEKIT".equalsIgnoreCase(settings.get(SettingKey.VIDEO_PROVIDER))) {
|
||||
try {
|
||||
Set<String> 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<NotificationItem> 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<NotificationItem> 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<QuizAttemptItem> 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<AttendanceItem> 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<QuizAttemptItem> openQuizAttempts,
|
||||
List<AttendanceItem> 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<RecordingItem> inProgress,
|
||||
List<String> orphanEgressIds,
|
||||
String problem) {
|
||||
}
|
||||
|
||||
public record RecordingItem(Long id, String providerRecordingId, Long webinarId,
|
||||
String webinarTitle, OffsetDateTime startedAt, boolean stuck) {
|
||||
}
|
||||
|
||||
public record NotificationsSection(List<NotificationItem> queued, List<NotificationItem> 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) {
|
||||
}
|
||||
}
|
||||
@@ -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<String> 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.
|
||||
*
|
||||
|
||||
@@ -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() {
|
||||
</RequireAdmin>
|
||||
}
|
||||
/>
|
||||
{/*
|
||||
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.
|
||||
*/}
|
||||
<Route
|
||||
path="zasoby"
|
||||
element={
|
||||
<RequireAdmin>
|
||||
<ResourcesPage />
|
||||
</RequireAdmin>
|
||||
}
|
||||
/>
|
||||
{/* nieznana podstrona panelu zostaje w panelu, a nie wypada na korzen */}
|
||||
<Route path="*" element={<Navigate to={panelPath()} replace />} />
|
||||
</Route>
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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<ResourcesView>('/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 <strong>{egressId}</strong> 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 <strong>{item.webinarTitle}</strong>
|
||||
</>
|
||||
) : 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ść <strong>{item.template}</strong> do <strong>{item.recipient}</strong> 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 <Spinner />
|
||||
if (query.isError) {
|
||||
return <ErrorState message={errorMessage(query.error, 'Nie udało się wczytać stanu zasobów')} />
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-xl font-semibold text-slate-900">Zasoby</h1>
|
||||
<p className="text-sm text-slate-500">
|
||||
Zadania prowadzone w tle: nagrania, kolejka wiadomości i porzucone sesje
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<p className="hidden text-xs text-slate-400 sm:block">
|
||||
Sprawdzono: {formatLocal(data.checkedAt)}
|
||||
</p>
|
||||
<Button
|
||||
variant="secondary"
|
||||
icon="refresh"
|
||||
onClick={() => refresh()}
|
||||
disabled={query.isFetching}
|
||||
>
|
||||
{query.isFetching ? 'Odświeżanie...' : 'Odśwież'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{everythingClear ? (
|
||||
<Card>
|
||||
<EmptyState
|
||||
icon="check-circle"
|
||||
title="Nic nie wymaga uwagi"
|
||||
description="Żadne nagranie nie utknęło, kolejka wiadomości jest pusta, a wszystkie podejścia do quizów i sesje obecności są domknięte."
|
||||
/>
|
||||
</Card>
|
||||
) : 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 && (
|
||||
<Card title="Osierocone zadania nagrywania" description="Wymagają ręcznego zatrzymania">
|
||||
<div className="space-y-3">
|
||||
<Alert tone="alarm" icon="alert-triangle">
|
||||
Zadanie działa u dostawcy, ale nie ma go w bazie. Nie zatrzyma się samo i zajmuje moc
|
||||
serwera.
|
||||
</Alert>
|
||||
|
||||
<ul className="space-y-2">
|
||||
{data.recordings.orphanEgressIds.map((egressId) => (
|
||||
<li
|
||||
key={egressId}
|
||||
className="flex flex-wrap items-center justify-between gap-3 rounded-md border border-alarm-500/20 bg-alarm-50 p-3"
|
||||
>
|
||||
<p className="hx-data min-w-0 break-all text-sm font-medium text-alarm-600">
|
||||
{egressId}
|
||||
</p>
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
icon="x-circle"
|
||||
onClick={() => askStop(egressId)}
|
||||
disabled={stopRecording.isPending}
|
||||
>
|
||||
Zatrzymaj
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/*
|
||||
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 && (
|
||||
<Alert tone="warn">
|
||||
{data.recordings.problem}. Lista osieroconych zadań może być niepełna.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Card title="Nagrania w toku" description="Wpisy w stanie pobierania po naszej stronie">
|
||||
{data.recordings.inProgress.length === 0 ? (
|
||||
<p className="text-sm text-slate-500">Żadne nagranie nie jest w toku.</p>
|
||||
) : (
|
||||
<>
|
||||
{/* na telefonie karty, tabela dopiero od duzego ekranu */}
|
||||
<ul className="space-y-3 lg:hidden">
|
||||
{data.recordings.inProgress.map((item) => (
|
||||
<li
|
||||
key={item.id}
|
||||
className={`space-y-3 rounded-md border p-3 ${
|
||||
item.stuck ? 'border-warn-500/25 bg-warn-50' : 'border-slate-100'
|
||||
}`}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium text-slate-900">
|
||||
{item.webinarTitle ?? 'Webinar usunięty'}
|
||||
</p>
|
||||
<p className="hx-data break-all text-xs text-slate-500">
|
||||
{item.providerRecordingId ?? 'brak identyfikatora u dostawcy'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="hx-data text-xs text-slate-500">
|
||||
{formatLocal(item.startedAt)}
|
||||
</span>
|
||||
{item.stuck && <Badge tone="warn">wisi ponad 6 godzin</Badge>}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{item.providerRecordingId && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
icon="x-circle"
|
||||
onClick={() => askStop(item.providerRecordingId as string)}
|
||||
disabled={stopRecording.isPending}
|
||||
>
|
||||
Zatrzymaj
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
icon="trash"
|
||||
onClick={() => askDropRecording(item)}
|
||||
disabled={dropRecording.isPending}
|
||||
className="text-alarm-600 hover:bg-alarm-50"
|
||||
>
|
||||
Usuń wpis
|
||||
</Button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<div className="hidden overflow-x-auto lg:block">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="border-b border-slate-200 text-left text-xs uppercase tracking-wide text-slate-500">
|
||||
<tr>
|
||||
<th className="px-3 py-2 font-semibold">Webinar</th>
|
||||
<th className="px-3 py-2 font-semibold">Zadanie u dostawcy</th>
|
||||
<th className="px-3 py-2 font-semibold">Rozpoczęto</th>
|
||||
<th className="px-3 py-2 text-right font-semibold">Akcje</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{data.recordings.inProgress.map((item) => (
|
||||
<tr key={item.id} className={item.stuck ? 'bg-warn-50' : undefined}>
|
||||
<td className="px-3 py-3">
|
||||
<p className="font-medium text-slate-900">
|
||||
{item.webinarTitle ?? 'Webinar usunięty'}
|
||||
</p>
|
||||
{item.stuck && (
|
||||
<Badge tone="warn" className="mt-1">
|
||||
wisi ponad 6 godzin
|
||||
</Badge>
|
||||
)}
|
||||
</td>
|
||||
<td className="hx-data break-all px-3 py-3 text-xs text-slate-600">
|
||||
{item.providerRecordingId ?? '-'}
|
||||
</td>
|
||||
<td className="hx-data px-3 py-3 text-slate-600">
|
||||
{formatLocal(item.startedAt)}
|
||||
</td>
|
||||
<td className="px-3 py-3">
|
||||
<div className="flex justify-end gap-1">
|
||||
{item.providerRecordingId && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
icon="x-circle"
|
||||
onClick={() => askStop(item.providerRecordingId as string)}
|
||||
disabled={stopRecording.isPending}
|
||||
>
|
||||
Zatrzymaj
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
icon="trash"
|
||||
onClick={() => askDropRecording(item)}
|
||||
disabled={dropRecording.isPending}
|
||||
className="text-alarm-600 hover:bg-alarm-50"
|
||||
>
|
||||
Usuń wpis
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title="Kolejka wiadomości"
|
||||
description="Wiadomości czekające na wysyłkę i te, które się nie udały"
|
||||
>
|
||||
<div className="space-y-6">
|
||||
<NotificationGroup
|
||||
heading="Czekają w kolejce"
|
||||
emptyText="Kolejka jest pusta."
|
||||
items={data.notifications.queued}
|
||||
onRetry={(id) => retryNotification.mutate(id)}
|
||||
onDrop={askDropNotification}
|
||||
retryPending={retryNotification.isPending}
|
||||
dropPending={dropNotification.isPending}
|
||||
/>
|
||||
<NotificationGroup
|
||||
heading="Nieudane"
|
||||
emptyText="Żadna wiadomość nie zakończyła się błędem."
|
||||
items={data.notifications.failed}
|
||||
onRetry={(id) => retryNotification.mutate(id)}
|
||||
onDrop={askDropNotification}
|
||||
retryPending={retryNotification.isPending}
|
||||
dropPending={dropNotification.isPending}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title="Porzucone podejścia i sesje"
|
||||
description="Domykane zadaniem w tle, tutaj można je domknąć ręcznie"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<CountTile
|
||||
icon="list-checks"
|
||||
label="Otwarte podejścia do quizów"
|
||||
count={data.openQuizAttempts.length}
|
||||
hint="Otwarte podejście blokuje kursantowi kolejne."
|
||||
/>
|
||||
<CountTile
|
||||
icon="users"
|
||||
label="Nieświeże sesje obecności"
|
||||
count={data.staleAttendance.length}
|
||||
hint="Sesja bez zamknięcia zawyża czas obecności."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button
|
||||
icon="check-circle"
|
||||
onClick={() => cleanup.mutate()}
|
||||
disabled={cleanup.isPending || abandonedCount === 0}
|
||||
>
|
||||
{cleanup.isPending ? 'Domykanie...' : 'Domknij porzucone'}
|
||||
</Button>
|
||||
{abandonedCount === 0 && <p className="text-sm text-slate-500">Nie ma czego domykać.</p>}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Kafelek z liczba pozycji czekajacych na domkniecie. */
|
||||
function CountTile({
|
||||
icon,
|
||||
label,
|
||||
count,
|
||||
hint,
|
||||
}: {
|
||||
icon: IconName
|
||||
label: string
|
||||
count: number
|
||||
hint: string
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={`rounded-md border p-3 ${
|
||||
count > 0 ? 'border-warn-500/25 bg-warn-50' : 'border-slate-100'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon name={icon} className={count > 0 ? 'text-warn-600' : 'text-slate-400'} />
|
||||
<p className="text-sm font-semibold text-slate-800">{label}</p>
|
||||
</div>
|
||||
<p className="hx-data mt-1 text-2xl font-semibold text-slate-900">{count}</p>
|
||||
<p className="mt-1 text-xs leading-relaxed text-slate-500">{hint}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h3 className="text-sm font-semibold text-slate-800">{heading}</h3>
|
||||
<Badge tone={items.length > 0 ? 'info' : 'neutral'}>{items.length}</Badge>
|
||||
</div>
|
||||
|
||||
{items.length === 0 ? (
|
||||
<p className="text-sm text-slate-500">{emptyText}</p>
|
||||
) : (
|
||||
<>
|
||||
{/* na telefonie karty, tabela dopiero od duzego ekranu */}
|
||||
<ul className="space-y-3 lg:hidden">
|
||||
{items.map((item) => (
|
||||
<li
|
||||
key={item.id}
|
||||
className={`space-y-3 rounded-md border p-3 ${
|
||||
item.stuck ? 'border-warn-500/25 bg-warn-50' : 'border-slate-100'
|
||||
}`}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium text-slate-900">{item.template}</p>
|
||||
<p className="truncate text-sm text-slate-500">{item.recipient}</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge tone="neutral">{item.channel}</Badge>
|
||||
{item.stuck && <Badge tone="warn">zalega ponad 2 godziny</Badge>}
|
||||
<span className="hx-data text-xs text-slate-500">
|
||||
{formatLocal(item.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
{item.error && (
|
||||
<p className="break-words rounded bg-alarm-50 px-2 py-1 text-xs text-alarm-600">
|
||||
{item.error}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
icon="refresh"
|
||||
onClick={() => onRetry(item.id)}
|
||||
disabled={retryPending}
|
||||
>
|
||||
Ponów
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
icon="trash"
|
||||
onClick={() => onDrop(item)}
|
||||
disabled={dropPending}
|
||||
className="text-alarm-600 hover:bg-alarm-50"
|
||||
>
|
||||
Usuń
|
||||
</Button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<div className="hidden overflow-x-auto lg:block">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="border-b border-slate-200 text-left text-xs uppercase tracking-wide text-slate-500">
|
||||
<tr>
|
||||
<th className="px-3 py-2 font-semibold">Wiadomość</th>
|
||||
<th className="px-3 py-2 font-semibold">Kanał</th>
|
||||
<th className="px-3 py-2 font-semibold">Dodano</th>
|
||||
<th className="px-3 py-2 text-right font-semibold">Akcje</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{items.map((item) => (
|
||||
<tr key={item.id} className={item.stuck ? 'bg-warn-50' : undefined}>
|
||||
<td className="px-3 py-3">
|
||||
<p className="font-medium text-slate-900">{item.template}</p>
|
||||
<p className="text-xs text-slate-500">{item.recipient}</p>
|
||||
{item.stuck && (
|
||||
<Badge tone="warn" className="mt-1">
|
||||
zalega ponad 2 godziny
|
||||
</Badge>
|
||||
)}
|
||||
{item.error && (
|
||||
<p className="mt-1 break-words text-xs text-alarm-600">{item.error}</p>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-3">
|
||||
<Badge tone="neutral">{item.channel}</Badge>
|
||||
</td>
|
||||
<td className="hx-data px-3 py-3 text-slate-600">
|
||||
{formatLocal(item.createdAt)}
|
||||
</td>
|
||||
<td className="px-3 py-3">
|
||||
<div className="flex justify-end gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
icon="refresh"
|
||||
onClick={() => onRetry(item.id)}
|
||||
disabled={retryPending}
|
||||
>
|
||||
Ponów
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
icon="trash"
|
||||
onClick={() => onDrop(item)}
|
||||
disabled={dropPending}
|
||||
className="text-alarm-600 hover:bg-alarm-50"
|
||||
>
|
||||
Usuń
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user