Rename frontend directory (#472)

* Rename directory

* Cleanup

* Recover lost images

* remove lint
This commit is contained in:
Andrey Pokhilko
2023-09-26 10:04:44 +01:00
committed by GitHub
parent 133eef6745
commit dd7aca70ff
146 changed files with 595 additions and 309 deletions

View File

@@ -0,0 +1,44 @@
/*
* @file Badge.stories.tsx
* @description Badge stories, using Storybook.
* We create a story for the componenet badge,
* and we can use it to test the component in Storybook.
* There, we can see the component in different states, and
* play with the props to see how it behaves.
* We'll use a generic story for the component, and we'll
* use the args to pass the props.
* We'll use a template to create the story.
* Refer to Badge.tsx and the BadgeProps interface to see what props
* the component accepts. The story works with the same props.
*
* @see https://storybook.js.org/docs/react/writing-stories/introduction
*/
import { ComponentStory } from "@storybook/react";
import Badge, { BadgeProps } from "./Badge";
// We create a generic template for the component.
const Template: ComponentStory<typeof Badge> = (args: BadgeProps) => (
<Badge {...args} />
);
// We export the story, and we pass the template to it. For now,
// we are only going to use the default story.
export const Default = Template.bind({});
// We set the props for the story. Recall that the props are the same as the
// ones in BadgeProps, which we impoted.
Default.args = {
type: "success",
children: "Success",
};
// We set the metadata for the story.
// Refer to https://storybook.js.org/docs/react/writing-stories/introduction
// for more information.
export default {
title: "Badge",
component: Badge,
args: {
type: "success",
children: "Success",
},
};

View File

@@ -0,0 +1,72 @@
/**
* This is a generic badge component.
* By passing props you can customize the badge.
* The basic custom types are:
* warning, success, error, info, default.
* You can use this badge like any other html element.
*
* behind the scenes, it uses tailwindcss classes to imlement the badge,
* with the correct styles.
*
* @example
* <Badge type="warning">Warning</Badge>
*
* @param {string} type - The type of the badge.
* @param {string} children - The content of the badge.
* @returns {JSX.Element} - The badge component.
*
*
*/
export type BadgeCode = "success" | "warning" | "error" | "unknown";
export const BadgeCodes = Object.freeze({
ERROR: "error",
WARNING: "warning",
SUCCESS: "success",
UNKNOWN: "unknown",
});
export interface BadgeProps {
type: BadgeCode;
children: React.ReactNode;
additionalClassNames?: string;
}
export default function Badge(props: BadgeProps): JSX.Element {
const colorVariants = {
[BadgeCodes.SUCCESS]: "bg-text-success text-black-800",
[BadgeCodes.WARNING]: "bg-text-warning text-white",
[BadgeCodes.ERROR]: "bg-text-danger text-white",
[BadgeCodes.UNKNOWN]: "bg-secondary text-danger",
};
const badgeBase =
"inline-flex items-center px-1 py-1 rounded text-xs font-light";
const badgeElem = (
<span
className={`${badgeBase} ${colorVariants[props.type]} ${
props.additionalClassNames ?? ""
}`}
>
{props.children}
</span>
);
return badgeElem;
}
export const getBadgeType = (status: string): BadgeCode => {
if (status === "Unknown") {
return BadgeCodes.UNKNOWN;
} else if (
status === "Healthy" ||
status.toLowerCase().includes("exists") ||
status === "available"
) {
return BadgeCodes.SUCCESS;
} else if (status === "Progressing") {
return BadgeCodes.WARNING;
} else {
return BadgeCodes.ERROR;
}
};

View File

@@ -0,0 +1,35 @@
/* eslint-disable no-console */
// Status.stories.ts|tsx
import { ComponentStory, ComponentMeta } from "@storybook/react";
import Button, { ButtonProps } from "./Button";
//👇 This default export determines where your story goes in the story list
export default {
/* 👇 The title prop is optional.
* See https://storybook.js.org/docs/react/configure/overview#configure-story-loading
* to learn how to generate automatic titles
*/
title: "Button",
component: Button,
} as ComponentMeta<typeof Button>;
// Recall that Button has 'props' which is of type ButtonProps
// We want to past theme to the story with the name 'Default', so we
// create a template for it.
// We want to declare default values for the props, so we create a
// default args object.
const Template: ComponentStory<typeof Button> = (args: ButtonProps) => (
<Button {...args} />
);
export const Default = Template.bind({});
Default.args = {
children: (
<>
<span>&uarr;</span>
<span>Update</span>
</>
),
onClick: () => {
console.log("click");
},
};

View File

@@ -0,0 +1,36 @@
/**
* @file Button.tsx
* This component is a generic button component using tailwind.
* You can include an optional icon.
* You can pass the action to be done when the button is clicked using
* the onClick prop.
*
* Props:
*
* @param children: children
* @param onClick: () => void
*
*
*/
// this is a type declaration for the action prop.
// it is a function that takes a string as an argument and returns void.
export interface ButtonProps extends React.HTMLAttributes<HTMLButtonElement> {
children: React.ReactNode;
disabled?: boolean;
onClick: () => void;
className?: string;
}
export default function Button(props: ButtonProps): JSX.Element {
return (
<>
<button
onClick={props.onClick}
className={`${props.className} bg-white border border-gray-300 hover:bg-gray-50 text-black py-1 px-4 rounded `}
disabled={props.disabled}
>
{props.children}
</button>
</>
);
}

View File

@@ -0,0 +1,29 @@
/* eslint-disable no-console */
// ClustersListBar.stories.ts|tsx
import { ComponentStory, ComponentMeta } from "@storybook/react";
import ClustersList from "./ClustersList";
//👇 This default export determines where your story goes in the story list
export default {
/* 👇 The title prop is optional.
* See https://storybook.js.org/docs/react/configure/overview#configure-story-loading
* to learn how to generate automatic titles
*/
title: "ClustersList",
component: ClustersList,
} as ComponentMeta<typeof ClustersList>;
//👇 We create a “template” of how args map to rendering
const Template: ComponentStory<typeof ClustersList> = () => (
<ClustersList
filteredNamespaces={[""]}
installedReleases={[]}
onClusterChange={() => {
console.log("onClusterChange called");
}}
selectedCluster={""}
/>
);
export const Default = Template.bind({});

View File

@@ -0,0 +1,165 @@
import { useMemo } from "react";
import { Cluster, Release } from "../data/types";
import apiService from "../API/apiService";
import { useQuery } from "@tanstack/react-query";
import useCustomSearchParams from "../hooks/useCustomSearchParams";
import { useAppContext } from "../context/AppContext";
import { v4 as uuidv4 } from "uuid";
type ClustersListProps = {
onClusterChange: (clusterName: string) => void;
selectedCluster: string;
filteredNamespaces: string[];
installedReleases?: Release[];
};
function getCleanClusterName(rawClusterName: string) {
if (rawClusterName.indexOf("arn") === 0) {
// AWS cluster
const clusterSplit = rawClusterName.split(":");
const clusterName = clusterSplit.slice(-1)[0].replace("cluster/", "");
const region = clusterSplit.at(-3);
return region + "/" + clusterName + " [AWS]";
}
if (rawClusterName.indexOf("gke") === 0) {
// GKE cluster
return (
rawClusterName.split("_").at(-2) +
"/" +
rawClusterName.split("_").at(-1) +
" [GKE]"
);
}
return rawClusterName;
}
function ClustersList({
installedReleases,
selectedCluster,
filteredNamespaces,
onClusterChange,
}: ClustersListProps) {
const { upsertSearchParams, removeSearchParam } = useCustomSearchParams();
const { clusterMode } = useAppContext();
const { data: clusters } = useQuery<Cluster[]>({
queryKey: ["clusters", selectedCluster],
queryFn: apiService.getClusters,
onSuccess(data) {
const sortedData = data?.sort((a, b) =>
getCleanClusterName(a.Name).localeCompare(getCleanClusterName(b.Name))
);
if (sortedData && sortedData.length > 0 && !selectedCluster) {
onClusterChange(sortedData[0].Name);
}
if (selectedCluster) {
const cluster = data.find(
(cluster) => getCleanClusterName(cluster.Name) === selectedCluster
);
if (!filteredNamespaces && cluster?.Namespace) {
upsertSearchParams("filteredNamespace", cluster.Namespace);
}
}
},
});
const namespaces = useMemo(() => {
const mapNamespaces = new Map<string, number>();
installedReleases?.forEach((release) => {
const amount = mapNamespaces.get(release.namespace)
? Number(mapNamespaces.get(release.namespace)) + 1
: 1;
mapNamespaces.set(release.namespace, amount);
});
return Array.from(mapNamespaces, ([key, value]) => ({
id: uuidv4(),
name: key,
amount: value,
}));
}, [installedReleases]);
const onNamespaceChange = (namespace: string) => {
const newSelectedNamespaces = filteredNamespaces?.includes(namespace)
? filteredNamespaces?.filter((ns) => ns !== namespace)
: [...(filteredNamespaces ?? []), namespace];
removeSearchParam("filteredNamespace");
if (newSelectedNamespaces.length > 0) {
upsertSearchParams(
"filteredNamespace",
newSelectedNamespaces.map((ns) => ns).join("+")
);
}
};
return (
<div className="bg-white flex flex-col p-2 rounded custom-shadow text-cluster-list w-48 m-5 h-fit pb-4 custom-">
{!clusterMode ? (
<>
<label className="font-bold">Clusters</label>
{clusters
?.sort((a, b) =>
getCleanClusterName(a.Name).localeCompare(
getCleanClusterName(b.Name)
)
)
?.map((cluster) => {
return (
<span
key={cluster.Name}
className="flex items-center mt-2 text-xs"
>
<input
className="cursor-pointer"
onChange={(e) => {
onClusterChange(e.target.value);
}}
type="radio"
id={cluster.Name}
value={cluster.Name}
checked={cluster.Name === selectedCluster}
name="clusters"
/>
<label htmlFor={cluster.Name} className="ml-1 ">
{getCleanClusterName(cluster.Name)}
</label>
</span>
);
})}
</>
) : null}
<label className="font-bold mt-4">Namespaces</label>
{namespaces
?.sort((a, b) => a.name.localeCompare(b.name))
?.map((namespace) => (
<span key={namespace.name} className="flex items-center mt-2 text-xs">
<input
type="checkbox"
id={namespace.name}
onChange={(event) => {
onNamespaceChange(event.target.value);
}}
value={namespace.name}
checked={
filteredNamespaces
? filteredNamespaces.includes(namespace.name)
: false
}
/>
<label
htmlFor={namespace.name}
className="ml-1"
>{`${namespace.name} [${namespace.amount}]`}</label>
</span>
))}
</div>
);
}
export default ClustersList;

View File

@@ -0,0 +1,41 @@
import { HD_RESOURCE_CONDITION_TYPE } from "../../API/releases";
import { Tooltip } from "flowbite-react";
import { ReleaseHealthStatus } from "../../data/types";
import { v4 as uuidv4 } from "uuid";
interface Props {
statusData: ReleaseHealthStatus[];
}
const HealthStatus = ({ statusData }: Props) => {
const statuses = statusData.map((item) => {
for (let i = 0; i < item.status.conditions.length; i++) {
const cond = item.status.conditions[i];
if (cond.type !== HD_RESOURCE_CONDITION_TYPE) {
continue;
}
return (
<Tooltip
key={uuidv4()} // this is not a good practice, we need to fetch some unique id from the backend
content={`${cond.status} ${item.kind} ${item.metadata.name}`}
>
<span
className={`inline-block ${
cond.status === "Healthy"
? "bg-success"
: cond.status === "Progressing"
? "bg-warning"
: "bg-danger"
} w-2.5 h-2.5 rounded-sm`}
></span>
</Tooltip>
);
}
});
return <div className="flex flex-wrap gap-1">{statuses}</div>;
};
export default HealthStatus;

View File

@@ -0,0 +1,41 @@
// InstalledPackageCard.stories.ts|tsx
import { ComponentStory, ComponentMeta } from "@storybook/react";
import InstalledPackageCard from "./InstalledPackageCard";
//👇 This default export determines where your story goes in the story list
export default {
/* 👇 The title prop is optional.
* See https://storybook.js.org/docs/react/configure/overview#configure-story-loading
* to learn how to generate automatic titles
*/
title: "InstalledPackageCard",
component: InstalledPackageCard,
} as ComponentMeta<typeof InstalledPackageCard>;
//👇 We create a “template” of how args map to rendering
const Template: ComponentStory<typeof InstalledPackageCard> = (args) => (
<InstalledPackageCard {...args} />
);
export const Default = Template.bind({});
Default.args = {
release: {
id: "",
name: "",
namespace: "",
revision: 1,
updated: "",
status: "",
chart: "",
chart_name: "",
chart_ver: "",
app_version: "",
icon: "",
description: "",
has_tests: false,
chartName: "", // duplicated in some cases in the backend, we need to resolve this
chartVersion: "", // duplicated in some cases in the backend, we need to resolve this
},
};

View File

@@ -0,0 +1,153 @@
import { useState } from "react";
import { Release } from "../../data/types";
import { BsArrowUpCircleFill, BsPlusCircleFill } from "react-icons/bs";
import { getAge } from "../../timeUtils";
import StatusLabel, {
DeploymentStatus,
getStatusColor,
} from "../common/StatusLabel";
import { useQuery } from "@tanstack/react-query";
import apiService from "../../API/apiService";
import HealthStatus from "./HealthStatus";
import HelmGrayIcon from "../../assets/helm-gray-50.svg";
import Spinner from "../Spinner";
import { useGetLatestVersion } from "../../API/releases";
import { isNewerVersion } from "../../utils";
import { LatestChartVersion } from "../../API/interfaces";
import useNavigateWithSearchParams from "../../hooks/useNavigateWithSearchParams";
import { useParams } from "react-router-dom";
type InstalledPackageCardProps = {
release: Release;
};
export default function InstalledPackageCard({
release,
}: InstalledPackageCardProps) {
const navigate = useNavigateWithSearchParams();
const { context: selectedCluster } = useParams();
const [isMouseOver, setIsMouseOver] = useState(false);
const { data: latestVersionResult } = useGetLatestVersion(release.chartName, {
queryKey: ["chartName", release.chartName],
cacheTime: 0,
});
const { data: statusData } = useQuery<any>({
queryKey: ["resourceStatus", release],
queryFn: () => apiService.getResourceStatus({ release }),
});
const latestVersionData: LatestChartVersion | undefined =
latestVersionResult?.[0];
const canUpgrade =
!latestVersionData?.version || !release.chartVersion
? false
: isNewerVersion(release.chartVersion, latestVersionData?.version);
const installRepoSuggestion = latestVersionData?.isSuggestedRepo
? latestVersionData.repository
: null;
const handleMouseOver = () => {
setIsMouseOver(true);
};
const handleMouseOut = () => {
setIsMouseOver(false);
};
const handleOnClick = () => {
const { name, namespace } = release;
navigate(
`/${selectedCluster}/${namespace}/${name}/installed/revision/${release.revision}`,
{ state: release }
);
};
const statusColor = getStatusColor(release.status as DeploymentStatus);
const borderLeftColor: { [key: string]: string } = {
[DeploymentStatus.DEPLOYED]: "border-l-border-deployed",
[DeploymentStatus.FAILED]: "border-l-text-danger",
[DeploymentStatus.PENDING]: "border-l-border",
};
return (
<div
className={`${
borderLeftColor[release.status]
} text-xs grid grid-cols-12 items-center bg-white rounded-md p-2 py-6 my-2 custom-shadow border-l-4 border-l-[${statusColor}] cursor-pointer ${
isMouseOver && "custom-shadow-lg"
}`}
onMouseOver={handleMouseOver}
onMouseOut={handleMouseOut}
onClick={handleOnClick}
>
<img
src={release.icon || HelmGrayIcon}
alt="helm release icon"
className="w-[45px] mx-4 col-span-1 min-w-[45px]"
/>
<div className="col-span-11 -mb-5">
<div className="grid grid-cols-11">
<div className="col-span-3 font-bold text-xl mr-0.5 font-roboto-slab">
{release.name}
</div>
<div className="col-span-3">
<StatusLabel status={release.status} />
</div>
<div className="col-span-2 font-bold">{release.chart}</div>
<div className="col-span-1 font-bold text-xs">
#{release.revision}
</div>
<div className="col-span-1 font-bold text-xs">
{release.namespace}
</div>
<div className="col-span-1 font-bold text-xs">{getAge(release)}</div>
</div>
<div
className="grid grid-cols-11 text-xs mt-3"
style={{ marginBottom: "12px" }}
>
<div className="col-span-3 h-12 line-clamp-3 mr-1">
{release.description}
</div>
<div className="col-span-3 mr-2">
{statusData ? (
<HealthStatus statusData={statusData} />
) : (
<Spinner size={4} />
)}
</div>
<div className="col-span-2 text-muted flex flex-col items">
<span>CHART VERSION</span>
{(canUpgrade || installRepoSuggestion) && (
<div
className="text-upgradable flex flex-row items-center gap-1 font-bold"
title={`upgrade available: ${latestVersionData?.version} from ${latestVersionData?.repository}`}
>
{canUpgrade && !installRepoSuggestion ? (
<>
<BsArrowUpCircleFill />
UPGRADE
</>
) : (
<>
<BsPlusCircleFill />
ADD REPO
</>
)}
</div>
)}
</div>
<div className="col-span-1 text-muted">REVISION</div>
<div className="col-span-1 text-muted">NAMESPACE</div>
<div className="col-span-1 text-muted">UPDATED</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,60 @@
// InstalledPackagesHeader.stories.ts|tsx
import { ComponentStory, ComponentMeta } from "@storybook/react";
import InstalledPackagesHeader from "./InstalledPackagesHeader";
//👇 This default export determines where your story goes in the story list
export default {
/* 👇 The title prop is optional.
* See https://storybook.js.org/docs/react/configure/overview#configure-story-loading
* to learn how to generate automatic titles
*/
title: "InstalledPackagesHeader",
component: InstalledPackagesHeader,
} as ComponentMeta<typeof InstalledPackagesHeader>;
//👇 We create a “template” of how args map to rendering
const Template: ComponentStory<typeof InstalledPackagesHeader> = (args) => (
<InstalledPackagesHeader {...args} />
);
export const Default = Template.bind({});
Default.args = {
filteredReleases: [
{
id: "",
name: "",
namespace: "",
revision: 1,
updated: "",
status: "",
chart: "",
chart_name: "",
chart_ver: "",
app_version: "",
icon: "",
description: "",
has_tests: false,
chartName: "", // duplicated in some cases in the backend, we need to resolve this
chartVersion: "", // duplicated in some cases in the
},
{
id: "",
name: "",
namespace: "",
revision: 1,
updated: "",
status: "",
chart: "",
chart_name: "",
chart_ver: "",
app_version: "",
icon: "",
description: "",
has_tests: false,
chartName: "", // duplicated in some cases in the backend, we need to resolve this
chartVersion: "", // duplicated in some cases in the
},
],
};

View File

@@ -0,0 +1,51 @@
import HeaderLogo from "../../assets/packges-header.svg";
import { Release } from "../../data/types";
type InstalledPackagesHeaderProps = {
filteredReleases?: Release[];
setFilterKey: React.Dispatch<React.SetStateAction<string>>;
isLoading: boolean;
};
export default function InstalledPackagesHeader({
filteredReleases,
setFilterKey,
isLoading,
}: InstalledPackagesHeaderProps) {
const numOfPackages = filteredReleases?.length;
const showNoPackageAlert = Boolean(
!isLoading && (numOfPackages === undefined || numOfPackages === 0)
);
return (
<div className="custom-shadow rounded-t-md ">
<div className="flex items-center justify-between bg-white px-2 py-0.5 font-inter rounded-t-md ">
<div className="flex items-center">
<img
src={HeaderLogo}
alt="Helm-DashBoard"
className="display-inline h-12 ml-3 mr-3 w-[28px] "
/>
<h2 className="display-inline font-bold text-base ">{`Installed Charts (${
numOfPackages || "0"
})`}</h2>
</div>
<div className="w-1/3">
<input
className="border-installed-charts-filter rounded p-1 text-sm w-11/12"
placeholder="Filter..."
type="text"
onChange={(ev) => setFilterKey(ev.target.value)}
/>
</div>
</div>
{showNoPackageAlert && (
<div className="bg-white rounded shadow display-none no-charts mt-3 text-sm p-4">
Looks like you don&apos;t have any charts installed.
&quot;Repository&quot; section may be a good place to start.
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,60 @@
// InstalledPackagesList.stories.ts|tsx
import { ComponentStory, ComponentMeta } from "@storybook/react";
import InstalledPackagesList from "./InstalledPackagesList";
//👇 This default export determines where your story goes in the story list
export default {
/* 👇 The title prop is optional.
* See https://storybook.js.org/docs/react/configure/overview#configure-story-loading
* to learn how to generate automatic titles
*/
title: "InstalledPackagesList",
component: InstalledPackagesList,
} as ComponentMeta<typeof InstalledPackagesList>;
//👇 We create a “template” of how args map to rendering
const Template: ComponentStory<typeof InstalledPackagesList> = (args) => (
<InstalledPackagesList {...args} />
);
export const Default = Template.bind({});
Default.args = {
installedReleases: [
{
id: "",
name: "",
namespace: "",
revision: 1,
updated: "",
status: "",
chart: "",
chart_name: "",
chart_ver: "",
app_version: "",
icon: "",
description: "",
has_tests: false,
chartName: "", // duplicated in some cases in the backend, we need to resolve this
chartVersion: "", // duplicated in some cases in the
},
{
id: "",
name: "",
namespace: "",
revision: 1,
updated: "",
status: "",
chart: "",
chart_name: "",
chart_ver: "",
app_version: "",
icon: "",
description: "",
has_tests: false,
chartName: "", // duplicated in some cases in the backend, we need to resolve this
chartVersion: "", // duplicated in some cases in the
},
],
};

View File

@@ -0,0 +1,23 @@
import InstalledPackageCard from "./InstalledPackageCard";
import { Release } from "../../data/types";
type InstalledPackagesListProps = {
filteredReleases: Release[];
};
export default function InstalledPackagesList({
filteredReleases,
}: InstalledPackagesListProps) {
return (
<div>
{filteredReleases.map((installedPackage: Release) => {
return (
<InstalledPackageCard
key={installedPackage.name}
release={installedPackage}
/>
);
})}
</div>
);
}

View File

@@ -0,0 +1,24 @@
import { NavLink, useLocation } from "react-router-dom";
const LinkWithSearchParams = ({
to,
...props
}: {
to: string;
end?: boolean;
exclude?: string[];
className?: string;
children: React.ReactNode;
}) => {
const { search } = useLocation();
const params = new URLSearchParams(search);
// For state we don't want to keep while navigating
props.exclude?.forEach((key) => {
params.delete(key);
});
return <NavLink to={`${to}/?${params.toString()}`} {...props} />;
};
export default LinkWithSearchParams;

View File

@@ -0,0 +1,38 @@
/* eslint-disable no-console */
/**
* @file SelectMenu.stories.tsx
* @description This file contains the SelectMenu
* component stories.
* currently there is only the default story.
* The default story renders the component with the default props.
*/
import { ComponentStory, ComponentMeta } from "@storybook/react";
import SelectMenu, { SelectMenuItem, SelectMenuProps } from "./SelectMenu";
//👇 This default export determines where your story goes in the story list
export default {
/* 👇 The title prop is optional.
* See https://storybook.js.org/docs/react/configure/overview#configure-story-loading
* to learn how to generate automatic titles
*/
title: "SelectMenu",
component: SelectMenu,
} as ComponentMeta<typeof SelectMenu>;
//👇 We create a "template" of how args map to rendering
const Template: ComponentStory<typeof SelectMenu> = (args: SelectMenuProps) => (
<SelectMenu {...args} />
);
export const Default = Template.bind({});
Default.args = {
header: "Header",
children: [
<SelectMenuItem label="Item 1" id={1} key="item1" />,
<SelectMenuItem label="Item 2" id={2} key="item2" />,
<SelectMenuItem label="Item 3" id={3} key="item3" />,
],
selected: 1,
onSelect: (id: number) => console.log(id),
};

View File

@@ -0,0 +1,82 @@
/**
*
* @file SelectMenu.tsx
* @description SelectMenu component
* This component is used to render a select menu. This is a component
* with two parts: The menu header and the menu items. The menu header
* is just text, and the menu items are radio toggles.
* The menu items are passed as children of type SelectMenuItem,
* which is an object with a label and id.
* SelectMenuItem is defined in this file.
* The menu header is a string.
*
* We use an interface to define the props that are passed to the component.
* The interface name is SelectMenuProps. The interface is defined in the
* same file as the component.
*
* @interface SelectMenuProps:
* @property {string} header - The menu header
* @property {SelectMenuItem[]} children - The menu items
* @property {number} selected - The id of the selected menu item
* @property {Function} onSelect - The function to call when a menu item is selected
*
* @return {JSX.Element} - The component
*
*
*/
// define the SelectMenuItem type:
// This is an object with a label and id.
// The label is a string, and the id is a number.
// The id is used to identify the selected menu item.
export interface SelectMenuItemProps {
label: string;
id: number;
}
// define the SelectMenuProps interface:
export interface SelectMenuProps {
header: string;
children: React.ReactNode;
selected: number;
onSelect: (id: number) => void;
}
// define the SelectMenu component:
// remember to use tailwind classes for styling
// recall that the menu items are radio buttons
// the selected menu item is the one that is checked
// the onSelect function is called when a menu item is selected
// the onSelect function is passed the id of the selected menu item
export function SelectMenuItem({
label,
id,
}: SelectMenuItemProps): JSX.Element {
return (
<div className="flex flex-row">
<input
type="radio"
name="menu"
id={id.toString()} // id must be a string
value={id}
checked={false}
onChange={() => {
return;
}}
/>
<label htmlFor={id.toString()}>{label}</label>
</div>
);
}
export default function SelectMenu(props: SelectMenuProps): JSX.Element {
const { header, children } = props;
return (
<div className="card flex flex-col">
<h2 className="text-xl font-bold">{header}</h2>
<div className="flex flex-col">{children}</div>
</div>
);
}

View File

@@ -0,0 +1,21 @@
// TabsBar.stories.ts|tsx
import { ComponentStory, ComponentMeta } from "@storybook/react";
import ShutDownButton from "./ShutDownButton";
//👇 This default export determines where your story goes in the story list
export default {
/* 👇 The title prop is optional.
* See https://storybook.js.org/docs/react/configure/overview#configure-story-loading
* to learn how to generate automatic titles
*/
title: "ShutDownButton",
component: ShutDownButton,
} as ComponentMeta<typeof ShutDownButton>;
//👇 We create a “template” of how args map to rendering
const Template: ComponentStory<typeof ShutDownButton> = () => (
<ShutDownButton />
);
export const Default = Template.bind({});

View File

@@ -0,0 +1,33 @@
import { BsPower } from "react-icons/bs";
import Modal from "./modal/Modal";
import { useShutdownHelmDashboard } from "../API/other";
function ShutDownButton() {
const { mutate: signOut, status } = useShutdownHelmDashboard();
const handleClick = async () => {
signOut();
};
return (
<div className="w-full">
<Modal title="Session Ended" isOpen={status === "success"}>
<p>
The Helm Dashboard application has been shut down. You can now close
the browser tab.
</p>
</Modal>
<button
onClick={handleClick}
title="Shut down the Helm Dashboard application"
className="flex justify-center w-full mr-5 py-3 border border-transparent hover:border hover:border-gray-500 rounded hover:rounded-lg"
>
<BsPower className="w-6" />
</button>
</div>
);
}
export default ShutDownButton;

View File

@@ -0,0 +1,22 @@
export default function Spinner({ size = 8 }: { size?: number }) {
return (
<div role="status">
<svg
aria-hidden="true"
className={`w-${size} h-${size} mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-blue-600`}
viewBox="0 0 100 101"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z"
fill="currentColor"
/>
<path
d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z"
fill="currentFill"
/>
</svg>
</div>
);
}

View File

@@ -0,0 +1,39 @@
// TabsBar.stories.ts|tsx
import { ComponentStory, ComponentMeta } from "@storybook/react";
import Tabs from "./Tabs";
//👇 This default export determines where your story goes in the story list
export default {
/* 👇 The title prop is optional.
* See https://storybook.js.org/docs/react/configure/overview#configure-story-loading
* to learn how to generate automatic titles
*/
title: "Tabs",
component: Tabs,
} as ComponentMeta<typeof Tabs>;
//👇 We create a “template” of how args map to rendering
const Template: ComponentStory<typeof Tabs> = (args) => <Tabs {...args} />;
export const Default = Template.bind({});
const defaultArgs = {
tabs: [
{
label: "tab1",
content: <div>tab1</div>,
},
{
label: "tab2",
content: <div>tab2</div>,
},
{
label: "tab3",
content: <div>tab3</div>,
},
],
};
//@ts-ignore
Default.args = defaultArgs;

View File

@@ -0,0 +1,43 @@
import { ReactNode } from "react";
import useCustomSearchParams from "../hooks/useCustomSearchParams";
export interface Tab {
value: string;
label: string;
content: ReactNode;
}
interface TabsProps {
tabs: Tab[];
selectedTab: Tab;
}
export default function Tabs({ tabs, selectedTab }: TabsProps) {
const { upsertSearchParams } = useCustomSearchParams();
const moveTab = (tab: Tab) => {
upsertSearchParams("tab", tab.value);
};
return (
<div className="flex flex-col">
<div className="flex pb-2">
{tabs.map((tab) => (
<button
key={tab.label}
className={`cursor-pointer px-4 py-2 text-sm font-normal text-tab-color focus:outline-none"
${
selectedTab.value === tab.value &&
"border-b-[3px] border-tab-color"
}
`}
onClick={() => moveTab(tab)}
>
{tab.label}
</button>
))}
</div>
<div>{selectedTab.content}</div>
</div>
);
}

View File

@@ -0,0 +1,39 @@
// TabsBar.stories.ts|tsx
import { ComponentStory, ComponentMeta } from "@storybook/react";
import TabsBar from "./TabsBar";
//👇 This default export determines where your story goes in the story list
export default {
/* 👇 The title prop is optional.
* See https://storybook.js.org/docs/react/configure/overview#configure-story-loading
* to learn how to generate automatic titles
*/
title: "TabsBar",
component: TabsBar,
} as ComponentMeta<typeof TabsBar>;
//👇 We create a “template” of how args map to rendering
const Template: ComponentStory<typeof TabsBar> = (args) => (
<TabsBar {...args} />
);
export const Default = Template.bind({});
Default.args = {
tabs: [
{
name: "tab1",
component: <div className="w-250 h-250 bg-green-400">tab1</div>,
},
{
name: "tab2",
component: <div className="w-250 h-250 bg-red-400">tab2</div>,
},
{
name: "tab3",
component: <div className="w-250 h-250 bg-blue-400">tab3</div>,
},
],
activeTab: "tab1",
};

View File

@@ -0,0 +1,47 @@
/**
* @file TabsBar.tsx
*
* This component is the bar that contains the tabs.
* it gets the tabs as a prop that contains a list with the name of the tabs
* and the component that should be rendered when the tab is clicked.
*
* @param {Array} tabs - the tabs that should be rendered
* @param {string} activeTab - the name of the active tab
* @param {Function} setActiveTab - the function that should be called when a tab is clicked
* @param {Function} setTabContent - the function that should be called when a tab is clicked
*
* @returns {JSX.Element} - the tabs bar
*
*
*/
interface TabsBarProps {
tabs: Array<{ name: string; component: JSX.Element }>;
activeTab: string;
setActiveTab: (tab: string) => void;
setTabContent: (tab: string) => void;
}
export default function TabsBar({
tabs,
activeTab,
setActiveTab,
setTabContent,
}: TabsBarProps): JSX.Element {
return (
<div className="relative">
{tabs.map((tab) => (
<div
className={`tab ${activeTab === tab.name ? "active" : ""}`}
onClick={() => {
setActiveTab(tab.name);
setTabContent(tab.name);
}}
key={tab.name}
>
{tab.name}
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,30 @@
/**
* @file TextInput.stories.tsx
* @description This file contains the TextInput component stories.
* the first story simply renders the component with the default props.
*/
import { ComponentStory, ComponentMeta } from "@storybook/react";
import TextInput, { TextInputProps } from "./TextInput";
//👇 This default export determines where your story goes in the story list
export default {
/* 👇 The title prop is optional.
* See https://storybook.js.org/docs/react/configure/overview#configure-story-loading
* to learn how to generate automatic titles
*/
title: "TextInput",
component: TextInput,
} as ComponentMeta<typeof TextInput>;
//👇 We create a “template” of how args map to rendering
const Template: ComponentStory<typeof TextInput> = (args: TextInputProps) => (
<TextInput {...args} />
);
export const Default = Template.bind({});
Default.args = {
label: "Label",
placeholder: "Placeholder",
isMandatory: false,
};

View File

@@ -0,0 +1,38 @@
/**
*
* @file TextInput.tsx
* @description This is a single-lined text field.
* You can choose a placeholder, label,
* and whether the field is mandatory.
* @interface TextInputProps:
* - label: the label to be displayed
* - placeholder: placeholder text
* - isMandatory: adds a red star if is.
*
* @return JSX.Element
*
*/
export interface TextInputProps {
label: string;
placeholder: string;
isMandatory?: boolean;
onChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
}
export default function TextInput(props: TextInputProps): JSX.Element {
return (
<div className="mb-6">
<label className="block ml-1 mb-1 text-sm font-medium text-gray-900dark:text-white">
{props.label}
{/* if prop.isMandatory is true, add a whitespace and a red star to signify it*/}
{props.isMandatory ? <span className="text-red-500"> *</span> : ""}
</label>
<input
type="text"
placeholder={props.placeholder}
className="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 "
/>
</div>
);
}

View File

@@ -0,0 +1,41 @@
import { type ReactElement, cloneElement } from "react";
export default function Tooltip({
id,
title,
element,
}: {
title: string;
id: string;
element: ReactElement;
}) {
return (
<>
{cloneElement(element, { "data-tooltip-target": id })}
<div
id={id}
role="tooltip"
className="absolute z-10 invisible inline-block px-3 py-2 text-sm font-medium text-white transition-opacity duration-300 bg-gray-900 rounded-lg shadow-sm opacity-0 tooltip dark:bg-gray-700"
>
{title}
<div className="tooltip-arrow" data-popper-arrow></div>
</div>
<button
data-tooltip-target="tooltip-default"
type="button"
className="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800"
>
Default tooltip
</button>
<div
id="tooltip-default"
role="tooltip"
className="absolute z-10 invisible inline-block px-3 py-2 text-sm font-medium text-white transition-opacity duration-300 bg-gray-900 rounded-lg shadow-sm opacity-0 tooltip dark:bg-gray-700"
>
Tooltip content
<div className="tooltip-arrow" data-popper-arrow></div>
</div>
</>
);
}

View File

@@ -0,0 +1,10 @@
import { Meta, StoryFn } from "@storybook/react";
import { Troubleshoot } from "./Troubleshoot";
export default {
title: "Troubleshoot",
component: Troubleshoot,
} as Meta<typeof Troubleshoot>;
const Template: StoryFn<typeof Troubleshoot> = () => <Troubleshoot />;
export const Default = Template.bind({});

View File

@@ -0,0 +1,18 @@
import { RiExternalLinkLine } from "react-icons/ri";
export const Troubleshoot = () => {
return (
<div>
<a
href="https://www.komodor.com/helm-dash/?utm_campaign=Helm%20Dashboard%20%7C%20CTA&utm_source=helm-dash&utm_medium=cta&utm_content=helm-dash"
target="_blank"
rel="noreferrer"
>
<button className="bg-primary text-white p-2 flex items-center rounded text-sm font-medium font-roboto">
Troubleshoot in Komodor
<RiExternalLinkLine className="ml-2 text-lg" />
</button>
</a>
</div>
);
};

View File

@@ -0,0 +1,40 @@
import { ComponentStory, ComponentMeta } from "@storybook/react";
import { Button } from "./Button";
// More on default export: https://storybook.js.org/docs/react/writing-stories/introduction#default-export
export default {
title: "Example/Button",
component: Button,
// More on argTypes: https://storybook.js.org/docs/react/api/argtypes
argTypes: {
backgroundColor: { control: "color" },
},
} as ComponentMeta<typeof Button>;
// More on component templates: https://storybook.js.org/docs/react/writing-stories/introduction#using-args
const Template: ComponentStory<typeof Button> = (args) => <Button {...args} />;
export const Primary = Template.bind({});
// More on args: https://storybook.js.org/docs/react/writing-stories/args
Primary.args = {
primary: true,
label: "Button",
};
export const Secondary = Template.bind({});
Secondary.args = {
label: "Button",
};
export const Large = Template.bind({});
Large.args = {
size: "large",
label: "Button",
};
export const Small = Template.bind({});
Small.args = {
size: "small",
label: "Button",
};

View File

@@ -0,0 +1,51 @@
import "./button.css";
interface ButtonProps {
/**
* Is this the principal call to action on the page?
*/
primary?: boolean;
/**
* What background color to use
*/
backgroundColor?: string;
/**
* How large should the button be?
*/
size?: "small" | "medium" | "large";
/**
* Button contents
*/
label: string;
/**
* Optional click handler
*/
onClick?: () => void;
}
/**
* Primary UI component for user interaction
*/
export const Button = ({
primary = false,
size = "medium",
backgroundColor,
label,
...props
}: ButtonProps) => {
const mode = primary
? "storybook-button--primary"
: "storybook-button--secondary";
return (
<button
type="button"
className={["storybook-button", `storybook-button--${size}`, mode].join(
" "
)}
style={{ backgroundColor }}
{...props}
>
{label}
</button>
);
};

View File

@@ -0,0 +1,30 @@
.storybook-button {
font-family: "Nunito Sans", "Helvetica Neue", Helvetica, Arial, sans-serif;
font-weight: 700;
border: 0;
border-radius: 3em;
cursor: pointer;
display: inline-block;
line-height: 1;
}
.storybook-button--primary {
color: white;
background-color: #1ea7fd;
}
.storybook-button--secondary {
color: #333;
background-color: transparent;
box-shadow: rgba(0, 0, 0, 0.15) 0px 0px 0px 1px inset;
}
.storybook-button--small {
font-size: 12px;
padding: 10px 16px;
}
.storybook-button--medium {
font-size: 14px;
padding: 11px 20px;
}
.storybook-button--large {
font-size: 16px;
padding: 12px 24px;
}

View File

@@ -0,0 +1,35 @@
/* eslint-disable no-console */
// DropDown.stories.ts|tsx
import { ComponentStory, ComponentMeta } from "@storybook/react";
import DropDown from "./DropDown";
import { BsSlack, BsGithub } from "react-icons/bs";
//👇 This default export determines where your story goes in the story list
export default {
/* 👇 The title prop is optional.
* See https://storybook.js.org/docs/react/configure/overview#configure-story-loading
* to learn how to generate automatic titles
*/
title: "DropDown",
component: DropDown,
} as ComponentMeta<typeof DropDown>;
//👇 We create a “template” of how args map to rendering
const Template: ComponentStory<typeof DropDown> = (args) => (
<DropDown {...args} />
);
export const Default = Template.bind({});
const onClick = () => {
console.log("drop down clicked");
};
Default.args = {
items: [
{ id: "1", text: "Menu Item 1", onClick: onClick, icon: <BsSlack /> },
{ id: "2 ", isSeparator: true },
{ id: "3", text: "Menu Item 3", isDisabled: true, icon: <BsGithub /> },
],
};

View File

@@ -0,0 +1,107 @@
import { ReactNode, useEffect, useRef, useState } from "react";
import ArrowDownIcon from "../../assets/arrow-down-icon.svg";
export type DropDownItem = {
id: string;
text?: string;
icon?: ReactNode;
onClick?: () => void;
isSeparator?: boolean;
isDisabled?: boolean;
};
export type DropDownProps = {
items: DropDownItem[];
};
type PopupState = {
isOpen: boolean;
X: number;
Y: number;
};
function DropDown({ items }: DropDownProps) {
const [popupState, setPopupState] = useState<PopupState>({
isOpen: false,
X: 0,
Y: 0,
});
const modalRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (popupState.isOpen) {
document.addEventListener("mousedown", handleClickOutside);
} else {
document.removeEventListener("mousedown", handleClickOutside);
}
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, [popupState.isOpen]);
const handleClickOutside = (event: MouseEvent) => {
if (modalRef.current && !modalRef.current.contains(event.target as Node)) {
setPopupState((prev) => ({
...prev,
isOpen: false,
}));
}
};
return (
<>
<div className="relative flex flex-col items-center">
<button
onClick={(e) => {
setPopupState((prev) => ({
...prev,
isOpen: !prev.isOpen,
X: e.pageX,
Y: e.pageY,
}));
}}
className="flex items-center justify-between"
>
Help
<img src={ArrowDownIcon} className="ml-2 w-[10px] h-[10px]" />
</button>
</div>
{popupState.isOpen && (
<div
ref={modalRef}
className={`z-10 flex flex-col py-1 gap-1 bg-white mt-3 absolute rounded border top-[${popupState.Y}] left-[${popupState.X}] border-gray-200`}
>
{items.map((item) => (
<>
{item.isSeparator ? (
<div className="bg-gray-300 h-[1px]" />
) : (
<div
onClick={() => {
item.onClick?.();
setPopupState((prev) => ({
...prev,
isOpen: false,
}));
}}
className={`cursor-pointer font-normal flex items-center gap-2 py-1 pl-3 pr-7 hover:bg-dropdown ${
item.isDisabled
? "cursor-default hover:bg-transparent text-gray-400"
: ""
}`}
>
{item.icon && <span> {item.icon ?? null}</span>}
<span>{item.text}</span>
</div>
)}
</>
))}
</div>
)}
</>
);
}
export default DropDown;

View File

@@ -0,0 +1,24 @@
import { ComponentStory, ComponentMeta } from "@storybook/react";
import { Header } from "./Header";
export default {
title: "Example/Header",
component: Header,
parameters: {
// More on Story layout: https://storybook.js.org/docs/react/configure/story-layout
layout: "fullscreen",
},
} as ComponentMeta<typeof Header>;
const Template: ComponentStory<typeof Header> = (args) => <Header {...args} />;
export const LoggedIn = Template.bind({});
LoggedIn.args = {
user: {
name: "Jane Doe",
},
};
export const LoggedOut = Template.bind({});
LoggedOut.args = {};

View File

@@ -0,0 +1,69 @@
import { Button } from "../Button/Button";
import "./header.css";
type User = {
name: string;
};
interface HeaderProps {
user?: User;
onLogin: () => void;
onLogout: () => void;
onCreateAccount: () => void;
}
export const Header = ({
user,
onLogin,
onLogout,
onCreateAccount,
}: HeaderProps) => (
<header>
<div className="wrapper">
<div>
<svg
width="32"
height="32"
viewBox="0 0 32 32"
xmlns="http://www.w3.org/2000/svg"
>
<g fill="none" fillRule="evenodd">
<path
d="M10 0h12a10 10 0 0110 10v12a10 10 0 01-10 10H10A10 10 0 010 22V10A10 10 0 0110 0z"
fill="#FFF"
/>
<path
d="M5.3 10.6l10.4 6v11.1l-10.4-6v-11zm11.4-6.2l9.7 5.5-9.7 5.6V4.4z"
fill="#555AB9"
/>
<path
d="M27.2 10.6v11.2l-10.5 6V16.5l10.5-6zM15.7 4.4v11L6 10l9.7-5.5z"
fill="#91BAF8"
/>
</g>
</svg>
<h1>Acme</h1>
</div>
<div>
{user ? (
<>
<span className="welcome">
Welcome, <b>{user.name}</b>!
</span>
<Button size="small" onClick={onLogout} label="Log out" />
</>
) : (
<>
<Button size="small" onClick={onLogin} label="Log in" />
<Button
primary
size="small"
onClick={onCreateAccount}
label="Sign up"
/>
</>
)}
</div>
</div>
</header>
);

View File

@@ -0,0 +1,32 @@
.wrapper {
font-family: "Nunito Sans", "Helvetica Neue", Helvetica, Arial, sans-serif;
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
padding: 15px 20px;
display: flex;
align-items: center;
justify-content: space-between;
}
svg {
display: inline-block;
vertical-align: top;
}
h1 {
font-weight: 900;
font-size: 20px;
line-height: 1;
margin: 6px 0 6px 10px;
display: inline-block;
vertical-align: top;
}
button + button {
margin-left: 10px;
}
.welcome {
color: #333;
font-size: 14px;
margin-right: 10px;
}

View File

@@ -0,0 +1,25 @@
import { ComponentStory, ComponentMeta } from "@storybook/react";
import { within, userEvent } from "@storybook/testing-library";
import { Page } from "./Page";
export default {
title: "Example/Page",
component: Page,
parameters: {
// More on Story layout: https://storybook.js.org/docs/react/configure/story-layout
layout: "fullscreen",
},
} as ComponentMeta<typeof Page>;
const Template: ComponentStory<typeof Page> = (args) => <Page {...args} />;
export const LoggedOut = Template.bind({});
export const LoggedIn = Template.bind({});
// More on interaction testing: https://storybook.js.org/docs/react/writing-tests/interaction-testing
LoggedIn.play = async ({ canvasElement }) => {
const canvas = within(canvasElement);
const loginButton = await canvas.getByRole("button", { name: /Log in/i });
await userEvent.click(loginButton);
};

View File

@@ -0,0 +1,92 @@
import React from "react";
import { Header } from "../Header/Header";
import "./page.css";
type User = {
name: string;
};
export const Page: React.VFC = () => {
const [user, setUser] = React.useState<User>();
return (
<article>
<Header
user={user}
onLogin={() => setUser({ name: "Jane Doe" })}
onLogout={() => setUser(undefined)}
onCreateAccount={() => setUser({ name: "Jane Doe" })}
/>
<section>
<h2>Pages in Storybook</h2>
<p>
We recommend building UIs with a{" "}
<a
href="https://componentdriven.org"
target="_blank"
rel="noopener noreferrer"
>
<strong>component-driven</strong>
</a>{" "}
process starting with atomic components and ending with pages.
</p>
<p>
Render pages with mock data. This makes it easy to build and review
page states without needing to navigate to them in your app. Here are
some handy patterns for managing page data in Storybook:
</p>
<ul>
<li>
Use a higher-level connected component. Storybook helps you compose
such data from the &quot;args&quot; of child component stories
</li>
<li>
Assemble data in the page component from your services. You can mock
these services out using Storybook.
</li>
</ul>
<p>
Get a guided tutorial on component-driven development at{" "}
<a
href="https://storybook.js.org/tutorials/"
target="_blank"
rel="noopener noreferrer"
>
Storybook tutorials
</a>
. Read more in the{" "}
<a
href="https://storybook.js.org/docs"
target="_blank"
rel="noopener noreferrer"
>
docs
</a>
.
</p>
<div className="tip-wrapper">
<span className="tip">Tip</span> Adjust the width of the canvas with
the{" "}
<svg
width="10"
height="10"
viewBox="0 0 12 12"
xmlns="http://www.w3.org/2000/svg"
>
<g fill="none" fillRule="evenodd">
<path
d="M1.5 5.2h4.8c.3 0 .5.2.5.4v5.1c-.1.2-.3.3-.4.3H1.4a.5.5 0 01-.5-.4V5.7c0-.3.2-.5.5-.5zm0-2.1h6.9c.3 0 .5.2.5.4v7a.5.5 0 01-1 0V4H1.5a.5.5 0 010-1zm0-2.1h9c.3 0 .5.2.5.4v9.1a.5.5 0 01-1 0V2H1.5a.5.5 0 010-1zm4.3 5.2H2V10h3.8V6.2z"
id="a"
fill="#999"
/>
</g>
</svg>
Viewports addon in the toolbar
</div>
</section>
</article>
);
};

View File

@@ -0,0 +1,69 @@
section {
font-family: "Nunito Sans", "Helvetica Neue", Helvetica, Arial, sans-serif;
font-size: 14px;
line-height: 24px;
padding: 48px 20px;
margin: 0 auto;
max-width: 600px;
color: #333;
}
section h2 {
font-weight: 900;
font-size: 32px;
line-height: 1;
margin: 0 0 4px;
display: inline-block;
vertical-align: top;
}
section p {
margin: 1em 0;
}
section a {
text-decoration: none;
color: #1ea7fd;
}
section ul {
padding-left: 30px;
margin: 1em 0;
}
section li {
margin-bottom: 8px;
}
section .tip {
display: inline-block;
border-radius: 1em;
font-size: 11px;
line-height: 12px;
font-weight: 700;
background: #e7fdd8;
color: #66bf3c;
padding: 4px 12px;
margin-right: 10px;
vertical-align: top;
}
section .tip-wrapper {
font-size: 13px;
line-height: 20px;
margin-top: 40px;
margin-bottom: 40px;
}
section .tip-wrapper svg {
display: inline-block;
height: 12px;
width: 12px;
margin-right: 4px;
vertical-align: top;
margin-top: 3px;
}
section .tip-wrapper svg path {
fill: #1ea7fd;
}

View File

@@ -0,0 +1,39 @@
import { ComponentStory } from "@storybook/react";
import StatusLabel, { DeploymentStatus } from "./StatusLabel";
export default {
title: "StatusLabel",
component: StatusLabel,
};
const Template: ComponentStory<typeof StatusLabel> = (args) => (
<StatusLabel {...args} />
);
export const Deployed = Template.bind({});
Deployed.args = {
status: DeploymentStatus.DEPLOYED,
isRollback: false,
};
export const Failed = Template.bind({});
Failed.args = {
status: DeploymentStatus.FAILED,
isRollback: false,
};
export const Pending = Template.bind({});
Pending.args = {
status: DeploymentStatus.PENDING,
isRollback: false,
};
export const Superseded = Template.bind({});
Superseded.args = {
status: DeploymentStatus.SUPERSEDED,
isRollback: false,
};

View File

@@ -0,0 +1,42 @@
import { AiOutlineReload } from "react-icons/ai";
type StatusLabelProps = {
status: string;
isRollback?: boolean;
};
export enum DeploymentStatus {
DEPLOYED = "deployed",
FAILED = "failed",
PENDING = "pending-install",
SUPERSEDED = "superseded",
}
export function getStatusColor(status: DeploymentStatus) {
if (status === DeploymentStatus.DEPLOYED) return "text-deployed";
if (status === DeploymentStatus.FAILED) return "text-failed";
if (status === DeploymentStatus.PENDING) return "text-pending";
else return "text-superseded";
}
function StatusLabel({ status, isRollback }: StatusLabelProps) {
const statusColor = getStatusColor(status as DeploymentStatus);
return (
<div
style={{
minWidth: "100px",
display: "flex",
fontSize: "14px",
justifyContent: "space-between",
}}
>
<span className={`${statusColor} font-bold text-xs`}>
{status.toUpperCase()}
</span>
{isRollback && <AiOutlineReload size={14} />}
</div>
);
}
export default StatusLabel;

View File

@@ -0,0 +1,25 @@
// Modal.stories.ts|tsx
import { ComponentStory, ComponentMeta } from "@storybook/react";
import AddRepositoryModal from "./AddRepositoryModal";
//👇 This default export determines where your story goes in the story list
export default {
/* 👇 The title prop is optional.
* See https://storybook.js.org/docs/react/configure/overview#configure-story-loading
* to learn how to generate automatic titles
*/
title: "AddRepositoryModal",
component: AddRepositoryModal,
} as ComponentMeta<typeof AddRepositoryModal>;
//👇 We create a “template” of how args map to rendering
const Template: ComponentStory<typeof AddRepositoryModal> = (args) => (
<AddRepositoryModal {...args} isOpen={true} />
);
export const Default = Template.bind({});
Default.args = {
isOpen: true,
};

View File

@@ -0,0 +1,166 @@
import { useEffect, useState } from "react";
import Modal from "./Modal";
import Spinner from "../Spinner";
import useAlertError from "../../hooks/useAlertError";
import useCustomSearchParams from "../../hooks/useCustomSearchParams";
import { useAppContext } from "../../context/AppContext";
import { useQueryClient } from "@tanstack/react-query";
import { useNavigate, useParams } from "react-router-dom";
import apiService from "../../API/apiService";
interface FormKeys {
name: string;
url: string;
username: string;
password: string;
}
type AddRepositoryModalProps = {
isOpen: boolean;
onClose: () => void;
};
function AddRepositoryModal({ isOpen, onClose }: AddRepositoryModalProps) {
const [formData, setFormData] = useState<FormKeys>({} as FormKeys);
const [isLoading, setIsLoading] = useState(false);
const alertError = useAlertError();
const { searchParamsObject } = useCustomSearchParams();
const { repo_url, repo_name } = searchParamsObject;
const { setSelectedRepo } = useAppContext();
const { context } = useParams();
const navigate = useNavigate();
const queryClient = useQueryClient();
useEffect(() => {
if (!repo_url || !repo_name) return;
setFormData({ ...formData, name: repo_name, url: repo_url });
}, [repo_url, repo_name, formData]);
const addRepository = () => {
const body = new FormData();
body.append("name", formData.name ?? "");
body.append("url", formData.url ?? "");
body.append("username", formData.username ?? "");
body.append("password", formData.password ?? "");
setIsLoading(true);
apiService.fetchWithDefaults<void>("/api/helm/repositories", {
method: "POST",
body,
})
.then(() => {
setIsLoading(false);
onClose();
queryClient.invalidateQueries({
queryKey: ["helm", "repositories"],
});
setSelectedRepo(formData.name || "");
navigate(`/${context}/repository/${formData.name}`, {
replace: true,
});
})
.catch((error) => {
alertError.setShowErrorModal({
title: "Failed to add repo",
msg: error.message,
});
})
.finally(() => {
setIsLoading(false);
});
};
return (
<Modal
containerClassNames={"w-full max-w-5xl"}
title="Add Chart Repository"
isOpen={isOpen}
onClose={onClose}
bottomContent={
<div className="flex justify-end p-6 space-x-2 border-t border-gray-200 rounded-b dark:border-gray-600">
<button
className="flex items-center text-white font-medium px-3 py-1.5 bg-primary hover:bg-add-repo focus:ring-4 focus:outline-none focus:ring-blue-300 disabled:bg-blue-300 rounded-lg text-base text-center dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800"
onClick={addRepository}
disabled={isLoading}
>
{isLoading && <Spinner size={4} />}
Add Repository
</button>
</div>
}
>
<div className="flex gap-x-3">
<label className="flex-1" htmlFor="name">
<div className="mb-2 text-sm require">Name</div>
<input
value={formData.name}
onChange={(e) =>
setFormData({
...formData,
[e.target.id]: e.target.value,
})
}
required
id="name"
type="text"
placeholder="Komodorio"
className="rounded-lg p-2 w-full border border-gray-300 focus:outline-none focus:border-sky-500 input-box-shadow"
/>
</label>
<label className="flex-1" htmlFor="url">
<div className="mb-2 text-sm require">URL</div>
<input
value={formData.url}
onChange={(e) =>
setFormData({
...formData,
[e.target.id]: e.target.value,
})
}
required
id="url"
type="text"
placeholder="https://helm-charts.komodor.io"
className="rounded-lg p-2 w-full border border-gray-300 focus:outline-none focus:border-sky-500 input-box-shadow"
/>
</label>
</div>
<div className="flex gap-x-3">
<label className="flex-1 " htmlFor="username">
<div className="mb-2 text-sm">Username</div>
<input
onChange={(e) =>
setFormData({
...formData,
[e.target.id]: e.target.value,
})
}
required
id="username"
type="text"
className="rounded-lg p-2 w-full border border-gray-300 focus:outline-none focus:border-sky-500 input-box-shadow"
/>
</label>
<label className="flex-1" htmlFor="password">
<div className="mb-2 text-sm">Password</div>
<input
onChange={(e) =>
setFormData({
...formData,
[e.target.id]: e.target.value,
})
}
required
id="password"
type="text"
className="rounded-lg p-2 w-full border border-gray-300 focus:outline-none focus:border-sky-500 input-box-shadow"
/>
</label>
</div>
</Modal>
);
}
export default AddRepositoryModal;

View File

@@ -0,0 +1,31 @@
/* eslint-disable no-console */
// Modal.stories.ts|tsx
import { ComponentStory, ComponentMeta } from "@storybook/react";
import ErrorModal from "./ErrorModal";
//👇 This default export determines where your story goes in the story list
export default {
/* 👇 The title prop is optional.
* See https://storybook.js.org/docs/react/configure/overview#configure-story-loading
* to learn how to generate automatic titles
*/
title: "ErrorModal",
component: ErrorModal,
} as ComponentMeta<typeof ErrorModal>;
//👇 We create a “template” of how args map to rendering
const Template: ComponentStory<typeof ErrorModal> = (args) => (
<ErrorModal {...args} />
);
export const Default = Template.bind({});
Default.args = {
onClose: () => {
console.log("on Close clicked");
},
titleText: "Failed to get list of charts",
contentText:
"failed to get list of releases, cause: Kubernetes cluster unreachable: Get &#34;https://kubernetes.docker.internal:6443/version&#34;: dial tcp 127.0.0.1:6443: connectex: No connection could be made because the target machine actively refused it.",
};

View File

@@ -0,0 +1,63 @@
import Modal from "./Modal";
interface ErrorModalProps {
isOpen: boolean;
titleText: string;
contentText: string;
onClose: () => void;
}
export default function ErrorModal({
isOpen,
onClose,
titleText,
contentText,
}: ErrorModalProps) {
const ErrorTitle = (
<div className="font-medium text-2xl text-error-color">
<div className="flex gap-3">
<svg
xmlns="http://www.w3.org/2000/svg"
width="26"
height="26"
fill="currentColor"
className="bi bi-exclamation-triangle-fill mt-1"
viewBox="0 0 16 16"
>
<path d="M8.982 1.566a1.13 1.13 0 0 0-1.96 0L.165 13.233c-.457.778.091 1.767.98 1.767h13.713c.889 0 1.438-.99.98-1.767L8.982 1.566zM8 5c.535 0 .954.462.9.995l-.35 3.507a.552.552 0 0 1-1.1 0L7.1 5.995A.905.905 0 0 1 8 5zm.002 6a1 1 0 1 1 0 2 1 1 0 0 1 0-2z" />
</svg>
{titleText}
</div>
<h4 className="alert-heading" />
</div>
);
const bottomContent = (
<div className="flex py-6 px-4 space-x-2 border-t border-gray-200 rounded-b dark:border-gray-600">
<span className="text-sm text-muted fs-80 text-gray-500">
Hint: Komodor has the same HELM capabilities, with enterprise features
and support.{" "}
<a
href="https://www.komodor.com/helm-dash/?utm_campaign=Helm%20Dashboard%20%7C%20CTA&utm_source=helm-dash&utm_medium=cta&utm_content=helm-dash"
target="_blank" rel="noreferrer"
>
<span className="text-link-color underline">Sign up for free.</span>
</a>
</span>
</div>
);
return (
<Modal
containerClassNames={
"border-2 border-error-border-color bg-error-background w-2/3"
}
title={ErrorTitle}
isOpen={isOpen}
onClose={onClose}
bottomContent={bottomContent}
>
<p className="text-error-color border-green-400">{contentText}</p>
</Modal>
);
}

View File

@@ -0,0 +1,66 @@
import Modal from "./Modal";
interface ErrorModalProps {
isOpen: boolean;
titleText: string;
contentText: string;
onClose: () => void;
}
export default function GlobalErrorModal({
isOpen,
onClose,
titleText,
contentText,
}: ErrorModalProps) {
const ErrorTitle = (
<div className="font-medium text-2xl text-error-color">
<div className="flex gap-3">
<svg
xmlns="http://www.w3.org/2000/svg"
width="26"
height="26"
fill="currentColor"
className="bi bi-exclamation-triangle-fill mt-1"
viewBox="0 0 16 16"
>
<path d="M8.982 1.566a1.13 1.13 0 0 0-1.96 0L.165 13.233c-.457.778.091 1.767.98 1.767h13.713c.889 0 1.438-.99.98-1.767L8.982 1.566zM8 5c.535 0 .954.462.9.995l-.35 3.507a.552.552 0 0 1-1.1 0L7.1 5.995A.905.905 0 0 1 8 5zm.002 6a1 1 0 1 1 0 2 1 1 0 0 1 0-2z" />
</svg>
{titleText}
</div>
<h4 className="alert-heading" />
</div>
);
return (
<Modal
containerClassNames={
"border-2 border-error-border-color bg-error-background w-3/5 "
}
title={ErrorTitle}
isOpen={isOpen}
onClose={onClose}
bottomContent={
<div className="text-xs">
Hint: Komodor has the same HELM capabilities, with enterprise features
and support.{" "}
<a
className="text-blue-500"
href="https://komodor.com/helm-dash/?utm_campaign=Helm+Dashboard+%7C+CTA&utm_source=helm-dash&utm_medium=cta&utm_content=helm-dash"
target="_blank"
rel="noopener noreferrer"
>
Sign up for free.
</a>
</div>
}
>
<p
style={{ minWidth: "500px" }}
className="text-error-color border-green-400 text-sm"
>
{contentText}
</p>
</Modal>
);
}

View File

@@ -0,0 +1,39 @@
import hljs from "highlight.js";
import Spinner from "../../Spinner";
export const ChartValues = ({
chartValues,
loading,
}: {
chartValues: string;
loading: boolean;
}) => {
return (
<div className="w-1/2">
<label
className="block tracking-wide text-gray-700 text-xl font-medium mb-2"
htmlFor="grid-user-defined-values"
>
Chart Value Reference:
</label>
<pre
className="text-base bg-chart-values p-2 rounded font-medium w-full max-h-[330px] block overflow-y-auto font-sf-mono"
dangerouslySetInnerHTML={
chartValues && !loading
? {
__html: hljs.highlight(chartValues, {
language: "yaml",
}).value,
}
: undefined
}
>
{loading ? (
<Spinner />
) : !chartValues && !loading ? (
"No original values information found"
) : null}
</pre>
</div>
);
};

View File

@@ -0,0 +1,26 @@
import { ChartValues } from "./ChartValues";
import { UserDefinedValues } from "./UserDefinedValues";
interface DefinedValuesProps {
initialValue: string;
onUserValuesChange: (values: string) => void;
chartValues: string;
loading: boolean;
}
export const DefinedValues = ({
initialValue,
chartValues,
onUserValuesChange,
loading,
}: DefinedValuesProps) => {
return (
<div className="flex w-full gap-6 mt-4">
<UserDefinedValues
initialValue={initialValue}
onValuesChange={onUserValuesChange}
/>
<ChartValues chartValues={chartValues} loading={loading} />
</div>
);
};

View File

@@ -0,0 +1,56 @@
import { useState, useEffect } from "react";
import { useParams } from "react-router-dom";
import useDebounce from "../../../hooks/useDebounce";
export const GeneralDetails = ({
releaseName,
namespace = "",
disabled,
onNamespaceInput,
onReleaseNameInput,
}: {
releaseName: string;
namespace?: string;
disabled: boolean;
onNamespaceInput: (namespace: string) => void;
onReleaseNameInput: (chartName: string) => void;
}) => {
const [namespaceInputValue, setNamespaceInputValue] = useState(namespace);
const namespaceInputValueDebounced = useDebounce<string>(namespaceInputValue, 500);
useEffect(() => {
onNamespaceInput(namespaceInputValueDebounced);
}, [namespaceInputValueDebounced, onNamespaceInput]);
const { context } = useParams();
const inputClassName = ` text-lg py-1 px-2 border border-1 border-gray-300 ${
disabled ? "bg-gray-200" : "bg-white "
} rounded`;
return (
<div className="flex gap-8">
<div>
<h4 className="text-lg">Release name:</h4>
<input
className={inputClassName}
value={releaseName}
disabled={disabled}
onChange={(e) => onReleaseNameInput(e.target.value)}
></input>
</div>
<div>
<h4 className="text-lg">Namespace (optional):</h4>
<input
className={inputClassName}
value={namespaceInputValue}
disabled={disabled}
onChange={(e) => setNamespaceInputValue(e.target.value)}
></input>
</div>
{context ? (
<div className="flex">
<h4 className="text-lg">Cluster:</h4>
<p className="text-lg">{context}</p>
</div>
) : null}
</div>
);
};

View File

@@ -0,0 +1,260 @@
import { useParams } from "react-router-dom";
import useAlertError from "../../../hooks/useAlertError";
import { useMemo, useState } from "react";
import {
useChartReleaseValues,
useGetReleaseManifest,
useGetVersions,
useVersionData,
} from "../../../API/releases";
import Modal, { ModalButtonStyle } from "../Modal";
import { GeneralDetails } from "./GeneralDetails";
import { ManifestDiff } from "./ManifestDiff";
import { useMutation } from "@tanstack/react-query";
import useNavigateWithSearchParams from "../../../hooks/useNavigateWithSearchParams";
import { VersionToInstall } from "./VersionToInstall";
import { isNewerVersion, isNoneEmptyArray } from "../../../utils";
import useCustomSearchParams from "../../../hooks/useCustomSearchParams";
import { useChartRepoValues } from "../../../API/repositories";
import { useDiffData } from "../../../API/shared";
import { InstallChartModalProps } from "../../../data/types";
import { DefinedValues } from "./DefinedValues";
export const InstallReleaseChartModal = ({
isOpen,
onClose,
chartName,
currentlyInstalledChartVersion,
latestVersion,
isUpgrade = false,
latestRevision,
}: InstallChartModalProps) => {
const navigate = useNavigateWithSearchParams();
const { setShowErrorModal } = useAlertError();
const [userValues, setUserValues] = useState<string>();
const [installError, setInstallError] = useState("");
const {
namespace: queryNamespace,
chart: _releaseName,
context: selectedCluster,
} = useParams();
const { searchParamsObject } = useCustomSearchParams();
const { filteredNamespace } = searchParamsObject;
const [namespace, setNamespace] = useState(queryNamespace || "");
const [releaseName, setReleaseName] = useState(_releaseName || "");
const { error: versionsError, data: _versions } = useGetVersions(chartName, {
select: (data) => {
return data?.sort((a, b) =>
isNewerVersion(a.version, b.version) ? 1 : -1
);
},
onSuccess: (data) => {
const empty = { version: "", repository: "", urls: [] };
return setSelectedVersionData(data[0] ?? empty);
},
});
const versions = _versions?.map((v) => ({
...v,
isChartVersion: v.version === currentlyInstalledChartVersion,
}));
// eslint-disable-next-line @typescript-eslint/no-unused-vars
latestVersion = latestVersion ?? currentlyInstalledChartVersion; // a guard for typescript, latestVersion is always defined
const [selectedVersionData, setSelectedVersionData] = useState<{
version: string;
repository?: string;
urls: string[];
}>();
const selectedVersion = useMemo(() => {
return selectedVersionData?.version;
}, [selectedVersionData]);
const selectedRepo = useMemo(() => {
return selectedVersionData?.repository || "";
}, [selectedVersionData]);
const chartAddress = useMemo(() => {
if (!selectedVersionData || !selectedVersionData.repository) return "";
return selectedVersionData.urls?.[0]?.startsWith("file://")
? selectedVersionData.urls[0]
: `${selectedVersionData.repository}/${chartName}`;
}, [selectedVersionData, chartName]);
// the original chart values
const { data: chartValues } = useChartRepoValues({
version: selectedVersion || "",
chart: chartAddress,
});
// The user defined values (if any we're set)
const { data: releaseValues, isLoading: loadingReleaseValues } =
useChartReleaseValues({
namespace,
release: String(releaseName),
revision: latestRevision ? latestRevision : undefined,
});
// This hold the selected version manifest, we use it for the diff
const { data: selectedVerData, error: selectedVerDataError } = useVersionData(
{
version: selectedVersion || "",
userValues: userValues || "",
chartAddress,
releaseValues,
namespace,
releaseName,
}
);
const { data: currentVerManifest, error: currentVerManifestError } =
useGetReleaseManifest({
namespace,
chartName: _releaseName || "",
});
const {
data: diffData,
isLoading: isLoadingDiff,
error: diffError,
} = useDiffData({
selectedRepo,
versionsError: versionsError as string,
currentVerManifest,
selectedVerData,
chart: chartAddress,
});
// Confirm method (install)
const setReleaseVersionMutation = useMutation(
[
"setVersion",
namespace,
releaseName,
selectedVersion,
selectedRepo,
selectedCluster,
chartAddress,
],
async () => {
setInstallError("");
const formData = new FormData();
formData.append("preview", "false");
if (chartAddress) {
formData.append("chart", chartAddress);
}
formData.append("version", selectedVersion || "");
formData.append("values", userValues || releaseValues || ""); // if userValues is empty, we use the release values
const res = await fetch(
// Todo: Change to BASE_URL from env
`/api/helm/releases/${
namespace ? namespace : "default"
}${`/${releaseName}`}`,
{
method: "post",
body: formData,
headers: {
"X-Kubecontext": selectedCluster as string,
},
}
);
if (!res.ok) {
setShowErrorModal({
title: "Failed to upgrade the chart",
msg: String(await res.text()),
});
}
return res.json();
},
{
onSuccess: async (response) => {
onClose();
setSelectedVersionData({ version: "", urls: [] }); //cleanup
navigate(
`/${selectedCluster}/${
namespace ? namespace : "default"
}/${releaseName}/installed/revision/${response.version}`
);
window.location.reload();
},
onError: (error) => {
setInstallError((error as Error)?.message || "Failed to update");
},
}
);
return (
<Modal
isOpen={isOpen}
onClose={() => {
setSelectedVersionData({ version: "", urls: [] });
setUserValues(releaseValues);
onClose();
}}
title={
<div className="font-bold">
{`${isUpgrade ? "Upgrade" : "Install"} `}
{(isUpgrade || releaseValues) && (
<span className="text-green-700 ">{chartName}</span>
)}
</div>
}
containerClassNames="w-full text-2xl h-2/3"
actions={[
{
id: "1",
callback: setReleaseVersionMutation.mutate,
variant: ModalButtonStyle.info,
isLoading: setReleaseVersionMutation.isLoading,
disabled:
loadingReleaseValues ||
isLoadingDiff ||
setReleaseVersionMutation.isLoading,
},
]}
>
{versions && isNoneEmptyArray(versions) && (
<VersionToInstall
versions={versions}
initialVersion={selectedVersionData}
onSelectVersion={setSelectedVersionData}
showCurrentVersion
/>
)}
<GeneralDetails
releaseName={releaseName}
disabled
namespace={namespace ? namespace : filteredNamespace}
onReleaseNameInput={setReleaseName}
onNamespaceInput={setNamespace}
/>
<DefinedValues
initialValue={releaseValues}
onUserValuesChange={(values: string) => setUserValues(values)}
chartValues={chartValues}
loading={loadingReleaseValues}
/>
<ManifestDiff
diff={diffData as string}
isLoading={isLoadingDiff}
error={
(currentVerManifestError as string) ||
(selectedVerDataError as string) ||
(diffError as string) ||
installError ||
(versionsError as string)
}
/>
</Modal>
);
};

View File

@@ -0,0 +1,229 @@
import { useParams } from "react-router-dom";
import useAlertError from "../../../hooks/useAlertError";
import { useMemo, useState } from "react";
import { useGetVersions, useVersionData } from "../../../API/releases";
import Modal, { ModalButtonStyle } from "../Modal";
import { GeneralDetails } from "./GeneralDetails";
import { ManifestDiff } from "./ManifestDiff";
import { useMutation } from "@tanstack/react-query";
import { useChartRepoValues } from "../../../API/repositories";
import useNavigateWithSearchParams from "../../../hooks/useNavigateWithSearchParams";
import { VersionToInstall } from "./VersionToInstall";
import { isNewerVersion, isNoneEmptyArray } from "../../../utils";
import { useDiffData } from "../../../API/shared";
import { InstallChartModalProps } from "../../../data/types";
import { DefinedValues } from "./DefinedValues";
export const InstallRepoChartModal = ({
isOpen,
onClose,
chartName,
currentlyInstalledChartVersion,
latestVersion,
}: InstallChartModalProps) => {
const navigate = useNavigateWithSearchParams();
const { setShowErrorModal } = useAlertError();
const [userValues, setUserValues] = useState("");
const [installError, setInstallError] = useState("");
const { context: selectedCluster, selectedRepo: currentRepoCtx } =
useParams();
const [namespace, setNamespace] = useState("");
const [releaseName, setReleaseName] = useState(chartName);
const { error: versionsError, data: _versions } = useGetVersions(chartName, {
select: (data) => {
return data?.sort((a, b) =>
isNewerVersion(a.version, b.version) ? 1 : -1
);
},
onSuccess: (data) => {
const empty = { version: "", repository: "", urls: [] };
const versionsToRepo = data.filter(
(v) => v.repository === currentRepoCtx
);
return setSelectedVersionData(versionsToRepo[0] ?? empty);
},
});
const versions = _versions?.map((v) => ({
...v,
isChartVersion: v.version === currentlyInstalledChartVersion,
}));
// eslint-disable-next-line @typescript-eslint/no-unused-vars
latestVersion = latestVersion ?? currentlyInstalledChartVersion; // a guard for typescript, latestVersion is always defined
const [selectedVersionData, setSelectedVersionData] = useState<{
version: string;
repository?: string;
urls: string[];
}>();
const selectedVersion = useMemo(() => {
return selectedVersionData?.version;
}, [selectedVersionData]);
const selectedRepo = useMemo(() => {
return selectedVersionData?.repository;
}, [selectedVersionData]);
const chartAddress = useMemo(() => {
if (!selectedVersionData || !selectedVersionData?.repository) {
return "";
}
return selectedVersionData?.urls?.[0]?.startsWith("file://")
? selectedVersionData?.urls[0]
: `${selectedVersionData?.repository}/${chartName}`;
}, [selectedVersionData, chartName]);
const { data: chartValues, isLoading: loadingChartValues } =
useChartRepoValues({
version: selectedVersion || "",
chart: chartAddress,
});
// This hold the selected version manifest, we use it for the diff
const { data: selectedVerData, error: selectedVerDataError } = useVersionData(
{
version: selectedVersion || "",
userValues,
chartAddress,
releaseValues: userValues,
namespace,
releaseName,
isInstallRepoChart: true,
options: {
enabled: Boolean(chartAddress),
},
}
);
const {
data: diffData,
isLoading: isLoadingDiff,
error: diffError,
} = useDiffData({
selectedRepo: selectedRepo || "",
versionsError: versionsError as string,
currentVerManifest: "", // current version manifest should always be empty since its a fresh install
selectedVerData,
chart: chartAddress,
});
// Confirm method (install)
const setReleaseVersionMutation = useMutation(
[
"setVersion",
namespace,
releaseName,
selectedVersion,
selectedRepo,
selectedCluster,
chartAddress,
],
async () => {
setInstallError("");
const formData = new FormData();
formData.append("preview", "false");
formData.append("chart", chartAddress);
formData.append("version", selectedVersion || "");
formData.append("values", userValues);
formData.append("name", releaseName || "");
const res = await fetch(
// Todo: Change to BASE_URL from env
`/api/helm/releases/${namespace ? namespace : "default"}`,
{
method: "post",
body: formData,
headers: {
"X-Kubecontext": selectedCluster as string,
},
}
);
if (!res.ok) {
setShowErrorModal({
title: "Failed to install the chart",
msg: String(await res.text()),
});
}
return res.json();
},
{
onSuccess: async (response) => {
onClose();
navigate(
`/${selectedCluster}/${response.namespace}/${response.name}/installed/revision/1`
);
},
onError: (error) => {
setInstallError((error as Error)?.message || "Failed to update");
},
}
);
return (
<Modal
isOpen={isOpen}
onClose={() => {
setSelectedVersionData({ version: "", urls: [] });
onClose();
}}
title={
<div className="font-bold">
Install <span className="text-green-700 ">{chartName}</span>
</div>
}
containerClassNames="w-full text-2xl h-2/3"
actions={[
{
id: "1",
callback: setReleaseVersionMutation.mutate,
variant: ModalButtonStyle.info,
isLoading: setReleaseVersionMutation.isLoading,
disabled:
loadingChartValues ||
isLoadingDiff ||
setReleaseVersionMutation.isLoading,
},
]}
>
{versions && isNoneEmptyArray(versions) && (
<VersionToInstall
versions={versions}
initialVersion={selectedVersionData}
onSelectVersion={setSelectedVersionData}
showCurrentVersion={false}
/>
)}
<GeneralDetails
releaseName={releaseName ?? ""}
disabled={false}
namespace={namespace}
onReleaseNameInput={setReleaseName}
onNamespaceInput={setNamespace}
/>
<DefinedValues
initialValue={""}
onUserValuesChange={setUserValues}
chartValues={chartValues}
loading={loadingChartValues}
/>
<ManifestDiff
diff={diffData as string}
isLoading={isLoadingDiff}
error={
(selectedVerDataError as string) ||
(diffError as string) ||
installError ||
(versionsError as string)
}
/>
</Modal>
);
};

View File

@@ -0,0 +1,65 @@
import { Diff2HtmlUI } from "diff2html/lib/ui/js/diff2html-ui-base";
import hljs from "highlight.js";
import { useEffect, useRef } from "react";
import Spinner from "../../Spinner";
import { diffConfiguration } from "../../../utils";
interface ManifestDiffProps {
diff?: string;
isLoading: boolean;
error: string;
}
export const ManifestDiff = ({ diff, isLoading, error }: ManifestDiffProps) => {
const diffContainerRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (isLoading) {
// we're listening to isLoading to draw new diffs which are not
// always rerender, probably because of the use of ref
return;
}
if (diff && diffContainerRef.current) {
const diff2htmlUi = new Diff2HtmlUI(
diffContainerRef.current,
diff,
diffConfiguration,
hljs
);
diff2htmlUi.draw();
diff2htmlUi.highlightCode();
}
}, [diff, isLoading]);
if (isLoading && !error) {
return (
<div className="flex text-lg items-end">
<Spinner />
Calculating diff...
</div>
);
}
return (
<div>
<h4 className="text-xl">Manifest changes:</h4>
{error ? (
<p className="text-red-600 text-lg">
Failed to get upgrade info: {error.toString()}
</p>
) : diff ? (
<div
ref={diffContainerRef}
className="relative overflow-y-auto leading-5"
></div>
) : (
<pre className="font-roboto text-base">
No changes will happen to the cluster
</pre>
)}
</div>
);
};

View File

@@ -0,0 +1,39 @@
import { useEffect, useState } from "react";
import useDebounce from "../../../hooks/useDebounce";
export const UserDefinedValues = ({
initialValue,
onValuesChange,
}: {
initialValue: string;
onValuesChange: (val: string) => void;
}) => {
const [userDefinedValues, setUserDefinedValues] = useState(initialValue);
const debouncedValue = useDebounce<string>(userDefinedValues, 500);
useEffect(() => {
if (!debouncedValue || debouncedValue === initialValue) {
return;
}
onValuesChange(debouncedValue);
}, [debouncedValue, onValuesChange, initialValue]);
return (
<div className="w-1/2 ">
<label
className="block tracking-wide text-gray-700 text-xl font-medium mb-2"
htmlFor="grid-user-defined-values"
>
User-Defined Values:
</label>
<textarea
value={userDefinedValues}
defaultValue={initialValue}
onChange={(e) => setUserDefinedValues(e.target.value)}
rows={14}
className="block p-2.5 w-full text-md text-gray-900 rounded-lg border border-gray-300 focus:ring-blue-500 focus:border-blue-500 resize-none font-monospace"
></textarea>
</div>
);
};

View File

@@ -0,0 +1,113 @@
import { useMemo, useState } from "react";
import Select, { components } from "react-select";
import { BsCheck2 } from "react-icons/bs";
import { NonEmptyArray } from "../../../data/types";
interface Version {
repository: string;
version: string;
isChartVersion: boolean;
urls: string[];
}
export const VersionToInstall: React.FC<{
versions: NonEmptyArray<Version>;
initialVersion?: {
repository?: string;
version?: string;
};
onSelectVersion: (props: {
version: string;
repository: string;
urls: string[];
}) => void;
showCurrentVersion: boolean;
}> = ({ versions, onSelectVersion, showCurrentVersion, initialVersion }) => {
const chartVersion = useMemo(
() => versions.find(({ isChartVersion }) => isChartVersion)?.version,
[versions]
);
const currentVersion =
chartVersion && showCurrentVersion ? (
<p className="text-xl text-muted ml-2">
{"(current version is "}
<span className="text-green-700">{`${chartVersion}`}</span>
{")"}
</p>
) : null;
// Prepare your options for react-select
const options = useMemo(
() =>
versions.map(({ repository, version, urls }) => ({
value: { repository, version, urls },
label: `${repository} @ ${version}`,
check: chartVersion === version,
})) || [],
[chartVersion, versions]
);
const [selectedOption, setSelectedOption] =
useState<(typeof options)[number]>();
const initOpt = useMemo(
() =>
options.find(
({ value }) =>
value.version === initialVersion?.version &&
value.repository === initialVersion?.repository
),
[options, initialVersion]
);
return (
<div className="flex gap-2 text-xl items-center">
{versions?.length && (selectedOption || initOpt) ? (
<>
Version to install:{" "}
<Select
className="basic-single cursor-pointer min-w-[272px]"
classNamePrefix="select"
isClearable={false}
isSearchable={false}
name="version"
options={options}
onChange={(selectedOption) => {
if (selectedOption) {
setSelectedOption(selectedOption);
onSelectVersion(selectedOption.value);
}
}}
value={selectedOption ?? initOpt}
components={{
SingleValue: ({ children, ...props }) => (
<components.SingleValue {...props}>
<span className="text-green-700 font-bold">{children}</span>
{props.data.check && showCurrentVersion && (
<BsCheck2 className="inline-block ml-2 text-green-700 font-bold" />
)}
</components.SingleValue>
),
Option: ({ children, innerProps, data }) => (
<div
className={
"flex items-center py-2 pl-4 pr-2 text-green-700 hover:bg-blue-100"
}
{...innerProps}
>
<div className="width-auto">{children}</div>
{data.check && showCurrentVersion && (
<BsCheck2
fontWeight={"bold"}
className="inline-block ml-2 text-green-700 font-bold"
/>
)}
</div>
),
}} // Use the custom Option component
/>
</>
) : null}
{currentVersion}
</div>
);
};

View File

@@ -0,0 +1,116 @@
/* eslint-disable no-console */
// Modal.stories.ts|tsx
import { ComponentStory, ComponentMeta } from "@storybook/react";
import Modal, { ModalAction, ModalButtonStyle } from "./Modal";
//👇 This default export determines where your story goes in the story list
export default {
/* 👇 The title prop is optional.
* See https://storybook.js.org/docs/react/configure/overview#configure-story-loading
* to learn how to generate automatic titles
*/
title: "Modal",
component: Modal,
} as ComponentMeta<typeof Modal>;
//👇 We create a “template” of how args map to rendering
const Template: ComponentStory<typeof Modal> = (args) => (
<Modal {...args}>Basic text content</Modal>
);
export const Default = Template.bind({});
const confirmModalActions: ModalAction[] = [
{
id: "1",
text: "Cancel",
callback: () => {
console.log("confirmModal: clicked Cancel");
},
},
{
id: "2",
text: "Confirm",
callback: () => {
console.log("confirmModal: clicked Confirm");
},
variant: ModalButtonStyle.info,
},
];
Default.args = {
title: "Basic text title",
isOpen: true,
actions: confirmModalActions,
};
const customModalActions: ModalAction[] = [
{
id: "1",
text: "custom button 1",
className:
"text-white bg-green-700 hover:bg-green-800 focus:ring-4 focus:outline-none focus:ring-green-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:bg-green-600 dark:hover:bg-green-700 dark:focus:ring-green-800",
callback: () => {
console.log("confirmModal: clicked custom button 1");
},
},
{
id: "2",
text: "custom button 2",
callback: () => {
console.log("confirmModal: clicked custom button 2");
},
variant: ModalButtonStyle.error,
},
];
export const CustomModal: ComponentStory<typeof Modal> = (args) => (
<Modal {...args}>
<div>
<p className="text-base leading-relaxed text-gray-500 dark:text-gray-400">
Custom Modal Content
</p>
<button
className="bg-cyan-500 p-2"
type="button"
onClick={() => console.log("just a button")}
>
Just a button
</button>
</div>
</Modal>
);
CustomModal.args = {
title: (
<div>
Custom <span className="text-red-500"> Title</span>
</div>
),
isOpen: true,
actions: customModalActions,
};
export const AutoScrollWhenContentIsMoreThan500Height: ComponentStory<
typeof Modal
> = (args) => (
<Modal {...args}>
<div
style={{
height: "1000px",
width: "50%",
backgroundColor: "skyblue",
}}
>
This div height is 1000 px so we can see a vertical scroll to the right of
it.
</div>
</Modal>
);
AutoScrollWhenContentIsMoreThan500Height.args = {
title: "Auto Scroll when content is more than 500px height",
isOpen: true,
actions: confirmModalActions,
};

View File

@@ -0,0 +1,158 @@
import { PropsWithChildren, ReactNode } from "react";
import ReactDom from "react-dom";
import Spinner from "../Spinner";
export enum ModalButtonStyle {
default,
info,
error,
success,
disabled,
}
export interface ModalAction {
id: string;
callback: () => void;
text?: string;
variant?: ModalButtonStyle;
className?: string;
disabled?: boolean;
isLoading?: boolean;
}
export interface ModalProps extends PropsWithChildren {
title?: string | ReactNode;
isOpen: boolean;
onClose?: () => void;
containerClassNames?: string;
actions?: ModalAction[];
bottomContent?: ReactNode;
}
const Modal = ({
title,
isOpen,
onClose,
children,
actions,
containerClassNames,
bottomContent,
}: ModalProps) => {
const colorVariants = new Map<ModalButtonStyle, string>([
[
ModalButtonStyle.default,
"text-base font-semibold text-gray-500 bg-white hover:bg-gray-100 disabled:bg-gray-200 focus:ring-4 focus:outline-none focus:ring-gray-200 rounded-lg border border-gray-200 font-medium px-5 py-1 hover:text-gray-900 focus:z-10 ",
],
[
ModalButtonStyle.info,
"font-semibold text-white bg-blue-700 hover:bg-blue-800 disabled:bg-blue-700/80 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-base px-3 py-1.5 text-center ",
],
[
ModalButtonStyle.success,
"font-semibold text-white bg-green-700 hover:bg-green-800 disabled:bg-green-700/80 focus:ring-4 focus:outline-none focus:ring-green-300 font-medium rounded-lg text-base px-3 py-1.5 text-center ",
],
[
ModalButtonStyle.error,
"font-semibold text-white bg-red-700 hover:bg-red-800 disabled:bg-red-700/80 focus:ring-4 focus:outline-none focus:ring-red-300 font-medium rounded-lg text-base px-3 py-1.5 text-center ",
],
[
ModalButtonStyle.disabled,
"font-semibold text-gray-500 bg-gray-200 hover:bg-gray-100 focus:ring-4 focus:outline-none focus:ring-gray-200 rounded-lg border border-gray-200 text-base font-medium px-3 py-1.5 hover:text-gray-900 focus:z-10 ",
],
]);
const getClassName = (action: ModalAction) => {
if (action.className) return action.className;
return action.variant
? colorVariants.get(action.variant)
: colorVariants.get(ModalButtonStyle.default);
};
const getTitle = (title: string | ReactNode) => {
if (typeof title === "string")
return <h3 className="text-xl font-medium text-grey">{title}</h3>;
else return title;
};
return ReactDom.createPortal(
<>
{isOpen && (
<div className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity ">
<div className="flex justify-center">
<div
style={{
maxHeight: "95vh",
overflow: "hidden",
}}
className={`relative rounded-lg shadow m-7 w-2/5 max-w-[1300px] ${
!containerClassNames ||
(containerClassNames && !containerClassNames.includes("bg-"))
? "bg-white"
: ""
} ${containerClassNames ?? ""}`}
>
{title && (
<div className="flex items-start justify-between p-4 border-b rounded-t ">
{getTitle(title)}
{onClose ? (
<button
type="button"
className="text-gray-400 bg-transparent hover:bg-gray-200 hover:text-gray-900 rounded-lg text-sm p-1.5 ml-auto inline-flex items-center dark:hover:bg-gray-600 dark:hover:text-white"
data-modal-hide="staticModal"
onClick={() => onClose()}
>
<svg
className="w-5 h-5"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
clipRule="evenodd"
></path>
</svg>
</button>
) : null}
</div>
)}
<div className="p-4 space-y-6 overflow-y-auto max-h-[calc(100vh_-_200px)]">
{children}
</div>
{bottomContent ? (
<div className="p-5 text-sm">{bottomContent}</div>
) : (
<div className="flex justify-end p-6 space-x-2 border-t border-gray-200 rounded-b ">
{actions?.map((action) => (
<button
key={action.id}
type="button"
className={
action.isLoading
? `flex items-center font-bold justify-around space-x-1 ${getClassName(
action
)}`
: `${getClassName(action)} `
}
onClick={action.callback}
disabled={action.disabled || action.isLoading}
>
{action.isLoading ? <Spinner size={4} /> : null}
{action.text ?? "Confirm"}
</button>
))}
</div>
)}
</div>
</div>
</div>
)}
</>,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
document.getElementById("portal")!
);
};
export default Modal;

View File

@@ -0,0 +1,32 @@
// ChartViewer.stories.ts|tsx
import { ComponentStory, ComponentMeta } from "@storybook/react";
import ChartViewer from "./ChartViewer";
//👇 This default export determines where your story goes in the story list
export default {
/* 👇 The title prop is optional.
* See https://storybook.js.org/docs/react/configure/overview#configure-story-loading
* to learn how to generate automatic titles
*/
title: "ChartViewer",
component: ChartViewer,
} as ComponentMeta<typeof ChartViewer>;
//👇 We create a “template” of how args map to rendering
const Template: ComponentStory<typeof ChartViewer> = (args) => (
<ChartViewer {...args} />
);
export const Default = Template.bind({});
const defaultArgs = {
chart: {
name: "chart1",
description: "chart1 description",
version: "v1.0.0",
},
};
//@ts-ignore
Default.args = defaultArgs;

View File

@@ -0,0 +1,56 @@
import { useState } from "react";
import { Chart } from "../../data/types";
import { InstallRepoChartModal } from "../modal/InstallChartModal/InstallRepoChartModal";
type ChartViewerProps = {
chart: Chart;
};
function ChartViewer({ chart }: ChartViewerProps) {
const [showInstallButton, setShowInstallButton] = useState(false);
const [showInstallModal, setShowInstallModal] = useState(false);
const handleMouseOver = () => {
setShowInstallButton(true);
};
const handleMouseOut = () => {
setShowInstallButton(false);
};
return (
<>
<div
className="grid grid-cols-10 gap-3 hover:bg-body-background p-4 text-sm"
onMouseOver={handleMouseOver}
onMouseOut={handleMouseOut}
>
<span className="col-span-2 font-semibold flex flex-row items-center gap-1 text-base">
<img src={chart.icon} className="h-4" />
{chart.name}
</span>
<span className="col-span-6 text-sm">{chart.description}</span>
<span className="col-span-1 text-center">{chart.version}</span>
<span className="col-span-1 text-center">
<button
className={`bg-white border border-gray-300 px-2 p-1 rounded-md font-semibold ${
showInstallButton ? "visible" : "invisible"
}`}
onClick={() => setShowInstallModal(true)}
>
Install
</button>
</span>
</div>
{showInstallModal && (
<InstallRepoChartModal
chartName={chart.name}
currentlyInstalledChartVersion={chart.version}
isOpen={showInstallModal}
onClose={() => setShowInstallModal(false)}
/>
)}
</>
);
}
export default ChartViewer;

View File

@@ -0,0 +1,27 @@
// RepositoriesList.stories.ts|tsx
import { ComponentStory, ComponentMeta } from "@storybook/react";
import RepositoriesList from "./RepositoriesList";
//👇 This default export determines where your story goes in the story list
export default {
/* 👇 The title prop is optional.
* See https://storybook.js.org/docs/react/configure/overview#configure-story-loading
* to learn how to generate automatic titles
*/
title: "RepositoriesList",
component: RepositoriesList,
} as ComponentMeta<typeof RepositoriesList>;
//👇 We create a “template” of how args map to rendering
const Template: ComponentStory<typeof RepositoriesList> = () => (
<RepositoriesList
selectedRepository={undefined}
// in this case we allow Unexpected empty method
//eslint-disable-next-line @typescript-eslint/no-empty-function
onRepositoryChanged={() => {}}
repositories={[]}
/>
);
export const Default = Template.bind({});

View File

@@ -0,0 +1,82 @@
import { useMemo } from "react";
import AddRepositoryModal from "../modal/AddRepositoryModal";
import { Repository } from "../../data/types";
import useCustomSearchParams from "../../hooks/useCustomSearchParams";
type RepositoriesListProps = {
selectedRepository: Repository | undefined;
onRepositoryChanged: (selectedRepository: Repository) => void;
repositories: Repository[];
};
function RepositoriesList({
onRepositoryChanged,
selectedRepository,
repositories,
}: RepositoriesListProps) {
const { searchParamsObject, upsertSearchParams, removeSearchParam } =
useCustomSearchParams();
const showAddRepositoryModal = useMemo(
() => searchParamsObject["add_repo"] === "true",
[searchParamsObject]
);
const setShowAddRepositoryModal = (value: boolean) => {
if (value) {
upsertSearchParams("add_repo", "true");
} else {
removeSearchParam("add_repo");
}
};
return (
<>
<div className="h-fit bg-white w-72 flex flex-col p-3 rounded custom-shadow text-dark gap-3">
<label className="font-bold">Repositories</label>
<div className="flex flex-col gap-1">
{repositories?.map((repository) => (
<span
className="flex items-center"
key={repository.url + repository.name}
title={repository.url}
>
<input
onChange={() => {
onRepositoryChanged(repository);
}}
className="cursor-pointer"
type="radio"
id={repository.name}
value={repository.name}
checked={repository.name === selectedRepository?.name}
name="clusters"
/>
<label htmlFor={repository.name} className="ml-1 text-sm">
{repository.name}
</label>
</span>
))}
</div>
<button
type="button"
style={{ marginTop: "10px" }}
className="h-8 w-fit flex items-center gap-2 border rounded text-muted border-gray-300 px-3 py-1 text-sm font-semibold"
onClick={() => setShowAddRepositoryModal(true)}
>
+ Add Repository
</button>
<p className="text-xs">
Charts developers: you can also add local directories as chart source.
Use{" "}
<span className="text-green-600 font-monospace">--local-chart</span>{" "}
CLI switch to specify it.
</p>
</div>
<AddRepositoryModal
isOpen={showAddRepositoryModal}
onClose={() => setShowAddRepositoryModal(false)}
/>
</>
);
}
export default RepositoriesList;

View File

@@ -0,0 +1,21 @@
// RepositoryViewer.stories.ts|tsx
import { ComponentStory, ComponentMeta } from "@storybook/react";
import RepositoryViewer from "./RepositoryViewer";
//👇 This default export determines where your story goes in the story list
export default {
/* 👇 The title prop is optional.
* See https://storybook.js.org/docs/react/configure/overview#configure-story-loading
* to learn how to generate automatic titles
*/
title: "RepositoryViewer",
component: RepositoryViewer,
} as ComponentMeta<typeof RepositoryViewer>;
//👇 We create a “template” of how args map to rendering
const Template: ComponentStory<typeof RepositoryViewer> = () => (
<RepositoryViewer repository={undefined} />
);
export const Default = Template.bind({});

View File

@@ -0,0 +1,155 @@
import { BsTrash3, BsArrowRepeat } from "react-icons/bs";
import { Chart, Repository } from "../../data/types";
import ChartViewer from "./ChartViewer";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import apiService from "../../API/apiService";
import Spinner from "../Spinner";
import { useUpdateRepo } from "../../API/repositories";
import { useEffect, useMemo, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { useAppContext } from "../../context/AppContext";
type RepositoryViewerProps = {
repository: Repository | undefined;
};
function RepositoryViewer({ repository }: RepositoryViewerProps) {
const [searchValue, setSearchValue] = useState("");
const [isRemoveLoading, setIsRemove] = useState(false);
const { context } = useParams();
const { setSelectedRepo, selectedRepo } = useAppContext();
const queryClient = useQueryClient();
const navigate = useNavigate();
const { data: charts, isLoading } = useQuery<Chart[]>({
//@ts-ignore
queryKey: ["charts", repository?.name || ""],
queryFn: apiService.getRepositoryCharts,
refetchOnWindowFocus: false,
enabled: !!repository?.name,
});
useEffect(() => {
setSearchValue("");
}, [repository, selectedRepo]);
const update = useUpdateRepo(repository?.name || "", {
retry: false,
onSuccess: () => {
window.location.reload();
},
});
const removeRepository = async () => {
//this is expected
//eslint-disable-next-line no-alert
if (confirm("Confirm removing repository?")) {
try {
setIsRemove(true);
const repo = repository?.name || "";
await apiService.fetchWithDefaults<void>(
`/api/helm/repositories/${repo}`,
{
method: "DELETE",
}
);
navigate(`/${context}/repository`, { replace: true });
setSelectedRepo("");
queryClient.invalidateQueries({
queryKey: ["helm", "repositories"],
});
} catch (error) {
console.error(error);
} finally {
setIsRemove(false);
}
}
};
const numOfCharts = (charts as Chart[])?.length;
const showNoChartsAlert = Boolean(!numOfCharts && numOfCharts === 0);
const filteredCharts = useMemo(() => {
return (charts as Chart[])?.filter((ch: Chart) =>
ch.name.toLowerCase().includes(searchValue.toLowerCase())
);
}, [charts, searchValue]);
if (repository === undefined) {
return (
<div className="bg-white rounded shadow display-none no-charts mt-3 text-sm p-4">
Looks like you don&apos;t have any repositories installed. You can add
one with the &quot;Add Repository&quot; button on the left side bar.
</div>
);
}
return (
<div className="flex flex-col p-6 gap-3 bg-white custom-shadow border rounded-md">
<span className="text-muted font-bold text-xs">REPOSITORY</span>
<div className="flex justify-between">
<span className="text-dark text-3xl font-semibold">
{repository?.name}
</span>
<div className="flex flex-col">
<div className="flex flex-row gap-2">
<button
onClick={() => {
update.mutate();
}}
>
<span className="h-8 flex items-center gap-2 bg-white border border-gray-300 px-5 py-1 text-sm font-semibold rounded">
{update.isLoading ? <Spinner size={4} /> : <BsArrowRepeat />}
Update
</span>
</button>
<button
onClick={() => {
removeRepository();
}}
>
<span className="h-8 flex items-center gap-2 bg-white border border-gray-300 px-5 py-1 text-sm font-semibold rounded">
{isRemoveLoading ? <Spinner size={4} /> : <BsTrash3 />}
Remove
</span>
</button>
</div>
<input
onChange={(e) => setSearchValue(e.target.value)}
value={searchValue}
type="text"
placeholder="Filter..."
className="mt-2 h-8 p-2 text-sm w-full border border-gray-300 focus:outline-none focus:border-sky-500 input-box-shadow rounded"
/>
</div>
</div>
<span className="text-dark text-sm bg-repository px-3 py-1 rounded-md self-start -mt-10">
URL: <span className="font-bold">{repository?.url}</span>
</span>
<div className="bg-secondary grid grid-cols-10 text-xs font-bold p-2 px-4 mt-4 rounded-md">
<span className="col-span-2">CHART NAME</span>
<span className="col-span-6">DESCRIPTION</span>
<span className="col-span-1 text-center">VERSION</span>
<span className="col-span-1 text-center"></span>
</div>
{isLoading ? (
<Spinner />
) : (
(filteredCharts || charts)?.map((chart: Chart) => (
<ChartViewer key={chart.name} chart={chart} />
))
)}
{showNoChartsAlert && (
<div className="bg-white rounded shadow display-none no-charts mt-3 text-sm p-4">
Looks like you don&apos;t have any repositories installed. You can add
one with the &quot;Add Repository&quot; button on the left side bar.
</div>
)}
</div>
);
}
export default RepositoryViewer;

View File

@@ -0,0 +1,533 @@
import { useEffect, useRef, useState } from "react";
import { Diff2HtmlUI } from "diff2html/lib/ui/js/diff2html-ui-slim.js";
import {
BsPencil,
BsTrash3,
BsHourglassSplit,
BsArrowRepeat,
BsArrowUp,
BsCheckCircle,
} from "react-icons/bs";
import { Release, ReleaseRevision } from "../../data/types";
import StatusLabel, { DeploymentStatus } from "../common/StatusLabel";
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
import {
useGetReleaseInfoByType,
useGetLatestVersion,
useGetResources,
useRollbackRelease,
useTestRelease,
} from "../../API/releases";
import RevisionDiff from "./RevisionDiff";
import RevisionResource from "./RevisionResource";
import Tabs from "../Tabs";
import { useMutation } from "@tanstack/react-query";
import Modal, { ModalButtonStyle } from "../modal/Modal";
import Spinner from "../Spinner";
import useAlertError from "../../hooks/useAlertError";
import Button from "../Button";
import { InstallReleaseChartModal } from "../modal/InstallChartModal/InstallReleaseChartModal";
import { diffConfiguration, isNewerVersion } from "../../utils";
import useNavigateWithSearchParams from "../../hooks/useNavigateWithSearchParams";
import apiService from "../../API/apiService";
type RevisionTagProps = {
caption: string;
text: string;
};
type RevisionDetailsProps = {
release: Release;
installedRevision: ReleaseRevision;
isLatest: boolean;
latestRevision: number;
};
export default function RevisionDetails({
release,
installedRevision,
isLatest,
latestRevision,
}: RevisionDetailsProps) {
const [searchParams] = useSearchParams();
const revisionTabs = [
{
value: "resources",
label: "Resources",
content: <RevisionResource isLatest={isLatest} />,
},
{
value: "manifests",
label: "Manifests",
content: <RevisionDiff latestRevision={latestRevision} />,
},
{
value: "values",
label: "Values",
content: (
<RevisionDiff
latestRevision={latestRevision}
includeUserDefineOnly={true}
/>
),
},
{
value: "notes",
label: "Notes",
content: <RevisionDiff latestRevision={latestRevision} />,
},
];
const { context, namespace, chart } = useParams();
const tab = searchParams.get("tab");
const selectedTab =
revisionTabs.find((t) => t.value === tab) || revisionTabs[0];
const [isReconfigureModalOpen, setIsReconfigureModalOpen] = useState(false);
const {
data: latestVerData,
refetch: refetchLatestVersion,
isLoading: isLoadingLatestVersion,
isRefetching: isRefetchingLatestVersion,
} = useGetLatestVersion(release.chart_name, { cacheTime: 0 });
const [showTestsResults, setShowTestResults] = useState(false);
const { setShowErrorModal } = useAlertError();
const {
mutate: runTests,
isLoading: isRunningTests,
data: testResults,
} = useTestRelease({
onError: (error) => {
setShowTestResults(false);
setShowErrorModal({
title: "Failed to run tests for chart " + chart,
msg: (error as Error).message,
});
console.error("Failed to execute test for chart", error);
},
});
const handleRunTests = () => {
if (!namespace || !chart) {
setShowErrorModal({
title: "Missing data to run test",
msg: "Missing chart and/or namespace",
});
return;
}
try {
runTests({
ns: namespace,
name: chart,
});
} catch (error: any) {
setShowErrorModal({
title: "Test failed to run",
msg: error.message,
});
}
setShowTestResults(true);
};
const displayTestResults = () => {
if (!testResults || (testResults as []).length === 0) {
return (
<div>
Tests executed successfully
<br />
<br />
<pre>Empty response from API</pre>
</div>
);
} else {
return (
<div>
{(testResults as string).split("\n").map((line, index) => (
<div key={index} className="mb-2">
{line}
<br />
</div>
))}
</div>
);
}
};
const Header = () => {
const navigate = useNavigate();
return (
<header className="flex flex-wrap justify-between">
<h1 className=" text-3xl font-semibold float-left mb-1 font-roboto-slab">
{chart}
</h1>
<div className="flex flex-row flex-wrap gap-3 float-right h-fit">
<div className="flex flex-col">
<Button
className="flex justify-center items-center gap-2 min-w-[150px] text-sm font-semibold disabled:bg-gray-200"
onClick={() => setIsReconfigureModalOpen(true)}
disabled={isLoadingLatestVersion || isRefetchingLatestVersion}
>
{isLoadingLatestVersion || isRefetchingLatestVersion ? (
<>
<BsHourglassSplit />
Checking...
</>
) : canUpgrade ? (
<>
<BsArrowUp />
Upgrade to {latestVerData?.[0]?.version}
</>
) : (
<>
<BsPencil />
Reconfigure
</>
)}
</Button>
{isReconfigureModalOpen && (
<InstallReleaseChartModal
isOpen={isReconfigureModalOpen}
chartName={release.chart_name}
currentlyInstalledChartVersion={installedRevision.chart_ver}
latestVersion={latestVerData?.[0]?.version}
isUpgrade={canUpgrade}
onClose={() => {
setIsReconfigureModalOpen(false);
}}
latestRevision={latestRevision}
/>
)}
{latestVerData?.[0]?.isSuggestedRepo ? (
<span
onClick={() => {
navigate(
`/${context}/repository?add_repo=true&repo_url=${latestVerData[0].urls[0]}&repo_name=${latestVerData[0].repository}`
);
}}
className="underline text-sm cursor-pointer text-blue-600"
>
Add repository for it: {latestVerData[0].repository}
</span>
) : (
<span
onClick={() => refetchLatestVersion()}
className="underline cursor-pointer text-xs"
>
Check for new version
</span>
)}
</div>
<Rollback release={release} installedRevision={installedRevision} />
{release.has_tests ? (
<>
{" "}
<Button
onClick={handleRunTests}
className="flex items-center gap-2 h-1/2 text-sm font-semibold"
>
<BsCheckCircle />
Run tests
</Button>
<Modal
containerClassNames="w-4/5"
title="Test results"
isOpen={showTestsResults}
onClose={() => setShowTestResults(false)}
>
{isRunningTests ? (
<div className="flex mr-2 items-center">
<Spinner /> Waiting for completion..
</div>
) : (
displayTestResults()
)}
</Modal>{" "}
</>
) : null}
<Uninstall />
</div>
</header>
);
};
const canUpgrade = !latestVerData?.[0]?.version
? false
: isNewerVersion(installedRevision.chart_ver, latestVerData?.[0]?.version);
return (
<div className="flex flex-col px-16 pt-5 gap-3">
<StatusLabel status={release.status} />
<Header />
<div className="flex flex-row gap-6 text-sm -mt-4">
<span>
Revision <span className="font-semibold">#{release.revision}</span>
</span>
<span className="text-sm">
{new Intl.DateTimeFormat("en-US", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "numeric",
minute: "numeric",
second: "numeric",
hour12: true,
}).format(new Date(release.updated))}
</span>
</div>
<div className="flex flex-wrap gap-4">
<RevisionTag caption="chart version" text={release.chart} />
<RevisionTag
caption="app version"
text={release.app_version || "N/A"}
/>
<RevisionTag caption="namespace" text={namespace ?? ""} />
<RevisionTag caption="cluster" text={context ?? ""} />
</div>
<span
className={`text-sm ${
release.status === DeploymentStatus.FAILED ? "text-red-600" : ""
}`}
>
{release.description}
</span>
<Tabs tabs={revisionTabs} selectedTab={selectedTab} />
</div>
);
}
function RevisionTag({ caption, text }: RevisionTagProps) {
return (
<span className="bg-revision p-1 rounded px-2 text-sm">
<span>{caption}:</span>
<span className="font-bold"> {text}</span>
</span>
);
}
const Rollback = ({
release,
installedRevision,
}: {
release: Release;
installedRevision: ReleaseRevision;
}) => {
const { chart, namespace, revision, context } = useParams();
const navigate = useNavigateWithSearchParams();
const [showRollbackDiff, setShowRollbackDiff] = useState(false);
const revisionInt = parseInt(revision || "", 10);
const { mutate: rollbackRelease, isLoading: isRollingBackRelease } =
useRollbackRelease({
onSuccess: () => {
navigate(
`/${context}/${namespace}/${chart}/installed/revision/${
revisionInt + 1
}`
);
window.location.reload();
},
});
const handleRollback = () => {
setShowRollbackDiff(true);
};
if (!chart || !namespace || !revision) {
return null;
}
if (release.revision <= 1) return null;
const rollbackRevision =
installedRevision.revision === release.revision
? installedRevision.revision - 1
: revisionInt;
const rollbackTitle = (
<div className="font-semibold text-lg">
Rollback <span className="text-red-500">{chart}</span> from revision{" "}
{installedRevision.revision} to {rollbackRevision}
</div>
);
const RollbackModal = () => {
const response = useGetReleaseInfoByType(
{
chart,
namespace,
revision: rollbackRevision.toString(),
tab: "manifests",
},
`&revisionDiff=${installedRevision.revision}`
);
return (
<Modal
title={rollbackTitle}
isOpen={showRollbackDiff}
onClose={() => setShowRollbackDiff(false)}
containerClassNames="w-4/5"
actions={[
{
id: "1",
callback: () => {
rollbackRelease({
ns: namespace as string,
name: String(chart),
revision: release.revision,
});
},
variant: ModalButtonStyle.info,
isLoading: isRollingBackRelease,
text: isRollingBackRelease ? "Rolling back" : "Confirm",
},
]}
>
<RollbackModalContent dataResponse={response} />
</Modal>
);
};
const RollbackModalContent = ({ dataResponse }: { dataResponse: any }) => {
const {
data,
isLoading,
isSuccess: fetchedDataSuccessfully,
} = dataResponse;
const diffElement = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (data && fetchedDataSuccessfully && diffElement?.current) {
const diff2htmlUi = new Diff2HtmlUI(
diffElement.current,
data,
diffConfiguration
);
diff2htmlUi.draw();
diff2htmlUi.highlightCode();
}
}, [data, isLoading, fetchedDataSuccessfully]);
return (
<div className="flex flex-col space-y-4">
{isLoading ? (
<div className="flex gap-2 text-sm">
<Spinner />
<p>Loading changes that will happen to cluster</p>
</div>
) : data ? (
<p className="text-sm">Following changes will happen to cluster:</p>
) : (
<p className="text-base">No changes will happen to cluster</p>
)}
<div className="relative leading-5" ref={diffElement} />
</div>
);
};
return (
<>
<Button
onClick={handleRollback}
className="flex items-center gap-2 h-1/2 text-sm font-semibold"
>
<BsArrowRepeat />
Rollback to #{rollbackRevision}
</Button>
{showRollbackDiff && <RollbackModal />}
</>
);
};
const Uninstall = () => {
const [isOpen, setIsOpen] = useState(false);
const { namespace = "", chart = "" } = useParams();
const { data: resources } = useGetResources(namespace, chart, {
enabled: isOpen,
});
const uninstallMutation = useMutation(
["uninstall", namespace, chart],
() =>
apiService.fetchWithDefaults(
"/api/helm/releases/" + namespace + "/" + chart,
{
method: "delete",
}
),
{
onSuccess: () => {
window.location.href = "/";
},
}
);
const uninstallTitle = (
<div className="font-semibold text-lg">
Uninstall <span className="text-red-500">{chart}</span> from namespace{" "}
<span className="text-red-500">{namespace}</span>
</div>
);
return (
<>
<Button
onClick={() => setIsOpen(true)}
className="flex items-center gap-2 hover:bg-red-200 h-1/2 text-sm font-semibold"
>
<BsTrash3 />
Uninstall
</Button>
{resources?.length ? (
<Modal
title={uninstallTitle}
isOpen={isOpen}
onClose={() => setIsOpen(false)}
actions={[
{
id: "1",
callback: uninstallMutation.mutate,
variant: ModalButtonStyle.info,
isLoading: uninstallMutation.isLoading,
},
]}
containerClassNames="w-[800px]"
>
<div>Following resources will be deleted from the cluster:</div>
<div>
{resources?.map((resource) => (
<div
key={
resource.apiVersion + resource.kind + resource.metadata.name
}
className="flex justify-start gap-1 w-full mb-3"
>
<span
style={{
textAlign: "end",
paddingRight: "30px",
}}
className=" w-3/5 italic"
>
{resource.kind}
</span>
<span className=" w-4/5 font-semibold">
{resource.metadata.name}
</span>
</div>
))}
</div>
</Modal>
) : null}
</>
);
};

View File

@@ -0,0 +1,232 @@
import { ChangeEvent, useMemo, useState, useRef, useEffect } from "react";
import { Diff2HtmlUI } from "diff2html/lib/ui/js/diff2html-ui-slim.js";
import { useGetReleaseInfoByType } from "../../API/releases";
import { useParams } from "react-router-dom";
import useCustomSearchParams from "../../hooks/useCustomSearchParams";
import parse from "html-react-parser";
import hljs from "highlight.js";
import Spinner from "../Spinner";
import { diffConfiguration } from "../../utils";
type RevisionDiffProps = {
includeUserDefineOnly?: boolean;
latestRevision: number;
};
const VIEW_MODE_VIEW_ONLY = "view";
const VIEW_MODE_DIFF_PREV = "diff-with-previous";
const VIEW_MODE_DIFF_SPECIFIC = "diff-with-specific-revision";
function RevisionDiff({
includeUserDefineOnly,
latestRevision,
}: RevisionDiffProps) {
const params = useParams();
const [specificVersion, setSpecificVersion] = useState(latestRevision);
const {
searchParamsObject: searchParams,
upsertSearchParams,
removeSearchParam,
} = useCustomSearchParams();
const {
tab,
mode: viewMode = VIEW_MODE_VIEW_ONLY,
"user-defined": userDefinedValue,
} = searchParams;
//@ts-ignore
const diffElement = useRef<HTMLElement>({});
const handleChanged = (e: ChangeEvent<HTMLInputElement>) => {
upsertSearchParams("mode", e.target.value);
};
const handleUserDefinedCheckbox = (e: ChangeEvent<HTMLInputElement>) => {
if (e.target.checked) {
upsertSearchParams("user-defined", `${e.target.checked}`);
} else {
removeSearchParam("user-defined");
}
};
const revisionInt = parseInt(params.revision || "", 10);
const hasMultipleRevisions = revisionInt > 1;
const additionalParams = useMemo(() => {
let additionalParamStr = "";
if (userDefinedValue) {
additionalParamStr += "&userDefined=true";
}
if (viewMode === VIEW_MODE_DIFF_PREV && hasMultipleRevisions) {
additionalParamStr += `&revisionDiff=${revisionInt - 1}`;
}
const specificRevisionInt = parseInt(specificVersion?.toString() || "", 10);
if (
viewMode === VIEW_MODE_DIFF_SPECIFIC &&
hasMultipleRevisions &&
!Number.isNaN(specificRevisionInt)
) {
additionalParamStr += `&revisionDiff=${specificVersion}`;
}
return additionalParamStr;
}, [
viewMode,
userDefinedValue,
specificVersion,
revisionInt,
hasMultipleRevisions,
]);
const hasRevisionToDiff = !!additionalParams;
const {
data,
isLoading,
isSuccess: fetchedDataSuccessfully,
} = useGetReleaseInfoByType({ ...params, tab }, additionalParams);
const content = useMemo(() => {
if (
data &&
!isLoading &&
(viewMode === VIEW_MODE_VIEW_ONLY || !hasRevisionToDiff)
) {
return hljs.highlight(data, { language: "yaml" }).value;
}
if (fetchedDataSuccessfully && !data && viewMode === VIEW_MODE_VIEW_ONLY) {
return "No value to display";
}
return "";
}, [data, isLoading, viewMode, hasRevisionToDiff, fetchedDataSuccessfully]);
useEffect(() => {
if (
viewMode !== VIEW_MODE_VIEW_ONLY &&
hasRevisionToDiff &&
data &&
!isLoading
) {
const diff2htmlUi = new Diff2HtmlUI(
diffElement!.current!,
data,
diffConfiguration
);
diff2htmlUi.draw();
diff2htmlUi.highlightCode();
} else if (viewMode === VIEW_MODE_VIEW_ONLY && diffElement.current) {
diffElement.current.innerHTML = "";
} else if (
fetchedDataSuccessfully &&
(!hasRevisionToDiff || !data) &&
diffElement.current
) {
diffElement.current.innerHTML = "No differences to display";
}
}, [
viewMode,
hasRevisionToDiff,
data,
isLoading,
fetchedDataSuccessfully,
diffElement,
]);
return (
<div>
<div className="flex mb-3 p-2 border border-revision flex-row items-center justify-between w-full bg-white rounded">
<div className="flex items-center">
<input
checked={viewMode === "view"}
onChange={handleChanged}
id="view"
type="radio"
value="view"
name="notes-view"
className="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300"
/>
<label
htmlFor="view"
className="ml-2 text-sm font-medium text-gray-900 dark:text-gray-300"
>
View
</label>
</div>
<div className="flex items-center">
<input
checked={viewMode === "diff-with-previous"}
onChange={handleChanged}
id="diff-with-previous"
type="radio"
value="diff-with-previous"
name="notes-view"
className="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300"
/>
<label
htmlFor="diff-with-previous"
className="ml-2 text-sm font-medium text-gray-900 dark:text-gray-300"
>
Diff with previous
</label>
</div>
<div className="flex items-center">
<input
checked={viewMode === "diff-with-specific-revision"}
onChange={handleChanged}
id="diff-with-specific-revision"
type="radio"
value="diff-with-specific-revision"
name="notes-view"
className="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300"
/>
<label
htmlFor="diff-with-specific-revision"
className="ml-2 text-sm font-medium text-gray-900 dark:text-gray-300"
>
<div>
Diff with specific revision:
<input
className="border ml-2 border-gray-500 w-10 p-1 rounded-sm"
type="text"
value={specificVersion}
onChange={(e) => setSpecificVersion(Number(e.target.value))}
></input>
</div>
</label>
</div>
{includeUserDefineOnly && (
<div className="flex items-center">
<input
id="user-define-only-checkbox"
type="checkbox"
onChange={handleUserDefinedCheckbox}
checked={!!userDefinedValue}
className="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600"
/>
<label
htmlFor="user-define-only-checkbox"
className="ml-2 text-sm font-medium text-gray-900 dark:text-gray-300"
>
User-defined only
</label>
</div>
)}
</div>
{isLoading ? <Spinner /> : ""}
{viewMode === VIEW_MODE_VIEW_ONLY && content ? (
<div className="bg-white overflow-x-auto w-full p-3 relative">
<pre className="bg-white rounded font-sf-mono">{parse(content)}</pre>
</div>
) : (
""
)}
<div
className="bg-white w-full relative leading-5 font-sf-mono"
//@ts-ignore
ref={diffElement}
></div>
</div>
);
}
export default RevisionDiff;

View File

@@ -0,0 +1,223 @@
import { useEffect, useState } from "react";
import { useParams } from "react-router-dom";
import hljs from "highlight.js";
import { RiExternalLinkLine } from "react-icons/ri";
import {
StructuredResources,
useGetResourceDescription,
useGetResources,
} from "../../API/releases";
import closeIcon from "../../assets/close.png";
import Drawer from "react-modern-drawer";
import "react-modern-drawer/dist/index.css";
import Button from "../Button";
import Badge, { getBadgeType } from "../Badge";
import Spinner from "../Spinner";
import { Troubleshoot } from "../Troubleshoot";
interface Props {
isLatest: boolean;
}
export default function RevisionResource({ isLatest }: Props) {
const { namespace = "", chart = "" } = useParams();
const { data: resources, isLoading } = useGetResources(namespace, chart);
const interestingResources = ["STATEFULSET", "DEAMONSET", "DEPLOYMENT"];
return (
<table
cellPadding={6}
className="border-spacing-y-2 font-semibold border-separate w-full text-xs "
>
<thead className="bg-zinc-200 font-bold h-8 rounded">
<tr>
<td className="pl-6 rounded">RESOURCE TYPE</td>
<td>NAME</td>
<td>STATUS</td>
<td>STATUS MESSAGE</td>
<td className="rounded"></td>
</tr>
</thead>
{isLoading ? (
<Spinner />
) : (
<tbody className="bg-white mt-4 h-8 rounded w-full">
{resources?.length ? (
resources
.sort(function (a, b) {
return (
interestingResources.indexOf(a.kind.toUpperCase()) -
interestingResources.indexOf(b.kind.toUpperCase())
);
})
.reverse()
.map((resource: StructuredResources) => (
<ResourceRow
key={
resource.apiVersion + resource.kind + resource.metadata.name
}
resource={resource}
isLatest={isLatest}
/>
))
) : (
<tr>
<div className="bg-white rounded shadow display-none no-charts mt-3 text-sm p-4">
Looks like you don&apos;t have any resources.{" "}
<RiExternalLinkLine className="ml-2 text-lg" />
</div>
</tr>
)}
</tbody>
)}
</table>
);
}
const ResourceRow = ({
resource,
isLatest,
}: {
resource: StructuredResources;
isLatest: boolean;
}) => {
const {
kind,
metadata: { name },
status: { conditions },
} = resource;
const [isOpen, setIsOpen] = useState(false);
const toggleDrawer = () => {
setIsOpen((prevState) => !prevState);
};
const { reason = "", status = "", message = "" } = conditions?.[0] || {};
const badgeType = getBadgeType(status);
return (
<>
<tr className="min-w-[100%] min-h[70px] text-sm py-2">
<td className="pl-6 rounded text-sm font-normal w-48">{kind}</td>
<td className="font-bold text-sm w-56">{name}</td>
<td>{reason ? <Badge type={badgeType}>{reason}</Badge> : null}</td>
<td className="rounded text-gray-100">
<div className="flex flex-col space-y-1 justify-start items-start ">
{message && (
<div className="text-gray-500 font-thin">{message}</div>
)}
{(badgeType === "error" || badgeType === "warning") && (
<Troubleshoot />
)}
</div>
</td>
<td className="rounded">
{isLatest && reason !== "NotFound" ? (
<div className="flex justify-end items-center mr-36">
<Button className="px-1 text-xs" onClick={toggleDrawer}>
Describe
</Button>
</div>
) : null}
</td>
</tr>
<Drawer
open={isOpen}
onClose={toggleDrawer}
direction="right"
className="min-w-[85%] "
>
{isOpen ? (
<DescribeResource
resource={resource}
closeDrawer={() => {
setIsOpen(false);
}}
/>
) : null}
</Drawer>
</>
);
};
const DescribeResource = ({
resource,
closeDrawer,
}: {
resource: StructuredResources;
closeDrawer: () => void;
}) => {
const {
kind,
metadata: { name },
status: { conditions },
} = resource;
const { status, reason = "" } = conditions?.[0] || {};
const { namespace = "", chart = "" } = useParams();
const { data, isLoading } = useGetResourceDescription(
resource.kind,
namespace,
chart
);
const [yamlFormattedData, setYamlFormattedData] = useState("");
useEffect(() => {
if (data) {
const val = hljs.highlight(data, { language: "yaml" }).value;
setYamlFormattedData(val);
}
}, [data]);
const badgeType = getBadgeType(status);
return (
<>
<div className="flex justify-between px-3 py-4 border-b ">
<div>
<div className="flex gap-3">
<h3 className="font-medium text-xl font-poppins">{name}</h3>
<Badge type={badgeType}>{reason}</Badge>
</div>
<p className="m-0 mt-4 font-inter text-sm font-normal">{kind}</p>
</div>
<div className="flex items-center gap-4 pr-4">
<a
href="https://www.komodor.com/helm-dash/?utm_campaign=Helm%20Dashboard%20%7C%20CTA&amp;utm_source=helm-dash&amp;utm_medium=cta&amp;utm_content=helm-dash"
className="bg-primary text-white p-1.5 text-sm flex items-center rounded"
target="_blank"
rel="noreferrer"
>
See more details in Komodor
<RiExternalLinkLine className="ml-2 text-lg" />
</a>
<button
type="button"
className="h-fit"
data-bs-dismiss="offcanvas"
aria-label="Close"
onClick={closeDrawer}
>
<img src={closeIcon} alt="close" className="w-[16px] h-[16px]" />
</button>
</div>
</div>
{isLoading ? (
<Spinner />
) : (
<div className="h-full overflow-y-auto ">
<pre
className="bg-white rounded p-4 font-medium text-base font-sf-mono"
style={{ overflow: "unset" }}
dangerouslySetInnerHTML={{
__html: yamlFormattedData,
}}
/>
</div>
)}
</>
);
};

View File

@@ -0,0 +1,97 @@
import { BsArrowDownRight, BsArrowUpRight } from "react-icons/bs";
import { useParams } from "react-router-dom";
import { compare } from "compare-versions";
import { ReleaseRevision } from "../../data/types";
import { getAge } from "../../timeUtils";
import StatusLabel from "../common/StatusLabel";
import useNavigateWithSearchParams from "../../hooks/useNavigateWithSearchParams";
import { DateTime } from "luxon";
type RevisionsListProps = {
releaseRevisions: ReleaseRevision[];
selectedRevision: number;
};
export default function RevisionsList({
releaseRevisions,
selectedRevision,
}: RevisionsListProps) {
const navigate = useNavigateWithSearchParams();
const { context, namespace, chart } = useParams();
const changeRelease = (newRevision: number) => {
navigate(
`/${context}/${namespace}/${chart}/installed/revision/${newRevision}`
);
};
return (
<>
{releaseRevisions?.map((release, idx) => {
const hasMultipleReleases =
releaseRevisions.length > 1 && idx < releaseRevisions.length - 1;
const prevRelease = hasMultipleReleases
? releaseRevisions[idx + 1]
: null;
const isRollback = release.description.startsWith("Rollback to ");
return (
<div
title={
isRollback ? `Rollback to ${Number(release.revision) - 1}` : ""
}
onClick={() => changeRelease(release.revision)}
key={release.revision}
className={`flex flex-col border rounded-md mx-5 p-2 gap-4 cursor-pointer ${
release.revision === selectedRevision
? "border-revision-dark bg-white"
: "border-revision-light bg-body-background"
}`}
>
<div className="flex row justify-between">
<StatusLabel status={release.status} isRollback={isRollback} />
<span className="font-bold">#{release.revision}</span>
</div>
<div
className="self-end text-muted text-xs flex flex-wrap gap-1"
style={{
width: "100%",
display: "flex",
justifyContent: "space-between",
}}
>
<div
style={{
display: "flex",
justifyContent: "space-between",
}}
>
{prevRelease
? compare(prevRelease.chart_ver, release.chart_ver, "!=") && (
<>
<span className="line-through">
{prevRelease.chart_ver}
</span>
{compare(
prevRelease.chart_ver,
release.chart_ver,
">"
) ? (
<BsArrowDownRight />
) : (
<BsArrowUpRight />
)}
<span>{release.chart_ver}</span>
</>
)
: ""}
</div>
<span title={DateTime.fromISO(release.updated).toString()}>
AGE:{getAge(release, releaseRevisions[idx - 1])}
</span>
</div>
</div>
);
})}
</>
);
}