From 7edac797ef5fdae9aa24552a1dacb459b181864b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20G=C3=B6bel?= <dgoebel@techfak.uni-bielefeld.de> Date: Mon, 4 Mar 2024 16:10:04 +0100 Subject: [PATCH] Add view to accept and reject resource synchronization requests #100 --- .gitlab-ci.yml | 4 +- src/App.vue | 1 - .../resource/services/ResourceService.ts | 2 +- src/components/AppHeader.vue | 9 +- src/components/modals/ReasonModal.vue | 6 +- src/router/adminRoutes.ts | 9 + src/router/index.ts | 9 +- src/stores/resources.ts | 39 +++ src/views/ImprintView.vue | 6 +- src/views/PrivacyPolicyView.vue | 6 +- src/views/TermsOfUsageView.vue | 6 +- src/views/admin/AdminResourcesView.vue | 44 ++- src/views/admin/AdminSyncRequestsView.vue | 266 ++++++++++++++++++ 13 files changed, 365 insertions(+), 42 deletions(-) create mode 100644 src/views/admin/AdminSyncRequestsView.vue diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 7f2ea9f..15d4639 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -37,7 +37,7 @@ build: publish-main-docker-container-job: stage: deploy image: - name: gcr.io/kaniko-project/executor:v1.20.0-debug + name: gcr.io/kaniko-project/executor:v1.21.0-debug entrypoint: [""] only: refs: @@ -54,7 +54,7 @@ publish-main-docker-container-job: publish-docker-container-job: stage: deploy image: - name: gcr.io/kaniko-project/executor:v1.20.0-debug + name: gcr.io/kaniko-project/executor:v1.21.0-debug entrypoint: [""] only: - tags diff --git a/src/App.vue b/src/App.vue index 972b73d..0ba8e44 100644 --- a/src/App.vue +++ b/src/App.vue @@ -54,7 +54,6 @@ onBeforeMount(() => { window._paq.push(["setCustomUrl", to.path]); window._paq.push(["setDocumentTitle", to.name]); if (store.currentUID.length > 0) { - console.log(store.currentUID); window._paq.push(["setUserId", store.currentUID]); } window._paq.push(["trackPageView"]); diff --git a/src/client/resource/services/ResourceService.ts b/src/client/resource/services/ResourceService.ts index a8fa541..7edb063 100644 --- a/src/client/resource/services/ResourceService.ts +++ b/src/client/resource/services/ResourceService.ts @@ -80,7 +80,7 @@ export class ResourceService { * @returns UserSynchronizationRequestOut Successful Response * @throws ApiError */ - public static resourceListReviewableResources(): CancelablePromise<Array<UserSynchronizationRequestOut>> { + public static resourceListSyncRequests(): CancelablePromise<Array<UserSynchronizationRequestOut>> { return __request(OpenAPI, { method: 'GET', url: '/resources/sync_requests', diff --git a/src/components/AppHeader.vue b/src/components/AppHeader.vue index 4a85939..254d218 100644 --- a/src/components/AppHeader.vue +++ b/src/components/AppHeader.vue @@ -216,8 +216,6 @@ watch( >Users </router-link> </li> - <li><a class="dropdown-item disabled" href="#">Bucket</a></li> - <li><a class="dropdown-item disabled" href="#">Workflow</a></li> <li> <router-link class="dropdown-item" @@ -225,6 +223,13 @@ watch( >Resources </router-link> </li> + <li> + <router-link + class="dropdown-item" + :to="{ name: 'admin-sync-requests' }" + >Resources Synchronization + </router-link> + </li> </ul> </li> </ul> diff --git a/src/components/modals/ReasonModal.vue b/src/components/modals/ReasonModal.vue index e41c4f9..38d4b2a 100644 --- a/src/components/modals/ReasonModal.vue +++ b/src/components/modals/ReasonModal.vue @@ -33,7 +33,11 @@ function sendSaveEvent() { </script> <template> - <bootstrap-modal :modal-id="props.modalId" :modal-label="props.modalLabel"> + <bootstrap-modal + :modal-id="props.modalId" + :modal-label="props.modalLabel" + size-modifier="lg" + > <template #header> <slot name="header" /> </template> diff --git a/src/router/adminRoutes.ts b/src/router/adminRoutes.ts index 4015a57..00093a7 100644 --- a/src/router/adminRoutes.ts +++ b/src/router/adminRoutes.ts @@ -19,4 +19,13 @@ export const adminRoutes: RouteRecordRaw[] = [ title: "Manage Users", }, }, + { + path: "admin/sync-requests", + name: "admin-sync-requests", + component: () => import("../views/admin/AdminSyncRequestsView.vue"), + meta: { + requiresAdminRole: true, + title: "Sync Requests", + }, + }, ]; diff --git a/src/router/index.ts b/src/router/index.ts index 53a4e46..7c4188f 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -5,6 +5,9 @@ import { workflowRoutes } from "@/router/workflowRoutes"; import { s3Routes } from "@/router/s3Routes"; import { resourceRoutes } from "@/router/resourceRoutes"; import { adminRoutes } from "@/router/adminRoutes"; +import ImprintView from "@/views/ImprintView.vue"; +import PrivacyPolicyView from "@/views/PrivacyPolicyView.vue"; +import TermsOfUsageView from "@/views/TermsOfUsageView.vue"; const router = createRouter({ history: createWebHistory(import.meta.env.BASE_URL), @@ -44,7 +47,7 @@ const router = createRouter({ meta: { title: "Privacy Policy", }, - component: import("../views/PrivacyPolicyView.vue"), + component: PrivacyPolicyView, }, { path: "/terms", @@ -52,7 +55,7 @@ const router = createRouter({ meta: { title: "Terms of Usage", }, - component: import("../views/TermsOfUsageView.vue"), + component: TermsOfUsageView, }, { path: "/imprint", @@ -60,7 +63,7 @@ const router = createRouter({ meta: { title: "Imprint", }, - component: import("../views/ImprintView.vue"), + component: ImprintView, }, { path: "/:pathMatch(.*)", diff --git a/src/stores/resources.ts b/src/stores/resources.ts index 3c9edd0..08e9338 100644 --- a/src/stores/resources.ts +++ b/src/stores/resources.ts @@ -6,6 +6,7 @@ import type { ResourceVersionOut, UserRequestAnswer, UserSynchronizationRequestIn, + UserSynchronizationRequestOut, } from "@/client/resource"; import { ResourceService, @@ -22,10 +23,14 @@ export const useResourceStore = defineStore({ resourceMapping: {}, ownResourceMapping: {}, reviewableResourceMapping: {}, + syncRequestMapping: {}, + __syncRequestsFetched: false, }) as { resourceMapping: Record<string, ResourceOut>; ownResourceMapping: Record<string, ResourceOut>; reviewableResourceMapping: Record<string, ResourceOut>; + syncRequestMapping: Record<string, UserSynchronizationRequestOut>; + __syncRequestsFetched: boolean; }, getters: { resources(): ResourceOut[] { @@ -37,6 +42,9 @@ export const useResourceStore = defineStore({ reviewableResources(): ResourceOut[] { return Object.values(this.reviewableResourceMapping); }, + syncRequests(): UserSynchronizationRequestOut[] { + return Object.values(this.syncRequestMapping); + }, }, actions: { fetchResource( @@ -58,6 +66,24 @@ export const useResourceStore = defineStore({ return resource; }); }, + fetchSyncRequests( + onFinally?: () => void, + ): Promise<UserSynchronizationRequestOut[]> { + if (this.__syncRequestsFetched) { + onFinally?.(); + } + return ResourceService.resourceListSyncRequests() + .then((requests) => { + this.__syncRequestsFetched = true; + const newMapping: Record<string, UserSynchronizationRequestOut> = {}; + for (const request of requests) { + newMapping[request.resource_version_id] = request; + } + this.syncRequestMapping = newMapping; + return requests; + }) + .finally(onFinally); + }, fetchResources( maintainerId?: string, versionStatus?: Status[], @@ -268,6 +294,19 @@ export const useResourceStore = defineStore({ requestAnswer, ).then(this._updateReviewableResourceVersion); }, + syncResource( + resourceVersion: ResourceVersionOut, + requestAnswer: UserRequestAnswer, + ): Promise<ResourceVersionOut> { + return ResourceVersionService.resourceVersionResourceVersionSync( + resourceVersion.resource_id, + resourceVersion.resource_version_id, + requestAnswer, + ).then((version) => { + delete this.syncRequestMapping[version.resource_version_id]; + return version; + }); + }, _updateReviewableResourceVersion( version: ResourceVersionOut, ): ResourceVersionOut { diff --git a/src/views/ImprintView.vue b/src/views/ImprintView.vue index 8e4188c..4ea27c7 100644 --- a/src/views/ImprintView.vue +++ b/src/views/ImprintView.vue @@ -1,8 +1,10 @@ <script setup lang="ts"></script> <template> - <h2>Impressum</h2> - <p>TBD</p> + <div> + <h2>Impressum</h2> + <p>TBD</p> + </div> </template> <style scoped></style> diff --git a/src/views/PrivacyPolicyView.vue b/src/views/PrivacyPolicyView.vue index 3656b66..d6d98a1 100644 --- a/src/views/PrivacyPolicyView.vue +++ b/src/views/PrivacyPolicyView.vue @@ -1,8 +1,10 @@ <script setup lang="ts"></script> <template> - <h2>Privacy Policy</h2> - <p>TBD</p> + <div> + <h2>Privacy Policy</h2> + <p>TBD</p> + </div> </template> <style scoped></style> diff --git a/src/views/TermsOfUsageView.vue b/src/views/TermsOfUsageView.vue index 21503d7..5d6374d 100644 --- a/src/views/TermsOfUsageView.vue +++ b/src/views/TermsOfUsageView.vue @@ -1,8 +1,10 @@ <script setup lang="ts"></script> <template> - <h2>Terms of Usage</h2> - <p>TBD</p> + <div> + <h2>Terms of Usage</h2> + <p>TBD</p> + </div> </template> <style scoped></style> diff --git a/src/views/admin/AdminResourcesView.vue b/src/views/admin/AdminResourcesView.vue index fd2c614..b371e7e 100644 --- a/src/views/admin/AdminResourcesView.vue +++ b/src/views/admin/AdminResourcesView.vue @@ -110,17 +110,7 @@ function deleteInS3(resourceVersion: ResourceVersionOut) { function syncToCluster(resourceVersion: ResourceVersionOut) { resourceState.loading = true; resourceRepository - .reviewResource(resourceVersion, { deny: false }) - .then(replaceResourceVersion) - .finally(() => { - resourceState.loading = false; - }); -} - -function denyResource(resourceVersion: ResourceVersionOut) { - resourceState.loading = true; - resourceRepository - .reviewResource(resourceVersion, { deny: false, reason: "Whatever" }) + .syncResource(resourceVersion, { deny: false }) .then(replaceResourceVersion) .finally(() => { resourceState.loading = false; @@ -313,11 +303,26 @@ function resetForm() { Set to Latest </button> </li> + <li v-if="version.status === Status.WAIT_FOR_REVIEW"> + <router-link + class="dropdown-item" + :to="{ name: 'resource-review' }" + > + Review Resource + </router-link> + </li> + <li v-if="version.status === Status.SYNC_REQUESTED"> + <router-link + class="dropdown-item" + :to="{ name: 'admin-sync-requests' }" + > + Review sync request + </router-link> + </li> <li v-if=" version.status === Status.APPROVED || - version.status === Status.SYNC_ERROR || - version.status === Status.SYNC_REQUESTED + version.status === Status.SYNC_ERROR " > <button @@ -331,19 +336,6 @@ function resetForm() { <span class="ms-1">Sync to Cluster</span> </button> </li> - <li v-if="version.status === Status.SYNC_REQUESTED"> - <button - class="dropdown-item" - type="button" - @click="denyResource(version)" - > - <font-awesome-icon - icon="fa-solid fa-xmark" - class="text-danger" - /> - <span class="ms-1">Deny Synchronization</span> - </button> - </li> <li v-if=" version.status === Status.SYNCHRONIZED || diff --git a/src/views/admin/AdminSyncRequestsView.vue b/src/views/admin/AdminSyncRequestsView.vue new file mode 100644 index 0000000..856617d --- /dev/null +++ b/src/views/admin/AdminSyncRequestsView.vue @@ -0,0 +1,266 @@ +<script setup lang="ts"> +import BootstrapToast from "@/components/BootstrapToast.vue"; +import ReasonModal from "@/components/modals/ReasonModal.vue"; +import ResourceVersionInfoModal from "@/components/resources/ResourceVersionInfoModal.vue"; +import { onMounted, reactive } from "vue"; +import { + type ResourceOut, + type ResourceVersionOut, + Status, + type UserSynchronizationRequestOut, +} from "@/client/resource"; +import { useResourceStore } from "@/stores/resources"; +import { useNameStore } from "@/stores/names"; +import { useAuthStore } from "@/stores/users"; +import { Modal, Toast } from "bootstrap"; +import FontAwesomeIcon from "@/components/FontAwesomeIcon.vue"; + +const resourceState = reactive<{ + sendingRequest: boolean; + loading: boolean; + resources: Record<string, ResourceOut>; + inspectResource?: ResourceOut; + rejectResource?: ResourceVersionOut; + inspectVersionIndex: number; +}>({ + sendingRequest: false, + loading: true, + resources: {}, + inspectResource: undefined, + rejectResource: undefined, + inspectVersionIndex: 0, +}); + +const resourceRepository = useResourceStore(); +const nameRepository = useNameStore(); +const userRepository = useAuthStore(); + +let rejectReasonModal: Modal | null = null; +let successToast: Toast | null = null; +let rejectToast: Toast | null = null; +let refreshTimeout: NodeJS.Timeout | undefined = undefined; + +function rejectSyncRequest( + reason: string, + resourceVersion?: ResourceVersionOut, +) { + if (resourceVersion != undefined) { + resourceState.sendingRequest = true; + resourceRepository + .syncResource(resourceVersion, { deny: true, reason }) + .then(() => { + rejectReasonModal?.hide(); + rejectToast?.show(); + }) + .finally(() => { + resourceState.sendingRequest = false; + }); + } +} + +function syncVersion(resourceId: string, resourceVersionId: string) { + resourceState.sendingRequest = true; + resourceRepository + .syncResource( + { + resource_version_id: resourceVersionId, + resource_id: resourceId, + release: "", + created_at: 0, + s3_path: "", + cluster_path: "", + status: Status.SYNC_REQUESTED, + }, + { deny: false }, + ) + .then(() => { + successToast?.show(); + delete resourceState.resources[resourceId]; + }) + .finally(() => { + resourceState.sendingRequest = false; + }); +} + +function fetchResources( + syncRequests: UserSynchronizationRequestOut[], +): Promise<ResourceOut[]> { + return Promise.all( + syncRequests.map((request) => + resourceRepository.fetchResource(request.resource_id, [ + Status.SYNC_REQUESTED, + ]), + ), + ); +} + +function fetchUserNames( + syncRequests: UserSynchronizationRequestOut[], +): UserSynchronizationRequestOut[] { + userRepository.fetchUsernames( + syncRequests.map((request) => request.requester_id), + ); + return syncRequests; +} + +function fetchRequests() { + resourceState.loading = true; + resourceRepository + .fetchSyncRequests(() => { + resourceState.loading = false; + }) + .then(fetchUserNames) + .then(fetchResources) + .then((resources) => { + const newMapping: Record<string, ResourceOut> = {}; + for (const resource of resources) { + newMapping[resource.resource_id] = resource; + } + resourceState.resources = newMapping; + }); +} + +function clickRefreshRequests() { + clearTimeout(refreshTimeout); + refreshTimeout = setTimeout(() => { + fetchRequests(); + }, 500); +} + +onMounted(() => { + fetchRequests(); + + rejectReasonModal = new Modal("#sync-request-reject-modal"); + successToast = new Toast("#accept-sync-request-toast"); + rejectToast = new Toast("#reject-sync-request-toast"); +}); +</script> + +<template> + <bootstrap-toast toast-id="accept-sync-request-toast" color-class="success"> + Syncing resource to the cluster + </bootstrap-toast> + <bootstrap-toast toast-id="reject-sync-request-toast" color-class="danger"> + Rejected resource synchronization request + </bootstrap-toast> + <resource-version-info-modal + modal-id="sync-request-resource-version-info-modal" + :resource-version-index="resourceState.inspectVersionIndex" + :resource="resourceState.inspectResource" + /> + <reason-modal + modal-id="sync-request-reject-modal" + modal-label="Resource Synchronization Request Reject Modal" + :loading="resourceState.sendingRequest" + @save="(reason) => rejectSyncRequest(reason, resourceState.rejectResource)" + > + <template #header> + Reject Resource Synchronization Request + <b>{{ resourceState.rejectResource?.release }}</b> + </template> + </reason-modal> + <div + class="row border-bottom mb-4 justify-content-between align-items-center" + > + <h2 class="w-fit">Review resource synchronization requests</h2> + <span + class="w-fit" + tabindex="0" + data-bs-title="Refresh Reviewable Resources" + data-bs-toggle="tooltip" + id="refreshReviewableResourcesButton" + > + <button + type="button" + class="btn btn-primary btn-light me-2 shadow-sm border w-fit" + :disabled="resourceState.loading || resourceState.sendingRequest" + @click="clickRefreshRequests" + > + <font-awesome-icon icon="fa-solid fa-arrow-rotate-right" /> + <span class="visually-hidden">Refresh Reviewable Resources</span> + </button> + </span> + </div> + <div v-if="resourceState.loading" class="text-center mt-5"> + <div class="spinner-border" style="width: 3rem; height: 3rem" role="status"> + <span class="visually-hidden">Loading...</span> + </div> + </div> + <div + v-else-if="resourceRepository.syncRequests.length === 0" + class="text-center mt-5 fs-4" + > + There are currently no resource synchronization requests + </div> + <div v-else class="d-flex flex-column"> + <div + class="border p-2 pb-0 rounded mb-2 d-flex" + v-for="request in resourceRepository.syncRequests" + :key="request.resource_version_id" + > + <div class="flex-grow-1"> + <h6> + {{ nameRepository.getName(request.resource_id) }}@{{ + nameRepository.getName(request.resource_version_id) + }} + </h6> + <div> + <b>Requester</b>: {{ nameRepository.getName(request.requester_id) }} + </div> + <div><b>Reason</b>:</div> + <p>{{ request.reason }}</p> + </div> + <div class="d-flex flex-column justify-content-evenly align-items-center"> + <button + type="button" + class="btn btn-secondary btn-sm" + data-bs-toggle="modal" + data-bs-target="#sync-request-resource-version-info-modal" + @click=" + resourceState.inspectResource = + resourceState.resources[request.resource_id]; + resourceState.inspectVersionIndex = resourceState.resources[ + request.resource_id + ].versions.findIndex( + (version) => + version.resource_version_id === request.resource_version_id, + ); + " + > + Inspect Resource + </button> + <div class="btn-group"> + <button + type="button" + class="btn btn-success btn-sm" + :disabled="resourceState.sendingRequest" + @click=" + syncVersion(request.resource_id, request.resource_version_id) + " + > + Accept + </button> + <button + type="button" + class="btn btn-danger btn-sm" + @click=" + resourceState.rejectResource = resourceState.resources[ + request.resource_id + ].versions.find( + (version) => + version.resource_version_id === request.resource_version_id, + ) + " + data-bs-toggle="modal" + data-bs-target="#sync-request-reject-modal" + :disabled="resourceState.sendingRequest" + > + Reject + </button> + </div> + </div> + </div> + </div> +</template> + +<style scoped></style> -- GitLab