Skip to content

Commit 5cdfea5

Browse files
committed
feat: Show /status on instances table
https://harperdb.atlassian.net/browse/STUDIO-669
1 parent 7109cf7 commit 5cdfea5

5 files changed

Lines changed: 211 additions & 5 deletions

File tree

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import { Badge } from '@/components/ui/badge';
2+
import { Button } from '@/components/ui/button';
3+
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
4+
import { useInstanceClientIdParams } from '@/config/useInstanceClient';
5+
import { useOrganizationClusterInstancePermissions } from '@/hooks/usePermissions';
6+
import { Instance } from '@/integrations/api/api.patch';
7+
import { getStatusQueryOptions, getSystemStatusById } from '@/integrations/api/instance/status/getStatus';
8+
import { useSetStatus } from '@/integrations/api/instance/status/setStatus';
9+
import { getOperationsUrlForInstance } from '@/lib/urls/getOperationsUrlForInstance';
10+
import { useQuery } from '@tanstack/react-query';
11+
import { LoaderCircleIcon, ShieldCheckIcon, ShieldXIcon } from 'lucide-react';
12+
import { useEffect, useMemo, useState } from 'react';
13+
14+
export function InstanceStatusCell(
15+
{ instance }: { readonly instance: Instance },
16+
) {
17+
const operationsUrl = useMemo(() => getOperationsUrlForInstance(instance), [instance]);
18+
const instanceParams = useInstanceClientIdParams({ operationsUrl, instanceId: instance.id });
19+
const { update: canManage } = useOrganizationClusterInstancePermissions();
20+
const { mutate: setStatus, isPending: isSettingStatus } = useSetStatus();
21+
22+
// We want to spread the initial requests across 5 seconds.
23+
const [randomOffset] = useState(() => Math.floor(Math.random() * 5_000));
24+
const [ready, setReady] = useState(false);
25+
26+
useEffect(() => {
27+
const timer = setTimeout(() => setReady(true), randomOffset);
28+
return () => clearTimeout(timer);
29+
}, [randomOffset]);
30+
31+
const { data: statusResponse, isLoading, isFetching } = useQuery(getStatusQueryOptions(instanceParams, ready));
32+
33+
const systemStatus = getSystemStatusById(statusResponse, 'availability') || 'Unknown';
34+
const isAvailable = systemStatus === 'Available';
35+
const isUnavailable = systemStatus === 'Unavailable';
36+
37+
return (
38+
<div className="flex items-center gap-2">
39+
<Tooltip>
40+
<TooltipTrigger asChild>
41+
<div className="flex items-center">
42+
{isLoading || !ready || (isFetching && !statusResponse)
43+
? <LoaderCircleIcon className="animate-spin size-5 text-muted-foreground" />
44+
: (
45+
<Badge
46+
variant={isAvailable ? 'success' : isUnavailable ? 'destructive' : 'default'}
47+
className="size-4 rounded-full p-0"
48+
>
49+
<span className="sr-only">{isAvailable ? 'Online' : 'Offline'}</span>
50+
</Badge>
51+
)}
52+
</div>
53+
</TooltipTrigger>
54+
<TooltipContent>
55+
{isFetching && statusResponse ? 'Refreshing... ' : ''}
56+
{systemStatus}
57+
</TooltipContent>
58+
</Tooltip>
59+
60+
{canManage && (
61+
<div className="flex gap-1">
62+
{isAvailable && (
63+
<Tooltip>
64+
<TooltipTrigger asChild>
65+
<Button
66+
variant="destructiveGhost"
67+
size="icon"
68+
onClick={() => setStatus({ ...instanceParams, id: 'availability', status: 'Unavailable' })}
69+
disabled={isSettingStatus}
70+
>
71+
{isSettingStatus
72+
? <LoaderCircleIcon className="animate-spin size-4" />
73+
: <ShieldXIcon className="size-4" />}
74+
</Button>
75+
</TooltipTrigger>
76+
<TooltipContent>Bring out of rotation</TooltipContent>
77+
</Tooltip>
78+
)}
79+
{isUnavailable && (
80+
<Tooltip>
81+
<TooltipTrigger asChild>
82+
<Button
83+
variant="ghost"
84+
size="icon"
85+
onClick={() => setStatus({ ...instanceParams, id: 'availability', status: 'Available' })}
86+
disabled={isSettingStatus}
87+
>
88+
{isSettingStatus
89+
? <LoaderCircleIcon className="animate-spin size-4" />
90+
: <ShieldCheckIcon className="size-4" />}
91+
</Button>
92+
</TooltipTrigger>
93+
<TooltipContent>Bring back into rotation</TooltipContent>
94+
</Tooltip>
95+
)}
96+
</div>
97+
)}
98+
</div>
99+
);
100+
}

src/features/cluster/Instances.tsx

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { ColumnDef } from '@tanstack/react-table';
1717
import { useMemo } from 'react';
1818
import { EmptyCluster } from './EmptyCluster';
1919
import { InstanceLogInCell } from './InstanceLogInCell';
20+
import { InstanceStatusCell } from './InstanceStatusCell';
2021
import { getClusterInfoQueryOptions } from './queries/getClusterInfoQuery';
2122

2223
export function Instances() {
@@ -34,7 +35,7 @@ export function Instances() {
3435
size: 1,
3536
minSize: 1,
3637
cell: (cell) => (
37-
<div className="flex justify-end">
38+
<div className="flex justify-end gap-2 items-center">
3839
<InstanceLogInCell isSelfManaged={isSelfManaged} instance={cell.row.original} />
3940
</div>
4041
),
@@ -56,14 +57,19 @@ export function Instances() {
5657
size: 90,
5758
header: 'Name',
5859
},
59-
!isSelfManaged && {
60+
{
6061
accessorKey: 'status',
6162
header: 'Status',
6263
size: 1,
6364
minSize: 1,
6465
cell: (cell) => {
6566
const status = cell.getValue() as string;
66-
return <Badge variant={renderBadgeStatusVariant(status)}>{capitalizeWords(status)}</Badge>;
67+
return (
68+
<div className="flex items-center gap-2">
69+
<InstanceStatusCell instance={cell.row.original} />
70+
{status ? <Badge variant={renderBadgeStatusVariant(status)}>{capitalizeWords(status)}</Badge> : null}
71+
</div>
72+
);
6773
},
6874
},
6975
!isSelfManaged && {
@@ -128,7 +134,7 @@ export function Instances() {
128134
return (
129135
<>
130136
<SubNavMenu />
131-
<div className="mt-32 px-4 pt-4 md:px-12 min-h-[calc(100vh-theme(spacing.32))]">
137+
<div className="mt-32 px-4 pt-4 md:px-12 min-h-[calc(100vh-(--spacing(32)))]">
132138
<Card className="p-0 mt-4 min-h-96">
133139
<CardContent className="p-0 min-h-96">
134140
{clusterIsLoading
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { getSystemStatusById } from './getStatus';
3+
4+
describe('getSystemStatusById', () => {
5+
it('returns the status for a given id when it exists', () => {
6+
const mockStatusResponse = {
7+
systemStatus: [
8+
{
9+
id: 'availability',
10+
status: 'Available',
11+
__updatedtime__: 123456789,
12+
__createdtime__: 123456780,
13+
},
14+
{
15+
id: 'maintenance',
16+
status: 'Unavailable',
17+
__updatedtime__: 123456790,
18+
__createdtime__: 123456780,
19+
},
20+
],
21+
restartRequired: false,
22+
componentStatus: [],
23+
};
24+
25+
expect(getSystemStatusById(mockStatusResponse, 'availability')).toBe('Available');
26+
expect(getSystemStatusById(mockStatusResponse, 'maintenance')).toBe('Unavailable');
27+
});
28+
29+
it('returns undefined if statusResponse is undefined', () => {
30+
expect(getSystemStatusById(undefined, 'availability')).toBeUndefined();
31+
});
32+
33+
it('returns undefined if systemStatus array is missing', () => {
34+
// @ts-expect-error - testing invalid input
35+
expect(getSystemStatusById({}, 'availability')).toBeUndefined();
36+
});
37+
38+
it('returns undefined if the id is not found in systemStatus', () => {
39+
const mockStatusResponse = {
40+
systemStatus: [
41+
{
42+
id: 'availability',
43+
status: 'Available',
44+
__updatedtime__: 123456789,
45+
__createdtime__: 123456780,
46+
},
47+
],
48+
restartRequired: false,
49+
componentStatus: [],
50+
};
51+
52+
expect(getSystemStatusById(mockStatusResponse, 'non-existent')).toBeUndefined();
53+
});
54+
55+
it('returns undefined if systemStatus is empty', () => {
56+
const mockStatusResponse = {
57+
systemStatus: [],
58+
restartRequired: false,
59+
componentStatus: [],
60+
};
61+
62+
expect(getSystemStatusById(mockStatusResponse, 'availability')).toBeUndefined();
63+
});
64+
});

src/integrations/api/instance/status/getStatus.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { InstanceClientIdConfig } from '@/config/instanceClientConfig';
22
import { queryOptions } from '@tanstack/react-query';
33

4-
interface SystemStatus {
4+
export interface SystemStatus {
55
id: 'availability' | 'maintenance' | 'primary' | string;
66
status: 'Available' | 'Unavailable' | string;
77
__updatedtime__: number;
@@ -53,3 +53,11 @@ export function getStatusQueryOptions({ entityId, instanceClient }: InstanceClie
5353
},
5454
});
5555
}
56+
57+
export function getSystemStatusById(
58+
statusResponse: StatusResponse | undefined,
59+
id: SystemStatus['id'],
60+
): SystemStatus['status'] | undefined {
61+
const systemStatus = statusResponse?.systemStatus?.find(s => s.id === id);
62+
return systemStatus?.status;
63+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { InstanceClientIdConfig } from '@/config/instanceClientConfig';
2+
import { SystemStatus } from '@/integrations/api/instance/status/getStatus';
3+
import { ReplicatedResponse } from '@/integrations/api/replication';
4+
import { queryClient } from '@/react-query/queryClient';
5+
import { useMutation } from '@tanstack/react-query';
6+
7+
interface SetConfigurationParams extends Pick<SystemStatus, 'id' | 'status'>, InstanceClientIdConfig {
8+
}
9+
10+
async function setStatus({
11+
instanceClient,
12+
id,
13+
status,
14+
}: SetConfigurationParams): Promise<ReplicatedResponse> {
15+
const { data } = await instanceClient.post('/', {
16+
operation: 'set_status',
17+
id,
18+
status,
19+
});
20+
return data;
21+
}
22+
23+
export function useSetStatus() {
24+
return useMutation({
25+
mutationFn: setStatus,
26+
onSuccess: (_data, variables) => queryClient.invalidateQueries({ queryKey: [variables.entityId, 'get_status'] }),
27+
});
28+
}

0 commit comments

Comments
 (0)