jobs_v2: strand management UI

test plan:
 - create a Setting to make an n_strand parallelizable:
   Setting.set('foobar_num_strands', '2')
 - queue up some jobs on that strand:
   100.times { delay(n_strand: 'foobar').sleep(10) }
 - go to Queued | Strand in the top nav and click the
   "foobar" strand
 - a gear icon should appear next to the strand box
   (labeled 'Manage strand "foobar"')
 - click it and a modal should appear that lets you
   change the concurrency and the priority of the
   strand

 - queue up some jobs in a strand for which there is no
   "num_strands" Setting
 - select the strand as before
 - the gear icon should be there, but the dialog that
   appears offers no option to change the concurrency

flag=jobs_v2
closes DE-1128

Change-Id: I3abff0e52391bf01f20fd4d333ad04ae484adae6
Reviewed-on: https://gerrit.instructure.com/c/canvas-lms/+/290137
Tested-by: Service Cloud Jenkins <svc.cloudjenkins@instructure.com>
Reviewed-by: Aaron Ogata <aogata@instructure.com>
QA-Review: Jeremy Stanley <jeremy@instructure.com>
Product-Review: Jeremy Stanley <jeremy@instructure.com>
This commit is contained in:
Jeremy Stanley 2022-04-21 07:41:42 -06:00
parent 2f27f0308b
commit 4bcd011d09
4 changed files with 293 additions and 0 deletions

View File

@ -48,6 +48,7 @@ class JobsV2Controller < ApplicationController
jobs_server = @domain_root_account.shard.delayed_jobs_shard&.database_server_id
cluster = @domain_root_account.shard&.database_server_id
js_env(
manage_jobs: Account.site_admin.grants_right?(@current_user, session, :manage_jobs),
jobs_scope_filter: {
jobs_server: (jobs_server && t("Server: %{server}", server: jobs_server)) || t("All Jobs"),
cluster: cluster && t("Cluster: %{cluster}", cluster: cluster),

View File

@ -0,0 +1,186 @@
/*
* 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 {useScope as useI18nScope} from '@canvas/i18n'
import React, {useState, useCallback, useMemo} from 'react'
import {Modal} from '@instructure/ui-modal'
import {Button, IconButton, CloseButton} from '@instructure/ui-buttons'
import {IconSettingsLine, IconWarningLine} from '@instructure/ui-icons'
import {Text} from '@instructure/ui-text'
import {Flex} from '@instructure/ui-flex'
import {Heading} from '@instructure/ui-heading'
import {NumberInput} from '@instructure/ui-number-input'
import {Spinner} from '@instructure/ui-spinner'
import doFetchApi from '@canvas/do-fetch-api-effect'
import {Tooltip} from '@instructure/ui-tooltip'
const I18n = useI18nScope('jobs_v2')
function boundMaxConcurrent(value) {
if (value < 1) return 1
else if (value >= 255) return 255
else return value
}
function boundPriority(value) {
if (value < 0) return 0
else if (value >= 1000000) return 1000000
else return value
}
export default function JobManager({groupType, groupText, jobs, onUpdate}) {
const computedMaxConcurrent = useMemo(() => {
return Math.max(...jobs.map(job => job.max_concurrent))
}, [jobs])
const computedPriority = useMemo(() => {
return Math.min(...jobs.map(job => job.priority))
}, [jobs])
const [modalOpen, setModalOpen] = useState(false)
const [loading, setLoading] = useState(false)
const [error, setError] = useState(false)
const [maxConcurrent, setMaxConcurrent] = useState(computedMaxConcurrent)
const [priority, setPriority] = useState(computedPriority)
const handleClose = useCallback(() => setModalOpen(false), [])
const handleSubmit = useCallback(
e => {
e.preventDefault()
setLoading(true)
setError(false)
return doFetchApi({
method: 'PUT',
path: '/api/v1/jobs2/manage',
params: {strand: groupText, max_concurrent: maxConcurrent || '', priority}
}).then(
result => {
setLoading(false)
handleClose()
onUpdate(result)
},
_error => {
setLoading(false)
setError(true)
}
)
},
[groupText, handleClose, maxConcurrent, onUpdate, priority]
)
const onChangeConcurrency = useCallback((_event, value) => {
setMaxConcurrent(value && boundMaxConcurrent(parseInt(value, 10)))
}, [])
const onIncrementConcurrency = useCallback(
diff => {
setMaxConcurrent(boundMaxConcurrent(maxConcurrent + diff))
},
[maxConcurrent]
)
const onChangePriority = useCallback((_event, value) => {
setPriority(value && boundPriority(parseInt(value, 10)))
}, [])
const onIncrementPriority = useCallback(
diff => {
setPriority(boundPriority(priority + diff))
},
[priority]
)
const enableSubmit = useCallback(
() => !loading && typeof maxConcurrent === 'number' && typeof priority === 'number',
[loading, maxConcurrent, priority]
)
// presently all we do is update parallelism / priority for entire strands, so don't render an icon otherwise
if (groupType !== 'strand' || !groupText || jobs.length === 0) return null
const caption = I18n.t('Manage strand "%{strand}"', {strand: groupText})
return (
<>
<Tooltip renderTip={caption} on={['hover', 'focus']}>
<IconButton onClick={() => setModalOpen(true)} screenReaderLabel={caption}>
<IconSettingsLine />
</IconButton>
</Tooltip>
<Modal
as="form"
label={caption}
open={modalOpen}
onDismiss={handleClose}
onSubmit={handleSubmit}
shouldCloseOnDocumentClick={false}
>
<Modal.Header>
<CloseButton
placement="end"
offset="small"
onClick={handleClose}
screenReaderLabel={I18n.t('Close')}
/>
<Heading>{groupText}</Heading>
</Modal.Header>
<Modal.Body>
<Flex direction="column">
<Flex.Item padding="xx-small">
<NumberInput
renderLabel={I18n.t('Priority')}
value={priority}
onChange={onChangePriority}
onIncrement={() => onIncrementPriority(1)}
onDecrement={() => onIncrementPriority(-1)}
/>
</Flex.Item>
<Flex.Item padding="0 xx-small">
<Text fontStyle="italic">{I18n.t('Smaller numbers mean higher priority')}</Text>
</Flex.Item>
{computedMaxConcurrent > 1 ? (
<Flex.Item margin="medium 0 0 0" padding="xx-small">
<NumberInput
renderLabel={I18n.t('Max n_strand parallelism')}
value={maxConcurrent}
onChange={onChangeConcurrency}
onIncrement={() => onIncrementConcurrency(1)}
onDecrement={() => onIncrementConcurrency(-1)}
/>
</Flex.Item>
) : null}
</Flex>
</Modal.Body>
<Modal.Footer>
{loading ? (
<Spinner renderTitle={I18n.t('Applying changes')} size="small" margin="0 medium" />
) : error ? (
<IconWarningLine size="small" color="error" title={I18n.t('Failed to update strand')} />
) : null}
<Button
interaction={enableSubmit() ? 'enabled' : 'disabled'}
color="primary"
type="submit"
>
{I18n.t('Apply')}
</Button>
</Modal.Footer>
</Modal>
</>
)
}

View File

@ -0,0 +1,91 @@
/*
* 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 JobManager from '../JobManager'
import doFetchApi from '@canvas/do-fetch-api-effect'
jest.mock('@canvas/do-fetch-api-effect')
const flushPromises = () => new Promise(setImmediate)
const fakeJob = {
id: '1024',
priority: 20,
max_concurrent: 1,
strand: 'foobar'
}
describe('JobManager', () => {
beforeAll(() => {
doFetchApi.mockResolvedValue({status: 'OK', count: 1})
})
beforeEach(() => {
doFetchApi.mockClear()
})
it("doesn't render a button if a strand isn't selected", async () => {
const {queryByRole} = render(
<JobManager groupType="tag" groupText="foobar" jobs={[fakeJob]} onUpdate={jest.fn()} />
)
expect(queryByRole('button', {name: /Manage strand/})).not.toBeInTheDocument()
})
it('edits priority but not concurrency for normal strand', async () => {
const onUpdate = jest.fn()
const {getByRole, getByLabelText, queryByLabelText} = render(
<JobManager groupType="strand" groupText="foobar" jobs={[fakeJob]} onUpdate={onUpdate} />
)
fireEvent.click(getByRole('button', {name: /Manage strand/}))
expect(queryByLabelText('Max n_strand parallelism')).not.toBeInTheDocument()
fireEvent.change(getByLabelText('Priority'), {target: {value: '11'}})
fireEvent.click(getByRole('button', {name: 'Apply'}))
expect(doFetchApi).toHaveBeenCalledWith({
path: '/api/v1/jobs2/manage',
method: 'PUT',
params: {strand: 'foobar', priority: 11, max_concurrent: 1}
})
await flushPromises()
expect(onUpdate).toHaveBeenCalledWith({status: 'OK', count: 1})
})
it('edits both priority and concurrency for n_strand', async () => {
const onUpdate = jest.fn()
const {getByRole, getByLabelText} = render(
<JobManager
groupType="strand"
groupText="foobar"
jobs={[{...fakeJob, max_concurrent: 2}]}
onUpdate={onUpdate}
/>
)
fireEvent.click(getByRole('button', {name: /Manage strand/}))
fireEvent.change(getByLabelText('Priority'), {target: {value: '11'}})
fireEvent.change(getByLabelText('Max n_strand parallelism'), {target: {value: '7'}})
fireEvent.click(getByRole('button', {name: 'Apply'}))
expect(doFetchApi).toHaveBeenCalledWith({
path: '/api/v1/jobs2/manage',
method: 'PUT',
params: {strand: 'foobar', priority: 11, max_concurrent: 7}
})
await flushPromises()
expect(onUpdate).toHaveBeenCalledWith({status: 'OK', count: 1})
})
})

View File

@ -27,6 +27,7 @@ import JobDetails from './components/JobDetails'
import SearchBox from './components/SearchBox'
import JobLookup from './components/JobLookup'
import SectionRefreshHeader from './components/SectionRefreshHeader'
import JobManager from './components/JobManager'
import {jobsReducer, initialState} from './reducer'
import {Heading} from '@instructure/ui-heading'
import {Flex} from '@instructure/ui-flex'
@ -199,6 +200,20 @@ export default function JobsIndex() {
autoRefresh={state.auto_refresh}
/>
</Flex.Item>
{ENV.manage_jobs &&
state.bucket !== 'failed' &&
state.group_type === 'strand' &&
state.group_text &&
state.jobs?.length > 0 ? (
<Flex.Item padding="large small small 0">
<JobManager
groupType={state.group_type}
groupText={state.group_text}
jobs={state.jobs}
onUpdate={() => dispatch({type: 'REFRESH_ALL'})}
/>
</Flex.Item>
) : null}
<Flex.Item size="33%" shouldGrow padding="large 0 small 0">
<SearchBox
bucket={state.bucket}