diff --git a/src/api/model.js b/src/api/model.js index 6d3982f0cc4f3a9849a4d494b4071b6841d5589c..e3fd8fb6c19ab04d0eb24d7d0856ff1d193cbe50 100644 --- a/src/api/model.js +++ b/src/api/model.js @@ -4,6 +4,8 @@ import { unique } from '.' // List available models export const listModels = unique(async params => (await axios.get('/models/', { params })).data) +export const retrieveModel = unique(async id => (await axios.get(`/model/${id}/`)).data) + // List available model version export const listModelVersions = unique(async ({ id, ...params }) => (await axios.get(`/model/${id}/versions/`, { params })).data) diff --git a/src/components/Model/Model.vue b/src/components/Model/Model.vue new file mode 100644 index 0000000000000000000000000000000000000000..71b07518b5d7aad5738f39366f8660ac8a812017 --- /dev/null +++ b/src/components/Model/Model.vue @@ -0,0 +1,58 @@ +<template> + <div class="title is-4">Versions</div> + <div class="control"> + <VersionList + :model-id="modelId" + :worker-run-id="workerRunId" + :process-id="processId" + /> + </div> + <template v-if="!processId"> + <hr /> + <h2 class="title is-4">Members</h2> + <ListMembers + content-type="model" + :content-id="modelId" + v-model:page-number="membersPageNumber" + /> + </template> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue' +import { UUID_REGEX } from '@/config' +import ListMembers from '@/components/Memberships/ListMembers.vue' +import VersionList from './Versions/List.vue' + +export default defineComponent({ + props: { + modelId: { + type: String, + required: true, + validator: value => typeof value === 'string' && UUID_REGEX.test(value) + }, + processId: { + type: String, + default: '', + validator: value => typeof value === 'string' && (value === '' || UUID_REGEX.test(value)) + }, + workerRunId: { + type: String, + default: '', + validator: value => typeof value === 'string' && (value === '' || UUID_REGEX.test(value)) + } + }, + components: { + ListMembers, + VersionList + }, + data: () => ({ + membersPageNumber: 1 + }), + watch: { + modelId () { + this.membersPageNumber = 1 + } + } +}) +</script> diff --git a/src/components/Model/Selection.vue b/src/components/Model/Selection.vue index 97cfa8a9e096929ac34ac05d38043e79966e89df..635fa96e503f4261f484e17bb531726eeb8f4f2e 100644 --- a/src/components/Model/Selection.vue +++ b/src/components/Model/Selection.vue @@ -30,7 +30,7 @@ <script> import { mapState, mapActions } from 'vuex' import Modal from '@/components/Modal.vue' -import ModelList from '@/views/ModelList' +import ModelList from '@/views/Model/List' export default { components: { diff --git a/src/components/Model/index.ts b/src/components/Model/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..02ad20bf59613068aefcb7ba5d7208a17f28bc44 --- /dev/null +++ b/src/components/Model/index.ts @@ -0,0 +1 @@ +export { default } from './Model.vue' diff --git a/src/components/Process/Workers/WorkerRuns/WorkerRunDetails.vue b/src/components/Process/Workers/WorkerRuns/WorkerRunDetails.vue index 7c8432ca47e3b220bb16a711814818d4e2d97a3b..3a2f1aafd21a0d42fed33b848be7e0505af1a2d5 100644 --- a/src/components/Process/Workers/WorkerRuns/WorkerRunDetails.vue +++ b/src/components/Process/Workers/WorkerRuns/WorkerRunDetails.vue @@ -124,18 +124,18 @@ <span class="button" v-on:click="close">Close</span> <div class="field is-grouped ml-auto"> <router-link - v-if="workerRun?.worker_version.worker.id" + v-if="workerRun?.model_version?.id" class="button" - :to="{ name: 'worker-manage', params: { workerId: workerRun?.worker_version.worker.id } }" + :to="{ name: 'model-version', params: { versionId: workerRun?.model_version.id } }" > - View worker + View model version </router-link> <router-link v-if="workerRun?.worker_version.id" class="button" :to="{ name: 'worker-version', params: { versionId: workerRun?.worker_version.id } }" > - View version + View worker version </router-link> <router-link v-if="workerRun?.process.id" diff --git a/src/router/index.js b/src/router/index.js index c674a418e16ef0355865e5056c39639a3a220904..00f4cca3a6312323a5a541b67e8cbf61c344eddf 100644 --- a/src/router/index.js +++ b/src/router/index.js @@ -31,7 +31,9 @@ const ReposList = () => import(/* webpackChunkName: "users" */ '@/views/Repos/Li const ReposCreate = () => import(/* webpackChunkName: "users" */ '@/views/Repos/Create') const ReposRights = () => import(/* webpackChunkName: "users" */ '@/views/Repos/Rights') const ProcessList = () => import(/* webpackChunkName: "users" */ '@/views/Process/List') -const ModelsList = () => import(/* webpackChunkName: "users" */ '@/views/ModelList') +const ModelsList = () => import(/* webpackChunkName: "users" */ '@/views/Model/List') +const Model = () => import(/* webpackChunkName: "users" */ '@/views/Model') +const ModelVersion = () => import(/* webpackChunkName: "users" */ '@/views/Model/Version') const ProcessStatus = () => import(/* webpackChunkName: "users" */ '@/views/Process/Status') const ProcessFilter = () => import(/* webpackChunkName: "users" */ '@/views/Process/Filter') const ProcessConfigure = () => import(/* webpackChunkName: "users" */ '@/views/Process/Configure') @@ -326,6 +328,20 @@ const routes = [ meta: { requiresLogin: true, requiresVerified: true }, props: true }, + { + path: `/model/:modelId(${UUID})`, + name: 'model', + component: Model, + meta: { requiresLogin: true, requiresVerified: true }, + props: true + }, + { + path: `/model-version/:versionId(${UUID})`, + name: 'model-version', + component: ModelVersion, + meta: { requiresLogin: true, requiresVerified: true }, + props: true + }, { path: '/errors/unreachable', name: 'no-backend', diff --git a/src/store/model.js b/src/store/model.js index 4fd1f7115014d0e9a57ebcc5c05d2c3aa75667fa..9f1327c1227708895b171b4513fc3cca7386ee83 100644 --- a/src/store/model.js +++ b/src/store/model.js @@ -51,6 +51,11 @@ export const actions = { return resp }, + async retrieveModel ({ commit }, modelId) { + const resp = await api.retrieveModel(modelId) + commit('setModels', [resp]) + }, + async getModelVersion ({ commit }, modelVersionId) { const resp = await api.retrieveModelVersion(modelVersionId) commit('setModelVersions', [resp]) diff --git a/src/views/ModelList.vue b/src/views/Model/List.vue similarity index 80% rename from src/views/ModelList.vue rename to src/views/Model/List.vue index fe4181e1b72dee1da46035388f1601a11a2762ec..733937c77674b08896551468f846740cc57ee2ef 100644 --- a/src/views/ModelList.vue +++ b/src/views/Model/List.vue @@ -52,25 +52,12 @@ <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" - :worker-run-id="workerRunId" - :process-id="processId" - /> - </div> - <template v-if="!processId"> - <hr /> - <h2 class="title is-4">Members</h2> - <ListMembers - content-type="model" - :content-id="selectedModel" - v-model:page-number="membersPageNumber" - /> - </template> - </template> + <Model + v-else + :model-id="selectedModel" + :process-id="processId" + :worker-run-id="workerRunId" + /> </div> </div> </main> @@ -78,16 +65,14 @@ <script> import { mapActions, mapMutations } from 'vuex' -import VersionList from '@/components/Model/Versions/List' +import Model from '@/components/Model' import Paginator from '@/components/Paginator.vue' import { errorParser } from '@/helpers' -import ListMembers from '@/components/Memberships/ListMembers.vue' export default { components: { Paginator, - VersionList, - ListMembers + Model }, props: { processId: { @@ -107,7 +92,6 @@ export default { data: () => ({ loading: false, modelsPage: null, - membersPageNumber: 1, // ID of the selected model selectedModel: null, page: 1, @@ -139,9 +123,6 @@ export default { page: { immediate: true, handler: 'updateModelsPage' - }, - selectedModel () { - this.membersPageNumber = 1 } } } diff --git a/src/views/Model/Model.vue b/src/views/Model/Model.vue new file mode 100644 index 0000000000000000000000000000000000000000..cb05fc7f8ac9a46846780d5db686473eb30782f2 --- /dev/null +++ b/src/views/Model/Model.vue @@ -0,0 +1,113 @@ +<template> + <main class="container is-fluid"> + <template v-if="model"> + <router-link class="button is-pulled-right" :to="{ name: 'models-list' }"> + <i class="icon-arrow-left"></i> + Models + </router-link> + <div class="subtitle is-5">Model</div> + <div class="title">{{ model.name }}</div> + + <div class="columns"> + <div class="column"> + <div class="field"> + <label class="label">Created</label> + <div class="control"> + <p>{{ creationDate }}</p> + </div> + </div> + </div> + <div class="column"> + <div class="field"> + <label class="label">Last updated</label> + <div class="control"> + <p>{{ updateDate }}</p> + </div> + </div> + </div> + </div> + + <div class="field"> + <label class="label">Description</label> + <div class="control"> + <p class="has-line-breaks"> + {{ model.description || '—' }} + </p> + </div> + </div> + + <hr /> + <Model :model-id="modelId" /> + </template> + <div v-else-if="error" class="notification is-danger">{{ error }}</div> + <div v-else class="loader is-size-2 mx-auto"></div> + </main> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue' +import { mapState, mapMutations, mapActions } from 'vuex' +import { UUID_REGEX } from '@/config' +import { ago, errorParser } from '@/helpers' +import { UUID } from '@/types' +import Model from '@/components/Model' + +export default defineComponent({ + props: { + modelId: { + type: String, + required: true, + validator: value => typeof value === 'string' && UUID_REGEX.test(value) + } + }, + components: { + Model + }, + data: () => ({ + loading: false, + error: null as string | null + }), + computed: { + ...mapState('model', ['models']), + model () { + return this.models[this.modelId] + }, + creationDate () { + if (!this.model) return + return ago(new Date(this.model.created)) + }, + updateDate () { + if (!this.model) return + return ago(new Date(this.model.updated)) + } + }, + methods: { + ...mapMutations('notifications', ['notify']), + ...mapActions('model', ['retrieveModel']), + async loadModel (modelId: UUID) { + this.loading = true + try { + await this.retrieveModel(modelId) + } catch (err) { + this.error = errorParser(err) + } finally { + this.loading = false + } + } + }, + watch: { + modelId: { + immediate: true, + handler (newValue: UUID) { + if (!this.models[newValue]) this.loadModel(newValue) + } + } + } +}) +</script> + +<style scoped> +.has-line-breaks { + white-space: pre-line +} +</style> diff --git a/src/views/Model/Version.vue b/src/views/Model/Version.vue new file mode 100644 index 0000000000000000000000000000000000000000..a46b5a071d0703cdcda67db41a88ed0eec8f2302 --- /dev/null +++ b/src/views/Model/Version.vue @@ -0,0 +1,145 @@ +<template> + <main class="container is-fluid"> + <template v-if="model"> + <h1 class="title is-3"> + <router-link :to="{ name: 'model', params: { modelId: model.id } }"> + {{ model.name }} + </router-link> + </h1> + <p class="subtitle is-5">Version <ItemId :item-id="version.id" /></p> + <hr /> + + <div class="field"> + <label class="label">State</label> + <div class="control"> + <div class="tag is-capitalized" :class="stateClass">{{ version.state }}</div> + </div> + </div> + + <div class="field"> + <label class="label">Description</label> + <div class="control"> + <p class="has-line-breaks"> + {{ version.description || '—' }} + </p> + </div> + </div> + + <div class="field"> + <label class="label">Tag</label> + <div class="control"> + <p>{{ version.tag || '—' }}</p> + </div> + </div> + + <div class="field"> + <label class="label">Parent</label> + <div class="control"> + <router-link + v-if="version.parent" + :to="{ name: 'model-version', params: { versionId: version.parent } }" + > + <samp>{{ truncateShort(version.parent) }}</samp> + </router-link> + <template v-else>—</template> + </div> + </div> + + <div class="field"> + <label class="label">Size</label> + <div class="control"> + <p :title="version.size">{{ size }}</p> + </div> + </div> + + <div class="field"> + <label class="label">Configuration</label> + <div class="control"> + <pre v-if="model.configuration">{{ model.configuration }}</pre> + <template v-else>—</template> + </div> + </div> + </template> + <div class="notification is-danger" v-else-if="error">{{ error }}</div> + <div class="loader is-size-2 mx-auto" v-else></div> + </main> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue' +import { mapActions, mapState } from 'vuex' +import { MODEL_VERSION_STATE_COLORS, UUID_REGEX } from '@/config' +import { errorParser, formatBytes } from '@/helpers' +import { truncateMixin } from '@/mixins' +import ItemId from '@/components/ItemId.vue' + +export default defineComponent({ + mixins: [ + truncateMixin + ], + props: { + versionId: { + type: String, + required: true, + validator: id => typeof id === 'string' && UUID_REGEX.test(id) + } + }, + components: { + ItemId + }, + data: () => ({ + error: null as string | null + }), + computed: { + ...mapState('model', ['models', 'modelVersions']), + model () { + if (!this.version) return null + return this.models[this.version.model_id] + }, + version () { + return this.modelVersions[this.versionId] + }, + size () { + if (!this.version) return null + return formatBytes(this.version.size) + }, + stateClass () { + if (!this.version) return null + // @ts-expect-error Requires migrating the models store to Pinia to get a ModelVersion type + return MODEL_VERSION_STATE_COLORS[this.version.state] + } + }, + methods: { + ...mapActions('model', ['getModelVersion', 'retrieveModel']) + }, + watch: { + versionId: { + immediate: true, + async handler (newValue) { + try { + await this.getModelVersion(newValue) + } catch (err) { + this.error = errorParser(err) + } + } + }, + version: { + immediate: true, + async handler (newValue) { + if (!newValue?.model_id) return + try { + await this.retrieveModel(newValue.model_id) + } catch (err) { + this.error = errorParser(err) + } + } + } + } +}) +</script> + +<style scoped> +.has-line-breaks { + white-space: pre-line +} +</style> diff --git a/src/views/Model/index.ts b/src/views/Model/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..02ad20bf59613068aefcb7ba5d7208a17f28bc44 --- /dev/null +++ b/src/views/Model/index.ts @@ -0,0 +1 @@ +export { default } from './Model.vue' diff --git a/tests/unit/store/model.spec.js b/tests/unit/store/model.spec.js index 4887839fd4b0edaa54407eb93b561d3f6e2da0a9..be0dcc8e9ff63568c0bedb61142bd5b1497e5017 100644 --- a/tests/unit/store/model.spec.js +++ b/tests/unit/store/model.spec.js @@ -94,6 +94,29 @@ describe('model', () => { }) }) + describe('retrieveModel', () => { + it('retrieves a model', async () => { + const model = { id: 'model1' } + mock.onGet('/model/model1/').reply(200, model) + + await store.dispatch('model/retrieveModel', 'model1') + + assert.deepStrictEqual(store.history, [ + { + action: 'model/retrieveModel', + payload: 'model1' + }, + { + mutation: 'model/setModels', + payload: [model] + } + ]) + assert.deepStrictEqual(store.state.model.models, { + model1: model + }) + }) + }) + describe('listModelVersions', () => { it('lists stored model versions', async () => { const reply = { @@ -118,6 +141,29 @@ describe('model', () => { }) }) + describe('getModelVersion', () => { + it('retrieves a model version', async () => { + const version = { id: 'model_version1' } + mock.onGet('/modelversion/model_version1/').reply(200, version) + + await store.dispatch('model/getModelVersion', 'model_version1') + + assert.deepStrictEqual(store.history, [ + { + action: 'model/getModelVersion', + payload: 'model_version1' + }, + { + mutation: 'model/setModelVersions', + payload: [version] + } + ]) + assert.deepStrictEqual(store.state.model.modelVersions, { + model_version1: version + }) + }) + }) + describe('deleteModelVersions', () => { it('deletes a model version', async () => { store.state.model.modelVersions = { model_version_0: { id: 'model_version_0' } }