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:
parent
2f27f0308b
commit
4bcd011d09
|
@ -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),
|
||||
|
|
|
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
|
@ -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})
|
||||
})
|
||||
})
|
|
@ -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}
|
||||
|
|
Loading…
Reference in New Issue