mirror of
https://github.com/lensapp/lens.git
synced 2025-05-20 05:10:56 +00:00
Lens app source code (#119)
Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com>
This commit is contained in:
1 parent
936cbd53d4
commit
1d0815abd2
797 files changed
+73714
-215
No files matched your search
@@ -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,
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in new issue
Block a user