Skip to content
Snippets Groups Projects
Commit 063f196d authored by Valentin Rigal's avatar Valentin Rigal Committed by Erwan Rouchet
Browse files

Manage model's compatible workers

parent ad0472d7
No related branches found
No related tags found
1 merge request!1607Manage model's compatible workers
......@@ -40,3 +40,7 @@ export const deleteModelVersion = unique(async (id: UUID) => (await axios.delete
export const retrieveModelVersion = unique(async (id: UUID): Promise<ModelVersion> => (await axios.get(`/modelversion/${id}/`)).data)
export const updateModelVersion = unique(async (id: UUID, params: ModelVersionEditParameters): Promise<ModelVersion> => (await axios.patch(`/modelversion/${id}/`, params)).data)
export const createCompatibleWorker = unique(async (modelId: UUID, workerId: UUID) => (await axios.post(`/model/${modelId}/compatible-worker/${workerId}/`)))
export const deleteCompatibleWorker = unique(async (modelId: UUID, workerId: UUID) => (await axios.delete(`/model/${modelId}/compatible-worker/${workerId}/`)))
......@@ -10,6 +10,7 @@ export interface ListWorkersParameters extends PageNumberPaginationParameters {
repository_id?: UUID
type?: string
archived?: boolean
compatible_model?: UUID
}
export type CreateWorkerPayload = Pick<Worker, 'name' | 'slug' | 'type'> & Partial<Pick<Worker, 'description'>>
......
......@@ -17,6 +17,9 @@
/>
</div>
<template v-if="!processId">
<hr />
<ModelWorkers :model-id="modelId" />
<hr />
<h2 class="title is-4">Members</h2>
<ListMembers
......@@ -32,8 +35,14 @@ import { defineComponent } from 'vue'
import { UUID_REGEX } from '@/config'
import ListMembers from '@/components/Memberships/ListMembers.vue'
import VersionList from './Versions/List.vue'
import ModelWorkers from '@/components/Model/Workers/List.vue'
export default defineComponent({
components: {
ListMembers,
VersionList,
ModelWorkers
},
props: {
modelId: {
type: String,
......@@ -51,10 +60,6 @@ export default defineComponent({
validator: value => typeof value === 'string' && (value === '' || UUID_REGEX.test(value))
}
},
components: {
ListMembers,
VersionList
},
data: () => ({
membersPageNumber: 1
}),
......
<template>
<Manage :model-id="modelId" class="is-pulled-right" />
<h2 class="title is-4">Workers</h2>
<h4 class="subtitle is-6">Executable workers that are compatible with this model</h4>
<div v-if="compatibleWorkers === null" class="loader"></div>
<div v-else-if="compatibleWorkers.length === 0" class="notification is-warning">No results.</div>
<table v-else class="table is-fullwidth is-hoverable">
<thead>
<tr>
<th>ID</th>
<th>Name</th>
</tr>
</thead>
<tbody>
<tr v-for="worker in compatibleWorkers" :key="worker.id">
<td><ItemId :item-id="worker.id" /></td>
<td><WorkerTag :worker="worker" /></td>
</tr>
</tbody>
</table>
</template>
<script lang="ts">
import { defineComponent, PropType } from 'vue'
import { mapActions, mapState } from 'pinia'
import { errorParser } from '@/helpers'
import Manage from '@/components/Model/Workers/Manage.vue'
import { useModelStore } from '@/stores/model'
import { useNotificationStore } from '@/stores'
import { UUID } from '@/types'
import ItemId from '@/components/ItemId.vue'
import WorkerTag from '@/components/Process/Workers/WorkerRuns/WorkerTag.vue'
export default defineComponent({
components: {
ItemId,
WorkerTag,
Manage
},
props: {
modelId: {
type: String as PropType<UUID>,
required: true
}
},
data: () => ({
loading: false,
createLoading: false
}),
computed: {
...mapState(useModelStore, { modelCompatibleWorkers: 'compatibleWorkers' }),
compatibleWorkers () {
return this.modelCompatibleWorkers[this.modelId] || null
}
},
methods: {
...mapActions(useNotificationStore, ['notify']),
...mapActions(useModelStore, ['listCompatibleWorkers']),
async fetchCompatibleWorkers () {
if (this.loading || this.compatibleWorkers !== null) return
this.loading = true
try {
await this.listCompatibleWorkers(this.modelId)
} catch (err) {
this.notify({ type: 'error', text: `An error occurred listing compatible models: ${errorParser(err)}` })
} finally {
this.loading = false
}
}
},
watch: {
modelId: {
immediate: true,
handler: 'fetchCompatibleWorkers'
}
}
})
</script>
<template>
<button
class="button is-primary"
v-on:click="openModal = canWrite"
v-bind="$attrs"
:disabled="!canWrite || undefined"
:title="canWrite ? 'Manage compatible workers' : 'A write access to the model is required to manage compatible workers'"
>
Manage
</button>
<Modal v-model="openModal" title="Add or remove compatible workers">
<div class="field has-addons">
<div class="control">
<div class="select">
<select v-model="typeFilter">
<option value="">Filter by worker type…</option>
<option
v-for="t in workerTypes"
:key="t.id"
:value="t.slug"
>
{{ truncateShort(t.display_name) }}
</option>
</select>
</div>
</div>
<div class="control">
<form
v-if="workersPage"
class="field"
v-on:submit.prevent="filter"
>
<input
class="input"
type="text"
v-model="nameFilter"
placeholder="Filter by name…"
/>
</form>
</div>
</div>
<Paginator
:response="workersPage"
v-slot="{ results }"
:loading="loading"
v-model:page="page"
singular="worker"
plural="workers"
>
<table class="table is-fullwidth is-hoverable">
<thead>
<tr>
<th>ID</th>
<th>Name</th>
<th>Action</th>
</tr>
</thead>
<tbody>
<tr
v-for="worker in results"
:key="worker.id"
class="is-clickable"
>
<td>
<ItemId :item-id="worker.id" />
</td>
<td>
<WorkerTag :worker="worker" />
</td>
<td>
<button
v-if="compatibleWorkersIds.includes(worker.id)"
class="button is-danger"
:class="{ 'is-loading': removeLoading === worker.id }"
v-on:click="remove(worker)"
>
<i class="icon-minus"></i>
</button>
<button
v-else
class="button is-success"
:class="{ 'is-loading': addLoading === worker.id }"
v-on:click="add(worker)"
>
<i class="icon-plus"></i>
</button>
</td>
</tr>
</tbody>
</table>
</Paginator>
</Modal>
</template>
<script lang="ts">
import { ListWorkersParameters } from '@/api'
import { isEmpty } from 'lodash'
import { defineComponent, PropType } from 'vue'
import { mapActions, mapState } from 'pinia'
import { errorParser } from '@/helpers'
import Modal from '@/components/Modal.vue'
import Paginator from '@/components/Paginator.vue'
import WorkerTag from '@/components/Process/Workers/WorkerRuns/WorkerTag.vue'
import { useModelStore, useWorkerStore, useNotificationStore } from '@/stores'
import { PageNumberPagination, UUID } from '@/types'
import { Model } from '@/types/model'
import { Worker } from '@/types/worker'
import { truncateMixin } from '@/mixins'
import ItemId from '@/components/ItemId.vue'
export default defineComponent({
components: {
Modal,
Paginator,
WorkerTag,
ItemId
},
mixins: [
truncateMixin
],
props: {
modelId: {
type: String as PropType<UUID>,
required: true
}
},
data: () => ({
openModal: false,
loading: false,
addLoading: null as UUID | null,
removeLoading: null as UUID | null,
page: 1,
nameFilter: '',
typeFilter: '',
workersPage: null as PageNumberPagination<Worker> | null
}),
computed: {
...mapState(useModelStore, { modelCompatibleWorkers: 'compatibleWorkers', models: 'models' }),
...mapState(useWorkerStore, ['workerTypes']),
compatibleWorkersIds () {
return (this.modelCompatibleWorkers[this.modelId] || []).map(w => w.id)
},
model (): Model | null {
return this.models[this.modelId] || null
},
canWrite () {
return this.model !== null && this.model.rights.includes('write')
}
},
methods: {
...mapActions(useModelStore, ['addCompatibleWorker', 'removeCompatibleWorker']),
...mapActions(useWorkerStore, ['listWorkers', 'listWorkerTypes']),
...mapActions(useNotificationStore, ['notify']),
async fetchWorkerTypes () {
try {
await this.listWorkerTypes()
} catch (err) {
this.notify({ type: 'error', text: `An error occurred listing worker types: ${errorParser(err)}` })
}
},
filter () {
this.page = 1
this.updateWorkersPage()
},
async updateWorkersPage () {
this.loading = true
if (isEmpty(this.workerTypes)) await this.fetchWorkerTypes()
try {
const payload = { page: this.page } as ListWorkersParameters
if (this.nameFilter) payload.name = this.nameFilter
if (this.typeFilter) payload.type = this.typeFilter
this.workersPage = await this.listWorkers(payload)
} catch (err) {
this.notify({ type: 'error', text: `An error occurred listing workers: ${errorParser(err)}` })
} finally {
this.loading = false
}
},
async remove (worker: Worker) {
if (this.removeLoading !== null) return
this.removeLoading = worker.id
try {
await this.removeCompatibleWorker(this.modelId, worker)
this.notify({ type: 'success', text: `"${this.truncateLong(worker.name)}" removed from compatible workers` })
} catch (err) {
this.notify({ type: 'error', text: `An error occurred: ${errorParser(err)}` })
} finally {
this.removeLoading = null
}
},
async add (worker: Worker) {
if (this.addLoading !== null) return
this.addLoading = worker.id
try {
await this.addCompatibleWorker(this.modelId, worker)
this.notify({ type: 'success', text: `"${this.truncateLong(worker.name)}" added to compatible workers` })
} catch (err) {
this.notify({ type: 'error', text: `An error occurred: ${errorParser(err)}` })
} finally {
this.addLoading = null
}
}
},
watch: {
page: {
immediate: true,
handler: 'updateWorkersPage'
},
typeFilter: 'filter'
}
})
</script>
......@@ -2,11 +2,14 @@ import { defineStore } from 'pinia'
import { errorParser } from '@/helpers'
import { UUID } from '@/types'
import { Model, ModelVersion } from '@/types/model'
import { Worker } from '@/types/worker'
import {
CreateModelPayload,
UpdateModelPayload,
ModelListParameters,
createModel,
createCompatibleWorker,
deleteCompatibleWorker,
updateModel,
listModels,
listModelVersions,
......@@ -16,19 +19,22 @@ import {
ModelVersionEditParameters,
updateModelVersion
} from '@/api'
import { useNotificationStore } from '.'
import { useNotificationStore, useWorkerStore } from '.'
interface State {
models: { [id: UUID]: Model }
modelVersions: { [id: UUID]: ModelVersion }
allModels: { [id: UUID]: Model }
// Lists of compatible workers for a specific model
compatibleWorkers: { [modelId: UUID]: Worker[] }
}
export const useModelStore = defineStore('model', {
state: (): State => ({
models: {},
modelVersions: {},
allModels: {}
allModels: {},
compatibleWorkers: {}
}),
actions: {
async createModel (params: CreateModelPayload) {
......@@ -80,6 +86,40 @@ export const useModelStore = defineStore('model', {
return resp
},
/**
* Automatically lists workers compatible with a specific model through all pages
*/
async listCompatibleWorkers (modelId: UUID, page = 1) {
if (page === 1) this.compatibleWorkers[modelId] = []
try {
const workerStore = useWorkerStore()
const resp = await workerStore.listWorkers({ compatible_model: modelId, page })
if (!resp || !resp.number || page !== resp.number) {
throw new Error("Pagination failed while listing models's compatible workers")
} else if (resp.next) {
// Fetch next pages asynchronously
this.listCompatibleWorkers(modelId, page + 1)
}
this.compatibleWorkers[modelId].push(...resp.results)
} catch (err) {
useNotificationStore().notify({ type: 'error', text: errorParser(err) })
}
},
async addCompatibleWorker (modelId: UUID, worker: Worker) {
await createCompatibleWorker(modelId, worker.id)
if (this.compatibleWorkers[modelId] === undefined) return
this.compatibleWorkers[modelId].push(worker)
},
async removeCompatibleWorker (modelId: UUID, worker: Worker) {
await deleteCompatibleWorker(modelId, worker.id)
const workersList = this.compatibleWorkers[modelId]
if (workersList === undefined) return
const index = workersList.findIndex(w => w.id === worker.id)
if (index >= 0) workersList.splice(index, 1)
},
async retrieveModel (modelId: UUID) {
const model = await retrieveModel(modelId)
this.models[model.id] = model
......
import axios from 'axios'
import { assert } from 'chai'
import { pick } from 'lodash'
import { createPinia, setActivePinia } from 'pinia'
import { setActivePinia } from 'pinia'
import { workersSample } from '../samples'
import { useModelStore, useNotificationStore } from '@/stores'
import { FakeAxios } from '../testhelpers'
import { FakeAxios, setUpTestPinia, actionsCompletedPlugin } from '../testhelpers'
describe('model', () => {
describe('actions', () => {
......@@ -11,7 +12,8 @@ describe('model', () => {
before('Setting up mocks', () => {
mock = new FakeAxios(axios)
setActivePinia(createPinia())
const [pinia] = setUpTestPinia([actionsCompletedPlugin])
setActivePinia(pinia)
store = useModelStore()
notificationStore = useNotificationStore()
})
......@@ -253,5 +255,65 @@ describe('model', () => {
})
})
})
describe('listCompatibleWorkers', () => {
it('list compatible workers for a model through all pages', async () => {
store.compatibleWorkers = {
model_id: [{ id: 'worker_1', name: 'Worker 1' }],
other_model: [{ id: 'worker_1', name: 'Worker 1' }]
}
mock.onGet('/workers/', { params: { compatible_model: 'model_id', page: 1 } }).reply(200, {
count: 2,
number: 1,
previous: null,
next: 'next',
results: [{ id: 'worker_2', name: 'Worker 2' }]
})
mock.onGet('/workers/', { params: { compatible_model: 'model_id', page: 2 } }).reply(200, {
count: 2,
number: 2,
previous: 'previous',
next: null,
results: [{ id: 'worker_3', name: 'Worker 3' }]
})
await store.listCompatibleWorkers('model_id')
await store.actionsCompleted()
assert.deepStrictEqual(store.compatibleWorkers, {
other_model: [{ id: 'worker_1', name: 'Worker 1' }],
model_id: [{ id: 'worker_2', name: 'Worker 2' }, { id: 'worker_3', name: 'Worker 3' }]
})
})
})
describe('addCompatibleWorker', () => {
it('sets a worker as compatible with a model', async () => {
store.compatibleWorkers = {
model_id: [{ id: 'worker_1', name: 'Worker 1' }]
}
const worker = workersSample.results[0]
mock.onPost(`/model/model_id/compatible-worker/${worker.id}/`).reply(201)
await store.addCompatibleWorker('model_id', worker)
assert.deepStrictEqual(store.compatibleWorkers, {
model_id: [{ id: 'worker_1', name: 'Worker 1' }, worker]
})
})
})
describe('removeCompatibleWorker', () => {
it('unsets a worker as compatible with a model', async () => {
const worker = workersSample.results[0]
store.compatibleWorkers = {
model_id: [
{ id: 'worker_1', name: 'Worker 1' },
worker
]
}
mock.onDelete(`/model/model_id/compatible-worker/${worker.id}/`).reply(204)
await store.removeCompatibleWorker('model_id', worker)
assert.deepStrictEqual(store.compatibleWorkers, {
model_id: [{ id: 'worker_1', name: 'Worker 1' }]
})
})
})
})
})
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment