Skip to content

Commit

Permalink
jobs_v2: add UI to unstuck a strand or singleton
Browse files Browse the repository at this point in the history
test plan:
 - queue some jobs in a new strand while explicitly setting
   next_in_strand to false, e.g.

 5.times { delay(strand: 'sad', next_in_strand: false).sleep(1) }

 - also queue a singleton that way:

 delay(singleton: 'stuck', next_in_strand: false).sleep(1)

 - verify the strand and singleton are orphaned in the jobs_v2
   user interface (strand / singleton tabs) by the presence of the
   exclamation mark icon with the tooltip
 - click that icon and a modal should appear asking you to
   confirm the operation
 - if you click "Unblock" it should unblock the strand or
   singleton and refresh the list

flag=jobs_v2
closes DE-1314

Change-Id: Id9463e5c45046d705324cec946e089bd2eeaa5bb
Reviewed-on: https://gerrit.instructure.com/c/canvas-lms/+/300698
Tested-by: Service Cloud Jenkins <[email protected]>
Reviewed-by: Aaron Ogata <[email protected]>
QA-Review: Jeremy Stanley <[email protected]>
Product-Review: Jeremy Stanley <[email protected]>
  • Loading branch information
jstanley0 committed Sep 26, 2022
1 parent 67bad20 commit 5a08336
Show file tree
Hide file tree
Showing 4 changed files with 221 additions and 11 deletions.
7 changes: 6 additions & 1 deletion ui/features/jobs_v2/react/components/GroupsTable.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ export default function GroupsTable({
sortColumn,
onClickGroup,
onClickHeader,
onUnblock,
timeZone
}) {
const renderColHeader = useCallback(
Expand Down Expand Up @@ -83,7 +84,11 @@ export default function GroupsTable({
<Table.Row key={tag_or_strand}>
<Table.Cell>
{group.orphaned ? (
<OrphanedStrandIndicator name={tag_or_strand} type={type} />
<OrphanedStrandIndicator
name={tag_or_strand}
type={type}
onComplete={onUnblock}
/>
) : null}
<Link onClick={() => onClickGroup(tag_or_strand)}>{tag_or_strand}</Link>
</Table.Cell>
Expand Down
123 changes: 115 additions & 8 deletions ui/features/jobs_v2/react/components/OrphanedStrandIndicator.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,20 +17,127 @@
*/

import {useScope as useI18nScope} from '@canvas/i18n'
import React from 'react'
import React, {useState} from 'react'
import {Tooltip} from '@instructure/ui-tooltip'
import {IconWarningSolid} from '@instructure/ui-icons'
import {View} from '@instructure/ui-view'
import {Button, IconButton} from '@instructure/ui-buttons'
import CanvasModal from '@canvas/instui-bindings/react/Modal'
import {Spinner} from '@instructure/ui-spinner'
import doFetchApi from '@canvas/do-fetch-api-effect'
import {Text} from '@instructure/ui-text'
import {Alert} from '@instructure/ui-alerts'

const I18n = useI18nScope('jobs_v2')

export default function OrphanedStrandIndicator({name, type}) {
const title = I18n.t('%{type} "%{name}" has no next_in_strand', {name, type})
export default function OrphanedStrandIndicator({name, type, onComplete}) {
const [modalOpen, setModalOpen] = useState(false)
const [loading, setLoading] = useState(false)
const [error, setError] = useState()
const [blocked, setBlocked] = useState(false)

let tipLabel =
type === 'strand'
? I18n.t('Strand "%{name}" has no next_in_strand.', {name})
: I18n.t('Singleton "%{name}" has no next_in_strand.', {name})
if (ENV?.manage_jobs) {
tipLabel += ' ' + I18n.t('Click to fix')
}

const actionLabel =
type === 'strand'
? I18n.t('Unblock strand "%{name}"', {name})
: I18n.t('Unblock singleton "%{name}"', {name})

const onClose = () => setModalOpen(false)

const onSubmit = () => {
setError(null)
setBlocked(false)
setLoading(true)
doFetchApi({
method: 'PUT',
path: `/api/v1/jobs2/unstuck`,
params: {[type]: name}
})
.then(({json}) => {
if (json.status === 'OK') {
onComplete(json)
setLoading(false)
setModalOpen(false)
} else if (json.status === 'blocked') {
setLoading(false)
setBlocked(true)
}
})
.catch(e => {
setLoading(false)
setError(e)
})
}

const LoadingFeedback = () => {
if (loading) {
return (
<View as="div" margin="medium 0 0 0">
<Spinner size="small" renderTitle={I18n.t('Working')} />
</View>
)
}
return null
}

const Footer = () => {
return (
<>
<Button
interaction={loading ? 'disabled' : 'enabled'}
onClick={onClose}
margin="0 x-small 0 0"
>
{I18n.t('Cancel')}
</Button>
<Button interaction={loading ? 'disabled' : 'enabled'} color="primary" onClick={onSubmit}>
{I18n.t('Unblock')}
</Button>
</>
)
}

return (
<Tooltip as="span" renderTip={title}>
<View margin="0 x-small 0 0">
<IconWarningSolid color="warning" />
</View>
</Tooltip>
<>
<Tooltip as="span" renderTip={tipLabel}>
<View margin="0 x-small 0 0">
{ENV?.manage_jobs ? (
<IconButton screenReaderLabel={actionLabel} onClick={() => setModalOpen(true)}>
<IconWarningSolid color="warning" />
</IconButton>
) : (
<IconWarningSolid color="warning" />
)}
</View>
</Tooltip>
<CanvasModal
open={modalOpen}
padding="large"
onDismiss={onClose}
label={actionLabel}
footer={<Footer />}
shouldCloseOnDocumentClick={false}
>
{error && <Alert variant="error">{I18n.t('Failed to unblock strand/singleton')}</Alert>}
{blocked && (
<Alert variant="warning">
{I18n.t('Strand or singleton is blocked by the shard migrator')}
</Alert>
)}
<Text>
{I18n.t(
'This will set next_in_strand on the appropriate number of jobs to unblock the strand or singleton.'
)}
</Text>
<LoadingFeedback />
</CanvasModal>
</>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
/*
* Copyright (C) 2022 - present Instructure, Inc.
*
* This file is part of Canvas.
*
* Canvas is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3 of the License.
*
* Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
* A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/

import React from 'react'
import {render, fireEvent} from '@testing-library/react'
import OrphanedStrandIndicator from '../OrphanedStrandIndicator'
import doFetchApi from '@canvas/do-fetch-api-effect'

jest.mock('@canvas/do-fetch-api-effect')

const flushPromises = () => new Promise(setTimeout)

function mockUnstuckApi({path, params}) {
if (path === '/api/v1/jobs2/unstuck') {
if (params.strand) {
return Promise.resolve({json: {status: 'OK', count: 2}})
} else if (params.singleton) {
return Promise.resolve({json: {status: 'OK', count: 1}})
} else {
return Promise.resolve({
json: {
status: 'pending',
progress: {
id: 101,
url: 'http://example.com/api/v1/progress/101',
workflow_state: 'queued'
}
}
})
}
} else {
return Promise.reject()
}
}

describe('OrphanedStrandIndicator', () => {
let oldEnv
beforeAll(() => {
oldEnv = {...window.ENV}
doFetchApi.mockImplementation(mockUnstuckApi)
})

beforeEach(() => {
doFetchApi.mockClear()
})

afterAll(() => {
window.ENV = oldEnv
})

it("doesn't render button if the user lacks :manage_jobs", async () => {
ENV.manage_jobs = false
const {queryByText} = render(
<OrphanedStrandIndicator name="strandy" type="strand" onComplete={jest.fn()} />
)
expect(queryByText('Unblock strand "strandy"')).not.toBeInTheDocument()
})

it('unstucks a strand', async () => {
ENV.manage_jobs = true
const onComplete = jest.fn()
const {getByText} = render(
<OrphanedStrandIndicator name="strandy" type="strand" onComplete={onComplete} />
)
fireEvent.click(getByText('Unblock strand "strandy"'))
fireEvent.click(getByText('Unblock'))
await flushPromises()
expect(onComplete).toHaveBeenCalledWith({status: 'OK', count: 2})
})

it('unstucks a singleton', async () => {
ENV.manage_jobs = true
const onComplete = jest.fn()
const {getByText} = render(
<OrphanedStrandIndicator name="tony" type="singleton" onComplete={onComplete} />
)
fireEvent.click(getByText('Unblock singleton "tony"'))
fireEvent.click(getByText('Unblock'))
await flushPromises()
expect(onComplete).toHaveBeenCalledWith({status: 'OK', count: 1})
})
})
5 changes: 3 additions & 2 deletions ui/features/jobs_v2/react/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,7 @@ export default function JobsIndex() {
dispatch({type: 'CHANGE_GROUP_TEXT', payload: text})
}}
onClickHeader={col => dispatch({type: 'CHANGE_GROUP_ORDER', payload: col})}
onUnblock={() => dispatch({type: 'REFRESH_ALL'})}
timeZone={state.time_zone}
/>
{state.groups_page_count > 1 ? (
Expand Down Expand Up @@ -214,7 +215,7 @@ export default function JobsIndex() {
/>
</Flex.Item>
) : null}
<Flex.Item size="33%" shouldGrow padding="large 0 small 0">
<Flex.Item size="33%" shouldGrow={true} padding="large 0 small 0">
<SearchBox
bucket={state.bucket}
group={state.group_type}
Expand Down Expand Up @@ -267,7 +268,7 @@ export default function JobsIndex() {
{I18n.t('Details')}
</Heading>
</Flex.Item>
<Flex.Item size="33%" shouldGrow padding="large 0 small 0">
<Flex.Item size="33%" shouldGrow={true} padding="large 0 small 0">
<JobLookup
manualSelection={state.job?.id || ''}
setSelectedItem={item => {
Expand Down

0 comments on commit 5a08336

Please sign in to comment.