mirror of
https://github.com/komodorio/helm-dashboard.git
synced 2026-03-26 06:18:04 +00:00
Rename frontend directory (#472)
* Rename directory * Cleanup * Recover lost images * remove lint
This commit is contained in:
44
frontend/src/components/Badge.stories.tsx
Normal file
44
frontend/src/components/Badge.stories.tsx
Normal 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",
|
||||
},
|
||||
};
|
||||
72
frontend/src/components/Badge.tsx
Normal file
72
frontend/src/components/Badge.tsx
Normal 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;
|
||||
}
|
||||
};
|
||||
35
frontend/src/components/Button.stories.tsx
Normal file
35
frontend/src/components/Button.stories.tsx
Normal 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>↑</span>
|
||||
<span>Update</span>
|
||||
</>
|
||||
),
|
||||
onClick: () => {
|
||||
console.log("click");
|
||||
},
|
||||
};
|
||||
36
frontend/src/components/Button.tsx
Normal file
36
frontend/src/components/Button.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
29
frontend/src/components/ClustersList.stories.tsx
Normal file
29
frontend/src/components/ClustersList.stories.tsx
Normal 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({});
|
||||
165
frontend/src/components/ClustersList.tsx
Normal file
165
frontend/src/components/ClustersList.tsx
Normal 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;
|
||||
41
frontend/src/components/InstalledPackages/HealthStatus.tsx
Normal file
41
frontend/src/components/InstalledPackages/HealthStatus.tsx
Normal 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;
|
||||
@@ -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
|
||||
},
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -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't have any charts installed.
|
||||
"Repository" section may be a good place to start.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
24
frontend/src/components/LinkWithSearchParams.tsx
Normal file
24
frontend/src/components/LinkWithSearchParams.tsx
Normal 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;
|
||||
38
frontend/src/components/SelectMenu.stories.tsx
Normal file
38
frontend/src/components/SelectMenu.stories.tsx
Normal 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),
|
||||
};
|
||||
82
frontend/src/components/SelectMenu.tsx
Normal file
82
frontend/src/components/SelectMenu.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
21
frontend/src/components/ShutDownButton.stories.tsx
Normal file
21
frontend/src/components/ShutDownButton.stories.tsx
Normal 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({});
|
||||
33
frontend/src/components/ShutDownButton.tsx
Normal file
33
frontend/src/components/ShutDownButton.tsx
Normal 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;
|
||||
22
frontend/src/components/Spinner/index.tsx
Normal file
22
frontend/src/components/Spinner/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
39
frontend/src/components/Tabs.stories.tsx
Normal file
39
frontend/src/components/Tabs.stories.tsx
Normal 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;
|
||||
43
frontend/src/components/Tabs.tsx
Normal file
43
frontend/src/components/Tabs.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
39
frontend/src/components/TabsBar.stories.tsx
Normal file
39
frontend/src/components/TabsBar.stories.tsx
Normal 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",
|
||||
};
|
||||
47
frontend/src/components/TabsBar.tsx
Normal file
47
frontend/src/components/TabsBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
30
frontend/src/components/TextInput.stories.tsx
Normal file
30
frontend/src/components/TextInput.stories.tsx
Normal 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,
|
||||
};
|
||||
38
frontend/src/components/TextInput.tsx
Normal file
38
frontend/src/components/TextInput.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
41
frontend/src/components/Tooltip.tsx
Normal file
41
frontend/src/components/Tooltip.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
10
frontend/src/components/Troubleshoot.stories.tsx
Normal file
10
frontend/src/components/Troubleshoot.stories.tsx
Normal 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({});
|
||||
18
frontend/src/components/Troubleshoot.tsx
Normal file
18
frontend/src/components/Troubleshoot.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
40
frontend/src/components/common/Button/Button.stories.tsx
Normal file
40
frontend/src/components/common/Button/Button.stories.tsx
Normal 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",
|
||||
};
|
||||
51
frontend/src/components/common/Button/Button.tsx
Normal file
51
frontend/src/components/common/Button/Button.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
30
frontend/src/components/common/Button/button.css
Normal file
30
frontend/src/components/common/Button/button.css
Normal 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;
|
||||
}
|
||||
35
frontend/src/components/common/DropDown.stories.tsx
Normal file
35
frontend/src/components/common/DropDown.stories.tsx
Normal 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 /> },
|
||||
],
|
||||
};
|
||||
107
frontend/src/components/common/DropDown.tsx
Normal file
107
frontend/src/components/common/DropDown.tsx
Normal 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;
|
||||
24
frontend/src/components/common/Header/Header.stories.tsx
Normal file
24
frontend/src/components/common/Header/Header.stories.tsx
Normal 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 = {};
|
||||
69
frontend/src/components/common/Header/Header.tsx
Normal file
69
frontend/src/components/common/Header/Header.tsx
Normal 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>
|
||||
);
|
||||
32
frontend/src/components/common/Header/header.css
Normal file
32
frontend/src/components/common/Header/header.css
Normal 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;
|
||||
}
|
||||
25
frontend/src/components/common/Page/Page.stories.tsx
Normal file
25
frontend/src/components/common/Page/Page.stories.tsx
Normal 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);
|
||||
};
|
||||
92
frontend/src/components/common/Page/Page.tsx
Normal file
92
frontend/src/components/common/Page/Page.tsx
Normal 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 "args" 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>
|
||||
);
|
||||
};
|
||||
69
frontend/src/components/common/Page/page.css
Normal file
69
frontend/src/components/common/Page/page.css
Normal 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;
|
||||
}
|
||||
39
frontend/src/components/common/StatusLabel.stories.tsx
Normal file
39
frontend/src/components/common/StatusLabel.stories.tsx
Normal 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,
|
||||
};
|
||||
42
frontend/src/components/common/StatusLabel.tsx
Normal file
42
frontend/src/components/common/StatusLabel.tsx
Normal 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;
|
||||
25
frontend/src/components/modal/AddRepositoryModal.stories.tsx
Normal file
25
frontend/src/components/modal/AddRepositoryModal.stories.tsx
Normal 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,
|
||||
};
|
||||
166
frontend/src/components/modal/AddRepositoryModal.tsx
Normal file
166
frontend/src/components/modal/AddRepositoryModal.tsx
Normal 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;
|
||||
31
frontend/src/components/modal/ErrorModal.stories.tsx
Normal file
31
frontend/src/components/modal/ErrorModal.stories.tsx
Normal 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 "https://kubernetes.docker.internal:6443/version": dial tcp 127.0.0.1:6443: connectex: No connection could be made because the target machine actively refused it.",
|
||||
};
|
||||
63
frontend/src/components/modal/ErrorModal.tsx
Normal file
63
frontend/src/components/modal/ErrorModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
66
frontend/src/components/modal/GlobalErrorModal.tsx
Normal file
66
frontend/src/components/modal/GlobalErrorModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
116
frontend/src/components/modal/Modal.stories.tsx
Normal file
116
frontend/src/components/modal/Modal.stories.tsx
Normal 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,
|
||||
};
|
||||
158
frontend/src/components/modal/Modal.tsx
Normal file
158
frontend/src/components/modal/Modal.tsx
Normal 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;
|
||||
32
frontend/src/components/repository/ChartViewer.stories.tsx
Normal file
32
frontend/src/components/repository/ChartViewer.stories.tsx
Normal 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;
|
||||
56
frontend/src/components/repository/ChartViewer.tsx
Normal file
56
frontend/src/components/repository/ChartViewer.tsx
Normal 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;
|
||||
@@ -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({});
|
||||
82
frontend/src/components/repository/RepositoriesList.tsx
Normal file
82
frontend/src/components/repository/RepositoriesList.tsx
Normal 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;
|
||||
@@ -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({});
|
||||
155
frontend/src/components/repository/RepositoryViewer.tsx
Normal file
155
frontend/src/components/repository/RepositoryViewer.tsx
Normal 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't have any repositories installed. You can add
|
||||
one with the "Add Repository" 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't have any repositories installed. You can add
|
||||
one with the "Add Repository" button on the left side bar.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default RepositoryViewer;
|
||||
533
frontend/src/components/revision/RevisionDetails.tsx
Normal file
533
frontend/src/components/revision/RevisionDetails.tsx
Normal 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}
|
||||
</>
|
||||
);
|
||||
};
|
||||
232
frontend/src/components/revision/RevisionDiff.tsx
Normal file
232
frontend/src/components/revision/RevisionDiff.tsx
Normal 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;
|
||||
223
frontend/src/components/revision/RevisionResource.tsx
Normal file
223
frontend/src/components/revision/RevisionResource.tsx
Normal 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'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&utm_source=helm-dash&utm_medium=cta&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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
97
frontend/src/components/revision/RevisionsList.tsx
Normal file
97
frontend/src/components/revision/RevisionsList.tsx
Normal 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>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user