1
0
Fork 0
mirror of https://github.com/portainer/portainer.git synced 2025-08-05 13:55:21 +02:00

feat(ingress): autodetect ingress controllers EE-673 (#7712)

This commit is contained in:
Dakota Walsh 2022-09-27 08:43:24 +13:00 committed by GitHub
parent c96551e410
commit 89eda13eb3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
48 changed files with 1252 additions and 1047 deletions

View file

@ -0,0 +1,29 @@
import { Meta } from '@storybook/react';
import { Badge, Props } from './Badge';
export default {
component: Badge,
title: 'Components/Badge',
argTypes: {
type: {
control: {
type: 'select',
options: ['success', 'danger', 'warn', 'info'],
},
},
},
} as Meta<Props>;
// : JSX.IntrinsicAttributes & PropsWithChildren<Props>
function Template({ type = 'success' }: Props) {
const message = {
success: 'success badge',
danger: 'danger badge',
warn: 'warn badge',
info: 'info badge',
};
return <Badge type={type}>{message[type]}</Badge>;
}
export const Example = Template.bind({});

View file

@ -0,0 +1,59 @@
import clsx from 'clsx';
import { PropsWithChildren } from 'react';
type BadgeType = 'success' | 'danger' | 'warn' | 'info';
export interface Props {
type?: BadgeType;
className?: string;
}
// this component is used in tables and lists in portainer. It looks like this:
// https://www.figma.com/file/g5TUMngrblkXM7NHSyQsD1/New-UI?node-id=76%3A2
export function Badge({ type, className, children }: PropsWithChildren<Props>) {
const baseClasses =
'flex w-fit items-center !text-xs font-medium rounded-full px-2 py-0.5';
const typeClasses = getClasses(type);
return (
<span className={clsx(baseClasses, typeClasses, className)}>
{children}
</span>
);
}
// classes in full to prevent a dev server bug, where tailwind doesn't render the interpolated classes
function getClasses(type: BadgeType | undefined) {
switch (type) {
case 'success':
return clsx(
`text-success-9 bg-success-2`,
`th-dark:text-success-3 th-dark:bg-success-10`,
`th-highcontrast:text-success-3 th-highcontrast:bg-success-10`
);
case 'warn':
return clsx(
`text-warning-9 bg-warning-2`,
`th-dark:text-warning-3 th-dark:bg-warning-10`,
`th-highcontrast:text-warning-3 th-highcontrast:bg-warning-10`
);
case 'danger':
return clsx(
`text-error-9 bg-error-2`,
`th-dark:text-error-3 th-dark:bg-error-10`,
`th-highcontrast:text-error-3 th-highcontrast:bg-error-10`
);
case 'info':
return clsx(
`text-blue-9 bg-blue-2`,
`th-dark:text-blue-3 th-dark:bg-blue-10`,
`th-highcontrast:text-blue-3 th-highcontrast:bg-blue-10`
);
default:
return clsx(
`text-blue-9 bg-blue-2`,
`th-dark:text-blue-3 th-dark:bg-blue-10`,
`th-highcontrast:text-blue-3 th-highcontrast:bg-blue-10`
);
}
}

View file

@ -0,0 +1 @@
export { Badge } from './Badge';

View file

@ -54,6 +54,7 @@ interface Props<
initialTableState?: Partial<TableState<D>>;
isLoading?: boolean;
totalCount?: number;
description?: JSX.Element;
}
export function Datatable<
@ -74,6 +75,7 @@ export function Datatable<
initialTableState = {},
isLoading,
totalCount = dataset.length,
description,
}: Props<D, TSettings>) {
const [searchBarValue, setSearchBarValue] = useSearchBarState(storageKey);
@ -145,6 +147,7 @@ export function Datatable<
label={titleOptions.title}
icon={titleOptions.icon}
featherIcon={titleOptions.featherIcon}
description={description}
>
<SearchBar value={searchBarValue} onChange={setGlobalFilter} />
{renderTableActions && (

View file

@ -20,8 +20,8 @@ export function SearchBar({
const [searchValue, setSearchValue] = useDebounce(value, onChange);
return (
<div className="searchBar items-center flex">
<Search className="searchIcon feather" />
<div className="searchBar items-center flex min-w-[90px]">
<Search className="searchIcon feather shrink-0" />
<input
type="text"
className="searchInput"

View file

@ -22,7 +22,7 @@ export function TableHeaderCell({
render,
onSortClick,
isSorted,
isSortedDesc,
isSortedDesc = true,
canFilter,
renderFilter,
}: Props) {
@ -55,7 +55,7 @@ function SortWrapper({
children,
onClick = () => {},
isSorted,
isSortedDesc,
isSortedDesc = true,
}: PropsWithChildren<SortWrapperProps>) {
if (!canSort) {
return <>{children}</>;
@ -93,7 +93,7 @@ export function TableColumnHeaderAngular({
canSort,
isSorted,
colTitle,
isSortedDesc,
isSortedDesc = true,
}: TableColumnHeaderAngularProps) {
return (
<div className="flex flex-row flex-nowrap h-full">

View file

@ -6,6 +6,7 @@ interface Props {
icon?: ReactNode | ComponentType<unknown>;
featherIcon?: boolean;
label: string;
description?: JSX.Element;
}
export function TableTitle({
@ -13,23 +14,27 @@ export function TableTitle({
featherIcon,
label,
children,
description,
}: PropsWithChildren<Props>) {
return (
<div className="toolBar">
<div className="toolBarTitle">
{icon && (
<div className="widget-icon">
<Icon
icon={icon}
feather={featherIcon}
className="space-right feather"
/>
</div>
)}
<div className="toolBar flex-col">
<div className="flex gap-1 p-0 w-full items-center">
<div className="toolBarTitle">
{icon && (
<div className="widget-icon">
<Icon
icon={icon}
feather={featherIcon}
className="space-right feather"
/>
</div>
)}
{label}
{label}
</div>
{children}
</div>
{children}
{description && description}
</div>
);
}

View file

@ -0,0 +1,221 @@
import { useState } from 'react';
import { confirmWarn } from '@/portainer/services/modal.service/confirm';
import { Datatable } from '@@/datatables';
import { Button, ButtonGroup } from '@@/buttons';
import { Icon } from '@@/Icon';
import { IngressControllerClassMap } from '../types';
import { useColumns } from './columns';
import { createStore } from './datatable-store';
const useStore = createStore('ingressClasses');
interface Props {
onChangeAvailability: (
controllerClassMap: IngressControllerClassMap[]
) => void; // angular function to save the ingress class list
description: string;
ingressControllers: IngressControllerClassMap[] | undefined;
noIngressControllerLabel: string;
view: string;
}
export function IngressClassDatatable({
onChangeAvailability,
description,
ingressControllers,
noIngressControllerLabel,
view,
}: Props) {
const [ingControllerFormValues, setIngControllerFormValues] =
useState(ingressControllers);
const settings = useStore();
const columns = useColumns();
return (
<div className="-mx-[15px]">
<Datatable
dataset={ingControllerFormValues || []}
storageKey="ingressClasses"
columns={columns}
settingsStore={settings}
isLoading={!ingControllerFormValues}
emptyContentLabel={noIngressControllerLabel}
titleOptions={{
icon: 'database',
title: 'Ingress controllers',
featherIcon: true,
}}
getRowId={(row) => `${row.Name}-${row.ClassName}-${row.Type}`}
renderTableActions={(selectedRows) => renderTableActions(selectedRows)}
description={renderIngressClassDescription()}
/>
</div>
);
function renderTableActions(selectedRows: IngressControllerClassMap[]) {
return (
<div className="flex items-start">
<ButtonGroup>
<Button
disabled={
selectedRows.filter((row) => row.Availability === true).length ===
0
}
color="dangerlight"
size="small"
onClick={() =>
updateIngressControllers(
selectedRows,
ingControllerFormValues || [],
false
)
}
>
Disallow selected
</Button>
<Button
disabled={
selectedRows.filter((row) => row.Availability === false)
.length === 0
}
color="default"
size="small"
onClick={() =>
updateIngressControllers(
selectedRows,
ingControllerFormValues || [],
true
)
}
>
Allow selected
</Button>
</ButtonGroup>
</div>
);
}
function renderIngressClassDescription() {
return (
<div className="flex flex-col !text-xs text-muted w-full">
<div className="mt-1">{description}</div>
{ingressControllers &&
ingControllerFormValues &&
isUnsavedChanges(ingressControllers, ingControllerFormValues) && (
<span className="flex items-center text-warning mt-1">
<Icon icon="alert-triangle" feather className="!mr-1" />
<span className="text-warning">Unsaved changes.</span>
</span>
)}
</div>
);
}
function updateIngressControllers(
selectedRows: IngressControllerClassMap[],
ingControllerFormValues: IngressControllerClassMap[],
availability: boolean
) {
const updatedIngressControllers = getUpdatedIngressControllers(
selectedRows,
ingControllerFormValues || [],
availability
);
if (ingressControllers && ingressControllers.length) {
const newAllowed = updatedIngressControllers.map(
(ingController) => ingController.Availability
);
if (view === 'namespace') {
setIngControllerFormValues(updatedIngressControllers);
onChangeAvailability(updatedIngressControllers);
return;
}
const usedControllersToDisallow = ingressControllers.filter(
(ingController, index) => {
// if any of the current controllers are allowed, and are used, then become disallowed, then add the controller to a new list
if (
ingController.Availability &&
ingController.Used &&
!newAllowed[index]
) {
return true;
}
return false;
}
);
if (usedControllersToDisallow.length > 0) {
const usedControllerHtmlListItems = usedControllersToDisallow.map(
(controller) => `<li>${controller.ClassName}</li>`
);
const usedControllerHtmlList = `<ul class="ml-6">${usedControllerHtmlListItems.join(
''
)}</ul>`;
confirmWarn({
title: 'Disallow in-use ingress controllers?',
message: `
<div>
<p>There are ingress controllers you want to disallow that are in use:</p>
${usedControllerHtmlList}
<p>No new ingress rules can be created for the disallowed controllers.</p>
</div>`,
buttons: {
cancel: {
label: 'Cancel',
className: 'btn-default',
},
confirm: {
label: 'Disallow',
className: 'btn-warning',
},
},
callback: (confirmed) => {
if (confirmed) {
setIngControllerFormValues(updatedIngressControllers);
onChangeAvailability(updatedIngressControllers);
}
},
});
return;
}
setIngControllerFormValues(updatedIngressControllers);
onChangeAvailability(updatedIngressControllers);
}
}
}
function isUnsavedChanges(
oldIngressControllers: IngressControllerClassMap[],
newIngressControllers: IngressControllerClassMap[]
) {
for (let i = 0; i < oldIngressControllers.length; i += 1) {
if (
oldIngressControllers[i].Availability !==
newIngressControllers[i].Availability
) {
return true;
}
}
return false;
}
function getUpdatedIngressControllers(
selectedRows: IngressControllerClassMap[],
allRows: IngressControllerClassMap[],
allow: boolean
) {
const selectedRowClassNames = selectedRows.map((row) => row.ClassName);
const updatedIngressControllers = allRows?.map((row) => {
if (selectedRowClassNames.includes(row.ClassName)) {
return { ...row, Availability: allow };
}
return row;
});
return updatedIngressControllers;
}

View file

@ -0,0 +1,11 @@
import { Environment } from '@/portainer/environments/types';
import { createRowContext } from '@@/datatables/RowContext';
interface RowContextState {
environment: Environment;
}
const { RowProvider, useRowContext } = createRowContext<RowContextState>();
export { RowProvider, useRowContext };

View file

@ -0,0 +1,27 @@
import { CellProps, Column } from 'react-table';
import { Badge } from '@@/Badge';
import { Icon } from '@@/Icon';
import type { IngressControllerClassMap } from '../../types';
export const availability: Column<IngressControllerClassMap> = {
Header: 'Availability',
accessor: 'Availability',
Cell: AvailailityCell,
id: 'availability',
disableFilters: true,
canHide: true,
sortInverted: true,
sortType: 'basic',
Filter: () => null,
};
function AvailailityCell({ value }: CellProps<IngressControllerClassMap>) {
return (
<Badge type={value ? 'success' : 'danger'}>
<Icon icon={value ? 'check' : 'x'} feather className="!mr-1" />
{value ? 'Allowed' : 'Disallowed'}
</Badge>
);
}

View file

@ -0,0 +1,9 @@
import { useMemo } from 'react';
import { availability } from './availability';
import { type } from './type';
import { name } from './name';
export function useColumns() {
return useMemo(() => [name, type, availability], []);
}

View file

@ -0,0 +1,25 @@
import { CellProps, Column } from 'react-table';
import { Badge } from '@@/Badge';
import type { IngressControllerClassMap } from '../../types';
export const name: Column<IngressControllerClassMap> = {
Header: 'Ingress class',
accessor: 'ClassName',
Cell: NameCell,
id: 'name',
disableFilters: true,
canHide: true,
Filter: () => null,
sortType: 'string',
};
function NameCell({ row }: CellProps<IngressControllerClassMap>) {
return (
<span className="flex flex-nowrap">
{row.original.ClassName}
{row.original.New && <Badge className="ml-1">Newly detected</Badge>}
</span>
);
}

View file

@ -0,0 +1,14 @@
import { CellProps, Column } from 'react-table';
import type { IngressControllerClassMap } from '../../types';
export const type: Column<IngressControllerClassMap> = {
Header: 'Ingress controller type',
accessor: 'Type',
Cell: ({ row }: CellProps<IngressControllerClassMap>) =>
row.original.Type || '-',
id: 'type',
disableFilters: true,
canHide: true,
Filter: () => null,
};

View file

@ -0,0 +1,26 @@
import create from 'zustand';
import { persist } from 'zustand/middleware';
import { keyBuilder } from '@/portainer/hooks/useLocalStorage';
import {
paginationSettings,
sortableSettings,
} from '@/react/components/datatables/types';
import { TableSettings } from './types';
export const TRUNCATE_LENGTH = 32;
export function createStore(storageKey: string) {
return create<TableSettings>()(
persist(
(set) => ({
...sortableSettings(set),
...paginationSettings(set),
}),
{
name: keyBuilder(storageKey),
}
)
);
}

View file

@ -0,0 +1 @@
export * from './IngressClassDatatable';

View file

@ -0,0 +1,8 @@
import {
PaginationTableSettings,
SortableTableSettings,
} from '@/react/components/datatables/types';
export interface TableSettings
extends SortableTableSettings,
PaginationTableSettings {}

View file

@ -0,0 +1,23 @@
import axios, { parseAxiosError } from '@/portainer/services/axios';
import { EnvironmentId } from '@/portainer/environments/types';
import {
KubernetesApiListResponse,
V1IngressClass,
} from '@/react/kubernetes/services/kubernetes/types';
export async function getAllIngressClasses(environmentId: EnvironmentId) {
try {
const {
data: { items },
} = await axios.get<KubernetesApiListResponse<V1IngressClass[]>>(
urlBuilder(environmentId)
);
return items;
} catch (error) {
throw parseAxiosError(error as Error);
}
}
function urlBuilder(environmentId: EnvironmentId) {
return `endpoints/${environmentId}/kubernetes/apis/networking.k8s.io/v1/ingressclasses`;
}

View file

@ -0,0 +1,10 @@
type SupportedIngControllerNames = 'nginx' | 'traefik' | 'unknown';
export interface IngressControllerClassMap extends Record<string, unknown> {
Name: string;
ClassName: string;
Type: SupportedIngControllerNames;
Availability: boolean;
New: boolean;
Used: boolean; // if the controller is used by any ingress in the cluster
}

View file

@ -0,0 +1,57 @@
import { EnvironmentId } from '@/portainer/environments/types';
import PortainerError from '@/portainer/error';
import axios from '@/portainer/services/axios';
import { IngressControllerClassMap } from '../types';
// get all supported ingress classes and controllers for the cluster
// allowedOnly set to true will hide globally disallowed ingresscontrollers
export async function getIngressControllerClassMap({
environmentId,
namespace,
allowedOnly,
}: {
environmentId: EnvironmentId;
namespace?: string;
allowedOnly?: boolean;
}) {
try {
const { data: controllerMaps } = await axios.get<
IngressControllerClassMap[]
>(
buildUrl(environmentId, namespace),
allowedOnly ? { params: { allowedOnly: true } } : undefined
);
return controllerMaps;
} catch (e) {
throw new PortainerError('Unable to get ingress controllers.', e as Error);
}
}
// get all supported ingress classes and controllers for the cluster
export async function updateIngressControllerClassMap(
environmentId: EnvironmentId,
ingressControllerClassMap: IngressControllerClassMap[],
namespace?: string
) {
try {
const { data: controllerMaps } = await axios.put<
IngressControllerClassMap[]
>(buildUrl(environmentId, namespace), ingressControllerClassMap);
return controllerMaps;
} catch (e) {
throw new PortainerError(
'Unable to update ingress controllers.',
e as Error
);
}
}
function buildUrl(environmentId: EnvironmentId, namespace?: string) {
let url = `kubernetes/${environmentId}/`;
if (namespace) {
url += `namespaces/${namespace}/`;
}
url += 'ingresscontrollers';
return url;
}

View file

@ -0,0 +1,11 @@
export * from './v1IngressClass';
export * from './v1ObjectMeta';
export type KubernetesApiListResponse<T> = {
apiVersion: string;
kind: string;
items: T;
metadata: {
resourceVersion?: string;
};
};

View file

@ -0,0 +1,48 @@
import { V1ObjectMeta } from './v1ObjectMeta';
/**
* IngressClassParametersReference identifies an API object. This can be used to specify a cluster or namespace-scoped resource.
*/
type V1IngressClassParametersReference = {
/**
* APIGroup is the group for the resource being referenced. If APIGroup is not specified, the specified Kind must be in the core API group. For any other third-party types, APIGroup is required.
*/
apiGroup?: string;
/**
* Kind is the type of resource being referenced.
*/
kind: string;
/**
* Name is the name of resource being referenced.
*/
name: string;
/**
* Namespace is the namespace of the resource being referenced. This field is required when scope is set to \"Namespace\" and must be unset when scope is set to \"Cluster\".
*/
namespace?: string;
/**
* Scope represents if this refers to a cluster or namespace scoped resource. This may be set to \"Cluster\" (default) or \"Namespace\".
*/
scope?: string;
};
type V1IngressClassSpec = {
controller?: string;
parameters?: V1IngressClassParametersReference;
};
/**
* IngressClass represents the class of the Ingress, referenced by the Ingress Spec. The `ingressclass.kubernetes.io/is-default-class` annotation can be used to indicate that an IngressClass should be considered default. When a single IngressClass resource has this annotation set to true, new Ingress resources without a class specified will be assigned this default class.
*/
export type V1IngressClass = {
/**
* APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources
*/
apiVersion?: string;
/**
* Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds
*/
kind?: string;
metadata?: V1ObjectMeta;
spec?: V1IngressClassSpec;
};

View file

@ -0,0 +1,33 @@
// type definitions taken from https://github.com/kubernetes-client/javascript/blob/master/src/gen/model/v1ObjectMeta.ts
// and simplified to only include the types we need
export type V1ObjectMeta = {
/**
* Annotations is an unstructured key value map stored with a resource that may be set by external tools to store and retrieve arbitrary metadata. They are not queryable and should be preserved when modifying objects. More info: http://kubernetes.io/docs/user-guide/annotations
*/
annotations?: { [key: string]: string };
/**
* Map of string keys and values that can be used to organize and categorize (scope and select) objects. May match selectors of replication controllers and services. More info: http://kubernetes.io/docs/user-guide/labels
*/
labels?: { [key: string]: string };
/**
* Deprecated: ClusterName is a legacy field that was always cleared by the system and never used; it will be removed completely in 1.25. The name in the go struct is changed to help clients detect accidental use.
*/
clusterName?: string;
/**
* Name must be unique within a namespace. Is required when creating resources, although some resources may allow a client to request the generation of an appropriate name automatically. Name is primarily intended for creation idempotence and configuration definition. Cannot be updated. More info: http://kubernetes.io/docs/user-guide/identifiers#names
*/
name?: string;
/**
* Namespace defines the space within which each name must be unique. An empty namespace is equivalent to the \"default\" namespace, but \"default\" is the canonical representation. Not all objects are required to be scoped to a namespace - the value of this field for those objects will be empty. Must be a DNS_LABEL. Cannot be updated. More info: http://kubernetes.io/docs/user-guide/namespaces
*/
namespace?: string;
/**
* An opaque value that represents the internal version of this object that can be used by clients to determine when objects have changed. May be used for optimistic concurrency, change detection, and the watch operation on a resource or set of resources. Clients must treat these values as opaque and passed unmodified back to the server. They may only be valid for a particular resource or set of resources. Populated by the system. Read-only. Value must be treated as opaque by clients and . More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#concurrency-control-and-consistency
*/
resourceVersion?: string;
/**
* UID is the unique in time and space value for this object. It is typically generated by the server on successful creation of a resource and is not allowed to change on PUT operations. Populated by the system. Read-only. More info: http://kubernetes.io/docs/user-guide/identifiers#uids
*/
uid?: string;
};