mirror of
https://github.com/lensapp/lens.git
synced 2025-05-20 05:10:56 +00:00
fix: namespace-store refactoring / saving selected-namespaces to external json-file
Signed-off-by: Roman <ixrock@gmail.com>
This commit is contained in:
parent
f96b789280
commit
3357269c5d
@ -3,12 +3,10 @@ import { observer } from "mobx-react";
|
|||||||
import { TabLayout, TabLayoutRoute } from "../layout/tab-layout";
|
import { TabLayout, TabLayoutRoute } from "../layout/tab-layout";
|
||||||
import { HelmCharts, helmChartsRoute, helmChartsURL } from "../+apps-helm-charts";
|
import { HelmCharts, helmChartsRoute, helmChartsURL } from "../+apps-helm-charts";
|
||||||
import { HelmReleases, releaseRoute, releaseURL } from "../+apps-releases";
|
import { HelmReleases, releaseRoute, releaseURL } from "../+apps-releases";
|
||||||
import { namespaceUrlParam } from "../+namespaces/namespace.store";
|
|
||||||
|
|
||||||
@observer
|
@observer
|
||||||
export class Apps extends React.Component {
|
export class Apps extends React.Component {
|
||||||
static get tabRoutes(): TabLayoutRoute[] {
|
static get tabRoutes(): TabLayoutRoute[] {
|
||||||
const query = namespaceUrlParam.toObjectParam();
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
@ -20,7 +18,7 @@ export class Apps extends React.Component {
|
|||||||
{
|
{
|
||||||
title: "Releases",
|
title: "Releases",
|
||||||
component: HelmReleases,
|
component: HelmReleases,
|
||||||
url: releaseURL({ query }),
|
url: releaseURL(),
|
||||||
routePath: releaseRoute.path.toString(),
|
routePath: releaseRoute.path.toString(),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@ -3,7 +3,6 @@ import { observer } from "mobx-react";
|
|||||||
import { TabLayout, TabLayoutRoute } from "../layout/tab-layout";
|
import { TabLayout, TabLayoutRoute } from "../layout/tab-layout";
|
||||||
import { ConfigMaps, configMapsRoute, configMapsURL } from "../+config-maps";
|
import { ConfigMaps, configMapsRoute, configMapsURL } from "../+config-maps";
|
||||||
import { Secrets, secretsRoute, secretsURL } from "../+config-secrets";
|
import { Secrets, secretsRoute, secretsURL } from "../+config-secrets";
|
||||||
import { namespaceUrlParam } from "../+namespaces/namespace.store";
|
|
||||||
import { resourceQuotaRoute, ResourceQuotas, resourceQuotaURL } from "../+config-resource-quotas";
|
import { resourceQuotaRoute, ResourceQuotas, resourceQuotaURL } from "../+config-resource-quotas";
|
||||||
import { pdbRoute, pdbURL, PodDisruptionBudgets } from "../+config-pod-disruption-budgets";
|
import { pdbRoute, pdbURL, PodDisruptionBudgets } from "../+config-pod-disruption-budgets";
|
||||||
import { HorizontalPodAutoscalers, hpaRoute, hpaURL } from "../+config-autoscalers";
|
import { HorizontalPodAutoscalers, hpaRoute, hpaURL } from "../+config-autoscalers";
|
||||||
@ -13,14 +12,13 @@ import { LimitRanges, limitRangesRoute, limitRangeURL } from "../+config-limit-r
|
|||||||
@observer
|
@observer
|
||||||
export class Config extends React.Component {
|
export class Config extends React.Component {
|
||||||
static get tabRoutes(): TabLayoutRoute[] {
|
static get tabRoutes(): TabLayoutRoute[] {
|
||||||
const query = namespaceUrlParam.toObjectParam();
|
|
||||||
const routes: TabLayoutRoute[] = [];
|
const routes: TabLayoutRoute[] = [];
|
||||||
|
|
||||||
if (isAllowedResource("configmaps")) {
|
if (isAllowedResource("configmaps")) {
|
||||||
routes.push({
|
routes.push({
|
||||||
title: "ConfigMaps",
|
title: "ConfigMaps",
|
||||||
component: ConfigMaps,
|
component: ConfigMaps,
|
||||||
url: configMapsURL({ query }),
|
url: configMapsURL(),
|
||||||
routePath: configMapsRoute.path.toString(),
|
routePath: configMapsRoute.path.toString(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -29,7 +27,7 @@ export class Config extends React.Component {
|
|||||||
routes.push({
|
routes.push({
|
||||||
title: "Secrets",
|
title: "Secrets",
|
||||||
component: Secrets,
|
component: Secrets,
|
||||||
url: secretsURL({ query }),
|
url: secretsURL(),
|
||||||
routePath: secretsRoute.path.toString(),
|
routePath: secretsRoute.path.toString(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -38,7 +36,7 @@ export class Config extends React.Component {
|
|||||||
routes.push({
|
routes.push({
|
||||||
title: "Resource Quotas",
|
title: "Resource Quotas",
|
||||||
component: ResourceQuotas,
|
component: ResourceQuotas,
|
||||||
url: resourceQuotaURL({ query }),
|
url: resourceQuotaURL(),
|
||||||
routePath: resourceQuotaRoute.path.toString(),
|
routePath: resourceQuotaRoute.path.toString(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -47,7 +45,7 @@ export class Config extends React.Component {
|
|||||||
routes.push({
|
routes.push({
|
||||||
title: "Limit Ranges",
|
title: "Limit Ranges",
|
||||||
component: LimitRanges,
|
component: LimitRanges,
|
||||||
url: limitRangeURL({ query }),
|
url: limitRangeURL(),
|
||||||
routePath: limitRangesRoute.path.toString(),
|
routePath: limitRangesRoute.path.toString(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -56,7 +54,7 @@ export class Config extends React.Component {
|
|||||||
routes.push({
|
routes.push({
|
||||||
title: "HPA",
|
title: "HPA",
|
||||||
component: HorizontalPodAutoscalers,
|
component: HorizontalPodAutoscalers,
|
||||||
url: hpaURL({ query }),
|
url: hpaURL(),
|
||||||
routePath: hpaRoute.path.toString(),
|
routePath: hpaRoute.path.toString(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -65,7 +63,7 @@ export class Config extends React.Component {
|
|||||||
routes.push({
|
routes.push({
|
||||||
title: "Pod Disruption Budgets",
|
title: "Pod Disruption Budgets",
|
||||||
component: PodDisruptionBudgets,
|
component: PodDisruptionBudgets,
|
||||||
url: pdbURL({ query }),
|
url: pdbURL(),
|
||||||
routePath: pdbRoute.path.toString(),
|
routePath: pdbRoute.path.toString(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,35 +1,18 @@
|
|||||||
import { action, comparer, computed, IReactionDisposer, IReactionOptions, makeObservable, observable, reaction, } from "mobx";
|
import { action, comparer, computed, IReactionDisposer, IReactionOptions, makeObservable, reaction, when, } from "mobx";
|
||||||
import { autoBind, createStorage } from "../../utils";
|
import { autoBind, createStorage } from "../../utils";
|
||||||
import { KubeObjectStore, KubeObjectStoreLoadingParams } from "../../kube-object.store";
|
import { KubeObjectStore, KubeObjectStoreLoadingParams } from "../../kube-object.store";
|
||||||
import { Namespace, namespacesApi } from "../../api/endpoints/namespaces.api";
|
import { Namespace, namespacesApi } from "../../api/endpoints/namespaces.api";
|
||||||
import { createPageParam } from "../../navigation";
|
|
||||||
import { apiManager } from "../../api/api-manager";
|
import { apiManager } from "../../api/api-manager";
|
||||||
|
|
||||||
// FIXME: something fishy with sync selected-namespaces with URL
|
|
||||||
const selectedNamespaces = createStorage<string[] | undefined>("selected_namespaces", undefined);
|
|
||||||
|
|
||||||
export const namespaceUrlParam = createPageParam<string[]>({
|
|
||||||
name: "namespaces",
|
|
||||||
defaultValue: [],
|
|
||||||
});
|
|
||||||
|
|
||||||
export function getDummyNamespace(name: string) {
|
|
||||||
return new Namespace({
|
|
||||||
kind: Namespace.kind,
|
|
||||||
apiVersion: "v1",
|
|
||||||
metadata: {
|
|
||||||
name,
|
|
||||||
uid: "",
|
|
||||||
resourceVersion: "",
|
|
||||||
selfLink: `/api/v1/namespaces/${name}`
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export class NamespaceStore extends KubeObjectStore<Namespace> {
|
export class NamespaceStore extends KubeObjectStore<Namespace> {
|
||||||
api = namespacesApi;
|
api = namespacesApi;
|
||||||
|
|
||||||
@observable private contextNs = observable.set<string>();
|
private defaultNamespaces: string[] = [];
|
||||||
|
private storage = createStorage<string[]>("selected_namespaces", this.defaultNamespaces);
|
||||||
|
|
||||||
|
@computed get selectedNamespaces(): string[] {
|
||||||
|
return this.storage.get();
|
||||||
|
}
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
@ -41,29 +24,19 @@ export class NamespaceStore extends KubeObjectStore<Namespace> {
|
|||||||
|
|
||||||
private async init() {
|
private async init() {
|
||||||
await this.contextReady;
|
await this.contextReady;
|
||||||
await selectedNamespaces.whenReady;
|
await when(() => this.storage.initialized);
|
||||||
|
|
||||||
this.setContext(this.initialNamespaces);
|
this.setContext(this.initialNamespaces);
|
||||||
this.autoLoadAllowedNamespaces();
|
this.autoLoadAllowedNamespaces();
|
||||||
this.autoUpdateUrlAndLocalStorage();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public onContextChange(callback: (contextNamespaces: string[]) => void, opts: IReactionOptions = {}): IReactionDisposer {
|
public onContextChange(callback: (namespaces: string[]) => void, opts: IReactionOptions = {}): IReactionDisposer {
|
||||||
return reaction(() => Array.from(this.contextNs), callback, {
|
return reaction(() => Array.from(this.selectedNamespaces), callback, {
|
||||||
equals: comparer.shallow,
|
equals: comparer.shallow,
|
||||||
...opts,
|
...opts,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private autoUpdateUrlAndLocalStorage(): IReactionDisposer {
|
|
||||||
return this.onContextChange(namespaces => {
|
|
||||||
selectedNamespaces.set(namespaces); // save to local-storage
|
|
||||||
namespaceUrlParam.set(namespaces, { replaceHistory: true }); // update url
|
|
||||||
}, {
|
|
||||||
fireImmediately: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private autoLoadAllowedNamespaces(): IReactionDisposer {
|
private autoLoadAllowedNamespaces(): IReactionDisposer {
|
||||||
return reaction(() => this.allowedNamespaces, namespaces => this.loadAll({ namespaces }), {
|
return reaction(() => this.allowedNamespaces, namespaces => this.loadAll({ namespaces }), {
|
||||||
fireImmediately: true,
|
fireImmediately: true,
|
||||||
@ -72,19 +45,18 @@ export class NamespaceStore extends KubeObjectStore<Namespace> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private get initialNamespaces(): string[] {
|
private get initialNamespaces(): string[] {
|
||||||
const namespaces = new Set(this.allowedNamespaces);
|
const { allowedNamespaces, selectedNamespaces, defaultNamespaces } = this;
|
||||||
const prevSelectedNamespaces = selectedNamespaces.get();
|
|
||||||
|
|
||||||
// return previously saved namespaces from local-storage (if any)
|
// return previously saved namespaces from local-storage (if any)
|
||||||
if (prevSelectedNamespaces) {
|
if (selectedNamespaces !== defaultNamespaces) {
|
||||||
return prevSelectedNamespaces.filter(namespace => namespaces.has(namespace));
|
return selectedNamespaces.filter(namespace => allowedNamespaces.includes(namespace));
|
||||||
}
|
}
|
||||||
|
|
||||||
// otherwise select "default" or first allowed namespace
|
// otherwise select "default" or first allowed namespace
|
||||||
if (namespaces.has("default")) {
|
if (allowedNamespaces.includes("default")) {
|
||||||
return ["default"];
|
return ["default"];
|
||||||
} else if (namespaces.size) {
|
} else if (allowedNamespaces.length) {
|
||||||
return [Array.from(namespaces)[0]];
|
return [allowedNamespaces[0]];
|
||||||
}
|
}
|
||||||
|
|
||||||
return [];
|
return [];
|
||||||
@ -98,13 +70,11 @@ export class NamespaceStore extends KubeObjectStore<Namespace> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@computed get contextNamespaces(): string[] {
|
@computed get contextNamespaces(): string[] {
|
||||||
const namespaces = Array.from(this.contextNs);
|
if (!this.selectedNamespaces.length) {
|
||||||
|
|
||||||
if (!namespaces.length) {
|
|
||||||
return this.allowedNamespaces; // show all namespaces when nothing selected
|
return this.allowedNamespaces; // show all namespaces when nothing selected
|
||||||
}
|
}
|
||||||
|
|
||||||
return namespaces;
|
return this.selectedNamespaces;
|
||||||
}
|
}
|
||||||
|
|
||||||
getSubscribeApis() {
|
getSubscribeApis() {
|
||||||
@ -132,30 +102,39 @@ export class NamespaceStore extends KubeObjectStore<Namespace> {
|
|||||||
|
|
||||||
@action
|
@action
|
||||||
setContext(namespace: string | string[]) {
|
setContext(namespace: string | string[]) {
|
||||||
const namespaces = [namespace].flat();
|
const namespaces = Array.from(new Set([namespace].flat()));
|
||||||
|
|
||||||
this.contextNs.replace(namespaces);
|
this.storage.set(namespaces);
|
||||||
}
|
}
|
||||||
|
|
||||||
@action
|
@action
|
||||||
resetContext() {
|
resetContext(namespaces?: string | string[]) {
|
||||||
this.contextNs.clear();
|
if (namespaces) {
|
||||||
|
const resettingNamespaces = [namespaces].flat();
|
||||||
|
const newNamespaces = this.storage.get().filter(ns => !resettingNamespaces.includes(ns));
|
||||||
|
|
||||||
|
this.storage.set(newNamespaces);
|
||||||
|
} else {
|
||||||
|
this.storage.reset();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
hasContext(namespaces: string | string[]) {
|
hasContext(namespaces: string | string[]): boolean {
|
||||||
return [namespaces].flat().every(namespace => this.contextNs.has(namespace));
|
return [namespaces]
|
||||||
|
.flat()
|
||||||
|
.every(namespace => this.selectedNamespaces.includes(namespace));
|
||||||
}
|
}
|
||||||
|
|
||||||
@computed get hasAllContexts(): boolean {
|
@computed get hasAllContexts(): boolean {
|
||||||
return this.contextNs.size === this.allowedNamespaces.length;
|
return this.selectedNamespaces.length === this.allowedNamespaces.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
@action
|
@action
|
||||||
toggleContext(namespace: string) {
|
toggleContext(namespaces: string | string[]) {
|
||||||
if (this.hasContext(namespace)) {
|
if (this.hasContext(namespaces)) {
|
||||||
this.contextNs.delete(namespace);
|
this.resetContext(namespaces);
|
||||||
} else {
|
} else {
|
||||||
this.contextNs.add(namespace);
|
this.setContext([this.selectedNamespaces, namespaces].flat());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -175,9 +154,22 @@ export class NamespaceStore extends KubeObjectStore<Namespace> {
|
|||||||
@action
|
@action
|
||||||
async remove(item: Namespace) {
|
async remove(item: Namespace) {
|
||||||
await super.remove(item);
|
await super.remove(item);
|
||||||
this.contextNs.delete(item.getName());
|
this.resetContext(item.getName());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const namespaceStore = new NamespaceStore();
|
export const namespaceStore = new NamespaceStore();
|
||||||
apiManager.registerStore(namespaceStore);
|
apiManager.registerStore(namespaceStore);
|
||||||
|
|
||||||
|
export function getDummyNamespace(name: string) {
|
||||||
|
return new Namespace({
|
||||||
|
kind: Namespace.kind,
|
||||||
|
apiVersion: "v1",
|
||||||
|
metadata: {
|
||||||
|
name,
|
||||||
|
uid: "",
|
||||||
|
resourceVersion: "",
|
||||||
|
selfLink: `/api/v1/namespaces/${name}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@ -7,20 +7,18 @@ import { Services, servicesRoute, servicesURL } from "../+network-services";
|
|||||||
import { endpointRoute, Endpoints, endpointURL } from "../+network-endpoints";
|
import { endpointRoute, Endpoints, endpointURL } from "../+network-endpoints";
|
||||||
import { Ingresses, ingressRoute, ingressURL } from "../+network-ingresses";
|
import { Ingresses, ingressRoute, ingressURL } from "../+network-ingresses";
|
||||||
import { NetworkPolicies, networkPoliciesRoute, networkPoliciesURL } from "../+network-policies";
|
import { NetworkPolicies, networkPoliciesRoute, networkPoliciesURL } from "../+network-policies";
|
||||||
import { namespaceUrlParam } from "../+namespaces/namespace.store";
|
|
||||||
import { isAllowedResource } from "../../../common/rbac";
|
import { isAllowedResource } from "../../../common/rbac";
|
||||||
|
|
||||||
@observer
|
@observer
|
||||||
export class Network extends React.Component {
|
export class Network extends React.Component {
|
||||||
static get tabRoutes(): TabLayoutRoute[] {
|
static get tabRoutes(): TabLayoutRoute[] {
|
||||||
const query = namespaceUrlParam.toObjectParam();
|
|
||||||
const routes: TabLayoutRoute[] = [];
|
const routes: TabLayoutRoute[] = [];
|
||||||
|
|
||||||
if (isAllowedResource("services")) {
|
if (isAllowedResource("services")) {
|
||||||
routes.push({
|
routes.push({
|
||||||
title: "Services",
|
title: "Services",
|
||||||
component: Services,
|
component: Services,
|
||||||
url: servicesURL({ query }),
|
url: servicesURL(),
|
||||||
routePath: servicesRoute.path.toString(),
|
routePath: servicesRoute.path.toString(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -29,7 +27,7 @@ export class Network extends React.Component {
|
|||||||
routes.push({
|
routes.push({
|
||||||
title: "Endpoints",
|
title: "Endpoints",
|
||||||
component: Endpoints,
|
component: Endpoints,
|
||||||
url: endpointURL({ query }),
|
url: endpointURL(),
|
||||||
routePath: endpointRoute.path.toString(),
|
routePath: endpointRoute.path.toString(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -38,7 +36,7 @@ export class Network extends React.Component {
|
|||||||
routes.push({
|
routes.push({
|
||||||
title: "Ingresses",
|
title: "Ingresses",
|
||||||
component: Ingresses,
|
component: Ingresses,
|
||||||
url: ingressURL({ query }),
|
url: ingressURL(),
|
||||||
routePath: ingressRoute.path.toString(),
|
routePath: ingressRoute.path.toString(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -47,7 +45,7 @@ export class Network extends React.Component {
|
|||||||
routes.push({
|
routes.push({
|
||||||
title: "Network Policies",
|
title: "Network Policies",
|
||||||
component: NetworkPolicies,
|
component: NetworkPolicies,
|
||||||
url: networkPoliciesURL({ query }),
|
url: networkPoliciesURL(),
|
||||||
routePath: networkPoliciesRoute.path.toString(),
|
routePath: networkPoliciesRoute.path.toString(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,20 +6,18 @@ import { TabLayout, TabLayoutRoute } from "../layout/tab-layout";
|
|||||||
import { PersistentVolumes, volumesRoute, volumesURL } from "../+storage-volumes";
|
import { PersistentVolumes, volumesRoute, volumesURL } from "../+storage-volumes";
|
||||||
import { StorageClasses, storageClassesRoute, storageClassesURL } from "../+storage-classes";
|
import { StorageClasses, storageClassesRoute, storageClassesURL } from "../+storage-classes";
|
||||||
import { PersistentVolumeClaims, volumeClaimsRoute, volumeClaimsURL } from "../+storage-volume-claims";
|
import { PersistentVolumeClaims, volumeClaimsRoute, volumeClaimsURL } from "../+storage-volume-claims";
|
||||||
import { namespaceUrlParam } from "../+namespaces/namespace.store";
|
|
||||||
import { isAllowedResource } from "../../../common/rbac";
|
import { isAllowedResource } from "../../../common/rbac";
|
||||||
|
|
||||||
@observer
|
@observer
|
||||||
export class Storage extends React.Component {
|
export class Storage extends React.Component {
|
||||||
static get tabRoutes() {
|
static get tabRoutes() {
|
||||||
const tabRoutes: TabLayoutRoute[] = [];
|
const tabRoutes: TabLayoutRoute[] = [];
|
||||||
const query = namespaceUrlParam.toObjectParam();
|
|
||||||
|
|
||||||
if (isAllowedResource("persistentvolumeclaims")) {
|
if (isAllowedResource("persistentvolumeclaims")) {
|
||||||
tabRoutes.push({
|
tabRoutes.push({
|
||||||
title: "Persistent Volume Claims",
|
title: "Persistent Volume Claims",
|
||||||
component: PersistentVolumeClaims,
|
component: PersistentVolumeClaims,
|
||||||
url: volumeClaimsURL({ query }),
|
url: volumeClaimsURL(),
|
||||||
routePath: volumeClaimsRoute.path.toString(),
|
routePath: volumeClaimsRoute.path.toString(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,7 +6,6 @@ import { Roles } from "../+user-management-roles";
|
|||||||
import { RoleBindings } from "../+user-management-roles-bindings";
|
import { RoleBindings } from "../+user-management-roles-bindings";
|
||||||
import { ServiceAccounts } from "../+user-management-service-accounts";
|
import { ServiceAccounts } from "../+user-management-service-accounts";
|
||||||
import { podSecurityPoliciesRoute, podSecurityPoliciesURL, roleBindingsRoute, roleBindingsURL, rolesRoute, rolesURL, serviceAccountsRoute, serviceAccountsURL } from "./user-management.route";
|
import { podSecurityPoliciesRoute, podSecurityPoliciesURL, roleBindingsRoute, roleBindingsURL, rolesRoute, rolesURL, serviceAccountsRoute, serviceAccountsURL } from "./user-management.route";
|
||||||
import { namespaceUrlParam } from "../+namespaces/namespace.store";
|
|
||||||
import { PodSecurityPolicies } from "../+pod-security-policies";
|
import { PodSecurityPolicies } from "../+pod-security-policies";
|
||||||
import { isAllowedResource } from "../../../common/rbac";
|
import { isAllowedResource } from "../../../common/rbac";
|
||||||
|
|
||||||
@ -14,25 +13,24 @@ import { isAllowedResource } from "../../../common/rbac";
|
|||||||
export class UserManagement extends React.Component {
|
export class UserManagement extends React.Component {
|
||||||
static get tabRoutes() {
|
static get tabRoutes() {
|
||||||
const tabRoutes: TabLayoutRoute[] = [];
|
const tabRoutes: TabLayoutRoute[] = [];
|
||||||
const query = namespaceUrlParam.toObjectParam();
|
|
||||||
|
|
||||||
tabRoutes.push(
|
tabRoutes.push(
|
||||||
{
|
{
|
||||||
title: "Service Accounts",
|
title: "Service Accounts",
|
||||||
component: ServiceAccounts,
|
component: ServiceAccounts,
|
||||||
url: serviceAccountsURL({ query }),
|
url: serviceAccountsURL(),
|
||||||
routePath: serviceAccountsRoute.path.toString(),
|
routePath: serviceAccountsRoute.path.toString(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Role Bindings",
|
title: "Role Bindings",
|
||||||
component: RoleBindings,
|
component: RoleBindings,
|
||||||
url: roleBindingsURL({ query }),
|
url: roleBindingsURL(),
|
||||||
routePath: roleBindingsRoute.path.toString(),
|
routePath: roleBindingsRoute.path.toString(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Roles",
|
title: "Roles",
|
||||||
component: Roles,
|
component: Roles,
|
||||||
url: rolesURL({ query }),
|
url: rolesURL(),
|
||||||
routePath: rolesRoute.path.toString(),
|
routePath: rolesRoute.path.toString(),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@ -5,7 +5,6 @@ import { observer } from "mobx-react";
|
|||||||
import { TabLayout, TabLayoutRoute } from "../layout/tab-layout";
|
import { TabLayout, TabLayoutRoute } from "../layout/tab-layout";
|
||||||
import { WorkloadsOverview } from "../+workloads-overview/overview";
|
import { WorkloadsOverview } from "../+workloads-overview/overview";
|
||||||
import { cronJobsRoute, cronJobsURL, daemonSetsRoute, daemonSetsURL, deploymentsRoute, deploymentsURL, jobsRoute, jobsURL, overviewRoute, overviewURL, podsRoute, podsURL, replicaSetsRoute, replicaSetsURL, statefulSetsRoute, statefulSetsURL } from "./workloads.route";
|
import { cronJobsRoute, cronJobsURL, daemonSetsRoute, daemonSetsURL, deploymentsRoute, deploymentsURL, jobsRoute, jobsURL, overviewRoute, overviewURL, podsRoute, podsURL, replicaSetsRoute, replicaSetsURL, statefulSetsRoute, statefulSetsURL } from "./workloads.route";
|
||||||
import { namespaceUrlParam } from "../+namespaces/namespace.store";
|
|
||||||
import { Pods } from "../+workloads-pods";
|
import { Pods } from "../+workloads-pods";
|
||||||
import { Deployments } from "../+workloads-deployments";
|
import { Deployments } from "../+workloads-deployments";
|
||||||
import { DaemonSets } from "../+workloads-daemonsets";
|
import { DaemonSets } from "../+workloads-daemonsets";
|
||||||
@ -18,12 +17,11 @@ import { ReplicaSets } from "../+workloads-replicasets";
|
|||||||
@observer
|
@observer
|
||||||
export class Workloads extends React.Component {
|
export class Workloads extends React.Component {
|
||||||
static get tabRoutes(): TabLayoutRoute[] {
|
static get tabRoutes(): TabLayoutRoute[] {
|
||||||
const query = namespaceUrlParam.toObjectParam();
|
|
||||||
const routes: TabLayoutRoute[] = [
|
const routes: TabLayoutRoute[] = [
|
||||||
{
|
{
|
||||||
title: "Overview",
|
title: "Overview",
|
||||||
component: WorkloadsOverview,
|
component: WorkloadsOverview,
|
||||||
url: overviewURL({ query }),
|
url: overviewURL(),
|
||||||
routePath: overviewRoute.path.toString()
|
routePath: overviewRoute.path.toString()
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
@ -32,7 +30,7 @@ export class Workloads extends React.Component {
|
|||||||
routes.push({
|
routes.push({
|
||||||
title: "Pods",
|
title: "Pods",
|
||||||
component: Pods,
|
component: Pods,
|
||||||
url: podsURL({ query }),
|
url: podsURL(),
|
||||||
routePath: podsRoute.path.toString()
|
routePath: podsRoute.path.toString()
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -41,7 +39,7 @@ export class Workloads extends React.Component {
|
|||||||
routes.push({
|
routes.push({
|
||||||
title: "Deployments",
|
title: "Deployments",
|
||||||
component: Deployments,
|
component: Deployments,
|
||||||
url: deploymentsURL({ query }),
|
url: deploymentsURL(),
|
||||||
routePath: deploymentsRoute.path.toString(),
|
routePath: deploymentsRoute.path.toString(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -50,7 +48,7 @@ export class Workloads extends React.Component {
|
|||||||
routes.push({
|
routes.push({
|
||||||
title: "DaemonSets",
|
title: "DaemonSets",
|
||||||
component: DaemonSets,
|
component: DaemonSets,
|
||||||
url: daemonSetsURL({ query }),
|
url: daemonSetsURL(),
|
||||||
routePath: daemonSetsRoute.path.toString(),
|
routePath: daemonSetsRoute.path.toString(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -59,7 +57,7 @@ export class Workloads extends React.Component {
|
|||||||
routes.push({
|
routes.push({
|
||||||
title: "StatefulSets",
|
title: "StatefulSets",
|
||||||
component: StatefulSets,
|
component: StatefulSets,
|
||||||
url: statefulSetsURL({ query }),
|
url: statefulSetsURL(),
|
||||||
routePath: statefulSetsRoute.path.toString(),
|
routePath: statefulSetsRoute.path.toString(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -68,7 +66,7 @@ export class Workloads extends React.Component {
|
|||||||
routes.push({
|
routes.push({
|
||||||
title: "ReplicaSets",
|
title: "ReplicaSets",
|
||||||
component: ReplicaSets,
|
component: ReplicaSets,
|
||||||
url: replicaSetsURL({ query }),
|
url: replicaSetsURL(),
|
||||||
routePath: replicaSetsRoute.path.toString(),
|
routePath: replicaSetsRoute.path.toString(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -77,7 +75,7 @@ export class Workloads extends React.Component {
|
|||||||
routes.push({
|
routes.push({
|
||||||
title: "Jobs",
|
title: "Jobs",
|
||||||
component: Jobs,
|
component: Jobs,
|
||||||
url: jobsURL({ query }),
|
url: jobsURL(),
|
||||||
routePath: jobsRoute.path.toString(),
|
routePath: jobsRoute.path.toString(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -86,7 +84,7 @@ export class Workloads extends React.Component {
|
|||||||
routes.push({
|
routes.push({
|
||||||
title: "CronJobs",
|
title: "CronJobs",
|
||||||
component: CronJobs,
|
component: CronJobs,
|
||||||
url: cronJobsURL({ query }),
|
url: cronJobsURL(),
|
||||||
routePath: cronJobsRoute.path.toString(),
|
routePath: cronJobsRoute.path.toString(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -16,7 +16,6 @@ import { clusterRoute, clusterURL } from "../+cluster";
|
|||||||
import { Config, configRoute, configURL } from "../+config";
|
import { Config, configRoute, configURL } from "../+config";
|
||||||
import { eventRoute, eventsURL } from "../+events";
|
import { eventRoute, eventsURL } from "../+events";
|
||||||
import { Apps, appsRoute, appsURL } from "../+apps";
|
import { Apps, appsRoute, appsURL } from "../+apps";
|
||||||
import { namespaceUrlParam } from "../+namespaces/namespace.store";
|
|
||||||
import { Workloads } from "../+workloads";
|
import { Workloads } from "../+workloads";
|
||||||
import { UserManagement } from "../+user-management";
|
import { UserManagement } from "../+user-management";
|
||||||
import { Storage } from "../+storage";
|
import { Storage } from "../+storage";
|
||||||
@ -48,7 +47,7 @@ export class Sidebar extends React.Component<Props> {
|
|||||||
if (crdStore.isLoading) {
|
if (crdStore.isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-center">
|
<div className="flex justify-center">
|
||||||
<Spinner />
|
<Spinner/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -154,7 +153,6 @@ export class Sidebar extends React.Component<Props> {
|
|||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { toggle, compact, className } = this.props;
|
const { toggle, compact, className } = this.props;
|
||||||
const query = namespaceUrlParam.toObjectParam();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cssNames(Sidebar.displayName, "flex column", { compact }, className)}>
|
<div className={cssNames(Sidebar.displayName, "flex column", { compact }, className)}>
|
||||||
@ -193,7 +191,7 @@ export class Sidebar extends React.Component<Props> {
|
|||||||
text="Workloads"
|
text="Workloads"
|
||||||
isActive={isActiveRoute(workloadsRoute)}
|
isActive={isActiveRoute(workloadsRoute)}
|
||||||
isHidden={Workloads.tabRoutes.length == 0}
|
isHidden={Workloads.tabRoutes.length == 0}
|
||||||
url={workloadsURL({ query })}
|
url={workloadsURL()}
|
||||||
icon={<Icon svg="workloads"/>}
|
icon={<Icon svg="workloads"/>}
|
||||||
>
|
>
|
||||||
{this.renderTreeFromTabRoutes(Workloads.tabRoutes)}
|
{this.renderTreeFromTabRoutes(Workloads.tabRoutes)}
|
||||||
@ -203,7 +201,7 @@ export class Sidebar extends React.Component<Props> {
|
|||||||
text="Configuration"
|
text="Configuration"
|
||||||
isActive={isActiveRoute(configRoute)}
|
isActive={isActiveRoute(configRoute)}
|
||||||
isHidden={Config.tabRoutes.length == 0}
|
isHidden={Config.tabRoutes.length == 0}
|
||||||
url={configURL({ query })}
|
url={configURL()}
|
||||||
icon={<Icon material="list"/>}
|
icon={<Icon material="list"/>}
|
||||||
>
|
>
|
||||||
{this.renderTreeFromTabRoutes(Config.tabRoutes)}
|
{this.renderTreeFromTabRoutes(Config.tabRoutes)}
|
||||||
@ -213,7 +211,7 @@ export class Sidebar extends React.Component<Props> {
|
|||||||
text="Network"
|
text="Network"
|
||||||
isActive={isActiveRoute(networkRoute)}
|
isActive={isActiveRoute(networkRoute)}
|
||||||
isHidden={Network.tabRoutes.length == 0}
|
isHidden={Network.tabRoutes.length == 0}
|
||||||
url={networkURL({ query })}
|
url={networkURL()}
|
||||||
icon={<Icon material="device_hub"/>}
|
icon={<Icon material="device_hub"/>}
|
||||||
>
|
>
|
||||||
{this.renderTreeFromTabRoutes(Network.tabRoutes)}
|
{this.renderTreeFromTabRoutes(Network.tabRoutes)}
|
||||||
@ -223,7 +221,7 @@ export class Sidebar extends React.Component<Props> {
|
|||||||
text="Storage"
|
text="Storage"
|
||||||
isActive={isActiveRoute(storageRoute)}
|
isActive={isActiveRoute(storageRoute)}
|
||||||
isHidden={Storage.tabRoutes.length == 0}
|
isHidden={Storage.tabRoutes.length == 0}
|
||||||
url={storageURL({ query })}
|
url={storageURL()}
|
||||||
icon={<Icon svg="storage"/>}
|
icon={<Icon svg="storage"/>}
|
||||||
>
|
>
|
||||||
{this.renderTreeFromTabRoutes(Storage.tabRoutes)}
|
{this.renderTreeFromTabRoutes(Storage.tabRoutes)}
|
||||||
@ -241,14 +239,14 @@ export class Sidebar extends React.Component<Props> {
|
|||||||
text="Events"
|
text="Events"
|
||||||
isActive={isActiveRoute(eventRoute)}
|
isActive={isActiveRoute(eventRoute)}
|
||||||
isHidden={!isAllowedResource("events")}
|
isHidden={!isAllowedResource("events")}
|
||||||
url={eventsURL({ query })}
|
url={eventsURL()}
|
||||||
icon={<Icon material="access_time"/>}
|
icon={<Icon material="access_time"/>}
|
||||||
/>
|
/>
|
||||||
<SidebarItem
|
<SidebarItem
|
||||||
id="apps"
|
id="apps"
|
||||||
text="Apps" // helm charts
|
text="Apps" // helm charts
|
||||||
isActive={isActiveRoute(appsRoute)}
|
isActive={isActiveRoute(appsRoute)}
|
||||||
url={appsURL({ query })}
|
url={appsURL()}
|
||||||
icon={<Icon material="apps"/>}
|
icon={<Icon material="apps"/>}
|
||||||
>
|
>
|
||||||
{this.renderTreeFromTabRoutes(Apps.tabRoutes)}
|
{this.renderTreeFromTabRoutes(Apps.tabRoutes)}
|
||||||
@ -257,7 +255,7 @@ export class Sidebar extends React.Component<Props> {
|
|||||||
id="users"
|
id="users"
|
||||||
text="Access Control"
|
text="Access Control"
|
||||||
isActive={isActiveRoute(usersManagementRoute)}
|
isActive={isActiveRoute(usersManagementRoute)}
|
||||||
url={usersManagementURL({ query })}
|
url={usersManagementURL()}
|
||||||
icon={<Icon material="security"/>}
|
icon={<Icon material="security"/>}
|
||||||
>
|
>
|
||||||
{this.renderTreeFromTabRoutes(UserManagement.tabRoutes)}
|
{this.renderTreeFromTabRoutes(UserManagement.tabRoutes)}
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import { action, makeObservable } from "mobx";
|
|||||||
|
|
||||||
export interface PageParamInit<V = any> {
|
export interface PageParamInit<V = any> {
|
||||||
name: string;
|
name: string;
|
||||||
defaultValue?: V;
|
defaultValue?: V; // multi-values param must be defined with array-value, e.g. []
|
||||||
prefix?: string; // name prefix, for extensions it's `${extension.id}:`
|
prefix?: string; // name prefix, for extensions it's `${extension.id}:`
|
||||||
parse?(value: string | string[]): V; // from URL
|
parse?(value: string | string[]): V; // from URL
|
||||||
stringify?(value: V): string | string[]; // to URL
|
stringify?(value: V): string | string[]; // to URL
|
||||||
@ -106,10 +106,4 @@ export class PageParam<V = any> {
|
|||||||
|
|
||||||
return `${withPrefix ? "?" : ""}${searchParams}`;
|
return `${withPrefix ? "?" : ""}${searchParams}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
toObjectParam(): Record<string, V> {
|
|
||||||
return {
|
|
||||||
[this.name]: this.get(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,12 +8,10 @@ import { StorageHelper } from "./storageHelper";
|
|||||||
import { ClusterStore, getHostedClusterId } from "../../common/cluster-store";
|
import { ClusterStore, getHostedClusterId } from "../../common/cluster-store";
|
||||||
import logger from "../../main/logger";
|
import logger from "../../main/logger";
|
||||||
|
|
||||||
const LOG_PREFIX = "[LocalStorageHelper]:";
|
|
||||||
|
|
||||||
const storage = observable({
|
const storage = observable({
|
||||||
initialized: false,
|
initialized: false,
|
||||||
loaded: false,
|
loaded: false,
|
||||||
data: {} as { [key: string]: any }, // json-compatible state
|
data: {} as Record<string/*key*/, any>, // json-serializable
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -22,6 +20,7 @@ const storage = observable({
|
|||||||
* @param defaultValue
|
* @param defaultValue
|
||||||
*/
|
*/
|
||||||
export function createStorage<T>(key: string, defaultValue: T) {
|
export function createStorage<T>(key: string, defaultValue: T) {
|
||||||
|
const { logPrefix } = StorageHelper;
|
||||||
const clusterId = getHostedClusterId();
|
const clusterId = getHostedClusterId();
|
||||||
const folder = path.resolve((app || remote.app).getPath("userData"), "lens-local-storage");
|
const folder = path.resolve((app || remote.app).getPath("userData"), "lens-local-storage");
|
||||||
const fileName = `${clusterId ?? "app"}.json`;
|
const fileName = `${clusterId ?? "app"}.json`;
|
||||||
@ -39,7 +38,7 @@ export function createStorage<T>(key: string, defaultValue: T) {
|
|||||||
.then(data => storage.data = data)
|
.then(data => storage.data = data)
|
||||||
.catch(() => null) // ignore empty / non-existing / invalid json files
|
.catch(() => null) // ignore empty / non-existing / invalid json files
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
logger.info(`${LOG_PREFIX} loaded local-storage file "${filePath}"`);
|
logger.info(`${logPrefix} loading finished for ${filePath}`);
|
||||||
storage.loaded = true;
|
storage.loaded = true;
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -55,20 +54,20 @@ export function createStorage<T>(key: string, defaultValue: T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function saveFile(state: Record<string, any> = {}) {
|
async function saveFile(state: Record<string, any> = {}) {
|
||||||
logger.info(`${LOG_PREFIX} saving ${filePath}`);
|
logger.info(`${logPrefix} saving ${filePath}`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await fse.ensureDir(folder, { mode: 0o755 });
|
await fse.ensureDir(folder, { mode: 0o755 });
|
||||||
await fse.writeJson(filePath, state, { spaces: 2 });
|
await fse.writeJson(filePath, state, { spaces: 2 });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`${LOG_PREFIX} saving failed: ${error}`, {
|
logger.error(`${logPrefix} saving failed: ${error}`, {
|
||||||
json: state, jsonFilePath: filePath
|
json: state, jsonFilePath: filePath
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeFile() {
|
function removeFile() {
|
||||||
logger.debug(`${LOG_PREFIX} removing ${filePath}`);
|
logger.debug(`${logPrefix} removing ${filePath}`);
|
||||||
fse.unlink(filePath).catch(Function);
|
fse.unlink(filePath).catch(Function);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
// Helper for working with storages (e.g. window.localStorage, NodeJS/file-system, etc.)
|
// Helper for working with storages (e.g. window.localStorage, NodeJS/file-system, etc.)
|
||||||
|
|
||||||
import { action, comparer, IObservableValue, makeObservable, observable, toJS, when, } from "mobx";
|
import { action, IObservableValue, makeObservable, observable, toJS, when, } from "mobx";
|
||||||
import produce, { Draft } from "immer";
|
import produce, { Draft } from "immer";
|
||||||
import { isEqual, isFunction, isPlainObject } from "lodash";
|
import { isEqual, isFunction, isPlainObject } from "lodash";
|
||||||
import logger from "../../main/logger";
|
import logger from "../../main/logger";
|
||||||
@ -20,33 +20,30 @@ export interface StorageHelperOptions<T> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class StorageHelper<T> {
|
export class StorageHelper<T> {
|
||||||
static readonly defaultOptions: Partial<StorageHelperOptions<any>> = {
|
static logPrefix = "[StorageHelper]:";
|
||||||
autoInit: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
|
readonly storage: StorageAdapter<T>;
|
||||||
private data: IObservableValue<T>;
|
private data: IObservableValue<T>;
|
||||||
@observable initialized = false;
|
|
||||||
whenReady = when(() => this.initialized);
|
|
||||||
|
|
||||||
public readonly storage: StorageAdapter<T>;
|
@observable initialized = false;
|
||||||
public readonly defaultValue: T;
|
whenReady = when(() => this.initialized); // FIXME: invalid, use when() at the place of usage
|
||||||
|
|
||||||
|
get defaultValue(): T {
|
||||||
|
// return as-is since options.defaultValue might be a getter too
|
||||||
|
return this.options.defaultValue;
|
||||||
|
}
|
||||||
|
|
||||||
constructor(readonly key: string, private options: StorageHelperOptions<T>) {
|
constructor(readonly key: string, private options: StorageHelperOptions<T>) {
|
||||||
|
const { storage, defaultValue, autoInit = true } = options;
|
||||||
makeObservable(this);
|
makeObservable(this);
|
||||||
|
|
||||||
this.storage = options.storage;
|
this.storage = storage;
|
||||||
this.defaultValue = options.defaultValue;
|
this.data = observable.box<T>(defaultValue);
|
||||||
this.data = observable.box<T>(this.defaultValue, {
|
|
||||||
autoBind: true,
|
|
||||||
deep: true,
|
|
||||||
equals: comparer.structural,
|
|
||||||
});
|
|
||||||
|
|
||||||
this.data.observe_(({ newValue, oldValue }) => {
|
this.data.observe_(({ newValue, oldValue }) => {
|
||||||
this.onChange(newValue as T, oldValue as T);
|
this.onChange(newValue as T, oldValue as T);
|
||||||
});
|
});
|
||||||
|
|
||||||
if (this.options.autoInit) {
|
if (autoInit) {
|
||||||
this.init();
|
this.init();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -63,7 +60,7 @@ export class StorageHelper<T> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
private onError = (error: any): void => {
|
private onError = (error: any): void => {
|
||||||
logger.error(`[load]: ${error}`, this);
|
logger.error(`${StorageHelper.logPrefix} loading error: ${error}`, this);
|
||||||
};
|
};
|
||||||
|
|
||||||
@action
|
@action
|
||||||
@ -101,21 +98,21 @@ export class StorageHelper<T> {
|
|||||||
|
|
||||||
this.storage.onChange?.({ value, oldValue, key: this.key });
|
this.storage.onChange?.({ value, oldValue, key: this.key });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`[change]: ${error}`, this, { value, oldValue });
|
logger.error(`${StorageHelper.logPrefix} updating storage: ${error}`, this, { value, oldValue });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
get(): T {
|
get(): T {
|
||||||
return this.data.get();
|
const value = this.data.get();
|
||||||
|
|
||||||
|
// return real default-value (not a copy from observable when it's an object, e.g. [])
|
||||||
|
// this will allow to compare values by link with == operator
|
||||||
|
return this.isDefaultValue(value) ? this.defaultValue : value;
|
||||||
}
|
}
|
||||||
|
|
||||||
@action
|
@action
|
||||||
set(value: T) {
|
set(value: T) {
|
||||||
if (value == null) {
|
this.data.set(value);
|
||||||
this.reset();
|
|
||||||
} else {
|
|
||||||
this.data.set(value);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@action
|
@action
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user