mxcpctl 0.0.1__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.
- mxcpctl/__init__.py +3 -0
- mxcpctl/cli.py +596 -0
- mxcpctl-0.0.1.dist-info/METADATA +16 -0
- mxcpctl-0.0.1.dist-info/RECORD +7 -0
- mxcpctl-0.0.1.dist-info/WHEEL +5 -0
- mxcpctl-0.0.1.dist-info/entry_points.txt +2 -0
- mxcpctl-0.0.1.dist-info/top_level.txt +1 -0
mxcpctl/__init__.py
ADDED
mxcpctl/cli.py
ADDED
|
@@ -0,0 +1,596 @@
|
|
|
1
|
+
"""CLI commands for mxcpctl.
|
|
2
|
+
|
|
3
|
+
This module provides the main command-line interface for managing
|
|
4
|
+
MXCP instances through the mxcpd API.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
import click
|
|
10
|
+
import httpx
|
|
11
|
+
from rich.console import Console
|
|
12
|
+
|
|
13
|
+
console = Console()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def get_headers(token: str | None) -> dict[str, str]:
|
|
17
|
+
"""Get HTTP headers with optional authentication token.
|
|
18
|
+
|
|
19
|
+
Args:
|
|
20
|
+
token: Optional API token for Bearer authentication
|
|
21
|
+
|
|
22
|
+
Returns:
|
|
23
|
+
Dictionary of HTTP headers
|
|
24
|
+
"""
|
|
25
|
+
headers = {}
|
|
26
|
+
if token:
|
|
27
|
+
headers["Authorization"] = f"Bearer {token}"
|
|
28
|
+
return headers
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@click.group()
|
|
32
|
+
@click.option("--host", default="localhost", envvar="MXCPCTL_HOST", help="mxcpd host")
|
|
33
|
+
@click.option("--port", default=8000, envvar="MXCPCTL_PORT", help="mxcpd port")
|
|
34
|
+
@click.option("--tls/--no-tls", default=False, help="Use HTTPS")
|
|
35
|
+
@click.option("--token", envvar="MXCPCTL_TOKEN", help="API token for authentication")
|
|
36
|
+
@click.version_option(version="0.1.0")
|
|
37
|
+
@click.pass_context
|
|
38
|
+
def main(ctx: click.Context, host: str, port: int, tls: bool, token: str | None) -> None:
|
|
39
|
+
"""mxcpctl - MXCP instance management CLI.
|
|
40
|
+
|
|
41
|
+
Communicates with mxcpd to monitor and control MXCP instances.
|
|
42
|
+
"""
|
|
43
|
+
# Store connection info in context
|
|
44
|
+
ctx.ensure_object(dict)
|
|
45
|
+
ctx.obj["host"] = host
|
|
46
|
+
ctx.obj["port"] = port
|
|
47
|
+
ctx.obj["base_url"] = f"{'https' if tls else 'http'}://{host}:{port}"
|
|
48
|
+
ctx.obj["token"] = token
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@main.command()
|
|
52
|
+
@click.pass_context
|
|
53
|
+
def health(ctx: click.Context) -> None:
|
|
54
|
+
"""Check mxcpd health."""
|
|
55
|
+
base_url = ctx.obj["base_url"]
|
|
56
|
+
token = ctx.obj.get("token")
|
|
57
|
+
|
|
58
|
+
try:
|
|
59
|
+
response = httpx.get(f"{base_url}/health", headers=get_headers(token), timeout=5.0)
|
|
60
|
+
response.raise_for_status()
|
|
61
|
+
data = response.json()
|
|
62
|
+
|
|
63
|
+
console.print("[green]✓[/green] mxcpd is healthy")
|
|
64
|
+
console.print(f" Version: {data['version']}")
|
|
65
|
+
console.print(f" Instance: {data['instance']}")
|
|
66
|
+
console.print(f" Environment: {data['environment']}")
|
|
67
|
+
console.print(f" Status: {data['status']}")
|
|
68
|
+
|
|
69
|
+
except httpx.ConnectError:
|
|
70
|
+
console.print(f"[red]✗[/red] Failed to connect to {base_url}")
|
|
71
|
+
console.print(" Is mxcpd running?")
|
|
72
|
+
sys.exit(1)
|
|
73
|
+
except httpx.TimeoutException:
|
|
74
|
+
console.print("[red]✗[/red] Connection timeout")
|
|
75
|
+
sys.exit(1)
|
|
76
|
+
except httpx.HTTPStatusError as e:
|
|
77
|
+
console.print(f"[red]✗[/red] HTTP error: {e.response.status_code}")
|
|
78
|
+
sys.exit(1)
|
|
79
|
+
except Exception as e:
|
|
80
|
+
console.print(f"[red]✗[/red] Unexpected error: {e}")
|
|
81
|
+
sys.exit(1)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@main.command()
|
|
85
|
+
@click.option("--instance", "-i", help="Filter by instance ID")
|
|
86
|
+
@click.pass_context
|
|
87
|
+
def status(ctx: click.Context, instance: str | None) -> None:
|
|
88
|
+
"""Get instance status."""
|
|
89
|
+
base_url = ctx.obj["base_url"]
|
|
90
|
+
token = ctx.obj.get("token")
|
|
91
|
+
params = {"instance": instance} if instance else {}
|
|
92
|
+
|
|
93
|
+
try:
|
|
94
|
+
response = httpx.get(
|
|
95
|
+
f"{base_url}/api/v1/status", params=params, headers=get_headers(token), timeout=10.0
|
|
96
|
+
)
|
|
97
|
+
response.raise_for_status()
|
|
98
|
+
data = response.json()
|
|
99
|
+
|
|
100
|
+
# Multi-instance response is a list
|
|
101
|
+
for instance_data in data:
|
|
102
|
+
console.print(
|
|
103
|
+
f"\n[cyan]Instance: {instance_data['instance_name']}[/cyan] ({instance_data['instance_id']})" # noqa: E501
|
|
104
|
+
)
|
|
105
|
+
console.print(f" Status: [green]{instance_data.get('status', 'unknown')}[/green]")
|
|
106
|
+
console.print(f" Version: {instance_data.get('version', 'unknown')}")
|
|
107
|
+
console.print(f" Uptime: {instance_data.get('uptime', 'unknown')}")
|
|
108
|
+
console.print(f" Profile: {instance_data.get('profile', 'unknown')}")
|
|
109
|
+
console.print(f" Mode: {instance_data.get('mode', 'unknown')}")
|
|
110
|
+
|
|
111
|
+
if instance_data.get("error"):
|
|
112
|
+
console.print(f" [red]Error: {instance_data['error']}[/red]")
|
|
113
|
+
|
|
114
|
+
except httpx.RequestError as e:
|
|
115
|
+
console.print(f"[red]✗[/red] Request failed: {e}")
|
|
116
|
+
sys.exit(1)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@main.command()
|
|
120
|
+
@click.option("--instance", "-i", help="Filter by instance ID")
|
|
121
|
+
@click.pass_context
|
|
122
|
+
def config(ctx: click.Context, instance: str | None) -> None:
|
|
123
|
+
"""Get instance configuration."""
|
|
124
|
+
base_url = ctx.obj["base_url"]
|
|
125
|
+
token = ctx.obj.get("token")
|
|
126
|
+
params = {"instance": instance} if instance else {}
|
|
127
|
+
|
|
128
|
+
try:
|
|
129
|
+
response = httpx.get(
|
|
130
|
+
f"{base_url}/api/v1/config", params=params, headers=get_headers(token), timeout=10.0
|
|
131
|
+
)
|
|
132
|
+
response.raise_for_status()
|
|
133
|
+
data = response.json()
|
|
134
|
+
|
|
135
|
+
for config_data in data:
|
|
136
|
+
console.print(
|
|
137
|
+
f"\n[cyan]Instance: {config_data['instance_name']}[/cyan] ({config_data['instance_id']})" # noqa: E501
|
|
138
|
+
)
|
|
139
|
+
console.print(f" Profile: {config_data.get('profile', 'N/A')}")
|
|
140
|
+
console.print(f" Environment: {config_data.get('environment', 'N/A')}")
|
|
141
|
+
console.print(f" Read-only: {config_data.get('readonly', False)}")
|
|
142
|
+
console.print(f" Debug: {config_data.get('debug', False)}")
|
|
143
|
+
|
|
144
|
+
if config_data.get("features"):
|
|
145
|
+
features = config_data["features"]
|
|
146
|
+
console.print(" Features:")
|
|
147
|
+
console.print(f" SQL Tools: {features.get('sql_tools', False)}")
|
|
148
|
+
console.print(f" Audit Logging: {features.get('audit_logging', False)}")
|
|
149
|
+
console.print(f" Telemetry: {features.get('telemetry', False)}")
|
|
150
|
+
|
|
151
|
+
except httpx.RequestError as e:
|
|
152
|
+
console.print(f"[red]✗[/red] Request failed: {e}")
|
|
153
|
+
sys.exit(1)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
@main.command()
|
|
157
|
+
@click.option("--instance", "-i", help="Filter by instance ID")
|
|
158
|
+
@click.pass_context
|
|
159
|
+
def reload(ctx: click.Context, instance: str | None) -> None:
|
|
160
|
+
"""Trigger configuration reload."""
|
|
161
|
+
base_url = ctx.obj["base_url"]
|
|
162
|
+
token = ctx.obj.get("token")
|
|
163
|
+
params = {"instance": instance} if instance else {}
|
|
164
|
+
|
|
165
|
+
try:
|
|
166
|
+
response = httpx.post(
|
|
167
|
+
f"{base_url}/api/v1/reload", params=params, headers=get_headers(token), timeout=30.0
|
|
168
|
+
)
|
|
169
|
+
response.raise_for_status()
|
|
170
|
+
data = response.json()
|
|
171
|
+
|
|
172
|
+
for reload_data in data:
|
|
173
|
+
instance_name = reload_data.get("instance_name", "unknown")
|
|
174
|
+
status = reload_data.get("status", "unknown")
|
|
175
|
+
|
|
176
|
+
if status == "success":
|
|
177
|
+
console.print(f"[green]✓[/green] Reload triggered for {instance_name}")
|
|
178
|
+
if reload_data.get("request_id"):
|
|
179
|
+
console.print(f" Request ID: {reload_data['request_id']}")
|
|
180
|
+
else:
|
|
181
|
+
console.print(f"[red]✗[/red] Reload failed for {instance_name}")
|
|
182
|
+
if reload_data.get("error"):
|
|
183
|
+
console.print(f" Error: {reload_data['error']}")
|
|
184
|
+
|
|
185
|
+
except httpx.RequestError as e:
|
|
186
|
+
console.print(f"[red]✗[/red] Request failed: {e}")
|
|
187
|
+
sys.exit(1)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
@main.group()
|
|
191
|
+
def endpoints() -> None:
|
|
192
|
+
"""Manage endpoints."""
|
|
193
|
+
pass
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
@endpoints.command("list")
|
|
197
|
+
@click.option("--instance", "-i", help="Filter by instance ID")
|
|
198
|
+
@click.option(
|
|
199
|
+
"--type",
|
|
200
|
+
"-t",
|
|
201
|
+
type=click.Choice(["tool", "resource", "prompt"]),
|
|
202
|
+
help="Filter by endpoint type",
|
|
203
|
+
)
|
|
204
|
+
@click.pass_context
|
|
205
|
+
def list_endpoints(ctx: click.Context, instance: str | None, type: str | None) -> None:
|
|
206
|
+
"""List all endpoints."""
|
|
207
|
+
base_url = ctx.obj["base_url"]
|
|
208
|
+
token = ctx.obj.get("token")
|
|
209
|
+
params = {}
|
|
210
|
+
if instance:
|
|
211
|
+
params["instance"] = instance
|
|
212
|
+
|
|
213
|
+
try:
|
|
214
|
+
response = httpx.get(
|
|
215
|
+
f"{base_url}/api/v1/endpoints", params=params, headers=get_headers(token), timeout=10.0
|
|
216
|
+
)
|
|
217
|
+
response.raise_for_status()
|
|
218
|
+
data = response.json()
|
|
219
|
+
|
|
220
|
+
for instance_data in data:
|
|
221
|
+
console.print(
|
|
222
|
+
f"\n[cyan]Instance: {instance_data['instance_name']}[/cyan] ({instance_data['instance_id']})" # noqa: E501
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
endpoints_list = instance_data.get("endpoints", [])
|
|
226
|
+
|
|
227
|
+
# Filter by type if specified
|
|
228
|
+
if type:
|
|
229
|
+
endpoints_list = [ep for ep in endpoints_list if ep.get("type") == type]
|
|
230
|
+
|
|
231
|
+
if not endpoints_list:
|
|
232
|
+
console.print(" No endpoints found")
|
|
233
|
+
continue
|
|
234
|
+
|
|
235
|
+
# Group by type
|
|
236
|
+
by_type: dict[str, list] = {}
|
|
237
|
+
for ep in endpoints_list:
|
|
238
|
+
ep_type = ep.get("type", "unknown")
|
|
239
|
+
if ep_type not in by_type:
|
|
240
|
+
by_type[ep_type] = []
|
|
241
|
+
by_type[ep_type].append(ep)
|
|
242
|
+
|
|
243
|
+
for ep_type, eps in by_type.items():
|
|
244
|
+
console.print(f"\n [yellow]{ep_type.upper()}S[/yellow] ({len(eps)})")
|
|
245
|
+
for ep in eps:
|
|
246
|
+
name = ep.get("name", "unknown")
|
|
247
|
+
enabled = ep.get("enabled", False)
|
|
248
|
+
status = ep.get("status", "unknown")
|
|
249
|
+
|
|
250
|
+
status_icon = "✓" if status == "ok" else "✗"
|
|
251
|
+
status_color = "green" if status == "ok" else "red"
|
|
252
|
+
|
|
253
|
+
console.print(f" [{status_color}]{status_icon}[/{status_color}] {name}")
|
|
254
|
+
if ep.get("description"):
|
|
255
|
+
console.print(f" {ep['description']}")
|
|
256
|
+
if not enabled:
|
|
257
|
+
console.print(" [dim](disabled)[/dim]")
|
|
258
|
+
if ep.get("error"):
|
|
259
|
+
console.print(f" [red]Error: {ep['error']}[/red]")
|
|
260
|
+
|
|
261
|
+
except httpx.RequestError as e:
|
|
262
|
+
console.print(f"[red]✗[/red] Request failed: {e}")
|
|
263
|
+
sys.exit(1)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
@main.group()
|
|
267
|
+
def audit() -> None:
|
|
268
|
+
"""Query audit logs."""
|
|
269
|
+
pass
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
@audit.command("query")
|
|
273
|
+
@click.option("--instance", "-i", help="Filter by instance ID")
|
|
274
|
+
@click.option("--operation-type", help="Filter by operation type (tool, resource, prompt)")
|
|
275
|
+
@click.option("--operation-name", help="Filter by operation name")
|
|
276
|
+
@click.option("--status", type=click.Choice(["success", "error"]), help="Filter by status")
|
|
277
|
+
@click.option("--user-id", help="Filter by user ID")
|
|
278
|
+
@click.option("--limit", default=10, help="Maximum number of records (default: 10)")
|
|
279
|
+
@click.option("--offset", default=0, help="Number of records to skip")
|
|
280
|
+
@click.pass_context
|
|
281
|
+
def query_audit(
|
|
282
|
+
ctx: click.Context,
|
|
283
|
+
instance: str | None,
|
|
284
|
+
operation_type: str | None,
|
|
285
|
+
operation_name: str | None,
|
|
286
|
+
status: str | None,
|
|
287
|
+
user_id: str | None,
|
|
288
|
+
limit: int,
|
|
289
|
+
offset: int,
|
|
290
|
+
) -> None:
|
|
291
|
+
"""Query audit logs with filters."""
|
|
292
|
+
base_url = ctx.obj["base_url"]
|
|
293
|
+
token = ctx.obj.get("token")
|
|
294
|
+
params: dict[str, str | int] = {"limit": limit, "offset": offset}
|
|
295
|
+
|
|
296
|
+
if instance:
|
|
297
|
+
params["instance"] = instance
|
|
298
|
+
if operation_type:
|
|
299
|
+
params["operation_type"] = operation_type
|
|
300
|
+
if operation_name:
|
|
301
|
+
params["operation_name"] = operation_name
|
|
302
|
+
if status:
|
|
303
|
+
params["operation_status"] = status
|
|
304
|
+
if user_id:
|
|
305
|
+
params["user_id"] = user_id
|
|
306
|
+
|
|
307
|
+
try:
|
|
308
|
+
response = httpx.get(
|
|
309
|
+
f"{base_url}/api/v1/audit/query",
|
|
310
|
+
params=params,
|
|
311
|
+
headers=get_headers(token),
|
|
312
|
+
timeout=30.0,
|
|
313
|
+
)
|
|
314
|
+
response.raise_for_status()
|
|
315
|
+
data = response.json()
|
|
316
|
+
|
|
317
|
+
for instance_data in data:
|
|
318
|
+
console.print(
|
|
319
|
+
f"\n[cyan]Instance: {instance_data['instance_name']}[/cyan] ({instance_data['instance_id']})" # noqa: E501
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
records = instance_data.get("records", [])
|
|
323
|
+
count = instance_data.get("count", 0)
|
|
324
|
+
|
|
325
|
+
console.print(f" Found {count} record(s)")
|
|
326
|
+
|
|
327
|
+
if not records:
|
|
328
|
+
continue
|
|
329
|
+
|
|
330
|
+
for record in records:
|
|
331
|
+
timestamp = record.get("timestamp", "N/A")
|
|
332
|
+
op_type = record.get("operation_type", "N/A")
|
|
333
|
+
op_name = record.get("operation_name", "N/A")
|
|
334
|
+
op_status = record.get("operation_status", "N/A")
|
|
335
|
+
duration = record.get("duration_ms")
|
|
336
|
+
|
|
337
|
+
status_icon = "✓" if op_status == "success" else "✗"
|
|
338
|
+
status_color = "green" if op_status == "success" else "red"
|
|
339
|
+
|
|
340
|
+
console.print(
|
|
341
|
+
f"\n [{status_color}]{status_icon}[/{status_color}] {op_type}/{op_name}"
|
|
342
|
+
)
|
|
343
|
+
console.print(f" Time: {timestamp}")
|
|
344
|
+
console.print(f" Status: {op_status}")
|
|
345
|
+
if duration is not None:
|
|
346
|
+
console.print(f" Duration: {duration}ms")
|
|
347
|
+
if record.get("user_id"):
|
|
348
|
+
console.print(f" User: {record['user_id']}")
|
|
349
|
+
if record.get("error_message"):
|
|
350
|
+
console.print(f" [red]Error: {record['error_message']}[/red]")
|
|
351
|
+
|
|
352
|
+
except httpx.RequestError as e:
|
|
353
|
+
console.print(f"[red]✗[/red] Request failed: {e}")
|
|
354
|
+
sys.exit(1)
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
@audit.command("stats")
|
|
358
|
+
@click.option("--instance", "-i", help="Filter by instance ID")
|
|
359
|
+
@click.pass_context
|
|
360
|
+
def audit_stats(ctx: click.Context, instance: str | None) -> None:
|
|
361
|
+
"""Get audit log statistics."""
|
|
362
|
+
base_url = ctx.obj["base_url"]
|
|
363
|
+
token = ctx.obj.get("token")
|
|
364
|
+
params = {"instance": instance} if instance else {}
|
|
365
|
+
|
|
366
|
+
try:
|
|
367
|
+
response = httpx.get(
|
|
368
|
+
f"{base_url}/api/v1/audit/stats",
|
|
369
|
+
params=params,
|
|
370
|
+
headers=get_headers(token),
|
|
371
|
+
timeout=10.0,
|
|
372
|
+
)
|
|
373
|
+
response.raise_for_status()
|
|
374
|
+
data = response.json()
|
|
375
|
+
|
|
376
|
+
for instance_data in data:
|
|
377
|
+
console.print(
|
|
378
|
+
f"\n[cyan]Instance: {instance_data['instance_name']}[/cyan] ({instance_data['instance_id']})" # noqa: E501
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
total = instance_data.get("total_records", 0)
|
|
382
|
+
console.print(f" Total Records: {total}")
|
|
383
|
+
|
|
384
|
+
if instance_data.get("by_type"):
|
|
385
|
+
console.print("\n By Type:")
|
|
386
|
+
for op_type, count in instance_data["by_type"].items():
|
|
387
|
+
console.print(f" {op_type}: {count}")
|
|
388
|
+
|
|
389
|
+
if instance_data.get("by_status"):
|
|
390
|
+
console.print("\n By Status:")
|
|
391
|
+
for status, count in instance_data["by_status"].items():
|
|
392
|
+
status_color = "green" if status == "success" else "red"
|
|
393
|
+
console.print(f" [{status_color}]{status}[/{status_color}]: {count}")
|
|
394
|
+
|
|
395
|
+
if instance_data.get("earliest_timestamp"):
|
|
396
|
+
console.print("\n Time Range:")
|
|
397
|
+
console.print(f" Earliest: {instance_data['earliest_timestamp']}")
|
|
398
|
+
console.print(f" Latest: {instance_data.get('latest_timestamp', 'N/A')}")
|
|
399
|
+
|
|
400
|
+
except httpx.RequestError as e:
|
|
401
|
+
console.print(f"[red]✗[/red] Request failed: {e}")
|
|
402
|
+
sys.exit(1)
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
@main.group()
|
|
406
|
+
def telemetry() -> None:
|
|
407
|
+
"""Query telemetry data (traces and metrics)."""
|
|
408
|
+
pass
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
@telemetry.command("status")
|
|
412
|
+
@click.pass_context
|
|
413
|
+
def telemetry_status(ctx: click.Context) -> None:
|
|
414
|
+
"""Get telemetry receiver status."""
|
|
415
|
+
base_url = ctx.obj["base_url"]
|
|
416
|
+
token = ctx.obj.get("token")
|
|
417
|
+
|
|
418
|
+
try:
|
|
419
|
+
response = httpx.get(
|
|
420
|
+
f"{base_url}/api/v1/telemetry/status", headers=get_headers(token), timeout=10.0
|
|
421
|
+
)
|
|
422
|
+
response.raise_for_status()
|
|
423
|
+
result = response.json()
|
|
424
|
+
data = result.get("data", {})
|
|
425
|
+
|
|
426
|
+
console.print("\n[cyan]Telemetry Receiver Status[/cyan]")
|
|
427
|
+
console.print(f" Enabled: {'Yes' if data.get('enabled') else 'No'}")
|
|
428
|
+
console.print(f" Traces Received: {data.get('traces_received', 0)}")
|
|
429
|
+
console.print(f" Traces Stored: {data.get('traces_stored', 0)}")
|
|
430
|
+
console.print(f" Metrics Received: {data.get('metrics_received', 0)}")
|
|
431
|
+
console.print(f" Storage Usage: {data.get('storage_usage_mb', 0):.2f} MB")
|
|
432
|
+
|
|
433
|
+
if data.get("last_trace_time"):
|
|
434
|
+
console.print(f" Last Trace: {data['last_trace_time']}")
|
|
435
|
+
|
|
436
|
+
except httpx.RequestError as e:
|
|
437
|
+
console.print(f"[red]✗[/red] Request failed: {e}")
|
|
438
|
+
sys.exit(1)
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
@telemetry.command("traces")
|
|
442
|
+
@click.option("--limit", default=20, help="Maximum number of traces to show")
|
|
443
|
+
@click.option("--endpoint", help="Filter by endpoint name")
|
|
444
|
+
@click.pass_context
|
|
445
|
+
def list_traces(ctx: click.Context, limit: int, endpoint: str | None) -> None:
|
|
446
|
+
"""List recent traces."""
|
|
447
|
+
base_url = ctx.obj["base_url"]
|
|
448
|
+
token = ctx.obj.get("token")
|
|
449
|
+
params: dict[str, str | int] = {"limit": limit}
|
|
450
|
+
|
|
451
|
+
if endpoint:
|
|
452
|
+
params["endpoint_name"] = endpoint
|
|
453
|
+
|
|
454
|
+
try:
|
|
455
|
+
response = httpx.get(
|
|
456
|
+
f"{base_url}/api/v1/telemetry/traces",
|
|
457
|
+
params=params,
|
|
458
|
+
headers=get_headers(token),
|
|
459
|
+
timeout=10.0,
|
|
460
|
+
)
|
|
461
|
+
response.raise_for_status()
|
|
462
|
+
result = response.json()
|
|
463
|
+
data = result.get("data", {})
|
|
464
|
+
traces = data.get("items", [])
|
|
465
|
+
|
|
466
|
+
if not traces:
|
|
467
|
+
console.print("No traces found")
|
|
468
|
+
return
|
|
469
|
+
|
|
470
|
+
console.print(f"\n[cyan]Recent Traces[/cyan] (showing {len(traces)})")
|
|
471
|
+
|
|
472
|
+
for trace in traces:
|
|
473
|
+
trace_id = trace.get("trace_id", "N/A")
|
|
474
|
+
endpoint_name = trace.get("endpoint_name", "N/A")
|
|
475
|
+
duration = trace.get("duration_ms", 0)
|
|
476
|
+
status = trace.get("status", "unknown")
|
|
477
|
+
span_count = trace.get("span_count", 0)
|
|
478
|
+
|
|
479
|
+
status_icon = "✓" if status == "ok" else "✗"
|
|
480
|
+
status_color = "green" if status == "ok" else "red"
|
|
481
|
+
|
|
482
|
+
console.print(f"\n [{status_color}]{status_icon}[/{status_color}] {endpoint_name}")
|
|
483
|
+
console.print(f" Trace ID: {trace_id}")
|
|
484
|
+
console.print(f" Duration: {duration:.2f}ms")
|
|
485
|
+
console.print(f" Spans: {span_count}")
|
|
486
|
+
console.print(f" Time: {trace.get('start_time', 'N/A')}")
|
|
487
|
+
|
|
488
|
+
except httpx.RequestError as e:
|
|
489
|
+
console.print(f"[red]✗[/red] Request failed: {e}")
|
|
490
|
+
sys.exit(1)
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
@telemetry.command("trace")
|
|
494
|
+
@click.argument("trace_id")
|
|
495
|
+
@click.pass_context
|
|
496
|
+
def get_trace(ctx: click.Context, trace_id: str) -> None:
|
|
497
|
+
"""Get detailed trace information."""
|
|
498
|
+
base_url = ctx.obj["base_url"]
|
|
499
|
+
token = ctx.obj.get("token")
|
|
500
|
+
|
|
501
|
+
try:
|
|
502
|
+
response = httpx.get(
|
|
503
|
+
f"{base_url}/api/v1/telemetry/traces/{trace_id}",
|
|
504
|
+
headers=get_headers(token),
|
|
505
|
+
timeout=10.0,
|
|
506
|
+
)
|
|
507
|
+
response.raise_for_status()
|
|
508
|
+
result = response.json()
|
|
509
|
+
trace = result.get("data", {})
|
|
510
|
+
|
|
511
|
+
console.print("\n[cyan]Trace Details[/cyan]")
|
|
512
|
+
console.print(f" Trace ID: {trace.get('trace_id', 'N/A')}")
|
|
513
|
+
console.print(f" Endpoint: {trace.get('endpoint_name', 'N/A')}")
|
|
514
|
+
console.print(f" Service: {trace.get('service_name', 'N/A')}")
|
|
515
|
+
console.print(f" Duration: {trace.get('duration_ms', 0):.2f}ms")
|
|
516
|
+
console.print(f" Status: {trace.get('status', 'unknown')}")
|
|
517
|
+
console.print(f" Span Count: {trace.get('span_count', 0)}")
|
|
518
|
+
|
|
519
|
+
spans = trace.get("spans", [])
|
|
520
|
+
if spans:
|
|
521
|
+
console.print("\n [yellow]Spans[/yellow]:")
|
|
522
|
+
for span in spans:
|
|
523
|
+
indent = " " if span.get("parent_span_id") else " "
|
|
524
|
+
name = span.get("name", "N/A")
|
|
525
|
+
duration = span.get("duration_ms", 0)
|
|
526
|
+
console.print(f"{indent}• {name} ({duration:.2f}ms)")
|
|
527
|
+
|
|
528
|
+
except httpx.HTTPStatusError as e:
|
|
529
|
+
if e.response.status_code == 404:
|
|
530
|
+
console.print(f"[red]✗[/red] Trace not found: {trace_id}")
|
|
531
|
+
else:
|
|
532
|
+
console.print(f"[red]✗[/red] HTTP error: {e.response.status_code}")
|
|
533
|
+
sys.exit(1)
|
|
534
|
+
except httpx.RequestError as e:
|
|
535
|
+
console.print(f"[red]✗[/red] Request failed: {e}")
|
|
536
|
+
sys.exit(1)
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
@telemetry.command("metrics")
|
|
540
|
+
@click.option("--endpoint", help="Filter by endpoint name")
|
|
541
|
+
@click.option("--window", type=int, help="Time window in hours (1-24)")
|
|
542
|
+
@click.pass_context
|
|
543
|
+
def get_metrics(ctx: click.Context, endpoint: str | None, window: int | None) -> None:
|
|
544
|
+
"""Get aggregated performance metrics."""
|
|
545
|
+
base_url = ctx.obj["base_url"]
|
|
546
|
+
token = ctx.obj.get("token")
|
|
547
|
+
params: dict[str, str | int] = {}
|
|
548
|
+
|
|
549
|
+
if endpoint:
|
|
550
|
+
params["endpoint_name"] = endpoint
|
|
551
|
+
if window:
|
|
552
|
+
params["window_hours"] = window
|
|
553
|
+
|
|
554
|
+
try:
|
|
555
|
+
response = httpx.get(
|
|
556
|
+
f"{base_url}/api/v1/telemetry/metrics",
|
|
557
|
+
params=params,
|
|
558
|
+
headers=get_headers(token),
|
|
559
|
+
timeout=10.0,
|
|
560
|
+
)
|
|
561
|
+
response.raise_for_status()
|
|
562
|
+
result = response.json()
|
|
563
|
+
data = result.get("data", {})
|
|
564
|
+
metrics = data.get("metrics", [])
|
|
565
|
+
|
|
566
|
+
if not metrics:
|
|
567
|
+
console.print("No metrics found")
|
|
568
|
+
return
|
|
569
|
+
|
|
570
|
+
window_str = f"{window}h" if window else "all time"
|
|
571
|
+
console.print(f"\n[cyan]Performance Metrics[/cyan] ({window_str})")
|
|
572
|
+
|
|
573
|
+
for metric in metrics:
|
|
574
|
+
endpoint_name = metric.get("endpoint_name", "N/A")
|
|
575
|
+
requests = metric.get("request_count", 0)
|
|
576
|
+
errors = metric.get("error_count", 0)
|
|
577
|
+
error_rate = metric.get("error_rate", 0) * 100
|
|
578
|
+
|
|
579
|
+
console.print(f"\n [yellow]{endpoint_name}[/yellow]")
|
|
580
|
+
console.print(f" Requests: {requests}")
|
|
581
|
+
console.print(f" Errors: {errors} ({error_rate:.1f}%)")
|
|
582
|
+
|
|
583
|
+
if metric.get("p50_ms") is not None:
|
|
584
|
+
console.print(" Latency:")
|
|
585
|
+
console.print(f" P50: {metric['p50_ms']:.2f}ms")
|
|
586
|
+
console.print(f" P95: {metric.get('p95_ms', 0):.2f}ms")
|
|
587
|
+
console.print(f" P99: {metric.get('p99_ms', 0):.2f}ms")
|
|
588
|
+
console.print(f" Avg: {metric.get('avg_ms', 0):.2f}ms")
|
|
589
|
+
|
|
590
|
+
except httpx.RequestError as e:
|
|
591
|
+
console.print(f"[red]✗[/red] Request failed: {e}")
|
|
592
|
+
sys.exit(1)
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
if __name__ == "__main__":
|
|
596
|
+
main()
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: mxcpctl
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: CLI tool for managing MXCP instances via mxcpd
|
|
5
|
+
Requires-Python: >=3.11
|
|
6
|
+
Requires-Dist: click>=8.1.0
|
|
7
|
+
Requires-Dist: httpx>=0.25.0
|
|
8
|
+
Requires-Dist: rich>=13.0.0
|
|
9
|
+
Requires-Dist: pyyaml>=6.0
|
|
10
|
+
Provides-Extra: dev
|
|
11
|
+
Requires-Dist: pytest>=7.4.0; extra == "dev"
|
|
12
|
+
Requires-Dist: black>=23.0.0; extra == "dev"
|
|
13
|
+
Requires-Dist: ruff>=0.1.0; extra == "dev"
|
|
14
|
+
Requires-Dist: mypy>=1.6.0; extra == "dev"
|
|
15
|
+
Requires-Dist: build>=1.0.0; extra == "dev"
|
|
16
|
+
Requires-Dist: twine>=4.0.0; extra == "dev"
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
mxcpctl/__init__.py,sha256=tQqWLRLxhYv38rMhgMkvWqLgUmStzsn438kxHNlaQ0A,78
|
|
2
|
+
mxcpctl/cli.py,sha256=4SH8D6U08u-oB_TdLjVU0J00rF3o4fHRIAgOy6J4IMo,21687
|
|
3
|
+
mxcpctl-0.0.1.dist-info/METADATA,sha256=MwRAKlzq_uxD41RkhtVaN21YRtYY2eUeV7pSsjzRsUk,527
|
|
4
|
+
mxcpctl-0.0.1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
5
|
+
mxcpctl-0.0.1.dist-info/entry_points.txt,sha256=5ITlu28KJS5YWhg0F-iv9lOuh-EIxYDx5YaYI_vWqAA,45
|
|
6
|
+
mxcpctl-0.0.1.dist-info/top_level.txt,sha256=mcV_HZ-XvcgShz4YMvskJBPppB8vxmvthbijikw3YRw,8
|
|
7
|
+
mxcpctl-0.0.1.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
mxcpctl
|