From ee569bd3001f2e3e0fd274987d3ce68da3f52731 Mon Sep 17 00:00:00 2001 From: unxsist Date: Thu, 9 May 2024 12:19:28 +0200 Subject: [PATCH] feat: added visual cpu and memory usage indicator to pod overview --- src-tauri/Cargo.lock | 19 +++ src-tauri/Cargo.toml | 1 + src-tauri/src/main.rs | 32 ++++ src/components/tables/pods.ts | 11 +- src/components/tables/types.ts | 5 +- src/components/ui/DataTable.vue | 4 +- src/components/ui/PodUsageChart.vue | 154 ++++++++++++++++++ src/components/ui/PodUsageChartBar.vue | 29 ++++ src/components/ui/tooltip/Tooltip.vue | 14 ++ src/components/ui/tooltip/TooltipContent.vue | 31 ++++ src/components/ui/tooltip/TooltipProvider.vue | 11 ++ src/components/ui/tooltip/TooltipTrigger.vue | 11 ++ src/components/ui/tooltip/index.ts | 4 + src/lib/unitParsers.ts | 23 +++ src/services/Kubernetes.ts | 25 ++- src/views/Pods.vue | 55 +++++-- tailwind.config.js | 3 + 17 files changed, 410 insertions(+), 22 deletions(-) create mode 100644 src/components/ui/PodUsageChart.vue create mode 100644 src/components/ui/PodUsageChartBar.vue create mode 100644 src/components/ui/tooltip/Tooltip.vue create mode 100644 src/components/ui/tooltip/TooltipContent.vue create mode 100644 src/components/ui/tooltip/TooltipProvider.vue create mode 100644 src/components/ui/tooltip/TooltipTrigger.vue create mode 100644 src/components/ui/tooltip/index.ts create mode 100644 src/lib/unitParsers.ts diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 9ea549b..2f586e6 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1513,6 +1513,12 @@ dependencies = [ "regex-syntax 0.8.3", ] +[[package]] +name = "go-parse-duration" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "558b88954871f5e5b2af0e62e2e176c8bde7a6c2c4ed41b13d138d96da2e2cbd" + [[package]] name = "gobject-sys" version = "0.15.10" @@ -1966,6 +1972,7 @@ dependencies = [ "either", "fix-path-env", "istio-api-rs", + "k8s-metrics", "k8s-openapi", "kube", "portable-pty", @@ -2039,6 +2046,18 @@ dependencies = [ "thiserror", ] +[[package]] +name = "k8s-metrics" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f233f3178d1ad8363e6fe593a5c3fed7a894387ff75f27d91acce4121364bdce" +dependencies = [ + "go-parse-duration", + "k8s-openapi", + "serde", + "thiserror", +] + [[package]] name = "k8s-openapi" version = "0.20.0" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 4109eb5..101fd60 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -23,6 +23,7 @@ fix-path-env = { git = "https://github.com/tauri-apps/fix-path-env-rs", branch = portable-pty = "0.8.1" uuid = "1.4.1" either = "1.9.0" +k8s-metrics = "0.14.0" [target.'cfg(target_os = "macos")'.dependencies] tauri-nspanel = { git = "https://github.com/ahkohd/tauri-nspanel" } diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 14d37ee..ec9b90c 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -3,6 +3,7 @@ use either::Either; use istio_api_rs::networking::v1beta1::virtual_service::VirtualService; +use k8s_metrics::v1beta1::PodMetrics; use k8s_openapi::api::batch::v1::{CronJob, Job}; use k8s_openapi::api::networking::v1::Ingress; use k8s_openapi::apimachinery::pkg::apis::meta::v1::{APIGroup, APIResource}; @@ -191,6 +192,19 @@ async fn list_pods( .map_err(|err| SerializableKubeError::from(err)); } + +#[tauri::command] +async fn get_pod_metrics(context: &str, namespace: &str) -> Result, SerializableKubeError> { + let client = client_with_context(context).await?; + let metrics_api : Api = Api::namespaced(client, namespace); + + return metrics_api + .list(&ListParams::default()) + .await + .map(|metrics| metrics.items) + .map_err(|err| SerializableKubeError::from(err)); +} + #[tauri::command] async fn get_pod(context: &str, namespace: &str, name: &str) -> Result { let client = client_with_context(context).await?; @@ -242,6 +256,22 @@ async fn list_deployments( .map_err(|err| SerializableKubeError::from(err)); } +#[tauri::command] +async fn restart_deployment( + context: &str, + namespace: &str, + name: &str, +) -> Result { + let client = client_with_context(context).await?; + let deployment_api: Api = Api::namespaced(client, namespace); + + return deployment_api + .restart(name) + .await + .map(|_deployment| true) + .map_err(|err| SerializableKubeError::from(err)); +} + #[tauri::command] async fn list_services( context: &str, @@ -739,6 +769,7 @@ fn main() { get_pod, delete_pod, list_deployments, + restart_deployment, list_jobs, list_cronjobs, list_configmaps, @@ -758,6 +789,7 @@ fn main() { replace_virtualservice, replace_ingress, replace_persistentvolumeclaim, + get_pod_metrics, create_tty_session, stop_tty_session, write_to_pty diff --git a/src/components/tables/pods.ts b/src/components/tables/pods.ts index 3cc92c0..6ee3b0a 100644 --- a/src/components/tables/pods.ts +++ b/src/components/tables/pods.ts @@ -1,8 +1,9 @@ -import { V1Pod } from "@kubernetes/client-node"; +import { PodMetric, V1Pod } from "@kubernetes/client-node"; import { ColumnDef } from "@tanstack/vue-table"; import { formatDateTimeDifference } from "@/lib/utils"; +import PodUsageChart from "../ui/PodUsageChart.vue"; -export const columns: ColumnDef[] = [ +export const columns: ColumnDef[] = [ { accessorKey: "metadata.name", header: "Name", @@ -37,7 +38,11 @@ export const columns: ColumnDef[] = [ }, }, { - header: "CPU", + header: "Usage", + size: 100, + cell: ({ row }) => { + return h(PodUsageChart, { pod: row.original }); + }, }, { header: "IP", diff --git a/src/components/tables/types.ts b/src/components/tables/types.ts index 2733016..bb34549 100644 --- a/src/components/tables/types.ts +++ b/src/components/tables/types.ts @@ -90,11 +90,8 @@ export function getDefaultActions( console.log(error); }); - command.on("close", () => { - dialog.close(); - }); - command.spawn(); + dialog.close(); }, }, ], diff --git a/src/components/ui/DataTable.vue b/src/components/ui/DataTable.vue index 9b487ea..3785518 100644 --- a/src/components/ui/DataTable.vue +++ b/src/components/ui/DataTable.vue @@ -72,6 +72,7 @@ const table = useVueTable({ v-bind:enable-header-drag-region="true" v-for="header in headerGroup.headers" :key="header.id" + :style="{ width: `${header.getSize()}px` }" > Columns - +import { V1Pod, PodMetric } from "@kubernetes/client-node"; +import { memoryParser } from "@/lib/unitParsers"; +import PodUsageChartBar from "./PodUsageChartBar.vue"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; + +const props = defineProps<{ + pod: V1Pod & { metrics: PodMetric[] }; +}>(); + +const cpuDatapoints = computed(() => { + return props.pod.metrics.map((metric: PodMetric) => { + return metric.containers.reduce((acc, container) => { + return acc + Number(container.usage.cpu.replace("n", "")) / 1000000; + }, 0); + }); +}); + +const memoryDatapoints = computed(() => { + return props.pod.metrics.map((metric: PodMetric) => { + return metric.containers.reduce((acc, container) => { + return acc + Number(container.usage.memory.replace("Ki", "")) * 1000; + }, 0); + }); +}); + +const latestCpuDatapoint = computed(() => { + return cpuDatapoints.value.toReversed()[0]; +}); + +const latestMemoryDatapoint = computed(() => { + return memoryDatapoints.value.toReversed()[0]; +}); + +const cpuRequest = computed(() => { + return props.pod.spec.containers.reduce((acc, container) => { + if (container.resources?.requests?.cpu === undefined) { + return acc; + } + + if (!container.resources?.requests?.cpu.includes("m")) { + return acc + Number(container.resources?.requests?.cpu) * 1000; + } + + return ( + acc + Number(container.resources?.requests?.cpu?.replace("m", "") || 0) + ); + }, 0); +}); + +const memoryRequest = computed(() => { + return props.pod.spec.containers.reduce((acc, container) => { + if (container.resources?.requests?.memory === undefined) { + return acc; + } + + return acc + memoryParser(container.resources?.requests?.memory); + + return ( + acc + Number(container.resources?.requests?.cpu?.replace("Mi", "") || 0) + ); + }, 0); +}); + +const cpuLimit = computed(() => { + return props.pod.spec.containers.reduce((acc, container) => { + if (container.resources?.limits?.cpu === undefined) { + return acc; + } + + if (!container.resources?.limits?.cpu.includes("m")) { + return acc + Number(container.resources?.limits?.cpu) * 1000; + } + + return ( + acc + Number(container.resources?.limits?.cpu?.replace("m", "") || 0) + ); + }, 0); +}); + +const memoryLimit = computed(() => { + return props.pod.spec.containers.reduce((acc, container) => { + if (container.resources?.limits?.memory === undefined) { + return acc; + } + + return acc + memoryParser(container.resources?.limits?.memory); + }, 0); +}); + +const cpuUsage = computed(() => { + return cpuLimit.value > 0 + ? (latestCpuDatapoint.value / cpuLimit.value) * 100 + : 0; +}); + +const memoryUsage = computed(() => { + return memoryLimit.value > 0 + ? (latestMemoryDatapoint.value / memoryLimit.value) * 100 + : 0; +}); + +const cpuThreshold = computed(() => { + return cpuLimit.value > 0 ? (cpuRequest.value / cpuLimit.value) * 100 : 0; +}); + +const memoryThreshold = computed(() => { + return memoryLimit.value > 0 + ? (memoryRequest.value / memoryLimit.value) * 100 + : 0; +}); + + diff --git a/src/components/ui/PodUsageChartBar.vue b/src/components/ui/PodUsageChartBar.vue new file mode 100644 index 0000000..6150efb --- /dev/null +++ b/src/components/ui/PodUsageChartBar.vue @@ -0,0 +1,29 @@ + + diff --git a/src/components/ui/tooltip/Tooltip.vue b/src/components/ui/tooltip/Tooltip.vue new file mode 100644 index 0000000..b421f0f --- /dev/null +++ b/src/components/ui/tooltip/Tooltip.vue @@ -0,0 +1,14 @@ + + + diff --git a/src/components/ui/tooltip/TooltipContent.vue b/src/components/ui/tooltip/TooltipContent.vue new file mode 100644 index 0000000..a885caa --- /dev/null +++ b/src/components/ui/tooltip/TooltipContent.vue @@ -0,0 +1,31 @@ + + + diff --git a/src/components/ui/tooltip/TooltipProvider.vue b/src/components/ui/tooltip/TooltipProvider.vue new file mode 100644 index 0000000..816505d --- /dev/null +++ b/src/components/ui/tooltip/TooltipProvider.vue @@ -0,0 +1,11 @@ + + + diff --git a/src/components/ui/tooltip/TooltipTrigger.vue b/src/components/ui/tooltip/TooltipTrigger.vue new file mode 100644 index 0000000..f5b0e57 --- /dev/null +++ b/src/components/ui/tooltip/TooltipTrigger.vue @@ -0,0 +1,11 @@ + + + diff --git a/src/components/ui/tooltip/index.ts b/src/components/ui/tooltip/index.ts new file mode 100644 index 0000000..82049b4 --- /dev/null +++ b/src/components/ui/tooltip/index.ts @@ -0,0 +1,4 @@ +export { default as Tooltip } from './Tooltip.vue' +export { default as TooltipContent } from './TooltipContent.vue' +export { default as TooltipTrigger } from './TooltipTrigger.vue' +export { default as TooltipProvider } from './TooltipProvider.vue' diff --git a/src/lib/unitParsers.ts b/src/lib/unitParsers.ts new file mode 100644 index 0000000..78e0d93 --- /dev/null +++ b/src/lib/unitParsers.ts @@ -0,0 +1,23 @@ +const memoryMultipliers: { [key: string]: number } = { + k: 1000, + M: 1000 ** 2, + G: 1000 ** 3, + T: 1000 ** 4, + P: 1000 ** 5, + E: 1000 ** 6, + Ki: 1024, + Mi: 1024 ** 2, + Gi: 1024 ** 3, + Ti: 1024 ** 4, + Pi: 1024 ** 5, + Ei: 1024 ** 6, +}; + +export function memoryParser(input: string) { + const unitMatch = input.match(/^([0-9]+)([A-Za-z]{1,2})$/); + if (unitMatch) { + return parseInt(unitMatch[1], 10) * memoryMultipliers[unitMatch[2]]; + } + + return parseInt(input, 10); +} diff --git a/src/services/Kubernetes.ts b/src/services/Kubernetes.ts index 03e0766..b8e6f5c 100644 --- a/src/services/Kubernetes.ts +++ b/src/services/Kubernetes.ts @@ -1,5 +1,6 @@ import { KubernetesObject, + PodMetric, V1APIGroup, V1APIResource, V1ConfigMap, @@ -15,7 +16,7 @@ import { } from "@kubernetes/client-node"; import { VirtualService } from "@kubernetes-models/istio/networking.istio.io/v1beta1"; import { invoke } from "@tauri-apps/api/tauri"; -import { Child, Command } from "@tauri-apps/api/shell"; +import { Command } from "@tauri-apps/api/shell"; export interface KubernetesError { message: string; @@ -127,6 +128,16 @@ export class Kubernetes { }); } + static async getPodMetrics( + context: string, + namespace: string + ): Promise { + return invoke("get_pod_metrics", { + context: context, + namespace: namespace, + }); + } + static async getPod( context: string, namespace: string, @@ -178,6 +189,18 @@ export class Kubernetes { }); } + static async restartDeployment( + context: string, + namespace: string, + name: string + ): Promise { + return invoke("restart_deployment", { + context: context, + namespace: namespace, + name: name, + }); + } + static async getJobs(context: string, namespace: string): Promise { return invoke("list_jobs", { context: context, diff --git a/src/views/Pods.vue b/src/views/Pods.vue index c1bce5e..33c4f6f 100644 --- a/src/views/Pods.vue +++ b/src/views/Pods.vue @@ -1,9 +1,8 @@