1
0
Fork 0
mirror of https://github.com/portainer/portainer.git synced 2025-07-24 15:59:41 +02:00

feat(kubernetes): list all kube services screen [EE-1571] (#8524)

* port services from ee

* fix external link

* post review improvements

* remove applications-ports-datatable

* minor post review updates

* add services help url

* post review update

* more post review updates

* post review updates

* rename index to component

* fix external ip display and sorting

* fix external apps tag

* fix ingress screen time format

* use uid for row id. Prevent blank link

* fix some missing bits ported from EE

* match ee

* fix display of show system resources

* remove icon next to service type
This commit is contained in:
Matt Hook 2023-03-03 08:45:19 +13:00 committed by GitHub
parent 8d6797dc9f
commit ac47649631
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
43 changed files with 1121 additions and 456 deletions

View file

@ -0,0 +1,190 @@
import { Row, TableRowProps } from 'react-table';
import { Shuffle, Trash2 } from 'lucide-react';
import { useStore } from 'zustand';
import { useRouter } from '@uirouter/react';
import clsx from 'clsx';
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
import {
Authorized,
useAuthorizations,
useCurrentUser,
} from '@/react/hooks/useUser';
import KubernetesNamespaceHelper from '@/kubernetes/helpers/namespaceHelper';
import { notifyError, notifySuccess } from '@/portainer/services/notifications';
import { Datatable, Table, TableSettingsMenu } from '@@/datatables';
import { confirmDelete } from '@@/modals/confirm';
import { useSearchBarState } from '@@/datatables/SearchBar';
import { Button } from '@@/buttons';
import { Link } from '@@/Link';
import { useMutationDeleteServices, useServices } from '../service';
import { Service } from '../types';
import { DefaultDatatableSettings } from '../../datatables/DefaultDatatableSettings';
import { useColumns } from './columns';
import { createStore } from './datatable-store';
import { ServicesDatatableDescription } from './ServicesDatatableDescription';
const storageKey = 'k8sServicesDatatable';
const settingsStore = createStore(storageKey);
export function ServicesDatatable() {
const environmentId = useEnvironmentId();
const servicesQuery = useServices(environmentId);
const settings = useStore(settingsStore);
const [search, setSearch] = useSearchBarState(storageKey);
const columns = useColumns();
const readOnly = !useAuthorizations(['K8sServiceW']);
const { isAdmin } = useCurrentUser();
const filteredServices = servicesQuery.data?.filter(
(service) =>
(isAdmin && settings.showSystemResources) ||
!KubernetesNamespaceHelper.isSystemNamespace(service.Namespace)
);
return (
<Datatable
dataset={filteredServices || []}
columns={columns}
isLoading={servicesQuery.isLoading}
emptyContentLabel="No services found"
title="Services"
titleIcon={Shuffle}
getRowId={(row) => row.UID}
isRowSelectable={(row) =>
!KubernetesNamespaceHelper.isSystemNamespace(row.values.namespace)
}
disableSelect={readOnly}
renderTableActions={(selectedRows) => (
<TableActions selectedItems={selectedRows} />
)}
initialPageSize={settings.pageSize}
onPageSizeChange={settings.setPageSize}
initialSortBy={settings.sortBy}
onSortByChange={settings.setSortBy}
searchValue={search}
onSearchChange={setSearch}
renderTableSettings={() => (
<TableSettingsMenu>
<DefaultDatatableSettings
settings={settings}
hideShowSystemResources={!isAdmin}
/>
</TableSettingsMenu>
)}
description={
<ServicesDatatableDescription
showSystemResources={settings.showSystemResources || !isAdmin}
/>
}
renderRow={servicesRenderRow}
/>
);
}
// needed to apply custom styling to the row cells and not globally.
// required in the AC's for this ticket.
function servicesRenderRow<D extends Record<string, unknown>>(
row: Row<D>,
rowProps: TableRowProps,
highlightedItemId?: string
) {
return (
<Table.Row<D>
key={rowProps.key}
cells={row.cells}
className={clsx('[&>td]:!py-4 [&>td]:!align-top', rowProps.className, {
active: highlightedItemId === row.id,
})}
role={rowProps.role}
style={rowProps.style}
/>
);
}
interface SelectedService {
Namespace: string;
Name: string;
}
type TableActionsProps = {
selectedItems: Service[];
};
function TableActions({ selectedItems }: TableActionsProps) {
const environmentId = useEnvironmentId();
const deleteServicesMutation = useMutationDeleteServices(environmentId);
const router = useRouter();
async function handleRemoveClick(services: SelectedService[]) {
const confirmed = await confirmDelete(
<>
<p>Are you sure you want to delete the selected service(s)?</p>
<ul className="pl-6">
{services.map((s, index) => (
<li key={index}>
{s.Namespace}/{s.Name}
</li>
))}
</ul>
</>
);
if (!confirmed) {
return null;
}
const payload: Record<string, string[]> = {};
services.forEach((service) => {
payload[service.Namespace] = payload[service.Namespace] || [];
payload[service.Namespace].push(service.Name);
});
deleteServicesMutation.mutate(
{ environmentId, data: payload },
{
onSuccess: () => {
notifySuccess(
'Services successfully removed',
services.map((s) => `${s.Namespace}/${s.Name}`).join(', ')
);
router.stateService.reload();
},
onError: (error) => {
notifyError(
'Unable to delete service(s)',
error as Error,
services.map((s) => `${s.Namespace}/${s.Name}`).join(', ')
);
},
}
);
return services;
}
return (
<div className="servicesDatatable-actions">
<Authorized authorizations="K8sServiceW">
<Button
className="btn-wrapper"
color="dangerlight"
disabled={selectedItems.length === 0}
onClick={() => handleRemoveClick(selectedItems)}
icon={Trash2}
>
Remove
</Button>
<Link to="kubernetes.deploy" className="space-left">
<Button className="btn-wrapper" color="primary" icon="plus">
Create from manifest
</Button>
</Link>
</Authorized>
</div>
);
}

View file

@ -0,0 +1,17 @@
import { TextTip } from '@@/Tip/TextTip';
interface Props {
showSystemResources: boolean;
}
export function ServicesDatatableDescription({ showSystemResources }: Props) {
return (
<div className="w-full">
{!showSystemResources && (
<TextTip color="blue" className="!mb-0">
System resources are hidden, this can be changed in the table settings
</TextTip>
)}
</div>
);
}

View file

@ -0,0 +1,34 @@
import { CellProps, Column } from 'react-table';
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
import { Link } from '@@/Link';
import { Service } from '../../types';
export const application: Column<Service> = {
Header: 'Application',
accessor: (row) => (row.Applications ? row.Applications[0].Name : ''),
id: 'application',
Cell: ({ row, value: appname }: CellProps<Service, string>) => {
const environmentId = useEnvironmentId();
return appname ? (
<Link
to="kubernetes.applications.application"
params={{
endpointId: environmentId,
namespace: row.original.Namespace,
name: appname,
}}
title={appname}
>
{appname}
</Link>
) : (
'-'
);
},
canHide: true,
disableFilters: true,
};

View file

@ -0,0 +1,41 @@
import { CellProps, Column } from 'react-table';
import { Service } from '../../types';
export const clusterIP: Column<Service> = {
Header: 'Cluster IP',
accessor: 'ClusterIPs',
id: 'clusterIP',
Cell: ({ value: clusterIPs }: CellProps<Service, Service['ClusterIPs']>) => {
if (!clusterIPs?.length) {
return '-';
}
return clusterIPs.map((ip) => <div key={ip}>{ip}</div>);
},
disableFilters: true,
canHide: true,
sortType: (rowA, rowB) => {
const a = rowA.original.ClusterIPs;
const b = rowB.original.ClusterIPs;
const ipA = a?.[0];
const ipB = b?.[0];
// no ip's at top, followed by 'None', then ordered by ip
if (!ipA) return 1;
if (!ipB) return -1;
if (ipA === ipB) return 0;
if (ipA === 'None') return 1;
if (ipB === 'None') return -1;
// natural sort of the ip
return ipA.localeCompare(
ipB,
navigator.languages[0] || navigator.language,
{
numeric: true,
ignorePunctuation: true,
}
);
},
};

View file

@ -0,0 +1,23 @@
import { CellProps, Column } from 'react-table';
import { formatDate } from '@/portainer/filters/filters';
import { Service } from '../../types';
export const created: Column<Service> = {
Header: 'Created',
id: 'created',
accessor: (row) => row.CreationTimestamp,
Cell: ({ row }: CellProps<Service>) => {
const owner =
row.original.Labels?.['io.portainer.kubernetes.application.owner'];
if (owner) {
return `${formatDate(row.original.CreationTimestamp)} by ${owner}`;
}
return formatDate(row.original.CreationTimestamp);
},
disableFilters: true,
canHide: true,
};

View file

@ -0,0 +1,136 @@
import { CellProps, Column } from 'react-table';
import { Service } from '../../types';
import { ExternalIPLink } from './externalIPLink';
// calculate the scheme based on the ports of the service
// favour https over http.
function getSchemeAndPort(svc: Service): [string, number] {
let scheme = '';
let servicePort = 0;
svc.Ports?.forEach((port) => {
if (port.Protocol === 'TCP') {
switch (port.TargetPort) {
case '443':
case '8443':
case 'https':
scheme = 'https';
servicePort = port.Port;
break;
case '80':
case '8080':
case 'http':
if (scheme !== 'https') {
scheme = 'http';
servicePort = port.Port;
}
break;
default:
break;
}
}
});
return [scheme, servicePort];
}
export const externalIP: Column<Service> = {
Header: 'External IP',
id: 'externalIP',
accessor: (row) => {
if (row.Type === 'ExternalName') {
return row.ExternalName;
}
if (row.ExternalIPs?.length) {
return row.ExternalIPs?.slice(0);
}
return row.IngressStatus?.slice(0);
},
Cell: ({ row }: CellProps<Service>) => {
if (row.original.Type === 'ExternalName') {
if (row.original.ExternalName) {
const linkto = `http://${row.original.ExternalName}`;
return <ExternalIPLink to={linkto} text={row.original.ExternalName} />;
}
return '-';
}
const [scheme, port] = getSchemeAndPort(row.original);
if (row.original.ExternalIPs?.length) {
return row.original.ExternalIPs?.map((ip, index) => {
// some ips come through blank
if (ip.length === 0) {
return '-';
}
if (scheme) {
let linkto = `${scheme}://${ip}`;
if (port !== 80 && port !== 443) {
linkto = `${linkto}:${port}`;
}
return (
<div key={index}>
<ExternalIPLink to={linkto} text={ip} />
</div>
);
}
return <div key={index}>{ip}</div>;
});
}
const status = row.original.IngressStatus;
if (status) {
return status?.map((status, index) => {
// some ips come through blank
if (status.IP.length === 0) {
return '-';
}
if (scheme) {
let linkto = `${scheme}://${status.IP}`;
if (port !== 80 && port !== 443) {
linkto = `${linkto}:${port}`;
}
return (
<div key={index}>
<ExternalIPLink to={linkto} text={status.IP} />
</div>
);
}
return <div key={index}>{status.IP}</div>;
});
}
return '-';
},
disableFilters: true,
canHide: true,
sortType: (rowA, rowB) => {
const a = rowA.original.IngressStatus;
const b = rowB.original.IngressStatus;
const aExternal = rowA.original.ExternalIPs;
const bExternal = rowB.original.ExternalIPs;
const ipA = a?.[0].IP || aExternal?.[0] || rowA.original.ExternalName;
const ipB = b?.[0].IP || bExternal?.[0] || rowA.original.ExternalName;
if (!ipA) return 1;
if (!ipB) return -1;
// use a nat sort order for ip addresses
return ipA.localeCompare(
ipB,
navigator.languages[0] || navigator.language,
{
numeric: true,
ignorePunctuation: true,
}
);
},
};

View file

@ -0,0 +1,22 @@
import { ExternalLink } from 'lucide-react';
import { Icon } from '@@/Icon';
interface Props {
to: string;
text: string;
}
export function ExternalIPLink({ to, text }: Props) {
return (
<a
href={to}
target="_blank"
rel="noreferrer"
className="flex items-center gap-1"
>
<Icon icon={ExternalLink} />
<span>{text}</span>
</a>
);
}

View file

@ -0,0 +1,23 @@
import { name } from './name';
import { type } from './type';
import { namespace } from './namespace';
import { ports } from './ports';
import { clusterIP } from './clusterIP';
import { externalIP } from './externalIP';
import { targetPorts } from './targetPorts';
import { application } from './application';
import { created } from './created';
export function useColumns() {
return [
name,
application,
namespace,
type,
ports,
targetPorts,
clusterIP,
externalIP,
created,
];
}

View file

@ -0,0 +1,45 @@
import { CellProps, Column } from 'react-table';
import { Authorized } from '@/react/hooks/useUser';
import KubernetesNamespaceHelper from '@/kubernetes/helpers/namespaceHelper';
import { Service } from '../../types';
export const name: Column<Service> = {
Header: 'Name',
id: 'Name',
accessor: (row) => row.Name,
Cell: ({ row }: CellProps<Service>) => {
const isSystem = KubernetesNamespaceHelper.isSystemNamespace(
row.original.Namespace
);
const isExternal =
!row.original.Labels ||
!row.original.Labels['io.portainer.kubernetes.application.owner'];
return (
<Authorized
authorizations="K8sServiceW"
childrenUnauthorized={row.original.Name}
>
{row.original.Name}
{isSystem && (
<span className="label label-info image-tag label-margins">
system
</span>
)}
{isExternal && !isSystem && (
<span className="label label-primary image-tag label-margins">
external
</span>
)}
</Authorized>
);
},
disableFilters: true,
canHide: true,
};

View file

@ -0,0 +1,33 @@
import { CellProps, Column, Row } from 'react-table';
import { filterHOC } from '@/react/components/datatables/Filter';
import { Link } from '@@/Link';
import { Service } from '../../types';
export const namespace: Column<Service> = {
Header: 'Namespace',
id: 'namespace',
accessor: 'Namespace',
Cell: ({ row }: CellProps<Service>) => (
<Link
to="kubernetes.resourcePools.resourcePool"
params={{
id: row.original.Namespace,
}}
title={row.original.Namespace}
>
{row.original.Namespace}
</Link>
),
canHide: true,
disableFilters: false,
Filter: filterHOC('Filter by namespace'),
filter: (rows: Row<Service>[], _filterValue, filters) => {
if (filters.length === 0) {
return rows;
}
return rows.filter((r) => filters.includes(r.original.Namespace));
},
};

View file

@ -0,0 +1,76 @@
import { CellProps, Column } from 'react-table';
import { Tooltip } from '@@/Tip/Tooltip';
import { Service } from '../../types';
export const ports: Column<Service> = {
Header: () => (
<>
Ports
<Tooltip message="The format of Ports is port[:nodePort]/protocol. Protocol is either TCP, UDP or SCTP." />
</>
),
id: 'ports',
accessor: (row) => {
const ports = row.Ports;
return ports.map(
(port) => `${port.Port}:${port.NodePort}/${port.Protocol}`
);
},
Cell: ({ row }: CellProps<Service>) => {
if (!row.original.Ports.length) {
return '-';
}
return (
<>
{row.original.Ports.map((port, index) => {
if (port.NodePort !== 0) {
return (
<div key={index}>
{port.Port}:{port.NodePort}/{port.Protocol}
</div>
);
}
return (
<div key={index}>
{port.Port}/{port.Protocol}
</div>
);
})}
</>
);
},
disableFilters: true,
canHide: true,
sortType: (rowA, rowB) => {
const a = rowA.original.Ports;
const b = rowB.original.Ports;
if (!a.length && !b.length) return 0;
if (!a.length) return 1;
if (!b.length) return -1;
// sort order based on first port
const portA = a[0].Port;
const portB = b[0].Port;
if (portA === portB) {
// longer list of ports is considered "greater"
if (a.length < b.length) return -1;
if (a.length > b.length) return 1;
return 0;
}
// now do a regular number sort
if (portA < portB) return -1;
if (portA > portB) return 1;
return 0;
},
};

View file

@ -0,0 +1,53 @@
import { CellProps, Column } from 'react-table';
import { Service } from '../../types';
export const targetPorts: Column<Service> = {
Header: 'Target Ports',
id: 'targetPorts',
accessor: (row) => {
const ports = row.Ports;
if (!ports.length) {
return '-';
}
return ports.map((port) => `${port.TargetPort}`);
},
Cell: ({ row }: CellProps<Service>) => {
const ports = row.original.Ports;
if (!ports.length) {
return '-';
}
return ports.map((port, index) => <div key={index}>{port.TargetPort}</div>);
},
disableFilters: true,
canHide: true,
sortType: (rowA, rowB) => {
const a = rowA.original.Ports;
const b = rowB.original.Ports;
if (!a.length && !b.length) return 0;
if (!a.length) return 1;
if (!b.length) return -1;
const portA = a[0].TargetPort;
const portB = b[0].TargetPort;
if (portA === portB) {
if (a.length < b.length) return -1;
if (a.length > b.length) return 1;
return 0;
}
// natural sort of the port
return portA.localeCompare(
portB,
navigator.languages[0] || navigator.language,
{
numeric: true,
ignorePunctuation: true,
}
);
},
};

View file

@ -0,0 +1,22 @@
import { CellProps, Column, Row } from 'react-table';
import { filterHOC } from '@@/datatables/Filter';
import { Service } from '../../types';
export const type: Column<Service> = {
Header: 'Type',
id: 'type',
accessor: (row) => row.Type,
Cell: ({ row }: CellProps<Service>) => <div>{row.original.Type}</div>,
canHide: true,
disableFilters: false,
Filter: filterHOC('Filter by type'),
filter: (rows: Row<Service>[], _filterValue, filters) => {
if (filters.length === 0) {
return rows;
}
return rows.filter((r) => filters.includes(r.original.Type));
},
};

View file

@ -0,0 +1,13 @@
import { refreshableSettings, createPersistedStore } from '@@/datatables/types';
import {
systemResourcesSettings,
TableSettings,
} from '../../datatables/DefaultDatatableSettings';
export function createStore(storageKey: string) {
return createPersistedStore<TableSettings>(storageKey, 'Name', (set) => ({
...refreshableSettings(set),
...systemResourcesSettings(set),
}));
}

View file

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