ipmg 3.0.0__tar.gz → 3.1.0__tar.gz

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.
Files changed (85) hide show
  1. {ipmg-3.0.0/src/ipmg.egg-info → ipmg-3.1.0}/PKG-INFO +52 -1
  2. {ipmg-3.0.0 → ipmg-3.1.0}/README.md +51 -0
  3. {ipmg-3.0.0 → ipmg-3.1.0}/pyproject.toml +1 -1
  4. {ipmg-3.0.0 → ipmg-3.1.0}/src/ipmg/__init__.py +1 -1
  5. {ipmg-3.0.0 → ipmg-3.1.0}/src/ipmg/cli/commands.py +1 -0
  6. {ipmg-3.0.0 → ipmg-3.1.0}/src/ipmg/cli/parser.py +19 -0
  7. {ipmg-3.0.0 → ipmg-3.1.0}/src/ipmg/infrastructure/database.py +17 -1
  8. ipmg-3.1.0/src/ipmg/reporting/metrics.py +125 -0
  9. {ipmg-3.0.0 → ipmg-3.1.0}/src/ipmg/web/app.py +27 -2
  10. {ipmg-3.0.0 → ipmg-3.1.0}/src/ipmg/web/server.py +7 -1
  11. {ipmg-3.0.0 → ipmg-3.1.0/src/ipmg.egg-info}/PKG-INFO +52 -1
  12. {ipmg-3.0.0 → ipmg-3.1.0}/src/ipmg.egg-info/SOURCES.txt +2 -0
  13. {ipmg-3.0.0 → ipmg-3.1.0}/tests/test_commands.py +9 -1
  14. ipmg-3.1.0/tests/test_metrics.py +162 -0
  15. {ipmg-3.0.0 → ipmg-3.1.0}/LICENSE +0 -0
  16. {ipmg-3.0.0 → ipmg-3.1.0}/setup.cfg +0 -0
  17. {ipmg-3.0.0 → ipmg-3.1.0}/src/ipmg/__main__.py +0 -0
  18. {ipmg-3.0.0 → ipmg-3.1.0}/src/ipmg/cli/__init__.py +0 -0
  19. {ipmg-3.0.0 → ipmg-3.1.0}/src/ipmg/cli/config.py +0 -0
  20. {ipmg-3.0.0 → ipmg-3.1.0}/src/ipmg/core/__init__.py +0 -0
  21. {ipmg-3.0.0 → ipmg-3.1.0}/src/ipmg/core/diff.py +0 -0
  22. {ipmg-3.0.0 → ipmg-3.1.0}/src/ipmg/core/discovery.py +0 -0
  23. {ipmg-3.0.0 → ipmg-3.1.0}/src/ipmg/core/engine.py +0 -0
  24. {ipmg-3.0.0 → ipmg-3.1.0}/src/ipmg/core/health.py +0 -0
  25. {ipmg-3.0.0 → ipmg-3.1.0}/src/ipmg/core/ping.py +0 -0
  26. {ipmg-3.0.0 → ipmg-3.1.0}/src/ipmg/core/portscan.py +0 -0
  27. {ipmg-3.0.0 → ipmg-3.1.0}/src/ipmg/core/security.py +0 -0
  28. {ipmg-3.0.0 → ipmg-3.1.0}/src/ipmg/exceptions.py +0 -0
  29. {ipmg-3.0.0 → ipmg-3.1.0}/src/ipmg/infrastructure/__init__.py +0 -0
  30. {ipmg-3.0.0 → ipmg-3.1.0}/src/ipmg/infrastructure/file_io.py +0 -0
  31. {ipmg-3.0.0 → ipmg-3.1.0}/src/ipmg/infrastructure/incremental.py +0 -0
  32. {ipmg-3.0.0 → ipmg-3.1.0}/src/ipmg/infrastructure/notify.py +0 -0
  33. {ipmg-3.0.0 → ipmg-3.1.0}/src/ipmg/reporting/__init__.py +0 -0
  34. {ipmg-3.0.0 → ipmg-3.1.0}/src/ipmg/reporting/diff_report.py +0 -0
  35. {ipmg-3.0.0 → ipmg-3.1.0}/src/ipmg/reporting/frames.py +0 -0
  36. {ipmg-3.0.0 → ipmg-3.1.0}/src/ipmg/reporting/live.py +0 -0
  37. {ipmg-3.0.0 → ipmg-3.1.0}/src/ipmg/reporting/machine.py +0 -0
  38. {ipmg-3.0.0 → ipmg-3.1.0}/src/ipmg/reporting/summary.py +0 -0
  39. {ipmg-3.0.0 → ipmg-3.1.0}/src/ipmg/reporting/ui.py +0 -0
  40. {ipmg-3.0.0 → ipmg-3.1.0}/src/ipmg/services/__init__.py +0 -0
  41. {ipmg-3.0.0 → ipmg-3.1.0}/src/ipmg/services/history_service.py +0 -0
  42. {ipmg-3.0.0 → ipmg-3.1.0}/src/ipmg/services/scan_service.py +0 -0
  43. {ipmg-3.0.0 → ipmg-3.1.0}/src/ipmg/utils/__init__.py +0 -0
  44. {ipmg-3.0.0 → ipmg-3.1.0}/src/ipmg/utils/helpers.py +0 -0
  45. {ipmg-3.0.0 → ipmg-3.1.0}/src/ipmg/web/__init__.py +0 -0
  46. {ipmg-3.0.0 → ipmg-3.1.0}/src/ipmg/web/db.py +0 -0
  47. {ipmg-3.0.0 → ipmg-3.1.0}/src/ipmg/web/manager.py +0 -0
  48. {ipmg-3.0.0 → ipmg-3.1.0}/src/ipmg/web/schemas.py +0 -0
  49. {ipmg-3.0.0 → ipmg-3.1.0}/src/ipmg/web/static/css/app.css +0 -0
  50. {ipmg-3.0.0 → ipmg-3.1.0}/src/ipmg/web/static/index.html +0 -0
  51. {ipmg-3.0.0 → ipmg-3.1.0}/src/ipmg/web/static/js/api.js +0 -0
  52. {ipmg-3.0.0 → ipmg-3.1.0}/src/ipmg/web/static/js/app.js +0 -0
  53. {ipmg-3.0.0 → ipmg-3.1.0}/src/ipmg/web/static/js/charts.js +0 -0
  54. {ipmg-3.0.0 → ipmg-3.1.0}/src/ipmg/web/static/js/demo.js +0 -0
  55. {ipmg-3.0.0 → ipmg-3.1.0}/src/ipmg/web/static/js/views.js +0 -0
  56. {ipmg-3.0.0 → ipmg-3.1.0}/src/ipmg.egg-info/dependency_links.txt +0 -0
  57. {ipmg-3.0.0 → ipmg-3.1.0}/src/ipmg.egg-info/entry_points.txt +0 -0
  58. {ipmg-3.0.0 → ipmg-3.1.0}/src/ipmg.egg-info/requires.txt +0 -0
  59. {ipmg-3.0.0 → ipmg-3.1.0}/src/ipmg.egg-info/top_level.txt +0 -0
  60. {ipmg-3.0.0 → ipmg-3.1.0}/tests/test_api_contract.py +0 -0
  61. {ipmg-3.0.0 → ipmg-3.1.0}/tests/test_config.py +0 -0
  62. {ipmg-3.0.0 → ipmg-3.1.0}/tests/test_database_history.py +0 -0
  63. {ipmg-3.0.0 → ipmg-3.1.0}/tests/test_diff.py +0 -0
  64. {ipmg-3.0.0 → ipmg-3.1.0}/tests/test_diff_report.py +0 -0
  65. {ipmg-3.0.0 → ipmg-3.1.0}/tests/test_discover.py +0 -0
  66. {ipmg-3.0.0 → ipmg-3.1.0}/tests/test_engine.py +0 -0
  67. {ipmg-3.0.0 → ipmg-3.1.0}/tests/test_file_io.py +0 -0
  68. {ipmg-3.0.0 → ipmg-3.1.0}/tests/test_health.py +0 -0
  69. {ipmg-3.0.0 → ipmg-3.1.0}/tests/test_history_service.py +0 -0
  70. {ipmg-3.0.0 → ipmg-3.1.0}/tests/test_incremental.py +0 -0
  71. {ipmg-3.0.0 → ipmg-3.1.0}/tests/test_ipv6.py +0 -0
  72. {ipmg-3.0.0 → ipmg-3.1.0}/tests/test_live.py +0 -0
  73. {ipmg-3.0.0 → ipmg-3.1.0}/tests/test_machine_output.py +0 -0
  74. {ipmg-3.0.0 → ipmg-3.1.0}/tests/test_notify.py +0 -0
  75. {ipmg-3.0.0 → ipmg-3.1.0}/tests/test_parser.py +0 -0
  76. {ipmg-3.0.0 → ipmg-3.1.0}/tests/test_ping.py +0 -0
  77. {ipmg-3.0.0 → ipmg-3.1.0}/tests/test_ping_command.py +0 -0
  78. {ipmg-3.0.0 → ipmg-3.1.0}/tests/test_portscan.py +0 -0
  79. {ipmg-3.0.0 → ipmg-3.1.0}/tests/test_scan_service.py +0 -0
  80. {ipmg-3.0.0 → ipmg-3.1.0}/tests/test_ui.py +0 -0
  81. {ipmg-3.0.0 → ipmg-3.1.0}/tests/test_utils.py +0 -0
  82. {ipmg-3.0.0 → ipmg-3.1.0}/tests/test_web_api.py +0 -0
  83. {ipmg-3.0.0 → ipmg-3.1.0}/tests/test_web_db.py +0 -0
  84. {ipmg-3.0.0 → ipmg-3.1.0}/tests/test_web_manager.py +0 -0
  85. {ipmg-3.0.0 → ipmg-3.1.0}/tests/test_web_server.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ipmg
3
- Version: 3.0.0
3
+ Version: 3.1.0
4
4
  Summary: IP Management & Ping Monitoring CLI Tool
5
5
  Author: Sameer Alam
6
6
  Maintainer-email: Sameer Alam <sameeralam3127@gmail.com>
@@ -558,6 +558,57 @@ your own scripts. The [API guide](https://github.com/sameeralam3127/ipmg/blob/ma
558
558
  covers authentication, errors, and worked `curl` examples; the running server
559
559
  also serves interactive docs at `http://127.0.0.1:8080/docs`.
560
560
 
561
+ ### Prometheus metrics
562
+
563
+ `ipmg web --metrics` serves `/metrics` in the Prometheus text format, so the
564
+ results of every scan, whether run from cron with the CLI or from the browser,
565
+ land in the dashboards and alerts you already have. It reports the latest
566
+ completed scan of each source, and it is off unless you pass the flag:
567
+
568
+ ```bash
569
+ IPMG_WEB_TOKEN=$(cat /etc/ipmg/token) ipmg web --metrics --no-browser
570
+ ```
571
+
572
+ ```yaml
573
+ # prometheus.yml: the token is the same one the web UI uses
574
+ scrape_configs:
575
+ - job_name: ipmg
576
+ authorization:
577
+ credentials_file: /etc/prometheus/ipmg-token
578
+ static_configs:
579
+ - targets: ["127.0.0.1:8080"]
580
+
581
+ # an alert rule: a host that stopped answering
582
+ groups:
583
+ - name: ipmg
584
+ rules:
585
+ - alert: HostDown
586
+ expr: ipmg_host_up == 0
587
+ for: 10m
588
+ annotations:
589
+ summary: "{{ $labels.ip }} ({{ $labels.source }}) is not answering ping"
590
+ ```
591
+
592
+ | Metric | Labels | Meaning |
593
+ | --- | --- | --- |
594
+ | `ipmg_host_up` | `source`, `ip` | 1 if the host answered, else 0 |
595
+ | `ipmg_host_latency_seconds` | `source`, `ip` | Round-trip time, for hosts that answered |
596
+ | `ipmg_host_open_ports` | `source`, `ip` | Open TCP ports, for scans run with `--scan-ports` |
597
+ | `ipmg_host_info` | `source`, `ip`, `hostname` | Always 1; carries the reverse DNS name |
598
+ | `ipmg_scan_hosts` | `source`, `status` | Hosts in the latest scan, by status |
599
+ | `ipmg_scan_duration_seconds` | `source` | How long the latest scan took |
600
+ | `ipmg_scan_timestamp_seconds` | `source` | When the latest scan finished; alert on `time() - this` to catch a stalled cron job |
601
+ | `ipmg_scans_total` | `source` | Completed scans stored for the source |
602
+ | `ipmg_metrics_sources_omitted` | | Sources left out by `--metrics-sources` |
603
+ | `ipmg_build_info` | `version` | Always 1 |
604
+
605
+ **Cardinality is bounded.** Host series carry only `source` and `ip`, never
606
+ the hostname or status, which change, so a rename does not start a new series.
607
+ Only the `--metrics-sources` most recently scanned sources are exported
608
+ (default 20), and a scan holds at most 65,536 hosts, so the page stays at
609
+ about `sources × hosts × 4` series at most. Give recurring scans a stable
610
+ input (the same file) so each one updates its source rather than adding one.
611
+
561
612
  ### On a server with no browser
562
613
 
563
614
  On a Linux server with no display (e.g. accessed over plain SSH), IPMG detects
@@ -506,6 +506,57 @@ your own scripts. The [API guide](https://github.com/sameeralam3127/ipmg/blob/ma
506
506
  covers authentication, errors, and worked `curl` examples; the running server
507
507
  also serves interactive docs at `http://127.0.0.1:8080/docs`.
508
508
 
509
+ ### Prometheus metrics
510
+
511
+ `ipmg web --metrics` serves `/metrics` in the Prometheus text format, so the
512
+ results of every scan, whether run from cron with the CLI or from the browser,
513
+ land in the dashboards and alerts you already have. It reports the latest
514
+ completed scan of each source, and it is off unless you pass the flag:
515
+
516
+ ```bash
517
+ IPMG_WEB_TOKEN=$(cat /etc/ipmg/token) ipmg web --metrics --no-browser
518
+ ```
519
+
520
+ ```yaml
521
+ # prometheus.yml: the token is the same one the web UI uses
522
+ scrape_configs:
523
+ - job_name: ipmg
524
+ authorization:
525
+ credentials_file: /etc/prometheus/ipmg-token
526
+ static_configs:
527
+ - targets: ["127.0.0.1:8080"]
528
+
529
+ # an alert rule: a host that stopped answering
530
+ groups:
531
+ - name: ipmg
532
+ rules:
533
+ - alert: HostDown
534
+ expr: ipmg_host_up == 0
535
+ for: 10m
536
+ annotations:
537
+ summary: "{{ $labels.ip }} ({{ $labels.source }}) is not answering ping"
538
+ ```
539
+
540
+ | Metric | Labels | Meaning |
541
+ | --- | --- | --- |
542
+ | `ipmg_host_up` | `source`, `ip` | 1 if the host answered, else 0 |
543
+ | `ipmg_host_latency_seconds` | `source`, `ip` | Round-trip time, for hosts that answered |
544
+ | `ipmg_host_open_ports` | `source`, `ip` | Open TCP ports, for scans run with `--scan-ports` |
545
+ | `ipmg_host_info` | `source`, `ip`, `hostname` | Always 1; carries the reverse DNS name |
546
+ | `ipmg_scan_hosts` | `source`, `status` | Hosts in the latest scan, by status |
547
+ | `ipmg_scan_duration_seconds` | `source` | How long the latest scan took |
548
+ | `ipmg_scan_timestamp_seconds` | `source` | When the latest scan finished; alert on `time() - this` to catch a stalled cron job |
549
+ | `ipmg_scans_total` | `source` | Completed scans stored for the source |
550
+ | `ipmg_metrics_sources_omitted` | | Sources left out by `--metrics-sources` |
551
+ | `ipmg_build_info` | `version` | Always 1 |
552
+
553
+ **Cardinality is bounded.** Host series carry only `source` and `ip`, never
554
+ the hostname or status, which change, so a rename does not start a new series.
555
+ Only the `--metrics-sources` most recently scanned sources are exported
556
+ (default 20), and a scan holds at most 65,536 hosts, so the page stays at
557
+ about `sources × hosts × 4` series at most. Give recurring scans a stable
558
+ input (the same file) so each one updates its source rather than adding one.
559
+
509
560
  ### On a server with no browser
510
561
 
511
562
  On a Linux server with no display (e.g. accessed over plain SSH), IPMG detects
@@ -13,7 +13,7 @@ build-backend = "setuptools.build_meta"
13
13
 
14
14
  [project]
15
15
  name = "ipmg"
16
- version = "3.0.0" # Managed automatically by semantic-release
16
+ version = "3.1.0" # Managed automatically by semantic-release
17
17
  description = "IP Management & Ping Monitoring CLI Tool"
18
18
  readme = "README.md"
19
19
  requires-python = ">=3.9"
@@ -2,4 +2,4 @@
2
2
  ipmg - IP Management & Ping Monitoring Tool
3
3
  """
4
4
 
5
- __version__ = "3.0.0"
5
+ __version__ = "3.1.0"
@@ -82,6 +82,7 @@ def _web_command(argv: List[str]) -> int:
82
82
  port=args.port,
83
83
  open_browser=not args.no_browser,
84
84
  db_path=args.db,
85
+ metrics_sources=max(args.metrics_sources, 1) if args.metrics else None,
85
86
  )
86
87
  return EXIT_OK
87
88
 
@@ -27,6 +27,7 @@ from ipmg.infrastructure.notify import (
27
27
  )
28
28
  from ipmg.reporting.diff_report import DIFF_FORMATS
29
29
  from ipmg.reporting.live import DEFAULT_REFRESH_S, MAX_REFRESH_S, MIN_REFRESH_S
30
+ from ipmg.reporting.metrics import DEFAULT_MAX_SOURCES
30
31
 
31
32
  PROG = "IPMG - IP Management & Ping Monitoring Tool"
32
33
 
@@ -458,6 +459,24 @@ def build_web_parser() -> argparse.ArgumentParser:
458
459
  action="store_true",
459
460
  help="Do not open IPMG Web in a browser automatically.",
460
461
  )
462
+ parser.add_argument(
463
+ "--metrics",
464
+ action="store_true",
465
+ help=(
466
+ "Serve Prometheus metrics at /metrics for the latest completed scan of "
467
+ "each source (off by default; needs the access token)."
468
+ ),
469
+ )
470
+ parser.add_argument(
471
+ "--metrics-sources",
472
+ type=int,
473
+ default=DEFAULT_MAX_SOURCES,
474
+ metavar="N",
475
+ help=(
476
+ "Export only the N most recently scanned sources, which bounds the "
477
+ f"number of series (default: {DEFAULT_MAX_SOURCES})."
478
+ ),
479
+ )
461
480
  _add_database_argument(parser)
462
481
  parser.add_argument("--verbose", action="store_true")
463
482
  return parser
@@ -13,7 +13,7 @@ import threading
13
13
  from contextlib import contextmanager
14
14
  from datetime import datetime
15
15
  from pathlib import Path
16
- from typing import Any, Dict, Iterable, List, Optional, Sequence
16
+ from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple
17
17
 
18
18
  from ipmg.core.diff import HostSnapshot, ScanRef, ip_sort_key
19
19
  from ipmg.core.engine import HostResult
@@ -299,6 +299,22 @@ class Database:
299
299
  with self._session() as conn:
300
300
  return [dict(row) for row in conn.execute(query, params).fetchall()]
301
301
 
302
+ def latest_completed_scans(self, limit: int) -> Tuple[List[Dict[str, Any]], int]:
303
+ """The newest completed scan of each source, most recent source first.
304
+
305
+ Returns at most ``limit`` scans, each with a ``completed_count`` of the
306
+ completed scans its source has, plus how many sources exist in all, so
307
+ a caller can say how many it left out.
308
+ """
309
+ with self._session() as conn:
310
+ rows = conn.execute(
311
+ "SELECT s.*, latest.completed_count FROM scans s JOIN ("
312
+ " SELECT source, MAX(id) AS id, COUNT(*) AS completed_count "
313
+ " FROM scans WHERE status = 'complete' GROUP BY source"
314
+ ") latest ON s.id = latest.id ORDER BY s.id DESC"
315
+ ).fetchall()
316
+ return [self._scan_dict(row) for row in rows[:limit]], len(rows)
317
+
302
318
  # ------------------------------------------------------- aggregates
303
319
 
304
320
  def overview(self) -> Dict[str, Any]:
@@ -0,0 +1,125 @@
1
+ """Prometheus metrics for the most recent completed scan of each source.
2
+
3
+ Rendered by hand in the text exposition format (version 0.0.4), so serving
4
+ ``/metrics`` adds no dependency. Every per-host series is labelled by
5
+ ``source`` and ``ip`` only; the hostname, which changes, lives on a separate
6
+ ``ipmg_host_info`` series so a rename does not start a new latency series.
7
+
8
+ Cardinality is bounded by what is in the history database: one series per
9
+ host per metric for the newest completed scan of each of the ``max_sources``
10
+ most recently scanned sources, and a scan holds at most 65,536 hosts.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from datetime import datetime
16
+ from typing import Dict, Iterable, List, Optional, Sequence, Tuple
17
+
18
+ from ipmg import __version__
19
+ from ipmg.core.portscan import decode_ports
20
+ from ipmg.infrastructure.database import Database
21
+
22
+ CONTENT_TYPE = "text/plain; version=0.0.4; charset=utf-8"
23
+
24
+ #: Sources exported by default: the most recently scanned ones.
25
+ DEFAULT_MAX_SOURCES = 20
26
+
27
+ Labels = Sequence[Tuple[str, str]]
28
+
29
+
30
+ def _escape(value: str) -> str:
31
+ """Escape a label value as the exposition format requires."""
32
+ return value.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n")
33
+
34
+
35
+ def _number(value: float) -> str:
36
+ return repr(float(value)) if not float(value).is_integer() else str(int(value))
37
+
38
+
39
+ class _Exposition:
40
+ """Collects samples per metric, then writes each family under one header."""
41
+
42
+ def __init__(self) -> None:
43
+ self._families: Dict[str, Tuple[str, str, List[str]]] = {}
44
+
45
+ def declare(self, name: str, kind: str, help_text: str) -> None:
46
+ self._families[name] = (kind, help_text, [])
47
+
48
+ def sample(self, name: str, labels: Labels, value: float) -> None:
49
+ rendered = ",".join(f'{key}="{_escape(str(val))}"' for key, val in labels)
50
+ series = f"{name}{{{rendered}}}" if rendered else name
51
+ self._families[name][2].append(f"{series} {_number(value)}")
52
+
53
+ def render(self) -> str:
54
+ lines: List[str] = []
55
+ for name, (kind, help_text, samples) in self._families.items():
56
+ lines.append(f"# HELP {name} {help_text}")
57
+ lines.append(f"# TYPE {name} {kind}")
58
+ lines.extend(samples)
59
+ return "\n".join(lines) + "\n"
60
+
61
+
62
+ _FAMILIES = (
63
+ ("ipmg_build_info", "gauge", "IPMG version serving these metrics (always 1)."),
64
+ ("ipmg_host_up", "gauge", "1 if the host answered in its source's latest scan, else 0."),
65
+ (
66
+ "ipmg_host_latency_seconds",
67
+ "gauge",
68
+ "Round-trip time in the latest scan, hosts that answered.",
69
+ ),
70
+ ("ipmg_host_open_ports", "gauge", "Open TCP ports found, for scans that checked ports."),
71
+ ("ipmg_host_info", "gauge", "The host's reverse DNS name in the latest scan (always 1)."),
72
+ ("ipmg_scan_hosts", "gauge", "Hosts in the source's latest scan, by status."),
73
+ ("ipmg_scan_duration_seconds", "gauge", "How long the source's latest scan took."),
74
+ ("ipmg_scan_timestamp_seconds", "gauge", "When the source's latest scan finished (Unix time)."),
75
+ ("ipmg_scans_total", "counter", "Completed scans stored for the source."),
76
+ ("ipmg_metrics_sources_omitted", "gauge", "Sources left out by the --metrics-sources limit."),
77
+ )
78
+
79
+
80
+ def _finished_at(scan: Dict) -> Optional[float]:
81
+ try:
82
+ return datetime.fromisoformat(scan["finished_at"]).timestamp()
83
+ except (KeyError, TypeError, ValueError):
84
+ return None
85
+
86
+
87
+ def _host_samples(out: _Exposition, source: str, rows: Iterable[Dict], ports: bool) -> None:
88
+ for row in rows:
89
+ labels = (("source", source), ("ip", row["ip"]))
90
+ out.sample("ipmg_host_up", labels, 1 if row["status"] == "Active" else 0)
91
+ if row["latency"] is not None:
92
+ out.sample("ipmg_host_latency_seconds", labels, row["latency"] / 1000)
93
+ if ports:
94
+ out.sample("ipmg_host_open_ports", labels, len(decode_ports(row["open_ports"])))
95
+ if row["hostname"]:
96
+ out.sample("ipmg_host_info", (*labels, ("hostname", row["hostname"])), 1)
97
+
98
+
99
+ def render_metrics(database: Database, max_sources: int = DEFAULT_MAX_SOURCES) -> str:
100
+ """The metrics page for ``database``, in Prometheus text format."""
101
+ out = _Exposition()
102
+ for name, kind, help_text in _FAMILIES:
103
+ out.declare(name, kind, help_text)
104
+ out.sample("ipmg_build_info", (("version", __version__),), 1)
105
+
106
+ scans, source_count = database.latest_completed_scans(max_sources)
107
+ for scan in scans:
108
+ source = scan["source"]
109
+ rows = database.get_results(scan["id"])
110
+ _host_samples(out, source, rows, bool(scan["config"].get("scan_ports")))
111
+
112
+ counts: Dict[str, int] = {}
113
+ for row in rows:
114
+ counts[row["status"]] = counts.get(row["status"], 0) + 1
115
+ for status, count in sorted(counts.items()):
116
+ out.sample("ipmg_scan_hosts", (("source", source), ("status", status)), count)
117
+
118
+ out.sample("ipmg_scan_duration_seconds", (("source", source),), scan["duration_s"] or 0)
119
+ finished = _finished_at(scan)
120
+ if finished is not None:
121
+ out.sample("ipmg_scan_timestamp_seconds", (("source", source),), finished)
122
+ out.sample("ipmg_scans_total", (("source", source),), scan["completed_count"])
123
+
124
+ out.sample("ipmg_metrics_sources_omitted", (), max(source_count - len(scans), 0))
125
+ return out.render()
@@ -568,8 +568,31 @@ def _install_openapi(app: FastAPI) -> None:
568
568
  app.openapi = openapi # type: ignore[method-assign]
569
569
 
570
570
 
571
- def create_app(db: Optional[Database] = None, token: Optional[str] = None) -> FastAPI:
572
- """Build the app. ``token`` guards every API route; a random one is made if omitted."""
571
+ def _register_metrics_route(app: FastAPI, database: Database, token: str, max_sources: int) -> None:
572
+ # Outside /api/v1 because /metrics is where Prometheus looks, but behind
573
+ # the same token: a scrape config sends it as a Bearer credential.
574
+ # Imported here: the renderer is only needed when --metrics turns it on.
575
+ from ipmg.reporting.metrics import CONTENT_TYPE as METRICS_CONTENT_TYPE, render_metrics
576
+
577
+ @app.get(
578
+ "/metrics",
579
+ dependencies=[Depends(_require_token(token))],
580
+ include_in_schema=False,
581
+ )
582
+ def metrics() -> Response:
583
+ return Response(render_metrics(database, max_sources), media_type=METRICS_CONTENT_TYPE)
584
+
585
+
586
+ def create_app(
587
+ db: Optional[Database] = None,
588
+ token: Optional[str] = None,
589
+ metrics_sources: Optional[int] = None,
590
+ ) -> FastAPI:
591
+ """Build the app. ``token`` guards every API route; a random one is made if omitted.
592
+
593
+ ``metrics_sources`` turns on ``/metrics`` for that many of the most
594
+ recently scanned sources; left as None, the endpoint does not exist.
595
+ """
573
596
  database = db if db is not None else Database(DEFAULT_DB_PATH)
574
597
  token = token or secrets.token_urlsafe(32)
575
598
  manager = ScanManager(database)
@@ -601,6 +624,8 @@ def create_app(db: Optional[Database] = None, token: Optional[str] = None) -> Fa
601
624
  app.include_router(api)
602
625
 
603
626
  _register_websocket(app, manager, token)
627
+ if metrics_sources is not None:
628
+ _register_metrics_route(app, database, token, metrics_sources)
604
629
  _register_error_handlers(app)
605
630
  _install_openapi(app)
606
631
 
@@ -69,10 +69,11 @@ def run_dashboard(
69
69
  port: int = 8080,
70
70
  open_browser: bool = True,
71
71
  db_path: Optional[str] = None,
72
+ metrics_sources: Optional[int] = None,
72
73
  ) -> None:
73
74
  database = Database(Path(db_path) if db_path else DEFAULT_DB_PATH)
74
75
  token = _access_token()
75
- app = create_app(database, token)
76
+ app = create_app(database, token, metrics_sources=metrics_sources)
76
77
  # The token goes in the fragment, which browsers never send to the server,
77
78
  # so it stays out of access logs and proxies.
78
79
  url = f"http://{_display_host(host)}:{port}/#token={token}"
@@ -94,6 +95,11 @@ def run_dashboard(
94
95
  threading.Timer(1.0, webbrowser.open, args=(url,)).start()
95
96
 
96
97
  ui.note("The link carries this session's access token; the API refuses requests without it.")
98
+ if metrics_sources is not None:
99
+ ui.note(
100
+ f"Prometheus metrics at http://{_display_host(host)}:{port}/metrics "
101
+ "(send the token as a Bearer credential; pin it with IPMG_WEB_TOKEN)."
102
+ )
97
103
  if _is_loopback(host):
98
104
  ui.note(
99
105
  f"Remote access: ssh -L {port}:127.0.0.1:{port} user@this-host, then open the link."
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ipmg
3
- Version: 3.0.0
3
+ Version: 3.1.0
4
4
  Summary: IP Management & Ping Monitoring CLI Tool
5
5
  Author: Sameer Alam
6
6
  Maintainer-email: Sameer Alam <sameeralam3127@gmail.com>
@@ -558,6 +558,57 @@ your own scripts. The [API guide](https://github.com/sameeralam3127/ipmg/blob/ma
558
558
  covers authentication, errors, and worked `curl` examples; the running server
559
559
  also serves interactive docs at `http://127.0.0.1:8080/docs`.
560
560
 
561
+ ### Prometheus metrics
562
+
563
+ `ipmg web --metrics` serves `/metrics` in the Prometheus text format, so the
564
+ results of every scan, whether run from cron with the CLI or from the browser,
565
+ land in the dashboards and alerts you already have. It reports the latest
566
+ completed scan of each source, and it is off unless you pass the flag:
567
+
568
+ ```bash
569
+ IPMG_WEB_TOKEN=$(cat /etc/ipmg/token) ipmg web --metrics --no-browser
570
+ ```
571
+
572
+ ```yaml
573
+ # prometheus.yml: the token is the same one the web UI uses
574
+ scrape_configs:
575
+ - job_name: ipmg
576
+ authorization:
577
+ credentials_file: /etc/prometheus/ipmg-token
578
+ static_configs:
579
+ - targets: ["127.0.0.1:8080"]
580
+
581
+ # an alert rule: a host that stopped answering
582
+ groups:
583
+ - name: ipmg
584
+ rules:
585
+ - alert: HostDown
586
+ expr: ipmg_host_up == 0
587
+ for: 10m
588
+ annotations:
589
+ summary: "{{ $labels.ip }} ({{ $labels.source }}) is not answering ping"
590
+ ```
591
+
592
+ | Metric | Labels | Meaning |
593
+ | --- | --- | --- |
594
+ | `ipmg_host_up` | `source`, `ip` | 1 if the host answered, else 0 |
595
+ | `ipmg_host_latency_seconds` | `source`, `ip` | Round-trip time, for hosts that answered |
596
+ | `ipmg_host_open_ports` | `source`, `ip` | Open TCP ports, for scans run with `--scan-ports` |
597
+ | `ipmg_host_info` | `source`, `ip`, `hostname` | Always 1; carries the reverse DNS name |
598
+ | `ipmg_scan_hosts` | `source`, `status` | Hosts in the latest scan, by status |
599
+ | `ipmg_scan_duration_seconds` | `source` | How long the latest scan took |
600
+ | `ipmg_scan_timestamp_seconds` | `source` | When the latest scan finished; alert on `time() - this` to catch a stalled cron job |
601
+ | `ipmg_scans_total` | `source` | Completed scans stored for the source |
602
+ | `ipmg_metrics_sources_omitted` | | Sources left out by `--metrics-sources` |
603
+ | `ipmg_build_info` | `version` | Always 1 |
604
+
605
+ **Cardinality is bounded.** Host series carry only `source` and `ip`, never
606
+ the hostname or status, which change, so a rename does not start a new series.
607
+ Only the `--metrics-sources` most recently scanned sources are exported
608
+ (default 20), and a scan holds at most 65,536 hosts, so the page stays at
609
+ about `sources × hosts × 4` series at most. Give recurring scans a stable
610
+ input (the same file) so each one updates its source rather than adding one.
611
+
561
612
  ### On a server with no browser
562
613
 
563
614
  On a Linux server with no display (e.g. accessed over plain SSH), IPMG detects
@@ -32,6 +32,7 @@ src/ipmg/reporting/diff_report.py
32
32
  src/ipmg/reporting/frames.py
33
33
  src/ipmg/reporting/live.py
34
34
  src/ipmg/reporting/machine.py
35
+ src/ipmg/reporting/metrics.py
35
36
  src/ipmg/reporting/summary.py
36
37
  src/ipmg/reporting/ui.py
37
38
  src/ipmg/services/__init__.py
@@ -67,6 +68,7 @@ tests/test_incremental.py
67
68
  tests/test_ipv6.py
68
69
  tests/test_live.py
69
70
  tests/test_machine_output.py
71
+ tests/test_metrics.py
70
72
  tests/test_notify.py
71
73
  tests/test_parser.py
72
74
  tests/test_ping.py
@@ -208,7 +208,15 @@ def test_both_spellings_of_web_start_it(argv, monkeypatch):
208
208
  calls = web_calls(monkeypatch)
209
209
 
210
210
  assert commands.run(argv) == commands.EXIT_OK
211
- assert calls == [{"host": "127.0.0.1", "port": 8080, "open_browser": True, "db_path": None}]
211
+ assert calls == [
212
+ {
213
+ "host": "127.0.0.1",
214
+ "port": 8080,
215
+ "open_browser": True,
216
+ "db_path": None,
217
+ "metrics_sources": None,
218
+ }
219
+ ]
212
220
 
213
221
 
214
222
  def test_web_flag_still_takes_the_web_options(monkeypatch):
@@ -0,0 +1,162 @@
1
+ """The Prometheus /metrics endpoint and the text it serves."""
2
+
3
+ import re
4
+
5
+ import pytest
6
+ from fastapi.testclient import TestClient
7
+
8
+ from ipmg import __version__
9
+ from ipmg.cli.parser import build_web_parser
10
+ from ipmg.core.engine import HostResult, ScanConfig
11
+ from ipmg.infrastructure.database import Database
12
+ from ipmg.reporting.metrics import CONTENT_TYPE, render_metrics
13
+ from ipmg.services.history_service import HistoryService
14
+ from ipmg.web.app import create_app
15
+
16
+
17
+ @pytest.fixture()
18
+ def database(tmp_path):
19
+ return Database(tmp_path / "metrics.db")
20
+
21
+
22
+ def record(database, source, results, scan_ports=False, duration=2.5):
23
+ return HistoryService(database).record_scan(
24
+ source=source,
25
+ results=results,
26
+ config=ScanConfig(scan_ports=scan_ports),
27
+ duration_s=duration,
28
+ )
29
+
30
+
31
+ def samples(text):
32
+ """{series: value} for every sample line, ignoring HELP and TYPE."""
33
+ found = {}
34
+ for line in text.splitlines():
35
+ if line and not line.startswith("#"):
36
+ series, value = line.rsplit(" ", 1)
37
+ found[series] = float(value)
38
+ return found
39
+
40
+
41
+ # ------------------------------------------------------------------ content
42
+
43
+
44
+ def test_hosts_are_reported_from_the_latest_scan_of_each_source(database):
45
+ record(database, "targets.txt", [HostResult("10.0.0.1", "Timeout", None)])
46
+ record(
47
+ database,
48
+ "targets.txt",
49
+ [HostResult("10.0.0.1", "Active", 12.5, "gw.lan"), HostResult("10.0.0.2", "Timeout", None)],
50
+ duration=4.0,
51
+ )
52
+
53
+ metrics = samples(render_metrics(database))
54
+
55
+ assert metrics['ipmg_host_up{source="targets.txt",ip="10.0.0.1"}'] == 1
56
+ assert metrics['ipmg_host_up{source="targets.txt",ip="10.0.0.2"}'] == 0
57
+ assert metrics['ipmg_host_latency_seconds{source="targets.txt",ip="10.0.0.1"}'] == 0.0125
58
+ assert 'ipmg_host_latency_seconds{source="targets.txt",ip="10.0.0.2"}' not in metrics
59
+ assert metrics['ipmg_host_info{source="targets.txt",ip="10.0.0.1",hostname="gw.lan"}'] == 1
60
+ assert metrics['ipmg_scan_hosts{source="targets.txt",status="Active"}'] == 1
61
+ assert metrics['ipmg_scan_hosts{source="targets.txt",status="Timeout"}'] == 1
62
+ assert metrics['ipmg_scan_duration_seconds{source="targets.txt"}'] == 4.0
63
+ assert metrics['ipmg_scans_total{source="targets.txt"}'] == 2
64
+ assert metrics['ipmg_scan_timestamp_seconds{source="targets.txt"}'] > 1.7e9
65
+ assert metrics[f'ipmg_build_info{{version="{__version__}"}}'] == 1
66
+
67
+
68
+ def test_each_source_keeps_its_own_latest_scan(database):
69
+ record(database, "office.txt", [HostResult("10.0.0.1", "Active", 1.0)])
70
+ record(database, "lab.txt", [HostResult("10.0.0.1", "Timeout", None)])
71
+
72
+ metrics = samples(render_metrics(database))
73
+
74
+ assert metrics['ipmg_host_up{source="office.txt",ip="10.0.0.1"}'] == 1
75
+ assert metrics['ipmg_host_up{source="lab.txt",ip="10.0.0.1"}'] == 0
76
+
77
+
78
+ def test_open_ports_only_appear_for_scans_that_checked_ports(database):
79
+ record(
80
+ database, "ports.txt", [HostResult("10.0.0.1", "Active", 1.0, open_ports=(22, 443))], True
81
+ )
82
+ record(database, "plain.txt", [HostResult("10.0.0.2", "Active", 1.0)])
83
+
84
+ metrics = samples(render_metrics(database))
85
+
86
+ assert metrics['ipmg_host_open_ports{source="ports.txt",ip="10.0.0.1"}'] == 2
87
+ assert not any(key.startswith('ipmg_host_open_ports{source="plain.txt"') for key in metrics)
88
+
89
+
90
+ def test_running_and_failed_scans_are_not_reported(database):
91
+ running = database.create_scan("live.txt", 1, {})
92
+ database.add_result(running, HostResult("10.0.0.1", "Active", 1.0))
93
+
94
+ assert "live.txt" not in render_metrics(database)
95
+
96
+
97
+ def test_the_source_limit_bounds_the_series_and_says_what_it_left_out(database):
98
+ for name in ("a.txt", "b.txt", "c.txt"):
99
+ record(database, name, [HostResult("10.0.0.1", "Active", 1.0)])
100
+
101
+ text = render_metrics(database, max_sources=2)
102
+
103
+ assert 'source="c.txt"' in text and 'source="b.txt"' in text # the newest two
104
+ assert 'source="a.txt"' not in text
105
+ assert samples(text)["ipmg_metrics_sources_omitted"] == 1
106
+
107
+
108
+ def test_label_values_are_escaped(database):
109
+ record(database, 'odd "name"\\path\nnext', [HostResult("10.0.0.1", "Active", 1.0)])
110
+
111
+ assert 'source="odd \\"name\\"\\\\path\\nnext"' in render_metrics(database)
112
+
113
+
114
+ def test_every_family_is_declared_once_even_when_empty(database):
115
+ text = render_metrics(database)
116
+
117
+ assert len(re.findall(r"^# TYPE ipmg_host_up gauge$", text, re.MULTILINE)) == 1
118
+ assert "# TYPE ipmg_scans_total counter" in text
119
+ assert text.endswith("\n")
120
+ assert samples(text)["ipmg_metrics_sources_omitted"] == 0
121
+
122
+
123
+ def test_ipv6_hosts_are_labelled_as_written(database):
124
+ record(database, "v6.txt", [HostResult("fe80::1%eth0", "Active", 1.0)])
125
+
126
+ assert 'ipmg_host_up{source="v6.txt",ip="fe80::1%eth0"} 1' in render_metrics(database)
127
+
128
+
129
+ # ------------------------------------------------------------------ endpoint
130
+
131
+
132
+ def test_metrics_are_off_unless_asked_for(database):
133
+ with TestClient(create_app(database, token="t" * 20)) as client:
134
+ response = client.get("/metrics", headers={"Authorization": "Bearer " + "t" * 20})
135
+
136
+ assert response.status_code == 404
137
+
138
+
139
+ def test_the_endpoint_serves_prometheus_text_with_the_token(database):
140
+ record(database, "targets.txt", [HostResult("10.0.0.1", "Active", 1.0)])
141
+ app = create_app(database, token="t" * 20, metrics_sources=5)
142
+
143
+ with TestClient(app) as client:
144
+ anonymous = client.get("/metrics")
145
+ scraped = client.get("/metrics", headers={"Authorization": "Bearer " + "t" * 20})
146
+
147
+ assert anonymous.status_code == 401
148
+ assert scraped.status_code == 200
149
+ assert scraped.headers["content-type"] == CONTENT_TYPE
150
+ assert 'ipmg_host_up{source="targets.txt",ip="10.0.0.1"} 1' in scraped.text
151
+
152
+
153
+ def test_the_endpoint_stays_out_of_the_api_schema(database):
154
+ app = create_app(database, token="t" * 20, metrics_sources=5)
155
+
156
+ assert "/metrics" not in app.openapi()["paths"]
157
+
158
+
159
+ def test_web_parser_metrics_flags():
160
+ assert build_web_parser().parse_args([]).metrics is False
161
+ args = build_web_parser().parse_args(["--metrics", "--metrics-sources", "3"])
162
+ assert (args.metrics, args.metrics_sources) == (True, 3)
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes