Skip to content

Commit 1acd87c

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

25 files changed

Lines changed: 1351 additions & 86 deletions

src/App.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,9 @@ const ClusterGroupList = lazy(
3535
const ClusterMemberList = lazy(
3636
async () => import("pages/cluster/ClusterMemberList"),
3737
);
38+
const ClusterLinkList = lazy(
39+
async () => import("pages/cluster/ClusterLinkList"),
40+
);
3841
const ClusterMemberDetail = lazy(
3942
async () => import("pages/cluster/ClusterMemberDetail"),
4043
);
@@ -523,6 +526,10 @@ const App: FC = () => {
523526
path={`${ROOT_PATH}/ui/cluster/groups`}
524527
element={<ProtectedRoute outlet={<ClusterGroupList />} />}
525528
/>
529+
<Route
530+
path={`${ROOT_PATH}/ui/cluster/links`}
531+
element={<ProtectedRoute outlet={<ClusterLinkList />} />}
532+
/>
526533
<Route
527534
path={`${ROOT_PATH}/ui/cluster/members`}
528535
element={<ProtectedRoute outlet={<ClusterMemberList />} />}

src/api/cluster-links.tsx

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
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+
import { addEntitlements } from "util/entitlements/api";
10+
11+
const clusterLinkEntitlements = ["can_edit", "can_delete"];
12+
13+
export const fetchClusterLinks = async (
14+
isFineGrained: boolean | null,
15+
): Promise<LxdClusterLink[]> => {
16+
const params = new URLSearchParams();
17+
params.set("recursion", "2");
18+
addEntitlements(params, isFineGrained, clusterLinkEntitlements);
19+
20+
return fetch(`${ROOT_PATH}/1.0/cluster/links?${params.toString()}`)
21+
.then(handleResponse)
22+
.then((data: LxdApiResponse<LxdClusterLink[]>) => {
23+
return data.metadata;
24+
});
25+
};
26+
27+
export const fetchClusterLink = async (
28+
link: string,
29+
isFineGrained: boolean | null,
30+
): Promise<LxdClusterLink> => {
31+
const params = new URLSearchParams();
32+
params.set("recursion", "2");
33+
addEntitlements(params, isFineGrained, clusterLinkEntitlements);
34+
35+
return fetch(
36+
`${ROOT_PATH}/1.0/cluster/links/${encodeURIComponent(link)}?${params.toString()}`,
37+
)
38+
.then(handleResponse)
39+
.then((data: LxdApiResponse<LxdClusterLink>) => {
40+
return data.metadata;
41+
});
42+
};
43+
44+
export const fetchClusterLinkState = async (
45+
link: string,
46+
): Promise<LxdClusterLinkState> => {
47+
return fetch(
48+
`${ROOT_PATH}/1.0/cluster/links/${encodeURIComponent(link)}/state`,
49+
)
50+
.then(handleResponse)
51+
.then((data: LxdApiResponse<LxdClusterLinkState>) => {
52+
return data.metadata;
53+
});
54+
};
55+
56+
export const createClusterLink = async (
57+
body: string,
58+
): Promise<LxdClusterLinkCreated | null> => {
59+
return fetch(`${ROOT_PATH}/1.0/cluster/links`, {
60+
method: "POST",
61+
headers: {
62+
"Content-Type": "application/json",
63+
},
64+
body,
65+
})
66+
.then(handleResponse)
67+
.then((data: LxdApiResponse<LxdClusterLinkCreated | null>) => {
68+
return data.metadata;
69+
});
70+
};
71+
72+
export const updateClusterLink = async (
73+
link: string,
74+
body: string,
75+
): Promise<void> => {
76+
await fetch(`${ROOT_PATH}/1.0/cluster/links/${encodeURIComponent(link)}`, {
77+
method: "PUT",
78+
headers: {
79+
"Content-Type": "application/json",
80+
},
81+
body,
82+
}).then(handleResponse);
83+
};
84+
85+
export const deleteClusterLink = async (link: string): Promise<void> => {
86+
await fetch(`${ROOT_PATH}/1.0/cluster/links/${encodeURIComponent(link)}`, {
87+
method: "DELETE",
88+
}).then(handleResponse);
89+
};

src/components/Navigation.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -528,6 +528,16 @@ const Navigation: FC = () => {
528528
Groups
529529
</NavLink>
530530
</SideNavigationItem>,
531+
<SideNavigationItem key="links">
532+
<NavLink
533+
to={`${ROOT_PATH}/ui/cluster/links`}
534+
title="Links"
535+
onClick={softToggleMenu}
536+
className="accordion-nav-secondary"
537+
>
538+
Links
539+
</NavLink>
540+
</SideNavigationItem>,
531541
<SideNavigationItem key="placement">
532542
<NavLink
533543
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
@@ -11,6 +11,7 @@ export type ResourceIconType =
1111
| "profile"
1212
| "project"
1313
| "cluster-group"
14+
| "cluster-link"
1415
| "cluster-member"
1516
| "network"
1617
| "network-acl"
@@ -38,6 +39,7 @@ const resourceIcons: Record<ResourceIconType, string> = {
3839
project: "folder",
3940
"cluster-group": "cluster-host",
4041
"cluster-member": "single-host",
42+
"cluster-link": "applications",
4143
network: "exposed",
4244
peering: "exposed",
4345
"network-acl": "security-tick",

src/context/useClusterLinks.tsx

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
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+
import { useAuth } from "context/auth";
11+
12+
export const useClusterLinks = (): UseQueryResult<LxdClusterLink[]> => {
13+
const { isFineGrained } = useAuth();
14+
return useQuery({
15+
queryKey: [queryKeys.cluster, queryKeys.links],
16+
queryFn: async () => fetchClusterLinks(isFineGrained),
17+
});
18+
};
19+
20+
export const useClusterLink = (
21+
link: string,
22+
): UseQueryResult<LxdClusterLink> => {
23+
const { isFineGrained } = useAuth();
24+
return useQuery({
25+
queryKey: [queryKeys.cluster, queryKeys.links, link],
26+
queryFn: async () => fetchClusterLink(link, isFineGrained),
27+
});
28+
};
29+
30+
export const useClusterLinkState = (
31+
link: string,
32+
): UseQueryResult<LxdClusterLinkState> => {
33+
return useQuery({
34+
queryKey: [queryKeys.cluster, queryKeys.links, link, queryKeys.state],
35+
queryFn: async () => fetchClusterLinkState(link),
36+
});
37+
};
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: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
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+
initialAuthGroups?: string[];
15+
}
16+
17+
interface Props {
18+
formik: FormikProps<ClusterLinkFormValues>;
19+
}
20+
21+
const ClusterLinkForm: FC<Props> = ({ formik }) => {
22+
const { data: authGroups = [] } = useAuthGroups();
23+
24+
const selectedGroups = new Set(formik.values.authGroups);
25+
const initial = new Set(formik.values.initialAuthGroups);
26+
const removed = [...initial].filter((g) => !selectedGroups.has(g));
27+
const added = [...selectedGroups].filter((g) => !initial.has(g));
28+
const modifiedGroups = new Set([...removed, ...added]);
29+
30+
return (
31+
<Form onSubmit={formik.handleSubmit}>
32+
{/* hidden submit to enable enter key in inputs */}
33+
<Input type="submit" hidden value="Hidden input" />
34+
{formik.values.isCreating && (
35+
<Input
36+
{...formik.getFieldProps("name")}
37+
type="text"
38+
label="Name"
39+
placeholder="Enter name"
40+
required
41+
autoFocus
42+
error={formik.touched.name ? formik.errors.name : null}
43+
/>
44+
)}
45+
<Input
46+
{...formik.getFieldProps("description")}
47+
type="text"
48+
label="Description"
49+
placeholder="Enter description"
50+
/>
51+
{formik.values.isCreating && (
52+
<>
53+
<div className="u-sv1">
54+
<RadioInput
55+
inline
56+
labelClassName="margin-right"
57+
label="Generate token"
58+
checked={formik.values.tokenType === "generate"}
59+
onClick={() => {
60+
formik.setFieldValue("tokenType", "generate");
61+
}}
62+
/>
63+
<RadioInput
64+
inline
65+
label="Consume token"
66+
checked={formik.values.tokenType === "consume"}
67+
onClick={() => {
68+
formik.setFieldValue("tokenType", "consume");
69+
}}
70+
/>
71+
</div>
72+
{formik.values.tokenType === "consume" && (
73+
<Input
74+
{...formik.getFieldProps("token")}
75+
type="text"
76+
label="Token"
77+
placeholder="Enter token"
78+
/>
79+
)}
80+
</>
81+
)}
82+
<Label className="u-sv-2">Auth groups</Label>
83+
<p className="u-text--muted u-sv-1">
84+
Control access for incoming requests through this cluster link.
85+
</p>
86+
<GroupSelection
87+
groups={authGroups}
88+
modifiedGroups={modifiedGroups}
89+
parentItemName="cluster link"
90+
selectedGroups={selectedGroups}
91+
setSelectedGroups={(val, isUnselectAll) => {
92+
if (isUnselectAll) {
93+
formik.setFieldValue("authGroups", []);
94+
} else {
95+
formik.setFieldValue("authGroups", val);
96+
}
97+
}}
98+
toggleGroup={(group) => {
99+
const currentGroups = formik.values.authGroups;
100+
if (currentGroups.includes(group)) {
101+
formik.setFieldValue(
102+
"authGroups",
103+
currentGroups.filter((g) => g !== group),
104+
);
105+
} else {
106+
formik.setFieldValue("authGroups", [...currentGroups, group]);
107+
}
108+
}}
109+
scrollDependencies={[formik]}
110+
/>
111+
</Form>
112+
);
113+
};
114+
115+
export default ClusterLinkForm;

0 commit comments

Comments
 (0)