cctv-stack 0.3.1__tar.gz → 0.5.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.
- {cctv_stack-0.3.1 → cctv_stack-0.5.0}/.gitignore +10 -0
- {cctv_stack-0.3.1 → cctv_stack-0.5.0}/PKG-INFO +13 -7
- {cctv_stack-0.3.1 → cctv_stack-0.5.0}/README.md +12 -6
- {cctv_stack-0.3.1 → cctv_stack-0.5.0}/pyproject.toml +3 -1
- {cctv_stack-0.3.1 → cctv_stack-0.5.0}/src/cctv/__init__.py +1 -1
- {cctv_stack-0.3.1 → cctv_stack-0.5.0}/src/cctv/db.py +106 -0
- {cctv_stack-0.3.1 → cctv_stack-0.5.0}/src/cctv/health.py +39 -1
- cctv_stack-0.5.0/src/cctv/host.py +490 -0
- cctv_stack-0.5.0/src/cctv/host_cli.py +121 -0
- cctv_stack-0.5.0/src/cctv/migrations/0003_disk_samples.sql +16 -0
- cctv_stack-0.5.0/src/cctv/monitoring.py +332 -0
- cctv_stack-0.5.0/src/cctv/status.py +1164 -0
- cctv_stack-0.5.0/src/cctv/status_service.py +359 -0
- {cctv_stack-0.3.1 → cctv_stack-0.5.0}/LICENSE +0 -0
- {cctv_stack-0.3.1 → cctv_stack-0.5.0}/src/cctv/atomic.py +0 -0
- {cctv_stack-0.3.1 → cctv_stack-0.5.0}/src/cctv/cleanup.py +0 -0
- {cctv_stack-0.3.1 → cctv_stack-0.5.0}/src/cctv/clip.py +0 -0
- {cctv_stack-0.3.1 → cctv_stack-0.5.0}/src/cctv/config.py +0 -0
- {cctv_stack-0.3.1 → cctv_stack-0.5.0}/src/cctv/config_cli.py +0 -0
- {cctv_stack-0.3.1 → cctv_stack-0.5.0}/src/cctv/dispatch.py +0 -0
- {cctv_stack-0.3.1 → cctv_stack-0.5.0}/src/cctv/ffprobe.py +0 -0
- {cctv_stack-0.3.1 → cctv_stack-0.5.0}/src/cctv/frigate.py +0 -0
- {cctv_stack-0.3.1 → cctv_stack-0.5.0}/src/cctv/logging.py +0 -0
- {cctv_stack-0.3.1 → cctv_stack-0.5.0}/src/cctv/materialise.py +0 -0
- {cctv_stack-0.3.1 → cctv_stack-0.5.0}/src/cctv/migrations/0001_init.sql +0 -0
- {cctv_stack-0.3.1 → cctv_stack-0.5.0}/src/cctv/migrations/0002_finding_kinds.sql +0 -0
- {cctv_stack-0.3.1 → cctv_stack-0.5.0}/src/cctv/mqtt.py +0 -0
- {cctv_stack-0.3.1 → cctv_stack-0.5.0}/src/cctv/paths.py +0 -0
- {cctv_stack-0.3.1 → cctv_stack-0.5.0}/src/cctv/reviews.py +0 -0
- {cctv_stack-0.3.1 → cctv_stack-0.5.0}/src/cctv/storage.py +0 -0
- {cctv_stack-0.3.1 → cctv_stack-0.5.0}/src/cctv/sweep.py +0 -0
- {cctv_stack-0.3.1 → cctv_stack-0.5.0}/src/cctv/timeutil.py +0 -0
- {cctv_stack-0.3.1 → cctv_stack-0.5.0}/src/cctv/uploader.py +0 -0
- {cctv_stack-0.3.1 → cctv_stack-0.5.0}/src/cctv/worker.py +0 -0
|
@@ -5,6 +5,9 @@ __pycache__/
|
|
|
5
5
|
build/
|
|
6
6
|
dist/
|
|
7
7
|
|
|
8
|
+
# mkdocs output. Generated by `uv run mkdocs build`; the published copy is built by CI.
|
|
9
|
+
site/
|
|
10
|
+
|
|
8
11
|
# Environments
|
|
9
12
|
.venv/
|
|
10
13
|
venv/
|
|
@@ -39,6 +42,13 @@ data/
|
|
|
39
42
|
# is the site's file, not the repository's. `cameras.example.yml` is committed; this is not.
|
|
40
43
|
config/cameras.yml
|
|
41
44
|
|
|
45
|
+
# The generated monitoring config. It carries the heartbeat token and is derived from
|
|
46
|
+
# cameras.yml at start-up, so editing it by hand is wasted work as well.
|
|
47
|
+
config/gatus/
|
|
48
|
+
|
|
49
|
+
# Generated by install.sh from probes of this machine (lld/07 §5).
|
|
50
|
+
config/host.yml
|
|
51
|
+
|
|
42
52
|
# Generated Frigate config, plus Frigate's own runtime state, because the directory is
|
|
43
53
|
# mounted as its /config.
|
|
44
54
|
#
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: cctv-stack
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.0
|
|
4
4
|
Summary: A packaging-first self-hosted CCTV stack built on Frigate.
|
|
5
5
|
Project-URL: Homepage, https://github.com/aravindmyd/cctv-stack
|
|
6
6
|
Project-URL: Repository, https://github.com/aravindmyd/cctv-stack
|
|
@@ -252,9 +252,9 @@ adds the four pieces it leaves to the operator:
|
|
|
252
252
|
|
|
253
253
|
It is not a replacement for Frigate. It is the missing layer around it.
|
|
254
254
|
|
|
255
|
-
**Current release: `0.
|
|
256
|
-
implemented. Cloud retention
|
|
257
|
-
not — see [`CONTRIBUTING.md`](CONTRIBUTING.md) for the exact state and roadmap.
|
|
255
|
+
**Current release: `0.5.0`.** Clips, B2 upload, local retention, the aggregate status page
|
|
256
|
+
and alerting through Gatus/ntfy are implemented. Cloud retention and the one-command
|
|
257
|
+
installer are not — see [`CONTRIBUTING.md`](CONTRIBUTING.md) for the exact state and roadmap.
|
|
258
258
|
|
|
259
259
|
---
|
|
260
260
|
|
|
@@ -281,10 +281,10 @@ pip install "cctv-stack[mqtt,storage]"
|
|
|
281
281
|
### Container image
|
|
282
282
|
|
|
283
283
|
```bash
|
|
284
|
-
docker pull ghcr.io/aravindmyd/cctv-stack:0.
|
|
284
|
+
docker pull ghcr.io/aravindmyd/cctv-stack:0.5.0
|
|
285
285
|
```
|
|
286
286
|
|
|
287
|
-
Tags published per release: `:0.
|
|
287
|
+
Tags published per release: `:0.5.0`, `:0.5`, and the commit tag `:sha-<short>`.
|
|
288
288
|
A moving `:edge` tag follows `master`. If the registry package is private, run
|
|
289
289
|
`docker login ghcr.io` first. The image includes `ffmpeg`/`ffprobe`.
|
|
290
290
|
|
|
@@ -297,10 +297,16 @@ A moving `:edge` tag follows `master`. If the registry package is private, run
|
|
|
297
297
|
| `cctv-clip-worker` | Turn completed Frigate reviews into canonical clips |
|
|
298
298
|
| `cctv-uploader` | Upload clips to B2 through the persisted retry state machine |
|
|
299
299
|
| `cctv-cleanup` | Enforce local retention after a successful upload |
|
|
300
|
+
| `cctv-status` | Serve the aggregate status page (`:8084/status`), health and metrics |
|
|
301
|
+
| `cctv-host` | Detect this machine's host profile and Frigate's `shm_size` |
|
|
300
302
|
|
|
301
|
-
The
|
|
303
|
+
The four services are long-running and configuration-driven; they take no command-line
|
|
302
304
|
arguments. Run them under systemd, Docker, or Compose.
|
|
303
305
|
|
|
306
|
+
With Compose, the answer to *is my CCTV working?* is at
|
|
307
|
+
`http://<host>:8084/status`; the dashboard and history are at `http://<host>:3001` (Gatus),
|
|
308
|
+
and alerts arrive through ntfy at `http://<host>:8085`.
|
|
309
|
+
|
|
304
310
|
## Quick start
|
|
305
311
|
|
|
306
312
|
The only required input is a `cameras.yml`. This minimal file is valid; `${VAR}` values
|
|
@@ -18,9 +18,9 @@ adds the four pieces it leaves to the operator:
|
|
|
18
18
|
|
|
19
19
|
It is not a replacement for Frigate. It is the missing layer around it.
|
|
20
20
|
|
|
21
|
-
**Current release: `0.
|
|
22
|
-
implemented. Cloud retention
|
|
23
|
-
not — see [`CONTRIBUTING.md`](CONTRIBUTING.md) for the exact state and roadmap.
|
|
21
|
+
**Current release: `0.5.0`.** Clips, B2 upload, local retention, the aggregate status page
|
|
22
|
+
and alerting through Gatus/ntfy are implemented. Cloud retention and the one-command
|
|
23
|
+
installer are not — see [`CONTRIBUTING.md`](CONTRIBUTING.md) for the exact state and roadmap.
|
|
24
24
|
|
|
25
25
|
---
|
|
26
26
|
|
|
@@ -47,10 +47,10 @@ pip install "cctv-stack[mqtt,storage]"
|
|
|
47
47
|
### Container image
|
|
48
48
|
|
|
49
49
|
```bash
|
|
50
|
-
docker pull ghcr.io/aravindmyd/cctv-stack:0.
|
|
50
|
+
docker pull ghcr.io/aravindmyd/cctv-stack:0.5.0
|
|
51
51
|
```
|
|
52
52
|
|
|
53
|
-
Tags published per release: `:0.
|
|
53
|
+
Tags published per release: `:0.5.0`, `:0.5`, and the commit tag `:sha-<short>`.
|
|
54
54
|
A moving `:edge` tag follows `master`. If the registry package is private, run
|
|
55
55
|
`docker login ghcr.io` first. The image includes `ffmpeg`/`ffprobe`.
|
|
56
56
|
|
|
@@ -63,10 +63,16 @@ A moving `:edge` tag follows `master`. If the registry package is private, run
|
|
|
63
63
|
| `cctv-clip-worker` | Turn completed Frigate reviews into canonical clips |
|
|
64
64
|
| `cctv-uploader` | Upload clips to B2 through the persisted retry state machine |
|
|
65
65
|
| `cctv-cleanup` | Enforce local retention after a successful upload |
|
|
66
|
+
| `cctv-status` | Serve the aggregate status page (`:8084/status`), health and metrics |
|
|
67
|
+
| `cctv-host` | Detect this machine's host profile and Frigate's `shm_size` |
|
|
66
68
|
|
|
67
|
-
The
|
|
69
|
+
The four services are long-running and configuration-driven; they take no command-line
|
|
68
70
|
arguments. Run them under systemd, Docker, or Compose.
|
|
69
71
|
|
|
72
|
+
With Compose, the answer to *is my CCTV working?* is at
|
|
73
|
+
`http://<host>:8084/status`; the dashboard and history are at `http://<host>:3001` (Gatus),
|
|
74
|
+
and alerts arrive through ntfy at `http://<host>:8085`.
|
|
75
|
+
|
|
70
76
|
## Quick start
|
|
71
77
|
|
|
72
78
|
The only required input is a `cameras.yml`. This minimal file is valid; `${VAR}` values
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "cctv-stack"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.5.0"
|
|
8
8
|
description = "A packaging-first self-hosted CCTV stack built on Frigate."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.12"
|
|
@@ -74,6 +74,8 @@ cctv-clip-worker = "cctv.worker:main"
|
|
|
74
74
|
cctv-config = "cctv.config_cli:main"
|
|
75
75
|
cctv-uploader = "cctv.uploader:main"
|
|
76
76
|
cctv-cleanup = "cctv.cleanup:main"
|
|
77
|
+
cctv-status = "cctv.status_service:main"
|
|
78
|
+
cctv-host = "cctv.host_cli:main"
|
|
77
79
|
|
|
78
80
|
[project.urls]
|
|
79
81
|
Homepage = "https://github.com/aravindmyd/cctv-stack"
|
|
@@ -1476,3 +1476,109 @@ def overdue_local(conn: sqlite3.Connection, *, camera: str, cutoff: Instant) ->
|
|
|
1476
1476
|
None if row["oldest"] is None else max(0.0, cutoff - float(row["oldest"]))
|
|
1477
1477
|
),
|
|
1478
1478
|
)
|
|
1479
|
+
|
|
1480
|
+
|
|
1481
|
+
# ---------------------------------------------------------------------------------------
|
|
1482
|
+
# Status-collector reads. All read-only, all bounded, none of them a table scan on every
|
|
1483
|
+
# scrape: the collector polls these on its own cadence, and a status page that is expensive
|
|
1484
|
+
# to render is a status page that hides problems behind its own latency.
|
|
1485
|
+
# ---------------------------------------------------------------------------------------
|
|
1486
|
+
|
|
1487
|
+
|
|
1488
|
+
def last_clip_per_camera(conn: sqlite3.Connection) -> dict[str, Instant]:
|
|
1489
|
+
"""The most recent clip start time for each camera that has ever produced one.
|
|
1490
|
+
|
|
1491
|
+
A camera absent from this mapping has never produced a clip. That is different from a
|
|
1492
|
+
camera whose last clip is old, and the status page must not conflate them: a camera added
|
|
1493
|
+
ten minutes ago is not "quiet since 1970".
|
|
1494
|
+
"""
|
|
1495
|
+
return {
|
|
1496
|
+
str(row["camera"]): float(row["last"])
|
|
1497
|
+
for row in conn.execute("SELECT camera, MAX(start_time) AS last FROM clips GROUP BY camera")
|
|
1498
|
+
}
|
|
1499
|
+
|
|
1500
|
+
|
|
1501
|
+
def clip_start_times_since(
|
|
1502
|
+
conn: sqlite3.Connection, *, since: Instant, limit_per_camera: int = 500
|
|
1503
|
+
) -> dict[str, list[Instant]]:
|
|
1504
|
+
"""Clip start times per camera since `since`, newest first.
|
|
1505
|
+
|
|
1506
|
+
The silent-camera check (lld/06 §3.4) needs the *typical* gap between clips, which is a
|
|
1507
|
+
median over history rather than a count. One clip per minute for a busy doorbell would
|
|
1508
|
+
make an unbounded query grow with footage; `limit_per_camera` keeps the query's cost flat
|
|
1509
|
+
while leaving far more samples than a median needs.
|
|
1510
|
+
"""
|
|
1511
|
+
out: dict[str, list[Instant]] = {}
|
|
1512
|
+
for row in conn.execute(
|
|
1513
|
+
"SELECT camera, start_time FROM ("
|
|
1514
|
+
" SELECT camera, start_time,"
|
|
1515
|
+
" ROW_NUMBER() OVER (PARTITION BY camera ORDER BY start_time DESC) AS n"
|
|
1516
|
+
" FROM clips WHERE start_time >= ?"
|
|
1517
|
+
") WHERE n <= ? ORDER BY camera, start_time DESC",
|
|
1518
|
+
(since, limit_per_camera),
|
|
1519
|
+
):
|
|
1520
|
+
out.setdefault(str(row["camera"]), []).append(float(row["start_time"]))
|
|
1521
|
+
return out
|
|
1522
|
+
|
|
1523
|
+
|
|
1524
|
+
@dataclass(frozen=True, slots=True)
|
|
1525
|
+
class DiskSample:
|
|
1526
|
+
at: Instant
|
|
1527
|
+
free_bytes: int
|
|
1528
|
+
clips_bytes: int
|
|
1529
|
+
media_bytes: int
|
|
1530
|
+
|
|
1531
|
+
|
|
1532
|
+
def record_disk_sample(
|
|
1533
|
+
conn: sqlite3.Connection,
|
|
1534
|
+
*,
|
|
1535
|
+
at: Instant,
|
|
1536
|
+
free_bytes: int,
|
|
1537
|
+
clips_bytes: int,
|
|
1538
|
+
media_bytes: int,
|
|
1539
|
+
) -> None:
|
|
1540
|
+
"""Upsert one sample. Keyed on `at`, so a retried collection cannot duplicate history."""
|
|
1541
|
+
with write_txn(conn):
|
|
1542
|
+
conn.execute(
|
|
1543
|
+
"INSERT OR REPLACE INTO disk_samples (at, free_bytes, clips_bytes, media_bytes) "
|
|
1544
|
+
"VALUES (?, ?, ?, ?)",
|
|
1545
|
+
(at, free_bytes, clips_bytes, media_bytes),
|
|
1546
|
+
)
|
|
1547
|
+
|
|
1548
|
+
|
|
1549
|
+
def disk_samples_since(conn: sqlite3.Connection, *, since: Instant) -> list[DiskSample]:
|
|
1550
|
+
"""Samples from `since` onwards, oldest first. The storage projection's only input."""
|
|
1551
|
+
return [
|
|
1552
|
+
DiskSample(
|
|
1553
|
+
at=float(row["at"]),
|
|
1554
|
+
free_bytes=int(row["free_bytes"]),
|
|
1555
|
+
clips_bytes=int(row["clips_bytes"]),
|
|
1556
|
+
media_bytes=int(row["media_bytes"]),
|
|
1557
|
+
)
|
|
1558
|
+
for row in conn.execute(
|
|
1559
|
+
"SELECT at, free_bytes, clips_bytes, media_bytes FROM disk_samples "
|
|
1560
|
+
"WHERE at >= ? ORDER BY at",
|
|
1561
|
+
(since,),
|
|
1562
|
+
)
|
|
1563
|
+
]
|
|
1564
|
+
|
|
1565
|
+
|
|
1566
|
+
def prune_disk_samples(conn: sqlite3.Connection, *, before: Instant) -> int:
|
|
1567
|
+
"""Delete samples older than `before`. Returns how many rows went."""
|
|
1568
|
+
with write_txn(conn):
|
|
1569
|
+
cur = conn.execute("DELETE FROM disk_samples WHERE at < ?", (before,))
|
|
1570
|
+
return int(cur.rowcount or 0)
|
|
1571
|
+
|
|
1572
|
+
|
|
1573
|
+
def last_cleanup_run_at(conn: sqlite3.Connection) -> Instant | None:
|
|
1574
|
+
"""When retention last completed a pass, from the row `cctv-cleanup` writes.
|
|
1575
|
+
|
|
1576
|
+
Read here rather than derived from `clips`, because "retention is running" is a fact only
|
|
1577
|
+
the cleanup service knows; a database with nothing to delete looks identical whether
|
|
1578
|
+
cleanup is working or dead.
|
|
1579
|
+
"""
|
|
1580
|
+
row = conn.execute(
|
|
1581
|
+
"SELECT COALESCE(last_success_at, last_run_at) AS at FROM sweep_state "
|
|
1582
|
+
"WHERE name = 'cleanup_local'"
|
|
1583
|
+
).fetchone()
|
|
1584
|
+
return None if row is None or row["at"] is None else float(row["at"])
|
|
@@ -19,6 +19,7 @@ Endpoints, per
|
|
|
19
19
|
```text
|
|
20
20
|
GET /health JSON report; ?strict=1 makes DEGRADED/UNKNOWN return 503
|
|
21
21
|
GET /metrics Prometheus text exposition
|
|
22
|
+
GET /status aggregate status (status-collector only): JSON, or HTML to a browser
|
|
22
23
|
GET /admin/queue backlog summary (requires INTERNAL_API_TOKEN)
|
|
23
24
|
POST /admin/retry requeue failed clips (requires INTERNAL_API_TOKEN and a reason)
|
|
24
25
|
```
|
|
@@ -76,11 +77,13 @@ CODES = frozenset(
|
|
|
76
77
|
{
|
|
77
78
|
"BACKLOG_AGING",
|
|
78
79
|
"CAMERA_OFFLINE",
|
|
80
|
+
"CAMERA_QUIET",
|
|
79
81
|
"CLEANUP_STALE",
|
|
80
82
|
"CLIPS_OVERDUE_RETENTION",
|
|
81
83
|
"CLIP_COVERAGE_LOW",
|
|
82
84
|
"CLIP_FLUSH_PRESSURE",
|
|
83
85
|
"CLIP_HAS_GAPS",
|
|
86
|
+
"COLLECTOR_STALE",
|
|
84
87
|
"DB_INTEGRITY_FAILED",
|
|
85
88
|
"DB_UNAVAILABLE",
|
|
86
89
|
"DETECTION_FALLING_BEHIND",
|
|
@@ -304,6 +307,9 @@ class _Handler(BaseHTTPRequestHandler):
|
|
|
304
307
|
if path.path == "/metrics":
|
|
305
308
|
self._metrics()
|
|
306
309
|
return
|
|
310
|
+
if path.path == "/status":
|
|
311
|
+
self._status()
|
|
312
|
+
return
|
|
307
313
|
if path.path == "/admin/queue":
|
|
308
314
|
self._admin_queue()
|
|
309
315
|
return
|
|
@@ -326,6 +332,24 @@ class _Handler(BaseHTTPRequestHandler):
|
|
|
326
332
|
gauges = self._config.gauges() if self._config.gauges else None
|
|
327
333
|
self._write(200, render_metrics(report, gauges=gauges).encode("utf-8"), "text/plain")
|
|
328
334
|
|
|
335
|
+
def _status(self) -> None:
|
|
336
|
+
"""Aggregate status: HTML for a browser, JSON for everything else.
|
|
337
|
+
|
|
338
|
+
Negotiated on `Accept` rather than a separate path, so a monitor and a human can be
|
|
339
|
+
given the same URL and neither has to know the other exists. A service that did not
|
|
340
|
+
mount the route (every service but `status-collector`) answers 404 rather than an
|
|
341
|
+
empty page, which is the honest thing to say.
|
|
342
|
+
"""
|
|
343
|
+
if self._config.status is None:
|
|
344
|
+
self._json(HTTPStatus.NOT_FOUND, {"error": "no aggregate status on this service"})
|
|
345
|
+
return
|
|
346
|
+
wants_html = "text/html" in (self.headers.get("Accept") or "")
|
|
347
|
+
if wants_html and self._config.status_html is not None:
|
|
348
|
+
body = self._config.status_html().encode("utf-8")
|
|
349
|
+
self._write(200, body, "text/html; charset=utf-8")
|
|
350
|
+
return
|
|
351
|
+
self._json(200, dict(self._config.status()))
|
|
352
|
+
|
|
329
353
|
def _admin_guard(self) -> bool:
|
|
330
354
|
if self._config.admin is None or not self._config.token:
|
|
331
355
|
self._json(
|
|
@@ -385,12 +409,16 @@ class HealthServer(ThreadingHTTPServer):
|
|
|
385
409
|
admin: AdminRoutes | None = None,
|
|
386
410
|
token: str | None = None,
|
|
387
411
|
gauges: Callable[[], Mapping[str, float | int]] | None = None,
|
|
412
|
+
status: Callable[[], Mapping[str, Any]] | None = None,
|
|
413
|
+
status_html: Callable[[], str] | None = None,
|
|
388
414
|
) -> None:
|
|
389
415
|
super().__init__(address, _Handler)
|
|
390
416
|
self.snapshot = snapshot
|
|
391
417
|
self.admin = admin
|
|
392
418
|
self.token = token
|
|
393
419
|
self.gauges = gauges
|
|
420
|
+
self.status = status
|
|
421
|
+
self.status_html = status_html
|
|
394
422
|
|
|
395
423
|
|
|
396
424
|
def start_health_server(
|
|
@@ -401,13 +429,23 @@ def start_health_server(
|
|
|
401
429
|
admin: AdminRoutes | None = None,
|
|
402
430
|
token: str | None = None,
|
|
403
431
|
gauges: Callable[[], Mapping[str, float | int]] | None = None,
|
|
432
|
+
status: Callable[[], Mapping[str, Any]] | None = None,
|
|
433
|
+
status_html: Callable[[], str] | None = None,
|
|
404
434
|
) -> tuple[HealthServer, threading.Thread]:
|
|
405
435
|
"""Start serving in a background thread. Returns the server and its thread.
|
|
406
436
|
|
|
407
437
|
Port `0` means "disabled" — a deployment that does not want the surface pays nothing for
|
|
408
438
|
it, and the caller logs that choice rather than leaving it implicit.
|
|
409
439
|
"""
|
|
410
|
-
server = HealthServer(
|
|
440
|
+
server = HealthServer(
|
|
441
|
+
(host, port),
|
|
442
|
+
snapshot=snapshot,
|
|
443
|
+
admin=admin,
|
|
444
|
+
token=token,
|
|
445
|
+
gauges=gauges,
|
|
446
|
+
status=status,
|
|
447
|
+
status_html=status_html,
|
|
448
|
+
)
|
|
411
449
|
thread = threading.Thread(target=server.serve_forever, name="health", daemon=True)
|
|
412
450
|
thread.start()
|
|
413
451
|
return server, thread
|