Sekcja Zasoby: podglad i sprzatanie zadan w tle
Deploy PROD / deploy (push) Successful in 26s

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:
2026-09-05 08:06:19 +02:00
parent 06097be829
commit f35bfe1b8c
7 changed files with 1085 additions and 0 deletions
+15
View File
@@ -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>
+1
View File
@@ -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>
)
}