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:
parent
8d6797dc9f
commit
ac47649631
43 changed files with 1121 additions and 456 deletions
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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,
|
||||
};
|
|
@ -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,
|
||||
}
|
||||
);
|
||||
},
|
||||
};
|
|
@ -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,
|
||||
};
|
|
@ -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,
|
||||
}
|
||||
);
|
||||
},
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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,
|
||||
];
|
||||
}
|
|
@ -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,
|
||||
};
|
|
@ -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));
|
||||
},
|
||||
};
|
|
@ -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;
|
||||
},
|
||||
};
|
|
@ -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,
|
||||
}
|
||||
);
|
||||
},
|
||||
};
|
|
@ -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));
|
||||
},
|
||||
};
|
|
@ -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),
|
||||
}));
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
export { ServicesDatatable } from './ServicesDatatable';
|
Loading…
Add table
Add a link
Reference in a new issue