diff --git a/src/api/worker.ts b/src/api/worker.ts index 0f8fc10d218cca4bb051ef252ca2b06cfe2c649d..6466290b6a115953ad49f17f194ae07a27bde9b4 100644 --- a/src/api/worker.ts +++ b/src/api/worker.ts @@ -13,6 +13,8 @@ export interface ListWorkersParameters extends PageNumberPaginationParameters { export interface CreateWorkerPayload extends Pick<Worker, 'name' | 'slug'> { type: WorkerType['slug'] } +export interface CreateWorkerVersionPayload extends Pick<WorkerVersion, 'configuration'>, Partial<Pick<WorkerVersion, 'docker_image' | 'docker_image_iid' | 'state' | 'gpu_usage' | 'model_usage'>> { revision_id?: UUID } + // List executable workers export const listWorkers = unique(async (params: ListWorkersParameters): Promise<PageNumberPagination<Worker>> => (await axios.get('/workers/', { params })).data) @@ -37,7 +39,10 @@ export const retrieveWorker = unique(async (id: UUID): Promise<Worker> => (await * This call will throw a DuplicatedWorker error in case a worker already exists with this slug and is executable by the user. */ export const createWorker = unique(async (params: CreateWorkerPayload): Promise<Worker> => { - const result = await axios.post(`/workers/`, params) + const result = await axios.post('/workers/', params) if (result.status === 200) throw new DuplicatedWorker() return result.data }) + +// Create a worker version +export const createWorkerVersion = unique(async (workerId: UUID, params: CreateWorkerVersionPayload): Promise<WorkerVersion> => (await axios.post(`/workers/${workerId}/versions/`, params)).data) diff --git a/src/components/Process/Workers/Versions/CreateForm.vue b/src/components/Process/Workers/Versions/CreateForm.vue new file mode 100644 index 0000000000000000000000000000000000000000..432f4de0e87758aedc659c42ee994b9ccca06963 --- /dev/null +++ b/src/components/Process/Workers/Versions/CreateForm.vue @@ -0,0 +1,240 @@ +<template> + <button + class="button is-primary" + v-on:click="openModal = allowCreation" + v-bind="$attrs" + :disabled="!allowCreation || null" + :title="allowTitle" + > + Create + </button> + + <Modal v-model="openModal" :title="modalTitle" is-large> + <form class="form" v-on:submit.prevent="create"> + <div class="field"> + <label class="label">Docker image reference</label> + <div class="control"> + <input + class="input" + :class="{ 'is-danger': fieldErrors.docker_image_iid }" + type="text" + placeholder="…" + maxlength="512" + v-model.trim="payload.docker_image_iid" + :disabled="loading || null" + /> + </div> + <p v-if="fieldErrors.docker_image_iid" class="help is-danger">{{ fieldErrors.docker_image_iid.join(', ') }}</p> + </div> + + <div class="field"> + <label class="label"> + JSON configuration + <div class="dropdown is-hoverable"> + <div class="dropdown-trigger"> + <i class="icon-help has-text-info"></i> + </div> + <div class="dropdown-menu"> + <div class="message message-body help-message-width"> + <p>You can define here any JSON configuration for this version.</p> + <p> + If you used <a href="https://pypi.org/project/arkindex-base-worker/" target="_blank">arkindex-base-worker</a> + to create your image, this value will be accessible as <samp class="tag is-light">self.config</samp> within the worker class.</p> + <p>Please refer to the <a href="https://doc.arkindex.org" target="_blank">documentation</a> for more information.</p> + </div> + </div> + </div> + </label> + <div class="control"> + <textarea + class="textarea is-family-monospace" + :class="{ 'is-danger': Boolean(JSONError || fieldErrors.configuration) }" + v-model="configurationString" + :disabled="loading || null" + ></textarea> + </div> + <p v-if="JSONError" class="help is-danger">{{ JSONError }}</p> + <p v-if="fieldErrors.configuration" class="help is-danger">{{ fieldErrors.configuration.join(', ') }}</p> + </div> + + <div class="field"> + <div class="control"> + <input + id="switchModel" + type="checkbox" + class="switch is-rtl is-rounded is-info" + v-model="payload.model_usage" + :disabled="loading || null" + /> + <label for="switchModel">Use a model</label> + </div> + <p v-if="fieldErrors.model_usage" class="help is-danger">{{ fieldErrors.model_usage.join(', ') }}</p> + </div> + + <div class="field"> + <label class="label">GPU usage</label> + <div class="control"> + <div class="select" :class="{ 'is-danger': fieldErrors.gpu_usage }"> + <select + v-model="payload.gpu_usage" + :disabled="loading || null" + > + <option + v-for="(value, key) in WorkerVersionGPUUsage" + :key="key" + :value="value" + :title="key" + > + {{ key }} + </option> + </select> + </div> + </div> + <p v-if="fieldErrors.gpu_usage" class="help is-danger">{{ fieldErrors.gpu_usage.join(', ') }}</p> + </div> + </form> + + <template v-slot:footer="{ close }"> + <button class="button" v-on:click="close">Cancel</button> + <button + type="submit" + v-on:click="create" + class="button is-primary ml-auto" + :class="{ 'is-loading': loading }" + :disabled="JSONError || null" + :title="saveTitle" + > + Create + </button> + </template> + </Modal> +</template> + +<script lang="ts"> +import { mapState, mapActions } from 'pinia' +import { errorParser, ensureArray } from '@/helpers' +import Modal from '@/components/Modal.vue' +import { useNotificationStore, useWorkerStore } from '@/stores' +import { CreateWorkerVersionPayload } from '@/api' +import { defineComponent } from 'vue' +import { isAxiosError } from 'axios' +import { Worker, isWorker } from '@/types/worker' +import { WorkerVersionGPUUsage, WorkerVersionState } from '@/enums' + +export default defineComponent({ + emits: ['created'], + components: { + Modal + }, + props: { + workerId: { + type: String, + required: true + } + }, + data: () => ({ + WorkerVersionGPUUsage, + openModal: false, + configurationString: '{}', + JSONError: null as string | null, + payload: { + docker_image_iid: '', + configuration: {}, + model_usage: false, + gpu_usage: WorkerVersionGPUUsage.Disabled + } as CreateWorkerVersionPayload, + loading: false, + fieldErrors: {} as Partial<Record<keyof CreateWorkerVersionPayload, string[]>> + }), + computed: { + ...mapState(useWorkerStore, ['workers']), + worker (): Worker | null { + const worker = this.workers?.[this.workerId] + if (!worker || !isWorker(worker)) return null + return worker + }, + allowCreation () { + if (this.worker === null) return false + return this.worker.repository_id === null + }, + allowTitle () { + if (this.worker === null) return '' + return this.worker.repository_id === null + ? 'Create a version' + : 'Versions cannot be created on workers linked to a repository' + }, + modalTitle () { + return this.worker ? `Create a version for worker "${this.worker.name}"` : 'Create a version' + }, + saveTitle () { + return this.JSONError ? 'Please enter a valid configuration' : 'Create a new version' + } + }, + methods: { + ...mapActions(useWorkerStore, ['createWorkerVersion', 'getWorker']), + ...mapActions(useNotificationStore, ['notify']), + async create () { + if (this.JSONError) return + this.loading = true + this.fieldErrors = {} + try { + const cleanPayload = { ...this.payload } + if (cleanPayload.docker_image_iid) { + // Automatically mark the worker as available when a docker image is set + cleanPayload.state = WorkerVersionState.Available + } else { + delete cleanPayload.docker_image_iid + } + await this.createWorkerVersion(this.workerId, cleanPayload) + this.notify({ type: 'info', text: 'Worker version created successfully.' }) + this.openModal = false + this.payload = { + docker_image_iid: '', + configuration: {}, + model_usage: false, + gpu_usage: WorkerVersionGPUUsage.Disabled + } + this.configurationString = '{}' + this.$emit('created') + } catch (err) { + this.notify({ type: 'error', text: errorParser(err) }) + if (isAxiosError(err)) { + this.fieldErrors = err?.response?.data + ? Object.fromEntries(Object.entries(err?.response?.data).map(([k, v]) => [k, ensureArray(v)])) + : {} + } + } finally { + this.loading = false + } + } + }, + watch: { + worker () { + // Fetch the worker individually to know if it is linked to a repository + const worker = this.workers?.[this.workerId] + if (worker && !isWorker(worker)) this.getWorker(this.workerId) + }, + configurationString (newValue) { + this.JSONError = null + try { + const data = JSON.parse(newValue) + // Update the payload value directly when checking errors + if (typeof data === 'string' || Array.isArray(data)) { + this.JSONError = 'Configuration JSON body must be an object' + } else { + this.payload.configuration = data + } + } catch (e) { + if (e instanceof SyntaxError) this.JSONError = e.message + else throw e + } + } + } +}) +</script> + +<style scoped> +.help-message-width { + width: min(50vw, 100rem); +} +</style> diff --git a/src/components/Process/Workers/Versions/List.vue b/src/components/Process/Workers/Versions/List.vue index 7c4f0bc5a27fe20e67c22510fe5f83bec1404d76..4a0cbb631b3701a99efae5eb545af850a64efe97 100644 --- a/src/components/Process/Workers/Versions/List.vue +++ b/src/components/Process/Workers/Versions/List.vue @@ -6,8 +6,9 @@ class="switch is-rtl is-rounded is-info" v-model="advancedMode" /> - <label class="is-pulled-right" for="switchAll">Display all versions</label> + <label class="is-pulled-right ml-3" for="switchAll">Display all versions</label> </template> + <CreateForm :worker-id="worker.id" class="is-pulled-right" v-on:created="fetchVersions" /> <h2 class="title is-4">Versions</h2> <span class="is-clearfix"></span> <div v-if="versionsError" class="notification is-warning">{{ versionsError }}</div> @@ -64,9 +65,11 @@ import { useNotificationStore, useWorkerStore } from '@/stores' import { WorkerVersionListParameters } from '@/api' import { PageNumberPagination } from '@/types' import { WorkerVersion, Worker } from '@/types/worker' +import CreateForm from '@/components/Process/Workers/Versions/CreateForm.vue' export default defineComponent({ components: { + CreateForm, Paginator, Row }, diff --git a/src/stores/workers.ts b/src/stores/workers.ts index abadce8581f3883b685400444d16bcbeb38a418b..8791303c53bbc6f23168768add056e0d3397bb34 100644 --- a/src/stores/workers.ts +++ b/src/stores/workers.ts @@ -21,6 +21,8 @@ import { retrieveWorkerRun, CreateWorkerPayload, createWorker, + CreateWorkerVersionPayload, + createWorkerVersion, listUserWorkerRuns } from '@/api' import { isAxiosError } from 'axios' @@ -159,6 +161,12 @@ export const useWorkerStore = defineStore('worker', { return resp }, + async createWorkerVersion (workerId: UUID, params: CreateWorkerVersionPayload) { + const resp = await createWorkerVersion(workerId, params) + this.workerVersions[resp.id] = resp + return resp + }, + async getWorkerVersion (workerVersionId: UUID) { this.workerVersions[workerVersionId] = await retrieveWorkerVersion(workerVersionId) }, diff --git a/tests/unit/stores/workers.spec.js b/tests/unit/stores/workers.spec.js index 079abe1253423ff50f75cbf5c46c01107ffa41c5..16ffaf16af1f0350e66c59bb51b0386a43412086 100644 --- a/tests/unit/stores/workers.spec.js +++ b/tests/unit/stores/workers.spec.js @@ -4,7 +4,7 @@ import { pick } from 'lodash' import { useWorkerStore, useNotificationStore } from '@/stores' import { FakeAxios, setUpTestPinia, actionsCompletedPlugin, assertRejects } from '../testhelpers' import { DuplicatedWorker } from '@/api' -import { workerConfigurationsSample, workersSample, workerSample, workerRunsSample } from '../samples' +import { workerConfigurationsSample, workersSample, workerVersionsSample, workerSample, workerRunsSample } from '../samples' describe('workers', () => { describe('actions', () => { @@ -491,5 +491,28 @@ describe('workers', () => { }) }) }) + + describe('createWorkerVersion', () => { + it("creates a user's worker version", async () => { + const payload = { + configuration: {}, + model_usage: false, + gpu_usage: 'disabled' + } + const sample = workerVersionsSample.results[0] + mock.onPost('/workers/workerid/versions/', payload).reply(201, sample) + + await store.createWorkerVersion('workerid', payload) + assert.deepStrictEqual(mock.history.all.map(req => pick(req, ['method', 'url'])), [ + { + method: 'post', + url: '/workers/workerid/versions/' + } + ]) + assert.deepStrictEqual(store.workerVersions, { + [sample.id]: sample + }) + }) + }) }) })