Migrate job metrics store and component to use pinia and composition api

This commit is contained in:
Rendani Gangazhe
2023-05-06 14:16:25 +02:00
parent 853d009b04
commit ff77bc7d11
7 changed files with 332 additions and 241 deletions
@@ -1,12 +1,10 @@
import { mount, createLocalVue } from "@vue/test-utils";
import flushPromises from "flush-promises";
import JobMetrics from "./JobMetrics";
import { createTestingPinia } from "@pinia/testing";
import ec2 from "./ec2.json";
import Vuex from "vuex";
import createCache from "vuex-cache";
import { jobMetricsStore } from "store/jobMetricsStore";
const JOB_ID = "moo";
import flushPromises from "flush-promises";
import { getLocalVue } from "tests/jest/helpers";
import JobMetrics from "./JobMetrics";
import { mount } from "@vue/test-utils";
import { setActivePinia } from "pinia";
// Ignore all axios calls, data is mocked locally -- just say "OKAY!"
jest.mock("axios", () => ({
@@ -15,54 +13,57 @@ jest.mock("axios", () => ({
},
}));
const localVue = createLocalVue();
localVue.use(Vuex);
const testStore = new Vuex.Store({
plugins: [createCache()],
modules: {
jobMetricsStore,
},
});
const localVue = getLocalVue();
describe("JobMetrics/JobMetrics.vue", () => {
it("should not render a div if no plugins found in store", async () => {
const propsData = {
jobId: JOB_ID,
};
const wrapper = mount(JobMetrics, {
store: testStore,
propsData,
pinia: createTestingPinia(),
propsData: {
jobId: "9000",
},
localVue,
});
await wrapper.vm.$nextTick();
expect(wrapper.find("div").exists()).toBe(false);
});
it("should group plugins by type", async () => {
const propsData = {
jobId: JOB_ID,
};
const metricsResponse = [
const JOB_ID = "9000";
const mockMetricsResponse = [
{ plugin: "core", title: "runtime", value: 145 },
{ plugin: "core", title: "memory", value: 146 },
{ plugin: "extended", title: "awesomeness", value: 42 },
];
const wrapper = mount(JobMetrics, {
store: testStore,
propsData,
localVue,
computed: {
jobMetrics() {
return metricsResponse;
const pinia = createTestingPinia({
initialState: {
jobMetricsStore: {
jobMetricsByJobId: {
[`${JOB_ID}`]: mockMetricsResponse,
},
jobMetricsByHdaId: {},
jobMetricsByLddaId: {},
},
},
});
setActivePinia(pinia);
const wrapper = mount(JobMetrics, {
localVue,
pinia,
propsData: {
jobId: JOB_ID,
},
});
// Wait for axios and rendering.
await flushPromises();
expect(wrapper.vm.jobMetrics.length).toBe(3);
expect(wrapper.vm.jobId).toBe(JOB_ID);
expect(wrapper.vm.metricsByPlugins.core.runtime).toBe(145);
expect(wrapper.vm.orderedPlugins.length).toBe(2);
expect(wrapper.vm.jobMetricsGroupedByPluginType.core.runtime).toBe(145);
expect(wrapper.vm.pluginsSortedByPluginType.length).toBe(2);
// Three metrics, begin metrics for two plugins
const metricsTables = wrapper.findAll(".metrics_plugin");
expect(metricsTables.length).toBe(2);
@@ -72,45 +73,53 @@ describe("JobMetrics/JobMetrics.vue", () => {
expect(metricsTables.at(1).findAll("tr").length).toBe(1);
});
it("should render correct AWS Estimates", async () => {
it("renders correct AWS estimates", async () => {
const deriveRenderedAwsEstimate = async (cores, seconds, memory) => {
const JOB_ID = Math.random().toString(36).substring(2);
const propsData = {
jobId: JOB_ID,
aws_estimate: true,
};
const metricsResponse = [
const mockMetricsResponse = [
{ plugin: "core", name: "galaxy_slots", raw_value: cores },
{ plugin: "core", name: "runtime_seconds", raw_value: seconds },
{ plugin: "core", name: "galaxy_memory_mb", raw_value: memory },
];
const wrapper = mount(JobMetrics, {
store: testStore,
propsData,
localVue,
computed: {
jobMetrics() {
return metricsResponse;
const pinia = createTestingPinia({
initialState: {
jobMetricsStore: {
jobMetricsByJobId: {
[`${JOB_ID}`]: mockMetricsResponse,
},
jobMetricsByHdaId: {},
jobMetricsByLddaId: {},
},
},
});
setActivePinia(pinia);
const wrapper = mount(JobMetrics, {
localVue,
pinia,
propsData: {
jobId: JOB_ID,
aws_estimate: true,
},
});
// Wait for axios and rendering.
await flushPromises();
const estimates = {};
if (!wrapper.find("#aws-estimate").exists()) {
return false;
if (wrapper.find("#aws-estimate").exists()) {
return {
cost: wrapper.find("#aws-estimate b").text(),
vcpus: wrapper.find("#aws_vcpus").text(),
cpu: wrapper.find("#aws_cpu").text(),
mem: wrapper.find("#aws_mem").text(),
name: wrapper.find("#aws_name").text(),
};
}
estimates.cost = wrapper.find("#aws-estimate b").text();
estimates.vcpus = wrapper.find("#aws_vcpus").text();
estimates.cpu = wrapper.find("#aws_cpu").text();
estimates.mem = wrapper.find("#aws_mem").text();
estimates.name = wrapper.find("#aws_name").text();
return estimates;
return {};
};
const assertAwsInstance = (estimates) => {
const instance = ec2.find((instance) => estimates.name === instance.name);
expect(estimates.mem).toBe(instance.mem.toString());
@@ -119,7 +128,6 @@ describe("JobMetrics/JobMetrics.vue", () => {
};
const estimates_small = await deriveRenderedAwsEstimate("1.0000000", "9.0000000", "2048.0000000");
expect(estimates_small.name).toBe("t2.small");
expect(estimates_small.cost).toBe("0.00 USD");
assertAwsInstance(estimates_small);
@@ -134,6 +142,6 @@ describe("JobMetrics/JobMetrics.vue", () => {
"18000.0000000",
"99999.0000000"
);
expect(estimates_not_available).toBe(false);
expect(estimates_not_available).toEqual({});
});
});
+129 -110
View File
@@ -1,11 +1,127 @@
<script setup lang="ts">
import ec2 from "./ec2.json";
import { useJobMetricsStore } from "@/stores/jobMetricsStore";
import { computed, onMounted } from "vue";
const props = defineProps({
jobId: {
type: String,
default: "",
},
datasetId: {
type: String,
default: "",
},
// eslint-disable-next-line vue/prop-name-casing
aws_estimate: {
type: Boolean,
default: false,
},
datasetType: {
type: String,
default: "hda",
},
includeTitle: {
type: Boolean,
default: true,
},
});
const jobMetricsStore = useJobMetricsStore();
onMounted(async () => {
if (props.jobId) {
await jobMetricsStore.fetchJobMetricsForJobId(props.jobId);
} else {
await jobMetricsStore.fetchJobMetricsForDatasetId(props.datasetId, props.datasetType);
}
});
const jobMetrics = computed(() => {
if (props.jobId) {
return jobMetricsStore.getJobMetricsByJobId(props.jobId);
} else {
return jobMetricsStore.getJobMetricsByDatasetId(props.datasetId, props.datasetType);
}
});
const jobMetricsGroupedByPluginType = computed(() => {
const pluginGroups: Record<string, any> = {};
for (const metric of jobMetrics.value) {
// new group found
if (!(metric.plugin in pluginGroups)) {
pluginGroups[metric.plugin] = {};
}
const group = pluginGroups[metric.plugin];
group[metric.title] = metric.value;
}
return pluginGroups;
});
const pluginsSortedByPluginType = computed(() => {
return Object.keys(jobMetricsGroupedByPluginType.value).sort();
});
const computedAwsEstimate = computed(() => {
if (!props.aws_estimate) {
return;
}
const aws: Record<string, any> = {};
for (const metric of jobMetrics.value) {
switch (metric.name) {
case "galaxy_memory_mb":
aws.memory = parseInt(metric.raw_value);
break;
case "galaxy_slots":
aws.vcpus = parseInt(metric.raw_value);
break;
case "runtime_seconds":
aws.seconds = parseInt(metric.raw_value);
break;
default:
}
}
if (aws.memory) {
aws.memory /= 1024;
} else {
// if memory was not specified, assign the smallest amount (we judge based on CPU-count only)
aws.memory = 0.5;
}
// ec2 is already pre-sorted
aws.instance = ec2.find((ec) => {
return ec.mem >= aws.memory && ec.vcpus >= aws.vcpus;
});
if (!aws.instance) {
return;
}
aws.price = ((aws.seconds * aws.instance.price) / 3600).toFixed(2);
return aws;
});
defineExpose({ jobMetricsGroupedByPluginType, pluginsSortedByPluginType });
</script>
<template>
<div v-if="orderedPlugins.length > 0">
<div v-if="pluginsSortedByPluginType.length > 0">
<h2 v-if="includeTitle" class="h-md">Job Metrics</h2>
<div v-for="plugin in orderedPlugins" :key="plugin" class="metrics_plugin">
<h3 class="metrics_plugin_title m-sm">{{ plugin }}</h3>
<div v-for="pluginType in pluginsSortedByPluginType" :key="pluginType" class="metrics_plugin">
<h3 class="metrics_plugin_title m-sm">{{ pluginType }}</h3>
<table class="tabletip info_data_table">
<tbody>
<tr v-for="(metricValue, metricTitle) in metricsByPlugins[plugin]" :key="metricTitle">
<tr
v-for="(metricValue, metricTitle) in jobMetricsGroupedByPluginType[pluginType]"
:key="metricTitle">
<td>{{ metricTitle }}</td>
<td>{{ metricValue }}</td>
</tr>
@@ -16,122 +132,25 @@
<div v-if="aws_estimate && computedAwsEstimate" id="aws-estimate">
<div class="aws">
<h3>AWS estimate</h3>
<b>{{ computedAwsEstimate.price }} USD</b><br />
This job requested {{ computedAwsEstimate.vcpus }} cores and {{ computedAwsEstimate.memory }} Gb. Given
this, the smallest EC2 machine we could find is
<span id="aws_name">{{ computedAwsEstimate.instance.name }}</span> (<span id="aws_mem">{{
This job requested {{ computedAwsEstimate.vcpus }} core{{
computedAwsEstimate.vcpus > 1 ? "s" : ""
}}
and {{ computedAwsEstimate.memory }} Gb. Given this information, the smallest EC2 machine we could find
is <span id="aws_name">{{ computedAwsEstimate.instance.name }}</span> (<span id="aws_mem">{{
computedAwsEstimate.instance.mem
}}</span>
GB / <span id="aws_vcpus">{{ computedAwsEstimate.instance.vcpus }}</span> vCPUs /
<span id="aws_cpu">{{ computedAwsEstimate.instance.cpu }}</span
>). That instance is priced at {{ computedAwsEstimate.instance.price }} USD/hour.<br />
Please note, that those numbers are only estimates, all jobs are always free of charge for all users.
>). This instance is priced at {{ computedAwsEstimate.instance.price }} USD/hour.<br />
&ast;Please note, that these numbers are only estimates, all jobs are always free of charge for all
users.
</div>
</div>
</div>
</template>
<script>
import { mapCacheActions } from "vuex-cache";
import { mapGetters } from "vuex";
import ec2 from "./ec2.json";
export default {
props: {
jobId: {
type: String,
},
datasetId: {
type: String,
},
aws_estimate: {
type: Boolean,
},
datasetType: {
type: String,
default: "hda",
},
includeTitle: {
type: Boolean,
default: true,
},
},
computed: {
...mapGetters(["getJobMetricsByDatasetId", "getJobMetricsByJobId"]),
computedAwsEstimate: function () {
if (!this.aws_estimate) {
return;
}
const aws = {};
this.jobMetrics.forEach((metric) => {
switch (metric.name) {
case "galaxy_memory_mb":
aws.memory = parseInt(metric.raw_value);
break;
case "galaxy_slots":
aws.vcpus = parseInt(metric.raw_value);
break;
case "runtime_seconds":
aws.seconds = parseInt(metric.raw_value);
break;
default:
}
});
if (aws.memory) {
aws.memory /= 1024;
}
// if memory was not specified, assign the smallest amount (we judge based on CPU-count only)
else {
aws.memory = 0.5;
}
// ec2 is already pre-sorted
aws.instance = ec2.find((ec) => {
return ec.mem >= aws.memory && ec.vcpus >= aws.vcpus;
});
if (aws.instance === undefined) {
return;
}
aws.price = ((aws.seconds * aws.instance.price) / 3600).toFixed(2);
return aws;
},
jobMetrics: function () {
if (this.jobId) {
return this.getJobMetricsByJobId(this.jobId);
} else {
return this.getJobMetricsByDatasetId(this.datasetId, this.datasetType);
}
},
metricsByPlugins: function () {
const metricsByPlugins = {};
const metrics = this.jobMetrics;
metrics.forEach((metric) => {
if (!(metric.plugin in metricsByPlugins)) {
metricsByPlugins[metric.plugin] = {};
}
const metricsForPlugin = metricsByPlugins[metric.plugin];
metricsForPlugin[metric.title] = metric.value;
});
return metricsByPlugins;
},
orderedPlugins: function () {
return Object.keys(this.metricsByPlugins).sort();
},
},
created: function () {
if (this.jobId) {
this.fetchJobMetricsForJobId(this.jobId);
} else {
this.fetchJobMetricsForDatasetId({ datasetId: this.datasetId, datasetType: this.datasetType });
}
},
methods: {
...mapCacheActions(["fetchJobMetricsForDatasetId", "fetchJobMetricsForJobId"]),
},
};
</script>
<style scoped>
.aws {
padding-top: 0.6rem;
-2
View File
@@ -12,7 +12,6 @@ import config from "config";
import { gridSearchStore } from "./gridSearchStore";
import { tagStore } from "./tagStore";
import { jobMetricsStore } from "./jobMetricsStore";
import { jobDestinationParametersStore } from "./jobDestinationParametersStore";
import { invocationStore } from "./invocationStore";
import { collectionElementsStore, datasetStore } from "./historyStore";
@@ -59,7 +58,6 @@ export function createStore() {
datasetExtFiles: datasetExtFilesStore,
datasetPathDestination: datasetPathDestinationStore,
invocations: invocationStore,
jobMetrics: jobMetricsStore,
gridSearch: gridSearchStore,
panels: panelStore,
tags: tagStore,
-49
View File
@@ -1,49 +0,0 @@
export const state = {
jobMetricsByHdaId: {},
jobMetricsByLddaId: {},
jobMetricsByJobId: {},
};
import Vue from "vue";
import { getAppRoot } from "onload/loadConfig";
import axios from "axios";
const getters = {
getJobMetricsByDatasetId:
(state) =>
(datasetId, datasetType = "hda") => {
const jobMetricsObject = datasetType == "hda" ? state.jobMetricsByHdaId : state.jobMetricsByLddaId;
return jobMetricsObject[datasetId] || [];
},
getJobMetricsByJobId: (state) => (jobId) => {
return state.jobMetricsByJobId[jobId] || [];
},
};
const actions = {
fetchJobMetricsForDatasetId: async ({ commit }, { datasetId, datasetType }) => {
const { data } = await axios.get(`${getAppRoot()}api/datasets/${datasetId}/metrics?hda_ldda=${datasetType}`);
commit("saveJobMetricsForDatasetId", { datasetId, datasetType, jobMetrics: data });
},
fetchJobMetricsForJobId: async ({ commit }, jobId) => {
const { data } = await axios.get(`${getAppRoot()}api/jobs/${jobId}/metrics`);
commit("saveJobMetricsForJobId", { jobId, jobMetrics: data });
},
};
const mutations = {
saveJobMetricsForDatasetId: (state, { datasetId, datasetType, jobMetrics }) => {
const jobMetricsObject = datasetType == "hda" ? state.jobMetricsByHdaId : state.jobMetricsByLddaId;
Vue.set(jobMetricsObject, datasetId, jobMetrics);
},
saveJobMetricsForJobId: (state, { jobId, jobMetrics }) => {
Vue.set(state.jobMetricsByJobId, jobId, jobMetrics);
},
};
export const jobMetricsStore = {
state,
getters,
actions,
mutations,
};
-19
View File
@@ -1,19 +0,0 @@
import { jobMetricsStore } from "./jobMetricsStore";
describe("store/jobMetricsStore.js", () => {
test("getter should return empty list for unfetched job IDs", () => {
const state = {
jobMetricsByJobId: {},
};
const metrics = jobMetricsStore.getters.getJobMetricsByJobId(state)("123");
expect(metrics.length == 0).toBeTruthy();
});
test("should simply return fetched metrics list for job ID", () => {
const state = {
jobMetricsByJobId: { 123: [{ plugin: "core", value: 123 }] },
};
const metrics = jobMetricsStore.getters.getJobMetricsByJobId(state)("123");
expect(metrics.length == 1).toBeTruthy();
});
});
+69
View File
@@ -0,0 +1,69 @@
import { createPinia, setActivePinia } from "pinia";
import { useJobMetricsStore } from "./jobMetricsStore";
describe("stores/jobMetricsStore", () => {
const testPlugin = { plugin: "core", value: "123", title: "foo", name: "bar", raw_value: "raw-chicken" };
beforeEach(() => {
setActivePinia(createPinia());
});
it("returns an empty list for unfetched job and dataset IDs.", () => {
const jobMetricsStore = useJobMetricsStore();
jobMetricsStore.$state = {
jobMetricsByJobId: {},
jobMetricsByHdaId: {},
jobMetricsByLddaId: {},
};
expect(jobMetricsStore.getJobMetricsByJobId("123").length).toBe(0);
expect(jobMetricsStore.getJobMetricsByDatasetId("123").length).toBe(0);
expect(jobMetricsStore.getJobMetricsByDatasetId("123", "not-hda").length).toBe(0);
});
it("returns metrics for job ID.", () => {
const jobMetricsStore = useJobMetricsStore();
jobMetricsStore.$state = {
jobMetricsByJobId: { 123: [testPlugin] },
jobMetricsByHdaId: {},
jobMetricsByLddaId: {},
};
const metrics = jobMetricsStore.getJobMetricsByJobId("123");
expect(metrics.length).toBe(1);
expect(metrics[0]).toEqual(testPlugin);
});
it("returns metrics by hda ID for dataset ID by default.", () => {
const jobMetricsStore = useJobMetricsStore();
jobMetricsStore.$state = {
jobMetricsByJobId: {},
jobMetricsByHdaId: { 123: [testPlugin] },
jobMetricsByLddaId: {},
};
const metrics = jobMetricsStore.getJobMetricsByDatasetId("123");
expect(metrics.length).toBe(1);
expect(metrics[0]).toEqual(testPlugin);
});
it("returns metrics by Ldda ID for dataset ID when dataset type is not hda.", () => {
const jobMetricsStore = useJobMetricsStore();
jobMetricsStore.$state = {
jobMetricsByJobId: {},
jobMetricsByHdaId: {},
jobMetricsByLddaId: { 123: [testPlugin] },
};
const metrics = jobMetricsStore.getJobMetricsByDatasetId("123", "not-hda");
expect(metrics.length).toBe(1);
expect(metrics[0]).toEqual(testPlugin);
});
});
+65
View File
@@ -0,0 +1,65 @@
import axios from "axios";
import { defineStore } from "pinia";
import { prependPath } from "@/utils/redirect";
import Vue, { computed, ref } from "vue";
interface JobMertic {
title: string;
value: string;
plugin: string;
name: string;
raw_value: string;
}
export const useJobMetricsStore = defineStore("jobMetricsStore", () => {
const jobMetricsByHdaId = ref<Record<string, JobMertic[]>>({});
const jobMetricsByLddaId = ref<Record<string, JobMertic[]>>({});
const jobMetricsByJobId = ref<Record<string, JobMertic[]>>({});
const getJobMetricsByDatasetId = computed(() => {
return (datasetId: string, datasetType = "hda") => {
const jobMetricsObject = datasetType == "hda" ? jobMetricsByHdaId : jobMetricsByLddaId;
return jobMetricsObject.value[datasetId] || ([] as JobMertic[]);
};
});
const getJobMetricsByJobId = computed(() => {
return (jobId: string) => {
return jobMetricsByJobId.value[jobId] || ([] as JobMertic[]);
};
});
async function fetchJobMetricsForDatasetId(datasetId: string, datasetType = "hda") {
if (jobMetricsByHdaId.value[datasetId] || jobMetricsByLddaId.value[datasetId]) {
return;
}
const jobMetrics = (await axios.get(prependPath(`api/datasets/${datasetId}/metrics?hda_ldda=${datasetType}`)))
.data as JobMertic[];
const jobMetricsObject = datasetType == "hda" ? jobMetricsByHdaId : jobMetricsByLddaId;
Vue.set(jobMetricsObject.value, datasetId, jobMetrics);
}
async function fetchJobMetricsForJobId(jobId: string) {
if (jobMetricsByJobId.value[jobId]) {
return;
}
const jobMetrics = (await axios.get(prependPath(`api/jobs/${jobId}/metrics`))).data as JobMertic[];
Vue.set(jobMetricsByJobId.value, jobId, jobMetrics);
}
return {
jobMetricsByHdaId,
jobMetricsByLddaId,
jobMetricsByJobId,
getJobMetricsByDatasetId,
getJobMetricsByJobId,
fetchJobMetricsForJobId,
fetchJobMetricsForDatasetId,
};
});