Compare commits

..

1 Commits

Author SHA1 Message Date
portainer-bot[bot]
0b8d0db0be fix(api/workflows): kubernetes UAC (#2507)
Co-authored-by: LP B <xAt0mZ@users.noreply.github.com>
2026-04-29 22:48:43 +00:00
14 changed files with 25 additions and 91 deletions

View File

@@ -615,7 +615,7 @@
"RequiredPasswordLength": 12
},
"KubeconfigExpiry": "0",
"KubectlShellImage": "portainer/kubectl-shell:2.41.1",
"KubectlShellImage": "portainer/kubectl-shell:2.41.0",
"LDAPSettings": {
"AnonymousMode": true,
"AutoCreateUsers": true,
@@ -947,7 +947,7 @@
}
],
"version": {
"VERSION": "{\"SchemaVersion\":\"2.41.1\",\"MigratorCount\":0,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
"VERSION": "{\"SchemaVersion\":\"2.41.0\",\"MigratorCount\":0,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
},
"webhooks": null
}

View File

@@ -81,7 +81,7 @@ type Handler struct {
}
// @title PortainerCE API
// @version 2.41.1
// @version 2.41.0
// @description.markdown api-description.md
// @termsOfService

View File

@@ -1945,7 +1945,7 @@ type (
const (
// APIVersion is the version number of the Portainer API
APIVersion = "2.41.1"
APIVersion = "2.41.0"
// 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

View File

@@ -117,7 +117,6 @@ 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) => {
@@ -186,15 +185,11 @@ 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">
{header}
{renderGroupHeader(groupKey, groupCountByKey[groupKey] ?? 0)}
</td>
</tr>
{renderRow(row)}
@@ -204,9 +199,7 @@ export function GroupSortTable<TItem extends object>({
function handleSortChange(key: string) {
tableState.setPage(1);
const newDesc =
tableState.sortBy?.id === key ? !tableState.sortBy.desc : false;
tableState.setSortBy(key, newDesc);
tableState.setSortBy(key, false);
}
function handleGroupFilterChange(value: string | null) {

View File

@@ -29,20 +29,13 @@ 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}
sortDesc={sortDesc}
onSortChange={handleSortChange}
onSortChange={setSortBy}
searchTerm={searchTerm}
onSearchChange={setSearchTerm}
sortOptions={[...sortOptions]}
@@ -57,19 +50,12 @@ 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}
sortDesc={sortDesc}
onSortChange={handleSortChange}
onSortChange={setSortBy}
searchTerm={searchTerm}
onSearchChange={setSearchTerm}
sortOptions={[...sortOptions]}
@@ -84,20 +70,13 @@ 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}
sortDesc={sortDesc}
onSortChange={handleSortChange}
onSortChange={setSortBy}
searchTerm={searchTerm}
onSearchChange={setSearchTerm}
sortOptions={[...sortOptions]}

View File

@@ -21,7 +21,6 @@ function renderHeader(
) {
const props = {
sortBy: 'Group' as string,
sortDesc: false,
onSortChange: vi.fn(),
searchTerm: '',
onSearchChange: vi.fn(),

View File

@@ -13,7 +13,6 @@ export type { SortOption };
interface Props<TSortKey extends string> {
sortBy: TSortKey;
sortDesc: boolean;
onSortChange: (key: TSortKey) => void;
searchTerm: string;
onSearchChange: (term: string) => void;
@@ -28,7 +27,6 @@ interface Props<TSortKey extends string> {
export function GroupSortTableHeader<TSortKey extends string>({
sortBy,
sortDesc,
onSortChange,
searchTerm,
onSearchChange,
@@ -45,12 +43,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'
'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'
)}
>
<SortByGroup
sortBy={sortBy}
sortDesc={sortDesc}
onSortChange={onSortChange}
sortOptions={sortOptions}
groupFilter={groupFilter}

View File

@@ -139,7 +139,6 @@ function renderComponent({
withTestRouter(() => (
<SortByGroup
sortBy={sortBy}
sortDesc={false}
onSortChange={onSortChange}
sortOptions={sortOptions}
groupFilter={groupFilter}
@@ -163,13 +162,13 @@ describe('SortByGroup', () => {
expect(onSortChange).toHaveBeenCalledExactlyOnceWith('Name');
});
test('clicking the already-active non-grouped button calls onSortChange to toggle sort order', async () => {
test('clicking the already-active button does not call onSortChange', async () => {
const user = userEvent.setup();
const { onSortChange } = renderComponent({ sortBy: 'Name' });
await user.click(screen.getByRole('button', { name: /^Name Asc/i }));
await user.click(screen.getByRole('button', { name: /^Name$/i }));
expect(onSortChange).toHaveBeenCalledExactlyOnceWith('Name');
expect(onSortChange).not.toHaveBeenCalled();
});
});

View File

@@ -6,13 +6,10 @@ 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;
@@ -23,7 +20,6 @@ export interface SortByGroupProps<TSortKey extends string> {
export function SortByGroup<TSortKey extends string>({
sortBy,
sortDesc,
onSortChange,
sortOptions,
groupFilter,
@@ -53,7 +49,6 @@ 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}
@@ -87,7 +82,6 @@ const inactiveBtn = clsx(
interface SortOptionItemProps<TSortKey extends string> {
option: SortOption<TSortKey>;
isActive: boolean;
sortDesc: boolean;
isFirst: boolean;
isLast: boolean;
onSortChange: (key: TSortKey) => void;
@@ -100,7 +94,6 @@ interface SortOptionItemProps<TSortKey extends string> {
function SortOptionItem<TSortKey extends string>({
option,
isActive,
sortDesc,
isFirst,
isLast,
onSortChange,
@@ -139,30 +132,19 @@ 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>
);
}

View File

@@ -58,11 +58,8 @@ export function SortableList<T>({
<SortableListCard>
<GroupSortTableHeader
sortBy={activeSortKey}
sortDesc={tableState.sortBy?.desc ?? false}
onSortChange={(key) => {
const newDesc =
tableState.sortBy?.id === key ? !tableState.sortBy.desc : false;
tableState.setSortBy(key, newDesc);
tableState.setSortBy(key, false);
}}
searchTerm={tableState.search}
onSearchChange={(value) => {

View File

@@ -521,7 +521,7 @@ test('URL param groupBy=platform&filter=docker activates Docker platform filter
});
});
test('URL param filter without groupBy is ignored (default Age sort used)', async () => {
test('URL param filter without groupBy is ignored (default Group 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 Age sort used)', asyn
]
);
// status[] should not be sent; sort defaults to Age
// status[] should not be sent; sort defaults to Group
await waitFor(() => {
expect(capturedParams).not.toBeNull();
});
expect(capturedParams!.getAll('status[]')).toHaveLength(0);
expect(capturedParams!.get('sort')).toBe('Age');
expect(capturedParams!.get('sort')).toBe('Group');
});
test('selecting a sort/filter updates URL via stateService.go', async () => {

View File

@@ -33,7 +33,6 @@ 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';
@@ -58,7 +57,6 @@ const HEALTH_SORT_ORDER: Record<string, number> = {
};
const columns: ColumnDef<EnvironmentRow>[] = [
{ id: 'Age', accessorKey: 'age' },
{ id: 'Platform', accessorKey: 'platformName' },
{ id: 'Group', accessorKey: 'groupName' },
{
@@ -71,13 +69,7 @@ const columns: ColumnDef<EnvironmentRow>[] = [
{ id: 'Name', accessorKey: 'Name' },
];
const SORT_OPTIONS: SortOption[] = [
{
key: 'Age',
label: 'Age',
descendingLabel: 'Newest',
ascendingLabel: 'Oldest',
},
const SORT_OPTIONS = [
{ key: 'Group', label: 'Group', grouped: true },
{ key: 'Platform', label: 'Platform', grouped: true },
{ key: 'Health', label: 'Health', grouped: true },
@@ -137,7 +129,7 @@ export function EnvironmentList({
const tableState = useGroupSortTableState(
storageKey,
'Age',
'Group',
DEFAULT_PAGE_LIMIT
);
@@ -196,8 +188,6 @@ 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)],
@@ -369,8 +359,6 @@ 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');
}
@@ -389,7 +377,6 @@ export function EnvironmentList({
}
type EnvironmentRow = Environment & {
age: number;
groupName: string;
platformName: string;
healthLabel: string;

View File

@@ -2,7 +2,7 @@
"author": "Portainer.io",
"name": "@portainer/ce",
"homepage": "http://portainer.io",
"version": "2.41.1",
"version": "2.41.0",
"repository": {
"type": "git",
"url": "git@github.com:portainer/portainer.git"

View File

@@ -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 atomic.Int32
var requestCount 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
}
requestCount.Add(1)
atomic.AddInt32(&requestCount, 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), requestCount.Load())
require.Equal(t, int32(1), atomic.LoadInt32(&requestCount))
}