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) 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.addMigrations("2.20",
m.updateAppTemplatesVersionForDB110, m.updateAppTemplatesVersionForDB110,
m.setUserCacheForDB110,
) )
// Add new migrations below... // Add new migrations below...

View File

@ -903,6 +903,7 @@
"color": "" "color": ""
}, },
"TokenIssueAt": 0, "TokenIssueAt": 0,
"UseCache": true,
"Username": "admin" "Username": "admin"
}, },
{ {
@ -932,10 +933,11 @@
"color": "" "color": ""
}, },
"TokenIssueAt": 0, "TokenIssueAt": 0,
"UseCache": true,
"Username": "prabhat" "Username": "prabhat"
} }
], ],
"version": { "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"` Username string `validate:"required" example:"bob"`
Password string `validate:"required" example:"cg9Wgky3"` Password string `validate:"required" example:"cg9Wgky3"`
NewPassword string `validate:"required" example:"asfj2emv"` NewPassword string `validate:"required" example:"asfj2emv"`
UseCache *bool `validate:"required" example:"true"`
Theme *themePayload Theme *themePayload
// User role (1 for administrator account and 2 for regular account) // 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 { if payload.Role != 0 {
user.Role = portainer.UserRole(payload.Role) user.Role = portainer.UserRole(payload.Role)
user.TokenIssueAt = time.Now().Unix() user.TokenIssueAt = time.Now().Unix()

View File

@ -1316,7 +1316,8 @@ type (
// User role (1 for administrator account and 2 for regular account) // User role (1 for administrator account and 2 for regular account)
Role UserRole `json:"Role" example:"1"` Role UserRole `json:"Role" example:"1"`
TokenIssueAt int64 `json:"TokenIssueAt" example:"1"` TokenIssueAt int64 `json:"TokenIssueAt" example:"1"`
ThemeSettings UserThemeSettings ThemeSettings UserThemeSettings `json:"ThemeSettings"`
UseCache bool `json:"UseCache" example:"true"`
// Deprecated fields // Deprecated fields

View File

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

View File

@ -1,13 +1,52 @@
import { EnvironmentStatus } from '@/react/portainer/environments/types'; import { EnvironmentStatus } from '@/react/portainer/environments/types';
import { getSelfSubjectAccessReview } from '@/react/kubernetes/namespaces/getSelfSubjectAccessReview'; import { getSelfSubjectAccessReview } from '@/react/kubernetes/namespaces/getSelfSubjectAccessReview';
import { updateAxiosAdapter } from '@/portainer/services/axios';
import { PortainerEndpointTypes } from 'Portainer/models/endpoint/models'; 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 registriesModule from './registries';
import customTemplateModule from './custom-templates'; import customTemplateModule from './custom-templates';
import { reactModule } from './react'; import { reactModule } from './react';
import './views/kubernetes.css'; 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([ angular.module('portainer.kubernetes', ['portainer.app', registriesModule, customTemplateModule, reactModule]).config([
'$stateRegistryProvider', '$stateRegistryProvider',
function ($stateRegistryProvider) { function ($stateRegistryProvider) {
@ -19,8 +58,31 @@ angular.module('portainer.kubernetes', ['portainer.app', registriesModule, custo
parent: 'endpoint', parent: 'endpoint',
abstract: true, 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 () => { 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 = [ const kubeTypes = [
PortainerEndpointTypes.KubernetesLocalEnvironment, PortainerEndpointTypes.KubernetesLocalEnvironment,
PortainerEndpointTypes.AgentOnKubernetesEnvironment, 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="row">
<div class="col-sm-12"> <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> <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 KubernetesNamespaceConverter from 'Kubernetes/converters/namespace';
import { updateNamespaces } from 'Kubernetes/store/namespace'; import { updateNamespaces } from 'Kubernetes/store/namespace';
import $allSettled from 'Portainer/services/allSettled'; import $allSettled from 'Portainer/services/allSettled';
import { getSelfSubjectAccessReview } from '@/react/kubernetes/namespaces/getSelfSubjectAccessReview';
class KubernetesNamespaceService { class KubernetesNamespaceService {
/* @ngInject */ /* @ngInject */
constructor($async, KubernetesNamespaces, LocalStorage) { constructor($async, KubernetesNamespaces, LocalStorage, $state) {
this.$async = $async; this.$async = $async;
this.$state = $state;
this.KubernetesNamespaces = KubernetesNamespaces; this.KubernetesNamespaces = KubernetesNamespaces;
this.LocalStorage = LocalStorage; this.LocalStorage = LocalStorage;
@ -66,8 +68,10 @@ class KubernetesNamespaceService {
try { try {
// get the list of all namespaces (RBAC allows users to see the list of namespaces) // get the list of all namespaces (RBAC allows users to see the list of namespaces)
const data = await this.KubernetesNamespaces().get().$promise; const data = await this.KubernetesNamespaces().get().$promise;
// get the status of each namespace (RBAC will give permission denied for status of unauthorised namespaces) // get the status of each namespace with accessReviews (to avoid failed forbidden responses, which aren't cached)
const promises = data.items.map((item) => this.KubernetesNamespaces().status({ id: item.metadata.name }).$promise); 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); const namespaces = await $allSettled(promises);
// only return namespaces if the user has access to namespaces // only return namespaces if the user has access to namespaces
const allNamespaces = namespaces.fulfilled.map((item) => { const allNamespaces = namespaces.fulfilled.map((item) => {

View File

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

View File

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

View File

@ -12,6 +12,7 @@ export function UserViewModel(data) {
} }
this.AuthenticationMethod = data.AuthenticationMethod; this.AuthenticationMethod = data.AuthenticationMethod;
this.Checked = false; this.Checked = false;
this.UseCache = data.UseCache;
} }
export function UserTokenModel(data) { 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 { environmentsModule } from './environments';
import { envListModule } from './environments-list-view-components'; import { envListModule } from './environments-list-view-components';
import { registriesModule } from './registries'; import { registriesModule } from './registries';
import { accountModule } from './account';
export const ngModule = angular export const ngModule = angular
.module('portainer.app.react.components', [ .module('portainer.app.react.components', [
@ -55,6 +56,7 @@ export const ngModule = angular
gitFormModule, gitFormModule,
registriesModule, registriesModule,
settingsModule, settingsModule,
accountModule,
]) ])
.component( .component(
'tagSelector', 'tagSelector',

View File

@ -1,4 +1,5 @@
import axiosOrigin, { AxiosError, AxiosRequestConfig } from 'axios'; import axiosOrigin, { AxiosError, AxiosRequestConfig } from 'axios';
import { setupCache } from 'axios-cache-adapter';
import { loadProgressBar } from 'axios-progress-bar'; import { loadProgressBar } from 'axios-progress-bar';
import 'axios-progress-bar/dist/nprogress.css'; 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 { get as localStorageGet } from '@/react/hooks/useLocalStorage';
import { import {
CACHE_DURATION,
dispatchCacheRefreshEventIfNeeded,
portainerAgentManagerOperation, portainerAgentManagerOperation,
portainerAgentTargetHeader, portainerAgentTargetHeader,
} from './http-request.helper'; } 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' }); 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); loadProgressBar(undefined, axios);
export default 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 { interface Headers {
agentTargetQueue: string[]; agentTargetQueue: string[];
agentManagerOperation: boolean; agentManagerOperation: boolean;

View File

@ -1,6 +1,6 @@
import { UserId } from '../types'; import { UserId } from '../types';
export const queryKeys = { export const userQueryKeys = {
base: () => ['users'] as const, 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 { buildUrl } from '../user.service';
import { User, UserId } from '../types'; import { User, UserId } from '../types';
import { queryKeys } from './queryKeys'; import { userQueryKeys } from './queryKeys';
export function useUser( export function useUser(
id: UserId, id: UserId,
{ staleTime }: { staleTime?: number } = {} { staleTime }: { staleTime?: number } = {}
) { ) {
return useQuery(queryKeys.user(id), () => getUser(id), { return useQuery(userQueryKeys.user(id), () => getUser(id), {
...withError('Unable to retrieve user details'), ...withError('Unable to retrieve user details'),
staleTime, staleTime,
}); });

View File

@ -20,6 +20,7 @@ export type User = {
EndpointAuthorizations: { EndpointAuthorizations: {
[endpointId: EnvironmentId]: AuthorizationMap; [endpointId: EnvironmentId]: AuthorizationMap;
}; };
UseCache: boolean;
ThemeSettings: { ThemeSettings: {
color: 'dark' | 'light' | 'highcontrast' | 'auto'; 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> <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"> <form name="form" class="form-horizontal" style="margin-top: 15px">
<!-- current-password-input --> <!-- current-password-input -->
<div class="form-group"> <div class="form-group">
<label for="current_password" class="col-sm-2 control-label required text-left">Current password</label> <label for="current_password" class="col-sm-3 col-lg-2 control-label required text-left">Current password</label>
<div class="col-sm-8"> <div class="col-sm-9 col-lg-10">
<input type="password" class="form-control" ng-model="formValues.currentPassword" id="current_password" /> <input type="password" class="form-control" ng-model="formValues.currentPassword" id="current_password" />
</div> </div>
</div> </div>
<!-- !current-password-input --> <!-- !current-password-input -->
<!-- new-password-input --> <!-- new-password-input -->
<div class="form-group"> <div class="form-group">
<label for="new_password" class="col-sm-2 control-label required text-left">New password</label> <label for="new_password" class="col-sm-3 col-lg-2 control-label required text-left">New password</label>
<div class="col-sm-8"> <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" /> <input type="password" class="form-control" ng-model="formValues.newPassword" ng-minlength="requiredPasswordLength" id="new_password" name="new_password" />
</div> </div>
</div> </div>
@ -33,8 +33,8 @@
<!-- confirm-password-input --> <!-- confirm-password-input -->
<div class="form-group"> <div class="form-group">
<label for="confirm_password" class="col-sm-2 control-label required text-left">Confirm password</label> <label for="confirm_password" class="col-sm-3 col-lg-2 control-label required text-left">Confirm password</label>
<div class="col-sm-8"> <div class="col-sm-9 col-lg-10">
<div class="input-group"> <div class="input-group">
<input type="password" class="form-control" ng-model="formValues.confirmPassword" id="confirm_password" /> <input type="password" class="form-control" ng-model="formValues.confirmPassword" id="confirm_password" />
<span class="input-group-addon"> <span class="input-group-addon">
@ -86,6 +86,8 @@
</div> </div>
</div> </div>
<application-settings-widget></application-settings-widget>
<div class="row"> <div class="row">
<div class="col-lg-12 col-md-12 col-xs-12"> <div class="col-lg-12 col-md-12 col-xs-12">
<access-tokens-datatable <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="row">
<div class="col-sm-12"> <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="row" ng-if="!$ctrl.state.loading">
<div class="col-sm-12"> <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="row">
<div class="col-sm-12"> <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="row">
<div class="col-sm-12"> <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="row">
<div class="col-sm-12"> <div class="col-sm-12">

View File

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

View File

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

View File

@ -7,6 +7,7 @@
link: 'portainer.groups.group', link: 'portainer.groups.group',
linkParams:{id: group.Id} linkParams:{id: group.Id}
}, 'Access management']" }, 'Access management']"
reload="true"
> >
</page-header> </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="row">
<div class="col-sm-12"> <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="row">
<div class="col-sm-12"> <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="row">
<div class="col-sm-12"> <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="row" ng-if="!$ctrl.state.loading">
<div class="col-sm-12"> <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="row">
<div class="col-sm-12"> <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="row">
<div class="col-sm-12" ng-if="$ctrl.settings"> <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="row">
<div class="col-sm-12"> <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="row">
<div class="col-lg-12 col-md-12 col-xs-12"> <div class="col-lg-12 col-md-12 col-xs-12">

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -573,6 +573,7 @@ export function CreateIngressView() {
label: isEdit ? 'Edit ingress' : 'Create ingress', label: isEdit ? 'Edit ingress' : 'Create ingress',
}, },
]} ]}
reload
/> />
<div className="row ingress-rules"> <div className="row ingress-rules">
<div className="col-sm-12"> <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: 'My account', link: 'portainer.account' },
{ label: 'Create Helm repository' }, { label: 'Create Helm repository' },
]} ]}
reload
/> />
<div className="row"> <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' }, { label: 'Environments', link: 'portainer.endpoints' },
'Automatic Edge Environment Creation', 'Automatic Edge Environment Creation',
]} ]}
reload
/> />
<div className="mx-3"> <div className="mx-3">

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -75,6 +75,7 @@
"angularjs-slider": "^6.4.0", "angularjs-slider": "^6.4.0",
"angulartics": "^1.6.0", "angulartics": "^1.6.0",
"axios": "^0.24.0", "axios": "^0.24.0",
"axios-cache-adapter": "^2.7.3",
"axios-progress-bar": "^1.2.0", "axios-progress-bar": "^1.2.0",
"babel-plugin-angularjs-annotate": "^0.10.0", "babel-plugin-angularjs-annotate": "^0.10.0",
"bootstrap": "^3.4.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" resolved "https://registry.yarnpkg.com/axe-core/-/axe-core-4.7.0.tgz#34ba5a48a8b564f67e103f0aa5768d76e15bbbbf"
integrity sha512-M0JtH+hlOL5pLQwHOLNYZaXuhqmvS8oExsqB1SBYgA4Dk7u/xx+YdGHXaK5pyUfed5mYXdlYiphWq3G8cRi5JQ== 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: axios-progress-bar@^1.2.0:
version "1.2.0" version "1.2.0"
resolved "https://registry.yarnpkg.com/axios-progress-bar/-/axios-progress-bar-1.2.0.tgz#f9ee88dc9af977246be1ef07eedfa4c990c639c5" 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 "^16.2.0"
yargs-parser "^20.2.9" 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: call-bind@^1.0.0, call-bind@^1.0.2:
version "1.0.2" version "1.0.2"
resolved "https://registry.yarnpkg.com/call-bind/-/call-bind-1.0.2.tgz#b1d4e89e688119c3c9a903ad30abb2f6a919be3c" resolved "https://registry.yarnpkg.com/call-bind/-/call-bind-1.0.2.tgz#b1d4e89e688119c3c9a903ad30abb2f6a919be3c"