diff --git a/js/api.js b/js/api.js index c8915c40d42e8605e0b73169c6e3ab7132eed3cf..f9e4f2f6c2becc0c91b04f2ea8ae420a2154d12c 100644 --- a/js/api.js +++ b/js/api.js @@ -432,3 +432,9 @@ export const listExports = unique(async ({ id, ...params }) => (await axios.get( // Start an export on a corpus export const startExport = unique(async id => (await axios.post(`/corpus/${id}/export/`)).data) + +// List available models +export const listModels = unique(async params => (await axios.get('/models/', { params })).data) + +// List available model version +export const listModelVersions = unique(async ({ id, ...params }) => (await axios.get(`/model/${id}/versions/`, { params })).data) diff --git a/js/config.js b/js/config.js index 3ce7639f8ad431dc6b160f75b741dd49b7838225..8e8b2020105ae34bb42f3f3c3ee61d9c28638e49 100644 --- a/js/config.js +++ b/js/config.js @@ -353,6 +353,12 @@ export const REVISION_STATE_COLORS = { error: 'is-danger' } +export const MODEL_VERSION_STATE_COLORS = { + created: 'is-info', + available: 'is-success', + error: 'is-danger' +} + export const DEFAULT_CORPUS_ATTRS = { public: false, description: '', diff --git a/js/router.js b/js/router.js index c83a5c8c4f069f9ef156bee8420db1321f713a0d..c84fa59a33e254b52fdd3d23c75240b758bb064a 100644 --- a/js/router.js +++ b/js/router.js @@ -29,6 +29,7 @@ const ReposList = () => import(/* webpackChunkName: "users" */ '~/vue/Repos/List const ReposCreate = () => import(/* webpackChunkName: "users" */ '~/vue/Repos/Create') const ReposRights = () => import(/* webpackChunkName: "users" */ '~/vue/Repos/Rights') const ProcessList = () => import(/* webpackChunkName: "users" */ '~/vue/Process/List') +const ModelsList = () => import(/* webpackChunkName: "users" */ '~/vue/Model/List') const ProcessStatus = () => import(/* webpackChunkName: "users" */ '~/vue/Process/Status/Main') const ProcessFilter = () => import(/* webpackChunkName: "users" */ '~/vue/Process/Filter') const ProcessConfigure = () => import(/* webpackChunkName: "users" */ '~/vue/Process/Configure') @@ -285,6 +286,13 @@ const routes = [ component: CorpusList, props: false }, + { + path: '/models', + name: 'models-list', + component: ModelsList, + meta: { requiresLogin: true, requiresVerified: true }, + props: true + }, { path: '/errors/unreachable', name: 'no-backend', diff --git a/js/store/index.js b/js/store/index.js index abecfbaa6920b0038f1b29717bb536551ec6d983..a6c4dc25f4d0aa0a35b12aeb4c3aee2199f60873 100644 --- a/js/store/index.js +++ b/js/store/index.js @@ -19,6 +19,7 @@ const moduleNames = [ 'folderpicker', 'image', 'jobs', + 'model', 'navigation', 'notifications', 'oauth', diff --git a/js/store/model.js b/js/store/model.js new file mode 100644 index 0000000000000000000000000000000000000000..16614d9df06d0e4236ac3259409739835f515d8c --- /dev/null +++ b/js/store/model.js @@ -0,0 +1,53 @@ +import { assign } from 'lodash' +import * as api from '~/js/api' + +export const initialState = () => ({ + /* + * Stores ML models and their versions. + * { [modelId]: model } + */ + models: {}, + // { [modelVersionId]: modelVersion } + modelVersions: {} +}) + +export const mutations = { + setModels (state, models) { + state.models = { + ...state.models, + ...Object.fromEntries(models.map(model => [model.id, model])) + } + }, + + setModelVersions (state, versions) { + state.modelVersions = { + ...state.modelVersions, + ...Object.fromEntries(versions.map(v => [v.id, v])) + } + }, + + reset (state) { + assign(state, initialState()) + } +} + +export const actions = { + async listModels ({ commit }, params) { + const resp = await api.listModels(params) + commit('setModels', resp.results) + return resp + }, + + async listModelVersions ({ commit }, { modelId, page = 1 }) { + const resp = await api.listModelVersions({ id: modelId, page }) + commit('setModelVersions', resp.results) + return resp + } +} + +export default { + namespaced: true, + state: initialState(), + mutations, + actions +} diff --git a/test/Model/List.js b/test/Model/List.js new file mode 100644 index 0000000000000000000000000000000000000000..2ec312d9622a1522edbf0f71812834f72223383c --- /dev/null +++ b/test/Model/List.js @@ -0,0 +1,89 @@ +import assert from 'assert' +import axios from 'axios' +import Vuex from 'vuex' +import { shallowMount, createLocalVue } from '@vue/test-utils' +import Models from '~/vue/Model/List.vue' +import { FakeAxios } from '~/test/testhelpers' +import store from '~/test/store' +import { modelsSample } from '~/test/samples' + +const localVue = createLocalVue() +localVue.use(Vuex) + +describe('Model/List.vue', () => { + let mock + + before('Setting up Axios mock', () => { + mock = new FakeAxios(axios) + }) + + afterEach(() => { + mock.reset() + store.reset() + }) + + after('Removing mocks', () => { + mock.restore() + }) + + it('lists available models', async () => { + mock.onGet('/models/', { page: 1 }).reply(200, modelsSample) + shallowMount(Models, { + store, + localVue, + propsData: {} + }) + await store.actionsCompleted() + assert.deepStrictEqual(store.history, [ + { + action: 'model/listModels', + payload: { page: 1 } + }, + { + mutation: 'model/setModels', + payload: modelsSample.results + } + ]) + }) + + it('handles errors when listing models', async () => { + mock.onGet('/models/', { page: 1 }).reply(418) + + shallowMount(Models, { store, localVue }) + + await store.actionsCompleted() + assert.deepStrictEqual(store.history, [ + { + action: 'model/listModels', + payload: { page: 1 } + }, + { + mutation: 'notifications/notify', + payload: { + text: 'An error occurred listing models: Request failed with status code 418', + type: 'error' + } + } + ]) + }) + + it('displays versions of a model using a VersionList', async () => { + mock.onGet('/models/', { page: 1 }).reply(200, modelsSample) + + const wrapper = shallowMount(Models, { + store, + localVue, + propsData: {} + }) + // No version list is displayed when no model is selected + assert.ok(!wrapper.find('versionlist-stub').exists()) + + await wrapper.setData({ selectedModel: 'modelid' }) + await wrapper.vm.$nextTick() + + const versionList = wrapper.get('versionlist-stub') + + const attrs = versionList.attributes() + assert.strictEqual(attrs.modelid, 'modelid') + }) +}) diff --git a/test/Model/Versions/List.js b/test/Model/Versions/List.js new file mode 100644 index 0000000000000000000000000000000000000000..aef5ad3001c78685ac31a154036de24f2d8431a7 --- /dev/null +++ b/test/Model/Versions/List.js @@ -0,0 +1,46 @@ +import assert from 'assert' +import { cloneDeep } from 'lodash' +import axios from 'axios' +import Vuex from 'vuex' +import { shallowMount, createLocalVue } from '@vue/test-utils' +import store from '~/test/store' +import { modelVersionsSample } from '~/test/samples' +import { FakeAxios } from '~/test/testhelpers' +import VersionList from '~/vue/Model/Versions/List.vue' +import Paginator from '~/vue/Paginator' + +const localVue = createLocalVue() +localVue.use(Vuex) + +describe('Model/Versions/List.vue', () => { + let mock + + before('Setting up Axios mock', () => { + mock = new FakeAxios(axios) + }) + + afterEach(() => { + mock.reset() + store.reset() + }) + + after('Removing mocks', () => { + mock.restore() + }) + + it('fetches and displays versions of a given worker', async () => { + mock.onGet('/model/modelid/versions/', { page: 1 }).reply(200, cloneDeep(modelVersionsSample)) + + const wrapper = shallowMount(VersionList, { + store, + localVue, + stubs: { Paginator }, + propsData: { + modelId: 'modelid' + } + }) + await store.actionsCompleted() + assert.deepStrictEqual(Object.keys(store.state.model.modelVersions), ['versionid1', 'versionid2']) + assert.deepStrictEqual(wrapper.findAll('row-stub').wrappers.map(w => w.props('version')), modelVersionsSample.results) + }) +}) diff --git a/test/samples.js b/test/samples.js index fe41c9afc17ecd7fc1f021ba2dc11fe626d7c61b..af03f35445653035b8fb18660ac29331dcab4ece 100644 --- a/test/samples.js +++ b/test/samples.js @@ -98,6 +98,42 @@ export const templateSample = { template_id: null } +export const modelsSample = makeSampleResults([ + { + id: 'modelid', + name: 'test-model' + }, + { + id: 'modelid2', + name: 'test-model2' + } +]) + +export const modelVersionsSample = makeSampleResults([ + { + id: 'versionid1', + model_id: 'modelid', + created: '2022-03-30T14:01:34.476283Z', + parent: null, + description: '', + tag: null, + state: 'created', + size: 8, + configuration: {} + }, + { + id: 'versionid2', + model_id: 'modelid', + created: '2022-03-31T14:01:34.476283Z', + parent: 'versionid1', + description: '', + tag: null, + state: 'available', + size: 8, + configuration: {} + } +]) + export const makeTranscriptionResult = text => ({ id: text + '-transcription', text, diff --git a/test/store/auth.js b/test/store/auth.js index 8834863cc4cbb313241ba5da25f0db1f93f5f700..eab90b6aa03c6aefb0e351bd26a651c1ab0004c6 100644 --- a/test/store/auth.js +++ b/test/store/auth.js @@ -117,6 +117,7 @@ describe('auth', () => { { mutation: 'folderpicker/reset' }, { mutation: 'image/reset' }, { mutation: 'jobs/reset' }, + { mutation: 'model/reset' }, { mutation: 'navigation/reset' }, { mutation: 'notifications/reset' }, { mutation: 'oauth/reset' }, @@ -155,6 +156,7 @@ describe('auth', () => { { mutation: 'folderpicker/reset' }, { mutation: 'image/reset' }, { mutation: 'jobs/reset' }, + { mutation: 'model/reset' }, { mutation: 'navigation/reset' }, { mutation: 'notifications/reset' }, { mutation: 'oauth/reset' }, @@ -191,6 +193,7 @@ describe('auth', () => { { mutation: 'folderpicker/reset' }, { mutation: 'image/reset' }, { mutation: 'jobs/reset' }, + { mutation: 'model/reset' }, { mutation: 'navigation/reset' }, { mutation: 'notifications/reset' }, { mutation: 'oauth/reset' }, @@ -229,6 +232,7 @@ describe('auth', () => { { mutation: 'folderpicker/reset' }, { mutation: 'image/reset' }, { mutation: 'jobs/reset' }, + { mutation: 'model/reset' }, { mutation: 'navigation/reset' }, { mutation: 'notifications/reset' }, { mutation: 'oauth/reset' }, @@ -263,6 +267,7 @@ describe('auth', () => { { mutation: 'folderpicker/reset' }, { mutation: 'image/reset' }, { mutation: 'jobs/reset' }, + { mutation: 'model/reset' }, { mutation: 'navigation/reset' }, { mutation: 'notifications/reset' }, { mutation: 'oauth/reset' }, diff --git a/test/store/index.js b/test/store/index.js index 00d23a5c8005077caea0568c64ec052ca297ffc5..2dfed26752c456e8f6c6635c750d73462d00d540 100644 --- a/test/store/index.js +++ b/test/store/index.js @@ -45,6 +45,7 @@ describe('store', () => { { mutation: 'folderpicker/reset' }, { mutation: 'image/reset' }, { mutation: 'jobs/reset' }, + { mutation: 'model/reset' }, { mutation: 'navigation/reset' }, { mutation: 'notifications/reset' }, { mutation: 'oauth/reset' }, @@ -69,6 +70,7 @@ describe('store', () => { require('./folderpicker') require('./image') require('./jobs') + require('./model') require('./navigation') require('./oauth') require('./ponos') diff --git a/test/store/model.js b/test/store/model.js new file mode 100644 index 0000000000000000000000000000000000000000..d44c4e4452248c807945c8e78fb66be314ba47c8 --- /dev/null +++ b/test/store/model.js @@ -0,0 +1,109 @@ +import assert from 'assert' +import axios from 'axios' +import { pick } from 'lodash' +import { mutations } from '~/js/store/model' +import { modelsSample, modelVersionsSample } from '~/test/samples' +import store from '~/test/store' +import { FakeAxios } from '~/test/testhelpers' + +describe('model', () => { + describe('mutations', () => { + describe('setModels', () => { + it('sets a Model', () => { + const state = { + models: { model_0: { id: 'model_0' } } + } + mutations.setModels(state, modelsSample.results) + assert.deepStrictEqual(state, { + models: { + model_0: { id: 'model_0' }, + ...Object.fromEntries(modelsSample.results.map(model => [model.id, model])) + } + }) + }) + }) + + describe('setModelVersions', () => { + it('sets a Model Version', () => { + const state = { + modelVersions: { model_version_0: { id: 'model_version_0' } } + } + mutations.setModelVersions(state, modelVersionsSample.results) + assert.deepStrictEqual(state, { + modelVersions: { + model_version_0: { id: 'model_version_0' }, + ...Object.fromEntries(modelVersionsSample.results.map(v => [v.id, v])) + } + }) + }) + }) + }) + + describe('actions', () => { + let mock + + before('Setting up Axios mock', () => { + mock = new FakeAxios(axios) + }) + + afterEach(() => { + // Remove any handlers, but leave mocking in place + mock.reset() + store.reset() + }) + + after('Removing Axios mock', () => { + // Remove mocking entirely + mock.restore() + }) + + describe('listModels', () => { + it('lists stored models with filters', async () => { + const reply = { + count: 1, + results: [ + { id: 'model1' } + ] + } + mock.onGet('/models/').reply(200, reply) + await store.dispatch('model/listModels', { name: '1' }) + assert.deepStrictEqual(store.history, [ + { action: 'model/listModels', payload: { name: '1' } }, + { mutation: 'model/setModels', payload: reply.results } + ]) + assert.deepStrictEqual(mock.history.all.map(req => pick(req, ['method', 'url', 'params'])), [ + { + method: 'get', + url: '/models/', + params: { name: '1' } + } + ]) + assert.deepStrictEqual(store.state.model.models, { model1: { id: 'model1' } }) + }) + }) + + describe('listModelVersions', () => { + it('lists stored model versions', async () => { + const reply = { + count: 1, + results: [ + { id: 'model_version1' } + ] + } + mock.onGet('/model/model1/versions/').reply(200, reply) + await store.dispatch('model/listModelVersions', { modelId: 'model1', page: '1' }) + assert.deepStrictEqual(store.history, [ + { action: 'model/listModelVersions', payload: { modelId: 'model1', page: '1' } }, + { mutation: 'model/setModelVersions', payload: reply.results } + ]) + assert.deepStrictEqual(mock.history.all.map(req => pick(req, ['method', 'url', 'params'])), [ + { + method: 'get', + url: '/model/model1/versions/', + params: { page: '1' } + }]) + assert.deepStrictEqual(store.state.model.modelVersions, { model_version1: { id: 'model_version1' } }) + }) + }) + }) +}) diff --git a/vue/Model/List.vue b/vue/Model/List.vue new file mode 100644 index 0000000000000000000000000000000000000000..82669c29eeb21b5bf43ae897b741a1f4b5c345e0 --- /dev/null +++ b/vue/Model/List.vue @@ -0,0 +1,127 @@ +<template> + <main class="container is-fluid"> + <div class="columns"> + <div class="field column is-one-third"> + <!-- Selection of a worker is required to list versions --> + <form + v-if="modelsPage" + class="field is-pulled-right" + v-on:submit.prevent="filter" + > + <div class="control"> + <input + class="input" + type="text" + v-model="nameFilter" + placeholder="Filter by name…" + /> + </div> + </form> + <div class="title is-4">Available Models</div> + <div class="control"> + <Paginator + :response="modelsPage" + v-slot="{ results }" + :loading="loading" + :page.sync="page" + singular="model" + plural="models" + > + <table class="table is-fullwidth is-hoverable"> + <thead> + <tr><th>Name</th></tr> + </thead> + <tbody> + <tr + v-for="model in results" + :key="model.id" + v-on:click="selectedModel = model.id" + class="is-clickable" + :class="{ 'is-selected': selectedModel === model.id }" + > + <td> + {{ model.name }} + </td> + </tr> + </tbody> + </table> + </Paginator> + </div> + </div> + <div class="field column is-two-thirds"> + <div v-if="!selectedModel" class="notification is-info"> + Please select a <strong>model</strong> on the left panel. + </div> + <template v-else> + <div class="title is-4">Versions</div> + <div class="control"> + <VersionList + :model-id="selectedModel" + /> + </div> + </template> + </div> + </div> + </main> +</template> + +<script> +import { mapActions, mapMutations } from 'vuex' +import VersionList from './Versions/List' +import Paginator from '~/vue/Paginator' +import { errorParser } from '~/js/helpers' +export default { + components: { + Paginator, + VersionList + }, + data: () => ({ + loading: false, + modelsPage: null, + // ID of the selected model + selectedModel: null, + page: 1, + nameFilter: '' + }), + methods: { + ...mapMutations('notifications', ['notify']), + ...mapActions('model', ['listModels']), + async updateModelsPage () { + this.loading = true + try { + this.selectedModel = null + const payload = { page: this.page } + if (this.nameFilter) payload.name = this.nameFilter + this.modelsPage = await this.listModels(payload) + } catch (err) { + this.notify({ type: 'error', text: `An error occurred listing models: ${errorParser(err)}` }) + } finally { + this.loading = false + } + }, + filter () { + // Update models list. Reset page if required + if (this.page === 1) return this.updateModelsPage() + this.page = 1 + } + }, + watch: { + page: { + immediate: true, + handler: 'updateModelsPage' + } + } +} +</script> + +<style lang="sass" scoped> +.container { + height: 100%; + > .columns { + height: inherit; + > .column { + overflow: auto; + } + } +} +</style> diff --git a/vue/Model/Versions/List.vue b/vue/Model/Versions/List.vue new file mode 100644 index 0000000000000000000000000000000000000000..e51f1263ab02596c138c2416f7d5af316cc647d8 --- /dev/null +++ b/vue/Model/Versions/List.vue @@ -0,0 +1,89 @@ +<template> + <div v-if="versionsError" class="notification is-warning">{{ versionsError }}</div> + <div v-else> + <Paginator + :response="versionsPage" + :loading="loading" + v-slot="{ results }" + :page.sync="page" + singular="version" + plural="versions" + > + <table class="table is-fullwidth is-hoverable"> + <thead> + <tr> + <th>ID</th> + <th>State</th> + <th>Tag</th> + <th>Created</th> + <th>Parent</th> + </tr> + </thead> + <tbody> + <Row + :version="version" + :key="version.id" + v-for="version in results" + /> + </tbody> + </table> + </Paginator> + </div> +</template> + +<script> +import { mapActions, mapMutations } from 'vuex' +import { errorParser } from '~/js/helpers' +import Paginator from '~/vue/Paginator' +import Row from './Row' +export default { + components: { + Paginator, + Row + }, + props: { + // Lists versions related to a specific worker + modelId: { + type: String, + required: true + } + }, + data: () => ({ + versionsError: null, + versionsPage: null, + loading: false, + page: 1 + }), + methods: { + ...mapMutations('notifications', ['notify']), + ...mapActions('model', ['listModelVersions']), + async fetchVersions () { + this.versionsPage = null + this.versionsError = null + this.loading = true + const payload = { + modelId: this.modelId, + page: this.page + } + try { + this.versionsPage = await this.listModelVersions(payload) + } catch (err) { + this.versionsError = errorParser(err) + this.notify({ type: 'error', text: `An error occurred listing model versions: ${this.versionsError}` }) + } finally { + this.loading = false + } + } + }, + watch: { + page: 'fetchVersions', + modelId: { + immediate: true, + handler () { + this.page = 1 + this.fetchVersions() + } + } + } +} +</script> diff --git a/vue/Model/Versions/Row.vue b/vue/Model/Versions/Row.vue new file mode 100644 index 0000000000000000000000000000000000000000..29dcf44dc6000ea433d2290db99b085a0751e297 --- /dev/null +++ b/vue/Model/Versions/Row.vue @@ -0,0 +1,94 @@ +<template> + <tr> + <td> + <div + v-if="shortId" + v-on:click="copyId" + title="Copy the model version id to your clipboard" + class="is-clickable" + > + {{ shortId }} + </div> + </td> + <td> + <span class="tag" :class="version.state|stateClass"> + {{ version.state | capitalize }} + </span> + </td> + <td>{{ version.tag }}</td> + <td> + <span v-if="createdDate" :title="createdDate"> + {{ createdDate | ago }} + </span> + </td> + <td> + <div + v-if="shortParent" + v-on:click="copyParentId" + title="Copy the parent model version id to your clipboard" + class="is-clickable" + > + {{ shortParent }} + </div> + </td> + </tr> +</template> + +<script> +import { ago } from '~/js/helpers/text' +import { MODEL_VERSION_STATE_COLORS } from '~/js/config' +import { mapMutations } from 'vuex' +export default { + props: { + version: { + type: Object, + required: true + } + }, + filters: { + stateClass (state) { + return MODEL_VERSION_STATE_COLORS[state] + }, + capitalize (value) { + if (!value) return '' + value = value.toString() + return value.charAt(0).toUpperCase() + value.slice(1) + }, + ago + }, + computed: { + shortId () { + return this.version.id && this.version.id.substring(0, 8) + }, + shortParent () { + if (!this.version) return + return this.version.parent && this.version.parent.substring(0, 8) + }, + createdDate () { + if (!this.version) return + return new Date(this.version.created) + } + }, + methods: { + ...mapMutations('notifications', ['notify']), + async copyId () { + if (!this.version.id) return + try { + await navigator.clipboard.writeText(this.version.id) + this.notify({ type: 'success', text: 'Model version ID copied to clipboard' }) + } catch (err) { + this.notify({ type: 'error', text: `Failed to copy model version ID '${this.version.id}': ${err}` }) + } + }, + async copyParentId () { + if (!this.version.parent) return + try { + await navigator.clipboard.writeText(this.version.parent) + this.notify({ type: 'success', text: 'Parent model version ID copied to clipboard' }) + } catch (err) { + this.notify({ type: 'error', text: `Failed to copy parent model version ID '${this.version.parent}': ${err}` }) + } + } + } +} +</script> diff --git a/vue/Navbar.vue b/vue/Navbar.vue index 39e062b48226ebaddaca9d033141b46aaa4dedcc..e1a8300b31fdd6d767c31fea8bf9f5dd447d7676 100644 --- a/vue/Navbar.vue +++ b/vue/Navbar.vue @@ -69,6 +69,14 @@ > My workers </router-link> + <router-link + :to="{ name: 'models-list' }" + class="navbar-item" + active-class="is-active" + > + <span>My models</span> + <span class="tag is-info ml-1">beta</span> + </router-link> <router-link :to="{ name: 'repos-list' }" class="navbar-item" active-class="is-active"> My repositories </router-link>