netdata-cli 0.1.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.
- netdata_cli/__init__.py +3 -0
- netdata_cli/cli.py +460 -0
- netdata_cli/client.py +223 -0
- netdata_cli/formatters.py +331 -0
- netdata_cli-0.1.0.dist-info/METADATA +223 -0
- netdata_cli-0.1.0.dist-info/RECORD +10 -0
- netdata_cli-0.1.0.dist-info/WHEEL +5 -0
- netdata_cli-0.1.0.dist-info/entry_points.txt +2 -0
- netdata_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- netdata_cli-0.1.0.dist-info/top_level.txt +1 -0
netdata_cli/__init__.py
ADDED
netdata_cli/cli.py
ADDED
|
@@ -0,0 +1,460 @@
|
|
|
1
|
+
"""netdata-cli — CLI entry point."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
|
|
9
|
+
from . import __version__
|
|
10
|
+
from .client import NetdataClient, NetdataError
|
|
11
|
+
from .formatters import (
|
|
12
|
+
HAS_RICH,
|
|
13
|
+
console,
|
|
14
|
+
print_alarm_log,
|
|
15
|
+
print_alarms,
|
|
16
|
+
print_chart_detail,
|
|
17
|
+
print_charts,
|
|
18
|
+
print_data,
|
|
19
|
+
print_function,
|
|
20
|
+
print_info,
|
|
21
|
+
print_json,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _client(url: str, timeout: int) -> NetdataClient:
|
|
26
|
+
return NetdataClient(base_url=url, timeout=timeout)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _handle_error(exc: NetdataError) -> None:
|
|
30
|
+
msg = f"Error: {exc}"
|
|
31
|
+
if console:
|
|
32
|
+
console.print(f"[bold red]{msg}[/]")
|
|
33
|
+
else:
|
|
34
|
+
print(msg, file=sys.stderr)
|
|
35
|
+
sys.exit(1)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# ------------------------------------------------------------------
|
|
39
|
+
# Root group
|
|
40
|
+
# ------------------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@click.group()
|
|
44
|
+
@click.version_option(version=__version__, prog_name="netdata-cli")
|
|
45
|
+
@click.option(
|
|
46
|
+
"--url",
|
|
47
|
+
"-u",
|
|
48
|
+
envvar="NETDATA_URL",
|
|
49
|
+
default="http://localhost:19999",
|
|
50
|
+
show_default=True,
|
|
51
|
+
help="Netdata agent URL.",
|
|
52
|
+
)
|
|
53
|
+
@click.option("--timeout", "-t", default=10, show_default=True, help="Request timeout in seconds.")
|
|
54
|
+
@click.option("--json", "-j", "as_json", is_flag=True, help="Output raw JSON.")
|
|
55
|
+
@click.pass_context
|
|
56
|
+
def cli(ctx: click.Context, url: str, timeout: int, as_json: bool) -> None:
|
|
57
|
+
"""netdata-cli — A powerful CLI for the Netdata Agent REST API.
|
|
58
|
+
|
|
59
|
+
Query metrics, charts, alarms, functions, and agent info from the
|
|
60
|
+
command line. Supports rich tables, JSON output, and time filtering.
|
|
61
|
+
|
|
62
|
+
Set NETDATA_URL env var to change the default agent address.
|
|
63
|
+
"""
|
|
64
|
+
ctx.ensure_object(dict)
|
|
65
|
+
ctx.obj["client"] = _client(url, timeout)
|
|
66
|
+
ctx.obj["as_json"] = as_json
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# ------------------------------------------------------------------
|
|
70
|
+
# info
|
|
71
|
+
# ------------------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@cli.command()
|
|
75
|
+
@click.pass_context
|
|
76
|
+
def info(ctx: click.Context) -> None:
|
|
77
|
+
"""Show Netdata agent info (version, OS, charts, alarms, collectors)."""
|
|
78
|
+
try:
|
|
79
|
+
data = ctx.obj["client"].info()
|
|
80
|
+
print_info(data, as_json=ctx.obj["as_json"])
|
|
81
|
+
except NetdataError as exc:
|
|
82
|
+
_handle_error(exc)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
# ------------------------------------------------------------------
|
|
86
|
+
# ping
|
|
87
|
+
# ------------------------------------------------------------------
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@cli.command()
|
|
91
|
+
@click.pass_context
|
|
92
|
+
def ping(ctx: click.Context) -> None:
|
|
93
|
+
"""Check if the Netdata agent is reachable."""
|
|
94
|
+
try:
|
|
95
|
+
ok = ctx.obj["client"].ping()
|
|
96
|
+
if ok:
|
|
97
|
+
msg = "pong ✓"
|
|
98
|
+
if console:
|
|
99
|
+
console.print(f"[bold green]{msg}[/]")
|
|
100
|
+
else:
|
|
101
|
+
print(msg)
|
|
102
|
+
else:
|
|
103
|
+
msg = "Agent not responding"
|
|
104
|
+
if console:
|
|
105
|
+
console.print(f"[bold red]{msg}[/]")
|
|
106
|
+
else:
|
|
107
|
+
print(msg)
|
|
108
|
+
sys.exit(1)
|
|
109
|
+
except NetdataError as exc:
|
|
110
|
+
_handle_error(exc)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
# ------------------------------------------------------------------
|
|
114
|
+
# charts
|
|
115
|
+
# ------------------------------------------------------------------
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@cli.command()
|
|
119
|
+
@click.argument("query", required=False)
|
|
120
|
+
@click.option("--type", "-T", "chart_type", help="Filter by chart type (e.g. system, disk, cgroup).")
|
|
121
|
+
@click.option("--limit", "-n", default=0, help="Max charts to show (0 = all).")
|
|
122
|
+
@click.pass_context
|
|
123
|
+
def charts(ctx: click.Context, query: str | None, chart_type: str | None, limit: int) -> None:
|
|
124
|
+
"""List or search charts.
|
|
125
|
+
|
|
126
|
+
With no arguments, lists all charts. With QUERY, searches chart
|
|
127
|
+
id, title, type, family, units, and dimension names.
|
|
128
|
+
"""
|
|
129
|
+
try:
|
|
130
|
+
raw = ctx.obj["client"].charts()
|
|
131
|
+
items = raw.get("charts", {})
|
|
132
|
+
|
|
133
|
+
results = []
|
|
134
|
+
for chart_id, info in items.items():
|
|
135
|
+
entry = {"id": chart_id, **info}
|
|
136
|
+
|
|
137
|
+
# type filter
|
|
138
|
+
if chart_type and info.get("type", "") != chart_type:
|
|
139
|
+
continue
|
|
140
|
+
|
|
141
|
+
# text search
|
|
142
|
+
if query:
|
|
143
|
+
q = query.lower()
|
|
144
|
+
searchable = " ".join(
|
|
145
|
+
[
|
|
146
|
+
chart_id,
|
|
147
|
+
info.get("name", ""),
|
|
148
|
+
info.get("type", ""),
|
|
149
|
+
info.get("family", ""),
|
|
150
|
+
info.get("title", ""),
|
|
151
|
+
info.get("units", ""),
|
|
152
|
+
info.get("plugin", ""),
|
|
153
|
+
info.get("module", ""),
|
|
154
|
+
" ".join(info.get("dimensions", {}).keys()),
|
|
155
|
+
]
|
|
156
|
+
).lower()
|
|
157
|
+
if q not in searchable:
|
|
158
|
+
continue
|
|
159
|
+
|
|
160
|
+
results.append(entry)
|
|
161
|
+
|
|
162
|
+
if limit > 0:
|
|
163
|
+
results = results[:limit]
|
|
164
|
+
|
|
165
|
+
print_charts(results, as_json=ctx.obj["as_json"])
|
|
166
|
+
except NetdataError as exc:
|
|
167
|
+
_handle_error(exc)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
# ------------------------------------------------------------------
|
|
171
|
+
# chart (detail)
|
|
172
|
+
# ------------------------------------------------------------------
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
@cli.command()
|
|
176
|
+
@click.argument("chart_id")
|
|
177
|
+
@click.pass_context
|
|
178
|
+
def chart(ctx: click.Context, chart_id: str) -> None:
|
|
179
|
+
"""Show details for a specific chart (dimensions, metadata)."""
|
|
180
|
+
try:
|
|
181
|
+
data = ctx.obj["client"].chart(chart_id)
|
|
182
|
+
print_chart_detail(data, as_json=ctx.obj["as_json"])
|
|
183
|
+
except NetdataError as exc:
|
|
184
|
+
_handle_error(exc)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
# ------------------------------------------------------------------
|
|
188
|
+
# data
|
|
189
|
+
# ------------------------------------------------------------------
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
@cli.command()
|
|
193
|
+
@click.argument("chart_id")
|
|
194
|
+
@click.option("--after", "-a", default=-60, show_default=True, help="Start time (seconds from now, negative).")
|
|
195
|
+
@click.option("--before", "-b", default=0, show_default=True, help="End time (seconds from now, 0=now).")
|
|
196
|
+
@click.option("--points", "-p", default=60, show_default=True, help="Number of data points.")
|
|
197
|
+
@click.option("--group", "-g", default="average", show_default=True, help="Grouping: average, min, max, sum, incremental.")
|
|
198
|
+
@click.option("--options", "-o", default="", help="Extra options: seconds, jsonwrap, flip, percent.")
|
|
199
|
+
@click.pass_context
|
|
200
|
+
def data(
|
|
201
|
+
ctx: click.Context,
|
|
202
|
+
chart_id: str,
|
|
203
|
+
after: int,
|
|
204
|
+
before: int,
|
|
205
|
+
points: int,
|
|
206
|
+
group: str,
|
|
207
|
+
options: str,
|
|
208
|
+
) -> None:
|
|
209
|
+
"""Fetch historical data for a chart.
|
|
210
|
+
|
|
211
|
+
\b
|
|
212
|
+
Examples:
|
|
213
|
+
nd data system.cpu --after -300 --points 10
|
|
214
|
+
nd data disk.util.sda --after -3600 --group max
|
|
215
|
+
nd data mem.available --after -86400 --points 1440
|
|
216
|
+
"""
|
|
217
|
+
try:
|
|
218
|
+
result = ctx.obj["client"].data(
|
|
219
|
+
chart_id=chart_id,
|
|
220
|
+
after=after,
|
|
221
|
+
before=before,
|
|
222
|
+
points=points,
|
|
223
|
+
group=group,
|
|
224
|
+
options=options,
|
|
225
|
+
)
|
|
226
|
+
print_data(result, as_json=ctx.obj["as_json"])
|
|
227
|
+
except NetdataError as exc:
|
|
228
|
+
_handle_error(exc)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
# ------------------------------------------------------------------
|
|
232
|
+
# alarms
|
|
233
|
+
# ------------------------------------------------------------------
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
@cli.command()
|
|
237
|
+
@click.option("--all", "-a", "show_all", is_flag=True, help="Include inactive alarms.")
|
|
238
|
+
@click.option("--active-only", is_flag=True, help="Show only WARNING/CRITICAL alarms.")
|
|
239
|
+
@click.pass_context
|
|
240
|
+
def alarms(ctx: click.Context, show_all: bool, active_only: bool) -> None:
|
|
241
|
+
"""List alarms (health checks).
|
|
242
|
+
|
|
243
|
+
\b
|
|
244
|
+
Examples:
|
|
245
|
+
nd alarms # active alarms
|
|
246
|
+
nd alarms --all # all including inactive
|
|
247
|
+
nd alarms --active-only # only warn/critical
|
|
248
|
+
"""
|
|
249
|
+
try:
|
|
250
|
+
data = ctx.obj["client"].alarms(all=show_all)
|
|
251
|
+
alarm_list = []
|
|
252
|
+
for alarm_id, info in data.get("alarms", {}).items():
|
|
253
|
+
alarm_list.append({"id": alarm_id, **info})
|
|
254
|
+
|
|
255
|
+
if active_only:
|
|
256
|
+
alarm_list = [
|
|
257
|
+
a for a in alarm_list
|
|
258
|
+
if a.get("status", "").upper() in ("WARNING", "CRITICAL")
|
|
259
|
+
]
|
|
260
|
+
|
|
261
|
+
# sort by status severity
|
|
262
|
+
severity_order = {"CRITICAL": 0, "WARNING": 1, "UNDEFINED": 2, "CLEAR": 3, "REMOVED": 4}
|
|
263
|
+
alarm_list.sort(key=lambda a: severity_order.get(a.get("status", "").upper(), 5))
|
|
264
|
+
|
|
265
|
+
print_alarms(alarm_list, as_json=ctx.obj["as_json"])
|
|
266
|
+
except NetdataError as exc:
|
|
267
|
+
_handle_error(exc)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
# ------------------------------------------------------------------
|
|
271
|
+
# alarm-log
|
|
272
|
+
# ------------------------------------------------------------------
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
@cli.command("alarm-log")
|
|
276
|
+
@click.option("--after", "-a", default=0, show_default=True, help="Last N seconds (0 = all).")
|
|
277
|
+
@click.option("--unique", "-u", is_flag=True, help="One entry per alarm (latest status).")
|
|
278
|
+
@click.pass_context
|
|
279
|
+
def alarm_log(ctx: click.Context, after: int, unique: bool) -> None:
|
|
280
|
+
"""Show alarm history log.
|
|
281
|
+
|
|
282
|
+
\b
|
|
283
|
+
Examples:
|
|
284
|
+
nd alarm-log # all history
|
|
285
|
+
nd alarm-log --after 3600 # last hour
|
|
286
|
+
nd alarm-log --after 86400 --unique # last 24h, latest per alarm
|
|
287
|
+
"""
|
|
288
|
+
try:
|
|
289
|
+
c = ctx.obj["client"]
|
|
290
|
+
if unique:
|
|
291
|
+
entries = c.alarm_log_unique(after=after if after else 0)
|
|
292
|
+
else:
|
|
293
|
+
entries = c.alarm_log(after=after if after else 0)
|
|
294
|
+
|
|
295
|
+
# Normalize — API returns dict with 'alarms' key or a list
|
|
296
|
+
if isinstance(entries, dict):
|
|
297
|
+
entries = entries.get("alarms", [])
|
|
298
|
+
if isinstance(entries, list):
|
|
299
|
+
entries.sort(key=lambda e: e.get("when", 0), reverse=True)
|
|
300
|
+
|
|
301
|
+
print_alarm_log(entries, as_json=ctx.obj["as_json"])
|
|
302
|
+
except NetdataError as exc:
|
|
303
|
+
_handle_error(exc)
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
# ------------------------------------------------------------------
|
|
307
|
+
# allmetrics
|
|
308
|
+
# ------------------------------------------------------------------
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
@cli.command()
|
|
312
|
+
@click.option("--format", "-f", "fmt", default="json", type=click.Choice(["json", "shell", "prometheus"]), show_default=True)
|
|
313
|
+
@click.pass_context
|
|
314
|
+
def allmetrics(ctx: click.Context, fmt: str) -> None:
|
|
315
|
+
"""Snapshot of every metric in one call.
|
|
316
|
+
|
|
317
|
+
Use --format prometheus for Prometheus-compatible output.
|
|
318
|
+
"""
|
|
319
|
+
try:
|
|
320
|
+
data = ctx.obj["client"].allmetrics(format=fmt)
|
|
321
|
+
if fmt == "json":
|
|
322
|
+
if isinstance(data, dict):
|
|
323
|
+
count = len(data)
|
|
324
|
+
print(f"Metrics families: {count}")
|
|
325
|
+
if ctx.obj["as_json"]:
|
|
326
|
+
print_json(data)
|
|
327
|
+
else:
|
|
328
|
+
# Show summary
|
|
329
|
+
for name in sorted(data.keys())[:30]:
|
|
330
|
+
entry = data[name]
|
|
331
|
+
dims = entry.get("dimensions", {})
|
|
332
|
+
print(f" {name}: {len(dims)} dims = {list(dims.values())[:3]}...")
|
|
333
|
+
if count > 30:
|
|
334
|
+
print(f" ... and {count - 30} more")
|
|
335
|
+
else:
|
|
336
|
+
print_json(data)
|
|
337
|
+
else:
|
|
338
|
+
print(data)
|
|
339
|
+
except NetdataError as exc:
|
|
340
|
+
_handle_error(exc)
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
# ------------------------------------------------------------------
|
|
344
|
+
# function
|
|
345
|
+
# ------------------------------------------------------------------
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
@cli.command("function")
|
|
349
|
+
@click.argument("name")
|
|
350
|
+
@click.option("--after", "-a", default=0, help="Start time (seconds from now, negative) or 0.")
|
|
351
|
+
@click.option("--before", "-b", default=0, help="End time or 0 for now.")
|
|
352
|
+
@click.option("--points", "-p", default=0, help="Number of data points (0 = server default).")
|
|
353
|
+
@click.pass_context
|
|
354
|
+
def function_cmd(ctx: click.Context, name: str, after: int, before: int, points: int) -> None:
|
|
355
|
+
"""Call a Netdata function (systemd-journal, docker:container-ls, etc).
|
|
356
|
+
|
|
357
|
+
\b
|
|
358
|
+
Examples:
|
|
359
|
+
nd function docker:container-ls
|
|
360
|
+
nd function systemd-journal --after -3600
|
|
361
|
+
nd function mount-points
|
|
362
|
+
nd function network-interfaces
|
|
363
|
+
"""
|
|
364
|
+
try:
|
|
365
|
+
data = ctx.obj["client"].function(
|
|
366
|
+
function_name=name,
|
|
367
|
+
after=after,
|
|
368
|
+
before=before,
|
|
369
|
+
points=points,
|
|
370
|
+
)
|
|
371
|
+
print_function(data, as_json=ctx.obj["as_json"])
|
|
372
|
+
except NetdataError as exc:
|
|
373
|
+
_handle_error(exc)
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
# ------------------------------------------------------------------
|
|
377
|
+
# functions (list available)
|
|
378
|
+
# ------------------------------------------------------------------
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
@cli.command("functions")
|
|
382
|
+
@click.pass_context
|
|
383
|
+
def functions_list(ctx: click.Context) -> None:
|
|
384
|
+
"""List available Netdata functions."""
|
|
385
|
+
try:
|
|
386
|
+
data = ctx.obj["client"].info()
|
|
387
|
+
funcs = data.get("functions", {})
|
|
388
|
+
|
|
389
|
+
if ctx.obj["as_json"]:
|
|
390
|
+
print_json(funcs)
|
|
391
|
+
return
|
|
392
|
+
|
|
393
|
+
if HAS_RICH:
|
|
394
|
+
from rich.table import Table
|
|
395
|
+
|
|
396
|
+
t = Table(title=f"Functions ({len(funcs)})")
|
|
397
|
+
t.add_column("Name", style="cyan")
|
|
398
|
+
t.add_column("Help", max_width=60)
|
|
399
|
+
t.add_column("Tags", style="dim")
|
|
400
|
+
t.add_column("Timeout", justify="right")
|
|
401
|
+
for name, info in funcs.items():
|
|
402
|
+
t.add_row(
|
|
403
|
+
name,
|
|
404
|
+
info.get("help", "")[:60],
|
|
405
|
+
", ".join(info.get("tags", [])) if isinstance(info.get("tags"), list) else str(info.get("tags", "")),
|
|
406
|
+
str(info.get("timeout", "")),
|
|
407
|
+
)
|
|
408
|
+
console.print(t)
|
|
409
|
+
else:
|
|
410
|
+
for name, info in funcs.items():
|
|
411
|
+
print(f" {name}: {info.get('help', '')[:80]}")
|
|
412
|
+
except NetdataError as exc:
|
|
413
|
+
_handle_error(exc)
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
# ------------------------------------------------------------------
|
|
417
|
+
# collectors
|
|
418
|
+
# ------------------------------------------------------------------
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
@cli.command()
|
|
422
|
+
@click.pass_context
|
|
423
|
+
def collectors(ctx: click.Context) -> None:
|
|
424
|
+
"""List active data collectors."""
|
|
425
|
+
try:
|
|
426
|
+
data = ctx.obj["client"].info()
|
|
427
|
+
colls = data.get("collectors", [])
|
|
428
|
+
|
|
429
|
+
if ctx.obj["as_json"]:
|
|
430
|
+
print_json(colls)
|
|
431
|
+
return
|
|
432
|
+
|
|
433
|
+
if HAS_RICH:
|
|
434
|
+
from rich.table import Table
|
|
435
|
+
|
|
436
|
+
t = Table(title=f"Collectors ({len(colls)})")
|
|
437
|
+
t.add_column("Plugin", style="cyan")
|
|
438
|
+
t.add_column("Module")
|
|
439
|
+
for c in colls:
|
|
440
|
+
t.add_row(c.get("plugin", "?"), c.get("module", "—"))
|
|
441
|
+
console.print(t)
|
|
442
|
+
else:
|
|
443
|
+
for c in colls:
|
|
444
|
+
mod = c.get("module", "")
|
|
445
|
+
suffix = f" / {mod}" if mod else ""
|
|
446
|
+
print(f" {c.get('plugin', '?')}{suffix}")
|
|
447
|
+
except NetdataError as exc:
|
|
448
|
+
_handle_error(exc)
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
# ------------------------------------------------------------------
|
|
452
|
+
# Top-level entry
|
|
453
|
+
# ------------------------------------------------------------------
|
|
454
|
+
|
|
455
|
+
def main() -> None:
|
|
456
|
+
cli()
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
if __name__ == "__main__":
|
|
460
|
+
main()
|
netdata_cli/client.py
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
"""Netdata REST API client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import requests
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class NetdataError(Exception):
|
|
12
|
+
"""Error communicating with Netdata."""
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class NetdataClient:
|
|
16
|
+
"""Thin wrapper around the Netdata REST API (v1)."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, base_url: str = "http://localhost:19999", timeout: int = 10):
|
|
19
|
+
self.base_url = base_url.rstrip("/")
|
|
20
|
+
self.timeout = timeout
|
|
21
|
+
self._session = requests.Session()
|
|
22
|
+
self._session.headers.update({"Accept": "application/json"})
|
|
23
|
+
|
|
24
|
+
# ------------------------------------------------------------------
|
|
25
|
+
# Low-level
|
|
26
|
+
# ------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
def _get(self, path: str, params: dict | None = None, raw: bool = False) -> Any:
|
|
29
|
+
url = f"{self.base_url}{path}"
|
|
30
|
+
try:
|
|
31
|
+
resp = self._session.get(url, params=params, timeout=self.timeout)
|
|
32
|
+
resp.raise_for_status()
|
|
33
|
+
if raw:
|
|
34
|
+
return resp.text
|
|
35
|
+
if not resp.content:
|
|
36
|
+
return [] if "alarm_log" in path else {}
|
|
37
|
+
return resp.json()
|
|
38
|
+
except requests.ConnectionError:
|
|
39
|
+
raise NetdataError(
|
|
40
|
+
f"Cannot connect to Netdata at {self.base_url}. "
|
|
41
|
+
"Is the agent running?"
|
|
42
|
+
)
|
|
43
|
+
except requests.Timeout:
|
|
44
|
+
raise NetdataError(f"Request to {url} timed out after {self.timeout}s")
|
|
45
|
+
except (requests.HTTPError, requests.exceptions.ChunkedEncodingError) as exc:
|
|
46
|
+
raise NetdataError(f"HTTP error from {url}: {exc}")
|
|
47
|
+
except ValueError:
|
|
48
|
+
raise NetdataError(f"Invalid JSON response from {url}")
|
|
49
|
+
|
|
50
|
+
# ------------------------------------------------------------------
|
|
51
|
+
# Agent info
|
|
52
|
+
# ------------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
def info(self) -> dict:
|
|
55
|
+
"""Return /api/v1/info — agent version, OS, collectors, etc."""
|
|
56
|
+
return self._get("/api/v1/info")
|
|
57
|
+
|
|
58
|
+
# ------------------------------------------------------------------
|
|
59
|
+
# Charts
|
|
60
|
+
# ------------------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
def charts(self) -> dict:
|
|
63
|
+
"""Return all charts. Response['charts'] is a dict keyed by chart id."""
|
|
64
|
+
return self._get("/api/v1/charts")
|
|
65
|
+
|
|
66
|
+
def chart(self, chart_id: str) -> dict:
|
|
67
|
+
"""Return details for a single chart."""
|
|
68
|
+
return self._get("/api/v1/chart", params={"chart": chart_id})
|
|
69
|
+
|
|
70
|
+
# ------------------------------------------------------------------
|
|
71
|
+
# Data (historical)
|
|
72
|
+
# ------------------------------------------------------------------
|
|
73
|
+
|
|
74
|
+
def data(
|
|
75
|
+
self,
|
|
76
|
+
chart_id: str,
|
|
77
|
+
after: int = -60,
|
|
78
|
+
before: int = 0,
|
|
79
|
+
points: int = 60,
|
|
80
|
+
group: str = "average",
|
|
81
|
+
format: str = "json",
|
|
82
|
+
options: str = "",
|
|
83
|
+
) -> dict:
|
|
84
|
+
"""
|
|
85
|
+
Fetch data points for *chart_id*.
|
|
86
|
+
|
|
87
|
+
Parameters
|
|
88
|
+
----------
|
|
89
|
+
after : int
|
|
90
|
+
Seconds relative to now (negative) or unix timestamp.
|
|
91
|
+
before : int
|
|
92
|
+
Seconds relative to now (negative) or unix timestamp. 0 = now.
|
|
93
|
+
points : int
|
|
94
|
+
Number of data points to return.
|
|
95
|
+
group : str
|
|
96
|
+
Grouping method: average, min, max, sum, incremental, etc.
|
|
97
|
+
format : str
|
|
98
|
+
json | csv | tsv | ssv |Cumhurbaşkan
|
|
99
|
+
options : str
|
|
100
|
+
Comma-separated: seconds, jsonwrap, flip, min2max, percent, etc.
|
|
101
|
+
"""
|
|
102
|
+
params: dict[str, Any] = {
|
|
103
|
+
"chart": chart_id,
|
|
104
|
+
"after": after,
|
|
105
|
+
"before": before,
|
|
106
|
+
"points": points,
|
|
107
|
+
"group": group,
|
|
108
|
+
"format": format,
|
|
109
|
+
}
|
|
110
|
+
if options:
|
|
111
|
+
params["options"] = options
|
|
112
|
+
return self._get("/api/v1/data", params=params)
|
|
113
|
+
|
|
114
|
+
# ------------------------------------------------------------------
|
|
115
|
+
# All metrics (snapshot)
|
|
116
|
+
# ------------------------------------------------------------------
|
|
117
|
+
|
|
118
|
+
def allmetrics(self, format: str = "json") -> dict | str:
|
|
119
|
+
"""Return a snapshot of every metric. format: json | shell | prometheus."""
|
|
120
|
+
if format == "json":
|
|
121
|
+
return self._get("/api/v1/allmetrics")
|
|
122
|
+
# shell/prometheus return plain text
|
|
123
|
+
return self._get(
|
|
124
|
+
"/api/v1/allmetrics",
|
|
125
|
+
params={"format": format, "help": "yes"},
|
|
126
|
+
raw=True,
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
# ------------------------------------------------------------------
|
|
130
|
+
# Alarms
|
|
131
|
+
# ------------------------------------------------------------------
|
|
132
|
+
|
|
133
|
+
def alarms(self, all: bool = False) -> dict:
|
|
134
|
+
"""Active alarms, or all (including inactive) if *all* is True."""
|
|
135
|
+
path = "/api/v1/alarms"
|
|
136
|
+
params = {"all": "yes"} if all else None
|
|
137
|
+
return self._get(path, params=params)
|
|
138
|
+
|
|
139
|
+
def alarm_log(self, after: int = 0) -> list:
|
|
140
|
+
"""Alarm log entries. *after*: 0 = all, negative = last N seconds."""
|
|
141
|
+
params = {}
|
|
142
|
+
if after:
|
|
143
|
+
params["after"] = after
|
|
144
|
+
try:
|
|
145
|
+
return self._get("/api/v1/alarm_log", params=params)
|
|
146
|
+
except NetdataError:
|
|
147
|
+
# Netdata returns empty chunked response when alarm log is empty
|
|
148
|
+
return []
|
|
149
|
+
|
|
150
|
+
def alarm_log_unique(self, after: int = 0) -> list:
|
|
151
|
+
"""Alarm log, one entry per unique alarm (latest status)."""
|
|
152
|
+
params = {"all": "yes"}
|
|
153
|
+
if after:
|
|
154
|
+
params["after"] = after
|
|
155
|
+
try:
|
|
156
|
+
return self._get("/api/v1/alarm_log", params=params)
|
|
157
|
+
except NetdataError:
|
|
158
|
+
return []
|
|
159
|
+
|
|
160
|
+
# ------------------------------------------------------------------
|
|
161
|
+
# Functions (systemd-journal, docker:container-ls, etc.)
|
|
162
|
+
# ------------------------------------------------------------------
|
|
163
|
+
|
|
164
|
+
def function(
|
|
165
|
+
self,
|
|
166
|
+
function_name: str,
|
|
167
|
+
context: str = "",
|
|
168
|
+
after: int = 0,
|
|
169
|
+
before: int = 0,
|
|
170
|
+
points: int = 0,
|
|
171
|
+
format: str = "json",
|
|
172
|
+
options: str = "",
|
|
173
|
+
) -> Any:
|
|
174
|
+
"""Call a Netdata function (e.g. systemd-journal, docker:container-ls)."""
|
|
175
|
+
params: dict[str, Any] = {"function": function_name}
|
|
176
|
+
if context:
|
|
177
|
+
params["context"] = context
|
|
178
|
+
if after:
|
|
179
|
+
params["after"] = after
|
|
180
|
+
if before:
|
|
181
|
+
params["before"] = before
|
|
182
|
+
if points:
|
|
183
|
+
params["points"] = points
|
|
184
|
+
if format:
|
|
185
|
+
params["format"] = format
|
|
186
|
+
if options:
|
|
187
|
+
params["options"] = options
|
|
188
|
+
return self._get("/api/v1/function", params=params)
|
|
189
|
+
|
|
190
|
+
# ------------------------------------------------------------------
|
|
191
|
+
# Helpers
|
|
192
|
+
# ------------------------------------------------------------------
|
|
193
|
+
|
|
194
|
+
def search_charts(self, query: str) -> list[dict]:
|
|
195
|
+
"""Search charts by id, name, type, family, title, or units."""
|
|
196
|
+
q = query.lower()
|
|
197
|
+
raw = self.charts()
|
|
198
|
+
results = []
|
|
199
|
+
for chart_id, info in raw.get("charts", {}).items():
|
|
200
|
+
searchable = " ".join(
|
|
201
|
+
[
|
|
202
|
+
chart_id,
|
|
203
|
+
info.get("name", ""),
|
|
204
|
+
info.get("type", ""),
|
|
205
|
+
info.get("family", ""),
|
|
206
|
+
info.get("title", ""),
|
|
207
|
+
info.get("units", ""),
|
|
208
|
+
info.get("plugin", ""),
|
|
209
|
+
info.get("module", ""),
|
|
210
|
+
" ".join(info.get("dimensions", {}).keys()),
|
|
211
|
+
]
|
|
212
|
+
).lower()
|
|
213
|
+
if q in searchable:
|
|
214
|
+
results.append({"id": chart_id, **info})
|
|
215
|
+
return results
|
|
216
|
+
|
|
217
|
+
def ping(self) -> bool:
|
|
218
|
+
"""Return True if the agent is reachable and ready."""
|
|
219
|
+
try:
|
|
220
|
+
data = self._get("/api/v1/info")
|
|
221
|
+
return bool(data.get("version"))
|
|
222
|
+
except NetdataError:
|
|
223
|
+
return False
|
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
"""Output formatters for netdata-cli."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
try:
|
|
11
|
+
from rich.console import Console
|
|
12
|
+
from rich.table import Table
|
|
13
|
+
from rich.text import Text
|
|
14
|
+
|
|
15
|
+
HAS_RICH = True
|
|
16
|
+
except ImportError:
|
|
17
|
+
HAS_RICH = False
|
|
18
|
+
|
|
19
|
+
console = Console() if HAS_RICH else None
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# ------------------------------------------------------------------
|
|
23
|
+
# Generic helpers
|
|
24
|
+
# ------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def print_json(data: Any, pretty: bool = True) -> None:
|
|
28
|
+
"""Print data as JSON."""
|
|
29
|
+
kwargs = {"indent": 2, "default": str} if pretty else {"default": str}
|
|
30
|
+
print(json.dumps(data, **kwargs))
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _human_bytes(n: float | int | None) -> str:
|
|
34
|
+
if n is None:
|
|
35
|
+
return "—"
|
|
36
|
+
for unit in ("B", "KiB", "MiB", "GiB", "TiB"):
|
|
37
|
+
if abs(n) < 1024:
|
|
38
|
+
return f"{n:.1f} {unit}"
|
|
39
|
+
n /= 1024
|
|
40
|
+
return f"{n:.1f} PiB"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _human_duration(seconds: float | int | None) -> str:
|
|
44
|
+
if seconds is None:
|
|
45
|
+
return "—"
|
|
46
|
+
if seconds < 60:
|
|
47
|
+
return f"{seconds:.0f}s"
|
|
48
|
+
if seconds < 3600:
|
|
49
|
+
return f"{seconds / 60:.0f}m"
|
|
50
|
+
if seconds < 86400:
|
|
51
|
+
return f"{seconds / 3600:.1f}h"
|
|
52
|
+
return f"{seconds / 86400:.1f}d"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _ts_to_str(ts: int | float | None) -> str:
|
|
56
|
+
if ts is None:
|
|
57
|
+
return "—"
|
|
58
|
+
return datetime.fromtimestamp(ts, tz=timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _colorize_severity(severity: str) -> str:
|
|
62
|
+
if not HAS_RICH:
|
|
63
|
+
return severity
|
|
64
|
+
mapping = {
|
|
65
|
+
"CRITICAL": "[bold red]CRITICAL[/]",
|
|
66
|
+
"WARNING": "[bold yellow]WARNING[/]",
|
|
67
|
+
"CLEAR": "[bold green]CLEAR[/]",
|
|
68
|
+
"UNDEFINED": "[dim]UNDEFINED[/]",
|
|
69
|
+
"REMOVED": "[dim]REMOVED[/]",
|
|
70
|
+
}
|
|
71
|
+
return mapping.get(severity.upper(), severity)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# ------------------------------------------------------------------
|
|
75
|
+
# Info
|
|
76
|
+
# ------------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def print_info(data: dict, as_json: bool = False) -> None:
|
|
80
|
+
if as_json:
|
|
81
|
+
print_json(data)
|
|
82
|
+
return
|
|
83
|
+
|
|
84
|
+
alarms = data.get("alarms", {})
|
|
85
|
+
rows = [
|
|
86
|
+
("Version", data.get("version", "?")),
|
|
87
|
+
("OS", data.get("container_os_name", data.get("os_name", "?"))),
|
|
88
|
+
("Kernel", f"{data.get('kernel_name', '?')} {data.get('kernel_version', '?')}"),
|
|
89
|
+
("Architecture", data.get("architecture", "?")),
|
|
90
|
+
("CPU Cores", data.get("cores_total", "?")),
|
|
91
|
+
("CPU Freq", f"{int(data.get('cpu_freq', 0)) / 1e6:.0f} MHz"),
|
|
92
|
+
("RAM", _human_bytes(int(data.get("ram_total", 0)))),
|
|
93
|
+
("Disk", _human_bytes(int(data.get("total_disk_space", 0)))),
|
|
94
|
+
("Container", data.get("container", "none")),
|
|
95
|
+
("Charts", data.get("charts-count", "?")),
|
|
96
|
+
("Metrics", data.get("metrics-count", "?")),
|
|
97
|
+
("Memory Mode", data.get("memory-mode", "?")),
|
|
98
|
+
("Alarms OK", alarms.get("normal", "?")),
|
|
99
|
+
("Alarms Warn", alarms.get("warning", "?")),
|
|
100
|
+
("Alarms Crit", alarms.get("critical", "?")),
|
|
101
|
+
("Cloud", "claimed" if data.get("agent-claimed") else "not claimed"),
|
|
102
|
+
("HTTPS", "yes" if data.get("https-enabled") else "no"),
|
|
103
|
+
("Collectors", len(data.get("collectors", []))),
|
|
104
|
+
]
|
|
105
|
+
|
|
106
|
+
if HAS_RICH:
|
|
107
|
+
t = Table(title="Netdata Agent", show_header=False, box=None, padding=(0, 2))
|
|
108
|
+
t.add_column(style="bold cyan", min_width=14)
|
|
109
|
+
t.add_column()
|
|
110
|
+
for label, value in rows:
|
|
111
|
+
t.add_row(label, str(value))
|
|
112
|
+
console.print(t)
|
|
113
|
+
else:
|
|
114
|
+
for label, value in rows:
|
|
115
|
+
print(f" {label:14s} {value}")
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
# ------------------------------------------------------------------
|
|
119
|
+
# Charts
|
|
120
|
+
# ------------------------------------------------------------------
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def print_charts(charts: list[dict], as_json: bool = False) -> None:
|
|
124
|
+
if as_json:
|
|
125
|
+
print_json(charts)
|
|
126
|
+
return
|
|
127
|
+
|
|
128
|
+
if HAS_RICH:
|
|
129
|
+
t = Table(title=f"Charts ({len(charts)})")
|
|
130
|
+
t.add_column("Chart ID", style="cyan", max_width=45)
|
|
131
|
+
t.add_column("Title", max_width=40)
|
|
132
|
+
t.add_column("Type", style="dim")
|
|
133
|
+
t.add_column("Units", style="green")
|
|
134
|
+
t.add_column("Dims", justify="right")
|
|
135
|
+
t.add_column("Family", style="dim")
|
|
136
|
+
for c in charts:
|
|
137
|
+
t.add_row(
|
|
138
|
+
c.get("id", "?"),
|
|
139
|
+
c.get("title", "")[:40],
|
|
140
|
+
c.get("type", ""),
|
|
141
|
+
c.get("units", ""),
|
|
142
|
+
str(len(c.get("dimensions", {}))),
|
|
143
|
+
c.get("family", ""),
|
|
144
|
+
)
|
|
145
|
+
console.print(t)
|
|
146
|
+
else:
|
|
147
|
+
print(f" {'Chart ID':45s} {'Title':40s} {'Type':15s} {'Units':10s} Dims")
|
|
148
|
+
print(f" {'─' * 45} {'─' * 40} {'─' * 15} {'─' * 10} ────")
|
|
149
|
+
for c in charts:
|
|
150
|
+
dims = len(c.get("dimensions", {}))
|
|
151
|
+
print(
|
|
152
|
+
f" {c.get('id',''):45s} {c.get('title','')[:40]:40s} "
|
|
153
|
+
f"{c.get('type',''):15s} {c.get('units',''):10s} {dims:>4}"
|
|
154
|
+
)
|
|
155
|
+
print(f"\n Total: {len(charts)} charts")
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def print_chart_detail(data: dict, as_json: bool = False) -> None:
|
|
159
|
+
if as_json:
|
|
160
|
+
print_json(data)
|
|
161
|
+
return
|
|
162
|
+
|
|
163
|
+
if HAS_RICH:
|
|
164
|
+
t = Table(title=data.get("id", "Chart"), show_header=False, box=None)
|
|
165
|
+
t.add_column(style="bold cyan", min_width=14)
|
|
166
|
+
t.add_column()
|
|
167
|
+
for key in ("id", "name", "type", "family", "title", "units", "plugin", "module"):
|
|
168
|
+
t.add_row(key, str(data.get(key, "—")))
|
|
169
|
+
t.add_row("dimensions", str(len(data.get("dimensions", {}))))
|
|
170
|
+
t.add_row("update_every", str(data.get("update_every", "—")))
|
|
171
|
+
t.add_row("history", str(data.get("history", "—")))
|
|
172
|
+
console.print(t)
|
|
173
|
+
|
|
174
|
+
if data.get("dimensions"):
|
|
175
|
+
dt = Table(title="Dimensions")
|
|
176
|
+
dt.add_column("ID", style="cyan")
|
|
177
|
+
dt.add_column("Name")
|
|
178
|
+
dt.add_column("Algorithm")
|
|
179
|
+
dt.add_column("Multiplier")
|
|
180
|
+
dt.add_column("Divisor")
|
|
181
|
+
for dim_id, dim_info in data["dimensions"].items():
|
|
182
|
+
dt.add_row(
|
|
183
|
+
dim_id,
|
|
184
|
+
dim_info.get("name", ""),
|
|
185
|
+
dim_info.get("algorithm", ""),
|
|
186
|
+
str(dim_info.get("multiplier", "")),
|
|
187
|
+
str(dim_info.get("divisor", "")),
|
|
188
|
+
)
|
|
189
|
+
console.print(dt)
|
|
190
|
+
else:
|
|
191
|
+
for key in ("id", "name", "type", "family", "title", "units", "plugin", "module"):
|
|
192
|
+
print(f" {key:14s} {data.get(key, '—')}")
|
|
193
|
+
print(f"\n Dimensions:")
|
|
194
|
+
for dim_id, dim_info in data.get("dimensions", {}).items():
|
|
195
|
+
print(f" {dim_id}: {dim_info.get('name','')} ({dim_info.get('algorithm','')})")
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
# ------------------------------------------------------------------
|
|
199
|
+
# Data
|
|
200
|
+
# ------------------------------------------------------------------
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def print_data(data: dict, as_json: bool = False) -> None:
|
|
204
|
+
"""Print /api/v1/data response."""
|
|
205
|
+
if as_json:
|
|
206
|
+
print_json(data)
|
|
207
|
+
return
|
|
208
|
+
|
|
209
|
+
labels = data.get("labels", [])
|
|
210
|
+
rows = data.get("data", [])
|
|
211
|
+
|
|
212
|
+
if HAS_RICH:
|
|
213
|
+
t = Table(title=data.get("chart", "Data"))
|
|
214
|
+
for label in labels:
|
|
215
|
+
style = "cyan" if label == "time" else None
|
|
216
|
+
t.add_column(label, style=style, justify="right")
|
|
217
|
+
for row in rows[-20:]: # last 20 rows
|
|
218
|
+
formatted = []
|
|
219
|
+
for i, val in enumerate(row):
|
|
220
|
+
if labels[i] == "time":
|
|
221
|
+
formatted.append(_ts_to_str(val))
|
|
222
|
+
elif isinstance(val, float):
|
|
223
|
+
formatted.append(f"{val:.2f}")
|
|
224
|
+
else:
|
|
225
|
+
formatted.append(str(val) if val is not None else "—")
|
|
226
|
+
t.add_row(*formatted)
|
|
227
|
+
console.print(t)
|
|
228
|
+
if len(rows) > 20:
|
|
229
|
+
console.print(f"[dim] ... showing last 20 of {len(rows)} points[/]")
|
|
230
|
+
else:
|
|
231
|
+
header = " ".join(f"{l:>12s}" for l in labels)
|
|
232
|
+
print(f" {header}")
|
|
233
|
+
for row in rows[-20:]:
|
|
234
|
+
vals = []
|
|
235
|
+
for i, val in enumerate(row):
|
|
236
|
+
if labels[i] == "time":
|
|
237
|
+
vals.append(f"{_ts_to_str(val):>12s}")
|
|
238
|
+
elif isinstance(val, float):
|
|
239
|
+
vals.append(f"{val:>12.2f}")
|
|
240
|
+
else:
|
|
241
|
+
vals.append(f"{str(val) if val is not None else '—':>12s}")
|
|
242
|
+
print(f" {' '.join(vals)}")
|
|
243
|
+
if len(rows) > 20:
|
|
244
|
+
print(f" ... showing last 20 of {len(rows)} points")
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
# ------------------------------------------------------------------
|
|
248
|
+
# Alarms
|
|
249
|
+
# ------------------------------------------------------------------
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def print_alarms(alarms: list[dict], as_json: bool = False) -> None:
|
|
253
|
+
if as_json:
|
|
254
|
+
print_json(alarms)
|
|
255
|
+
return
|
|
256
|
+
|
|
257
|
+
if HAS_RICH:
|
|
258
|
+
t = Table(title=f"Alarms ({len(alarms)})")
|
|
259
|
+
t.add_column("Chart", style="cyan", max_width=30)
|
|
260
|
+
t.add_column("Name", max_width=30)
|
|
261
|
+
t.add_column("Status", justify="center")
|
|
262
|
+
t.add_column("Value", justify="right")
|
|
263
|
+
t.add_column("Warning", justify="right", style="yellow")
|
|
264
|
+
t.add_column("Critical", justify="right", style="red")
|
|
265
|
+
t.add_column("Last Updated")
|
|
266
|
+
for a in alarms:
|
|
267
|
+
t.add_row(
|
|
268
|
+
a.get("chart", "?"),
|
|
269
|
+
a.get("name", "?"),
|
|
270
|
+
_colorize_severity(a.get("status", "?")),
|
|
271
|
+
str(a.get("value", "—")),
|
|
272
|
+
str(a.get("warn", "—")),
|
|
273
|
+
str(a.get("crit", "—")),
|
|
274
|
+
_ts_to_str(a.get("last_updated")),
|
|
275
|
+
)
|
|
276
|
+
console.print(t)
|
|
277
|
+
else:
|
|
278
|
+
print(f" {'Chart':30s} {'Name':30s} {'Status':10s} {'Value':>10s} {'Warn':>10s} {'Crit':>10s}")
|
|
279
|
+
for a in alarms:
|
|
280
|
+
print(
|
|
281
|
+
f" {a.get('chart',''):30s} {a.get('name',''):30s} "
|
|
282
|
+
f"{a.get('status',''):10s} {str(a.get('value','—')):>10s} "
|
|
283
|
+
f"{str(a.get('warn','—')):>10s} {str(a.get('crit','—')):>10s}"
|
|
284
|
+
)
|
|
285
|
+
print(f"\n Total: {len(alarms)} alarms")
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def print_alarm_log(entries: list[dict], as_json: bool = False) -> None:
|
|
289
|
+
if as_json:
|
|
290
|
+
print_json(entries)
|
|
291
|
+
return
|
|
292
|
+
|
|
293
|
+
if HAS_RICH:
|
|
294
|
+
t = Table(title=f"Alarm Log ({len(entries)} entries)")
|
|
295
|
+
t.add_column("Time", style="dim")
|
|
296
|
+
t.add_column("Chart", style="cyan", max_width=30)
|
|
297
|
+
t.add_column("Name", max_width=25)
|
|
298
|
+
t.add_column("Status", justify="center")
|
|
299
|
+
t.add_column("Old", justify="center")
|
|
300
|
+
t.add_column("Value", justify="right")
|
|
301
|
+
for e in entries[-50:]:
|
|
302
|
+
t.add_row(
|
|
303
|
+
_ts_to_str(e.get("when")),
|
|
304
|
+
e.get("chart", "?"),
|
|
305
|
+
e.get("name", "?"),
|
|
306
|
+
_colorize_severity(e.get("status_new", e.get("status", "?"))),
|
|
307
|
+
e.get("status_old", "—"),
|
|
308
|
+
str(e.get("value", "—")),
|
|
309
|
+
)
|
|
310
|
+
console.print(t)
|
|
311
|
+
if len(entries) > 50:
|
|
312
|
+
console.print(f"[dim] ... showing last 50 of {len(entries)} entries[/]")
|
|
313
|
+
else:
|
|
314
|
+
for e in entries[-30:]:
|
|
315
|
+
print(
|
|
316
|
+
f" {_ts_to_str(e.get('when'))} {e.get('chart',''):25s} "
|
|
317
|
+
f"{e.get('name',''):20s} {e.get('status_new', e.get('status',''))}"
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
# ------------------------------------------------------------------
|
|
322
|
+
# Functions
|
|
323
|
+
# ------------------------------------------------------------------
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def print_function(data: Any, as_json: bool = False) -> None:
|
|
327
|
+
"""Generic function output."""
|
|
328
|
+
if as_json:
|
|
329
|
+
print_json(data)
|
|
330
|
+
return
|
|
331
|
+
print_json(data)
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: netdata-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A powerful CLI for the Netdata Agent REST API — query metrics, charts, alarms, and functions from the terminal.
|
|
5
|
+
Author-email: Sosi <sosi@example.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/sosiman/netdata-cli
|
|
8
|
+
Project-URL: Repository, https://github.com/sosiman/netdata-cli
|
|
9
|
+
Project-URL: Issues, https://github.com/sosiman/netdata-cli/issues
|
|
10
|
+
Keywords: netdata,cli,monitoring,metrics,devops,observability
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: System Administrators
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Topic :: System :: Monitoring
|
|
22
|
+
Classifier: Topic :: System :: Systems Administration
|
|
23
|
+
Requires-Python: >=3.10
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
License-File: LICENSE
|
|
26
|
+
Requires-Dist: click>=8.0
|
|
27
|
+
Requires-Dist: requests>=2.28
|
|
28
|
+
Requires-Dist: rich>=13.0
|
|
29
|
+
Provides-Extra: dev
|
|
30
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
31
|
+
Requires-Dist: pytest-cov; extra == "dev"
|
|
32
|
+
Requires-Dist: ruff; extra == "dev"
|
|
33
|
+
Requires-Dist: mypy; extra == "dev"
|
|
34
|
+
Dynamic: license-file
|
|
35
|
+
|
|
36
|
+
# netdata-cli
|
|
37
|
+
|
|
38
|
+
A powerful CLI for the [Netdata](https://www.netdata.cloud/) Agent REST API.
|
|
39
|
+
|
|
40
|
+
Query metrics, charts, alarms, functions, and agent info — all from the terminal.
|
|
41
|
+
Rich table output, JSON mode, time filtering, and search built-in.
|
|
42
|
+
|
|
43
|
+
```
|
|
44
|
+
┌─────────────────────────────────────────────┐
|
|
45
|
+
│ Netdata Agent │
|
|
46
|
+
│ Version v2.10.4 │
|
|
47
|
+
│ OS Debian GNU/Linux 13 (trixie) │
|
|
48
|
+
│ Kernel Linux 6.8.0-136-generic │
|
|
49
|
+
│ CPU Cores 4 │
|
|
50
|
+
│ RAM 15.4 GiB │
|
|
51
|
+
│ Charts 1669 │
|
|
52
|
+
│ Metrics 4124 │
|
|
53
|
+
│ Alarms OK 603 │
|
|
54
|
+
└─────────────────────────────────────────────┘
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Installation
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
pip install netdata-cli
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Or from source:
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
git clone https://github.com/sosiman/netdata-cli
|
|
67
|
+
cd netdata-cli
|
|
68
|
+
pip install -e .
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Quick Start
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
# Agent info
|
|
75
|
+
nd info
|
|
76
|
+
|
|
77
|
+
# Check connectivity
|
|
78
|
+
nd ping
|
|
79
|
+
|
|
80
|
+
# Search charts
|
|
81
|
+
nd charts cpu
|
|
82
|
+
nd charts --type disk
|
|
83
|
+
|
|
84
|
+
# Get historical data
|
|
85
|
+
nd data system.cpu --after -300 --points 10
|
|
86
|
+
nd data mem.available --after -3600
|
|
87
|
+
|
|
88
|
+
# Alarms
|
|
89
|
+
nd alarms
|
|
90
|
+
nd alarms --active-only
|
|
91
|
+
nd alarm-log --after 86400
|
|
92
|
+
|
|
93
|
+
# Functions
|
|
94
|
+
nd functions
|
|
95
|
+
nd function docker:container-ls
|
|
96
|
+
nd function mount-points
|
|
97
|
+
nd function network-interfaces
|
|
98
|
+
|
|
99
|
+
# Collectors
|
|
100
|
+
nd collectors
|
|
101
|
+
|
|
102
|
+
# Raw JSON output
|
|
103
|
+
nd --json info
|
|
104
|
+
nd --json charts
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## Commands
|
|
108
|
+
|
|
109
|
+
| Command | Description |
|
|
110
|
+
|---|---|
|
|
111
|
+
| `nd info` | Agent version, OS, CPU, RAM, chart/alarm counts, collectors |
|
|
112
|
+
| `nd ping` | Check if the agent is reachable |
|
|
113
|
+
| `nd charts [QUERY]` | List or search charts by id, title, type, units, dimensions |
|
|
114
|
+
| `nd chart CHART_ID` | Show chart details (dimensions, metadata, history) |
|
|
115
|
+
| `nd data CHART_ID` | Fetch historical data points with time range and grouping |
|
|
116
|
+
| `nd alarms` | List health alarms (active or all) |
|
|
117
|
+
| `nd alarm-log` | Alarm history with time filtering |
|
|
118
|
+
| `nd allmetrics` | Snapshot of every metric (JSON, shell, or Prometheus format) |
|
|
119
|
+
| `nd function NAME` | Call Netdata functions (docker, systemd, network, etc.) |
|
|
120
|
+
| `nd functions` | List available functions |
|
|
121
|
+
| `nd collectors` | List active data collectors |
|
|
122
|
+
|
|
123
|
+
## Configuration
|
|
124
|
+
|
|
125
|
+
Set the `NETDATA_URL` environment variable to point to a remote agent:
|
|
126
|
+
|
|
127
|
+
```bash
|
|
128
|
+
export NETDATA_URL=http://my-server:19999
|
|
129
|
+
nd info
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
Or pass `--url` on every call:
|
|
133
|
+
|
|
134
|
+
```bash
|
|
135
|
+
nd --url http://my-server:19999 charts
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
## Output Formats
|
|
139
|
+
|
|
140
|
+
Every command supports `--json` (`-j`) for raw JSON output, useful for piping to `jq`:
|
|
141
|
+
|
|
142
|
+
```bash
|
|
143
|
+
nd --json charts | jq '.[] | select(.units == "percentage")'
|
|
144
|
+
nd --json alarms | jq '.[] | select(.status == "WARNING")'
|
|
145
|
+
nd --json data system.cpu | jq '.data[-1]'
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
## Time Filtering
|
|
149
|
+
|
|
150
|
+
The `--after` flag accepts negative seconds (relative to now):
|
|
151
|
+
|
|
152
|
+
| Value | Meaning |
|
|
153
|
+
|---|---|
|
|
154
|
+
| `-60` | Last minute |
|
|
155
|
+
| `-300` | Last 5 minutes |
|
|
156
|
+
| `-3600` | Last hour |
|
|
157
|
+
| `-86400` | Last 24 hours |
|
|
158
|
+
| `-604800` | Last 7 days |
|
|
159
|
+
|
|
160
|
+
Data granularity depends on the chart's `update_every` setting and the Netdata database retention.
|
|
161
|
+
|
|
162
|
+
## Examples
|
|
163
|
+
|
|
164
|
+
### Monitor CPU in real-time
|
|
165
|
+
|
|
166
|
+
```bash
|
|
167
|
+
watch -n 2 'nd data system.cpu --after -10 --points 10'
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
### Find all disk charts
|
|
171
|
+
|
|
172
|
+
```bash
|
|
173
|
+
nd charts disk
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
### Export all metrics as Prometheus
|
|
177
|
+
|
|
178
|
+
```bash
|
|
179
|
+
nd allmetrics --format prometheus
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
### Check only critical alarms
|
|
183
|
+
|
|
184
|
+
```bash
|
|
185
|
+
nd alarms --active-only
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
### List Docker containers via Netdata
|
|
189
|
+
|
|
190
|
+
```bash
|
|
191
|
+
nd function docker:container-ls
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
### Show network interfaces
|
|
195
|
+
|
|
196
|
+
```bash
|
|
197
|
+
nd function network-interfaces
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
## Requirements
|
|
201
|
+
|
|
202
|
+
- Python 3.10+
|
|
203
|
+
- A running Netdata agent (v1.x or v2.x)
|
|
204
|
+
|
|
205
|
+
## Dependencies
|
|
206
|
+
|
|
207
|
+
- [click](https://click.palletsprojects.com/) — CLI framework
|
|
208
|
+
- [requests](https://requests.readthedocs.io/) — HTTP client
|
|
209
|
+
- [rich](https://rich.readthedocs.io/) — Terminal formatting
|
|
210
|
+
|
|
211
|
+
## Development
|
|
212
|
+
|
|
213
|
+
```bash
|
|
214
|
+
git clone https://github.com/sosiman/netdata-cli
|
|
215
|
+
cd netdata-cli
|
|
216
|
+
pip install -e ".[dev]"
|
|
217
|
+
pytest
|
|
218
|
+
ruff check .
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
## License
|
|
222
|
+
|
|
223
|
+
[MIT](LICENSE)
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
netdata_cli/__init__.py,sha256=rIbZbYbNCaZoSgcA-IOtDHW52a1eZSUD-2ONKIaMVsM,92
|
|
2
|
+
netdata_cli/cli.py,sha256=ZCfaEzf119wYfzv5ZM2kM4hjrZMC6BvpMInU8PBpszQ,14570
|
|
3
|
+
netdata_cli/client.py,sha256=T4DuVOP_CKvzpF1FiXbG1OvYvqhgO29tOqHykFCp48A,7862
|
|
4
|
+
netdata_cli/formatters.py,sha256=rokGtvuwK1MsR2cMOEjza2ggL4YvqSOLnvCgCwiPm_w,11644
|
|
5
|
+
netdata_cli-0.1.0.dist-info/licenses/LICENSE,sha256=Tq3uYZqMEv-MzVnsiXx-L99tt5Css4jGrocf6KpFdfM,1061
|
|
6
|
+
netdata_cli-0.1.0.dist-info/METADATA,sha256=QoL8zXtGoXNJFhlbCjhuhNmazh3m_u_fQ8dD9S4Fs7A,5686
|
|
7
|
+
netdata_cli-0.1.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
|
8
|
+
netdata_cli-0.1.0.dist-info/entry_points.txt,sha256=15fw-SmjPRO9-1z1h0g8I2pWuyNXZDm_MmHkiDklELw,44
|
|
9
|
+
netdata_cli-0.1.0.dist-info/top_level.txt,sha256=TC9SwX7mrl2Q1QDRKMs045wkb5Bg3MH1y_iAiL1jhDc,12
|
|
10
|
+
netdata_cli-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Sosi
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
netdata_cli
|