1
0
Fork 0
mirror of https://github.com/portainer/portainer.git synced 2025-07-22 23:09:41 +02:00

refactor(kube/apps): migrate table to react [EE-4685] (#11028)
Some checks are pending
ci / build_images (map[arch:amd64 platform:linux version:]) (push) Waiting to run
ci / build_images (map[arch:amd64 platform:windows version:1809]) (push) Waiting to run
ci / build_images (map[arch:amd64 platform:windows version:ltsc2022]) (push) Waiting to run
ci / build_images (map[arch:arm platform:linux version:]) (push) Waiting to run
ci / build_images (map[arch:arm64 platform:linux version:]) (push) Waiting to run
ci / build_images (map[arch:ppc64le platform:linux version:]) (push) Waiting to run
ci / build_images (map[arch:s390x platform:linux version:]) (push) Waiting to run
ci / build_manifests (push) Blocked by required conditions
/ triage (push) Waiting to run
Lint / Run linters (push) Waiting to run
Test / test-client (push) Waiting to run
Test / test-server (map[arch:amd64 platform:linux]) (push) Waiting to run
Test / test-server (map[arch:amd64 platform:windows version:1809]) (push) Waiting to run
Test / test-server (map[arch:amd64 platform:windows version:ltsc2022]) (push) Waiting to run
Test / test-server (map[arch:arm64 platform:linux]) (push) Waiting to run

This commit is contained in:
Chaim Lev-Ari 2024-04-11 10:11:17 +03:00 committed by GitHub
parent e9ebef15a0
commit 76e49ed9a8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
41 changed files with 756 additions and 903 deletions

View file

@ -0,0 +1,166 @@
import { useEffect } from 'react';
import { BoxIcon } from 'lucide-react';
import { useKubeStore } from '@/react/kubernetes/datatables/default-kube-datatable-store';
import { DefaultDatatableSettings } from '@/react/kubernetes/datatables/DefaultDatatableSettings';
import { SystemResourceDescription } from '@/react/kubernetes/datatables/SystemResourceDescription';
import { CreateFromManifestButton } from '@/react/kubernetes/components/CreateFromManifestButton';
import { useNamespacesQuery } from '@/react/kubernetes/namespaces/queries/useNamespacesQuery';
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
import { useCurrentEnvironment } from '@/react/hooks/useCurrentEnvironment';
import { useAuthorizations } from '@/react/hooks/useUser';
import { TableSettingsMenu } from '@@/datatables';
import { useRepeater } from '@@/datatables/useRepeater';
import { DeleteButton } from '@@/buttons/DeleteButton';
import { AddButton } from '@@/buttons';
import { ExpandableDatatable } from '@@/datatables/ExpandableDatatable';
import { NamespaceFilter } from '../ApplicationsStacksDatatable/NamespaceFilter';
import { Namespace } from '../ApplicationsStacksDatatable/types';
import { Application, ConfigKind } from './types';
import { useColumns } from './useColumns';
import { getPublishedUrls } from './PublishedPorts';
import { SubRow } from './SubRow';
import { HelmInsightsBox } from './HelmInsightsBox';
export function ApplicationsDatatable({
dataset,
onRefresh,
isLoading,
onRemove,
namespace = '',
namespaces,
onNamespaceChange,
showSystem,
onShowSystemChange,
hideStacks,
}: {
dataset: Array<Application>;
onRefresh: () => void;
isLoading: boolean;
onRemove: (selectedItems: Application[]) => void;
namespace?: string;
namespaces: Array<Namespace>;
onNamespaceChange(namespace: string): void;
showSystem?: boolean;
onShowSystemChange(showSystem: boolean): void;
hideStacks: boolean;
}) {
const envId = useEnvironmentId();
const envQuery = useCurrentEnvironment();
const namespaceMetaListQuery = useNamespacesQuery(envId);
const tableState = useKubeStore('kubernetes.applications', 'Name');
useRepeater(tableState.autoRefreshRate, onRefresh);
const hasWriteAuthQuery = useAuthorizations(
'K8sApplicationsW',
undefined,
true
);
const { setShowSystemResources } = tableState;
useEffect(() => {
setShowSystemResources(showSystem || false);
}, [showSystem, setShowSystemResources]);
const columns = useColumns(hideStacks);
const filteredDataset = !showSystem
? dataset.filter(
(item) => !namespaceMetaListQuery.data?.[item.ResourcePool]?.IsSystem
)
: dataset;
return (
<ExpandableDatatable
data-cy="k8sApp-appTable"
noWidget
dataset={filteredDataset}
settingsManager={tableState}
columns={columns}
title="Applications"
titleIcon={BoxIcon}
isLoading={isLoading}
disableSelect={!hasWriteAuthQuery.authorized}
isRowSelectable={(row) =>
!namespaceMetaListQuery.data?.[row.original.ResourcePool]?.IsSystem
}
getRowCanExpand={(row) => isExpandable(row.original)}
renderSubRow={(row) => (
<SubRow
item={row.original}
hideStacks={hideStacks}
areSecretsRestricted={
envQuery.data?.Kubernetes.Configuration.RestrictSecrets || false
}
/>
)}
renderTableActions={(selectedItems) =>
hasWriteAuthQuery.authorized && (
<>
<DeleteButton
data-cy="k8sApp-removeAppButton"
disabled={selectedItems.length === 0}
confirmMessage="Do you want to remove the selected application(s)?"
onConfirmed={() => onRemove(selectedItems)}
/>
<AddButton data-cy="k8sApp-addApplicationButton" color="secondary">
Add with form
</AddButton>
<CreateFromManifestButton data-cy="k8sApp-deployFromManifestButton" />
</>
)
}
renderTableSettings={() => (
<TableSettingsMenu>
<DefaultDatatableSettings
settings={tableState}
onShowSystemChange={onShowSystemChange}
/>
</TableSettingsMenu>
)}
description={
<div className="w-full">
<div className="min-w-[140px] float-right">
<NamespaceFilter
namespaces={namespaces}
value={namespace}
onChange={onNamespaceChange}
showSystem={tableState.showSystemResources}
/>
</div>
<div className="space-y-2">
<SystemResourceDescription
showSystemResources={tableState.showSystemResources}
/>
<div className="w-fit">
<HelmInsightsBox />
</div>
</div>
</div>
}
/>
);
}
function isExpandable(item: Application) {
return (
!!item.KubernetesApplications ||
!!getPublishedUrls(item).length ||
hasConfigurationSecrets(item)
);
}
function hasConfigurationSecrets(item: Application) {
return !!item.Configurations?.some(
(config) => config.Data && config.Kind === ConfigKind.Secret
);
}

View file

@ -0,0 +1,56 @@
import { useAuthorizations } from '@/react/hooks/useUser';
import { SensitiveDetails } from './SensitiveDetails';
import { Application, ConfigKind } from './types';
export function ConfigurationDetails({
item,
areSecretsRestricted,
username,
}: {
item: Application;
areSecretsRestricted: boolean;
username: string;
}) {
const isEnvironmentAdminQuery = useAuthorizations(['K8sResourcePoolsW']);
const secrets = item.Configurations?.filter(
(config) => config.Data && config.Kind === ConfigKind.Secret
);
if (isEnvironmentAdminQuery.isLoading || !secrets || secrets.length === 0) {
return null;
}
return (
<>
<div className="col-xs-12 !px-0 !py-1 text-[13px]"> Secrets </div>
<table className="w-1/2">
<tbody>
<tr>
<td>
{secrets.map((secret) =>
Object.entries(secret.Data || {}).map(([key, value]) => (
<SensitiveDetails
key={key}
name={key}
value={value}
canSeeValue={canSeeValue(secret)}
/>
))
)}
</td>
</tr>
</tbody>
</table>
</>
);
function canSeeValue(secret: { ConfigurationOwner: string }) {
return (
!areSecretsRestricted ||
isEnvironmentAdminQuery.authorized ||
secret.ConfigurationOwner === username
);
}
}

View file

@ -0,0 +1,22 @@
import { NestedDatatable } from '@@/datatables/NestedDatatable';
import { Application } from './types';
import { useBaseColumns } from './useColumns';
export function InnerTable({
dataset,
hideStacks,
}: {
dataset: Array<Application>;
hideStacks: boolean;
}) {
const columns = useBaseColumns(hideStacks);
return (
<NestedDatatable
dataset={dataset}
columns={columns}
data-cy="applications-nested-datatable"
/>
);
}

View file

@ -0,0 +1,70 @@
import { ExternalLinkIcon } from 'lucide-react';
import { getSchemeFromPort } from '@/react/common/network-utils';
import { Icon } from '@@/Icon';
import { Application } from './types';
export function PublishedPorts({ item }: { item: Application }) {
const urls = getPublishedUrls(item);
if (urls.length === 0) {
return null;
}
return (
<div className="published-url-container">
<div>
<div className="text-muted"> Published URL(s) </div>
</div>
<div>
{urls.map((url) => (
<div key={url}>
<a
href={url}
target="_blank"
className="publish-url-link vertical-center"
rel="noreferrer"
>
<Icon icon={ExternalLinkIcon} />
{url}
</a>
</div>
))}
</div>
</div>
);
}
export function getPublishedUrls(item: Application) {
// Map all ingress rules in published ports to their respective URLs
const ingressUrls =
item.PublishedPorts?.flatMap((pp) => pp.IngressRules)
.filter(({ Host, IP }) => Host || IP)
.map(({ Host, IP, Path, TLS }) => {
const scheme =
TLS &&
TLS.filter((tls) => tls.hosts && tls.hosts.includes(Host)).length > 0
? 'https'
: 'http';
return `${scheme}://${Host || IP}${Path}`;
}) || [];
// Map all load balancer service ports to ip address
const loadBalancerURLs =
(item.LoadBalancerIPAddress &&
item.PublishedPorts?.map(
(pp) =>
`${getSchemeFromPort(pp.Port)}://${item.LoadBalancerIPAddress}:${
pp.Port
}`
)) ||
[];
// combine ingress urls
const publishedUrls = [...ingressUrls, ...loadBalancerURLs];
// Return the first URL - priority given to ingress urls, then services (load balancers)
return publishedUrls.length > 0 ? publishedUrls : [];
}

View file

@ -0,0 +1,11 @@
.sensitive-details-container {
display: grid;
grid-template-columns: 25ch 25ch auto;
gap: 20px;
align-items: start;
}
.show-hide-container {
display: flex;
justify-content: space-between;
}

View file

@ -0,0 +1,61 @@
import { useState } from 'react';
import { EyeIcon, EyeOffIcon } from 'lucide-react';
import { RestrictedSecretBadge } from '@/react/kubernetes/configs/RestrictedSecretBadge';
import { Button, CopyButton } from '@@/buttons';
import styles from './SensitiveDetails.module.css';
export function SensitiveDetails({
name,
value,
canSeeValue,
}: {
name: string;
value: string;
canSeeValue?: boolean;
}) {
return (
<div className={styles.sensitiveDetailsContainer}>
<div className="text-wrap">{name}</div>
{canSeeValue ? (
<>
<ShowHide value={value} useAsterisk />
<CopyButton copyText={value} data-cy="copy-button" />
</>
) : (
<RestrictedSecretBadge />
)}
</div>
);
}
function ShowHide({
value,
useAsterisk,
}: {
value: string;
useAsterisk: boolean;
}) {
const [show, setShow] = useState(false);
return (
<div className={styles.showHideContainer}>
<div className="small text-muted text-wrap">
{show ? value : useAsterisk && '********'}
</div>
<Button
color="link"
type="button"
onClick={() => setShow((show) => !show)}
title="Show/Hide value"
icon={show ? EyeIcon : EyeOffIcon}
data-cy="show-hide-button"
>
{show ? 'Hide' : 'Show'}
</Button>
</div>
);
}

View file

@ -0,0 +1,45 @@
import clsx from 'clsx';
import { useCurrentUser } from '@/react/hooks/useUser';
import { ConfigurationDetails } from './ConfigurationDetails';
import { InnerTable } from './InnerTable';
import { PublishedPorts } from './PublishedPorts';
import { Application } from './types';
export function SubRow({
item,
hideStacks,
areSecretsRestricted,
}: {
item: Application;
hideStacks: boolean;
areSecretsRestricted: boolean;
}) {
const {
user: { Username: username },
} = useCurrentUser();
return (
<tr className={clsx({ 'secondary-body': !item.KubernetesApplications })}>
<td />
<td colSpan={8} className="datatable-padding-vertical">
{item.KubernetesApplications ? (
<InnerTable
dataset={item.KubernetesApplications}
hideStacks={hideStacks}
/>
) : (
<>
<PublishedPorts item={item} />
<ConfigurationDetails
item={item}
areSecretsRestricted={areSecretsRestricted}
username={username}
/>
</>
)}
</td>
</tr>
);
}

View file

@ -0,0 +1,5 @@
import { createColumnHelper } from '@tanstack/react-table';
import { Application } from './types';
export const helper = createColumnHelper<Application>();

View file

@ -0,0 +1,47 @@
import { CellContext } from '@tanstack/react-table';
import { useIsSystemNamespace } from '@/react/kubernetes/namespaces/queries/useIsSystemNamespace';
import { Link } from '@@/Link';
import { SystemBadge } from '@@/Badge/SystemBadge';
import { ExternalBadge } from '@@/Badge/ExternalBadge';
import { helper } from './columns.helper';
import { Application } from './types';
export const name = helper.accessor('Name', {
header: 'Name',
cell: Cell,
});
function Cell({ row: { original: item } }: CellContext<Application, string>) {
const isSystem = useIsSystemNamespace(item.ResourcePool);
return (
<div className="flex items-center gap-2">
{item.KubernetesApplications ? (
<Link
data-cy="application-helm-link"
to="kubernetes.helm"
params={{ name: item.Name, namespace: item.ResourcePool }}
>
{item.Name}
</Link>
) : (
<Link
data-cy="application-link"
to="kubernetes.applications.application"
params={{
name: item.Name,
namespace: item.ResourcePool,
'resource-type': item.ApplicationType,
}}
>
{item.Name}
</Link>
)}
{isSystem ? <SystemBadge /> : !item.ApplicationOwner && <ExternalBadge />}
</div>
);
}

View file

@ -0,0 +1,13 @@
.status-indicator {
padding: 0 !important;
margin-right: 1ch;
border-radius: 50%;
background-color: var(--red-3);
height: 10px;
width: 10px;
display: inline-block;
}
.status-indicator.ok {
background-color: var(--green-3);
}

View file

@ -0,0 +1,62 @@
import { CellContext } from '@tanstack/react-table';
import clsx from 'clsx';
import {
KubernetesApplicationDeploymentTypes,
KubernetesApplicationTypes,
} from '@/kubernetes/models/application/models/appConstants';
import styles from './columns.status.module.css';
import { helper } from './columns.helper';
import { Application } from './types';
export const status = helper.accessor('Status', {
header: 'Status',
cell: Cell,
enableSorting: false,
});
function Cell({ row: { original: item } }: CellContext<Application, string>) {
if (
item.ApplicationType === KubernetesApplicationTypes.Pod &&
item.Pods &&
item.Pods.length > 0
) {
return item.Pods[0].Status;
}
return (
<>
<span
className={clsx([
styles.statusIndicator,
{
[styles.ok]:
(item.TotalPodsCount > 0 &&
item.TotalPodsCount === item.RunningPodsCount) ||
item.Status === 'Ready',
},
])}
/>
{item.DeploymentType ===
KubernetesApplicationDeploymentTypes.Replicated && (
<span>Replicated</span>
)}
{item.DeploymentType === KubernetesApplicationDeploymentTypes.Global && (
<span>Global</span>
)}
{item.RunningPodsCount >= 0 && item.TotalPodsCount >= 0 && (
<span>
<code aria-label="Running Pods" title="Running Pods">
{item.RunningPodsCount}
</code>{' '}
/{' '}
<code aria-label="Total Pods" title="Total Pods">
{item.TotalPodsCount}
</code>
</span>
)}
{item.KubernetesApplications && <span>{item.Status}</span>}
</>
);
}

View file

@ -0,0 +1,48 @@
import { isoDate, truncate } from '@/portainer/filters/filters';
import { helper } from './columns.helper';
export const stackName = helper.accessor('StackName', {
header: 'Stack',
cell: ({ getValue }) => getValue() || '-',
});
export const namespace = helper.accessor('ResourcePool', {
header: 'Namespace',
cell: ({ getValue }) => getValue() || '-',
});
export const image = helper.accessor('Image', {
header: 'Image',
cell: ({ row: { original: item } }) => (
<>
{truncate(item.Image, 64)}
{item.Containers && item.Containers?.length > 1 && (
<>+ {item.Containers.length - 1}</>
)}
</>
),
});
export const appType = helper.accessor('ApplicationType', {
header: 'Application Type',
});
export const published = helper.accessor('Services', {
header: 'Published',
cell: ({ row: { original: item } }) =>
item.Services?.length === 0 ? 'No' : 'Yes',
enableSorting: false,
});
export const created = helper.accessor('CreationDate', {
header: 'Created',
cell({ row: { original: item } }) {
return (
<>
{isoDate(item.CreationDate)}{' '}
{item.ApplicationOwner ? ` by ${item.ApplicationOwner}` : ''}
</>
);
},
});

View file

@ -0,0 +1,47 @@
import { AppType, DeploymentType } from '../../types';
export interface Application {
Id: string;
Name: string;
Image: string;
Containers?: Array<unknown>;
Services?: Array<unknown>;
CreationDate: string;
ApplicationOwner?: string;
StackName?: string;
ResourcePool: string;
ApplicationType: AppType;
KubernetesApplications?: Array<Application>;
Metadata?: {
labels: Record<string, string>;
};
Status: 'Ready' | string;
TotalPodsCount: number;
RunningPodsCount: number;
DeploymentType: DeploymentType;
Pods?: Array<{
Status: string;
}>;
Configurations?: Array<{
Data?: object;
Kind: ConfigKind;
ConfigurationOwner: string;
}>;
LoadBalancerIPAddress?: string;
PublishedPorts?: Array<{
IngressRules: Array<{
Host: string;
IP: string;
Path: string;
TLS: Array<{
hosts: Array<string>;
}>;
}>;
Port: number;
}>;
}
export enum ConfigKind {
ConfigMap = 1,
Secret,
}

View file

@ -0,0 +1,34 @@
import _ from 'lodash';
import { buildExpandColumn } from '@@/datatables/expand-column';
import { name } from './columns.name';
import { status } from './columns.status';
import {
appType,
created,
image,
namespace,
published,
stackName,
} from './columns';
import { Application } from './types';
export function useColumns(hideStacks: boolean) {
const baseColumns = useBaseColumns(hideStacks);
return _.compact([buildExpandColumn<Application>(), ...baseColumns]);
}
export function useBaseColumns(hideStacks: boolean) {
return _.compact([
name,
!hideStacks && stackName,
namespace,
image,
appType,
status,
published,
created,
]);
}