feat(cache): introduce cache option [EE-6293] (#10641)

pull/10658/head
Ali 2023-11-20 10:22:48 +13:00 committed by GitHub
parent fffc7b364e
commit 2c032f1739
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
70 changed files with 418 additions and 45 deletions

View File

@ -23,3 +23,21 @@ func (migrator *Migrator) updateAppTemplatesVersionForDB110() error {
return migrator.settingsService.UpdateSettings(settings)
}
// setUseCacheForDB110 sets the user cache to true for all users
func (migrator *Migrator) setUserCacheForDB110() error {
users, err := migrator.userService.ReadAll()
if err != nil {
return err
}
for i := range users {
user := &users[i]
user.UseCache = true
if err := migrator.userService.Update(user.ID, user); err != nil {
return err
}
}
return nil
}

View File

@ -231,6 +231,7 @@ func (m *Migrator) initMigrations() {
m.addMigrations("2.20",
m.updateAppTemplatesVersionForDB110,
m.setUserCacheForDB110,
)
// Add new migrations below...

View File

@ -903,6 +903,7 @@
"color": ""
},
"TokenIssueAt": 0,
"UseCache": true,
"Username": "admin"
},
{
@ -932,10 +933,11 @@
"color": ""
},
"TokenIssueAt": 0,
"UseCache": true,
"Username": "prabhat"
}
],
"version": {
"VERSION": "{\"SchemaVersion\":\"2.20.0\",\"MigratorCount\":1,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
"VERSION": "{\"SchemaVersion\":\"2.20.0\",\"MigratorCount\":2,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
}
}

View File

@ -24,6 +24,7 @@ type userUpdatePayload struct {
Username string `validate:"required" example:"bob"`
Password string `validate:"required" example:"cg9Wgky3"`
NewPassword string `validate:"required" example:"asfj2emv"`
UseCache *bool `validate:"required" example:"true"`
Theme *themePayload
// User role (1 for administrator account and 2 for regular account)
@ -147,6 +148,10 @@ func (handler *Handler) userUpdate(w http.ResponseWriter, r *http.Request) *http
}
}
if payload.UseCache != nil {
user.UseCache = *payload.UseCache
}
if payload.Role != 0 {
user.Role = portainer.UserRole(payload.Role)
user.TokenIssueAt = time.Now().Unix()

View File

@ -1314,9 +1314,10 @@ type (
Username string `json:"Username" example:"bob"`
Password string `json:"Password,omitempty" swaggerignore:"true"`
// User role (1 for administrator account and 2 for regular account)
Role UserRole `json:"Role" example:"1"`
TokenIssueAt int64 `json:"TokenIssueAt" example:"1"`
ThemeSettings UserThemeSettings
Role UserRole `json:"Role" example:"1"`
TokenIssueAt int64 `json:"TokenIssueAt" example:"1"`
ThemeSettings UserThemeSettings `json:"ThemeSettings"`
UseCache bool `json:"UseCache" example:"true"`
// Deprecated fields

View File

@ -1,6 +1,7 @@
import { Terminal } from 'xterm';
import * as fit from 'xterm/lib/addons/fit/fit';
import { agentInterceptor } from './portainer/services/axios';
import { dispatchCacheRefreshEventIfNeeded } from './portainer/services/http-request.helper';
/* @ngInject */
export function configApp($urlRouterProvider, $httpProvider, localStorageServiceProvider, jwtOptionsProvider, $uibTooltipProvider, $compileProvider, cfpLoadingBarProvider) {
@ -8,6 +9,14 @@ export function configApp($urlRouterProvider, $httpProvider, localStorageService
$compileProvider.debugInfoEnabled(false);
}
// ask to clear cache on mutation
$httpProvider.interceptors.push(() => ({
request: (reqConfig) => {
dispatchCacheRefreshEventIfNeeded(reqConfig);
return reqConfig;
},
}));
localStorageServiceProvider.setPrefix('portainer');
jwtOptionsProvider.config({

View File

@ -1,4 +1,4 @@
<page-header title="'Create Edge stack'" breadcrumbs="[{label:'Edge Stacks', link:'edge.stacks'}, 'Create Edge stack']"> </page-header>
<page-header title="'Create Edge stack'" breadcrumbs="[{label:'Edge Stacks', link:'edge.stacks'}, 'Create Edge stack']" reload="true"> </page-header>
<div class="row">
<div class="col-sm-12">

View File

@ -1,13 +1,52 @@
import { EnvironmentStatus } from '@/react/portainer/environments/types';
import { getSelfSubjectAccessReview } from '@/react/kubernetes/namespaces/getSelfSubjectAccessReview';
import { updateAxiosAdapter } from '@/portainer/services/axios';
import { PortainerEndpointTypes } from 'Portainer/models/endpoint/models';
import { CACHE_REFRESH_EVENT, CACHE_DURATION } from '../portainer/services/http-request.helper';
import { cache } from '../portainer/services/axios';
import registriesModule from './registries';
import customTemplateModule from './custom-templates';
import { reactModule } from './react';
import './views/kubernetes.css';
// The angular-cache npm package didn't have exclude options, so implement a custom cache
// with an added check to only cache kubernetes requests
class ExpirationCache {
constructor() {
this.store = new Map();
this.timeout = CACHE_DURATION;
}
get(key) {
return this.store.get(key);
}
put(key, val) {
// only cache requests with 'kubernetes' in the url
if (key.includes('kubernetes')) {
this.store.set(key, val);
// remove it once it's expired
setTimeout(() => {
this.remove(key);
}, this.timeout);
}
}
remove(key) {
this.store.delete(key);
}
removeAll() {
this.store = new Map();
}
delete() {
// skip because this is standalone, not a part of $cacheFactory
}
}
angular.module('portainer.kubernetes', ['portainer.app', registriesModule, customTemplateModule, reactModule]).config([
'$stateRegistryProvider',
function ($stateRegistryProvider) {
@ -19,8 +58,31 @@ angular.module('portainer.kubernetes', ['portainer.app', registriesModule, custo
parent: 'endpoint',
abstract: true,
onEnter: /* @ngInject */ function onEnter($async, $state, endpoint, KubernetesHealthService, KubernetesNamespaceService, Notifications, StateManager) {
onEnter: /* @ngInject */ function onEnter(
$async,
$state,
endpoint,
KubernetesHealthService,
KubernetesNamespaceService,
Notifications,
StateManager,
$http,
Authentication,
UserService
) {
return $async(async () => {
// if the user wants to use front end cache for performance, set the angular caching settings
const userDetails = Authentication.getUserDetails();
const user = await UserService.user(userDetails.ID);
updateAxiosAdapter(user.UseCache);
if (user.UseCache) {
$http.defaults.cache = new ExpirationCache();
window.addEventListener(CACHE_REFRESH_EVENT, () => {
$http.defaults.cache.removeAll();
cache.store.clear();
});
}
const kubeTypes = [
PortainerEndpointTypes.KubernetesLocalEnvironment,
PortainerEndpointTypes.AgentOnKubernetesEnvironment,

View File

@ -1,4 +1,4 @@
<page-header title="'Create Custom template'" breadcrumbs="[{label:'Custom Templates', link:'kubernetes.templates.custom'}, 'Create Custom template']"> </page-header>
<page-header title="'Create Custom template'" breadcrumbs="[{label:'Custom Templates', link:'kubernetes.templates.custom'}, 'Create Custom template']" reload="true"> </page-header>
<div class="row">
<div class="col-sm-12">

View File

@ -1,4 +1,4 @@
<page-header title="'Registry access'" breadcrumbs="[{label:'Registries', link:'kubernetes.registries'}, $ctrl.registry.Name, 'Access management']"> </page-header>
<page-header title="'Registry access'" breadcrumbs="[{label:'Registries', link:'kubernetes.registries'}, $ctrl.registry.Name, 'Access management']" reload="true"> </page-header>
<registry-details registry="$ctrl.registry" ng-if="$ctrl.registry"></registry-details>

View File

@ -4,11 +4,13 @@ import { KubernetesCommonParams } from 'Kubernetes/models/common/params';
import KubernetesNamespaceConverter from 'Kubernetes/converters/namespace';
import { updateNamespaces } from 'Kubernetes/store/namespace';
import $allSettled from 'Portainer/services/allSettled';
import { getSelfSubjectAccessReview } from '@/react/kubernetes/namespaces/getSelfSubjectAccessReview';
class KubernetesNamespaceService {
/* @ngInject */
constructor($async, KubernetesNamespaces, LocalStorage) {
constructor($async, KubernetesNamespaces, LocalStorage, $state) {
this.$async = $async;
this.$state = $state;
this.KubernetesNamespaces = KubernetesNamespaces;
this.LocalStorage = LocalStorage;
@ -66,8 +68,10 @@ class KubernetesNamespaceService {
try {
// get the list of all namespaces (RBAC allows users to see the list of namespaces)
const data = await this.KubernetesNamespaces().get().$promise;
// get the status of each namespace (RBAC will give permission denied for status of unauthorised namespaces)
const promises = data.items.map((item) => this.KubernetesNamespaces().status({ id: item.metadata.name }).$promise);
// get the status of each namespace with accessReviews (to avoid failed forbidden responses, which aren't cached)
const accessReviews = await Promise.all(data.items.map((namespace) => getSelfSubjectAccessReview(this.$state.params.endpointId, namespace.metadata.name)));
const allowedNamespaceNames = accessReviews.filter((ar) => ar.status.allowed).map((ar) => ar.spec.resourceAttributes.namespace);
const promises = allowedNamespaceNames.map((name) => this.KubernetesNamespaces().status({ id: name }).$promise);
const namespaces = await $allSettled(promises);
// only return namespaces if the user has access to namespaces
const allNamespaces = namespaces.fulfilled.map((item) => {

View File

@ -75,7 +75,7 @@ class KubernetesResourcePoolsController {
async getResourcePoolsAsync() {
try {
this.resourcePools = await this.KubernetesResourcePoolService.get('', { getQuota: true });
this.resourcePools = await this.KubernetesResourcePoolService.get();
} catch (err) {
this.Notifications.error('Failure', err, 'Unable to retreive namespaces');
}

View File

@ -1,5 +1,5 @@
import { notifyError, notifySuccess } from '@/portainer/services/notifications';
import { queryKeys } from '@/portainer/users/queries/queryKeys';
import { userQueryKeys } from '@/portainer/users/queries/queryKeys';
import { queryClient } from '@/react-tools/react-query';
import { options } from '@/react/portainer/account/AccountView/theme-options';
@ -32,7 +32,7 @@ export default class ThemeSettingsController {
try {
if (!this.state.isDemo) {
await this.UserService.updateUserTheme(this.state.userId, theme);
await queryClient.invalidateQueries(queryKeys.user(this.state.userId));
await queryClient.invalidateQueries(userQueryKeys.user(this.state.userId));
}
notifySuccess('Success', 'User theme settings successfully updated');

View File

@ -12,6 +12,7 @@ export function UserViewModel(data) {
}
this.AuthenticationMethod = data.AuthenticationMethod;
this.Checked = false;
this.UseCache = data.UseCache;
}
export function UserTokenModel(data) {

View File

@ -0,0 +1,17 @@
import angular from 'angular';
import { r2a } from '@/react-tools/react2angular';
import { withCurrentUser } from '@/react-tools/withCurrentUser';
import { withUIRouter } from '@/react-tools/withUIRouter';
import { withReactQuery } from '@/react-tools/withReactQuery';
import { ApplicationSettingsWidget } from '@/react/portainer/account/AccountView/ApplicationSettings';
export const accountModule = angular
.module('portainer.app.react.components.account', [])
.component(
'applicationSettingsWidget',
r2a(
withUIRouter(withReactQuery(withCurrentUser(ApplicationSettingsWidget))),
[]
)
).name;

View File

@ -45,6 +45,7 @@ import { accessControlModule } from './access-control';
import { environmentsModule } from './environments';
import { envListModule } from './environments-list-view-components';
import { registriesModule } from './registries';
import { accountModule } from './account';
export const ngModule = angular
.module('portainer.app.react.components', [
@ -55,6 +56,7 @@ export const ngModule = angular
gitFormModule,
registriesModule,
settingsModule,
accountModule,
])
.component(
'tagSelector',

View File

@ -1,4 +1,5 @@
import axiosOrigin, { AxiosError, AxiosRequestConfig } from 'axios';
import { setupCache } from 'axios-cache-adapter';
import { loadProgressBar } from 'axios-progress-bar';
import 'axios-progress-bar/dist/nprogress.css';
@ -6,12 +7,48 @@ import PortainerError from '@/portainer/error';
import { get as localStorageGet } from '@/react/hooks/useLocalStorage';
import {
CACHE_DURATION,
dispatchCacheRefreshEventIfNeeded,
portainerAgentManagerOperation,
portainerAgentTargetHeader,
} from './http-request.helper';
export const cache = setupCache({
maxAge: CACHE_DURATION,
debug: false, // set to true to print cache hits/misses
exclude: {
query: false, // include urls with query params
methods: ['put', 'patch', 'delete'],
filter: (req: AxiosRequestConfig) => {
// exclude caching get requests unless the path contains 'kubernetes'
if (!req.url?.includes('kubernetes') && req.method === 'get') {
return true;
}
// exclude caching post requests unless the path contains 'selfsubjectaccessreview'
if (
!req.url?.includes('selfsubjectaccessreview') &&
req.method === 'post'
) {
return true;
}
return false;
},
},
// ask to clear cache on mutation
invalidate: async (_, req) => {
dispatchCacheRefreshEventIfNeeded(req);
},
});
// by default don't use the cache adapter
const axios = axiosOrigin.create({ baseURL: 'api' });
// when entering a kubernetes environment, or updating user settings, update the cache adapter
export function updateAxiosAdapter(useCache: boolean) {
axios.defaults.adapter = useCache ? cache.adapter : undefined;
}
loadProgressBar(undefined, axios);
export default axios;

View File

@ -1,3 +1,31 @@
import { AxiosRequestConfig } from 'axios';
export const CACHE_DURATION = 5 * 60 * 1000; // 5m in ms
// event emitted when cache need to be refreshed
// used to sync $http + axios cache clear
export const CACHE_REFRESH_EVENT = '__cache__refresh__event__';
// utility function to dispatch catch refresh event
export function dispatchCacheRefreshEvent() {
dispatchEvent(new CustomEvent(CACHE_REFRESH_EVENT, {}));
}
// perform checks on config.method and config.url
// to dispatch event in only specific scenarios
export function dispatchCacheRefreshEventIfNeeded(req: AxiosRequestConfig) {
if (
req.method &&
['post', 'patch', 'put', 'delete'].includes(req.method.toLowerCase()) &&
// don't clear cache when we try to check for namespaces accesses
// otherwise we will clear it on every page
req.url &&
!req.url.includes('selfsubjectaccessreviews') &&
req.url.includes('kubernetes')
) {
dispatchCacheRefreshEvent();
}
}
interface Headers {
agentTargetQueue: string[];
agentManagerOperation: boolean;

View File

@ -1,6 +1,6 @@
import { UserId } from '../types';
export const queryKeys = {
export const userQueryKeys = {
base: () => ['users'] as const,
user: (id: UserId) => [...queryKeys.base(), id] as const,
user: (id: UserId) => [...userQueryKeys.base(), id] as const,
};

View File

@ -6,13 +6,13 @@ import { withError } from '@/react-tools/react-query';
import { buildUrl } from '../user.service';
import { User, UserId } from '../types';
import { queryKeys } from './queryKeys';
import { userQueryKeys } from './queryKeys';
export function useUser(
id: UserId,
{ staleTime }: { staleTime?: number } = {}
) {
return useQuery(queryKeys.user(id), () => getUser(id), {
return useQuery(userQueryKeys.user(id), () => getUser(id), {
...withError('Unable to retrieve user details'),
staleTime,
});

View File

@ -20,6 +20,7 @@ export type User = {
EndpointAuthorizations: {
[endpointId: EnvironmentId]: AuthorizationMap;
};
UseCache: boolean;
ThemeSettings: {
color: 'dark' | 'light' | 'highcontrast' | 'auto';
};

View File

@ -1,4 +1,4 @@
<page-header title="'User settings'" breadcrumbs="['User settings']"> </page-header>
<page-header title="'User settings'" breadcrumbs="['User settings']" reload="true"> </page-header>
<demo-feature-indicator ng-if="isDemoUser" content="'You cannot change the password of this account in the demo version of Portainer.'"> </demo-feature-indicator>
@ -16,16 +16,16 @@
<form name="form" class="form-horizontal" style="margin-top: 15px">
<!-- current-password-input -->
<div class="form-group">
<label for="current_password" class="col-sm-2 control-label required text-left">Current password</label>
<div class="col-sm-8">
<label for="current_password" class="col-sm-3 col-lg-2 control-label required text-left">Current password</label>
<div class="col-sm-9 col-lg-10">
<input type="password" class="form-control" ng-model="formValues.currentPassword" id="current_password" />
</div>
</div>
<!-- !current-password-input -->
<!-- new-password-input -->
<div class="form-group">
<label for="new_password" class="col-sm-2 control-label required text-left">New password</label>
<div class="col-sm-8">
<label for="new_password" class="col-sm-3 col-lg-2 control-label required text-left">New password</label>
<div class="col-sm-9 col-lg-10">
<input type="password" class="form-control" ng-model="formValues.newPassword" ng-minlength="requiredPasswordLength" id="new_password" name="new_password" />
</div>
</div>
@ -33,8 +33,8 @@
<!-- confirm-password-input -->
<div class="form-group">
<label for="confirm_password" class="col-sm-2 control-label required text-left">Confirm password</label>
<div class="col-sm-8">
<label for="confirm_password" class="col-sm-3 col-lg-2 control-label required text-left">Confirm password</label>
<div class="col-sm-9 col-lg-10">
<div class="input-group">
<input type="password" class="form-control" ng-model="formValues.confirmPassword" id="confirm_password" />
<span class="input-group-addon">
@ -86,6 +86,8 @@
</div>
</div>
<application-settings-widget></application-settings-widget>
<div class="row">
<div class="col-lg-12 col-md-12 col-xs-12">
<access-tokens-datatable

View File

@ -1,4 +1,4 @@
<page-header title="'Create access token'" breadcrumbs="[{label:'User settings', link:'portainer.account'}, 'Add access token']"> </page-header>
<page-header title="'Create access token'" breadcrumbs="[{label:'User settings', link:'portainer.account'}, 'Add access token']" reload="true"> </page-header>
<div class="row">
<div class="col-sm-12">

View File

@ -1,4 +1,4 @@
<page-header title="'Create Custom template'" breadcrumbs="[{label:'Custom Templates', link:'docker.templates.custom'}, 'Create Custom template']"> </page-header>
<page-header title="'Create Custom template'" breadcrumbs="[{label:'Custom Templates', link:'docker.templates.custom'}, 'Create Custom template']" reload="true"> </page-header>
<div class="row" ng-if="!$ctrl.state.loading">
<div class="col-sm-12">

View File

@ -1,4 +1,4 @@
<page-header title="'FDO Device Configuration'" breadcrumbs="[{label:'Environments', link:'portainer.endpoints'}, 'Import FDO Device']"> </page-header>
<page-header title="'FDO Device Configuration'" breadcrumbs="[{label:'Environments', link:'portainer.endpoints'}, 'Import FDO Device']" reload="true"> </page-header>
<div class="row">
<div class="col-sm-12">

View File

@ -1,4 +1,4 @@
<page-header title="'Create profile'" breadcrumbs="[{label:'Settings', link:'portainer.settings'}, 'Edge Compute']"> </page-header>
<page-header title="'Create profile'" breadcrumbs="[{label:'Settings', link:'portainer.settings'}, 'Edge Compute']" reload="true"> </page-header>
<div class="row">
<div class="col-sm-12">

View File

@ -1,4 +1,4 @@
<page-header title="'Edit profile'" breadcrumbs="[{label:'Settings', link:'portainer.settings'}, 'Edge Compute']"> </page-header>
<page-header title="'Edit profile'" breadcrumbs="[{label:'Settings', link:'portainer.settings'}, 'Edge Compute']" reload="true"> </page-header>
<div class="row">
<div class="col-sm-12">

View File

@ -7,6 +7,7 @@
link: 'portainer.endpoints.endpoint',
linkParams:{id: ctrl.endpoint.Id}
}, 'Access management']"
reload="true"
>
</page-header>

View File

@ -9,6 +9,7 @@
},
$state.deviceName,
'KVM Control']"
reload="true"
>
</page-header>

View File

@ -7,6 +7,7 @@
link: 'portainer.groups.group',
linkParams:{id: group.Id}
}, 'Access management']"
reload="true"
>
</page-header>

View File

@ -1,4 +1,4 @@
<page-header title="'Create environment group'" breadcrumbs="[{label:'Environment groups', link:'portainer.groups'}, 'Add group']"> </page-header>
<page-header title="'Create environment group'" breadcrumbs="[{label:'Environment groups', link:'portainer.groups'}, 'Add group']" reload="true"> </page-header>
<div class="row">
<div class="col-sm-12">

View File

@ -1,4 +1,4 @@
<page-header title="'Environment group details'" breadcrumbs="[{label:'Groups', link:'portainer.groups'}, group.Name]"> </page-header>
<page-header title="'Environment group details'" breadcrumbs="[{label:'Groups', link:'portainer.groups'}, group.Name]" reload="true"> </page-header>
<div class="row">
<div class="col-sm-12">

View File

@ -1,4 +1,4 @@
<page-header title="'Create registry'" breadcrumbs="[{label:'Registries', link:'portainer.registries'}, 'Add registry']"> </page-header>
<page-header title="'Create registry'" breadcrumbs="[{label:'Registries', link:'portainer.registries'}, 'Add registry']" reload="true"> </page-header>
<div class="row">
<div class="col-sm-12">

View File

@ -1,4 +1,4 @@
<page-header title="'Registry details'" breadcrumbs="[{label:'Registries', link:'portainer.registries'}, $ctrl.registry.Name]"> </page-header>
<page-header title="'Registry details'" breadcrumbs="[{label:'Registries', link:'portainer.registries'}, $ctrl.registry.Name]" reload="true"> </page-header>
<div class="row" ng-if="!$ctrl.state.loading">
<div class="col-sm-12">

View File

@ -1,4 +1,4 @@
<page-header title="'Authentication settings'" breadcrumbs="[{label:'Settings', link:'portainer.settings'}, 'Authentication']"> </page-header>
<page-header title="'Authentication settings'" breadcrumbs="[{label:'Settings', link:'portainer.settings'}, 'Authentication']" reload="true"> </page-header>
<div class="row">
<div class="col-sm-12">

View File

@ -1,4 +1,4 @@
<page-header title="'Settings'" breadcrumbs="[{label:'Settings', link:'portainer.settings'}, 'Edge Compute']"> </page-header>
<page-header title="'Settings'" breadcrumbs="[{label:'Settings', link:'portainer.settings'}, 'Edge Compute']" reload="true"> </page-header>
<div class="row">
<div class="col-sm-12" ng-if="$ctrl.settings">

View File

@ -1,4 +1,4 @@
<page-header title="'Create stack'" breadcrumbs="[{label:'Stacks', link:'docker.stacks'}, 'Add stack']"> </page-header>
<page-header title="'Create stack'" breadcrumbs="[{label:'Stacks', link:'docker.stacks'}, 'Add stack']" reload="true"> </page-header>
<div class="row">
<div class="col-sm-12">

View File

@ -1,4 +1,4 @@
<page-header title="'User details'" breadcrumbs="[{label:'Users', link:'portainer.users'}, formValues.username]"> </page-header>
<page-header title="'User details'" breadcrumbs="[{label:'Users', link:'portainer.users'}, formValues.username]" reload="true"> </page-header>
<div class="row">
<div class="col-lg-12 col-md-12 col-xs-12">

View File

@ -17,6 +17,7 @@ export function createMockUsers(
Checked: false,
EndpointAuthorizations: {},
PortainerAuthorizations: {},
UseCache: false,
ThemeSettings: {
color: 'auto',
},

View File

@ -27,7 +27,7 @@ export function DashboardView() {
return (
<>
<PageHeader title="Home" breadcrumbs={[{ label: 'Dashboard' }]} />
<PageHeader title="Home" breadcrumbs={[{ label: 'Dashboard' }]} reload />
<div className="mx-4">
{subscriptionsQuery.data && (

View File

@ -12,6 +12,7 @@ export function CreateView() {
{ link: 'azure.containerinstances', label: 'Container instances' },
{ label: 'Add container' },
]}
reload
/>
<div className="row">

View File

@ -68,6 +68,7 @@ export function ItemView() {
{ link: 'azure.containerinstances', label: 'Container instances' },
{ label: container.name },
]}
reload
/>
<div className="row">

View File

@ -32,9 +32,9 @@ export function ListView() {
return (
<>
<PageHeader
title="Container list"
breadcrumbs="Container instances"
reload
title="Container list"
/>
<ContainersDatatable

View File

@ -31,6 +31,7 @@ function Template({ title }: StoryProps) {
{ label: 'bread3' },
{ label: 'bread4' },
]}
reload
/>
</UserContext.Provider>
);

View File

@ -1,6 +1,8 @@
import { useRouter } from '@uirouter/react';
import { RefreshCw } from 'lucide-react';
import { PropsWithChildren } from 'react';
import { RefreshCw } from 'lucide-react';
import { dispatchCacheRefreshEvent } from '@/portainer/services/http-request.helper';
import { Button } from '../buttons';
@ -51,6 +53,7 @@ export function PageHeader({
);
function onClickedRefresh() {
dispatchCacheRefreshEvent();
return onReload ? onReload() : router.stateService.reload();
}
}

View File

@ -6,6 +6,7 @@ export function createMockUser(id: number, username: string): UserViewModel {
Username: username,
Role: 2,
EndpointAuthorizations: {},
UseCache: false,
PortainerAuthorizations: {
PortainerDockerHubInspect: true,
PortainerEndpointGroupInspect: true,

View File

@ -36,6 +36,7 @@ export function CreateView() {
{ label: 'Containers', link: 'docker.containers' },
'Add container',
]}
reload
/>
<CreateForm />

View File

@ -61,6 +61,7 @@ export function ItemView() {
label: networkQuery.data.Name,
},
]}
reload
/>
<NetworkDetailsTable
network={networkQuery.data}

View File

@ -19,6 +19,7 @@ function WaitingRoomView() {
<PageHeader
title="Waiting Room"
breadcrumbs={[{ label: 'Waiting Room' }]}
reload
/>
<InformationPanel>

View File

@ -27,7 +27,6 @@ export function ConfigureView() {
<>
<PageHeader
title="Kubernetes features configuration"
reload
breadcrumbs={[
{ label: 'Environments', link: 'portainer.endpoints' },
{
@ -37,6 +36,7 @@ export function ConfigureView() {
},
'Kubernetes configuration',
]}
reload
/>
<div className="row">
<div className="col-sm-12">

View File

@ -573,6 +573,7 @@ export function CreateIngressView() {
label: isEdit ? 'Edit ingress' : 'Create ingress',
},
]}
reload
/>
<div className="row ingress-rules">
<div className="col-sm-12">

View File

@ -0,0 +1,86 @@
import { Form, Formik } from 'formik';
import { useCurrentUser } from '@/react/hooks/useUser';
import { notifySuccess } from '@/portainer/services/notifications';
import { updateAxiosAdapter } from '@/portainer/services/axios';
import { withError } from '@/react-tools/react-query';
import { TextTip } from '@@/Tip/TextTip';
import { LoadingButton } from '@@/buttons';
import { SwitchField } from '@@/form-components/SwitchField';
import { useUpdateUserMutation } from '../../useUpdateUserMutation';
type FormValues = {
useCache: boolean;
};
export function ApplicationSettingsForm() {
const { user } = useCurrentUser();
const updateSettingsMutation = useUpdateUserMutation();
const initialValues = {
useCache: user.UseCache,
};
return (
<Formik<FormValues>
initialValues={initialValues}
onSubmit={handleSubmit}
validateOnMount
enableReinitialize
>
{({ isValid, dirty, values, setFieldValue }) => (
<Form className="form-horizontal">
<TextTip color="orange" className="mb-3">
Enabling front-end data caching can mean that changes to Kubernetes
clusters made by other users or outside of Portainer may take up to
five minutes to show in your session. This caching only applies to
Kubernetes environments.
</TextTip>
<SwitchField
label="Enable front-end data caching for Kubernetes environments"
checked={values.useCache}
onChange={(value) => setFieldValue('useCache', value)}
labelClass="col-lg-2 col-sm-3" // match the label width of the other fields in the page
fieldClass="!mb-4"
/>
<div className="form-group">
<div className="col-sm-12">
<LoadingButton
loadingText="Saving..."
isLoading={updateSettingsMutation.isLoading}
disabled={!isValid || !dirty}
className="!ml-0"
data-cy="account-applicationSettingsSaveButton"
>
Save
</LoadingButton>
</div>
</div>
</Form>
)}
</Formik>
);
function handleSubmit(values: FormValues) {
updateSettingsMutation.mutate(
{
Id: user.Id,
UseCache: values.useCache,
},
{
onSuccess() {
updateAxiosAdapter(values.useCache);
notifySuccess(
'Success',
'Successfully updated application settings.'
);
// a full reload is required to update the angular $http cache setting
setTimeout(() => window.location.reload(), 2000); // allow 2s to show the success notification
},
...withError('Unable to update application settings'),
}
);
}
}

View File

@ -0,0 +1,20 @@
import { Settings } from 'lucide-react';
import { Widget, WidgetBody, WidgetTitle } from '@@/Widget';
import { ApplicationSettingsForm } from './ApplicationSettingsForm';
export function ApplicationSettingsWidget() {
return (
<div className="row">
<div className="col-sm-12">
<Widget>
<WidgetTitle icon={Settings} title="Application settings" />
<WidgetBody>
<ApplicationSettingsForm />
</WidgetBody>
</Widget>
</div>
</div>
);
}

View File

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

View File

@ -12,6 +12,7 @@ export function CreateHelmRepositoriesView() {
{ label: 'My account', link: 'portainer.account' },
{ label: 'Create Helm repository' },
]}
reload
/>
<div className="row">

View File

@ -0,0 +1,32 @@
import { useMutation } from 'react-query';
import axios, { parseAxiosError } from '@/portainer/services/axios';
import { User } from '@/portainer/users/types';
import {
mutationOptions,
withInvalidate,
queryClient,
} from '@/react-tools/react-query';
import { userQueryKeys } from '@/portainer/users/queries/queryKeys';
import { useCurrentUser } from '@/react/hooks/useUser';
export function useUpdateUserMutation() {
const {
user: { Id: userId },
} = useCurrentUser();
return useMutation(
(user: Partial<User>) => updateUser(user, userId),
mutationOptions(withInvalidate(queryClient, [userQueryKeys.base()]))
// error notification should be handled by the caller
);
}
async function updateUser(user: Partial<User>, userId: number) {
try {
const { data } = await axios.put(`/users/${userId}`, user);
return data;
} catch (error) {
throw parseAxiosError(error);
}
}

View File

@ -17,6 +17,7 @@ function EdgeAutoCreateScriptView() {
{ label: 'Environments', link: 'portainer.endpoints' },
'Automatic Edge Environment Creation',
]}
reload
/>
<div className="mx-3">

View File

@ -54,6 +54,7 @@ function CreateView() {
<PageHeader
title="Update & Rollback"
breadcrumbs="Edge agent update and rollback"
reload
/>
<BetaAlert

View File

@ -78,6 +78,7 @@ function ItemView() {
{ label: 'Edge agent update and rollback', link: '^' },
item.name,
]}
reload
/>
<BetaAlert

View File

@ -56,8 +56,8 @@ export function ListView() {
<>
<PageHeader
title="Update & Rollback"
reload
breadcrumbs="Update and rollback"
reload
/>
<BetaAlert

View File

@ -27,6 +27,7 @@ export function EnvironmentTypeSelectView() {
<PageHeader
title="Quick Setup"
breadcrumbs={[{ label: 'Environment Wizard' }]}
reload
/>
<div className="row">

View File

@ -71,6 +71,7 @@ export function EnvironmentCreationView() {
<PageHeader
title="Quick Setup"
breadcrumbs={[{ label: 'Environment Wizard' }]}
reload
/>
<div className={styles.wizardWrapper}>

View File

@ -22,6 +22,7 @@ export function HomeView() {
<PageHeader
title="Quick Setup"
breadcrumbs={[{ label: 'Environment Wizard' }]}
reload
/>
<div className="row">

View File

@ -18,7 +18,7 @@ import { ExperimentalFeatures } from './ExperimentalFeatures';
export function SettingsView() {
return (
<>
<PageHeader title="Settings" breadcrumbs="Settings" />
<PageHeader title="Settings" breadcrumbs="Settings" reload />
<div className="mx-4 space-y-4">
<ApplicationSettingsPanel onSuccess={handleSuccess} />

View File

@ -38,6 +38,7 @@ export function ItemView() {
<PageHeader
title="Team details"
breadcrumbs={[{ label: 'Teams' }, { label: team.Name }]}
reload
/>
{membershipsQuery.data && (

View File

@ -42,6 +42,7 @@ export function mockExampleData() {
RoleName: 'user',
Checked: false,
AuthenticationMethod: '',
UseCache: false,
},
{
Id: 13,
@ -69,6 +70,7 @@ export function mockExampleData() {
RoleName: 'user',
Checked: false,
AuthenticationMethod: '',
UseCache: false,
},
];

View File

@ -16,7 +16,11 @@ export function ListView() {
return (
<>
<PageHeader title="Teams" breadcrumbs={[{ label: 'Teams management' }]} />
<PageHeader
title="Teams"
breadcrumbs={[{ label: 'Teams management' }]}
reload
/>
{isAdmin && usersQuery.data && teamsQuery.data && (
<CreateTeamForm users={usersQuery.data} teams={teamsQuery.data} />

View File

@ -8,6 +8,7 @@ const mockUser: User = {
Id: 1,
Role: 1,
Username: 'mock',
UseCache: false,
ThemeSettings: {
color: 'auto',
},

View File

@ -75,6 +75,7 @@
"angularjs-slider": "^6.4.0",
"angulartics": "^1.6.0",
"axios": "^0.24.0",
"axios-cache-adapter": "^2.7.3",
"axios-progress-bar": "^1.2.0",
"babel-plugin-angularjs-annotate": "^0.10.0",
"bootstrap": "^3.4.0",

View File

@ -6475,6 +6475,14 @@ axe-core@^4.6.2:
resolved "https://registry.yarnpkg.com/axe-core/-/axe-core-4.7.0.tgz#34ba5a48a8b564f67e103f0aa5768d76e15bbbbf"
integrity sha512-M0JtH+hlOL5pLQwHOLNYZaXuhqmvS8oExsqB1SBYgA4Dk7u/xx+YdGHXaK5pyUfed5mYXdlYiphWq3G8cRi5JQ==
axios-cache-adapter@^2.7.3:
version "2.7.3"
resolved "https://registry.yarnpkg.com/axios-cache-adapter/-/axios-cache-adapter-2.7.3.tgz#0d1eefa0f25b88f42a95c7528d7345bde688181d"
integrity sha512-A+ZKJ9lhpjthOEp4Z3QR/a9xC4du1ALaAsejgRGrH9ef6kSDxdFrhRpulqsh9khsEnwXxGfgpUuDp1YXMNMEiQ==
dependencies:
cache-control-esm "1.0.0"
md5 "^2.2.1"
axios-progress-bar@^1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/axios-progress-bar/-/axios-progress-bar-1.2.0.tgz#f9ee88dc9af977246be1ef07eedfa4c990c639c5"
@ -7058,6 +7066,11 @@ c8@^7.6.0:
yargs "^16.2.0"
yargs-parser "^20.2.9"
cache-control-esm@1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/cache-control-esm/-/cache-control-esm-1.0.0.tgz#417647ecf1837a5e74155f55d5a4ae32a84e2581"
integrity sha512-Fa3UV4+eIk4EOih8FTV6EEsVKO0W5XWtNs6FC3InTfVz+EjurjPfDXY5wZDo/lxjDxg5RjNcurLyxEJBcEUx9g==
call-bind@^1.0.0, call-bind@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/call-bind/-/call-bind-1.0.2.tgz#b1d4e89e688119c3c9a903ad30abb2f6a919be3c"