Skip to content

Commit 5628496

Browse files
committed
feat(ui): improve nodegroup filtering and readiness
1 parent b2f0fcd commit 5628496

File tree

2 files changed

+231
-61
lines changed

2 files changed

+231
-61
lines changed

kubernetes_monitoring.py

Lines changed: 127 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -74,33 +74,56 @@ def kbhit(self) -> bool: ...
7474
def getwch(self) -> str: ...
7575

7676

77+
def _normalize_attr_key(key: Any) -> str:
78+
"""Attribute-style 접근을 위한 키 정규화."""
79+
if isinstance(key, str):
80+
text = key
81+
else:
82+
text = str(key)
83+
return "".join(ch for ch in text if ch.isalnum()).lower()
84+
85+
7786
class AttrDict:
7887
"""dict를 속성 접근 방식으로 다룰 수 있게 감싸는 래퍼."""
7988

80-
__slots__ = ("_data",)
89+
__slots__ = ("_data", "_key_map")
8190

8291
def __init__(self, data: Dict[str, Any]) -> None:
8392
self._data = data
93+
key_map: Dict[str, str] = {}
94+
for original_key in data:
95+
normalized = _normalize_attr_key(original_key)
96+
key_map.setdefault(normalized, original_key)
97+
self._key_map = key_map
8498

8599
def __getattr__(self, item: str) -> Any:
86-
if item not in self._data:
100+
actual_key = self._key_map.get(_normalize_attr_key(item))
101+
if actual_key is None or actual_key not in self._data:
87102
return None
88-
value = self._data[item]
103+
value = self._data[actual_key]
89104
wrapped = _wrap_kubectl_value(value)
90105
if wrapped is not value:
91-
self._data[item] = wrapped
106+
self._data[actual_key] = wrapped
92107
return wrapped
93108

94109
def __getitem__(self, key: str) -> Any:
95-
return self.__getattr__(key)
110+
actual_key = self._key_map.get(_normalize_attr_key(key))
111+
if actual_key is None or actual_key not in self._data:
112+
raise KeyError(key)
113+
value = self._data[actual_key]
114+
wrapped = _wrap_kubectl_value(value)
115+
if wrapped is not value:
116+
self._data[actual_key] = wrapped
117+
return wrapped
96118

97119
def get(self, key: str, default: Any = None) -> Any:
98-
if key not in self._data:
120+
actual_key = self._key_map.get(_normalize_attr_key(key))
121+
if actual_key is None or actual_key not in self._data:
99122
return default
100-
value = self._data[key]
123+
value = self._data[actual_key]
101124
wrapped = _wrap_kubectl_value(value)
102125
if wrapped is not value:
103-
self._data[key] = wrapped
126+
self._data[actual_key] = wrapped
104127
return wrapped
105128

106129
def to_dict(self) -> Dict[str, Any]:
@@ -189,6 +212,69 @@ class SnapshotPayload:
189212
command: Optional[str]
190213

191214

215+
@dataclass(frozen=True)
216+
class PodContainerSummary:
217+
"""Pod 컨테이너 Ready/Restart 지표 요약."""
218+
219+
ready: int
220+
total: int
221+
restarts: int
222+
223+
224+
@dataclass(frozen=True)
225+
class NodeGroupInfo:
226+
"""NodeGroup 라벨 값과 해당 노드 목록."""
227+
228+
value: str
229+
nodes: Tuple[str, ...]
230+
231+
@property
232+
def label(self) -> str:
233+
return f"{NODE_GROUP_LABEL}={self.value}"
234+
235+
@property
236+
def node_count(self) -> int:
237+
return len(self.nodes)
238+
239+
240+
def _summarize_pod_containers(pod: AttrDict) -> PodContainerSummary:
241+
"""kubectl JSON Pod 객체에서 Ready/총 컨테이너/재시작 횟수를 계산."""
242+
status = getattr(pod, "status", None)
243+
spec = getattr(pod, "spec", None)
244+
container_statuses = list(getattr(status, "container_statuses", None) or [])
245+
ready = sum(1 for item in container_statuses if getattr(item, "ready", False))
246+
total = (
247+
len(container_statuses)
248+
if container_statuses
249+
else len(getattr(spec, "containers", []) or [])
250+
)
251+
restarts = sum(
252+
int(getattr(item, "restart_count", 0)) for item in container_statuses
253+
)
254+
return PodContainerSummary(ready=ready, total=total, restarts=restarts)
255+
256+
257+
def _extract_node_group_infos(nodes: Sequence[AttrDict]) -> List[NodeGroupInfo]:
258+
"""노드 목록에서 NodeGroup 라벨 정보를 정리해 반환."""
259+
node_groups: Dict[str, Set[str]] = {}
260+
for node in nodes:
261+
metadata = getattr(node, "metadata", None)
262+
labels = getattr(metadata, "labels", None) or {}
263+
value = labels.get(NODE_GROUP_LABEL)
264+
if not value:
265+
continue
266+
node_name = getattr(metadata, "name", "") or ""
267+
if not node_name:
268+
continue
269+
group_nodes = node_groups.setdefault(str(value), set())
270+
group_nodes.add(str(node_name))
271+
infos = [
272+
NodeGroupInfo(value=value, nodes=tuple(sorted(node_names)))
273+
for value, node_names in node_groups.items()
274+
]
275+
return sorted(infos, key=lambda info: info.value.lower())
276+
277+
192278
def _ensure_datetime(value: Optional[datetime.datetime]) -> Optional[datetime.datetime]:
193279
"""datetime 또는 ISO 문자열 입력을 UTC datetime으로 정규화."""
194280
if value is None:
@@ -1129,23 +1215,28 @@ def choose_node_group() -> Optional[str]:
11291215
console.print(f"명령어: {command}", style="dim")
11301216
return None
11311217

1132-
node_groups: Set[str] = set()
1133-
for node in getattr(payload, "items", []) or []:
1134-
labels = getattr(getattr(node, "metadata", None), "labels", None)
1135-
if labels and NODE_GROUP_LABEL in labels:
1136-
value = labels[NODE_GROUP_LABEL]
1137-
if value:
1138-
node_groups.add(str(value))
1139-
1140-
sorted_groups = sorted(node_groups)
1141-
if not sorted_groups:
1218+
items = list(getattr(payload, "items", []) or [])
1219+
node_group_infos = _extract_node_group_infos(items)
1220+
if not node_group_infos:
11421221
print("노드 그룹이 존재하지 않습니다.")
11431222
return None
11441223
table = Table(show_header=True, header_style="bold magenta", box=box.ROUNDED)
11451224
table.add_column("Index", style="bold green", width=5)
1146-
table.add_column("Node Group", overflow="fold")
1147-
for idx, ng in enumerate(sorted_groups, start=1):
1148-
table.add_row(str(idx), ng)
1225+
table.add_column("Label", overflow="fold")
1226+
table.add_column("Node Count", justify="right")
1227+
table.add_column("Sample Nodes", overflow="fold")
1228+
for idx, info in enumerate(node_group_infos, start=1):
1229+
sample_nodes = ", ".join(info.nodes[:3])
1230+
if len(info.nodes) > 3:
1231+
sample_nodes = f"{sample_nodes}, ..."
1232+
if not sample_nodes:
1233+
sample_nodes = "-"
1234+
table.add_row(
1235+
str(idx),
1236+
info.label,
1237+
str(info.node_count),
1238+
sample_nodes,
1239+
)
11491240
console.print("\n=== Available Node Groups ===", style="bold green")
11501241
console.print(table)
11511242

@@ -1158,10 +1249,15 @@ def choose_node_group() -> Optional[str]:
11581249
print("숫자로 입력해주세요. 필터링하지 않음으로 진행합니다.")
11591250
return None
11601251
index = int(selection)
1161-
if index < 1 or index > len(sorted_groups):
1252+
if index < 1 or index > len(node_group_infos):
11621253
print("유효하지 않은 번호입니다. 필터링하지 않음으로 진행합니다.")
11631254
return None
1164-
chosen_ng = sorted_groups[index - 1]
1255+
chosen_info = node_group_infos[index - 1]
1256+
console.print(
1257+
f"선택한 NodeGroup 라벨: {chosen_info.label} (노드 {chosen_info.node_count}개)",
1258+
style="bold green",
1259+
)
1260+
chosen_ng = chosen_info.value
11651261
return chosen_ng
11661262

11671263

@@ -1599,27 +1695,12 @@ def watch_pod_monitoring_by_creation() -> None:
15991695
creation = _format_timestamp(
16001696
getattr(metadata, "creation_timestamp", None)
16011697
)
1602-
container_statuses = list(
1603-
getattr(status, "container_statuses", None) or []
1604-
)
1605-
ready_count = sum(
1606-
1
1607-
for item in container_statuses
1608-
if getattr(item, "ready", False)
1609-
)
1610-
total_containers = (
1611-
len(container_statuses)
1612-
if container_statuses
1613-
else len(getattr(spec, "containers", []) or [])
1614-
)
1698+
summary = _summarize_pod_containers(pod)
16151699
ready_display = _format_ready_ratio(
1616-
ready_count,
1617-
total_containers,
1618-
)
1619-
restarts = sum(
1620-
int(getattr(item, "restart_count", 0))
1621-
for item in container_statuses
1700+
summary.ready,
1701+
summary.total,
16221702
)
1703+
restarts = summary.restarts
16231704
phase = getattr(status, "phase", "") or "-"
16241705
pod_ip = getattr(status, "pod_ip", "") or "-"
16251706
node_name = getattr(spec, "node_name", "") or "-"
@@ -1794,27 +1875,12 @@ def _is_non_running(pod: AttrDict) -> bool:
17941875
creation = _format_timestamp(
17951876
getattr(metadata, "creation_timestamp", None)
17961877
)
1797-
container_statuses = list(
1798-
getattr(status, "container_statuses", None) or []
1799-
)
1800-
ready_count = sum(
1801-
1
1802-
for item in container_statuses
1803-
if getattr(item, "ready", False)
1804-
)
1805-
total_containers = (
1806-
len(container_statuses)
1807-
if container_statuses
1808-
else len(getattr(spec, "containers", []) or [])
1809-
)
1878+
summary = _summarize_pod_containers(pod)
18101879
ready_display = _format_ready_ratio(
1811-
ready_count,
1812-
total_containers,
1813-
)
1814-
restarts = sum(
1815-
int(getattr(item, "restart_count", 0))
1816-
for item in container_statuses
1880+
summary.ready,
1881+
summary.total,
18171882
)
1883+
restarts = summary.restarts
18181884
phase = getattr(status, "phase", "") or "-"
18191885
pod_ip = getattr(status, "pod_ip", "") or "-"
18201886
node_name = getattr(spec, "node_name", "") or "-"

tests/test_kubernetes_monitoring.py

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,110 @@ def test_format_ready_ratio():
4747
assert kubernetes_monitoring._format_ready_ratio(3, 2) == "[2/2]"
4848

4949

50+
def test_attrdict_snake_case_resolution():
51+
"""kubectl JSON camelCase 키를 snake_case로 접근할 수 있다."""
52+
pod_payload = {
53+
"status": {
54+
"containerStatuses": [
55+
{
56+
"name": "app",
57+
"ready": True,
58+
"restartCount": 2,
59+
"lastState": {
60+
"terminated": {
61+
"finishedAt": "2024-01-01T00:00:00Z",
62+
}
63+
},
64+
}
65+
],
66+
"phase": "Running",
67+
"podIP": "10.0.0.10",
68+
},
69+
"spec": {
70+
"containers": [{"name": "app"}],
71+
"nodeName": "node-a",
72+
},
73+
}
74+
pod = kubernetes_monitoring._wrap_kubectl_value(pod_payload)
75+
status = pod.status
76+
assert status is not None
77+
containers = status.container_statuses
78+
assert containers and containers[0].ready is True
79+
assert containers[0].restart_count == 2
80+
assert containers[0].last_state.terminated.finished_at == "2024-01-01T00:00:00Z"
81+
assert status.pod_ip == "10.0.0.10"
82+
assert pod.spec.node_name == "node-a"
83+
84+
85+
def test_ready_ratio_computation_with_kubectl_payload():
86+
"""컨테이너 Ready/Total 계산이 kubectl JSON 구조에서 정상 동작한다."""
87+
pod_payload = {
88+
"status": {
89+
"containerStatuses": [
90+
{"name": "app", "ready": True, "restartCount": 1},
91+
{"name": "sidecar", "ready": False, "restartCount": 0},
92+
]
93+
},
94+
"spec": {
95+
"containers": [
96+
{"name": "app"},
97+
{"name": "sidecar"},
98+
]
99+
},
100+
}
101+
pod = kubernetes_monitoring._wrap_kubectl_value(pod_payload)
102+
status = pod.status
103+
spec = pod.spec
104+
container_statuses = list(getattr(status, "container_statuses", None) or [])
105+
ready_count = sum(1 for item in container_statuses if getattr(item, "ready", False))
106+
total_containers = (
107+
len(container_statuses)
108+
if container_statuses
109+
else len(getattr(spec, "containers", []) or [])
110+
)
111+
ready_display = kubernetes_monitoring._format_ready_ratio(
112+
ready_count, total_containers
113+
)
114+
assert ready_display == "[1/2]"
115+
116+
117+
def test_extract_node_group_infos():
118+
"""NodeGroup 라벨 목록이 정렬 및 카운트된 정보를 반환한다."""
119+
nodes_payload = [
120+
{
121+
"metadata": {
122+
"name": "node-a",
123+
"labels": {kubernetes_monitoring.NODE_GROUP_LABEL: "group-1"},
124+
}
125+
},
126+
{
127+
"metadata": {
128+
"name": "node-b",
129+
"labels": {kubernetes_monitoring.NODE_GROUP_LABEL: "group-2"},
130+
}
131+
},
132+
{
133+
"metadata": {
134+
"name": "node-c",
135+
"labels": {kubernetes_monitoring.NODE_GROUP_LABEL: "group-1"},
136+
}
137+
},
138+
{
139+
"metadata": {
140+
"name": "node-d",
141+
"labels": {"unrelated": "value"},
142+
}
143+
},
144+
]
145+
nodes = [kubernetes_monitoring._wrap_kubectl_value(item) for item in nodes_payload]
146+
infos = kubernetes_monitoring._extract_node_group_infos(nodes)
147+
assert [info.value for info in infos] == ["group-1", "group-2"]
148+
group1 = infos[0]
149+
assert group1.node_count == 2
150+
assert group1.nodes == ("node-a", "node-c")
151+
assert group1.label == f"{kubernetes_monitoring.NODE_GROUP_LABEL}=group-1"
152+
153+
50154
@patch("kubernetes_monitoring.subprocess.run")
51155
def test_get_kubectl_top_pod_all_namespaces_success(mock_run):
52156
"""kubectl top pod 전체 namespace 파싱 검증"""

0 commit comments

Comments
 (0)