Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bf5d5cab68 | ||
|
|
51ffea9c93 | ||
|
|
eb0ee117a5 |
@@ -615,7 +615,7 @@
|
||||
"RequiredPasswordLength": 12
|
||||
},
|
||||
"KubeconfigExpiry": "0",
|
||||
"KubectlShellImage": "portainer/kubectl-shell:2.41.0",
|
||||
"KubectlShellImage": "portainer/kubectl-shell:2.41.1",
|
||||
"LDAPSettings": {
|
||||
"AnonymousMode": true,
|
||||
"AutoCreateUsers": true,
|
||||
@@ -947,7 +947,7 @@
|
||||
}
|
||||
],
|
||||
"version": {
|
||||
"VERSION": "{\"SchemaVersion\":\"2.41.0\",\"MigratorCount\":0,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
|
||||
"VERSION": "{\"SchemaVersion\":\"2.41.1\",\"MigratorCount\":0,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
|
||||
},
|
||||
"webhooks": null
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"github.com/portainer/portainer/api/internal/authorization"
|
||||
"github.com/portainer/portainer/api/internal/endpointutils"
|
||||
"github.com/portainer/portainer/api/internal/snapshot"
|
||||
"github.com/portainer/portainer/api/kubernetes/cli"
|
||||
"github.com/portainer/portainer/api/set"
|
||||
"github.com/portainer/portainer/api/slicesx"
|
||||
"github.com/portainer/portainer/api/stacks/stackutils"
|
||||
@@ -46,12 +47,21 @@ func buildEndpointMap(tx dataservices.DataStoreTx, stacks []portainer.Stack) (ma
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func filterStacksByAccess(tx dataservices.DataStoreTx, stacks []portainer.Stack, sc *security.RestrictedRequestContext) ([]portainer.Stack, error) {
|
||||
type endpointAccess struct {
|
||||
isKubeAdmin bool
|
||||
nonAdminNamespaces []string
|
||||
}
|
||||
|
||||
// filterDockerStacksByAccess filters stacks to only those the current user can access.
|
||||
func filterDockerStacksByAccess(tx dataservices.DataStoreTx, stacks []portainer.Stack, sc *security.RestrictedRequestContext) ([]portainer.Stack, error) {
|
||||
if sc.IsAdmin {
|
||||
return stacks, nil
|
||||
}
|
||||
|
||||
stackResourceIDSet := set.ToSet(slicesx.Map(stacks, func(s portainer.Stack) string {
|
||||
// do not try to check UAC on kube stacks
|
||||
filtered, dockerStacks := slicesx.Partition(stacks, func(s portainer.Stack) bool { return s.Type == portainer.KubernetesStack })
|
||||
|
||||
stackResourceIDSet := set.ToSet(slicesx.Map(dockerStacks, func(s portainer.Stack) string {
|
||||
return stackutils.ResourceControlID(s.EndpointID, s.Name)
|
||||
}))
|
||||
|
||||
@@ -62,8 +72,51 @@ func filterStacksByAccess(tx dataservices.DataStoreTx, stacks []portainer.Stack,
|
||||
return nil, err
|
||||
}
|
||||
|
||||
stacks = authorization.DecorateStacks(stacks, resourceControls)
|
||||
dockerStacks = authorization.DecorateStacks(dockerStacks, resourceControls)
|
||||
|
||||
userTeamIDs := authorization.TeamIDs(sc.UserMemberships)
|
||||
return authorization.FilterAuthorizedStacks(stacks, sc.UserID, userTeamIDs), nil
|
||||
filtered = append(filtered, authorization.FilterAuthorizedStacks(dockerStacks, sc.UserID, userTeamIDs)...)
|
||||
return filtered, nil
|
||||
}
|
||||
|
||||
func resolveKubeAccess(k8sFactory *cli.ClientFactory, sc *security.RestrictedRequestContext, ep *portainer.Endpoint) (endpointAccess, error) {
|
||||
if sc.IsAdmin {
|
||||
return endpointAccess{isKubeAdmin: true}, nil
|
||||
}
|
||||
|
||||
pcli, err := k8sFactory.GetPrivilegedKubeClient(ep)
|
||||
if err != nil {
|
||||
return endpointAccess{}, fmt.Errorf("unable to get privileged kube client for endpoint %d: %w", ep.ID, err)
|
||||
}
|
||||
|
||||
teamIDs := make([]int, 0, len(sc.UserMemberships))
|
||||
for _, m := range sc.UserMemberships {
|
||||
teamIDs = append(teamIDs, int(m.TeamID))
|
||||
}
|
||||
|
||||
nonAdminNamespaces, err := pcli.GetNonAdminNamespaces(int(sc.UserID), teamIDs, ep.Kubernetes.Configuration.RestrictDefaultNamespace)
|
||||
if err != nil {
|
||||
return endpointAccess{}, fmt.Errorf("unable to retrieve non-admin namespaces for endpoint %d: %w", ep.ID, err)
|
||||
}
|
||||
|
||||
return endpointAccess{isKubeAdmin: false, nonAdminNamespaces: nonAdminNamespaces}, nil
|
||||
}
|
||||
|
||||
func buildEndpointAccessMap(k8sFactory *cli.ClientFactory, sc *security.RestrictedRequestContext, endpointMap map[portainer.EndpointID]portainer.Endpoint) (map[portainer.EndpointID]endpointAccess, error) {
|
||||
result := make(map[portainer.EndpointID]endpointAccess, len(endpointMap))
|
||||
|
||||
for epID, ep := range endpointMap {
|
||||
if !endpointutils.IsKubernetesEndpoint(&ep) {
|
||||
continue
|
||||
}
|
||||
|
||||
access, err := resolveKubeAccess(k8sFactory, sc, &ep)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result[epID] = access
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
@@ -157,7 +157,7 @@ func (h *Handler) fetchWorkflows(ctx context.Context, sc *security.RestrictedReq
|
||||
return err
|
||||
}
|
||||
|
||||
stacks, err = filterStacksByAccess(tx, stacks, sc)
|
||||
stacks, err = filterDockerStacksByAccess(tx, stacks, sc)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -176,7 +176,12 @@ func (h *Handler) fetchWorkflows(ctx context.Context, sc *security.RestrictedReq
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
entries, err = filterK8SStacks(entries, endpointMap, h.k8sFactory, sc.UserID)
|
||||
accessMap, err := buildEndpointAccessMap(h.k8sFactory, sc, endpointMap)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
entries, err = filterK8SStacks(entries, endpointMap, h.k8sFactory, accessMap)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -197,7 +202,7 @@ func shouldPerformEnvLookup(endpoint *portainer.Endpoint) bool {
|
||||
(endpointutils.IsEdgeEndpoint(endpoint) && !endpoint.Edge.AsyncMode))
|
||||
}
|
||||
|
||||
func filterK8SStacks(items []portainer.Stack, endpointMap map[portainer.EndpointID]portainer.Endpoint, k8sFactory *cli.ClientFactory, userId portainer.UserID) ([]portainer.Stack, error) {
|
||||
func filterK8SStacks(items []portainer.Stack, endpointMap map[portainer.EndpointID]portainer.Endpoint, k8sFactory *cli.ClientFactory, accessMap map[portainer.EndpointID]endpointAccess) ([]portainer.Stack, error) {
|
||||
k8sStacks, result := slicesx.Partition(items, func(s portainer.Stack) bool {
|
||||
return s.Type == portainer.KubernetesStack
|
||||
})
|
||||
@@ -212,10 +217,15 @@ func filterK8SStacks(items []portainer.Stack, endpointMap map[portainer.Endpoint
|
||||
continue
|
||||
}
|
||||
|
||||
kcl, err := k8sFactory.GetPrivilegedUserKubeClient(&ep, userId)
|
||||
kcl, err := k8sFactory.GetPrivilegedKubeClient(&ep)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
access := accessMap[envID]
|
||||
kcl.SetIsKubeAdmin(access.isKubeAdmin)
|
||||
kcl.SetClientNonAdminNamespaces(access.nonAdminNamespaces)
|
||||
|
||||
apps, err := kcl.GetApplications("", "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -81,7 +81,7 @@ type Handler struct {
|
||||
}
|
||||
|
||||
// @title PortainerCE API
|
||||
// @version 2.41.0
|
||||
// @version 2.41.1
|
||||
// @description.markdown api-description.md
|
||||
// @termsOfService
|
||||
|
||||
|
||||
@@ -1945,7 +1945,7 @@ type (
|
||||
|
||||
const (
|
||||
// APIVersion is the version number of the Portainer API
|
||||
APIVersion = "2.41.0"
|
||||
APIVersion = "2.41.1"
|
||||
// Support annotation for the API version ("STS" for Short-Term Support or "LTS" for Long-Term Support)
|
||||
APIVersionSupport = "STS"
|
||||
// Edition is what this edition of Portainer is called
|
||||
|
||||
@@ -117,6 +117,7 @@ export function GroupSortTable<TItem extends object>({
|
||||
<Widget className="overflow-clip [&_table]:bg-transparent" data-cy={dataCy}>
|
||||
<GroupSortTableHeader
|
||||
sortBy={sortBy}
|
||||
sortDesc={tableState.sortBy?.desc ?? false}
|
||||
onSortChange={handleSortChange}
|
||||
searchTerm={tableState.search}
|
||||
onSearchChange={(value) => {
|
||||
@@ -185,11 +186,15 @@ export function GroupSortTable<TItem extends object>({
|
||||
return renderRow(row);
|
||||
}
|
||||
|
||||
const header = renderGroupHeader(groupKey, groupCountByKey[groupKey] ?? 0);
|
||||
if (header == null) {
|
||||
return renderRow(row);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<tr>
|
||||
<td colSpan={Number.MAX_SAFE_INTEGER} className="!p-0">
|
||||
{renderGroupHeader(groupKey, groupCountByKey[groupKey] ?? 0)}
|
||||
{header}
|
||||
</td>
|
||||
</tr>
|
||||
{renderRow(row)}
|
||||
@@ -199,7 +204,9 @@ export function GroupSortTable<TItem extends object>({
|
||||
|
||||
function handleSortChange(key: string) {
|
||||
tableState.setPage(1);
|
||||
tableState.setSortBy(key, false);
|
||||
const newDesc =
|
||||
tableState.sortBy?.id === key ? !tableState.sortBy.desc : false;
|
||||
tableState.setSortBy(key, newDesc);
|
||||
}
|
||||
|
||||
function handleGroupFilterChange(value: string | null) {
|
||||
|
||||
@@ -29,13 +29,20 @@ type SortKey = (typeof sortOptions)[number]['key'];
|
||||
|
||||
export function Interactive() {
|
||||
const [sortBy, setSortBy] = useState<SortKey>('name');
|
||||
const [sortDesc, setSortDesc] = useState(false);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [groupFilter, setGroupFilter] = useState<string | null>(null);
|
||||
|
||||
function handleSortChange(key: SortKey) {
|
||||
setSortDesc((prev) => (sortBy === key ? !prev : false));
|
||||
setSortBy(key);
|
||||
}
|
||||
|
||||
return (
|
||||
<GroupSortTableHeader
|
||||
sortBy={sortBy}
|
||||
onSortChange={setSortBy}
|
||||
sortDesc={sortDesc}
|
||||
onSortChange={handleSortChange}
|
||||
searchTerm={searchTerm}
|
||||
onSearchChange={setSearchTerm}
|
||||
sortOptions={[...sortOptions]}
|
||||
@@ -50,12 +57,19 @@ export function Interactive() {
|
||||
|
||||
export function WithGroupFilter() {
|
||||
const [sortBy, setSortBy] = useState<SortKey>('group');
|
||||
const [sortDesc, setSortDesc] = useState(false);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
|
||||
function handleSortChange(key: SortKey) {
|
||||
setSortDesc((prev) => (sortBy === key ? !prev : false));
|
||||
setSortBy(key);
|
||||
}
|
||||
|
||||
return (
|
||||
<GroupSortTableHeader
|
||||
sortBy={sortBy}
|
||||
onSortChange={setSortBy}
|
||||
sortDesc={sortDesc}
|
||||
onSortChange={handleSortChange}
|
||||
searchTerm={searchTerm}
|
||||
onSearchChange={setSearchTerm}
|
||||
sortOptions={[...sortOptions]}
|
||||
@@ -70,13 +84,20 @@ export function WithGroupFilter() {
|
||||
|
||||
export function WithActionButton() {
|
||||
const [sortBy, setSortBy] = useState<SortKey>('name');
|
||||
const [sortDesc, setSortDesc] = useState(false);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [groupFilter, setGroupFilter] = useState<string | null>(null);
|
||||
|
||||
function handleSortChange(key: SortKey) {
|
||||
setSortDesc((prev) => (sortBy === key ? !prev : false));
|
||||
setSortBy(key);
|
||||
}
|
||||
|
||||
return (
|
||||
<GroupSortTableHeader
|
||||
sortBy={sortBy}
|
||||
onSortChange={setSortBy}
|
||||
sortDesc={sortDesc}
|
||||
onSortChange={handleSortChange}
|
||||
searchTerm={searchTerm}
|
||||
onSearchChange={setSearchTerm}
|
||||
sortOptions={[...sortOptions]}
|
||||
|
||||
@@ -21,6 +21,7 @@ function renderHeader(
|
||||
) {
|
||||
const props = {
|
||||
sortBy: 'Group' as string,
|
||||
sortDesc: false,
|
||||
onSortChange: vi.fn(),
|
||||
searchTerm: '',
|
||||
onSearchChange: vi.fn(),
|
||||
|
||||
@@ -13,6 +13,7 @@ export type { SortOption };
|
||||
|
||||
interface Props<TSortKey extends string> {
|
||||
sortBy: TSortKey;
|
||||
sortDesc: boolean;
|
||||
onSortChange: (key: TSortKey) => void;
|
||||
searchTerm: string;
|
||||
onSearchChange: (term: string) => void;
|
||||
@@ -27,6 +28,7 @@ interface Props<TSortKey extends string> {
|
||||
|
||||
export function GroupSortTableHeader<TSortKey extends string>({
|
||||
sortBy,
|
||||
sortDesc,
|
||||
onSortChange,
|
||||
searchTerm,
|
||||
onSearchChange,
|
||||
@@ -43,12 +45,12 @@ export function GroupSortTableHeader<TSortKey extends string>({
|
||||
<div
|
||||
className={clsx(
|
||||
'flex flex-wrap items-center justify-between gap-3 px-5 py-3',
|
||||
'bg-gray-2 th-highcontrast:bg-black th-dark:bg-gray-iron-10',
|
||||
'border-0 border-b border-solid border-gray-5 th-dark:border-gray-9'
|
||||
'bg-gray-2 th-highcontrast:bg-black th-dark:bg-gray-iron-10'
|
||||
)}
|
||||
>
|
||||
<SortByGroup
|
||||
sortBy={sortBy}
|
||||
sortDesc={sortDesc}
|
||||
onSortChange={onSortChange}
|
||||
sortOptions={sortOptions}
|
||||
groupFilter={groupFilter}
|
||||
|
||||
@@ -139,6 +139,7 @@ function renderComponent({
|
||||
withTestRouter(() => (
|
||||
<SortByGroup
|
||||
sortBy={sortBy}
|
||||
sortDesc={false}
|
||||
onSortChange={onSortChange}
|
||||
sortOptions={sortOptions}
|
||||
groupFilter={groupFilter}
|
||||
@@ -162,13 +163,13 @@ describe('SortByGroup', () => {
|
||||
expect(onSortChange).toHaveBeenCalledExactlyOnceWith('Name');
|
||||
});
|
||||
|
||||
test('clicking the already-active button does not call onSortChange', async () => {
|
||||
test('clicking the already-active non-grouped button calls onSortChange to toggle sort order', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { onSortChange } = renderComponent({ sortBy: 'Name' });
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /^Name$/i }));
|
||||
await user.click(screen.getByRole('button', { name: /^Name Asc/i }));
|
||||
|
||||
expect(onSortChange).not.toHaveBeenCalled();
|
||||
expect(onSortChange).toHaveBeenCalledExactlyOnceWith('Name');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -6,10 +6,13 @@ export interface SortOption<TSortKey extends string = string> {
|
||||
key: TSortKey;
|
||||
label: string;
|
||||
grouped?: boolean;
|
||||
descendingLabel?: string;
|
||||
ascendingLabel?: string;
|
||||
}
|
||||
|
||||
export interface SortByGroupProps<TSortKey extends string> {
|
||||
sortBy: TSortKey;
|
||||
sortDesc: boolean;
|
||||
onSortChange: (key: TSortKey) => void;
|
||||
sortOptions: SortOption<TSortKey>[];
|
||||
groupFilter: string | null;
|
||||
@@ -20,6 +23,7 @@ export interface SortByGroupProps<TSortKey extends string> {
|
||||
|
||||
export function SortByGroup<TSortKey extends string>({
|
||||
sortBy,
|
||||
sortDesc,
|
||||
onSortChange,
|
||||
sortOptions,
|
||||
groupFilter,
|
||||
@@ -49,6 +53,7 @@ export function SortByGroup<TSortKey extends string>({
|
||||
key={option.key}
|
||||
option={option}
|
||||
isActive={sortBy === option.key}
|
||||
sortDesc={sortDesc}
|
||||
isFirst={index === 0}
|
||||
isLast={index === sortOptions.length - 1}
|
||||
onSortChange={onSortChange}
|
||||
@@ -82,6 +87,7 @@ const inactiveBtn = clsx(
|
||||
interface SortOptionItemProps<TSortKey extends string> {
|
||||
option: SortOption<TSortKey>;
|
||||
isActive: boolean;
|
||||
sortDesc: boolean;
|
||||
isFirst: boolean;
|
||||
isLast: boolean;
|
||||
onSortChange: (key: TSortKey) => void;
|
||||
@@ -94,6 +100,7 @@ interface SortOptionItemProps<TSortKey extends string> {
|
||||
function SortOptionItem<TSortKey extends string>({
|
||||
option,
|
||||
isActive,
|
||||
sortDesc,
|
||||
isFirst,
|
||||
isLast,
|
||||
onSortChange,
|
||||
@@ -132,19 +139,30 @@ function SortOptionItem<TSortKey extends string>({
|
||||
);
|
||||
}
|
||||
|
||||
const badge = isActive
|
||||
? sortDesc
|
||||
? option.descendingLabel || 'Desc'
|
||||
: option.ascendingLabel || 'Asc'
|
||||
: null;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={className}
|
||||
onClick={() => {
|
||||
onSortChange(option.key);
|
||||
if (!isActive) {
|
||||
onSortChange(option.key);
|
||||
onGroupFilterChange(null);
|
||||
}
|
||||
}}
|
||||
data-cy={`${dataCy}-sort-by-${option.key.toLowerCase()}-button`}
|
||||
>
|
||||
{option.label}
|
||||
{badge && (
|
||||
<span className="py-0.2 ml-1 rounded-md bg-blue-7 px-1 text-[10px] font-normal text-white">
|
||||
{badge}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -58,8 +58,11 @@ export function SortableList<T>({
|
||||
<SortableListCard>
|
||||
<GroupSortTableHeader
|
||||
sortBy={activeSortKey}
|
||||
sortDesc={tableState.sortBy?.desc ?? false}
|
||||
onSortChange={(key) => {
|
||||
tableState.setSortBy(key, false);
|
||||
const newDesc =
|
||||
tableState.sortBy?.id === key ? !tableState.sortBy.desc : false;
|
||||
tableState.setSortBy(key, newDesc);
|
||||
}}
|
||||
searchTerm={tableState.search}
|
||||
onSearchChange={(value) => {
|
||||
|
||||
@@ -521,7 +521,7 @@ test('URL param groupBy=platform&filter=docker activates Docker platform filter
|
||||
});
|
||||
});
|
||||
|
||||
test('URL param filter without groupBy is ignored (default Group sort used)', async () => {
|
||||
test('URL param filter without groupBy is ignored (default Age sort used)', async () => {
|
||||
let capturedParams: URLSearchParams | null = null;
|
||||
|
||||
// filter present but no groupBy — the component should bail out early
|
||||
@@ -540,12 +540,12 @@ test('URL param filter without groupBy is ignored (default Group sort used)', as
|
||||
]
|
||||
);
|
||||
|
||||
// status[] should not be sent; sort defaults to Group
|
||||
// status[] should not be sent; sort defaults to Age
|
||||
await waitFor(() => {
|
||||
expect(capturedParams).not.toBeNull();
|
||||
});
|
||||
expect(capturedParams!.getAll('status[]')).toHaveLength(0);
|
||||
expect(capturedParams!.get('sort')).toBe('Group');
|
||||
expect(capturedParams!.get('sort')).toBe('Age');
|
||||
});
|
||||
|
||||
test('selecting a sort/filter updates URL via stateService.go', async () => {
|
||||
|
||||
@@ -33,6 +33,7 @@ import { KubeconfigButton } from '@/react/portainer/HomeView/EnvironmentList/Kub
|
||||
import { EnvironmentCard } from '@/react/portainer/HomeView/EnvironmentList/EnvironmentItem/EnvironmentCard';
|
||||
|
||||
import { GroupSortTable } from '@@/GroupSortTable/GroupSortTable';
|
||||
import { SortOption } from '@@/GroupSortTable/SortByGroup';
|
||||
import { GroupSortTableGroupRow } from '@@/GroupSortTable/GroupSortTableGroupRow';
|
||||
import { useGroupSortTableState } from '@@/GroupSortTable/useGroupSortTableState';
|
||||
|
||||
@@ -57,6 +58,7 @@ const HEALTH_SORT_ORDER: Record<string, number> = {
|
||||
};
|
||||
|
||||
const columns: ColumnDef<EnvironmentRow>[] = [
|
||||
{ id: 'Age', accessorKey: 'age' },
|
||||
{ id: 'Platform', accessorKey: 'platformName' },
|
||||
{ id: 'Group', accessorKey: 'groupName' },
|
||||
{
|
||||
@@ -69,7 +71,13 @@ const columns: ColumnDef<EnvironmentRow>[] = [
|
||||
{ id: 'Name', accessorKey: 'Name' },
|
||||
];
|
||||
|
||||
const SORT_OPTIONS = [
|
||||
const SORT_OPTIONS: SortOption[] = [
|
||||
{
|
||||
key: 'Age',
|
||||
label: 'Age',
|
||||
descendingLabel: 'Newest',
|
||||
ascendingLabel: 'Oldest',
|
||||
},
|
||||
{ key: 'Group', label: 'Group', grouped: true },
|
||||
{ key: 'Platform', label: 'Platform', grouped: true },
|
||||
{ key: 'Health', label: 'Health', grouped: true },
|
||||
@@ -129,7 +137,7 @@ export function EnvironmentList({
|
||||
|
||||
const tableState = useGroupSortTableState(
|
||||
storageKey,
|
||||
'Group',
|
||||
'Age',
|
||||
DEFAULT_PAGE_LIMIT
|
||||
);
|
||||
|
||||
@@ -188,6 +196,8 @@ export function EnvironmentList({
|
||||
const environmentRows = useMemo<EnvironmentRow[]>(() => {
|
||||
const rows = environments.map((env) => ({
|
||||
...env,
|
||||
// Use Environment ID to sort age as lower ID = older environment
|
||||
age: env.Id,
|
||||
groupName: groupNameById.get(env.GroupId) ?? 'Unassigned',
|
||||
platformName:
|
||||
PlatformType[getPlatformType(env.Type, env.ContainerEngine)],
|
||||
@@ -359,6 +369,8 @@ export function EnvironmentList({
|
||||
} else if (sortId === 'Health' && healthDetails[groupKey]) {
|
||||
icon = getHealthIcon(healthDetails[groupKey].type, 'md');
|
||||
description = healthDetails[groupKey].description;
|
||||
} else if (sortId === 'Age') {
|
||||
return null;
|
||||
} else {
|
||||
icon = getGroupIcon('md');
|
||||
}
|
||||
@@ -377,6 +389,7 @@ export function EnvironmentList({
|
||||
}
|
||||
|
||||
type EnvironmentRow = Environment & {
|
||||
age: number;
|
||||
groupName: string;
|
||||
platformName: string;
|
||||
healthLabel: string;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"author": "Portainer.io",
|
||||
"name": "@portainer/ce",
|
||||
"homepage": "http://portainer.io",
|
||||
"version": "2.41.0",
|
||||
"version": "2.41.1",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git@github.com:portainer/portainer.git"
|
||||
|
||||
@@ -100,7 +100,7 @@ func TestValidateHelmRepositoryURL(t *testing.T) {
|
||||
func Test_ValidateSeedsCacheAndSearchUsesCache(t *testing.T) {
|
||||
const indexYAML = "apiVersion: v1\nentries: {}\ngenerated: \"2020-01-01T00:00:00Z\"\n"
|
||||
|
||||
var requestCount int32
|
||||
var requestCount atomic.Int32
|
||||
var fail bool
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -109,7 +109,7 @@ func Test_ValidateSeedsCacheAndSearchUsesCache(t *testing.T) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
atomic.AddInt32(&requestCount, 1)
|
||||
requestCount.Add(1)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(indexYAML))
|
||||
return
|
||||
@@ -128,5 +128,5 @@ func Test_ValidateSeedsCacheAndSearchUsesCache(t *testing.T) {
|
||||
// validate cache is used
|
||||
err := ValidateHelmRepositoryURL(srv.URL, nil)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int32(1), atomic.LoadInt32(&requestCount))
|
||||
require.Equal(t, int32(1), requestCount.Load())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user