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:
@@ -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 }));
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
+47
@@ -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';
|
||||
+102
@@ -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>
|
||||
);
|
||||
};
|
||||
+55
@@ -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>
|
||||
);
|
||||
};
|
||||
+127
@@ -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();
|
||||
});
|
||||
});
|
||||
+97
@@ -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>
|
||||
);
|
||||
};
|
||||
+129
@@ -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
|
||||
}
|
||||
}
|
||||
+43
@@ -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;
|
||||
+576
@@ -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
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
+29
@@ -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;
|
||||
+135
@@ -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
|
||||
}
|
||||
}
|
||||
+129
@@ -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
|
||||
}
|
||||
}
|
||||
+27
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
+39
@@ -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>
|
||||
);
|
||||
};
|
||||
+62
@@ -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>
|
||||
);
|
||||
};
|
||||
+129
@@ -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';
|
||||
+67
@@ -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>
|
||||
);
|
||||
};
|
||||
+46
@@ -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();
|
||||
});
|
||||
});
|
||||
+206
@@ -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';
|
||||
+52
@@ -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();
|
||||
});
|
||||
});
|
||||
+50
@@ -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>
|
||||
);
|
||||
};
|
||||
+82
@@ -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>
|
||||
);
|
||||
};
|
||||
+33
@@ -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>
|
||||
);
|
||||
};
|
||||
+57
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
+325
@@ -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';
|
||||
+135
@@ -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>
|
||||
);
|
||||
};
|
||||
+186
@@ -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();
|
||||
});
|
||||
});
|
||||
+104
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
Reference in New Issue
Block a user