Skip to content

Commit 8b97edb

Browse files
authored
New ip namespace list view with count (#6520)
1 parent a2942f5 commit 8b97edb

File tree

9 files changed

+300
-5
lines changed

9 files changed

+300
-5
lines changed

frontend/app/src/app/router.tsx

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { RESOURCE_GENERIC_KIND } from "@/entities/resource-manager/constants";
77
import { SchemaProvider } from "@/entities/schema/ui/providers/schema-provider";
88
import { constructPath } from "@/shared/api/rest/fetch";
99
import { ErrorBoundaryRouter } from "@/shared/components/errors/error-boundary-router";
10+
import { BreadcrumbItem } from "@/shared/components/layout/breadcrumb-navigation/type";
1011
import { ReactRouter7Adapter } from "@/shared/libs/use-query-params";
1112
import queryString from "query-string";
1213
import { RouterProvider } from "react-aria-components";
@@ -394,6 +395,19 @@ export const router = createBrowserRouter([
394395
index: true,
395396
lazy: () => import("@/entities/ipam/ipam-router"),
396397
},
398+
{
399+
path: "namespaces",
400+
lazy: () => import("@/pages/ipam/namespaces/ip-namespace-list-page"),
401+
handle: {
402+
breadcrumb: () => {
403+
return {
404+
type: "link",
405+
label: "namespaces",
406+
to: constructPath("/ipam/namespaces"),
407+
} satisfies BreadcrumbItem;
408+
},
409+
},
410+
},
397411
{
398412
path: IPAM_ROUTE.ADDRESSES,
399413
lazy: () => import("@/entities/ipam/ipam-router"),

frontend/app/src/entities/ipam/ip-namespace-selector.tsx

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import { GET_IP_NAMESPACES } from "@/entities/ipam/api/ip-namespaces";
22
import { IpamNamespace } from "@/shared/api/graphql/generated/graphql";
33
import useQuery from "@/shared/api/graphql/useQuery";
4+
import { constructPath } from "@/shared/api/rest/fetch";
5+
import { LinkButton } from "@/shared/components/buttons/button-primitive";
6+
import { Col } from "@/shared/components/container";
47
import { Skeleton } from "@/shared/components/skeleton";
58
import {
69
Combobox,
@@ -100,6 +103,16 @@ const IpNamespaceSelectorContent = ({ namespaces }: IpNamespaceSelectorContentPr
100103
</ComboboxItem>
101104
))}
102105
</ComboboxList>
106+
<Col className="border-t border-neutral-200">
107+
<LinkButton
108+
to={constructPath("/ipam/namespaces")}
109+
variant="ghost"
110+
size="sm"
111+
className="text-xs justify-start m-2"
112+
>
113+
View all IP namespaces
114+
</LinkButton>
115+
</Col>
103116
</ComboboxContent>
104117
</Combobox>
105118
</div>
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { useCurrentBranch } from "@/entities/branches/ui/branches-provider";
2+
import { NAMESPACE_GENERIC } from "@/entities/ipam/constants";
3+
import {
4+
GetIpNamespaceListParams,
5+
getIpNamespaceList,
6+
} from "@/entities/ipam/namespaces/domain/get-ip-namespace-list";
7+
import { OBJECTS_PER_PAGE } from "@/entities/nodes/object/domain/get-objects";
8+
import { ContextParams, PaginationParams } from "@/shared/api/types";
9+
import { datetimeAtom } from "@/shared/stores/time.atom";
10+
import { infiniteQueryOptions, useInfiniteQuery } from "@tanstack/react-query";
11+
import { useAtomValue } from "jotai";
12+
13+
export type GetIpNamespaceListInfiniteQueryOptionsParams = Omit<
14+
GetIpNamespaceListParams,
15+
keyof PaginationParams
16+
>;
17+
18+
export function getIpNamespaceListInfiniteQueryOptions(
19+
params: GetIpNamespaceListInfiniteQueryOptionsParams
20+
) {
21+
return infiniteQueryOptions({
22+
queryKey: [params.branchName, params.atDate, NAMESPACE_GENERIC, "objects", params.filters],
23+
queryFn: async ({ pageParam }) => {
24+
return getIpNamespaceList({
25+
...params,
26+
offset: pageParam,
27+
});
28+
},
29+
initialPageParam: 0,
30+
getNextPageParam: (lastPage, _, lastPageParam) => {
31+
if (lastPage.length < OBJECTS_PER_PAGE) {
32+
return undefined;
33+
}
34+
return lastPageParam + OBJECTS_PER_PAGE;
35+
},
36+
});
37+
}
38+
39+
export function useGetIpNamespaceList(
40+
params?: Omit<GetIpNamespaceListInfiniteQueryOptionsParams, keyof ContextParams>
41+
) {
42+
const { currentBranch } = useCurrentBranch();
43+
const timeMachineDate = useAtomValue(datetimeAtom);
44+
45+
return useInfiniteQuery(
46+
getIpNamespaceListInfiniteQueryOptions({
47+
...params,
48+
branchName: currentBranch.name,
49+
atDate: timeMachineDate,
50+
})
51+
);
52+
}
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { OBJECTS_PER_PAGE } from "@/entities/nodes/object/domain/get-objects";
2+
import { NodeCore } from "@/entities/nodes/types";
3+
import graphqlClient from "@/shared/api/graphql/graphqlClientApollo";
4+
import { addFiltersToRequest } from "@/shared/api/graphql/utils";
5+
import { ContextParams, PaginationParams } from "@/shared/api/types";
6+
import { Filter } from "@/shared/hooks/useFilters";
7+
import { gql } from "@apollo/client";
8+
import { jsonToGraphQLQuery } from "json-to-graphql-query";
9+
import { NAMESPACE_GENERIC } from "../../constants";
10+
11+
export interface GetIpNamespaceListParams extends ContextParams, PaginationParams {
12+
filters?: Array<Filter>;
13+
}
14+
15+
export interface IpNamespace extends NodeCore {
16+
description: { value: string };
17+
ip_addresses: { count: number };
18+
ip_prefixes: { count: number };
19+
}
20+
21+
export type GetIpNamespaceList = (params: GetIpNamespaceListParams) => Promise<Array<IpNamespace>>;
22+
23+
export const getIpNamespaceList: GetIpNamespaceList = async ({
24+
filters,
25+
limit = OBJECTS_PER_PAGE,
26+
offset,
27+
branchName,
28+
atDate,
29+
}) => {
30+
const query = gql(
31+
jsonToGraphQLQuery({
32+
query: {
33+
__name: `GetObjects${NAMESPACE_GENERIC}`,
34+
[NAMESPACE_GENERIC]: {
35+
__args: {
36+
limit,
37+
offset,
38+
...(filters ? addFiltersToRequest(filters) : {}),
39+
},
40+
edges: {
41+
node: {
42+
id: true,
43+
display_label: true,
44+
hfid: true,
45+
description: {
46+
value: true,
47+
},
48+
ip_prefixes: {
49+
count: true,
50+
},
51+
ip_addresses: {
52+
count: true,
53+
},
54+
},
55+
},
56+
},
57+
},
58+
})
59+
);
60+
61+
const { data, errors } = await graphqlClient.query({
62+
query,
63+
context: {
64+
branch: branchName,
65+
date: atDate,
66+
processErrorMessage: () => {},
67+
},
68+
});
69+
70+
if (errors?.[0]?.message) {
71+
throw new Error(errors[0].message);
72+
}
73+
74+
return data?.[NAMESPACE_GENERIC]?.edges?.map((edge: any) => edge.node) ?? [];
75+
};
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { IpNamespace } from "@/entities/ipam/namespaces/domain/get-ip-namespace-list";
2+
import { getNodeLabel } from "@/entities/nodes/object/utils/get-node-label";
3+
import { getObjectDetailsUrl } from "@/entities/nodes/utils";
4+
import { Row } from "@/shared/components/container";
5+
import { Badge } from "@/shared/components/ui/badge";
6+
import { focusVisibleStyle } from "@/shared/components/ui/style";
7+
import { classNames } from "@/shared/utils/common";
8+
import { pluralize } from "@/shared/utils/string";
9+
import { Link } from "react-router";
10+
11+
export interface IpNamespaceCardProps {
12+
ipNamespace: IpNamespace;
13+
}
14+
15+
const CARD_STYLES = {
16+
container: classNames(
17+
"bg-white rounded-lg border border-gray-200 p-4 flex flex-col gap-2",
18+
"transition-all hover:border-custom-blue-600 hover:shadow-sm",
19+
focusVisibleStyle
20+
),
21+
title: "text-lg font-semibold truncate",
22+
badge: "px-3 py-1.5 bg-blue-50 text-blue-700 font-medium rounded-full",
23+
description: "text-sm text-gray-600",
24+
};
25+
26+
export function IpNamespaceCard({ ipNamespace }: IpNamespaceCardProps) {
27+
const { id, __typename, description, ip_prefixes, ip_addresses } = ipNamespace;
28+
const detailsUrl = getObjectDetailsUrl(__typename, id);
29+
const nodeLabel = getNodeLabel(ipNamespace);
30+
31+
return (
32+
<Link to={detailsUrl} className={CARD_STYLES.container}>
33+
<h2 className={CARD_STYLES.title}>{nodeLabel}</h2>
34+
<Row>
35+
<Badge className={CARD_STYLES.badge}>{pluralize(ip_prefixes.count, "Prefix", "es")}</Badge>
36+
<Badge className={CARD_STYLES.badge}>
37+
{pluralize(ip_addresses.count, "Address", "es")}
38+
</Badge>
39+
<p className={CARD_STYLES.description}>{description?.value}</p>
40+
</Row>
41+
</Link>
42+
);
43+
}

frontend/app/src/entities/nodes/object/domain/get-objects.query.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { useCurrentBranch } from "@/entities/branches/ui/branches-provider";
2-
import { PaginationParams } from "@/shared/api/types";
2+
import { ContextParams, PaginationParams } from "@/shared/api/types";
33
import { datetimeAtom } from "@/shared/stores/time.atom";
44
import { infiniteQueryOptions, useInfiniteQuery } from "@tanstack/react-query";
55
import { useAtomValue } from "jotai";
@@ -38,7 +38,7 @@ export function getObjectsInfiniteQueryOptions({
3838
});
3939
}
4040

41-
export function useObjects(params: Omit<GetObjectsQueryParams, "branchName" | "atDate">) {
41+
export function useObjects(params: Omit<GetObjectsQueryParams, keyof ContextParams>) {
4242
const { currentBranch } = useCurrentBranch();
4343
const timeMachineDate = useAtomValue(datetimeAtom);
4444

frontend/app/src/pages/ipam/layout.tsx

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,4 @@ function IpamLayout() {
2626
);
2727
}
2828

29-
export function Component() {
30-
return <IpamLayout />;
31-
}
29+
export const Component = IpamLayout;
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import { NAMESPACE_GENERIC } from "@/entities/ipam/constants";
2+
import { useGetIpNamespaceList } from "@/entities/ipam/namespaces/domain/get-ip-namespace-list.query";
3+
import { IpNamespaceCard } from "@/entities/ipam/namespaces/ui/ip-namespace-card";
4+
import { FilterSearchInput } from "@/entities/nodes/object/ui/filters/filter-search-input";
5+
import { ObjectTableEmpty } from "@/entities/nodes/object/ui/object-table/object-table-empty";
6+
import { Permission } from "@/entities/permission/types";
7+
import { RequireObjectPermissions } from "@/entities/permission/ui/require-object-permissions";
8+
import { ModelSchema } from "@/entities/schema/types";
9+
import { useSchema } from "@/entities/schema/ui/hooks/useSchema";
10+
import { queryClient } from "@/shared/api/rest/client";
11+
import { Col } from "@/shared/components/container";
12+
import ErrorScreen from "@/shared/components/errors/error-screen";
13+
import { ObjectCreateFormTrigger } from "@/shared/components/form/object-create-form-trigger";
14+
import { Spinner } from "@/shared/components/ui/spinner";
15+
import { InfiniteScroll } from "@/shared/components/utils/infinite-scroll";
16+
import useFilters from "@/shared/hooks/useFilters";
17+
import React from "react";
18+
19+
interface IpNamespaceListPageProps {
20+
namespaceSchema: ModelSchema;
21+
permission: Permission;
22+
}
23+
24+
function IpNamespaceListPage({ namespaceSchema, permission }: IpNamespaceListPageProps) {
25+
const [filters] = useFilters();
26+
const { isPending, data, fetchNextPage, isFetchingNextPage, hasNextPage } = useGetIpNamespaceList(
27+
{
28+
filters,
29+
}
30+
);
31+
32+
const flatData = React.useMemo(() => data?.pages?.flat() ?? [], [data]);
33+
34+
const isLoading = isPending || isFetchingNextPage;
35+
36+
return (
37+
<Col className="overflow-hidden h-full gap-0">
38+
<div className="flex items-center h-14 shrink-0 border-b px-2 border-gray-200">
39+
<FilterSearchInput schema={namespaceSchema} />
40+
41+
<ObjectCreateFormTrigger
42+
schema={namespaceSchema}
43+
onSuccess={() => {
44+
queryClient.invalidateQueries({
45+
predicate: (query) => query.queryKey.includes("objects"),
46+
});
47+
}}
48+
permission={permission}
49+
className="ml-auto"
50+
/>
51+
</div>
52+
53+
<InfiniteScroll hasNextPage={hasNextPage} onLoadMore={fetchNextPage}>
54+
<Col className="p-2">
55+
{flatData.map((item) => {
56+
return <IpNamespaceCard key={item.id} ipNamespace={item} />;
57+
})}
58+
59+
{isLoading && (
60+
<div className="flex justify-center grow">
61+
<Spinner />
62+
</div>
63+
)}
64+
65+
{!isLoading && flatData.length === 0 && <ObjectTableEmpty schema={namespaceSchema} />}
66+
</Col>
67+
</InfiniteScroll>
68+
</Col>
69+
);
70+
}
71+
72+
export const Component = () => {
73+
const { schema } = useSchema(NAMESPACE_GENERIC);
74+
75+
if (!schema) {
76+
return <ErrorScreen message={`Schema ${NAMESPACE_GENERIC} not found.`} />;
77+
}
78+
79+
return (
80+
<RequireObjectPermissions objectKind={NAMESPACE_GENERIC}>
81+
{({ permission }) => {
82+
return <IpNamespaceListPage namespaceSchema={schema} permission={permission} />;
83+
}}
84+
</RequireObjectPermissions>
85+
);
86+
};
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { classNames } from "@/shared/utils/common";
2+
import React from "react";
3+
4+
export interface RowProps extends React.HTMLAttributes<HTMLDivElement> {
5+
children: React.ReactNode;
6+
}
7+
8+
export function Row({ className, ...props }: RowProps) {
9+
return <div className={classNames("flex items-center gap-2", className)} {...props} />;
10+
}
11+
12+
export function Col({ className, ...props }: RowProps) {
13+
return <div className={classNames("flex flex-col items-stretch gap-2", className)} {...props} />;
14+
}

0 commit comments

Comments
 (0)