diff --git a/src/Frontend/src/assets/main.css b/src/Frontend/src/assets/main.css index 47c52a772..8d749df20 100644 --- a/src/Frontend/src/assets/main.css +++ b/src/Frontend/src/assets/main.css @@ -509,3 +509,9 @@ div.alert.alert-warning strong { .tooltip { font-size: 12px; } + +/* Added when dealing with long queue names in EndpointHeader and CollapsedEndpoint */ +.tippy-box { + overflow-wrap: break-word; + word-break: break-word; /* Fallback for older browsers */ +} diff --git a/src/Frontend/src/components/DetailsItem.vue b/src/Frontend/src/components/DetailsItem.vue index ab6b6f374..c2d4d208f 100644 --- a/src/Frontend/src/components/DetailsItem.vue +++ b/src/Frontend/src/components/DetailsItem.vue @@ -6,7 +6,7 @@ defineProps<{ label: string; tooltip?: string; ariaLabel?: string }>();
- +
@@ -29,5 +29,6 @@ defineProps<{ label: string; tooltip?: string; ariaLabel?: string }>(); .details-item span { padding: 0.375rem 0; + width: fit-content; } diff --git a/src/Frontend/src/components/ModalDialog.vue b/src/Frontend/src/components/ModalDialog.vue new file mode 100644 index 000000000..4aad531f3 --- /dev/null +++ b/src/Frontend/src/components/ModalDialog.vue @@ -0,0 +1,68 @@ + + + + + diff --git a/src/Frontend/src/resources/LicenseDetails.ts b/src/Frontend/src/resources/LicenseDetails.ts index a535b6b87..3392a1b1e 100644 --- a/src/Frontend/src/resources/LicenseDetails.ts +++ b/src/Frontend/src/resources/LicenseDetails.ts @@ -4,12 +4,14 @@ export interface LicensedEndpointDetails { endpoints: ApiEndpoint[]; infrastructure_queues: ApiQueue[]; excluded_queues: ApiQueue[]; + unassigned_queues: ApiQueue[]; service_end_date: string; products: ApiProduct[]; valid_id: boolean; } export interface ApiEndpoint { + client_id: string; name: string; classification: EndpointClassification; endpoint_size: string; @@ -19,6 +21,7 @@ export interface ApiEndpoint { export interface ApiQueue { name_hash: string; scope_hash: string; + original_location: string; } interface ApiProduct { @@ -34,24 +37,29 @@ export enum EndpointClassification { export interface Queue { nameHash: string; scopeHash: string; + originalLocation?: string; details: QueueThroughputSummary; } export class Endpoint { readonly clientId: string; name: string; - readonly queues: Queue[]; - readonly classification: EndpointClassification; - readonly endpointSize: EndpointSize; + queues: Queue[]; + classification: EndpointClassification; + readonly licensedSize: EndpointSize | null; readonly availableSizes: EndpointSize[]; - constructor(clientId: string, name: string, queues: Queue[], classification: EndpointClassification, endpointSize: EndpointSize, availableSizes: EndpointSize[]) { + amended: boolean; + + constructor(clientId: string, name: string, queues: Queue[], classification: EndpointClassification, licensedSize: EndpointSize | null, availableSizes: EndpointSize[]) { this.clientId = clientId; this.name = name; this.queues = queues; this.classification = classification; - this.endpointSize = endpointSize; + this.licensedSize = licensedSize; this.availableSizes = availableSizes; + + this.amended = false; } get totalAverageMonthlyThroughput() { @@ -63,7 +71,7 @@ export class Endpoint { } get matchesLicensedSize() { - return this.endpointSize === this.currentSize; + return this.licensedSize != null && this.licensedSize === this.currentSize; } } diff --git a/src/Frontend/src/router/config.ts b/src/Frontend/src/router/config.ts index 1664a8acf..2cc41731f 100644 --- a/src/Frontend/src/router/config.ts +++ b/src/Frontend/src/router/config.ts @@ -201,6 +201,11 @@ const config: RouteItem[] = [ path: routeLinks.throughput.licenseDetails.excludedQueues.template, component: () => import("@/views/throughputreport/licenseDetails/ExcludedQueuesView.vue"), }, + { + title: "Unassigned Queues", + path: routeLinks.throughput.licenseDetails.unassignedQueues.template, + component: () => import("@/views/throughputreport/licenseDetails/UnassignedQueuesView.vue"), + }, ], }, ], diff --git a/src/Frontend/src/router/routeLinks.ts b/src/Frontend/src/router/routeLinks.ts index 1a60d58da..58a06966a 100644 --- a/src/Frontend/src/router/routeLinks.ts +++ b/src/Frontend/src/router/routeLinks.ts @@ -100,6 +100,7 @@ const throughputEndpointLinks = (root: string) => { licensedEndpoints: createLink("licensed"), infrastructureQueues: createLink("infrastructure"), excludedQueues: createLink("excluded"), + unassignedQueues: createLink("unassigned"), }; }; diff --git a/src/Frontend/src/stores/LicenseDetailsStore.spec.ts b/src/Frontend/src/stores/LicenseDetailsStore.spec.ts new file mode 100644 index 000000000..905c656c6 --- /dev/null +++ b/src/Frontend/src/stores/LicenseDetailsStore.spec.ts @@ -0,0 +1,364 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { createPinia, setActivePinia } from "pinia"; +import type QueueThroughputSummary from "@/resources/QueueThroughputSummary"; +import { EndpointClassification, type LicensedEndpointDetails } from "@/resources/LicenseDetails"; + +const mocks = vi.hoisted(() => ({ + isSupported: false, + fetchTypedFromServiceControl: vi.fn(), + postToServiceControl: vi.fn(), + postFileToServiceControl: vi.fn(), + queues: vi.fn(), + showToast: vi.fn(), +})); + +vi.mock("@/views/throughputreport/licenseDetails/isLicenseDetailsSupported", async () => { + const { ref } = await import("vue"); + return { default: () => ref(mocks.isSupported) }; +}); + +vi.mock("@/components/serviceControlClient", () => ({ + default: { + fetchTypedFromServiceControl: mocks.fetchTypedFromServiceControl, + postToServiceControl: mocks.postToServiceControl, + postFileToServiceControl: mocks.postFileToServiceControl, + }, +})); + +vi.mock("@/views/throughputreport/throughputClient", () => ({ + default: () => ({ queues: mocks.queues }), +})); + +vi.mock("@/composables/toast", () => ({ + useShowToast: mocks.showToast, +})); + +vi.mock("@/logger", () => ({ + default: { + error: vi.fn(), + }, +})); + +import { TYPE } from "vue-toastification"; +import { useLicenseDetailsStore } from "@/stores/LicenseDetailsStore"; + +describe("LicenseDetailsStore", () => { + beforeEach(() => { + setActivePinia(createPinia()); + + mocks.isSupported = false; + mocks.fetchTypedFromServiceControl.mockReset(); + mocks.postToServiceControl.mockReset(); + mocks.postFileToServiceControl.mockReset(); + mocks.queues.mockReset(); + mocks.showToast.mockReset(); + + mocks.postToServiceControl.mockResolvedValue(new Response(null, { status: 200 })); + mocks.postFileToServiceControl.mockResolvedValue(new Response(null, { status: 200 })); + }); + + test("creates an endpoint from a queue", async () => { + const { + store, + queues: [queue], + } = await createQueue({ name: "Sales", nameHash: "sales-hash" }); + + store.createEndpointFromQueue(queue); + + expect(store.endpoints).toHaveLength(1); + expect(store.endpoints[0]).toMatchObject({ + name: "Sales", + classification: EndpointClassification.Full, + queues: [queue], + }); + expect(store.unassignedQueues).toEqual([]); + expect(store.hasChanges).toBe(true); + }); + + test("moves an unassigned queue to an endpoint", async () => { + const { + store, + queues: [queue], + } = await createQueue({ + name: "Sales", + nameHash: "sales-hash", + }); + + expect(store.unassignedQueues).toEqual([queue]); + + store.createEndpointFromQueue(queue); + + expect(store.endpoints).toHaveLength(1); + expect(store.endpoints[0].queues).toEqual([queue]); + expect(store.unassignedQueues).toEqual([]); + expect(store.hasChanges).toBe(true); + }); + + test("moves a queue between unassigned and infrastructure collections", async () => { + const { + store, + queues: [queue], + } = await createQueue({ + name: "Sales", + nameHash: "sales-hash", + }); + + store.markQueueAsInfrastructure(queue); + + expect(store.infrastructureQueues).toEqual([queue]); + expect(store.unassignedQueues).toEqual([]); + + store.unmarkQueueAsInfrastructure(queue); + + expect(store.infrastructureQueues).toEqual([]); + expect(store.unassignedQueues).toEqual([queue]); + expect(store.hasChanges).toBe(true); + }); + + test("moves a queue between unassigned and excluded collections", async () => { + const { + store, + queues: [queue], + } = await createQueue({ + name: "Sales", + nameHash: "sales-hash", + }); + + store.excludeQueue(queue); + + expect(store.excludedQueues).toEqual([queue]); + expect(store.unassignedQueues).toEqual([]); + + store.unexcludeQueue(queue); + + expect(store.excludedQueues).toEqual([]); + expect(store.unassignedQueues).toEqual([queue]); + expect(store.hasChanges).toBe(true); + }); + + test("returns a queue to unassigned when removed from an endpoint", async () => { + const { + store, + queues: [queue], + } = await createQueue({ + name: "Sales", + nameHash: "sales-hash", + }); + + store.createEndpointFromQueue(queue); + const endpoint = store.endpoints[0]; + + store.removeQueueFromEndpoint(queue, endpoint); + + expect(endpoint.queues).toEqual([]); + expect(store.unassignedQueues).toEqual([queue]); + expect(store.hasChanges).toBe(true); + }); + + test("renames and reclassifies an endpoint", async () => { + const { + store, + queues: [queue], + } = await createQueue({ name: "Sales", nameHash: "sales-hash" }); + + store.createEndpointFromQueue(queue); + const endpoint = store.endpoints[0]; + + store.renameEndpoint(endpoint, "Sales Senders"); + store.reclassifyEndpoint(endpoint, EndpointClassification.SendOnly); + + expect(endpoint.name).toBe("Sales Senders"); + expect(endpoint.classification).toBe(EndpointClassification.SendOnly); + expect(endpoint.amended).toBe(true); + expect(store.hasChanges).toBe(true); + }); + + test("sorts queue collections by queue name without changing insertion order", async () => { + const { + store, + queues: [zebra, alpha], + } = await createQueue({ name: "Zebra", nameHash: "zebra-hash" }, { name: "alpha", nameHash: "alpha-hash" }); + + store.excludeQueue(zebra); + store.excludeQueue(alpha); + + expect(store.excludedQueues.map((queue) => queue.details.name)).toEqual(["alpha", "Zebra"]); + }); + + test("loads license details and adds newly discovered queues to unassigned", async () => { + mocks.isSupported = true; + + const details: LicensedEndpointDetails = { + endpoints: [ + { + client_id: "endpoint-1", + name: "Sales", + classification: EndpointClassification.Full, + endpoint_size: "large", + queues: [{ name_hash: "sales-hash", scope_hash: "", original_location: "endpoint-1" }], + }, + ], + infrastructure_queues: [], + excluded_queues: [], + unassigned_queues: [], + service_end_date: "2026-12-31T00:00:00Z", + valid_id: true, + products: [ + { product_code: "large", monthly_throughput: 1_000 }, + { product_code: "small", monthly_throughput: 100 }, + ], + }; + + mocks.fetchTypedFromServiceControl.mockResolvedValue([new Response(), details]); + mocks.queues.mockResolvedValue([createQueueDetails("Sales", "sales-hash"), createQueueDetails("Shipping", "shipping-hash")]); + + const store = useLicenseDetailsStore(); + + await vi.waitFor(() => expect(store.hasLicenseDetails).toBe(true)); + + expect(store.endpointSizes.map((size) => size.name)).toEqual(["small", "large"]); + expect(store.endpointSizes[1].throughputMin).toBe(101); + expect(store.endpoints[0].queues[0].details.name).toBe("Sales"); + expect(store.unassignedQueues.map((queue) => queue.details.name)).toEqual(["Shipping"]); + expect(store.serviceEndDate).toEqual(new Date("2026-12-31T00:00:00Z")); + expect(store.validId).toBe(true); + expect(store.hasChanges).toBe(false); + }); + + test("saves amended endpoints and newly assigned queues", async () => { + const { + store, + queues: [endpointQueue, infrastructureQueue, excludedQueue], + } = await createQueue( + { + name: "Sales", + nameHash: "sales-hash", + originalLocation: "previous-endpoint", + }, + { + name: "Audit", + nameHash: "audit-hash", + originalLocation: "previous-location", + }, + { + name: "Error", + nameHash: "error-hash", + originalLocation: "previous-location", + } + ); + + store.createEndpointFromQueue(endpointQueue); + const endpoint = store.endpoints[0]; + store.renameEndpoint(endpoint, "Sales Endpoint"); + store.markQueueAsInfrastructure(infrastructureQueue); + store.excludeQueue(excludedQueue); + + await store.saveChanges(); + + expect(mocks.postToServiceControl).toHaveBeenCalledWith("license/amendDetails", { + endpoints: [ + { + client_id: endpoint.clientId, + name: "Sales Endpoint", + classification: EndpointClassification.Full, + amended_queues: [ + { + name_hash: "sales-hash", + scope_hash: "", + original_location: "previous-endpoint", + }, + ], + }, + ], + removed_endpoint_ids: [], + infrastructure_queues: [ + { + name_hash: "audit-hash", + scope_hash: "", + original_location: "previous-location", + }, + ], + excluded_queues: [ + { + name_hash: "error-hash", + scope_hash: "", + original_location: "previous-location", + }, + ], + unassigned_queues: [], + unmapped_queues: [], + }); + expect(mocks.showToast).toHaveBeenCalledWith(TYPE.SUCCESS, "Saved", "", false, { timeout: 1000 }); + expect(store.saving).toBe(false); + }); + + test("reports upload failures with the response details", async () => { + mocks.postFileToServiceControl.mockResolvedValue( + new Response("Invalid metadata", { + status: 400, + statusText: "Bad Request", + }) + ); + + const store = useLicenseDetailsStore(); + const file = new File(["{}"], "license.json", { type: "application/json" }); + + await expect(store.uploadEndpointMetadata(file)).rejects.toThrow("Upload Failed: 400 Bad Request - Invalid metadata"); + }); +}); + +interface QueueDefinition { + name: string; + nameHash: string; + scopeHash?: string; + originalLocation?: string; +} + +async function createQueue(...definitions: QueueDefinition[]) { + mocks.isSupported = true; + + const details: LicensedEndpointDetails = { + endpoints: [], + infrastructure_queues: [], + excluded_queues: [], + unassigned_queues: definitions.map((definition) => ({ + name_hash: definition.nameHash, + scope_hash: definition.scopeHash ?? "", + original_location: definition.originalLocation ?? "unassigned", + })), + service_end_date: "2026-12-31T00:00:00Z", + valid_id: true, + products: [], + }; + + mocks.fetchTypedFromServiceControl.mockResolvedValue([new Response(), details]); + mocks.queues.mockResolvedValue(definitions.map((definition) => createQueueDetails(definition.name, definition.nameHash, definition.scopeHash))); + + const store = useLicenseDetailsStore(); + + await vi.waitFor(() => { + expect(mocks.fetchTypedFromServiceControl).toHaveBeenCalledWith("license/details"); + expect(store.unassignedQueues).toHaveLength(definitions.length); + }); + + const queues = definitions.map((definition) => { + const queue = store.unassignedQueues.find((candidate) => candidate.nameHash === definition.nameHash && candidate.scopeHash === (definition.scopeHash ?? "")); + + expect(queue).toBeDefined(); + return queue!; + }); + + return { store, queues }; +} + +function createQueueDetails(name: string, nameHash: string, scopeHash = ""): QueueThroughputSummary { + return { + name, + name_hash: nameHash, + scope_hash: scopeHash, + is_known_endpoint: true, + user_indicator: "", + max_daily_throughput: 0, + average_monthly_throughput: 0, + }; +} diff --git a/src/Frontend/src/stores/LicenseDetailsStore.ts b/src/Frontend/src/stores/LicenseDetailsStore.ts index b86592b74..561e5c055 100644 --- a/src/Frontend/src/stores/LicenseDetailsStore.ts +++ b/src/Frontend/src/stores/LicenseDetailsStore.ts @@ -1,10 +1,12 @@ import { acceptHMRUpdate, defineStore } from "pinia"; import logger from "@/logger"; -import { ref, watch } from "vue"; +import { computed, ref, watch } from "vue"; import createThroughputClient from "@/views/throughputreport/throughputClient"; -import { Endpoint, EndpointSize, type Queue, type LicensedEndpointDetails, type ApiQueue } from "@/resources/LicenseDetails"; +import { Endpoint, EndpointSize, type Queue, type LicensedEndpointDetails, type ApiQueue, EndpointClassification } from "@/resources/LicenseDetails"; import serviceControlClient from "@/components/serviceControlClient"; import useIsLicenseDetailsSupported from "@/views/throughputreport/licenseDetails/isLicenseDetailsSupported"; +import { useShowToast } from "@/composables/toast"; +import { TYPE } from "vue-toastification"; export const useLicenseDetailsStore = defineStore("LicenseDetailsStore", () => { const isLicenseDetailsSupported = useIsLicenseDetailsSupported(); @@ -13,9 +15,15 @@ export const useLicenseDetailsStore = defineStore("LicenseDetailsStore", () => { const endpoints = ref([]); const infrastructureQueues = ref([]); const excludedQueues = ref([]); + const unassignedQueues = ref([]); + /** stores queues that have no mapped details (so we don't want them in unassigned), but were previously assigned */ + const unmappedQueues = ref([]); const serviceEndDate = ref(); const validId = ref(true); const hasLicenseDetails = ref(false); + const hasChanges = ref(false); + const removedEndpoints = ref([]); + const saving = ref(false); const error = ref(); function reset() { @@ -23,11 +31,92 @@ export const useLicenseDetailsStore = defineStore("LicenseDetailsStore", () => { endpoints.value = []; infrastructureQueues.value = []; excludedQueues.value = []; + unassignedQueues.value = []; + unmappedQueues.value = []; hasLicenseDetails.value = false; + hasChanges.value = false; + removedEndpoints.value = []; + serviceEndDate.value = undefined; + validId.value = true; + error.value = null; + } + + function excludeQueue(queue: Queue) { + excludedQueues.value.push(queue); + unassignedQueues.value = unassignedQueues.value.filter((unassigned) => unassigned !== queue); + hasChanges.value = true; + } + + function unexcludeQueue(queue: Queue) { + excludedQueues.value = excludedQueues.value.filter((assigned) => assigned !== queue); + returnToUnassigned(queue); + hasChanges.value = true; + } + + function markQueueAsInfrastructure(queue: Queue) { + infrastructureQueues.value.push(queue); + unassignedQueues.value = unassignedQueues.value.filter((unassigned) => unassigned !== queue); + hasChanges.value = true; + } + + function unmarkQueueAsInfrastructure(queue: Queue) { + infrastructureQueues.value = infrastructureQueues.value.filter((assigned) => assigned !== queue); + returnToUnassigned(queue); + hasChanges.value = true; + } + + function returnToUnassigned(queue: Queue) { + if (queue.details) unassignedQueues.value.push(queue); + else unmappedQueues.value.push(queue); + hasChanges.value = true; + } + + function createEndpointFromQueue(queue: Queue) { + //max endpoint name length of 80 - take from the end to reduce chance of clashes + let name = queue.details.name.substring(queue.details.name.length - 80); + while (endpoints.value.find((endpoint) => endpoint.name === name)) { + //NOTE that this could potentially lead to an infinite loop, if it finds more than 40 clashes + // but the likelihood of this happening is infinitesimal so we're not handling it + name = `${name}_1`.substring(name.length + 2 - 80); + } + const endpoint = new Endpoint(crypto.randomUUID(), name, [], EndpointClassification.Full, null, endpointSizes.value); + addQueueToEndpoint(queue, endpoint); + endpoints.value.push(endpoint); + } + + function addQueueToEndpoint(queue: Queue, endpoint: Endpoint) { + endpoint.queues.push(queue); + unassignedQueues.value = unassignedQueues.value.filter((unassigned) => unassigned !== queue); + hasChanges.value = true; + } + + function removeQueueFromEndpoint(queue: Queue, endpoint: Endpoint) { + endpoint.queues = endpoint.queues.filter((assigned) => assigned !== queue); + //TODO + //endpoint.excludeThroughput = endpoint.excludeThroughput.filter((et) => !queue.matches(et)); + returnToUnassigned(queue); + } + + function removeEndpoint(endpoint: Endpoint) { + endpoints.value = endpoints.value.filter((e) => e !== endpoint); + endpoint.queues.forEach((queue) => returnToUnassigned(queue)); + removedEndpoints.value.push(endpoint.clientId); + } + + function renameEndpoint(endpoint: Endpoint, name: string) { + endpoint.name = name; + endpoint.amended = true; + hasChanges.value = true; + } + + function reclassifyEndpoint(endpoint: Endpoint, classification: EndpointClassification) { + endpoint.classification = classification; + endpoint.amended = true; + hasChanges.value = true; } async function refresh() { - if (!isLicenseDetailsSupported) { + if (!isLicenseDetailsSupported.value) { reset(); return; } @@ -47,21 +136,42 @@ export const useLicenseDetailsStore = defineStore("LicenseDetailsStore", () => { ({ nameHash: metaQ.name_hash, scopeHash: metaQ.scope_hash, + originalLocation: metaQ.original_location, details: scQueues.find((scQ) => scQ.name_hash === metaQ.name_hash && ((!metaQ.scope_hash && !scQ.scope_hash) || metaQ.scope_hash === scQ.scope_hash)), }) as Queue; const sortedProducts = data.products.toSorted((p1, p2) => (p1.monthly_throughput ?? Number.MAX_VALUE) - (p2.monthly_throughput ?? Number.MAX_VALUE)); const sortedEndpointSizes = sortedProducts.map((product, i) => new EndpointSize(product.product_code, i > 0 ? (sortedProducts[i - 1].monthly_throughput ?? 0) + 1 : 0, product.monthly_throughput)); endpointSizes.value = sortedEndpointSizes; - endpoints.value = data.endpoints.map((metaEp) => new Endpoint(crypto.randomUUID(), metaEp.name, metaEp.queues.map(toQueue), metaEp.classification, sortedEndpointSizes.find((es) => es.name === metaEp.endpoint_size)!, sortedEndpointSizes)); + endpoints.value = data.endpoints.map((metaEp) => new Endpoint(metaEp.client_id, metaEp.name, metaEp.queues.map(toQueue), metaEp.classification, sortedEndpointSizes.find((es) => es.name === metaEp.endpoint_size)!, sortedEndpointSizes)); infrastructureQueues.value = data.infrastructure_queues.map(toQueue); excludedQueues.value = data.excluded_queues.map(toQueue); + /* only used for save, not load */ + unmappedQueues.value = []; + removedEndpoints.value = []; + const initialUnassignedQueues = data.unassigned_queues.map(toQueue); + const assignedQueues = [...endpoints.value.flatMap((endpoint) => endpoint.queues), ...infrastructureQueues.value, ...excludedQueues.value, ...initialUnassignedQueues]; + unassignedQueues.value = [ + ...initialUnassignedQueues, + ...scQueues + .filter((scQ) => !assignedQueues.find((queue) => scQ.name_hash === queue.nameHash && ((!queue.scopeHash && !scQ.scope_hash) || queue.scopeHash === scQ.scope_hash))) + .map( + (scQ) => + ({ + nameHash: scQ.name_hash, + scopeHash: scQ.scope_hash ?? "", + details: scQ, + }) as Queue + ), + ]; serviceEndDate.value = new Date(data.service_end_date); validId.value = data.valid_id; + hasChanges.value = false; // eslint-disable-next-line @typescript-eslint/no-explicit-any } catch (err: any) { error.value = `Error fetching endpoint metadata: ${err.message ?? err}`; logger.error("Error fetching endpoint metadata", err); + throw err; } } @@ -74,18 +184,89 @@ export const useLicenseDetailsStore = defineStore("LicenseDetailsStore", () => { await refresh(); } + async function discardChanges() { + saving.value = true; + try { + await refresh(); + } finally { + saving.value = false; + } + } + + async function saveChanges() { + saving.value = true; + function toApiQueue(queue: Queue) { + return { name_hash: queue.nameHash, scope_hash: queue.scopeHash, original_location: queue.originalLocation }; + } + try { + const response = await serviceControlClient.postToServiceControl("license/amendDetails", { + endpoints: endpoints.value + .filter((endpoint) => endpoint.amended || endpoint.queues.some((queue) => queue.originalLocation !== endpoint.clientId)) + .map((endpoint) => ({ + client_id: endpoint.clientId, + name: endpoint.name, + classification: endpoint.classification, + amended_queues: endpoint.queues.filter((queue) => queue.originalLocation !== endpoint.clientId).map(toApiQueue), + })), + removed_endpoint_ids: removedEndpoints.value, + infrastructure_queues: infrastructureQueues.value.filter((queue) => queue.originalLocation !== "infrastructure").map(toApiQueue), + excluded_queues: excludedQueues.value.filter((queue) => queue.originalLocation !== "excluded").map(toApiQueue), + unassigned_queues: unassignedQueues.value.filter((queue) => queue.originalLocation != null).map(toApiQueue), + unmapped_queues: unmappedQueues.value.map(toApiQueue), + }); + if (!response.ok) { + const text = await response.text(); + throw new Error(`Save Failed: ${response.status} ${response.statusText} - ${text}`); + } + await refresh(); + useShowToast(TYPE.SUCCESS, "Saved", "", false, { timeout: 1000 }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } catch (err: any) { + useShowToast(TYPE.ERROR, "Error", "Failed to save changes to endpoint metadata."); + logger.error("Failed to save changes to endpoint metadata.", err); + } finally { + saving.value = false; + } + } + + const compare = (q1: Queue, q2: Queue) => { + const name1 = q1.details?.name; + const name2 = q2.details?.name; + + if (name1 == null && name2 == null) return 0; + if (name1 == null) return 1; + if (name2 == null) return -1; + + return name1.toLocaleLowerCase().localeCompare(name2.toLocaleLowerCase()); + }; + watch(isLicenseDetailsSupported, refresh, { immediate: true }); return { endpoints, endpointSizes, - infrastructureQueues, - excludedQueues, + infrastructureQueues: computed(() => infrastructureQueues.value.toSorted(compare)), + excludedQueues: computed(() => excludedQueues.value.toSorted(compare)), + unassignedQueues: computed(() => unassignedQueues.value.toSorted(compare)), serviceEndDate, validId, hasLicenseDetails, + hasChanges, + saving, error, uploadEndpointMetadata, + excludeQueue, + unexcludeQueue, + markQueueAsInfrastructure, + unmarkQueueAsInfrastructure, + createEndpointFromQueue, + addQueueToEndpoint, + removeQueueFromEndpoint, + removeEndpoint, + renameEndpoint, + reclassifyEndpoint, + discardChanges, + saveChanges, }; }); diff --git a/src/Frontend/src/views/throughputreport/licenseDetails/CollapsedEndpoint.vue b/src/Frontend/src/views/throughputreport/licenseDetails/CollapsedEndpoint.vue index 0b17c38a6..854556ebc 100644 --- a/src/Frontend/src/views/throughputreport/licenseDetails/CollapsedEndpoint.vue +++ b/src/Frontend/src/views/throughputreport/licenseDetails/CollapsedEndpoint.vue @@ -23,15 +23,14 @@ const endpointClassificationStyle = computed(() => {
- {{ endpoint.name }} + {{ endpoint.name }}
Queues: {{ endpoint.queues.length }} Throughput: {{ endpoint.totalAverageMonthlyThroughput.toLocaleString() }} - + Current Size: {{ endpoint.currentSize.name }} + + Licensed Size: {{ endpoint.licensedSize?.name ?? (endpoint.classification === EndpointClassification.Full ? "Not Licensed" : "Send Only") }}
- - + Expand All + Collapse All