Lens app source code (#119)

Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com>
This commit is contained in:
Jari Kolehmainen authored and GitHub committed 2020-03-15 09:52:02 +02:00
1 parent 936cbd53d4
commit 1d0815abd2
797 files changed
+73714 -215

No files matched your search

+79
View File
@@ -0,0 +1,79 @@
import React from "react";
import { observable } from "mobx";
import { autobind } from "../utils/autobind";
import { KubeApi } from "./kube-api";
import { KubeObjectStore } from "../kube-object.store";
import { KubeObjectDetailsProps, KubeObjectListLayoutProps, KubeObjectMenuProps } from "../components/kube-object";
export interface ApiComponents {
List?: React.ComponentType<KubeObjectListLayoutProps>;
Menu?: React.ComponentType<KubeObjectMenuProps>;
Details?: React.ComponentType<KubeObjectDetailsProps>;
}
@autobind()
export class ApiManager {
private apis = observable.map<string, KubeApi>();
private stores = observable.map<KubeApi, KubeObjectStore>();
private views = observable.map<KubeApi, ApiComponents>();
getApi(pathOrCallback: string | ((api: KubeApi) => boolean)) {
const apis = this.apis;
if (typeof pathOrCallback === "string") {
let api = apis.get(pathOrCallback);
if (!api) {
const { apiBase } = KubeApi.parseApi(pathOrCallback);
api = apis.get(apiBase);
}
return api;
}
else {
return Array.from(apis.values()).find(pathOrCallback);
}
}
registerApi(apiBase: string, api: KubeApi) {
if (this.apis.has(apiBase)) return;
this.apis.set(apiBase, api);
}
protected resolveApi(api: string | KubeApi): KubeApi {
if (typeof api === "string") return this.getApi(api)
return api;
}
unregisterApi(api: string | KubeApi) {
if (typeof api === "string") this.apis.delete(api);
else {
const apis = Array.from(this.apis.entries());
const entry = apis.find(entry => entry[1] === api);
if (entry) this.unregisterApi(entry[0]);
}
}
registerStore(api: KubeApi, store: KubeObjectStore) {
this.stores.set(api, store);
}
getStore(api: string | KubeApi): KubeObjectStore {
return this.stores.get(this.resolveApi(api));
}
registerViews(api: KubeApi | KubeApi[], views: ApiComponents) {
if (Array.isArray(api)) {
api.forEach(api => this.registerViews(api, views));
return;
}
const currentViews = this.views.get(api) || {};
this.views.set(api, {
...currentViews,
...views,
});
}
getViews(api: string | KubeApi): ApiComponents {
return this.views.get(this.resolveApi(api)) || {}
}
}
export const apiManager = new ApiManager();
@@ -0,0 +1,47 @@
import { CronJob } from "../";
//jest.mock('../../../components/+login/auth.store.ts', () => 'authStore');
jest.mock('../../kube-watch-api.ts', () => 'kube-watch-api');
const cronJob = new CronJob({
metadata: {
name: "hello",
namespace: "default",
selfLink: "/apis/batch/v1beta1/namespaces/default/cronjobs/hello",
uid: "cd3af13f-0b70-11ea-93da-9600002795a0",
resourceVersion: "51394448",
creationTimestamp: "2019-11-20T08:36:09Z",
},
spec: {
schedule: "30 06 31 12 *",
concurrencyPolicy: "Allow",
suspend: false,
},
status: {}
} as any)
describe("Check for CronJob schedule never run", () => {
test("Should be false with normal schedule", () => {
expect(cronJob.isNeverRun()).toBeFalsy();
});
test("Should be false with other normal schedule", () => {
cronJob.spec.schedule = "0 1 * * *";
expect(cronJob.isNeverRun()).toBeFalsy();
});
test("Should be true with date 31 of February", () => {
cronJob.spec.schedule = "30 06 31 2 *"
expect(cronJob.isNeverRun()).toBeTruthy();
});
test("Should be true with date 32 of July", () => {
cronJob.spec.schedule = "0 30 06 32 7 *"
expect(cronJob.isNeverRun()).toBeTruthy();
});
test("Should be false with predefined schedule", () => {
cronJob.spec.schedule = "@hourly";
expect(cronJob.isNeverRun()).toBeFalsy();
});
});
@@ -0,0 +1,261 @@
// Kubernetes certificate management controller apis
// Reference: https://docs.cert-manager.io/en/latest/reference/index.html
// API docs: https://docs.cert-manager.io/en/latest/reference/api-docs/index.html
import { KubeObject } from "../kube-object";
import { ISecretRef, secretsApi } from "./secret.api";
import { getDetailsUrl } from "../../navigation";
import { KubeApi } from "../kube-api";
export class Certificate extends KubeObject {
static kind = "Certificate"
spec: {
secretName: string;
commonName?: string;
dnsNames?: string[];
organization?: string[];
ipAddresses?: string[];
duration?: string;
renewBefore?: string;
isCA?: boolean;
keySize?: number;
keyAlgorithm?: "rsa" | "ecdsa";
issuerRef: {
kind?: string;
name: string;
};
acme?: {
config: {
domains: string[];
http01: {
ingress?: string;
ingressClass?: string;
};
dns01?: {
provider: string;
};
}[];
};
}
status: {
conditions?: {
lastTransitionTime: string; // 2019-06-04T07:35:58Z,
message: string; // Certificate is up to date and has not expired,
reason: string; // Ready,
status: string; // True,
type: string; // Ready
}[];
notAfter: string; // 2019-11-01T05:36:27Z
lastFailureTime?: string;
}
getType(): string {
const { isCA, acme } = this.spec;
if (isCA) return "CA"
if (acme) return "ACME"
}
getCommonName() {
return this.spec.commonName || ""
}
getIssuerName() {
return this.spec.issuerRef.name;
}
getSecretName() {
return this.spec.secretName;
}
getIssuerDetailsUrl() {
return getDetailsUrl(issuersApi.getUrl({
namespace: this.getNs(),
name: this.getIssuerName(),
}))
}
getSecretDetailsUrl() {
return getDetailsUrl(secretsApi.getUrl({
namespace: this.getNs(),
name: this.getSecretName(),
}))
}
getConditions() {
const { conditions = [] } = this.status;
return conditions.map(condition => {
const { message, reason, lastTransitionTime, status } = condition;
return {
...condition,
isReady: status === "True",
tooltip: `${message || reason} (${lastTransitionTime})`
}
});
}
}
export class Issuer extends KubeObject {
static kind = "Issuer"
spec: {
acme?: {
email: string;
server: string;
skipTLSVerify?: boolean;
privateKeySecretRef: ISecretRef;
solvers?: {
dns01?: {
cnameStrategy: string;
acmedns?: {
host: string;
accountSecretRef: ISecretRef;
};
akamai?: {
accessTokenSecretRef: ISecretRef;
clientSecretSecretRef: ISecretRef;
clientTokenSecretRef: ISecretRef;
serviceConsumerDomain: string;
};
azuredns?: {
clientID: string;
clientSecretSecretRef: ISecretRef;
hostedZoneName: string;
resourceGroupName: string;
subscriptionID: string;
tenantID: string;
};
clouddns?: {
project: string;
serviceAccountSecretRef: ISecretRef;
};
cloudflare?: {
email: string;
apiKeySecretRef: ISecretRef;
};
digitalocean?: {
tokenSecretRef: ISecretRef;
};
rfc2136?: {
nameserver: string;
tsigAlgorithm: string;
tsigKeyName: string;
tsigSecretSecretRef: ISecretRef;
};
route53?: {
accessKeyID: string;
hostedZoneID: string;
region: string;
secretAccessKeySecretRef: ISecretRef;
};
webhook?: {
config: object; // arbitrary json
groupName: string;
solverName: string;
};
};
http01?: {
ingress: {
class: string;
name: string;
serviceType: string;
};
};
selector?: {
dnsNames: string[];
matchLabels: {
[label: string]: string;
};
};
}[];
};
ca?: {
secretName: string;
};
vault?: {
path: string;
server: string;
caBundle: string; // <base64 encoded caBundle PEM file>
auth: {
appRole: {
path: string;
roleId: string;
secretRef: ISecretRef;
};
};
};
selfSigned?: {};
venafi?: {
zone: string;
cloud?: {
apiTokenSecretRef: ISecretRef;
};
tpp?: {
url: string;
caBundle: string; // <base64 encoded caBundle PEM file>
credentialsRef: {
name: string;
};
};
};
}
status: {
acme?: {
uri: string;
};
conditions?: {
lastTransitionTime: string; // 2019-06-05T07:10:42Z,
message: string; // The ACME account was registered with the ACME server,
reason: string; // ACMEAccountRegistered,
status: string; // True,
type: string; // Ready
}[];
}
getType() {
const { acme, ca, selfSigned, vault, venafi } = this.spec;
if (acme) return "ACME"
if (ca) return "CA"
if (selfSigned) return "SelfSigned"
if (vault) return "Vault"
if (venafi) return "Venafi"
}
getConditions() {
const { conditions = [] } = this.status;
return conditions.map(condition => {
const { message, reason, lastTransitionTime, status } = condition;
return {
...condition,
isReady: status === "True",
tooltip: `${message || reason} (${lastTransitionTime})`,
}
});
}
}
export class ClusterIssuer extends Issuer {
static kind = "ClusterIssuer"
}
export const certificatesApi = new KubeApi({
kind: Certificate.kind,
apiBase: "/apis/certmanager.k8s.io/v1alpha1/certificates",
isNamespaced: true,
objectConstructor: Certificate,
});
export const issuersApi = new KubeApi({
kind: Issuer.kind,
apiBase: "/apis/certmanager.k8s.io/v1alpha1/issuers",
isNamespaced: true,
objectConstructor: Issuer,
});
export const clusterIssuersApi = new KubeApi({
kind: ClusterIssuer.kind,
apiBase: "/apis/certmanager.k8s.io/v1alpha1/clusterissuers",
isNamespaced: false,
objectConstructor: ClusterIssuer,
});
@@ -0,0 +1,13 @@
import { RoleBinding } from "./role-binding.api";
import { KubeApi } from "../kube-api";
export class ClusterRoleBinding extends RoleBinding {
static kind = "ClusterRoleBinding"
}
export const clusterRoleBindingApi = new KubeApi({
kind: ClusterRoleBinding.kind,
apiBase: "/apis/rbac.authorization.k8s.io/v1/clusterrolebindings",
isNamespaced: false,
objectConstructor: ClusterRoleBinding,
});
@@ -0,0 +1,15 @@
import { autobind } from "../../utils";
import { Role } from "./role.api";
import { KubeApi } from "../kube-api";
@autobind()
export class ClusterRole extends Role {
static kind = "ClusterRole"
}
export const clusterRoleApi = new KubeApi({
kind: ClusterRole.kind,
apiBase: "/apis/rbac.authorization.k8s.io/v1/clusterroles",
isNamespaced: false,
objectConstructor: ClusterRole,
});
@@ -0,0 +1,114 @@
import { IMetrics, IMetricsReqParams, metricsApi } from "./metrics.api";
import { KubeObject } from "../kube-object";
import { KubeApi } from "../kube-api";
export class ClusterApi extends KubeApi<Cluster> {
async getMetrics(nodeNames: string[], params?: IMetricsReqParams): Promise<IClusterMetrics> {
const nodes = nodeNames.join("|");
const memoryUsage = `
sum(
node_memory_MemTotal_bytes - (node_memory_MemFree_bytes + node_memory_Buffers_bytes + node_memory_Cached_bytes)
) by (kubernetes_name)
`.replace(/_bytes/g, `_bytes{kubernetes_node=~"${nodes}"}`);
const memoryRequests = `sum(kube_pod_container_resource_requests{node=~"${nodes}", resource="memory"}) by (component)`;
const memoryLimits = `sum(kube_pod_container_resource_limits{node=~"${nodes}", resource="memory"}) by (component)`;
const memoryCapacity = `sum(kube_node_status_capacity{node=~"${nodes}", resource="memory"}) by (component)`;
const cpuUsage = `sum(rate(node_cpu_seconds_total{kubernetes_node=~"${nodes}", mode=~"user|system"}[1m]))`;
const cpuRequests = `sum(kube_pod_container_resource_requests{node=~"${nodes}", resource="cpu"}) by (component)`;
const cpuLimits = `sum(kube_pod_container_resource_limits{node=~"${nodes}", resource="cpu"}) by (component)`;
const cpuCapacity = `sum(kube_node_status_capacity{node=~"${nodes}", resource="cpu"}) by (component)`;
const podUsage = `sum(kubelet_running_pod_count{instance=~"${nodes}"})`;
const podCapacity = `sum(kube_node_status_capacity{node=~"${nodes}", resource="pods"}) by (component)`;
const fsSize = `sum(node_filesystem_size_bytes{kubernetes_node=~"${nodes}", mountpoint="/"}) by (kubernetes_node)`;
const fsUsage = `sum(node_filesystem_size_bytes{kubernetes_node=~"${nodes}", mountpoint="/"} - node_filesystem_avail_bytes{kubernetes_node=~"${nodes}", mountpoint="/"}) by (kubernetes_node)`;
return metricsApi.getMetrics({
memoryUsage,
memoryRequests,
memoryLimits,
memoryCapacity,
cpuUsage,
cpuRequests,
cpuLimits,
cpuCapacity,
podUsage,
podCapacity,
fsSize,
fsUsage
}, params);
}
}
export enum ClusterStatus {
ACTIVE = "Active",
CREATING = "Creating",
REMOVING = "Removing",
ERROR = "Error"
}
export interface IClusterMetrics<T = IMetrics> {
[metric: string]: T;
memoryUsage: T;
memoryRequests: T;
memoryLimits: T;
memoryCapacity: T;
cpuUsage: T;
cpuRequests: T;
cpuLimits: T;
cpuCapacity: T;
podUsage: T;
podCapacity: T;
fsSize: T;
fsUsage: T;
}
export class Cluster extends KubeObject {
static kind = "Cluster";
spec: {
clusterNetwork?: {
serviceDomain?: string;
pods?: {
cidrBlocks?: string[];
};
services?: {
cidrBlocks?: string[];
};
};
providerSpec: {
value: {
profile: string;
};
};
}
status?: {
apiEndpoints: {
host: string;
port: string;
}[];
providerStatus: {
adminUser?: string;
adminPassword?: string;
kubeconfig?: string;
processState?: string;
lensAddress?: string;
};
errorMessage?: string;
errorReason?: string;
}
getStatus() {
if (this.metadata.deletionTimestamp) return ClusterStatus.REMOVING;
if (!this.status || !this.status) return ClusterStatus.CREATING;
if (this.status.errorMessage) return ClusterStatus.ERROR;
return ClusterStatus.ACTIVE;
}
}
export const clusterApi = new ClusterApi({
kind: Cluster.kind,
apiBase: "/apis/cluster.k8s.io/v1alpha1/clusters",
isNamespaced: true,
objectConstructor: Cluster,
});
@@ -0,0 +1,25 @@
import { KubeObject } from "../kube-object";
import { KubeApi } from "../kube-api";
export interface IComponentStatusCondition {
type: string;
status: string;
message: string;
}
export class ComponentStatus extends KubeObject {
static kind = "ComponentStatus"
conditions: IComponentStatusCondition[]
getTruthyConditions() {
return this.conditions.filter(c => c.status === "True");
}
}
export const componentStatusApi = new KubeApi({
kind: ComponentStatus.kind,
apiBase: "/api/v1/componentstatuses",
isNamespaced: false,
objectConstructor: ComponentStatus,
});
@@ -0,0 +1,9 @@
// App configuration api
import { apiBase } from "../index";
import { IConfig } from "../../../server/common/config";
export const configApi = {
getConfig() {
return apiBase.get<IConfig>("/config")
},
};
@@ -0,0 +1,29 @@
import { KubeObject } from "../kube-object";
import { KubeJsonApiData } from "../kube-json-api";
import { autobind } from "../../utils";
import { KubeApi } from "../kube-api";
@autobind()
export class ConfigMap extends KubeObject {
static kind = "ConfigMap";
constructor(data: KubeJsonApiData) {
super(data);
this.data = this.data || {};
}
data: {
[param: string]: string;
}
getKeys(): string[] {
return Object.keys(this.data);
}
}
export const configMapApi = new KubeApi({
kind: ConfigMap.kind,
apiBase: "/api/v1/configmaps",
isNamespaced: true,
objectConstructor: ConfigMap,
});
+138
View File
@@ -0,0 +1,138 @@
import { KubeObject } from "../kube-object";
import { KubeApi } from "../kube-api";
import { crdResourcesURL } from "../../components/+custom-resources/crd.route";
export class CustomResourceDefinition extends KubeObject {
static kind = "CustomResourceDefinition";
spec: {
group: string;
version: string;
names: {
plural: string;
singular: string;
kind: string;
listKind: string;
};
scope: "Namespaced" | "Cluster" | string;
validation?: any;
versions: {
name: string;
served: boolean;
storage: boolean;
}[];
conversion: {
strategy?: string;
webhook?: any;
};
additionalPrinterColumns?: {
name: string;
type: "integer" | "number" | "string" | "boolean" | "date";
priority: number;
description: string;
JSONPath: string;
}[];
}
status: {
conditions: {
lastTransitionTime: string;
message: string;
reason: string;
status: string;
type: string;
}[];
acceptedNames: {
plural: string;
singular: string;
kind: string;
shortNames: string[];
listKind: string;
};
storedVersions: string[];
}
getResourceUrl() {
return crdResourcesURL({
params: {
group: this.getGroup(),
name: this.getPluralName(),
}
})
}
getResourceApiBase() {
const { version, group } = this.spec;
return `/apis/${group}/${version}/${this.getPluralName()}`
}
getPluralName() {
return this.getNames().plural
}
getResourceKind() {
return this.spec.names.kind
}
getResourceTitle() {
const name = this.getPluralName();
return name[0].toUpperCase() + name.substr(1)
}
getGroup() {
return this.spec.group;
}
getScope() {
return this.spec.scope;
}
getVersion() {
return this.spec.version;
}
isNamespaced() {
return this.getScope() === "Namespaced";
}
getStoredVersions() {
return this.status.storedVersions.join(", ");
}
getNames() {
return this.spec.names;
}
getConversion() {
return JSON.stringify(this.spec.conversion);
}
getPrinterColumns(ignorePriority = true) {
const columns = this.spec.additionalPrinterColumns || [];
return columns
.filter(column => column.name != "Age")
.filter(column => ignorePriority ? true : !column.priority);
}
getValidation() {
return JSON.stringify(this.spec.validation, null, 2);
}
getConditions() {
if (!this.status.conditions) return [];
return this.status.conditions.map(condition => {
const { message, reason, lastTransitionTime, status } = condition;
return {
...condition,
isReady: status === "True",
tooltip: `${message || reason} (${lastTransitionTime})`
}
});
}
}
export const crdApi = new KubeApi<CustomResourceDefinition>({
kind: CustomResourceDefinition.kind,
apiBase: "/apis/apiextensions.k8s.io/v1beta1/customresourcedefinitions",
isNamespaced: false,
objectConstructor: CustomResourceDefinition,
});
@@ -0,0 +1,88 @@
import moment from "moment";
import { KubeObject } from "../kube-object";
import { IPodContainer } from "./pods.api";
import { formatDuration } from "../../utils/formatDuration";
import { autobind } from "../../utils";
import { KubeApi } from "../kube-api";
@autobind()
export class CronJob extends KubeObject {
static kind = "CronJob"
kind: string
apiVersion: string
metadata: {
name: string;
namespace: string;
selfLink: string;
uid: string;
resourceVersion: string;
creationTimestamp: string;
labels: {
[key: string]: string;
};
annotations: {
[key: string]: string;
};
}
spec: {
schedule: string;
concurrencyPolicy: string;
suspend: boolean;
jobTemplate: {
metadata: {
creationTimestamp?: string;
};
spec: {
template: {
metadata: {
creationTimestamp?: string;
};
spec: {
containers: IPodContainer[];
restartPolicy: string;
terminationGracePeriodSeconds: number;
dnsPolicy: string;
hostPID: boolean;
schedulerName: string;
};
};
};
};
successfulJobsHistoryLimit: number;
failedJobsHistoryLimit: number;
}
status: {
lastScheduleTime: string;
}
getSuspendFlag() {
return this.spec.suspend.toString()
}
getLastScheduleTime() {
const diff = moment().diff(this.status.lastScheduleTime)
return formatDuration(diff, true)
}
getSchedule() {
return this.spec.schedule
}
isNeverRun() {
const schedule = this.getSchedule();
const daysInMonth = [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
const stamps = schedule.split(" ");
const day = Number(stamps[stamps.length - 3]); // 1-31
const month = Number(stamps[stamps.length - 2]); // 1-12
if (schedule.startsWith("@")) return false;
return day > daysInMonth[month - 1];
}
}
export const cronJobApi = new KubeApi({
kind: CronJob.kind,
apiBase: "/apis/batch/v1beta1/cronjobs",
isNamespaced: true,
objectConstructor: CronJob,
});
@@ -0,0 +1,76 @@
import get from "lodash/get";
import { IPodContainer } from "./pods.api";
import { IAffinity, WorkloadKubeObject } from "../workload-kube-object";
import { autobind } from "../../utils";
import { KubeApi } from "../kube-api";
@autobind()
export class DaemonSet extends WorkloadKubeObject {
static kind = "DaemonSet"
spec: {
selector: {
matchLabels: {
[name: string]: string;
};
};
template: {
metadata: {
creationTimestamp?: string;
labels: {
name: string;
};
};
spec: {
containers: IPodContainer[];
initContainers?: IPodContainer[];
restartPolicy: string;
terminationGracePeriodSeconds: number;
dnsPolicy: string;
hostPID: boolean;
affinity?: IAffinity;
nodeSelector?: {
[selector: string]: string;
};
securityContext: {};
schedulerName: string;
tolerations: {
key: string;
operator: string;
effect: string;
tolerationSeconds: number;
}[];
};
};
updateStrategy: {
type: string;
rollingUpdate: {
maxUnavailable: number;
};
};
revisionHistoryLimit: number;
}
status: {
currentNumberScheduled: number;
numberMisscheduled: number;
desiredNumberScheduled: number;
numberReady: number;
observedGeneration: number;
updatedNumberScheduled: number;
numberAvailable: number;
numberUnavailable: number;
}
getImages() {
const containers: IPodContainer[] = get(this, "spec.template.spec.containers", [])
const initContainers: IPodContainer[] = get(this, "spec.template.spec.initContainers", [])
return [...containers, ...initContainers].map(container => container.image)
}
}
export const daemonSetApi = new KubeApi({
kind: DaemonSet.kind,
apiBase: "/apis/apps/v1/daemonsets",
isNamespaced: true,
objectConstructor: DaemonSet,
});
@@ -0,0 +1,171 @@
import { IAffinity, WorkloadKubeObject } from "../workload-kube-object";
import { autobind } from "../../utils";
import { KubeApi } from "../kube-api";
export class DeploymentApi extends KubeApi<Deployment> {
protected getScaleApiUrl(params: { namespace: string; name: string }) {
return this.getUrl(params) + "/scale"
}
getReplicas(params: { namespace: string; name: string }): Promise<number> {
return this.request
.get(this.getScaleApiUrl(params))
.then(({ status }: any) => status.replicas)
}
scale(params: { namespace: string; name: string }, replicas: number) {
return this.request.put(this.getScaleApiUrl(params), {
data: {
metadata: params,
spec: {
replicas: replicas
}
}
})
}
}
@autobind()
export class Deployment extends WorkloadKubeObject {
static kind = "Deployment"
spec: {
replicas: number;
selector: { matchLabels: { [app: string]: string } };
template: {
metadata: {
creationTimestamp?: string;
labels: { [app: string]: string };
};
spec: {
containers: {
name: string;
image: string;
args?: string[];
ports?: {
name: string;
containerPort: number;
protocol: string;
}[];
env?: {
name: string;
value: string;
}[];
resources: {
limits?: {
cpu: string;
memory: string;
};
requests: {
cpu: string;
memory: string;
};
};
volumeMounts?: {
name: string;
mountPath: string;
}[];
livenessProbe?: {
httpGet: {
path: string;
port: number;
scheme: string;
};
initialDelaySeconds: number;
timeoutSeconds: number;
periodSeconds: number;
successThreshold: number;
failureThreshold: number;
};
readinessProbe?: {
httpGet: {
path: string;
port: number;
scheme: string;
};
initialDelaySeconds: number;
timeoutSeconds: number;
periodSeconds: number;
successThreshold: number;
failureThreshold: number;
};
terminationMessagePath: string;
terminationMessagePolicy: string;
imagePullPolicy: string;
}[];
restartPolicy: string;
terminationGracePeriodSeconds: number;
dnsPolicy: string;
affinity?: IAffinity;
nodeSelector?: {
[selector: string]: string;
};
serviceAccountName: string;
serviceAccount: string;
securityContext: {};
schedulerName: string;
tolerations?: {
key: string;
operator: string;
effect: string;
tolerationSeconds: number;
}[];
volumes?: {
name: string;
configMap: {
name: string;
defaultMode: number;
optional: boolean;
};
}[];
};
};
strategy: {
type: string;
rollingUpdate: {
maxUnavailable: number;
maxSurge: number;
};
};
}
status: {
observedGeneration: number;
replicas: number;
updatedReplicas: number;
readyReplicas: number;
availableReplicas?: number;
unavailableReplicas?: number;
conditions: {
type: string;
status: string;
lastUpdateTime: string;
lastTransitionTime: string;
reason: string;
message: string;
}[];
}
getConditions(activeOnly = false) {
const { conditions } = this.status
if (!conditions) return []
if (activeOnly) {
return conditions.filter(c => c.status === "True")
}
return conditions
}
getConditionsText(activeOnly = true) {
return this.getConditions(activeOnly).map(({ type }) => type).join(" ")
}
getReplicas() {
return this.spec.replicas || 0;
}
}
export const deploymentApi = new DeploymentApi({
kind: Deployment.kind,
apiBase: "/apis/apps/v1/deployments",
isNamespaced: true,
objectConstructor: Deployment,
});
@@ -0,0 +1,59 @@
import moment from "moment";
import { KubeObject } from "../kube-object";
import { formatDuration } from "../../utils/formatDuration";
import { autobind } from "../../utils";
import { KubeApi } from "../kube-api";
@autobind()
export class KubeEvent extends KubeObject {
static kind = "Event"
involvedObject: {
kind: string;
namespace: string;
name: string;
uid: string;
apiVersion: string;
resourceVersion: string;
fieldPath: string;
}
reason: string
message: string
source: {
component: string;
host: string;
}
firstTimestamp: string
lastTimestamp: string
count: number
type: string
eventTime: null
reportingComponent: string
reportingInstance: string
isWarning() {
return this.type === "Warning";
}
getSource() {
const { component, host } = this.source
return `${component} ${host || ""}`
}
getFirstSeenTime() {
const diff = moment().diff(this.firstTimestamp)
return formatDuration(diff, true)
}
getLastSeenTime() {
const diff = moment().diff(this.lastTimestamp)
return formatDuration(diff, true)
}
}
export const eventApi = new KubeApi({
kind: KubeEvent.kind,
apiBase: "/api/v1/events",
isNamespaced: true,
objectConstructor: KubeEvent,
})
@@ -0,0 +1,129 @@
import pathToRegExp from "path-to-regexp";
import { apiKubeHelm } from "../index";
import { stringify } from "querystring";
import { autobind } from "../../utils";
interface IHelmChartList {
[repo: string]: {
[name: string]: HelmChart;
};
}
export interface IHelmChartDetails {
readme: string;
versions: HelmChart[];
}
const endpoint = pathToRegExp.compile(`/v2/charts/:repo?/:name?`) as (params?: {
repo?: string;
name?: string;
}) => string;
export const helmChartsApi = {
list() {
return apiKubeHelm
.get<IHelmChartList>(endpoint())
.then(data => {
return Object
.values(data)
.reduce((allCharts, repoCharts) => allCharts.concat(Object.values(repoCharts)), [])
.map(HelmChart.create);
});
},
get(repo: string, name: string, readmeVersion?: string) {
const path = endpoint({ repo, name });
return apiKubeHelm
.get<IHelmChartDetails>(path + "?" + stringify({ version: readmeVersion }))
.then(data => {
const versions = data.versions.map(HelmChart.create);
const readme = data.readme;
return {
readme,
versions,
}
});
},
getValues(repo: string, name: string, version: string) {
return apiKubeHelm
.get<string>(`/v2/charts/${repo}/${name}/values?` + stringify({ version }));
}
};
@autobind()
export class HelmChart {
constructor(data: any) {
Object.assign(this, data);
}
static create(data: any) {
return new HelmChart(data);
}
apiVersion: string
name: string
version: string
repo: string
kubeVersion?: string
created: string
description?: string
digest: string
keywords?: string[]
home?: string
sources?: string[]
maintainers?: {
name: string;
email: string;
url: string;
}[]
engine?: string
icon?: string
appVersion?: string
deprecated?: boolean
tillerVersion?: string
getId() {
return this.digest;
}
getName() {
return this.name;
}
getFullName(splitter = "/") {
return [this.getRepository(), this.getName()].join(splitter);
}
getDescription() {
return this.description;
}
getIcon() {
return this.icon;
}
getHome() {
return this.home;
}
getMaintainers() {
return this.maintainers || [];
}
getVersion() {
return this.version;
}
getRepository() {
return this.repo;
}
getAppVersion() {
return this.appVersion || "";
}
getKeywords() {
return this.keywords || [];
}
}
@@ -0,0 +1,213 @@
import jsYaml from "js-yaml";
import pathToRegExp from "path-to-regexp";
import { autobind, formatDuration } from "../../utils";
import capitalize from "lodash/capitalize";
import { apiKubeHelm } from "../index";
import { helmChartStore } from "../../components/+apps-helm-charts/helm-chart.store";
import { ItemObject } from "../../item.store";
import { KubeObject } from "../kube-object";
interface IReleasePayload {
name: string;
namespace: string;
version: string;
config: string; // release values
manifest: string;
info: {
deleted: string;
description: string;
first_deployed: string;
last_deployed: string;
notes: string;
status: string;
};
}
interface IReleaseRawDetails extends IReleasePayload {
resources: string;
}
export interface IReleaseDetails extends IReleasePayload {
resources: KubeObject[];
}
export interface IReleaseCreatePayload {
name?: string;
repo: string;
chart: string;
namespace: string;
version: string;
values: string;
}
export interface IReleaseUpdatePayload {
repo: string;
chart: string;
version: string;
values: string;
}
export interface IReleaseUpdateDetails {
log: string;
release: IReleaseDetails;
}
export interface IReleaseRevision {
revision: number;
updated: string;
status: string;
chart: string;
description: string;
}
const endpoint = pathToRegExp.compile(`/v2/releases/:namespace?/:name?`) as (
params?: {
namespace?: string;
name?: string;
}
) => string;
export const helmReleasesApi = {
list(namespace?: string) {
return apiKubeHelm
.get<HelmRelease[]>(endpoint({ namespace }))
.then(releases => releases.map(HelmRelease.create));
},
get(name: string, namespace: string) {
const path = endpoint({ name, namespace });
return apiKubeHelm.get<IReleaseRawDetails>(path).then(details => {
const items: KubeObject[] = JSON.parse(details.resources).items;
const resources = items.map(item => KubeObject.create(item));
return {
...details,
resources
}
});
},
create(payload: IReleaseCreatePayload): Promise<IReleaseUpdateDetails> {
const { repo, ...data } = payload;
data.chart = `${repo}/${data.chart}`;
data.values = jsYaml.safeLoad(data.values);
return apiKubeHelm.post(endpoint(), { data });
},
update(name: string, namespace: string, payload: IReleaseUpdatePayload): Promise<IReleaseUpdateDetails> {
const { repo, ...data } = payload;
data.chart = `${repo}/${data.chart}`;
data.values = jsYaml.safeLoad(data.values);
return apiKubeHelm.put(endpoint({ name, namespace }), { data });
},
async delete(name: string, namespace: string) {
const path = endpoint({ name, namespace });
return apiKubeHelm.del(path);
},
getValues(name: string, namespace: string) {
const path = endpoint({ name, namespace }) + "/values";
return apiKubeHelm.get<string>(path);
},
getHistory(name: string, namespace: string): Promise<IReleaseRevision[]> {
const path = endpoint({ name, namespace }) + "/history";
return apiKubeHelm.get(path);
},
rollback(name: string, namespace: string, revision: number) {
const path = endpoint({ name, namespace }) + "/rollback";
return apiKubeHelm.put(path, {
data: {
revision: revision
}
});
}
};
@autobind()
export class HelmRelease implements ItemObject {
constructor(data: any) {
Object.assign(this, data);
}
static create(data: any) {
return new HelmRelease(data);
}
appVersion: string
name: string
namespace: string
chart: string
status: string
updated: string
revision: number
getId() {
return this.namespace + this.name;
}
getName() {
return this.name;
}
getNs() {
return this.namespace;
}
getChart(withVersion = false) {
return withVersion ?
this.chart :
this.chart.substr(0, this.chart.lastIndexOf("-"));
}
getRevision() {
return this.revision;
}
getStatus() {
return capitalize(this.status);
}
getVersion() {
return this.chart.match(/(\d+)[^-]*$/)[0];
}
getUpdated(humanize = true, compact = true) {
const now = new Date().getTime();
const updated = this.updated.replace(/\s\w*$/, ""); // 2019-11-26 10:58:09 +0300 MSK -> 2019-11-26 10:58:09 +0300 to pass into Date()
const updatedDate = new Date(updated).getTime();
const diff = now - updatedDate;
if (humanize) {
return formatDuration(diff, compact);
}
return diff;
}
// Helm does not store from what repository the release is installed,
// so we have to try to guess it by searching charts
async getRepo() {
const chartName = this.getChart();
const version = this.getVersion();
const versions = await helmChartStore.getVersions(chartName);
const chartVersion = versions.find(chartVersion => chartVersion.version === version);
return chartVersion ? chartVersion.repo : "";
}
getLastVersion(): string | null {
const chartName = this.getChart();
const versions = helmChartStore.versions.get(chartName);
if (!versions) {
return null; // checking new version state
}
if (versions.length) {
return versions[0].version; // versions already sorted when loaded, the first is latest
}
return this.getVersion();
}
hasNewVersion() {
const lastVersion = this.getLastVersion();
return lastVersion && lastVersion !== this.getVersion();
}
}
+140
View File
@@ -0,0 +1,140 @@
import { KubeObject } from "../kube-object";
import { KubeApi } from "../kube-api";
export enum HpaMetricType {
Resource = "Resource",
Pods = "Pods",
Object = "Object",
External = "External",
}
export type IHpaMetricData<T = any> = T & {
target?: {
kind: string;
name: string;
apiVersion: string;
};
name?: string;
metricName?: string;
currentAverageUtilization?: number;
currentAverageValue?: string;
targetAverageUtilization?: number;
targetAverageValue?: string;
}
export interface IHpaMetric {
[kind: string]: IHpaMetricData;
type: HpaMetricType;
resource?: IHpaMetricData<{ name: string }>;
pods?: IHpaMetricData;
external?: IHpaMetricData;
object?: IHpaMetricData<{
describedObject: {
apiVersion: string;
kind: string;
name: string;
};
}>;
}
export class HorizontalPodAutoscaler extends KubeObject {
static kind = "HorizontalPodAutoscaler";
spec: {
scaleTargetRef: {
kind: string;
name: string;
apiVersion: string;
};
minReplicas: number;
maxReplicas: number;
metrics: IHpaMetric[];
}
status: {
currentReplicas: number;
desiredReplicas: number;
currentMetrics: IHpaMetric[];
conditions: {
lastTransitionTime: string;
message: string;
reason: string;
status: string;
type: string;
}[];
}
getMaxPods() {
return this.spec.maxReplicas || 0;
}
getMinPods() {
return this.spec.minReplicas || 0;
}
getReplicas() {
return this.status.currentReplicas;
}
getConditions() {
if (!this.status.conditions) return [];
return this.status.conditions.map(condition => {
const { message, reason, lastTransitionTime, status } = condition;
return {
...condition,
isReady: status === "True",
tooltip: `${message || reason} (${lastTransitionTime})`
}
});
}
getMetrics() {
return this.spec.metrics || [];
}
getCurrentMetrics() {
return this.status.currentMetrics || [];
}
protected getMetricName(metric: IHpaMetric): string {
const { type, resource, pods, object, external } = metric;
switch (type) {
case HpaMetricType.Resource:
return resource.name
case HpaMetricType.Pods:
return pods.metricName;
case HpaMetricType.Object:
return object.metricName;
case HpaMetricType.External:
return external.metricName;
}
}
// todo: refactor
getMetricValues(metric: IHpaMetric): string {
const metricType = metric.type.toLowerCase();
const currentMetric = this.getCurrentMetrics().find(current =>
metric.type == current.type && this.getMetricName(metric) == this.getMetricName(current)
);
const current = currentMetric ? currentMetric[metricType] : null;
const target = metric[metricType];
let currentValue = "unknown";
let targetValue = "unknown";
if (current) {
currentValue = current.currentAverageUtilization || current.currentAverageValue || current.currentValue;
if (current.currentAverageUtilization) currentValue += "%";
}
if (target) {
targetValue = target.targetAverageUtilization || target.targetAverageValue || target.targetValue;
if (target.targetAverageUtilization) targetValue += "%"
}
return `${currentValue} / ${targetValue}`;
}
}
export const hpaApi = new KubeApi({
kind: HorizontalPodAutoscaler.kind,
apiBase: "/apis/autoscaling/v2beta1/horizontalpodautoscalers",
isNamespaced: true,
objectConstructor: HorizontalPodAutoscaler,
});
+32
View File
@@ -0,0 +1,32 @@
// Local express.js & kontena endpoints
export * from "./config.api"
export * from "./cluster.api"
export * from "./kubeconfig.api"
// Kubernetes endpoints
// Docs: https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.10/
export * from "./namespaces.api"
export * from "./cluster-role.api"
export * from "./cluster-role-binding.api"
export * from "./role.api"
export * from "./role-binding.api"
export * from "./secret.api"
export * from "./service-accounts.api"
export * from "./nodes.api"
export * from "./pods.api"
export * from "./deployment.api"
export * from "./daemon-set.api"
export * from "./stateful-set.api"
export * from "./replica-set.api"
export * from "./job.api"
export * from "./cron-job.api"
export * from "./configmap.api"
export * from "./ingress.api"
export * from "./network-policy.api"
export * from "./persistent-volume-claims.api"
export * from "./persistent-volume.api"
export * from "./service.api"
export * from "./storage-class.api"
export * from "./pod-metrics.api"
export * from "./podsecuritypolicy.api"
export * from "./selfsubjectrulesreviews.api"
@@ -0,0 +1,118 @@
import { KubeObject } from "../kube-object";
import { autobind } from "../../utils";
import { IMetrics, metricsApi } from "./metrics.api";
import { KubeApi } from "../kube-api";
export class IngressApi extends KubeApi<Ingress> {
getMetrics(ingress: string, namespace: string): Promise<IIngressMetrics> {
const bytesSent = (statuses: string) =>
`sum(rate(nginx_ingress_controller_bytes_sent_sum{ingress="${ingress}", status=~"${statuses}"}[1m])) by (ingress)`;
const bytesSentSuccess = bytesSent("^2\\\\d*"); // Requests with status 2**
const bytesSentFailure = bytesSent("^5\\\\d*"); // Requests with status 5**
const requestDurationSeconds = `sum(rate(nginx_ingress_controller_request_duration_seconds_sum{ingress="${ingress}"}[1m])) by (ingress)`;
const responseDurationSeconds = `sum(rate(nginx_ingress_controller_response_duration_seconds_sum{ingress="${ingress}"}[1m])) by (ingress)`;
return metricsApi.getMetrics({
bytesSentSuccess,
bytesSentFailure,
requestDurationSeconds,
responseDurationSeconds
}, {
namespace,
});
}
}
export interface IIngressMetrics<T = IMetrics> {
[metric: string]: T;
bytesSentSuccess: T;
bytesSentFailure: T;
requestDurationSeconds: T;
responseDurationSeconds: T;
}
@autobind()
export class Ingress extends KubeObject {
static kind = "Ingress"
spec: {
tls: {
secretName: string;
}[];
rules?: {
host?: string;
http: {
paths: {
path?: string;
backend: {
serviceName: string;
servicePort: number;
};
}[];
};
}[];
backend?: {
serviceName: string;
servicePort: number;
};
}
status: {
loadBalancer: {
ingress: any[];
};
}
getRoutes() {
const { spec: { tls, rules } } = this
if (!rules) return []
let protocol = "http"
const routes: string[] = []
if (tls && tls.length > 0) {
protocol += "s"
}
rules.map(rule => {
const host = rule.host ? rule.host : "*"
if (rule.http && rule.http.paths) {
rule.http.paths.forEach(path => {
routes.push(protocol + "://" + host + (path.path || "/") + " ⇢ " + path.backend.serviceName + ":" + path.backend.servicePort)
})
}
})
return routes;
}
getHosts() {
const { spec: { rules } } = this
if (!rules) return []
return rules.filter(rule => rule.host).map(rule => rule.host)
}
getPorts() {
const ports: number[] = []
const { spec: { tls, rules, backend } } = this
const httpPort = 80
const tlsPort = 443
if (rules && rules.length > 0) {
if (rules.some(rule => rule.hasOwnProperty("http"))) {
ports.push(httpPort)
}
}
else {
if (backend && backend.servicePort) {
ports.push(backend.servicePort)
}
}
if (tls && tls.length > 0) {
ports.push(tlsPort)
}
return ports.join(", ")
}
}
export const ingressApi = new IngressApi({
kind: Ingress.kind,
apiBase: "/apis/extensions/v1beta1/ingresses",
isNamespaced: true,
objectConstructor: Ingress,
});
+98
View File
@@ -0,0 +1,98 @@
import get from "lodash/get";
import { autobind } from "../../utils";
import { IAffinity, WorkloadKubeObject } from "../workload-kube-object";
import { IPodContainer } from "./pods.api";
import { KubeApi } from "../kube-api";
@autobind()
export class Job extends WorkloadKubeObject {
static kind = "Job"
spec: {
parallelism?: number;
completions?: number;
backoffLimit?: number;
selector: {
matchLabels: {
[name: string]: string;
};
};
template: {
metadata: {
creationTimestamp?: string;
labels: {
name: string;
};
};
spec: {
containers: IPodContainer[];
restartPolicy: string;
terminationGracePeriodSeconds: number;
dnsPolicy: string;
hostPID: boolean;
affinity?: IAffinity;
nodeSelector?: {
[selector: string]: string;
};
tolerations: {
key: string;
operator: string;
effect: string;
tolerationSeconds: number;
}[];
schedulerName: string;
};
};
containers?: IPodContainer[];
restartPolicy?: string;
terminationGracePeriodSeconds?: number;
dnsPolicy?: string;
serviceAccountName?: string;
serviceAccount?: string;
schedulerName?: string;
}
status: {
conditions: {
type: string;
status: string;
lastProbeTime: string;
lastTransitionTime: string;
message?: string;
}[];
startTime: string;
completionTime: string;
succeeded: number;
}
getDesiredCompletions() {
return this.spec.completions || 0;
}
getCompletions() {
return this.status.succeeded || 0;
}
getParallelism() {
return this.spec.parallelism;
}
getCondition() {
// Type of Job condition could be only Complete or Failed
// https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.14/#jobcondition-v1-batch
const { conditions } = this.status;
if (!conditions) return;
return conditions.find(({ status }) => status === "True");
}
getImages() {
const containers: IPodContainer[] = get(this, "spec.template.spec.containers", [])
return [...containers].map(container => container.image)
}
}
export const jobApi = new KubeApi({
kind: Job.kind,
apiBase: "/apis/batch/v1/jobs",
isNamespaced: true,
objectConstructor: Job,
});
@@ -0,0 +1,12 @@
// Kubeconfig api
import { apiBase } from "../index";
export const kubeConfigApi = {
getUserConfig() {
return apiBase.get("/kubeconfig/user");
},
getServiceAccountConfig(account: string, namespace: string) {
return apiBase.get(`/kubeconfig/service-account/${namespace}/${account}`);
},
};
@@ -0,0 +1,112 @@
// Metrics api
import moment from "moment";
import { apiBase } from "../index";
import { IMetricsQuery } from "../../../server/common/metrics";
export interface IMetrics {
status: string;
data: {
resultType: string;
result: IMetricsResult[];
};
}
export interface IMetricsResult {
metric: {
[name: string]: string;
instance: string;
node?: string;
pod?: string;
kubernetes?: string;
kubernetes_node?: string;
kubernetes_namespace?: string;
};
values: [number, string][];
}
export interface IMetricsReqParams {
start?: number | string; // timestamp in seconds or valid date-string
end?: number | string;
step?: number; // step in seconds (default: 60s = each point 1m)
range?: number; // time-range in seconds for data aggregation (default: 3600s = last 1h)
namespace?: string; // rbac-proxy validation param
}
export const metricsApi = {
async getMetrics<T = IMetricsQuery>(query: T, reqParams: IMetricsReqParams = {}): Promise<T extends object ? { [K in keyof T]: IMetrics } : IMetrics> {
const { range = 3600, step = 60, namespace } = reqParams;
let { start, end } = reqParams;
if (!start && !end) {
const timeNow = Date.now() / 1000;
const now = moment.unix(timeNow).startOf('minute').unix(); // round date to minutes
start = now - range;
end = now;
}
return apiBase.post("/metrics", {
data: query,
query: {
start, end, step,
"kubernetes_namespace": namespace,
}
});
},
};
export function normalizeMetrics(metrics: IMetrics, frames = 60): IMetrics {
const { result } = metrics.data;
if (result.length) {
if (frames > 0) {
// fill the gaps
result.forEach(res => {
if (!res.values || !res.values.length) return;
while (res.values.length < frames) {
const timestamp = moment.unix(res.values[0][0]).subtract(1, "minute").unix();
res.values.unshift([timestamp, "0"])
}
});
}
} else {
// always return at least empty values array
result.push({
metric: {},
values: []
} as IMetricsResult);
}
return metrics;
}
export function isMetricsEmpty(metrics: { [key: string]: IMetrics }) {
return Object.values(metrics).every(metric => !metric.data.result.length);
}
export function getItemMetrics(metrics: { [key: string]: IMetrics }, itemName: string) {
if (!metrics) return;
const itemMetrics = { ...metrics };
for (const metric in metrics) {
const results = metrics[metric].data.result;
const result = results.find(res => Object.values(res.metric)[0] == itemName);
itemMetrics[metric].data.result = result ? [result] : [];
}
return itemMetrics;
}
export function getMetricLastPoints(metrics: { [key: string]: IMetrics }) {
const result: Partial<{[metric: string]: number}> = {};
Object.keys(metrics).forEach(metricName => {
try {
const metric = metrics[metricName];
if (metric.data.result.length) {
result[metricName] = +metric.data.result[0].values.slice(-1)[0][1];
}
} catch (e) {
}
return result;
}, {});
return result;
}
@@ -0,0 +1,28 @@
import { KubeApi } from "../kube-api";
import { KubeObject } from "../kube-object";
import { autobind } from "../../utils";
export enum NamespaceStatus {
ACTIVE = "Active",
TERMINATING = "Terminating",
}
@autobind()
export class Namespace extends KubeObject {
static kind = "Namespace";
status?: {
phase: string;
}
getStatus() {
return this.status ? this.status.phase : "-";
}
}
export const namespacesApi = new KubeApi({
kind: Namespace.kind,
apiBase: "/api/v1/namespaces",
isNamespaced: false,
objectConstructor: Namespace,
});
@@ -0,0 +1,72 @@
import { KubeObject } from "../kube-object";
import { autobind } from "../../utils";
import { KubeApi } from "../kube-api";
export interface IPolicyIpBlock {
cidr: string;
except?: string[];
}
export interface IPolicySelector {
matchLabels: {
[label: string]: string;
};
}
export interface IPolicyIngress {
from: {
ipBlock?: IPolicyIpBlock;
namespaceSelector?: IPolicySelector;
podSelector?: IPolicySelector;
}[];
ports: {
protocol: string;
port: number;
}[];
}
export interface IPolicyEgress {
to: {
ipBlock: IPolicyIpBlock;
}[];
ports: {
protocol: string;
port: number;
}[];
}
@autobind()
export class NetworkPolicy extends KubeObject {
static kind = "NetworkPolicy"
spec: {
podSelector: {
matchLabels: {
[label: string]: string;
role: string;
};
};
policyTypes: string[];
ingress: IPolicyIngress[];
egress: IPolicyEgress[];
}
getMatchLabels(): string[] {
if (!this.spec.podSelector || !this.spec.podSelector.matchLabels) return [];
return Object
.entries(this.spec.podSelector.matchLabels)
.map(data => data.join(":"))
}
getTypes(): string[] {
if (!this.spec.policyTypes) return [];
return this.spec.policyTypes;
}
}
export const networkPolicyApi = new KubeApi({
kind: NetworkPolicy.kind,
apiBase: "/apis/networking.k8s.io/v1/networkpolicies",
isNamespaced: true,
objectConstructor: NetworkPolicy,
});
+158
View File
@@ -0,0 +1,158 @@
import { KubeObject } from "../kube-object";
import { autobind, cpuUnitsToNumber, unitsToBytes } from "../../utils";
import { IMetrics, metricsApi } from "./metrics.api";
import { KubeApi } from "../kube-api";
export class NodesApi extends KubeApi<Node> {
getMetrics(): Promise<INodeMetrics> {
const memoryUsage = `
sum (
node_memory_MemTotal_bytes - (node_memory_MemFree_bytes + node_memory_Buffers_bytes + node_memory_Cached_bytes)
) by (kubernetes_node)
`;
const memoryCapacity = `sum(kube_node_status_capacity{resource="memory"}) by (node)`;
const cpuUsage = `sum(rate(node_cpu_seconds_total{mode=~"user|system"}[1m])) by(kubernetes_node)`;
const cpuCapacity = `sum(kube_node_status_allocatable{resource="cpu"}) by (node)`;
const fsSize = `sum(node_filesystem_size_bytes{mountpoint="/"}) by (kubernetes_node)`;
const fsUsage = `sum(node_filesystem_size_bytes{mountpoint="/"} - node_filesystem_avail_bytes{mountpoint="/"}) by (kubernetes_node)`;
return metricsApi.getMetrics({
memoryUsage,
memoryCapacity,
cpuUsage,
cpuCapacity,
fsSize,
fsUsage
});
}
}
export interface INodeMetrics<T = IMetrics> {
[metric: string]: T;
memoryUsage: T;
memoryCapacity: T;
cpuUsage: T;
cpuCapacity: T;
fsUsage: T;
fsSize: T;
}
@autobind()
export class Node extends KubeObject {
static kind = "Node"
spec: {
podCIDR: string;
externalID: string;
taints?: {
key: string;
value: string;
effect: string;
}[];
unschedulable?: boolean;
}
status: {
capacity: {
cpu: string;
memory: string;
pods: string;
};
allocatable: {
cpu: string;
memory: string;
pods: string;
};
conditions: {
type: string;
status?: string;
lastHeartbeatTime?: string;
lastTransitionTime?: string;
reason?: string;
message?: string;
}[];
addresses: {
type: string;
address: string;
}[];
nodeInfo: {
machineID: string;
systemUUID: string;
bootID: string;
kernelVersion: string;
osImage: string;
containerRuntimeVersion: string;
kubeletVersion: string;
kubeProxyVersion: string;
operatingSystem: string;
architecture: string;
};
images: {
names: string[];
sizeBytes: number;
}[];
}
getNodeConditionText() {
const { conditions } = this.status
if (!conditions) return ""
return conditions.reduce((types, current) => {
if (current.status !== "True") return ""
return types += ` ${current.type}`
}, "")
}
getTaints() {
return this.spec.taints || [];
}
getRoleLabels() {
const roleLabels = Object.keys(this.metadata.labels).filter(key =>
key.includes("node-role.kubernetes.io")
).map(key => key.match(/([^/]+$)/)[0]) // all after last slash
return roleLabels.join(", ")
}
getCpuCapacity() {
if (!this.status.capacity || !this.status.capacity.cpu) return 0
return cpuUnitsToNumber(this.status.capacity.cpu)
}
getMemoryCapacity() {
if (!this.status.capacity || !this.status.capacity.memory) return 0
return unitsToBytes(this.status.capacity.memory)
}
getConditions() {
const conditions = this.status.conditions || [];
if (this.isUnschedulable()) {
return [{ type: "SchedulingDisabled", status: "True" }, ...conditions];
}
return conditions;
}
getActiveConditions() {
return this.getConditions().filter(c => c.status === "True");
}
getWarningConditions() {
const goodConditions = ["Ready", "HostUpgrades", "SchedulingDisabled"];
return this.getActiveConditions().filter(condition => {
return !goodConditions.includes(condition.type);
});
}
getKubeletVersion() {
return this.status.nodeInfo.kubeletVersion;
}
isUnschedulable() {
return this.spec.unschedulable
}
}
export const nodesApi = new NodesApi({
kind: Node.kind,
apiBase: "/api/v1/nodes",
isNamespaced: false,
objectConstructor: Node,
});
@@ -0,0 +1,91 @@
import { KubeObject } from "../kube-object";
import { autobind } from "../../utils";
import { IMetrics, metricsApi } from "./metrics.api";
import { Pod } from "./pods.api";
import { KubeApi } from "../kube-api";
export class PersistentVolumeClaimsApi extends KubeApi<PersistentVolumeClaim> {
getMetrics(pvcName: string, namespace: string): Promise<IPvcMetrics> {
const diskUsage = `sum(kubelet_volume_stats_used_bytes{persistentvolumeclaim="${pvcName}"}) by (persistentvolumeclaim, namespace)`;
const diskCapacity = `sum(kubelet_volume_stats_capacity_bytes{persistentvolumeclaim="${pvcName}"}) by (persistentvolumeclaim, namespace)`;
return metricsApi.getMetrics({
diskUsage,
diskCapacity
}, {
namespace
});
}
}
export interface IPvcMetrics<T = IMetrics> {
[key: string]: T;
diskUsage: T;
diskCapacity: T;
}
@autobind()
export class PersistentVolumeClaim extends KubeObject {
static kind = "PersistentVolumeClaim"
spec: {
accessModes: string[];
storageClassName: string;
selector: {
matchLabels: {
release: string;
};
matchExpressions: {
key: string; // environment,
operator: string; // In,
values: string[]; // [dev]
}[];
};
resources: {
requests: {
storage: string; // 8Gi
};
};
}
status: {
phase: string; // Pending
}
getPods(allPods: Pod[]): Pod[] {
const pods = allPods.filter(pod => pod.getNs() === this.getNs())
return pods.filter(pod => {
return pod.getVolumes().filter(volume =>
volume.persistentVolumeClaim &&
volume.persistentVolumeClaim.claimName === this.getName()
).length > 0
})
}
getStorage(): string {
if (!this.spec.resources || !this.spec.resources.requests) return "-";
return this.spec.resources.requests.storage;
}
getMatchLabels(): string[] {
if (!this.spec.selector || !this.spec.selector.matchLabels) return [];
return Object.entries(this.spec.selector.matchLabels)
.map(([name, val]) => `${name}:${val}`);
}
getMatchExpressions() {
if (!this.spec.selector || !this.spec.selector.matchExpressions) return [];
return this.spec.selector.matchExpressions;
}
getStatus(): string {
if (this.status) return this.status.phase;
return "-"
}
}
export const pvcApi = new PersistentVolumeClaimsApi({
kind: PersistentVolumeClaim.kind,
apiBase: "/api/v1/persistentvolumeclaims",
isNamespaced: true,
objectConstructor: PersistentVolumeClaim,
});
@@ -0,0 +1,71 @@
import { KubeObject } from "../kube-object";
import { unitsToBytes } from "../../utils/convertMemory";
import { autobind } from "../../utils";
import { KubeApi } from "../kube-api";
@autobind()
export class PersistentVolume extends KubeObject {
static kind = "PersistentVolume"
spec: {
capacity: {
storage: string; // 8Gi
};
flexVolume: {
driver: string; // ceph.rook.io/rook-ceph-system,
options: {
clusterNamespace: string; // rook-ceph,
image: string; // pvc-c5d7c485-9f1b-11e8-b0ea-9600000e54fb,
pool: string; // replicapool,
storageClass: string; // rook-ceph-block
};
};
mountOptions?: string[];
accessModes: string[]; // [ReadWriteOnce]
claimRef: {
kind: string; // PersistentVolumeClaim,
namespace: string; // storage,
name: string; // nfs-provisioner,
uid: string; // c5d7c485-9f1b-11e8-b0ea-9600000e54fb,
apiVersion: string; // v1,
resourceVersion: string; // 292180
};
persistentVolumeReclaimPolicy: string; // Delete,
storageClassName: string; // rook-ceph-block
nfs?: {
path: string;
server: string;
};
}
status: {
phase: string;
reason?: string;
}
getCapacity(inBytes = false) {
const capacity = this.spec.capacity;
if (capacity) {
if (inBytes) return unitsToBytes(capacity.storage)
return capacity.storage;
}
return 0;
}
getStatus() {
if (!this.status) return;
return this.status.phase || "-";
}
getClaimRefName() {
const { claimRef } = this.spec;
return claimRef ? claimRef.name : "";
}
}
export const persistentVolumeApi = new KubeApi({
kind: PersistentVolume.kind,
apiBase: "/api/v1/persistentvolumes",
isNamespaced: false,
objectConstructor: PersistentVolume,
});
@@ -0,0 +1,21 @@
import { KubeObject } from "../kube-object";
import { KubeApi } from "../kube-api";
export class PodMetrics extends KubeObject {
timestamp: string
window: string
containers: {
name: string;
usage: {
cpu: string;
memory: string;
};
}[]
}
export const podMetricsApi = new KubeApi({
kind: PodMetrics.kind,
apiBase: "/apis/metrics.k8s.io/v1beta1/pods",
isNamespaced: true,
objectConstructor: PodMetrics,
});
+411
View File
@@ -0,0 +1,411 @@
import { IAffinity, WorkloadKubeObject } from "../workload-kube-object";
import { autobind } from "../../utils";
import { IMetrics, metricsApi } from "./metrics.api";
import { KubeApi } from "../kube-api";
export class PodsApi extends KubeApi<Pod> {
async getLogs(params: { namespace: string; name: string }, query?: IPodLogsQuery): Promise<string> {
const path = this.getUrl(params) + "/log";
return this.request.get(path, { query });
}
getMetrics(pods: Pod[], namespace: string, selector = "pod, namespace"): Promise<IPodMetrics> {
const podSelector = pods.map(pod => pod.getName()).join("|");
const cpuUsage = `sum(rate(container_cpu_usage_seconds_total{container_name!="POD",container_name!="",pod_name=~"${podSelector}",namespace="${namespace}"}[1m])) by (${selector})`;
const cpuRequests = `sum(kube_pod_container_resource_requests{pod=~"${podSelector}",resource="cpu",namespace="${namespace}"}) by (${selector})`;
const cpuLimits = `sum(kube_pod_container_resource_limits{pod=~"${podSelector}",resource="cpu",namespace="${namespace}"}) by (${selector})`;
const memoryUsage = `sum(container_memory_working_set_bytes{container_name!="POD",container_name!="",pod_name=~"${podSelector}",namespace="${namespace}"}) by (${selector})`;
const memoryRequests = `sum(kube_pod_container_resource_requests{pod=~"${podSelector}",resource="memory",namespace="${namespace}"}) by (${selector})`;
const memoryLimits = `sum(kube_pod_container_resource_limits{pod=~"${podSelector}",resource="memory",namespace="${namespace}"}) by (${selector})`;
const fsUsage = `sum(container_fs_usage_bytes{container_name!="POD",container_name!="",pod_name=~"${podSelector}",namespace="${namespace}"}) by (${selector})`;
const networkReceive = `sum(rate(container_network_receive_bytes_total{pod_name=~"${podSelector}",namespace="${namespace}"}[1m])) by (${selector})`;
const networkTransit = `sum(rate(container_network_transmit_bytes_total{pod_name=~"${podSelector}",namespace="${namespace}"}[1m])) by (${selector})`;
return metricsApi.getMetrics({
cpuUsage,
cpuRequests,
cpuLimits,
memoryUsage,
memoryRequests,
memoryLimits,
fsUsage,
networkReceive,
networkTransit,
}, {
namespace,
});
}
}
export interface IPodMetrics<T = IMetrics> {
[metric: string]: T;
cpuUsage: T;
cpuRequests: T;
cpuLimits: T;
memoryUsage: T;
memoryRequests: T;
memoryLimits: T;
fsUsage: T;
networkReceive: T;
networkTransit: T;
}
export interface IPodLogsQuery {
container?: string;
tailLines?: number;
timestamps?: boolean;
sinceTime?: string; // Date.toISOString()-format
}
export enum PodStatus {
TERMINATED = "Terminated",
FAILED = "Failed",
PENDING = "Pending",
RUNNING = "Running",
SUCCEEDED = "Succeeded",
EVICTED = "Evicted"
}
export interface IPodContainer {
name: string;
image: string;
command?: string[];
args?: string[];
ports: {
name?: string;
containerPort: number;
protocol: string;
}[];
resources?: {
limits: {
cpu: string;
memory: string;
};
requests: {
cpu: string;
memory: string;
};
};
env?: {
name: string;
value?: string;
valueFrom?: {
fieldRef?: {
apiVersion: string;
fieldPath: string;
};
secretKeyRef?: {
key: string;
name: string;
};
configMapKeyRef?: {
key: string;
name: string;
};
};
}[];
envFrom?: {
configMapRef?: {
name: string;
};
}[];
volumeMounts?: {
name: string;
readOnly: boolean;
mountPath: string;
}[];
livenessProbe?: IContainerProbe;
readinessProbe?: IContainerProbe;
imagePullPolicy: string;
}
interface IContainerProbe {
httpGet?: {
path?: string;
port: number;
scheme: string;
host?: string;
};
exec?: {
command: string[];
};
tcpSocket?: {
port: number;
};
initialDelaySeconds?: number;
timeoutSeconds?: number;
periodSeconds?: number;
successThreshold?: number;
failureThreshold?: number;
}
export interface IPodContainerStatus {
name: string;
state: {
[index: string]: object;
running?: {
startedAt: string;
};
waiting?: {
reason: string;
message: string;
};
terminated?: {
startedAt: string;
finishedAt: string;
exitCode: number;
reason: string;
};
};
lastState: {};
ready: boolean;
restartCount: number;
image: string;
imageID: string;
containerID: string;
}
@autobind()
export class Pod extends WorkloadKubeObject {
static kind = "Pod"
spec: {
volumes?: {
name: string;
persistentVolumeClaim: {
claimName: string;
};
secret: {
secretName: string;
defaultMode: number;
};
}[];
initContainers: IPodContainer[];
containers: IPodContainer[];
restartPolicy: string;
terminationGracePeriodSeconds: number;
dnsPolicy: string;
serviceAccountName: string;
serviceAccount: string;
priority: number;
priorityClassName: string;
nodeName: string;
nodeSelector?: {
[selector: string]: string;
};
securityContext: {};
schedulerName: string;
tolerations: {
key: string;
operator: string;
effect: string;
tolerationSeconds: number;
}[];
affinity: IAffinity;
}
status: {
phase: string;
conditions: {
type: string;
status: string;
lastProbeTime: number;
lastTransitionTime: string;
}[];
hostIP: string;
podIP: string;
startTime: string;
initContainerStatuses?: IPodContainerStatus[];
containerStatuses?: IPodContainerStatus[];
qosClass: string;
reason?: string;
}
getInitContainers() {
return this.spec.initContainers || [];
}
getContainers() {
return this.spec.containers || [];
}
getAllContainers() {
return this.getContainers().concat(this.getInitContainers());
}
getRunningContainers() {
const statuses = this.getContainerStatuses()
return this.getAllContainers().filter(container => {
return statuses.find(status => status.name === container.name && !!status.state["running"])
}
)
}
getContainerStatuses(includeInitContainers = true) {
const statuses: IPodContainerStatus[] = [];
const { containerStatuses, initContainerStatuses } = this.status;
if (containerStatuses) {
statuses.push(...containerStatuses);
}
if (includeInitContainers && initContainerStatuses) {
statuses.push(...initContainerStatuses);
}
return statuses;
}
getRestartsCount(): number {
const { containerStatuses } = this.status;
if (!containerStatuses) return 0;
return containerStatuses.reduce((count, item) => count + item.restartCount, 0);
}
getQosClass() {
return this.status.qosClass || "";
}
getReason() {
return this.status.reason || "";
}
getPriorityClassName() {
return this.spec.priorityClassName || "";
}
// Returns one of 5 statuses: Running, Succeeded, Pending, Failed, Evicted
getStatus() {
const phase = this.getStatusPhase();
const reason = this.getReason();
const goodConditions = ["Initialized", "Ready"].every(condition =>
!!this.getConditions().find(item => item.type === condition && item.status === "True")
);
if (reason === PodStatus.EVICTED) {
return PodStatus.EVICTED;
}
if (phase === PodStatus.FAILED) {
return PodStatus.FAILED;
}
if (phase === PodStatus.SUCCEEDED) {
return PodStatus.SUCCEEDED;
}
if (phase === PodStatus.RUNNING && goodConditions) {
return PodStatus.RUNNING;
}
return PodStatus.PENDING;
}
// Returns pod phase or container error if occured
getStatusMessage() {
let message = "";
const statuses = this.getContainerStatuses(false); // not including initContainers
if (statuses.length) {
statuses.forEach(status => {
const { state } = status;
if (state.waiting) {
const { reason } = state.waiting;
message = reason ? reason : "Waiting";
}
if (state.terminated) {
const { reason } = state.terminated;
message = reason ? reason : "Terminated";
}
})
}
if (this.getReason() === PodStatus.EVICTED) return "Evicted";
if (message) return message;
return this.getStatusPhase();
}
getStatusPhase() {
return this.status.phase;
}
getConditions() {
return this.status.conditions || [];
}
getVolumes() {
return this.spec.volumes || [];
}
getSecrets(): string[] {
return this.getVolumes()
.filter(vol => vol.secret)
.map(vol => vol.secret.secretName);
}
getNodeSelectors(): string[] {
const { nodeSelector } = this.spec
if (!nodeSelector) return []
return Object.entries(nodeSelector).map(values => values.join(": "))
}
getTolerations() {
return this.spec.tolerations || []
}
getAffinity(): IAffinity {
return this.spec.affinity
}
hasIssues() {
const notReady = !!this.getConditions().find(condition => {
return condition.type == "Ready" && condition.status !== "True"
});
const crashLoop = !!this.getContainerStatuses().find(condition => {
const waiting = condition.state.waiting
return (waiting && waiting.reason == "CrashLoopBackOff")
})
return (
notReady ||
crashLoop ||
this.getStatusPhase() !== "Running"
)
}
getLivenessProbe(container: IPodContainer) {
return this.getProbe(container.livenessProbe);
}
getReadinessProbe(container: IPodContainer) {
return this.getProbe(container.readinessProbe);
}
getProbe(probeData: IContainerProbe) {
if (!probeData) return [];
const {
httpGet, exec, tcpSocket, initialDelaySeconds, timeoutSeconds,
periodSeconds, successThreshold, failureThreshold
} = probeData;
const probe = [];
// HTTP Request
if (httpGet) {
const { path, port, host, scheme } = httpGet;
probe.push(
"http-get",
`${scheme.toLowerCase()}://${host || ""}:${port || ""}${path || ""}`,
);
}
// Command
if (exec && exec.command) {
probe.push(`exec [${exec.command.join(" ")}]`);
}
// TCP Probe
if (tcpSocket && tcpSocket.port) {
probe.push(`tcp-socket :${tcpSocket.port}`);
}
probe.push(
`delay=${initialDelaySeconds || "0"}s`,
`timeout=${timeoutSeconds || "0"}s`,
`period=${periodSeconds || "0"}s`,
`#success=${successThreshold || "0"}`,
`#failure=${failureThreshold || "0"}`,
);
return probe;
}
}
export const podsApi = new PodsApi({
kind: Pod.kind,
apiBase: "/api/v1/pods",
isNamespaced: true,
objectConstructor: Pod,
});
@@ -0,0 +1,94 @@
import { autobind } from "../../utils";
import { KubeObject } from "../kube-object";
import { KubeApi } from "../kube-api";
@autobind()
export class PodSecurityPolicy extends KubeObject {
static kind = "PodSecurityPolicy"
spec: {
allowPrivilegeEscalation?: boolean;
allowedCSIDrivers?: {
name: string;
}[];
allowedCapabilities: string[];
allowedFlexVolumes?: {
driver: string;
}[];
allowedHostPaths?: {
pathPrefix: string;
readOnly: boolean;
}[];
allowedProcMountTypes?: string[];
allowedUnsafeSysctls?: string[];
defaultAddCapabilities?: string[];
defaultAllowPrivilegeEscalation?: boolean;
forbiddenSysctls?: string[];
fsGroup?: {
rule: string;
ranges: { max: number; min: number }[];
};
hostIPC?: boolean;
hostNetwork?: boolean;
hostPID?: boolean;
hostPorts?: {
max: number;
min: number;
}[];
privileged?: boolean;
readOnlyRootFilesystem?: boolean;
requiredDropCapabilities?: string[];
runAsGroup?: {
ranges: { max: number; min: number }[];
rule: string;
};
runAsUser?: {
rule: string;
ranges: { max: number; min: number }[];
};
runtimeClass?: {
allowedRuntimeClassNames: string[];
defaultRuntimeClassName: string;
};
seLinux?: {
rule: string;
seLinuxOptions: {
level: string;
role: string;
type: string;
user: string;
};
};
supplementalGroups?: {
rule: string;
ranges: { max: number; min: number }[];
};
volumes?: string[];
}
isPrivileged() {
return !!this.spec.privileged;
}
getVolumes() {
return this.spec.volumes || [];
}
getRules() {
const { fsGroup, runAsGroup, runAsUser, supplementalGroups, seLinux } = this.spec;
return {
fsGroup: fsGroup ? fsGroup.rule : "",
runAsGroup: runAsGroup ? runAsGroup.rule : "",
runAsUser: runAsUser ? runAsUser.rule : "",
supplementalGroups: supplementalGroups ? supplementalGroups.rule : "",
seLinux: seLinux ? seLinux.rule : "",
};
}
}
export const pspApi = new KubeApi({
kind: PodSecurityPolicy.kind,
apiBase: "/apis/policy/v1beta1/podsecuritypolicies",
isNamespaced: false,
objectConstructor: PodSecurityPolicy,
});
@@ -0,0 +1,58 @@
import get from "lodash/get";
import { autobind } from "../../utils";
import { IAffinity, WorkloadKubeObject } from "../workload-kube-object";
import { IPodContainer } from "./pods.api";
import { KubeApi } from "../kube-api";
@autobind()
export class ReplicaSet extends WorkloadKubeObject {
static kind = "ReplicaSet"
spec: {
replicas?: number;
selector?: {
matchLabels: {
[key: string]: string;
};
};
containers?: IPodContainer[];
template?: {
spec?: {
affinity?: IAffinity;
nodeSelector?: {
[selector: string]: string;
};
tolerations: {
key: string;
operator: string;
effect: string;
tolerationSeconds: number;
}[];
containers: IPodContainer[];
};
};
restartPolicy?: string;
terminationGracePeriodSeconds?: number;
dnsPolicy?: string;
schedulerName?: string;
}
status: {
replicas: number;
fullyLabeledReplicas: number;
readyReplicas: number;
availableReplicas: number;
observedGeneration: number;
}
getImages() {
const containers: IPodContainer[] = get(this, "spec.template.spec.containers", [])
return [...containers].map(container => container.image)
}
}
export const replicaSetApi = new KubeApi({
kind: ReplicaSet.kind,
apiBase: "/apis/apps/v1/replicasets",
isNamespaced: true,
objectConstructor: ReplicaSet,
});
@@ -0,0 +1,26 @@
import jsYaml from "js-yaml"
import { KubeObject } from "../kube-object";
import { KubeJsonApiData } from "../kube-json-api";
import { apiKubeResourceApplier } from "../index";
import { apiManager } from "../api-manager";
export const resourceApplierApi = {
annotations: [
"kubectl.kubernetes.io/last-applied-configuration"
],
async update<D extends KubeObject>(resource: object | string): Promise<D> {
if (typeof resource === "string") {
resource = jsYaml.safeLoad(resource);
}
return apiKubeResourceApplier
.post<KubeJsonApiData[]>("/stack", { data: resource })
.then(data => {
const items = data.map(obj => {
const api = apiManager.getApi(obj.metadata.selfLink);
return new api.objectConstructor(obj);
});
return items.length === 1 ? items[0] : items;
});
}
};
@@ -0,0 +1,68 @@
import { KubeObject } from "../kube-object";
import { KubeApi } from "../kube-api";
import { KubeJsonApiData } from "../kube-json-api";
export interface IResourceQuotaValues {
[quota: string]: string;
// Compute Resource Quota
"limits.cpu"?: string;
"limits.memory"?: string;
"requests.cpu"?: string;
"requests.memory"?: string;
// Storage Resource Quota
"requests.storage"?: string;
"persistentvolumeclaims"?: string;
// Object Count Quota
"count/pods"?: string;
"count/persistentvolumeclaims"?: string;
"count/services"?: string;
"count/secrets"?: string;
"count/configmaps"?: string;
"count/replicationcontrollers"?: string;
"count/deployments.apps"?: string;
"count/replicasets.apps"?: string;
"count/statefulsets.apps"?: string;
"count/jobs.batch"?: string;
"count/cronjobs.batch"?: string;
"count/deployments.extensions"?: string;
}
export class ResourceQuota extends KubeObject {
static kind = "ResourceQuota"
constructor(data: KubeJsonApiData) {
super(data);
this.spec = this.spec || {} as any
}
spec: {
hard: IResourceQuotaValues;
scopeSelector?: {
matchExpressions: {
operator: string;
scopeName: string;
values: string[];
}[];
};
}
status: {
hard: IResourceQuotaValues;
used: IResourceQuotaValues;
}
getScopeSelector() {
const { matchExpressions = [] } = this.spec.scopeSelector || {};
return matchExpressions;
}
}
export const resourceQuotaApi = new KubeApi({
kind: ResourceQuota.kind,
apiBase: "/api/v1/resourcequotas",
isNamespaced: true,
objectConstructor: ResourceQuota,
});
@@ -0,0 +1,37 @@
import { autobind } from "../../utils";
import { KubeObject } from "../kube-object";
import { KubeApi } from "../kube-api";
export interface IRoleBindingSubject {
kind: string;
name: string;
namespace?: string;
apiGroup?: string;
}
@autobind()
export class RoleBinding extends KubeObject {
static kind = "RoleBinding"
subjects?: IRoleBindingSubject[]
roleRef: {
kind: string;
name: string;
apiGroup?: string;
}
getSubjects() {
return this.subjects || [];
}
getSubjectNames(): string {
return this.getSubjects().map(subject => subject.name).join(", ")
}
}
export const roleBindingApi = new KubeApi({
kind: RoleBinding.kind,
apiBase: "/apis/rbac.authorization.k8s.io/v1/rolebindings",
isNamespaced: true,
objectConstructor: RoleBinding,
});
@@ -0,0 +1,24 @@
import { KubeObject } from "../kube-object";
import { KubeApi } from "../kube-api";
export class Role extends KubeObject {
static kind = "Role"
rules: {
verbs: string[];
apiGroups: string[];
resources: string[];
resourceNames?: string[];
}[]
getRules() {
return this.rules || [];
}
}
export const roleApi = new KubeApi({
kind: Role.kind,
apiBase: "/apis/rbac.authorization.k8s.io/v1/roles",
isNamespaced: true,
objectConstructor: Role,
});
@@ -0,0 +1,51 @@
import { KubeObject } from "../kube-object";
import { KubeJsonApiData } from "../kube-json-api";
import { autobind } from "../../utils";
import { KubeApi } from "../kube-api";
export enum SecretType {
Opaque = "Opaque",
ServiceAccountToken = "kubernetes.io/service-account-token",
Dockercfg = "kubernetes.io/dockercfg",
DockerConfigJson = "kubernetes.io/dockerconfigjson",
BasicAuth = "kubernetes.io/basic-auth",
SSHAuth = "kubernetes.io/ssh-auth",
TLS = "kubernetes.io/tls",
BootstrapToken = "bootstrap.kubernetes.io/token",
}
export interface ISecretRef {
key?: string;
name: string;
}
@autobind()
export class Secret extends KubeObject {
static kind = "Secret"
type: SecretType;
data: {
[prop: string]: string;
token?: string;
}
constructor(data: KubeJsonApiData) {
super(data);
this.data = this.data || {};
}
getKeys(): string[] {
return Object.keys(this.data);
}
getToken() {
return this.data.token;
}
}
export const secretsApi = new KubeApi({
kind: Secret.kind,
apiBase: "/api/v1/secrets",
isNamespaced: true,
objectConstructor: Secret,
});
@@ -0,0 +1,68 @@
import { KubeObject } from "../kube-object";
import { KubeApi } from "../kube-api";
export class SelfSubjectRulesReviewApi extends KubeApi<SelfSubjectRulesReview> {
create({ namespace = "default" }): Promise<SelfSubjectRulesReview> {
return super.create({}, {
spec: {
namespace
},
}
);
}
}
export interface ISelfSubjectReviewRule {
verbs: string[];
apiGroups?: string[];
resources?: string[];
resourceNames?: string[];
nonResourceURLs?: string[];
}
export class SelfSubjectRulesReview extends KubeObject {
static kind = "SelfSubjectRulesReview"
spec: {
// fixme: add more types from api docs
namespace?: string;
}
status: {
resourceRules: ISelfSubjectReviewRule[];
nonResourceRules: ISelfSubjectReviewRule[];
incomplete: boolean;
}
getResourceRules() {
const rules = this.status && this.status.resourceRules || [];
return rules.map(rule => this.normalize(rule));
}
getNonResourceRules() {
const rules = this.status && this.status.nonResourceRules || [];
return rules.map(rule => this.normalize(rule));
}
protected normalize(rule: ISelfSubjectReviewRule): ISelfSubjectReviewRule {
const { apiGroups = [], resourceNames = [], verbs = [], nonResourceURLs = [], resources = [] } = rule;
return {
apiGroups,
nonResourceURLs,
resourceNames,
verbs,
resources: resources.map((resource, index) => {
const apiGroup = apiGroups.length >= index + 1 ? apiGroups[index] : apiGroups.slice(-1)[0];
const separator = apiGroup == "" ? "" : ".";
return resource + separator + apiGroup;
})
}
}
}
export const selfSubjectRulesReviewApi = new SelfSubjectRulesReviewApi({
kind: SelfSubjectRulesReview.kind,
apiBase: "/apis/authorization.k8s.io/v1/selfsubjectrulesreviews",
isNamespaced: false,
objectConstructor: SelfSubjectRulesReview,
});
@@ -0,0 +1,30 @@
import { autobind } from "../../utils";
import { KubeObject } from "../kube-object";
import { KubeApi } from "../kube-api";
@autobind()
export class ServiceAccount extends KubeObject {
static kind = "ServiceAccount";
secrets?: {
name: string;
}[]
imagePullSecrets?: {
name: string;
}[]
getSecrets() {
return this.secrets || [];
}
getImagePullSecrets() {
return this.imagePullSecrets || [];
}
}
export const serviceAccountsApi = new KubeApi<ServiceAccount>({
kind: ServiceAccount.kind,
apiBase: "/api/v1/serviceaccounts",
isNamespaced: true,
objectConstructor: ServiceAccount,
});
@@ -0,0 +1,75 @@
import { autobind } from "../../utils";
import { KubeObject } from "../kube-object";
import { KubeApi } from "../kube-api";
@autobind()
export class Service extends KubeObject {
static kind = "Service"
spec: {
type: string;
clusterIP: string;
externalTrafficPolicy?: string;
loadBalancerIP?: string;
sessionAffinity: string;
selector: { [key: string]: string };
ports: { name?: string; protocol: string; port: number; targetPort: number }[];
externalIPs?: string[]; // https://kubernetes.io/docs/concepts/services-networking/service/#external-ips
}
status: {
loadBalancer?: {
ingress?: {
ip?: string;
hostname?: string;
}[];
};
}
getClusterIp() {
return this.spec.clusterIP;
}
getExternalIps() {
const lb = this.getLoadBalancer();
if (lb && lb.ingress) {
return lb.ingress.map(val => val.ip || val.hostname)
}
return this.spec.externalIPs || [];
}
getType() {
return this.spec.type || "-";
}
getSelector(): string[] {
if (!this.spec.selector) return [];
return Object.entries(this.spec.selector).map(val => val.join("="));
}
getPorts(): string[] {
const ports = this.spec.ports || [];
return ports.map(({ port, protocol, targetPort }) => {
return `${port}${port === targetPort ? "" : ":" + targetPort}/${protocol}`
})
}
getLoadBalancer() {
return this.status.loadBalancer;
}
isActive() {
return this.getType() !== "LoadBalancer" || this.getExternalIps().length > 0;
}
getStatus() {
return this.isActive() ? "Active" : "Pending";
}
}
export const serviceApi = new KubeApi({
kind: Service.kind,
apiBase: "/api/v1/services",
isNamespaced: true,
objectConstructor: Service,
});
@@ -0,0 +1,84 @@
import get from "lodash/get";
import { IPodContainer } from "./pods.api";
import { IAffinity, WorkloadKubeObject } from "../workload-kube-object";
import { autobind } from "../../utils";
import { KubeApi } from "../kube-api";
@autobind()
export class StatefulSet extends WorkloadKubeObject {
static kind = "StatefulSet"
spec: {
serviceName: string;
replicas: number;
selector: {
matchLabels: {
[key: string]: string;
};
};
template: {
metadata: {
labels: {
app: string;
};
};
spec: {
containers: {
name: string;
image: string;
ports: {
containerPort: number;
name: string;
}[];
volumeMounts: {
name: string;
mountPath: string;
}[];
}[];
affinity?: IAffinity;
nodeSelector?: {
[selector: string]: string;
};
tolerations: {
key: string;
operator: string;
effect: string;
tolerationSeconds: number;
}[];
};
};
volumeClaimTemplates: {
metadata: {
name: string;
};
spec: {
accessModes: string[];
resources: {
requests: {
storage: string;
};
};
};
}[];
}
status: {
observedGeneration: number;
replicas: number;
currentReplicas: number;
currentRevision: string;
updateRevision: string;
collisionCount: number;
}
getImages() {
const containers: IPodContainer[] = get(this, "spec.template.spec.containers", [])
return [...containers].map(container => container.image)
}
}
export const statefulSetApi = new KubeApi({
kind: StatefulSet.kind,
apiBase: "/apis/apps/v1/statefulsets",
isNamespaced: true,
objectConstructor: StatefulSet,
});
@@ -0,0 +1,39 @@
import { autobind } from "../../utils";
import { KubeObject } from "../kube-object";
import { KubeApi } from "../kube-api";
@autobind()
export class StorageClass extends KubeObject {
static kind = "StorageClass"
provisioner: string; // e.g. "storage.k8s.io/v1"
mountOptions?: string[];
volumeBindingMode: string;
reclaimPolicy: string;
parameters: {
[param: string]: string; // every provisioner has own set of these parameters
}
isDefault() {
const annotations = this.metadata.annotations || {};
return (
annotations["storageclass.kubernetes.io/is-default-class"] === "true" ||
annotations["storageclass.beta.kubernetes.io/is-default-class"] === "true"
)
}
getVolumeBindingMode() {
return this.volumeBindingMode || "-"
}
getReclaimPolicy() {
return this.reclaimPolicy || "-"
}
}
export const storageClassApi = new KubeApi({
kind: StorageClass.kind,
apiBase: "/apis/storage.k8s.io/v1/storageclasses",
isNamespaced: false,
objectConstructor: StorageClass,
});
+43
View File
@@ -0,0 +1,43 @@
import { JsonApi, JsonApiErrorParsed } from "./json-api";
import { KubeJsonApi } from "./kube-json-api";
import { Notifications } from "../components/notifications";
import { clientVars } from "../../server/config";
//-- JSON HTTP APIS
export const apiBase = new JsonApi({
debug: !clientVars.IS_PRODUCTION,
apiPrefix: clientVars.API_PREFIX.BASE,
});
export const apiKube = new KubeJsonApi({
debug: !clientVars.IS_PRODUCTION,
apiPrefix: clientVars.API_PREFIX.KUBE_BASE,
});
export const apiKubeUsers = new KubeJsonApi({
debug: !clientVars.IS_PRODUCTION,
apiPrefix: clientVars.API_PREFIX.KUBE_USERS,
});
export const apiKubeHelm = new KubeJsonApi({
debug: !clientVars.IS_PRODUCTION,
apiPrefix: clientVars.API_PREFIX.KUBE_HELM,
});
export const apiKubeResourceApplier = new KubeJsonApi({
debug: !clientVars.IS_PRODUCTION,
apiPrefix: clientVars.API_PREFIX.KUBE_RESOURCE_APPLIER,
});
// Common handler for HTTP api errors
function onApiError(error: JsonApiErrorParsed, res: Response) {
switch (res.status) {
case 403:
error.isUsedForNotification = true;
Notifications.error(error);
break;
}
}
apiBase.onError.addListener(onApiError);
apiKube.onError.addListener(onApiError);
apiKubeUsers.onError.addListener(onApiError);
apiKubeHelm.onError.addListener(onApiError);
apiKubeResourceApplier.onError.addListener(onApiError);
+154
View File
@@ -0,0 +1,154 @@
// Base http-service / json-api class
import { stringify } from "querystring";
import { EventEmitter } from "../utils/eventEmitter";
import { cancelableFetch } from "../utils/cancelableFetch";
export interface JsonApiData {
}
export interface JsonApiError {
code?: number;
message?: string;
errors?: { id: string; title: string; status?: number }[];
}
export interface JsonApiParams<D = any> {
query?: { [param: string]: string | number | any };
data?: D; // request body
}
export interface JsonApiLog {
method: string;
reqUrl: string;
reqInit: RequestInit;
data?: any;
error?: any;
}
export interface JsonApiConfig {
apiPrefix: string;
debug?: boolean;
}
export class JsonApi<D = JsonApiData, P extends JsonApiParams = JsonApiParams> {
static reqInitDefault: RequestInit = {
headers: {
'content-type': 'application/json'
}
};
static configDefault: Partial<JsonApiConfig> = {
debug: false
};
constructor(protected config: JsonApiConfig, protected reqInit?: RequestInit) {
this.config = Object.assign({}, JsonApi.configDefault, config);
this.reqInit = Object.assign({}, JsonApi.reqInitDefault, reqInit);
this.parseResponse = this.parseResponse.bind(this);
}
public onData = new EventEmitter<[D, Response]>();
public onError = new EventEmitter<[JsonApiErrorParsed, Response]>();
get<T = D>(path: string, params?: P, reqInit: RequestInit = {}) {
return this.request<T>(path, params, { ...reqInit, method: "get" });
}
post<T = D>(path: string, params?: P, reqInit: RequestInit = {}) {
return this.request<T>(path, params, { ...reqInit, method: "post" });
}
put<T = D>(path: string, params?: P, reqInit: RequestInit = {}) {
return this.request<T>(path, params, { ...reqInit, method: "put" });
}
patch<T = D>(path: string, params?: P, reqInit: RequestInit = {}) {
return this.request<T>(path, params, { ...reqInit, method: "patch" });
}
del<T = D>(path: string, params?: P, reqInit: RequestInit = {}) {
return this.request<T>(path, params, { ...reqInit, method: "delete" });
}
protected request<D>(path: string, params?: P, init: RequestInit = {}) {
let reqUrl = this.config.apiPrefix + path;
const reqInit: RequestInit = { ...this.reqInit, ...init };
const { data, query } = params || {} as P;
if (data && !reqInit.body) {
reqInit.body = JSON.stringify(data);
}
if (query) {
const queryString = stringify(query);
reqUrl += (reqUrl.includes("?") ? "&" : "?") + queryString;
}
const infoLog: JsonApiLog = {
method: reqInit.method.toUpperCase(),
reqUrl: reqUrl,
reqInit: reqInit,
};
return cancelableFetch(reqUrl, reqInit).then(res => {
return this.parseResponse<D>(res, infoLog);
});
}
protected parseResponse<D>(res: Response, log: JsonApiLog): Promise<D> {
const { status } = res;
return res.text().then(text => {
let data;
try {
data = text ? JSON.parse(text) : ""; // DELETE-requests might not have response-body
} catch (e) {
data = text;
}
if (status >= 200 && status < 300) {
this.onData.emit(data, res);
this.writeLog({ ...log, data });
return data;
}
else {
const error = new JsonApiErrorParsed(data, this.parseError(data, res));
this.onError.emit(error, res);
this.writeLog({ ...log, error })
throw error;
}
})
}
protected parseError(error: JsonApiError | string, res: Response): string[] {
if (typeof error === "string") {
return [error]
}
else if (Array.isArray(error.errors)) {
return error.errors.map(error => error.title)
}
else if (error.message) {
return [error.message]
}
return [res.statusText || "Error!"]
}
protected writeLog(log: JsonApiLog) {
if (!this.config.debug) return;
const { method, reqUrl, ...params } = log;
let textStyle = 'font-weight: bold;';
if (params.data) textStyle += 'background: green; color: white;';
if (params.error) textStyle += 'background: red; color: white;';
console.log(`%c${method} ${reqUrl}`, textStyle, params);
}
}
export class JsonApiErrorParsed {
isUsedForNotification = false;
constructor(private error: JsonApiError | DOMException, private messages: string[]) {
}
get isAborted() {
return this.error.code === DOMException.ABORT_ERR;
}
toString() {
return this.messages.join("\n");
}
}
+233
View File
@@ -0,0 +1,233 @@
// Base class for building all kubernetes apis
import merge from "lodash/merge"
import { stringify } from "querystring";
import { IKubeObjectConstructor, KubeObject } from "./kube-object";
import { IKubeObjectRef, KubeJsonApi, KubeJsonApiData, KubeJsonApiDataList } from "./kube-json-api";
import { apiKube } from "./index";
import { kubeWatchApi } from "./kube-watch-api";
import { apiManager } from "./api-manager";
export interface IKubeApiOptions<T extends KubeObject> {
kind: string; // resource type within api-group, e.g. "Namespace"
apiBase: string; // base api-path for listing all resources, e.g. "/api/v1/pods"
isNamespaced: boolean;
objectConstructor?: IKubeObjectConstructor<T>;
request?: KubeJsonApi;
}
export interface IKubeApiQueryParams {
watch?: boolean | number;
resourceVersion?: string;
timeoutSeconds?: number;
limit?: number; // doesn't work with ?watch
continue?: string; // might be used with ?limit from second request
}
export interface IKubeApiLinkRef {
apiPrefix?: string;
apiVersion: string;
resource: string;
name: string;
namespace?: string;
}
export class KubeApi<T extends KubeObject = any> {
static matcher = /(\/apis?.*?)\/(?:(.*?)\/)?(v.*?)(?:\/namespaces\/(.+?))?\/([^\/]+)(?:\/([^\/?]+))?.*$/
static parseApi(apiPath = "") {
apiPath = new URL(apiPath, location.origin).pathname;
const [, apiPrefix, apiGroup = "", apiVersion, namespace, resource, name] = apiPath.match(KubeApi.matcher) || [];
const apiVersionWithGroup = [apiGroup, apiVersion].filter(v => v).join("/");
const apiBase = [apiPrefix, apiGroup, apiVersion, resource].filter(v => v).join("/");
return {
apiBase,
apiPrefix, apiGroup,
apiVersion, apiVersionWithGroup,
namespace, resource, name,
}
}
static createLink(ref: IKubeApiLinkRef): string {
const { apiPrefix = "/apis", resource, apiVersion, name } = ref;
let { namespace } = ref;
if (namespace) {
namespace = `namespaces/${namespace}`
}
return [apiPrefix, apiVersion, namespace, resource, name]
.filter(v => !!v)
.join("/")
}
static watchAll(...apis: KubeApi[]) {
const disposers = apis.map(api => api.watch());
return () => disposers.forEach(unwatch => unwatch());
}
readonly kind: string
readonly apiBase: string
readonly apiPrefix: string
readonly apiGroup: string
readonly apiVersion: string
readonly apiVersionWithGroup: string
readonly apiResource: string
readonly isNamespaced: boolean
public objectConstructor: IKubeObjectConstructor<T>;
protected request: KubeJsonApi;
protected resourceVersions = new Map<string, string>();
constructor(protected options: IKubeApiOptions<T>) {
const {
kind,
isNamespaced = false,
objectConstructor = KubeObject as IKubeObjectConstructor,
request = apiKube
} = options || {};
const { apiBase, apiPrefix, apiGroup, apiVersion, apiVersionWithGroup, resource } = KubeApi.parseApi(options.apiBase);
this.kind = kind;
this.isNamespaced = isNamespaced;
this.apiBase = apiBase;
this.apiPrefix = apiPrefix;
this.apiGroup = apiGroup;
this.apiVersion = apiVersion;
this.apiVersionWithGroup = apiVersionWithGroup;
this.apiResource = resource;
this.request = request;
this.objectConstructor = objectConstructor;
this.parseResponse = this.parseResponse.bind(this);
apiManager.registerApi(apiBase, this);
}
setResourceVersion(namespace = "", newVersion: string) {
this.resourceVersions.set(namespace, newVersion);
}
getResourceVersion(namespace = "") {
return this.resourceVersions.get(namespace);
}
async refreshResourceVersion(params?: { namespace: string }) {
return this.list(params, { limit: 1 });
}
getUrl({ name = "", namespace = "" } = {}, query?: Partial<IKubeApiQueryParams>) {
const { apiPrefix, apiVersionWithGroup, apiResource } = this;
const resourcePath = KubeApi.createLink({
apiPrefix: apiPrefix,
apiVersion: apiVersionWithGroup,
resource: apiResource,
namespace: this.isNamespaced ? namespace : undefined,
name: name,
});
return resourcePath + (query ? `?` + stringify(query) : "");
}
protected parseResponse(data: KubeJsonApiData | KubeJsonApiData[] | KubeJsonApiDataList, namespace?: string): any {
const KubeObjectConstructor = this.objectConstructor;
if (KubeObject.isJsonApiData(data)) {
return new KubeObjectConstructor(data);
}
// process items list response
else if (KubeObject.isJsonApiDataList(data)) {
const { apiVersion, items, metadata } = data;
this.setResourceVersion(namespace, metadata.resourceVersion);
this.setResourceVersion("", metadata.resourceVersion);
return items.map(item => new KubeObjectConstructor({
kind: this.kind,
apiVersion: apiVersion,
...item,
}))
}
// custom apis might return array for list response, e.g. users, groups, etc.
else if (Array.isArray(data)) {
return data.map(data => new KubeObjectConstructor(data));
}
return data;
}
async list({ namespace = "" } = {}, query?: IKubeApiQueryParams): Promise<T[]> {
return this.request
.get(this.getUrl({ namespace }), { query })
.then(data => this.parseResponse(data, namespace));
}
async get({ name = "", namespace = "default" } = {}, query?: IKubeApiQueryParams): Promise<T> {
return this.request
.get(this.getUrl({ namespace, name }), { query })
.then(this.parseResponse);
}
async create({ name = "", namespace = "default" } = {}, data?: Partial<T>): Promise<T> {
const apiUrl = this.getUrl({ namespace });
return this.request.post(apiUrl, {
data: merge({
kind: this.kind,
apiVersion: this.apiVersionWithGroup,
metadata: {
name,
namespace
}
}, data)
}).then(this.parseResponse);
}
async update({ name = "", namespace = "default" } = {}, data?: Partial<T>): Promise<T> {
const apiUrl = this.getUrl({ namespace, name });
return this.request
.put(apiUrl, { data })
.then(this.parseResponse)
}
async delete({ name = "", namespace = "default" }) {
const apiUrl = this.getUrl({ namespace, name });
return this.request.del(apiUrl)
}
getWatchUrl(namespace = "", query: IKubeApiQueryParams = {}) {
return this.getUrl({ namespace }, {
watch: 1,
resourceVersion: this.getResourceVersion(namespace),
...query,
})
}
watch(): () => void {
return kubeWatchApi.subscribe(this);
}
}
export function lookupApiLink(ref: IKubeObjectRef, parentObject: KubeObject): string {
const {
kind, apiVersion, name,
namespace = parentObject.getNs()
} = ref;
// search in registered apis by 'kind' & 'apiVersion'
const api = apiManager.getApi(api => api.kind === kind && api.apiVersionWithGroup == apiVersion)
if (api) {
return api.getUrl({ namespace, name })
}
// lookup api by generated resource link
const apiPrefixes = ["/apis", "/api"];
const resource = kind.toLowerCase() + kind.endsWith("s") ? "es" : "s";
for (const apiPrefix of apiPrefixes) {
const apiLink = KubeApi.createLink({ apiPrefix, apiVersion, name, namespace, resource });
if (apiManager.getApi(apiLink)) {
return apiLink;
}
}
// resolve by kind only (hpa's might use refs to older versions of resources for example)
const apiByKind = apiManager.getApi(api => api.kind === kind);
if (apiByKind) {
return apiByKind.getUrl({ name, namespace })
}
// otherwise generate link with default prefix
// resource still might exists in k8s, but api is not registered in the app
return KubeApi.createLink({ apiVersion, name, namespace, resource })
}
+68
View File
@@ -0,0 +1,68 @@
import { JsonApi, JsonApiData, JsonApiError } from "./json-api";
export interface KubeJsonApiDataList<T = KubeJsonApiData> {
kind: string;
apiVersion: string;
items: T[];
metadata: {
resourceVersion: string;
selfLink: string;
};
}
export interface KubeJsonApiData extends JsonApiData {
kind: string;
apiVersion: string;
metadata: {
uid: string;
name: string;
namespace?: string;
creationTimestamp?: string;
resourceVersion: string;
continue?: string;
finalizers?: string[];
selfLink: string;
labels?: {
[label: string]: string;
};
annotations?: {
[annotation: string]: string;
};
};
}
export interface IKubeObjectRef {
kind: string;
apiVersion: string;
name: string;
namespace?: string;
}
export interface KubeJsonApiError extends JsonApiError {
code: number;
status: string;
message?: string;
reason: string;
details: {
name: string;
kind: string;
};
}
export interface IKubeJsonApiQuery {
watch?: any;
resourceVersion?: string;
timeoutSeconds?: number;
limit?: number; // doesn't work with ?watch
continue?: string; // might be used with ?limit from second request
}
export class KubeJsonApi extends JsonApi<KubeJsonApiData> {
protected parseError(error: KubeJsonApiError | any, res: Response): string[] {
const { status, reason, message } = error;
if (status && reason) {
return [message || `${status}: ${reason}`];
}
return super.parseError(error, res);
}
}
+142
View File
@@ -0,0 +1,142 @@
// Base class for all kubernetes objects
import moment from "moment";
import { KubeJsonApiData, KubeJsonApiDataList } from "./kube-json-api";
import { autobind, formatDuration } from "../utils";
import { ItemObject } from "../item.store";
import { apiKube } from "./index";
import { resourceApplierApi } from "./endpoints/resource-applier.api";
export type IKubeObjectConstructor<T extends KubeObject = any> = (new (data: KubeJsonApiData | any) => T) & {
kind?: string;
};
export interface IKubeObjectMetadata {
uid: string;
name: string;
namespace?: string;
creationTimestamp: string;
resourceVersion: string;
selfLink: string;
deletionTimestamp?: string;
finalizers?: string[];
continue?: string; // provided when used "?limit=" query param to fetch objects list
labels?: {
[label: string]: string;
};
annotations?: {
[annotation: string]: string;
};
}
export type IKubeMetaField = keyof KubeObject["metadata"];
@autobind()
export class KubeObject implements ItemObject {
static readonly kind: string;
static create(data: any) {
return new KubeObject(data);
}
static isNonSystem(item: KubeJsonApiData | KubeObject) {
return !item.metadata.name.startsWith("system:");
}
static isJsonApiData(object: any): object is KubeJsonApiData {
return !object.items && object.metadata;
}
static isJsonApiDataList(object: any): object is KubeJsonApiDataList {
return object.items && object.metadata;
}
static stringifyLabels(labels: { [name: string]: string }): string[] {
if (!labels) return [];
return Object.entries(labels).map(([name, value]) => `${name}=${value}`)
}
constructor(data: KubeJsonApiData) {
Object.assign(this, data);
}
apiVersion: string
kind: string
metadata: IKubeObjectMetadata;
get selfLink() {
return this.metadata.selfLink
}
getId() {
return this.metadata.uid;
}
getResourceVersion() {
return this.metadata.resourceVersion;
}
getName() {
return this.metadata.name;
}
getNs() {
// avoid "null" serialization via JSON.stringify when post data
return this.metadata.namespace || undefined;
}
// todo: refactor with named arguments
getAge(humanize = true, compact = true, fromNow = false) {
if (fromNow) {
return moment(this.metadata.creationTimestamp).fromNow();
}
const diff = new Date().getTime() - new Date(this.metadata.creationTimestamp).getTime();
if (humanize) {
return formatDuration(diff, compact);
}
return diff;
}
getFinalizers(): string[] {
return this.metadata.finalizers || [];
}
getLabels(): string[] {
return KubeObject.stringifyLabels(this.metadata.labels);
}
getAnnotations(): string[] {
const labels = KubeObject.stringifyLabels(this.metadata.annotations);
return labels.filter(label => {
const skip = resourceApplierApi.annotations.some(key => label.startsWith(key));
return !skip;
})
}
getSearchFields() {
const { getName, getId, getNs, getAnnotations, getLabels } = this
return [
getName(),
getNs(),
getId(),
...getLabels(),
...getAnnotations(),
]
}
toPlainObject(): object {
return JSON.parse(JSON.stringify(this));
}
// use unified resource-applier api for updating all k8s objects
async update<T extends KubeObject>(data: Partial<T>) {
return resourceApplierApi.update<T>({
...this.toPlainObject(),
...data,
});
}
delete() {
return apiKube.del(this.selfLink);
}
}
+151
View File
@@ -0,0 +1,151 @@
// Kubernetes watch-api consumer
import { computed, observable, reaction } from "mobx";
import { stringify } from "querystring"
import { autobind, EventEmitter, interval } from "../utils";
import { KubeJsonApiData } from "./kube-json-api";
import { IKubeWatchEvent, IKubeWatchRouteEvent, IKubeWatchRouteQuery } from "../../server/common/kubewatch";
import { KubeObjectStore } from "../kube-object.store";
import { KubeApi } from "./kube-api";
import { configStore } from "../config.store";
import { apiManager } from "./api-manager";
export {
IKubeWatchEvent
}
@autobind()
export class KubeWatchApi {
protected evtSource: EventSource;
protected onData = new EventEmitter<[IKubeWatchEvent]>();
protected apiUrl = configStore.apiPrefix.BASE + "/watch";
protected subscribers = observable.map<KubeApi, number>();
protected reconnectInterval = interval(60 * 5, this.reconnect); // background reconnect every 5min
protected reconnectTimeoutMs = 5000;
protected maxReconnectsOnError = 10;
protected reconnectAttempts = this.maxReconnectsOnError;
constructor() {
reaction(() => this.activeApis, () => this.connect(), {
fireImmediately: true,
delay: 500,
});
}
@computed get activeApis() {
return Array.from(this.subscribers.keys());
}
getSubscribersCount(api: KubeApi) {
return this.subscribers.get(api) || 0;
}
subscribe(...apis: KubeApi[]) {
apis.forEach(api => {
this.subscribers.set(api, this.getSubscribersCount(api) + 1);
});
return () => apis.forEach(api => {
const count = this.getSubscribersCount(api) - 1;
if (count <= 0) this.subscribers.delete(api);
else this.subscribers.set(api, count);
});
}
protected getQuery(): Partial<IKubeWatchRouteQuery> {
const { isClusterAdmin, allowedNamespaces } = configStore;
return {
api: this.activeApis.map(api => {
if (isClusterAdmin) return api.getWatchUrl();
return allowedNamespaces.map(namespace => api.getWatchUrl(namespace))
}).flat()
}
}
// todo: maybe switch to websocket to avoid often reconnects
@autobind()
protected connect() {
if (this.evtSource) this.disconnect(); // close previous connection
if (!this.activeApis.length) {
return;
}
const query = this.getQuery();
const apiUrl = this.apiUrl + "?" + stringify(query);
this.evtSource = new EventSource(apiUrl);
this.evtSource.onmessage = this.onMessage;
this.evtSource.onerror = this.onError;
this.writeLog("CONNECTING", query.api);
}
reconnect() {
if (!this.evtSource || this.evtSource.readyState !== EventSource.OPEN) {
this.reconnectAttempts = this.maxReconnectsOnError;
this.connect();
}
}
protected disconnect() {
if (!this.evtSource) return;
this.evtSource.close();
this.evtSource.onmessage = null;
this.evtSource = null;
}
protected onMessage(evt: MessageEvent) {
if (!evt.data) return;
const data = JSON.parse(evt.data);
if ((data as IKubeWatchEvent).object) {
this.onData.emit(data);
}
else {
this.onRouteEvent(data);
}
}
protected async onRouteEvent({ type, url }: IKubeWatchRouteEvent) {
if (type === "STREAM_END") {
this.disconnect();
const { apiBase, namespace } = KubeApi.parseApi(url);
const api = apiManager.getApi(apiBase);
if (api) {
await api.refreshResourceVersion({ namespace });
this.reconnect();
}
}
}
protected onError(evt: MessageEvent) {
const { reconnectAttempts: attemptsRemain, reconnectTimeoutMs } = this;
if (evt.eventPhase === EventSource.CLOSED) {
if (attemptsRemain > 0) {
this.reconnectAttempts--;
setTimeout(() => this.connect(), reconnectTimeoutMs);
}
}
}
protected writeLog(...data: any[]) {
if (configStore.isDevelopment) {
console.log('%cKUBE-WATCH-API:', `font-weight: bold`, ...data);
}
}
addListener(store: KubeObjectStore, callback: (evt: IKubeWatchEvent) => void) {
const listener = (evt: IKubeWatchEvent<KubeJsonApiData>) => {
const { selfLink, namespace, resourceVersion } = evt.object.metadata;
const api = apiManager.getApi(selfLink);
api.setResourceVersion(namespace, resourceVersion);
api.setResourceVersion("", resourceVersion);
if (store == apiManager.getStore(api)) {
callback(evt);
}
};
this.onData.addListener(listener);
return () => this.onData.removeListener(listener);
}
reset() {
this.subscribers.clear();
}
}
export const kubeWatchApi = new KubeWatchApi();
+172
View File
@@ -0,0 +1,172 @@
import { stringify } from "querystring";
import { autobind, base64, EventEmitter, interval } from "../utils";
import { WebSocketApi } from "./websocket-api";
import { configStore } from "../config.store";
import isEqual from "lodash/isEqual"
export enum TerminalChannels {
STDIN = 0,
STDOUT = 1,
STDERR = 2,
TERMINAL_SIZE = 4,
TOKEN = 9,
}
enum TerminalColor {
RED = "\u001b[31m",
GREEN = "\u001b[32m",
YELLOW = "\u001b[33m",
BLUE = "\u001b[34m",
MAGENTA = "\u001b[35m",
CYAN = "\u001b[36m",
GRAY = "\u001b[90m",
LIGHT_GRAY = "\u001b[37m",
NO_COLOR = "\u001b[0m",
}
export interface ITerminalApiOptions {
id: string;
node?: string;
colorTheme?: "light" | "dark";
}
export class TerminalApi extends WebSocketApi {
protected size: { Width: number; Height: number };
protected currentToken: string;
protected tokenInterval = interval(60, this.sendNewToken); // refresh every minute
public onReady = new EventEmitter<[]>();
public isReady = false;
constructor(protected options: ITerminalApiOptions) {
super({
logging: configStore.isDevelopment,
flushOnOpen: false,
pingIntervalSeconds: 30,
});
}
async getUrl(token: string) {
const { hostname, protocol } = location;
const { id, node } = this.options;
const apiPrefix = configStore.apiPrefix.TERMINAL;
const wss = `ws${protocol === "https:" ? "s" : ""}://`;
const queryParams = { token, id };
if (node) {
Object.assign(queryParams, {
node: node,
type: "node"
});
}
return `${wss}${hostname}${configStore.serverPort}${apiPrefix}/api?${stringify(queryParams)}`;
}
async connect() {
const token = await configStore.getToken();
const apiUrl = await this.getUrl(token);
const { colorTheme } = this.options;
this.emitStatus("Connecting...", {
color: colorTheme == "light" ? TerminalColor.GRAY : TerminalColor.LIGHT_GRAY
});
this.onData.addListener(this._onReady, { prepend: true });
this.currentToken = token;
this.tokenInterval.start();
return super.connect(apiUrl);
}
@autobind()
async sendNewToken() {
const token = await configStore.getToken();
if (!this.isReady || token == this.currentToken) return;
this.sendCommand(token, TerminalChannels.TOKEN);
this.currentToken = token;
}
destroy() {
if (!this.socket) return;
const exitCode = String.fromCharCode(4); // ctrl+d
this.sendCommand(exitCode);
this.tokenInterval.stop();
setTimeout(() => super.destroy(), 2000);
}
removeAllListeners() {
super.removeAllListeners();
this.onReady.removeAllListeners();
}
@autobind()
protected _onReady(data: string) {
if (!data) return;
this.isReady = true;
this.onReady.emit();
this.onData.removeListener(this._onReady);
this.flush();
this.onData.emit(data); // re-emit data
return false; // prevent calling rest of listeners
}
reconnect() {
const { reconnectDelaySeconds } = this.params;
if (reconnectDelaySeconds) {
this.emitStatus(`Reconnect in ${reconnectDelaySeconds} seconds`, {
color: TerminalColor.YELLOW,
showTime: true,
});
}
super.reconnect();
}
sendCommand(key: string, channel = TerminalChannels.STDIN) {
return this.send(channel + base64.encode(key));
}
sendTerminalSize(cols: number, rows: number) {
const newSize = { Width: cols, Height: rows };
if (!isEqual(this.size, newSize)) {
this.sendCommand(JSON.stringify(newSize), TerminalChannels.TERMINAL_SIZE);
this.size = newSize;
}
}
protected parseMessage(data: string) {
data = data.substr(1); // skip channel
return base64.decode(data);
}
protected _onOpen(evt: Event) {
// Client should send terminal size in special channel 4,
// But this size will be changed by terminal.fit()
this.sendTerminalSize(120, 80);
super._onOpen(evt);
}
protected _onClose(evt: CloseEvent) {
const { code, reason, wasClean } = evt;
if (code !== 1000 || !wasClean) {
this.emitStatus("\r\n");
this.emitError(`Closed by "${reason}" (code: ${code}) at ${new Date()}.`);
}
super._onClose(evt);
this.isReady = false;
}
protected emitStatus(data: string, options: { color?: TerminalColor; showTime?: boolean } = {}) {
const { color, showTime } = options;
if (color) {
data = `${color}${data}${TerminalColor.NO_COLOR}`;
}
let time;
if (showTime) {
time = (new Date()).toLocaleString() + " ";
}
this.onData.emit(`${showTime ? time : ""}${data}\r\n`);
}
protected emitError(error: string) {
this.emitStatus(error, {
color: TerminalColor.RED
});
}
}
+169
View File
@@ -0,0 +1,169 @@
import { observable } from "mobx";
import { EventEmitter } from "../utils/eventEmitter";
interface IParams {
url?: string; // connection url, starts with ws:// or wss://
autoConnect?: boolean; // auto-connect in constructor
flushOnOpen?: boolean; // flush pending commands on open socket
reconnectDelaySeconds?: number; // reconnect timeout in case of error (0 - don't reconnect)
pingIntervalSeconds?: number; // send ping message for keeping connection alive in some env, e.g. AWS (0 - don't ping)
logging?: boolean; // show logs in console
}
interface IMessage {
id: string;
data: string;
}
export enum WebSocketApiState {
PENDING = -1,
OPEN,
CONNECTING,
RECONNECTING,
CLOSED,
}
export class WebSocketApi {
protected socket: WebSocket;
protected pendingCommands: IMessage[] = [];
protected reconnectTimer: any;
protected pingTimer: any;
protected pingMessage = "PING";
@observable readyState = WebSocketApiState.PENDING;
public onOpen = new EventEmitter<[]>();
public onData = new EventEmitter<[string]>();
public onClose = new EventEmitter<[]>();
static defaultParams: Partial<IParams> = {
autoConnect: true,
logging: false,
reconnectDelaySeconds: 10,
pingIntervalSeconds: 0,
flushOnOpen: true,
};
constructor(protected params: IParams) {
this.params = Object.assign({}, WebSocketApi.defaultParams, params);
const { autoConnect, pingIntervalSeconds } = this.params;
if (autoConnect) {
setTimeout(() => this.connect());
}
if (pingIntervalSeconds) {
this.pingTimer = setInterval(() => this.ping(), pingIntervalSeconds * 1000);
}
}
get isConnected() {
const state = this.socket ? this.socket.readyState : -1;
return state === WebSocket.OPEN && this.isOnline;
}
get isOnline() {
return navigator.onLine;
}
setParams(params: Partial<IParams>) {
Object.assign(this.params, params);
}
connect(url = this.params.url) {
if (this.socket) {
this.socket.close(); // close previous connection first
}
this.socket = new WebSocket(url);
this.socket.onopen = this._onOpen.bind(this);
this.socket.onmessage = this._onMessage.bind(this);
this.socket.onerror = this._onError.bind(this);
this.socket.onclose = this._onClose.bind(this);
this.readyState = WebSocketApiState.CONNECTING;
}
ping() {
if (!this.isConnected) return;
this.send(this.pingMessage);
}
reconnect() {
const { reconnectDelaySeconds } = this.params;
if (!reconnectDelaySeconds) return;
this.writeLog('reconnect after', reconnectDelaySeconds + "ms");
this.reconnectTimer = setTimeout(() => this.connect(), reconnectDelaySeconds * 1000);
this.readyState = WebSocketApiState.RECONNECTING;
}
destroy() {
if (!this.socket) return;
this.socket.close();
this.socket = null;
this.pendingCommands = [];
this.removeAllListeners();
clearTimeout(this.reconnectTimer);
clearInterval(this.pingTimer);
this.readyState = WebSocketApiState.PENDING;
}
removeAllListeners() {
this.onOpen.removeAllListeners();
this.onData.removeAllListeners();
this.onClose.removeAllListeners();
}
send(command: string) {
const msg: IMessage = {
id: (Math.random() * Date.now()).toString(16).replace(".", ""),
data: command,
};
if (this.isConnected) {
this.socket.send(msg.data);
}
else {
this.pendingCommands.push(msg);
}
}
protected flush() {
this.pendingCommands.forEach(msg => this.send(msg.data));
this.pendingCommands.length = 0;
}
protected parseMessage(data: string) {
return data;
}
protected _onOpen(evt: Event) {
this.onOpen.emit();
if (this.params.flushOnOpen) this.flush();
this.readyState = WebSocketApiState.OPEN;
this.writeLog('%cOPEN', 'color:green;font-weight:bold;', evt);
}
protected _onMessage(evt: MessageEvent) {
const data = this.parseMessage(evt.data);
this.onData.emit(data);
this.writeLog('%cMESSAGE', 'color:black;font-weight:bold;', data);
}
protected _onError(evt: Event) {
this.writeLog('%cERROR', 'color:red;font-weight:bold;', evt)
}
protected _onClose(evt: CloseEvent) {
const error = evt.code !== 1000 || !evt.wasClean;
if (error) {
this.reconnect();
}
else {
this.readyState = WebSocketApiState.CLOSED;
this.onClose.emit();
}
this.writeLog('%cCLOSE', `color:${error ? "red" : "black"};font-weight:bold;`, evt);
}
protected writeLog(...data: any[]) {
if (this.params.logging) {
console.log(...data);
}
}
}
@@ -0,0 +1,100 @@
import get from "lodash/get";
import { IKubeObjectMetadata, KubeObject } from "./kube-object";
interface IToleration {
key?: string;
operator?: string;
effect?: string;
tolerationSeconds?: number;
}
interface IMatchExpression {
key: string;
operator: string;
values: string[];
}
interface INodeAffinity {
nodeSelectorTerms?: {
matchExpressions: IMatchExpression[];
}[];
weight: number;
preference: {
matchExpressions: IMatchExpression[];
};
}
interface IPodAffinity {
labelSelector: {
matchExpressions: IMatchExpression[];
};
topologyKey: string;
}
export interface IAffinity {
nodeAffinity?: {
requiredDuringSchedulingIgnoredDuringExecution?: INodeAffinity[];
preferredDuringSchedulingIgnoredDuringExecution?: INodeAffinity[];
};
podAffinity?: {
requiredDuringSchedulingIgnoredDuringExecution?: IPodAffinity[];
preferredDuringSchedulingIgnoredDuringExecution?: IPodAffinity[];
};
podAntiAffinity?: {
requiredDuringSchedulingIgnoredDuringExecution?: IPodAffinity[];
preferredDuringSchedulingIgnoredDuringExecution?: IPodAffinity[];
};
}
export class WorkloadKubeObject extends KubeObject {
metadata: IKubeObjectMetadata & {
ownerReferences?: {
apiVersion: string;
kind: string;
name: string;
uid: string;
controller: boolean;
blockOwnerDeletion: boolean;
}[];
}
// fixme: add type
spec: any;
getOwnerRefs() {
const refs = this.metadata.ownerReferences || [];
return refs.map(ownerRef => ({
...ownerRef,
namespace: this.getNs(),
}))
}
getSelectors(): string[] {
const selector = this.spec.selector;
return KubeObject.stringifyLabels(selector ? selector.matchLabels : null);
}
getNodeSelectors(): string[] {
const nodeSelector = get(this, "spec.template.spec.nodeSelector");
return KubeObject.stringifyLabels(nodeSelector);
}
getTemplateLabels(): string[] {
const labels = get(this, "spec.template.metadata.labels");
return KubeObject.stringifyLabels(labels);
}
getTolerations(): IToleration[] {
return get(this, "spec.template.spec.tolerations", [])
}
getAffinity(): IAffinity {
return get(this, "spec.template.spec.affinity")
}
getAffinityNumber() {
const affinity = this.getAffinity()
if (!affinity) return 0
return Object.keys(affinity).length
}
}
+20
View File
@@ -0,0 +1,20 @@
import * as React from "react";
import { Notifications } from "./components/notifications";
import { Trans } from "@lingui/macro";
export function browserCheck() {
const ua = window.navigator.userAgent
const msie = ua.indexOf('MSIE ') // IE < 11
const trident = ua.indexOf('Trident/') // IE 11
const edge = ua.indexOf('Edge') // Edge
if (msie > 0 || trident > 0 || edge > 0) {
Notifications.info(
<p>
<Trans>
<b>Your browser does not support all Kontena Lens features. </b>{" "}
Please consider using another browser.
</Trans>
</p>
)
}
}
@@ -0,0 +1 @@
export * from "./not-found"
@@ -0,0 +1,15 @@
import * as React from "react";
import { Trans } from "@lingui/macro";
import { MainLayout } from "../layout/main-layout";
export class NotFound extends React.Component {
render() {
return (
<MainLayout className="NotFound" contentClass="flex" footer={null}>
<p className="box center">
<Trans>Page not found</Trans>
</p>
</MainLayout>
)
}
}
@@ -0,0 +1,53 @@
.HelmChartDetails {
.intro-logo {
margin-right: $margin * 2;
background: $helmLogoBackground;
border-radius: $radius;
max-width: 150px;
max-height: 100px;
padding: $padding;
box-sizing: content-box;
}
.intro-contents {
.description {
font-weight: bold;
color: $textColorAccent;
padding-bottom: $padding;
.Button {
padding-left: $padding * 3;
padding-right: $padding * 3;
margin-left: $margin * 2;
align-self: flex-start;
}
}
.version {
.Select {
width: 80px;
min-width: 80px;
white-space: nowrap;
}
.Icon {
margin-right: $margin;
}
}
.maintainers {
a {
display: inline-block;
margin-right: $margin;
}
}
.DrawerItem {
align-items: center;
}
}
.chart-description {
margin-top: $margin * 2;
}
}
@@ -0,0 +1,138 @@
import "./helm-chart-details.scss";
import React, { Component } from "react";
import { HelmChart, helmChartsApi } from "../../api/endpoints/helm-charts.api";
import { t, Trans } from "@lingui/macro";
import { autorun, observable } from "mobx";
import { disposeOnUnmount, observer } from "mobx-react";
import { Drawer, DrawerItem } from "../drawer";
import { autobind, stopPropagation } from "../../utils";
import { MarkdownViewer } from "../markdown-viewer";
import { Spinner } from "../spinner";
import { CancelablePromise } from "../../utils/cancelableFetch";
import { Button } from "../button";
import { Select, SelectOption } from "../select";
import { createInstallChartTab } from "../dock/install-chart.store";
import { Badge } from "../badge";
import { _i18n } from "../../i18n";
interface Props {
chart: HelmChart;
hideDetails(): void;
}
@observer
export class HelmChartDetails extends Component<Props> {
@observable chartVersions: HelmChart[];
@observable selectedChart: HelmChart;
@observable description: string = null;
private chartPromise: CancelablePromise<{ readme: string; versions: HelmChart[] }>;
@disposeOnUnmount
chartSelector = autorun(async () => {
if (!this.props.chart) return;
this.chartVersions = null;
this.selectedChart = null;
this.description = null;
this.loadChartData();
this.chartPromise.then(data => {
this.description = data.readme;
this.chartVersions = data.versions;
this.selectedChart = data.versions[0];
});
});
loadChartData(version?: string) {
const { chart: { name, repo } } = this.props;
if (this.chartPromise) this.chartPromise.cancel();
this.chartPromise = helmChartsApi.get(repo, name, version);
}
@autobind()
onVersionChange(opt: SelectOption) {
const version = opt.value;
this.selectedChart = this.chartVersions.find(chart => chart.version === version);
this.description = null;
this.loadChartData(version);
this.chartPromise.then(data => {
this.description = data.readme
});
}
@autobind()
install() {
createInstallChartTab(this.selectedChart);
this.props.hideDetails()
}
renderIntroduction() {
const { selectedChart, chartVersions, onVersionChange } = this;
const placeholder = require("./helm-placeholder.svg");
return (
<div className="introduction flex align-flex-start">
<img
className="intro-logo"
src={selectedChart.getIcon() || placeholder}
onError={(event) => event.currentTarget.src = placeholder}
/>
<div className="intro-contents box grow">
<div className="description flex align-center justify-space-between">
{selectedChart.getDescription()}
<Button primary label={_i18n._(t`Install`)} onClick={this.install}/>
</div>
<DrawerItem name={_i18n._(t`Version`)} className="version" onClick={stopPropagation}>
<Select
themeName="outlined"
menuPortalTarget={null}
options={chartVersions.map(chart => chart.version)}
value={selectedChart.getVersion()}
onChange={onVersionChange}
/>
</DrawerItem>
<DrawerItem name={_i18n._(t`Home`)}>
<a href={selectedChart.getHome()} target="_blank">{selectedChart.getHome()}</a>
</DrawerItem>
<DrawerItem name={_i18n._(t`Maintainers`)} className="maintainers">
{selectedChart.getMaintainers().map(({ name, email, url }) =>
<a key={name} href={url ? url : `mailto:${email}`} target="_blank">{name}</a>
)}
</DrawerItem>
{selectedChart.getKeywords().length > 0 && (
<DrawerItem name={_i18n._(t`Keywords`)} labelsOnly>
{selectedChart.getKeywords().map(key => <Badge key={key} label={key}/>)}
</DrawerItem>
)}
</div>
</div>
);
}
renderContent() {
if (this.selectedChart === null || this.description === null) return <Spinner center/>;
return (
<div className="box grow">
{this.renderIntroduction()}
<div className="chart-description">
<MarkdownViewer markdown={this.description}/>
</div>
</div>
);
}
render() {
const { chart, hideDetails } = this.props;
const title = chart ? <Trans>Chart: {chart.getFullName()}</Trans> : "";
return (
<Drawer
className="HelmChartDetails"
usePortal={true}
open={!!chart}
title={title}
onClose={hideDetails}
>
{this.renderContent()}
</Drawer>
);
}
}
@@ -0,0 +1,67 @@
import { observable } from "mobx";
import { autobind } from "../../utils";
import { HelmChart, helmChartsApi } from "../../api/endpoints/helm-charts.api";
import { ItemStore } from "../../item.store";
import flatten from "lodash/flatten"
export interface IChartVersion {
repo: string;
version: string;
}
@autobind()
export class HelmChartStore extends ItemStore<HelmChart> {
@observable versions = observable.map<string, IChartVersion[]>();
loadAll() {
return this.loadItems(() => helmChartsApi.list());
}
getByName(name: string, repo: string) {
return this.items.find(chart => chart.getName() === name && chart.getRepository() === repo);
}
protected sortVersions = (versions: IChartVersion[]) => {
return versions.sort((first, second) => {
const firstVersion = first.version.replace(/[^\d.]/g, "").split(".").map(Number);
const secondVersion = second.version.replace(/[^\d.]/g, "").split(".").map(Number);
return firstVersion.every((version, index) => {
return version > secondVersion[index];
}) ? -1 : 0;
});
};
async getVersions(chartName: string, force?: boolean): Promise<IChartVersion[]> {
let versions = this.versions.get(chartName);
if (versions && !force) {
return versions;
}
const loadVersions = (repo: string) => {
return helmChartsApi.get(repo, chartName).then(({ versions }) => {
return versions.map(chart => ({
repo: repo,
version: chart.getVersion()
}))
})
};
if (!this.isLoaded) {
await this.loadAll();
}
const repos = this.items
.filter(chart => chart.getName() === chartName)
.map(chart => chart.getRepository());
versions = await Promise.all(repos.map(loadVersions))
.then(flatten)
.then(this.sortVersions);
this.versions.set(chartName, versions);
return versions;
}
reset() {
super.reset();
this.versions.clear();
}
}
export const helmChartStore = new HelmChartStore();
@@ -0,0 +1,14 @@
import { RouteProps } from "react-router"
import { appsRoute } from "../+apps/apps.route";
import { buildURL } from "../../navigation";
export const helmChartsRoute: RouteProps = {
path: appsRoute.path + "/charts/:repo?/:chartName?"
}
export interface IHelmChartsRouteParams {
chartName?: string;
repo?: string;
}
export const helmChartsURL = buildURL<IHelmChartsRouteParams>(helmChartsRoute.path)
@@ -0,0 +1,62 @@
.HelmCharts {
.SearchInput {
width: 70%;
margin: auto;
> label {
padding: $padding $padding * 2;
}
}
.TableCell {
text-overflow: ellipsis;
&.name {
flex-grow: 1.3;
}
&.icon {
display: flex;
flex-grow: 0.3;
padding: 0;
figure {
$iconSize: $unit * 3.5;
width: $iconSize;
height: $iconSize;
border-radius: 50%;
background: $helmImgBackground url(./helm-placeholder.svg) center center no-repeat;
background-size: 72%; // bg size looks same as image on top of it
margin: auto;
img {
object-fit: contain;
width: inherit;
height: inherit;
visibility: hidden;
border-radius: inherit;
background-color: $helmImgBackground;
padding: $padding / 2;
&.visible {
visibility: visible;
}
}
}
}
&.description {
flex-grow: 2.5;
}
&.repository {
&.stable {
color: $helmStableRepo;
}
&.incubator {
color: $helmIncubatorRepo;
}
}
}
}
@@ -0,0 +1,109 @@
import "./helm-charts.scss";
import React, { Component } from "react";
import { RouteComponentProps } from "react-router";
import { observer } from "mobx-react";
import { helmChartsURL, IHelmChartsRouteParams } from "./helm-charts.route";
import { helmChartStore } from "./helm-chart.store";
import { HelmChart } from "../../api/endpoints/helm-charts.api";
import { HelmChartDetails } from "./helm-chart-details";
import { navigation } from "../../navigation";
import { ItemListLayout } from "../item-object-list/item-list-layout";
import { t, Trans } from "@lingui/macro";
import { _i18n } from "../../i18n";
import { SearchInput } from "../input";
enum sortBy {
name = "name",
repo = "repo",
}
interface Props extends RouteComponentProps<IHelmChartsRouteParams> {
}
@observer
export class HelmCharts extends Component<Props> {
componentDidMount() {
helmChartStore.loadAll();
}
get selectedChart() {
const { match: { params: { chartName, repo } } } = this.props
return helmChartStore.getByName(chartName, repo);
}
showDetails = (chart: HelmChart) => {
if (!chart) {
navigation.merge(helmChartsURL())
}
else {
navigation.merge(helmChartsURL({
params: {
chartName: chart.getName(),
repo: chart.getRepository(),
}
}))
}
}
hideDetails = () => {
this.showDetails(null);
}
render() {
return (
<>
<ItemListLayout
className="HelmCharts"
store={helmChartStore}
isClusterScoped={true}
isSelectable={false}
sortingCallbacks={{
[sortBy.name]: (chart: HelmChart) => chart.getName(),
[sortBy.repo]: (chart: HelmChart) => chart.getRepository(),
}}
searchFilters={[
(chart: HelmChart) => chart.getName(),
(chart: HelmChart) => chart.getVersion(),
(chart: HelmChart) => chart.getAppVersion(),
(chart: HelmChart) => chart.getKeywords(),
]}
filterItems={[
(items: HelmChart[]) => items.filter(item => !item.deprecated)
]}
customizeHeader={() => (
<SearchInput placeholder={_i18n._(t`Search Helm Charts`)}/>
)}
renderTableHeader={[
{ className: "icon" },
{ title: <Trans>Name</Trans>, className: "name", sortBy: sortBy.name },
{ title: <Trans>Description</Trans>, className: "description" },
{ title: <Trans>Version</Trans>, className: "version" },
{ title: <Trans>App Version</Trans>, className: "app-version" },
{ title: <Trans>Repository</Trans>, className: "repository", sortBy: sortBy.repo },
]}
renderTableContents={(chart: HelmChart) => [
<figure>
<img
src={chart.getIcon() || require("./helm-placeholder.svg")}
onLoad={evt => evt.currentTarget.classList.add("visible")}
/>
</figure>,
chart.getName(),
chart.getDescription(),
chart.getVersion(),
chart.getAppVersion(),
{ title: chart.getRepository(), className: chart.getRepository().toLowerCase() }
]}
detailsItem={this.selectedChart}
onDetails={this.showDetails}
/>
<HelmChartDetails
chart={this.selectedChart}
hideDetails={this.hideDetails}
/>
</>
);
}
}
@@ -0,0 +1 @@
<svg enable-background="new 0 0 722.8 702" viewBox="0 0 722.8 702" xmlns="http://www.w3.org/2000/svg"><g fill="#929ba6"><path d="m318 299.5c2.1 1.6 4.8 2.5 7.6 2.5 6.9 0 12.6-5.5 12.9-12.3l.3-.2 4.3-76.7c-5.2.6-10.4 1.5-15.6 2.7-28.5 6.5-53.2 20.5-72.6 39.5l62.9 44.6z"/><path d="m309.5 411.9c-1.4-5.9-6.6-9.9-12.4-10-.8 0-1.7.1-2.5.2l-.1-.2-75.5 12.8c11.7 32.2 33.4 58.5 60.8 76.1l29.2-70.7-.2-.3c1.1-2.4 1.4-5.2.7-7.9z"/><path d="m284.4 357.5c2.5-.7 4.9-2.2 6.7-4.4 4.3-5.4 3.6-13.2-1.6-17.8l.1-.3-57.4-51.4c-17 27.8-25.1 61.1-21.4 95.3l73.6-21.2z"/><path d="m340.2 380 21.2 10.2 21.1-10.1 5.3-22.9-14.6-18.2h-23.6l-14.6 18.2z"/><path d="m384.2 289.4c.1 2.6 1 5.2 2.8 7.5 4.3 5.4 12.1 6.4 17.7 2.4l.2.1 62.5-44.3c-23.6-23.1-54.4-38.2-87.6-42.2z"/><path d="m490.3 283.7-57.1 51.1v.2c-2 1.7-3.5 4.1-4.1 6.8-1.5 6.8 2.5 13.5 9.2 15.3l.1.3 74 21.3c1.6-16 .6-32.5-3.2-49-3.9-16.8-10.4-32.2-18.9-46z"/><path d="m372.8 439.6c-1.2-2.3-3.2-4.3-5.8-5.5-2-.9-4-1.4-6-1.3-4.5.2-8.7 2.6-10.9 6.8h-.1l-37.1 67.1c25.7 8.8 54.1 10.7 82.5 4.2 5.1-1.2 10-2.5 14.9-4.2l-37.3-67.1z"/><path d="m711.7 425-60.4-262.2c-3.2-13.7-12.5-25.3-25.3-31.4l-244.4-116.8c-7.1-3.4-14.8-4.9-22.7-4.5-6.2.3-12.3 1.9-17.9 4.5l-244.3 116.7c-12.8 6.1-22.1 17.7-25.3 31.4l-60.2 262.3c-2.8 12.2-.5 25 6.3 35.5.8 1.3 1.7 2.5 2.7 3.7l169.1 210.3c8.9 11 22.3 17.4 36.5 17.4l271.2-.1c14.2 0 27.7-6.4 36.5-17.4l169.1-210.3c8.9-10.9 12.2-25.4 9.1-39.1zm-93-3.2c-1.8 7.8-10.2 12.6-18.9 10.7-.1 0-.2 0-.2 0-.1 0-.2-.1-.3-.1-1.2-.3-2.7-.5-3.8-.8-5-1.3-8.6-3.3-13.1-5.1-9.7-3.5-17.7-6.4-25.5-7.5-4-.3-6 1.6-8.2 3-1.1-.2-4.4-.8-6.2-1.1-14 44-43.9 82.2-84.3 106.1.7 1.7 1.9 5.3 2.4 5.9-.9 2.5-2.3 4.8-1.1 8.6 2.8 7.4 7.4 14.6 13 23.2 2.7 4 5.4 7.1 7.8 11.7.6 1.1 1.3 2.8 1.9 3.9 3.8 8 1 17.3-6.2 20.8-7.3 3.5-16.3-.2-20.2-8.3-.6-1.1-1.3-2.7-1.8-3.8-2.1-4.7-2.8-8.8-4.2-13.4-3.3-9.7-6-17.8-10-24.6-2.2-3.3-5-3.7-7.5-4.5-.5-.8-2.2-4-3.1-5.6-8.1 3.1-16.4 5.6-25.1 7.6-37.9 8.6-75.9 5.1-109.9-7.9l-3.3 6c-2.5.7-4.8 1.3-6.3 3.1-5.3 6.4-7.5 16.6-11.3 26.3-1.5 4.6-2.1 8.7-4.2 13.4-.5 1.1-1.3 2.6-1.8 3.7-3.9 8.1-12.9 11.7-20.2 8.2-7.2-3.5-10-12.7-6.2-20.8.6-1.2 1.3-2.8 1.9-3.9 2.4-4.6 5.2-7.7 7.8-11.7 5.5-8.7 10.4-16.4 13.2-23.8.7-2.4-.3-5.8-1.3-8.3l2.7-6.4c-38.9-23.1-69.7-59.8-84.3-105.3l-6.4 1.1c-1.7-1-5.1-3.2-8.4-3-7.8 1.1-15.8 4-25.5 7.5-4.5 1.7-8.1 3.7-13.1 5-1.1.3-2.6.6-3.8.8-.1 0-.2.1-.3.1s-.2 0-.2 0c-8.7 1.9-17.1-2.9-18.9-10.7s3.8-15.7 12.4-17.8c.1 0 .2 0 .2-.1h.1c1.2-.3 2.8-.7 3.9-.9 5.1-1 9.2-.7 14-1.1 10.2-1.1 18.7-1.9 26.2-4.3 2.4-1 4.7-4.3 6.3-6.3l6.1-1.8c-6.9-47.5 4.8-94.2 29.8-131.9l-4.7-4.2c-.3-1.8-.7-6-2.9-8.4-5.8-5.4-13-9.9-21.8-15.3-4.2-2.4-8-4-12.1-7.1-.9-.7-2.1-1.7-3-2.4-.1-.1-.1-.1-.2-.2-7-5.6-8.6-15.2-3.6-21.6 2.8-3.6 7.2-5.3 11.7-5.2 3.5.1 7.1 1.4 10.2 3.8 1 .8 2.4 1.8 3.2 2.6 3.9 3.4 6.3 6.7 9.6 10.2 7.2 7.3 13.2 13.4 19.7 17.8 3.4 2 6.1 1.2 8.7.8.8.6 3.7 2.6 5.3 3.8 24.9-26.4 57.6-46 95.6-54.6 8.8-2 17.7-3.3 26.4-4.1l.3-6.2c1.9-1.9 4.1-4.6 4.8-7.6.6-7.9-.4-16.3-1.6-26.5-.7-4.8-1.8-8.7-2-13.9 0-1.1 0-2.5 0-3.8 0-.1 0-.3 0-.4 0-9 6.5-16.2 14.6-16.2s14.6 7.3 14.6 16.2c0 1.3.1 3 0 4.2-.2 5.2-1.3 9.1-2 13.9-1.2 10.2-2.3 18.7-1.7 26.5.6 3.9 2.9 5.5 4.8 7.3 0 1.1.2 4.6.3 6.5 46.5 4.1 89.7 25.4 121.4 58.7l5.6-4c1.9.1 6 .7 8.9-1 6.5-4.4 12.5-10.5 19.7-17.8 3.3-3.5 5.7-6.8 9.7-10.2.9-.8 2.3-1.8 3.2-2.6 7-5.6 16.8-5 21.8 1.3s3.4 16-3.6 21.6c-1 .8-2.3 1.9-3.2 2.6-4.2 3.1-8 4.7-12.2 7.1-8.7 5.4-16 9.9-21.8 15.3-2.7 2.9-2.5 5.7-2.8 8.3-.8.7-3.7 3.3-5.2 4.7 12.6 18.8 22.1 40.1 27.4 63.3 5.3 23.1 6.1 46.1 3.1 68.3l5.9 1.7c1.1 1.5 3.2 5.2 6.3 6.3 7.5 2.4 16 3.2 26.2 4.3 4.8.4 8.9.2 14 1.1 1.2.2 3 .7 4.2 1 8.9 2.4 14.4 10.4 12.6 18.2z"/><path d="m428 401.7c-1-.2-2-.3-3-.2-1.7.1-3.3.5-4.9 1.3-6.2 3-9 10.4-6.2 16.7l-.1.1 29.6 71.4c28.5-18.2 49.8-45.3 61-76.6l-76.2-12.9z"/></g></svg>

After

Width:  |  Height:  |  Size: 3.7 KiB

@@ -0,0 +1,2 @@
export * from "./helm-charts";
export * from "./helm-charts.route";
@@ -0,0 +1,2 @@
export * from "./releases";
export * from "./release.route";
@@ -0,0 +1,86 @@
@import "./release.mixins.scss";
.ReleaseDetails {
&.light {
.AceEditor {
border: 1px solid gainsboro;
border-radius: $radius;
}
}
.DrawerItem {
align-items: center;
}
.status {
.Badge {
@include release-status-bgs;
&:first-child {
margin-left: 0;
}
}
}
.chart {
.upgrade {
min-width: 150px;
}
}
.resources {
.SubTitle {
text-transform: none;
padding-left: 0;
}
.Table {
margin-bottom: $margin * 4;
border: 1px solid var(--drawerSubtitleBackground);
border-radius: $radius;
overflow: auto;
@include custom-scrollbar();
.TableHead {
border-bottom: none;
.TableCell {
background-color: var(--drawerSubtitleBackground);
word-break: unset;
}
}
.TableCell {
text-overflow: unset;
word-break: break-word;
min-width: 100px;
&.name {
flex-basis: auto;
flex-grow: 0;
width: 230px;
}
&.volume {
flex-basis: 30%;
}
}
}
}
.notes {
white-space: pre-line;
font-family: "RobotoMono", monospace;
font-size: small;
}
.values {
.AceEditor {
min-height: 300px;
}
.AceEditor + .Button {
align-self: flex-start;
}
}
}
@@ -0,0 +1,254 @@
import "./release-details.scss";
import React, { Component } from "react";
import groupBy from "lodash/groupBy";
import isEqual from "lodash/isEqual";
import { observable, reaction } from "mobx";
import { Link } from "react-router-dom";
import { t, Trans } from "@lingui/macro";
import kebabCase from "lodash/kebabCase";
import { HelmRelease, helmReleasesApi, IReleaseDetails } from "../../api/endpoints/helm-releases.api";
import { HelmReleaseMenu } from "./release-menu";
import { Drawer, DrawerItem, DrawerTitle } from "../drawer";
import { Badge } from "../badge";
import { cssNames, stopPropagation } from "../../utils";
import { disposeOnUnmount, observer } from "mobx-react";
import { Spinner } from "../spinner";
import { Table, TableCell, TableHead, TableRow } from "../table";
import { AceEditor } from "../ace-editor";
import { Button } from "../button";
import { releaseStore } from "./release.store";
import { Notifications } from "../notifications";
import { Icon } from "../icon";
import { createUpgradeChartTab } from "../dock/upgrade-chart.store";
import { getDetailsUrl } from "../../navigation";
import { _i18n } from "../../i18n";
import { themeStore } from "../../theme.store";
import { apiManager } from "../../api/api-manager";
import { SubTitle } from "../layout/sub-title";
import { secretsStore } from "../+config-secrets/secrets.store";
import { Secret } from "../../api/endpoints";
interface Props {
release: HelmRelease;
hideDetails(): void;
}
@observer
export class ReleaseDetails extends Component<Props> {
@observable details: IReleaseDetails;
@observable values = "";
@observable saving = false;
@observable releaseSecret: Secret;
@disposeOnUnmount
releaseSelector = reaction(() => this.props.release, release => {
if (!release) return;
this.loadDetails();
this.loadValues();
this.releaseSecret = null;
}
);
@disposeOnUnmount
secretWatcher = reaction(() => secretsStore.items.toJS(), () => {
if (!this.props.release) return;
const { getReleaseSecret } = releaseStore;
const { release } = this.props;
const secret = getReleaseSecret(release);
if (this.releaseSecret) {
if (isEqual(this.releaseSecret.getLabels(), secret.getLabels())) return;
this.loadDetails();
}
this.releaseSecret = secret;
});
async loadDetails() {
const { release } = this.props;
this.details = null;
this.details = await helmReleasesApi.get(release.getName(), release.getNs());
}
async loadValues() {
const { release } = this.props;
this.values = "";
this.values = await helmReleasesApi.getValues(release.getName(), release.getNs());
}
updateValues = async () => {
const { release } = this.props;
const name = release.getName();
const namespace = release.getNs()
const data = {
chart: release.getChart(),
repo: await release.getRepo(),
version: release.getVersion(),
values: this.values
};
this.saving = true;
try {
await releaseStore.update(name, namespace, data);
Notifications.ok(
<p>Release <b>{name}</b> successfully updated!</p>
);
} catch (err) {
Notifications.error(err);
}
this.saving = false;
}
upgradeVersion = () => {
const { release, hideDetails } = this.props;
createUpgradeChartTab(release);
hideDetails();
}
renderValues() {
const { values, saving } = this;
return (
<div className="values">
<DrawerTitle title={_i18n._(t`Values`)}/>
<div className="flex column gaps">
<AceEditor
mode="yaml"
value={values}
onChange={values => this.values = values}
/>
<Button
primary
label={_i18n._(t`Save`)}
waiting={saving}
onClick={this.updateValues}
/>
</div>
</div>
)
}
renderNotes() {
if (!this.details.info?.notes) return null;
const { notes } = this.details.info;
return (
<div className="notes">
{notes}
</div>
);
}
renderResources() {
const { resources } = this.details;
if (!resources) return null;
const groups = groupBy(resources, item => item.kind);
const tables = Object.entries(groups).map(([kind, items]) => {
return (
<React.Fragment key={kind}>
<SubTitle title={kind}/>
<Table scrollable={false}>
<TableHead sticky={false}>
<TableCell className="name">Name</TableCell>
{items[0].getNs() && <TableCell className="namespace">Namespace</TableCell>}
<TableCell className="age">Age</TableCell>
</TableHead>
{items.map(item => {
const name = item.getName();
const namespace = item.getNs();
const api = apiManager.getApi(item.metadata.selfLink);
const detailsUrl = api ? getDetailsUrl(api.getUrl({
name,
namespace,
})) : "";
return (
<TableRow key={item.getId()}>
<TableCell className="name">
{detailsUrl ? <Link to={detailsUrl}>{name}</Link> : name}
</TableCell>
{namespace && <TableCell className="namespace">{namespace}</TableCell>}
<TableCell className="age">{item.getAge()}</TableCell>
</TableRow>
);
})}
</Table>
</React.Fragment>
);
});
return (
<div className="resources">
{tables}
</div>
);
}
renderContent() {
const { release } = this.props;
const { details } = this;
if (!release) return null;
if (!details) {
return <Spinner center/>;
}
return (
<div>
<DrawerItem name={<Trans>Chart</Trans>} className="chart">
<div className="flex gaps align-center">
<span>{release.getChart()}</span>
{release.hasNewVersion() && (
<Button
primary
label={_i18n._(t`Upgrade`)}
className="box right upgrade"
onClick={this.upgradeVersion}
/>
)}
</div>
</DrawerItem>
<DrawerItem name={<Trans>Updated</Trans>}>
{release.getUpdated()} <Trans>ago</Trans> ({release.updated})
</DrawerItem>
<DrawerItem name={<Trans>Namespace</Trans>}>
{release.getNs()}
</DrawerItem>
<DrawerItem name={<Trans>Version</Trans>} onClick={stopPropagation}>
<div className="version flex gaps align-center">
<span>
{release.getVersion()}
</span>
{!release.getLastVersion() && (
<Icon svg="spinner" small/>
)}
{release.hasNewVersion() && (
<span><Trans>New version available:</Trans> <b>{release.getLastVersion()}</b></span>
)}
</div>
</DrawerItem>
<DrawerItem name={<Trans>Status</Trans>} className="status" labelsOnly>
<Badge
label={release.getStatus()}
className={cssNames("status", kebabCase(release.getStatus()))}
/>
</DrawerItem>
{this.renderValues()}
<DrawerTitle title={_i18n._(t`Notes`)}/>
{this.renderNotes()}
<DrawerTitle title={_i18n._(t`Resources`)}/>
{this.renderResources()}
</div>
)
}
render() {
const { release, hideDetails } = this.props
const title = release ? <Trans>Release: {release.getName()}</Trans> : ""
const toolbar = <HelmReleaseMenu release={release} toolbar hideDetails={hideDetails}/>
return (
<Drawer
className={cssNames("ReleaseDetails", themeStore.activeTheme.type)}
usePortal={true}
open={!!release}
title={title}
onClose={hideDetails}
toolbar={toolbar}
>
{this.renderContent()}
</Drawer>
)
}
}
@@ -0,0 +1,70 @@
import * as React from "react";
import { t, Trans } from "@lingui/macro";
import { HelmRelease } from "../../api/endpoints/helm-releases.api";
import { autobind, cssNames } from "../../utils";
import { releaseStore } from "./release.store";
import { MenuActions, MenuActionsProps } from "../menu/menu-actions";
import { MenuItem } from "../menu";
import { Icon } from "../icon";
import { ReleaseRollbackDialog } from "./release-rollback-dialog";
import { createUpgradeChartTab } from "../dock/upgrade-chart.store";
import { _i18n } from "../../i18n";
interface Props extends MenuActionsProps {
release: HelmRelease;
hideDetails?(): void;
}
export class HelmReleaseMenu extends React.Component<Props> {
@autobind()
remove() {
return releaseStore.remove(this.props.release);
}
@autobind()
upgrade() {
const { release, hideDetails } = this.props;
createUpgradeChartTab(release);
hideDetails && hideDetails();
}
@autobind()
rollback() {
ReleaseRollbackDialog.open(this.props.release);
}
renderContent() {
const { release, toolbar } = this.props;
if (!release) return;
const hasRollback = release && release.getRevision() > 1;
const hasNewVersion = release.hasNewVersion();
return (
<>
{hasRollback && (
<MenuItem onClick={this.rollback}>
<Icon material="history" interactive={toolbar} title={_i18n._(t`Rollback`)}/>
<span className="title"><Trans>Rollback</Trans></span>
</MenuItem>
)}
{hasNewVersion && (
<MenuItem onClick={this.upgrade}>
<Icon material="build" interactive={toolbar} title={_i18n._(t`Upgrade`)}/>
<span className="title"><Trans>Upgrade</Trans></span>
</MenuItem>
)}
</>
)
}
render() {
const { className, release, ...menuProps } = this.props;
return (
<MenuActions
{...menuProps}
className={cssNames("HelmReleaseMenu", className)}
removeAction={this.remove}
children={this.renderContent()}
/>
);
}
}
@@ -0,0 +1,9 @@
.ReleaseRollbackDialog {
.WizardStep {
text-align: center;
.step-content {
padding: var(--wizard-spacing);
}
}
}
@@ -0,0 +1,109 @@
import "./release-rollback-dialog.scss";
import * as React from "react";
import { observable } from "mobx";
import { observer } from "mobx-react";
import { Trans } from "@lingui/macro";
import { Dialog, DialogProps } from "../dialog";
import { Wizard, WizardStep } from "../wizard";
import { HelmRelease, helmReleasesApi, IReleaseRevision } from "../../api/endpoints/helm-releases.api";
import { releaseStore } from "./release.store";
import { Select, SelectOption } from "../select";
import { Notifications } from "../notifications";
import orderBy from "lodash/orderBy"
interface Props extends DialogProps {
}
@observer
export class ReleaseRollbackDialog extends React.Component<Props> {
@observable static isOpen = false;
@observable.ref static release: HelmRelease = null;
@observable isLoading = false;
@observable revision: IReleaseRevision;
@observable revisions = observable.array<IReleaseRevision>();
static open(release: HelmRelease) {
ReleaseRollbackDialog.isOpen = true;
ReleaseRollbackDialog.release = release;
}
static close() {
ReleaseRollbackDialog.isOpen = false;
}
get release(): HelmRelease {
return ReleaseRollbackDialog.release;
}
onOpen = async () => {
this.isLoading = true;
const currentRevision = this.release.getRevision();
let releases = await helmReleasesApi.getHistory(this.release.getName(), this.release.getNs());
releases = releases.filter(item => item.revision !== currentRevision); // remove current
releases = orderBy(releases, "revision", "desc"); // sort
this.revisions.replace(releases);
this.revision = this.revisions[0];
this.isLoading = false;
}
rollback = async () => {
const revisionNumber = this.revision.revision;
try {
await releaseStore.rollback(this.release.getName(), this.release.getNs(), revisionNumber);
this.close();
} catch (err) {
Notifications.error(err);
}
};
close = () => {
ReleaseRollbackDialog.close();
}
renderContent() {
const { revision, revisions } = this;
if (!revision) {
return <p><Trans>No revisions to rollback.</Trans></p>
}
return (
<div className="flex gaps align-center">
<b><Trans>Revision</Trans></b>
<Select
themeName="light"
value={revision}
options={revisions}
formatOptionLabel={({ value }: SelectOption<IReleaseRevision>) => `${value.revision} - ${value.chart}`}
onChange={({ value }: SelectOption<IReleaseRevision>) => this.revision = value}
/>
</div>
)
}
render() {
const { ...dialogProps } = this.props;
const releaseName = this.release ? this.release.getName() : "";
const header = <h5><Trans>Rollback <b>{releaseName}</b></Trans></h5>
return (
<Dialog
{...dialogProps}
className="ReleaseRollbackDialog"
isOpen={ReleaseRollbackDialog.isOpen}
onOpen={this.onOpen}
close={this.close}
>
<Wizard header={header} done={this.close}>
<WizardStep
scrollable={false}
nextLabel={<Trans>Rollback</Trans>}
next={this.rollback}
loading={this.isLoading}
>
{this.renderContent()}
</WizardStep>
</Wizard>
</Dialog>
)
}
}
@@ -0,0 +1,25 @@
$release-status-color-list: (
deployed: $colorSuccess,
failed: $colorError,
deleting: $colorWarning,
pendingInstall: $colorInfo,
pendingUpgrade: $colorInfo,
pendingRollback: $colorInfo,
);
@mixin release-status-bgs {
@each $status, $color in $release-status-color-list {
&.#{$status} {
color: white;
background: $color;
}
}
}
@mixin release-status-colors {
@each $status, $color in $release-status-color-list {
&.#{$status} {
color: $color;
}
}
}
@@ -0,0 +1,14 @@
import { RouteProps } from "react-router"
import { appsRoute } from "../+apps/apps.route";
import { buildURL } from "../../navigation";
export const releaseRoute: RouteProps = {
path: appsRoute.path + "/releases/:namespace?/:name?"
}
export interface IReleaseRouteParams {
name?: string;
namespace?: string;
}
export const releaseURL = buildURL<IReleaseRouteParams>(releaseRoute.path);
@@ -0,0 +1,112 @@
import isEqual from "lodash/isEqual";
import { action, observable, when, IReactionDisposer, reaction } from "mobx";
import { autobind } from "../../utils";
import { HelmRelease, helmReleasesApi, IReleaseCreatePayload, IReleaseUpdatePayload } from "../../api/endpoints/helm-releases.api";
import { ItemStore } from "../../item.store";
import { configStore } from "../../config.store";
import { secretsStore } from "../+config-secrets/secrets.store";
import { Secret } from "../../api/endpoints";
@autobind()
export class ReleaseStore extends ItemStore<HelmRelease> {
@observable releaseSecrets: Secret[] = [];
@observable secretWatcher: IReactionDisposer;
constructor() {
super();
when(() => secretsStore.isLoaded, () => {
this.releaseSecrets = this.getReleaseSecrets();
});
}
watch() {
this.secretWatcher = reaction(() => secretsStore.items.toJS(), () => {
if (this.isLoading) return;
const secrets = this.getReleaseSecrets();
const amountChanged = secrets.length !== this.releaseSecrets.length;
const labelsChanged = this.releaseSecrets.some(item => {
const secret = secrets.find(secret => secret.getId() == item.getId());
if (!secret) return;
return !isEqual(item.getLabels(), secret.getLabels());
});
if (amountChanged || labelsChanged) {
this.loadAll();
}
this.releaseSecrets = [...secrets];
})
}
unwatch() {
this.secretWatcher();
}
getReleaseSecrets() {
return secretsStore.getByLabel({ owner: "helm" });
}
getReleaseSecret(release: HelmRelease) {
const labels = {
owner: "helm",
name: release.getName()
}
return secretsStore.getByLabel(labels)
.filter(secret => secret.getNs() == release.getNs())[0];
}
@action
async loadAll() {
this.isLoading = true;
let items;
try {
const { isClusterAdmin, allowedNamespaces } = configStore;
items = await this.loadItems(!isClusterAdmin ? allowedNamespaces : null);
} finally {
if (items) {
items = this.sortItems(items);
this.items.replace(items);
}
this.isLoaded = true;
this.isLoading = false;
}
}
async loadItems(namespaces?: string[]) {
if (!namespaces) {
return helmReleasesApi.list();
}
else {
return Promise
.all(namespaces.map(namespace => helmReleasesApi.list(namespace)))
.then(items => items.flat());
}
}
async create(payload: IReleaseCreatePayload) {
const response = await helmReleasesApi.create(payload);
if (this.isLoaded) this.loadAll();
return response;
}
async update(name: string, namespace: string, payload: IReleaseUpdatePayload) {
const response = await helmReleasesApi.update(name, namespace, payload);
if (this.isLoaded) this.loadAll();
return response;
}
async rollback(name: string, namespace: string, revision: number) {
const response = await helmReleasesApi.rollback(name, namespace, revision);
if (this.isLoaded) this.loadAll();
return response;
}
async remove(release: HelmRelease) {
return super.removeItem(release, () => helmReleasesApi.delete(release.getName(), release.getNs()));
}
async removeSelectedItems() {
if (!this.selectedItems.length) return;
return Promise.all(this.selectedItems.map(this.remove));
}
}
export const releaseStore = new ReleaseStore();
@@ -0,0 +1,19 @@
@import "./release.mixins.scss";
.HelmReleases {
.TableCell {
&.status {
@include release-status-colors;
}
&.version {
> .Icon {
margin-left: $margin;
&.new-version {
color: $colorInfo;
}
}
}
}
}
@@ -0,0 +1,183 @@
import "./releases.scss";
import React, { Component } from "react";
import kebabCase from "lodash/kebabCase";
import { observer } from "mobx-react";
import { Trans } from "@lingui/macro";
import { RouteComponentProps } from "react-router";
import { autobind, interval } from "../../utils";
import { releaseStore } from "./release.store";
import { helmChartStore } from "../+apps-helm-charts/helm-chart.store";
import { IReleaseRouteParams, releaseURL } from "./release.route";
import { HelmRelease } from "../../api/endpoints/helm-releases.api";
import { ReleaseDetails } from "./release-details";
import { ReleaseRollbackDialog } from "./release-rollback-dialog";
import { navigation } from "../../navigation";
import { ItemListLayout } from "../item-object-list/item-list-layout";
import { HelmReleaseMenu } from "./release-menu";
import { Icon } from "../icon";
import { secretsStore } from "../+config-secrets/secrets.store";
import { when } from "mobx";
enum sortBy {
name = "name",
namespace = "namespace",
revision = "revision",
chart = "chart",
status = "status",
updated = "update"
}
interface Props extends RouteComponentProps<IReleaseRouteParams> {
}
@observer
export class HelmReleases extends Component<Props> {
private versionsWatcher = interval(3600, this.checkVersions);
componentDidMount() {
// Watch for secrets associated with releases and react to their changes
releaseStore.watch();
this.versionsWatcher.start();
when(() => releaseStore.isLoaded, this.checkVersions);
}
componentWillUnmount() {
releaseStore.unwatch();
this.versionsWatcher.stop();
}
// Check all available versions every 1 hour for installed releases.
// This required to show "upgrade" icon in the list and upgrade button in the details view.
@autobind()
checkVersions() {
const charts = releaseStore.items.map(release => release.getChart());
return charts.reduce((promise, chartName) => {
const loadVersions = () => helmChartStore.getVersions(chartName, true);
return promise.then(loadVersions, loadVersions);
}, Promise.resolve({}))
};
get selectedRelease() {
const { match: { params: { name, namespace } } } = this.props;
return releaseStore.items.find(release => {
return release.getName() == name && release.getNs() == namespace;
});
}
showDetails = (item: HelmRelease) => {
if (!item) {
navigation.merge(releaseURL())
}
else {
navigation.merge(releaseURL({
params: {
name: item.getName(),
namespace: item.getNs()
}
}))
}
}
hideDetails = () => {
this.showDetails(null);
}
renderRemoveDialogMessage(selectedItems: HelmRelease[]) {
const releaseNames = selectedItems.map(item => item.getName()).join(", ");
return (
<div>
<Trans>Remove <b>{releaseNames}</b>?</Trans>
<p className="warning">
<Trans>Note: StatefulSet Volumes won't be deleted automatically</Trans>
</p>
</div>
)
}
render() {
return (
<>
<ItemListLayout
className="HelmReleases"
store={releaseStore}
dependentStores={[secretsStore]}
sortingCallbacks={{
[sortBy.name]: (release: HelmRelease) => release.getName(),
[sortBy.namespace]: (release: HelmRelease) => release.getNs(),
[sortBy.revision]: (release: HelmRelease) => release.getRevision(),
[sortBy.chart]: (release: HelmRelease) => release.getChart(),
[sortBy.status]: (release: HelmRelease) => release.getStatus(),
[sortBy.updated]: (release: HelmRelease) => release.getUpdated(false, false),
}}
searchFilters={[
(release: HelmRelease) => release.getName(),
(release: HelmRelease) => release.getNs(),
(release: HelmRelease) => release.getChart(),
(release: HelmRelease) => release.getStatus(),
(release: HelmRelease) => release.getVersion(),
]}
renderHeaderTitle={<Trans>Releases</Trans>}
renderTableHeader={[
{ title: <Trans>Name</Trans>, className: "name", sortBy: sortBy.name },
{ title: <Trans>Namespace</Trans>, className: "namespace", sortBy: sortBy.namespace },
{ title: <Trans>Chart</Trans>, className: "chart", sortBy: sortBy.chart },
{ title: <Trans>Revision</Trans>, className: "revision", sortBy: sortBy.revision },
{ title: <Trans>Version</Trans>, className: "version" },
{ title: <Trans>App Version</Trans>, className: "app-version" },
{ title: <Trans>Status</Trans>, className: "status", sortBy: sortBy.status },
{ title: <Trans>Updated</Trans>, className: "updated", sortBy: sortBy.updated },
]}
renderTableContents={(release: HelmRelease) => {
const version = release.getVersion();
const lastVersion = release.getLastVersion();
return [
release.getName(),
release.getNs(),
release.getChart(),
release.getRevision(),
<>
{version}
{!lastVersion && (
<Icon
small svg="spinner"
className="checking-update"
tooltip={<Trans>Checking update</Trans>}
/>
)}
{release.hasNewVersion() && (
<Icon
material="new_releases"
className="new-version"
tooltip={<Trans>New version: {lastVersion}</Trans>}
/>
)}
</>,
release.appVersion,
{ title: release.getStatus(), className: kebabCase(release.getStatus()) },
release.getUpdated(),
]
}}
renderItemMenu={(release: HelmRelease) => {
return (
<HelmReleaseMenu
release={release}
removeConfirmationMessage={this.renderRemoveDialogMessage([release])}
/>
)
}}
customizeRemoveDialog={(selectedItems: HelmRelease[]) => ({
message: this.renderRemoveDialogMessage(selectedItems)
})}
detailsItem={this.selectedRelease}
onDetails={this.showDetails}
/>
<ReleaseDetails
release={this.selectedRelease}
hideDetails={this.hideDetails}
/>
<ReleaseRollbackDialog/>
</>
);
}
}
@@ -0,0 +1,8 @@
import { RouteProps } from "react-router";
import { buildURL } from "../../navigation";
export const appsRoute: RouteProps = {
path: "/apps",
};
export const appsURL = buildURL(appsRoute.path);
@@ -0,0 +1,41 @@
import React from "react";
import { observer } from "mobx-react";
import { Redirect, Route, Switch } from "react-router";
import { Trans } from "@lingui/macro";
import { MainLayout, TabRoute } from "../layout/main-layout";
import { HelmCharts, helmChartsRoute, helmChartsURL } from "../+apps-helm-charts";
import { HelmReleases, releaseRoute, releaseURL } from "../+apps-releases";
import { namespaceStore } from "../+namespaces/namespace.store";
@observer
export class Apps extends React.Component {
static get tabRoutes(): TabRoute[] {
const query = namespaceStore.getContextParams();
return [
{
title: <Trans>Charts</Trans>,
component: HelmCharts,
url: helmChartsURL(),
path: helmChartsRoute.path,
},
{
title: <Trans>Releases</Trans>,
component: HelmReleases,
url: releaseURL({ query }),
path: releaseRoute.path,
},
]
}
render() {
const tabRoutes = Apps.tabRoutes;
return (
<MainLayout className="Apps" tabs={tabRoutes}>
<Switch>
{tabRoutes.map((route, index) => <Route key={index} {...route}/>)}
<Redirect to={tabRoutes[0].url}/>
</Switch>
</MainLayout>
)
}
}
@@ -0,0 +1,2 @@
export * from "./apps";
export * from "./apps.route";
@@ -0,0 +1,58 @@
.ClusterIssues {
min-height: 350px;
position: relative;
@include media("<1024px") {
grid-column-start: 1!important;
grid-column-end: 1!important;
}
&.wide {
grid-column-start: 1;
grid-column-end: 3;
}
.SubHeader {
.Icon {
font-size: 130%;
color: $colorError;
}
}
.Table {
.TableHead {
background-color: transparent;
border-bottom: 1px solid $borderFaintColor;
.TableCell {
padding-top: 0;
padding-bottom: floor($padding / 1.33);
}
}
.TableCell {
white-space: nowrap;
text-overflow: ellipsis;
&.message {
flex-grow: 3;
}
&.object {
flex-grow: 2;
}
}
}
.no-issues {
.Icon {
color: white;
}
.ok-title {
font-size: large;
color: $textColorAccent;
font-weight: bold;
}
}
}
@@ -0,0 +1,149 @@
import "./cluster-issues.scss"
import * as React from "react";
import { observer } from "mobx-react";
import { computed } from "mobx";
import { Trans } from "@lingui/macro";
import { Icon } from "../icon";
import { SubHeader } from "../layout/sub-header";
import { Table, TableCell, TableHead, TableRow } from "../table";
import { nodesStore } from "../+nodes/nodes.store";
import { eventStore } from "../+events/event.store";
import { autobind, cssNames, prevDefault } from "../../utils";
import { getSelectedDetails, showDetails } from "../../navigation";
import { ItemObject } from "../../item.store";
import { Spinner } from "../spinner";
import { themeStore } from "../../theme.store";
import { lookupApiLink } from "../../api/kube-api";
interface Props {
className?: string;
}
interface IWarning extends ItemObject {
kind: string;
message: string;
selfLink: string;
}
enum sortBy {
type = "type",
object = "object"
}
@observer
export class ClusterIssues extends React.Component<Props> {
private sortCallbacks = {
[sortBy.type]: (warning: IWarning) => warning.kind,
[sortBy.object]: (warning: IWarning) => warning.getName(),
};
@computed get warnings() {
const warnings: IWarning[] = [];
// Node bad conditions
nodesStore.items.forEach(node => {
const { kind, selfLink, getId, getName } = node
node.getWarningConditions().forEach(({ message }) => {
warnings.push({
kind,
getId,
getName,
selfLink,
message,
})
})
});
// Warning events for Workloads
const events = eventStore.getWarnings();
events.forEach(error => {
const { message, involvedObject } = error;
const { uid, name, kind } = involvedObject;
warnings.push({
getId: () => uid,
getName: () => name,
message,
kind,
selfLink: lookupApiLink(involvedObject, error),
});
})
return warnings;
}
@autobind()
getTableRow(uid: string) {
const { warnings } = this;
const warning = warnings.find(warn => warn.getId() == uid);
const { getId, getName, message, kind, selfLink } = warning;
return (
<TableRow
key={getId()}
sortItem={warning}
selected={selfLink === getSelectedDetails()}
onClick={prevDefault(() => showDetails(selfLink))}
>
<TableCell className="message">
{message}
</TableCell>
<TableCell className="object">
{getName()}
</TableCell>
<TableCell className="kind">
{kind}
</TableCell>
</TableRow>
);
}
renderContent() {
const { warnings } = this;
if (!eventStore.isLoaded) {
return (
<Spinner center/>
);
}
if (!warnings.length) {
return (
<div className="no-issues flex column box grow gaps align-center justify-center">
<div><Icon material="check" big sticker/></div>
<div className="ok-title"><Trans>No issues found</Trans></div>
<span><Trans>Everything is fine in the Cluster</Trans></span>
</div>
);
}
return (
<>
<SubHeader>
<Icon material="error_outline"/>{" "}
<Trans>Warnings: {warnings.length}</Trans>
</SubHeader>
<Table
items={warnings}
virtual
selectable
sortable={this.sortCallbacks}
sortByDefault={{ sortBy: sortBy.object, orderBy: "asc" }}
sortSyncWithUrl={false}
getTableRow={this.getTableRow}
className={cssNames("box grow", themeStore.activeTheme.type)}
>
<TableHead nowrap>
<TableCell className="message"><Trans>Message</Trans></TableCell>
<TableCell className="object" sortBy={sortBy.object}><Trans>Object</Trans></TableCell>
<TableCell className="kind" sortBy={sortBy.type}><Trans>Type</Trans></TableCell>
</TableHead>
</Table>
</>
);
}
render() {
return (
<div className={cssNames("ClusterIssues flex column", this.props.className)}>
{this.renderContent()}
</div>
);
}
}
@@ -0,0 +1,7 @@
.ClusterMetricSwitchers {
margin-bottom: $margin * 2;
.metric-switch {
text-align: right;
}
}
@@ -0,0 +1,43 @@
import "./cluster-metric-switchers.scss";
import React from "react";
import { Trans } from "@lingui/macro";
import { observer } from "mobx-react";
import { nodesStore } from "../+nodes/nodes.store";
import { cssNames } from "../../utils";
import { Radio, RadioGroup } from "../radio";
import { clusterStore, MetricNodeRole, MetricType } from "./cluster.store";
export const ClusterMetricSwitchers = observer(() => {
const { metricType, metricNodeRole, getMetricsValues, metrics } = clusterStore;
const { masterNodes, workerNodes } = nodesStore;
const metricsValues = getMetricsValues(metrics);
const disableRoles = !masterNodes.length || !workerNodes.length;
const disableMetrics = !metricsValues.length;
return (
<div className="ClusterMetricSwitchers flex gaps">
<div className="box grow">
<RadioGroup
asButtons
className={cssNames("RadioGroup flex gaps", { disabled: disableRoles })}
value={metricNodeRole}
onChange={(metric: MetricNodeRole) => clusterStore.metricNodeRole = metric}
>
<Radio label={<Trans>Master</Trans>} value={MetricNodeRole.MASTER}/>
<Radio label={<Trans>Worker</Trans>} value={MetricNodeRole.WORKER}/>
</RadioGroup>
</div>
<div className="box grow metric-switch">
<RadioGroup
asButtons
className={cssNames("RadioGroup flex gaps", { disabled: disableMetrics })}
value={metricType}
onChange={(value: MetricType) => clusterStore.metricType = value}
>
<Radio label={<Trans>CPU</Trans>} value={MetricType.CPU}/>
<Radio label={<Trans>Memory</Trans>} value={MetricType.MEMORY}/>
</RadioGroup>
</div>
</div>
);
});
@@ -0,0 +1,16 @@
.ClusterMetrics {
position: relative;
min-height: 280px;
.Chart {
.chart-container {
width: 100%;
height: 100%;
}
}
.empty {
margin-top: -45px;
text-align: center;
}
}
@@ -0,0 +1,95 @@
import "./cluster-metrics.scss";
import React from "react";
import { observer } from "mobx-react";
import { ChartOptions, ChartPoint } from "chart.js";
import { clusterStore, MetricType } from "./cluster.store";
import { BarChart } from "../chart";
import { bytesToUnits } from "../../utils";
import { Spinner } from "../spinner";
import { ZebraStripes } from "../chart/zebra-stripes.plugin";
import { ClusterNoMetrics } from "./cluster-no-metrics";
import { ClusterMetricSwitchers } from "./cluster-metric-switchers";
import { getMetricLastPoints } from "../../api/endpoints/metrics.api";
export const ClusterMetrics = observer(() => {
const { metricType, metricNodeRole, getMetricsValues, metricsLoaded, metrics, liveMetrics } = clusterStore;
const { memoryCapacity, cpuCapacity } = getMetricLastPoints(clusterStore.metrics);
const metricValues = getMetricsValues(metrics);
const liveMetricValues = getMetricsValues(liveMetrics);
const colors = { cpu: "#3D90CE", memory: "#C93DCE" };
const data = metricValues.map(value => ({
x: value[0],
y: parseFloat(value[1]).toFixed(3)
}));
const datasets = [{
id: metricType + metricNodeRole,
label: metricType.toUpperCase() + " usage",
borderColor: colors[metricType],
data: data
}];
const cpuOptions: ChartOptions = {
scales: {
yAxes: [{
ticks: {
suggestedMax: cpuCapacity,
callback: (value) => value
}
}]
},
tooltips: {
callbacks: {
label: ({ index }, data) => {
const value = data.datasets[0].data[index] as ChartPoint;
return value.y.toString();
}
}
}
};
const memoryOptions: ChartOptions = {
scales: {
yAxes: [{
ticks: {
suggestedMax: memoryCapacity,
callback: (value: string) => !value ? 0 : bytesToUnits(parseInt(value))
}
}]
},
tooltips: {
callbacks: {
label: ({ index }, data) => {
const value = data.datasets[0].data[index] as ChartPoint;
return bytesToUnits(parseInt(value.y as string), 3);
}
}
}
};
const options = metricType === MetricType.CPU ? cpuOptions : memoryOptions;
const renderMetrics = () => {
if ((!metricValues.length || !liveMetricValues.length) && !metricsLoaded) {
return <Spinner center/>;
}
if (!memoryCapacity || !cpuCapacity) {
return <ClusterNoMetrics className="empty"/>
}
return (
<BarChart
name={`${metricNodeRole}-${metricType}`}
options={options}
data={{ datasets }}
timeLabelStep={5}
showLegend={false}
plugins={[ZebraStripes]}
/>
);
};
return (
<div className="ClusterMetrics flex column">
<ClusterMetricSwitchers/>
{renderMetrics()}
</div>
);
});
@@ -0,0 +1,18 @@
import React from "react";
import { Icon } from "../icon";
import { Trans } from "@lingui/macro";
import { cssNames } from "../../utils";
interface Props {
className: string;
}
export function ClusterNoMetrics({ className }: Props) {
return (
<div className={cssNames("ClusterNoMetrics flex column box grow justify-center align-center", className)}>
<Icon material="info"/>
<p><Trans>Metrics are not available due to missing or invalid Prometheus configuration.</Trans></p>
<p><Trans>Right click cluster icon to open cluster settings.</Trans></p>
</div>
);
}
@@ -0,0 +1,29 @@
.ClusterPieCharts {
background: transparent!important;
padding: 0!important;
.empty {
background: $contentColor;
min-height: 280px;
text-align: center;
padding: $padding * 2;
}
.NodeCharts {
margin-bottom: 0;
}
.chart {
--flex-gap: #{$padding * 2};
background: $contentColor;
padding: $padding * 2 $padding;
.chart-title {
margin-bottom: 0;
}
.legend {
--flex-gap: #{$padding};
}
}
}
@@ -0,0 +1,204 @@
import "./cluster-pie-charts.scss";
import * as React from "react";
import { observer } from "mobx-react";
import { t, Trans } from "@lingui/macro";
import { useLingui } from "@lingui/react";
import { clusterStore, MetricNodeRole } from "./cluster.store";
import { Spinner } from "../spinner";
import { Icon } from "../icon";
import { nodesStore } from "../+nodes/nodes.store";
import { ChartData, PieChart } from "../chart";
import { ClusterNoMetrics } from "./cluster-no-metrics";
import { bytesToUnits } from "../../utils";
import { themeStore } from "../../theme.store";
import { getMetricLastPoints } from "../../api/endpoints/metrics.api";
export const ClusterPieCharts = observer(() => {
const { i18n } = useLingui();
const renderLimitWarning = () => {
return (
<div className="node-warning flex gaps align-center">
<Icon material="info"/>
<p><Trans>Specified limits are higher than node capacity!</Trans></p>
</div>
);
}
const renderCharts = () => {
const data = getMetricLastPoints(clusterStore.metrics);
const { memoryUsage, memoryRequests, memoryCapacity, memoryLimits } = data;
const { cpuUsage, cpuRequests, cpuCapacity, cpuLimits } = data;
const { podUsage, podCapacity } = data;
const cpuLimitsOverload = cpuLimits > cpuCapacity;
const memoryLimitsOverload = memoryLimits > memoryCapacity;
const defaultColor = themeStore.activeTheme.colors.pieChartDefaultColor;
if (!memoryCapacity || !cpuCapacity || !podCapacity) return null;
const cpuData: ChartData = {
datasets: [
{
data: [
cpuUsage,
cpuUsage ? cpuCapacity - cpuUsage : 1,
],
backgroundColor: [
"#c93dce",
defaultColor,
],
id: "cpuUsage"
},
{
data: [
cpuRequests,
cpuRequests ? cpuCapacity - cpuRequests : 1,
],
backgroundColor: [
"#4caf50",
defaultColor,
],
id: "cpuRequests"
},
{
data: [
cpuLimits,
cpuLimitsOverload ? 0 : cpuCapacity - cpuLimits,
],
backgroundColor: [
"#3d90ce",
defaultColor,
],
id: "cpuLimits"
},
],
labels: [
i18n._(t`Usage`) + `: ${cpuUsage ? cpuUsage.toFixed(2) : "N/A"}`,
i18n._(t`Requests`) + `: ${cpuRequests ? cpuRequests.toFixed(2) : "N/A"}`,
i18n._(t`Limits`) + `: ${cpuLimits ? cpuLimits.toFixed(2) : "N/A"}`,
i18n._(t`Capacity`) + `: ${cpuCapacity || "N/A"}`
]
};
const memoryData: ChartData = {
datasets: [
{
data: [
memoryUsage,
memoryUsage ? memoryCapacity - memoryUsage : 1,
],
backgroundColor: [
"#c93dce",
defaultColor,
],
id: "memoryUsage"
},
{
data: [
memoryRequests,
memoryRequests ? memoryCapacity - memoryRequests : 1,
],
backgroundColor: [
"#4caf50",
defaultColor,
],
id: "memoryRequests"
},
{
data: [
memoryLimits,
memoryLimitsOverload ? 0 : memoryCapacity - memoryLimits,
],
backgroundColor: [
"#3d90ce",
defaultColor,
],
id: "memoryLimits"
},
],
labels: [
i18n._(t`Usage`) + `: ${bytesToUnits(memoryUsage)}`,
i18n._(t`Requests`) + `: ${bytesToUnits(memoryRequests)}`,
i18n._(t`Limits`) + `: ${bytesToUnits(memoryLimits)}`,
i18n._(t`Capacity`) + `: ${bytesToUnits(memoryCapacity)}`,
]
};
const podsData: ChartData = {
datasets: [
{
data: [
podUsage,
podUsage ? podCapacity - podUsage : 1,
],
backgroundColor: [
"#4caf50",
defaultColor,
],
id: "podUsage"
},
],
labels: [
i18n._(t`Usage`) + `: ${podUsage || 0}`,
i18n._(t`Capacity`) + `: ${podCapacity}`,
]
};
return (
<div className="NodeCharts flex justify-center box grow gaps">
<div className="chart flex column align-center box grow">
<PieChart
data={cpuData}
title={i18n._(t`CPU`)}
legendColors={["#c93dce", "#4caf50", "#3d90ce", defaultColor]}
/>
{cpuLimitsOverload && renderLimitWarning()}
</div>
<div className="chart flex column align-center box grow">
<PieChart
data={memoryData}
title={i18n._(t`Memory`)}
legendColors={["#c93dce", "#4caf50", "#3d90ce", defaultColor]}
/>
{memoryLimitsOverload && renderLimitWarning()}
</div>
<div className="chart flex column align-center box grow">
<PieChart
data={podsData}
title={i18n._(t`Pods`)}
legendColors={["#4caf50", defaultColor]}
/>
</div>
</div>
);
}
const renderContent = () => {
const { masterNodes, workerNodes } = nodesStore;
const { metricNodeRole, metricsLoaded } = clusterStore;
const nodes = metricNodeRole === MetricNodeRole.MASTER ? masterNodes : workerNodes;
if (!nodes.length) {
return (
<div className="empty flex column box grow align-center justify-center">
<Icon material="info"/>
<Trans>No Nodes Available.</Trans>
</div>
);
}
if (!metricsLoaded) {
return (
<div className="flex justify-center align-center box grow empty">
<Spinner/>
</div>
);
}
const { memoryCapacity, cpuCapacity, podCapacity } = getMetricLastPoints(clusterStore.metrics);
if (!memoryCapacity || !cpuCapacity || !podCapacity) {
return <ClusterNoMetrics className="empty"/>;
}
return renderCharts();
}
return (
<div className="ClusterPieCharts flex">
{renderContent()}
</div>
);
})
@@ -0,0 +1,8 @@
import { RouteProps } from "react-router";
import { buildURL } from "../../navigation";
export const clusterRoute: RouteProps = {
path: "/cluster"
}
export const clusterURL = buildURL(clusterRoute.path)
@@ -0,0 +1,24 @@
.Cluster {
$gridGap: $margin * 2;
position: relative;
height: 100%;
min-height: 650px;
display: grid;
grid-gap: $gridGap;
grid-template-columns: repeat(auto-fit, minmax(500px, 1fr));
grid-template-rows: 1fr 1fr;
@include media(">1600px") {
grid-template-columns: 1fr 1fr;
}
> *:not(.Spinner) {
padding: $gridGap;
background: $contentColor;
> .SubHeader {
padding-top: 0;
}
}
}
@@ -0,0 +1,110 @@
import { observable, reaction, when } from "mobx";
import { KubeObjectStore } from "../../kube-object.store";
import { Cluster, clusterApi, IClusterMetrics } from "../../api/endpoints";
import { autobind, createStorage } from "../../utils";
import { IMetricsReqParams, normalizeMetrics } from "../../api/endpoints/metrics.api";
import { nodesStore } from "../+nodes/nodes.store";
import { apiManager } from "../../api/api-manager";
export enum MetricType {
MEMORY = "memory",
CPU = "cpu"
}
export enum MetricNodeRole {
MASTER = "master",
WORKER = "worker"
}
@autobind()
export class ClusterStore extends KubeObjectStore<Cluster> {
api = clusterApi
@observable metrics: Partial<IClusterMetrics> = {};
@observable liveMetrics: Partial<IClusterMetrics> = {};
@observable metricsLoaded = false;
@observable metricType: MetricType;
@observable metricNodeRole: MetricNodeRole;
constructor() {
super();
this.resetMetrics();
// sync user setting with local storage
const storage = createStorage("cluster_metric_switchers", {});
Object.assign(this, storage.get());
reaction(() => {
const { metricType, metricNodeRole } = this;
return { metricType, metricNodeRole }
},
settings => storage.set(settings)
);
// auto-update metrics
reaction(() => this.metricNodeRole, () => {
if (!this.metricsLoaded) return;
this.metrics = {};
this.liveMetrics = {};
this.metricsLoaded = false;
this.getAllMetrics();
});
// check which node type to select
reaction(() => nodesStore.items.length, () => {
const { masterNodes, workerNodes } = nodesStore;
if (!masterNodes.length) this.metricNodeRole = MetricNodeRole.WORKER;
if (!workerNodes.length) this.metricNodeRole = MetricNodeRole.MASTER;
});
}
async loadMetrics(params?: IMetricsReqParams) {
await when(() => nodesStore.isLoaded);
const { masterNodes, workerNodes } = nodesStore;
const nodes = this.metricNodeRole === MetricNodeRole.MASTER && masterNodes.length ? masterNodes : workerNodes;
return clusterApi.getMetrics(nodes.map(node => node.getName()), params);
}
async getAllMetrics() {
await this.getMetrics();
await this.getLiveMetrics();
this.metricsLoaded = true;
}
async getMetrics() {
this.metrics = await this.loadMetrics();
}
async getLiveMetrics() {
const step = 3;
const range = 15;
const end = Date.now() / 1000;
const start = end - range;
this.liveMetrics = await this.loadMetrics({ start, end, step, range });
}
getMetricsValues(source: Partial<IClusterMetrics>) {
const metrics =
this.metricType === MetricType.CPU ? source.cpuUsage :
this.metricType === MetricType.MEMORY ? source.memoryUsage
: null;
if (!metrics) {
return [];
}
return normalizeMetrics(metrics).data.result[0].values;
}
resetMetrics() {
this.metrics = {};
this.metricsLoaded = false;
this.metricType = MetricType.CPU;
this.metricNodeRole = MetricNodeRole.WORKER;
}
reset() {
super.reset();
this.resetMetrics();
}
}
export const clusterStore = new ClusterStore();
apiManager.registerStore(clusterApi, clusterStore);
@@ -0,0 +1,69 @@
import "./cluster.scss"
import React from "react";
import { computed, reaction } from "mobx";
import { disposeOnUnmount, observer } from "mobx-react";
import { MainLayout } from "../layout/main-layout";
import { ClusterIssues } from "./cluster-issues";
import { Spinner } from "../spinner";
import { cssNames, interval, isElectron } from "../../utils";
import { ClusterPieCharts } from "./cluster-pie-charts";
import { ClusterMetrics } from "./cluster-metrics";
import { nodesStore } from "../+nodes/nodes.store";
import { podsStore } from "../+workloads-pods/pods.store";
import { clusterStore } from "./cluster.store";
import { eventStore } from "../+events/event.store";
@observer
export class Cluster extends React.Component {
private watchers = [
interval(60, () => clusterStore.getMetrics()),
interval(20, () => eventStore.loadAll())
];
private dependentStores = [nodesStore, podsStore];
async componentDidMount() {
const { dependentStores } = this;
this.watchers.forEach(watcher => watcher.start(true));
await Promise.all([
...dependentStores.map(store => store.loadAll()),
clusterStore.getAllMetrics()
]);
disposeOnUnmount(this, [
...dependentStores.map(store => store.subscribe()),
() => this.watchers.forEach(watcher => watcher.stop()),
reaction(
() => clusterStore.metricNodeRole,
() => this.watchers.forEach(watcher => watcher.restart())
)
])
}
@computed get isLoaded() {
return (
nodesStore.isLoaded &&
podsStore.isLoaded
)
}
render() {
const { isLoaded } = this;
return (
<MainLayout>
<div className="Cluster">
{!isLoaded && <Spinner center/>}
{isLoaded && (
<>
<ClusterMetrics/>
<ClusterPieCharts/>
<ClusterIssues className={cssNames({ wide: isElectron })}/>
</>
)}
</div>
</MainLayout>
)
}
}
@@ -0,0 +1,2 @@
export * from "./cluster.routes"
@@ -0,0 +1,14 @@
$hpa-status-colors: (
abletoscale: $colorOk,
scalingactive: $colorInfo,
scalinglimited: $colorSoftError,
);
@mixin hpa-status-bgc {
@each $status, $color in $hpa-status-colors {
&.#{$status} {
background: $color;
color: white;
}
}
}
@@ -0,0 +1,17 @@
@import "autoscaler.mixins";
.HpaDetails {
.Badge {
@include hpa-status-bgc;
}
.metrics .Table {
.TableCell {
word-break: break-word;
&.name {
flex-grow: 2;
}
}
}
}
@@ -0,0 +1,133 @@
import "./hpa-details.scss";
import React from "react";
import { observer } from "mobx-react";
import { Link } from "react-router-dom";
import { DrawerItem, DrawerTitle } from "../drawer";
import { Badge } from "../badge";
import { KubeObjectDetailsProps } from "../kube-object";
import { cssNames } from "../../utils";
import { HorizontalPodAutoscaler, hpaApi, HpaMetricType, IHpaMetric } from "../../api/endpoints/hpa.api";
import { KubeEventDetails } from "../+events/kube-event-details";
import { Trans } from "@lingui/macro";
import { Table, TableCell, TableHead, TableRow } from "../table";
import { getDetailsUrl } from "../../navigation";
import { lookupApiLink } from "../../api/kube-api";
import { apiManager } from "../../api/api-manager";
import { KubeObjectMeta } from "../kube-object/kube-object-meta";
interface Props extends KubeObjectDetailsProps<HorizontalPodAutoscaler> {
}
@observer
export class HpaDetails extends React.Component<Props> {
renderMetrics() {
const { object: hpa } = this.props;
const renderName = (metric: IHpaMetric) => {
switch (metric.type) {
case HpaMetricType.Resource:
const addition = metric.resource.targetAverageUtilization ? <Trans>(as a percentage of request)</Trans> : "";
return <Trans>Resource {metric.resource.name} on Pods {addition}</Trans>;
case HpaMetricType.Pods:
return <Trans>{metric.pods.metricName} on Pods</Trans>;
case HpaMetricType.Object:
const { target } = metric.object;
const { kind, name } = target;
const objectUrl = getDetailsUrl(lookupApiLink(target, hpa));
return (
<Trans>
{metric.object.metricName} on{" "}
<Link to={objectUrl}>{kind}/{name}</Link>
</Trans>
);
case HpaMetricType.External:
return (
<Trans>
{metric.external.metricName} on{" "}
{JSON.stringify(metric.external.selector)}
</Trans>
);
}
}
return (
<Table>
<TableHead>
<TableCell className="name"><Trans>Name</Trans></TableCell>
<TableCell className="metrics"><Trans>Current / Target</Trans></TableCell>
</TableHead>
{
hpa.getMetrics().map((metric, index) => {
const name = renderName(metric);
const values = hpa.getMetricValues(metric);
return (
<TableRow key={index}>
<TableCell className="name">{name}</TableCell>
<TableCell className="metrics">{values}</TableCell>
</TableRow>
)
})
}
</Table>
);
}
render() {
const { object: hpa } = this.props;
if (!hpa) return;
const { scaleTargetRef } = hpa.spec;
return (
<div className="HpaDetails">
<KubeObjectMeta object={hpa}/>
<DrawerItem name={<Trans>Reference</Trans>}>
{scaleTargetRef && (
<Link to={getDetailsUrl(lookupApiLink(scaleTargetRef, hpa))}>
{scaleTargetRef.kind}/{scaleTargetRef.name}
</Link>
)}
</DrawerItem>
<DrawerItem name={<Trans>Min Pods</Trans>}>
{hpa.getMinPods()}
</DrawerItem>
<DrawerItem name={<Trans>Max Pods</Trans>}>
{hpa.getMaxPods()}
</DrawerItem>
<DrawerItem name={<Trans>Replicas</Trans>}>
{hpa.getReplicas()}
</DrawerItem>
<DrawerItem name={<Trans>Status</Trans>} labelsOnly>
{hpa.getConditions().map(({ type, tooltip, isReady }) => {
if (!isReady) return null;
return (
<Badge
key={type}
label={type}
tooltip={tooltip}
className={cssNames({ [type.toLowerCase()]: isReady })}
/>
)
})}
</DrawerItem>
<DrawerTitle title="Metrics"/>
<div className="metrics">
{this.renderMetrics()}
</div>
<KubeEventDetails object={hpa}/>
</div>
);
}
}
apiManager.registerViews(hpaApi, {
Details: HpaDetails,
});
@@ -0,0 +1,11 @@
import { RouteProps } from "react-router";
import { buildURL } from "../../navigation";
export const hpaRoute: RouteProps = {
path: "/hpa"
}
export interface IHpaRouteParams {
}
export const hpaURL = buildURL<IHpaRouteParams>(hpaRoute.path)
@@ -0,0 +1,26 @@
@import "./autoscaler.mixins";
.HorizontalPodAutoscalers {
.TableCell {
&.name {
flex: 1.5;
}
&.metrics {
flex: 1.5;
}
&.status {
flex: 1.5;
@include table-cell-labels-offsets;
.Badge {
@include hpa-status-bgc;
}
}
&.age {
flex: .5;
}
}
}
@@ -0,0 +1,12 @@
import { autobind } from "../../utils";
import { KubeObjectStore } from "../../kube-object.store";
import { HorizontalPodAutoscaler, hpaApi } from "../../api/endpoints/hpa.api";
import { apiManager } from "../../api/api-manager";
@autobind()
export class HPAStore extends KubeObjectStore<HorizontalPodAutoscaler> {
api = hpaApi
}
export const hpaStore = new HPAStore();
apiManager.registerStore(hpaApi, hpaStore);
@@ -0,0 +1,99 @@
import "./hpa.scss"
import * as React from "react";
import { observer } from "mobx-react";
import { RouteComponentProps } from "react-router";
import { Trans } from "@lingui/macro";
import { KubeObjectMenu, KubeObjectMenuProps } from "../kube-object/kube-object-menu";
import { KubeObjectListLayout } from "../kube-object";
import { IHpaRouteParams } from "./hpa.route";
import { HorizontalPodAutoscaler, hpaApi } from "../../api/endpoints/hpa.api";
import { hpaStore } from "./hpa.store";
import { Badge } from "../badge";
import { cssNames } from "../../utils";
import { apiManager } from "../../api/api-manager";
enum sortBy {
name = "name",
namespace = "namespace",
minPods = "min-pods",
maxPods = "max-pods",
replicas = "replicas",
age = "age",
}
interface Props extends RouteComponentProps<IHpaRouteParams> {
}
@observer
export class HorizontalPodAutoscalers extends React.Component<Props> {
getTargets(hpa: HorizontalPodAutoscaler) {
const metrics = hpa.getMetrics();
const metricsRemainCount = metrics.length - 1;
const metricsRemain = metrics.length > 1 ? <Trans>{metricsRemainCount} more...</Trans> : null;
const metricValues = hpa.getMetricValues(metrics[0]);
return <p>{metricValues} {metricsRemain && "+"}{metricsRemain}</p>;
}
render() {
return (
<KubeObjectListLayout
className="HorizontalPodAutoscalers" store={hpaStore}
sortingCallbacks={{
[sortBy.name]: (item: HorizontalPodAutoscaler) => item.getName(),
[sortBy.namespace]: (item: HorizontalPodAutoscaler) => item.getNs(),
[sortBy.minPods]: (item: HorizontalPodAutoscaler) => item.getMinPods(),
[sortBy.maxPods]: (item: HorizontalPodAutoscaler) => item.getMaxPods(),
[sortBy.replicas]: (item: HorizontalPodAutoscaler) => item.getReplicas()
}}
searchFilters={[
(item: HorizontalPodAutoscaler) => item.getSearchFields()
]}
renderHeaderTitle={<Trans>Horizontal Pod Autoscalers</Trans>}
renderTableHeader={[
{ title: <Trans>Name</Trans>, className: "name", sortBy: sortBy.name },
{ title: <Trans>Namespace</Trans>, className: "namespace", sortBy: sortBy.namespace },
{ title: <Trans>Metrics</Trans>, className: "metrics" },
{ title: <Trans>Min Pods</Trans>, className: "min-pods", sortBy: sortBy.minPods },
{ title: <Trans>Max Pods</Trans>, className: "max-pods", sortBy: sortBy.maxPods },
{ title: <Trans>Replicas</Trans>, className: "replicas", sortBy: sortBy.replicas },
{ title: <Trans>Age</Trans>, className: "age", sortBy: sortBy.age },
{ title: <Trans>Status</Trans>, className: "status" },
]}
renderTableContents={(hpa: HorizontalPodAutoscaler) => [
hpa.getName(),
hpa.getNs(),
this.getTargets(hpa),
hpa.getMinPods(),
hpa.getMaxPods(),
hpa.getReplicas(),
hpa.getAge(),
hpa.getConditions().map(({ type, tooltip, isReady }) => {
if (!isReady) return null;
return (
<Badge
key={type}
label={type}
tooltip={tooltip}
className={cssNames(type.toLowerCase())}
/>
)
})
]}
renderItemMenu={(item: HorizontalPodAutoscaler) => {
return <HpaMenu object={item}/>
}}
/>
);
}
}
export function HpaMenu(props: KubeObjectMenuProps<HorizontalPodAutoscaler>) {
return (
<KubeObjectMenu {...props}/>
)
}
apiManager.registerViews(hpaApi, {
Menu: HpaMenu,
})
@@ -0,0 +1,3 @@
export * from "./hpa"
export * from "./hpa-details"
export * from "./hpa.route"
@@ -0,0 +1,15 @@
.ConfigMapDetails {
.data {
margin-bottom: $margin * 2;
.name {
color: $textColorSecondary;
font-weight: $font-weight-bold;
padding-bottom: $padding / 2;
}
}
.save-btn {
margin-top: $margin;
}
}
@@ -0,0 +1,99 @@
import "./config-map-details.scss";
import * as React from "react";
import { autorun, observable } from "mobx";
import { disposeOnUnmount, observer } from "mobx-react";
import { Trans } from "@lingui/macro";
import { DrawerTitle } from "../drawer";
import { Notifications } from "../notifications";
import { Input } from "../input";
import { Button } from "../button";
import { KubeEventDetails } from "../+events/kube-event-details";
import { configMapsStore } from "./config-maps.store";
import { KubeObjectDetailsProps } from "../kube-object";
import { ConfigMap, configMapApi } from "../../api/endpoints";
import { apiManager } from "../../api/api-manager";
import { KubeObjectMeta } from "../kube-object/kube-object-meta";
interface Props extends KubeObjectDetailsProps<ConfigMap> {
}
@observer
export class ConfigMapDetails extends React.Component<Props> {
@observable isSaving = false;
@observable data = observable.map();
async componentDidMount() {
disposeOnUnmount(this, [
autorun(() => {
const { object: configMap } = this.props;
if (configMap) {
this.data.replace(configMap.data); // refresh
}
})
])
}
save = async () => {
const { object: configMap } = this.props;
try {
this.isSaving = true;
await configMapsStore.update(configMap, { ...configMap, data: this.data.toJSON() });
Notifications.ok(
<p>
<Trans>ConfigMap <b>{configMap.getName()}</b> successfully updated.</Trans>
</p>
);
} finally {
this.isSaving = false;
}
}
render() {
const { object: configMap } = this.props;
if (!configMap) return null;
const data = Object.entries(this.data.toJSON());
return (
<div className="ConfigMapDetails">
<KubeObjectMeta object={configMap}/>
{
data.length > 0 && (
<>
<DrawerTitle title={<Trans>Data</Trans>}/>
{
data.map(([name, value]) => {
return (
<div key={name} className="data">
<div className="name">{name}</div>
<div className="flex gaps align-flex-start">
<Input
multiLine
theme="round-black"
className="box grow"
value={value}
onChange={v => this.data.set(name, v)}
/>
</div>
</div>
)
})
}
<Button
primary
label={<Trans>Save</Trans>} waiting={this.isSaving}
className="save-btn"
onClick={this.save}
/>
</>
)
}
<KubeEventDetails object={configMap}/>
</div>
);
}
}
apiManager.registerViews(configMapApi, {
Details: ConfigMapDetails
})
@@ -0,0 +1,11 @@
import { RouteProps } from "react-router";
import { buildURL } from "../../navigation";
export const configMapsRoute: RouteProps = {
path: "/configmaps"
}
export interface IConfigMapsRouteParams {
}
export const configMapsURL = buildURL<IConfigMapsRouteParams>(configMapsRoute.path);
Loaded 100 of 797 files, more files were not shown because too many files have changed in this diff. Show more