From a25cf090660164323b79ce22cfe0e26db21e7ac1 Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Tue, 7 Apr 2026 15:22:51 -0400 Subject: [PATCH 1/2] refactor: Parameterize instance uses --- src/config/getInstanceClient.ts | 12 ++++- src/config/useInstanceClient.tsx | 52 ++++++++++++------- src/features/auth/ClusterInstanceSignIn.tsx | 2 +- src/features/cluster/FinishSetup.tsx | 2 +- src/features/cluster/InstanceLogInCell.tsx | 2 +- .../clusters/components/ClusterCard.tsx | 2 +- src/lib/urls/getRestUrlForInstance.ts | 12 +++++ 7 files changed, 59 insertions(+), 25 deletions(-) create mode 100644 src/lib/urls/getRestUrlForInstance.ts diff --git a/src/config/getInstanceClient.ts b/src/config/getInstanceClient.ts index d1fd64f22..951ea3900 100644 --- a/src/config/getInstanceClient.ts +++ b/src/config/getInstanceClient.ts @@ -12,8 +12,16 @@ interface InstanceClient { } export function getInstanceClient( - { id = OverallAppSignIn, operationsUrl, port, secure, forceFabricConnect }: InstanceClient & { + { + id = OverallAppSignIn, + operationsUrl, + port, + secure, + forceFabricConnect, + disableFabricConnect, + }: InstanceClient & { forceFabricConnect?: boolean; + disableFabricConnect?: boolean; } = {}, ) { let baseURL = operationsUrl || authStore.getOperationsUrl(id); @@ -30,7 +38,7 @@ export function getInstanceClient( } } - const fabricConnect = forceFabricConnect || authStore.checkForFabricConnect(id); + const fabricConnect = !disableFabricConnect && (forceFabricConnect || authStore.checkForFabricConnect(id)); if (fabricConnect) { if (id.startsWith('clu-')) { baseURL = apiClient.defaults.baseURL + `/Cluster/${id}/operation`; diff --git a/src/config/useInstanceClient.tsx b/src/config/useInstanceClient.tsx index f5f0c0c08..c7cce79e5 100644 --- a/src/config/useInstanceClient.tsx +++ b/src/config/useInstanceClient.tsx @@ -5,37 +5,49 @@ import { OverallAppSignIn } from '@/features/auth/store/authStore'; import { useParams } from '@tanstack/react-router'; import { useMemo } from 'react'; -export function useInstanceClient(operationsUrl?: string | null, port?: number, secure?: boolean) { +interface UseParams { + operationsUrl?: string | null; + port?: number; + secure?: boolean; + disableFabricConnect?: boolean; + instanceId?: string; + clusterId?: string; +} + +export function useInstanceClient( + params: UseParams = {}, +) { const { instanceId, clusterId }: { instanceId?: string; clusterId?: string } = useParams({ strict: false }); - const id = isLocalStudio ? OverallAppSignIn : instanceId ?? clusterId; - return getInstanceClient({ id, operationsUrl, port, secure }); + const id = isLocalStudio ? OverallAppSignIn : params.instanceId ?? instanceId ?? params.clusterId ?? clusterId; + return getInstanceClient({ id, ...params }); } export function useInstanceClientParams( - operationsUrl?: string | null, - port?: number, - secure?: boolean, + params: UseParams = {}, ): InstanceClientConfig & InstanceTypeConfig { const { instanceId, clusterId }: { instanceId?: string; clusterId?: string } = useParams({ strict: false }); - const id = isLocalStudio ? OverallAppSignIn : instanceId ?? clusterId; + const id = isLocalStudio ? OverallAppSignIn : params.instanceId ?? instanceId ?? params.clusterId ?? clusterId; return { - instanceClient: getInstanceClient({ id, operationsUrl, port, secure }), + instanceClient: getInstanceClient({ id, ...params }), entityType: (isLocalStudio || instanceId) ? 'instance' : 'cluster', }; } export function useInstanceClientIdParams( - operationsUrl?: string | null, - port?: number, - secure?: boolean, + params: UseParams = {}, ): InstanceClientIdConfig & InstanceTypeConfig { - const params: { instanceId?: string; clusterId?: string } = useParams({ strict: false }); - return useMemo(() => getInstanceClientIdFromParams({ ...params, operationsUrl, port, secure }), [ - params, - operationsUrl, - port, - secure, - ]); + const { instanceId, clusterId }: { instanceId?: string; clusterId?: string } = useParams({ strict: false }); + return useMemo( + () => getInstanceClientIdFromParams({ instanceId, clusterId, ...params }), + [ + params.instanceId ?? instanceId, + params.clusterId ?? clusterId, + params.operationsUrl, + params.port, + params.secure, + params.disableFabricConnect, + ], + ); } export function getInstanceClientIdFromParams({ @@ -44,19 +56,21 @@ export function getInstanceClientIdFromParams({ operationsUrl, port, secure, + disableFabricConnect, }: { instanceId?: string; clusterId?: string; operationsUrl?: string | null; port?: number; secure?: boolean; + disableFabricConnect?: boolean; }): InstanceClientIdConfig & InstanceTypeConfig { const id = isLocalStudio ? OverallAppSignIn : instanceId ?? clusterId; if (!id) { throw new Error('id could not be automatically calculated in useInstanceClientIdParams'); } return { - instanceClient: getInstanceClient({ id, operationsUrl, port, secure }), + instanceClient: getInstanceClient({ id, operationsUrl, port, secure, disableFabricConnect }), entityId: id, entityType: (isLocalStudio || instanceId) ? 'instance' : 'cluster', }; diff --git a/src/features/auth/ClusterInstanceSignIn.tsx b/src/features/auth/ClusterInstanceSignIn.tsx index cdb01cf25..b2f9ff2dd 100644 --- a/src/features/auth/ClusterInstanceSignIn.tsx +++ b/src/features/auth/ClusterInstanceSignIn.tsx @@ -54,7 +54,7 @@ export function ClusterInstanceSignIn() { return null; }, [cluster, instance]); - const instanceParams = useInstanceClientIdParams(operationsUrl); + const instanceParams = useInstanceClientIdParams({ operationsUrl }); const warnAboutLocalDeviceAccess = useMemo( () => operationsUrl?.includes('localhost') || operationsUrl?.includes('127.0.0.1'), [operationsUrl], diff --git a/src/features/cluster/FinishSetup.tsx b/src/features/cluster/FinishSetup.tsx index 0ef5f68af..53746a76a 100644 --- a/src/features/cluster/FinishSetup.tsx +++ b/src/features/cluster/FinishSetup.tsx @@ -33,7 +33,7 @@ export function FinishSetup() { const navigate = useNavigate(); const operationsUrl = useMemo(() => getOperationsUrlForCluster(cluster), [cluster]); - const instanceClient = useInstanceClient(operationsUrl); + const instanceClient = useInstanceClient({ operationsUrl }); const { redirect } = useSearch({ strict: false }); const router = useRouter(); diff --git a/src/features/cluster/InstanceLogInCell.tsx b/src/features/cluster/InstanceLogInCell.tsx index 6e7ca2c9a..d8755efe9 100644 --- a/src/features/cluster/InstanceLogInCell.tsx +++ b/src/features/cluster/InstanceLogInCell.tsx @@ -16,7 +16,7 @@ export function InstanceLogInCell( ) { const { user: instanceUser, isLoading: instanceAuthIsLoading } = useInstanceAuth(instance.id); const operationsUrl = useMemo(() => getOperationsUrlForInstance(instance), [instance]); - const instanceClient = useInstanceClient(operationsUrl); + const instanceClient = useInstanceClient({ operationsUrl }); const { update } = useOrganizationClusterInstancePermissions(); const isFabricConnect = authStore.checkForFabricConnect(instance.id); diff --git a/src/features/clusters/components/ClusterCard.tsx b/src/features/clusters/components/ClusterCard.tsx index ae8f3d5a8..9c47a2670 100644 --- a/src/features/clusters/components/ClusterCard.tsx +++ b/src/features/clusters/components/ClusterCard.tsx @@ -48,7 +48,7 @@ export function ClusterCard({ cluster }: { cluster: Cluster }) { const router = useRouter(); const queryClient = useQueryClient(); const operationsUrl = useMemo(() => getOperationsUrlForCluster(cluster), [cluster]); - const instanceClient = useInstanceClient(operationsUrl); + const instanceClient = useInstanceClient({ operationsUrl }); const auth = useInstanceAuth(cluster.id); const [, setSavedClusterState] = useLocalStorage(LocalStorageKeys.SavedClusterState, null); diff --git a/src/lib/urls/getRestUrlForInstance.ts b/src/lib/urls/getRestUrlForInstance.ts new file mode 100644 index 000000000..0a9f222ef --- /dev/null +++ b/src/lib/urls/getRestUrlForInstance.ts @@ -0,0 +1,12 @@ +import { Instance } from '@/integrations/api/api.patch'; + +export function getRestUrlForInstance( + instance: Pick, +): string { + let fqdn = instance.instanceFqdn; + if (!fqdn.match(/^https?:\/\//i)) { + fqdn = `https://${fqdn}`; + } + const url = new URL(fqdn); + return url.toString(); +} From 0aa16dab711c1690eaeb4dfe3bdc86584f5d6e80 Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Tue, 7 Apr 2026 15:52:03 -0400 Subject: [PATCH 2/2] feat: Show /status on instances table https://harperdb.atlassian.net/browse/STUDIO-669 --- src/features/cluster/InstanceStatusCell.tsx | 100 ++++++++++++++++++ src/features/cluster/Instances.tsx | 14 ++- .../api/instance/status/getStatus.test.ts | 64 +++++++++++ .../api/instance/status/getStatus.ts | 10 +- .../api/instance/status/setStatus.ts | 28 +++++ 5 files changed, 211 insertions(+), 5 deletions(-) create mode 100644 src/features/cluster/InstanceStatusCell.tsx create mode 100644 src/integrations/api/instance/status/getStatus.test.ts create mode 100644 src/integrations/api/instance/status/setStatus.ts diff --git a/src/features/cluster/InstanceStatusCell.tsx b/src/features/cluster/InstanceStatusCell.tsx new file mode 100644 index 000000000..554a3dab4 --- /dev/null +++ b/src/features/cluster/InstanceStatusCell.tsx @@ -0,0 +1,100 @@ +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; +import { useInstanceClientIdParams } from '@/config/useInstanceClient'; +import { useOrganizationClusterInstancePermissions } from '@/hooks/usePermissions'; +import { Instance } from '@/integrations/api/api.patch'; +import { getStatusQueryOptions, getSystemStatusById } from '@/integrations/api/instance/status/getStatus'; +import { useSetStatus } from '@/integrations/api/instance/status/setStatus'; +import { getOperationsUrlForInstance } from '@/lib/urls/getOperationsUrlForInstance'; +import { useQuery } from '@tanstack/react-query'; +import { LoaderCircleIcon, ShieldCheckIcon, ShieldXIcon } from 'lucide-react'; +import { useEffect, useMemo, useState } from 'react'; + +export function InstanceStatusCell( + { instance }: { readonly instance: Instance }, +) { + const operationsUrl = useMemo(() => getOperationsUrlForInstance(instance), [instance]); + const instanceParams = useInstanceClientIdParams({ operationsUrl, instanceId: instance.id }); + const { update: canManage } = useOrganizationClusterInstancePermissions(); + const { mutate: setStatus, isPending: isSettingStatus } = useSetStatus(); + + // We want to spread the initial requests across 5 seconds. + const [randomOffset] = useState(() => Math.floor(Math.random() * 5_000)); + const [ready, setReady] = useState(false); + + useEffect(() => { + const timer = setTimeout(() => setReady(true), randomOffset); + return () => clearTimeout(timer); + }, [randomOffset]); + + const { data: statusResponse, isLoading, isFetching } = useQuery(getStatusQueryOptions(instanceParams, ready)); + + const systemStatus = getSystemStatusById(statusResponse, 'availability') || 'Unknown'; + const isAvailable = systemStatus === 'Available'; + const isUnavailable = systemStatus === 'Unavailable'; + + return ( +
+ + +
+ {isLoading || !ready || (isFetching && !statusResponse) + ? + : ( + + {isAvailable ? 'Online' : 'Offline'} + + )} +
+
+ + {isFetching && statusResponse ? 'Refreshing... ' : ''} + {systemStatus} + +
+ + {canManage && ( +
+ {isAvailable && ( + + + + + Bring out of rotation + + )} + {isUnavailable && ( + + + + + Bring back into rotation + + )} +
+ )} +
+ ); +} diff --git a/src/features/cluster/Instances.tsx b/src/features/cluster/Instances.tsx index 02ff7909a..9be2ce885 100644 --- a/src/features/cluster/Instances.tsx +++ b/src/features/cluster/Instances.tsx @@ -17,6 +17,7 @@ import { ColumnDef } from '@tanstack/react-table'; import { useMemo } from 'react'; import { EmptyCluster } from './EmptyCluster'; import { InstanceLogInCell } from './InstanceLogInCell'; +import { InstanceStatusCell } from './InstanceStatusCell'; import { getClusterInfoQueryOptions } from './queries/getClusterInfoQuery'; export function Instances() { @@ -34,7 +35,7 @@ export function Instances() { size: 1, minSize: 1, cell: (cell) => ( -
+
), @@ -56,14 +57,19 @@ export function Instances() { size: 90, header: 'Name', }, - !isSelfManaged && { + { accessorKey: 'status', header: 'Status', size: 1, minSize: 1, cell: (cell) => { const status = cell.getValue() as string; - return {capitalizeWords(status)}; + return ( +
+ + {status ? {capitalizeWords(status)} : null} +
+ ); }, }, !isSelfManaged && { @@ -128,7 +134,7 @@ export function Instances() { return ( <> -
+
{clusterIsLoading diff --git a/src/integrations/api/instance/status/getStatus.test.ts b/src/integrations/api/instance/status/getStatus.test.ts new file mode 100644 index 000000000..15e5092c1 --- /dev/null +++ b/src/integrations/api/instance/status/getStatus.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from 'vitest'; +import { getSystemStatusById } from './getStatus'; + +describe('getSystemStatusById', () => { + it('returns the status for a given id when it exists', () => { + const mockStatusResponse = { + systemStatus: [ + { + id: 'availability', + status: 'Available', + __updatedtime__: 123456789, + __createdtime__: 123456780, + }, + { + id: 'maintenance', + status: 'Unavailable', + __updatedtime__: 123456790, + __createdtime__: 123456780, + }, + ], + restartRequired: false, + componentStatus: [], + }; + + expect(getSystemStatusById(mockStatusResponse, 'availability')).toBe('Available'); + expect(getSystemStatusById(mockStatusResponse, 'maintenance')).toBe('Unavailable'); + }); + + it('returns undefined if statusResponse is undefined', () => { + expect(getSystemStatusById(undefined, 'availability')).toBeUndefined(); + }); + + it('returns undefined if systemStatus array is missing', () => { + // @ts-expect-error - testing invalid input + expect(getSystemStatusById({}, 'availability')).toBeUndefined(); + }); + + it('returns undefined if the id is not found in systemStatus', () => { + const mockStatusResponse = { + systemStatus: [ + { + id: 'availability', + status: 'Available', + __updatedtime__: 123456789, + __createdtime__: 123456780, + }, + ], + restartRequired: false, + componentStatus: [], + }; + + expect(getSystemStatusById(mockStatusResponse, 'non-existent')).toBeUndefined(); + }); + + it('returns undefined if systemStatus is empty', () => { + const mockStatusResponse = { + systemStatus: [], + restartRequired: false, + componentStatus: [], + }; + + expect(getSystemStatusById(mockStatusResponse, 'availability')).toBeUndefined(); + }); +}); diff --git a/src/integrations/api/instance/status/getStatus.ts b/src/integrations/api/instance/status/getStatus.ts index 11d2c6777..abd2cc788 100644 --- a/src/integrations/api/instance/status/getStatus.ts +++ b/src/integrations/api/instance/status/getStatus.ts @@ -1,7 +1,7 @@ import { InstanceClientIdConfig } from '@/config/instanceClientConfig'; import { queryOptions } from '@tanstack/react-query'; -interface SystemStatus { +export interface SystemStatus { id: 'availability' | 'maintenance' | 'primary' | string; status: 'Available' | 'Unavailable' | string; __updatedtime__: number; @@ -53,3 +53,11 @@ export function getStatusQueryOptions({ entityId, instanceClient }: InstanceClie }, }); } + +export function getSystemStatusById( + statusResponse: StatusResponse | undefined, + id: SystemStatus['id'], +): SystemStatus['status'] | undefined { + const systemStatus = statusResponse?.systemStatus?.find(s => s.id === id); + return systemStatus?.status; +} diff --git a/src/integrations/api/instance/status/setStatus.ts b/src/integrations/api/instance/status/setStatus.ts new file mode 100644 index 000000000..a9f43e069 --- /dev/null +++ b/src/integrations/api/instance/status/setStatus.ts @@ -0,0 +1,28 @@ +import { InstanceClientIdConfig } from '@/config/instanceClientConfig'; +import { SystemStatus } from '@/integrations/api/instance/status/getStatus'; +import { ReplicatedResponse } from '@/integrations/api/replication'; +import { queryClient } from '@/react-query/queryClient'; +import { useMutation } from '@tanstack/react-query'; + +interface SetConfigurationParams extends Pick, InstanceClientIdConfig { +} + +async function setStatus({ + instanceClient, + id, + status, +}: SetConfigurationParams): Promise { + const { data } = await instanceClient.post('/', { + operation: 'set_status', + id, + status, + }); + return data; +} + +export function useSetStatus() { + return useMutation({ + mutationFn: setStatus, + onSuccess: (_data, variables) => queryClient.invalidateQueries({ queryKey: [variables.entityId, 'get_status'] }), + }); +}