refactor(ce): remove leftover dead BE code — gates, orphans, dead selectors/CSS (F1-F4)

F1: drop the two HomeView edition-gate panels + their files (License/BackupFailed).
F2: delete zero-importer orphans (edition mutation, HubspotForm, HomepageFilter,
    relations mutation, ActivityLogsView cluster, ExperimentalFeatures subtree).
F3: collapse single-option selectors (Backup settings, init restore, env types)
    and delete the option files they orphaned.
F4: remove dead BE-teaser CSS rules and the --BE-only variable.
Also drop the orphaned .btn-warninglight BE-teaser variant.
F5 (limitedToBE) intentionally left — it is still read by BoxSelectorAngular.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
claude code agent
2026-06-29 14:13:55 +03:00
parent b7df90905d
commit b1b09e5da0
34 changed files with 37 additions and 1108 deletions

View File

@@ -99,8 +99,6 @@
--orange-1: #e86925;
--BE-only: var(--ui-gray-6);
--text-log-viewer-color-json-grey: var(--text-log-viewer-color);
--text-log-viewer-color-json-magenta: var(--text-log-viewer-color);
--text-log-viewer-color-json-yellow: var(--text-log-viewer-color);
@@ -269,8 +267,6 @@
/* Dark Theme */
[theme='dark'] {
--BE-only: var(--ui-gray-6);
--text-log-viewer-color-json-grey: var(--text-log-viewer-color);
--text-log-viewer-color-json-magenta: var(--text-log-viewer-color);
--text-log-viewer-color-json-yellow: var(--text-log-viewer-color);
@@ -440,7 +436,6 @@
/* High Contrast Theme */
[theme='highcontrast'] {
--BE-only: var(--ui-gray-6);
--text-log-viewer-color-json-grey: var(--text-log-viewer-color);
--text-log-viewer-color-json-magenta: var(--text-log-viewer-color);
--text-log-viewer-color-json-yellow: var(--text-log-viewer-color);

View File

@@ -137,49 +137,45 @@
</div>
<!-- !note -->
<box-selector slim="true" options="restoreOptions" value="formValues.restoreFormType" on-change="(onChangeRestoreType)" radio-name="'restore-type'"></box-selector>
<div ng-if="formValues.restoreFormType === RESTORE_FORM_TYPES.FILE">
<!-- note -->
<div class="form-group">
<div class="col-sm-12">
<span class="small text-muted"> You can upload a backup file from your computer. </span>
</div>
<!-- note -->
<div class="form-group">
<div class="col-sm-12">
<span class="small text-muted"> You can upload a backup file from your computer. </span>
</div>
<!-- !note -->
<!-- select-file-input -->
<div class="form-group">
<div class="col-sm-12 vertical-center">
<button
class="btn btn-sm btn-primary"
ngf-select
accept=".gz,.encrypted"
ngf-accept="'application/x-tar,application/x-gzip'"
ng-model="formValues.BackupFile"
auto-focus
data-cy="init-selectBackupFileButton"
>Select file</button
>
<span class="space-left vertical-center">
{{ formValues.BackupFile.name }}
<pr-icon icon="'x-circle'" class-name="'icon-danger'" ng-if="!formValues.BackupFile"></pr-icon>
</span>
</div>
</div>
<!-- password-input -->
<div class="form-group">
<label for="password" class="col-sm-3 control-label text-left">
Password
<portainer-tooltip
message="'If the backup is password protected, provide the password in order to extract the backup file, otherwise this field can be left empty.'"
></portainer-tooltip>
</label>
<div class="col-sm-4">
<input type="password" class="form-control" ng-model="formValues.Password" id="password" data-cy="init-backupPasswordInput" />
</div>
</div>
<!-- !password-input -->
</div>
<!-- !note -->
<!-- select-file-input -->
<div class="form-group">
<div class="col-sm-12 vertical-center">
<button
class="btn btn-sm btn-primary"
ngf-select
accept=".gz,.encrypted"
ngf-accept="'application/x-tar,application/x-gzip'"
ng-model="formValues.BackupFile"
auto-focus
data-cy="init-selectBackupFileButton"
>Select file</button
>
<span class="space-left vertical-center">
{{ formValues.BackupFile.name }}
<pr-icon icon="'x-circle'" class-name="'icon-danger'" ng-if="!formValues.BackupFile"></pr-icon>
</span>
</div>
</div>
<!-- password-input -->
<div class="form-group">
<label for="password" class="col-sm-3 control-label text-left">
Password
<portainer-tooltip
message="'If the backup is password protected, provide the password in order to extract the backup file, otherwise this field can be left empty.'"
></portainer-tooltip>
</label>
<div class="col-sm-4">
<input type="password" class="form-control" ng-model="formValues.Password" id="password" data-cy="init-backupPasswordInput" />
</div>
</div>
<!-- !password-input -->
<!-- !select-file-input -->
<!-- note -->
<div class="form-group">

View File

@@ -1,5 +1,4 @@
import { getEnvironments } from '@/react/portainer/environments/environment.service';
import { restoreOptions } from '@/react/portainer/init/InitAdminView/restore-options';
const REDIRECT_REASON_TIMEOUT = 'AdminInitTimeout';
@@ -14,19 +13,15 @@ angular.module('portainer.app').controller('InitAdminController', [
'BackupService',
'StatusService',
function ($scope, $state, Notifications, Authentication, StateManager, SettingsService, UserService, BackupService, StatusService) {
$scope.restoreOptions = restoreOptions;
$scope.uploadBackup = uploadBackup;
$scope.logo = StateManager.getState().application.logo;
$scope.RESTORE_FORM_TYPES = { S3: 's3', FILE: 'file' };
$scope.formValues = {
Username: 'admin',
Password: '',
ConfirmPassword: '',
SetupToken: '',
restoreFormType: $scope.RESTORE_FORM_TYPES.FILE,
};
$scope.state = {
@@ -42,13 +37,6 @@ angular.module('portainer.app').controller('InitAdminController', [
$scope.state.showRestorePortainer = !$scope.state.showRestorePortainer;
};
$scope.onChangeRestoreType = onChangeRestoreType;
function onChangeRestoreType(value) {
$scope.$evalAsync(() => {
$scope.formValues.restoreFormType = value;
});
}
$scope.createAdminUser = function () {
var username = $scope.formValues.Username;
var password = $scope.formValues.Password;

View File

@@ -37,13 +37,3 @@
.content {
padding-left: 20px;
}
/* used for BE teaser */
.box-selector-item.limited.business label,
.box-selector-item.limited.business input:checked + label {
@apply border-gray-6 bg-gray-6 bg-opacity-10;
@apply th-dark:border-gray-6 th-dark:bg-gray-6 th-dark:bg-opacity-10;
@apply th-highcontrast:border-gray-6 th-highcontrast:bg-gray-6 th-highcontrast:bg-opacity-10;
filter: none;
}

View File

@@ -1,112 +0,0 @@
import { ReactNode, useRef } from 'react';
import { useQuery } from '@tanstack/react-query';
let globalId = 0;
interface Props {
portalId: HubSpotCreateFormOptions['portalId'];
formId: HubSpotCreateFormOptions['formId'];
region: HubSpotCreateFormOptions['region'];
onSubmitted: () => void;
loading?: ReactNode;
}
export function HubspotForm({
loading,
portalId,
region,
formId,
onSubmitted,
}: Props) {
const elRef = useRef<HTMLDivElement>(null);
const id = useRef(`reactHubspotForm${globalId++}`);
const { isLoading } = useHubspotForm({
elId: id.current,
formId,
portalId,
region,
onSubmitted,
});
return (
<>
<div
ref={elRef}
id={id.current}
style={{ display: isLoading ? 'none' : 'block' }}
/>
{isLoading && loading}
</>
);
}
function useHubspotForm({
elId,
formId,
portalId,
region,
onSubmitted,
}: {
elId: string;
portalId: HubSpotCreateFormOptions['portalId'];
formId: HubSpotCreateFormOptions['formId'];
region: HubSpotCreateFormOptions['region'];
onSubmitted: () => void;
}) {
return useQuery(
['hubspot', { elId, formId, portalId, region }],
async () => {
await loadHubspot();
await createForm(`#${elId}`, {
formId,
portalId,
region,
onFormSubmitted: onSubmitted,
});
},
{
refetchOnWindowFocus: false,
}
);
}
async function loadHubspot() {
return new Promise<void>((resolve) => {
if (window.hbspt) {
resolve();
return;
}
const script = document.createElement(`script`);
script.defer = true;
script.onload = () => {
resolve();
};
script.src = `//js.hsforms.net/forms/v2.js`;
document.head.appendChild(script);
});
}
async function createForm(
target: string,
options: Omit<HubSpotCreateFormOptions, 'target'>
) {
return new Promise<void>((resolve) => {
if (!window.hbspt) {
throw new Error('hbspt object is missing');
}
window.hbspt.forms.create({
...options,
target,
onFormReady(...rest) {
options.onFormReady?.(...rest);
resolve();
},
});
});
}

View File

@@ -39,7 +39,3 @@
.tooltip-heading {
font-weight: 500;
}
.tooltip-beteaser {
@apply text-blue-8 hover:text-blue-9;
}

View File

@@ -17,13 +17,6 @@
color: inherit;
}
/* used for BE teaser. Dark theme specs defined by EE-5621 */
.btn-warninglight {
@apply border-warning-5 bg-warning-2 text-black;
@apply th-dark:border-blue-8 th-dark:bg-blue-8 th-dark:bg-opacity-10 th-dark:text-white;
@apply th-highcontrast:bg-warning-5 th-highcontrast:bg-opacity-10 th-highcontrast:text-white;
}
.btn-none:active {
outline: none;
background-color: transparent;

View File

@@ -22,7 +22,6 @@ type Color =
| 'link'
| 'light'
| 'dangerlight'
| 'warninglight'
| 'warning'
| 'success'
| 'blue'

View File

@@ -54,22 +54,6 @@
cursor: not-allowed;
}
.switch.limited {
touch-action: none;
}
.switch.limited i {
opacity: 1;
cursor: not-allowed;
}
.switch.business i {
background-color: var(--BE-only);
box-shadow:
inset 0 0 1px rgb(0 0 0 / 50%),
inset 0 0 40px var(--BE-only);
}
.switch input[type='checkbox']:disabled + .slider {
cursor: not-allowed;
}

View File

@@ -1,50 +0,0 @@
import { http, HttpResponse } from 'msw';
import { render } from '@testing-library/react';
import { server } from '@/setup-tests/server';
import { isoDate } from '@/portainer/filters/filters';
import { withTestRouter } from '@/react/test-utils/withRouter';
import { withTestQueryProvider } from '@/react/test-utils/withTestQuery';
import { BackupFailedPanel } from './BackupFailedPanel';
test('when backup failed, should show message', async () => {
const timestamp = 1500;
const { findByText } = renderComponent({ failed: true, timestamp });
await expect(
findByText(
`The latest automated backup has failed at ${isoDate(
timestamp
)}. For details please see the log files and have a look at the`,
{ exact: false }
)
).resolves.toBeVisible();
});
test("when user is using less nodes then allowed he shouldn't see message", async () => {
const { findByText } = renderComponent({ failed: false });
await expect(
findByText('The latest automated backup has failed at', { exact: false })
).rejects.toBeTruthy();
});
function renderComponent({
failed,
timestamp,
}: {
failed: boolean;
timestamp?: number;
}) {
server.use(
http.get('/api/backup/s3/status', () =>
HttpResponse.json({ Failed: failed, TimestampUTC: timestamp })
)
);
const Wrapped = withTestQueryProvider(withTestRouter(BackupFailedPanel));
return render(<Wrapped />);
}

View File

@@ -1,49 +0,0 @@
import { useQuery } from '@tanstack/react-query';
import { error as notifyError } from '@/portainer/services/notifications';
import { getBackupStatus } from '@/portainer/services/api/backup.service';
import { isoDate } from '@/portainer/filters/filters';
import { InformationPanel } from '@@/InformationPanel';
import { TextTip } from '@@/Tip/TextTip';
import { Link } from '@@/Link';
export function BackupFailedPanel() {
const { status, isLoading } = useBackupStatus();
if (isLoading || !status || !status.Failed) {
return null;
}
return (
<div className="row">
<div className="col-sm-12">
<InformationPanel title="Information">
<TextTip>
The latest automated backup has failed at{' '}
{isoDate(status.TimestampUTC)}. For details please see the log files
and have a look at the{' '}
<Link to="portainer.settings" data-cy="backup-failed-settings-link">
settings
</Link>{' '}
to verify the backup configuration.
</TextTip>
</InformationPanel>
</div>
</div>
);
}
function useBackupStatus() {
const { data, isLoading } = useQuery(
['backup', 'status'],
() => getBackupStatus(),
{
onError(error) {
notifyError('Failure', error as Error, 'Failed to get license info');
},
}
);
return { status: data, isLoading };
}

View File

@@ -1,69 +0,0 @@
import { components, OptionProps } from 'react-select';
import { useLocalStorage } from '@/react/hooks/useLocalStorage';
import {
type Option as OptionType,
PortainerSelect,
} from '@@/form-components/PortainerSelect';
interface Props<TValue = number> {
filterOptions?: OptionType<TValue>[];
onChange: (value: TValue[]) => void;
placeHolder: string;
value: TValue[];
}
function Option<TValue = number>(props: OptionProps<OptionType<TValue>, true>) {
const { isSelected, label } = props;
return (
<div>
<components.Option
// eslint-disable-next-line react/jsx-props-no-spreading
{...props}
>
<div className="flex items-center gap-2">
<input
type="checkbox"
checked={isSelected}
onChange={() => null}
data-cy={`homepage-filter-option-${label}`}
/>
<label className="whitespace-nowrap">{label}</label>
</div>
</components.Option>
</div>
);
}
export function HomepageFilter<TValue = number>({
filterOptions = [],
onChange,
placeHolder,
value,
}: Props<TValue>) {
return (
<PortainerSelect<TValue>
placeholder={placeHolder}
options={filterOptions}
value={value}
isMulti
components={{ Option }}
onChange={(option) => onChange([...option])}
bindToBody
data-cy="homepage-filter"
/>
);
}
export function useHomePageFilter<T>(
key: string,
defaultValue: T
): [T, (value: T) => void] {
const filterKey = keyBuilder(key);
return useLocalStorage(filterKey, defaultValue, sessionStorage);
}
function keyBuilder(key: string) {
return `datatable_home_filter_type_${key}`;
}

View File

@@ -14,8 +14,6 @@ import { buildConfirmButton } from '@@/modals/utils';
import { EnvironmentList } from './EnvironmentList';
import { EdgeLoadingSpinner } from './EdgeLoadingSpinner';
import { MotdPanel } from './MotdPanel';
import { LicenseNodePanel } from './LicenseNodePanel';
import { BackupFailedPanel } from './BackupFailedPanel';
import { EnvironmentHeader } from './EnvironmentHeader/EnvironmentHeader';
export function HomeView() {
@@ -65,12 +63,8 @@ export function HomeView() {
breadcrumbs={[{ label: 'Environments' }]}
/>
{process.env.PORTAINER_EDITION !== 'CE' && <LicenseNodePanel />}
<MotdPanel />
{process.env.PORTAINER_EDITION !== 'CE' && <BackupFailedPanel />}
{connectingToEdgeEndpoint ? (
<div className="mb-5 flex flex-1 flex-col items-center justify-center">
<EdgeLoadingSpinner />

View File

@@ -1,53 +0,0 @@
import { http, HttpResponse } from 'msw';
import { render } from '@testing-library/react';
import { server } from '@/setup-tests/server';
import { withTestQueryProvider } from '@/react/test-utils/withTestQuery';
import { LicenseType } from '../licenses/types';
import { LicenseNodePanel } from './LicenseNodePanel';
test('when user is using more nodes then allowed he should see message', async () => {
const allowed = 2;
const used = 5;
server.use(
http.get('/api/licenses/info', () =>
HttpResponse.json({ nodes: allowed, type: LicenseType.Subscription })
),
http.get('/api/system/nodes', () => HttpResponse.json({ nodes: used }))
);
const { findByText } = renderComponent();
await expect(
findByText(
/The number of nodes for your license has been exceeded. Please contact your administrator./
)
).resolves.toBeVisible();
});
test("when user is using less nodes then allowed he shouldn't see message", async () => {
const allowed = 5;
const used = 2;
server.use(
http.get('/api/licenses/info', () =>
HttpResponse.json({ nodes: allowed, type: LicenseType.Subscription })
),
http.get('/api/system/nodes', () => HttpResponse.json({ nodes: used }))
);
const { findByText } = renderComponent();
await expect(
findByText(
/The number of nodes for your license has been exceeded. Please contact your administrator./
)
).rejects.toBeTruthy();
});
function renderComponent() {
const Wrapped = withTestQueryProvider(LicenseNodePanel);
return render(<Wrapped />);
}

View File

@@ -1,39 +0,0 @@
import { TextTip } from '@@/Tip/TextTip';
import { InformationPanel } from '@@/InformationPanel';
import { useNodesCount } from '../system/useNodesCount';
import { useLicenseInfo } from '../licenses/use-license.service';
import { LicenseType } from '../licenses/types';
export function LicenseNodePanel() {
const nodesValid = useNodesValid();
if (nodesValid) {
return null;
}
return (
<InformationPanel title="License node allowance exceeded">
<TextTip>
The number of nodes for your license has been exceeded. Please contact
your administrator.
</TextTip>
</InformationPanel>
);
}
function useNodesValid() {
const { isLoading: isLoadingNodes, data: nodesCount = 0 } = useNodesCount();
const { isLoading: isLoadingLicense, info } = useLicenseInfo();
if (
isLoadingLicense ||
isLoadingNodes ||
!info ||
info.type === LicenseType.Trial
) {
return true;
}
return nodesCount <= info.nodes;
}

View File

@@ -1,51 +0,0 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import axios, { parseAxiosError } from '@/portainer/services/axios/axios';
import {
mutationOptions,
withError,
withInvalidate,
} from '@/react-tools/react-query';
import { EdgeGroup } from '@/react/edge/edge-groups/types';
import { TagId } from '@/portainer/tags/types';
import { queryKeys as edgeGroupQueryKeys } from '@/react/edge/edge-groups/queries/query-keys';
import { queryKeys as groupQueryKeys } from '@/react/portainer/environments/environment-groups/queries/query-keys';
import { tagKeys } from '@/portainer/tags/queries';
import { EnvironmentId, EnvironmentGroupId } from '../types';
import { buildUrl } from '../environment.service/utils';
import { environmentQueryKeys } from './query-keys';
export function useUpdateEnvironmentsRelationsMutation() {
const queryClient = useQueryClient();
return useMutation(
updateEnvironmentRelations,
mutationOptions(
withInvalidate(queryClient, [
environmentQueryKeys.base(),
edgeGroupQueryKeys.base(),
groupQueryKeys.base(),
tagKeys.all,
]),
withError('Unable to update environment relations')
)
);
}
export interface EnvironmentRelationsPayload {
edgeGroups: Array<EdgeGroup['Id']>;
group: EnvironmentGroupId;
tags: Array<TagId>;
}
export async function updateEnvironmentRelations(
relations: Record<EnvironmentId, EnvironmentRelationsPayload>
) {
try {
await axios.put(buildUrl(undefined, 'relations'), { relations });
} catch (e) {
throw parseAxiosError(e as Error, 'Unable to update environment relations');
}
}

View File

@@ -11,7 +11,6 @@ import { EnvironmentSelector } from './EnvironmentSelector';
import {
EnvironmentOptionValue,
existingEnvironmentTypes,
newEnvironmentTypes,
} from './environment-types';
export function EnvironmentTypeSelectView() {
@@ -45,16 +44,6 @@ export function EnvironmentTypeSelectView() {
onChange={setTypes}
options={existingEnvironmentTypes}
/>
<p className="control-label !mb-2">Set up new environments</p>
<EnvironmentSelector
value={types}
onChange={setTypes}
options={newEnvironmentTypes}
hiddenSpacingCount={
existingEnvironmentTypes.length -
newEnvironmentTypes.length
}
/>
</FormSection>
</div>
<Button

View File

@@ -61,11 +61,8 @@ export const existingEnvironmentTypes: EnvironmentOption[] = [
},
];
export const newEnvironmentTypes: EnvironmentOption[] = [];
export const environmentTypes: EnvironmentOption[] = [
...existingEnvironmentTypes,
...newEnvironmentTypes,
];
export const formTitles: Record<EnvironmentOptionValue, string> = {

View File

@@ -25,16 +25,6 @@
box-shadow: 0 3px 10px -2px rgb(161 170 166 / 80%);
}
.teaser {
@apply border-2;
border-color: var(--BE-only);
color: var(--text-muted-color);
}
.teaser:hover {
box-shadow: 0 3px 10px -2px rgb(161 170 166 / 80%);
}
.active {
@apply border-blue-6 bg-blue-3;
@apply th-dark:border-blue-7 th-dark:bg-blue-10;

View File

@@ -1,13 +0,0 @@
import { Upload } from 'lucide-react';
import { BoxSelectorOption } from '@@/BoxSelector';
export const restoreOptions: ReadonlyArray<BoxSelectorOption<string>> = [
{
id: 'restore_file',
value: 'file',
icon: Upload,
iconType: 'badge',
label: 'Upload backup file',
},
] as const;

View File

@@ -1,116 +0,0 @@
import { createColumnHelper } from '@tanstack/react-table';
import { History, Search } from 'lucide-react';
import { isoDateFromTimestamp } from '@/portainer/filters/filters';
import { ExpandableDatatable } from '@@/datatables/ExpandableDatatable';
import { Button } from '@@/buttons';
import { JsonTree } from '@@/JsonTree';
import { ActivityLog } from './types';
import { getSortType } from './useActivityLogs';
const columnHelper = createColumnHelper<ActivityLog>();
const columns = [
columnHelper.accessor('timestamp', {
id: 'Timestamp',
header: 'Time',
cell: ({ getValue }) => {
const value = getValue();
return value ? isoDateFromTimestamp(value) : '';
},
}),
columnHelper.accessor('username', {
id: 'Username',
header: 'User',
}),
columnHelper.accessor('context', {
id: 'Context',
header: 'Environment',
}),
columnHelper.accessor('action', {
id: 'Action',
header: 'Action',
}),
columnHelper.accessor('payload', {
header: 'Payload',
enableSorting: false,
cell: ({ row, getValue }) =>
getValue() ? (
<Button
color="link"
onClick={() => row.toggleExpanded()}
icon={Search}
data-cy={`activity-logs-inspect_${row.index}`}
>
inspect
</Button>
) : null,
}),
];
export function ActivityLogsTable({
dataset,
currentPage,
keyword,
limit,
onChangeKeyword,
onChangeLimit,
onChangePage,
onChangeSort,
sort,
totalItems,
}: {
keyword: string;
onChangeKeyword(keyword: string): void;
sort: { id: string; desc: boolean } | undefined;
onChangeSort(sort: { id: string; desc: boolean } | undefined): void;
limit: number;
onChangeLimit(limit: number): void;
currentPage: number;
onChangePage(page: number): void;
totalItems: number;
dataset?: Array<ActivityLog>;
}) {
return (
<ExpandableDatatable<ActivityLog>
title="Activity logs"
titleIcon={History}
columns={columns}
dataset={dataset || []}
isLoading={!dataset}
settingsManager={{
pageSize: limit,
search: keyword,
setPageSize: onChangeLimit,
setSearch: onChangeKeyword,
setSortBy: (id, desc) =>
onChangeSort({ id: getSortType(id) || 'Timestamp', desc }),
sortBy: sort
? {
id: sort.id,
desc: sort.desc,
}
: undefined,
}}
page={currentPage}
onPageChange={onChangePage}
isServerSidePagination
totalCount={totalItems}
disableSelect
renderSubRow={(row) => <SubRow item={row.original} />}
data-cy="activity-logs-datatable"
/>
);
}
function SubRow({ item }: { item: ActivityLog }) {
return (
<tr>
<td colSpan={Number.MAX_SAFE_INTEGER}>
<JsonTree data={item.payload} />
</td>
</tr>
);
}

View File

@@ -1,43 +0,0 @@
import { DownloadIcon } from 'lucide-react';
import { Widget } from '@@/Widget';
import { TextTip } from '@@/Tip/TextTip';
import { Button } from '@@/buttons';
import { DateRangePicker } from '../components/DateRangePicker';
export function FilterBar({
value,
onChange,
onExport,
}: {
value: { start: Date; end: Date | null } | undefined;
onChange: (value?: { start: Date; end: Date | null }) => void;
onExport: () => void;
}) {
return (
<Widget>
<Widget.Body>
<form className="form-horizontal">
<DateRangePicker value={value} onChange={onChange} />
<TextTip color="blue">
Portainer user activity logs have a maximum retention of 7 days.
</TextTip>
<div className="mt-4">
<Button
color="primary"
icon={DownloadIcon}
onClick={onExport}
className="!ml-0"
data-cy="activity-logs-export-csv-button"
>
Export as CSV
</Button>
</div>
</form>
</Widget.Body>
</Widget>
);
}

View File

@@ -1,19 +0,0 @@
interface BaseActivityLog {
timestamp: number;
action: string;
context: string;
id: number;
username: string;
}
export interface ActivityLogResponse extends BaseActivityLog {
payload: string;
}
export interface ActivityLog extends BaseActivityLog {
payload: string | object;
}
export interface ActivityLogsResponse {
logs: Array<ActivityLogResponse>;
totalCount: number;
}

View File

@@ -1,85 +0,0 @@
import { useQuery } from '@tanstack/react-query';
import axios, { parseAxiosError } from '@/portainer/services/axios/axios';
import { ActivityLogResponse, ActivityLogsResponse } from './types';
export const sortKeys = ['Context', 'Action', 'Timestamp', 'Username'] as const;
export type SortKey = (typeof sortKeys)[number];
export function isSortKey(value?: string): value is SortKey {
return !!value && sortKeys.includes(value as SortKey);
}
export function getSortType(value?: string): SortKey | undefined {
return isSortKey(value) ? value : undefined;
}
export interface Query {
offset: number;
limit: number;
sortBy?: SortKey;
sortDesc?: boolean;
keyword: string;
after?: number;
before?: number;
}
export function useActivityLogs(query: Query) {
return useQuery({
queryKey: ['activityLogs', query] as const,
queryFn: () => fetchActivityLogs(query),
keepPreviousData: true,
select: (data) => ({
...data,
logs: decorateLogs(data.logs),
}),
});
}
async function fetchActivityLogs(query: Query): Promise<ActivityLogsResponse> {
try {
const { data } = await axios.get<ActivityLogsResponse>(
'/useractivity/logs',
{ params: query }
);
return data;
} catch (err) {
throw parseAxiosError(err, 'Failed loading user activity logs csv');
}
}
/**
* Decorates logs with the payload parsed from base64
*/
function decorateLogs(logs?: ActivityLogResponse[]) {
if (!logs || logs.length === 0) {
return [];
}
return logs.map((log) => ({
...log,
payload: parseBase64AsObject(log.payload),
}));
}
function parseBase64AsObject(value: string): string | object {
if (!value) {
return value;
}
try {
return JSON.parse(safeAtob(value));
} catch (err) {
return safeAtob(value);
}
}
function safeAtob(value: string) {
if (!value) {
return value;
}
try {
return window.atob(value);
} catch (err) {
// If the payload is not base64 encoded, return the original value
return value;
}
}

View File

@@ -1,32 +0,0 @@
import { useMutation } from '@tanstack/react-query';
import { saveAs } from 'file-saver';
import axios, { parseAxiosError } from '@/portainer/services/axios/axios';
import { Query } from './useActivityLogs';
export function useExportMutation() {
return useMutation({
mutationFn: exportActivityLogs,
});
}
async function exportActivityLogs(query: Omit<Query, 'limit'>) {
try {
const { data, headers } = await axios.get<Blob>('/useractivity/logs.csv', {
params: { ...query, limit: 0 },
responseType: 'blob',
headers: {
'Content-type': 'text/csv',
},
});
const contentDispositionHeader = headers['content-disposition'] || '';
const filename =
contentDispositionHeader.replace('attachment; filename=', '').trim() ||
'logs.csv';
saveAs(data, filename);
} catch (err) {
throw parseAxiosError(err, 'Failed loading user activity logs csv');
}
}

View File

@@ -1,16 +1,11 @@
import { Download } from 'lucide-react';
import { useState } from 'react';
import { Widget, WidgetBody, WidgetTitle } from '@@/Widget';
import { FormSection } from '@@/form-components/FormSection';
import { BoxSelector } from '@@/BoxSelector';
import { options } from './backup-options';
import { BackupFileForm } from './BackupFileForm';
export function BackupSettingsPanel() {
const [backupType, setBackupType] = useState(options[0].value);
return (
<Widget>
<WidgetTitle icon={Download} title="Back up Portainer" />
@@ -21,13 +16,6 @@ export function BackupSettingsPanel() {
This will back up your Portainer server configuration and does not
include containers.
</div>
<BoxSelector
slim
options={options}
value={backupType}
onChange={(v) => setBackupType(v)}
radioName="backup-type"
/>
<BackupFileForm />
</FormSection>

View File

@@ -1,17 +0,0 @@
import { DownloadCloud } from 'lucide-react';
import { BadgeIcon } from '@@/BadgeIcon';
export enum BackupFormType {
S3 = 's3',
File = 'file',
}
export const options = [
{
id: 'backup_file',
icon: <BadgeIcon icon={DownloadCloud} />,
label: 'Download backup file',
value: BackupFormType.File,
},
];

View File

@@ -1,28 +0,0 @@
import { FlaskConical } from 'lucide-react';
import { useExperimentalSettings } from '@/react/portainer/settings/queries';
import { Widget, WidgetBody, WidgetTitle } from '@@/Widget';
import { ExperimentalFeaturesSettingsForm } from './ExperimentalFeaturesForm';
export function ExperimentalFeatures() {
const settingsQuery = useExperimentalSettings();
if (!settingsQuery.data) {
return null;
}
const settings = settingsQuery.data;
return (
<Widget>
<WidgetTitle icon={FlaskConical} title="Experimental features" />
<WidgetBody>
<ExperimentalFeaturesSettingsForm
settings={settings.experimentalFeatures}
/>
</WidgetBody>
</Widget>
);
}

View File

@@ -1,87 +0,0 @@
import { Form, Formik } from 'formik';
import * as yup from 'yup';
import { useCallback } from 'react';
import { FlaskConical } from 'lucide-react';
import { notifySuccess } from '@/portainer/services/notifications';
import { ExperimentalFeatures } from '@/react/portainer/settings/types';
import { useUpdateExperimentalSettingsMutation } from '@/react/portainer/settings/queries';
import { LoadingButton } from '@@/buttons/LoadingButton';
import { TextTip } from '@@/Tip/TextTip';
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
interface FormValues {}
const validation = yup.object({});
interface Props {
settings: ExperimentalFeatures;
}
export function ExperimentalFeaturesSettingsForm({ settings }: Props) {
const initialValues: FormValues = settings;
const mutation = useUpdateExperimentalSettingsMutation();
const { mutate: updateSettings } = mutation;
const handleSubmit = useCallback(() => {
updateSettings(
{},
{
onSuccess() {
notifySuccess(
'Success',
'Successfully updated experimental features settings'
);
},
}
);
}, [updateSettings]);
return (
<Formik<FormValues>
initialValues={initialValues}
onSubmit={handleSubmit}
validationSchema={validation}
validateOnMount
enableReinitialize
>
{({ isValid, dirty }) => (
<Form className="form-horizontal">
<TextTip color="blue" icon={FlaskConical}>
Experimental features may be discontinued without notice.
</TextTip>
<br />
<br />
<div className="form-group col-sm-12 text-muted small">
In Portainer releases, we may introduce features that we&apos;re
experimenting with. These will be items in the early phases of
development with limited testing.
<br />
Our goal is to gain early user feedback, so we can refine, enhance
and ultimately make our features the best they can be. Disabling an
experimental feature will prevent access to it.
</div>
<div className="form-group">
<div className="col-sm-12">
<LoadingButton
loadingText="Saving settings..."
isLoading={mutation.isLoading}
disabled={!isValid || !dirty}
className="!ml-0"
data-cy="settings-experimentalButton"
>
Save experimental settings
</LoadingButton>
</div>
</div>
</Form>
)}
</Formik>
);
}

View File

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

View File

@@ -5,5 +5,3 @@ export {
useUpdateSettingsMutation,
} from './useSettings';
export { usePublicSettings } from './usePublicSettings';
export { useExperimentalSettings } from './useExperimentalSettings';
export { useUpdateExperimentalSettingsMutation } from './useExperimentalSettingsMutation';

View File

@@ -1,39 +0,0 @@
import { useQuery } from '@tanstack/react-query';
import axios, { parseAxiosError } from '@/portainer/services/axios/axios';
import { withError } from '@/react-tools/react-query';
import { ExperimentalFeatures } from '../types';
import { buildUrl } from '../settings.service';
import { queryKeys } from './queryKeys';
type ExperimentalFeaturesSettings = {
experimentalFeatures: ExperimentalFeatures;
};
export function useExperimentalSettings<T = ExperimentalFeaturesSettings>(
select?: (settings: ExperimentalFeaturesSettings) => T,
enabled = true
) {
return useQuery(queryKeys.experimental(), getExperimentalSettings, {
select,
enabled,
staleTime: 50,
...withError('Unable to retrieve experimental settings'),
});
}
async function getExperimentalSettings() {
try {
const { data } = await axios.get<ExperimentalFeaturesSettings>(
buildUrl('experimental')
);
return data;
} catch (e) {
throw parseAxiosError(
e as Error,
'Unable to retrieve experimental settings'
);
}
}

View File

@@ -1,35 +0,0 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import axios, { parseAxiosError } from '@/portainer/services/axios/axios';
import {
mutationOptions,
withError,
withInvalidate,
} from '@/react-tools/react-query';
import { ExperimentalFeatures } from '../types';
import { buildUrl } from '../settings.service';
import { queryKeys } from './queryKeys';
export function useUpdateExperimentalSettingsMutation() {
const queryClient = useQueryClient();
return useMutation(
updateExperimentalSettings,
mutationOptions(
withInvalidate(queryClient, [queryKeys.base()]),
withError('Unable to update experimental settings')
)
);
}
async function updateExperimentalSettings(
settings: Partial<ExperimentalFeatures>
) {
try {
await axios.put(buildUrl('experimental'), settings);
} catch (e) {
throw parseAxiosError(e as Error, 'Unable to update experimental settings');
}
}

View File

@@ -1,30 +0,0 @@
import { useMutation } from '@tanstack/react-query';
import axios, { parseAxiosError } from '@/portainer/services/axios/axios';
import { withError } from '@/react-tools/react-query';
import { isAxiosError } from '@/portainer/services/axios/utils/isAxiosError';
import { buildUrl } from './build-url';
export function useUpgradeEditionMutation() {
return useMutation(upgradeEdition, {
...withError('Unable to upgrade edition'),
});
}
async function upgradeEdition({ license }: { license: string }) {
try {
await axios.post(buildUrl('upgrade'), { license });
} catch (error) {
if (!isAxiosError(error)) {
throw error;
}
// if error is because the server disconnected, then everything went well
if (!error.response || !error.response.status) {
return;
}
throw parseAxiosError(error);
}
}