feat: task ui basic done

This commit is contained in:
2025-06-13 04:02:01 +08:00
parent c60f6f511e
commit 882b29d7a1
17 changed files with 949 additions and 835 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -39,7 +39,12 @@ import type {
} from '@/infra/graphql/gql/graphql';
import type { RouteStateDataOption } from '@/infra/routes/traits';
import { useMutation, useQuery } from '@apollo/client';
import { createFileRoute, useNavigate } from '@tanstack/react-router';
import {
createFileRoute,
useCanGoBack,
useNavigate,
useRouter,
} from '@tanstack/react-router';
import { ArrowLeft, Eye, EyeOff, Save, X } from 'lucide-react';
import { useCallback, useState } from 'react';
import { toast } from 'sonner';
@@ -63,11 +68,17 @@ function FormView({
const togglePasswordVisibility = () => {
setShowPassword((prev) => !prev);
};
const router = useRouter();
const canGoBack = useCanGoBack();
const handleBack = () => {
navigate({
to: '/credential3rd/manage',
});
if (canGoBack) {
router.history.back();
} else {
navigate({
to: '/credential3rd/manage',
});
}
};
const [updateCredential, { loading: updating }] = useMutation<

View File

@@ -1,10 +1,10 @@
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { DataTablePagination } from '@/components/ui/data-table-pagination';
import { DataTableRowActions } from '@/components/ui/data-table-row-actions';
import { DataTableViewOptions } from '@/components/ui/data-table-view-options';
import { DialogTrigger } from '@/components/ui/dialog';
import { DropdownMenuItem } from '@/components/ui/dropdown-menu';
import { DropdownMenuActions } from '@/components/ui/dropdown-menu-actions';
import { QueryErrorView } from '@/components/ui/query-error-view';
import { Skeleton } from '@/components/ui/skeleton';
import {
@@ -231,9 +231,8 @@ function CredentialManageRouteComponent() {
{
id: 'actions',
cell: ({ row }) => (
<DataTableRowActions
row={row}
getId={(row) => row.original.id}
<DropdownMenuActions
id={row.original.id}
showEdit
showDelete
showDetail
@@ -261,7 +260,7 @@ function CredentialManageRouteComponent() {
id={row.original.id}
/>
</Dialog>
</DataTableRowActions>
</DropdownMenuActions>
),
},
];

View File

@@ -1,9 +1,9 @@
import { Button } from '@/components/ui/button';
import { DataTablePagination } from '@/components/ui/data-table-pagination';
import { DataTableRowActions } from '@/components/ui/data-table-row-actions';
import { DataTableViewOptions } from '@/components/ui/data-table-view-options';
import { Dialog, DialogTrigger } from '@/components/ui/dialog';
import { DropdownMenuItem } from '@/components/ui/dropdown-menu';
import { DropdownMenuActions } from '@/components/ui/dropdown-menu-actions';
import { QueryErrorView } from '@/components/ui/query-error-view';
import { Skeleton } from '@/components/ui/skeleton';
import { Switch } from '@/components/ui/switch';
@@ -225,9 +225,8 @@ function SubscriptionManageRouteComponent() {
{
id: 'actions',
cell: ({ row }) => (
<DataTableRowActions
row={row}
getId={(row) => row.original.id}
<DropdownMenuActions
id={row.original.id}
showDetail
showEdit
showDelete
@@ -253,7 +252,7 @@ function SubscriptionManageRouteComponent() {
</DialogTrigger>
<SubscriptionSyncDialogContent id={row.original.id} />
</Dialog>
</DataTableRowActions>
</DropdownMenuActions>
),
},
];

View File

@@ -0,0 +1,44 @@
import { Badge } from '@/components/ui/badge';
import { SubscriberTaskStatusEnum } from '@/infra/graphql/gql/graphql';
import { AlertCircle, CheckCircle, Clock, Loader2 } from 'lucide-react';
export function getStatusBadge(status: SubscriberTaskStatusEnum) {
switch (status) {
case SubscriberTaskStatusEnum.Done:
return (
<Badge variant="secondary" className="bg-green-100 text-green-800">
<CheckCircle className="mr-1 h-3 w-3 capitalize" />
{status}
</Badge>
);
case SubscriberTaskStatusEnum.Running:
return (
<Badge variant="secondary" className="bg-blue-100 text-blue-800">
<Loader2 className="mr-1 h-3 w-3 animate-spin capitalize" />
{status}
</Badge>
);
case SubscriberTaskStatusEnum.Killed:
case SubscriberTaskStatusEnum.Failed:
return (
<Badge variant="destructive">
<AlertCircle className="mr-1 h-3 w-3 capitalize" />
{status}
</Badge>
);
case SubscriberTaskStatusEnum.Scheduled:
case SubscriberTaskStatusEnum.Pending:
return (
<Badge variant="secondary" className="bg-yellow-100 text-yellow-800">
<Clock className="mr-1 h-3 w-3 capitalize" />
{status}
</Badge>
);
default:
return (
<Badge variant="outline" className="capitalize">
{status}
</Badge>
);
}
}

View File

@@ -1,5 +1,34 @@
import { DetailCardSkeleton } from '@/components/detail-card-skeleton';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { DetailEmptyView } from '@/components/ui/detail-empty-view';
import { Label } from '@/components/ui/label';
import { QueryErrorView } from '@/components/ui/query-error-view';
import { Separator } from '@/components/ui/separator';
import { GET_TASKS } from '@/domains/recorder/schema/tasks';
import {
type GetTasksQuery,
type GetTasksQueryVariables,
SubscriberTaskStatusEnum,
} from '@/infra/graphql/gql/graphql';
import type { RouteStateDataOption } from '@/infra/routes/traits';
import { createFileRoute } from '@tanstack/react-router';
import { useQuery } from '@apollo/client';
import {
createFileRoute,
useCanGoBack,
useNavigate,
useRouter,
} from '@tanstack/react-router';
import { format } from 'date-fns';
import { ArrowLeft, RefreshCw } from 'lucide-react';
import { getStatusBadge } from './-status-badge';
export const Route = createFileRoute('/_app/tasks/detail/$id')({
component: TaskDetailRouteComponent,
@@ -9,5 +38,203 @@ export const Route = createFileRoute('/_app/tasks/detail/$id')({
});
function TaskDetailRouteComponent() {
return <div>Hello "/_app/tasks/detail/$id"!</div>;
const { id } = Route.useParams();
const navigate = useNavigate();
const router = useRouter();
const canGoBack = useCanGoBack();
const handleBack = () => {
if (canGoBack) {
router.history.back();
} else {
navigate({
to: '/tasks/manage',
});
}
};
const { data, loading, error, refetch } = useQuery<
GetTasksQuery,
GetTasksQueryVariables
>(GET_TASKS, {
variables: {
filters: {
id: {
eq: id,
},
},
pagination: {
page: {
page: 0,
limit: 1,
},
},
orderBy: {},
},
pollInterval: 5000, // Auto-refresh every 5 seconds for running tasks
});
const task = data?.subscriberTasks?.nodes?.[0];
if (loading) {
return <DetailCardSkeleton />;
}
if (error) {
return <QueryErrorView message={error.message} onRetry={refetch} />;
}
if (!task) {
return <DetailEmptyView message="Task not found" />;
}
return (
<div className="container mx-auto max-w-4xl py-6">
<div className="mb-6 flex items-center justify-between">
<div className="flex items-center gap-4">
<Button
variant="ghost"
size="sm"
onClick={handleBack}
className="h-8 w-8 p-0"
>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
<h1 className="font-bold text-2xl">Task Detail</h1>
<p className="mt-1 text-muted-foreground">View task #{task.id}</p>
</div>
</div>
<Button variant="outline" size="sm" onClick={() => refetch()}>
<RefreshCw className="h-4 w-4" />
Refresh
</Button>
</div>
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>Task Information</CardTitle>
<CardDescription className="mt-2">
View task execution details
</CardDescription>
</div>
<div className="flex items-center gap-2">
{getStatusBadge(task.status)}
</div>
</div>
</CardHeader>
<CardContent>
<div className="space-y-6">
{/* Basic Information */}
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
<div className="space-y-2">
<Label className="font-medium text-sm">Task ID</Label>
<div className="rounded-md bg-muted p-3">
<code className="text-sm">{task.id}</code>
</div>
</div>
<div className="space-y-2">
<Label className="font-medium text-sm">Task Type</Label>
<div className="rounded-md bg-muted p-3">
<Badge variant="secondary">{task.taskType}</Badge>
</div>
</div>
<div className="space-y-2">
<Label className="font-medium text-sm">Priority</Label>
<div className="rounded-md bg-muted p-3">
<span className="text-sm">{task.priority}</span>
</div>
</div>
<div className="space-y-2">
<Label className="font-medium text-sm">Attempts</Label>
<div className="rounded-md bg-muted p-3">
<span className="text-sm">
{task.attempts} / {task.maxAttempts}
</span>
</div>
</div>
<div className="space-y-2">
<Label className="font-medium text-sm">
Scheduled Run Time
</Label>
<div className="rounded-md bg-muted p-3">
<span className="text-sm">
{format(new Date(task.runAt), 'yyyy-MM-dd HH:mm:ss')}
</span>
</div>
</div>
<div className="space-y-2">
<Label className="font-medium text-sm">Done Time</Label>
<div className="rounded-md bg-muted p-3">
<span className="text-sm">
{task.doneAt
? format(new Date(task.doneAt), 'yyyy-MM-dd HH:mm:ss')
: '-'}
</span>
</div>
</div>
<div className="space-y-2">
<Label className="font-medium text-sm">Lock Time</Label>
<div className="rounded-md bg-muted p-3">
<span className="text-sm">
{task.lockAt
? format(new Date(task.lockAt), 'yyyy-MM-dd HH:mm:ss')
: '-'}
</span>
</div>
</div>
<div className="space-y-2">
<Label className="font-medium text-sm">Lock By</Label>
<div className="rounded-md bg-muted p-3">
<code className="text-sm">{task.lockBy || '-'}</code>
</div>
</div>
</div>
{/* Job Details */}
{task.job && (
<>
<Separator />
<div className="space-y-2">
<Label className="font-medium text-sm">Job Details</Label>
<div className="rounded-md bg-muted p-3">
<pre className="overflow-x-auto whitespace-pre-wrap text-sm">
<code>{JSON.stringify(task.job, null, 2)}</code>
</pre>
</div>
</div>
</>
)}
{/* Error Information */}
{(task.status === SubscriberTaskStatusEnum.Failed ||
task.status === SubscriberTaskStatusEnum.Killed) &&
task.lastError && (
<>
<Separator />
<div className="space-y-2">
<Label className="font-medium text-sm">Last Error</Label>
<div className="rounded-md bg-destructive/10 p-3">
<p className="text-destructive text-sm">
{task.lastError}
</p>
</div>
</div>
</>
)}
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -1,12 +1,15 @@
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { DataTablePagination } from '@/components/ui/data-table-pagination';
import { DataTableRowActions } from '@/components/ui/data-table-row-actions';
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 { GET_TASKS, type TaskDto } from '@/domains/recorder/schema/tasks';
import type { GetTasksQuery } from '@/infra/graphql/gql/graphql';
import {
type GetTasksQuery,
SubscriberTaskStatusEnum,
} from '@/infra/graphql/gql/graphql';
import type { RouteStateDataOption } from '@/infra/routes/traits';
import { useDebouncedSkeleton } from '@/presentation/hooks/use-debounded-skeleton';
import { useQuery } from '@apollo/client';
@@ -21,14 +24,10 @@ import {
useReactTable,
} from '@tanstack/react-table';
import { format } from 'date-fns';
import {
AlertCircle,
CheckCircle,
Clock,
Loader2,
RefreshCw,
} from 'lucide-react';
import { RefreshCw } from 'lucide-react';
import { useMemo, useState } from 'react';
import { getStatusBadge } from './-status-badge';
export const Route = createFileRoute('/_app/tasks/manage')({
component: TaskManageRouteComponent,
@@ -87,111 +86,9 @@ function TaskManageRouteComponent() {
);
},
},
{
header: 'Status',
accessorKey: 'status',
cell: ({ row }) => {
return getStatusBadge(row.original.status);
},
},
{
header: 'Priority',
accessorKey: 'priority',
cell: ({ row }) => {
return getPriorityBadge(row.original.priority);
},
},
{
header: 'Attempts',
accessorKey: 'attempts',
cell: ({ row }) => {
const attempts = row.original.attempts;
const maxAttempts = row.original.maxAttempts;
return (
<div className="text-sm">
{attempts} / {maxAttempts}
</div>
);
},
},
{
header: 'Run At',
accessorKey: 'runAt',
cell: ({ row }) => {
const runAt = row.original.runAt;
return (
<div className="text-sm">
{format(new Date(runAt), 'yyyy-MM-dd HH:mm:ss')}
</div>
);
},
},
{
header: 'Done At',
accessorKey: 'doneAt',
cell: ({ row }) => {
const doneAt = row.original.doneAt;
return (
<div className="text-sm">
{doneAt ? format(new Date(doneAt), 'yyyy-MM-dd HH:mm:ss') : '-'}
</div>
);
},
},
{
header: 'Last Error',
accessorKey: 'lastError',
cell: ({ row }) => {
const lastError = row.original.lastError;
return (
<div
className="max-w-xs truncate text-sm"
title={lastError || undefined}
>
{lastError || '-'}
</div>
);
},
},
{
header: 'Lock At',
accessorKey: 'lockAt',
cell: ({ row }) => {
const lockAt = row.original.lockAt;
return (
<div className="text-sm">
{lockAt ? format(new Date(lockAt), 'yyyy-MM-dd HH:mm:ss') : '-'}
</div>
);
},
},
{
header: 'Lock By',
accessorKey: 'lockBy',
cell: ({ row }) => {
const lockBy = row.original.lockBy;
return <div className="font-mono text-sm">{lockBy || '-'}</div>;
},
},
{
id: 'actions',
cell: ({ row }) => (
<DataTableRowActions
row={row}
getId={(row) => row.original.id}
showDetail
onDetail={() => {
navigate({
to: '/tasks/detail/$id',
params: { id: row.original.id },
});
}}
/>
),
},
];
return cs;
}, [navigate]);
}, []);
const table = useReactTable({
data: useMemo(() => (tasks?.nodes ?? []) as TaskDto[], [tasks]),
@@ -226,8 +123,8 @@ function TaskManageRouteComponent() {
<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">Subscription Management</h1>
<p className="text-muted-foreground">Manage your subscription</p>
<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" />
@@ -241,12 +138,12 @@ function TaskManageRouteComponent() {
))}
{!showSkeleton && table.getRowModel().rows?.length > 0 ? (
table.getRowModel().rows.map((row) => {
table.getRowModel().rows.map((row, index) => {
const task = row.original;
return (
<div
key={task.id}
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">
@@ -259,10 +156,10 @@ function TaskManageRouteComponent() {
</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">
<DataTableRowActions
row={row}
getId={(r) => r.original.id}
<DropdownMenuActions
id={task.id}
showDetail
onDetail={() => {
navigate({
@@ -274,19 +171,6 @@ function TaskManageRouteComponent() {
</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>
)}
{/* Time info */}
<div className="grid grid-cols-2 gap-2 text-sm">
<div>
@@ -311,19 +195,38 @@ function TaskManageRouteComponent() {
</span>
</div>
{/* Priority */}
{/* Lock at */}
<div className="text-sm">
<span className="text-muted-foreground">Priority: </span>
<span>{task.priority}</span>
<span className="text-muted-foreground">Lock at: </span>
<span>
{task.lockAt
? format(new Date(task.lockAt), 'MM/dd HH:mm')
: '-'}
</span>
</div>
</div>
{/* Error if exists */}
{task.status === 'error' && task.lastError && (
<div className="rounded bg-destructive/10 p-2 text-destructive text-sm">
{task.lastError}
{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>
);
})
@@ -336,42 +239,3 @@ function TaskManageRouteComponent() {
</div>
);
}
function getStatusBadge(status: string) {
switch (status.toLowerCase()) {
case 'completed':
case 'done':
return (
<Badge variant="secondary" className="bg-green-100 text-green-800">
<CheckCircle className="mr-1 h-3 w-3" />
Completed
</Badge>
);
case 'running':
case 'active':
return (
<Badge variant="secondary" className="bg-blue-100 text-blue-800">
<Loader2 className="mr-1 h-3 w-3 animate-spin" />
Running
</Badge>
);
case 'failed':
case 'error':
return (
<Badge variant="destructive">
<AlertCircle className="mr-1 h-3 w-3" />
Failed
</Badge>
);
case 'pending':
case 'waiting':
return (
<Badge variant="secondary" className="bg-yellow-100 text-yellow-800">
<Clock className="mr-1 h-3 w-3" />
Pending
</Badge>
);
default:
return <Badge variant="outline">{status}</Badge>;
}
}