mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
@grafana/runtime: User storage (#95834)
This commit is contained in:
parent
fb0fff6de1
commit
a41902330f
@ -56,3 +56,4 @@ export { type EmbeddedDashboardProps, EmbeddedDashboard, setEmbeddedDashboard }
|
|||||||
export { hasPermission, hasPermissionInMetadata, hasAllPermissions, hasAnyPermission } from './utils/rbac';
|
export { hasPermission, hasPermissionInMetadata, hasAllPermissions, hasAnyPermission } from './utils/rbac';
|
||||||
export { QueryEditorWithMigration } from './components/QueryEditorWithMigration';
|
export { QueryEditorWithMigration } from './components/QueryEditorWithMigration';
|
||||||
export { type MigrationHandler, isMigrationHandler, migrateQuery, migrateRequest } from './utils/migrationHandler';
|
export { type MigrationHandler, isMigrationHandler, migrateQuery, migrateRequest } from './utils/migrationHandler';
|
||||||
|
export { usePluginUserStorage } from './utils/userStorage';
|
||||||
|
145
packages/grafana-runtime/src/utils/userStorage.test.tsx
Normal file
145
packages/grafana-runtime/src/utils/userStorage.test.tsx
Normal file
@ -0,0 +1,145 @@
|
|||||||
|
import { cloneDeep } from 'lodash';
|
||||||
|
import { of } from 'rxjs';
|
||||||
|
|
||||||
|
import { config } from '../config';
|
||||||
|
import { BackendSrvRequest, FetchError, FetchResponse, BackendSrv } from '../services';
|
||||||
|
|
||||||
|
import { usePluginUserStorage } from './userStorage';
|
||||||
|
|
||||||
|
const request = jest.fn<Promise<FetchResponse | FetchError>, BackendSrvRequest[]>();
|
||||||
|
|
||||||
|
const backendSrv = {
|
||||||
|
fetch: (options: BackendSrvRequest) => {
|
||||||
|
return of(request(options));
|
||||||
|
},
|
||||||
|
} as unknown as BackendSrv;
|
||||||
|
|
||||||
|
jest.mock('../services', () => ({
|
||||||
|
...jest.requireActual('../services'),
|
||||||
|
getBackendSrv: () => backendSrv,
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('@grafana/data', () => ({
|
||||||
|
...jest.requireActual('@grafana/data'),
|
||||||
|
usePluginContext: jest.fn().mockReturnValue({ meta: { id: 'plugin-id' } }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('userStorage', () => {
|
||||||
|
const originalGetItem = Storage.prototype.getItem;
|
||||||
|
const originalSetItem = Storage.prototype.setItem;
|
||||||
|
const originalConfig = cloneDeep(config);
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
config.featureToggles.userStorageAPI = true;
|
||||||
|
config.bootData.user.isSignedIn = true;
|
||||||
|
config.bootData.user.uid = 'abc';
|
||||||
|
request.mockReset();
|
||||||
|
Storage.prototype.setItem = jest.fn();
|
||||||
|
Storage.prototype.getItem = jest.fn();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
Storage.prototype.setItem = originalSetItem;
|
||||||
|
Storage.prototype.getItem = originalGetItem;
|
||||||
|
config.featureToggles = originalConfig.featureToggles;
|
||||||
|
config.bootData = originalConfig.bootData;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('UserStorageAPI.getItem', () => {
|
||||||
|
it('use localStorage if the feature flag is disabled', async () => {
|
||||||
|
config.featureToggles.userStorageAPI = false;
|
||||||
|
const storage = usePluginUserStorage();
|
||||||
|
storage.getItem('key');
|
||||||
|
expect(localStorage.getItem).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('use localStorage if the user is not logged in', async () => {
|
||||||
|
config.bootData.user.isSignedIn = false;
|
||||||
|
const storage = usePluginUserStorage();
|
||||||
|
storage.getItem('key');
|
||||||
|
expect(localStorage.getItem).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('use localStorage if the user storage is not found', async () => {
|
||||||
|
request.mockReturnValue(Promise.reject({ status: 404 } as FetchError));
|
||||||
|
const storage = usePluginUserStorage();
|
||||||
|
await storage.getItem('key');
|
||||||
|
expect(localStorage.getItem).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the value from the user storage', async () => {
|
||||||
|
request.mockReturnValue(
|
||||||
|
Promise.resolve({ status: 200, data: { spec: { data: { key: 'value' } } } } as FetchResponse)
|
||||||
|
);
|
||||||
|
const storage = usePluginUserStorage();
|
||||||
|
const value = await storage.getItem('key');
|
||||||
|
expect(value).toBe('value');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('setItem', () => {
|
||||||
|
it('use localStorage if the feature flag is disabled', async () => {
|
||||||
|
config.featureToggles.userStorageAPI = false;
|
||||||
|
const storage = usePluginUserStorage();
|
||||||
|
storage.setItem('key', 'value');
|
||||||
|
expect(localStorage.setItem).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('use localStorage if the user is not logged in', async () => {
|
||||||
|
config.bootData.user.isSignedIn = false;
|
||||||
|
const storage = usePluginUserStorage();
|
||||||
|
storage.setItem('key', 'value');
|
||||||
|
expect(localStorage.setItem).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates a new user storage if it does not exist', async () => {
|
||||||
|
request.mockReturnValueOnce(Promise.reject({ status: 404 } as FetchError));
|
||||||
|
const storage = usePluginUserStorage();
|
||||||
|
await storage.setItem('key', 'value');
|
||||||
|
expect(request).toHaveBeenCalledWith({
|
||||||
|
url: '/apis/userstorage.grafana.app/v0alpha1/namespaces/default/user-storage/plugin-id:abc',
|
||||||
|
method: 'GET',
|
||||||
|
showErrorAlert: false,
|
||||||
|
});
|
||||||
|
expect(request).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
url: '/apis/userstorage.grafana.app/v0alpha1/namespaces/default/user-storage/',
|
||||||
|
method: 'POST',
|
||||||
|
data: {
|
||||||
|
metadata: { labels: { service: 'plugin-id', user: 'abc' }, name: 'plugin-id:abc' },
|
||||||
|
spec: {
|
||||||
|
data: { key: 'value' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates the user storage if it exists', async () => {
|
||||||
|
request.mockReturnValueOnce(
|
||||||
|
Promise.resolve({
|
||||||
|
status: 200,
|
||||||
|
data: { metadata: { name: 'service:abc' }, spec: { data: { key: 'value' } } },
|
||||||
|
} as FetchResponse)
|
||||||
|
);
|
||||||
|
const storage = usePluginUserStorage();
|
||||||
|
await storage.setItem('key', 'new-value');
|
||||||
|
expect(request).toHaveBeenCalledWith({
|
||||||
|
url: '/apis/userstorage.grafana.app/v0alpha1/namespaces/default/user-storage/plugin-id:abc',
|
||||||
|
method: 'GET',
|
||||||
|
showErrorAlert: false,
|
||||||
|
});
|
||||||
|
expect(request).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
url: '/apis/userstorage.grafana.app/v0alpha1/namespaces/default/user-storage/plugin-id:abc',
|
||||||
|
method: 'PATCH',
|
||||||
|
data: {
|
||||||
|
spec: {
|
||||||
|
data: { key: 'new-value' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
153
packages/grafana-runtime/src/utils/userStorage.tsx
Normal file
153
packages/grafana-runtime/src/utils/userStorage.tsx
Normal file
@ -0,0 +1,153 @@
|
|||||||
|
import { get } from 'lodash';
|
||||||
|
import { lastValueFrom } from 'rxjs';
|
||||||
|
|
||||||
|
import { usePluginContext } from '@grafana/data';
|
||||||
|
|
||||||
|
import { config } from '../config';
|
||||||
|
import { BackendSrvRequest, getBackendSrv } from '../services';
|
||||||
|
|
||||||
|
const baseURL = `/apis/userstorage.grafana.app/v0alpha1/namespaces/${config.namespace}/user-storage`;
|
||||||
|
|
||||||
|
interface RequestOptions extends BackendSrvRequest {
|
||||||
|
manageError?: (err: unknown) => { error: unknown };
|
||||||
|
showErrorAlert?: boolean;
|
||||||
|
|
||||||
|
// rtk codegen sets this
|
||||||
|
body?: BackendSrvRequest['data'];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UserStorageSpec = {
|
||||||
|
data: { [key: string]: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
async function apiRequest<T>(requestOptions: RequestOptions) {
|
||||||
|
try {
|
||||||
|
const { data: responseData, ...meta } = await lastValueFrom(
|
||||||
|
getBackendSrv().fetch<T>({
|
||||||
|
...requestOptions,
|
||||||
|
url: baseURL + requestOptions.url,
|
||||||
|
data: requestOptions.body,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
return { data: responseData, meta };
|
||||||
|
} catch (error) {
|
||||||
|
return requestOptions.manageError ? requestOptions.manageError(error) : { error };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A class for interacting with the backend user storage.
|
||||||
|
* Unexported because it is currently only be used through the useUserStorage hook.
|
||||||
|
*/
|
||||||
|
class UserStorage {
|
||||||
|
private service: string;
|
||||||
|
private resourceName: string;
|
||||||
|
private userUID: string;
|
||||||
|
private canUseUserStorage: boolean;
|
||||||
|
private storageSpec: UserStorageSpec | null | undefined;
|
||||||
|
|
||||||
|
constructor(service: string) {
|
||||||
|
this.service = service;
|
||||||
|
this.userUID = config.bootData.user.uid === '' ? config.bootData.user.id.toString() : config.bootData.user.uid;
|
||||||
|
this.resourceName = `${service}:${this.userUID}`;
|
||||||
|
this.canUseUserStorage = config.featureToggles.userStorageAPI === true && config.bootData.user.isSignedIn;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async init() {
|
||||||
|
if (this.storageSpec !== undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const userStorage = await apiRequest<{ spec: UserStorageSpec }>({
|
||||||
|
url: `/${this.resourceName}`,
|
||||||
|
method: 'GET',
|
||||||
|
showErrorAlert: false,
|
||||||
|
});
|
||||||
|
if ('error' in userStorage) {
|
||||||
|
if (get(userStorage, 'error.status') !== 404) {
|
||||||
|
console.error('Failed to get user storage', userStorage.error);
|
||||||
|
}
|
||||||
|
// No user storage found, return null
|
||||||
|
this.storageSpec = null;
|
||||||
|
} else {
|
||||||
|
this.storageSpec = userStorage.data.spec;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getItem(key: string): Promise<string | null> {
|
||||||
|
if (!this.canUseUserStorage) {
|
||||||
|
// Fallback to localStorage
|
||||||
|
return localStorage.getItem(this.resourceName);
|
||||||
|
}
|
||||||
|
// Ensure this.storageSpec is initialized
|
||||||
|
await this.init();
|
||||||
|
if (!this.storageSpec) {
|
||||||
|
// Also, fallback to localStorage for backward compatibility once userStorageAPI is enabled
|
||||||
|
return localStorage.getItem(this.resourceName);
|
||||||
|
}
|
||||||
|
return this.storageSpec.data[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
async setItem(key: string, value: string): Promise<void> {
|
||||||
|
if (!this.canUseUserStorage) {
|
||||||
|
// Fallback to localStorage
|
||||||
|
localStorage.setItem(key, value);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newData = { data: { [key]: value } };
|
||||||
|
// Ensure this.storageSpec is initialized
|
||||||
|
await this.init();
|
||||||
|
|
||||||
|
if (!this.storageSpec) {
|
||||||
|
// No user storage found, create a new one
|
||||||
|
await apiRequest<UserStorageSpec>({
|
||||||
|
url: `/`,
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
metadata: { name: this.resourceName, labels: { user: this.userUID, service: this.service } },
|
||||||
|
spec: newData,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
this.storageSpec = newData;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update existing user storage
|
||||||
|
this.storageSpec.data[key] = value;
|
||||||
|
await apiRequest<UserStorageSpec>({
|
||||||
|
headers: { 'Content-Type': 'application/merge-patch+json' },
|
||||||
|
url: `/${this.resourceName}`,
|
||||||
|
method: 'PATCH',
|
||||||
|
body: { spec: newData },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PluginUserStorage {
|
||||||
|
/**
|
||||||
|
* Retrieves an item from the backend user storage or local storage if not enabled.
|
||||||
|
* @param key - The key of the item to retrieve.
|
||||||
|
* @returns A promise that resolves to the item value or null if not found.
|
||||||
|
*/
|
||||||
|
getItem(key: string): Promise<string | null>;
|
||||||
|
/**
|
||||||
|
* Sets an item in the backend user storage or local storage if not enabled.
|
||||||
|
* @param key - The key of the item to set.
|
||||||
|
* @param value - The value of the item to set.
|
||||||
|
* @returns A promise that resolves when the item is set.
|
||||||
|
*/
|
||||||
|
setItem(key: string, value: string): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A hook for interacting with the backend user storage (or local storage if not enabled).
|
||||||
|
* @returns An scoped object for a plugin and a user with getItem and setItem functions.
|
||||||
|
* @alpha Experimental
|
||||||
|
*/
|
||||||
|
export function usePluginUserStorage(): PluginUserStorage {
|
||||||
|
const context = usePluginContext();
|
||||||
|
if (!context) {
|
||||||
|
throw new Error(`No PluginContext found. The usePluginUserStorage() hook can only be used from a plugin.`);
|
||||||
|
}
|
||||||
|
return new UserStorage(context?.meta.id);
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user