konobangu/apps/webui/src/presentation/routes/_app/tasks/manage.tsx

325 lines
10 KiB
TypeScript

import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { DataTablePagination } from '@/components/ui/data-table-pagination';
import { DetailEmptyView } from '@/components/ui/detail-empty-view';
import { DropdownMenuActions } from '@/components/ui/dropdown-menu-actions';
import { QueryErrorView } from '@/components/ui/query-error-view';
import { Skeleton } from '@/components/ui/skeleton';
import {
DELETE_TASKS,
GET_TASKS,
RETRY_TASKS,
type TaskDto,
} from '@/domains/recorder/schema/tasks';
import {
type DeleteTasksMutation,
type DeleteTasksMutationVariables,
type GetTasksQuery,
type RetryTasksMutation,
type RetryTasksMutationVariables,
SubscriberTaskStatusEnum,
} from '@/infra/graphql/gql/graphql';
import type { RouteStateDataOption } from '@/infra/routes/traits';
import { useDebouncedSkeleton } from '@/presentation/hooks/use-debounded-skeleton';
import { useMutation, useQuery } from '@apollo/client';
import { createFileRoute, useNavigate } from '@tanstack/react-router';
import {
type ColumnDef,
type PaginationState,
type SortingState,
type VisibilityState,
getCoreRowModel,
getPaginationRowModel,
useReactTable,
} from '@tanstack/react-table';
import { format } from 'date-fns';
import { RefreshCw } from 'lucide-react';
import { DropdownMenuItem } from '@/components/ui/dropdown-menu';
import {
apolloErrorToMessage,
getApolloQueryError,
} from '@/infra/errors/apollo';
import { useMemo, useState } from 'react';
import { toast } from 'sonner';
import { getStatusBadge } from './-status-badge';
export const Route = createFileRoute('/_app/tasks/manage')({
component: TaskManageRouteComponent,
staticData: {
breadcrumb: { label: 'Manage' },
} satisfies RouteStateDataOption,
});
function TaskManageRouteComponent() {
const navigate = useNavigate();
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({
lockAt: false,
lockBy: false,
attempts: false,
});
const [sorting, setSorting] = useState<SortingState>([]);
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 10,
});
const { loading, error, data, refetch } = useQuery<GetTasksQuery>(GET_TASKS, {
variables: {
pagination: {
page: {
page: pagination.pageIndex,
limit: pagination.pageSize,
},
},
filters: {},
orderBy: {
runAt: 'DESC',
},
},
pollInterval: 5000, // Auto-refresh every 5 seconds
});
const { showSkeleton } = useDebouncedSkeleton({ loading });
const tasks = data?.subscriberTasks;
const [deleteTasks] = useMutation<
DeleteTasksMutation,
DeleteTasksMutationVariables
>(DELETE_TASKS, {
onCompleted: async () => {
const refetchResult = await refetch();
const error = getApolloQueryError(refetchResult);
if (error) {
toast.error('Failed to delete tasks', {
description: apolloErrorToMessage(error),
});
return;
}
toast.success('Tasks deleted');
},
onError: (error) => {
toast.error('Failed to delete tasks', {
description: error.message,
});
},
});
const [retryTasks] = useMutation<
RetryTasksMutation,
RetryTasksMutationVariables
>(RETRY_TASKS, {
onCompleted: () => {
toast.success('Tasks retried');
},
onError: (error) => {
toast.error('Failed to retry tasks', {
description: error.message,
});
},
});
const columns = useMemo(() => {
const cs: ColumnDef<TaskDto>[] = [
{
header: 'ID',
accessorKey: 'id',
cell: ({ row }) => {
return (
<div
className="max-w-[200px] truncate font-mono text-sm"
title={row.original.id}
>
{row.original.id}
</div>
);
},
},
];
return cs;
}, []);
const table = useReactTable({
data: useMemo(() => (tasks?.nodes ?? []) as TaskDto[], [tasks]),
columns,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
onPaginationChange: setPagination,
onSortingChange: setSorting,
onColumnVisibilityChange: setColumnVisibility,
pageCount: tasks?.paginationInfo?.pages,
rowCount: tasks?.paginationInfo?.total,
enableColumnPinning: true,
autoResetPageIndex: true,
manualPagination: true,
state: {
pagination,
sorting,
columnVisibility,
},
initialState: {
columnPinning: {
right: ['actions'],
},
},
});
if (error) {
return <QueryErrorView message={error.message} onRetry={refetch} />;
}
return (
<div className="container mx-auto space-y-4 px-4">
<div className="flex items-center justify-between pt-4">
<div>
<h1 className="font-bold text-2xl">Tasks Management</h1>
<p className="text-muted-foreground">Manage your tasks</p>
</div>
<Button onClick={() => refetch()} variant="outline" size="sm">
<RefreshCw className="h-4 w-4" />
</Button>
</div>
<div className="space-y-3">
{showSkeleton &&
Array.from(new Array(10)).map((_, index) => (
<Skeleton key={index} className="h-32 w-full" />
))}
{!showSkeleton && table.getRowModel().rows?.length > 0 ? (
table.getRowModel().rows.map((row, index) => {
const task = row.original;
return (
<div
className="space-y-3 rounded-lg border bg-card p-4"
key={`${task.id}-${index}`}
>
{/* Header with status and priority */}
<div className="flex items-center justify-between gap-2">
<div className="font-mono text-muted-foreground text-xs">
# {task.id}
</div>
<div className="flex gap-2">
<Badge variant="outline">{task.taskType}</Badge>
</div>
</div>
<div className="mt-1 flex items-center gap-2">
{getStatusBadge(task.status)}
<Badge variant="outline">Priority: {task.priority}</Badge>
<div className="mr-0 ml-auto">
<DropdownMenuActions
id={task.id}
showDetail
onDetail={() => {
navigate({
to: '/tasks/detail/$id',
params: { id: task.id },
});
}}
showDelete
onDelete={() =>
deleteTasks({
variables: {
filters: {
id: {
eq: task.id,
},
},
},
})
}
>
{task.status ===
(SubscriberTaskStatusEnum.Killed ||
SubscriberTaskStatusEnum.Failed) && (
<DropdownMenuItem
onSelect={() =>
retryTasks({
variables: {
filters: {
id: {
eq: task.id,
},
},
},
})
}
>
Retry
</DropdownMenuItem>
)}
</DropdownMenuActions>
</div>
</div>
{/* Time info */}
<div className="grid grid-cols-2 gap-2 text-sm">
<div>
<span className="text-muted-foreground">Run at: </span>
<span>{format(new Date(task.runAt), 'MM/dd HH:mm')}</span>
</div>
<div>
<span className="text-muted-foreground">Done: </span>
<span>
{task.doneAt
? format(new Date(task.doneAt), 'MM/dd HH:mm')
: '-'}
</span>
</div>
{/* Attempts */}
<div className="text-sm">
<span className="text-muted-foreground">Attempts: </span>
<span>
{task.attempts} / {task.maxAttempts}
</span>
</div>
{/* Lock at */}
<div className="text-sm">
<span className="text-muted-foreground">Lock at: </span>
<span>
{task.lockAt
? format(new Date(task.lockAt), 'MM/dd HH:mm')
: '-'}
</span>
</div>
</div>
{task.job && (
<div className="text-sm">
<span className="text-muted-foreground">Job: </span>
<br />
<span
className="whitespace-pre-wrap"
dangerouslySetInnerHTML={{
__html: JSON.stringify(task.job, null, 2),
}}
/>
</div>
)}
{/* Error if exists */}
{(task.status === SubscriberTaskStatusEnum.Failed ||
task.status === SubscriberTaskStatusEnum.Killed) &&
task.lastError && (
<div className="rounded bg-destructive/10 p-2 text-destructive text-sm">
{task.lastError}
</div>
)}
</div>
);
})
) : (
<DetailEmptyView message="No tasks found" fullWidth />
)}
</div>
<DataTablePagination table={table} showSelectedRowCount={false} />
</div>
);
}