diff --git a/scripts/ops/README.md b/scripts/ops/README.md index 5b8dd4f3e..a54e08bc1 100644 --- a/scripts/ops/README.md +++ b/scripts/ops/README.md @@ -37,4 +37,4 @@ We source the environment variables to not contaminate our current shell. ```bash bash -c "source /etc/armadillo/acc.env ; ./test_armadilloctl.bash" || echo FAILED -``` +``` \ No newline at end of file diff --git a/scripts/ops/armadilloctl.zsh b/scripts/ops/armadilloctl.zsh new file mode 100755 index 000000000..fd2acf7b5 --- /dev/null +++ b/scripts/ops/armadilloctl.zsh @@ -0,0 +1,176 @@ +#!/usr/bin/env zsh + +CURL_OPTS=--silent + +WARN="\033[33m" +ERR="\033[31m" +B="\033[0m" + +function warning() { + echo -e "WARNING: ${WARN}${1}${B}" +} + +function error() { + echo -e "ERROR: ${ERR}${1}${B}" + exit 1 +} + +function is_armadillo_running() { + cmd="${ARMADILLO_URL}" + curl $CURL_OPTS --request "GET" "${cmd}" > /dev/null || (error "Armadillo not running."; exit 1) +} + +function get_profiles() { + cmd="$ARMADILLO_URL/ds-profiles" + profile_names=$(curl $CURL_OPTS --user "${CREDENTIALS}" --request "GET" --header "accept: application/json" "${cmd}" | jq -r '.[] | "\(.name)"') +} + +function status { + if [ -z "$1" ]; then + error "$0 needs profile name. Or try ${0}All" + fi + name=$1 + + cmd="${ARMADILLO_URL}/ds-profiles/{$name}" + stats=$(curl $CURL_OPTS --user "${CREDENTIALS}" --request "GET" --header "accept: application/json" "${cmd}" | jq -r '"\(.name) = \(.container.status)"') + echo $stats +} + +function start { + if [ -z "$1" ]; then + error "$0 needs profile name. Or try ${0}All" + fi + + profile="$1" + cmd="$ARMADILLO_URL/ds-profiles/$profile/start" + echo "Starting '$profile' on $cmd" + + curl $CURL_OPTS --user $CREDENTIALS --request "POST" "$cmd" --data "" +} + +function stop { + if [ -z "$1" ]; then + error "$0 needs profile name. Or try ${0}All" + fi + + profile="$1" + cmd="$ARMADILLO_URL/ds-profiles/$profile/stop" + echo "Stopping '$profile' on $cmd" + + curl $CURL_OPTS --user $CREDENTIALS --request "POST" $cmd --data "" +} + +function restart { + if [ -z "$1" ]; then + error "$0 needs profile name. Or try ${0}All" + fi + + profile=$1 + echo "Restarting $profile" + stop "$profile" + sleep 5 + start "$profile" + sleep 5 + status "$profile" + +} + +function is_auto_start() { + if [[ $ARMADILLO_PROFILES_AUTOSTART =~ (^|[[:space:]])$1($|[[:space:]]) ]] + then + return 0 + else + return 1 + fi +} + +function doAll { + command=$1 + get_profiles + + echo "${profile_names}" | while read -r item; do + "$command" "${item}" + done +} + +function statusAll() { + doAll status +} + +function startAll() { + doAll start +} + +function stopAll() { + doAll stop +} + +function restartAll { + doAll stop + sleep 5 + doAll start +} + +function autoStart() { + get_profiles + echo "Auto starting ..." + echo "${profile_names}" | while read -r item + do + if is_auto_start "${item}" + then + start "${item}" + fi + done +} + +function check_dependencies() { + if ! which jq > /dev/null ; then + echo "Please install jq for json parsing ... exiting" + exit 1 + fi +} + +function var_found() { + echo "Variable $1 found ..." +} + +function var_empty() { + echo "Variable $1 not set! ... exiting" + exit 1 +} + +function all_set() { + [[ -n "$ARMADILLO_URL" ]] && var_found ARMADILLO_URL + [[ -z "$ARMADILLO_URL" ]] && var_empty ARMADILLO_URL + + [[ -n "$ARMADILLO_ADMIN_USER" ]] && var_found ARMADILLO_ADMIN_USER + [[ -z "$ARMADILLO_ADMIN_USER" ]] && var_empty ARMADILLO_ADMIN_USER + + [[ -n "$ARMADILLO_ADMIN_PASSWORD" ]] && var_found ARMADILLO_ADMIN_PASSWORD + [[ -z "$ARMADILLO_ADMIN_PASSWORD" ]] && var_empty ARMADILLO_ADMIN_PASSWORD + + # Set to all if not set with value. + [[ -n "${ARMADILLO_PROFILES_AUTOSTART}" ]] && var_found ARMADILLO_PROFILES_AUTOSTART + [[ -z "${ARMADILLO_PROFILES_AUTOSTART}" ]] && ARMADILLO_PROFILES_AUTOSTART="__ALL__" + + CREDENTIALS="${ARMADILLO_ADMIN_USER}:${ARMADILLO_ADMIN_PASSWORD}" + + echo "\nArmadillo settings:" + echo " URL : ${ARMADILLO_URL}" + echo " ADMIN_USER : ${ARMADILLO_ADMIN_USER}" + echo " PROFILES_AUTOSTART : ${ARMADILLO_PROFILES_AUTOSTART}" +} + +check_dependencies || exit + +all_set || exit + +is_armadillo_running || exit + +get_profiles + +if [[ "$1" =~ ^(status|start|stop|restart|statusAll|startAll|stopAll|restartAll|autoStart)$ ]]; then + "$@" +else + echo "\nPlease provide one of the following argument: status | start | stop | restart | statusAll | startAll | stopAll | restartAll | autoStart" +fi diff --git a/scripts/ops/env.dist b/scripts/ops/env.dist index 712f423b4..63cc264f3 100644 --- a/scripts/ops/env.dist +++ b/scripts/ops/env.dist @@ -5,4 +5,4 @@ export ARMADILLO_ADMIN_USER=admin export ARMADILLO_ADMIN_PASSWORD=admin # Space separated values between double quotes. Leaving empty falls back to all profiles. -export ARMADILLO_PROFILES_AUTOSTART="default rock" +export ARMADILLO_PROFILES_AUTOSTART="default rock" \ No newline at end of file diff --git a/scripts/ops/test_armadilloctl.zsh b/scripts/ops/test_armadilloctl.zsh new file mode 100755 index 000000000..9d480fe33 --- /dev/null +++ b/scripts/ops/test_armadilloctl.zsh @@ -0,0 +1,67 @@ +#!/usr/bin/env zsh + +OUT=/dev/stdout +if [ -z "$1" ]; then + OUT="/dev/null" +fi + +function usage() { + echo 'zsh -c "source dev.env ; ./test_armadilloctl.zsh" || echo FAILED' +} + +function colors() { + for c in {0..255}; do + printf "\033[48;5;%sm%3d\033[0m " "$c" "$c" + if (( c == 15 )) || (( c > 15 )) && (( (c-15) % 6 == 0 )); then + printf "\n" + fi + done +} + +OK="\033[32m" +WARN="\033[33m" +ERR="\033[31m" +B="\033[0m" + +function error() { + echo -e "TEST:: ERROR: ${ERR}${1}${B}" +} + +function warning() { + echo -e "TEST:: WARNING: ${WARN}${1}${B}" +} + +function success() { + echo -e "TEST:: SUCCES: ${OK}${1}${B}" +} + + +function failed() { + warn $0 +} +function do_sleep() { + warning "Pausing for $1 seconds before '$2'." + sleep "$1" + echo "..." +} + +error "NOTE: controller exit values are incomplete ... needs eyes" +warning "Make sure Armadillo is running" + +./armadilloctl.zsh > $OUT || success "Needs a command." + +./armadilloctl.zsh status > $OUT || success "status needs profile name" +./armadilloctl.zsh statusAll > $OUT && success "statusAll needs running Armadillo" + +./armadilloctl.zsh stopAll > $OUT && success "stopAll needs running Armadillo" + +do_sleep 5 "autoStart" +./armadilloctl.zsh autoStart > $OUT && success "autoStart needs running Armadillo" + +do_sleep 5 "startAll" +./armadilloctl.zsh startAll > $OUT && success "startAll needs running Armadillo" + +do_sleep 5 "restartAll" +./armadilloctl.zsh restartAll > $OUT && success "restartAll needs running Armadillo" + +# colors diff --git a/ui/src/views/ProjectsExplorer.vue b/ui/src/views/ProjectsExplorer.vue index c0c381c70..50a789d5b 100644 --- a/ui/src/views/ProjectsExplorer.vue +++ b/ui/src/views/ProjectsExplorer.vue @@ -431,7 +431,7 @@ export default defineComponent({ this.successMessage = `Successfully deleted file [${file}] from directory [${folder}] of project: [${this.projectId}]`; }) .catch((error) => { - this.errorMessage = error; + this.errorMessage = `${error}`; }); }, reloadProject(callback: Function | undefined = undefined) { diff --git a/ui/tests/unit/components/SimpleTable.spec.ts b/ui/tests/unit/components/SimpleTable.spec.ts index b73579e75..937aed65c 100644 --- a/ui/tests/unit/components/SimpleTable.spec.ts +++ b/ui/tests/unit/components/SimpleTable.spec.ts @@ -72,6 +72,8 @@ describe("SimpleTable", () => { props: { data: data, maxWidth: 600, + nCols: Object.keys(data[0]).length, + nRows: data.length }, }); }); diff --git a/ui/tests/unit/views/ProjectExplorer.spec.ts b/ui/tests/unit/views/ProjectExplorer.spec.ts new file mode 100644 index 000000000..26c011fbd --- /dev/null +++ b/ui/tests/unit/views/ProjectExplorer.spec.ts @@ -0,0 +1,246 @@ +import { shallowMount, VueWrapper } from "@vue/test-utils"; +import ProjectsExplorer from "@/views/ProjectsExplorer.vue"; +import { createRouter, createWebHistory } from "vue-router"; +import * as _api from "@/api/api"; + +const api = _api as any; + +jest.mock("@/api/api"); + +describe("ProjectsExplorer", () => { + let testData: Array; + + const mock_routes = [ + { + path: "/", + redirect: "/item_a" + }, + { + path: "/item_a", + component: { + template: "Welcome to item a", + }, + }, + { + path: "/item_b", + component: { + template: "Welcome to item b", + }, + }, + { + path: "/item_c", + component: { + template: "Welcome to item c", + }, + }, + ]; + const router = createRouter({ + history: createWebHistory(), + routes: mock_routes, + }); + + let wrapper: VueWrapper; + + beforeEach(function() { + const mockRouter = { + push: jest.fn(), + }; + + router.currentRoute.value.params = { projectId: "some-project" }; + + testData = [ + "some-project/folder-a-one/file1.parquet", + "some-project/folder-a-one/file2.parquet", + "some-project/folder-a-one/fileb.parquet", + "some-project/folder-a-one/filea.parquet", + "some-project/folder-b-two/file1.parquet", + "some-project/folder-b-two/file2.parquet", + "some-project/folder-d-four/file2.parquet", + "some-project/folder-d-four/file1.parquet", + "some-project/folder-c-three/file1.parquet" + ]; + + api.getProject.mockImplementationOnce(() => { + return Promise.resolve(testData); + }); + + wrapper = shallowMount(ProjectsExplorer, { + global: { + plugins: [router], + mocks: { + $router: mockRouter, + }, + }, + }); + + }); + + test("setting project content", () => { + expect(wrapper.vm.projectContent).toEqual( + { + "folder-a-one": [ + "file1.parquet", + "file2.parquet", + "fileb.parquet", + "filea.parquet" + ], + "folder-b-two": ["file1.parquet", "file2.parquet"], + "folder-d-four": ["file2.parquet", "file1.parquet"], + "folder-c-three": ["file1.parquet"] + } + ); + }); + + test("sorts folders", () => { + expect(wrapper.vm.getSortedFolders()).toEqual(["folder-a-one", "folder-b-two", "folder-c-three", "folder-d-four"]); + }); + + test("sorts files", () => { + wrapper.vm.selectedFolder = "folder-a-one"; + expect(wrapper.vm.getSortedFiles()).toEqual(["file1.parquet", "file2.parquet", "filea.parquet", "fileb.parquet"]); + wrapper.vm.selectedFolder = "folder-b-two"; + expect(wrapper.vm.getSortedFiles()).toEqual(["file1.parquet", "file2.parquet"]); + wrapper.vm.selectedFolder = "folder-c-three"; + expect(wrapper.vm.getSortedFiles()).toEqual(["file1.parquet"]); + wrapper.vm.selectedFolder = "folder-d-four"; + expect(wrapper.vm.getSortedFiles()).toEqual(["file1.parquet", "file2.parquet"]); + }); + + test("ask if preview is empty and setting empty", () => { + expect(wrapper.vm.askIfPreviewIsEmpty()).toBe(true); + wrapper.vm.filePreview = [{"some-file": "foobar"}]; + expect(wrapper.vm.askIfPreviewIsEmpty()).toBe(false); + wrapper.vm.clearFilePreview(); + expect(wrapper.vm.askIfPreviewIsEmpty()).toBe(true); + }); + + test("succesfully creating a new folder", () => { + expect(wrapper.vm.createNewFolder).toBe(false); + wrapper.vm.setCreateNewFolder(); + expect(wrapper.vm.createNewFolder).toBe(true); + wrapper.vm.newFolder = "FOLDER-e-five"; + wrapper.vm.addNewFolder(); + expect(wrapper.vm.getSortedFolders()).toContain("folder-e-five"); + }); + + test("error creating folder containing /", () => { + expect(wrapper.vm.errorMessage).toBe(""); + wrapper.vm.setCreateNewFolder(); + wrapper.vm.newFolder = "folder/five"; + wrapper.vm.addNewFolder(); + expect(wrapper.vm.errorMessage).toBe("Folder name cannot contain /"); + }); + + test("error creating folder containing /", () => { + expect(wrapper.vm.errorMessage).toBe(""); + wrapper.vm.setCreateNewFolder(); + wrapper.vm.addNewFolder(); + expect(wrapper.vm.errorMessage).toBe("Folder name cannot be empty"); + }); + + test("cancel creating a new folder", () => { + expect(wrapper.vm.createNewFolder).toBe(false); + expect(wrapper.vm.newFolder).toBe(""); + wrapper.vm.setCreateNewFolder(); + wrapper.vm.newFolder = "some-folder"; + expect(wrapper.vm.createNewFolder).toBe(true); + expect(wrapper.vm.newFolder).toBe("some-folder"); + wrapper.vm.cancelNewFolder(); + expect(wrapper.vm.createNewFolder).toBe(false); + expect(wrapper.vm.newFolder).toBe(""); + }); + + test("success message uploading a file", () => { + expect(wrapper.vm.successMessage).toBe(""); + wrapper.vm.onUploadSuccess({object: "folder-d-four", filename: "some-new-file.extension"}); + expect(wrapper.vm.successMessage).toBe("Successfully uploaded file [some-new-file.extension] into directory [folder-d-four] of project: [some-project]"); + }); + + test("non table type", () => { + expect(wrapper.vm.isNonTableType("some-file.parquet")).toBe(false); + expect(wrapper.vm.isNonTableType("some-file.csv.gz")).toBe(true); + expect(wrapper.vm.isNonTableType("some-file.rda")).toBe(true); + expect(wrapper.vm.isNonTableType("some-file")).toBe(true); + }); + + test("setting and clearing file to delete", () => { + expect(wrapper.vm.fileToDelete).toBe(""); + expect(wrapper.vm.folderToDeleteFrom).toBe(""); + // Important: selectedFolder before selectedFile, since watcher for selectedFolder resets selectedFile to "" + wrapper.vm.selectedFolder = "folder-d-four"; + wrapper.vm.selectedFile = "file1.parquet"; + wrapper.vm.deleteSelectedFile(); + expect(wrapper.vm.fileToDelete).toBe("file1.parquet"); + expect(wrapper.vm.folderToDeleteFrom).toBe("folder-d-four"); + wrapper.vm.clearRecordToDelete(); + expect(wrapper.vm.fileToDelete).toBe(""); + expect(wrapper.vm.folderToDeleteFrom).toBe(""); + }); + + test("successfully proceed deleting file", async () => { + api.deleteObject.mockImplementation(() => { + return Promise.resolve({}) + }); + wrapper.vm.selectedFolder = "folder-d-four"; + wrapper.vm.selectedFile = "file2.parquet"; + await wrapper.vm.proceedDelete("folder-d-four/file2.parquet"); + expect(wrapper.vm.selectedFile).toBe(""); + expect(wrapper.vm.successMessage).toBe("Successfully deleted file [file2.parquet] from directory [folder-d-four] of project: [some-project]"); + }); + + test("error proceed deleting file", async () => { + const error = new Error("fail"); + api.deleteObject.mockImplementation(() => { + return Promise.reject(error) + }); + wrapper.vm.selectedFolder = "doesnt-exist"; + wrapper.vm.selectedFile = "doesnot.exists"; + await wrapper.vm.proceedDelete("doesnt-exist/doesnot.exists"); + await wrapper.vm.$nextTick(); + expect(wrapper.vm.errorMessage).toBe(`${error}`); + }); + + test("successfully reloads project", async () => { + let new_data = [ + "some-project/other-folder/one-some.file", + "some-project/other-folder/one-some.file" + ]; + expect(wrapper.vm.getSortedFolders()).toEqual(["folder-a-one", "folder-b-two", "folder-c-three", "folder-d-four"]); + api.getProject.mockImplementation(() => { + return Promise.resolve(new_data) + }); + await wrapper.vm.reloadProject(); + await wrapper.vm.$nextTick(); + await wrapper.vm.$nextTick(); + expect(wrapper.vm.getSortedFolders()).toEqual(["other-folder"]); + }); + + test("error reloading project", async () => { + const error = new Error("fail"); + api.getProject.mockImplementation(() => { + return Promise.reject(error) + }); + await wrapper.vm.reloadProject(); + expect(wrapper.vm.errorMessage).toBe("Could not load project: Error: fail."); + }); + + test("reloads project with call to callback", async () => { + const someFunction = jest.fn(); + await wrapper.vm.reloadProject(someFunction); + await wrapper.vm.$nextTick(); + await wrapper.vm.$nextTick(); + expect(someFunction).toBeCalled(); + }); + + test("show error message with string", () => { + wrapper.vm.showErrorMessage("some very random error"); + expect(wrapper.vm.errorMessage).toBe("some very random error"); + }); + + test("show error message used when UploadFile fails", () => { + // Since showErrorMessage already expects a string and is only used in FileUpload + // and FileUpload only returns the error as string, using only a string in this test. + wrapper.vm.showErrorMessage("A very random UploadFile error string"); + expect(wrapper.vm.errorMessage).toBe("A very random UploadFile error string"); + }); +});