Skip to content

Commit 2758626

Browse files
committed
feat(cluster-link) add cluster link crud WD-23025
Signed-off-by: David Edler <david.edler@canonical.com>
1 parent 81d70fa commit 2758626

23 files changed

Lines changed: 1186 additions & 86 deletions

src/App.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,9 @@ const ClusterGroupList = lazy(
3232
const ClusterMemberList = lazy(
3333
async () => import("pages/cluster/ClusterMemberList"),
3434
);
35+
const ClusterLinkList = lazy(
36+
async () => import("pages/cluster/ClusterLinkList"),
37+
);
3538
const ClusterMemberDetail = lazy(
3639
async () => import("pages/cluster/ClusterMemberDetail"),
3740
);
@@ -515,6 +518,10 @@ const App: FC = () => {
515518
path={`${ROOT_PATH}/ui/cluster/groups`}
516519
element={<ProtectedRoute outlet={<ClusterGroupList />} />}
517520
/>
521+
<Route
522+
path={`${ROOT_PATH}/ui/cluster/links`}
523+
element={<ProtectedRoute outlet={<ClusterLinkList />} />}
524+
/>
518525
<Route
519526
path={`${ROOT_PATH}/ui/cluster/members`}
520527
element={<ProtectedRoute outlet={<ClusterMemberList />} />}

src/api/cluster-links.tsx

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { handleResponse } from "util/helpers";
2+
import type {
3+
LxdClusterLink,
4+
LxdClusterLinkCreated,
5+
LxdClusterLinkState,
6+
} from "types/cluster";
7+
import type { LxdApiResponse } from "types/apiResponse";
8+
import { ROOT_PATH } from "util/rootPath";
9+
10+
export const fetchClusterLinks = async (): Promise<LxdClusterLink[]> => {
11+
return fetch(`${ROOT_PATH}/1.0/cluster/links?recursion=2`)
12+
.then(handleResponse)
13+
.then((data: LxdApiResponse<LxdClusterLink[]>) => {
14+
return data.metadata;
15+
});
16+
};
17+
18+
export const fetchClusterLink = async (
19+
link: string,
20+
): Promise<LxdClusterLink> => {
21+
return fetch(
22+
`${ROOT_PATH}/1.0/cluster/links/${encodeURIComponent(link)}?recursion=2`,
23+
)
24+
.then(handleResponse)
25+
.then((data: LxdApiResponse<LxdClusterLink>) => {
26+
return data.metadata;
27+
});
28+
};
29+
30+
export const fetchClusterLinkState = async (
31+
link: string,
32+
): Promise<LxdClusterLinkState> => {
33+
return fetch(
34+
`${ROOT_PATH}/1.0/cluster/links/${encodeURIComponent(link)}/state`,
35+
)
36+
.then(handleResponse)
37+
.then((data: LxdApiResponse<LxdClusterLinkState>) => {
38+
return data.metadata;
39+
});
40+
};
41+
42+
export const createClusterLink = async (
43+
body: string,
44+
): Promise<LxdClusterLinkCreated> => {
45+
return fetch(`${ROOT_PATH}/1.0/cluster/links`, {
46+
method: "POST",
47+
headers: {
48+
"Content-Type": "application/json",
49+
},
50+
body,
51+
})
52+
.then(handleResponse)
53+
.then((data: LxdApiResponse<LxdClusterLinkCreated>) => {
54+
return data.metadata;
55+
});
56+
};
57+
58+
export const updateClusterLink = async (
59+
link: string,
60+
body: string,
61+
): Promise<void> => {
62+
await fetch(`${ROOT_PATH}/1.0/cluster/links/${encodeURIComponent(link)}`, {
63+
method: "PUT",
64+
headers: {
65+
"Content-Type": "application/json",
66+
},
67+
body,
68+
}).then(handleResponse);
69+
};
70+
71+
export const deleteClusterLink = async (link: string): Promise<void> => {
72+
await fetch(`${ROOT_PATH}/1.0/cluster/links/${encodeURIComponent(link)}`, {
73+
method: "DELETE",
74+
}).then(handleResponse);
75+
};

src/components/Navigation.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -511,6 +511,16 @@ const Navigation: FC = () => {
511511
Groups
512512
</NavLink>
513513
</SideNavigationItem>,
514+
<SideNavigationItem key="links">
515+
<NavLink
516+
to={`${ROOT_PATH}/ui/cluster/links`}
517+
title="Links"
518+
onClick={softToggleMenu}
519+
className="accordion-nav-secondary"
520+
>
521+
Links
522+
</NavLink>
523+
</SideNavigationItem>,
514524
<SideNavigationItem key="placement">
515525
<NavLink
516526
to={`${ROOT_PATH}/ui/project/${encodeURIComponent(projectName)}/placement-groups`}

src/components/ResourceIcon.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ export type ResourceIconType =
1313
| "profile"
1414
| "project"
1515
| "cluster-group"
16+
| "cluster-link"
1617
| "cluster-member"
1718
| "network"
1819
| "network-acl"
@@ -40,6 +41,7 @@ const resourceIcons: Record<ResourceIconType, string> = {
4041
project: "folder",
4142
"cluster-group": "cluster-host",
4243
"cluster-member": "single-host",
44+
"cluster-link": "applications",
4345
network: "exposed",
4446
peering: "exposed",
4547
"network-acl": "security-tick",

src/context/useClusterLinks.tsx

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { useQuery } from "@tanstack/react-query";
2+
import { queryKeys } from "util/queryKeys";
3+
import type { UseQueryResult } from "@tanstack/react-query";
4+
import {
5+
fetchClusterLink,
6+
fetchClusterLinks,
7+
fetchClusterLinkState,
8+
} from "api/cluster-links";
9+
import type { LxdClusterLink, LxdClusterLinkState } from "types/cluster";
10+
11+
export const useClusterLinks = (): UseQueryResult<LxdClusterLink[]> => {
12+
return useQuery({
13+
queryKey: [queryKeys.cluster, queryKeys.links],
14+
queryFn: fetchClusterLinks,
15+
});
16+
};
17+
18+
export const useClusterLink = (
19+
link: string,
20+
): UseQueryResult<LxdClusterLink> => {
21+
return useQuery({
22+
queryKey: [queryKeys.cluster, queryKeys.links, link],
23+
queryFn: async () => fetchClusterLink(link),
24+
});
25+
};
26+
27+
export const useClusterLinkState = (
28+
link: string,
29+
): UseQueryResult<LxdClusterLinkState> => {
30+
return useQuery({
31+
queryKey: [queryKeys.cluster, queryKeys.links, link, queryKeys.state],
32+
queryFn: async () => fetchClusterLinkState(link),
33+
});
34+
};
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import type { FC } from "react";
2+
import ExpandableList from "components/ExpandableList";
3+
import type { LxdClusterLink } from "types/cluster";
4+
import { Icon } from "@canonical/react-components";
5+
6+
interface Props {
7+
clusterLink: LxdClusterLink;
8+
}
9+
10+
const ClusterLinkAddresses: FC<Props> = ({ clusterLink }) => {
11+
return (
12+
<ExpandableList
13+
items={
14+
clusterLink.config["volatile.addresses"]?.split(",").map((address) => {
15+
return (
16+
<div key={address}>
17+
<a
18+
href={`https://${address}`}
19+
target="_blank"
20+
rel="noopener noreferrer"
21+
>
22+
{address}
23+
<Icon className="external-link-icon" name="external-link" />
24+
</a>
25+
</div>
26+
);
27+
}) ?? []
28+
}
29+
/>
30+
);
31+
};
32+
33+
export default ClusterLinkAddresses;
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import { Form, Input, Label, RadioInput } from "@canonical/react-components";
2+
import type { FC } from "react";
3+
import GroupSelection from "pages/permissions/panels/GroupSelection";
4+
import { useAuthGroups } from "context/useAuthGroups";
5+
import type { FormikProps } from "formik/dist/types";
6+
7+
export interface ClusterLinkFormValues {
8+
name: string;
9+
description?: string;
10+
token?: string;
11+
tokenType?: "generate" | "consume";
12+
authGroups: string[];
13+
isCreating: boolean;
14+
}
15+
16+
interface Props {
17+
formik: FormikProps<ClusterLinkFormValues>;
18+
}
19+
20+
const ClusterLinkForm: FC<Props> = ({ formik }) => {
21+
const { data: authGroups = [] } = useAuthGroups();
22+
23+
return (
24+
<Form onSubmit={formik.handleSubmit}>
25+
{/* hidden submit to enable enter key in inputs */}
26+
<Input type="submit" hidden value="Hidden input" />
27+
{formik.values.isCreating && (
28+
<Input
29+
{...formik.getFieldProps("name")}
30+
type="text"
31+
label="Name"
32+
placeholder="Enter name"
33+
required
34+
autoFocus
35+
error={formik.touched.name ? formik.errors.name : null}
36+
/>
37+
)}
38+
<Input
39+
{...formik.getFieldProps("description")}
40+
type="text"
41+
label="Description"
42+
placeholder="Enter description"
43+
/>
44+
{formik.values.isCreating && (
45+
<>
46+
<div className="u-sv1">
47+
<RadioInput
48+
inline
49+
labelClassName="margin-right"
50+
label="Generate token"
51+
checked={formik.values.tokenType === "generate"}
52+
onClick={() => {
53+
formik.setFieldValue("tokenType", "generate");
54+
}}
55+
/>
56+
<RadioInput
57+
inline
58+
label="Consume token"
59+
checked={formik.values.tokenType === "consume"}
60+
onClick={() => {
61+
formik.setFieldValue("tokenType", "consume");
62+
}}
63+
/>
64+
</div>
65+
{formik.values.tokenType === "consume" && (
66+
<Input
67+
{...formik.getFieldProps("token")}
68+
type="text"
69+
label="Token"
70+
placeholder="Enter token"
71+
/>
72+
)}
73+
</>
74+
)}
75+
<Label className="u-sv-2">Auth groups</Label>
76+
<p className="u-text--muted u-sv-1">
77+
Control access for incoming request on the cluster link.
78+
</p>
79+
<GroupSelection
80+
groups={authGroups}
81+
modifiedGroups={new Set(formik.values.authGroups)}
82+
parentItemName="cluster link"
83+
selectedGroups={new Set(formik.values.authGroups)}
84+
setSelectedGroups={(val, isUnselectAll) => {
85+
if (isUnselectAll) {
86+
formik.setFieldValue("authGroups", []);
87+
} else {
88+
formik.setFieldValue("authGroups", val);
89+
}
90+
}}
91+
toggleGroup={(group) => {
92+
const currentGroups = formik.values.authGroups;
93+
if (currentGroups.includes(group)) {
94+
formik.setFieldValue(
95+
"authGroups",
96+
currentGroups.filter((g) => g !== group),
97+
);
98+
} else {
99+
formik.setFieldValue("authGroups", [...currentGroups, group]);
100+
}
101+
}}
102+
scrollDependencies={[formik]}
103+
/>
104+
</Form>
105+
);
106+
};
107+
108+
export default ClusterLinkForm;

0 commit comments

Comments
 (0)