From 5630ecea3018dd2fafeea32fc635eb053ab8d2ac Mon Sep 17 00:00:00 2001
From: Yoann Schneider <yschneider@teklia.com>
Date: Wed, 6 Apr 2022 13:33:17 +0000
Subject: [PATCH] Browse models and their versions

---
 js/api.js                   |   6 ++
 js/config.js                |   6 ++
 js/router.js                |   8 +++
 js/store/index.js           |   1 +
 js/store/model.js           |  53 +++++++++++++++
 test/Model/List.js          |  89 +++++++++++++++++++++++++
 test/Model/Versions/List.js |  46 +++++++++++++
 test/samples.js             |  36 ++++++++++
 test/store/auth.js          |   5 ++
 test/store/index.js         |   2 +
 test/store/model.js         | 109 +++++++++++++++++++++++++++++++
 vue/Model/List.vue          | 127 ++++++++++++++++++++++++++++++++++++
 vue/Model/Versions/List.vue |  89 +++++++++++++++++++++++++
 vue/Model/Versions/Row.vue  |  94 ++++++++++++++++++++++++++
 vue/Navbar.vue              |   8 +++
 15 files changed, 679 insertions(+)
 create mode 100644 js/store/model.js
 create mode 100644 test/Model/List.js
 create mode 100644 test/Model/Versions/List.js
 create mode 100644 test/store/model.js
 create mode 100644 vue/Model/List.vue
 create mode 100644 vue/Model/Versions/List.vue
 create mode 100644 vue/Model/Versions/Row.vue

diff --git a/js/api.js b/js/api.js
index c8915c40d..f9e4f2f6c 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 3ce7639f8..8e8b20201 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 c83a5c8c4..c84fa59a3 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 abecfbaa6..a6c4dc25f 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 000000000..16614d9df
--- /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 000000000..2ec312d96
--- /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 000000000..aef5ad300
--- /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 fe41c9afc..af03f3544 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 8834863cc..eab90b6aa 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 00d23a5c8..2dfed2675 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 000000000..d44c4e445
--- /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 000000000..82669c29e
--- /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 000000000..e51f1263a
--- /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 000000000..29dcf44dc
--- /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 39e062b48..e1a8300b3 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>
-- 
GitLab