Skip to content

Commit 80368e4

Browse files
committed
Add node health rail
1 parent 3a56cfb commit 80368e4

9 files changed

Lines changed: 589 additions & 6 deletions

File tree

dev/kubeSnapshot.ts

Lines changed: 57 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ type KubeItem = {
5454
runtimeClassName?: string;
5555
schedulerName?: string;
5656
schedulingGates?: Array<{ name?: string }>;
57+
podCIDR?: string;
5758
imagePullSecrets?: Array<{ name?: string }>;
5859
selector?: Record<string, string> | {
5960
matchExpressions?: unknown[];
@@ -79,6 +80,7 @@ type KubeItem = {
7980
};
8081
scope?: string;
8182
template?: { spec?: { containers?: KubeContainerSpec[] } };
83+
taints?: KubeTaint[];
8284
rules?: Array<{
8385
backendRefs?: KubeGatewayBackendRef[];
8486
host?: string;
@@ -99,6 +101,8 @@ type KubeItem = {
99101
hostIP?: string;
100102
qosClass?: string;
101103
startTime?: string;
104+
allocatable?: Record<string, string | number>;
105+
capacity?: Record<string, string | number>;
102106
currentHealthy?: number;
103107
desiredHealthy?: number;
104108
disruptionsAllowed?: number;
@@ -117,6 +121,14 @@ type KubeItem = {
117121
initContainerStatuses?: KubeContainerStatus[];
118122
reason?: string;
119123
message?: string;
124+
nodeInfo?: {
125+
architecture?: string;
126+
containerRuntimeVersion?: string;
127+
kernelVersion?: string;
128+
kubeletVersion?: string;
129+
operatingSystem?: string;
130+
osImage?: string;
131+
};
120132
};
121133
};
122134

@@ -189,6 +201,12 @@ type KubeToleration = {
189201
value?: string;
190202
};
191203

204+
type KubeTaint = {
205+
effect?: string;
206+
key?: string;
207+
value?: string;
208+
};
209+
192210
type KubeLimitRangeItem = {
193211
default?: Record<string, string | number>;
194212
defaultRequest?: Record<string, string | number>;
@@ -370,17 +388,18 @@ export async function readResourceDetails(target: { kind: string; name: string;
370388
return readEventDetails(target);
371389
}
372390

373-
const [yaml, describe, events, pod, crd, logs, previousLogs] = await Promise.all([
391+
const [yaml, describe, events, pod, node, crd, logs, previousLogs] = await Promise.all([
374392
readResourceYaml(target).catch((error) => errorMessage(error)),
375393
readResourceDescribe(target).catch((error) => errorMessage(error)),
376394
readResourceEvents(target).catch(() => []),
377395
target.kind === "Pod" ? readPodDetails(target).catch(() => undefined) : undefined,
396+
target.kind === "Node" ? readNodeDetails(target).catch(() => undefined) : undefined,
378397
target.kind === "CustomResourceDefinition" ? readCrdDetails(target).catch(() => undefined) : undefined,
379398
target.kind === "Pod" ? readPodLogs(target).catch((error) => errorMessage(error)) : "",
380399
target.kind === "Pod" ? readPodLogs(target, true).catch(() => "") : "",
381400
]);
382401

383-
return { yaml, describe, events, logs, previousLogs, pod, crd };
402+
return { yaml, describe, events, logs, previousLogs, pod, node, crd };
384403
}
385404

386405
export async function runPodAction(input: {
@@ -684,6 +703,42 @@ async function readPodDetails(target: { name: string; namespace: string; cluster
684703
};
685704
}
686705

706+
async function readNodeDetails(target: { kind: string; name: string; namespace: string; cluster: string }) {
707+
const node = await readResourceJson<KubeItem>(target);
708+
const nodeInfo = node.status?.nodeInfo ?? {};
709+
710+
return {
711+
conditions: (node.status?.conditions ?? []).map((condition) => ({
712+
type: condition.type ?? "Condition",
713+
status: condition.status ?? "Unknown",
714+
reason: condition.reason ?? "",
715+
message: condition.message ?? "",
716+
})),
717+
capacity: stringRecord(node.status?.capacity),
718+
allocatable: stringRecord(node.status?.allocatable),
719+
kubeletVersion: nodeInfo.kubeletVersion ?? "",
720+
osImage: nodeInfo.osImage ?? "",
721+
architecture: nodeInfo.architecture ?? "",
722+
containerRuntimeVersion: nodeInfo.containerRuntimeVersion ?? "",
723+
kernelVersion: nodeInfo.kernelVersion ?? "",
724+
operatingSystem: nodeInfo.operatingSystem ?? "",
725+
podCidr: node.spec?.podCIDR ?? "",
726+
providerId: node.spec?.providerID ?? "",
727+
unschedulable: Boolean(node.spec?.unschedulable),
728+
taints: (node.spec?.taints ?? []).flatMap((taint) => {
729+
if (!taint.key) {
730+
return [];
731+
}
732+
const keyValue = taint.value ? `${taint.key}=${taint.value}` : taint.key;
733+
return taint.effect ? [`${keyValue}:${taint.effect}`] : [keyValue];
734+
}),
735+
};
736+
}
737+
738+
function stringRecord(record: Record<string, string | number> | undefined) {
739+
return Object.fromEntries(Object.entries(record ?? {}).map(([key, value]) => [key, String(value)]));
740+
}
741+
687742
async function readCrdDetails(target: { kind: string; name: string; namespace: string; cluster: string }) {
688743
return crdDetails(await readResourceJson<KubeItem>(target));
689744
}

docs/spec.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ Kite is a native Kubernetes GUI focused on interface quality, speed, and local-f
55
## What
66

77
- Tauri 2 desktop app with Rust backend and React frontend.
8-
- Multi-cluster Kubernetes cockpit with kubeconfig context switching, scoped resource exploration, pinned resources, keyboard-operable windowed sortable signal-aware inventory with selected-pod log jump actions, inline pod and service backend diagnostics, namespace ResourceQuota and LimitRange constraint visibility, HPA scale-target links, pod ServiceAccount identity links, ConfigMap/Secret/PVC/ServiceAccount consumer links, PriorityClass and RuntimeClass placement links, PodDisruptionBudget availability links, NetworkPolicy-to-pod visibility, exact Ingress/HTTPRoute-to-service topology links, CRD group/scope/version inspection, pod-to-node and node-to-pod debugging jumps, pod-level status diagnostics, pod placement constraints, pod condition diagnostics, newest-first warning-prioritized pod events, exec and port-forward handoff, guarded pod actions, guarded workload rollout restart, searchable log workflows, YAML/diff inspection, and a simple visual control-plane map.
8+
- Multi-cluster Kubernetes cockpit with kubeconfig context switching, scoped resource exploration, pinned resources, keyboard-operable windowed sortable signal-aware inventory with selected-pod log jump actions, inline pod and service backend diagnostics, namespace ResourceQuota and LimitRange constraint visibility, HPA scale-target links, pod ServiceAccount identity links, ConfigMap/Secret/PVC/ServiceAccount consumer links, PriorityClass and RuntimeClass placement links, PodDisruptionBudget availability links, NetworkPolicy-to-pod visibility, exact Ingress/HTTPRoute-to-service topology links, CRD group/scope/version inspection, pod-to-node and node-to-pod debugging jumps, node condition/capacity inspection, pod-level status diagnostics, pod placement constraints, pod condition diagnostics, newest-first warning-prioritized pod events, exec and port-forward handoff, guarded pod actions, guarded workload rollout restart, searchable log workflows, YAML/diff inspection, and a simple visual control-plane map.
99
- UI-first design system inspired by Endex's dense enterprise sections: dark green-black glass, thin grid lines, pale telemetry surfaces, emerald accents, and precise motion.
1010

1111
## Why

src-tauri/src/kube_commands.rs

Lines changed: 118 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,8 @@ use serde::Deserialize;
2828

2929
use crate::models::{
3030
ActionPreview, ActionRisk, ActionTarget, ClusterSummary, ContainerDetails, ContainerProbe, CrdDetails, CrdVersionDetails, HealthState,
31-
KubeContextSummary, LiveSnapshot, NamespaceHeat, PodActionResult, PodActionStatus, PodCondition, PodDetails, PodSchedulingDetails,
32-
ResourceDetails, ResourceEvent, ResourceReference, ResourceSummary,
31+
KubeContextSummary, LiveSnapshot, NamespaceHeat, NodeCondition, NodeDetails, PodActionResult, PodActionStatus, PodCondition,
32+
PodDetails, PodSchedulingDetails, ResourceDetails, ResourceEvent, ResourceReference, ResourceSummary,
3333
};
3434

3535
#[derive(Debug, Deserialize)]
@@ -290,6 +290,11 @@ pub async fn resource_details(target: ActionTarget) -> ResourceDetails {
290290
} else {
291291
None
292292
};
293+
let node = if target.kind == "Node" {
294+
node_details(&target).await.ok()
295+
} else {
296+
None
297+
};
293298
let crd = if target.kind == "CustomResourceDefinition" {
294299
crd_definition_details(&target).await.ok()
295300
} else {
@@ -306,7 +311,7 @@ pub async fn resource_details(target: ActionTarget) -> ResourceDetails {
306311
String::new()
307312
};
308313

309-
ResourceDetails { yaml, describe, events, logs, previous_logs, pod, crd }
314+
ResourceDetails { yaml, describe, events, logs, previous_logs, pod, node, crd }
310315
}
311316

312317
async fn event_details(target: ActionTarget) -> ResourceDetails {
@@ -334,6 +339,7 @@ fn event_resource_details(yaml: String, describe: String, json: &str) -> Resourc
334339
logs: String::new(),
335340
previous_logs: String::new(),
336341
pod: None,
342+
node: None,
337343
crd: None,
338344
}
339345
}
@@ -550,6 +556,7 @@ async fn helm_details(target: ActionTarget) -> ResourceDetails {
550556
logs: String::new(),
551557
previous_logs: String::new(),
552558
pod: None,
559+
node: None,
553560
crd: None,
554561
}
555562
}
@@ -560,6 +567,69 @@ async fn crd_definition_details(target: &ActionTarget) -> Result<CrdDetails, Str
560567
crd_definition_details_from_value(&value).ok_or_else(|| "Unable to parse CRD definition details.".to_string())
561568
}
562569

570+
async fn node_details(target: &ActionTarget) -> Result<NodeDetails, String> {
571+
let json = kubectl(resource_json_args(target)).await?;
572+
let value = serde_json::from_str::<serde_json::Value>(&json).map_err(|error| format!("Invalid node JSON: {error}"))?;
573+
Ok(node_details_from_value(&value))
574+
}
575+
576+
fn node_details_from_value(value: &serde_json::Value) -> NodeDetails {
577+
let status = value.get("status").unwrap_or(&serde_json::Value::Null);
578+
let spec = value.get("spec").unwrap_or(&serde_json::Value::Null);
579+
let node_info = status.get("nodeInfo").unwrap_or(&serde_json::Value::Null);
580+
581+
NodeDetails {
582+
conditions: node_conditions(status),
583+
capacity: string_map_field(status, "capacity"),
584+
allocatable: string_map_field(status, "allocatable"),
585+
kubelet_version: text_field(node_info, "kubeletVersion", ""),
586+
os_image: text_field(node_info, "osImage", ""),
587+
architecture: text_field(node_info, "architecture", ""),
588+
container_runtime_version: text_field(node_info, "containerRuntimeVersion", ""),
589+
kernel_version: text_field(node_info, "kernelVersion", ""),
590+
operating_system: text_field(node_info, "operatingSystem", ""),
591+
pod_cidr: text_field(spec, "podCIDR", ""),
592+
provider_id: text_field(spec, "providerID", ""),
593+
unschedulable: bool_field(spec, "unschedulable"),
594+
taints: node_taints(spec),
595+
}
596+
}
597+
598+
fn node_conditions(status: &serde_json::Value) -> Vec<NodeCondition> {
599+
status
600+
.get("conditions")
601+
.and_then(|conditions| conditions.as_array())
602+
.map(Vec::as_slice)
603+
.unwrap_or(&[])
604+
.iter()
605+
.map(|condition| NodeCondition {
606+
type_: text_field(condition, "type", "Condition"),
607+
status: text_field(condition, "status", "Unknown"),
608+
reason: text_field(condition, "reason", ""),
609+
message: text_field(condition, "message", ""),
610+
})
611+
.collect()
612+
}
613+
614+
fn node_taints(spec: &serde_json::Value) -> Vec<String> {
615+
spec.get("taints")
616+
.and_then(|taints| taints.as_array())
617+
.map(Vec::as_slice)
618+
.unwrap_or(&[])
619+
.iter()
620+
.filter_map(|taint| {
621+
let key = text_field(taint, "key", "");
622+
if key.is_empty() {
623+
return None;
624+
}
625+
let value = text_field(taint, "value", "");
626+
let effect = text_field(taint, "effect", "");
627+
let key_value = if value.is_empty() { key } else { format!("{key}={value}") };
628+
Some(if effect.is_empty() { key_value } else { format!("{key_value}:{effect}") })
629+
})
630+
.collect()
631+
}
632+
563633
fn crd_definition_details_from_value(value: &serde_json::Value) -> Option<CrdDetails> {
564634
let spec = value.get("spec")?;
565635
let names = spec.get("names").unwrap_or(&serde_json::Value::Null);
@@ -4924,6 +4994,51 @@ mod tests {
49244994
assert_eq!(resource_event(&missing_count).count, 1);
49254995
}
49264996

4997+
#[test]
4998+
fn node_details_preserve_conditions_capacity_and_taints() {
4999+
let node = serde_json::json!({
5000+
"spec": {
5001+
"podCIDR": "10.42.0.0/24",
5002+
"providerID": "kind://docker/kite/node/kite-control-plane",
5003+
"unschedulable": true,
5004+
"taints": [
5005+
{ "key": "node-role.kubernetes.io/control-plane", "effect": "NoSchedule" },
5006+
{ "key": "dedicated", "value": "debug", "effect": "NoExecute" }
5007+
]
5008+
},
5009+
"status": {
5010+
"capacity": { "cpu": "8", "memory": "16Gi", "pods": 110 },
5011+
"allocatable": { "cpu": "7800m", "memory": "14Gi", "pods": 100 },
5012+
"nodeInfo": {
5013+
"kubeletVersion": "v1.31.0",
5014+
"osImage": "Debian GNU/Linux 12",
5015+
"architecture": "arm64",
5016+
"containerRuntimeVersion": "containerd://1.7.20"
5017+
},
5018+
"conditions": [
5019+
{ "type": "Ready", "status": "True", "reason": "KubeletReady", "message": "kubelet is posting ready status" },
5020+
{ "type": "DiskPressure", "status": "False", "reason": "KubeletHasNoDiskPressure" }
5021+
]
5022+
}
5023+
});
5024+
5025+
let details = node_details_from_value(&node);
5026+
5027+
assert_eq!(details.kubelet_version, "v1.31.0");
5028+
assert_eq!(details.capacity.get("pods").map(String::as_str), Some("110"));
5029+
assert_eq!(details.allocatable.get("cpu").map(String::as_str), Some("7800m"));
5030+
assert!(details.unschedulable);
5031+
assert_eq!(
5032+
details.taints,
5033+
[
5034+
"node-role.kubernetes.io/control-plane:NoSchedule".to_string(),
5035+
"dedicated=debug:NoExecute".to_string(),
5036+
]
5037+
);
5038+
assert_eq!(details.conditions[0].type_, "Ready");
5039+
assert_eq!(details.conditions[1].reason, "KubeletHasNoDiskPressure");
5040+
}
5041+
49275042
#[test]
49285043
fn event_resource_details_returns_selected_event_payload() {
49295044
let json = serde_json::json!({

src-tauri/src/models.rs

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ pub struct ResourceDetails {
8585
pub logs: String,
8686
pub previous_logs: String,
8787
pub pod: Option<PodDetails>,
88+
pub node: Option<NodeDetails>,
8889
pub crd: Option<CrdDetails>,
8990
}
9091

@@ -148,6 +149,34 @@ pub struct PodSchedulingDetails {
148149
pub runtime_class_name: String,
149150
}
150151

152+
#[derive(Debug, Serialize)]
153+
#[serde(rename_all = "camelCase")]
154+
pub struct NodeDetails {
155+
pub conditions: Vec<NodeCondition>,
156+
pub capacity: BTreeMap<String, String>,
157+
pub allocatable: BTreeMap<String, String>,
158+
pub kubelet_version: String,
159+
pub os_image: String,
160+
pub architecture: String,
161+
pub container_runtime_version: String,
162+
pub kernel_version: String,
163+
pub operating_system: String,
164+
pub pod_cidr: String,
165+
pub provider_id: String,
166+
pub unschedulable: bool,
167+
pub taints: Vec<String>,
168+
}
169+
170+
#[derive(Debug, Serialize)]
171+
#[serde(rename_all = "camelCase")]
172+
pub struct NodeCondition {
173+
#[serde(rename = "type")]
174+
pub type_: String,
175+
pub status: String,
176+
pub reason: String,
177+
pub message: String,
178+
}
179+
151180
#[derive(Debug, Serialize)]
152181
#[serde(rename_all = "camelCase")]
153182
pub struct ContainerDetails {

0 commit comments

Comments
 (0)