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 }>();
{{ label }}
-
+
@@ -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(() => {