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:
@@ -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);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -39,7 +39,3 @@
|
||||
.tooltip-heading {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.tooltip-beteaser {
|
||||
@apply text-blue-8 hover:text-blue-9;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -22,7 +22,6 @@ type Color =
|
||||
| 'link'
|
||||
| 'light'
|
||||
| 'dangerlight'
|
||||
| 'warninglight'
|
||||
| 'warning'
|
||||
| 'success'
|
||||
| 'blue'
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 />);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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 />
|
||||
|
||||
@@ -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 />);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -61,11 +61,8 @@ export const existingEnvironmentTypes: EnvironmentOption[] = [
|
||||
},
|
||||
];
|
||||
|
||||
export const newEnvironmentTypes: EnvironmentOption[] = [];
|
||||
|
||||
export const environmentTypes: EnvironmentOption[] = [
|
||||
...existingEnvironmentTypes,
|
||||
...newEnvironmentTypes,
|
||||
];
|
||||
|
||||
export const formTitles: Record<EnvironmentOptionValue, string> = {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
];
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export { ExperimentalFeatures } from './ExperimentalFeatures';
|
||||
@@ -5,5 +5,3 @@ export {
|
||||
useUpdateSettingsMutation,
|
||||
} from './useSettings';
|
||||
export { usePublicSettings } from './usePublicSettings';
|
||||
export { useExperimentalSettings } from './useExperimentalSettings';
|
||||
export { useUpdateExperimentalSettingsMutation } from './useExperimentalSettingsMutation';
|
||||
|
||||
@@ -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'
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user