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
+ }
+`)