From 340a98626baa366b66a085382275ef386deec00d Mon Sep 17 00:00:00 2001 From: John Simons Date: Tue, 18 Nov 2025 22:47:12 +1000 Subject: [PATCH 01/26] Replace moment with dayjs This should save ~286KB gzipped --- .../failedmessages/DeletedMessages.vue | 99 ++++++++++++++++++- .../src/composables/formatter.spec.ts | 24 +++-- src/Frontend/src/stores/MessageStore.ts | 3 +- 3 files changed, 116 insertions(+), 10 deletions(-) diff --git a/src/Frontend/src/components/failedmessages/DeletedMessages.vue b/src/Frontend/src/components/failedmessages/DeletedMessages.vue index 4eb0de183..37f0dce2a 100644 --- a/src/Frontend/src/components/failedmessages/DeletedMessages.vue +++ b/src/Frontend/src/components/failedmessages/DeletedMessages.vue @@ -7,7 +7,8 @@ import ServiceControlAvailable from "../ServiceControlAvailable.vue"; import MessageList, { IMessageList } from "./MessageList.vue"; import ConfirmDialog from "../ConfirmDialog.vue"; import PaginationStrip from "../../components/PaginationStrip.vue"; -import { FailedMessageStatus } from "@/resources/FailedMessage"; +import dayjs from "@/utils/dayjs"; +import { ExtendedFailedMessage } from "@/resources/FailedMessage"; import { TYPE } from "vue-toastification"; import FAIcon from "@/components/FAIcon.vue"; import { faArrowRotateRight } from "@fortawesome/free-solid-svg-icons"; @@ -26,6 +27,102 @@ const { messages, groupId, groupName, totalCount, pageNumber, selectedPeriod } = const showConfirmRestore = ref(false); const messageList = ref(); +const messages = ref([]); + +watch(pageNumber, () => loadMessages()); + +const configurationStore = useConfigurationStore(); +const { configuration } = storeToRefs(configurationStore); +const serviceControlStore = useServiceControlStore(); + +function loadMessages() { + let startDate = new Date(0); + const endDate = new Date(); + + switch (selectedPeriod.value) { + case "All Deleted": + startDate = new Date(); + startDate.setHours(startDate.getHours() - 24 * 365); + break; + case "Deleted in the last 2 Hours": + startDate = new Date(); + startDate.setHours(startDate.getHours() - 2); + break; + case "Deleted in the last 1 Day": + startDate = new Date(); + startDate.setHours(startDate.getHours() - 24); + break; + case "Deleted in the last 7 days": + startDate = new Date(); + startDate.setHours(startDate.getHours() - 24 * 7); + break; + } + return loadPagedMessages(groupId.value, pageNumber.value, "", "", startDate.toISOString(), endDate.toISOString()); +} + +async function loadGroupDetails(groupId: string) { + const [, data] = await serviceControlStore.fetchTypedFromServiceControl(`archive/groups/id/${groupId}`); + groupName.value = data.title; +} + +function loadPagedMessages(groupId?: string, page: number = 1, sortBy: string = "modified", direction: string = "desc", startDate: string = new Date(0).toISOString(), endDate: string = new Date().toISOString()) { + const dateRange = startDate + "..." + endDate; + let loadGroupDetailsPromise; + if (groupId && !groupName.value) { + loadGroupDetailsPromise = loadGroupDetails(groupId); + } + + async function loadDelMessages() { + try { + const [response, data] = await serviceControlStore.fetchTypedFromServiceControl( + `${groupId ? `recoverability/groups/${groupId}/` : ""}errors?status=archived&page=${page}&per_page=${perPage}&sort=${sortBy}&direction=${direction}&modified=${dateRange}` + ); + + totalCount.value = parseInt(response.headers.get("Total-Count") ?? "0"); + + if (messages.value.length && data.length) { + // merge the previously selected messages into the new list so we can replace them + messages.value.forEach((previousMessage) => { + const receivedMessage = data.find((m) => m.id === previousMessage.id); + if (receivedMessage) { + if (previousMessage.last_modified === receivedMessage.last_modified) { + receivedMessage.retryInProgress = previousMessage.retryInProgress; + receivedMessage.deleteInProgress = previousMessage.deleteInProgress; + } + + receivedMessage.selected = previousMessage.selected; + } + }); + } + messages.value = updateMessagesScheduledDeletionDate(data); + } catch (err) { + console.log(err); + const result = { + message: "error", + }; + return result; + } + } + + const loadDelMessagesPromise = loadDelMessages(); + + if (loadGroupDetailsPromise) { + return Promise.all([loadGroupDetailsPromise, loadDelMessagesPromise]); + } + + return loadDelMessagesPromise; +} + +function updateMessagesScheduledDeletionDate(messages: ExtendedFailedMessage[]) { + //check deletion time + messages.forEach((message) => { + message.error_retention_period = dayjs.duration(configuration.value?.data_retention.error_retention_period ?? "PT0S").asHours(); + const countdown = dayjs(message.last_modified).add(message.error_retention_period, "hours"); + message.delete_soon = countdown < dayjs(); + message.deleted_in = countdown.format(); + }); + return messages; +} function numberSelected() { return messageList.value?.getSelectedMessages()?.length ?? 0; diff --git a/src/Frontend/src/composables/formatter.spec.ts b/src/Frontend/src/composables/formatter.spec.ts index 02999e1ba..f48281b79 100644 --- a/src/Frontend/src/composables/formatter.spec.ts +++ b/src/Frontend/src/composables/formatter.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "vitest"; -import { useFormatTime, useGetDayDiffFromToday, useFormatLargeNumber, createDateWithDayOffset } from "./formatter"; +import { useFormatTime, useGetDayDiffFromToday, useFormatLargeNumber } from "./formatter"; describe("useFormatTime", () => { describe("milliseconds formatting", () => { @@ -100,37 +100,47 @@ describe("useFormatTime", () => { describe("useGetDayDiffFromToday", () => { test("returns 0 for today's date", () => { - const today = createDateWithDayOffset(); + const today = new Date(); + today.setHours(12, 0, 0, 0); const result = useGetDayDiffFromToday(today.toISOString()); expect(result).toBe(0); }); test("returns positive number for future dates", () => { - const tomorrow = createDateWithDayOffset(1); + const tomorrow = new Date(); + tomorrow.setDate(tomorrow.getDate() + 1); + tomorrow.setHours(12, 0, 0, 0); const result = useGetDayDiffFromToday(tomorrow.toISOString()); expect(result).toBe(1); }); test("returns negative number for past dates", () => { - const yesterday = createDateWithDayOffset(-1); + const yesterday = new Date(); + yesterday.setDate(yesterday.getDate() - 1); + yesterday.setHours(12, 0, 0, 0); const result = useGetDayDiffFromToday(yesterday.toISOString()); expect(result).toBe(-1); }); test("returns 7 for date 7 days in the future", () => { - const futureDate = createDateWithDayOffset(7); + const futureDate = new Date(); + futureDate.setDate(futureDate.getDate() + 7); + futureDate.setHours(12, 0, 0, 0); const result = useGetDayDiffFromToday(futureDate.toISOString()); expect(result).toBe(7); }); test("returns -30 for date 30 days in the past", () => { - const pastDate = createDateWithDayOffset(-30); + const pastDate = new Date(); + pastDate.setDate(pastDate.getDate() - 30); + pastDate.setHours(12, 0, 0, 0); const result = useGetDayDiffFromToday(pastDate.toISOString()); expect(result).toBe(-30); }); test("handles dates without Z suffix", () => { - const date = createDateWithDayOffset(); + const date = new Date(); + date.setHours(12, 0, 0, 0); const isoString = date.toISOString().replace("Z", ""); const result = useGetDayDiffFromToday(isoString); expect(result).toBe(0); diff --git a/src/Frontend/src/stores/MessageStore.ts b/src/Frontend/src/stores/MessageStore.ts index 447b9d3e4..1ccaf000b 100644 --- a/src/Frontend/src/stores/MessageStore.ts +++ b/src/Frontend/src/stores/MessageStore.ts @@ -14,7 +14,6 @@ import { EditAndRetryConfig } from "@/resources/Configuration"; import EditRetryResponse from "@/resources/EditRetryResponse"; import { EditedMessage } from "@/resources/EditMessage"; import useEnvironmentAndVersionsAutoRefresh from "@/composables/useEnvironmentAndVersionsAutoRefresh"; -import { timeSpanToDuration } from "@/composables/formatter"; interface Model { id?: string; @@ -78,7 +77,7 @@ export const useMessageStore = defineStore("MessageStore", () => { const areSimpleHeadersSupported = environmentStore.serviceControlIsGreaterThan("5.2.0"); const { configuration } = storeToRefs(configStore); - const error_retention_period = computed(() => timeSpanToDuration(configuration.value?.data_retention?.error_retention_period).asHours()); + const error_retention_period = computed(() => dayjs.duration(configuration.value?.data_retention?.error_retention_period ?? "PT0S").asHours()); async function loadEditAndRetryConfiguration() { try { From 8a9a3b230e7f6e86bc705516bcfa23cf6d8768f0 Mon Sep 17 00:00:00 2001 From: Warwick Schroeder Date: Thu, 20 Nov 2025 12:43:18 +0800 Subject: [PATCH 02/26] Initial checkin for the Platform Capabilities section on the dashboard --- .../platformcapabilities/CapabilityCard.vue | 349 ++++++++++++++++++ .../PlatformCapabilitiesDashboardItem.vue | 63 ++++ .../capabilities/AuditingCapability.ts | 114 ++++++ .../capabilities/MonitoringCapability.ts | 118 ++++++ .../platformcapabilities/constants.ts | 31 ++ .../components/platformcapabilities/types.ts | 12 + src/Frontend/src/views/DashboardView.vue | 14 + 7 files changed, 701 insertions(+) create mode 100644 src/Frontend/src/components/platformcapabilities/CapabilityCard.vue create mode 100644 src/Frontend/src/components/platformcapabilities/PlatformCapabilitiesDashboardItem.vue create mode 100644 src/Frontend/src/components/platformcapabilities/capabilities/AuditingCapability.ts create mode 100644 src/Frontend/src/components/platformcapabilities/capabilities/MonitoringCapability.ts create mode 100644 src/Frontend/src/components/platformcapabilities/constants.ts create mode 100644 src/Frontend/src/components/platformcapabilities/types.ts diff --git a/src/Frontend/src/components/platformcapabilities/CapabilityCard.vue b/src/Frontend/src/components/platformcapabilities/CapabilityCard.vue new file mode 100644 index 000000000..eab39ff4a --- /dev/null +++ b/src/Frontend/src/components/platformcapabilities/CapabilityCard.vue @@ -0,0 +1,349 @@ + + + + + diff --git a/src/Frontend/src/components/platformcapabilities/PlatformCapabilitiesDashboardItem.vue b/src/Frontend/src/components/platformcapabilities/PlatformCapabilitiesDashboardItem.vue new file mode 100644 index 000000000..fb2bc6a91 --- /dev/null +++ b/src/Frontend/src/components/platformcapabilities/PlatformCapabilitiesDashboardItem.vue @@ -0,0 +1,63 @@ + + + + + diff --git a/src/Frontend/src/components/platformcapabilities/capabilities/AuditingCapability.ts b/src/Frontend/src/components/platformcapabilities/capabilities/AuditingCapability.ts new file mode 100644 index 000000000..89c0684fe --- /dev/null +++ b/src/Frontend/src/components/platformcapabilities/capabilities/AuditingCapability.ts @@ -0,0 +1,114 @@ +import { computed, type Ref, watchEffect } from "vue"; +import { CapabilityStatus, StatusIndicator } from "@/components/platformcapabilities/types"; +import { faCheck, faInfoCircle, faTimes, type IconDefinition } from "@fortawesome/free-solid-svg-icons"; +import type ConnectionTestResults from "@/resources/ConnectionTestResults"; +import useIsAllMessagesSupported from "@/components/audit/isAllMessagesSupported"; +import { storeToRefs } from "pinia"; +import { useAuditStore } from "@/stores/AuditStore"; +import { AuditingCardDescription, AuditingIndicatorTooltip } from "@/components/platformcapabilities/constants"; +import { MessageStatus } from "@/resources/Message"; + +export function useAuditingCapability(testResults: Ref) { + const auditStore = useAuditStore(); + // this gives us the messages array which includes all messages (successful and failed) + const { messages } = storeToRefs(auditStore); + const isAllMessagesSupported = useIsAllMessagesSupported(); + + // Count only successful audit messages + const successfulMessageCount = computed(() => { + return messages.value.filter((msg) => msg.status === MessageStatus.Successful || msg.status === MessageStatus.ResolvedSuccessfully).length; + }); + + // this tells us if the audit instance is configured and responding + const auditingConfiguredAndResponding = computed(() => { + return testResults.value?.audit_connection_result?.connection_successful ?? false; + }); + + watchEffect(() => { + // Trigger initial load of audit messages if audit is configured + if (auditingConfiguredAndResponding.value && messages.value.length === 0) { + // TODO: This is not auto refreshed. User will need to manually refresh the page to get updated data. Ideally this would auto refresh periodically. + auditStore.refresh(); + } + }); + + const auditStatus = computed(() => { + // If audit instance is not configured or not responding + if (!auditingConfiguredAndResponding.value) { + return CapabilityStatus.Unavailable; + } + + // If audit instance is available but 'All Messages' feature is not supported or there are no successful audit messages + if (!isAllMessagesSupported.value || successfulMessageCount.value === 0) { + return CapabilityStatus.NotConfigured; + } + + // Audit instance is available and there are successful audit messages + return CapabilityStatus.Available; + }); + + const auditIcon = computed(() => { + if (auditStatus.value === CapabilityStatus.NotConfigured) { + return faInfoCircle; + } + + if (auditStatus.value === CapabilityStatus.Available) { + return faCheck; + } + + // Uavailable + return faTimes; + }); + + const auditDescription = computed(() => { + if (auditStatus.value === CapabilityStatus.NotConfigured) { + return AuditingCardDescription.NotConfigured; + } + + if (auditStatus.value === CapabilityStatus.Available) { + return AuditingCardDescription.Available; + } + + // Uavailable + return AuditingCardDescription.Unavailable; + }); + + const auditIndicators = computed(() => { + const indicators: StatusIndicator[] = []; + + indicators.push({ + label: "Instance", + status: auditingConfiguredAndResponding.value ? CapabilityStatus.Available : CapabilityStatus.Unavailable, + tooltip: auditingConfiguredAndResponding.value ? AuditingIndicatorTooltip.InstanceAvailable : AuditingIndicatorTooltip.InstanceUnavailable, + }); + + // Messages available indicator + if (auditingConfiguredAndResponding.value) { + const messagesAvailable = isAllMessagesSupported.value && successfulMessageCount.value > 0; + + let messageTooltip = ""; + if (messagesAvailable) { + messageTooltip = AuditingIndicatorTooltip.MessagesAvailable; + } else if (!isAllMessagesSupported.value) { + messageTooltip = AuditingIndicatorTooltip.AllMessagesNotSupported; + } else { + messageTooltip = AuditingIndicatorTooltip.MessagesUnavailable; + } + + indicators.push({ + label: "Messages", + status: messagesAvailable ? CapabilityStatus.Available : CapabilityStatus.PartiallyAvailable, + tooltip: messageTooltip, + }); + } + + return indicators; + }); + + return { + status: auditStatus, + icon: auditIcon, + description: auditDescription, + indicators: auditIndicators, + }; +} diff --git a/src/Frontend/src/components/platformcapabilities/capabilities/MonitoringCapability.ts b/src/Frontend/src/components/platformcapabilities/capabilities/MonitoringCapability.ts new file mode 100644 index 000000000..928b97b07 --- /dev/null +++ b/src/Frontend/src/components/platformcapabilities/capabilities/MonitoringCapability.ts @@ -0,0 +1,118 @@ +import { computed } from "vue"; +import { CapabilityStatus, StatusIndicator } from "@/components/platformcapabilities/types"; +import { faCheck, faTimes, faExclamationTriangle, faInfoCircle, type IconDefinition } from "@fortawesome/free-solid-svg-icons"; +import { storeToRefs } from "pinia"; +import { useServiceControlStore } from "@/stores/ServiceControlStore"; +import { useMonitoringStore } from "@/stores/MonitoringStore"; +import { useConnectionsAndStatsStore } from "@/stores/ConnectionsAndStatsStore"; +import { MonitoringCardDescription, MonitoringIndicatorTooltip } from "@/components/platformcapabilities/constants"; + +export function useMonitoringCapability() { + const serviceControlStore = useServiceControlStore(); + // this tells us if monitoring is configured in ServiceControl + const { isMonitoringEnabled } = storeToRefs(serviceControlStore); + const monitoringStore = useMonitoringStore(); + // this tells us if there are any endpoints sending data + const { endpointListIsEmpty } = storeToRefs(monitoringStore); + const connectionsStore = useConnectionsAndStatsStore(); + // this tells us the connection state to the monitoring instance + const monitoringConnectionState = connectionsStore.monitoringConnectionState; + + // Trigger initial load of monitoring endpoints if monitoring is enabled + if (isMonitoringEnabled.value && endpointListIsEmpty.value) { + // TODO: This is not auto refreshed. User will need to manually refresh the page to get updated data. Ideally this would auto refresh periodically. + monitoringStore.updateEndpointList(); + } + + const monitoringStatus = computed(() => { + const isConfiguredInServiceControl = isMonitoringEnabled.value; + const connectionSuccessful = monitoringConnectionState.connected && !monitoringConnectionState.unableToConnect; + + // Promo mode - not configured + if (!isConfiguredInServiceControl) { + return CapabilityStatus.NotConfigured; + } + + // Disabled - configured but not responding + if (isConfiguredInServiceControl && !connectionSuccessful) { + return CapabilityStatus.Unavailable; + } + + // There are endpoints sending data. Fully enabled and working + if (!endpointListIsEmpty.value) { + return CapabilityStatus.Available; + } + + // Monitoring is configured and connected but no endpoints are sending data + return CapabilityStatus.PartiallyAvailable; + }); + + const monitoringIcon = computed(() => { + if (monitoringStatus.value === CapabilityStatus.NotConfigured) { + return faInfoCircle; + } + + if (monitoringStatus.value === CapabilityStatus.Available) { + return faCheck; + } + + if (monitoringStatus.value === CapabilityStatus.PartiallyAvailable) { + return faExclamationTriangle; + } + + // Unavailable + return faTimes; + }); + + const monitoringDescription = computed(() => { + if (monitoringStatus.value === CapabilityStatus.NotConfigured) { + return MonitoringCardDescription.NotConfigured; + } + + if (monitoringStatus.value === CapabilityStatus.Available) { + return MonitoringCardDescription.Available; + } + + if (monitoringStatus.value === CapabilityStatus.PartiallyAvailable) { + return MonitoringCardDescription.PartiallyAvailable; + } + + // Uavailable + return MonitoringCardDescription.Unavailable; + }); + + const monitoringIndicators = computed(() => { + const indicators: StatusIndicator[] = []; + + // Instance specific states + const connectionSuccessful = monitoringConnectionState.connected && !monitoringConnectionState.unableToConnect; + const instanceAvailable = isMonitoringEnabled.value && connectionSuccessful; + + // no indicators shown in promo mode + if (monitoringStatus.value !== CapabilityStatus.NotConfigured) { + indicators.push({ + label: "Instance", + status: instanceAvailable ? CapabilityStatus.Available : CapabilityStatus.Unavailable, + tooltip: instanceAvailable ? MonitoringIndicatorTooltip.InstanceAvailable : !isMonitoringEnabled.value ? MonitoringIndicatorTooltip.InstanceNotConfigured : MonitoringIndicatorTooltip.InstanceUnavailable, + }); + } + + // data available indicator - only show if instance is connected + if (instanceAvailable) { + indicators.push({ + label: "Data", + status: !endpointListIsEmpty.value ? CapabilityStatus.Available : CapabilityStatus.PartiallyAvailable, + tooltip: !endpointListIsEmpty.value ? MonitoringIndicatorTooltip.DataAvailable : MonitoringIndicatorTooltip.DataUnavailable, + }); + } + + return indicators; + }); + + return { + status: monitoringStatus, + icon: monitoringIcon, + description: monitoringDescription, + indicators: monitoringIndicators, + }; +} diff --git a/src/Frontend/src/components/platformcapabilities/constants.ts b/src/Frontend/src/components/platformcapabilities/constants.ts new file mode 100644 index 000000000..60e4d1144 --- /dev/null +++ b/src/Frontend/src/components/platformcapabilities/constants.ts @@ -0,0 +1,31 @@ +import { minimumSCVersionForAllMessages } from "@/components/audit/isAllMessagesSupported"; + +export enum MonitoringCardDescription { + NotConfigured = "Enable real-time endpoint performance monitoring to track throughput, processing times, and system health across your entire distributed system.", + Unavailable = "Monitoring instance is not responding", + PartiallyAvailable = "Monitoring instance is connected but no endpoints are sending throughput data. This may be because no endpoints are running or no endpoints have the monitoring plugin enabled.", + Available = "Monitoring is available and receiving throughput data from endpoints", +} + +export enum MonitoringIndicatorTooltip { + InstanceAvailable = "Monitoring instance is configured and available", + InstanceUnavailable = "Monitoring instance is configured but not responding", + InstanceNotConfigured = "Monitoring is not configured in ServiceControl", + DataAvailable = "Endpoints are sending throughput data", + DataUnavailable = "No endpoints are sending throughput data. Endpoints may not be running or may not have the monitoring plugin enabled.", +} + +export enum AuditingCardDescription { + NotConfigured = "Auditing instance is connected but no successful messages have been processed yet or you don't have auditing enabled for any endpoints. Enable auditing to track message flow and processing across your distributed system.", + Unavailable = "Auditing instance is not responding", + NotSupported = `Auditing instance is connected but the "All Messages" feature requires ServiceControl ${minimumSCVersionForAllMessages} or higher.`, + Available = "Auditing is available and processing successful messages", +} + +export enum AuditingIndicatorTooltip { + InstanceAvailable = "Auditing instance is configured and available", + InstanceUnavailable = "Auditing instance is not responding", + MessagesAvailable = "Successful messages are being processed", + MessagesUnavailable = "No successful messages have been processed yet or auditing is not enabled for any endpoints", + AllMessagesNotSupported = `The 'All Messages' feature requires ServiceControl ${minimumSCVersionForAllMessages} or higher`, +} diff --git a/src/Frontend/src/components/platformcapabilities/types.ts b/src/Frontend/src/components/platformcapabilities/types.ts new file mode 100644 index 000000000..fa929180c --- /dev/null +++ b/src/Frontend/src/components/platformcapabilities/types.ts @@ -0,0 +1,12 @@ +export interface StatusIndicator { + label: string; + status: CapabilityStatus; + tooltip: string; +} + +export enum CapabilityStatus { + Unavailable = "Unavailable", // Instance is configured but not responding or not available + Available = "Available", // Instance is available and responding + PartiallyAvailable = "Data Unavailable", // Instance is available but not data is flowing for reasons + NotConfigured = "Not Configured", // Instance is not configured. Promo should be shown +} diff --git a/src/Frontend/src/views/DashboardView.vue b/src/Frontend/src/views/DashboardView.vue index 935e89fba..f49c17cd8 100644 --- a/src/Frontend/src/views/DashboardView.vue +++ b/src/Frontend/src/views/DashboardView.vue @@ -5,6 +5,7 @@ import ServiceControlAvailable from "@/components/ServiceControlAvailable.vue"; import CustomChecksDashboardItem from "@/components/customchecks/CustomChecksDashboardItem.vue"; import HeartbeatsDashboardItem from "@/components/heartbeats/HeartbeatsDashboardItem.vue"; import FailedMessagesDashboardItem from "@/components/failedmessages/FailedMessagesDashboardItem.vue"; +import PlatformCapabilitiesDashboardItem from "@/components/platformcapabilities/PlatformCapabilitiesDashboardItem.vue"; diff --git a/src/Frontend/src/components/platformcapabilities/capabilities/AuditingCapability.ts b/src/Frontend/src/components/platformcapabilities/capabilities/AuditingCapability.ts index 27b511d93..394b17d63 100644 --- a/src/Frontend/src/components/platformcapabilities/capabilities/AuditingCapability.ts +++ b/src/Frontend/src/components/platformcapabilities/capabilities/AuditingCapability.ts @@ -1,5 +1,6 @@ import { computed } from "vue"; -import { CapabilityStatus, StatusIndicator } from "@/components/platformcapabilities/types"; +import { StatusIndicator } from "@/components/platformcapabilities/types"; +import { CapabilityStatus } from "@/components/platformcapabilities/constants"; import useIsAllMessagesSupported, { minimumSCVersionForAllMessages } from "@/components/audit/isAllMessagesSupported"; import { storeToRefs } from "pinia"; import { type CapabilityComposable, type CapabilityStatusToStringMap, useCapabilityBase } from "./BaseCapability"; diff --git a/src/Frontend/src/components/platformcapabilities/capabilities/BaseCapability.ts b/src/Frontend/src/components/platformcapabilities/capabilities/BaseCapability.ts index e202532a7..912084d60 100644 --- a/src/Frontend/src/components/platformcapabilities/capabilities/BaseCapability.ts +++ b/src/Frontend/src/components/platformcapabilities/capabilities/BaseCapability.ts @@ -1,6 +1,7 @@ import { type ComputedRef } from "vue"; import { faCheck, faInfoCircle, faTimes, faExclamationTriangle, type IconDefinition } from "@fortawesome/free-solid-svg-icons"; -import { CapabilityStatus, type StatusIndicator } from "@/components/platformcapabilities/types"; +import { StatusIndicator } from "@/components/platformcapabilities/types"; +import { CapabilityStatus } from "@/components/platformcapabilities/constants"; export interface CapabilityComposable { status: ComputedRef; diff --git a/src/Frontend/src/components/platformcapabilities/capabilities/ErrorCapability.ts b/src/Frontend/src/components/platformcapabilities/capabilities/ErrorCapability.ts index ff46bd740..d8faf1c90 100644 --- a/src/Frontend/src/components/platformcapabilities/capabilities/ErrorCapability.ts +++ b/src/Frontend/src/components/platformcapabilities/capabilities/ErrorCapability.ts @@ -1,6 +1,7 @@ import { computed } from "vue"; import { storeToRefs } from "pinia"; -import { CapabilityStatus, StatusIndicator } from "@/components/platformcapabilities/types"; +import { StatusIndicator } from "@/components/platformcapabilities/types"; +import { CapabilityStatus } from "@/components/platformcapabilities/constants"; import { useConnectionsAndStatsStore } from "@/stores/ConnectionsAndStatsStore"; import { type CapabilityComposable, type CapabilityStatusToStringMap, useCapabilityBase } from "./BaseCapability"; diff --git a/src/Frontend/src/components/platformcapabilities/capabilities/MonitoringCapability.ts b/src/Frontend/src/components/platformcapabilities/capabilities/MonitoringCapability.ts index 72c97f3c9..4cd6ee1d8 100644 --- a/src/Frontend/src/components/platformcapabilities/capabilities/MonitoringCapability.ts +++ b/src/Frontend/src/components/platformcapabilities/capabilities/MonitoringCapability.ts @@ -1,5 +1,6 @@ import { computed } from "vue"; -import { CapabilityStatus, StatusIndicator } from "@/components/platformcapabilities/types"; +import { StatusIndicator } from "@/components/platformcapabilities/types"; +import { CapabilityStatus } from "@/components/platformcapabilities/constants"; import { storeToRefs } from "pinia"; import { useServiceControlStore } from "@/stores/ServiceControlStore"; import { useConnectionsAndStatsStore } from "@/stores/ConnectionsAndStatsStore"; diff --git a/src/Frontend/src/components/platformcapabilities/constants.ts b/src/Frontend/src/components/platformcapabilities/constants.ts new file mode 100644 index 000000000..c2fb1242a --- /dev/null +++ b/src/Frontend/src/components/platformcapabilities/constants.ts @@ -0,0 +1,13 @@ +export enum CapabilityStatus { + Unavailable = "Unavailable", // Instance is configured but not responding or not available + PartiallyUnavailable = "Degraded", // At least one but not all instances are unavailable + Available = "Available", // Instance is available and responding + EndpointsNotConfigured = "Endpoints Not Configured", // Instance is not configured. Promo should be shown + InstanceNotConfigured = "Instance Not Configured", // Instance is not configured at all +} + +export enum Capability { + Monitoring = "Monitoring", + Auditing = "Auditing", + Error = "Recoverability", +} diff --git a/src/Frontend/src/components/platformcapabilities/styles/capabilityCard.css b/src/Frontend/src/components/platformcapabilities/styles/capabilityCard.css new file mode 100644 index 000000000..bb4f19f7b --- /dev/null +++ b/src/Frontend/src/components/platformcapabilities/styles/capabilityCard.css @@ -0,0 +1,247 @@ +.capability-card { + background: var(--card-bg, #fff); + border: 1px solid var(--border-color, #e0e0e0); + border-radius: 8px; + padding: 20px; + margin-bottom: 16px; + transition: all 0.2s ease; + position: relative; + min-height: 150px; +} + +.capability-available { + border-left: 4px solid var(--success-color, #28a745); +} + +.capability-unavailable { + border-left: 4px solid var(--danger-color, #dc3545); +} + +.capability-partially-unavailable { + border-left: 4px solid var(--warning-color, #ffc107); +} + +.capability-loading { + border-left: 4px solid var(--border-color, #e0e0e0); +} + +.capability-notconfigured { + background: linear-gradient(135deg, #f6f9fc 0%, #e9f2f9 100%); + border: 1px solid #c3ddf5; + border-left: 4px solid #007bff; +} + +.text-info { + color: #007bff; +} + +.text-warning { + color: var(--warning-color, #ffc107); +} + +.loading-overlay { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(255, 255, 255, 0.9); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + border-radius: 8px; + z-index: 10; +} + +.loading-spinner { + width: 40px; + height: 40px; + border: 3px solid var(--border-color, #e0e0e0); + border-top-color: var(--primary-color, #007bff); + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +.loading-text { + margin-top: 12px; + color: var(--text-secondary, #666); + font-size: 14px; +} + +.capability-header { + display: flex; + align-items: flex-start; + gap: 16px; + margin-bottom: 12px; +} + +.capability-icon { + font-size: 24px; + flex-shrink: 0; + margin-top: 2px; +} + +.capability-info { + flex: 1; + min-width: 0; +} + +.capability-title-row { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 4px; +} + +.capability-title { + font-size: 18px; + font-weight: 600; + color: var(--text-primary, #333); +} + +.status-indicators { + display: flex; + gap: 12px; + align-items: center; +} + +.indicator-item { + display: flex; + align-items: center; + gap: 4px; + cursor: help; +} + +.indicator-light { + font-size: 10px; +} + +.light-success { + color: var(--success-color, #28a745); +} + +.light-warning { + color: var(--warning-color, #ffc107); +} + +.light-danger { + color: var(--danger-color, #dc3545); +} + +.indicator-label { + font-size: 12px; + color: var(--text-secondary, #666); + white-space: nowrap; +} + +.capability-subtitle { + font-size: 14px; + color: var(--text-secondary, #666); + line-height: 1.4; +} + +.capability-status { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 4px; +} + +.status-badge { + padding: 4px 12px; + border-radius: 12px; + font-size: 12px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.status-available { + background-color: #d4edda; + color: #155724; +} + +.status-unavailable { + background-color: #f8d7da; + color: #721c24; +} + +.status-partially-unavailable { + background-color: #fff3cd; + color: #856404; +} + +.capability-footer { + display: flex; + justify-content: space-between; + align-items: center; + gap: 16px; + margin-top: 12px; + padding-top: 12px; + border-top: 1px solid var(--border-color, #e0e0e0); +} + +.capability-description { + flex: 1; + font-size: 13px; + color: var(--text-secondary, #666); + line-height: 1.5; + overflow: hidden; + text-overflow: ellipsis; + display: -webkit-box; + -webkit-line-clamp: 2; + line-clamp: 2; + -webkit-box-orient: vertical; +} + +.learn-more-btn { + padding: 8px 16px; + border-radius: 4px; + text-decoration: none; + font-size: 14px; + font-weight: 500; + transition: all 0.2s ease; + white-space: nowrap; + border: 1px solid transparent; +} + +.learn-more-btn.btn-primary { + background-color: var(--primary-color, #007bff); + color: white; + border-color: var(--primary-color, #007bff); +} + +.learn-more-btn.btn-primary:hover { + background-color: var(--primary-hover-color, #0056b3); + border-color: var(--primary-hover-color, #0056b3); +} + +/* Responsive adjustments */ +@media (max-width: 768px) { + .capability-header { + flex-direction: column; + } + + .capability-status { + align-items: flex-start; + flex-direction: row; + gap: 8px; + } + + .capability-footer { + flex-direction: column; + align-items: flex-start; + } + + .learn-more-btn { + width: 100%; + text-align: center; + } +} \ No newline at end of file diff --git a/src/Frontend/src/components/platformcapabilities/styles/platformDashboardSection.css b/src/Frontend/src/components/platformcapabilities/styles/platformDashboardSection.css new file mode 100644 index 000000000..a19ebf21a --- /dev/null +++ b/src/Frontend/src/components/platformcapabilities/styles/platformDashboardSection.css @@ -0,0 +1,15 @@ +.capabilities-header { + margin-bottom: 10px; +} +.capabilities-description { + font-size: 14px; + color: var(--text-secondary, #666); + margin: 0; +} +.capabilities-list { + display: flex; + gap: 16px; +} +.capabilities-list > * { + flex: 1; +} \ No newline at end of file diff --git a/src/Frontend/src/components/platformcapabilities/styles/wizardModal.css b/src/Frontend/src/components/platformcapabilities/styles/wizardModal.css new file mode 100644 index 000000000..80e85beb9 --- /dev/null +++ b/src/Frontend/src/components/platformcapabilities/styles/wizardModal.css @@ -0,0 +1,214 @@ +.wizard-content { + border-radius: 12px; + overflow: hidden; +} + +.wizard-image img { + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); +} + +.image-carousel { + display: flex; + align-items: center; + justify-content: center; + gap: 12px; +} + +.image-figure { + margin: 0; +} + +.image-caption { + margin-top: 8px; + font-size: 0.85rem; + color: #666; + font-style: italic; +} + +.carousel-nav { + background: #f0f0f0; + border: none; + border-radius: 50%; + width: 36px; + height: 36px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: all 0.2s ease; + flex-shrink: 0; +} + +.carousel-nav:hover:not(:disabled) { + background: #e0e0e0; +} + +.carousel-nav:disabled { + opacity: 0.3; + cursor: not-allowed; +} + +.image-indicators { + display: flex; + justify-content: center; + gap: 8px; +} + +.image-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background-color: #ddd; + cursor: pointer; + transition: all 0.2s ease; +} + +.image-dot:hover { + background-color: #bbb; +} + +.image-dot.active { + background-color: #007bff; +} + +.clickable-image { + cursor: zoom-in; + transition: transform 0.2s ease; +} + +.clickable-image:hover { + transform: scale(1.02); +} + +.image-lightbox { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.9); + display: flex; + align-items: center; + justify-content: center; + z-index: 2000; + cursor: zoom-out; +} + +.lightbox-content { + position: relative; + max-width: 90%; + max-height: 90%; + cursor: default; +} + +.lightbox-content img { + max-width: 100%; + max-height: 90vh; + border-radius: 8px; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3); +} + +.lightbox-close { + position: absolute; + top: -40px; + right: 0; + background: none; + border: none; + color: white; + font-size: 32px; + cursor: pointer; + padding: 0; + line-height: 1; +} + +.lightbox-close:hover { + color: #ccc; +} + +.page-title { + font-size: 1.25rem; + font-weight: 600; + color: #333; +} + +.page-content { + font-size: 0.95rem; + line-height: 1.7; + color: #555; +} + +.page-content :deep(p) { + margin-bottom: 0.75rem; +} + +.page-content :deep(ul) { + margin: 0.75rem 0; + padding-left: 1.25rem; +} + +.page-content :deep(li) { + margin-bottom: 0.5rem; +} + +.page-content :deep(code) { + background-color: #f5f5f5; + padding: 2px 6px; + border-radius: 4px; + font-size: 0.9em; + color: #d63384; +} + +.page-content :deep(strong) { + color: #333; +} + +.page-content :deep(a) { + color: #007bff; + text-decoration: none; +} + +.page-content :deep(a:hover) { + color: #0056b3; + text-decoration: underline; +} + +.learn-more-link { + color: #007bff; + text-decoration: none; + font-weight: 500; +} + +.learn-more-link:hover { + color: #0056b3; + text-decoration: underline; +} + +.page-dot { + width: 10px; + height: 10px; + border-radius: 50%; + border: none; + background-color: #ddd; + cursor: pointer; + transition: all 0.2s ease; + padding: 0; +} + +.page-dot:hover { + background-color: #bbb; +} + +.page-dot.visited { + background-color: #007bff; + opacity: 0.5; +} + +.page-dot.active { + background-color: #007bff; + opacity: 1; + transform: scale(1.2); +} + +.modal-footer { + background-color: #f8f9fa; +} \ No newline at end of file diff --git a/src/Frontend/src/components/platformcapabilities/types.ts b/src/Frontend/src/components/platformcapabilities/types.ts index a3ed00b97..2593d2f0d 100644 --- a/src/Frontend/src/components/platformcapabilities/types.ts +++ b/src/Frontend/src/components/platformcapabilities/types.ts @@ -1,19 +1,22 @@ +import { CapabilityStatus } from "./constants"; + export interface StatusIndicator { label: string; status: CapabilityStatus; tooltip: string; } -export enum CapabilityStatus { - Unavailable = "Unavailable", // Instance is configured but not responding or not available - PartiallyUnavailable = "Degraded", // At least one but not all instances are unavailable - Available = "Available", // Instance is available and responding - EndpointsNotConfigured = "Endpoints Not Configured", // Instance is not configured. Promo should be shown - InstanceNotConfigured = "Instance Not Configured", // Instance is not configured at all +export interface WizardImage { + src: string; + caption?: string; + maxHeight?: string; } -export enum Capability { - Monitoring = "Monitoring", - Auditing = "Auditing", - Error = "Recoverability", +export interface WizardPage { + title: string; + content: string; + image?: string | WizardImage; + images?: (string | WizardImage)[]; + learnMoreUrl?: string; + learnMoreText?: string; } diff --git a/src/Frontend/src/components/platformcapabilities/wizards/AuditingWizardPages.ts b/src/Frontend/src/components/platformcapabilities/wizards/AuditingWizardPages.ts index f472c05bf..3777ddc8f 100644 --- a/src/Frontend/src/components/platformcapabilities/wizards/AuditingWizardPages.ts +++ b/src/Frontend/src/components/platformcapabilities/wizards/AuditingWizardPages.ts @@ -1,5 +1,5 @@ -import type { WizardPage } from "../WizardDialog.vue"; -import { CapabilityStatus } from "../types"; +import { CapabilityStatus } from "../constants"; +import { WizardPage } from "../types"; const AuditingInstanceNotConfiguredPages: WizardPage[] = [ { diff --git a/src/Frontend/src/components/platformcapabilities/wizards/ErrorWizardPages.ts b/src/Frontend/src/components/platformcapabilities/wizards/ErrorWizardPages.ts index 7268d405b..84aacc639 100644 --- a/src/Frontend/src/components/platformcapabilities/wizards/ErrorWizardPages.ts +++ b/src/Frontend/src/components/platformcapabilities/wizards/ErrorWizardPages.ts @@ -1,5 +1,5 @@ -import type { WizardPage } from "../WizardDialog.vue"; -import { CapabilityStatus } from "../types"; +import { WizardPage } from "../types"; +import { CapabilityStatus } from "../constants"; const ErrorEndpointsNotConfiguredPages: WizardPage[] = [ { diff --git a/src/Frontend/src/components/platformcapabilities/wizards/MonitoringWizardPages.ts b/src/Frontend/src/components/platformcapabilities/wizards/MonitoringWizardPages.ts index 28d1d9d89..dcb04b818 100644 --- a/src/Frontend/src/components/platformcapabilities/wizards/MonitoringWizardPages.ts +++ b/src/Frontend/src/components/platformcapabilities/wizards/MonitoringWizardPages.ts @@ -1,5 +1,5 @@ -import type { WizardPage } from "../WizardDialog.vue"; -import { CapabilityStatus } from "../types"; +import { WizardPage } from "../types"; +import { CapabilityStatus } from "../constants"; const MonitoringInstanceNotConfiguredPages: WizardPage[] = [ { From a25ec6a8b0d2d950c31fc24241e3f412a0e09b91 Mon Sep 17 00:00:00 2001 From: Warwick Schroeder Date: Wed, 26 Nov 2025 13:17:10 +0800 Subject: [PATCH 20/26] Remove additional monitoring instance config --- src/Frontend/public/js/app.constants.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Frontend/public/js/app.constants.js b/src/Frontend/public/js/app.constants.js index 0c6522089..d138f9910 100644 --- a/src/Frontend/public/js/app.constants.js +++ b/src/Frontend/public/js/app.constants.js @@ -2,6 +2,6 @@ window.defaultConfig = { default_route: '/dashboard', version: '1.2.0', service_control_url: 'http://localhost:33333/api/', - monitoring_urls: ['http://localhost:33633/','http://localhost:33634/'], + monitoring_urls: ['http://localhost:33633/'], showPendingRetry: false, }; From 2c70a6aadc63a2f5b731a6978f9f0460429b6b3b Mon Sep 17 00:00:00 2001 From: Warwick Schroeder Date: Wed, 26 Nov 2025 14:06:53 +0800 Subject: [PATCH 21/26] Add new mocked endpoints for new and updated stores --- .../serviceControlWithThroughput.ts | 2 ++ .../test/preconditions/configuration.ts | 20 +++++++++++++++++++ .../serviceControlWithMonitoring.ts | 6 ++++++ 3 files changed, 28 insertions(+) diff --git a/src/Frontend/src/views/throughputreport/serviceControlWithThroughput.ts b/src/Frontend/src/views/throughputreport/serviceControlWithThroughput.ts index f8fb63cf4..28e2bc2a2 100644 --- a/src/Frontend/src/views/throughputreport/serviceControlWithThroughput.ts +++ b/src/Frontend/src/views/throughputreport/serviceControlWithThroughput.ts @@ -11,4 +11,6 @@ export const serviceControlWithThroughput = async ({ driver }: SetupFactoryOptio await driver.setUp(precondition.hasNoHeartbeatsEndpoints); await driver.setUp(precondition.hasServiceControlMainInstance(minimumSCVersionForThroughput)); await driver.setUp(precondition.hasEndpointSettings([])); + await driver.setUp(precondition.hasRemoteInstances()); + await driver.setUp(precondition.hasMessages()); }; diff --git a/src/Frontend/test/preconditions/configuration.ts b/src/Frontend/test/preconditions/configuration.ts index 7fd7813f2..f8fed22ef 100644 --- a/src/Frontend/test/preconditions/configuration.ts +++ b/src/Frontend/test/preconditions/configuration.ts @@ -1,5 +1,7 @@ import QueueAddress from "@/resources/QueueAddress"; import Redirect from "@/resources/Redirect"; +import { RemoteInstance } from "@/resources/RemoteInstance"; +import Message from "@/resources/Message"; import { SetupFactoryOptions } from "test/driver"; export const knownQueuesDefaultHandler = ({ driver }: SetupFactoryOptions) => { @@ -13,3 +15,21 @@ export const redirectsDefaultHandler = ({ driver }: SetupFactoryOptions) => { body: [], }); }; + +export const hasRemoteInstances = + (body: RemoteInstance[] = []) => + ({ driver }: SetupFactoryOptions) => { + driver.mockEndpoint(`${window.defaultConfig.service_control_url}configuration/remotes`, { + body, + }); + }; + +export const hasMessages = + (body: Message[] = []) => + ({ driver }: SetupFactoryOptions) => { + driver.mockEndpointDynamic(`${window.defaultConfig.service_control_url}messages2/*`, "get", () => + Promise.resolve({ + body, + }) + ); + }; diff --git a/src/Frontend/test/preconditions/serviceControlWithMonitoring.ts b/src/Frontend/test/preconditions/serviceControlWithMonitoring.ts index dfae768ac..6312d0036 100644 --- a/src/Frontend/test/preconditions/serviceControlWithMonitoring.ts +++ b/src/Frontend/test/preconditions/serviceControlWithMonitoring.ts @@ -83,4 +83,10 @@ export const serviceControlWithMonitoring = async ({ driver }: SetupFactoryOptio //default handler for /api/queues/addresses await driver.setUp(precondition.knownQueuesDefaultHandler); + + //default handler for /api/configuration/remotes + await driver.setUp(precondition.hasRemoteInstances()); + + //default handler for /api/messages2 + await driver.setUp(precondition.hasMessages()); }; From c7d4692b631d84bfe28351431f3a8ea4727dbdd6 Mon Sep 17 00:00:00 2001 From: Warwick Schroeder Date: Fri, 28 Nov 2025 10:15:40 +0800 Subject: [PATCH 22/26] Move styles into component files. --- .../platformcapabilities/CapabilityCard.vue | 248 +++++++++++++++++- .../PlatformCapabilitiesDashboardItem.vue | 16 +- .../platformcapabilities/WizardDialog.vue | 215 ++++++++++++++- .../styles/capabilityCard.css | 247 ----------------- .../styles/platformDashboardSection.css | 15 -- .../styles/wizardModal.css | 214 --------------- 6 files changed, 476 insertions(+), 479 deletions(-) delete mode 100644 src/Frontend/src/components/platformcapabilities/styles/capabilityCard.css delete mode 100644 src/Frontend/src/components/platformcapabilities/styles/platformDashboardSection.css delete mode 100644 src/Frontend/src/components/platformcapabilities/styles/wizardModal.css diff --git a/src/Frontend/src/components/platformcapabilities/CapabilityCard.vue b/src/Frontend/src/components/platformcapabilities/CapabilityCard.vue index 2c7c00a27..72d2d4119 100644 --- a/src/Frontend/src/components/platformcapabilities/CapabilityCard.vue +++ b/src/Frontend/src/components/platformcapabilities/CapabilityCard.vue @@ -108,5 +108,251 @@ function handleButtonClick() { diff --git a/src/Frontend/src/components/platformcapabilities/PlatformCapabilitiesDashboardItem.vue b/src/Frontend/src/components/platformcapabilities/PlatformCapabilitiesDashboardItem.vue index 9ec74b381..36bb50962 100644 --- a/src/Frontend/src/components/platformcapabilities/PlatformCapabilitiesDashboardItem.vue +++ b/src/Frontend/src/components/platformcapabilities/PlatformCapabilitiesDashboardItem.vue @@ -66,5 +66,19 @@ const errorWizardPages = computed(() => getErrorWizardPages(error.status.value)) diff --git a/src/Frontend/src/components/platformcapabilities/WizardDialog.vue b/src/Frontend/src/components/platformcapabilities/WizardDialog.vue index 860a068dc..e96ec97dd 100644 --- a/src/Frontend/src/components/platformcapabilities/WizardDialog.vue +++ b/src/Frontend/src/components/platformcapabilities/WizardDialog.vue @@ -208,5 +208,218 @@ onUnmounted(() => { diff --git a/src/Frontend/src/components/platformcapabilities/styles/capabilityCard.css b/src/Frontend/src/components/platformcapabilities/styles/capabilityCard.css deleted file mode 100644 index bb4f19f7b..000000000 --- a/src/Frontend/src/components/platformcapabilities/styles/capabilityCard.css +++ /dev/null @@ -1,247 +0,0 @@ -.capability-card { - background: var(--card-bg, #fff); - border: 1px solid var(--border-color, #e0e0e0); - border-radius: 8px; - padding: 20px; - margin-bottom: 16px; - transition: all 0.2s ease; - position: relative; - min-height: 150px; -} - -.capability-available { - border-left: 4px solid var(--success-color, #28a745); -} - -.capability-unavailable { - border-left: 4px solid var(--danger-color, #dc3545); -} - -.capability-partially-unavailable { - border-left: 4px solid var(--warning-color, #ffc107); -} - -.capability-loading { - border-left: 4px solid var(--border-color, #e0e0e0); -} - -.capability-notconfigured { - background: linear-gradient(135deg, #f6f9fc 0%, #e9f2f9 100%); - border: 1px solid #c3ddf5; - border-left: 4px solid #007bff; -} - -.text-info { - color: #007bff; -} - -.text-warning { - color: var(--warning-color, #ffc107); -} - -.loading-overlay { - position: absolute; - top: 0; - left: 0; - right: 0; - bottom: 0; - background: rgba(255, 255, 255, 0.9); - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - border-radius: 8px; - z-index: 10; -} - -.loading-spinner { - width: 40px; - height: 40px; - border: 3px solid var(--border-color, #e0e0e0); - border-top-color: var(--primary-color, #007bff); - border-radius: 50%; - animation: spin 0.8s linear infinite; -} - -@keyframes spin { - to { - transform: rotate(360deg); - } -} - -.loading-text { - margin-top: 12px; - color: var(--text-secondary, #666); - font-size: 14px; -} - -.capability-header { - display: flex; - align-items: flex-start; - gap: 16px; - margin-bottom: 12px; -} - -.capability-icon { - font-size: 24px; - flex-shrink: 0; - margin-top: 2px; -} - -.capability-info { - flex: 1; - min-width: 0; -} - -.capability-title-row { - display: flex; - align-items: center; - gap: 12px; - margin-bottom: 4px; -} - -.capability-title { - font-size: 18px; - font-weight: 600; - color: var(--text-primary, #333); -} - -.status-indicators { - display: flex; - gap: 12px; - align-items: center; -} - -.indicator-item { - display: flex; - align-items: center; - gap: 4px; - cursor: help; -} - -.indicator-light { - font-size: 10px; -} - -.light-success { - color: var(--success-color, #28a745); -} - -.light-warning { - color: var(--warning-color, #ffc107); -} - -.light-danger { - color: var(--danger-color, #dc3545); -} - -.indicator-label { - font-size: 12px; - color: var(--text-secondary, #666); - white-space: nowrap; -} - -.capability-subtitle { - font-size: 14px; - color: var(--text-secondary, #666); - line-height: 1.4; -} - -.capability-status { - display: flex; - flex-direction: column; - align-items: flex-end; - gap: 4px; -} - -.status-badge { - padding: 4px 12px; - border-radius: 12px; - font-size: 12px; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.5px; -} - -.status-available { - background-color: #d4edda; - color: #155724; -} - -.status-unavailable { - background-color: #f8d7da; - color: #721c24; -} - -.status-partially-unavailable { - background-color: #fff3cd; - color: #856404; -} - -.capability-footer { - display: flex; - justify-content: space-between; - align-items: center; - gap: 16px; - margin-top: 12px; - padding-top: 12px; - border-top: 1px solid var(--border-color, #e0e0e0); -} - -.capability-description { - flex: 1; - font-size: 13px; - color: var(--text-secondary, #666); - line-height: 1.5; - overflow: hidden; - text-overflow: ellipsis; - display: -webkit-box; - -webkit-line-clamp: 2; - line-clamp: 2; - -webkit-box-orient: vertical; -} - -.learn-more-btn { - padding: 8px 16px; - border-radius: 4px; - text-decoration: none; - font-size: 14px; - font-weight: 500; - transition: all 0.2s ease; - white-space: nowrap; - border: 1px solid transparent; -} - -.learn-more-btn.btn-primary { - background-color: var(--primary-color, #007bff); - color: white; - border-color: var(--primary-color, #007bff); -} - -.learn-more-btn.btn-primary:hover { - background-color: var(--primary-hover-color, #0056b3); - border-color: var(--primary-hover-color, #0056b3); -} - -/* Responsive adjustments */ -@media (max-width: 768px) { - .capability-header { - flex-direction: column; - } - - .capability-status { - align-items: flex-start; - flex-direction: row; - gap: 8px; - } - - .capability-footer { - flex-direction: column; - align-items: flex-start; - } - - .learn-more-btn { - width: 100%; - text-align: center; - } -} \ No newline at end of file diff --git a/src/Frontend/src/components/platformcapabilities/styles/platformDashboardSection.css b/src/Frontend/src/components/platformcapabilities/styles/platformDashboardSection.css deleted file mode 100644 index a19ebf21a..000000000 --- a/src/Frontend/src/components/platformcapabilities/styles/platformDashboardSection.css +++ /dev/null @@ -1,15 +0,0 @@ -.capabilities-header { - margin-bottom: 10px; -} -.capabilities-description { - font-size: 14px; - color: var(--text-secondary, #666); - margin: 0; -} -.capabilities-list { - display: flex; - gap: 16px; -} -.capabilities-list > * { - flex: 1; -} \ No newline at end of file diff --git a/src/Frontend/src/components/platformcapabilities/styles/wizardModal.css b/src/Frontend/src/components/platformcapabilities/styles/wizardModal.css deleted file mode 100644 index 80e85beb9..000000000 --- a/src/Frontend/src/components/platformcapabilities/styles/wizardModal.css +++ /dev/null @@ -1,214 +0,0 @@ -.wizard-content { - border-radius: 12px; - overflow: hidden; -} - -.wizard-image img { - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); -} - -.image-carousel { - display: flex; - align-items: center; - justify-content: center; - gap: 12px; -} - -.image-figure { - margin: 0; -} - -.image-caption { - margin-top: 8px; - font-size: 0.85rem; - color: #666; - font-style: italic; -} - -.carousel-nav { - background: #f0f0f0; - border: none; - border-radius: 50%; - width: 36px; - height: 36px; - display: flex; - align-items: center; - justify-content: center; - cursor: pointer; - transition: all 0.2s ease; - flex-shrink: 0; -} - -.carousel-nav:hover:not(:disabled) { - background: #e0e0e0; -} - -.carousel-nav:disabled { - opacity: 0.3; - cursor: not-allowed; -} - -.image-indicators { - display: flex; - justify-content: center; - gap: 8px; -} - -.image-dot { - width: 8px; - height: 8px; - border-radius: 50%; - background-color: #ddd; - cursor: pointer; - transition: all 0.2s ease; -} - -.image-dot:hover { - background-color: #bbb; -} - -.image-dot.active { - background-color: #007bff; -} - -.clickable-image { - cursor: zoom-in; - transition: transform 0.2s ease; -} - -.clickable-image:hover { - transform: scale(1.02); -} - -.image-lightbox { - position: fixed; - top: 0; - left: 0; - width: 100%; - height: 100%; - background-color: rgba(0, 0, 0, 0.9); - display: flex; - align-items: center; - justify-content: center; - z-index: 2000; - cursor: zoom-out; -} - -.lightbox-content { - position: relative; - max-width: 90%; - max-height: 90%; - cursor: default; -} - -.lightbox-content img { - max-width: 100%; - max-height: 90vh; - border-radius: 8px; - box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3); -} - -.lightbox-close { - position: absolute; - top: -40px; - right: 0; - background: none; - border: none; - color: white; - font-size: 32px; - cursor: pointer; - padding: 0; - line-height: 1; -} - -.lightbox-close:hover { - color: #ccc; -} - -.page-title { - font-size: 1.25rem; - font-weight: 600; - color: #333; -} - -.page-content { - font-size: 0.95rem; - line-height: 1.7; - color: #555; -} - -.page-content :deep(p) { - margin-bottom: 0.75rem; -} - -.page-content :deep(ul) { - margin: 0.75rem 0; - padding-left: 1.25rem; -} - -.page-content :deep(li) { - margin-bottom: 0.5rem; -} - -.page-content :deep(code) { - background-color: #f5f5f5; - padding: 2px 6px; - border-radius: 4px; - font-size: 0.9em; - color: #d63384; -} - -.page-content :deep(strong) { - color: #333; -} - -.page-content :deep(a) { - color: #007bff; - text-decoration: none; -} - -.page-content :deep(a:hover) { - color: #0056b3; - text-decoration: underline; -} - -.learn-more-link { - color: #007bff; - text-decoration: none; - font-weight: 500; -} - -.learn-more-link:hover { - color: #0056b3; - text-decoration: underline; -} - -.page-dot { - width: 10px; - height: 10px; - border-radius: 50%; - border: none; - background-color: #ddd; - cursor: pointer; - transition: all 0.2s ease; - padding: 0; -} - -.page-dot:hover { - background-color: #bbb; -} - -.page-dot.visited { - background-color: #007bff; - opacity: 0.5; -} - -.page-dot.active { - background-color: #007bff; - opacity: 1; - transform: scale(1.2); -} - -.modal-footer { - background-color: #f8f9fa; -} \ No newline at end of file From cf634b98df0ef9912fa415d17a68227ef2dcc538 Mon Sep 17 00:00:00 2001 From: Warwick Schroeder Date: Tue, 2 Dec 2025 13:41:40 +0800 Subject: [PATCH 23/26] Fix issues caused by master branch refactoring --- .../failedmessages/DeletedMessages.vue | 99 +------------------ .../capabilities/MonitoringCapability.ts | 19 ++-- .../composables/useAuditStoreAutoRefresh.ts | 21 ++-- .../useMonitoringStoreAutoRefresh.ts | 21 ++-- .../useRemoteInstancesAutoRefresh.ts | 2 +- src/Frontend/src/stores/AuditStore.ts | 2 +- src/Frontend/src/stores/MonitoringStore.ts | 4 +- .../src/stores/RemoteInstancesStore.ts | 15 +-- 8 files changed, 31 insertions(+), 152 deletions(-) diff --git a/src/Frontend/src/components/failedmessages/DeletedMessages.vue b/src/Frontend/src/components/failedmessages/DeletedMessages.vue index 37f0dce2a..4eb0de183 100644 --- a/src/Frontend/src/components/failedmessages/DeletedMessages.vue +++ b/src/Frontend/src/components/failedmessages/DeletedMessages.vue @@ -7,8 +7,7 @@ import ServiceControlAvailable from "../ServiceControlAvailable.vue"; import MessageList, { IMessageList } from "./MessageList.vue"; import ConfirmDialog from "../ConfirmDialog.vue"; import PaginationStrip from "../../components/PaginationStrip.vue"; -import dayjs from "@/utils/dayjs"; -import { ExtendedFailedMessage } from "@/resources/FailedMessage"; +import { FailedMessageStatus } from "@/resources/FailedMessage"; import { TYPE } from "vue-toastification"; import FAIcon from "@/components/FAIcon.vue"; import { faArrowRotateRight } from "@fortawesome/free-solid-svg-icons"; @@ -27,102 +26,6 @@ const { messages, groupId, groupName, totalCount, pageNumber, selectedPeriod } = const showConfirmRestore = ref(false); const messageList = ref(); -const messages = ref([]); - -watch(pageNumber, () => loadMessages()); - -const configurationStore = useConfigurationStore(); -const { configuration } = storeToRefs(configurationStore); -const serviceControlStore = useServiceControlStore(); - -function loadMessages() { - let startDate = new Date(0); - const endDate = new Date(); - - switch (selectedPeriod.value) { - case "All Deleted": - startDate = new Date(); - startDate.setHours(startDate.getHours() - 24 * 365); - break; - case "Deleted in the last 2 Hours": - startDate = new Date(); - startDate.setHours(startDate.getHours() - 2); - break; - case "Deleted in the last 1 Day": - startDate = new Date(); - startDate.setHours(startDate.getHours() - 24); - break; - case "Deleted in the last 7 days": - startDate = new Date(); - startDate.setHours(startDate.getHours() - 24 * 7); - break; - } - return loadPagedMessages(groupId.value, pageNumber.value, "", "", startDate.toISOString(), endDate.toISOString()); -} - -async function loadGroupDetails(groupId: string) { - const [, data] = await serviceControlStore.fetchTypedFromServiceControl(`archive/groups/id/${groupId}`); - groupName.value = data.title; -} - -function loadPagedMessages(groupId?: string, page: number = 1, sortBy: string = "modified", direction: string = "desc", startDate: string = new Date(0).toISOString(), endDate: string = new Date().toISOString()) { - const dateRange = startDate + "..." + endDate; - let loadGroupDetailsPromise; - if (groupId && !groupName.value) { - loadGroupDetailsPromise = loadGroupDetails(groupId); - } - - async function loadDelMessages() { - try { - const [response, data] = await serviceControlStore.fetchTypedFromServiceControl( - `${groupId ? `recoverability/groups/${groupId}/` : ""}errors?status=archived&page=${page}&per_page=${perPage}&sort=${sortBy}&direction=${direction}&modified=${dateRange}` - ); - - totalCount.value = parseInt(response.headers.get("Total-Count") ?? "0"); - - if (messages.value.length && data.length) { - // merge the previously selected messages into the new list so we can replace them - messages.value.forEach((previousMessage) => { - const receivedMessage = data.find((m) => m.id === previousMessage.id); - if (receivedMessage) { - if (previousMessage.last_modified === receivedMessage.last_modified) { - receivedMessage.retryInProgress = previousMessage.retryInProgress; - receivedMessage.deleteInProgress = previousMessage.deleteInProgress; - } - - receivedMessage.selected = previousMessage.selected; - } - }); - } - messages.value = updateMessagesScheduledDeletionDate(data); - } catch (err) { - console.log(err); - const result = { - message: "error", - }; - return result; - } - } - - const loadDelMessagesPromise = loadDelMessages(); - - if (loadGroupDetailsPromise) { - return Promise.all([loadGroupDetailsPromise, loadDelMessagesPromise]); - } - - return loadDelMessagesPromise; -} - -function updateMessagesScheduledDeletionDate(messages: ExtendedFailedMessage[]) { - //check deletion time - messages.forEach((message) => { - message.error_retention_period = dayjs.duration(configuration.value?.data_retention.error_retention_period ?? "PT0S").asHours(); - const countdown = dayjs(message.last_modified).add(message.error_retention_period, "hours"); - message.delete_soon = countdown < dayjs(); - message.deleted_in = countdown.format(); - }); - return messages; -} function numberSelected() { return messageList.value?.getSelectedMessages()?.length ?? 0; diff --git a/src/Frontend/src/components/platformcapabilities/capabilities/MonitoringCapability.ts b/src/Frontend/src/components/platformcapabilities/capabilities/MonitoringCapability.ts index 4cd6ee1d8..3628b1fb0 100644 --- a/src/Frontend/src/components/platformcapabilities/capabilities/MonitoringCapability.ts +++ b/src/Frontend/src/components/platformcapabilities/capabilities/MonitoringCapability.ts @@ -2,10 +2,10 @@ import { computed } from "vue"; import { StatusIndicator } from "@/components/platformcapabilities/types"; import { CapabilityStatus } from "@/components/platformcapabilities/constants"; import { storeToRefs } from "pinia"; -import { useServiceControlStore } from "@/stores/ServiceControlStore"; import { useConnectionsAndStatsStore } from "@/stores/ConnectionsAndStatsStore"; import useMonitoringStoreAutoRefresh from "@/composables/useMonitoringStoreAutoRefresh"; import { type CapabilityComposable, type CapabilityStatusToStringMap, useCapabilityBase } from "./BaseCapability"; +import monitoringClient from "@/components/monitoring/monitoringClient"; const MonitoringDescriptions: CapabilityStatusToStringMap = { [CapabilityStatus.EndpointsNotConfigured]: @@ -39,9 +39,8 @@ enum MonitoringIndicatorTooltip { export function useMonitoringCapability(): CapabilityComposable { const { getIconForStatus, getDescriptionForStatus, getHelpButtonTextForStatus, getHelpButtonUrlForStatus, createIndicator } = useCapabilityBase(); - // this tells us if monitoring is configured in ServiceControl - const serviceControlStore = useServiceControlStore(); - const { isMonitoringEnabled } = storeToRefs(serviceControlStore); + // this tells us if monitoring is configured in ServicePulse + const isMonitoringEnabled = monitoringClient.isMonitoringEnabled; // this tells us if there are any endpoints sending data // Uses auto-refresh to periodically check for monitored endpoints (every 5 seconds) @@ -55,11 +54,11 @@ export function useMonitoringCapability(): CapabilityComposable { // Determine overall monitoring status const monitoringStatus = computed(() => { - const isConfiguredInServiceControl = isMonitoringEnabled.value; + const isConfiguredInServicePulse = isMonitoringEnabled; const connectionSuccessful = monitoringConnectionState.connected && !monitoringConnectionState.unableToConnect; - // 1. Check if monitoring is configured in ServiceControl - if (!isConfiguredInServiceControl) { + // 1. Check if monitoring is configured in ServicePulse + if (!isConfiguredInServicePulse) { return CapabilityStatus.InstanceNotConfigured; } @@ -95,11 +94,11 @@ export function useMonitoringCapability(): CapabilityComposable { // Instance specific states const connectionSuccessful = monitoringConnectionState.connected && !monitoringConnectionState.unableToConnect; - const instanceAvailable = isMonitoringEnabled.value && connectionSuccessful; + const instanceAvailable = isMonitoringEnabled && connectionSuccessful; - const instanceTooltip = instanceAvailable ? MonitoringIndicatorTooltip.InstanceAvailable : !isMonitoringEnabled.value ? MonitoringIndicatorTooltip.InstanceNotConfigured : MonitoringIndicatorTooltip.InstanceUnavailable; + const instanceTooltip = instanceAvailable ? MonitoringIndicatorTooltip.InstanceAvailable : !isMonitoringEnabled ? MonitoringIndicatorTooltip.InstanceNotConfigured : MonitoringIndicatorTooltip.InstanceUnavailable; - if (isMonitoringEnabled.value) { + if (isMonitoringEnabled) { indicators.push(createIndicator("Instance", instanceAvailable ? CapabilityStatus.Available : CapabilityStatus.Unavailable, instanceTooltip)); } diff --git a/src/Frontend/src/composables/useAuditStoreAutoRefresh.ts b/src/Frontend/src/composables/useAuditStoreAutoRefresh.ts index bac4dd418..cc6e390fc 100644 --- a/src/Frontend/src/composables/useAuditStoreAutoRefresh.ts +++ b/src/Frontend/src/composables/useAuditStoreAutoRefresh.ts @@ -1,19 +1,10 @@ import { useAuditStore } from "@/stores/AuditStore"; -import { useAutoRefresh } from "./useAutoRefresh"; +import { useStoreAutoRefresh } from "./useAutoRefresh"; -let store: ReturnType | null = null; - -const refresh = () => { - if (!store) { - return Promise.resolve(); - } - return store.checkForSuccessfulMessages(); +// Override the refresh method to use checkForSuccessfulMessages, which is more lightweight +const useAuditStoreWithRefresh = () => { + const store = useAuditStore(); + return Object.assign(store, { refresh: store.checkForSuccessfulMessages }); }; -const autoRefresh = useAutoRefresh("auditStoreSuccessfulMessages", refresh, 5000); - -export default () => { - store = useAuditStore(); - autoRefresh(); - return { store }; -}; +export default useStoreAutoRefresh("auditStoreSuccessfulMessages", useAuditStoreWithRefresh, 5000).autoRefresh; diff --git a/src/Frontend/src/composables/useMonitoringStoreAutoRefresh.ts b/src/Frontend/src/composables/useMonitoringStoreAutoRefresh.ts index 83a6e5ca6..1dd7cef96 100644 --- a/src/Frontend/src/composables/useMonitoringStoreAutoRefresh.ts +++ b/src/Frontend/src/composables/useMonitoringStoreAutoRefresh.ts @@ -1,19 +1,10 @@ import { useMonitoringStore } from "@/stores/MonitoringStore"; -import { useAutoRefresh } from "./useAutoRefresh"; +import { useStoreAutoRefresh } from "./useAutoRefresh"; -let store: ReturnType | null = null; - -const refresh = () => { - if (!store) { - return Promise.resolve(); - } - return store.checkForMonitoredEndpoints(); +// Override the refresh method to use checkForMonitoredEndpoints, which is more lightweight +const useMonitoringStoreWithRefresh = () => { + const store = useMonitoringStore(); + return Object.assign(store, { refresh: store.checkForMonitoredEndpoints }); }; -const autoRefresh = useAutoRefresh("monitoringStoreMonitoredEndpoints", refresh, 5000); - -export default () => { - store = useMonitoringStore(); - autoRefresh(); - return { store }; -}; +export default useStoreAutoRefresh("monitoringStoreMonitoredEndpoints", useMonitoringStoreWithRefresh, 5000).autoRefresh; diff --git a/src/Frontend/src/composables/useRemoteInstancesAutoRefresh.ts b/src/Frontend/src/composables/useRemoteInstancesAutoRefresh.ts index dd0583a0a..b5754fc55 100644 --- a/src/Frontend/src/composables/useRemoteInstancesAutoRefresh.ts +++ b/src/Frontend/src/composables/useRemoteInstancesAutoRefresh.ts @@ -1,4 +1,4 @@ import { useRemoteInstancesStore } from "@/stores/RemoteInstancesStore"; import { useStoreAutoRefresh } from "./useAutoRefresh"; -export default useStoreAutoRefresh("remoteInstances", useRemoteInstancesStore, 5000); +export default useStoreAutoRefresh("remoteInstances", useRemoteInstancesStore, 5000).autoRefresh; diff --git a/src/Frontend/src/stores/AuditStore.ts b/src/Frontend/src/stores/AuditStore.ts index 98fb0a823..0293b3045 100644 --- a/src/Frontend/src/stores/AuditStore.ts +++ b/src/Frontend/src/stores/AuditStore.ts @@ -33,7 +33,7 @@ export const useAuditStore = defineStore("AuditStore", () => { try { // Fetch the latest 10 messages and check if any are successful // todo: ideally we would want to filter successful messages server-side, but the API doesn't currently support that - const [, data] = await serviceControlStore.fetchTypedFromServiceControl(`messages2/?page_size=10&sort=time_sent&direction=desc`); + const [, data] = await serviceControlClient.fetchTypedFromServiceControl(`messages2/?page_size=10&sort=time_sent&direction=desc`); hasSuccessfulMessages.value = data?.some((msg) => msg.status === MessageStatus.Successful) ?? false; } catch { hasSuccessfulMessages.value = false; diff --git a/src/Frontend/src/stores/MonitoringStore.ts b/src/Frontend/src/stores/MonitoringStore.ts index d3592360b..17248f5bd 100644 --- a/src/Frontend/src/stores/MonitoringStore.ts +++ b/src/Frontend/src/stores/MonitoringStore.ts @@ -72,12 +72,12 @@ export const useMonitoringStore = defineStore("MonitoringStore", () => { async function checkForMonitoredEndpoints() { try { - if (!serviceControlStore.isMonitoringEnabled || connectionStore.monitoringConnectionState.unableToConnect) { + if (!monitoringClient.isMonitoringEnabled || connectionStore.monitoringConnectionState.unableToConnect) { hasMonitoredEndpoints.value = false; return; } // Minimal query: just need to check if any endpoints exist - const [, data] = await serviceControlStore.fetchTypedFromMonitoring(`monitored-endpoints?history=1`); + const data = await monitoringClient.getMonitoredEndpoints(1); hasMonitoredEndpoints.value = (data?.length ?? 0) > 0; } catch { hasMonitoredEndpoints.value = false; diff --git a/src/Frontend/src/stores/RemoteInstancesStore.ts b/src/Frontend/src/stores/RemoteInstancesStore.ts index 2f440073a..4ece3249d 100644 --- a/src/Frontend/src/stores/RemoteInstancesStore.ts +++ b/src/Frontend/src/stores/RemoteInstancesStore.ts @@ -1,22 +1,17 @@ -import { acceptHMRUpdate, defineStore, storeToRefs } from "pinia"; -import { ref, watch } from "vue"; +import { acceptHMRUpdate, defineStore } from "pinia"; +import { ref } from "vue"; import { RemoteInstance } from "@/resources/RemoteInstance"; -import { useServiceControlStore } from "./ServiceControlStore"; +import serviceControlClient from "@/components/serviceControlClient"; export const useRemoteInstancesStore = defineStore("RemoteInstancesStore", () => { const remoteInstances = ref(null); - const serviceControlStore = useServiceControlStore(); - const { serviceControlUrl } = storeToRefs(serviceControlStore); - async function refresh() { - if (!serviceControlUrl.value) return; - - const response = await serviceControlStore.fetchFromServiceControl("configuration/remotes"); + const response = await serviceControlClient.fetchFromServiceControl("configuration/remotes"); remoteInstances.value = await response.json(); } - watch(serviceControlUrl, refresh, { immediate: true }); + refresh(); return { remoteInstances, From 90f810d6db2ba187303d2fc5def02d93acd51a53 Mon Sep 17 00:00:00 2001 From: Warwick Schroeder Date: Tue, 2 Dec 2025 13:48:57 +0800 Subject: [PATCH 24/26] Revert to master for specific files --- .../src/composables/formatter.spec.ts | 24 ++++++------------- src/Frontend/src/stores/MessageStore.ts | 3 ++- 2 files changed, 9 insertions(+), 18 deletions(-) diff --git a/src/Frontend/src/composables/formatter.spec.ts b/src/Frontend/src/composables/formatter.spec.ts index f48281b79..02999e1ba 100644 --- a/src/Frontend/src/composables/formatter.spec.ts +++ b/src/Frontend/src/composables/formatter.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "vitest"; -import { useFormatTime, useGetDayDiffFromToday, useFormatLargeNumber } from "./formatter"; +import { useFormatTime, useGetDayDiffFromToday, useFormatLargeNumber, createDateWithDayOffset } from "./formatter"; describe("useFormatTime", () => { describe("milliseconds formatting", () => { @@ -100,47 +100,37 @@ describe("useFormatTime", () => { describe("useGetDayDiffFromToday", () => { test("returns 0 for today's date", () => { - const today = new Date(); - today.setHours(12, 0, 0, 0); + const today = createDateWithDayOffset(); const result = useGetDayDiffFromToday(today.toISOString()); expect(result).toBe(0); }); test("returns positive number for future dates", () => { - const tomorrow = new Date(); - tomorrow.setDate(tomorrow.getDate() + 1); - tomorrow.setHours(12, 0, 0, 0); + const tomorrow = createDateWithDayOffset(1); const result = useGetDayDiffFromToday(tomorrow.toISOString()); expect(result).toBe(1); }); test("returns negative number for past dates", () => { - const yesterday = new Date(); - yesterday.setDate(yesterday.getDate() - 1); - yesterday.setHours(12, 0, 0, 0); + const yesterday = createDateWithDayOffset(-1); const result = useGetDayDiffFromToday(yesterday.toISOString()); expect(result).toBe(-1); }); test("returns 7 for date 7 days in the future", () => { - const futureDate = new Date(); - futureDate.setDate(futureDate.getDate() + 7); - futureDate.setHours(12, 0, 0, 0); + const futureDate = createDateWithDayOffset(7); const result = useGetDayDiffFromToday(futureDate.toISOString()); expect(result).toBe(7); }); test("returns -30 for date 30 days in the past", () => { - const pastDate = new Date(); - pastDate.setDate(pastDate.getDate() - 30); - pastDate.setHours(12, 0, 0, 0); + const pastDate = createDateWithDayOffset(-30); const result = useGetDayDiffFromToday(pastDate.toISOString()); expect(result).toBe(-30); }); test("handles dates without Z suffix", () => { - const date = new Date(); - date.setHours(12, 0, 0, 0); + const date = createDateWithDayOffset(); const isoString = date.toISOString().replace("Z", ""); const result = useGetDayDiffFromToday(isoString); expect(result).toBe(0); diff --git a/src/Frontend/src/stores/MessageStore.ts b/src/Frontend/src/stores/MessageStore.ts index 1ccaf000b..447b9d3e4 100644 --- a/src/Frontend/src/stores/MessageStore.ts +++ b/src/Frontend/src/stores/MessageStore.ts @@ -14,6 +14,7 @@ import { EditAndRetryConfig } from "@/resources/Configuration"; import EditRetryResponse from "@/resources/EditRetryResponse"; import { EditedMessage } from "@/resources/EditMessage"; import useEnvironmentAndVersionsAutoRefresh from "@/composables/useEnvironmentAndVersionsAutoRefresh"; +import { timeSpanToDuration } from "@/composables/formatter"; interface Model { id?: string; @@ -77,7 +78,7 @@ export const useMessageStore = defineStore("MessageStore", () => { const areSimpleHeadersSupported = environmentStore.serviceControlIsGreaterThan("5.2.0"); const { configuration } = storeToRefs(configStore); - const error_retention_period = computed(() => dayjs.duration(configuration.value?.data_retention?.error_retention_period ?? "PT0S").asHours()); + const error_retention_period = computed(() => timeSpanToDuration(configuration.value?.data_retention?.error_retention_period).asHours()); async function loadEditAndRetryConfiguration() { try { From ea148cf5d4adb91d2c14a98f6ccfb8c3bd1dc7e5 Mon Sep 17 00:00:00 2001 From: Warwick Schroeder Date: Thu, 4 Dec 2025 13:26:08 +0800 Subject: [PATCH 25/26] Support remote servicecontrol instances. Now will cache remotes. Add ability to hide individual cards, or entire platform capability section. --- .../platformcapabilities/CapabilityCard.vue | 34 +++++- .../PlatformCapabilitiesDashboardItem.vue | 107 +++++++++++++++++- .../capabilities/AuditingCapability.ts | 36 ++++-- .../capabilities/ErrorCapability.ts | 104 ++++++++++++----- .../capabilities/MonitoringCapability.ts | 7 +- .../wizards/ErrorWizardPages.ts | 51 --------- src/Frontend/src/resources/RemoteInstance.ts | 18 +++ .../src/stores/PlatformCapabilitiesStore.ts | 90 +++++++++++++++ .../src/stores/RemoteInstancesStore.ts | 83 +++++++++++++- 9 files changed, 435 insertions(+), 95 deletions(-) delete mode 100644 src/Frontend/src/components/platformcapabilities/wizards/ErrorWizardPages.ts create mode 100644 src/Frontend/src/stores/PlatformCapabilitiesStore.ts diff --git a/src/Frontend/src/components/platformcapabilities/CapabilityCard.vue b/src/Frontend/src/components/platformcapabilities/CapabilityCard.vue index 72d2d4119..ecf1b3c77 100644 --- a/src/Frontend/src/components/platformcapabilities/CapabilityCard.vue +++ b/src/Frontend/src/components/platformcapabilities/CapabilityCard.vue @@ -1,11 +1,15 @@ diff --git a/src/Frontend/src/components/platformcapabilities/capabilities/AuditingCapability.ts b/src/Frontend/src/components/platformcapabilities/capabilities/AuditingCapability.ts index 394b17d63..f644f5254 100644 --- a/src/Frontend/src/components/platformcapabilities/capabilities/AuditingCapability.ts +++ b/src/Frontend/src/components/platformcapabilities/capabilities/AuditingCapability.ts @@ -6,7 +6,24 @@ import { storeToRefs } from "pinia"; import { type CapabilityComposable, type CapabilityStatusToStringMap, useCapabilityBase } from "./BaseCapability"; import useRemoteInstancesAutoRefresh from "@/composables/useRemoteInstancesAutoRefresh"; import useAuditStoreAutoRefresh from "@/composables/useAuditStoreAutoRefresh"; -import { RemoteInstanceStatus, type RemoteInstance } from "@/resources/RemoteInstance"; +import { RemoteInstanceStatus, RemoteInstanceType, type RemoteInstance } from "@/resources/RemoteInstance"; + +/** + * Checks if a remote instance is an audit instance using the cached instance type + */ +function isAuditInstance(instance: RemoteInstance): boolean { + return instance.cachedInstanceType === RemoteInstanceType.Audit; +} + +/** + * Filters remote instances to only include audit instances + */ +function filterAuditInstances(instances: RemoteInstance[] | null | undefined): RemoteInstance[] { + if (!instances) { + return []; + } + return instances.filter(isAuditInstance); +} const AuditingDescriptions: CapabilityStatusToStringMap = { [CapabilityStatus.EndpointsNotConfigured]: @@ -87,6 +104,9 @@ export function useAuditingCapability(): CapabilityComposable { const { store: remoteInstancesStore } = useRemoteInstancesAutoRefresh(); const { remoteInstances } = storeToRefs(remoteInstancesStore); + // Filter to only include audit instances (those with audit_retention_period in configuration) + const auditInstances = computed(() => filterAuditInstances(remoteInstances.value)); + // This gives us the hasSuccessfulMessages flag which indicates if any successful messages exist. // Uses auto-refresh (minimal) to periodically check for at least 1 successful message (every 5 seconds) const { store: auditStore } = useAuditStoreAutoRefresh(); @@ -98,17 +118,17 @@ export function useAuditingCapability(): CapabilityComposable { // Determine overall auditing status const auditStatus = computed(() => { // 1. Check if there are any audit instances configured. - if (!remoteInstances.value || remoteInstances.value.length === 0) { + if (auditInstances.value.length === 0) { return CapabilityStatus.InstanceNotConfigured; } // 2. Check if all audit instances are unavailable - if (allAuditInstancesUnavailable(remoteInstances.value)) { + if (allAuditInstancesUnavailable(auditInstances.value)) { return CapabilityStatus.Unavailable; } // 3. Check if some but not all audit instances are unavailable - if (hasPartiallyUnavailableAuditInstances(remoteInstances.value)) { + if (hasPartiallyUnavailableAuditInstances(auditInstances.value)) { return CapabilityStatus.PartiallyUnavailable; } @@ -138,10 +158,10 @@ export function useAuditingCapability(): CapabilityComposable { const indicators: StatusIndicator[] = []; // Add an indicator for each remote audit instance - if (remoteInstances.value && remoteInstances.value.length > 0) { - remoteInstances.value.forEach((instance, index) => { + if (auditInstances.value.length > 0) { + auditInstances.value.forEach((instance, index) => { const isAvailable = instance.status === RemoteInstanceStatus.Online; - const label = remoteInstances.value!.length > 1 ? `Instance ${index + 1}` : "Instance"; + const label = auditInstances.value.length > 1 ? `Instance ${index + 1}` : "Instance"; const tooltip = isAvailable ? AuditingIndicatorTooltip.InstanceAvailable : AuditingIndicatorTooltip.InstanceUnavailable; indicators.push(createIndicator(label, isAvailable ? CapabilityStatus.Available : CapabilityStatus.Unavailable, tooltip, instance.api_uri, instance.version)); @@ -149,7 +169,7 @@ export function useAuditingCapability(): CapabilityComposable { } // Messages available indicator - show if at least one instance is available - if (hasAvailableAuditInstances(remoteInstances.value)) { + if (hasAvailableAuditInstances(auditInstances.value)) { const messagesAvailable = isAllMessagesSupported.value && hasSuccessfulMessages.value; let messageTooltip = ""; diff --git a/src/Frontend/src/components/platformcapabilities/capabilities/ErrorCapability.ts b/src/Frontend/src/components/platformcapabilities/capabilities/ErrorCapability.ts index d8faf1c90..856ee60c8 100644 --- a/src/Frontend/src/components/platformcapabilities/capabilities/ErrorCapability.ts +++ b/src/Frontend/src/components/platformcapabilities/capabilities/ErrorCapability.ts @@ -4,56 +4,103 @@ import { StatusIndicator } from "@/components/platformcapabilities/types"; import { CapabilityStatus } from "@/components/platformcapabilities/constants"; import { useConnectionsAndStatsStore } from "@/stores/ConnectionsAndStatsStore"; import { type CapabilityComposable, type CapabilityStatusToStringMap, useCapabilityBase } from "./BaseCapability"; +import useRemoteInstancesAutoRefresh from "@/composables/useRemoteInstancesAutoRefresh"; +import { RemoteInstanceStatus, RemoteInstanceType, type RemoteInstance } from "@/resources/RemoteInstance"; +import serviceControlClient from "@/components/serviceControlClient"; +import { useEnvironmentAndVersionsStore } from "@/stores/EnvironmentAndVersionsStore"; + +/** + * Checks if a remote instance is an error/recoverability instance using the cached instance type + */ +function isErrorInstance(instance: RemoteInstance): boolean { + return instance.cachedInstanceType === RemoteInstanceType.Error; +} + +/** + * Filters remote instances to only include error/recoverability instances + */ +function filterErrorInstances(instances: RemoteInstance[] | null | undefined): RemoteInstance[] { + if (!instances) { + return []; + } + return instances.filter(isErrorInstance); +} + +/** + * Checks if all error remote instances are unavailable + */ +function allErrorInstancesUnavailable(instances: RemoteInstance[]): boolean { + if (instances.length === 0) { + return false; + } + return instances.every((instance) => instance.status !== RemoteInstanceStatus.Online); +} const ErrorDescriptions: CapabilityStatusToStringMap = { - [CapabilityStatus.EndpointsNotConfigured]: "The ServiceControl Error instance is connected but no failed messages have been received yet. This may be because no failures have occurred, or recoverability is not configured.", - [CapabilityStatus.Available]: "The ServiceControl Error instance is available and has received failed messages.", + [CapabilityStatus.PartiallyUnavailable]: "Some ServiceControl Error instances are not responding.", + [CapabilityStatus.Available]: "All ServiceControl Error instances are available.", }; const ErrorHelpButtonText: CapabilityStatusToStringMap = { - [CapabilityStatus.EndpointsNotConfigured]: "Learn More", [CapabilityStatus.Available]: "View Failed Messages", }; const ErrorHelpButtonUrl: CapabilityStatusToStringMap = { - [CapabilityStatus.EndpointsNotConfigured]: "https://docs.particular.net/nservicebus/recoverability/", + [CapabilityStatus.PartiallyUnavailable]: "https://docs.particular.net/servicecontrol/troubleshooting", [CapabilityStatus.Available]: "#/failed-messages", }; enum ErrorIndicatorTooltip { InstanceAvailable = "The ServiceControl Error instance is configured and available", InstanceUnavailable = "The ServiceControl Error instance is not responding", - FailedMessagesAvailable = "Failed messages have been received and are available for management", - FailedMessagesUnavailable = "No failed messages have been received yet", } export function useErrorCapability(): CapabilityComposable { const { getIconForStatus, getDescriptionForStatus, getHelpButtonTextForStatus, getHelpButtonUrlForStatus, createIndicator } = useCapabilityBase(); - // This tells us the connection state to the ServiceControl Error instance - // and the failed message count. Auto refreshed every 5 seconds. + // This tells us the connection state to the primary ServiceControl Error instance. + // Auto refreshed every 5 seconds. const connectionsStore = useConnectionsAndStatsStore(); const connectionState = connectionsStore.connectionState; - const { failedMessageCount } = storeToRefs(connectionsStore); - // Determine if there are any failed messages - const hasFailedMessages = computed(() => failedMessageCount.value > 0); + // This gives us version information for the primary ServiceControl instance + const environmentStore = useEnvironmentAndVersionsStore(); + const { environment } = storeToRefs(environmentStore); + + // This gives us the list of secondary remote instances configured in ServiceControl. + // Uses auto-refresh to periodically check status (every 5 seconds) + const { store: remoteInstancesStore } = useRemoteInstancesAutoRefresh(); + const { remoteInstances } = storeToRefs(remoteInstancesStore); + + // Filter secondary instances to only include error instances (those with error_retention_period in configuration) + const secondaryErrorInstances = computed(() => filterErrorInstances(remoteInstances.value)); + + // Check if primary instance is connected + const isPrimaryConnected = computed(() => connectionState.connected && !connectionState.unableToConnect); + + // Total instance count (primary + secondary error instances) + const totalInstanceCount = computed(() => 1 + secondaryErrorInstances.value.length); + + // Count of available instances + const availableInstanceCount = computed(() => { + let count = isPrimaryConnected.value ? 1 : 0; + count += secondaryErrorInstances.value.filter((instance) => instance.status === RemoteInstanceStatus.Online).length; + return count; + }); // Determine overall error status const errorStatus = computed(() => { - const connectionSuccessful = connectionState.connected && !connectionState.unableToConnect; - - // 1. Check if we are connected to the error instance - if (!connectionSuccessful) { + // 1. Check if primary instance is unavailable and all secondary error instances are unavailable + if (!isPrimaryConnected.value && allErrorInstancesUnavailable(secondaryErrorInstances.value)) { return CapabilityStatus.Unavailable; } - // 2. Check if there are any failed messages - if (!hasFailedMessages.value) { - return CapabilityStatus.EndpointsNotConfigured; + // 2. Check if some but not all instances are unavailable (partially unavailable) + if (availableInstanceCount.value > 0 && availableInstanceCount.value < totalInstanceCount.value) { + return CapabilityStatus.PartiallyUnavailable; } - // 3. If connected and has failed messages, the error instance is fully available + // 3. All instances are available return CapabilityStatus.Available; }); @@ -73,15 +120,20 @@ export function useErrorCapability(): CapabilityComposable { const errorIndicators = computed(() => { const indicators: StatusIndicator[] = []; - const connectionSuccessful = connectionState.connected && !connectionState.unableToConnect; - const instanceTooltip = connectionSuccessful ? ErrorIndicatorTooltip.InstanceAvailable : ErrorIndicatorTooltip.InstanceUnavailable; + // Add indicator for primary instance + const primaryLabel = totalInstanceCount.value > 1 ? "Primary" : "Instance"; + const primaryTooltip = isPrimaryConnected.value ? ErrorIndicatorTooltip.InstanceAvailable : ErrorIndicatorTooltip.InstanceUnavailable; + indicators.push(createIndicator(primaryLabel, isPrimaryConnected.value ? CapabilityStatus.Available : CapabilityStatus.Unavailable, primaryTooltip, serviceControlClient.url, environment.value.sc_version)); - indicators.push(createIndicator("Instance", connectionSuccessful ? CapabilityStatus.Available : CapabilityStatus.Unavailable, instanceTooltip)); + // Add an indicator for each secondary error instance + if (secondaryErrorInstances.value.length > 0) { + secondaryErrorInstances.value.forEach((instance, index) => { + const isAvailable = instance.status === RemoteInstanceStatus.Online; + const label = `Instance ${index + 2}`; // Start at 2 since primary is Instance 1 + const tooltip = isAvailable ? ErrorIndicatorTooltip.InstanceAvailable : ErrorIndicatorTooltip.InstanceUnavailable; - // Only show failed messages indicator if instance is connected - if (connectionSuccessful) { - const messagesIndicatorTooltip = hasFailedMessages.value ? ErrorIndicatorTooltip.FailedMessagesAvailable : ErrorIndicatorTooltip.FailedMessagesUnavailable; - indicators.push(createIndicator("Failed Messages", hasFailedMessages.value ? CapabilityStatus.Available : CapabilityStatus.EndpointsNotConfigured, messagesIndicatorTooltip)); + indicators.push(createIndicator(label, isAvailable ? CapabilityStatus.Available : CapabilityStatus.Unavailable, tooltip, instance.api_uri, instance.version)); + }); } return indicators; diff --git a/src/Frontend/src/components/platformcapabilities/capabilities/MonitoringCapability.ts b/src/Frontend/src/components/platformcapabilities/capabilities/MonitoringCapability.ts index 3628b1fb0..72164157b 100644 --- a/src/Frontend/src/components/platformcapabilities/capabilities/MonitoringCapability.ts +++ b/src/Frontend/src/components/platformcapabilities/capabilities/MonitoringCapability.ts @@ -6,6 +6,7 @@ import { useConnectionsAndStatsStore } from "@/stores/ConnectionsAndStatsStore"; import useMonitoringStoreAutoRefresh from "@/composables/useMonitoringStoreAutoRefresh"; import { type CapabilityComposable, type CapabilityStatusToStringMap, useCapabilityBase } from "./BaseCapability"; import monitoringClient from "@/components/monitoring/monitoringClient"; +import { useEnvironmentAndVersionsStore } from "@/stores/EnvironmentAndVersionsStore"; const MonitoringDescriptions: CapabilityStatusToStringMap = { [CapabilityStatus.EndpointsNotConfigured]: @@ -52,6 +53,10 @@ export function useMonitoringCapability(): CapabilityComposable { const connectionsStore = useConnectionsAndStatsStore(); const monitoringConnectionState = connectionsStore.monitoringConnectionState; + // this gives us version information for the monitoring instance + const environmentStore = useEnvironmentAndVersionsStore(); + const { environment } = storeToRefs(environmentStore); + // Determine overall monitoring status const monitoringStatus = computed(() => { const isConfiguredInServicePulse = isMonitoringEnabled; @@ -99,7 +104,7 @@ export function useMonitoringCapability(): CapabilityComposable { const instanceTooltip = instanceAvailable ? MonitoringIndicatorTooltip.InstanceAvailable : !isMonitoringEnabled ? MonitoringIndicatorTooltip.InstanceNotConfigured : MonitoringIndicatorTooltip.InstanceUnavailable; if (isMonitoringEnabled) { - indicators.push(createIndicator("Instance", instanceAvailable ? CapabilityStatus.Available : CapabilityStatus.Unavailable, instanceTooltip)); + indicators.push(createIndicator("Instance", instanceAvailable ? CapabilityStatus.Available : CapabilityStatus.Unavailable, instanceTooltip, monitoringClient.url, environment.value.monitoring_version)); } // data available indicator - only show if instance is connected diff --git a/src/Frontend/src/components/platformcapabilities/wizards/ErrorWizardPages.ts b/src/Frontend/src/components/platformcapabilities/wizards/ErrorWizardPages.ts deleted file mode 100644 index 84aacc639..000000000 --- a/src/Frontend/src/components/platformcapabilities/wizards/ErrorWizardPages.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { WizardPage } from "../types"; -import { CapabilityStatus } from "../constants"; - -const ErrorEndpointsNotConfiguredPages: WizardPage[] = [ - { - title: "Configure Recoverability", - content: ` -

Your ServiceControl Error instance is connected, but no failed messages have been received yet. This could be because no message processing failures have occurred, endpoints are not configured to send failed messages to ServiceControl, or endpoints are not currently running.

-

To ensure failed messages appear in ServicePulse when failures occur, configure recoverability in your NServiceBus endpoints:

-
    -
  • Configure the error queue - Tell your endpoints where to send failed messages
  • -
  • Deploy your changes - Restart endpoints with the new configuration
  • -
- `, - learnMoreUrl: "https://docs.particular.net/nservicebus/recoverability/", - learnMoreText: "Learn about recoverability", - }, - { - title: "Configure Your Endpoints", - content: ` -

Add error queue configuration to your endpoint setup code:

-

endpointConfiguration.SendFailedMessagesTo("error");

-

Make sure the error queue name matches what your ServiceControl Error instance is monitoring.

- `, - learnMoreUrl: "https://docs.particular.net/nservicebus/recoverability/configure-error-handling", - learnMoreText: "View configuration options", - }, - { - title: "Verify Your Setup", - content: ` -

Once configured:

-
    -
  • Trigger a test failure - Send a message that will fail processing
  • -
  • Check this capability card - it will automatically update when failures are detected
  • -
  • View the Failed Messages tab to see and manage your failed messages
  • -
-

If failed messages don't appear after a few minutes, check your endpoint logs and ServiceControl logs for any errors.

- `, - learnMoreUrl: "https://docs.particular.net/servicecontrol/troubleshooting", - learnMoreText: "Troubleshooting guide", - }, -]; - -export function getErrorWizardPages(status: CapabilityStatus): WizardPage[] { - switch (status) { - case CapabilityStatus.EndpointsNotConfigured: - return ErrorEndpointsNotConfiguredPages; - default: - return []; - } -} diff --git a/src/Frontend/src/resources/RemoteInstance.ts b/src/Frontend/src/resources/RemoteInstance.ts index a4a22329f..8eff3e4cc 100644 --- a/src/Frontend/src/resources/RemoteInstance.ts +++ b/src/Frontend/src/resources/RemoteInstance.ts @@ -1,7 +1,25 @@ +export interface RemoteInstanceDataRetention { + audit_retention_period?: string; + error_retention_period?: string; +} + +export interface RemoteInstanceConfiguration { + data_retention?: RemoteInstanceDataRetention; +} + +export enum RemoteInstanceType { + Audit = "audit", + Error = "error", + Unknown = "unknown", +} + export interface RemoteInstance { api_uri: string; version: string; status: RemoteInstanceStatus; + configuration?: RemoteInstanceConfiguration; + /** Cached instance type - determined when the instance was last online */ + cachedInstanceType?: RemoteInstanceType; } export enum RemoteInstanceStatus { diff --git a/src/Frontend/src/stores/PlatformCapabilitiesStore.ts b/src/Frontend/src/stores/PlatformCapabilitiesStore.ts new file mode 100644 index 000000000..ec5f79176 --- /dev/null +++ b/src/Frontend/src/stores/PlatformCapabilitiesStore.ts @@ -0,0 +1,90 @@ +import { acceptHMRUpdate, defineStore } from "pinia"; +import { ref, watch } from "vue"; + +const STORAGE_KEY = "servicepulse-platform-capabilities-visibility"; + +interface PlatformCapabilitiesVisibility { + showSection: boolean; + showAuditingCard: boolean; + showMonitoringCard: boolean; + showErrorCard: boolean; +} + +const defaultVisibility: PlatformCapabilitiesVisibility = { + showSection: true, + showAuditingCard: true, + showMonitoringCard: true, + showErrorCard: true, +}; + +function loadVisibility(): PlatformCapabilitiesVisibility { + try { + const stored = localStorage.getItem(STORAGE_KEY); + if (stored) { + const parsed = JSON.parse(stored) as Partial; + return { ...defaultVisibility, ...parsed }; + } + } catch { + // Ignore parse errors, use defaults + } + return { ...defaultVisibility }; +} + +function saveVisibility(visibility: PlatformCapabilitiesVisibility): void { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(visibility)); + } catch { + // Ignore storage errors + } +} + +export const usePlatformCapabilitiesStore = defineStore("PlatformCapabilitiesStore", () => { + const visibility = ref(loadVisibility()); + + // Watch for changes and persist to localStorage + watch( + visibility, + (newValue) => { + saveVisibility(newValue); + }, + { deep: true } + ); + + function toggleSection() { + visibility.value.showSection = !visibility.value.showSection; + } + + function toggleAuditingCard() { + visibility.value.showAuditingCard = !visibility.value.showAuditingCard; + } + + function toggleMonitoringCard() { + visibility.value.showMonitoringCard = !visibility.value.showMonitoringCard; + } + + function toggleErrorCard() { + visibility.value.showErrorCard = !visibility.value.showErrorCard; + } + + function showAll() { + visibility.value.showSection = true; + visibility.value.showAuditingCard = true; + visibility.value.showMonitoringCard = true; + visibility.value.showErrorCard = true; + } + + return { + visibility, + toggleSection, + toggleAuditingCard, + toggleMonitoringCard, + toggleErrorCard, + showAll, + }; +}); + +if (import.meta.hot) { + import.meta.hot.accept(acceptHMRUpdate(usePlatformCapabilitiesStore, import.meta.hot)); +} + +export type PlatformCapabilitiesStore = ReturnType; diff --git a/src/Frontend/src/stores/RemoteInstancesStore.ts b/src/Frontend/src/stores/RemoteInstancesStore.ts index 4ece3249d..cdda3508d 100644 --- a/src/Frontend/src/stores/RemoteInstancesStore.ts +++ b/src/Frontend/src/stores/RemoteInstancesStore.ts @@ -1,14 +1,93 @@ import { acceptHMRUpdate, defineStore } from "pinia"; import { ref } from "vue"; -import { RemoteInstance } from "@/resources/RemoteInstance"; +import { RemoteInstance, RemoteInstanceType, RemoteInstanceStatus } from "@/resources/RemoteInstance"; import serviceControlClient from "@/components/serviceControlClient"; +const INSTANCE_TYPE_CACHE_KEY = "servicepulse-remote-instance-types"; + +/** + * Determines the instance type based on configuration data retention settings + */ +function determineInstanceType(instance: RemoteInstance): RemoteInstanceType { + if (instance.configuration?.data_retention?.audit_retention_period !== undefined) { + return RemoteInstanceType.Audit; + } + if (instance.configuration?.data_retention?.error_retention_period !== undefined) { + return RemoteInstanceType.Error; + } + return RemoteInstanceType.Unknown; +} + +/** + * Load instance type cache from localStorage + */ +function loadInstanceTypeCache(): Map { + try { + const cached = localStorage.getItem(INSTANCE_TYPE_CACHE_KEY); + if (cached) { + const parsed = JSON.parse(cached) as Record; + return new Map(Object.entries(parsed)); + } + } catch { + // Ignore parse errors, start with empty cache + } + return new Map(); +} + +/** + * Save instance type cache to localStorage + */ +function saveInstanceTypeCache(cache: Map): void { + try { + const obj = Object.fromEntries(cache); + localStorage.setItem(INSTANCE_TYPE_CACHE_KEY, JSON.stringify(obj)); + } catch { + // Ignore storage errors + } +} + export const useRemoteInstancesStore = defineStore("RemoteInstancesStore", () => { const remoteInstances = ref(null); + // Cache to store instance types by api_uri - persists in localStorage across page refreshes + const instanceTypeCache = loadInstanceTypeCache(); + async function refresh() { const response = await serviceControlClient.fetchFromServiceControl("configuration/remotes"); - remoteInstances.value = await response.json(); + const instances: RemoteInstance[] = await response.json(); + + let cacheUpdated = false; + + // Process each instance to determine and cache its type + for (const instance of instances) { + // If the instance is online, determine its type from configuration + if (instance.status === RemoteInstanceStatus.Online) { + const instanceType = determineInstanceType(instance); + if (instanceType !== RemoteInstanceType.Unknown) { + const existingType = instanceTypeCache.get(instance.api_uri); + if (existingType !== instanceType) { + instanceTypeCache.set(instance.api_uri, instanceType); + cacheUpdated = true; + } + } + } + + // Apply cached type to the instance (whether online or offline) + const cachedType = instanceTypeCache.get(instance.api_uri); + if (cachedType) { + instance.cachedInstanceType = cachedType; + } else { + // If no cached type and instance is online, try to determine it now + instance.cachedInstanceType = determineInstanceType(instance); + } + } + + // Persist cache to localStorage if it was updated + if (cacheUpdated) { + saveInstanceTypeCache(instanceTypeCache); + } + + remoteInstances.value = instances; } refresh(); From 40880a7f61a8ce326bd077a7efea3a3e49bf6584 Mon Sep 17 00:00:00 2001 From: Warwick Schroeder Date: Thu, 4 Dec 2025 14:54:38 +0800 Subject: [PATCH 26/26] Add tests for capability cards --- .../test/preconditions/auditCapability.ts | 153 ++++++++++ src/Frontend/test/preconditions/index.ts | 3 + .../preconditions/monitoringCapability.ts | 70 +++++ .../preconditions/recoverabilityCapability.ts | 74 +++++ .../audit-capability-card.spec.ts | 263 ++++++++++++++++++ .../monitoring-capability-card.spec.ts | 107 +++++++ .../questions/auditCapabilityCard.ts | 164 +++++++++++ .../questions/monitoringCapabilityCard.ts | 97 +++++++ .../questions/recoverabilityCapabilityCard.ts | 97 +++++++ .../recoverability-capability-card.spec.ts | 191 +++++++++++++ 10 files changed, 1219 insertions(+) create mode 100644 src/Frontend/test/preconditions/auditCapability.ts create mode 100644 src/Frontend/test/preconditions/monitoringCapability.ts create mode 100644 src/Frontend/test/preconditions/recoverabilityCapability.ts create mode 100644 src/Frontend/test/specs/platformcapabilities/audit-capability-card.spec.ts create mode 100644 src/Frontend/test/specs/platformcapabilities/monitoring-capability-card.spec.ts create mode 100644 src/Frontend/test/specs/platformcapabilities/questions/auditCapabilityCard.ts create mode 100644 src/Frontend/test/specs/platformcapabilities/questions/monitoringCapabilityCard.ts create mode 100644 src/Frontend/test/specs/platformcapabilities/questions/recoverabilityCapabilityCard.ts create mode 100644 src/Frontend/test/specs/platformcapabilities/recoverability-capability-card.spec.ts diff --git a/src/Frontend/test/preconditions/auditCapability.ts b/src/Frontend/test/preconditions/auditCapability.ts new file mode 100644 index 000000000..67a05ecd3 --- /dev/null +++ b/src/Frontend/test/preconditions/auditCapability.ts @@ -0,0 +1,153 @@ +import { RemoteInstance, RemoteInstanceStatus, RemoteInstanceType } from "@/resources/RemoteInstance"; +import Message, { MessageStatus } from "@/resources/Message"; +import { SetupFactoryOptions } from "../driver"; + +/** + * Creates a remote audit instance with the given configuration + */ +export function createAuditInstance(options: { apiUri?: string; version?: string; status?: RemoteInstanceStatus; retentionPeriod?: string } = {}): RemoteInstance { + const { apiUri = "http://localhost:33334/api/", version = "6.6.0", status = RemoteInstanceStatus.Online, retentionPeriod = "7.00:00:00" } = options; + + return { + api_uri: apiUri, + version, + status, + configuration: { + data_retention: { + audit_retention_period: retentionPeriod, + }, + }, + cachedInstanceType: RemoteInstanceType.Audit, + }; +} + +/** + * Creates a remote error instance with the given configuration + */ +export function createErrorInstance(options: { apiUri?: string; version?: string; status?: RemoteInstanceStatus; retentionPeriod?: string } = {}): RemoteInstance { + const { apiUri = "http://localhost:33335/api/", version = "6.6.0", status = RemoteInstanceStatus.Online, retentionPeriod = "15.00:00:00" } = options; + + return { + api_uri: apiUri, + version, + status, + configuration: { + data_retention: { + error_retention_period: retentionPeriod, + }, + }, + cachedInstanceType: RemoteInstanceType.Error, + }; +} + +/** + * Creates a successful message for testing + */ +export function createSuccessfulMessage(id: string = "msg-1"): Message { + return { + id, + message_id: id, + message_type: "TestMessage", + sending_endpoint: { name: "Sender", host_id: "host-1", host: "localhost" }, + receiving_endpoint: { name: "Receiver", host_id: "host-2", host: "localhost" }, + time_sent: new Date().toISOString(), + processed_at: new Date().toISOString(), + critical_time: "00:00:00.1234567", + processing_time: "00:00:00.0123456", + delivery_time: "00:00:00.0012345", + is_system_message: false, + conversation_id: "conv-1", + headers: [], + status: MessageStatus.Successful, + message_intent: "send" as never, + body_url: "", + body_size: 100, + instance_id: "instance-1", + }; +} + +/** + * Precondition: No audit instances configured (no remote instances at all) + */ +export const hasNoAuditInstances = ({ driver }: SetupFactoryOptions) => { + driver.mockEndpoint(`${window.defaultConfig.service_control_url}configuration/remotes`, { + body: [], + }); +}; + +/** + * Precondition: Single audit instance that is online + */ +export const hasAvailableAuditInstance = + (auditInstance: RemoteInstance = createAuditInstance()) => + ({ driver }: SetupFactoryOptions) => { + driver.mockEndpoint(`${window.defaultConfig.service_control_url}configuration/remotes`, { + body: [auditInstance], + }); + }; + +/** + * Precondition: Single audit instance that is unavailable + */ +export const hasUnavailableAuditInstance = ({ driver }: SetupFactoryOptions) => { + const instance = createAuditInstance({ status: RemoteInstanceStatus.Unavailable }); + driver.mockEndpoint(`${window.defaultConfig.service_control_url}configuration/remotes`, { + body: [instance], + }); +}; + +/** + * Precondition: Multiple audit instances with mixed availability + */ +export const hasPartiallyUnavailableAuditInstances = ({ driver }: SetupFactoryOptions) => { + const onlineInstance = createAuditInstance({ apiUri: "http://localhost:33334/api/" }); + const offlineInstance = createAuditInstance({ apiUri: "http://localhost:33336/api/", status: RemoteInstanceStatus.Unavailable }); + driver.mockEndpoint(`${window.defaultConfig.service_control_url}configuration/remotes`, { + body: [onlineInstance, offlineInstance], + }); +}; + +/** + * Precondition: Multiple audit instances all online + */ +export const hasMultipleAvailableAuditInstances = ({ driver }: SetupFactoryOptions) => { + const instance1 = createAuditInstance({ apiUri: "http://localhost:33334/api/" }); + const instance2 = createAuditInstance({ apiUri: "http://localhost:33336/api/" }); + driver.mockEndpoint(`${window.defaultConfig.service_control_url}configuration/remotes`, { + body: [instance1, instance2], + }); +}; + +/** + * Precondition: Has successful messages (endpoints configured for auditing) + */ +export const hasSuccessfulMessages = + (messages: Message[] = [createSuccessfulMessage()]) => + ({ driver }: SetupFactoryOptions) => { + driver.mockEndpointDynamic(`${window.defaultConfig.service_control_url}messages2/*`, "get", () => + Promise.resolve({ + body: messages, + }) + ); + }; + +/** + * Precondition: No successful messages (no endpoints configured for auditing) + */ +export const hasNoSuccessfulMessages = ({ driver }: SetupFactoryOptions) => { + driver.mockEndpointDynamic(`${window.defaultConfig.service_control_url}messages2/*`, "get", () => + Promise.resolve({ + body: [], + }) + ); +}; + +/** + * ServiceControl version that supports All Messages feature (>= 6.6.0) + */ +export const serviceControlVersionSupportingAllMessages = "6.6.0"; + +/** + * ServiceControl version that does NOT support All Messages feature (< 6.6.0) + */ +export const serviceControlVersionNotSupportingAllMessages = "6.5.0"; diff --git a/src/Frontend/test/preconditions/index.ts b/src/Frontend/test/preconditions/index.ts index b2a0bcc10..a23461d64 100644 --- a/src/Frontend/test/preconditions/index.ts +++ b/src/Frontend/test/preconditions/index.ts @@ -21,3 +21,6 @@ export { hasLicensingSettingTest } from "../preconditions/hasLicensingSettingTes export { hasLicensingEndpoints } from "../preconditions/hasLicensingEndpoints"; export { hasEndpointSettings } from "./hasEndpointSettings"; export * from "./configuration"; +export * from "./auditCapability"; +export * from "./monitoringCapability"; +export * from "./recoverabilityCapability"; diff --git a/src/Frontend/test/preconditions/monitoringCapability.ts b/src/Frontend/test/preconditions/monitoringCapability.ts new file mode 100644 index 000000000..12a796400 --- /dev/null +++ b/src/Frontend/test/preconditions/monitoringCapability.ts @@ -0,0 +1,70 @@ +import { Endpoint } from "@/resources/MonitoringEndpoint"; +import { monitoredEndpointTemplate } from "../mocks/monitored-endpoint-template"; +import { SetupFactoryOptions } from "../driver"; + +/** + * Creates a monitored endpoint with the given name + */ +export function createMonitoredEndpoint(name: string = "TestEndpoint"): Endpoint { + return { + ...monitoredEndpointTemplate, + name, + }; +} + +/** + * Precondition: Monitoring instance is available with monitored endpoints + * This sets up a successful monitoring scenario + */ +export const hasMonitoringWithEndpoints = + (endpoints: Endpoint[] = [createMonitoredEndpoint()]) => + ({ driver }: SetupFactoryOptions) => { + const monitoringInstanceUrl = window.defaultConfig.monitoring_urls[0]; + driver.mockEndpoint(`${monitoringInstanceUrl}monitored-endpoints`, { + body: endpoints, + }); + }; + +/** + * Precondition: Monitoring instance is available but no endpoints are sending data + */ +export const hasMonitoringWithNoEndpoints = ({ driver }: SetupFactoryOptions) => { + const monitoringInstanceUrl = window.defaultConfig.monitoring_urls[0]; + driver.mockEndpoint(`${monitoringInstanceUrl}monitored-endpoints`, { + body: [], + }); +}; + +/** + * Precondition: Monitoring instance is unavailable (returns error) + * Note: This simulates an unavailable monitoring instance by not mocking the endpoint, + * which will cause the connection to fail + */ +export const hasMonitoringUnavailable = ({ driver }: SetupFactoryOptions) => { + const monitoringInstanceUrl = window.defaultConfig.monitoring_urls[0]; + // Return a 500 error to simulate unavailable monitoring + driver.mockEndpoint(monitoringInstanceUrl, { + body: { error: "Service unavailable" }, + status: 500, + }); + driver.mockEndpoint(`${monitoringInstanceUrl}monitored-endpoints`, { + body: { error: "Service unavailable" }, + status: 500, + }); +}; + +/** + * Precondition: Multiple monitored endpoints + */ +export const hasMultipleMonitoredEndpoints = + (count: number = 3) => + ({ driver }: SetupFactoryOptions) => { + const endpoints: Endpoint[] = []; + for (let i = 0; i < count; i++) { + endpoints.push(createMonitoredEndpoint(`Endpoint${i + 1}`)); + } + const monitoringInstanceUrl = window.defaultConfig.monitoring_urls[0]; + driver.mockEndpoint(`${monitoringInstanceUrl}monitored-endpoints`, { + body: endpoints, + }); + }; diff --git a/src/Frontend/test/preconditions/recoverabilityCapability.ts b/src/Frontend/test/preconditions/recoverabilityCapability.ts new file mode 100644 index 000000000..277bdffe5 --- /dev/null +++ b/src/Frontend/test/preconditions/recoverabilityCapability.ts @@ -0,0 +1,74 @@ +import { RemoteInstance, RemoteInstanceStatus, RemoteInstanceType } from "@/resources/RemoteInstance"; +import { SetupFactoryOptions } from "../driver"; + +/** + * Creates a remote error/recoverability instance with the given configuration + */ +export function createRecoverabilityInstance(options: { apiUri?: string; version?: string; status?: RemoteInstanceStatus; retentionPeriod?: string } = {}): RemoteInstance { + const { apiUri = "http://localhost:33335/api/", version = "6.6.0", status = RemoteInstanceStatus.Online, retentionPeriod = "15.00:00:00" } = options; + + return { + api_uri: apiUri, + version, + status, + configuration: { + data_retention: { + error_retention_period: retentionPeriod, + }, + }, + cachedInstanceType: RemoteInstanceType.Error, + }; +} + +/** + * Precondition: Primary ServiceControl instance is available (no secondary error instances) + * This is the default state set by serviceControlWithMonitoring + */ +export const hasPrimaryErrorInstanceOnly = ({ driver }: SetupFactoryOptions) => { + driver.mockEndpoint(`${window.defaultConfig.service_control_url}configuration/remotes`, { + body: [], + }); +}; + +/** + * Precondition: Primary instance available with secondary error instance also available + */ +export const hasSecondaryErrorInstance = + (errorInstance: RemoteInstance = createRecoverabilityInstance()) => + ({ driver }: SetupFactoryOptions) => { + driver.mockEndpoint(`${window.defaultConfig.service_control_url}configuration/remotes`, { + body: [errorInstance], + }); + }; + +/** + * Precondition: Primary instance available with secondary error instance unavailable + * This creates a "Degraded" state + */ +export const hasSecondaryErrorInstanceUnavailable = ({ driver }: SetupFactoryOptions) => { + const instance = createRecoverabilityInstance({ status: RemoteInstanceStatus.Unavailable }); + driver.mockEndpoint(`${window.defaultConfig.service_control_url}configuration/remotes`, { + body: [instance], + }); +}; + +/** + * Precondition: Multiple secondary error instances with mixed availability + */ +export const hasMultipleSecondaryErrorInstances = + (instances: RemoteInstance[] = [createRecoverabilityInstance({ apiUri: "http://localhost:33335/api/" }), createRecoverabilityInstance({ apiUri: "http://localhost:33336/api/" })]) => + ({ driver }: SetupFactoryOptions) => { + driver.mockEndpoint(`${window.defaultConfig.service_control_url}configuration/remotes`, { + body: instances, + }); + }; + +/** + * Precondition: Multiple secondary error instances with one unavailable (degraded state) + */ +export const hasMultipleSecondaryErrorInstancesPartiallyUnavailable = ({ driver }: SetupFactoryOptions) => { + const instances = [createRecoverabilityInstance({ apiUri: "http://localhost:33335/api/", status: RemoteInstanceStatus.Online }), createRecoverabilityInstance({ apiUri: "http://localhost:33336/api/", status: RemoteInstanceStatus.Unavailable })]; + driver.mockEndpoint(`${window.defaultConfig.service_control_url}configuration/remotes`, { + body: instances, + }); +}; diff --git a/src/Frontend/test/specs/platformcapabilities/audit-capability-card.spec.ts b/src/Frontend/test/specs/platformcapabilities/audit-capability-card.spec.ts new file mode 100644 index 000000000..efaa37f24 --- /dev/null +++ b/src/Frontend/test/specs/platformcapabilities/audit-capability-card.spec.ts @@ -0,0 +1,263 @@ +import { test, describe } from "../../drivers/vitest/driver"; +import { expect } from "vitest"; +import * as precondition from "../../preconditions"; +import { waitFor } from "@testing-library/vue"; +import { + auditingCapabilityCard, + auditingStatusBadge, + auditingActionButton, + auditingStatusIndicators, + isAuditingCardAvailable, + isAuditingCardUnavailable, + isAuditingCardPartiallyUnavailable, + isAuditingCardNotConfigured, + auditingIndicatorByLabel, +} from "./questions/auditCapabilityCard"; + +describe("FEATURE: Audit capability card", () => { + describe("RULE: When no audit instance is configured, show 'Instance Not Configured' status", () => { + test("EXAMPLE: No remote audit instances configured shows 'Get Started' button", async ({ driver }) => { + // Arrange + await driver.setUp(precondition.serviceControlWithMonitoring); + await driver.setUp(precondition.hasNoAuditInstances); + await driver.setUp(precondition.hasNoSuccessfulMessages); + + // Act + await driver.goTo("/"); + + // Assert + await waitFor(async () => { + const card = await auditingCapabilityCard(); + expect(card).toBeInTheDocument(); + }); + + await waitFor(async () => { + expect(await isAuditingCardNotConfigured()).toBe(true); + }); + + const actionButton = await auditingActionButton(); + expect(actionButton).toBeInTheDocument(); + expect(actionButton?.textContent).toMatch(/Get Started/i); + }); + }); + + describe("RULE: When audit instance is configured but unavailable, show 'Unavailable' status", () => { + test("EXAMPLE: Single audit instance that is offline shows unavailable status", async ({ driver }) => { + // Arrange + await driver.setUp(precondition.serviceControlWithMonitoring); + await driver.setUp(precondition.hasUnavailableAuditInstance); + await driver.setUp(precondition.hasNoSuccessfulMessages); + + // Act + await driver.goTo("/"); + + // Assert + await waitFor(async () => { + const card = await auditingCapabilityCard(); + expect(card).toBeInTheDocument(); + }); + + await waitFor(async () => { + expect(await isAuditingCardUnavailable()).toBe(true); + }); + + const statusBadge = await auditingStatusBadge(); + expect(statusBadge).toBeInTheDocument(); + expect(statusBadge?.textContent).toMatch(/Unavailable/i); + + const actionButton = await auditingActionButton(); + expect(actionButton).toBeInTheDocument(); + expect(actionButton?.textContent).toMatch(/Learn More/i); + }); + }); + + describe("RULE: When some audit instances are unavailable, show 'Degraded' status", () => { + test("EXAMPLE: Multiple audit instances with mixed availability shows degraded status", async ({ driver }) => { + // Arrange + await driver.setUp(precondition.serviceControlWithMonitoring); + await driver.setUp(precondition.hasPartiallyUnavailableAuditInstances); + await driver.setUp(precondition.hasSuccessfulMessages()); + + // Act + await driver.goTo("/"); + + // Assert + await waitFor(async () => { + const card = await auditingCapabilityCard(); + expect(card).toBeInTheDocument(); + }); + + await waitFor(async () => { + expect(await isAuditingCardPartiallyUnavailable()).toBe(true); + }); + + const statusBadge = await auditingStatusBadge(); + expect(statusBadge).toBeInTheDocument(); + expect(statusBadge?.textContent).toMatch(/Degraded/i); + }); + }); + + describe("RULE: When audit instance is available but no messages exist, show 'Endpoints Not Configured' status", () => { + test("EXAMPLE: Audit instance available but no successful messages shows not configured status", async ({ driver }) => { + // Arrange + await driver.setUp(precondition.serviceControlWithMonitoring); + await driver.setUp(precondition.hasAvailableAuditInstance()); + await driver.setUp(precondition.hasNoSuccessfulMessages); + + // Act + await driver.goTo("/"); + + // Assert + await waitFor(async () => { + const card = await auditingCapabilityCard(); + expect(card).toBeInTheDocument(); + }); + + await waitFor(async () => { + expect(await isAuditingCardNotConfigured()).toBe(true); + }); + + const actionButton = await auditingActionButton(); + expect(actionButton).toBeInTheDocument(); + expect(actionButton?.textContent).toMatch(/Learn More/i); + }); + }); + + describe("RULE: When audit instance is available and messages exist, show 'Available' status", () => { + test("EXAMPLE: Audit instance available with successful messages shows available status", async ({ driver }) => { + // Arrange + // Need to set up ServiceControl with version >= 6.6.0 for "All Messages" feature support + await driver.setUp(precondition.hasActiveLicense); + await driver.setUp(precondition.hasLicensingSettingTest()); + await driver.setUp(precondition.hasServiceControlMainInstance(precondition.serviceControlVersionSupportingAllMessages)); + await driver.setUp(precondition.hasServiceControlMonitoringInstance); + await driver.setUp(precondition.hasUpToDateServiceControl); + await driver.setUp(precondition.hasUpToDateServicePulse); + await driver.setUp(precondition.errorsDefaultHandler); + await driver.setUp(precondition.hasCustomChecksEmpty); + await driver.setUp(precondition.hasNoDisconnectedEndpoints); + await driver.setUp(precondition.hasEventLogItems); + await driver.setUp(precondition.hasRecoverabilityGroups); + await driver.setUp(precondition.hasNoHeartbeatsEndpoints); + await driver.setUp(precondition.hasNoMonitoredEndpoints); + await driver.setUp(precondition.endpointRecoverabilityByInstanceDefaultHandler); + await driver.setUp(precondition.endpointRecoverabilityByNameDefaultHandler); + await driver.setUp(precondition.serviceControlMonitoringOptions); + await driver.setUp(precondition.serviceControlConfigurationDefaultHandler); + await driver.setUp(precondition.recoverabilityClassifiers); + await driver.setUp(precondition.recoverabilityHistoryDefaultHandler); + await driver.setUp(precondition.recoverabilityEditConfigDefaultHandler); + await driver.setUp(precondition.archivedGroupsWithClassifierDefaulthandler); + await driver.setUp(precondition.recoverabilityGroupsWithClassifierDefaulthandler); + await driver.setUp(precondition.hasLicensingReportAvailable()); + await driver.setUp(precondition.hasLicensingEndpoints()); + await driver.setUp(precondition.hasEndpointSettings([])); + await driver.setUp(precondition.redirectsDefaultHandler); + await driver.setUp(precondition.knownQueuesDefaultHandler); + await driver.setUp(precondition.hasAvailableAuditInstance()); + await driver.setUp(precondition.hasSuccessfulMessages()); + + // Act + await driver.goTo("/"); + + // Assert + await waitFor(async () => { + const card = await auditingCapabilityCard(); + expect(card).toBeInTheDocument(); + }); + + await waitFor(async () => { + expect(await isAuditingCardAvailable()).toBe(true); + }); + + const statusBadge = await auditingStatusBadge(); + expect(statusBadge).toBeInTheDocument(); + expect(statusBadge?.textContent).toMatch(/Available/i); + + const actionButton = await auditingActionButton(); + expect(actionButton).toBeInTheDocument(); + expect(actionButton?.textContent).toMatch(/View Messages/i); + }); + }); + + describe("RULE: Status indicators should show instance and message status", () => { + test("EXAMPLE: Available audit instance shows instance indicator as green", async ({ driver }) => { + // Arrange + await driver.setUp(precondition.serviceControlWithMonitoring); + await driver.setUp(precondition.hasAvailableAuditInstance()); + await driver.setUp(precondition.hasSuccessfulMessages()); + + // Act + await driver.goTo("/"); + + // Assert + await waitFor(async () => { + const card = await auditingCapabilityCard(); + expect(card).toBeInTheDocument(); + }); + + await waitFor(async () => { + const indicators = await auditingStatusIndicators(); + expect(indicators).not.toBeNull(); + expect(indicators!.length).toBeGreaterThanOrEqual(1); + }); + + const instanceIndicator = await auditingIndicatorByLabel("Instance"); + expect(instanceIndicator).toBeInTheDocument(); + }); + + test("EXAMPLE: Available audit instance with successful messages shows messages indicator", async ({ driver }) => { + // Arrange + await driver.setUp(precondition.serviceControlWithMonitoring); + await driver.setUp(precondition.hasAvailableAuditInstance()); + await driver.setUp(precondition.hasSuccessfulMessages()); + + // Act + await driver.goTo("/"); + + // Assert + await waitFor(async () => { + const card = await auditingCapabilityCard(); + expect(card).toBeInTheDocument(); + }); + + await waitFor(async () => { + const messagesIndicator = await auditingIndicatorByLabel("Messages"); + expect(messagesIndicator).toBeInTheDocument(); + }); + }); + + test("EXAMPLE: Multiple audit instances show numbered instance indicators", async ({ driver }) => { + // Arrange + await driver.setUp(precondition.serviceControlWithMonitoring); + await driver.setUp(precondition.hasMultipleAvailableAuditInstances); + await driver.setUp(precondition.hasSuccessfulMessages()); + + // Act + await driver.goTo("/"); + + // Assert + await waitFor(async () => { + const card = await auditingCapabilityCard(); + expect(card).toBeInTheDocument(); + }); + + await waitFor(async () => { + const indicators = await auditingStatusIndicators(); + expect(indicators).not.toBeNull(); + // Should have Instance 1, Instance 2, and Messages indicators + expect(indicators!.length).toBeGreaterThanOrEqual(3); + }); + + const instance1Indicator = await auditingIndicatorByLabel("Instance 1"); + expect(instance1Indicator).toBeInTheDocument(); + + const instance2Indicator = await auditingIndicatorByLabel("Instance 2"); + expect(instance2Indicator).toBeInTheDocument(); + }); + }); + + // Note: Testing ServiceControl version < 6.6.0 requires more complex setup with environment store reset + // The version check happens at app initialization, so changing it mid-test doesn't work without + // resetting the pinia stores. This would be better tested as a component unit test. +}); diff --git a/src/Frontend/test/specs/platformcapabilities/monitoring-capability-card.spec.ts b/src/Frontend/test/specs/platformcapabilities/monitoring-capability-card.spec.ts new file mode 100644 index 000000000..97fb9aab9 --- /dev/null +++ b/src/Frontend/test/specs/platformcapabilities/monitoring-capability-card.spec.ts @@ -0,0 +1,107 @@ +import { test, describe } from "../../drivers/vitest/driver"; +import { expect } from "vitest"; +import * as precondition from "../../preconditions"; +import { waitFor } from "@testing-library/vue"; +import { monitoringCapabilityCard, monitoringStatusBadge, monitoringActionButton, monitoringStatusIndicators, isMonitoringCardAvailable, isMonitoringCardNotConfigured, monitoringIndicatorByLabel } from "./questions/monitoringCapabilityCard"; + +describe("FEATURE: Monitoring capability card", () => { + describe("RULE: When monitoring instance is available with endpoints sending data, show 'Available' status", () => { + test("EXAMPLE: Monitoring instance available with monitored endpoints shows available status", async ({ driver }) => { + // Arrange + await driver.setUp(precondition.serviceControlWithMonitoring); + await driver.setUp(precondition.hasMonitoringWithEndpoints()); + + // Act + await driver.goTo("/"); + + // Assert + await waitFor(async () => { + const card = await monitoringCapabilityCard(); + expect(card).toBeInTheDocument(); + }); + + await waitFor(async () => { + expect(await isMonitoringCardAvailable()).toBe(true); + }); + + const statusBadge = await monitoringStatusBadge(); + expect(statusBadge).toBeInTheDocument(); + expect(statusBadge?.textContent).toMatch(/Available/i); + + const actionButton = await monitoringActionButton(); + expect(actionButton).toBeInTheDocument(); + expect(actionButton?.textContent).toMatch(/View Metrics/i); + }); + }); + + describe("RULE: When monitoring instance is available but no endpoints are sending data, show 'Endpoints Not Configured' status", () => { + test("EXAMPLE: Monitoring instance available but no monitored endpoints shows not configured status", async ({ driver }) => { + // Arrange + await driver.setUp(precondition.serviceControlWithMonitoring); + await driver.setUp(precondition.hasMonitoringWithNoEndpoints); + + // Act + await driver.goTo("/"); + + // Assert + await waitFor(async () => { + const card = await monitoringCapabilityCard(); + expect(card).toBeInTheDocument(); + }); + + await waitFor(async () => { + expect(await isMonitoringCardNotConfigured()).toBe(true); + }); + + const actionButton = await monitoringActionButton(); + expect(actionButton).toBeInTheDocument(); + expect(actionButton?.textContent).toMatch(/Learn More/i); + }); + }); + + describe("RULE: Status indicators should show instance and metrics status", () => { + test("EXAMPLE: Available monitoring instance shows instance indicator", async ({ driver }) => { + // Arrange + await driver.setUp(precondition.serviceControlWithMonitoring); + await driver.setUp(precondition.hasMonitoringWithEndpoints()); + + // Act + await driver.goTo("/"); + + // Assert + await waitFor(async () => { + const card = await monitoringCapabilityCard(); + expect(card).toBeInTheDocument(); + }); + + await waitFor(async () => { + const indicators = await monitoringStatusIndicators(); + expect(indicators).not.toBeNull(); + expect(indicators!.length).toBeGreaterThanOrEqual(1); + }); + + const instanceIndicator = await monitoringIndicatorByLabel("Instance"); + expect(instanceIndicator).toBeInTheDocument(); + }); + + test("EXAMPLE: Available monitoring instance with endpoints shows metrics indicator", async ({ driver }) => { + // Arrange + await driver.setUp(precondition.serviceControlWithMonitoring); + await driver.setUp(precondition.hasMonitoringWithEndpoints()); + + // Act + await driver.goTo("/"); + + // Assert + await waitFor(async () => { + const card = await monitoringCapabilityCard(); + expect(card).toBeInTheDocument(); + }); + + await waitFor(async () => { + const metricsIndicator = await monitoringIndicatorByLabel("Metrics"); + expect(metricsIndicator).toBeInTheDocument(); + }); + }); + }); +}); diff --git a/src/Frontend/test/specs/platformcapabilities/questions/auditCapabilityCard.ts b/src/Frontend/test/specs/platformcapabilities/questions/auditCapabilityCard.ts new file mode 100644 index 000000000..3d7bc8bff --- /dev/null +++ b/src/Frontend/test/specs/platformcapabilities/questions/auditCapabilityCard.ts @@ -0,0 +1,164 @@ +import { screen, within } from "@testing-library/vue"; + +/** + * Gets the platform capabilities section element + */ +export function platformCapabilitiesSection() { + return screen.queryByText("Platform Capabilities")?.closest(".platform-capabilities"); +} + +/** + * Gets the collapsed platform capabilities button + */ +export function platformCapabilitiesCollapsedButton() { + return screen.queryByRole("button", { name: /show platform capabilities/i }); +} + +/** + * Gets all capability cards on the page + */ +export function allCapabilityCards() { + return screen.queryAllByTestId("capability-card"); +} + +/** + * Gets the Auditing capability card by looking for the title + */ +export async function auditingCapabilityCard() { + const cards = await screen.findAllByTestId("capability-card"); + for (const card of cards) { + const title = within(card).queryByText("Auditing"); + if (title) { + return card; + } + } + return null; +} + +/** + * Gets the Auditing capability card synchronously (returns null if not found) + */ +export function auditingCapabilityCardSync() { + const cards = screen.queryAllByTestId("capability-card"); + for (const card of cards) { + const title = within(card).queryByText("Auditing"); + if (title) { + return card; + } + } + return null; +} + +/** + * Gets the status badge from the Auditing capability card + */ +export async function auditingStatusBadge() { + const card = await auditingCapabilityCard(); + if (!card) return null; + return within(card).queryByText(/Available|Unavailable|Degraded/); +} + +/** + * Gets the description text from the Auditing capability card + */ +export async function auditingDescription() { + const card = await auditingCapabilityCard(); + if (!card) return null; + const footer = card.querySelector(".capability-footer"); + if (!footer) return null; + const description = within(footer as HTMLElement).queryByText(/.+/); + return description?.textContent; +} + +/** + * Gets the help/action button from the Auditing capability card + */ +export async function auditingActionButton() { + const card = await auditingCapabilityCard(); + if (!card) return null; + return within(card).queryByRole("button", { name: /Learn More|Get Started|View Messages/i }); +} + +/** + * Gets the status indicators from the Auditing capability card + */ +export async function auditingStatusIndicators() { + const card = await auditingCapabilityCard(); + if (!card) return null; + return within(card).queryAllByTestId("status-indicator"); +} + +/** + * Checks if the Auditing card is in loading state + */ +export async function isAuditingCardLoading() { + const card = await auditingCapabilityCard(); + if (!card) return false; + return card.classList.contains("capability-loading"); +} + +/** + * Gets the loading text from the Auditing capability card + */ +export function auditingLoadingText() { + return screen.queryByText(/Loading Auditing capability status/i); +} + +/** + * Checks if the Auditing card has the "available" styling (green border) + */ +export async function isAuditingCardAvailable() { + const card = await auditingCapabilityCard(); + if (!card) return false; + return card.classList.contains("capability-available"); +} + +/** + * Checks if the Auditing card has the "unavailable" styling (red border) + */ +export async function isAuditingCardUnavailable() { + const card = await auditingCapabilityCard(); + if (!card) return false; + return card.classList.contains("capability-unavailable"); +} + +/** + * Checks if the Auditing card has the "partially unavailable" / degraded styling (yellow border) + */ +export async function isAuditingCardPartiallyUnavailable() { + const card = await auditingCapabilityCard(); + if (!card) return false; + return card.classList.contains("capability-partially-unavailable"); +} + +/** + * Checks if the Auditing card has the "not configured" styling (blue gradient) + */ +export async function isAuditingCardNotConfigured() { + const card = await auditingCapabilityCard(); + if (!card) return false; + return card.classList.contains("capability-notconfigured"); +} + +/** + * Gets the hide card button from the Auditing capability card + */ +export async function auditingHideButton() { + const card = await auditingCapabilityCard(); + if (!card) return null; + return within(card).queryByRole("button", { name: /hide this card/i }) || card.querySelector(".hide-card-btn"); +} + +/** + * Gets the indicator for a specific label (e.g., "Instance", "Messages") + */ +export async function auditingIndicatorByLabel(label: string) { + const indicators = await auditingStatusIndicators(); + if (!indicators) return null; + for (const indicator of indicators) { + if (indicator.textContent?.includes(label)) { + return indicator; + } + } + return null; +} diff --git a/src/Frontend/test/specs/platformcapabilities/questions/monitoringCapabilityCard.ts b/src/Frontend/test/specs/platformcapabilities/questions/monitoringCapabilityCard.ts new file mode 100644 index 000000000..0af7fd8da --- /dev/null +++ b/src/Frontend/test/specs/platformcapabilities/questions/monitoringCapabilityCard.ts @@ -0,0 +1,97 @@ +import { screen, within } from "@testing-library/vue"; + +/** + * Gets the Monitoring capability card by looking for the title + */ +export async function monitoringCapabilityCard() { + const cards = await screen.findAllByTestId("capability-card"); + for (const card of cards) { + const title = within(card).queryByText("Monitoring"); + if (title) { + return card; + } + } + return null; +} + +/** + * Gets the Monitoring capability card synchronously (returns null if not found) + */ +export function monitoringCapabilityCardSync() { + const cards = screen.queryAllByTestId("capability-card"); + for (const card of cards) { + const title = within(card).queryByText("Monitoring"); + if (title) { + return card; + } + } + return null; +} + +/** + * Gets the status badge from the Monitoring capability card + */ +export async function monitoringStatusBadge() { + const card = await monitoringCapabilityCard(); + if (!card) return null; + return within(card).queryByText(/Available|Unavailable|Degraded/); +} + +/** + * Gets the help/action button from the Monitoring capability card + */ +export async function monitoringActionButton() { + const card = await monitoringCapabilityCard(); + if (!card) return null; + return within(card).queryByRole("button", { name: /Learn More|Get Started|View Metrics/i }); +} + +/** + * Gets the status indicators from the Monitoring capability card + */ +export async function monitoringStatusIndicators() { + const card = await monitoringCapabilityCard(); + if (!card) return null; + return within(card).queryAllByTestId("status-indicator"); +} + +/** + * Checks if the Monitoring card has the "available" styling (green border) + */ +export async function isMonitoringCardAvailable() { + const card = await monitoringCapabilityCard(); + if (!card) return false; + return card.classList.contains("capability-available"); +} + +/** + * Checks if the Monitoring card has the "unavailable" styling (red border) + */ +export async function isMonitoringCardUnavailable() { + const card = await monitoringCapabilityCard(); + if (!card) return false; + return card.classList.contains("capability-unavailable"); +} + +/** + * Checks if the Monitoring card has the "not configured" styling (blue gradient) + */ +export async function isMonitoringCardNotConfigured() { + const card = await monitoringCapabilityCard(); + if (!card) return false; + return card.classList.contains("capability-notconfigured"); +} + +/** + * Gets the indicator for a specific label (e.g., "Instance", "Metrics") + */ +export async function monitoringIndicatorByLabel(label: string) { + const indicators = await monitoringStatusIndicators(); + if (!indicators) return null; + for (const indicator of indicators) { + if (indicator.textContent?.includes(label)) { + return indicator; + } + } + return null; +} diff --git a/src/Frontend/test/specs/platformcapabilities/questions/recoverabilityCapabilityCard.ts b/src/Frontend/test/specs/platformcapabilities/questions/recoverabilityCapabilityCard.ts new file mode 100644 index 000000000..78606a06e --- /dev/null +++ b/src/Frontend/test/specs/platformcapabilities/questions/recoverabilityCapabilityCard.ts @@ -0,0 +1,97 @@ +import { screen, within } from "@testing-library/vue"; + +/** + * Gets the Recoverability capability card by looking for the title + */ +export async function recoverabilityCapabilityCard() { + const cards = await screen.findAllByTestId("capability-card"); + for (const card of cards) { + const title = within(card).queryByText("Recoverability"); + if (title) { + return card; + } + } + return null; +} + +/** + * Gets the Recoverability capability card synchronously (returns null if not found) + */ +export function recoverabilityCapabilityCardSync() { + const cards = screen.queryAllByTestId("capability-card"); + for (const card of cards) { + const title = within(card).queryByText("Recoverability"); + if (title) { + return card; + } + } + return null; +} + +/** + * Gets the status badge from the Recoverability capability card + */ +export async function recoverabilityStatusBadge() { + const card = await recoverabilityCapabilityCard(); + if (!card) return null; + return within(card).queryByText(/Available|Unavailable|Degraded/); +} + +/** + * Gets the help/action button from the Recoverability capability card + */ +export async function recoverabilityActionButton() { + const card = await recoverabilityCapabilityCard(); + if (!card) return null; + return within(card).queryByRole("button", { name: /Learn More|View Failed Messages/i }); +} + +/** + * Gets the status indicators from the Recoverability capability card + */ +export async function recoverabilityStatusIndicators() { + const card = await recoverabilityCapabilityCard(); + if (!card) return null; + return within(card).queryAllByTestId("status-indicator"); +} + +/** + * Checks if the Recoverability card has the "available" styling (green border) + */ +export async function isRecoverabilityCardAvailable() { + const card = await recoverabilityCapabilityCard(); + if (!card) return false; + return card.classList.contains("capability-available"); +} + +/** + * Checks if the Recoverability card has the "unavailable" styling (red border) + */ +export async function isRecoverabilityCardUnavailable() { + const card = await recoverabilityCapabilityCard(); + if (!card) return false; + return card.classList.contains("capability-unavailable"); +} + +/** + * Checks if the Recoverability card has the "partially unavailable" / degraded styling (yellow border) + */ +export async function isRecoverabilityCardPartiallyUnavailable() { + const card = await recoverabilityCapabilityCard(); + if (!card) return false; + return card.classList.contains("capability-partially-unavailable"); +} + +/** + * Gets the indicator for a specific label (e.g., "Instance", "Primary") + */ +export async function recoverabilityIndicatorByLabel(label: string) { + const indicators = await recoverabilityStatusIndicators(); + if (!indicators) return null; + for (const indicator of indicators) { + if (indicator.textContent?.includes(label)) { + return indicator; + } + } + return null; +} diff --git a/src/Frontend/test/specs/platformcapabilities/recoverability-capability-card.spec.ts b/src/Frontend/test/specs/platformcapabilities/recoverability-capability-card.spec.ts new file mode 100644 index 000000000..fb4faae65 --- /dev/null +++ b/src/Frontend/test/specs/platformcapabilities/recoverability-capability-card.spec.ts @@ -0,0 +1,191 @@ +import { test, describe } from "../../drivers/vitest/driver"; +import { expect } from "vitest"; +import * as precondition from "../../preconditions"; +import { waitFor } from "@testing-library/vue"; +import { + recoverabilityCapabilityCard, + recoverabilityStatusBadge, + recoverabilityActionButton, + recoverabilityStatusIndicators, + isRecoverabilityCardAvailable, + isRecoverabilityCardPartiallyUnavailable, + recoverabilityIndicatorByLabel, +} from "./questions/recoverabilityCapabilityCard"; + +describe("FEATURE: Recoverability capability card", () => { + describe("RULE: When primary ServiceControl instance is available, show 'Available' status", () => { + test("EXAMPLE: Primary instance available shows available status", async ({ driver }) => { + // Arrange + await driver.setUp(precondition.serviceControlWithMonitoring); + await driver.setUp(precondition.hasPrimaryErrorInstanceOnly); + + // Act + await driver.goTo("/"); + + // Assert + await waitFor(async () => { + const card = await recoverabilityCapabilityCard(); + expect(card).toBeInTheDocument(); + }); + + await waitFor(async () => { + expect(await isRecoverabilityCardAvailable()).toBe(true); + }); + + const statusBadge = await recoverabilityStatusBadge(); + expect(statusBadge).toBeInTheDocument(); + expect(statusBadge?.textContent).toMatch(/Available/i); + + const actionButton = await recoverabilityActionButton(); + expect(actionButton).toBeInTheDocument(); + expect(actionButton?.textContent).toMatch(/View Failed Messages/i); + }); + }); + + describe("RULE: When primary and secondary instances are available, show 'Available' status", () => { + test("EXAMPLE: Primary and secondary error instances available shows available status", async ({ driver }) => { + // Arrange + await driver.setUp(precondition.serviceControlWithMonitoring); + await driver.setUp(precondition.hasSecondaryErrorInstance()); + + // Act + await driver.goTo("/"); + + // Assert + await waitFor(async () => { + const card = await recoverabilityCapabilityCard(); + expect(card).toBeInTheDocument(); + }); + + await waitFor(async () => { + expect(await isRecoverabilityCardAvailable()).toBe(true); + }); + + const statusBadge = await recoverabilityStatusBadge(); + expect(statusBadge).toBeInTheDocument(); + expect(statusBadge?.textContent).toMatch(/Available/i); + }); + }); + + describe("RULE: When some instances are unavailable, show 'Degraded' status", () => { + test("EXAMPLE: Primary available but secondary unavailable shows degraded status", async ({ driver }) => { + // Arrange + await driver.setUp(precondition.serviceControlWithMonitoring); + await driver.setUp(precondition.hasSecondaryErrorInstanceUnavailable); + + // Act + await driver.goTo("/"); + + // Assert + await waitFor(async () => { + const card = await recoverabilityCapabilityCard(); + expect(card).toBeInTheDocument(); + }); + + await waitFor(async () => { + expect(await isRecoverabilityCardPartiallyUnavailable()).toBe(true); + }); + + const statusBadge = await recoverabilityStatusBadge(); + expect(statusBadge).toBeInTheDocument(); + expect(statusBadge?.textContent).toMatch(/Degraded/i); + }); + + test("EXAMPLE: Multiple secondary instances with mixed availability shows degraded status", async ({ driver }) => { + // Arrange + await driver.setUp(precondition.serviceControlWithMonitoring); + await driver.setUp(precondition.hasMultipleSecondaryErrorInstancesPartiallyUnavailable); + + // Act + await driver.goTo("/"); + + // Assert + await waitFor(async () => { + const card = await recoverabilityCapabilityCard(); + expect(card).toBeInTheDocument(); + }); + + await waitFor(async () => { + expect(await isRecoverabilityCardPartiallyUnavailable()).toBe(true); + }); + + const statusBadge = await recoverabilityStatusBadge(); + expect(statusBadge).toBeInTheDocument(); + expect(statusBadge?.textContent).toMatch(/Degraded/i); + }); + }); + + describe("RULE: Status indicators should show instance status", () => { + test("EXAMPLE: Single primary instance shows 'Instance' indicator", async ({ driver }) => { + // Arrange + await driver.setUp(precondition.serviceControlWithMonitoring); + await driver.setUp(precondition.hasPrimaryErrorInstanceOnly); + + // Act + await driver.goTo("/"); + + // Assert + await waitFor(async () => { + const card = await recoverabilityCapabilityCard(); + expect(card).toBeInTheDocument(); + }); + + await waitFor(async () => { + const indicators = await recoverabilityStatusIndicators(); + expect(indicators).not.toBeNull(); + expect(indicators!.length).toBeGreaterThanOrEqual(1); + }); + + const instanceIndicator = await recoverabilityIndicatorByLabel("Instance"); + expect(instanceIndicator).toBeInTheDocument(); + }); + + test("EXAMPLE: Primary with secondary instances shows 'Primary' indicator", async ({ driver }) => { + // Arrange + await driver.setUp(precondition.serviceControlWithMonitoring); + await driver.setUp(precondition.hasSecondaryErrorInstance()); + + // Act + await driver.goTo("/"); + + // Assert + await waitFor(async () => { + const card = await recoverabilityCapabilityCard(); + expect(card).toBeInTheDocument(); + }); + + await waitFor(async () => { + const primaryIndicator = await recoverabilityIndicatorByLabel("Primary"); + expect(primaryIndicator).toBeInTheDocument(); + }); + }); + + test("EXAMPLE: Multiple instances show numbered indicators", async ({ driver }) => { + // Arrange + await driver.setUp(precondition.serviceControlWithMonitoring); + await driver.setUp(precondition.hasMultipleSecondaryErrorInstances()); + + // Act + await driver.goTo("/"); + + // Assert + await waitFor(async () => { + const card = await recoverabilityCapabilityCard(); + expect(card).toBeInTheDocument(); + }); + + await waitFor(async () => { + const indicators = await recoverabilityStatusIndicators(); + expect(indicators).not.toBeNull(); + // Should have Primary, Instance 2, Instance 3 indicators + expect(indicators!.length).toBeGreaterThanOrEqual(3); + }); + + const primaryIndicator = await recoverabilityIndicatorByLabel("Primary"); + expect(primaryIndicator).toBeInTheDocument(); + + const instance2Indicator = await recoverabilityIndicatorByLabel("Instance 2"); + expect(instance2Indicator).toBeInTheDocument(); + }); + }); +});