diff --git a/ee/tabby-ui/app/(dashboard)/(logs)/jobs/components/job-detail.tsx b/ee/tabby-ui/app/(dashboard)/(logs)/jobs/components/job-detail.tsx index ba0994cf194f..7a779c077676 100644 --- a/ee/tabby-ui/app/(dashboard)/(logs)/jobs/components/job-detail.tsx +++ b/ee/tabby-ui/app/(dashboard)/(logs)/jobs/components/job-detail.tsx @@ -1,19 +1,29 @@ 'use client' import React from 'react' -import { useSearchParams } from 'next/navigation' +import { useRouter, useSearchParams } from 'next/navigation' import Ansi from '@curvenote/ansi-to-react' +import humanizerDuration from 'humanize-duration' +import moment from 'moment' import { useQuery } from 'urql' import { listJobRuns } from '@/lib/tabby/query' import { cn } from '@/lib/utils' -import { IconAlertTriangle, IconTerminalSquare } from '@/components/ui/icons' +import { + IconAlertTriangle, + IconChevronLeft, + IconClock, + IconHistory, + IconStopWatch, + IconTerminalSquare +} from '@/components/ui/icons' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import { ListSkeleton } from '@/components/skeleton' -import { JobsTable } from './jobs-table' +import { getLabelByExitCode } from '../utils/state' export default function JobRunDetail() { + const router = useRouter() const searchParams = useSearchParams() const id = searchParams.get('id') const [{ data, error, fetching }, reexecuteQuery] = useQuery({ @@ -46,27 +56,73 @@ export default function JobRunDetail() { ) : (
- - - - - - stdout - - - - stderr - - -
- - - - - - -
-
+ {currentNode && ( + <> +
router.back()} + className="-ml-1 flex cursor-pointer items-center transition-opacity hover:opacity-60" + > + +

+ {currentNode.job} +

+
+
+
+ +

State: {getLabelByExitCode(currentNode.exitCode)}

+
+ + {currentNode.createdAt && ( +
+ +

+ Started:{' '} + {moment(currentNode.createdAt).format('YYYY-MM-DD HH:mm')} +

+
+ )} + + {currentNode.createdAt && currentNode.finishedAt && ( +
+ +

+ Duration:{' '} + {humanizerDuration( + moment + .duration( + moment(currentNode.finishedAt).diff( + currentNode.createdAt + ) + ) + .asMilliseconds() + )} +

+
+ )} +
+ + + + + stdout + + + + stderr + + +
+ + + + + + +
+
+ + )}
)} diff --git a/ee/tabby-ui/app/(dashboard)/(logs)/jobs/components/job-list.tsx b/ee/tabby-ui/app/(dashboard)/(logs)/jobs/components/job-list.tsx deleted file mode 100644 index 73b2712a026e..000000000000 --- a/ee/tabby-ui/app/(dashboard)/(logs)/jobs/components/job-list.tsx +++ /dev/null @@ -1,61 +0,0 @@ -'use client' - -import React from 'react' -import { useQuery } from 'urql' - -import { DEFAULT_PAGE_SIZE } from '@/lib/constants' -import { ListJobRunsQueryVariables } from '@/lib/gql/generates/graphql' -import { useIsQueryInitialized } from '@/lib/tabby/gql' -import { listJobRuns } from '@/lib/tabby/query' -import { Button } from '@/components/ui/button' -import { IconSpinner } from '@/components/ui/icons' -import { ListSkeleton } from '@/components/skeleton' - -import { JobsTable } from './jobs-table' - -const PAGE_SIZE = DEFAULT_PAGE_SIZE -export function JobRuns() { - const [variables, setVariables] = React.useState({ - last: PAGE_SIZE - }) - const [{ data, error, fetching, stale }] = useQuery({ - query: listJobRuns, - variables - }) - - const [initialized] = useIsQueryInitialized({ data, error, stale }) - - const edges = data?.jobRuns?.edges - const pageInfo = data?.jobRuns?.pageInfo - const hasNextPage = pageInfo?.hasPreviousPage - - const fetchNextPage = () => { - setVariables({ last: PAGE_SIZE, before: pageInfo?.startCursor }) - } - - const displayJobs = React.useMemo(() => { - return edges?.slice().reverse() - }, [edges]) - - return ( - <> - {initialized ? ( - <> - - {hasNextPage && ( -
- -
- )} - - ) : ( - - )} - - ) -} diff --git a/ee/tabby-ui/app/(dashboard)/(logs)/jobs/components/job-row.tsx b/ee/tabby-ui/app/(dashboard)/(logs)/jobs/components/job-row.tsx new file mode 100644 index 000000000000..cc51502eb72a --- /dev/null +++ b/ee/tabby-ui/app/(dashboard)/(logs)/jobs/components/job-row.tsx @@ -0,0 +1,207 @@ +'use client' + +import { useMemo } from 'react' +import Link from 'next/link' +import humanizerDuration from 'humanize-duration' +import { isNil } from 'lodash-es' +import moment from 'moment' +import { useQuery } from 'urql' + +import { listJobRuns, queryJobRunStats } from '@/lib/tabby/query' +import { cn } from '@/lib/utils' +import { TableCell, TableRow } from '@/components/ui/table' +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger +} from '@/components/ui/tooltip' +import LoadingWrapper from '@/components/loading-wrapper' +import { ListRowSkeleton } from '@/components/skeleton' + +function JobAggregateState({ + count, + activeClass, + tooltip +}: { + count?: number + activeClass: string + tooltip: string +}) { + return ( + + + +
+ {count || ''} +
+
+ +

{tooltip}

+
+
+
+ ) +} + +function JobRunState({ name }: { name: string }) { + const [{ data, fetching }] = useQuery({ + query: queryJobRunStats, + variables: { + jobs: [name] + } + }) + return ( + } + > +
+ + + +
+
+ ) +} + +export default function JobRow({ name }: { name: string }) { + const RECENT_DISPLAYED_SIZE = 10 + + const [{ data, fetching }] = useQuery({ + query: listJobRuns, + variables: { + last: RECENT_DISPLAYED_SIZE, + jobs: [name] + } + }) + + const edges = data?.jobRuns?.edges + const displayJobs = useMemo(() => { + return edges?.slice().reverse() + }, [edges]) + const lastJob = displayJobs?.[0] + const lastFinishedJob = displayJobs?.find(job => Boolean(job.node.finishedAt)) + const lastSuccessAt = lastFinishedJob + ? moment(lastFinishedJob.node.finishedAt).format('YYYY-MM-DD HH:mm') + : null + return ( + + + + + + } + > + + {name} + +
+ {displayJobs?.map(job => { + const { createdAt, finishedAt } = job.node + const startAt = + createdAt && moment(createdAt).format('YYYY-MM-DD HH:mm') + const duration: string | null = + (createdAt && + finishedAt && + humanizerDuration.humanizer({ + language: 'shortEn', + languages: { + shortEn: { + d: () => 'd', + h: () => 'h', + m: () => 'm', + s: () => 's' + } + } + })( + moment + .duration(moment(finishedAt).diff(createdAt)) + .asMilliseconds(), + { + units: ['d', 'h', 'm', 's'], + round: true, + largest: 1, + spacer: '', + language: 'shortEn' + } + )) ?? + null + + let displayedDuration = '' + if (duration !== null) { + const isSecond = duration.endsWith('s') + if (isSecond) { + displayedDuration = duration + } else { + const unit = duration.charAt(duration.length - 1) + const roundNumber = parseInt(duration) + 1 + displayedDuration = roundNumber + unit + } + } + + return ( + + + + + {displayedDuration} + + + + {startAt &&

{startAt}

} +
+
+
+ ) + })} +
+
+ + +

{lastSuccessAt}

+ +
+ + + +
+
+ ) +} diff --git a/ee/tabby-ui/app/(dashboard)/(logs)/jobs/components/jobs-table.tsx b/ee/tabby-ui/app/(dashboard)/(logs)/jobs/components/jobs-table.tsx deleted file mode 100644 index 51c87e5b8986..000000000000 --- a/ee/tabby-ui/app/(dashboard)/(logs)/jobs/components/jobs-table.tsx +++ /dev/null @@ -1,118 +0,0 @@ -import React from 'react' -import { useRouter } from 'next/navigation' -import humanizerDuration from 'humanize-duration' -import { isNil } from 'lodash-es' -import moment from 'moment' - -import { ListJobRunsQuery } from '@/lib/gql/generates/graphql' -import { cn } from '@/lib/utils' -import { Badge } from '@/components/ui/badge' -import { - IconCheckCircled, - IconCrossCircled, - IconInfoCircled -} from '@/components/ui/icons' -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow -} from '@/components/ui/table' - -type TJobRun = ListJobRunsQuery['jobRuns']['edges'][0] -interface JobsTableProps { - jobs: TJobRun[] | undefined - shouldRedirect?: boolean -} - -export const JobsTable: React.FC = ({ - jobs, - shouldRedirect = true -}) => { - const router = useRouter() - return ( - - - - Start Time - Duration - Job - Status - - - - {!jobs?.length ? ( - - - No Data - - - ) : ( - <> - {jobs?.map(x => { - return ( - { - if (shouldRedirect) { - router.push(`/jobs/detail?id=${x.node.id}`) - } - }} - > - - {moment(x.node.createdAt).format('MMMM D, YYYY h:mm a')} - - - {isNil(x.node?.exitCode) - ? 'Running' - : getJobDuration(x.node)} - - - {x.node.job} - - -
- -
-
-
- ) - })} - - )} -
-
- ) -} - -function getJobDuration({ - createdAt, - finishedAt -}: { - createdAt: string - finishedAt?: string -}) { - if (!createdAt || !finishedAt) return undefined - - let duration = moment - .duration(moment(finishedAt).diff(createdAt)) - .asMilliseconds() - return humanizerDuration(duration) -} - -function JobStatusIcon({ node }: { node: TJobRun }) { - if (!node) return null - const exitCode = node?.node?.exitCode - - if (isNil(exitCode)) { - return - } - if (exitCode === 0) { - return - } - - return -} diff --git a/ee/tabby-ui/app/(dashboard)/(logs)/jobs/components/jobs.tsx b/ee/tabby-ui/app/(dashboard)/(logs)/jobs/components/jobs.tsx index 5fcec01ce068..04650b6e9dc0 100644 --- a/ee/tabby-ui/app/(dashboard)/(logs)/jobs/components/jobs.tsx +++ b/ee/tabby-ui/app/(dashboard)/(logs)/jobs/components/jobs.tsx @@ -1,9 +1,46 @@ -import { JobRuns } from './job-list' +'use client' + +import { Metadata } from 'next' +import { useQuery } from 'urql' + +import { listJobs } from '@/lib/tabby/query' +import { + Table, + TableBody, + TableHead, + TableHeader, + TableRow +} from '@/components/ui/table' +import LoadingWrapper from '@/components/loading-wrapper' + +import JobRow from './job-row' + +export const metadata: Metadata = { + title: 'Jobs' +} export default function JobRunsPage() { + const [{ data, fetching }] = useQuery({ + query: listJobs + }) + return ( - <> - - + + + + + Job + Recent Tasks + Last Run + Job Runs + + + + {data?.jobs.map(jobName => { + return + })} + +
+
) } diff --git a/ee/tabby-ui/app/(dashboard)/(logs)/jobs/utils/state.ts b/ee/tabby-ui/app/(dashboard)/(logs)/jobs/utils/state.ts new file mode 100644 index 000000000000..a588263d5dbc --- /dev/null +++ b/ee/tabby-ui/app/(dashboard)/(logs)/jobs/utils/state.ts @@ -0,0 +1,5 @@ +import { isNil } from 'lodash-es' + +export function getLabelByExitCode(exitCode?: number | null) { + return isNil(exitCode) ? 'Pending' : exitCode === 0 ? 'Success' : 'Failed' +} diff --git a/ee/tabby-ui/components/skeleton.tsx b/ee/tabby-ui/components/skeleton.tsx index e985f9c4f480..2b8ff5a49914 100644 --- a/ee/tabby-ui/components/skeleton.tsx +++ b/ee/tabby-ui/components/skeleton.tsx @@ -20,6 +20,13 @@ export const ListSkeleton: React.FC> = ({ ) } +export const ListRowSkeleton: React.FC> = ({ + className, + ...props +}) => { + return +} + export const FormSkeleton: React.FC> = ({ className, ...props diff --git a/ee/tabby-ui/components/ui/icons.tsx b/ee/tabby-ui/components/ui/icons.tsx index f69d5bda6469..5958e4248cec 100644 --- a/ee/tabby-ui/components/ui/icons.tsx +++ b/ee/tabby-ui/components/ui/icons.tsx @@ -1208,6 +1208,68 @@ function IconCloudUpload({ className, ...props }: React.ComponentProps<'svg'>) { ) } +function IconHistory({ className, ...props }: React.ComponentProps<'svg'>) { + return ( + + + + + + ) +} + +function IconClock({ className, ...props }: React.ComponentProps<'svg'>) { + return ( + + + + + ) +} + +function IconStopWatch({ className, ...props }: React.ComponentProps<'svg'>) { + return ( + + + + ) +} + export { IconEdit, IconNextChat, @@ -1269,5 +1331,8 @@ export { IconCrossCircled, IconInfoCircled, IconFolderGit, - IconCloudUpload + IconCloudUpload, + IconHistory, + IconClock, + IconStopWatch } diff --git a/ee/tabby-ui/lib/tabby/gql.ts b/ee/tabby-ui/lib/tabby/gql.ts index 334fd9c82800..f41350f469c4 100644 --- a/ee/tabby-ui/lib/tabby/gql.ts +++ b/ee/tabby-ui/lib/tabby/gql.ts @@ -1,4 +1,3 @@ -import { useEffect, useState } from 'react' import { TypedDocumentNode } from '@graphql-typed-document-node/core' import { authExchange } from '@urql/exchange-auth' import { cacheExchange } from '@urql/exchange-graphcache' @@ -90,33 +89,6 @@ function makeFormErrorHandler(form: UseFormReturn) { } } -function useIsQueryInitialized({ - data, - error, - stale -}: { - data?: any - error?: CombinedError - stale?: boolean -}): [boolean, React.Dispatch>] { - const isDataExist = (data?: any, error?: CombinedError) => { - return !isNil(data) || !isNil(error) - } - const [initialized, setInitialized] = useState( - isDataExist(data, error) && !!stale - ) - - useEffect(() => { - if (initialized) return - - if (isDataExist(data, error)) { - setInitialized(true) - } - }, [data, error]) - - return [initialized, setInitialized] -} - const isTokenExpired = (exp: number) => { return Date.now() > exp * 1000 } @@ -128,8 +100,7 @@ const client = new Client({ resolvers: { Query: { invitations: relayPagination(), - repositories: relayPagination(), - jobRuns: relayPagination() + repositories: relayPagination() } }, updates: { @@ -292,4 +263,4 @@ export type { QueryVariables, QueryResponseData } -export { client, useMutation, useIsQueryInitialized } +export { client, useMutation } diff --git a/ee/tabby-ui/lib/tabby/query.ts b/ee/tabby-ui/lib/tabby/query.ts index d1352884cfb6..2efcc08a8d72 100644 --- a/ee/tabby-ui/lib/tabby/query.ts +++ b/ee/tabby-ui/lib/tabby/query.ts @@ -51,6 +51,7 @@ export const listRepositories = graphql(/* GraphQL */ ` export const listJobRuns = graphql(/* GraphQL */ ` query ListJobRuns( $ids: [ID!] + $jobs: [String!] $after: String $before: String $first: Int @@ -58,6 +59,7 @@ export const listJobRuns = graphql(/* GraphQL */ ` ) { jobRuns( ids: $ids + jobs: $jobs after: $after before: $before first: $first @@ -84,3 +86,19 @@ export const listJobRuns = graphql(/* GraphQL */ ` } } `) + +export const queryJobRunStats = graphql(/* GraphQL */ ` + query GetJobRunStats($jobs: [String!]) { + jobRunStats(jobs: $jobs) { + success + failed + pending + } + } +`) + +export const listJobs = graphql(/* GraphQL */ ` + query ListJobs { + jobs + } +`)