refactor: Refactors the Kubernetes plugin in line with ADR11 (#19446)

* refactor: k8s plugins

Signed-off-by: Matthew Clarke <mclarke@spotify.com>

* chore: move more files

Signed-off-by: Matthew Clarke <mclarke@spotify.com>

* chore: fix

Signed-off-by: Matthew Clarke <mclarke@spotify.com>

* fix: tsc

Signed-off-by: Matthew Clarke <mclarke@spotify.com>

* chore: changeset

Signed-off-by: Matthew Clarke <mclarke@spotify.com>

* chore: api report

Signed-off-by: Matthew Clarke <mclarke@spotify.com>

* fix: catalog

Signed-off-by: Matthew Clarke <mclarke@spotify.com>

* fix: types

Signed-off-by: Matthew Clarke <mclarke@spotify.com>

* fix: update lock

Signed-off-by: Matthew Clarke <mclarke@spotify.com>

---------

Signed-off-by: Matthew Clarke <mclarke@spotify.com>
This commit is contained in:
Matthew Clarke
2023-09-26 18:40:10 -04:00
committed by GitHub
parent ec13fcc4a7
commit 2d8151061c
215 changed files with 17249 additions and 992 deletions
@@ -0,0 +1,446 @@
{
"cronJobs": [
{
"metadata": {
"name": "dice-roller-cronjob",
"namespace": "default",
"uid": "9ea073bc-7a4b-4b99-8321-0305bce85568",
"resourceVersion": "1362732552",
"creationTimestamp": "2021-07-16T22:39:58Z",
"labels": {
"backstage.io/kubernetes-id": "dice-roller"
},
"annotations": {}
},
"spec": {
"schedule": "30 5 * * *",
"startingDeadlineSeconds": 1800,
"concurrencyPolicy": "Forbid",
"suspend": false,
"jobTemplate": {
"metadata": { "creationTimestamp": null },
"spec": {
"backoffLimit": 2,
"template": {
"metadata": {
"creationTimestamp": null,
"labels": {
"backstage.io/kubernetes-id": "dice-roller"
},
"annotations": {}
},
"spec": {
"containers": [
{
"name": "busybox",
"image": "busybox",
"resources": {},
"terminationMessagePath": "/dev/termination-log",
"terminationMessagePolicy": "File",
"imagePullPolicy": "IfNotPresent",
"command": ["/bin/sh", "-c", "date; echo Rolling a die!"]
}
],
"restartPolicy": "Never",
"terminationGracePeriodSeconds": 30,
"dnsPolicy": "ClusterFirst",
"securityContext": {},
"schedulerName": "default-scheduler"
}
}
}
},
"successfulJobsHistoryLimit": 2,
"failedJobsHistoryLimit": 2
},
"status": {
"active": [
{
"kind": "Job",
"namespace": "default",
"name": "dice-roller-cronjob-1637028600",
"uid": "32be1b89-5b53-45b2-aa84-277e75214f61",
"apiVersion": "batch/v1",
"resourceVersion": "1361174163"
}
],
"lastScheduleTime": "2021-11-16T02:10:00Z"
}
}
],
"jobs": [
{
"metadata": {
"name": "dice-roller-cronjob-1637025000",
"namespace": "default",
"uid": "69d5d242-a9a4-47b8-b9c7-c536ae8f151a",
"resourceVersion": "1361029181",
"creationTimestamp": "2021-11-16T01:10:24Z",
"labels": {
"backstage.io/kubernetes-id": "dice-roller"
},
"ownerReferences": [
{
"apiVersion": "batch/v1",
"kind": "CronJob",
"name": "dice-roller-cronjob",
"uid": "9ea073bc-7a4b-4b99-8321-0305bce85568",
"controller": true,
"blockOwnerDeletion": true
}
]
},
"spec": {
"parallelism": 1,
"completions": 1,
"backoffLimit": 2,
"selector": {
"matchLabels": {
"controller-uid": "69d5d242-a9a4-47b8-b9c7-c536ae8f151a"
}
},
"template": {
"metadata": {
"creationTimestamp": null,
"labels": {
"backstage.io/kubernetes-id": "dice-roller",
"controller-uid": "69d5d242-a9a4-47b8-b9c7-c536ae8f151a",
"job-name": "dice-roller-cronjob-1637025000"
},
"annotations": {}
},
"spec": {
"containers": [
{
"command": ["/bin/sh", "-c", "date; echo Rolling a die!"],
"image": "busybox",
"imagePullPolicy": "IfNotPresent",
"name": "busybox",
"resources": {},
"terminationMessagePath": "/dev/termination-log",
"terminationMessagePolicy": "File"
}
],
"restartPolicy": "Never",
"terminationGracePeriodSeconds": 30,
"dnsPolicy": "ClusterFirst",
"securityContext": {},
"schedulerName": "default-scheduler"
}
}
},
"status": {
"conditions": [
{
"type": "Complete",
"status": "True",
"lastProbeTime": "2021-11-16T01:11:31Z",
"lastTransitionTime": "2021-11-16T01:11:31Z"
}
],
"startTime": "2021-11-16T01:10:24Z",
"completionTime": "2021-11-16T01:11:31Z",
"succeeded": 1
}
},
{
"metadata": {
"name": "dice-roller-cronjob-1637028600",
"namespace": "default",
"uid": "32be1b89-5b53-45b2-aa84-277e75214f61",
"resourceVersion": "1361174166",
"creationTimestamp": "2021-11-16T02:10:22Z",
"labels": {
"backstage.io/kubernetes-id": "dice-roller"
},
"ownerReferences": [
{
"apiVersion": "batch/v1",
"kind": "CronJob",
"name": "dice-roller-cronjob",
"uid": "9ea073bc-7a4b-4b99-8321-0305bce85568",
"controller": true,
"blockOwnerDeletion": true
}
]
},
"spec": {
"parallelism": 1,
"completions": 1,
"backoffLimit": 2,
"selector": {
"matchLabels": {
"controller-uid": "32be1b89-5b53-45b2-aa84-277e75214f61"
}
},
"template": {
"metadata": {
"creationTimestamp": null,
"labels": {
"backstage.io/kubernetes-id": "dice-roller",
"controller-uid": "32be1b89-5b53-45b2-aa84-277e75214f61",
"job-name": "dice-roller-cronjob-1637028600"
},
"annotations": {}
},
"spec": {
"containers": [
{
"command": ["/bin/sh", "-c", "date; echo Rolling a die!"],
"image": "busybox",
"imagePullPolicy": "IfNotPresent",
"name": "busybox",
"resources": {},
"terminationMessagePath": "/dev/termination-log",
"terminationMessagePolicy": "File"
}
],
"restartPolicy": "Never",
"terminationGracePeriodSeconds": 30,
"dnsPolicy": "ClusterFirst",
"securityContext": {},
"schedulerName": "default-scheduler"
}
}
},
"status": { "startTime": "2021-11-16T02:10:22Z", "active": 1 }
}
],
"pods": [
{
"metadata": {
"name": "dice-roller-cronjob-1637025000-gstc4",
"generateName": "dice-roller-cronjob-1637025000-",
"namespace": "default",
"uid": "3b0f2b65-5ae2-441a-beda-bdc92bcafaf0",
"resourceVersion": "1361029179",
"creationTimestamp": "2021-11-16T01:10:24Z",
"labels": {
"backstage.io/kubernetes-id": "dice-roller",
"controller-uid": "69d5d242-a9a4-47b8-b9c7-c536ae8f151a",
"job-name": "dice-roller-cronjob-1637025000"
},
"annotations": {},
"ownerReferences": [
{
"apiVersion": "batch/v1",
"kind": "Job",
"name": "dice-roller-cronjob-1637025000",
"uid": "69d5d242-a9a4-47b8-b9c7-c536ae8f151a",
"controller": true,
"blockOwnerDeletion": true
}
]
},
"spec": {
"volumes": [],
"containers": [
{
"command": ["/bin/sh", "-c", "date; echo Rolling a die!"],
"image": "busybox",
"imagePullPolicy": "IfNotPresent",
"name": "busybox",
"resources": {},
"terminationMessagePath": "/dev/termination-log",
"terminationMessagePolicy": "File"
}
],
"restartPolicy": "Never",
"terminationGracePeriodSeconds": 30,
"dnsPolicy": "ClusterFirst",
"serviceAccountName": "default",
"serviceAccount": "default",
"nodeName": "minikube",
"securityContext": {},
"schedulerName": "default-scheduler",
"tolerations": [
{
"key": "node.kubernetes.io/not-ready",
"operator": "Exists",
"effect": "NoExecute",
"tolerationSeconds": 300
},
{
"key": "node.kubernetes.io/unreachable",
"operator": "Exists",
"effect": "NoExecute",
"tolerationSeconds": 300
}
],
"priority": 0,
"enableServiceLinks": true
},
"status": {
"phase": "Succeeded",
"conditions": [
{
"type": "Initialized",
"status": "True",
"lastProbeTime": null,
"lastTransitionTime": "2021-11-16T01:10:27Z",
"reason": "PodCompleted"
},
{
"type": "Ready",
"status": "False",
"lastProbeTime": null,
"lastTransitionTime": "2021-11-16T01:11:31Z",
"reason": "PodCompleted"
},
{
"type": "ContainersReady",
"status": "False",
"lastProbeTime": null,
"lastTransitionTime": "2021-11-16T01:11:31Z",
"reason": "PodCompleted"
},
{
"type": "PodScheduled",
"status": "True",
"lastProbeTime": null,
"lastTransitionTime": "2021-11-16T01:10:24Z"
}
],
"hostIP": "192.168.49.2",
"podIP": "172.17.0.25",
"podIPs": [{ "ip": "172.17.0.25" }],
"startTime": "2021-11-16T01:10:24Z",
"containerStatuses": [
{
"name": "busybox",
"state": {
"terminated": {
"exitCode": 0,
"reason": "Completed",
"startedAt": "2021-11-16T01:10:31Z",
"finishedAt": "2021-11-16T01:11:30Z",
"containerID": "docker://1062343e1bb3625e36717bc15617c1bbca976366c3c9dd87035c71f33d015615"
}
},
"lastState": {},
"ready": false,
"restartCount": 0,
"image": "busybox:latest/node",
"imageID": "docker-pullable://busybox@sha256:e7157b6d7ebbe2cce5eaa8cfe8aa4fa82d173999b9f90a9ec42e57323546c353",
"containerID": "docker://1062343e1bb3625e36717bc15617c1bbca976366c3c9dd87035c71f33d015615",
"started": false
}
],
"qosClass": "Burstable"
}
},
{
"metadata": {
"name": "dice-roller-cronjob-1637028600-p4mlc",
"generateName": "dice-roller-cronjob-1637028600-",
"namespace": "default",
"uid": "acddd5d2-ac7f-473b-a9d8-17a89f99ac39",
"resourceVersion": "1361174579",
"creationTimestamp": "2021-11-16T02:10:22Z",
"labels": {
"backstage.io/kubernetes-id": "dice-roller",
"controller-uid": "32be1b89-5b53-45b2-aa84-277e75214f61",
"job-name": "dice-roller-cronjob-1637028600"
},
"annotations": {},
"ownerReferences": [
{
"apiVersion": "batch/v1",
"kind": "Job",
"name": "dice-roller-cronjob-1637028600",
"uid": "32be1b89-5b53-45b2-aa84-277e75214f61",
"controller": true,
"blockOwnerDeletion": true
}
]
},
"spec": {
"volumes": [],
"containers": [
{
"command": ["/bin/sh", "-c", "date; echo Rolling a die!"],
"image": "busybox",
"imagePullPolicy": "IfNotPresent",
"name": "busybox",
"resources": {},
"terminationMessagePath": "/dev/termination-log",
"terminationMessagePolicy": "File"
}
],
"restartPolicy": "Never",
"terminationGracePeriodSeconds": 30,
"dnsPolicy": "ClusterFirst",
"serviceAccountName": "default",
"serviceAccount": "default",
"nodeName": "minikube",
"securityContext": {},
"schedulerName": "default-scheduler",
"tolerations": [
{
"key": "node.kubernetes.io/not-ready",
"operator": "Exists",
"effect": "NoExecute",
"tolerationSeconds": 300
},
{
"key": "node.kubernetes.io/unreachable",
"operator": "Exists",
"effect": "NoExecute",
"tolerationSeconds": 300
}
],
"priority": 0,
"enableServiceLinks": true
},
"status": {
"phase": "Running",
"conditions": [
{
"type": "Initialized",
"status": "True",
"lastProbeTime": null,
"lastTransitionTime": "2021-11-16T02:10:25Z"
},
{
"type": "Ready",
"status": "True",
"lastProbeTime": null,
"lastTransitionTime": "2021-11-16T02:10:35Z"
},
{
"type": "ContainersReady",
"status": "True",
"lastProbeTime": null,
"lastTransitionTime": "2021-11-16T02:10:35Z"
},
{
"type": "PodScheduled",
"status": "True",
"lastProbeTime": null,
"lastTransitionTime": "2021-11-16T02:10:22Z"
}
],
"hostIP": "192.168.49.2",
"podIP": "172.17.0.25",
"podIPs": [{ "ip": "172.17.0.25" }],
"startTime": "2021-11-16T02:10:22Z",
"containerStatuses": [
{
"name": "busybox",
"state": {
"running": { "startedAt": "2021-11-16T02:10:31Z" }
},
"lastState": {},
"ready": true,
"restartCount": 0,
"image": "busybox:latest",
"imageID": "docker-pullable://busybox@sha256:e7157b6d7ebbe2cce5eaa8cfe8aa4fa82d173999b9f90a9ec42e57323546c353",
"containerID": "docker://1062343e1bb3625e36717bc15617c1bbca976366c3c9dd87035c71f33d015615",
"started": true
}
],
"qosClass": "Burstable"
}
}
]
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,385 @@
{
"cronJobs": [
{
"metadata": {
"name": "dice-roller-cronjob",
"namespace": "default",
"uid": "9ea073bc-7a4b-4b99-8321-0305bce85568",
"resourceVersion": "1362732552",
"creationTimestamp": "2021-07-16T22:39:58Z",
"labels": {
"backstage.io/kubernetes-id": "dice-roller"
},
"annotations": {}
},
"spec": {
"schedule": "* */2 * * *",
"startingDeadlineSeconds": 1800,
"concurrencyPolicy": "Forbid",
"suspend": true,
"jobTemplate": {
"metadata": { "creationTimestamp": null },
"spec": {
"backoffLimit": 2,
"template": {
"metadata": {
"creationTimestamp": null,
"labels": {
"backstage.io/kubernetes-id": "dice-roller"
},
"annotations": {}
},
"spec": {
"containers": [
{
"name": "busybox",
"image": "busybox",
"resources": {},
"terminationMessagePath": "/dev/termination-log",
"terminationMessagePolicy": "File",
"imagePullPolicy": "IfNotPresent",
"command": ["/bin/sh", "-c", "date; echo Rolling a die!"]
}
],
"restartPolicy": "Never",
"terminationGracePeriodSeconds": 30,
"dnsPolicy": "ClusterFirst",
"securityContext": {},
"schedulerName": "default-scheduler"
}
}
}
},
"successfulJobsHistoryLimit": 2,
"failedJobsHistoryLimit": 2
},
"status": {
"lastScheduleTime": "2021-11-16T02:10:00Z"
}
}
],
"jobs": [
{
"metadata": {
"name": "dice-roller-cronjob-1637025000",
"namespace": "default",
"uid": "69d5d242-a9a4-47b8-b9c7-c536ae8f151a",
"resourceVersion": "1361029181",
"creationTimestamp": "2021-11-16T01:10:24Z",
"labels": {
"backstage.io/kubernetes-id": "dice-roller"
},
"ownerReferences": [
{
"apiVersion": "batch/v1",
"kind": "CronJob",
"name": "dice-roller-cronjob",
"uid": "9ea073bc-7a4b-4b99-8321-0305bce85568",
"controller": true,
"blockOwnerDeletion": true
}
]
},
"spec": {
"parallelism": 1,
"completions": 1,
"backoffLimit": 2,
"selector": {
"matchLabels": {
"controller-uid": "69d5d242-a9a4-47b8-b9c7-c536ae8f151a"
}
},
"template": {
"metadata": {
"creationTimestamp": null,
"labels": {
"backstage.io/kubernetes-id": "dice-roller",
"controller-uid": "69d5d242-a9a4-47b8-b9c7-c536ae8f151a",
"job-name": "dice-roller-cronjob-1637025000"
},
"annotations": {}
},
"spec": {
"containers": [
{
"command": ["/bin/sh", "-c", "date; echo Rolling a die!"],
"image": "busybox",
"imagePullPolicy": "IfNotPresent",
"name": "busybox",
"resources": {},
"terminationMessagePath": "/dev/termination-log",
"terminationMessagePolicy": "File"
}
],
"restartPolicy": "Never",
"terminationGracePeriodSeconds": 30,
"dnsPolicy": "ClusterFirst",
"securityContext": {},
"schedulerName": "default-scheduler"
}
}
},
"status": {
"conditions": [
{
"type": "Failed",
"status": "True",
"reason": "BackoffLimitExceeded",
"lastProbeTime": "2021-11-16T01:11:31Z",
"lastTransitionTime": "2021-11-16T01:11:31Z"
}
],
"startTime": "2021-11-16T01:10:24Z",
"failed": 2
}
}
],
"pods": [
{
"metadata": {
"name": "dice-roller-cronjob-1637025000-gstc4",
"generateName": "dice-roller-cronjob-1637025000-",
"namespace": "default",
"uid": "3b0f2b65-5ae2-441a-beda-bdc92bcafaf0",
"resourceVersion": "1361029179",
"creationTimestamp": "2021-11-16T01:10:24Z",
"labels": {
"backstage.io/kubernetes-id": "dice-roller",
"controller-uid": "69d5d242-a9a4-47b8-b9c7-c536ae8f151a",
"job-name": "dice-roller-cronjob-1637025000"
},
"annotations": {},
"ownerReferences": [
{
"apiVersion": "batch/v1",
"kind": "Job",
"name": "dice-roller-cronjob-1637025000",
"uid": "69d5d242-a9a4-47b8-b9c7-c536ae8f151a",
"controller": true,
"blockOwnerDeletion": true
}
]
},
"spec": {
"volumes": [],
"containers": [
{
"command": ["/bin/sh", "-c", "date; echo Rolling a die!"],
"image": "busybox",
"imagePullPolicy": "IfNotPresent",
"name": "busybox",
"resources": {},
"terminationMessagePath": "/dev/termination-log",
"terminationMessagePolicy": "File"
}
],
"restartPolicy": "Never",
"terminationGracePeriodSeconds": 30,
"dnsPolicy": "ClusterFirst",
"serviceAccountName": "default",
"serviceAccount": "default",
"nodeName": "minikube",
"securityContext": {},
"schedulerName": "default-scheduler",
"tolerations": [
{
"key": "node.kubernetes.io/not-ready",
"operator": "Exists",
"effect": "NoExecute",
"tolerationSeconds": 300
},
{
"key": "node.kubernetes.io/unreachable",
"operator": "Exists",
"effect": "NoExecute",
"tolerationSeconds": 300
}
],
"priority": 0,
"enableServiceLinks": true
},
"status": {
"phase": "Failed",
"conditions": [
{
"lastProbeTime": null,
"lastTransitionTime": "2021-11-18T19:10:13Z",
"status": "True",
"type": "Initialized"
},
{
"lastProbeTime": null,
"lastTransitionTime": "2021-11-18T19:11:02Z",
"message": "containers with unready status: [busybox]",
"reason": "ContainersNotReady",
"status": "False",
"type": "Ready"
},
{
"lastProbeTime": null,
"lastTransitionTime": "2021-11-18T19:11:02Z",
"message": "containers with unready status: [busybox]",
"reason": "ContainersNotReady",
"status": "False",
"type": "ContainersReady"
},
{
"lastProbeTime": null,
"lastTransitionTime": "2021-11-18T19:10:08Z",
"status": "True",
"type": "PodScheduled"
}
],
"hostIP": "192.168.49.2",
"podIP": "172.17.0.25",
"podIPs": [{ "ip": "172.17.0.25" }],
"startTime": "2021-11-18T19:10:08Z",
"containerStatuses": [
{
"name": "busybox",
"state": {
"terminated": {
"exitCode": 1,
"finishedAt": "2021-11-18T19:11:01Z",
"reason": "Error",
"startedAt": "2021-11-18T19:10:17Z",
"containerID": "docker://1062343e1bb3625e36717bc15617c1bbca976366c3c9dd87035c71f33d015615"
}
},
"lastState": {},
"ready": false,
"restartCount": 0,
"image": "busybox:latest",
"imageID": "docker-pullable://busybox@sha256:e7157b6d7ebbe2cce5eaa8cfe8aa4fa82d173999b9f90a9ec42e57323546c353",
"containerID": "docker://1062343e1bb3625e36717bc15617c1bbca976366c3c9dd87035c71f33d015615",
"started": false
}
],
"qosClass": "Burstable"
}
},
{
"metadata": {
"name": "dice-roller-cronjob-1637025000-p4mlc",
"generateName": "dice-roller-cronjob-1637025000-",
"namespace": "default",
"uid": "acddd5d2-ac7f-473b-a9d8-17a89f99ac39",
"resourceVersion": "1361174579",
"creationTimestamp": "2021-11-16T02:10:22Z",
"labels": {
"backstage.io/kubernetes-id": "dice-roller",
"controller-uid": "69d5d242-a9a4-47b8-b9c7-c536ae8f151a",
"job-name": "dice-roller-cronjob-1637025000"
},
"annotations": {},
"ownerReferences": [
{
"apiVersion": "batch/v1",
"kind": "Job",
"name": "dice-roller-cronjob-1637025000",
"uid": "69d5d242-a9a4-47b8-b9c7-c536ae8f151a",
"controller": true,
"blockOwnerDeletion": true
}
]
},
"spec": {
"volumes": [],
"containers": [
{
"command": ["/bin/sh", "-c", "date; echo Rolling a die!"],
"image": "busybox",
"imagePullPolicy": "IfNotPresent",
"name": "busybox",
"resources": {},
"terminationMessagePath": "/dev/termination-log",
"terminationMessagePolicy": "File"
}
],
"restartPolicy": "Never",
"terminationGracePeriodSeconds": 30,
"dnsPolicy": "ClusterFirst",
"serviceAccountName": "default",
"serviceAccount": "default",
"nodeName": "minikube",
"securityContext": {},
"schedulerName": "default-scheduler",
"tolerations": [
{
"key": "node.kubernetes.io/not-ready",
"operator": "Exists",
"effect": "NoExecute",
"tolerationSeconds": 300
},
{
"key": "node.kubernetes.io/unreachable",
"operator": "Exists",
"effect": "NoExecute",
"tolerationSeconds": 300
}
],
"priority": 0,
"enableServiceLinks": true
},
"status": {
"phase": "Failed",
"conditions": [
{
"lastProbeTime": null,
"lastTransitionTime": "2021-11-18T19:10:13Z",
"status": "True",
"type": "Initialized"
},
{
"lastProbeTime": null,
"lastTransitionTime": "2021-11-18T19:11:02Z",
"message": "containers with unready status: [busybox]",
"reason": "ContainersNotReady",
"status": "False",
"type": "Ready"
},
{
"lastProbeTime": null,
"lastTransitionTime": "2021-11-18T19:11:02Z",
"message": "containers with unready status: [busybox]",
"reason": "ContainersNotReady",
"status": "False",
"type": "ContainersReady"
},
{
"lastProbeTime": null,
"lastTransitionTime": "2021-11-18T19:10:08Z",
"status": "True",
"type": "PodScheduled"
}
],
"hostIP": "192.168.49.2",
"podIP": "172.17.0.25",
"podIPs": [{ "ip": "172.17.0.25" }],
"startTime": "2021-11-16T02:10:22Z",
"containerStatuses": [
{
"name": "busybox",
"state": {
"terminated": {
"exitCode": 1,
"finishedAt": "2021-11-18T19:11:01Z",
"reason": "Error",
"startedAt": "2021-11-18T19:10:17Z",
"containerID": "docker://2659c4d0f8a68f2b49863c18738322f1686d5b87275428e5e641fd9fd9e06739"
}
},
"lastState": {},
"ready": true,
"restartCount": 0,
"image": "busybox:latest",
"imageID": "docker-pullable://busybox@sha256:e7157b6d7ebbe2cce5eaa8cfe8aa4fa82d173999b9f90a9ec42e57323546c353",
"containerID": "docker://2659c4d0f8a68f2b49863c18738322f1686d5b87275428e5e641fd9fd9e06739",
"started": true
}
],
"qosClass": "Burstable"
}
}
]
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,563 @@
/*
* Copyright 2023 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { KubernetesAuthProvidersApi } from '../kubernetes-auth-provider';
import { KubernetesBackendClient } from './KubernetesBackendClient';
import { rest } from 'msw';
import { UrlPatternDiscovery } from '@backstage/core-app-api';
import { setupServer } from 'msw/node';
import { setupRequestMockHandlers } from '@backstage/test-utils';
import {
CustomObjectsByEntityRequest,
KubernetesRequestBody,
ObjectsByEntityResponse,
WorkloadsByEntityRequest,
} from '@backstage/plugin-kubernetes-common';
import { NotFoundError } from '@backstage/errors';
describe('KubernetesBackendClient', () => {
let backendClient: KubernetesBackendClient;
const kubernetesAuthProvidersApi: jest.Mocked<KubernetesAuthProvidersApi> = {
decorateRequestBodyForAuth: jest.fn(),
getCredentials: jest.fn(),
};
let mockResponse: ObjectsByEntityResponse;
const worker = setupServer();
setupRequestMockHandlers(worker);
const identityApi = {
getCredentials: jest.fn(),
getProfileInfo: jest.fn(),
getBackstageIdentity: jest.fn(),
signOut: jest.fn(),
};
beforeEach(() => {
jest.resetAllMocks();
backendClient = new KubernetesBackendClient({
discoveryApi: UrlPatternDiscovery.compile(
'http://localhost:1234/api/{{ pluginId }}',
),
identityApi,
kubernetesAuthProvidersApi,
});
mockResponse = {
items: [
{
cluster: {
name: 'cluster-a',
},
resources: [{ type: 'pods', resources: [] }],
podMetrics: [
{
pod: {},
cpu: { currentUsage: 8, requestTotal: 2, limitTotal: 1 },
memory: { currentUsage: 8, requestTotal: 2, limitTotal: 1 },
containers: [
{
container: 'test',
cpuUsage: { currentUsage: 8, requestTotal: 2, limitTotal: 1 },
memoryUsage: {
currentUsage: 8,
requestTotal: 2,
limitTotal: 1,
},
},
],
},
],
errors: [],
},
],
};
});
it('hits the /clusters API', async () => {
identityApi.getCredentials.mockResolvedValue({ token: 'idToken' });
worker.use(
rest.get('http://localhost:1234/api/kubernetes/clusters', (_, res, ctx) =>
res(ctx.json({ items: [{ name: 'cluster-a', authProvider: 'aws' }] })),
),
);
const clusters = await backendClient.getClusters();
expect(clusters).toStrictEqual([
{ name: 'cluster-a', authProvider: 'aws' },
]);
});
it('/clusters API throws a 404 Error', async () => {
identityApi.getCredentials.mockResolvedValue({ token: 'idToken' });
worker.use(
rest.get('http://localhost:1234/api/kubernetes/clusters', (_, res, ctx) =>
res(ctx.status(404)),
),
);
await expect(backendClient.getClusters()).rejects.toThrow(
'Could not find the Kubernetes Backend (HTTP 404). Make sure the plugin has been fully installed.',
);
});
it('/clusters API throws a 500 Error', async () => {
identityApi.getCredentials.mockResolvedValue({ token: 'idToken' });
worker.use(
rest.get('http://localhost:1234/api/kubernetes/clusters', (_, res, ctx) =>
res(ctx.status(500)),
),
);
await expect(backendClient.getClusters()).rejects.toThrow(
'Request failed with 500 Internal Server Error, ',
);
});
it('hits the /resources/custom/query API', async () => {
identityApi.getCredentials.mockResolvedValue({ token: 'idToken' });
worker.use(
rest.post(
'http://localhost:1234/api/kubernetes/resources/custom/query',
(_, res, ctx) => res(ctx.json(mockResponse)),
),
);
const request: CustomObjectsByEntityRequest = {
auth: {},
customResources: [
{
group: 'test-group',
apiVersion: 'v1',
plural: 'none',
},
],
entity: {
apiVersion: 'v1',
kind: 'pod',
metadata: {
name: 'test-name',
},
},
};
const customObject: ObjectsByEntityResponse =
await backendClient.getCustomObjectsByEntity(request);
expect(customObject).toStrictEqual(mockResponse);
});
it('/resources/custom/query API throws a 404 error', async () => {
identityApi.getCredentials.mockResolvedValue({ token: 'idToken' });
worker.use(
rest.post(
'http://localhost:1234/api/kubernetes/resources/custom/query',
(_, res, ctx) => res(ctx.status(404)),
),
);
const request: CustomObjectsByEntityRequest = {
auth: {},
customResources: [
{
group: 'test-group',
apiVersion: 'v1',
plural: 'none',
},
],
entity: {
apiVersion: 'v1',
kind: 'pod',
metadata: {
name: 'test-name',
},
},
};
const response = backendClient.getCustomObjectsByEntity(request);
await expect(response).rejects.toThrow(
'Could not find the Kubernetes Backend (HTTP 404). Make sure the plugin has been fully installed.',
);
});
it('/resources/custom/query API throws a 500 error', async () => {
identityApi.getCredentials.mockResolvedValue({ token: 'idToken' });
worker.use(
rest.post(
'http://localhost:1234/api/kubernetes/resources/custom/query',
(_, res, ctx) => res(ctx.status(500)),
),
);
const request: CustomObjectsByEntityRequest = {
auth: {},
customResources: [
{
group: 'test-group',
apiVersion: 'v1',
plural: 'none',
},
],
entity: {
apiVersion: 'v1',
kind: 'pod',
metadata: {
name: 'test-name',
},
},
};
const response = backendClient.getCustomObjectsByEntity(request);
await expect(response).rejects.toThrow(
'Request failed with 500 Internal Server Error, ',
);
});
it('hits the /services/{entityName} API', async () => {
identityApi.getCredentials.mockResolvedValue({ token: 'idToken' });
worker.use(
rest.post(
'http://localhost:1234/api/kubernetes/services/test-name',
(_, res, ctx) => res(ctx.json(mockResponse)),
),
);
const request: KubernetesRequestBody = {
entity: {
apiVersion: 'v1',
kind: 'pod',
metadata: {
name: 'test-name',
},
},
};
const entityObject: ObjectsByEntityResponse =
await backendClient.getObjectsByEntity(request);
expect(entityObject).toStrictEqual(mockResponse);
});
it('services/{entityName} API throws a 404 error', async () => {
identityApi.getCredentials.mockResolvedValue({ token: 'idToken' });
worker.use(
rest.post(
'http://localhost:1234/api/kubernetes/services/test-name',
(_, res, ctx) => res(ctx.status(404)),
),
);
const request: KubernetesRequestBody = {
entity: {
apiVersion: 'v1',
kind: 'pod',
metadata: {
name: 'test-name',
},
},
};
const response = backendClient.getObjectsByEntity(request);
await expect(response).rejects.toThrow(
'Could not find the Kubernetes Backend (HTTP 404). Make sure the plugin has been fully installed.',
);
});
it('services/{entityName} API throws a 500 error', async () => {
identityApi.getCredentials.mockResolvedValue({ token: 'idToken' });
worker.use(
rest.post(
'http://localhost:1234/api/kubernetes/services/test-name',
(_, res, ctx) => res(ctx.status(500)),
),
);
const request: KubernetesRequestBody = {
entity: {
apiVersion: 'v1',
kind: 'pod',
metadata: {
name: 'test-name',
},
},
};
const response = backendClient.getObjectsByEntity(request);
await expect(response).rejects.toThrow(
'Request failed with 500 Internal Server Error, ',
);
});
it('hits the /resources/workloads/query API', async () => {
identityApi.getCredentials.mockResolvedValue({ token: 'idToken' });
worker.use(
rest.post(
'http://localhost:1234/api/kubernetes/resources/workloads/query',
(_, res, ctx) => res(ctx.json(mockResponse)),
),
);
const request: WorkloadsByEntityRequest = {
auth: {},
entity: {
apiVersion: 'v1',
kind: 'pod',
metadata: {
name: 'test-name',
},
},
};
const response: ObjectsByEntityResponse =
await backendClient.getWorkloadsByEntity(request);
expect(response).toStrictEqual(mockResponse);
});
it('/resources/workloads/query API throws a 404 error', async () => {
identityApi.getCredentials.mockResolvedValue({ token: 'idToken' });
worker.use(
rest.post(
'http://localhost:1234/api/kubernetes/resources/workloads/query',
(_, res, ctx) => res(ctx.status(404)),
),
);
const request: WorkloadsByEntityRequest = {
auth: {},
entity: {
apiVersion: 'v1',
kind: 'pod',
metadata: {
name: 'test-name',
},
},
};
const response = backendClient.getWorkloadsByEntity(request);
await expect(response).rejects.toThrow(
'Could not find the Kubernetes Backend (HTTP 404). Make sure the plugin has been fully installed.',
);
});
it('/resources/workloads/query API throws a 500 error', async () => {
identityApi.getCredentials.mockResolvedValue({ token: 'idToken' });
worker.use(
rest.post(
'http://localhost:1234/api/kubernetes/resources/workloads/query',
(_, res, ctx) => res(ctx.status(500)),
),
);
const request: WorkloadsByEntityRequest = {
auth: {},
entity: {
apiVersion: 'v1',
kind: 'pod',
metadata: {
name: 'test-name',
},
},
};
const response = backendClient.getWorkloadsByEntity(request);
await expect(response).rejects.toThrow(
'Request failed with 500 Internal Server Error, ',
);
});
describe('proxy', () => {
beforeEach(() => {
worker.use(
rest.get(
'http://localhost:1234/api/kubernetes/clusters',
(_, res, ctx) =>
res(
ctx.json({ items: [{ name: 'cluster-a', authProvider: 'aws' }] }),
),
),
);
identityApi.getCredentials.mockResolvedValue({ token: 'idToken' });
});
it('hits the /proxy API', async () => {
kubernetesAuthProvidersApi.getCredentials.mockResolvedValue({
token: 'k8-token',
});
const nsResponse = {
kind: 'Namespace',
apiVersion: 'v1',
metadata: {
name: 'new-ns',
},
};
worker.use(
rest.get(
'http://localhost:1234/api/kubernetes/proxy/api/v1/namespaces',
(req, res, ctx) =>
res(
req.headers.get('Backstage-Kubernetes-Authorization') ===
'Bearer k8-token'
? ctx.json(nsResponse)
: ctx.status(403),
),
),
);
const request = {
clusterName: 'cluster-a',
path: '/api/v1/namespaces',
};
const response = await backendClient.proxy(request);
await expect(response.json()).resolves.toStrictEqual(nsResponse);
});
it('hits /proxy api when signed in as a guest', async () => {
// when a user is signed in as a guest the result of the getCredentials() method resolves to the {} value.
identityApi.getCredentials.mockResolvedValue({});
kubernetesAuthProvidersApi.getCredentials.mockResolvedValue({
token: 'k8-token',
});
const nsResponse = {
kind: 'Namespace',
apiVersion: 'v1',
metadata: {
name: 'new-ns',
},
};
worker.use(
rest.get(
'http://localhost:1234/api/kubernetes/proxy/api/v1/namespaces',
(req, res, ctx) =>
res(
req.headers.get('Backstage-Kubernetes-Authorization') ===
'Bearer k8-token'
? ctx.json(nsResponse)
: ctx.status(403),
),
),
);
const request = {
clusterName: 'cluster-a',
path: '/api/v1/namespaces',
};
const response = await backendClient.proxy(request);
await expect(response.json()).resolves.toStrictEqual(nsResponse);
});
it('/proxy API throws a 404 error', async () => {
kubernetesAuthProvidersApi.getCredentials.mockResolvedValue({
token: 'k8-token',
});
worker.use(
rest.get(
'http://localhost:1234/api/kubernetes/proxy/api/v1/namespaces',
(_, res, ctx) => res(ctx.status(404)),
),
);
const request = {
clusterName: 'cluster-a',
path: '/api/v1/namespaces',
};
const response = await backendClient.proxy(request);
expect(response.status).toEqual(404);
});
it('throws a ERROR_NOT_FOUND if the cluster in the request is not found', async () => {
const request = {
clusterName: 'cluster-b',
path: '/api/v1/namespaces',
};
await expect(backendClient.proxy(request)).rejects.toThrow(NotFoundError);
});
it('responds with an 403 error when invalid k8 token is provided', async () => {
kubernetesAuthProvidersApi.getCredentials.mockResolvedValue({
token: 'wrong-token',
});
const nsResponse = {
kind: 'Namespace',
apiVersion: 'v1',
metadata: {
name: 'new-ns',
},
};
worker.use(
rest.get(
'http://localhost:1234/api/kubernetes/proxy/api/v1/namespaces',
(req, res, ctx) =>
res(
req.headers.get('Backstage-Kubernetes-Authorization') ===
'Bearer k8-token'
? ctx.json(nsResponse)
: ctx.status(403),
),
),
);
const request = {
clusterName: 'cluster-a',
path: '/api/v1/namespaces',
};
const response = await backendClient.proxy(request);
expect(response.status).toEqual(403);
});
it('skips authorization header when auth provider returns no creds', async () => {
const nsResponse = {
kind: 'Namespace',
apiVersion: 'v1',
metadata: {
name: 'new-ns',
},
};
worker.use(
rest.get(
'http://localhost:1234/api/kubernetes/proxy/api/v1/namespaces/new-ns',
(req, res, ctx) =>
res(
req.headers.get('Backstage-Kubernetes-Authorization')
? ctx.status(403)
: ctx.json(nsResponse),
),
),
);
kubernetesAuthProvidersApi.getCredentials.mockResolvedValue({});
const response = await backendClient.proxy({
clusterName: 'cluster-a',
path: '/api/v1/namespaces/new-ns',
});
await expect(response.json()).resolves.toStrictEqual(nsResponse);
});
});
});
@@ -0,0 +1,162 @@
/*
* Copyright 2020 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { KubernetesApi } from './types';
import {
KubernetesRequestBody,
ObjectsByEntityResponse,
WorkloadsByEntityRequest,
CustomObjectsByEntityRequest,
} from '@backstage/plugin-kubernetes-common';
import { DiscoveryApi, IdentityApi } from '@backstage/core-plugin-api';
import { stringifyEntityRef } from '@backstage/catalog-model';
import { KubernetesAuthProvidersApi } from '../kubernetes-auth-provider';
import { NotFoundError } from '@backstage/errors';
/** @public */
export class KubernetesBackendClient implements KubernetesApi {
private readonly discoveryApi: DiscoveryApi;
private readonly identityApi: IdentityApi;
private readonly kubernetesAuthProvidersApi: KubernetesAuthProvidersApi;
constructor(options: {
discoveryApi: DiscoveryApi;
identityApi: IdentityApi;
kubernetesAuthProvidersApi: KubernetesAuthProvidersApi;
}) {
this.discoveryApi = options.discoveryApi;
this.identityApi = options.identityApi;
this.kubernetesAuthProvidersApi = options.kubernetesAuthProvidersApi;
}
private async handleResponse(response: Response): Promise<any> {
if (!response.ok) {
const payload = await response.text();
let message;
switch (response.status) {
case 404:
message =
'Could not find the Kubernetes Backend (HTTP 404). Make sure the plugin has been fully installed.';
break;
default:
message = `Request failed with ${response.status} ${response.statusText}, ${payload}`;
}
throw new Error(message);
}
return await response.json();
}
private async postRequired(path: string, requestBody: any): Promise<any> {
const url = `${await this.discoveryApi.getBaseUrl('kubernetes')}${path}`;
const { token: idToken } = await this.identityApi.getCredentials();
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(idToken && { Authorization: `Bearer ${idToken}` }),
},
body: JSON.stringify(requestBody),
});
return this.handleResponse(response);
}
private async getCluster(
clusterName: string,
): Promise<{ name: string; authProvider: string }> {
const cluster = await this.getClusters().then(clusters =>
clusters.find(c => c.name === clusterName),
);
if (!cluster) {
throw new NotFoundError(`Cluster ${clusterName} not found`);
}
return cluster;
}
private async getCredentials(
authProvider: string,
): Promise<{ token?: string }> {
return await this.kubernetesAuthProvidersApi.getCredentials(authProvider);
}
async getObjectsByEntity(
requestBody: KubernetesRequestBody,
): Promise<ObjectsByEntityResponse> {
return await this.postRequired(
`/services/${requestBody.entity.metadata.name}`,
requestBody,
);
}
async getWorkloadsByEntity(
request: WorkloadsByEntityRequest,
): Promise<ObjectsByEntityResponse> {
return await this.postRequired('/resources/workloads/query', {
auth: request.auth,
entityRef: stringifyEntityRef(request.entity),
});
}
async getCustomObjectsByEntity(
request: CustomObjectsByEntityRequest,
): Promise<ObjectsByEntityResponse> {
return await this.postRequired(`/resources/custom/query`, {
entityRef: stringifyEntityRef(request.entity),
auth: request.auth,
customResources: request.customResources,
});
}
async getClusters(): Promise<{ name: string; authProvider: string }[]> {
const { token: idToken } = await this.identityApi.getCredentials();
const url = `${await this.discoveryApi.getBaseUrl('kubernetes')}/clusters`;
const response = await fetch(url, {
method: 'GET',
headers: {
...(idToken && { Authorization: `Bearer ${idToken}` }),
},
});
return (await this.handleResponse(response)).items;
}
async proxy(options: {
clusterName: string;
path: string;
init?: RequestInit;
}): Promise<Response> {
const { authProvider } = await this.getCluster(options.clusterName);
const { token: k8sToken } = await this.getCredentials(authProvider);
const url = `${await this.discoveryApi.getBaseUrl('kubernetes')}/proxy${
options.path
}`;
const identityResponse = await this.identityApi.getCredentials();
const headers = {
...options.init?.headers,
[`Backstage-Kubernetes-Cluster`]: options.clusterName,
...(k8sToken && {
[`Backstage-Kubernetes-Authorization`]: `Bearer ${k8sToken}`,
}),
...(identityResponse.token && {
Authorization: `Bearer ${identityResponse.token}`,
}),
};
return await fetch(url, { ...options.init, headers });
}
}
@@ -0,0 +1,124 @@
/*
* Copyright 2023 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { DateTime } from 'luxon';
import { KubernetesProxyClient } from './KubernetesProxyClient';
describe('KubernetesProxyClient', () => {
let proxy: KubernetesProxyClient;
const callProxyMock = jest.fn();
const oneHourAgo = DateTime.now().minus({ hours: 1 }).toISO();
beforeEach(() => {
jest.resetAllMocks();
proxy = new KubernetesProxyClient({
kubernetesApi: {
proxy: callProxyMock,
} as any,
});
});
it('/logs returns log text', async () => {
const request = {
podName: 'some-pod',
namespace: 'some-namespace',
clusterName: 'some-cluster',
containerName: 'some-container',
};
callProxyMock.mockResolvedValue({
text: jest.fn().mockResolvedValue('Hello World'),
ok: true,
});
const response = await proxy.getPodLogs(request);
await expect(response).toStrictEqual({ text: 'Hello World' });
expect(callProxyMock).toHaveBeenCalledWith({
clusterName: 'some-cluster',
init: {
method: 'GET',
},
path: '/api/v1/namespaces/some-namespace/pods/some-pod/log?container=some-container',
});
});
it('/logs returns log text - crash logs', async () => {
const request = {
podName: 'some-pod',
namespace: 'some-namespace',
clusterName: 'some-cluster',
containerName: 'some-container',
previous: true,
};
callProxyMock.mockResolvedValue({
text: jest.fn().mockResolvedValue('Hello World'),
ok: true,
});
const response = await proxy.getPodLogs(request);
await expect(response).toStrictEqual({ text: 'Hello World' });
expect(callProxyMock).toHaveBeenCalledWith({
clusterName: 'some-cluster',
init: {
method: 'GET',
},
path: '/api/v1/namespaces/some-namespace/pods/some-pod/log?container=some-container&previous=',
});
});
it('/getEventsByInvolvedObjectName returns events', async () => {
const request = {
clusterName: 'some-cluster',
involvedObjectName: 'some-object',
namespace: 'some-namespace',
};
const events = [
{
type: 'Warning',
message: 'uh oh',
reason: 'something happened',
count: 23,
metadata: {
creationTimestamp: oneHourAgo,
},
},
{
type: 'Info',
message: 'hello there',
reason: 'something happened',
count: 52,
metadata: {
creationTimestamp: oneHourAgo,
},
},
];
callProxyMock.mockResolvedValue({
json: jest.fn().mockResolvedValue({
items: events,
}),
ok: true,
});
const response = await proxy.getEventsByInvolvedObjectName(request);
await expect(response).toStrictEqual(events);
expect(callProxyMock).toHaveBeenCalledWith({
clusterName: 'some-cluster',
init: {
method: 'GET',
},
path: '/api/v1/namespaces/some-namespace/events?fieldSelector=involvedObject.name=some-object',
});
});
});
@@ -0,0 +1,113 @@
/*
* Copyright 2023 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { KubernetesApi } from './types';
import { Event } from 'kubernetes-models/v1';
/**
* A client for common requests through the proxy endpoint of the kubernetes backend plugin.
*
* @public
*/
export class KubernetesProxyClient {
private readonly kubernetesApi: KubernetesApi;
constructor(options: { kubernetesApi: KubernetesApi }) {
this.kubernetesApi = options.kubernetesApi;
}
private async handleText(response: Response): Promise<string> {
if (!response.ok) {
const payload = await response.text();
let message;
switch (response.status) {
default:
message = `Proxy request failed with ${response.status} ${response.statusText}, ${payload}`;
}
throw new Error(message);
}
return await response.text();
}
private async handleJson(response: Response): Promise<any> {
if (!response.ok) {
const payload = await response.text();
let message = `Request failed with ${response.status} ${response.statusText}, ${payload}`;
switch (response.status) {
case 404:
message = `Proxy request failed with ${response.status} ${response.statusText}, ${payload}`;
break;
default:
message = `Request failed with ${response.status} ${response.statusText}, ${payload}`;
}
throw new Error(message);
}
return await response.json();
}
async getEventsByInvolvedObjectName({
clusterName,
involvedObjectName,
namespace,
}: {
clusterName: string;
involvedObjectName: string;
namespace: string;
}): Promise<Event[]> {
return await this.kubernetesApi
.proxy({
clusterName,
path: `/api/v1/namespaces/${namespace}/events?fieldSelector=involvedObject.name=${involvedObjectName}`,
init: {
method: 'GET',
},
})
.then(response => this.handleJson(response))
.then(eventList => eventList.items);
}
async getPodLogs({
podName,
namespace,
clusterName,
containerName,
previous,
}: {
podName: string;
namespace: string;
clusterName: string;
containerName: string;
previous?: boolean;
}): Promise<{ text: string }> {
const params = new URLSearchParams({
container: containerName,
});
if (previous) {
params.append('previous', '');
}
return await this.kubernetesApi
.proxy({
clusterName: clusterName,
path: `/api/v1/namespaces/${namespace}/pods/${podName}/log?${params.toString()}`,
init: {
method: 'GET',
},
})
.then(response => this.handleText(response))
.then(text => ({ text }));
}
}
+20
View File
@@ -0,0 +1,20 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { kubernetesApiRef, kubernetesProxyApiRef } from './types';
export type { KubernetesApi, KubernetesProxyApi } from './types';
export { KubernetesBackendClient } from './KubernetesBackendClient';
export { KubernetesProxyClient } from './KubernetesProxyClient';
+75
View File
@@ -0,0 +1,75 @@
/*
* Copyright 2020 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
KubernetesRequestBody,
ObjectsByEntityResponse,
WorkloadsByEntityRequest,
CustomObjectsByEntityRequest,
} from '@backstage/plugin-kubernetes-common';
import { createApiRef } from '@backstage/core-plugin-api';
import { Event } from 'kubernetes-models/v1';
/** @public */
export const kubernetesApiRef = createApiRef<KubernetesApi>({
id: 'plugin.kubernetes.service',
});
/** @public */
export const kubernetesProxyApiRef = createApiRef<KubernetesProxyApi>({
id: 'plugin.kubernetes.proxy-service',
});
/** @public */
export interface KubernetesApi {
getObjectsByEntity(
requestBody: KubernetesRequestBody,
): Promise<ObjectsByEntityResponse>;
getClusters(): Promise<
{
name: string;
authProvider: string;
oidcTokenProvider?: string | undefined;
}[]
>;
getWorkloadsByEntity(
request: WorkloadsByEntityRequest,
): Promise<ObjectsByEntityResponse>;
getCustomObjectsByEntity(
request: CustomObjectsByEntityRequest,
): Promise<ObjectsByEntityResponse>;
proxy(options: {
clusterName: string;
path: string;
init?: RequestInit;
}): Promise<Response>;
}
/** @public */
export interface KubernetesProxyApi {
getPodLogs(request: {
podName: string;
namespace: string;
clusterName: string;
containerName: string;
previous?: boolean;
}): Promise<{ text: string }>;
getEventsByInvolvedObjectName(request: {
clusterName: string;
involvedObjectName: string;
namespace: string;
}): Promise<Event[]>;
}
@@ -0,0 +1,59 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import { screen } from '@testing-library/react';
import { renderInTestApp } from '@backstage/test-utils';
import { Cluster } from './Cluster';
jest.mock('../../hooks');
import * as oneDeployment from '../../__fixtures__/1-deployments.json';
describe('Cluster', () => {
it('render 1 cluster', async () => {
await renderInTestApp(
<Cluster
{...({
clusterObjects: {
cluster: {
name: 'cluster-1',
},
resources: [
{
type: 'deployments',
resources: oneDeployment.deployments,
},
{
type: 'replicasets',
resources: oneDeployment.replicaSets,
},
{
type: 'pods',
resources: oneDeployment.pods,
},
],
podMetrics: [],
errors: [],
},
podsWithErrors: new Set<string>(),
} as any)}
/>,
);
expect(screen.getByText('cluster-1')).toBeInTheDocument();
expect(screen.getByText('10 pods')).toBeInTheDocument();
});
});
@@ -0,0 +1,180 @@
/*
* Copyright 2020 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import {
Accordion,
AccordionDetails,
AccordionSummary,
Grid,
Typography,
} from '@material-ui/core';
import {
ClientPodStatus,
ClusterObjects,
groupResponses,
} from '@backstage/plugin-kubernetes-common';
import ExpandMoreIcon from '@material-ui/icons/ExpandMore';
import { DeploymentsAccordions } from '../DeploymentsAccordions';
import { StatefulSetsAccordions } from '../StatefulSetsAccordions';
import { IngressesAccordions } from '../IngressesAccordions';
import { ServicesAccordions } from '../ServicesAccordions';
import { CronJobsAccordions } from '../CronJobsAccordions';
import { CustomResources } from '../CustomResources';
import {
ClusterContext,
GroupedResponsesContext,
PodNamesWithErrorsContext,
} from '../../hooks';
import { StatusError, StatusOK } from '@backstage/core-components';
import { PodMetricsContext } from '../../hooks/usePodMetrics';
type ClusterSummaryProps = {
clusterName: string;
totalNumberOfPods: number;
numberOfPodsWithErrors: number;
children?: React.ReactNode;
};
const ClusterSummary = ({
clusterName,
totalNumberOfPods,
numberOfPodsWithErrors,
}: ClusterSummaryProps) => {
return (
<Grid
container
direction="row"
justifyContent="space-between"
alignItems="flex-start"
spacing={0}
>
<Grid
xs={6}
item
container
direction="column"
justifyContent="flex-start"
alignItems="flex-start"
spacing={0}
>
<Grid item xs>
<Typography variant="body1">{clusterName}</Typography>
<Typography color="textSecondary" variant="subtitle1">
Cluster
</Typography>
</Grid>
</Grid>
<Grid
item
container
xs={3}
direction="column"
justifyContent="flex-start"
alignItems="flex-end"
spacing={0}
>
<Grid item>
<StatusOK>{totalNumberOfPods} pods</StatusOK>
</Grid>
<Grid item>
{numberOfPodsWithErrors > 0 ? (
<StatusError>{numberOfPodsWithErrors} pods with errors</StatusError>
) : (
<StatusOK>No pods with errors</StatusOK>
)}
</Grid>
</Grid>
</Grid>
);
};
/**
* Props for Cluster
*
* @public
*/
export type ClusterProps = {
clusterObjects: ClusterObjects;
podsWithErrors: Set<string>;
children?: React.ReactNode;
};
/**
* Component for rendering Kubernetes resources in a cluster
*
* @public
*/
export const Cluster = ({ clusterObjects, podsWithErrors }: ClusterProps) => {
const groupedResponses = groupResponses(clusterObjects.resources);
const podMetricsMap = new Map<string, ClientPodStatus[]>();
podMetricsMap.set(clusterObjects.cluster.name, clusterObjects.podMetrics);
return (
<ClusterContext.Provider value={clusterObjects.cluster}>
<GroupedResponsesContext.Provider value={groupedResponses}>
<PodMetricsContext.Provider value={podMetricsMap}>
<PodNamesWithErrorsContext.Provider value={podsWithErrors}>
<Accordion TransitionProps={{ unmountOnExit: true }}>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<ClusterSummary
clusterName={clusterObjects.cluster.name}
totalNumberOfPods={groupedResponses.pods.length}
numberOfPodsWithErrors={podsWithErrors.size}
/>
</AccordionSummary>
<AccordionDetails>
<Grid container direction="column">
{groupedResponses.customResources.length > 0 ? (
<Grid item>
<CustomResources />
</Grid>
) : undefined}
{groupedResponses.deployments.length > 0 ? (
<Grid item>
<DeploymentsAccordions />
</Grid>
) : undefined}
{groupedResponses.statefulsets.length > 0 ? (
<Grid item>
<StatefulSetsAccordions />
</Grid>
) : undefined}
{groupedResponses.ingresses.length > 0 ? (
<Grid item>
<IngressesAccordions />
</Grid>
) : undefined}
{groupedResponses.services.length > 0 ? (
<Grid item>
<ServicesAccordions />
</Grid>
) : undefined}
{groupedResponses.cronJobs.length > 0 ? (
<Grid item>
<CronJobsAccordions />
</Grid>
) : undefined}
</Grid>
</AccordionDetails>
</Accordion>
</PodNamesWithErrorsContext.Provider>
</PodMetricsContext.Provider>
</GroupedResponsesContext.Provider>
</ClusterContext.Provider>
);
};
@@ -0,0 +1,16 @@
/*
* Copyright 2020 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export * from './Cluster';
@@ -0,0 +1,47 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import { screen } from '@testing-library/react';
import { CronJobsAccordions } from './CronJobsAccordions';
import * as oneCronJobsFixture from '../../__fixtures__/1-cronjobs.json';
import * as twoCronJobsFixture from '../../__fixtures__/2-cronjobs.json';
import { renderInTestApp } from '@backstage/test-utils';
import { kubernetesProviders } from '../../hooks/test-utils';
describe('CronJobsAccordions', () => {
it('should render 1 active cronjobs', async () => {
const wrapper = kubernetesProviders(oneCronJobsFixture, new Set<string>());
await renderInTestApp(wrapper(<CronJobsAccordions />));
expect(screen.getByText('dice-roller-cronjob')).toBeInTheDocument();
expect(screen.getByText('CronJob')).toBeInTheDocument();
expect(screen.getByText('namespace: default')).toBeInTheDocument();
expect(screen.getByText('Active')).toBeInTheDocument();
});
it('should render 1 suspended cronjobs', async () => {
const wrapper = kubernetesProviders(twoCronJobsFixture, new Set<string>());
await renderInTestApp(wrapper(<CronJobsAccordions />));
expect(screen.getByText('dice-roller-cronjob')).toBeInTheDocument();
expect(screen.getByText('CronJob')).toBeInTheDocument();
expect(screen.getByText('namespace: default')).toBeInTheDocument();
expect(screen.getByText('Suspended')).toBeInTheDocument();
});
});
@@ -0,0 +1,136 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { useContext } from 'react';
import {
Accordion,
AccordionDetails,
AccordionSummary,
Grid,
Typography,
} from '@material-ui/core';
import ExpandMoreIcon from '@material-ui/icons/ExpandMore';
import { V1CronJob, V1Job } from '@kubernetes/client-node';
import { JobsAccordions } from '../JobsAccordions';
import { CronJobDrawer } from './CronJobsDrawer';
import { getOwnedResources } from '../../utils/owner';
import { GroupedResponsesContext } from '../../hooks';
import { StatusError, StatusOK } from '@backstage/core-components';
import { humanizeCron } from '../../utils/crons';
/**
*
*
* @public
*/
export type CronJobsAccordionsProps = {
children?: React.ReactNode;
};
type CronJobAccordionProps = {
cronJob: V1CronJob;
ownedJobs: V1Job[];
children?: React.ReactNode;
};
type CronJobSummaryProps = {
cronJob: V1CronJob;
children?: React.ReactNode;
};
const CronJobSummary = ({ cronJob }: CronJobSummaryProps) => {
return (
<Grid
container
direction="row"
justifyContent="space-between"
alignItems="center"
spacing={0}
>
<Grid xs={6} item>
<CronJobDrawer cronJob={cronJob} />
</Grid>
<Grid
item
container
xs={6}
direction="column"
justifyContent="flex-start"
alignItems="flex-end"
spacing={0}
>
<Grid item>
{cronJob.spec?.suspend ? (
<StatusError>Suspended</StatusError>
) : (
<StatusOK>Active</StatusOK>
)}
</Grid>
<Grid item>
<Typography variant="body1">
Schedule:{' '}
{cronJob.spec?.schedule
? `${cronJob.spec.schedule} (${humanizeCron(
cronJob.spec.schedule,
)})`
: 'N/A'}
</Typography>
</Grid>
</Grid>
</Grid>
);
};
const CronJobAccordion = ({ cronJob, ownedJobs }: CronJobAccordionProps) => {
return (
<Accordion TransitionProps={{ unmountOnExit: true }} variant="outlined">
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<CronJobSummary cronJob={cronJob} />
</AccordionSummary>
<AccordionDetails>
<JobsAccordions jobs={ownedJobs.reverse()} />
</AccordionDetails>
</Accordion>
);
};
/**
*
*
* @public
*/
export const CronJobsAccordions = ({}: CronJobsAccordionsProps) => {
const groupedResponses = useContext(GroupedResponsesContext);
return (
<Grid
container
direction="column"
justifyContent="flex-start"
alignItems="flex-start"
>
{groupedResponses.cronJobs.map((cronJob, i) => (
<Grid container item key={i} xs>
<Grid item xs>
<CronJobAccordion
ownedJobs={getOwnedResources(cronJob, groupedResponses.jobs)}
cronJob={cronJob}
/>
</Grid>
</Grid>
))}
</Grid>
);
};
@@ -0,0 +1,38 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import * as oneCronJobsFixture from '../../__fixtures__/1-cronjobs.json';
import { renderInTestApp } from '@backstage/test-utils';
import { CronJobDrawer } from './CronJobsDrawer';
describe('CronJobDrawer', () => {
it('should render cronJob drawer', async () => {
const { getByText, getAllByText } = await renderInTestApp(
<CronJobDrawer
cronJob={(oneCronJobsFixture as any).cronJobs[0]}
expanded
/>,
);
expect(getAllByText('dice-roller-cronjob')).toHaveLength(2);
expect(getAllByText('CronJob')).toHaveLength(2);
expect(getByText('YAML')).toBeInTheDocument();
expect(getByText('Schedule')).toBeInTheDocument();
expect(getByText('30 5 * * *')).toBeInTheDocument();
expect(getByText('Starting Deadline Seconds')).toBeInTheDocument();
expect(getByText('Last Schedule Time')).toBeInTheDocument();
});
});
@@ -0,0 +1,67 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import { V1CronJob } from '@kubernetes/client-node';
import { KubernetesStructuredMetadataTableDrawer } from '../KubernetesDrawer';
import { Typography, Grid, Chip } from '@material-ui/core';
export const CronJobDrawer = ({
cronJob,
expanded,
}: {
cronJob: V1CronJob;
expanded?: boolean;
}) => {
const namespace = cronJob.metadata?.namespace;
return (
<KubernetesStructuredMetadataTableDrawer
object={cronJob}
expanded={expanded}
kind="CronJob"
renderObject={(cronJobObj: V1CronJob) => ({
schedule: cronJobObj.spec?.schedule ?? '???',
startingDeadlineSeconds:
cronJobObj.spec?.startingDeadlineSeconds ?? '???',
concurrencyPolicy: cronJobObj.spec?.concurrencyPolicy ?? '???',
lastScheduleTime: cronJobObj.status?.lastScheduleTime ?? '???',
})}
>
<Grid
container
direction="column"
justifyContent="flex-start"
alignItems="flex-start"
spacing={0}
>
<Grid item>
<Typography variant="body1">
{cronJob.metadata?.name ?? 'unknown object'}
</Typography>
</Grid>
<Grid item>
<Typography color="textSecondary" variant="subtitle1">
CronJob
</Typography>
</Grid>
{namespace && (
<Grid item>
<Chip size="small" label={`namespace: ${namespace}`} />
</Grid>
)}
</Grid>
</KubernetesStructuredMetadataTableDrawer>
);
};
@@ -0,0 +1,16 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export * from './CronJobsAccordions';
@@ -0,0 +1,102 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import { screen } from '@testing-library/react';
import { renderInTestApp } from '@backstage/test-utils';
import { kubernetesProviders } from '../../../hooks/test-utils';
import * as rollout from './__fixtures__/rollout.json';
import * as pausedRollout from './__fixtures__/paused-rollout.json';
import * as abortedRollout from './__fixtures__/aborted-rollout.json';
import * as groupedResources from './__fixtures__/grouped-resources.json';
import { RolloutAccordions } from './Rollout';
import { DateTime, Duration } from 'luxon';
describe('Rollout', () => {
it('should render RolloutAccordion', async () => {
const wrapper = kubernetesProviders(groupedResources, new Set([]));
await renderInTestApp(
wrapper(<RolloutAccordions rollouts={[rollout] as any} />),
);
expect(screen.getByText('dice-roller')).toBeInTheDocument();
expect(screen.getByText('Rollout')).toBeInTheDocument();
expect(screen.getByText('2 pods')).toBeInTheDocument();
expect(screen.getByText('No pods with errors')).toBeInTheDocument();
expect(screen.queryByText('Paused')).toBeNull();
});
it('should render RolloutAccordion with error', async () => {
const wrapper = kubernetesProviders(
groupedResources,
new Set(['dice-roller-6c8646bfd-2m5hv']),
);
await renderInTestApp(
wrapper(<RolloutAccordions rollouts={[rollout] as any} />),
);
expect(screen.getByText('dice-roller')).toBeInTheDocument();
expect(screen.getByText('Rollout')).toBeInTheDocument();
expect(screen.getByText('2 pods')).toBeInTheDocument();
expect(screen.getByText('1 pod with errors')).toBeInTheDocument();
expect(screen.queryByText('Paused')).toBeNull();
});
it('should render Paused Rollout with pause text', async () => {
const wrapper = kubernetesProviders(groupedResources, new Set([]));
(pausedRollout.status.pauseConditions[0].startTime as any) =
DateTime.local()
// millis * secs * mins = 45 mins
.minus(Duration.fromMillis(1000 * 60 * 45));
await renderInTestApp(
wrapper(<RolloutAccordions rollouts={[pausedRollout] as any} />),
);
expect(screen.getByText('dice-roller')).toBeInTheDocument();
expect(screen.getByText('Rollout')).toBeInTheDocument();
expect(screen.getByText('2 pods')).toBeInTheDocument();
expect(screen.getByText('No pods with errors')).toBeInTheDocument();
expect(screen.getByText('Paused (45 minutes ago)')).toBeInTheDocument();
});
it('should render aborted Rollout with aborted text', async () => {
const wrapper = kubernetesProviders(groupedResources, new Set([]));
await renderInTestApp(
wrapper(
<RolloutAccordions
defaultExpanded
rollouts={[abortedRollout] as any}
/>,
),
);
expect(screen.getByText('dice-roller')).toBeInTheDocument();
expect(screen.getByText('Rollout')).toBeInTheDocument();
expect(screen.getByText('2 pods')).toBeInTheDocument();
expect(screen.getByText('No pods with errors')).toBeInTheDocument();
expect(screen.queryByText('Paused')).toBeNull();
expect(screen.getByText('Rollout status')).toBeInTheDocument();
expect(screen.getAllByText('Aborted')).toHaveLength(2);
expect(
screen.getByText('some metric related failure message'),
).toBeInTheDocument();
});
});
@@ -0,0 +1,289 @@
/*
* Copyright 2020 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { useContext } from 'react';
import {
Accordion,
AccordionDetails,
AccordionSummary,
Grid,
Typography,
} from '@material-ui/core';
import ExpandMoreIcon from '@material-ui/icons/ExpandMore';
import { V1Pod, V1HorizontalPodAutoscaler } from '@kubernetes/client-node';
import { PodsTable } from '../../Pods';
import { HorizontalPodAutoscalerDrawer } from '../../HorizontalPodAutoscalers';
import { RolloutDrawer } from './RolloutDrawer';
import PauseIcon from '@material-ui/icons/Pause';
import ErrorOutlineIcon from '@material-ui/icons/ErrorOutline';
import { DateTime } from 'luxon';
import { StepsProgress } from './StepsProgress';
import {
PodNamesWithErrorsContext,
GroupedResponsesContext,
} from '../../../hooks';
import {
getMatchingHpa,
getOwnedPodsThroughReplicaSets,
} from '../../../utils/owner';
import { StatusError, StatusOK } from '@backstage/core-components';
import { READY_COLUMNS, RESOURCE_COLUMNS } from '../../Pods/PodsTable';
type RolloutAccordionsProps = {
rollouts: any[];
defaultExpanded?: boolean;
children?: React.ReactNode;
};
type RolloutAccordionProps = {
rollout: any;
ownedPods: V1Pod[];
defaultExpanded?: boolean;
matchingHpa?: V1HorizontalPodAutoscaler;
children?: React.ReactNode;
};
type RolloutSummaryProps = {
rollout: any;
numberOfCurrentPods: number;
numberOfPodsWithErrors: number;
hpa?: V1HorizontalPodAutoscaler;
children?: React.ReactNode;
};
const AbortedTitle = (
<div
style={{
display: 'flex',
alignItems: 'center',
flexWrap: 'wrap',
}}
>
<ErrorOutlineIcon />
<Typography variant="subtitle1">Aborted</Typography>
</div>
);
const findAbortedMessage = (rollout: any): string | undefined =>
rollout.status?.conditions?.find(
(c: any) =>
c.type === 'Progressing' &&
c.status === 'False' &&
c.reason === 'RolloutAborted',
)?.message;
const RolloutSummary = ({
rollout,
numberOfCurrentPods,
numberOfPodsWithErrors,
hpa,
}: RolloutSummaryProps) => {
const pauseTime: string | undefined = rollout.status?.pauseConditions?.find(
(p: any) => p.reason === 'CanaryPauseStep',
)?.startTime;
const abortedMessage = findAbortedMessage(rollout);
return (
<Grid
container
direction="row"
justifyContent="space-between"
alignItems="center"
spacing={0}
>
<Grid xs={6} item>
<RolloutDrawer rollout={rollout} />
</Grid>
{hpa && (
<Grid item xs={3}>
<HorizontalPodAutoscalerDrawer hpa={hpa}>
<Grid
item
container
direction="column"
justifyContent="flex-start"
alignItems="flex-start"
spacing={0}
>
<Grid item>
<Typography variant="subtitle2">
min replicas {hpa.spec?.minReplicas ?? '?'} / max replicas{' '}
{hpa.spec?.maxReplicas ?? '?'}
</Typography>
</Grid>
<Grid item>
<Typography variant="subtitle2">
current CPU usage:{' '}
{hpa.status?.currentCPUUtilizationPercentage ?? '?'}%
</Typography>
</Grid>
<Grid item>
<Typography variant="subtitle2">
target CPU usage:{' '}
{hpa.spec?.targetCPUUtilizationPercentage ?? '?'}%
</Typography>
</Grid>
</Grid>
</HorizontalPodAutoscalerDrawer>
</Grid>
)}
<Grid
item
container
xs={3}
direction="column"
justifyContent="flex-start"
alignItems="flex-end"
spacing={0}
>
<Grid item>
<StatusOK>{numberOfCurrentPods} pods</StatusOK>
</Grid>
<Grid item>
{numberOfPodsWithErrors > 0 ? (
<StatusError>
{numberOfPodsWithErrors} pod
{numberOfPodsWithErrors > 1 ? 's' : ''} with errors
</StatusError>
) : (
<StatusOK>No pods with errors</StatusOK>
)}
</Grid>
</Grid>
{pauseTime && (
<Grid item xs={3}>
<div
style={{
display: 'flex',
alignItems: 'center',
flexWrap: 'wrap',
}}
>
<PauseIcon />
<Typography variant="subtitle1">
Paused ({DateTime.fromISO(pauseTime).toRelative({ locale: 'en' })}
)
</Typography>
</div>
</Grid>
)}
{abortedMessage && (
<Grid item xs={3}>
{AbortedTitle}
</Grid>
)}
</Grid>
);
};
const RolloutAccordion = ({
rollout,
ownedPods,
matchingHpa,
defaultExpanded,
}: RolloutAccordionProps) => {
const podNamesWithErrors = useContext(PodNamesWithErrorsContext);
const podsWithErrors = ownedPods.filter(p =>
podNamesWithErrors.has(p.metadata?.name ?? ''),
);
const currentStepIndex = rollout.status?.currentStepIndex ?? 0;
const abortedMessage = findAbortedMessage(rollout);
return (
<Accordion
defaultExpanded={defaultExpanded}
TransitionProps={{ unmountOnExit: true }}
variant="outlined"
>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<RolloutSummary
rollout={rollout}
numberOfCurrentPods={ownedPods.length}
numberOfPodsWithErrors={podsWithErrors.length}
hpa={matchingHpa}
/>
</AccordionSummary>
<AccordionDetails>
<div style={{ width: '100%' }}>
<div>
<Typography variant="h6">Rollout status</Typography>
</div>
<div style={{ margin: '1rem' }}>
{abortedMessage && (
<>
{AbortedTitle}
<Typography variant="subtitle2">{abortedMessage}</Typography>
</>
)}
<StepsProgress
aborted={abortedMessage !== undefined}
steps={rollout.spec?.strategy?.canary?.steps ?? []}
currentStepIndex={currentStepIndex}
/>
</div>
<div>
<PodsTable
pods={ownedPods}
extraColumns={[READY_COLUMNS, RESOURCE_COLUMNS]}
/>
</div>
</div>
</AccordionDetails>
</Accordion>
);
};
export const RolloutAccordions = ({
rollouts,
defaultExpanded = false,
}: RolloutAccordionsProps) => {
const groupedResponses = useContext(GroupedResponsesContext);
return (
<Grid
container
direction="column"
justifyContent="flex-start"
alignItems="flex-start"
>
{rollouts.map((rollout, i) => (
<Grid container item key={i} xs>
<Grid item xs>
<RolloutAccordion
defaultExpanded={defaultExpanded}
matchingHpa={getMatchingHpa(
{
name: rollout.metadata?.name,
namespace: rollout.metadata?.namespace,
kind: 'rollout',
},
groupedResponses.horizontalPodAutoscalers,
)}
ownedPods={getOwnedPodsThroughReplicaSets(
rollout,
groupedResponses.replicaSets,
groupedResponses.pods,
)}
rollout={rollout}
/>
</Grid>
</Grid>
))}
</Grid>
);
};
@@ -0,0 +1,55 @@
/*
* Copyright 2020 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import { KubernetesStructuredMetadataTableDrawer } from '../../KubernetesDrawer';
import { Typography, Grid } from '@material-ui/core';
export const RolloutDrawer = ({
rollout,
expanded,
}: {
rollout: any;
expanded?: boolean;
}) => {
return (
<KubernetesStructuredMetadataTableDrawer
object={rollout}
expanded={expanded}
kind="Rollout"
renderObject={() => ({})}
>
<Grid
container
direction="column"
justifyContent="flex-start"
alignItems="flex-start"
spacing={0}
>
<Grid item>
<Typography variant="body1">
{rollout.metadata?.name ?? 'unknown object'}
</Typography>
</Grid>
<Grid item>
<Typography color="textSecondary" variant="subtitle1">
Rollout
</Typography>
</Grid>
</Grid>
</KubernetesStructuredMetadataTableDrawer>
);
};
@@ -0,0 +1,127 @@
/*
* Copyright 2020 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import { screen } from '@testing-library/react';
import pauseSteps from './__fixtures__/pause-steps';
import setWeightSteps from './__fixtures__/setweight-steps';
import analysisSteps from './__fixtures__/analysis-steps';
import { renderInTestApp } from '@backstage/test-utils';
import { StepsProgress } from './StepsProgress';
describe('StepsProgress', () => {
it('should render Pause step text', async () => {
await renderInTestApp(
<StepsProgress currentStepIndex={0} aborted={false} steps={pauseSteps} />,
);
expect(screen.getByText('pause for 1h')).toBeInTheDocument();
expect(screen.getByText('infinite pause')).toBeInTheDocument();
});
it('should render SetWeight step text', async () => {
await renderInTestApp(
<StepsProgress
currentStepIndex={0}
aborted={false}
steps={setWeightSteps}
/>,
);
expect(screen.getByText('setWeight 10%')).toBeInTheDocument();
expect(screen.getByText('setWeight 95%')).toBeInTheDocument();
});
it('should render Analysis step text', async () => {
await renderInTestApp(
<StepsProgress
currentStepIndex={0}
aborted={false}
steps={analysisSteps}
/>,
);
expect(screen.getAllByText('analysis templates:')).toHaveLength(2);
expect(screen.getByText('always-pass')).toBeInTheDocument();
expect(screen.getByText('always-fail')).toBeInTheDocument();
expect(screen.getByText('req-rate (cluster scoped)')).toBeInTheDocument();
});
it('should render 3 different steps', async () => {
await renderInTestApp(
<StepsProgress
currentStepIndex={0}
aborted={false}
steps={[setWeightSteps[0], pauseSteps[0], analysisSteps[0]]}
/>,
);
expect(screen.getByText('setWeight 10%')).toBeInTheDocument();
expect(screen.getByText('pause for 1h')).toBeInTheDocument();
expect(screen.getByText('analysis templates:')).toBeInTheDocument();
expect(screen.getByText('always-pass')).toBeInTheDocument();
expect(screen.getByText('Canary promoted')).toBeInTheDocument();
});
it('current step is highlighted, previous steps are ticked', async () => {
await renderInTestApp(
<StepsProgress
currentStepIndex={1}
aborted={false}
steps={[setWeightSteps[0], pauseSteps[0], analysisSteps[0]]}
/>,
);
// It is ticked, so it's not visible
expect(screen.queryByText('1')).toBeNull();
// The current step
expect(screen.getByText('2')).toBeInTheDocument();
// The future step
expect(screen.getByText('3')).toBeInTheDocument();
// The canary promoted step should always be added at the end
expect(screen.getByText('4')).toBeInTheDocument();
});
it('aborted canary has all steps grey', async () => {
await renderInTestApp(
<StepsProgress
currentStepIndex={2}
aborted
steps={[setWeightSteps[0], pauseSteps[0], analysisSteps[0]]}
/>,
);
expect(screen.getByText('1')).toBeInTheDocument();
expect(screen.getByText('2')).toBeInTheDocument();
expect(screen.getByText('3')).toBeInTheDocument();
expect(screen.getByText('4')).toBeInTheDocument();
});
it('promoted canary has all steps ticked', async () => {
await renderInTestApp(
<StepsProgress
currentStepIndex={3}
aborted={false}
steps={[setWeightSteps[0], pauseSteps[0], analysisSteps[0]]}
/>,
);
expect(screen.queryByText('1')).toBeNull();
expect(screen.queryByText('2')).toBeNull();
expect(screen.queryByText('3')).toBeNull();
expect(screen.queryByText('4')).toBeNull();
});
});
@@ -0,0 +1,97 @@
/*
* Copyright 2020 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import { Step, StepLabel, Stepper } from '@material-ui/core';
import Typography from '@material-ui/core/Typography';
import {
ArgoRolloutCanaryStep,
SetWeightStep,
PauseStep,
AnalysisStep,
} from './types';
interface StepsProgressProps {
currentStepIndex: number;
aborted: boolean;
steps: ArgoRolloutCanaryStep[];
children?: React.ReactNode;
}
const isSetWeightStep = (step: ArgoRolloutCanaryStep): step is SetWeightStep =>
step.hasOwnProperty('setWeight');
const isPauseStep = (step: ArgoRolloutCanaryStep): step is PauseStep =>
step.hasOwnProperty('pause');
const isAnalysisStep = (step: ArgoRolloutCanaryStep): step is AnalysisStep =>
step.hasOwnProperty('analysis');
const createLabelForStep = (step: ArgoRolloutCanaryStep): React.ReactNode => {
if (isSetWeightStep(step)) {
return `setWeight ${step.setWeight}%`;
} else if (isPauseStep(step)) {
return step.pause.duration === undefined
? 'infinite pause'
: `pause for ${step.pause.duration}`;
} else if (isAnalysisStep(step)) {
return (
<div>
<Typography paragraph>analysis templates:</Typography>
{step.analysis.templates.map((t, i) => (
<Typography paragraph key={i}>{`${t.templateName}${
t.clusterScope ? ' (cluster scoped)' : ''
}`}</Typography>
))}
</div>
);
}
return 'unknown step';
};
export const StepsProgress = ({
currentStepIndex,
aborted,
steps,
}: StepsProgressProps) => {
// If the activeStep is greater/equal to the number of steps
// Then the canary is being promoted
// Increase the step index to mark the 'canary promoted' step as done also
const activeStepIndex =
currentStepIndex >= steps.length ? currentStepIndex + 1 : currentStepIndex;
/*
* When the Rollout is aborted set the active step to -1
* otherwise it appears to always be on the first step
*/
return (
<Stepper activeStep={aborted ? -1 : activeStepIndex} alternativeLabel>
{steps
.map((step, i) => (
<Step key={i}>
<StepLabel data-testid={`step-${i}`}>
{createLabelForStep(step)}
</StepLabel>
</Step>
))
.concat(
<Step key="-1">
<StepLabel data-testid="step--1">Canary promoted</StepLabel>
</Step>,
)}
</Stepper>
);
};
@@ -0,0 +1,129 @@
{
"apiVersion": "argoproj.io/v1alpha1",
"kind": "Rollout",
"metadata": {
"annotations": {
"kubectl.kubernetes.io/last-applied-configuration": "{\"apiVersion\":\"argoproj.io/v1alpha1\",\"kind\":\"Rollout\",\"metadata\":{\"annotations\":{},\"labels\":{\"backstage.io/kubernetes-id\":\"dice-roller\"},\"name\":\"dice-roller\",\"namespace\":\"default\"},\"spec\":{\"minReadySeconds\":30,\"replicas\":4,\"selector\":{\"matchLabels\":{\"app\":\"dice-roller\",\"backstage.io/kubernetes-id\":\"dice-roller\"}},\"strategy\":{\"canary\":{\"maxSurge\":\"25%\",\"maxUnavailable\":0,\"steps\":[{\"setWeight\":10},{\"analysis\":{\"templates\":[{\"templateName\":\"always-pass\"}]}},{\"pause\":{\"duration\":\"1m\"}},{\"setWeight\":20},{\"pause\":{\"duration\":\"1m\"}},{\"analysis\":{\"templates\":[{\"templateName\":\"always-pass\"}]}}]}},\"template\":{\"metadata\":{\"labels\":{\"app\":\"dice-roller\",\"backstage.io/kubernetes-id\":\"dice-roller\",\"start\":\"1234\"}},\"spec\":{\"containers\":[{\"image\":\"nginx:1.15.4\",\"name\":\"nginx\",\"ports\":[{\"containerPort\":80}]}]}}}}\n",
"rollout.argoproj.io/revision": "4"
},
"creationTimestamp": "2021-03-08T10:38:23Z",
"generation": 12,
"labels": {
"backstage.io/kubernetes-id": "dice-roller"
},
"name": "dice-roller",
"namespace": "default",
"resourceVersion": "3336911046",
"selfLink": "/apis/argoproj.io/v1alpha1/namespaces/default/rollouts/dice-roller",
"uid": "8552f08d-32e8-4f95-a43f-8524763eeg60"
},
"spec": {
"minReadySeconds": 30,
"replicas": 4,
"selector": {
"matchLabels": {
"app": "dice-roller",
"backstage.io/kubernetes-id": "dice-roller"
}
},
"strategy": {
"canary": {
"maxSurge": "25%",
"maxUnavailable": 0,
"steps": [
{
"setWeight": 10
},
{
"analysis": {
"templates": [
{
"templateName": "always-pass"
}
]
}
},
{
"pause": {
"duration": "1h"
}
},
{
"setWeight": 20
},
{
"pause": {
"duration": "1m"
}
},
{
"analysis": {
"templates": [
{
"templateName": "always-pass"
}
]
}
}
]
}
},
"template": {
"metadata": {
"creationTimestamp": null,
"labels": {
"app": "dice-roller",
"backstage.io/kubernetes-id": "dice-roller",
"start": "1236"
}
},
"spec": {
"containers": [
{
"image": "nginx:1.15.4",
"name": "nginx",
"ports": [
{
"containerPort": 80
}
],
"resources": {}
}
]
}
}
},
"status": {
"HPAReplicas": 4,
"availableReplicas": 4,
"blueGreen": {},
"canary": {},
"conditions": [
{
"lastTransitionTime": "2021-03-08T10:39:31Z",
"lastUpdateTime": "2021-03-08T10:39:31Z",
"message": "Rollout has minimum availability",
"reason": "AvailableReason",
"status": "True",
"type": "Available"
},
{
"lastTransitionTime": "2021-03-09T16:11:14Z",
"lastUpdateTime": "2021-03-09T16:13:09Z",
"message": "some metric related failure message",
"reason": "RolloutAborted",
"status": "False",
"type": "Progressing"
}
],
"currentPodHash": "546c476497",
"currentStepHash": "5b48bb87dc",
"currentStepIndex": 0,
"observedGeneration": "12",
"readyReplicas": 4,
"replicas": 4,
"selector": "app=dice-roller,backstage.io/kubernetes-id=dice-roller",
"stableRS": "546c476497",
"updatedReplicas": 4
}
}
@@ -0,0 +1,43 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { AnalysisStep } from '../types';
export const steps: AnalysisStep[] = [
{
analysis: {
templates: [
{
templateName: 'always-pass',
},
{
templateName: 'always-fail',
},
],
},
},
{
analysis: {
templates: [
{
templateName: 'req-rate',
clusterScope: true,
},
],
},
},
];
export default steps;
@@ -0,0 +1,576 @@
{
"pods": [
{
"metadata": {
"creationTimestamp": "2020-09-25T09:58:50.000Z",
"generateName": "dice-roller-6c8646bfd-",
"labels": {
"app": "dice-roller",
"backstage.io/kubernetes-id": "dice-roller",
"pod-template-hash": "6c8646bfd"
},
"managedFields": [
{
"apiVersion": "v1",
"fieldsType": "FieldsV1",
"fieldsV1": {
"f:metadata": {
"f:generateName": {},
"f:labels": {
".": {},
"f:app": {},
"f:backstage.io/kubernetes-id": {},
"f:pod-template-hash": {}
},
"f:ownerReferences": {
".": {},
"k:{\"uid\":\"5126c354-4310-4e23-a9e4-c9b87cb69792\"}": {
".": {},
"f:apiVersion": {},
"f:blockOwnerDeletion": {},
"f:controller": {},
"f:kind": {},
"f:name": {},
"f:uid": {}
}
}
},
"f:spec": {
"f:containers": {
"k:{\"name\":\"nginx\"}": {
".": {},
"f:image": {},
"f:imagePullPolicy": {},
"f:name": {},
"f:ports": {
".": {},
"k:{\"containerPort\":80,\"protocol\":\"TCP\"}": {
".": {},
"f:containerPort": {},
"f:protocol": {}
}
},
"f:resources": {},
"f:terminationMessagePath": {},
"f:terminationMessagePolicy": {}
}
},
"f:dnsPolicy": {},
"f:enableServiceLinks": {},
"f:restartPolicy": {},
"f:schedulerName": {},
"f:securityContext": {},
"f:terminationGracePeriodSeconds": {}
}
},
"manager": "kube-controller-manager",
"operation": "Update",
"time": "2020-09-25T09:58:50.000Z"
},
{
"apiVersion": "v1",
"fieldsType": "FieldsV1",
"fieldsV1": {
"f:status": {
"f:conditions": {
"k:{\"type\":\"ContainersReady\"}": {
".": {},
"f:lastProbeTime": {},
"f:lastTransitionTime": {},
"f:status": {},
"f:type": {}
},
"k:{\"type\":\"Initialized\"}": {
".": {},
"f:lastProbeTime": {},
"f:lastTransitionTime": {},
"f:status": {},
"f:type": {}
},
"k:{\"type\":\"Ready\"}": {
".": {},
"f:lastProbeTime": {},
"f:lastTransitionTime": {},
"f:status": {},
"f:type": {}
}
},
"f:containerStatuses": {},
"f:hostIP": {},
"f:phase": {},
"f:podIP": {},
"f:podIPs": {
".": {},
"k:{\"ip\":\"172.17.0.11\"}": {
".": {},
"f:ip": {}
}
},
"f:startTime": {}
}
},
"manager": "kubelet",
"operation": "Update",
"time": "2020-09-25T09:58:55.000Z"
}
],
"name": "dice-roller-6c8646bfd-2m5hv",
"namespace": "default",
"ownerReferences": [
{
"apiVersion": "apps/v1",
"blockOwnerDeletion": true,
"controller": true,
"kind": "ReplicaSet",
"name": "dice-roller-6c8646bfd",
"uid": "5126c354-4310-4e23-a9e4-c9b87cb69792"
}
],
"resourceVersion": "593216",
"selfLink": "/api/v1/namespaces/default/pods/dice-roller-6c8646bfd-2m5hv",
"uid": "aadb71c0-36fa-43e3-b38a-162f134d4359"
},
"spec": {
"containers": [
{
"image": "nginx:1.14.2",
"imagePullPolicy": "IfNotPresent",
"name": "nginx",
"ports": [
{
"containerPort": 80,
"protocol": "TCP"
}
],
"resources": {},
"terminationMessagePath": "/dev/termination-log",
"terminationMessagePolicy": "File",
"volumeMounts": [
{
"mountPath": "/var/run/secrets/kubernetes.io/serviceaccount",
"name": "default-token-5gctn",
"readOnly": true
}
]
}
],
"dnsPolicy": "ClusterFirst",
"enableServiceLinks": true,
"nodeName": "minikube",
"priority": 0,
"restartPolicy": "Always",
"schedulerName": "default-scheduler",
"securityContext": {},
"serviceAccount": "default",
"serviceAccountName": "default",
"terminationGracePeriodSeconds": 30,
"tolerations": [
{
"effect": "NoExecute",
"key": "node.kubernetes.io/not-ready",
"operator": "Exists",
"tolerationSeconds": 300
},
{
"effect": "NoExecute",
"key": "node.kubernetes.io/unreachable",
"operator": "Exists",
"tolerationSeconds": 300
}
],
"volumes": [
{
"name": "default-token-5gctn",
"secret": {
"defaultMode": 420,
"secretName": "default-token-5gctn"
}
}
]
},
"status": {
"conditions": [
{
"lastProbeTime": null,
"lastTransitionTime": "2020-09-25T09:58:50.000Z",
"status": "True",
"type": "Initialized"
},
{
"lastProbeTime": null,
"lastTransitionTime": "2020-09-25T09:58:55.000Z",
"status": "True",
"type": "Ready"
},
{
"lastProbeTime": null,
"lastTransitionTime": "2020-09-25T09:58:55.000Z",
"status": "True",
"type": "ContainersReady"
},
{
"lastProbeTime": null,
"lastTransitionTime": "2020-09-25T09:58:50.000Z",
"status": "True",
"type": "PodScheduled"
}
],
"containerStatuses": [
{
"containerID": "docker://aa4489297c34c48bb33c18474a8d2b33854a82ed42155680b259f635f556ce70",
"image": "nginx:1.14.2",
"imageID": "docker-pullable://nginx@sha256:f7988fb6c02e0ce69257d9bd9cf37ae20a60f1df7563c3a2a6abe24160306b8d",
"lastState": {},
"name": "nginx",
"ready": true,
"restartCount": 0,
"started": true,
"state": {
"running": {
"startedAt": "2020-09-25T09:58:53.000Z"
}
}
}
],
"hostIP": "192.168.64.2",
"phase": "Running",
"podIP": "172.17.0.11",
"podIPs": [
{
"ip": "172.17.0.11"
}
],
"qosClass": "BestEffort",
"startTime": "2020-09-25T09:58:50.000Z"
}
},
{
"metadata": {
"creationTimestamp": "2020-09-25T09:58:50.000Z",
"generateName": "dice-roller-6c8646bfd-",
"labels": {
"app": "dice-roller",
"backstage.io/kubernetes-id": "dice-roller",
"pod-template-hash": "6c8646bfd"
},
"managedFields": [
{
"apiVersion": "v1",
"fieldsType": "FieldsV1",
"fieldsV1": {
"f:metadata": {
"f:generateName": {},
"f:labels": {
".": {},
"f:app": {},
"f:backstage.io/kubernetes-id": {},
"f:pod-template-hash": {}
},
"f:ownerReferences": {
".": {},
"k:{\"uid\":\"5126c354-4310-4e23-a9e4-c9b87cb69792\"}": {
".": {},
"f:apiVersion": {},
"f:blockOwnerDeletion": {},
"f:controller": {},
"f:kind": {},
"f:name": {},
"f:uid": {}
}
}
},
"f:spec": {
"f:containers": {
"k:{\"name\":\"nginx\"}": {
".": {},
"f:image": {},
"f:imagePullPolicy": {},
"f:name": {},
"f:ports": {
".": {},
"k:{\"containerPort\":80,\"protocol\":\"TCP\"}": {
".": {},
"f:containerPort": {},
"f:protocol": {}
}
},
"f:resources": {},
"f:terminationMessagePath": {},
"f:terminationMessagePolicy": {}
}
},
"f:dnsPolicy": {},
"f:enableServiceLinks": {},
"f:restartPolicy": {},
"f:schedulerName": {},
"f:securityContext": {},
"f:terminationGracePeriodSeconds": {}
}
},
"manager": "kube-controller-manager",
"operation": "Update",
"time": "2020-09-25T09:58:50.000Z"
},
{
"apiVersion": "v1",
"fieldsType": "FieldsV1",
"fieldsV1": {
"f:status": {
"f:conditions": {
"k:{\"type\":\"ContainersReady\"}": {
".": {},
"f:lastProbeTime": {},
"f:lastTransitionTime": {},
"f:status": {},
"f:type": {}
},
"k:{\"type\":\"Initialized\"}": {
".": {},
"f:lastProbeTime": {},
"f:lastTransitionTime": {},
"f:status": {},
"f:type": {}
},
"k:{\"type\":\"Ready\"}": {
".": {},
"f:lastProbeTime": {},
"f:lastTransitionTime": {},
"f:status": {},
"f:type": {}
}
},
"f:containerStatuses": {},
"f:hostIP": {},
"f:phase": {},
"f:podIP": {},
"f:podIPs": {
".": {},
"k:{\"ip\":\"172.17.0.7\"}": {
".": {},
"f:ip": {}
}
},
"f:startTime": {}
}
},
"manager": "kubelet",
"operation": "Update",
"time": "2020-09-25T09:58:55.000Z"
}
],
"name": "dice-roller-6c8646bfd-4v6s8",
"namespace": "default",
"ownerReferences": [
{
"apiVersion": "apps/v1",
"blockOwnerDeletion": true,
"controller": true,
"kind": "ReplicaSet",
"name": "dice-roller-6c8646bfd",
"uid": "5126c354-4310-4e23-a9e4-c9b87cb69792"
}
],
"resourceVersion": "593221",
"selfLink": "/api/v1/namespaces/default/pods/dice-roller-6c8646bfd-4v6s8",
"uid": "32e56490-6f20-4757-991f-a0c7fb407358"
},
"spec": {
"containers": [
{
"image": "nginx:1.14.2",
"imagePullPolicy": "IfNotPresent",
"name": "nginx",
"ports": [
{
"containerPort": 80,
"protocol": "TCP"
}
],
"resources": {},
"terminationMessagePath": "/dev/termination-log",
"terminationMessagePolicy": "File",
"volumeMounts": [
{
"mountPath": "/var/run/secrets/kubernetes.io/serviceaccount",
"name": "default-token-5gctn",
"readOnly": true
}
]
}
],
"dnsPolicy": "ClusterFirst",
"enableServiceLinks": true,
"nodeName": "minikube",
"priority": 0,
"restartPolicy": "Always",
"schedulerName": "default-scheduler",
"securityContext": {},
"serviceAccount": "default",
"serviceAccountName": "default",
"terminationGracePeriodSeconds": 30,
"tolerations": [
{
"effect": "NoExecute",
"key": "node.kubernetes.io/not-ready",
"operator": "Exists",
"tolerationSeconds": 300
},
{
"effect": "NoExecute",
"key": "node.kubernetes.io/unreachable",
"operator": "Exists",
"tolerationSeconds": 300
}
],
"volumes": [
{
"name": "default-token-5gctn",
"secret": {
"defaultMode": 420,
"secretName": "default-token-5gctn"
}
}
]
},
"status": {
"conditions": [
{
"lastProbeTime": null,
"lastTransitionTime": "2020-09-25T09:58:50.000Z",
"status": "True",
"type": "Initialized"
},
{
"lastProbeTime": null,
"lastTransitionTime": "2020-09-25T09:58:55.000Z",
"status": "True",
"type": "Ready"
},
{
"lastProbeTime": null,
"lastTransitionTime": "2020-09-25T09:58:55.000Z",
"status": "True",
"type": "ContainersReady"
},
{
"lastProbeTime": null,
"lastTransitionTime": "2020-09-25T09:58:50.000Z",
"status": "True",
"type": "PodScheduled"
}
],
"containerStatuses": [
{
"containerID": "docker://d91cc76c41249b8d3dfcf538d180b471b2a7966aae0d17a818307c8a9b4af897",
"image": "nginx:1.14.2",
"imageID": "docker-pullable://nginx@sha256:f7988fb6c02e0ce69257d9bd9cf37ae20a60f1df7563c3a2a6abe24160306b8d",
"lastState": {},
"name": "nginx",
"ready": true,
"restartCount": 0,
"started": true,
"state": {
"running": {
"startedAt": "2020-09-25T09:58:53.000Z"
}
}
}
],
"hostIP": "192.168.64.2",
"phase": "Running",
"podIP": "172.17.0.7",
"podIPs": [
{
"ip": "172.17.0.7"
}
],
"qosClass": "BestEffort",
"startTime": "2020-09-25T09:58:50.000Z"
}
}
],
"horizontalPodAutoscalers": [],
"replicaSets": [
{
"metadata": {
"annotations": {
"deployment.kubernetes.io/desired-replicas": "10",
"deployment.kubernetes.io/max-replicas": "13",
"deployment.kubernetes.io/revision": "2"
},
"creationTimestamp": "2020-09-24T11:39:26.000Z",
"generation": 3,
"labels": {
"app": "dice-roller",
"backstage.io/kubernetes-id": "dice-roller",
"pod-template-hash": "6c8646bfd"
},
"managedFields": [],
"name": "dice-roller-6c8646bfd",
"namespace": "default",
"ownerReferences": [
{
"apiVersion": "apps/v1",
"blockOwnerDeletion": true,
"controller": true,
"kind": "Rollout",
"name": "dice-roller",
"uid": "8552f08d-32e8-4f95-a43f-8524763eeg60"
}
],
"resourceVersion": "593228",
"selfLink": "/apis/apps/v1/namespaces/default/replicasets/dice-roller-6c8646bfd",
"uid": "5126c354-4310-4e23-a9e4-c9b87cb69792"
},
"spec": {
"replicas": 10,
"selector": {
"matchLabels": {
"app": "dice-roller",
"pod-template-hash": "6c8646bfd"
}
},
"template": {
"metadata": {
"creationTimestamp": null,
"labels": {
"app": "dice-roller",
"backstage.io/kubernetes-id": "dice-roller",
"pod-template-hash": "6c8646bfd"
}
},
"spec": {
"containers": [
{
"image": "nginx:1.14.2",
"imagePullPolicy": "IfNotPresent",
"name": "nginx",
"ports": [
{
"containerPort": 80,
"protocol": "TCP"
}
],
"resources": {},
"terminationMessagePath": "/dev/termination-log",
"terminationMessagePolicy": "File"
}
],
"dnsPolicy": "ClusterFirst",
"restartPolicy": "Always",
"schedulerName": "default-scheduler",
"securityContext": {},
"terminationGracePeriodSeconds": 30
}
}
},
"status": {
"availableReplicas": 10,
"fullyLabeledReplicas": 10,
"observedGeneration": 3,
"readyReplicas": 10,
"replicas": 10
}
}
]
}
@@ -0,0 +1,29 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { PauseStep } from '../types';
export const steps: PauseStep[] = [
{
pause: {
duration: '1h',
},
},
{
pause: {},
},
];
export default steps;
@@ -0,0 +1,135 @@
{
"apiVersion": "argoproj.io/v1alpha1",
"kind": "Rollout",
"metadata": {
"annotations": {
"kubectl.kubernetes.io/last-applied-configuration": "{\"apiVersion\":\"argoproj.io/v1alpha1\",\"kind\":\"Rollout\",\"metadata\":{\"annotations\":{},\"labels\":{\"backstage.io/kubernetes-id\":\"dice-roller\"},\"name\":\"dice-roller\",\"namespace\":\"default\"},\"spec\":{\"minReadySeconds\":30,\"replicas\":4,\"selector\":{\"matchLabels\":{\"app\":\"dice-roller\",\"backstage.io/kubernetes-id\":\"dice-roller\"}},\"strategy\":{\"canary\":{\"maxSurge\":\"25%\",\"maxUnavailable\":0,\"steps\":[{\"setWeight\":10},{\"analysis\":{\"templates\":[{\"templateName\":\"always-pass\"}]}},{\"pause\":{\"duration\":\"1m\"}},{\"setWeight\":20},{\"pause\":{\"duration\":\"1m\"}},{\"analysis\":{\"templates\":[{\"templateName\":\"always-pass\"}]}}]}},\"template\":{\"metadata\":{\"labels\":{\"app\":\"dice-roller\",\"backstage.io/kubernetes-id\":\"dice-roller\",\"start\":\"1234\"}},\"spec\":{\"containers\":[{\"image\":\"nginx:1.15.4\",\"name\":\"nginx\",\"ports\":[{\"containerPort\":80}]}]}}}}\n",
"rollout.argoproj.io/revision": "4"
},
"creationTimestamp": "2021-03-08T10:38:23Z",
"generation": 12,
"labels": {
"backstage.io/kubernetes-id": "dice-roller"
},
"name": "dice-roller",
"namespace": "default",
"resourceVersion": "3336911046",
"selfLink": "/apis/argoproj.io/v1alpha1/namespaces/default/rollouts/dice-roller",
"uid": "8552f08d-32e8-4f95-a43f-8524763eeg60"
},
"spec": {
"minReadySeconds": 30,
"replicas": 4,
"selector": {
"matchLabels": {
"app": "dice-roller",
"backstage.io/kubernetes-id": "dice-roller"
}
},
"strategy": {
"canary": {
"maxSurge": "25%",
"maxUnavailable": 0,
"steps": [
{
"setWeight": 10
},
{
"analysis": {
"templates": [
{
"templateName": "always-pass"
}
]
}
},
{
"pause": {
"duration": "1h"
}
},
{
"setWeight": 20
},
{
"pause": {
"duration": "1m"
}
},
{
"analysis": {
"templates": [
{
"templateName": "always-pass"
}
]
}
}
]
}
},
"template": {
"metadata": {
"creationTimestamp": null,
"labels": {
"app": "dice-roller",
"backstage.io/kubernetes-id": "dice-roller",
"start": "1236"
}
},
"spec": {
"containers": [
{
"image": "nginx:1.15.4",
"name": "nginx",
"ports": [
{
"containerPort": 80
}
],
"resources": {}
}
]
}
}
},
"status": {
"HPAReplicas": 4,
"availableReplicas": 4,
"blueGreen": {},
"canary": {},
"pauseConditions": [
{
"reason": "CanaryPauseStep",
"startTime": "SET DYNAMICALLY IN TEST"
}
],
"conditions": [
{
"lastTransitionTime": "2021-03-08T10:39:31Z",
"lastUpdateTime": "2021-03-08T10:39:31Z",
"message": "Rollout has minimum availability",
"reason": "AvailableReason",
"status": "True",
"type": "Available"
},
{
"lastTransitionTime": "2021-03-09T16:11:14Z",
"lastUpdateTime": "2021-03-09T16:13:09Z",
"message": "ReplicaSet \"dice-roller-546c476497\" has successfully progressed.",
"reason": "NewReplicaSetAvailable",
"status": "True",
"type": "Progressing"
}
],
"currentPodHash": "546c476497",
"currentStepHash": "5b48bb87dc",
"currentStepIndex": 2,
"observedGeneration": "12",
"readyReplicas": 4,
"replicas": 4,
"selector": "app=dice-roller,backstage.io/kubernetes-id=dice-roller",
"stableRS": "546c476497",
"updatedReplicas": 4
}
}
@@ -0,0 +1,129 @@
{
"apiVersion": "argoproj.io/v1alpha1",
"kind": "Rollout",
"metadata": {
"annotations": {
"kubectl.kubernetes.io/last-applied-configuration": "{\"apiVersion\":\"argoproj.io/v1alpha1\",\"kind\":\"Rollout\",\"metadata\":{\"annotations\":{},\"labels\":{\"backstage.io/kubernetes-id\":\"dice-roller\"},\"name\":\"dice-roller\",\"namespace\":\"default\"},\"spec\":{\"minReadySeconds\":30,\"replicas\":4,\"selector\":{\"matchLabels\":{\"app\":\"dice-roller\",\"backstage.io/kubernetes-id\":\"dice-roller\"}},\"strategy\":{\"canary\":{\"maxSurge\":\"25%\",\"maxUnavailable\":0,\"steps\":[{\"setWeight\":10},{\"analysis\":{\"templates\":[{\"templateName\":\"always-pass\"}]}},{\"pause\":{\"duration\":\"1m\"}},{\"setWeight\":20},{\"pause\":{\"duration\":\"1m\"}},{\"analysis\":{\"templates\":[{\"templateName\":\"always-pass\"}]}}]}},\"template\":{\"metadata\":{\"labels\":{\"app\":\"dice-roller\",\"backstage.io/kubernetes-id\":\"dice-roller\",\"start\":\"1234\"}},\"spec\":{\"containers\":[{\"image\":\"nginx:1.15.4\",\"name\":\"nginx\",\"ports\":[{\"containerPort\":80}]}]}}}}\n",
"rollout.argoproj.io/revision": "4"
},
"creationTimestamp": "2021-03-08T10:38:23Z",
"generation": 12,
"labels": {
"backstage.io/kubernetes-id": "dice-roller"
},
"name": "dice-roller",
"namespace": "default",
"resourceVersion": "3336911046",
"selfLink": "/apis/argoproj.io/v1alpha1/namespaces/default/rollouts/dice-roller",
"uid": "8552f08d-32e8-4f95-a43f-8524763eeg60"
},
"spec": {
"minReadySeconds": 30,
"replicas": 4,
"selector": {
"matchLabels": {
"app": "dice-roller",
"backstage.io/kubernetes-id": "dice-roller"
}
},
"strategy": {
"canary": {
"maxSurge": "25%",
"maxUnavailable": 0,
"steps": [
{
"setWeight": 10
},
{
"analysis": {
"templates": [
{
"templateName": "always-pass"
}
]
}
},
{
"pause": {
"duration": "1h"
}
},
{
"setWeight": 20
},
{
"pause": {
"duration": "1m"
}
},
{
"analysis": {
"templates": [
{
"templateName": "always-pass"
}
]
}
}
]
}
},
"template": {
"metadata": {
"creationTimestamp": null,
"labels": {
"app": "dice-roller",
"backstage.io/kubernetes-id": "dice-roller",
"start": "1236"
}
},
"spec": {
"containers": [
{
"image": "nginx:1.15.4",
"name": "nginx",
"ports": [
{
"containerPort": 80
}
],
"resources": {}
}
]
}
}
},
"status": {
"HPAReplicas": 4,
"availableReplicas": 4,
"blueGreen": {},
"canary": {},
"conditions": [
{
"lastTransitionTime": "2021-03-08T10:39:31Z",
"lastUpdateTime": "2021-03-08T10:39:31Z",
"message": "Rollout has minimum availability",
"reason": "AvailableReason",
"status": "True",
"type": "Available"
},
{
"lastTransitionTime": "2021-03-09T16:11:14Z",
"lastUpdateTime": "2021-03-09T16:13:09Z",
"message": "ReplicaSet \"dice-roller-546c476497\" has successfully progressed.",
"reason": "NewReplicaSetAvailable",
"status": "True",
"type": "Progressing"
}
],
"currentPodHash": "546c476497",
"currentStepHash": "5b48bb87dc",
"currentStepIndex": 6,
"observedGeneration": "12",
"readyReplicas": 4,
"replicas": 4,
"selector": "app=dice-roller,backstage.io/kubernetes-id=dice-roller",
"stableRS": "546c476497",
"updatedReplicas": 4
}
}
@@ -0,0 +1,27 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { SetWeightStep } from '../types';
export const steps: SetWeightStep[] = [
{
setWeight: 10,
},
{
setWeight: 95,
},
];
export default steps;
@@ -0,0 +1,16 @@
/*
* Copyright 2020 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export * from './Rollout';
@@ -0,0 +1,36 @@
/*
* Copyright 2020 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export interface SetWeightStep {
setWeight: number;
}
export interface PauseStep {
pause: {
duration?: string;
};
}
export interface AnalysisStep {
analysis: {
templates: {
templateName: string;
clusterScope?: boolean;
}[];
};
}
export type ArgoRolloutCanaryStep = SetWeightStep | PauseStep | AnalysisStep;
@@ -0,0 +1,65 @@
/*
* Copyright 2020 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { useContext } from 'react';
import lodash, { Dictionary } from 'lodash';
import { RolloutAccordions } from './ArgoRollouts';
import { DefaultCustomResourceAccordions } from './DefaultCustomResource';
import { GroupedResponsesContext } from '../../hooks';
/**
*
*
* @public
*/
export interface CustomResourcesProps {
children?: React.ReactNode;
}
const kindToResource = (customResources: any[]): Dictionary<any[]> => {
return lodash.groupBy(customResources, value => {
return value.kind;
});
};
/**
*
*
* @public
*/
export const CustomResources = ({}: CustomResourcesProps) => {
const groupedResponses = useContext(GroupedResponsesContext);
const kindToResourceMap = kindToResource(groupedResponses.customResources);
return (
<>
{Object.entries(kindToResourceMap).map(([kind, resources], i) => {
switch (kind) {
case 'Rollout':
return <RolloutAccordions key={i} rollouts={resources} />;
default:
return (
<DefaultCustomResourceAccordions
key={i}
customResources={resources}
customResourceName={kind}
/>
);
}
})}
</>
);
};
@@ -0,0 +1,39 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import { screen } from '@testing-library/react';
import { renderInTestApp } from '@backstage/test-utils';
import { kubernetesProviders } from '../../hooks/test-utils';
import * as ar from './__fixtures__/analysis-run.json';
import { DefaultCustomResourceAccordions } from './DefaultCustomResource';
describe('DefaultCustomResource', () => {
it('should render DefaultCustomResource Accordion', async () => {
const wrapper = kubernetesProviders({}, new Set([]));
await renderInTestApp(
wrapper(
<DefaultCustomResourceAccordions
customResources={[ar] as any}
customResourceName="AnalysisRun"
/>,
),
);
expect(screen.getByText('dice-roller-546c476497-4-1')).toBeInTheDocument();
expect(screen.getByText('AnalysisRun')).toBeInTheDocument();
});
});
@@ -0,0 +1,121 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import {
Accordion,
AccordionDetails,
AccordionSummary,
Grid,
} from '@material-ui/core';
import ExpandMoreIcon from '@material-ui/icons/ExpandMore';
import { DefaultCustomResourceDrawer } from './DefaultCustomResourceDrawer';
import { StructuredMetadataTable } from '@backstage/core-components';
type DefaultCustomResourceAccordionsProps = {
customResources: any[];
customResourceName: string;
defaultExpanded?: boolean;
children?: React.ReactNode;
};
type DefaultCustomResourceAccordionProps = {
customResource: any;
customResourceName: string;
defaultExpanded?: boolean;
children?: React.ReactNode;
};
type DefaultCustomResourceSummaryProps = {
customResource: any;
customResourceName: string;
children?: React.ReactNode;
};
const DefaultCustomResourceSummary = ({
customResource,
customResourceName,
}: DefaultCustomResourceSummaryProps) => {
return (
<Grid
container
direction="row"
justifyContent="space-between"
alignItems="center"
spacing={0}
>
<Grid xs={12} item>
<DefaultCustomResourceDrawer
customResource={customResource}
customResourceName={customResourceName}
/>
</Grid>
</Grid>
);
};
const DefaultCustomResourceAccordion = ({
customResource,
customResourceName,
defaultExpanded,
}: DefaultCustomResourceAccordionProps) => {
return (
<Accordion
defaultExpanded={defaultExpanded}
TransitionProps={{ unmountOnExit: true }}
variant="outlined"
>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<DefaultCustomResourceSummary
customResource={customResource}
customResourceName={customResourceName}
/>
</AccordionSummary>
<AccordionDetails>
{customResource.hasOwnProperty('status') && (
<StructuredMetadataTable metadata={customResource.status} />
)}
</AccordionDetails>
</Accordion>
);
};
export const DefaultCustomResourceAccordions = ({
customResources,
customResourceName,
defaultExpanded = false,
}: DefaultCustomResourceAccordionsProps) => {
return (
<Grid
container
direction="column"
justifyContent="flex-start"
alignItems="flex-start"
>
{customResources.map((cr, i) => (
<Grid container item key={i} xs>
<Grid item xs>
<DefaultCustomResourceAccordion
defaultExpanded={defaultExpanded}
customResource={cr}
customResourceName={customResourceName}
/>
</Grid>
</Grid>
))}
</Grid>
);
};
@@ -0,0 +1,62 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import { KubernetesStructuredMetadataTableDrawer } from '../KubernetesDrawer';
import { Typography, Grid } from '@material-ui/core';
const capitalize = (str: string) =>
str.charAt(0).toLocaleUpperCase('en-US') + str.slice(1);
export const DefaultCustomResourceDrawer = ({
customResource,
customResourceName,
expanded,
}: {
customResource: any;
customResourceName: string;
expanded?: boolean;
}) => {
const capitalizedName = capitalize(customResourceName);
return (
<KubernetesStructuredMetadataTableDrawer
object={customResource}
expanded={expanded}
kind={capitalizedName}
renderObject={cr => cr}
>
<Grid
container
direction="column"
justifyContent="flex-start"
alignItems="flex-start"
spacing={0}
>
<Grid item>
<Typography variant="body1">
{customResource.metadata?.name ?? 'unknown object'}
</Typography>
</Grid>
<Grid item>
<Typography color="textSecondary" variant="subtitle1">
{capitalizedName}
</Typography>
</Grid>
</Grid>
</KubernetesStructuredMetadataTableDrawer>
);
};
@@ -0,0 +1,129 @@
{
"apiVersion": "argoproj.io/v1alpha1",
"kind": "AnalysisRun",
"metadata": {
"annotations": {
"rollout.argoproj.io/revision": "4"
},
"creationTimestamp": "2021-03-09T15:05:49Z",
"generation": 11,
"labels": {
"backstage.io/kubernetes-id": "dice-roller",
"rollout-type": "Step",
"rollouts-pod-template-hash": "546c476497",
"step-index": "1"
},
"name": "dice-roller-546c476497-4-1",
"namespace": "default",
"ownerReferences": [
{
"apiVersion": "argoproj.io/v1alpha1",
"blockOwnerDeletion": true,
"controller": true,
"kind": "Rollout",
"name": "dice-roller",
"uid": "8552f08d-32e8-4f95-a43f-8524763eef60"
}
],
"resourceVersion": "3342462005",
"selfLink": "/apis/argoproj.io/v1alpha1/namespaces/default/analysisruns/dice-roller-546c476497-4-1",
"uid": "b4e720ea-0488-42e2-8bd9-924c78843ee2"
},
"spec": {
"metrics": [
{
"count": 5,
"interval": "5s",
"name": "always-pass",
"provider": {
"job": {
"metadata": {
"creationTimestamp": null
},
"spec": {
"backoffLimit": 1,
"template": {
"metadata": {
"creationTimestamp": null
},
"spec": {
"containers": [
{
"image": "some-image",
"name": "always-pass",
"resources": {
"limits": {
"cpu": "800m",
"memory": "1G"
},
"requests": {
"cpu": "200m",
"memory": "1G"
}
}
}
],
"restartPolicy": "Never"
}
}
}
}
}
}
]
},
"status": {
"metricResults": [
{
"count": 5,
"measurements": [
{
"finishedAt": "2021-03-09T15:06:13Z",
"metadata": {
"job-name": "b4e720ea-0488-42e2-8bd9-924c78843ee2.always-pass.1"
},
"phase": "Successful",
"startedAt": "2021-03-09T15:05:49Z"
},
{
"finishedAt": "2021-03-09T15:06:41Z",
"metadata": {
"job-name": "b4e720ea-0488-42e2-8bd9-924c78843ee2.always-pass.2"
},
"phase": "Successful",
"startedAt": "2021-03-09T15:06:18Z"
},
{
"finishedAt": "2021-03-09T15:07:08Z",
"metadata": {
"job-name": "b4e720ea-0488-42e2-8bd9-924c78843ee2.always-pass.3"
},
"phase": "Successful",
"startedAt": "2021-03-09T15:06:46Z"
},
{
"finishedAt": "2021-03-09T15:07:35Z",
"metadata": {
"job-name": "b4e720ea-0488-42e2-8bd9-924c78843ee2.always-pass.4"
},
"phase": "Successful",
"startedAt": "2021-03-09T15:07:13Z"
},
{
"finishedAt": "2021-03-09T15:08:02Z",
"metadata": {
"job-name": "b4e720ea-0488-42e2-8bd9-924c78843ee2.always-pass.5"
},
"phase": "Successful",
"startedAt": "2021-03-09T15:07:40Z"
}
],
"name": "always-pass",
"phase": "Successful",
"successful": 5
}
],
"phase": "Successful",
"startedAt": "2021-03-09T15:05:49Z"
}
}
@@ -0,0 +1,16 @@
/*
* Copyright 2020 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export * from './CustomResources';
@@ -0,0 +1,67 @@
/*
* Copyright 2020 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import * as deployments from '../../__fixtures__/2-deployments.json';
import { renderInTestApp, textContentMatcher } from '@backstage/test-utils';
import { DeploymentDrawer } from './DeploymentDrawer';
describe('DeploymentDrawer', () => {
it('should render deployment drawer', async () => {
const { getByText, getAllByText } = await renderInTestApp(
<DeploymentDrawer
deployment={(deployments as any).deployments[0]}
expanded
/>,
);
expect(getAllByText('dice-roller')).toHaveLength(2);
expect(getAllByText('Deployment')).toHaveLength(2);
expect(getByText('YAML')).toBeInTheDocument();
expect(getByText('Strategy')).toBeInTheDocument();
expect(getByText('Rolling Update:')).toBeInTheDocument();
expect(getByText(textContentMatcher('Max Surge: 25%'))).toBeInTheDocument();
expect(
getByText(textContentMatcher('Max Unavailable: 25%')),
).toBeInTheDocument();
expect(
getByText(textContentMatcher('Type: RollingUpdate')),
).toBeInTheDocument();
expect(getByText('Min Ready Seconds')).toBeInTheDocument();
expect(getByText('???')).toBeInTheDocument();
expect(getByText('Progress Deadline Seconds')).toBeInTheDocument();
expect(getByText('600')).toBeInTheDocument();
expect(getByText('Progressing')).toBeInTheDocument();
expect(getByText('Available')).toBeInTheDocument();
expect(getByText('namespace: default')).toBeInTheDocument();
expect(getAllByText('True')).toHaveLength(2);
});
it('should render deployment drawer without namespace', async () => {
const deployment = (deployments as any).deployments[0];
const { queryByText } = await renderInTestApp(
<DeploymentDrawer
deployment={{
...deployment,
metadata: { ...deployment.metadata, namespace: undefined },
}}
expanded
/>,
);
expect(queryByText('namespace: default')).not.toBeInTheDocument();
});
});
@@ -0,0 +1,78 @@
/*
* Copyright 2020 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import { V1Deployment } from '@kubernetes/client-node';
import { KubernetesStructuredMetadataTableDrawer } from '../KubernetesDrawer';
import { renderCondition } from '../../utils/pod';
import { Typography, Grid, Chip } from '@material-ui/core';
export const DeploymentDrawer = ({
deployment,
expanded,
}: {
deployment: V1Deployment;
expanded?: boolean;
}) => {
const namespace = deployment.metadata?.namespace;
return (
<KubernetesStructuredMetadataTableDrawer
object={deployment}
expanded={expanded}
kind="Deployment"
renderObject={(deploymentObj: V1Deployment) => {
const conditions = (deploymentObj.status?.conditions ?? [])
.map(renderCondition)
.reduce((accum, next) => {
accum[next[0]] = next[1];
return accum;
}, {} as { [key: string]: React.ReactNode });
return {
strategy: deploymentObj.spec?.strategy ?? '???',
minReadySeconds: deploymentObj.spec?.minReadySeconds ?? '???',
progressDeadlineSeconds:
deploymentObj.spec?.progressDeadlineSeconds ?? '???',
...conditions,
};
}}
>
<Grid
container
direction="column"
justifyContent="flex-start"
alignItems="flex-start"
spacing={0}
>
<Grid item>
<Typography variant="body1">
{deployment.metadata?.name ?? 'unknown object'}
</Typography>
</Grid>
<Grid item>
<Typography color="textSecondary" variant="subtitle1">
Deployment
</Typography>
</Grid>
{namespace && (
<Grid item>
<Chip size="small" label={`namespace: ${namespace}`} />
</Grid>
)}
</Grid>
</KubernetesStructuredMetadataTableDrawer>
);
};
@@ -0,0 +1,46 @@
/*
* Copyright 2020 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import { screen } from '@testing-library/react';
import { DeploymentsAccordions } from './DeploymentsAccordions';
import * as twoDeployFixture from '../../__fixtures__/2-deployments.json';
import { renderInTestApp } from '@backstage/test-utils';
import { kubernetesProviders } from '../../hooks/test-utils';
describe('DeploymentsAccordions', () => {
it('should render 2 deployments', async () => {
const wrapper = kubernetesProviders(
twoDeployFixture,
new Set(['dice-roller-canary-7d64cd756c-vtbdx']),
);
await renderInTestApp(wrapper(<DeploymentsAccordions />));
expect(screen.getByText('dice-roller')).toBeInTheDocument();
expect(screen.getByText('10 pods')).toBeInTheDocument();
expect(screen.getByText('No pods with errors')).toBeInTheDocument();
expect(
screen.getByText('min replicas 10 / max replicas 15'),
).toBeInTheDocument();
expect(screen.getByText('current CPU usage: 30%')).toBeInTheDocument();
expect(screen.getByText('target CPU usage: 50%')).toBeInTheDocument();
expect(screen.getByText('dice-roller-canary')).toBeInTheDocument();
expect(screen.getByText('2 pods')).toBeInTheDocument();
expect(screen.getByText('1 pod with errors')).toBeInTheDocument();
});
});
@@ -0,0 +1,206 @@
/*
* Copyright 2020 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { useContext } from 'react';
import {
Accordion,
AccordionDetails,
AccordionSummary,
Grid,
Typography,
} from '@material-ui/core';
import ExpandMoreIcon from '@material-ui/icons/ExpandMore';
import {
V1Deployment,
V1Pod,
V1HorizontalPodAutoscaler,
} from '@kubernetes/client-node';
import { PodsTable } from '../Pods';
import { DeploymentDrawer } from './DeploymentDrawer';
import { HorizontalPodAutoscalerDrawer } from '../HorizontalPodAutoscalers';
import {
getOwnedPodsThroughReplicaSets,
getMatchingHpa,
} from '../../utils/owner';
import {
GroupedResponsesContext,
PodNamesWithErrorsContext,
} from '../../hooks';
import { StatusError, StatusOK } from '@backstage/core-components';
import { READY_COLUMNS, RESOURCE_COLUMNS } from '../Pods/PodsTable';
type DeploymentsAccordionsProps = {
children?: React.ReactNode;
};
type DeploymentAccordionProps = {
deployment: V1Deployment;
ownedPods: V1Pod[];
matchingHpa?: V1HorizontalPodAutoscaler;
children?: React.ReactNode;
};
type DeploymentSummaryProps = {
deployment: V1Deployment;
numberOfCurrentPods: number;
numberOfPodsWithErrors: number;
hpa?: V1HorizontalPodAutoscaler;
children?: React.ReactNode;
};
const DeploymentSummary = ({
deployment,
numberOfCurrentPods,
numberOfPodsWithErrors,
hpa,
}: DeploymentSummaryProps) => {
return (
<Grid
container
direction="row"
justifyContent="space-between"
alignItems="center"
spacing={0}
>
<Grid xs={4} item>
<DeploymentDrawer deployment={deployment} />
</Grid>
{hpa && (
<Grid item xs={4}>
<HorizontalPodAutoscalerDrawer hpa={hpa}>
<Grid
item
container
direction="column"
justifyContent="flex-start"
alignItems="flex-start"
spacing={0}
>
<Grid item>
<Typography variant="subtitle2">
min replicas {hpa.spec?.minReplicas ?? '?'} / max replicas{' '}
{hpa.spec?.maxReplicas ?? '?'}
</Typography>
</Grid>
<Grid item>
<Typography variant="subtitle2">
current CPU usage:{' '}
{hpa.status?.currentCPUUtilizationPercentage ?? '?'}%
</Typography>
</Grid>
<Grid item>
<Typography variant="subtitle2">
target CPU usage:{' '}
{hpa.spec?.targetCPUUtilizationPercentage ?? '?'}%
</Typography>
</Grid>
</Grid>
</HorizontalPodAutoscalerDrawer>
</Grid>
)}
<Grid
item
container
xs={4}
direction="column"
justifyContent="flex-start"
alignItems="flex-end"
spacing={0}
>
<Grid item>
<StatusOK>{numberOfCurrentPods} pods</StatusOK>
</Grid>
<Grid item>
{numberOfPodsWithErrors > 0 ? (
<StatusError>
{numberOfPodsWithErrors} pod
{numberOfPodsWithErrors > 1 ? 's' : ''} with errors
</StatusError>
) : (
<StatusOK>No pods with errors</StatusOK>
)}
</Grid>
</Grid>
</Grid>
);
};
const DeploymentAccordion = ({
deployment,
ownedPods,
matchingHpa,
}: DeploymentAccordionProps) => {
const podNamesWithErrors = useContext(PodNamesWithErrorsContext);
const podsWithErrors = ownedPods.filter(p =>
podNamesWithErrors.has(p.metadata?.name ?? ''),
);
return (
<Accordion TransitionProps={{ unmountOnExit: true }} variant="outlined">
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<DeploymentSummary
deployment={deployment}
numberOfCurrentPods={ownedPods.length}
numberOfPodsWithErrors={podsWithErrors.length}
hpa={matchingHpa}
/>
</AccordionSummary>
<AccordionDetails>
<PodsTable
pods={ownedPods}
extraColumns={[READY_COLUMNS, RESOURCE_COLUMNS]}
/>
</AccordionDetails>
</Accordion>
);
};
export const DeploymentsAccordions = ({}: DeploymentsAccordionsProps) => {
const groupedResponses = useContext(GroupedResponsesContext);
return (
<Grid
container
direction="column"
justifyContent="flex-start"
alignItems="flex-start"
>
{groupedResponses.deployments.map((deployment, i) => (
<Grid container item key={i} xs>
<Grid item xs>
<DeploymentAccordion
matchingHpa={getMatchingHpa(
{
name: deployment.metadata?.name,
namespace: deployment.metadata?.namespace,
kind: 'deployment',
},
groupedResponses.horizontalPodAutoscalers,
)}
ownedPods={getOwnedPodsThroughReplicaSets(
deployment,
groupedResponses.replicaSets,
groupedResponses.pods,
)}
deployment={deployment}
/>
</Grid>
</Grid>
))}
</Grid>
);
};
@@ -0,0 +1,16 @@
/*
* Copyright 2020 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { DeploymentsAccordions } from './DeploymentsAccordions';
@@ -0,0 +1,113 @@
/*
* Copyright 2020 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import { screen } from '@testing-library/react';
import { renderInTestApp } from '@backstage/test-utils';
import { ErrorPanel } from './ErrorPanel';
describe('ErrorPanel', () => {
it('displays path and status code when a cluster has an HTTP error', async () => {
await renderInTestApp(
<ErrorPanel
entityName="THIS_ENTITY"
clustersWithErrors={[
{
cluster: { name: 'THIS_CLUSTER' },
resources: [],
podMetrics: [],
errors: [
{
errorType: 'SYSTEM_ERROR',
statusCode: 500,
resourcePath: 'some/resource',
},
],
},
]}
/>,
);
// title
expect(
screen.getByText(
'There was a problem retrieving some Kubernetes resources for the entity: THIS_ENTITY. This could mean that the Error Reporting card is not completely accurate.',
),
).toBeInTheDocument();
// message
expect(screen.getByText('Errors:')).toBeInTheDocument();
expect(screen.getByText('Cluster: THIS_CLUSTER')).toBeInTheDocument();
expect(
screen.getByText(
"Error fetching Kubernetes resource: 'some/resource', error: SYSTEM_ERROR, status code: 500",
),
).toBeInTheDocument();
});
it('displays message for non-HTTP-status-related fetch errors', async () => {
await renderInTestApp(
<ErrorPanel
entityName="THIS_ENTITY"
clustersWithErrors={[
{
cluster: { name: 'THIS_CLUSTER' },
resources: [],
podMetrics: [],
errors: [
{
errorType: 'FETCH_ERROR',
message: 'description of error',
},
],
},
]}
/>,
);
// title
expect(
screen.getByText(
'There was a problem retrieving some Kubernetes resources for the entity: THIS_ENTITY. This could mean that the Error Reporting card is not completely accurate.',
),
).toBeInTheDocument();
// message
expect(screen.getByText('Errors:')).toBeInTheDocument();
expect(screen.getByText('Cluster: THIS_CLUSTER')).toBeInTheDocument();
expect(
screen.getByText(
'Error communicating with Kubernetes: FETCH_ERROR, message: description of error',
),
).toBeInTheDocument();
});
it('displays error message', async () => {
await renderInTestApp(
<ErrorPanel entityName="THIS_ENTITY" errorMessage="SOME_ERROR_MESSAGE" />,
);
// title
expect(
screen.getByText(
'There was a problem retrieving some Kubernetes resources for the entity: THIS_ENTITY. This could mean that the Error Reporting card is not completely accurate.',
),
).toBeInTheDocument();
// message
expect(screen.getByText('Errors: SOME_ERROR_MESSAGE')).toBeInTheDocument();
});
});
@@ -0,0 +1,77 @@
/*
* Copyright 2020 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import { Typography } from '@material-ui/core';
import { ClusterObjects } from '@backstage/plugin-kubernetes-common';
import { WarningPanel } from '@backstage/core-components';
const clustersWithErrorsToErrorMessage = (
clustersWithErrors: ClusterObjects[],
): React.ReactNode => {
return clustersWithErrors.map((c, i) => {
return (
<div key={i}>
<Typography variant="body2">{`Cluster: ${c.cluster.name}`}</Typography>
{c.errors.map((e, j) => {
return (
<Typography variant="body2" key={j}>
{e.errorType === 'FETCH_ERROR'
? `Error communicating with Kubernetes: ${e.errorType}, message: ${e.message}`
: `Error fetching Kubernetes resource: '${e.resourcePath}', error: ${e.errorType}, status code: ${e.statusCode}`}
</Typography>
);
})}
<br />
</div>
);
});
};
/**
*
*
* @public
*/
export type ErrorPanelProps = {
entityName: string;
errorMessage?: string;
clustersWithErrors?: ClusterObjects[];
children?: React.ReactNode;
};
/**
*
*
* @public
*/
export const ErrorPanel = ({
entityName,
errorMessage,
clustersWithErrors,
}: ErrorPanelProps) => (
<WarningPanel
title="There was a problem retrieving Kubernetes objects"
message={`There was a problem retrieving some Kubernetes resources for the entity: ${entityName}. This could mean that the Error Reporting card is not completely accurate.`}
>
{clustersWithErrors && (
<div>Errors: {clustersWithErrorsToErrorMessage(clustersWithErrors)}</div>
)}
{errorMessage && (
<Typography variant="body2">Errors: {errorMessage}</Typography>
)}
</WarningPanel>
);
@@ -0,0 +1,16 @@
/*
* Copyright 2020 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export * from './ErrorPanel';
@@ -0,0 +1,103 @@
/*
* Copyright 2020 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import * as React from 'react';
import {
DetectedError,
DetectedErrorsByCluster,
} from '@backstage/plugin-kubernetes-common';
import { Table, TableColumn } from '@backstage/core-components';
/**
*
*
* @public
*/
export type ErrorReportingProps = {
detectedErrors: DetectedErrorsByCluster;
};
const columns: TableColumn<Row>[] = [
{
title: 'cluster',
width: '10%',
render: (row: Row) => row.clusterName,
},
{
title: 'namespace',
width: '10%',
render: (row: Row) => row.error.sourceRef.namespace,
},
{
title: 'kind',
width: '10%',
render: (row: Row) => row.error.sourceRef.kind,
},
{
title: 'name',
width: '30%',
render: (row: Row) => {
return <>{row.error.sourceRef.name} </>;
},
},
{
title: 'messages',
width: '40%',
render: (row: Row) => row.error.message,
},
];
interface Row {
clusterName: string;
error: DetectedError;
}
const sortBySeverity = (a: Row, b: Row) => {
if (a.error.severity < b.error.severity) {
return 1;
} else if (b.error.severity < a.error.severity) {
return -1;
}
return 0;
};
/**
*
*
* @public
*/
export const ErrorReporting = ({ detectedErrors }: ErrorReportingProps) => {
const errors = Array.from(detectedErrors.entries())
.flatMap(([clusterName, resourceErrors]) => {
return resourceErrors.map(e => ({
clusterName,
error: e,
}));
})
.sort(sortBySeverity);
return (
<>
{errors.length !== 0 && (
<Table
title="Error Reporting"
data={errors}
columns={columns}
options={{ paging: true, search: false, emptyRowsWhenPaging: false }}
/>
)}
</>
);
};
@@ -0,0 +1,16 @@
/*
* Copyright 2020 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export * from './ErrorReporting';
@@ -0,0 +1,52 @@
/*
* Copyright 2020 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import { screen } from '@testing-library/react';
import * as hpas from './__fixtures__/horizontalpodautoscalers.json';
import { renderInTestApp } from '@backstage/test-utils';
import { HorizontalPodAutoscalerDrawer } from './HorizontalPodAutoscalerDrawer';
describe('HorizontalPodAutoscalersDrawer', () => {
it('should render hpa drawer', async () => {
await renderInTestApp(
<HorizontalPodAutoscalerDrawer hpa={hpas[0] as any} expanded>
<h1>CHILD</h1>
</HorizontalPodAutoscalerDrawer>,
);
expect(screen.getByText('dice-roller')).toBeInTheDocument();
expect(screen.getByText('CHILD')).toBeInTheDocument();
expect(screen.getByText('HorizontalPodAutoscaler')).toBeInTheDocument();
expect(screen.getByText('YAML')).toBeInTheDocument();
expect(
screen.getByText('Target CPU Utilization Percentage'),
).toBeInTheDocument();
expect(screen.getByText('50')).toBeInTheDocument();
expect(
screen.getByText('Current CPU Utilization Percentage'),
).toBeInTheDocument();
expect(screen.getByText('30')).toBeInTheDocument();
expect(screen.getByText('Min Replicas')).toBeInTheDocument();
expect(screen.getByText('10')).toBeInTheDocument();
expect(screen.getByText('Max Replicas')).toBeInTheDocument();
expect(screen.getByText('15')).toBeInTheDocument();
expect(screen.getByText('Current Replicas')).toBeInTheDocument();
expect(screen.getByText('13')).toBeInTheDocument();
expect(screen.getByText('Desired Replicas')).toBeInTheDocument();
expect(screen.getByText('14')).toBeInTheDocument();
});
});
@@ -0,0 +1,50 @@
/*
* Copyright 2020 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import { V1HorizontalPodAutoscaler } from '@kubernetes/client-node';
import { KubernetesStructuredMetadataTableDrawer } from '../KubernetesDrawer';
/** @public */
export const HorizontalPodAutoscalerDrawer = (props: {
hpa: V1HorizontalPodAutoscaler;
expanded?: boolean;
children?: React.ReactNode;
}) => {
const { hpa, expanded, children } = props;
return (
<KubernetesStructuredMetadataTableDrawer
kind="HorizontalPodAutoscaler"
object={hpa}
expanded={expanded}
renderObject={(hpaObject: V1HorizontalPodAutoscaler) => {
return {
targetCPUUtilizationPercentage:
hpaObject.spec?.targetCPUUtilizationPercentage,
currentCPUUtilizationPercentage:
hpaObject.status?.currentCPUUtilizationPercentage,
minReplicas: hpaObject.spec?.minReplicas,
maxReplicas: hpaObject.spec?.maxReplicas,
currentReplicas: hpaObject.status?.currentReplicas,
desiredReplicas: hpaObject.status?.desiredReplicas,
};
}}
>
{children}
</KubernetesStructuredMetadataTableDrawer>
);
};
@@ -0,0 +1,82 @@
[
{
"metadata": {
"annotations": {
"autoscaling.alpha.kubernetes.io/conditions": "[{\"type\":\"AbleToScale\",\"status\":\"True\",\"lastTransitionTime\":\"2020-09-28T13:28:15Z\",\"reason\":\"SucceededGetScale\",\"message\":\"the HPA controller was able to get the target's current scale\"},{\"type\":\"ScalingActive\",\"status\":\"False\",\"lastTransitionTime\":\"2020-09-28T13:28:15Z\",\"reason\":\"FailedGetResourceMetric\",\"message\":\"the HPA was unable to compute the replica count: unable to get metrics for resource cpu: unable to fetch metrics from resource metrics API: the server could not find the requested resource (get pods.metrics.k8s.io)\"}]",
"kubectl.kubernetes.io/last-applied-configuration": "{\"apiVersion\":\"autoscaling/v1\",\"kind\":\"HorizontalPodAutoscaler\",\"metadata\":{\"annotations\":{},\"labels\":{\"backstage.io/kubernetes-id\":\"dice-roller\"},\"name\":\"dice-roller\",\"namespace\":\"default\"},\"spec\":{\"maxReplicas\":15,\"minReplicas\":10,\"scaleTargetRef\":{\"apiVersion\":\"apps/v1\",\"kind\":\"Deployment\",\"name\":\"dice-roller\"},\"targetCPUUtilizationPercentage\":50}}\n"
},
"creationTimestamp": "2020-09-28T13:28:00.000Z",
"labels": {
"backstage.io/kubernetes-id": "dice-roller"
},
"managedFields": [
{
"apiVersion": "autoscaling/v1",
"fieldsType": "FieldsV1",
"fieldsV1": {
"f:metadata": {
"f:annotations": {
"f:autoscaling.alpha.kubernetes.io/conditions": {}
}
},
"f:status": {
"f:currentReplicas": {}
}
},
"manager": "kube-controller-manager",
"operation": "Update",
"time": "2020-09-28T13:28:15.000Z"
},
{
"apiVersion": "autoscaling/v1",
"fieldsType": "FieldsV1",
"fieldsV1": {
"f:metadata": {
"f:annotations": {
".": {},
"f:kubectl.kubernetes.io/last-applied-configuration": {}
},
"f:labels": {
".": {},
"f:backstage.io/kubernetes-id": {}
}
},
"f:spec": {
"f:maxReplicas": {},
"f:minReplicas": {},
"f:scaleTargetRef": {
"f:apiVersion": {},
"f:kind": {},
"f:name": {}
},
"f:targetCPUUtilizationPercentage": {}
}
},
"manager": "kubectl",
"operation": "Update",
"time": "2020-09-28T13:28:21.000Z"
}
],
"name": "dice-roller",
"namespace": "default",
"resourceVersion": "698957",
"selfLink": "/apis/autoscaling/v1/namespaces/default/horizontalpodautoscalers/dice-roller",
"uid": "a70c8a90-5605-4d7d-adea-05cfb8d9d446"
},
"spec": {
"maxReplicas": 15,
"minReplicas": 10,
"scaleTargetRef": {
"apiVersion": "apps/v1",
"kind": "Deployment",
"name": "dice-roller"
},
"targetCPUUtilizationPercentage": 50
},
"status": {
"currentReplicas": 13,
"desiredReplicas": 14,
"currentCPUUtilizationPercentage": 30
}
}
]
@@ -0,0 +1,17 @@
/*
* Copyright 2020 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { HorizontalPodAutoscalerDrawer } from './HorizontalPodAutoscalerDrawer';
@@ -0,0 +1,42 @@
/*
* Copyright 2020 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { renderInTestApp, textContentMatcher } from '@backstage/test-utils';
import { screen } from '@testing-library/react';
import React from 'react';
import { IngressDrawer } from './IngressDrawer';
import * as ingresses from './__fixtures__/2-ingresses.json';
describe('IngressDrawer', () => {
it('should render ingress drawer', async () => {
await renderInTestApp(
<IngressDrawer ingress={(ingresses as any).ingresses[0]} expanded />,
);
expect(screen.getAllByText('awesome-service')).toHaveLength(4);
expect(screen.getByText('YAML')).toBeInTheDocument();
expect(screen.getByText('Rules')).toBeInTheDocument();
expect(
screen.getByText(textContentMatcher('Host: api.awesome-host.io')),
).toBeInTheDocument();
expect(
screen.getAllByText(textContentMatcher('Service Port: 80')),
).toHaveLength(2);
expect(
screen.getAllByText(textContentMatcher('Service Name: awesome-service')),
).toHaveLength(2);
});
});
@@ -0,0 +1,58 @@
/*
* Copyright 2020 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import { V1Ingress } from '@kubernetes/client-node';
import { KubernetesStructuredMetadataTableDrawer } from '../KubernetesDrawer';
import { Typography, Grid } from '@material-ui/core';
export const IngressDrawer = ({
ingress,
expanded,
}: {
ingress: V1Ingress;
expanded?: boolean;
}) => {
return (
<KubernetesStructuredMetadataTableDrawer
object={ingress}
expanded={expanded}
kind="Ingress"
renderObject={(ingressObject: V1Ingress) => {
return ingressObject.spec || {};
}}
>
<Grid
container
direction="column"
justifyContent="flex-start"
alignItems="flex-start"
spacing={0}
>
<Grid item>
<Typography variant="body1">
{ingress.metadata?.name ?? 'unknown object'}
</Typography>
</Grid>
<Grid item>
<Typography color="textSecondary" variant="subtitle1">
Ingress
</Typography>
</Grid>
</Grid>
</KubernetesStructuredMetadataTableDrawer>
);
};
@@ -0,0 +1,33 @@
/*
* Copyright 2020 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import { screen } from '@testing-library/react';
import * as oneIngressFixture from './__fixtures__/2-ingresses.json';
import { renderInTestApp } from '@backstage/test-utils';
import { IngressesAccordions } from './IngressesAccordions';
import { kubernetesProviders } from '../../hooks/test-utils';
describe('IngressesAccordions', () => {
it('should render 1 ingress', async () => {
const wrapper = kubernetesProviders(oneIngressFixture, new Set());
await renderInTestApp(wrapper(<IngressesAccordions />));
expect(screen.getByText('awesome-service')).toBeInTheDocument();
expect(screen.getByText('Ingress')).toBeInTheDocument();
});
});
@@ -0,0 +1,108 @@
/*
* Copyright 2020 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { useContext } from 'react';
import {
Accordion,
AccordionDetails,
AccordionSummary,
Grid,
} from '@material-ui/core';
import ExpandMoreIcon from '@material-ui/icons/ExpandMore';
import { V1Ingress } from '@kubernetes/client-node';
import { IngressDrawer } from './IngressDrawer';
import { GroupedResponsesContext } from '../../hooks';
import { StructuredMetadataTable } from '@backstage/core-components';
/**
*
*
* @public
*/
export type IngressesAccordionsProps = {};
type IngressAccordionProps = {
ingress: V1Ingress;
};
type IngressSummaryProps = {
ingress: V1Ingress;
};
const IngressSummary = ({ ingress }: IngressSummaryProps) => {
return (
<Grid
container
direction="row"
justifyContent="flex-start"
alignItems="center"
>
<Grid xs={12} item>
<IngressDrawer ingress={ingress} />
</Grid>
</Grid>
);
};
type IngressCardProps = {
ingress: V1Ingress;
};
const IngressCard = ({ ingress }: IngressCardProps) => {
return (
<StructuredMetadataTable
metadata={{
...ingress.spec,
}}
/>
);
};
const IngressAccordion = ({ ingress }: IngressAccordionProps) => {
return (
<Accordion TransitionProps={{ unmountOnExit: true }}>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<IngressSummary ingress={ingress} />
</AccordionSummary>
<AccordionDetails>
<IngressCard ingress={ingress} />
</AccordionDetails>
</Accordion>
);
};
/**
*
*
* @public
*/
export const IngressesAccordions = ({}: IngressesAccordionsProps) => {
const groupedResponses = useContext(GroupedResponsesContext);
return (
<Grid
container
direction="row"
justifyContent="flex-start"
alignItems="flex-start"
>
{groupedResponses.ingresses.map((ingress, i) => (
<Grid item key={i} xs>
<IngressAccordion ingress={ingress} />
</Grid>
))}
</Grid>
);
};
@@ -0,0 +1,57 @@
{
"ingresses": [
{
"metadata": {
"annotations": {
"artifact.spinnaker.io/location": "default",
"artifact.spinnaker.io/name": "awesome-service",
"artifact.spinnaker.io/type": "kubernetes/ingress",
"kubernetes.io/ingress.class": "traefik",
"kubernetes.io/ingress.global-static-ip-name": "traefik-tcp-lb",
"moniker.spinnaker.io/application": "awesome-service",
"moniker.spinnaker.io/cluster": "ingress awesome-service"
},
"creationTimestamp": "2018-11-16T14:00:13.000Z",
"generation": 11,
"labels": {
"app": "awesome-service",
"app.kubernetes.io/managed-by": "spinnaker",
"app.kubernetes.io/name": "awesome-service"
},
"name": "awesome-service",
"namespace": "default",
"resourceVersion": "564824116",
"selfLink": "/apis/networking.k8s.io/v1beta1/namespaces/default/ingresses/awesome-service",
"uid": "f072e0b4-e9a7-11e8-af65-42010a9c0022"
},
"spec": {
"rules": [
{
"host": "api.awesome-host.io",
"http": {
"paths": [
{
"backend": {
"serviceName": "awesome-service",
"servicePort": 80
},
"path": "/v1/awesome-service"
},
{
"backend": {
"serviceName": "awesome-service",
"servicePort": 80
},
"path": "/v1/awesome-services"
}
]
}
}
]
},
"status": {
"loadBalancer": {}
}
}
]
}
@@ -0,0 +1,16 @@
/*
* Copyright 2020 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export * from './IngressesAccordions';
@@ -0,0 +1,45 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import { screen } from '@testing-library/react';
import { JobsAccordions } from './JobsAccordions';
import * as oneCronJobsFixture from '../../__fixtures__/1-cronjobs.json';
import { renderInTestApp } from '@backstage/test-utils';
import { kubernetesProviders } from '../../hooks/test-utils';
import { V1Job, ObjectSerializer } from '@kubernetes/client-node';
describe('JobsAccordions', () => {
it('should render 2 jobs', async () => {
const wrapper = kubernetesProviders(oneCronJobsFixture, new Set<string>());
const jobs: V1Job[] = oneCronJobsFixture.jobs.map(
job => ObjectSerializer.deserialize(job, 'V1Job') as V1Job,
);
await renderInTestApp(wrapper(<JobsAccordions jobs={jobs} />));
expect(
screen.getByText('dice-roller-cronjob-1637028600'),
).toBeInTheDocument();
expect(screen.getByText('Running')).toBeInTheDocument();
expect(
screen.getByText('dice-roller-cronjob-1637025000'),
).toBeInTheDocument();
expect(screen.getByText('Succeeded')).toBeInTheDocument();
});
});
@@ -0,0 +1,133 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { useContext } from 'react';
import {
Accordion,
AccordionDetails,
AccordionSummary,
Grid,
} from '@material-ui/core';
import ExpandMoreIcon from '@material-ui/icons/ExpandMore';
import { V1Job, V1Pod } from '@kubernetes/client-node';
import { PodsTable } from '../Pods';
import { JobDrawer } from './JobsDrawer';
import { getOwnedResources } from '../../utils/owner';
import { GroupedResponsesContext } from '../../hooks';
import {
StatusError,
StatusOK,
StatusPending,
} from '@backstage/core-components';
/**
*
*
* @public
*/
export type JobsAccordionsProps = {
jobs: V1Job[];
children?: React.ReactNode;
};
type JobAccordionProps = {
job: V1Job;
ownedPods: V1Pod[];
children?: React.ReactNode;
};
type JobSummaryProps = {
job: V1Job;
children?: React.ReactNode;
};
const JobSummary = ({ job }: JobSummaryProps) => {
return (
<Grid
container
direction="row"
justifyContent="space-between"
alignItems="center"
spacing={0}
>
<Grid xs={6} item>
<JobDrawer job={job} />
</Grid>
<Grid
item
container
xs={6}
direction="column"
justifyContent="flex-start"
alignItems="flex-end"
spacing={0}
>
<Grid item>
{job.status?.succeeded && <StatusOK>Succeeded</StatusOK>}
{job.status?.active && <StatusPending>Running</StatusPending>}
{job.status?.failed && <StatusError>Failed</StatusError>}
</Grid>
<Grid item>Start time: {job.status?.startTime?.toString()}</Grid>
{job.status?.completionTime && (
<Grid item>
Completion time: {job.status.completionTime.toString()}
</Grid>
)}
</Grid>
</Grid>
);
};
const JobAccordion = ({ job, ownedPods }: JobAccordionProps) => {
return (
<Accordion TransitionProps={{ unmountOnExit: true }} variant="outlined">
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<JobSummary job={job} />
</AccordionSummary>
<AccordionDetails>
<PodsTable pods={ownedPods} />
</AccordionDetails>
</Accordion>
);
};
/**
*
*
* @public
*/
export const JobsAccordions = ({ jobs }: JobsAccordionsProps) => {
const groupedResponses = useContext(GroupedResponsesContext);
return (
<Grid
container
direction="column"
justifyContent="flex-start"
alignItems="flex-start"
>
{jobs.map((job, i) => (
<Grid container item key={i} xs>
<Grid item xs>
<JobAccordion
ownedPods={getOwnedResources(job, groupedResponses.pods)}
job={job}
/>
</Grid>
</Grid>
))}
</Grid>
);
};
@@ -0,0 +1,35 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import * as oneCronJobsFixture from '../../__fixtures__/1-cronjobs.json';
import { renderInTestApp } from '@backstage/test-utils';
import { JobDrawer } from './JobsDrawer';
describe('JobDrawer', () => {
it('should render job drawer', async () => {
const { getByText, getAllByText } = await renderInTestApp(
<JobDrawer job={(oneCronJobsFixture as any).jobs[0]} expanded />,
);
expect(getAllByText('dice-roller-cronjob-1637025000')).toHaveLength(2);
expect(getAllByText('Job')).toHaveLength(2);
expect(getByText('YAML')).toBeInTheDocument();
expect(getByText('Parallelism')).toBeInTheDocument();
expect(getByText('Completions')).toBeInTheDocument();
expect(getByText('Backoff Limit')).toBeInTheDocument();
expect(getByText('Start Time')).toBeInTheDocument();
});
});
@@ -0,0 +1,62 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import { V1Job } from '@kubernetes/client-node';
import { KubernetesStructuredMetadataTableDrawer } from '../KubernetesDrawer';
import { Typography, Grid } from '@material-ui/core';
export const JobDrawer = ({
job,
expanded,
}: {
job: V1Job;
expanded?: boolean;
}) => {
return (
<KubernetesStructuredMetadataTableDrawer
object={job}
expanded={expanded}
kind="Job"
renderObject={(jobObj: V1Job) => {
return {
parallelism: jobObj.spec?.parallelism ?? '???',
completions: jobObj.spec?.completions ?? '???',
backoffLimit: jobObj.spec?.backoffLimit ?? '???',
startTime: jobObj.status?.startTime ?? '???',
};
}}
>
<Grid
container
direction="column"
justifyContent="flex-start"
alignItems="flex-start"
spacing={0}
>
<Grid item>
<Typography variant="body1">
{job.metadata?.name ?? 'unknown object'}
</Typography>
</Grid>
<Grid item>
<Typography color="textSecondary" variant="subtitle1">
Job
</Typography>
</Grid>
</Grid>
</KubernetesStructuredMetadataTableDrawer>
);
};
@@ -0,0 +1,16 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export * from './JobsAccordions';
@@ -0,0 +1,198 @@
/*
* Copyright 2023 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { ChangeEvent, useState } from 'react';
import { IObjectMeta } from '@kubernetes-models/apimachinery/apis/meta/v1/ObjectMeta';
import {
createStyles,
Drawer,
makeStyles,
Theme,
Grid,
IconButton,
Switch,
Typography,
Button,
withStyles,
FormControlLabel,
} from '@material-ui/core';
import CloseIcon from '@material-ui/icons/Close';
import { ManifestYaml } from './ManifestYaml';
const useDrawerContentStyles = makeStyles((_theme: Theme) =>
createStyles({
header: {
display: 'flex',
flexDirection: 'row',
justifyContent: 'space-between',
},
content: {
height: '80%',
},
icon: {
fontSize: 20,
},
}),
);
/**
* The type of object that can be represented by the Drawer
*
* @public
*/
export interface KubernetesObject {
kind: string;
metadata?: IObjectMeta;
}
interface KubernetesDrawerContentProps {
close: () => void;
kubernetesObject: KubernetesObject;
header?: React.ReactNode;
children?: React.ReactNode;
}
const KubernetesDrawerContent = ({
children,
header,
kubernetesObject,
close,
}: KubernetesDrawerContentProps) => {
const classes = useDrawerContentStyles();
const [isYaml, setIsYaml] = useState<boolean>(false);
return (
<>
<div className={classes.header}>
<Grid container justifyContent="flex-start" alignItems="flex-start">
<Grid item xs={11}>
<Typography variant="h5">
{kubernetesObject.metadata?.name}
</Typography>
</Grid>
<Grid item xs={1}>
<IconButton
key="dismiss"
title="Close the drawer"
onClick={() => close()}
color="inherit"
>
<CloseIcon className={classes.icon} />
</IconButton>
</Grid>
<Grid item xs={12}>
{header}
</Grid>
<Grid item xs={12}>
<FormControlLabel
control={
<Switch
checked={isYaml}
onChange={event => {
setIsYaml(event.target.checked);
}}
name="YAML"
/>
}
label="YAML"
/>
</Grid>
</Grid>
</div>
<div className={classes.content}>
{isYaml && <ManifestYaml object={kubernetesObject} />}
{!isYaml && children}
</div>
</>
);
};
/**
* Props of KubernetesDrawer
*
* @public
*/
export interface KubernetesDrawerProps {
open?: boolean;
kubernetesObject: KubernetesObject;
label: React.ReactNode;
drawerContentsHeader?: React.ReactNode;
children?: React.ReactNode;
}
const useDrawerStyles = makeStyles((theme: Theme) =>
createStyles({
paper: {
width: '50%',
justifyContent: 'space-between',
padding: theme.spacing(2.5),
},
}),
);
const DrawerButton = withStyles({
root: {
padding: '6px 5px',
},
label: {
textTransform: 'none',
},
})(Button);
/**
* Button/Drawer component for Kubernetes Objects
*
* @public
*/
export const KubernetesDrawer = ({
open,
label,
drawerContentsHeader,
kubernetesObject,
children,
}: KubernetesDrawerProps) => {
const classes = useDrawerStyles();
const [isOpen, setIsOpen] = useState<boolean>(open ?? false);
const toggleDrawer = (e: ChangeEvent<{}>, newValue: boolean) => {
e.stopPropagation();
setIsOpen(newValue);
};
return (
<>
<DrawerButton onClick={() => setIsOpen(true)}>{label}</DrawerButton>
<Drawer
classes={{
paper: classes.paper,
}}
anchor="right"
open={isOpen}
onClose={(e: any) => toggleDrawer(e, false)}
onClick={event => event.stopPropagation()}
>
{isOpen && (
<KubernetesDrawerContent
header={drawerContentsHeader}
kubernetesObject={kubernetesObject}
children={children}
close={() => setIsOpen(false)}
/>
)}
</Drawer>
</>
);
};
@@ -0,0 +1,325 @@
/*
* Copyright 2020 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { ChangeEvent, useContext, useState } from 'react';
import {
Button,
Typography,
makeStyles,
IconButton,
createStyles,
Theme,
Drawer,
Switch,
FormControlLabel,
Grid,
} from '@material-ui/core';
import Close from '@material-ui/icons/Close';
import OpenInNewIcon from '@material-ui/icons/OpenInNew';
import { V1ObjectMeta } from '@kubernetes/client-node';
import { withStyles } from '@material-ui/core/styles';
import {
LinkButton as BackstageButton,
StructuredMetadataTable,
WarningPanel,
} from '@backstage/core-components';
import { ClusterContext } from '../../hooks';
import { formatClusterLink } from '../../utils/clusterLinks';
import { ClusterAttributes } from '@backstage/plugin-kubernetes-common';
import { FormatClusterLinkOptions } from '../../utils/clusterLinks/formatClusterLink';
import { ManifestYaml } from './ManifestYaml';
const useDrawerStyles = makeStyles((theme: Theme) =>
createStyles({
paper: {
width: '50%',
justifyContent: 'space-between',
padding: theme.spacing(2.5),
},
}),
);
const useDrawerContentStyles = makeStyles((_: Theme) =>
createStyles({
header: {
display: 'flex',
flexDirection: 'row',
justifyContent: 'space-between',
},
errorMessage: {
marginTop: '1em',
marginBottom: '1em',
},
options: {
display: 'flex',
flexDirection: 'row',
justifyContent: 'space-between',
},
icon: {
fontSize: 20,
},
content: {
height: '80%',
},
}),
);
const PodDrawerButton = withStyles({
root: {
padding: '6px 5px',
},
label: {
textTransform: 'none',
},
})(Button);
/**
*
*
* @public
*/
export type LinkErrorPanelProps = {
cluster: ClusterAttributes;
errorMessage?: string;
children?: React.ReactNode;
};
/**
*
*
* @public
*/
export const LinkErrorPanel = ({
cluster,
errorMessage,
}: LinkErrorPanelProps) => (
<WarningPanel
title="There was a problem formatting the link to the Kubernetes dashboard"
message={`Could not format the link to the dashboard of your cluster named '${
cluster.name
}'. Its dashboardApp property has been set to '${
cluster.dashboardApp || 'standard'
}.'`}
>
{errorMessage && (
<Typography variant="body2">Errors: {errorMessage}</Typography>
)}
</WarningPanel>
);
/**
*
*
* @public
*/
export interface KubernetesDrawerable {
metadata?: V1ObjectMeta;
}
interface KubernetesStructuredMetadataTableDrawerContentProps<
T extends KubernetesDrawerable,
> {
toggleDrawer: (e: ChangeEvent<{}>, isOpen: boolean) => void;
object: T;
renderObject: (obj: T) => object;
kind: string;
}
function replaceNullsWithUndefined(someObj: any) {
const replacer = (_: any, value: any) =>
String(value) === 'null' || String(value) === 'undefined'
? undefined
: value;
return JSON.parse(JSON.stringify(someObj, replacer));
}
function tryFormatClusterLink(options: FormatClusterLinkOptions) {
try {
return {
clusterLink: formatClusterLink(options),
errorMessage: '',
};
} catch (err) {
return {
clusterLink: '',
errorMessage: err.message || err.toString(),
};
}
}
const KubernetesStructuredMetadataTableDrawerContent = <
T extends KubernetesDrawerable,
>({
toggleDrawer,
object,
renderObject,
kind,
}: KubernetesStructuredMetadataTableDrawerContentProps<T>) => {
const [isYaml, setIsYaml] = useState<boolean>(false);
const classes = useDrawerContentStyles();
const cluster = useContext(ClusterContext);
const { clusterLink, errorMessage } = tryFormatClusterLink({
dashboardUrl: cluster.dashboardUrl,
dashboardApp: cluster.dashboardApp,
dashboardParameters: cluster.dashboardParameters,
object,
kind,
});
return (
<>
<div className={classes.header}>
<Grid container justifyContent="flex-start" alignItems="flex-start">
<Grid item xs={11}>
<Typography variant="h5">
{object.metadata?.name ?? 'unknown name'}
</Typography>
</Grid>
<Grid item xs={1}>
<IconButton
key="dismiss"
title="Close the drawer"
onClick={e => toggleDrawer(e, false)}
color="inherit"
>
<Close className={classes.icon} />
</IconButton>
</Grid>
<Grid item xs={11}>
<Typography color="textSecondary" variant="body1">
{kind}
</Typography>
</Grid>
<Grid item xs={11}>
<FormControlLabel
control={
<Switch
checked={isYaml}
onChange={event => {
setIsYaml(event.target.checked);
}}
name="YAML"
/>
}
label="YAML"
/>
</Grid>
</Grid>
</div>
{errorMessage && (
<div className={classes.errorMessage}>
<LinkErrorPanel cluster={cluster} errorMessage={errorMessage} />
</div>
)}
<div className={classes.options}>
<div>
{clusterLink && (
<BackstageButton
variant="outlined"
color="primary"
size="small"
to={clusterLink}
endIcon={<OpenInNewIcon />}
>
Open Kubernetes Dashboard
</BackstageButton>
)}
</div>
</div>
<div className={classes.content}>
{isYaml && <ManifestYaml object={object} />}
{!isYaml && (
<StructuredMetadataTable
metadata={renderObject(replaceNullsWithUndefined(object))}
/>
)}
</div>
</>
);
};
/**
*
* @public
*/
export interface KubernetesStructuredMetadataTableDrawerProps<
T extends KubernetesDrawerable,
> {
object: T;
renderObject: (obj: T) => object;
buttonVariant?: 'h5' | 'subtitle2';
kind: string;
expanded?: boolean;
children?: React.ReactNode;
}
/**
*
* @public
*/
export const KubernetesStructuredMetadataTableDrawer = <
T extends KubernetesDrawerable,
>({
object,
renderObject,
kind,
buttonVariant = 'subtitle2',
expanded = false,
children,
}: KubernetesStructuredMetadataTableDrawerProps<T>) => {
const [isOpen, setIsOpen] = useState(expanded);
const classes = useDrawerStyles();
const toggleDrawer = (e: ChangeEvent<{}>, newValue: boolean) => {
e.stopPropagation();
setIsOpen(newValue);
};
return (
<>
<PodDrawerButton
onClick={e => toggleDrawer(e, true)}
onFocus={event => event.stopPropagation()}
>
{children === undefined ? (
<Typography variant={buttonVariant}>
{object.metadata?.name ?? 'unknown object'}
</Typography>
) : (
children
)}
</PodDrawerButton>
<Drawer
classes={{
paper: classes.paper,
}}
anchor="right"
open={isOpen}
onClose={(e: any) => toggleDrawer(e, false)}
onClick={event => event.stopPropagation()}
>
<KubernetesStructuredMetadataTableDrawerContent
kind={kind}
toggleDrawer={toggleDrawer}
object={object}
renderObject={renderObject}
/>
</Drawer>
</>
);
};
@@ -0,0 +1,70 @@
/*
* Copyright 2023 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { CodeSnippet } from '@backstage/core-components';
import { FormControlLabel, Switch } from '@material-ui/core';
import jsyaml from 'js-yaml';
import React, { useState } from 'react';
/**
* Props of ManifestYaml
*
* @public
*/
export interface ManifestYamlProps {
object: object;
}
/**
* Renders a Kubernetes object as a YAML code snippet
*
* @public
*/
export const ManifestYaml = ({ object }: ManifestYamlProps) => {
// Toggle whether the Kubernetes resource managed fields should be shown in
// the YAML display. This toggle is only available when the YAML is being
// shown because managed fields are never visible in the structured display.
const [managedFields, setManagedFields] = useState<boolean>(false);
return (
<>
<FormControlLabel
control={
<Switch
checked={managedFields}
onChange={event => {
setManagedFields(event.target.checked);
}}
name="Managed Fields"
/>
}
label="Managed Fields"
/>
<CodeSnippet
language="yaml"
text={jsyaml.dump(object, {
// NOTE: this will remove any field called `managedFields`
// not just the metadata one
// TODO: @mclarke make this only remove the `metadata.managedFields`
replacer: (key: string, value: string): any => {
if (!managedFields) {
return key === 'managedFields' ? undefined : value;
}
return value;
},
})}
/>
</>
);
};
@@ -0,0 +1,19 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export * from './KubernetesStructuredMetadataTableDrawer';
export * from './KubernetesDrawer';
export * from './ManifestYaml';
@@ -0,0 +1,97 @@
/*
* Copyright 2023 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { DiscoveryApi, discoveryApiRef } from '@backstage/core-plugin-api';
import {
renderInTestApp,
TestApiProvider,
textContentMatcher,
} from '@backstage/test-utils';
import '@testing-library/jest-dom';
import { screen } from '@testing-library/react';
import WS from 'jest-websocket-mock';
import React from 'react';
import './matchMedia.mock';
import { PodExecTerminal } from './PodExecTerminal';
global.TextEncoder = require('util').TextEncoder;
const textEncoder = new TextEncoder();
describe('PodExecTerminal', () => {
const clusterName = 'cluster1';
const containerName = 'container2';
const podName = 'pod1';
const podNamespace = 'podNamespace';
const mockDiscoveryApi: Partial<DiscoveryApi> = {
getBaseUrl: () => Promise.resolve('http://localhost'),
};
it('Should render an XTerm web terminal', async () => {
await renderInTestApp(
<TestApiProvider apis={[[discoveryApiRef, mockDiscoveryApi]]}>
<PodExecTerminal
clusterName={clusterName}
containerName={containerName}
podName={podName}
podNamespace={podNamespace}
/>
</TestApiProvider>,
);
await expect(
screen.findByText(
textContentMatcher('Starting terminal, please wait...'),
),
).resolves.toBeInTheDocument();
});
it('Should connect to WebSocket server & render response', async () => {
const server = new WS(
'ws://localhost/proxy/api/v1/namespaces/podNamespace/pods/pod1/exec?container=container2&stdin=true&stdout=true&stderr=true&tty=true&command=%2Fbin%2Fsh',
);
await renderInTestApp(
<TestApiProvider apis={[[discoveryApiRef, mockDiscoveryApi]]}>
<PodExecTerminal
clusterName={clusterName}
containerName={containerName}
podName={podName}
podNamespace={podNamespace}
/>
</TestApiProvider>,
);
// xterm uses a "W" character as a "measure element" -- if it exists, the terminal rendered correctly
await expect(
screen.findByText(textContentMatcher('W')),
).resolves.toBeInTheDocument();
await server.connected;
const { buffer } = Uint8Array.from([
1,
...textEncoder.encode('hello world'),
]);
server.send(buffer);
await expect(
screen.findByText(textContentMatcher('hello world')),
).resolves.toBeInTheDocument();
});
});
@@ -0,0 +1,131 @@
/*
* Copyright 2023 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import 'xterm';
import { discoveryApiRef, useApi } from '@backstage/core-plugin-api';
import React, { useEffect, useMemo, useState } from 'react';
import { Terminal } from 'xterm';
import { FitAddon } from 'xterm-addon-fit';
import { PodExecTerminalAttachAddon } from './PodExecTerminalAttachAddon';
/**
* Props drilled down to the PodExecTerminal component
*
* @public
*/
export interface PodExecTerminalProps {
clusterName: string;
containerName: string;
podName: string;
podNamespace: string;
}
const hasSocketProtocol = (url: string | URL) =>
/wss?:\/\//.test(url.toString());
/**
* Executes a `/bin/sh` process in the given pod's container and opens a terminal connected to it
*
* @public
*/
export const PodExecTerminal = (props: PodExecTerminalProps) => {
const { containerName, podNamespace, podName } = props;
const [baseUrl, setBaseUrl] = useState(window.location.host);
const terminalRef = React.useRef(null);
const discoveryApi = useApi(discoveryApiRef);
const namespace = podNamespace ?? 'default';
useEffect(() => {
discoveryApi
.getBaseUrl('kubernetes')
.then(url => url ?? window.location.host)
.then(url => url.replace(/^http(s?):\/\//, 'ws$1://'))
.then(url => setBaseUrl(url));
}, [discoveryApi]);
const urlParams = useMemo(() => {
const params = new URLSearchParams({
container: containerName,
stdin: 'true',
stdout: 'true',
stderr: 'true',
tty: 'true',
command: '/bin/sh',
});
return params;
}, [containerName]);
const socketUrl = useMemo(() => {
if (!hasSocketProtocol(baseUrl)) {
return '';
}
return new URL(
`${baseUrl}/proxy/api/v1/namespaces/${namespace}/pods/${podName}/exec?${urlParams}`,
);
}, [baseUrl, namespace, podName, urlParams]);
useEffect(() => {
if (!hasSocketProtocol(socketUrl)) {
return () => {};
}
const terminal = new Terminal();
const fitAddon = new FitAddon();
terminal.loadAddon(fitAddon);
if (terminalRef.current) {
terminal.open(terminalRef.current);
fitAddon.fit();
}
terminal.writeln('Starting terminal, please wait...');
const socket = new WebSocket(socketUrl, ['channel.k8s.io']);
socket.onopen = () => {
terminal.clear();
const attachAddon = new PodExecTerminalAttachAddon(socket, {
bidirectional: true,
});
terminal.loadAddon(attachAddon);
};
socket.onclose = () => {
terminal.writeln('Socket connection closed');
};
return () => {
terminal?.clear();
socket?.close();
};
}, [baseUrl, socketUrl]);
return (
<div
data-testid="terminal"
ref={terminalRef}
style={{
width: '100%',
height: '100%',
}}
/>
);
};
@@ -0,0 +1,46 @@
/*
* Copyright 2023 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { AttachAddon, IAttachOptions } from 'xterm-addon-attach';
export class PodExecTerminalAttachAddon extends AttachAddon {
#textEncoder = new TextEncoder();
constructor(socket: WebSocket, options?: IAttachOptions) {
super(socket, options);
const thisAddon = this as any;
// These methods are private in the original AttachAddon,
// thus have to override at runtime like this
thisAddon._sendBinary = (data: string) => {
if (!thisAddon._checkOpenSocket()) {
return;
}
const buffer = Uint8Array.from([0, ...this.#textEncoder.encode(data)]);
thisAddon._socket.send(buffer);
};
thisAddon._sendData = (data: string) => {
if (!thisAddon._checkOpenSocket()) {
return;
}
thisAddon._sendBinary(data);
};
}
}
@@ -0,0 +1,110 @@
/*
* Copyright 2023 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
Button,
createStyles,
Dialog,
DialogContent,
DialogTitle,
IconButton,
makeStyles,
Theme,
} from '@material-ui/core';
import CloseIcon from '@material-ui/icons/Close';
import OpenInBrowserIcon from '@material-ui/icons/OpenInBrowser';
import React, { useState } from 'react';
import { useIsPodExecTerminalSupported } from '../../hooks';
import { PodExecTerminal, PodExecTerminalProps } from './PodExecTerminal';
const useStyles = makeStyles((theme: Theme) =>
createStyles({
dialogPaper: { minHeight: 'calc(100% - 64px)' },
dialogContent: { flexBasis: 0 },
closeButton: {
position: 'absolute',
right: theme.spacing(1),
top: theme.spacing(1),
color: theme.palette.grey[500],
},
}),
);
/**
* Opens a terminal connected to the given pod's container in a dialog
*
* @public
*/
export const PodExecTerminalDialog = (props: PodExecTerminalProps) => {
const classes = useStyles();
const { clusterName, containerName, podName } = props;
const [open, setOpen] = useState(false);
const isPodExecTerminalSupported = useIsPodExecTerminalSupported();
const openDialog = () => {
setOpen(true);
};
const closeDialog = () => {
setOpen(false);
};
return (
<>
{!isPodExecTerminalSupported.loading &&
isPodExecTerminalSupported.value && (
<Dialog
maxWidth={false}
fullWidth
open={open}
onClose={closeDialog}
PaperProps={{ className: classes.dialogPaper }}
>
<DialogTitle id="dialog-title">
{podName} - {containerName} terminal shell on cluster{' '}
{clusterName}
<IconButton
aria-label="close"
className={classes.closeButton}
onClick={closeDialog}
>
<CloseIcon />
</IconButton>
</DialogTitle>
<DialogContent className={classes.dialogContent}>
<PodExecTerminal {...props} />
</DialogContent>
</Dialog>
)}
<Button
variant="outlined"
aria-label="open terminal"
component="label"
disabled={
isPodExecTerminalSupported.loading ||
!isPodExecTerminalSupported.value
}
onClick={openDialog}
startIcon={<OpenInBrowserIcon />}
>
Terminal
</Button>
</>
);
};
@@ -0,0 +1,17 @@
/*
* Copyright 2023 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export * from './PodExecTerminal';
export * from './PodExecTerminalDialog';
@@ -0,0 +1,31 @@
/*
* Copyright 2023 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: jest.fn().mockImplementation(query => ({
matches: false,
media: query,
onchange: null,
addListener: jest.fn(), // deprecated
removeListener: jest.fn(), // deprecated
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
dispatchEvent: jest.fn(),
})),
});
export {};
@@ -0,0 +1,64 @@
/*
* Copyright 2023 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import { render } from '@testing-library/react';
import { ErrorList } from './ErrorList';
import { Pod } from 'kubernetes-models/v1';
describe('ErrorList', () => {
it('error highlight should render', () => {
const { getByText } = render(
<ErrorList
podAndErrors={[
{
clusterName: 'some-cluster',
pod: {
metadata: {
name: 'some-pod',
namespace: 'some-namespace',
},
} as Pod,
errors: [
{
type: 'some-error',
severity: 10,
message: 'some error message',
occurrenceCount: 1,
sourceRef: {
name: 'some-pod',
namespace: 'some-namespace',
kind: 'Pod',
apiGroup: 'v1',
},
proposedFix: {
type: 'logs',
container: 'some-container',
errorType: 'some error type',
rootCauseExplanation: 'some root cause',
actions: ['fix1', 'fix2'],
},
},
],
},
]}
/>,
);
expect(getByText('some-pod')).toBeInTheDocument();
expect(getByText('some error message')).toBeInTheDocument();
});
});
@@ -0,0 +1,97 @@
/*
* Copyright 2023 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import {
List,
ListItem,
ListItemText,
Divider,
createStyles,
makeStyles,
Theme,
Paper,
Grid,
} from '@material-ui/core';
import { PodAndErrors } from '../types';
import { FixDialog } from '../FixDialog/FixDialog';
const useStyles = makeStyles((_theme: Theme) =>
createStyles({
root: {
overflow: 'auto',
},
list: {
width: '100%',
},
}),
);
/**
* Props for ErrorList
*
* @public
*/
export interface ErrorListProps {
podAndErrors: PodAndErrors[];
}
/**
* Shows a list of errors found on a Pod
*
* @public
*/
export const ErrorList = ({ podAndErrors }: ErrorListProps) => {
const classes = useStyles();
return (
<Paper className={classes.root}>
<List className={classes.list}>
{podAndErrors
.filter(pae => pae.errors.length > 0)
.flatMap(onlyPodWithErrors => {
return onlyPodWithErrors.errors.map((error, i) => {
return (
<React.Fragment
key={`${
onlyPodWithErrors.pod.metadata?.name ?? 'unknown'
}-eli-${i}`}
>
{i > 0 && <Divider key={`error-divider${i}`} />}
<ListItem>
<Grid container>
<Grid item xs={9}>
<ListItemText
primary={error.message}
secondary={onlyPodWithErrors.pod.metadata?.name}
/>
</Grid>
<Grid item xs={3}>
<FixDialog
pod={onlyPodWithErrors.pod}
error={error}
clusterName={onlyPodWithErrors.clusterName}
/>
</Grid>
</Grid>
</ListItem>
</React.Fragment>
);
});
})}
</List>
</Paper>
);
};
@@ -0,0 +1,16 @@
/*
* Copyright 2023 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export * from './ErrorList';
@@ -0,0 +1,96 @@
/*
* Copyright 2023 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import { EventsContent } from './Events';
import { render } from '@testing-library/react';
import { Event } from 'kubernetes-models/v1';
import { DateTime } from 'luxon';
describe('EventsContent', () => {
const oneHourAgo = DateTime.now().minus({ hours: 1 }).toISO();
it('should show info events', () => {
const { getByText } = render(
<EventsContent
events={[
{
type: 'Info',
message: 'hello there',
reason: 'something happened',
count: 52,
metadata: {
creationTimestamp: oneHourAgo,
},
} as Event,
]}
/>,
);
expect(getByText('First event 1 hour ago (count: 52)')).toBeInTheDocument();
expect(getByText('something happened: hello there')).toBeInTheDocument();
});
it('should show warning events', () => {
const { getByText } = render(
<EventsContent
events={[
{
type: 'Warning',
message: 'uh oh',
reason: 'something happened',
count: 23,
metadata: {
creationTimestamp: oneHourAgo,
},
} as Event,
]}
/>,
);
expect(getByText('First event 1 hour ago (count: 23)')).toBeInTheDocument();
expect(getByText('something happened: uh oh')).toBeInTheDocument();
});
it('should only show warning events when warningEventsOnly set', () => {
const { getByText, queryByText } = render(
<EventsContent
warningEventsOnly
events={
[
{
type: 'Warning',
message: 'uh oh',
reason: 'something happened',
count: 23,
metadata: {
creationTimestamp: oneHourAgo,
},
},
{
type: 'Info',
message: 'hello there',
reason: 'something happened',
count: 52,
metadata: {
creationTimestamp: oneHourAgo,
},
},
] as Event[]
}
/>,
);
expect(queryByText('First event 1 hour ago (count: 52)')).toBeNull();
expect(queryByText('something happened: hello there')).toBeNull();
expect(getByText('First event 1 hour ago (count: 23)')).toBeInTheDocument();
expect(getByText('something happened: uh oh')).toBeInTheDocument();
});
});
@@ -0,0 +1,154 @@
/*
* Copyright 2023 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import {
Avatar,
Container,
Grid,
List,
ListItem,
ListItemAvatar,
ListItemText,
Tooltip,
Typography,
} from '@material-ui/core';
import InfoIcon from '@material-ui/icons/Info';
import WarningIcon from '@material-ui/icons/Warning';
import { DateTime } from 'luxon';
import { useEvents } from './useEvents';
import { Skeleton } from '@material-ui/lab';
import { DismissableBanner } from '@backstage/core-components';
import { Event } from 'kubernetes-models/v1';
/**
* Props for Events
*
* @public
*/
export interface EventsContentProps {
warningEventsOnly?: boolean;
events: Event[];
}
const getAvatarByType = (type?: string) => {
return (
<ListItemAvatar>
<Avatar>{type === 'Warning' ? <WarningIcon /> : <InfoIcon />}</Avatar>
</ListItemAvatar>
);
};
/**
* Shows given Kubernetes events
*
* @public
*/
export const EventsContent = ({
events,
warningEventsOnly,
}: EventsContentProps) => {
if (events.length === 0) {
return <Typography>No events found</Typography>;
}
return (
<Container>
<Grid>
<List>
{events
.filter(event => {
if (warningEventsOnly) {
return event.type === 'Warning';
}
return true;
})
.map(event => {
const timeAgo = event.metadata.creationTimestamp
? DateTime.fromISO(event.metadata.creationTimestamp).toRelative(
{
locale: 'en',
},
)
: 'unknown';
return (
<ListItem key={event.metadata.name}>
<Tooltip title={`${event.type ?? ''} event`}>
{getAvatarByType(event.type)}
</Tooltip>
<ListItemText
primary={`First event ${timeAgo} (count: ${event.count})`}
secondary={`${event.reason}: ${event.message}`}
/>
</ListItem>
);
})}
</List>
</Grid>
</Container>
);
};
/**
* Props for Events
*
* @public
*/
export interface EventsProps {
involvedObjectName: string;
namespace: string;
clusterName: string;
warningEventsOnly?: boolean;
}
/**
* Retrieves and shows Kubernetes events for the given object
*
* @public
*/
export const Events = ({
involvedObjectName,
namespace,
clusterName,
warningEventsOnly,
}: EventsProps) => {
const { value, error, loading } = useEvents({
involvedObjectName,
namespace,
clusterName,
});
return (
<>
{error && (
<DismissableBanner
{...{
message: error.message,
variant: 'error',
fixed: false,
}}
id="events"
/>
)}
{loading && <Skeleton variant="rect" width="100%" height="100%" />}
{!loading && value !== undefined && (
<EventsContent warningEventsOnly={warningEventsOnly} events={value} />
)}
</>
);
};
@@ -0,0 +1,17 @@
/*
* Copyright 2023 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export * from './Events';
export * from './useEvents';
@@ -0,0 +1,68 @@
/*
* Copyright 2023 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { useApi } from '@backstage/core-plugin-api';
import { renderHook } from '@testing-library/react-hooks';
import { useEvents } from './useEvents';
import { DateTime } from 'luxon';
jest.mock('@backstage/core-plugin-api');
jest.mock('@backstage/plugin-kubernetes', () => ({
kubernetesProxyApiRef: () => jest.fn(),
}));
const oneHourAgo = DateTime.now().minus({ hours: 1 }).toISO();
const response = [
{
type: 'Info',
message: 'hello there',
reason: 'something happened',
count: 52,
metadata: {
creationTimestamp: oneHourAgo,
},
},
] as any;
describe('Events', () => {
const mockGetEventsByInvolvedObjectName = jest.fn();
afterEach(() => {
jest.resetAllMocks();
});
it('should fetch and show events', async () => {
(useApi as any).mockReturnValue({
getEventsByInvolvedObjectName:
mockGetEventsByInvolvedObjectName.mockResolvedValue(response),
});
const { result, waitForNextUpdate } = renderHook(() =>
useEvents({
involvedObjectName: 'some-objecgt',
namespace: 'some-namespace',
clusterName: 'some-cluster',
}),
);
expect(result.current.loading).toEqual(true);
await waitForNextUpdate();
expect(result.current.error).toBeUndefined();
expect(result.current.loading).toEqual(false);
expect(result.current.value).toStrictEqual(response);
});
});
@@ -0,0 +1,49 @@
/*
* Copyright 2023 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { useApi } from '@backstage/core-plugin-api';
import useAsync from 'react-use/lib/useAsync';
import { kubernetesProxyApiRef } from '../../../api/types';
/**
* Arguments for useEvents
*
* @public
*/
export interface EventsOptions {
involvedObjectName: string;
namespace: string;
clusterName: string;
}
/**
* Retrieves the events for the given object
*
* @public
*/
export const useEvents = ({
involvedObjectName,
namespace,
clusterName,
}: EventsOptions) => {
const kubernetesProxyApi = useApi(kubernetesProxyApiRef);
return useAsync(async () => {
return await kubernetesProxyApi.getEventsByInvolvedObjectName({
involvedObjectName,
namespace,
clusterName,
});
}, [involvedObjectName, namespace, clusterName]);
};
@@ -0,0 +1,159 @@
/*
* Copyright 2023 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import { render } from '@testing-library/react';
import { FixDialog } from './FixDialog';
import { Pod } from 'kubernetes-models/v1/Pod';
jest.mock('../Events', () => ({
Events: () => {
return <React.Fragment data-testid="events" />;
},
}));
jest.mock('../PodLogs', () => ({
PodLogs: () => {
return <React.Fragment data-testid="logs" />;
},
}));
describe('FixDialog', () => {
it('docs link should render', () => {
const { getByText } = render(
<FixDialog
open
clusterName="some-cluster"
pod={
{
metadata: {
name: 'some-pod',
namespace: 'some-namespace',
},
} as Pod
}
error={{
type: 'some error type',
severity: 10,
message: 'some error message',
occurrenceCount: 1,
sourceRef: {
name: 'some-pod',
namespace: 'some-namespace',
kind: 'Pod',
apiGroup: 'v1',
},
proposedFix: {
type: 'docs',
docsLink: 'http://google.com',
errorType: 'some error type',
rootCauseExplanation: 'some root cause',
actions: ['fix1', 'fix2'],
},
}}
/>,
);
expect(getByText('Open docs')).toBeInTheDocument();
expect(getByText('some error message')).toBeInTheDocument();
expect(getByText('some-pod - some error type')).toBeInTheDocument();
expect(getByText('some root cause')).toBeInTheDocument();
expect(getByText('fix1')).toBeInTheDocument();
expect(getByText('fix2')).toBeInTheDocument();
});
it('events button should render', () => {
const { getByText } = render(
<FixDialog
open
clusterName="some-cluster"
pod={
{
metadata: {
name: 'some-pod',
namespace: 'some-namespace',
},
} as Pod
}
error={{
type: 'some error type',
severity: 10,
message: 'some error message',
occurrenceCount: 1,
sourceRef: {
name: 'some-pod',
namespace: 'some-namespace',
kind: 'Pod',
apiGroup: 'v1',
},
proposedFix: {
type: 'events',
podName: 'some-pod',
errorType: 'some error type',
rootCauseExplanation: 'some root cause',
actions: ['fix1', 'fix2'],
},
}}
/>,
);
expect(getByText('Events:')).toBeInTheDocument();
expect(getByText('some error message')).toBeInTheDocument();
expect(getByText('some-pod - some error type')).toBeInTheDocument();
expect(getByText('some root cause')).toBeInTheDocument();
expect(getByText('fix1')).toBeInTheDocument();
expect(getByText('fix2')).toBeInTheDocument();
});
it('Logs button should render', () => {
const { getByText } = render(
<FixDialog
open
clusterName="some-cluster"
pod={
{
metadata: {
name: 'some-pod',
namespace: 'some-namespace',
},
} as Pod
}
error={{
type: 'some error type',
severity: 10,
message: 'some error message',
occurrenceCount: 1,
sourceRef: {
name: 'some-pod',
namespace: 'some-namespace',
kind: 'Pod',
apiGroup: 'v1',
},
proposedFix: {
type: 'logs',
container: 'some-container',
errorType: 'some error type',
rootCauseExplanation: 'some root cause',
actions: ['fix1', 'fix2'],
},
}}
/>,
);
expect(getByText('Crash logs:')).toBeInTheDocument();
expect(getByText('some error message')).toBeInTheDocument();
expect(getByText('some-pod - some error type')).toBeInTheDocument();
expect(getByText('some root cause')).toBeInTheDocument();
expect(getByText('fix1')).toBeInTheDocument();
expect(getByText('fix2')).toBeInTheDocument();
});
});
@@ -0,0 +1,187 @@
/*
* Copyright 2023 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { useState } from 'react';
import { Button, Grid } from '@material-ui/core';
import Dialog from '@material-ui/core/Dialog';
import DialogActions from '@material-ui/core/DialogActions';
import DialogContent from '@material-ui/core/DialogContent';
import DialogTitle from '@material-ui/core/DialogTitle';
import IconButton from '@material-ui/core/IconButton';
import { makeStyles, createStyles, Theme } from '@material-ui/core/styles';
import Typography from '@material-ui/core/Typography';
import CloseIcon from '@material-ui/icons/Close';
import HelpIcon from '@material-ui/icons/Help';
import OpenInNewIcon from '@material-ui/icons/OpenInNew';
import { Pod } from 'kubernetes-models/v1/Pod';
import { DetectedError } from '@backstage/plugin-kubernetes-common';
import { PodLogs } from '../PodLogs';
import { Events } from '../Events';
import { LinkButton } from '@backstage/core-components';
const useStyles = makeStyles((theme: Theme) =>
createStyles({
closeButton: {
position: 'absolute',
right: theme.spacing(1),
top: theme.spacing(1),
color: theme.palette.grey[500],
},
}),
);
/**
* Props for FixDialog
*
* @public
*/
export interface FixDialogProps {
open?: boolean;
clusterName: string;
pod: Pod;
error: DetectedError;
}
/**
* A dialog for fixing detected Kubernetes errors
*
* @public
*/
export const FixDialog: React.FC<FixDialogProps> = ({
open,
pod,
error,
clusterName,
}: FixDialogProps) => {
const [isOpen, setOpen] = useState(!!open);
const classes = useStyles();
const openDialog = () => {
setOpen(true);
};
const closeDialog = () => {
setOpen(false);
};
const pf = error.proposedFix;
const dialogContent = () => {
return (
<Grid container>
<Grid item xs={12}>
<Typography variant="h6">Detected error:</Typography>
<Typography>{error.message}</Typography>
</Grid>
<Grid item xs={12}>
<Typography variant="h6">Cause explanation:</Typography>
<Typography>
{error.proposedFix?.rootCauseExplanation ?? 'unknown'}
</Typography>
</Grid>
<Grid item xs={12}>
<Typography variant="h6">Fix:</Typography>
<Typography>
<ul>
{(error.proposedFix?.actions ?? []).map((fix, i) => {
return (
<li key={`${pod.metadata?.name ?? 'unknown'}-pf-${i}`}>
{fix}
</li>
);
})}
</ul>
</Typography>
</Grid>
{pf && pf.type === 'logs' && (
<>
<Grid item xs={12}>
<Typography variant="h6">Crash logs:</Typography>
</Grid>
<Grid item xs={9}>
<PodLogs
previous
containerScope={{
podName: pod.metadata?.name ?? 'unknown',
podNamespace: pod.metadata?.namespace ?? 'unknown',
clusterName: clusterName,
containerName: pf.container,
}}
/>
</Grid>
</>
)}
{pf && pf.type === 'events' && (
<>
<Grid item xs={12}>
<Typography variant="h6">Events:</Typography>
</Grid>
<Grid item xs={9}>
<Events
warningEventsOnly
involvedObjectName={pod.metadata?.name ?? ''}
namespace={pod.metadata?.namespace ?? ''}
clusterName={clusterName}
/>
</Grid>
</>
)}
</Grid>
);
};
return (
<>
<Button
variant="outlined"
aria-label="fix issue"
component="label"
onClick={openDialog}
startIcon={<HelpIcon />}
>
Help
</Button>
<Dialog maxWidth="xl" fullWidth open={isOpen} onClose={closeDialog}>
<DialogTitle id="dialog-title">
{pod.metadata?.name} - {error.type}
<IconButton
aria-label="close"
className={classes.closeButton}
onClick={closeDialog}
>
<CloseIcon />
</IconButton>
</DialogTitle>
<DialogContent>{dialogContent()}</DialogContent>
<DialogActions>
{pf && pf.type === 'docs' && (
<LinkButton
to={pf.docsLink}
variant="outlined"
startIcon={<OpenInNewIcon />}
target="_blank"
rel="noopener"
>
Open docs
</LinkButton>
)}
</DialogActions>
</Dialog>
</>
);
};
@@ -0,0 +1,16 @@
/*
* Copyright 2023 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export * from './FixDialog';
@@ -0,0 +1,135 @@
/*
* Copyright 2023 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import { screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import { renderInTestApp } from '@backstage/test-utils';
import { ContainerCard } from './ContainerCard';
import { DateTime } from 'luxon';
jest.mock('../../../hooks/useIsPodExecTerminalSupported');
const now = DateTime.now();
const oneHourAgo = now.minus({ hours: 1 }).toISO();
const twoHoursAgo = now.minus({ hours: 2 }).toISO();
describe('ContainerCard', () => {
it('show healthy when all checks pass', async () => {
await renderInTestApp(
<ContainerCard
{...({
podScope: {
name: 'some-name',
namespace: 'some-namespace',
clusterName: 'some-cluster',
},
containerSpec: {
readinessProbe: {},
},
containerStatus: {
name: 'some-name',
image: 'gcr.io/some-proj/some-image',
started: true,
ready: true,
restartCount: 0,
state: {
running: {
startedAt: oneHourAgo,
},
},
},
} as any)}
/>,
);
expect(screen.getByText('Started: 1 hour ago')).toBeInTheDocument();
expect(screen.getByText('Status: Running')).toBeInTheDocument();
expect(screen.getByText('some-name')).toBeInTheDocument();
expect(screen.getByText('gcr.io/some-proj/some-image')).toBeInTheDocument();
expect(screen.getAllByText('✅')).toHaveLength(5);
expect(screen.queryByText('❌')).toBeNull();
});
it('show unhealthy when all checks fail', async () => {
await renderInTestApp(
<ContainerCard
{...({
podScope: {
podName: 'some-name',
podNamespace: 'some-namespace',
clusterName: 'some-cluster',
},
containerSpec: {},
containerStatus: {
name: 'some-name',
image: 'gcr.io/some-proj/some-image',
started: false,
ready: false,
restartCount: 12,
state: {
waiting: {},
},
},
} as any)}
/>,
);
expect(screen.getByText('some-name')).toBeInTheDocument();
expect(screen.getByText('gcr.io/some-proj/some-image')).toBeInTheDocument();
expect(screen.getAllByText('❌')).toHaveLength(5);
expect(screen.queryByText('✅')).toBeNull();
});
it('show correct checks for completed container', async () => {
await renderInTestApp(
<ContainerCard
{...({
podScope: {
podName: 'some-name',
podNamespace: 'some-namespace',
clusterName: 'some-cluster',
},
containerSpec: {},
containerStatus: {
name: 'some-name',
image: 'gcr.io/some-proj/some-image',
started: false,
ready: false,
restartCount: 0,
state: {
terminated: {
exitCode: 0,
reason: 'Completed',
startedAt: twoHoursAgo,
finishedAt: oneHourAgo,
},
},
},
} as any)}
/>,
);
expect(screen.getByText('some-name')).toBeInTheDocument();
expect(screen.getByText('gcr.io/some-proj/some-image')).toBeInTheDocument();
expect(screen.getByText('Started: 2 hours ago')).toBeInTheDocument();
expect(screen.getByText('Completed: 1 hour ago')).toBeInTheDocument();
expect(
screen.getByText('Execution time: 1 hour, 0 minutes, 0 seconds'),
).toBeInTheDocument();
expect(screen.getByText('Status: Completed')).toBeInTheDocument();
expect(screen.getAllByText('✅')).toHaveLength(2);
expect(screen.queryByText('❌')).toBeNull();
});
});
@@ -0,0 +1,240 @@
/*
* Copyright 2023 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { StructuredMetadataTable } from '@backstage/core-components';
import { ClientContainerStatus } from '@backstage/plugin-kubernetes-common';
import {
Card,
CardActions,
CardContent,
CardHeader,
Grid,
Typography,
} from '@material-ui/core';
import { IContainer, IContainerStatus } from 'kubernetes-models/v1';
import { DateTime } from 'luxon';
import React from 'react';
import { bytesToMiB, formatMillicores } from '../../../utils/resources';
import { PodExecTerminalDialog } from '../../PodExecTerminal/PodExecTerminalDialog';
import { ResourceUtilization } from '../../ResourceUtilization';
import { PodLogsDialog, PodScope } from '../PodLogs';
const getContainerHealthChecks = (
containerSpec: IContainer,
containerStatus: IContainerStatus,
): { [key: string]: boolean } => {
if (containerStatus.state?.terminated?.reason === 'Completed') {
return {
'not waiting to start': containerStatus.state?.waiting === undefined,
'no restarts': containerStatus.restartCount === 0,
};
}
return {
'not waiting to start': containerStatus.state?.waiting === undefined,
started: !!containerStatus.started,
ready: containerStatus.ready,
'no restarts': containerStatus.restartCount === 0,
'readiness probe set':
containerSpec && containerSpec?.readinessProbe !== undefined,
};
};
const getCurrentState = (containerStatus: IContainerStatus): string => {
return (
containerStatus.state?.waiting?.reason ||
containerStatus.state?.terminated?.reason ||
(containerStatus.state?.running !== undefined ? 'Running' : 'Unknown')
);
};
const getStartedAtTime = (
containerStatus: IContainerStatus,
): string | undefined => {
return (
containerStatus.state?.running?.startedAt ||
containerStatus.state?.terminated?.startedAt
);
};
interface ContainerDatetimeProps {
prefix: string;
dateTime: string;
}
const ContainerDatetime = ({ prefix, dateTime }: ContainerDatetimeProps) => {
return (
<Typography variant="subtitle2">
{prefix}:{' '}
{DateTime.fromISO(dateTime).toRelative({
locale: 'en',
})}
</Typography>
);
};
/**
* Props for ContainerCard
*
* @public
*/
export interface ContainerCardProps {
podScope: PodScope;
containerSpec?: IContainer;
containerStatus: IContainerStatus;
containerMetrics?: ClientContainerStatus;
}
/**
* Shows details about a container within a pod
*
* @public
*/
export const ContainerCard: React.FC<ContainerCardProps> = ({
podScope,
containerSpec,
containerStatus,
containerMetrics,
}: ContainerCardProps) => {
// This should never be undefined
if (containerSpec === undefined) {
return <Typography>error reading pod from cluster</Typography>;
}
const containerStartedTime = getStartedAtTime(containerStatus);
const containerFinishedTime = containerStatus.state?.terminated?.finishedAt;
return (
<Card>
<CardHeader
title={containerStatus.name}
subheader={containerStatus.image}
/>
<CardContent>
<Grid container>
<Grid item xs={12}>
{containerStartedTime && (
<ContainerDatetime
prefix="Started"
dateTime={containerStartedTime}
/>
)}
{containerFinishedTime && (
<ContainerDatetime
prefix="Completed"
dateTime={containerFinishedTime}
/>
)}
{containerStartedTime && containerFinishedTime && (
<Typography variant="subtitle2">
Execution time:{' '}
{DateTime.fromISO(containerFinishedTime)
.diff(DateTime.fromISO(containerStartedTime), [
'hours',
'minutes',
'seconds',
])
.toHuman()}
</Typography>
)}
</Grid>
<Grid item xs={12}>
<Typography variant="subtitle2">
Status: {getCurrentState(containerStatus)}
</Typography>
</Grid>
{containerStatus.restartCount > 0 && (
<Grid item xs={12}>
<Typography variant="subtitle2">
Restarts: {containerStatus.restartCount}
</Typography>
</Grid>
)}
<Grid item xs={12}>
<Typography variant="subtitle2">Container health</Typography>
</Grid>
<Grid item xs={12}>
<StructuredMetadataTable
metadata={getContainerHealthChecks(
containerSpec,
containerStatus,
)}
/>
</Grid>
{containerMetrics && (
<Grid container item xs={12} spacing={0}>
<Grid item xs={12}>
<Typography variant="subtitle1">
Resource utilization
</Typography>
</Grid>
<Grid item xs={12} style={{ minHeight: '5rem' }}>
<ResourceUtilization
compressed
title="CPU requests"
usage={containerMetrics.cpuUsage.currentUsage}
total={containerMetrics.cpuUsage.requestTotal}
totalFormatted={formatMillicores(
containerMetrics.cpuUsage.requestTotal,
)}
/>
<ResourceUtilization
compressed
title="CPU limits"
usage={containerMetrics.cpuUsage.currentUsage}
total={containerMetrics.cpuUsage.limitTotal}
totalFormatted={formatMillicores(
containerMetrics.cpuUsage.limitTotal,
)}
/>
<ResourceUtilization
compressed
title="Memory requests"
usage={containerMetrics.memoryUsage.currentUsage}
total={containerMetrics.memoryUsage.requestTotal}
totalFormatted={bytesToMiB(
containerMetrics.memoryUsage.requestTotal,
)}
/>
<ResourceUtilization
compressed
title="Memory limits"
usage={containerMetrics.memoryUsage.currentUsage}
total={containerMetrics.memoryUsage.limitTotal}
totalFormatted={bytesToMiB(
containerMetrics.memoryUsage.limitTotal,
)}
/>
</Grid>
</Grid>
)}
</Grid>
</CardContent>
<CardActions>
<PodLogsDialog
containerScope={{
containerName: containerStatus.name,
...podScope,
}}
/>
<PodExecTerminalDialog
clusterName={podScope.clusterName}
containerName={containerStatus.name}
podName={podScope.podName}
podNamespace={podScope.podNamespace}
/>
</CardActions>
</Card>
);
};
@@ -0,0 +1,186 @@
/*
* Copyright 2023 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import { screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import { renderInTestApp } from '@backstage/test-utils';
import { PendingPodContent } from './PendingPodContent';
import { IPodCondition } from 'kubernetes-models/v1';
import { DateTime } from 'luxon';
const podWithConditions = (conditions: IPodCondition[]): any => {
return {
metadata: {
name: 'ok-pod',
},
spec: {
containers: [
{
name: 'some-container',
},
],
},
status: {
podIP: '127.0.0.1',
conditions: conditions,
},
};
};
describe('PendingPodContent', () => {
it('show startup conditions - all healthy', async () => {
const oneDayAgo = DateTime.now().minus({ days: 1 }).toISO()!;
await renderInTestApp(
<PendingPodContent
{...{
pod: podWithConditions([
{
type: 'Initialized',
status: 'True',
lastTransitionTime: oneDayAgo,
},
{
type: 'PodScheduled',
status: 'True',
lastTransitionTime: oneDayAgo,
},
{
type: 'ContainersReady',
status: 'True',
lastTransitionTime: oneDayAgo,
},
{
type: 'Ready',
status: 'True',
lastTransitionTime: oneDayAgo,
},
]),
}}
/>,
);
expect(screen.getByText('Pod is Pending. Conditions:')).toBeInTheDocument();
expect(screen.getByText('Initialized - (1 day ago)')).toBeInTheDocument();
expect(screen.getByText('PodScheduled - (1 day ago)')).toBeInTheDocument();
expect(
screen.getByText('ContainersReady - (1 day ago)'),
).toBeInTheDocument();
expect(screen.getByText('Ready - (1 day ago)')).toBeInTheDocument();
expect(screen.queryAllByLabelText('Status ok')).toHaveLength(4);
expect(screen.queryByLabelText('Status warning')).not.toBeInTheDocument();
expect(screen.queryByLabelText('Status error')).not.toBeInTheDocument();
});
it('show startup conditions - all fail', async () => {
const oneHourAgo = DateTime.now().minus({ hours: 1 }).toISO()!;
await renderInTestApp(
<PendingPodContent
{...{
pod: podWithConditions([
{
type: 'Initialized',
status: 'False',
reason: 'InitializedFailureReason',
message: 'reason why Initialized failed',
lastTransitionTime: oneHourAgo,
},
{
type: 'PodScheduled',
status: 'False',
reason: 'PodScheduledFailureReason',
message: 'reason why PodScheduled failed',
lastTransitionTime: oneHourAgo,
},
{
type: 'ContainersReady',
status: 'False',
reason: 'ContainersReadyFailureReason',
message: 'reason why ContainersReady failed',
lastTransitionTime: oneHourAgo,
},
{
type: 'Ready',
status: 'False',
reason: 'ReadyFailureReason',
message: 'reason why Ready failed',
lastTransitionTime: oneHourAgo,
},
]),
}}
/>,
);
expect(screen.getByText('Pod is Pending. Conditions:')).toBeInTheDocument();
expect(
screen.getByText(
'Initialized - (InitializedFailureReason 1 hour ago) - reason why Initialized failed',
),
).toBeInTheDocument();
expect(
screen.getByText(
'PodScheduled - (PodScheduledFailureReason 1 hour ago) - reason why PodScheduled failed',
),
).toBeInTheDocument();
expect(
screen.getByText(
'ContainersReady - (ContainersReadyFailureReason 1 hour ago) - reason why ContainersReady failed',
),
).toBeInTheDocument();
expect(
screen.getByText(
'Ready - (ReadyFailureReason 1 hour ago) - reason why Ready failed',
),
).toBeInTheDocument();
expect(screen.queryByLabelText('Status ok')).not.toBeInTheDocument();
expect(screen.queryByLabelText('Status warning')).not.toBeInTheDocument();
expect(screen.queryAllByLabelText('Status error')).toHaveLength(4);
});
it('show startup conditions - show unknown', async () => {
const oneHourAgo = DateTime.now().minus({ hours: 1 }).toISO()!;
await renderInTestApp(
<PendingPodContent
{...{
pod: podWithConditions([
{
type: 'Initialized',
status: 'Unknown',
reason: 'InitializedUnknownReason',
message: 'dont know what is happening',
lastTransitionTime: oneHourAgo,
},
]),
}}
/>,
);
expect(screen.getByText('Pod is Pending. Conditions:')).toBeInTheDocument();
expect(
screen.getByText(
'Initialized - (1 hour ago) dont know what is happening',
),
).toBeInTheDocument();
expect(screen.queryByLabelText('Status ok')).not.toBeInTheDocument();
expect(screen.getByLabelText('Status warning')).toBeInTheDocument();
expect(screen.queryByLabelText('Status error')).not.toBeInTheDocument();
});
});
@@ -0,0 +1,104 @@
/*
* Copyright 2023 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import { Grid, List, ListItem, Typography } from '@material-ui/core';
import { IPodCondition, Pod } from 'kubernetes-models/v1';
import {
StatusError,
StatusOK,
StatusWarning,
} from '@backstage/core-components';
import { DateTime } from 'luxon';
interface PodConditionProps {
condition: IPodCondition;
}
const PodCondition = ({ condition }: PodConditionProps) => {
return (
<>
{condition.status === 'False' && (
<StatusError>
{condition.type} - ({condition.reason}{' '}
{condition.lastTransitionTime &&
DateTime.fromISO(condition.lastTransitionTime).toRelative({
locale: 'en',
})}
) - {condition.message}{' '}
</StatusError>
)}
{condition.status === 'True' && (
<StatusOK>
{condition.type} - (
{condition.lastTransitionTime &&
DateTime.fromISO(condition.lastTransitionTime).toRelative({
locale: 'en',
})}
)
</StatusOK>
)}
{condition.status === 'Unknown' && (
<StatusWarning>
{condition.type} - (
{condition.lastTransitionTime &&
DateTime.fromISO(condition.lastTransitionTime).toRelative({
locale: 'en',
})}
) {condition.message}
</StatusWarning>
)}
</>
);
};
/**
* Props for PendingPodContent
*
* @public
*/
export interface PendingPodContentProps {
pod: Pod;
}
/**
* Shows details about pod's conditions as it starts
*
* @public
*/
export const PendingPodContent = ({ pod }: PendingPodContentProps) => {
// TODO add PodHasNetwork when it's out of alpha
// https://kubernetes.io/docs/concepts/workloads/pods/pod-lifecycle/#pod-conditions
const startupConditions = [
pod.status?.conditions?.find(c => c.type === 'PodScheduled'),
pod.status?.conditions?.find(c => c.type === 'Initialized'),
pod.status?.conditions?.find(c => c.type === 'ContainersReady'),
pod.status?.conditions?.find(c => c.type === 'Ready'),
].filter((c): c is IPodCondition => !!c); // filter out undefined
return (
<Grid container spacing={2}>
<Grid item xs={12}>
<Typography variant="h5">Pod is Pending. Conditions:</Typography>
<List>
{startupConditions.map(c => (
<ListItem key={c.type}>
<PodCondition condition={c} />
</ListItem>
))}
</List>
</Grid>
</Grid>
);
};
@@ -0,0 +1,97 @@
/*
* Copyright 2020 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import { screen } from '@testing-library/react';
import { TestApiProvider, renderInTestApp } from '@backstage/test-utils';
import '@testing-library/jest-dom';
import { PodDrawer } from './PodDrawer';
import { DiscoveryApi, discoveryApiRef } from '@backstage/core-plugin-api';
jest.mock('../../../hooks/useIsPodExecTerminalSupported');
describe('PodDrawer', () => {
it('Should show title and container names', async () => {
const mockDiscoveryApi: Partial<DiscoveryApi> = {
getBaseUrl: () => Promise.resolve('http://localhost'),
};
await renderInTestApp(
<TestApiProvider apis={[[discoveryApiRef, mockDiscoveryApi]]}>
<PodDrawer
{...({
open: true,
podAndErrors: {
clusterName: 'some-cluster-1',
pod: {
metadata: {
name: 'some-pod',
},
spec: {
containers: [
{
name: 'some-container',
},
],
},
status: {
podIP: '127.0.0.1',
containerStatuses: [
{
name: 'some-container',
},
],
},
},
errors: [
{
type: 'some-error',
severity: 10,
message: 'some error message',
occurrenceCount: 1,
sourceRef: {
name: 'some-pod',
namespace: 'some-namespace',
kind: 'Pod',
apiGroup: 'v1',
},
proposedFix: [
{
type: 'logs',
container: 'some-container',
errorType: 'some error type',
rootCauseExplanation: 'some root cause',
actions: ['fix1', 'fix2'],
},
],
},
],
},
} as any)}
/>
</TestApiProvider>,
);
expect(screen.getAllByText('some-pod')).toHaveLength(3);
expect(screen.getByText('Pod (127.0.0.1)')).toBeInTheDocument();
expect(screen.getByText('YAML')).toBeInTheDocument();
expect(screen.getByText('Containers')).toBeInTheDocument();
expect(screen.getByText('some-container')).toBeInTheDocument();
expect(screen.getByText('some error message')).toBeInTheDocument();
});
});
@@ -0,0 +1,189 @@
/*
* Copyright 2020 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import { ItemCardGrid } from '@backstage/core-components';
import {
createStyles,
makeStyles,
Theme,
Grid,
Typography,
} from '@material-ui/core';
import { Pod } from 'kubernetes-models/v1';
import { ContainerCard } from './ContainerCard';
import { PodAndErrors } from '../types';
import { KubernetesDrawer } from '../../KubernetesDrawer';
import { PendingPodContent } from './PendingPodContent';
import { ErrorList } from '../ErrorList';
import { usePodMetrics } from '../../../hooks/usePodMetrics';
import { ResourceUtilization } from '../../ResourceUtilization';
import { bytesToMiB, formatMillicores } from '../../../utils/resources';
const useDrawerContentStyles = makeStyles((_theme: Theme) =>
createStyles({
header: {
display: 'flex',
flexDirection: 'row',
justifyContent: 'space-between',
},
content: {
height: '80%',
},
icon: {
fontSize: 20,
},
podoklist: {
width: '100%',
maxWidth: 360,
maxHeight: 360,
},
}),
);
function getContainerSpecByName(pod: Pod, containerName: string) {
return pod.spec?.containers.find(c => c.name === containerName);
}
/**
* Props for PodDrawer
*
* @public
*/
export interface PodDrawerProps {
open?: boolean;
podAndErrors: PodAndErrors;
}
/**
* A Drawer for Kubernetes Pods
*
* @public
*/
export const PodDrawer = ({ podAndErrors, open }: PodDrawerProps) => {
const classes = useDrawerContentStyles();
const podMetrics = usePodMetrics(podAndErrors.clusterName, podAndErrors.pod);
return (
<KubernetesDrawer
open={open}
drawerContentsHeader={
<Typography variant="subtitle1">
Pod{' '}
{podAndErrors.pod.status?.podIP &&
`(${podAndErrors.pod.status?.podIP})`}
</Typography>
}
kubernetesObject={podAndErrors.pod}
label={
<Typography variant="subtitle1">
{podAndErrors.pod.metadata?.name ?? 'unknown'}
</Typography>
}
>
<div className={classes.content}>
{podMetrics && (
<Grid container item xs={12}>
<Grid item xs={12}>
<Typography variant="h5">Resource utilization</Typography>
</Grid>
<Grid item xs={6}>
<ResourceUtilization
title="CPU requests"
usage={podMetrics.cpu.currentUsage}
total={podMetrics.cpu.requestTotal}
totalFormatted={formatMillicores(podMetrics.cpu.requestTotal)}
/>
<ResourceUtilization
title="CPU limits"
usage={podMetrics.cpu.currentUsage}
total={podMetrics.cpu.limitTotal}
totalFormatted={formatMillicores(podMetrics.cpu.limitTotal)}
/>
</Grid>
<Grid item xs={6}>
<ResourceUtilization
title="Memory requests"
usage={podMetrics.memory.currentUsage}
total={podMetrics.memory.requestTotal}
totalFormatted={bytesToMiB(podMetrics.memory.requestTotal)}
/>
<ResourceUtilization
title="Memory limits"
usage={podMetrics.memory.currentUsage}
total={podMetrics.memory.limitTotal}
totalFormatted={bytesToMiB(podMetrics.memory.requestTotal)}
/>
</Grid>
</Grid>
)}
{podAndErrors.pod.status?.phase === 'Pending' && (
<PendingPodContent pod={podAndErrors.pod} />
)}
{podAndErrors.pod.status?.containerStatuses?.length && (
<Grid container spacing={2}>
<Grid item xs={12}>
<Typography variant="h5">Containers</Typography>
</Grid>
<Grid item xs={12}>
<ItemCardGrid>
{podAndErrors.pod.status?.containerStatuses?.map(
(containerStatus, i) => {
const containerSpec = getContainerSpecByName(
podAndErrors.pod,
containerStatus.name,
);
const containerMetrics = (
podMetrics?.containers ?? []
).find(c => c.container === containerStatus.name);
return (
<ContainerCard
key={`container-card-${podAndErrors.pod.metadata?.name}-${i}`}
containerMetrics={containerMetrics}
podScope={{
podName: podAndErrors.pod.metadata?.name ?? 'unknown',
podNamespace:
podAndErrors.pod.metadata?.namespace ?? 'unknown',
clusterName: podAndErrors.clusterName,
}}
containerSpec={containerSpec}
containerStatus={containerStatus}
/>
);
},
)}
</ItemCardGrid>
</Grid>
{podAndErrors.errors.length > 0 && (
<Grid item xs={12}>
<Typography variant="h5">Errors</Typography>
</Grid>
)}
{podAndErrors.errors.length > 0 && (
<Grid item xs={12}>
<ErrorList podAndErrors={[podAndErrors]} />
</Grid>
)}
</Grid>
)}
</div>
</KubernetesDrawer>
);
};
@@ -0,0 +1,18 @@
/*
* Copyright 2023 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export * from './PodDrawer';
export * from './ContainerCard';
export * from './PendingPodContent';
@@ -0,0 +1,84 @@
/*
* Copyright 2023 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import {
DismissableBanner,
EmptyState,
LogViewer,
} from '@backstage/core-components';
import { Paper } from '@material-ui/core';
import { Skeleton } from '@material-ui/lab';
import { ContainerScope } from './types';
import { usePodLogs } from './usePodLogs';
/**
* Props for PodLogs
*
* @public
*/
export interface PodLogsProps {
containerScope: ContainerScope;
previous?: boolean;
}
/**
* Shows the logs for the given pod
*
* @public
*/
export const PodLogs: React.FC<PodLogsProps> = ({
containerScope,
previous,
}: PodLogsProps) => {
const { value, error, loading } = usePodLogs({
containerScope,
previous,
});
return (
<>
{error && (
<DismissableBanner
{...{
message: error.message,
variant: 'error',
fixed: false,
}}
id="pod-logs"
/>
)}
<Paper
elevation={1}
style={{ height: '100%', width: '100%', minHeight: '55rem' }}
>
{loading && <Skeleton variant="rect" width="100%" height="100%" />}
{!loading &&
value !== undefined &&
(value.text === '' ? (
<EmptyState
missing="data"
title="No logs emitted"
description="No logs were emitted by the container"
/>
) : (
<LogViewer text={value.text} />
))}
</Paper>
</>
);
};
@@ -0,0 +1,100 @@
/*
* Copyright 2023 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { useState } from 'react';
import {
Button,
createStyles,
Dialog,
DialogContent,
DialogTitle,
IconButton,
makeStyles,
Theme,
} from '@material-ui/core';
import SubjectIcon from '@material-ui/icons/Subject';
import CloseIcon from '@material-ui/icons/Close';
import { PodLogs } from './PodLogs';
import { ContainerScope } from './types';
const useStyles = makeStyles((theme: Theme) =>
createStyles({
closeButton: {
position: 'absolute',
right: theme.spacing(1),
top: theme.spacing(1),
color: theme.palette.grey[500],
},
}),
);
/**
* Props for PodLogsDialog
*
* @public
*/
export interface PodLogsDialogProps {
containerScope: ContainerScope;
}
/**
* Shows the logs for the given pod in a Dialog
*
* @public
*/
export const PodLogsDialog = ({ containerScope }: PodLogsDialogProps) => {
const classes = useStyles();
const [open, setOpen] = useState(false);
const openDialog = () => {
setOpen(true);
};
const closeDialog = () => {
setOpen(false);
};
return (
<>
<Dialog maxWidth="xl" fullWidth open={open} onClose={closeDialog}>
<DialogTitle id="dialog-title">
{containerScope.podName} - {containerScope.containerName} logs on
cluster {containerScope.clusterName}
<IconButton
aria-label="close"
className={classes.closeButton}
onClick={closeDialog}
>
<CloseIcon />
</IconButton>
</DialogTitle>
<DialogContent>
<PodLogs containerScope={containerScope} />
</DialogContent>
</Dialog>
<Button
variant="outlined"
aria-label="get logs"
component="label"
onClick={openDialog}
startIcon={<SubjectIcon />}
>
Logs
</Button>
</>
);
};
@@ -0,0 +1,19 @@
/*
* Copyright 2023 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export * from './PodLogs';
export * from './PodLogsDialog';
export * from './usePodLogs';
export * from './types';
@@ -0,0 +1,35 @@
/*
* Copyright 2023 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* Contains the details needed to make a log request to Kubernetes, except the container name
*
* @public
*/
export interface PodScope {
podName: string;
podNamespace: string;
clusterName: string;
}
/**
* Contains the details needed to make a log request to Kubernetes
*
* @public
*/
export interface ContainerScope extends PodScope {
containerName: string;
}
@@ -0,0 +1,48 @@
/*
* Copyright 2023 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import useAsync from 'react-use/lib/useAsync';
import { ContainerScope } from './types';
import { useApi } from '@backstage/core-plugin-api';
import { kubernetesProxyApiRef } from '../../../api/types';
/**
* Arguments for usePodLogs
*
* @public
*/
export interface PodLogsOptions {
containerScope: ContainerScope;
previous?: boolean;
}
/**
* Retrieves the logs for the given pod
*
* @public
*/
export const usePodLogs = ({ containerScope, previous }: PodLogsOptions) => {
const kubernetesProxyApi = useApi(kubernetesProxyApiRef);
return useAsync(async () => {
return await kubernetesProxyApi.getPodLogs({
podName: containerScope.podName,
namespace: containerScope.podNamespace,
containerName: containerScope.containerName,
clusterName: containerScope.clusterName,
previous,
});
}, [JSON.stringify(containerScope)]);
};
@@ -0,0 +1,181 @@
/*
* Copyright 2020 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import { screen } from '@testing-library/react';
import * as pod from './__fixtures__/pod.json';
import * as crashingPod from './__fixtures__/crashing-pod.json';
import { renderInTestApp } from '@backstage/test-utils';
import { PodsTable, READY_COLUMNS, RESOURCE_COLUMNS } from './PodsTable';
import { kubernetesProviders } from '../../hooks/test-utils';
import { ClientPodStatus } from '@backstage/plugin-kubernetes-common';
describe('PodsTable', () => {
it('should render pod', async () => {
await renderInTestApp(<PodsTable pods={[pod as any]} />);
// titles
expect(screen.getByText('name')).toBeInTheDocument();
expect(screen.getByText('phase')).toBeInTheDocument();
expect(screen.getByText('status')).toBeInTheDocument();
// values
expect(screen.getByText('dice-roller-6c8646bfd-2m5hv')).toBeInTheDocument();
expect(screen.getByText('Running')).toBeInTheDocument();
expect(screen.getByText('OK')).toBeInTheDocument();
});
it('should render pod with extra columns', async () => {
await renderInTestApp(
<PodsTable pods={[pod as any]} extraColumns={[READY_COLUMNS]} />,
);
// titles
expect(screen.getByText('name')).toBeInTheDocument();
expect(screen.getByText('phase')).toBeInTheDocument();
expect(screen.getByText('containers ready')).toBeInTheDocument();
expect(screen.getByText('total restarts')).toBeInTheDocument();
expect(screen.getByText('status')).toBeInTheDocument();
// values
expect(screen.getByText('dice-roller-6c8646bfd-2m5hv')).toBeInTheDocument();
expect(screen.getByText('Running')).toBeInTheDocument();
expect(screen.getByText('1/1')).toBeInTheDocument();
expect(screen.getByText('0')).toBeInTheDocument();
expect(screen.getByText('OK')).toBeInTheDocument();
});
it('should render pod, with metrics context', async () => {
const clusterToClientPodStatus = new Map<string, ClientPodStatus[]>();
clusterToClientPodStatus.set('some-cluster', [
{
pod: {
metadata: {
name: 'dice-roller-6c8646bfd-2m5hv',
namespace: 'default',
},
},
memory: {
currentUsage: '1069056',
requestTotal: '67108864',
limitTotal: '134217728',
},
cpu: {
currentUsage: 0.4966115,
requestTotal: 0.05,
limitTotal: 0.05,
},
},
] as any);
const wrapper = kubernetesProviders(
undefined,
undefined,
clusterToClientPodStatus,
{
name: 'some-cluster',
},
);
await renderInTestApp(
wrapper(
<PodsTable
pods={[pod as any]}
extraColumns={[READY_COLUMNS, RESOURCE_COLUMNS]}
/>,
),
);
// titles
expect(screen.getByText('name')).toBeInTheDocument();
expect(screen.getByText('phase')).toBeInTheDocument();
expect(screen.getByText('containers ready')).toBeInTheDocument();
expect(screen.getByText('total restarts')).toBeInTheDocument();
expect(screen.getByText('status')).toBeInTheDocument();
expect(screen.getByText('CPU usage %')).toBeInTheDocument();
expect(screen.getByText('Memory usage %')).toBeInTheDocument();
// values
expect(screen.getByText('dice-roller-6c8646bfd-2m5hv')).toBeInTheDocument();
expect(screen.getByText('Running')).toBeInTheDocument();
expect(screen.getByText('1/1')).toBeInTheDocument();
expect(screen.getByText('0')).toBeInTheDocument();
expect(screen.getByText('OK')).toBeInTheDocument();
expect(screen.getByText('requests: 99% of 50m')).toBeInTheDocument();
expect(screen.getByText('limits: 99% of 50m')).toBeInTheDocument();
expect(screen.getByText('requests: 1% of 64MiB')).toBeInTheDocument();
expect(screen.getByText('limits: 0% of 128MiB')).toBeInTheDocument();
});
it('should render placeholder when empty metrics context', async () => {
const podNameToClientPodStatus = new Map<string, ClientPodStatus[]>();
const wrapper = kubernetesProviders(
undefined,
undefined,
podNameToClientPodStatus,
);
await renderInTestApp(
wrapper(
<PodsTable
pods={[pod as any]}
extraColumns={[READY_COLUMNS, RESOURCE_COLUMNS]}
/>,
),
);
// titles
expect(screen.getByText('name')).toBeInTheDocument();
expect(screen.getByText('phase')).toBeInTheDocument();
expect(screen.getByText('containers ready')).toBeInTheDocument();
expect(screen.getByText('total restarts')).toBeInTheDocument();
expect(screen.getByText('status')).toBeInTheDocument();
expect(screen.getByText('CPU usage %')).toBeInTheDocument();
expect(screen.getByText('Memory usage %')).toBeInTheDocument();
// values
expect(screen.getByText('dice-roller-6c8646bfd-2m5hv')).toBeInTheDocument();
expect(screen.getByText('Running')).toBeInTheDocument();
expect(screen.getByText('1/1')).toBeInTheDocument();
expect(screen.getByText('0')).toBeInTheDocument();
expect(screen.getByText('OK')).toBeInTheDocument();
expect(screen.getAllByText('unknown')).toHaveLength(2);
});
it('should render crashing pod with extra columns', async () => {
await renderInTestApp(
<PodsTable pods={[crashingPod as any]} extraColumns={[READY_COLUMNS]} />,
);
// titles
expect(screen.getByText('name')).toBeInTheDocument();
expect(screen.getByText('phase')).toBeInTheDocument();
expect(screen.getByText('containers ready')).toBeInTheDocument();
expect(screen.getByText('total restarts')).toBeInTheDocument();
expect(screen.getByText('status')).toBeInTheDocument();
// values
expect(
screen.getByText('dice-roller-canary-7d64cd756c-55rfq'),
).toBeInTheDocument();
expect(screen.getByText('Running')).toBeInTheDocument();
expect(screen.getByText('1/3')).toBeInTheDocument();
expect(screen.getByText('76')).toBeInTheDocument();
expect(screen.getByText('Container: side-car')).toBeInTheDocument();
expect(screen.getByText('Container: other-side-car')).toBeInTheDocument();
expect(screen.getAllByText('CrashLoopBackOff')).toHaveLength(2);
});
});
+184
View File
@@ -0,0 +1,184 @@
/*
* Copyright 2020 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { useContext } from 'react';
import { PodDrawer } from './PodDrawer';
import {
containersReady,
containerStatuses,
podStatusToCpuUtil,
podStatusToMemoryUtil,
totalRestarts,
} from '../../utils/pod';
import { Table, TableColumn } from '@backstage/core-components';
import { ClusterContext } from '../../hooks/Cluster';
import { useMatchingErrors } from '../../hooks/useMatchingErrors';
import { Pod } from 'kubernetes-models/v1/Pod';
import { V1Pod } from '@kubernetes/client-node';
import { usePodMetrics } from '../../hooks/usePodMetrics';
import { Typography } from '@material-ui/core';
/**
*
*
* @public
*/
export const READY_COLUMNS: PodColumns = 'READY';
/**
*
*
* @public
*/
export const RESOURCE_COLUMNS: PodColumns = 'RESOURCE';
/**
*
*
* @public
*/
export type PodColumns = 'READY' | 'RESOURCE';
/**
*
*
* @public
*/
export type PodsTablesProps = {
pods: Pod | V1Pod[];
extraColumns?: PodColumns[];
children?: React.ReactNode;
};
const READY: TableColumn<Pod>[] = [
{
title: 'containers ready',
align: 'center',
render: containersReady,
width: 'auto',
},
{
title: 'total restarts',
align: 'center',
render: totalRestarts,
type: 'numeric',
width: 'auto',
},
];
const PodDrawerTrigger = ({ pod }: { pod: Pod }) => {
const cluster = useContext(ClusterContext);
const errors = useMatchingErrors({
kind: 'Pod',
apiVersion: 'v1',
metadata: pod.metadata,
});
return (
<PodDrawer
podAndErrors={{
pod: pod as any,
clusterName: cluster.name,
errors: errors,
}}
/>
);
};
const Cpu = ({ clusterName, pod }: { clusterName: string; pod: Pod }) => {
const metrics = usePodMetrics(clusterName, pod);
if (!metrics) {
return <Typography>unknown</Typography>;
}
return <>{podStatusToCpuUtil(metrics)}</>;
};
const Memory = ({ clusterName, pod }: { clusterName: string; pod: Pod }) => {
const metrics = usePodMetrics(clusterName, pod);
if (!metrics) {
return <Typography>unknown</Typography>;
}
return <>{podStatusToMemoryUtil(metrics)}</>;
};
/**
*
*
* @public
*/
export const PodsTable = ({ pods, extraColumns = [] }: PodsTablesProps) => {
const cluster = useContext(ClusterContext);
const defaultColumns: TableColumn<Pod>[] = [
{
title: 'name',
highlight: true,
render: (pod: Pod) => {
return <PodDrawerTrigger pod={pod} />;
},
},
{
title: 'phase',
render: (pod: Pod) => pod.status?.phase ?? 'unknown',
width: 'auto',
},
{
title: 'status',
render: containerStatuses,
},
];
const columns: TableColumn<Pod>[] = [...defaultColumns];
if (extraColumns.includes(READY_COLUMNS)) {
columns.push(...READY);
}
if (extraColumns.includes(RESOURCE_COLUMNS)) {
const resourceColumns: TableColumn<Pod>[] = [
{
title: 'CPU usage %',
render: (pod: Pod) => {
return <Cpu clusterName={cluster.name} pod={pod} />;
},
width: 'auto',
},
{
title: 'Memory usage %',
render: (pod: Pod) => {
return <Memory clusterName={cluster.name} pod={pod} />;
},
width: 'auto',
},
];
columns.push(...resourceColumns);
}
const tableStyle = {
minWidth: '0',
width: '100%',
};
return (
<div style={tableStyle}>
<Table
options={{ paging: true, search: false, emptyRowsWhenPaging: false }}
data={pods as Pod[]}
columns={columns}
/>
</div>
);
};

Some files were not shown because too many files have changed in this diff Show More