kubectl-mcp-server 1.12.0__py3-none-any.whl → 1.13.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {kubectl_mcp_server-1.12.0.dist-info → kubectl_mcp_server-1.13.0.dist-info}/METADATA +81 -12
- {kubectl_mcp_server-1.12.0.dist-info → kubectl_mcp_server-1.13.0.dist-info}/RECORD +12 -11
- kubectl_mcp_tool/__init__.py +1 -1
- kubectl_mcp_tool/mcp_server.py +9 -0
- kubectl_mcp_tool/tools/__init__.py +3 -0
- kubectl_mcp_tool/tools/ui.py +1068 -0
- tests/test_browser.py +2 -2
- tests/test_tools.py +9 -6
- {kubectl_mcp_server-1.12.0.dist-info → kubectl_mcp_server-1.13.0.dist-info}/WHEEL +0 -0
- {kubectl_mcp_server-1.12.0.dist-info → kubectl_mcp_server-1.13.0.dist-info}/entry_points.txt +0 -0
- {kubectl_mcp_server-1.12.0.dist-info → kubectl_mcp_server-1.13.0.dist-info}/licenses/LICENSE +0 -0
- {kubectl_mcp_server-1.12.0.dist-info → kubectl_mcp_server-1.13.0.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,1068 @@
|
|
|
1
|
+
"""
|
|
2
|
+
MCP-UI enabled tools for kubectl-mcp-server.
|
|
3
|
+
|
|
4
|
+
These tools return UIResource objects that can be rendered by MCP hosts
|
|
5
|
+
that support the mcp-ui specification (Goose, LibreChat, Nanobot, etc.)
|
|
6
|
+
|
|
7
|
+
For hosts that don't support mcp-ui, these tools can optionally render
|
|
8
|
+
the UI to a screenshot using agent-browser, making them accessible to
|
|
9
|
+
ALL MCP hosts including Claude Desktop.
|
|
10
|
+
|
|
11
|
+
Installation: pip install mcp-ui-server
|
|
12
|
+
Browser screenshots: Requires agent-browser CLI (MCP_BROWSER_ENABLED=true)
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import base64
|
|
16
|
+
import json
|
|
17
|
+
import logging
|
|
18
|
+
import html
|
|
19
|
+
import os
|
|
20
|
+
import shutil
|
|
21
|
+
import subprocess
|
|
22
|
+
import tempfile
|
|
23
|
+
from typing import Any, Dict, List, Optional, Union
|
|
24
|
+
|
|
25
|
+
from mcp.types import ToolAnnotations
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger("mcp-server")
|
|
28
|
+
|
|
29
|
+
# Check if agent-browser is available for screenshot rendering
|
|
30
|
+
BROWSER_ENABLED = os.environ.get("MCP_BROWSER_ENABLED", "").lower() in ("1", "true")
|
|
31
|
+
BROWSER_AVAILABLE = shutil.which("agent-browser") is not None
|
|
32
|
+
|
|
33
|
+
# Try to import mcp-ui-server, gracefully handle if not installed
|
|
34
|
+
try:
|
|
35
|
+
from mcp_ui_server import create_ui_resource, UIMetadataKey
|
|
36
|
+
from mcp_ui_server.core import UIResource
|
|
37
|
+
MCP_UI_AVAILABLE = True
|
|
38
|
+
except ImportError:
|
|
39
|
+
MCP_UI_AVAILABLE = False
|
|
40
|
+
UIResource = Dict[str, Any] # Fallback type
|
|
41
|
+
logger.warning("mcp-ui-server not installed. UI tools will return plain JSON.")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _render_html_to_screenshot(html_content: str, width: int = 1200, height: int = 800) -> Optional[str]:
|
|
45
|
+
"""Render HTML to a screenshot using agent-browser.
|
|
46
|
+
|
|
47
|
+
Returns base64-encoded PNG image or None if browser not available.
|
|
48
|
+
"""
|
|
49
|
+
if not BROWSER_ENABLED or not BROWSER_AVAILABLE:
|
|
50
|
+
return None
|
|
51
|
+
|
|
52
|
+
try:
|
|
53
|
+
# Save HTML to temp file
|
|
54
|
+
with tempfile.NamedTemporaryFile(mode='w', suffix='.html', delete=False) as f:
|
|
55
|
+
f.write(html_content)
|
|
56
|
+
html_path = f.name
|
|
57
|
+
|
|
58
|
+
# Screenshot path
|
|
59
|
+
screenshot_path = tempfile.mktemp(suffix='.png')
|
|
60
|
+
|
|
61
|
+
try:
|
|
62
|
+
# Use agent-browser to render and screenshot
|
|
63
|
+
# Open the HTML file
|
|
64
|
+
subprocess.run(
|
|
65
|
+
["agent-browser", "open", f"file://{html_path}"],
|
|
66
|
+
capture_output=True, timeout=10
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
# Set viewport size
|
|
70
|
+
subprocess.run(
|
|
71
|
+
["agent-browser", "eval", f"window.resizeTo({width}, {height})"],
|
|
72
|
+
capture_output=True, timeout=5
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
# Wait for render
|
|
76
|
+
subprocess.run(
|
|
77
|
+
["agent-browser", "wait", "500"],
|
|
78
|
+
capture_output=True, timeout=5
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
# Take screenshot
|
|
82
|
+
result = subprocess.run(
|
|
83
|
+
["agent-browser", "screenshot", screenshot_path],
|
|
84
|
+
capture_output=True, timeout=10
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
if result.returncode == 0 and os.path.exists(screenshot_path):
|
|
88
|
+
with open(screenshot_path, 'rb') as f:
|
|
89
|
+
return base64.b64encode(f.read()).decode('utf-8')
|
|
90
|
+
|
|
91
|
+
return None
|
|
92
|
+
finally:
|
|
93
|
+
# Cleanup temp files
|
|
94
|
+
if os.path.exists(html_path):
|
|
95
|
+
os.unlink(html_path)
|
|
96
|
+
if os.path.exists(screenshot_path):
|
|
97
|
+
os.unlink(screenshot_path)
|
|
98
|
+
except Exception as e:
|
|
99
|
+
logger.warning(f"Failed to render screenshot: {e}")
|
|
100
|
+
return None
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _can_render_screenshots() -> bool:
|
|
104
|
+
"""Check if screenshot rendering is available."""
|
|
105
|
+
return BROWSER_ENABLED and BROWSER_AVAILABLE
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
# CSS styles for consistent theming across UI components
|
|
109
|
+
DARK_THEME_CSS = """
|
|
110
|
+
:root {
|
|
111
|
+
--bg-primary: #1e1e2e;
|
|
112
|
+
--bg-secondary: #313244;
|
|
113
|
+
--bg-tertiary: #45475a;
|
|
114
|
+
--text-primary: #cdd6f4;
|
|
115
|
+
--text-secondary: #a6adc8;
|
|
116
|
+
--text-muted: #6c7086;
|
|
117
|
+
--accent-blue: #89b4fa;
|
|
118
|
+
--accent-green: #a6e3a1;
|
|
119
|
+
--accent-yellow: #f9e2af;
|
|
120
|
+
--accent-red: #f38ba8;
|
|
121
|
+
--accent-purple: #cba6f7;
|
|
122
|
+
--border-color: #45475a;
|
|
123
|
+
}
|
|
124
|
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
125
|
+
body {
|
|
126
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
127
|
+
background: var(--bg-primary);
|
|
128
|
+
color: var(--text-primary);
|
|
129
|
+
line-height: 1.5;
|
|
130
|
+
padding: 16px;
|
|
131
|
+
}
|
|
132
|
+
.container { max-width: 100%; }
|
|
133
|
+
.header {
|
|
134
|
+
display: flex;
|
|
135
|
+
align-items: center;
|
|
136
|
+
gap: 12px;
|
|
137
|
+
margin-bottom: 16px;
|
|
138
|
+
padding-bottom: 12px;
|
|
139
|
+
border-bottom: 1px solid var(--border-color);
|
|
140
|
+
}
|
|
141
|
+
.header h2 { font-size: 1.25rem; font-weight: 600; }
|
|
142
|
+
.badge {
|
|
143
|
+
display: inline-flex;
|
|
144
|
+
align-items: center;
|
|
145
|
+
padding: 2px 8px;
|
|
146
|
+
border-radius: 4px;
|
|
147
|
+
font-size: 0.75rem;
|
|
148
|
+
font-weight: 500;
|
|
149
|
+
}
|
|
150
|
+
.badge-green { background: rgba(166, 227, 161, 0.2); color: var(--accent-green); }
|
|
151
|
+
.badge-yellow { background: rgba(249, 226, 175, 0.2); color: var(--accent-yellow); }
|
|
152
|
+
.badge-red { background: rgba(243, 139, 168, 0.2); color: var(--accent-red); }
|
|
153
|
+
.badge-blue { background: rgba(137, 180, 250, 0.2); color: var(--accent-blue); }
|
|
154
|
+
.card {
|
|
155
|
+
background: var(--bg-secondary);
|
|
156
|
+
border-radius: 8px;
|
|
157
|
+
padding: 16px;
|
|
158
|
+
margin-bottom: 12px;
|
|
159
|
+
border: 1px solid var(--border-color);
|
|
160
|
+
}
|
|
161
|
+
.card-header {
|
|
162
|
+
display: flex;
|
|
163
|
+
justify-content: space-between;
|
|
164
|
+
align-items: center;
|
|
165
|
+
margin-bottom: 12px;
|
|
166
|
+
}
|
|
167
|
+
.card-title { font-weight: 600; font-size: 0.9rem; }
|
|
168
|
+
pre, .log-content {
|
|
169
|
+
background: var(--bg-tertiary);
|
|
170
|
+
border-radius: 6px;
|
|
171
|
+
padding: 12px;
|
|
172
|
+
overflow-x: auto;
|
|
173
|
+
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
|
174
|
+
font-size: 0.8rem;
|
|
175
|
+
line-height: 1.6;
|
|
176
|
+
white-space: pre-wrap;
|
|
177
|
+
word-break: break-word;
|
|
178
|
+
}
|
|
179
|
+
.log-line { display: block; }
|
|
180
|
+
.log-line:hover { background: rgba(137, 180, 250, 0.1); }
|
|
181
|
+
.log-error { color: var(--accent-red); }
|
|
182
|
+
.log-warn { color: var(--accent-yellow); }
|
|
183
|
+
.log-info { color: var(--accent-blue); }
|
|
184
|
+
table {
|
|
185
|
+
width: 100%;
|
|
186
|
+
border-collapse: collapse;
|
|
187
|
+
font-size: 0.85rem;
|
|
188
|
+
}
|
|
189
|
+
th, td {
|
|
190
|
+
text-align: left;
|
|
191
|
+
padding: 8px 12px;
|
|
192
|
+
border-bottom: 1px solid var(--border-color);
|
|
193
|
+
}
|
|
194
|
+
th {
|
|
195
|
+
background: var(--bg-tertiary);
|
|
196
|
+
font-weight: 600;
|
|
197
|
+
color: var(--text-secondary);
|
|
198
|
+
font-size: 0.75rem;
|
|
199
|
+
text-transform: uppercase;
|
|
200
|
+
}
|
|
201
|
+
tr:hover td { background: rgba(137, 180, 250, 0.05); }
|
|
202
|
+
.btn {
|
|
203
|
+
display: inline-flex;
|
|
204
|
+
align-items: center;
|
|
205
|
+
gap: 6px;
|
|
206
|
+
padding: 6px 12px;
|
|
207
|
+
border-radius: 6px;
|
|
208
|
+
border: none;
|
|
209
|
+
font-size: 0.8rem;
|
|
210
|
+
font-weight: 500;
|
|
211
|
+
cursor: pointer;
|
|
212
|
+
transition: all 0.2s;
|
|
213
|
+
}
|
|
214
|
+
.btn-primary { background: var(--accent-blue); color: var(--bg-primary); }
|
|
215
|
+
.btn-primary:hover { opacity: 0.9; }
|
|
216
|
+
.btn-secondary { background: var(--bg-tertiary); color: var(--text-primary); }
|
|
217
|
+
.btn-secondary:hover { background: var(--border-color); }
|
|
218
|
+
.btn-danger { background: var(--accent-red); color: var(--bg-primary); }
|
|
219
|
+
.actions { display: flex; gap: 8px; margin-top: 12px; }
|
|
220
|
+
.stat-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); gap: 12px; }
|
|
221
|
+
.stat-card {
|
|
222
|
+
background: var(--bg-tertiary);
|
|
223
|
+
padding: 12px;
|
|
224
|
+
border-radius: 6px;
|
|
225
|
+
text-align: center;
|
|
226
|
+
}
|
|
227
|
+
.stat-value { font-size: 1.5rem; font-weight: 700; }
|
|
228
|
+
.stat-label { font-size: 0.75rem; color: var(--text-muted); margin-top: 4px; }
|
|
229
|
+
.search-box {
|
|
230
|
+
width: 100%;
|
|
231
|
+
padding: 8px 12px;
|
|
232
|
+
background: var(--bg-tertiary);
|
|
233
|
+
border: 1px solid var(--border-color);
|
|
234
|
+
border-radius: 6px;
|
|
235
|
+
color: var(--text-primary);
|
|
236
|
+
font-size: 0.85rem;
|
|
237
|
+
margin-bottom: 12px;
|
|
238
|
+
}
|
|
239
|
+
.search-box:focus { outline: none; border-color: var(--accent-blue); }
|
|
240
|
+
.timestamp { color: var(--text-muted); font-size: 0.75rem; }
|
|
241
|
+
.status-indicator {
|
|
242
|
+
display: inline-block;
|
|
243
|
+
width: 8px;
|
|
244
|
+
height: 8px;
|
|
245
|
+
border-radius: 50%;
|
|
246
|
+
margin-right: 6px;
|
|
247
|
+
}
|
|
248
|
+
.status-running { background: var(--accent-green); }
|
|
249
|
+
.status-pending { background: var(--accent-yellow); }
|
|
250
|
+
.status-failed { background: var(--accent-red); }
|
|
251
|
+
.status-unknown { background: var(--text-muted); }
|
|
252
|
+
"""
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _escape(text: str) -> str:
|
|
256
|
+
"""HTML escape text content."""
|
|
257
|
+
return html.escape(str(text)) if text else ""
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _create_ui_or_fallback(
|
|
261
|
+
uri: str,
|
|
262
|
+
html_content: str,
|
|
263
|
+
fallback_data: Dict[str, Any],
|
|
264
|
+
frame_size: tuple = ("800px", "500px"),
|
|
265
|
+
render_screenshot: bool = False
|
|
266
|
+
) -> Union[List[UIResource], Dict[str, Any]]:
|
|
267
|
+
"""Create UIResource if available, otherwise return fallback JSON.
|
|
268
|
+
|
|
269
|
+
Args:
|
|
270
|
+
uri: Unique URI for the resource (e.g., ui://cluster-overview)
|
|
271
|
+
html_content: The HTML content to render
|
|
272
|
+
fallback_data: JSON data to return if MCP-UI not available
|
|
273
|
+
frame_size: Preferred frame size (width, height) in CSS units
|
|
274
|
+
render_screenshot: If True and browser available, also render screenshot
|
|
275
|
+
"""
|
|
276
|
+
# Option 1: Render screenshot using agent-browser (works everywhere)
|
|
277
|
+
if render_screenshot and _can_render_screenshots():
|
|
278
|
+
width = int(frame_size[0].replace('px', '').replace('%', '0'))
|
|
279
|
+
height = int(frame_size[1].replace('px', '').replace('%', '0'))
|
|
280
|
+
screenshot_b64 = _render_html_to_screenshot(html_content, width, height)
|
|
281
|
+
if screenshot_b64:
|
|
282
|
+
# Return as embedded image that any MCP host can display
|
|
283
|
+
return {
|
|
284
|
+
"success": True,
|
|
285
|
+
"image": {
|
|
286
|
+
"type": "base64",
|
|
287
|
+
"media_type": "image/png",
|
|
288
|
+
"data": screenshot_b64
|
|
289
|
+
},
|
|
290
|
+
"note": "Screenshot rendered via agent-browser. For interactive UI, use a host that supports MCP-UI.",
|
|
291
|
+
**fallback_data
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
# Option 2: Return MCP-UI resource (for compatible hosts)
|
|
295
|
+
if MCP_UI_AVAILABLE:
|
|
296
|
+
try:
|
|
297
|
+
ui_resource = create_ui_resource({
|
|
298
|
+
"uri": uri,
|
|
299
|
+
"content": {
|
|
300
|
+
"type": "rawHtml",
|
|
301
|
+
"htmlString": html_content
|
|
302
|
+
},
|
|
303
|
+
"encoding": "text",
|
|
304
|
+
"uiMetadata": {
|
|
305
|
+
UIMetadataKey.PREFERRED_FRAME_SIZE: list(frame_size)
|
|
306
|
+
}
|
|
307
|
+
})
|
|
308
|
+
return [ui_resource]
|
|
309
|
+
except Exception as e:
|
|
310
|
+
logger.warning(f"Failed to create UI resource: {e}")
|
|
311
|
+
|
|
312
|
+
# Option 3: Return plain JSON fallback
|
|
313
|
+
return fallback_data
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def register_ui_tools(server, non_destructive: bool):
|
|
317
|
+
"""Register UI-enhanced tools with the MCP server.
|
|
318
|
+
|
|
319
|
+
Args:
|
|
320
|
+
server: FastMCP server instance
|
|
321
|
+
non_destructive: If True, block destructive operations
|
|
322
|
+
"""
|
|
323
|
+
|
|
324
|
+
@server.tool(
|
|
325
|
+
annotations=ToolAnnotations(
|
|
326
|
+
title="Show Pod Logs UI",
|
|
327
|
+
readOnlyHint=True,
|
|
328
|
+
),
|
|
329
|
+
)
|
|
330
|
+
def show_pod_logs_ui(
|
|
331
|
+
pod_name: str,
|
|
332
|
+
namespace: str = "default",
|
|
333
|
+
container: Optional[str] = None,
|
|
334
|
+
tail: int = 100
|
|
335
|
+
) -> Union[List[UIResource], Dict[str, Any]]:
|
|
336
|
+
"""Display pod logs in an interactive UI with search and syntax highlighting."""
|
|
337
|
+
try:
|
|
338
|
+
from kubernetes import client, config
|
|
339
|
+
config.load_kube_config()
|
|
340
|
+
v1 = client.CoreV1Api()
|
|
341
|
+
|
|
342
|
+
logs = v1.read_namespaced_pod_log(
|
|
343
|
+
name=pod_name,
|
|
344
|
+
namespace=namespace,
|
|
345
|
+
container=container,
|
|
346
|
+
tail_lines=tail
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
# Process log lines for highlighting
|
|
350
|
+
log_lines = logs.split('\n') if logs else []
|
|
351
|
+
processed_lines = []
|
|
352
|
+
for line in log_lines:
|
|
353
|
+
escaped = _escape(line)
|
|
354
|
+
css_class = "log-line"
|
|
355
|
+
if any(kw in line.lower() for kw in ['error', 'fail', 'exception', 'panic']):
|
|
356
|
+
css_class += " log-error"
|
|
357
|
+
elif any(kw in line.lower() for kw in ['warn', 'warning']):
|
|
358
|
+
css_class += " log-warn"
|
|
359
|
+
elif any(kw in line.lower() for kw in ['info']):
|
|
360
|
+
css_class += " log-info"
|
|
361
|
+
processed_lines.append(f'<span class="{css_class}">{escaped}</span>')
|
|
362
|
+
|
|
363
|
+
html_content = f"""<!DOCTYPE html>
|
|
364
|
+
<html><head><style>{DARK_THEME_CSS}</style></head>
|
|
365
|
+
<body>
|
|
366
|
+
<div class="container">
|
|
367
|
+
<div class="header">
|
|
368
|
+
<h2>Pod Logs</h2>
|
|
369
|
+
<span class="badge badge-blue">{_escape(namespace)}/{_escape(pod_name)}</span>
|
|
370
|
+
{f'<span class="badge badge-green">{_escape(container)}</span>' if container else ''}
|
|
371
|
+
</div>
|
|
372
|
+
<input type="text" class="search-box" placeholder="Search logs..." id="search" oninput="filterLogs()">
|
|
373
|
+
<div class="card">
|
|
374
|
+
<div class="card-header">
|
|
375
|
+
<span class="card-title">Last {len(log_lines)} lines</span>
|
|
376
|
+
<div class="actions">
|
|
377
|
+
<button class="btn btn-secondary" onclick="copyLogs()">Copy</button>
|
|
378
|
+
<button class="btn btn-primary" onclick="refreshLogs()">Refresh</button>
|
|
379
|
+
</div>
|
|
380
|
+
</div>
|
|
381
|
+
<pre class="log-content" id="logs">{''.join(processed_lines)}</pre>
|
|
382
|
+
</div>
|
|
383
|
+
</div>
|
|
384
|
+
<script>
|
|
385
|
+
function filterLogs() {{
|
|
386
|
+
const search = document.getElementById('search').value.toLowerCase();
|
|
387
|
+
const lines = document.querySelectorAll('.log-line');
|
|
388
|
+
lines.forEach(line => {{
|
|
389
|
+
line.style.display = line.textContent.toLowerCase().includes(search) ? 'block' : 'none';
|
|
390
|
+
}});
|
|
391
|
+
}}
|
|
392
|
+
function copyLogs() {{
|
|
393
|
+
const logs = document.getElementById('logs').textContent;
|
|
394
|
+
navigator.clipboard.writeText(logs);
|
|
395
|
+
}}
|
|
396
|
+
function refreshLogs() {{
|
|
397
|
+
window.parent.postMessage({{
|
|
398
|
+
type: 'tool',
|
|
399
|
+
payload: {{
|
|
400
|
+
toolName: 'show_pod_logs_ui',
|
|
401
|
+
params: {{ pod_name: '{_escape(pod_name)}', namespace: '{_escape(namespace)}', tail: {tail} }}
|
|
402
|
+
}}
|
|
403
|
+
}}, '*');
|
|
404
|
+
}}
|
|
405
|
+
</script>
|
|
406
|
+
</body></html>"""
|
|
407
|
+
|
|
408
|
+
return _create_ui_or_fallback(
|
|
409
|
+
uri=f"ui://pod-logs/{namespace}/{pod_name}",
|
|
410
|
+
html_content=html_content,
|
|
411
|
+
fallback_data={"success": True, "logs": logs, "lineCount": len(log_lines)},
|
|
412
|
+
frame_size=("900px", "600px")
|
|
413
|
+
)
|
|
414
|
+
|
|
415
|
+
except Exception as e:
|
|
416
|
+
logger.error(f"Error showing pod logs UI: {e}")
|
|
417
|
+
return {"success": False, "error": str(e)}
|
|
418
|
+
|
|
419
|
+
@server.tool(
|
|
420
|
+
annotations=ToolAnnotations(
|
|
421
|
+
title="Show Pods Dashboard UI",
|
|
422
|
+
readOnlyHint=True,
|
|
423
|
+
),
|
|
424
|
+
)
|
|
425
|
+
def show_pods_dashboard_ui(
|
|
426
|
+
namespace: Optional[str] = None
|
|
427
|
+
) -> Union[List[UIResource], Dict[str, Any]]:
|
|
428
|
+
"""Display an interactive dashboard showing all pods with status, metrics, and actions."""
|
|
429
|
+
try:
|
|
430
|
+
from kubernetes import client, config
|
|
431
|
+
config.load_kube_config()
|
|
432
|
+
v1 = client.CoreV1Api()
|
|
433
|
+
|
|
434
|
+
if namespace:
|
|
435
|
+
pods = v1.list_namespaced_pod(namespace)
|
|
436
|
+
else:
|
|
437
|
+
pods = v1.list_pod_for_all_namespaces()
|
|
438
|
+
|
|
439
|
+
# Count by status
|
|
440
|
+
status_counts = {"Running": 0, "Pending": 0, "Failed": 0, "Succeeded": 0, "Unknown": 0}
|
|
441
|
+
pod_rows = []
|
|
442
|
+
|
|
443
|
+
for pod in pods.items:
|
|
444
|
+
phase = pod.status.phase or "Unknown"
|
|
445
|
+
status_counts[phase] = status_counts.get(phase, 0) + 1
|
|
446
|
+
|
|
447
|
+
# Determine status indicator class
|
|
448
|
+
status_class = {
|
|
449
|
+
"Running": "status-running",
|
|
450
|
+
"Pending": "status-pending",
|
|
451
|
+
"Failed": "status-failed",
|
|
452
|
+
"Succeeded": "status-running"
|
|
453
|
+
}.get(phase, "status-unknown")
|
|
454
|
+
|
|
455
|
+
# Get restart count
|
|
456
|
+
restart_count = 0
|
|
457
|
+
if pod.status.container_statuses:
|
|
458
|
+
restart_count = sum(cs.restart_count for cs in pod.status.container_statuses)
|
|
459
|
+
|
|
460
|
+
pod_rows.append(f"""
|
|
461
|
+
<tr>
|
|
462
|
+
<td><span class="status-indicator {status_class}"></span>{_escape(pod.metadata.name)}</td>
|
|
463
|
+
<td>{_escape(pod.metadata.namespace)}</td>
|
|
464
|
+
<td><span class="badge badge-{'green' if phase == 'Running' else 'yellow' if phase == 'Pending' else 'red'}">{_escape(phase)}</span></td>
|
|
465
|
+
<td>{restart_count}</td>
|
|
466
|
+
<td>{_escape(pod.status.pod_ip or '-')}</td>
|
|
467
|
+
<td>
|
|
468
|
+
<button class="btn btn-secondary" onclick="viewLogs('{_escape(pod.metadata.name)}', '{_escape(pod.metadata.namespace)}')">Logs</button>
|
|
469
|
+
</td>
|
|
470
|
+
</tr>""")
|
|
471
|
+
|
|
472
|
+
total_pods = len(pods.items)
|
|
473
|
+
ns_display = f"Namespace: {_escape(namespace)}" if namespace else "All Namespaces"
|
|
474
|
+
|
|
475
|
+
html_content = f"""<!DOCTYPE html>
|
|
476
|
+
<html><head><style>{DARK_THEME_CSS}</style></head>
|
|
477
|
+
<body>
|
|
478
|
+
<div class="container">
|
|
479
|
+
<div class="header">
|
|
480
|
+
<h2>Pods Dashboard</h2>
|
|
481
|
+
<span class="badge badge-blue">{ns_display}</span>
|
|
482
|
+
</div>
|
|
483
|
+
|
|
484
|
+
<div class="stat-grid">
|
|
485
|
+
<div class="stat-card">
|
|
486
|
+
<div class="stat-value">{total_pods}</div>
|
|
487
|
+
<div class="stat-label">Total Pods</div>
|
|
488
|
+
</div>
|
|
489
|
+
<div class="stat-card">
|
|
490
|
+
<div class="stat-value" style="color: var(--accent-green)">{status_counts.get('Running', 0)}</div>
|
|
491
|
+
<div class="stat-label">Running</div>
|
|
492
|
+
</div>
|
|
493
|
+
<div class="stat-card">
|
|
494
|
+
<div class="stat-value" style="color: var(--accent-yellow)">{status_counts.get('Pending', 0)}</div>
|
|
495
|
+
<div class="stat-label">Pending</div>
|
|
496
|
+
</div>
|
|
497
|
+
<div class="stat-card">
|
|
498
|
+
<div class="stat-value" style="color: var(--accent-red)">{status_counts.get('Failed', 0)}</div>
|
|
499
|
+
<div class="stat-label">Failed</div>
|
|
500
|
+
</div>
|
|
501
|
+
</div>
|
|
502
|
+
|
|
503
|
+
<div class="card" style="margin-top: 16px">
|
|
504
|
+
<input type="text" class="search-box" placeholder="Filter pods..." oninput="filterPods(this.value)">
|
|
505
|
+
<div style="overflow-x: auto;">
|
|
506
|
+
<table id="pods-table">
|
|
507
|
+
<thead>
|
|
508
|
+
<tr>
|
|
509
|
+
<th>Name</th>
|
|
510
|
+
<th>Namespace</th>
|
|
511
|
+
<th>Status</th>
|
|
512
|
+
<th>Restarts</th>
|
|
513
|
+
<th>IP</th>
|
|
514
|
+
<th>Actions</th>
|
|
515
|
+
</tr>
|
|
516
|
+
</thead>
|
|
517
|
+
<tbody>{''.join(pod_rows)}</tbody>
|
|
518
|
+
</table>
|
|
519
|
+
</div>
|
|
520
|
+
</div>
|
|
521
|
+
</div>
|
|
522
|
+
<script>
|
|
523
|
+
function filterPods(search) {{
|
|
524
|
+
const rows = document.querySelectorAll('#pods-table tbody tr');
|
|
525
|
+
search = search.toLowerCase();
|
|
526
|
+
rows.forEach(row => {{
|
|
527
|
+
row.style.display = row.textContent.toLowerCase().includes(search) ? '' : 'none';
|
|
528
|
+
}});
|
|
529
|
+
}}
|
|
530
|
+
function viewLogs(pod, ns) {{
|
|
531
|
+
window.parent.postMessage({{
|
|
532
|
+
type: 'tool',
|
|
533
|
+
payload: {{ toolName: 'show_pod_logs_ui', params: {{ pod_name: pod, namespace: ns, tail: 100 }} }}
|
|
534
|
+
}}, '*');
|
|
535
|
+
}}
|
|
536
|
+
</script>
|
|
537
|
+
</body></html>"""
|
|
538
|
+
|
|
539
|
+
return _create_ui_or_fallback(
|
|
540
|
+
uri=f"ui://pods-dashboard/{namespace or 'all'}",
|
|
541
|
+
html_content=html_content,
|
|
542
|
+
fallback_data={
|
|
543
|
+
"success": True,
|
|
544
|
+
"totalPods": total_pods,
|
|
545
|
+
"statusCounts": status_counts,
|
|
546
|
+
"pods": [
|
|
547
|
+
{"name": p.metadata.name, "namespace": p.metadata.namespace, "status": p.status.phase}
|
|
548
|
+
for p in pods.items[:50] # Limit for JSON response
|
|
549
|
+
]
|
|
550
|
+
},
|
|
551
|
+
frame_size=("1000px", "700px")
|
|
552
|
+
)
|
|
553
|
+
|
|
554
|
+
except Exception as e:
|
|
555
|
+
logger.error(f"Error showing pods dashboard UI: {e}")
|
|
556
|
+
return {"success": False, "error": str(e)}
|
|
557
|
+
|
|
558
|
+
@server.tool(
|
|
559
|
+
annotations=ToolAnnotations(
|
|
560
|
+
title="Show Resource YAML UI",
|
|
561
|
+
readOnlyHint=True,
|
|
562
|
+
),
|
|
563
|
+
)
|
|
564
|
+
def show_resource_yaml_ui(
|
|
565
|
+
resource_type: str,
|
|
566
|
+
name: str,
|
|
567
|
+
namespace: str = "default"
|
|
568
|
+
) -> Union[List[UIResource], Dict[str, Any]]:
|
|
569
|
+
"""Display Kubernetes resource YAML with syntax highlighting and actions."""
|
|
570
|
+
try:
|
|
571
|
+
import subprocess
|
|
572
|
+
import yaml
|
|
573
|
+
|
|
574
|
+
cmd = ["kubectl", "get", resource_type, name, "-n", namespace, "-o", "yaml"]
|
|
575
|
+
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
|
|
576
|
+
|
|
577
|
+
if result.returncode != 0:
|
|
578
|
+
return {"success": False, "error": result.stderr.strip()}
|
|
579
|
+
|
|
580
|
+
yaml_content = result.stdout
|
|
581
|
+
|
|
582
|
+
# Basic YAML syntax highlighting
|
|
583
|
+
highlighted_lines = []
|
|
584
|
+
for line in yaml_content.split('\n'):
|
|
585
|
+
escaped = _escape(line)
|
|
586
|
+
# Highlight keys
|
|
587
|
+
if ':' in line and not line.strip().startswith('-'):
|
|
588
|
+
key_part = escaped.split(':')[0]
|
|
589
|
+
rest = ':'.join(escaped.split(':')[1:])
|
|
590
|
+
escaped = f'<span style="color: var(--accent-blue)">{key_part}</span>:{rest}'
|
|
591
|
+
# Highlight comments
|
|
592
|
+
if line.strip().startswith('#'):
|
|
593
|
+
escaped = f'<span style="color: var(--text-muted)">{escaped}</span>'
|
|
594
|
+
highlighted_lines.append(escaped)
|
|
595
|
+
|
|
596
|
+
# Escape YAML for JavaScript template literal (must be done outside f-string)
|
|
597
|
+
escaped_yaml = yaml_content.replace('`', r'\`').replace('$', r'\$')
|
|
598
|
+
|
|
599
|
+
html_content = f"""<!DOCTYPE html>
|
|
600
|
+
<html><head><style>{DARK_THEME_CSS}</style></head>
|
|
601
|
+
<body>
|
|
602
|
+
<div class="container">
|
|
603
|
+
<div class="header">
|
|
604
|
+
<h2>{_escape(resource_type)}: {_escape(name)}</h2>
|
|
605
|
+
<span class="badge badge-blue">{_escape(namespace)}</span>
|
|
606
|
+
</div>
|
|
607
|
+
<div class="card">
|
|
608
|
+
<div class="card-header">
|
|
609
|
+
<span class="card-title">YAML Definition</span>
|
|
610
|
+
<div class="actions">
|
|
611
|
+
<button class="btn btn-secondary" onclick="copyYaml()">Copy YAML</button>
|
|
612
|
+
</div>
|
|
613
|
+
</div>
|
|
614
|
+
<pre id="yaml-content">{chr(10).join(highlighted_lines)}</pre>
|
|
615
|
+
</div>
|
|
616
|
+
</div>
|
|
617
|
+
<script>
|
|
618
|
+
function copyYaml() {{
|
|
619
|
+
const yaml = `{escaped_yaml}`;
|
|
620
|
+
navigator.clipboard.writeText(yaml);
|
|
621
|
+
}}
|
|
622
|
+
</script>
|
|
623
|
+
</body></html>"""
|
|
624
|
+
|
|
625
|
+
return _create_ui_or_fallback(
|
|
626
|
+
uri=f"ui://resource-yaml/{resource_type}/{namespace}/{name}",
|
|
627
|
+
html_content=html_content,
|
|
628
|
+
fallback_data={"success": True, "yaml": yaml_content},
|
|
629
|
+
frame_size=("900px", "700px")
|
|
630
|
+
)
|
|
631
|
+
|
|
632
|
+
except Exception as e:
|
|
633
|
+
logger.error(f"Error showing resource YAML UI: {e}")
|
|
634
|
+
return {"success": False, "error": str(e)}
|
|
635
|
+
|
|
636
|
+
@server.tool(
|
|
637
|
+
annotations=ToolAnnotations(
|
|
638
|
+
title="Show Cluster Overview UI",
|
|
639
|
+
readOnlyHint=True,
|
|
640
|
+
),
|
|
641
|
+
)
|
|
642
|
+
def show_cluster_overview_ui() -> Union[List[UIResource], Dict[str, Any]]:
|
|
643
|
+
"""Display a comprehensive cluster overview dashboard with nodes, namespaces, and key metrics."""
|
|
644
|
+
try:
|
|
645
|
+
from kubernetes import client, config
|
|
646
|
+
config.load_kube_config()
|
|
647
|
+
v1 = client.CoreV1Api()
|
|
648
|
+
|
|
649
|
+
# Get nodes
|
|
650
|
+
nodes = v1.list_node()
|
|
651
|
+
node_rows = []
|
|
652
|
+
ready_nodes = 0
|
|
653
|
+
|
|
654
|
+
for node in nodes.items:
|
|
655
|
+
is_ready = False
|
|
656
|
+
for condition in (node.status.conditions or []):
|
|
657
|
+
if condition.type == "Ready" and condition.status == "True":
|
|
658
|
+
is_ready = True
|
|
659
|
+
ready_nodes += 1
|
|
660
|
+
break
|
|
661
|
+
|
|
662
|
+
# Get allocatable resources
|
|
663
|
+
allocatable = node.status.allocatable or {}
|
|
664
|
+
cpu = allocatable.get("cpu", "N/A")
|
|
665
|
+
memory = allocatable.get("memory", "N/A")
|
|
666
|
+
|
|
667
|
+
node_rows.append(f"""
|
|
668
|
+
<tr>
|
|
669
|
+
<td><span class="status-indicator {'status-running' if is_ready else 'status-failed'}"></span>{_escape(node.metadata.name)}</td>
|
|
670
|
+
<td><span class="badge badge-{'green' if is_ready else 'red'}">{'Ready' if is_ready else 'NotReady'}</span></td>
|
|
671
|
+
<td>{_escape(cpu)}</td>
|
|
672
|
+
<td>{_escape(memory)}</td>
|
|
673
|
+
</tr>""")
|
|
674
|
+
|
|
675
|
+
# Get namespaces
|
|
676
|
+
namespaces = v1.list_namespace()
|
|
677
|
+
ns_count = len(namespaces.items)
|
|
678
|
+
|
|
679
|
+
# Get pods summary
|
|
680
|
+
pods = v1.list_pod_for_all_namespaces()
|
|
681
|
+
total_pods = len(pods.items)
|
|
682
|
+
running_pods = sum(1 for p in pods.items if p.status.phase == "Running")
|
|
683
|
+
|
|
684
|
+
# Get services
|
|
685
|
+
services = v1.list_service_for_all_namespaces()
|
|
686
|
+
svc_count = len(services.items)
|
|
687
|
+
|
|
688
|
+
html_content = f"""<!DOCTYPE html>
|
|
689
|
+
<html><head><style>{DARK_THEME_CSS}</style></head>
|
|
690
|
+
<body>
|
|
691
|
+
<div class="container">
|
|
692
|
+
<div class="header">
|
|
693
|
+
<h2>Cluster Overview</h2>
|
|
694
|
+
<span class="badge badge-green">Connected</span>
|
|
695
|
+
</div>
|
|
696
|
+
|
|
697
|
+
<div class="stat-grid">
|
|
698
|
+
<div class="stat-card">
|
|
699
|
+
<div class="stat-value">{len(nodes.items)}</div>
|
|
700
|
+
<div class="stat-label">Nodes ({ready_nodes} Ready)</div>
|
|
701
|
+
</div>
|
|
702
|
+
<div class="stat-card">
|
|
703
|
+
<div class="stat-value">{ns_count}</div>
|
|
704
|
+
<div class="stat-label">Namespaces</div>
|
|
705
|
+
</div>
|
|
706
|
+
<div class="stat-card">
|
|
707
|
+
<div class="stat-value" style="color: var(--accent-green)">{running_pods}/{total_pods}</div>
|
|
708
|
+
<div class="stat-label">Pods Running</div>
|
|
709
|
+
</div>
|
|
710
|
+
<div class="stat-card">
|
|
711
|
+
<div class="stat-value">{svc_count}</div>
|
|
712
|
+
<div class="stat-label">Services</div>
|
|
713
|
+
</div>
|
|
714
|
+
</div>
|
|
715
|
+
|
|
716
|
+
<div class="card" style="margin-top: 16px">
|
|
717
|
+
<div class="card-header">
|
|
718
|
+
<span class="card-title">Nodes</span>
|
|
719
|
+
</div>
|
|
720
|
+
<table>
|
|
721
|
+
<thead>
|
|
722
|
+
<tr><th>Name</th><th>Status</th><th>CPU</th><th>Memory</th></tr>
|
|
723
|
+
</thead>
|
|
724
|
+
<tbody>{''.join(node_rows)}</tbody>
|
|
725
|
+
</table>
|
|
726
|
+
</div>
|
|
727
|
+
|
|
728
|
+
<div class="actions">
|
|
729
|
+
<button class="btn btn-primary" onclick="viewPods()">View All Pods</button>
|
|
730
|
+
<button class="btn btn-secondary" onclick="refresh()">Refresh</button>
|
|
731
|
+
</div>
|
|
732
|
+
</div>
|
|
733
|
+
<script>
|
|
734
|
+
function viewPods() {{
|
|
735
|
+
window.parent.postMessage({{
|
|
736
|
+
type: 'tool',
|
|
737
|
+
payload: {{ toolName: 'show_pods_dashboard_ui', params: {{}} }}
|
|
738
|
+
}}, '*');
|
|
739
|
+
}}
|
|
740
|
+
function refresh() {{
|
|
741
|
+
window.parent.postMessage({{
|
|
742
|
+
type: 'tool',
|
|
743
|
+
payload: {{ toolName: 'show_cluster_overview_ui', params: {{}} }}
|
|
744
|
+
}}, '*');
|
|
745
|
+
}}
|
|
746
|
+
</script>
|
|
747
|
+
</body></html>"""
|
|
748
|
+
|
|
749
|
+
return _create_ui_or_fallback(
|
|
750
|
+
uri="ui://cluster-overview",
|
|
751
|
+
html_content=html_content,
|
|
752
|
+
fallback_data={
|
|
753
|
+
"success": True,
|
|
754
|
+
"nodes": {"total": len(nodes.items), "ready": ready_nodes},
|
|
755
|
+
"namespaces": ns_count,
|
|
756
|
+
"pods": {"total": total_pods, "running": running_pods},
|
|
757
|
+
"services": svc_count
|
|
758
|
+
},
|
|
759
|
+
frame_size=("1000px", "700px")
|
|
760
|
+
)
|
|
761
|
+
|
|
762
|
+
except Exception as e:
|
|
763
|
+
logger.error(f"Error showing cluster overview UI: {e}")
|
|
764
|
+
return {"success": False, "error": str(e)}
|
|
765
|
+
|
|
766
|
+
@server.tool(
|
|
767
|
+
annotations=ToolAnnotations(
|
|
768
|
+
title="Show Events Timeline UI",
|
|
769
|
+
readOnlyHint=True,
|
|
770
|
+
),
|
|
771
|
+
)
|
|
772
|
+
def show_events_timeline_ui(
|
|
773
|
+
namespace: Optional[str] = None,
|
|
774
|
+
limit: int = 50
|
|
775
|
+
) -> Union[List[UIResource], Dict[str, Any]]:
|
|
776
|
+
"""Display Kubernetes events in a timeline view with filtering by type."""
|
|
777
|
+
try:
|
|
778
|
+
from kubernetes import client, config
|
|
779
|
+
config.load_kube_config()
|
|
780
|
+
v1 = client.CoreV1Api()
|
|
781
|
+
|
|
782
|
+
if namespace:
|
|
783
|
+
events = v1.list_namespaced_event(namespace)
|
|
784
|
+
else:
|
|
785
|
+
events = v1.list_event_for_all_namespaces()
|
|
786
|
+
|
|
787
|
+
# Sort by timestamp (most recent first)
|
|
788
|
+
sorted_events = sorted(
|
|
789
|
+
events.items,
|
|
790
|
+
key=lambda e: e.last_timestamp or e.metadata.creation_timestamp or "",
|
|
791
|
+
reverse=True
|
|
792
|
+
)[:limit]
|
|
793
|
+
|
|
794
|
+
event_items = []
|
|
795
|
+
warning_count = 0
|
|
796
|
+
normal_count = 0
|
|
797
|
+
|
|
798
|
+
for event in sorted_events:
|
|
799
|
+
is_warning = event.type == "Warning"
|
|
800
|
+
if is_warning:
|
|
801
|
+
warning_count += 1
|
|
802
|
+
else:
|
|
803
|
+
normal_count += 1
|
|
804
|
+
|
|
805
|
+
timestamp = event.last_timestamp or event.metadata.creation_timestamp
|
|
806
|
+
time_str = timestamp.strftime("%H:%M:%S") if timestamp else "Unknown"
|
|
807
|
+
|
|
808
|
+
event_items.append(f"""
|
|
809
|
+
<div class="card" style="border-left: 3px solid var({'--accent-red' if is_warning else '--accent-green'})">
|
|
810
|
+
<div class="card-header">
|
|
811
|
+
<span class="card-title">
|
|
812
|
+
<span class="badge badge-{'red' if is_warning else 'green'}">{_escape(event.type)}</span>
|
|
813
|
+
{_escape(event.reason)}
|
|
814
|
+
</span>
|
|
815
|
+
<span class="timestamp">{time_str}</span>
|
|
816
|
+
</div>
|
|
817
|
+
<p style="font-size: 0.85rem; color: var(--text-secondary)">
|
|
818
|
+
{_escape(event.involved_object.kind)}: {_escape(event.involved_object.name)}
|
|
819
|
+
<span style="color: var(--text-muted)">in {_escape(event.metadata.namespace)}</span>
|
|
820
|
+
</p>
|
|
821
|
+
<p style="margin-top: 8px; font-size: 0.85rem">{_escape(event.message)}</p>
|
|
822
|
+
</div>""")
|
|
823
|
+
|
|
824
|
+
ns_display = f"Namespace: {_escape(namespace)}" if namespace else "All Namespaces"
|
|
825
|
+
|
|
826
|
+
html_content = f"""<!DOCTYPE html>
|
|
827
|
+
<html><head><style>{DARK_THEME_CSS}</style></head>
|
|
828
|
+
<body>
|
|
829
|
+
<div class="container">
|
|
830
|
+
<div class="header">
|
|
831
|
+
<h2>Events Timeline</h2>
|
|
832
|
+
<span class="badge badge-blue">{ns_display}</span>
|
|
833
|
+
</div>
|
|
834
|
+
|
|
835
|
+
<div class="stat-grid" style="margin-bottom: 16px">
|
|
836
|
+
<div class="stat-card">
|
|
837
|
+
<div class="stat-value">{len(sorted_events)}</div>
|
|
838
|
+
<div class="stat-label">Total Events</div>
|
|
839
|
+
</div>
|
|
840
|
+
<div class="stat-card">
|
|
841
|
+
<div class="stat-value" style="color: var(--accent-red)">{warning_count}</div>
|
|
842
|
+
<div class="stat-label">Warnings</div>
|
|
843
|
+
</div>
|
|
844
|
+
<div class="stat-card">
|
|
845
|
+
<div class="stat-value" style="color: var(--accent-green)">{normal_count}</div>
|
|
846
|
+
<div class="stat-label">Normal</div>
|
|
847
|
+
</div>
|
|
848
|
+
</div>
|
|
849
|
+
|
|
850
|
+
<div style="display: flex; gap: 8px; margin-bottom: 16px">
|
|
851
|
+
<button class="btn btn-secondary" onclick="filterEvents('all')">All</button>
|
|
852
|
+
<button class="btn btn-secondary" onclick="filterEvents('Warning')">Warnings</button>
|
|
853
|
+
<button class="btn btn-secondary" onclick="filterEvents('Normal')">Normal</button>
|
|
854
|
+
</div>
|
|
855
|
+
|
|
856
|
+
<div id="events">{''.join(event_items)}</div>
|
|
857
|
+
</div>
|
|
858
|
+
<script>
|
|
859
|
+
function filterEvents(type) {{
|
|
860
|
+
const events = document.querySelectorAll('#events .card');
|
|
861
|
+
events.forEach(e => {{
|
|
862
|
+
if (type === 'all') e.style.display = 'block';
|
|
863
|
+
else e.style.display = e.innerHTML.includes('>' + type + '<') ? 'block' : 'none';
|
|
864
|
+
}});
|
|
865
|
+
}}
|
|
866
|
+
</script>
|
|
867
|
+
</body></html>"""
|
|
868
|
+
|
|
869
|
+
return _create_ui_or_fallback(
|
|
870
|
+
uri=f"ui://events-timeline/{namespace or 'all'}",
|
|
871
|
+
html_content=html_content,
|
|
872
|
+
fallback_data={
|
|
873
|
+
"success": True,
|
|
874
|
+
"totalEvents": len(sorted_events),
|
|
875
|
+
"warnings": warning_count,
|
|
876
|
+
"normal": normal_count,
|
|
877
|
+
"events": [
|
|
878
|
+
{
|
|
879
|
+
"type": e.type,
|
|
880
|
+
"reason": e.reason,
|
|
881
|
+
"message": e.message,
|
|
882
|
+
"object": f"{e.involved_object.kind}/{e.involved_object.name}"
|
|
883
|
+
}
|
|
884
|
+
for e in sorted_events[:20]
|
|
885
|
+
]
|
|
886
|
+
},
|
|
887
|
+
frame_size=("900px", "700px")
|
|
888
|
+
)
|
|
889
|
+
|
|
890
|
+
except Exception as e:
|
|
891
|
+
logger.error(f"Error showing events timeline UI: {e}")
|
|
892
|
+
return {"success": False, "error": str(e)}
|
|
893
|
+
|
|
894
|
+
@server.tool(
|
|
895
|
+
annotations=ToolAnnotations(
|
|
896
|
+
title="Render K8s Dashboard Screenshot",
|
|
897
|
+
readOnlyHint=True,
|
|
898
|
+
),
|
|
899
|
+
)
|
|
900
|
+
def render_k8s_dashboard_screenshot(
|
|
901
|
+
dashboard: str = "cluster",
|
|
902
|
+
namespace: Optional[str] = None,
|
|
903
|
+
pod_name: Optional[str] = None,
|
|
904
|
+
resource_type: Optional[str] = None,
|
|
905
|
+
resource_name: Optional[str] = None
|
|
906
|
+
) -> Dict[str, Any]:
|
|
907
|
+
"""Render a Kubernetes dashboard to a screenshot image (works in all MCP hosts).
|
|
908
|
+
|
|
909
|
+
This uses agent-browser to render the UI and capture a screenshot,
|
|
910
|
+
making visual dashboards accessible even in hosts that don't support MCP-UI.
|
|
911
|
+
|
|
912
|
+
Args:
|
|
913
|
+
dashboard: Type of dashboard to render:
|
|
914
|
+
- "cluster": Cluster overview with nodes, pods, services
|
|
915
|
+
- "pods": Pods dashboard with status table
|
|
916
|
+
- "events": Events timeline
|
|
917
|
+
- "logs": Pod logs viewer (requires pod_name)
|
|
918
|
+
- "yaml": Resource YAML viewer (requires resource_type and resource_name)
|
|
919
|
+
namespace: Namespace filter (optional)
|
|
920
|
+
pod_name: Pod name for logs dashboard
|
|
921
|
+
resource_type: Resource type for YAML view (e.g., "deployment", "service")
|
|
922
|
+
resource_name: Resource name for YAML view
|
|
923
|
+
"""
|
|
924
|
+
if not _can_render_screenshots():
|
|
925
|
+
return {
|
|
926
|
+
"success": False,
|
|
927
|
+
"error": "Screenshot rendering requires MCP_BROWSER_ENABLED=true and agent-browser installed"
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
try:
|
|
931
|
+
from kubernetes import client, config
|
|
932
|
+
config.load_kube_config()
|
|
933
|
+
|
|
934
|
+
# Generate the appropriate dashboard HTML
|
|
935
|
+
if dashboard == "cluster":
|
|
936
|
+
# Reuse cluster overview logic
|
|
937
|
+
v1 = client.CoreV1Api()
|
|
938
|
+
nodes = v1.list_node()
|
|
939
|
+
namespaces = v1.list_namespace()
|
|
940
|
+
pods = v1.list_pod_for_all_namespaces()
|
|
941
|
+
services = v1.list_service_for_all_namespaces()
|
|
942
|
+
|
|
943
|
+
ready_nodes = sum(1 for n in nodes.items
|
|
944
|
+
for c in (n.status.conditions or [])
|
|
945
|
+
if c.type == "Ready" and c.status == "True")
|
|
946
|
+
running_pods = sum(1 for p in pods.items if p.status.phase == "Running")
|
|
947
|
+
|
|
948
|
+
node_rows = []
|
|
949
|
+
for node in nodes.items:
|
|
950
|
+
is_ready = any(c.type == "Ready" and c.status == "True"
|
|
951
|
+
for c in (node.status.conditions or []))
|
|
952
|
+
allocatable = node.status.allocatable or {}
|
|
953
|
+
node_rows.append(f"""
|
|
954
|
+
<tr>
|
|
955
|
+
<td><span class="status-indicator {'status-running' if is_ready else 'status-failed'}"></span>{_escape(node.metadata.name)}</td>
|
|
956
|
+
<td><span class="badge badge-{'green' if is_ready else 'red'}">{'Ready' if is_ready else 'NotReady'}</span></td>
|
|
957
|
+
<td>{_escape(allocatable.get('cpu', 'N/A'))}</td>
|
|
958
|
+
<td>{_escape(allocatable.get('memory', 'N/A'))}</td>
|
|
959
|
+
</tr>""")
|
|
960
|
+
|
|
961
|
+
html_content = f"""<!DOCTYPE html>
|
|
962
|
+
<html><head><style>{DARK_THEME_CSS}</style></head>
|
|
963
|
+
<body>
|
|
964
|
+
<div class="container">
|
|
965
|
+
<div class="header">
|
|
966
|
+
<h2>Cluster Overview</h2>
|
|
967
|
+
<span class="badge badge-green">Connected</span>
|
|
968
|
+
</div>
|
|
969
|
+
<div class="stat-grid">
|
|
970
|
+
<div class="stat-card"><div class="stat-value">{len(nodes.items)}</div><div class="stat-label">Nodes ({ready_nodes} Ready)</div></div>
|
|
971
|
+
<div class="stat-card"><div class="stat-value">{len(namespaces.items)}</div><div class="stat-label">Namespaces</div></div>
|
|
972
|
+
<div class="stat-card"><div class="stat-value" style="color: var(--accent-green)">{running_pods}/{len(pods.items)}</div><div class="stat-label">Pods Running</div></div>
|
|
973
|
+
<div class="stat-card"><div class="stat-value">{len(services.items)}</div><div class="stat-label">Services</div></div>
|
|
974
|
+
</div>
|
|
975
|
+
<div class="card" style="margin-top: 16px">
|
|
976
|
+
<div class="card-header"><span class="card-title">Nodes</span></div>
|
|
977
|
+
<table><thead><tr><th>Name</th><th>Status</th><th>CPU</th><th>Memory</th></tr></thead>
|
|
978
|
+
<tbody>{''.join(node_rows)}</tbody></table>
|
|
979
|
+
</div>
|
|
980
|
+
</div>
|
|
981
|
+
</body></html>"""
|
|
982
|
+
|
|
983
|
+
elif dashboard == "pods":
|
|
984
|
+
v1 = client.CoreV1Api()
|
|
985
|
+
if namespace:
|
|
986
|
+
pods = v1.list_namespaced_pod(namespace)
|
|
987
|
+
else:
|
|
988
|
+
pods = v1.list_pod_for_all_namespaces()
|
|
989
|
+
|
|
990
|
+
pod_rows = []
|
|
991
|
+
for pod in pods.items[:30]: # Limit for screenshot
|
|
992
|
+
phase = pod.status.phase or "Unknown"
|
|
993
|
+
status_class = {"Running": "status-running", "Pending": "status-pending"}.get(phase, "status-failed")
|
|
994
|
+
badge_color = {"Running": "green", "Pending": "yellow"}.get(phase, "red")
|
|
995
|
+
pod_rows.append(f"""
|
|
996
|
+
<tr>
|
|
997
|
+
<td><span class="status-indicator {status_class}"></span>{_escape(pod.metadata.name[:40])}</td>
|
|
998
|
+
<td>{_escape(pod.metadata.namespace)}</td>
|
|
999
|
+
<td><span class="badge badge-{badge_color}">{_escape(phase)}</span></td>
|
|
1000
|
+
</tr>""")
|
|
1001
|
+
|
|
1002
|
+
html_content = f"""<!DOCTYPE html>
|
|
1003
|
+
<html><head><style>{DARK_THEME_CSS}</style></head>
|
|
1004
|
+
<body>
|
|
1005
|
+
<div class="container">
|
|
1006
|
+
<div class="header">
|
|
1007
|
+
<h2>Pods Dashboard</h2>
|
|
1008
|
+
<span class="badge badge-blue">{namespace or 'All Namespaces'}</span>
|
|
1009
|
+
</div>
|
|
1010
|
+
<div class="card">
|
|
1011
|
+
<table><thead><tr><th>Name</th><th>Namespace</th><th>Status</th></tr></thead>
|
|
1012
|
+
<tbody>{''.join(pod_rows)}</tbody></table>
|
|
1013
|
+
</div>
|
|
1014
|
+
</div>
|
|
1015
|
+
</body></html>"""
|
|
1016
|
+
|
|
1017
|
+
elif dashboard == "logs" and pod_name:
|
|
1018
|
+
v1 = client.CoreV1Api()
|
|
1019
|
+
ns = namespace or "default"
|
|
1020
|
+
logs = v1.read_namespaced_pod_log(name=pod_name, namespace=ns, tail_lines=50)
|
|
1021
|
+
log_lines = logs.split('\n') if logs else []
|
|
1022
|
+
processed = [f'<span class="log-line">{_escape(line)}</span>' for line in log_lines]
|
|
1023
|
+
|
|
1024
|
+
html_content = f"""<!DOCTYPE html>
|
|
1025
|
+
<html><head><style>{DARK_THEME_CSS}</style></head>
|
|
1026
|
+
<body>
|
|
1027
|
+
<div class="container">
|
|
1028
|
+
<div class="header">
|
|
1029
|
+
<h2>Pod Logs</h2>
|
|
1030
|
+
<span class="badge badge-blue">{_escape(ns)}/{_escape(pod_name)}</span>
|
|
1031
|
+
</div>
|
|
1032
|
+
<div class="card">
|
|
1033
|
+
<pre class="log-content">{''.join(processed)}</pre>
|
|
1034
|
+
</div>
|
|
1035
|
+
</div>
|
|
1036
|
+
</body></html>"""
|
|
1037
|
+
|
|
1038
|
+
else:
|
|
1039
|
+
return {"success": False, "error": f"Unknown dashboard type: {dashboard}"}
|
|
1040
|
+
|
|
1041
|
+
# Render to screenshot
|
|
1042
|
+
screenshot_b64 = _render_html_to_screenshot(html_content, 1200, 800)
|
|
1043
|
+
if screenshot_b64:
|
|
1044
|
+
return {
|
|
1045
|
+
"success": True,
|
|
1046
|
+
"dashboard": dashboard,
|
|
1047
|
+
"image": {
|
|
1048
|
+
"type": "base64",
|
|
1049
|
+
"media_type": "image/png",
|
|
1050
|
+
"data": screenshot_b64
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
else:
|
|
1054
|
+
return {"success": False, "error": "Failed to render screenshot"}
|
|
1055
|
+
|
|
1056
|
+
except Exception as e:
|
|
1057
|
+
logger.error(f"Error rendering dashboard screenshot: {e}")
|
|
1058
|
+
return {"success": False, "error": str(e)}
|
|
1059
|
+
|
|
1060
|
+
|
|
1061
|
+
def is_ui_available() -> bool:
|
|
1062
|
+
"""Check if MCP-UI is available."""
|
|
1063
|
+
return MCP_UI_AVAILABLE
|
|
1064
|
+
|
|
1065
|
+
|
|
1066
|
+
def is_screenshot_available() -> bool:
|
|
1067
|
+
"""Check if screenshot rendering is available."""
|
|
1068
|
+
return _can_render_screenshots()
|