mirror of
https://github.com/portainer/portainer.git
synced 2025-07-24 15:59:41 +02:00
feat(config): separate configmaps and secrets [EE-5078] (#9029)
This commit is contained in:
parent
4a331b71e1
commit
d7fc2046d7
102 changed files with 2845 additions and 665 deletions
|
@ -0,0 +1,192 @@
|
|||
import { Shuffle, Trash2 } from 'lucide-react';
|
||||
import { useRouter } from '@uirouter/react';
|
||||
import clsx from 'clsx';
|
||||
import { Row } from '@tanstack/react-table';
|
||||
|
||||
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
|
||||
import {
|
||||
Authorized,
|
||||
useAuthorizations,
|
||||
useCurrentUser,
|
||||
} from '@/react/hooks/useUser';
|
||||
import { notifyError, notifySuccess } from '@/portainer/services/notifications';
|
||||
import { pluralize } from '@/portainer/helpers/strings';
|
||||
|
||||
import { Datatable, Table, TableSettingsMenu } from '@@/datatables';
|
||||
import { confirmDelete } from '@@/modals/confirm';
|
||||
import { Button } from '@@/buttons';
|
||||
import { Link } from '@@/Link';
|
||||
import { useTableState } from '@@/datatables/useTableState';
|
||||
|
||||
import {
|
||||
useMutationDeleteServices,
|
||||
useServicesForCluster,
|
||||
} from '../../service';
|
||||
import { Service } from '../../types';
|
||||
import { DefaultDatatableSettings } from '../../../datatables/DefaultDatatableSettings';
|
||||
import { isSystemNamespace } from '../../../namespaces/utils';
|
||||
import { useNamespaces } from '../../../namespaces/queries';
|
||||
import { SystemResourceDescription } from '../../../datatables/SystemResourceDescription';
|
||||
|
||||
import { columns } from './columns';
|
||||
import { createStore } from './datatable-store';
|
||||
|
||||
const storageKey = 'k8sServicesDatatable';
|
||||
const settingsStore = createStore(storageKey);
|
||||
|
||||
export function ServicesDatatable() {
|
||||
const tableState = useTableState(settingsStore, storageKey);
|
||||
const environmentId = useEnvironmentId();
|
||||
const { data: namespaces, ...namespacesQuery } = useNamespaces(environmentId);
|
||||
const namespaceNames = (namespaces && Object.keys(namespaces)) || [];
|
||||
const { data: services, ...servicesQuery } = useServicesForCluster(
|
||||
environmentId,
|
||||
namespaceNames,
|
||||
{
|
||||
autoRefreshRate: tableState.autoRefreshRate * 1000,
|
||||
}
|
||||
);
|
||||
|
||||
const readOnly = !useAuthorizations(['K8sServiceW']);
|
||||
const { isAdmin } = useCurrentUser();
|
||||
|
||||
const filteredServices = services?.filter(
|
||||
(service) =>
|
||||
(isAdmin && tableState.showSystemResources) ||
|
||||
!isSystemNamespace(service.Namespace)
|
||||
);
|
||||
|
||||
return (
|
||||
<Datatable
|
||||
dataset={filteredServices || []}
|
||||
columns={columns}
|
||||
settingsManager={tableState}
|
||||
isLoading={servicesQuery.isLoading || namespacesQuery.isLoading}
|
||||
emptyContentLabel="No services found"
|
||||
title="Services"
|
||||
titleIcon={Shuffle}
|
||||
getRowId={(row) => row.UID}
|
||||
isRowSelectable={(row) => !isSystemNamespace(row.original.Namespace)}
|
||||
disableSelect={readOnly}
|
||||
renderTableActions={(selectedRows) => (
|
||||
<TableActions selectedItems={selectedRows} />
|
||||
)}
|
||||
renderTableSettings={() => (
|
||||
<TableSettingsMenu>
|
||||
<DefaultDatatableSettings
|
||||
settings={tableState}
|
||||
hideShowSystemResources={!isAdmin}
|
||||
/>
|
||||
</TableSettingsMenu>
|
||||
)}
|
||||
description={
|
||||
<SystemResourceDescription
|
||||
showSystemResources={tableState.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(row: Row<Service>, highlightedItemId?: string) {
|
||||
return (
|
||||
<Table.Row<Service>
|
||||
cells={row.getVisibleCells()}
|
||||
className={clsx('[&>td]:!py-4 [&>td]:!align-top', {
|
||||
active: highlightedItemId === row.id,
|
||||
})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
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 remove the selected ${pluralize(
|
||||
services.length,
|
||||
'service'
|
||||
)}?`}</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="K8sServicesW">
|
||||
<Button
|
||||
className="btn-wrapper"
|
||||
color="dangerlight"
|
||||
disabled={selectedItems.length === 0}
|
||||
onClick={() => handleRemoveClick(selectedItems)}
|
||||
icon={Trash2}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
|
||||
<Link
|
||||
to="kubernetes.deploy"
|
||||
params={{ referrer: 'kubernetes.services' }}
|
||||
className="space-left hover:no-decoration"
|
||||
>
|
||||
<Button className="btn-wrapper" color="primary" icon="plus">
|
||||
Create from manifest
|
||||
</Button>
|
||||
</Link>
|
||||
</Authorized>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -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,39 @@
|
|||
import { CellContext } from '@tanstack/react-table';
|
||||
|
||||
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
|
||||
|
||||
import { Link } from '@@/Link';
|
||||
|
||||
import { Service } from '../../../types';
|
||||
|
||||
import { columnHelper } from './helper';
|
||||
|
||||
export const application = columnHelper.accessor(
|
||||
(row) => (row.Applications ? row.Applications[0].Name : ''),
|
||||
{
|
||||
header: 'Application',
|
||||
id: 'application',
|
||||
cell: Cell,
|
||||
}
|
||||
);
|
||||
|
||||
function Cell({ row, getValue }: CellContext<Service, string>) {
|
||||
const appName = getValue();
|
||||
const environmentId = useEnvironmentId();
|
||||
|
||||
return appName ? (
|
||||
<Link
|
||||
to="kubernetes.applications.application"
|
||||
params={{
|
||||
endpointId: environmentId,
|
||||
namespace: row.original.Namespace,
|
||||
name: appName,
|
||||
}}
|
||||
title={appName}
|
||||
>
|
||||
{appName}
|
||||
</Link>
|
||||
) : (
|
||||
'-'
|
||||
);
|
||||
}
|
|
@ -0,0 +1,41 @@
|
|||
import { columnHelper } from './helper';
|
||||
|
||||
export const clusterIP = columnHelper.accessor(
|
||||
(row) => row.ClusterIPs?.join(','),
|
||||
{
|
||||
header: 'Cluster IP',
|
||||
id: 'clusterIP',
|
||||
cell: ({ row }) => {
|
||||
const clusterIPs = row.original.ClusterIPs;
|
||||
|
||||
if (!clusterIPs?.length) {
|
||||
return '-';
|
||||
}
|
||||
return clusterIPs.map((ip) => <div key={ip}>{ip}</div>);
|
||||
},
|
||||
sortingFn: (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 { formatDate } from '@/portainer/filters/filters';
|
||||
|
||||
import { columnHelper } from './helper';
|
||||
|
||||
export const created = columnHelper.accessor(
|
||||
(row) => {
|
||||
const owner = row.Labels?.['io.portainer.kubernetes.application.owner'];
|
||||
const date = formatDate(row.CreationTimestamp);
|
||||
return owner ? `${date} by ${owner}` : date;
|
||||
},
|
||||
{
|
||||
header: 'Created',
|
||||
id: 'created',
|
||||
cell: ({ row }) => {
|
||||
const date = formatDate(row.original.CreationTimestamp);
|
||||
|
||||
const owner =
|
||||
row.original.Labels?.['io.portainer.kubernetes.application.owner'];
|
||||
|
||||
return owner ? `${date} by ${owner}` : date;
|
||||
},
|
||||
}
|
||||
);
|
|
@ -0,0 +1,139 @@
|
|||
import { CellContext } from '@tanstack/react-table';
|
||||
|
||||
import { Service } from '../../../types';
|
||||
|
||||
import { ExternalIPLink } from './ExternalIPLink';
|
||||
import { columnHelper } from './helper';
|
||||
|
||||
export const externalIP = columnHelper.accessor(
|
||||
(row) => {
|
||||
if (row.Type === 'ExternalName') {
|
||||
return row.ExternalName || '';
|
||||
}
|
||||
|
||||
if (row.ExternalIPs?.length) {
|
||||
return row.ExternalIPs?.join(',') || '';
|
||||
}
|
||||
|
||||
return row.IngressStatus?.map((status) => status.IP).join(',') || '';
|
||||
},
|
||||
{
|
||||
header: 'External IP',
|
||||
id: 'externalIP',
|
||||
cell: Cell,
|
||||
sortingFn: (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,
|
||||
}
|
||||
);
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
function Cell({ row }: CellContext<Service, string>) {
|
||||
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 '-';
|
||||
}
|
||||
|
||||
// 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];
|
||||
}
|
|
@ -0,0 +1,5 @@
|
|||
import { createColumnHelper } from '@tanstack/react-table';
|
||||
|
||||
import { Service } from '../../../types';
|
||||
|
||||
export const columnHelper = createColumnHelper<Service>();
|
|
@ -0,0 +1,21 @@
|
|||
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 const columns = [
|
||||
name,
|
||||
application,
|
||||
namespace,
|
||||
type,
|
||||
ports,
|
||||
targetPorts,
|
||||
clusterIP,
|
||||
externalIP,
|
||||
created,
|
||||
];
|
|
@ -0,0 +1,53 @@
|
|||
import { Authorized } from '@/react/hooks/useUser';
|
||||
import { isSystemNamespace } from '@/react/kubernetes/namespaces/utils';
|
||||
|
||||
import { columnHelper } from './helper';
|
||||
|
||||
export const name = columnHelper.accessor(
|
||||
(row) => {
|
||||
let name = row.Name;
|
||||
|
||||
const isExternal =
|
||||
!row.Labels || !row.Labels['io.portainer.kubernetes.application.owner'];
|
||||
const isSystem = isSystemNamespace(row.Namespace);
|
||||
|
||||
if (isExternal && !isSystem) {
|
||||
name = `${name} external`;
|
||||
}
|
||||
|
||||
if (isSystem) {
|
||||
name = `${name} system`;
|
||||
}
|
||||
return name;
|
||||
},
|
||||
{
|
||||
header: 'Name',
|
||||
id: 'name',
|
||||
cell: ({ row }) => {
|
||||
const name = row.original.Name;
|
||||
const isSystem = isSystemNamespace(row.original.Namespace);
|
||||
|
||||
const isExternal =
|
||||
!row.original.Labels ||
|
||||
!row.original.Labels['io.portainer.kubernetes.application.owner'];
|
||||
|
||||
return (
|
||||
<Authorized authorizations="K8sServiceW" childrenUnauthorized={name}>
|
||||
{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>
|
||||
);
|
||||
},
|
||||
}
|
||||
);
|
|
@ -0,0 +1,35 @@
|
|||
import { Row } from '@tanstack/react-table';
|
||||
|
||||
import { filterHOC } from '@/react/components/datatables/Filter';
|
||||
|
||||
import { Link } from '@@/Link';
|
||||
|
||||
import { Service } from '../../../types';
|
||||
|
||||
import { columnHelper } from './helper';
|
||||
|
||||
export const namespace = columnHelper.accessor('Namespace', {
|
||||
header: 'Namespace',
|
||||
id: 'namespace',
|
||||
cell: ({ getValue }) => {
|
||||
const namespace = getValue();
|
||||
|
||||
return (
|
||||
<Link
|
||||
to="kubernetes.resourcePools.resourcePool"
|
||||
params={{
|
||||
id: namespace,
|
||||
}}
|
||||
title={namespace}
|
||||
>
|
||||
{namespace}
|
||||
</Link>
|
||||
);
|
||||
},
|
||||
meta: {
|
||||
filter: filterHOC('Filter by namespace'),
|
||||
},
|
||||
enableColumnFilter: true,
|
||||
filterFn: (row: Row<Service>, columnId: string, filterValue: string[]) =>
|
||||
filterValue.length === 0 || filterValue.includes(row.original.Namespace),
|
||||
});
|
|
@ -0,0 +1,73 @@
|
|||
import { Tooltip } from '@@/Tip/Tooltip';
|
||||
|
||||
import { columnHelper } from './helper';
|
||||
|
||||
export const ports = columnHelper.accessor(
|
||||
(row) =>
|
||||
row.Ports.map(
|
||||
(port) =>
|
||||
`${port.Port}${port.NodePort !== 0 ? `:${port.NodePort}` : ''}/${
|
||||
port.Protocol
|
||||
}`
|
||||
).join(',') || '-',
|
||||
{
|
||||
header: () => (
|
||||
<>
|
||||
Ports
|
||||
<Tooltip message="The format of Ports is port[:nodePort]/protocol. Protocol is either TCP, UDP or SCTP." />
|
||||
</>
|
||||
),
|
||||
id: 'ports',
|
||||
cell: ({ row }) => {
|
||||
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>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
},
|
||||
sortingFn: (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,45 @@
|
|||
import { columnHelper } from './helper';
|
||||
|
||||
export const targetPorts = columnHelper.accessor(
|
||||
(row) => row.Ports.map((port) => port.TargetPort).join(','),
|
||||
{
|
||||
header: 'Target Ports',
|
||||
id: 'targetPorts',
|
||||
cell: ({ row }) => {
|
||||
const ports = row.original.Ports.map((port) => port.TargetPort);
|
||||
if (!ports.length) {
|
||||
return '-';
|
||||
}
|
||||
|
||||
return ports.map((port, index) => <div key={index}>{port}</div>);
|
||||
},
|
||||
sortingFn: (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,18 @@
|
|||
import { Row } from '@tanstack/react-table';
|
||||
|
||||
import { filterHOC } from '@@/datatables/Filter';
|
||||
|
||||
import { Service } from '../../../types';
|
||||
|
||||
import { columnHelper } from './helper';
|
||||
|
||||
export const type = columnHelper.accessor('Type', {
|
||||
header: 'Type',
|
||||
id: 'type',
|
||||
meta: {
|
||||
filter: filterHOC('Filter by type'),
|
||||
},
|
||||
enableColumnFilter: true,
|
||||
filterFn: (row: Row<Service>, columnId: string, filterValue: string[]) =>
|
||||
filterValue.length === 0 || filterValue.includes(row.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';
|
12
app/react/kubernetes/services/ServicesView/ServicesView.tsx
Normal file
12
app/react/kubernetes/services/ServicesView/ServicesView.tsx
Normal file
|
@ -0,0 +1,12 @@
|
|||
import { PageHeader } from '@@/PageHeader';
|
||||
|
||||
import { ServicesDatatable } from './ServicesDatatable';
|
||||
|
||||
export function ServicesView() {
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="Service list" breadcrumbs="Services" reload />
|
||||
<ServicesDatatable />
|
||||
</>
|
||||
);
|
||||
}
|
1
app/react/kubernetes/services/ServicesView/index.ts
Normal file
1
app/react/kubernetes/services/ServicesView/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export { ServicesView } from './ServicesView';
|
|
@ -1,23 +0,0 @@
|
|||
import { saveAs } from 'file-saver';
|
||||
|
||||
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
|
||||
const baseUrl = 'kubernetes';
|
||||
|
||||
export async function downloadKubeconfigFile(environmentIds: EnvironmentId[]) {
|
||||
try {
|
||||
const { headers, data } = await axios.get<Blob>(`${baseUrl}/config`, {
|
||||
params: { ids: JSON.stringify(environmentIds) },
|
||||
responseType: 'blob',
|
||||
headers: {
|
||||
Accept: 'text/yaml',
|
||||
},
|
||||
});
|
||||
const contentDispositionHeader = headers['content-disposition'];
|
||||
const filename = contentDispositionHeader.replace('attachment;', '').trim();
|
||||
saveAs(data, filename);
|
||||
} catch (e) {
|
||||
throw parseAxiosError(e as Error, '');
|
||||
}
|
||||
}
|
|
@ -1,5 +0,0 @@
|
|||
## Common Services
|
||||
|
||||
This folder contains rest api services that are shared by different features within kubernetes.
|
||||
|
||||
This includes api requests to the portainer backend, and also requests to the kubernetes api.
|
107
app/react/kubernetes/services/service.ts
Normal file
107
app/react/kubernetes/services/service.ts
Normal file
|
@ -0,0 +1,107 @@
|
|||
import { useMutation, useQuery, useQueryClient } from 'react-query';
|
||||
import { compact } from 'lodash';
|
||||
import { ServiceList } from 'kubernetes-types/core/v1';
|
||||
|
||||
import { withError } from '@/react-tools/react-query';
|
||||
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
import { isFulfilled } from '@/portainer/helpers/promise-utils';
|
||||
|
||||
import { Service } from './types';
|
||||
|
||||
export const queryKeys = {
|
||||
clusterServices: (environmentId: EnvironmentId) =>
|
||||
['environments', environmentId, 'kubernetes', 'services'] as const,
|
||||
};
|
||||
|
||||
export function useServicesForCluster(
|
||||
environmentId: EnvironmentId,
|
||||
namespaceNames?: string[],
|
||||
options?: { autoRefreshRate?: number }
|
||||
) {
|
||||
return useQuery(
|
||||
queryKeys.clusterServices(environmentId),
|
||||
async () => {
|
||||
if (!namespaceNames?.length) {
|
||||
return [];
|
||||
}
|
||||
const settledServicesPromise = await Promise.allSettled(
|
||||
namespaceNames.map((namespace) =>
|
||||
getServices(environmentId, namespace, true)
|
||||
)
|
||||
);
|
||||
return compact(
|
||||
settledServicesPromise.filter(isFulfilled).flatMap((i) => i.value)
|
||||
);
|
||||
},
|
||||
{
|
||||
...withError('Unable to get services.'),
|
||||
refetchInterval() {
|
||||
return options?.autoRefreshRate ?? false;
|
||||
},
|
||||
enabled: !!namespaceNames?.length,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export function useMutationDeleteServices(environmentId: EnvironmentId) {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation(deleteServices, {
|
||||
onSuccess: () =>
|
||||
queryClient.invalidateQueries(queryKeys.clusterServices(environmentId)),
|
||||
...withError('Unable to delete service(s)'),
|
||||
});
|
||||
}
|
||||
|
||||
// get a list of services for a specific namespace from the Portainer API
|
||||
async function getServices(
|
||||
environmentId: EnvironmentId,
|
||||
namespace: string,
|
||||
lookupApps: boolean
|
||||
) {
|
||||
try {
|
||||
const { data: services } = await axios.get<Array<Service>>(
|
||||
`kubernetes/${environmentId}/namespaces/${namespace}/services`,
|
||||
{
|
||||
params: {
|
||||
lookupapplications: lookupApps,
|
||||
},
|
||||
}
|
||||
);
|
||||
return services;
|
||||
} catch (e) {
|
||||
throw parseAxiosError(e as Error, 'Unable to retrieve services');
|
||||
}
|
||||
}
|
||||
|
||||
// getNamespaceServices is used to get a list of services for a specific namespace directly from the Kubernetes API
|
||||
export async function getNamespaceServices(
|
||||
environmentId: EnvironmentId,
|
||||
namespace: string,
|
||||
queryParams?: Record<string, string>
|
||||
) {
|
||||
const { data: services } = await axios.get<ServiceList>(
|
||||
`/endpoints/${environmentId}/kubernetes/api/v1/namespaces/${namespace}/services`,
|
||||
{
|
||||
params: queryParams,
|
||||
}
|
||||
);
|
||||
return services.items;
|
||||
}
|
||||
|
||||
export async function deleteServices({
|
||||
environmentId,
|
||||
data,
|
||||
}: {
|
||||
environmentId: EnvironmentId;
|
||||
data: Record<string, string[]>;
|
||||
}) {
|
||||
try {
|
||||
return await axios.post(
|
||||
`kubernetes/${environmentId}/services/delete`,
|
||||
data
|
||||
);
|
||||
} catch (e) {
|
||||
throw parseAxiosError(e as Error, 'Unable to delete service(s)');
|
||||
}
|
||||
}
|
41
app/react/kubernetes/services/types.ts
Normal file
41
app/react/kubernetes/services/types.ts
Normal file
|
@ -0,0 +1,41 @@
|
|||
type ServicePort = {
|
||||
Name: string;
|
||||
NodePort: number;
|
||||
Port: number;
|
||||
Protocol: string;
|
||||
TargetPort: string;
|
||||
};
|
||||
|
||||
type IngressStatus = {
|
||||
Hostname: string;
|
||||
IP: string;
|
||||
};
|
||||
|
||||
type Application = {
|
||||
UID: string;
|
||||
Name: string;
|
||||
Type: string;
|
||||
};
|
||||
|
||||
export type ServiceType =
|
||||
| 'ClusterIP'
|
||||
| 'ExternalName'
|
||||
| 'NodePort'
|
||||
| 'LoadBalancer';
|
||||
|
||||
export type Service = {
|
||||
Name: string;
|
||||
UID: string;
|
||||
Namespace: string;
|
||||
Annotations?: Record<string, string>;
|
||||
Labels?: Record<string, string>;
|
||||
Type: ServiceType;
|
||||
Ports: Array<ServicePort>;
|
||||
Selector?: Record<string, string>;
|
||||
ClusterIPs?: Array<string>;
|
||||
IngressStatus?: Array<IngressStatus>;
|
||||
ExternalName?: string;
|
||||
ExternalIPs?: Array<string>;
|
||||
CreationTimestamp: string;
|
||||
Applications?: Application[];
|
||||
};
|
|
@ -1,11 +0,0 @@
|
|||
export * from './v1IngressClass';
|
||||
export * from './v1ObjectMeta';
|
||||
|
||||
export type KubernetesApiListResponse<T> = {
|
||||
apiVersion: string;
|
||||
kind: string;
|
||||
items: T;
|
||||
metadata: {
|
||||
resourceVersion?: string;
|
||||
};
|
||||
};
|
|
@ -1,48 +0,0 @@
|
|||
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;
|
||||
};
|
|
@ -1,33 +0,0 @@
|
|||
// 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;
|
||||
};
|
Loading…
Add table
Add a link
Reference in a new issue