Просмотр исходного кода

feat(dashboard): Implement UI for scheduled tasks

Michael Bromley 9 месяцев назад
Родитель
Сommit
4692b2deed

+ 7 - 8
packages/dashboard/src/app/routes/_authenticated/_system/job-queue.tsx

@@ -1,14 +1,13 @@
+import { Badge } from '@/components/ui/badge.js';
+import { Button } from '@/components/ui/button.js';
 import { ListPage } from '@/framework/page/list-page.js';
+import { api } from '@/graphql/api.js';
 import { Trans } from '@/lib/trans.js';
 import { createFileRoute } from '@tanstack/react-router';
-import { jobListDocument, jobQueueListDocument } from './job-queue.graphql.js';
-import { Badge } from '@/components/ui/badge.js';
-import { Button } from '@/components/ui/button.js';
+import { formatRelative } from 'date-fns';
+import { Ban, CheckCircle2Icon, CircleXIcon, ClockIcon, LoaderIcon, RotateCcw } from 'lucide-react';
 import { PayloadDialog } from './components/payload-dialog.js';
-import { differenceInMilliseconds, formatDuration, formatRelative } from 'date-fns';
-import { Ban, CircleXIcon, ClockIcon, LoaderIcon, RotateCcw } from 'lucide-react';
-import { CheckCircle2Icon } from 'lucide-react';
-import { api } from '@/graphql/api.js';
+import { jobListDocument, jobQueueListDocument } from './job-queue.graphql.js';
 
 export const Route = createFileRoute('/_authenticated/_system/job-queue')({
     component: JobQueuePage,
@@ -58,7 +57,7 @@ function JobQueuePage() {
             customizeColumns={{
                 createdAt: {
                     header: 'Created At',
-                    cell: ({ row }) => formatRelative(row.original.createdAt, new Date()),
+                    cell: ({ row }) => <div title={row.original.createdAt}>{formatRelative(new Date(row.original.createdAt), new Date())}</div>,
                 },
                 data: {
                     header: 'Data',

+ 206 - 0
packages/dashboard/src/app/routes/_authenticated/_system/scheduled-tasks.tsx

@@ -0,0 +1,206 @@
+import { FullWidthPageBlock, Page, PageLayout, PageTitle } from '@/framework/layout-engine/page-layout.js';
+import { api } from '@/graphql/api.js';
+import { graphql } from '@/graphql/graphql.js';
+import { DataTable } from '@/components/data-table/data-table.js';
+import { Trans } from '@/lib/trans.js';
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+import { createFileRoute } from '@tanstack/react-router';
+import { createColumnHelper } from '@tanstack/react-table';
+import { ResultOf } from '@/graphql/graphql.js';
+import { PayloadDialog } from './components/payload-dialog.js';
+import { Button } from '@/components/ui/button.js';
+import { Badge } from '@/components/ui/badge.js';
+import { useLocalFormat } from '@/hooks/use-local-format.js';
+import {
+    DropdownMenu,
+    DropdownMenuContent,
+    DropdownMenuItem,
+    DropdownMenuTrigger,
+} from '@/components/ui/dropdown-menu.js';
+import { EllipsisIcon } from 'lucide-react';
+
+export const Route = createFileRoute('/_authenticated/_system/scheduled-tasks')({
+    component: ScheduledTasksPage,
+    loader: () => ({ breadcrumb: () => <Trans>Scheduled Tasks</Trans> }),
+});
+
+const getScheduledTasksDocument = graphql(`
+    query ScheduledTasks {
+        scheduledTasks {
+            id
+            description
+            schedule
+            scheduleDescription
+            lastExecutedAt
+            nextExecutionAt
+            isRunning
+            lastResult
+            enabled
+        }
+    }
+`);
+
+const updateScheduledTaskDocument = graphql(`
+    mutation UpdateScheduledTask($input: UpdateScheduledTaskInput!) {
+        updateScheduledTask(input: $input) {
+            id
+            enabled
+        }
+    }
+`);
+
+type ScheduledTask = ResultOf<typeof getScheduledTasksDocument>['scheduledTasks'][number];
+
+function ScheduledTasksPage() {
+    const { data } = useQuery({
+        queryKey: ['scheduledTasks'],
+        queryFn: () => api.query(getScheduledTasksDocument),
+    });
+    const queryClient = useQueryClient();
+    const { mutate: updateScheduledTask } = useMutation({
+        mutationFn: api.mutate(updateScheduledTaskDocument),
+        onSuccess: () => {
+            queryClient.invalidateQueries({ queryKey: ['scheduledTasks'] });
+        },
+    });
+    const { formatDate, formatRelativeDate } = useLocalFormat();
+    const intlDateOptions = {
+        year: 'numeric',
+        month: 'short',
+        day: 'numeric',
+        hour: 'numeric',
+        minute: 'numeric',
+        second: 'numeric',
+    } as const;
+
+    const columnHelper = createColumnHelper<ScheduledTask>();
+    const columns = [
+        columnHelper.accessor('id', {
+            header: 'ID',
+        }),
+        columnHelper.accessor('description', {
+            header: 'Description',
+        }),
+        columnHelper.accessor('enabled', {
+            header: 'Enabled',
+            cell: ({ row }) => {
+                return row.original.enabled ? (
+                    <Badge variant="success">
+                        <Trans>Enabled</Trans>
+                    </Badge>
+                ) : (
+                    <Badge variant="secondary">
+                        <Trans>Disabled</Trans>
+                    </Badge>
+                );
+            },
+        }),
+        columnHelper.accessor('schedule', {
+            header: 'Schedule Pattern',
+        }),
+        columnHelper.accessor('scheduleDescription', {
+            header: 'Schedule',
+        }),
+        columnHelper.accessor('lastExecutedAt', {
+            header: 'Last Executed',
+            cell: ({ row }) => {
+                return row.original.lastExecutedAt ? (
+                    <div title={row.original.lastExecutedAt}>
+                        {formatRelativeDate(row.original.lastExecutedAt)}
+                    </div>
+                ) : (
+                    <Trans>Never</Trans>
+                );
+            },
+        }),
+        columnHelper.accessor('nextExecutionAt', {
+            header: 'Next Execution',
+            cell: ({ row }) => {
+                return row.original.nextExecutionAt ? (
+                    formatDate(row.original.nextExecutionAt, intlDateOptions)
+                ) : (
+                    <Trans>Never</Trans>
+                );
+            },
+        }),
+        columnHelper.accessor('isRunning', {
+            header: 'Running',
+            cell: ({ row }) => {
+                return row.original.isRunning ? (
+                    <Badge variant="success">
+                        <Trans>Running</Trans>
+                    </Badge>
+                ) : (
+                    <Badge variant="secondary">
+                        <Trans>Not Running</Trans>
+                    </Badge>
+                );
+            },
+        }),
+        columnHelper.accessor('lastResult', {
+            header: 'Last Result',
+            cell: ({ row }) => {
+                return row.original.lastResult ? (
+                    <PayloadDialog
+                        payload={row.original.lastResult}
+                        title={<Trans>View job result</Trans>}
+                        description={<Trans>The result of the job</Trans>}
+                        trigger={
+                            <Button size="sm" variant="secondary">
+                                View result
+                            </Button>
+                        }
+                    />
+                ) : (
+                    <div className="text-muted-foreground">
+                        <Trans>No result yet</Trans>
+                    </div>
+                );
+            },
+        }),
+        columnHelper.display({
+            id: 'actions',
+            header: 'Actions',
+            cell: ({ row }) => {
+                return (
+                    <DropdownMenu>
+                        <DropdownMenuTrigger asChild>
+                            <Button variant="ghost" size="icon">
+                                <EllipsisIcon />
+                            </Button>
+                        </DropdownMenuTrigger>
+                        <DropdownMenuContent>
+                            <DropdownMenuItem
+                                onClick={() =>
+                                    updateScheduledTask({
+                                        input: { id: row.original.id, enabled: !row.original.enabled },
+                                    })
+                                }
+                            >
+                                {row.original.enabled ? <Trans>Disable</Trans> : <Trans>Enable</Trans>}
+                            </DropdownMenuItem>
+                        </DropdownMenuContent>
+                    </DropdownMenu>
+                );
+            },
+        }),
+    ];
+
+    return (
+        <Page pageId="scheduled-tasks-list">
+            <PageTitle>Scheduled Tasks</PageTitle>
+            <PageLayout>
+                <FullWidthPageBlock blockId="list-table">
+                    <DataTable
+                        columns={columns}
+                        data={data?.scheduledTasks ?? []}
+                        totalItems={data?.scheduledTasks?.length ?? 0}
+                        defaultColumnVisibility={{
+                            schedule: false,
+                        }}
+                    />
+                </FullWidthPageBlock>
+            </PageLayout>
+        </Page>
+    );
+}

+ 4 - 4
packages/dashboard/src/lib/components/data-table/data-table.tsx

@@ -30,8 +30,8 @@ export interface FacetedFilter {
     options?: DataTableFacetedFilterOption[];
 }
 
-interface DataTableProps<TData, TValue> {
-    columns: ColumnDef<TData, TValue>[];
+interface DataTableProps<TData> {
+    columns: ColumnDef<TData, any>[];
     data: TData[];
     totalItems: number;
     page?: number;
@@ -52,7 +52,7 @@ interface DataTableProps<TData, TValue> {
     setTableOptions?: (table: TableOptions<TData>) => TableOptions<TData>;
 }
 
-export function DataTable<TData, TValue>({
+export function DataTable<TData>({
     columns,
     data,
     totalItems,
@@ -68,7 +68,7 @@ export function DataTable<TData, TValue>({
     facetedFilters,
     disableViewOptions,
     setTableOptions,
-}: DataTableProps<TData, TValue>) {
+}: DataTableProps<TData>) {
     const [sorting, setSorting] = React.useState<SortingState>(sortingInitialState || []);
     const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>(filtersInitialState || []);
     const [pagination, setPagination] = React.useState<PaginationState>({

+ 5 - 0
packages/dashboard/src/lib/framework/defaults.ts

@@ -122,6 +122,11 @@ export function registerDefaults() {
                         title: 'Healthchecks',
                         url: '/healthchecks',
                     },
+                    {
+                        id: 'scheduled-tasks',
+                        title: 'Scheduled Tasks',
+                        url: '/scheduled-tasks',
+                    },
                 ],
             },
             {

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
packages/dashboard/src/lib/graphql/graphql-env.d.ts


+ 24 - 0
packages/dashboard/src/lib/hooks/use-local-format.ts

@@ -65,6 +65,29 @@ export function useLocalFormat() {
         [locale],
     );
 
+    const formatRelativeDate = useCallback(
+        (value: string | Date, options?: Intl.RelativeTimeFormatOptions) => {
+            const now = new Date();
+            const date = new Date(value);
+            const diffSeconds = Math.floor((now.getTime() - date.getTime()) / 1000);
+            // if less than 1 minute, use seconds. Else use minutes, hours, days, months, years
+            if (diffSeconds < 60) {
+                return new Intl.RelativeTimeFormat(locale, options).format(diffSeconds * -1, 'seconds');
+            } else if (diffSeconds < 3600) {
+                return new Intl.RelativeTimeFormat(locale, options).format(diffSeconds * -1, 'minutes');
+            } else if (diffSeconds < 86400) {
+                return new Intl.RelativeTimeFormat(locale, options).format(diffSeconds * -1, 'hours');
+            } else if (diffSeconds < 2592000) {
+                return new Intl.RelativeTimeFormat(locale, options).format(diffSeconds * -1, 'days');
+            } else if (diffSeconds < 31536000) {
+                return new Intl.RelativeTimeFormat(locale, options).format(diffSeconds * -1, 'months');
+            } else {
+                return new Intl.RelativeTimeFormat(locale, options).format(diffSeconds * -1, 'years');
+            }
+        },
+        [locale],
+    );
+
     const formatLanguageName = useCallback(
         (value: string): string => {
             try {
@@ -111,6 +134,7 @@ export function useLocalFormat() {
         formatCurrency,
         formatNumber,
         formatDate,
+        formatRelativeDate,
         formatLanguageName,
         formatCurrencyName,
         toMajorUnits,

+ 1 - 1
packages/dashboard/tsconfig.json

@@ -12,7 +12,7 @@
       {
         "name": "gql.tada/ts-plugin",
         "schema": "http://localhost:3000/admin-api",
-        "tadaOutputLocation": "./src/graphql/graphql-env.d.ts"
+        "tadaOutputLocation": "./src/lib/graphql/graphql-env.d.ts"
       }
     ]
   },

+ 1 - 1
packages/dashboard/vite.config.mts

@@ -11,7 +11,7 @@ export default ({ mode }: { mode: string }) => {
     process.env = { ...process.env, ...loadEnv(mode, process.cwd()) };
 
     const adminApiHost = process.env.VITE_ADMIN_API_HOST || 'http://localhost';
-    const adminApiPort = process.env.VITE_ADMIN_API_PORT ? +process.env.VITE_ADMIN_API_PORT : 'auto';
+    const adminApiPort = process.env.VITE_ADMIN_API_PORT ? +process.env.VITE_ADMIN_API_PORT : '3000';
 
     process.env.IS_LOCAL_DEV = adminApiHost.includes('localhost') ? 'true' : 'false';
 

Некоторые файлы не были показаны из-за большого количества измененных файлов