471 lines
20 KiB
Python
471 lines
20 KiB
Python
"""设备/系统健康日报(每日 09:00):内存/swap/GPU 平均·峰值·最低 + 磁盘余量与目录增长 + 系统级错误。
|
||
|
||
对应 device-check deployment。数据来源:
|
||
- 24h 指标:metrics-agent 侧车容器每 5 分钟采样,写 data/metrics/metrics-YYYY-MM-DD.jsonl(worker 经
|
||
/data/metrics:ro 只读挂载读取;本地调试直接读仓库 data/metrics)
|
||
- 磁盘增长:metrics-agent 每日 du 快照 data/metrics/disk-*.json,日报对比最近两份得"增长最快目录"
|
||
- 采样缺失时降级为 psutil/nvidia-smi 瞬时值,不阻塞报告
|
||
另检查:Docker 容器异常、Prefect 近 24h 失败明细、OCR/PG 探活。超阈值(默认 80%)标 🔴。
|
||
|
||
依赖 compose 挂载:/data/metrics:ro、/:/host:ro、/var/run/docker.sock、GPU;缺失项自动降级。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import os
|
||
import subprocess
|
||
from datetime import datetime, timedelta
|
||
from pathlib import Path
|
||
|
||
import httpx
|
||
import psutil
|
||
from prefect import flow
|
||
|
||
from flows.shared import SyncDB, emit_sync_summary, send_markdown
|
||
|
||
# (容器内路径, 展示名);/host 为 compose 挂载的宿主机根目录
|
||
DISK_PATHS = [("/host", "/"), ("/host/home", "/home")]
|
||
DOCKER_SOCK = "/var/run/docker.sock"
|
||
METRICS_DIR_CANDIDATES = ("/data/metrics", str(Path(__file__).resolve().parents[2] / "data" / "metrics"))
|
||
GPU_TEMP_WARN = 85 # °C
|
||
|
||
_OCR_BASE = "http://192.168.124.92:9801"
|
||
|
||
|
||
def _gb(nbytes: float) -> str:
|
||
return f"{nbytes / (1 << 30):.1f}G"
|
||
|
||
|
||
def _fmt_growth(nbytes: float) -> str:
|
||
if abs(nbytes) >= 1 << 30:
|
||
return f"{nbytes / (1 << 30):+.1f}G"
|
||
return f"{nbytes / (1 << 20):+.0f}M"
|
||
|
||
|
||
def _metrics_dir() -> str | None:
|
||
cands = [os.environ.get("METRICS_DIR"), *METRICS_DIR_CANDIDATES]
|
||
for p in cands:
|
||
if p and os.path.isdir(p):
|
||
return p
|
||
return None
|
||
|
||
|
||
# ---------- 24h 采样聚合 ----------
|
||
|
||
def read_samples(metrics_dir: str | None, hours: int = 24) -> list[dict]:
|
||
"""读取 metrics-agent 的 JSONL 采样,返回窗口内记录(ts 升序)。"""
|
||
if not metrics_dir:
|
||
return []
|
||
now = datetime.now()
|
||
cutoff = now - timedelta(hours=hours)
|
||
out: list[dict] = []
|
||
for i in range(hours // 24 + 1):
|
||
day = (now - timedelta(days=i)).strftime("%Y-%m-%d")
|
||
path = Path(metrics_dir) / f"metrics-{day}.jsonl"
|
||
if not path.is_file():
|
||
continue
|
||
try:
|
||
rows = [json.loads(l) for l in path.read_text(encoding="utf-8").splitlines() if l.strip()]
|
||
except (json.JSONDecodeError, OSError):
|
||
continue
|
||
for r in rows:
|
||
try:
|
||
if datetime.fromisoformat(r["ts"]) >= cutoff:
|
||
out.append(r)
|
||
except (KeyError, ValueError, TypeError):
|
||
continue
|
||
out.sort(key=lambda r: r["ts"])
|
||
return out
|
||
|
||
|
||
def _stats(values: list[float]) -> dict | None:
|
||
if not values:
|
||
return None
|
||
return {"avg": sum(values) / len(values), "peak": max(values), "min": min(values)}
|
||
|
||
|
||
def aggregate_samples(samples: list[dict]) -> dict:
|
||
"""聚合采样:内存/swap 统计 + 按 GPU 的利用率/显存/温度统计。"""
|
||
agg: dict = {"count": len(samples)}
|
||
if not samples:
|
||
return agg
|
||
agg["firstTs"] = samples[0]["ts"]
|
||
mem = [s["memPct"] for s in samples if isinstance(s.get("memPct"), (int, float))]
|
||
swap = [s["swapPct"] for s in samples if isinstance(s.get("swapPct"), (int, float))]
|
||
agg["mem"], agg["swap"] = _stats(mem), _stats(swap)
|
||
agg["memTotal"] = next((s.get("memTotal") for s in reversed(samples) if s.get("memTotal")), None)
|
||
agg["swapTotal"] = next((s.get("swapTotal") for s in reversed(samples) if s.get("swapTotal")), None)
|
||
|
||
gpus: dict[int, dict] = {}
|
||
for s in samples:
|
||
for g in s.get("gpus") or []:
|
||
slot = gpus.setdefault(g.get("index", 0), {"name": g.get("name", "?"), "util": [], "memUsed": [], "memTotal": g.get("memTotal"), "temp": []})
|
||
for key in ("utilPct", "memUsed", "tempC"):
|
||
if isinstance(g.get(key), (int, float)):
|
||
slot["util" if key == "utilPct" else "memUsed" if key == "memUsed" else "temp"].append(g[key])
|
||
agg["gpus"] = [
|
||
{"index": idx, "name": slot["name"], "util": _stats(slot["util"]),
|
||
"memPeakUsed": max(slot["memUsed"]) if slot["memUsed"] else None,
|
||
"memTotal": slot["memTotal"], "tempPeak": max(slot["temp"]) if slot["temp"] else None}
|
||
for idx, slot in sorted(gpus.items())
|
||
]
|
||
return agg
|
||
|
||
|
||
# ---------- 磁盘目录增长(du 快照对比) ----------
|
||
|
||
def disk_growth(metrics_dir: str | None) -> dict:
|
||
"""对比最近两份 disk-*.json 快照,返回各挂载点 {dir, growth, snapshotTs}。"""
|
||
out: dict = {}
|
||
if not metrics_dir:
|
||
return out
|
||
snaps = sorted(Path(metrics_dir).glob("disk-*.json"))
|
||
if len(snaps) < 2:
|
||
return out
|
||
try:
|
||
new = json.loads(snaps[-1].read_text(encoding="utf-8"))
|
||
old = json.loads(snaps[-2].read_text(encoding="utf-8"))
|
||
except (json.JSONDecodeError, OSError):
|
||
return out
|
||
for key in ("root", "home"):
|
||
nd = (new.get(key) or {}).get("dirs") or {}
|
||
od = (old.get(key) or {}).get("dirs") or {}
|
||
best: tuple[str, int] | None = None
|
||
for name, nb in nd.items():
|
||
ob = od.get(name)
|
||
if ob is None:
|
||
continue
|
||
if best is None or nb - ob > best[1]:
|
||
best = (name, nb - ob)
|
||
out[key] = {"dir": best[0] if best else None,
|
||
"growth": best[1] if best else 0,
|
||
"snapshotTs": new.get("ts", "")}
|
||
return out
|
||
|
||
|
||
# ---------- 瞬时兜底检查(采样缺失时用) ----------
|
||
|
||
def _check_memory() -> dict:
|
||
vm = psutil.virtual_memory()
|
||
sw = psutil.swap_memory()
|
||
return {
|
||
"pct": vm.percent, "used": _gb(vm.used), "total": _gb(vm.total),
|
||
"swapPct": sw.percent, "swapUsed": _gb(sw.used), "swapTotal": _gb(sw.total),
|
||
}
|
||
|
||
|
||
def _check_disks() -> list[dict]:
|
||
paths = DISK_PATHS if os.path.isdir(DISK_PATHS[0][0]) else [("/", "/")]
|
||
out = []
|
||
for raw, label in paths:
|
||
try:
|
||
du = psutil.disk_usage(raw)
|
||
out.append({"label": label, "pct": du.percent, "used": _gb(du.used), "total": _gb(du.total)})
|
||
except Exception as err: # noqa: BLE001
|
||
out.append({"label": label, "error": str(err)[:80]})
|
||
return out
|
||
|
||
|
||
def _check_gpus() -> list[dict] | str:
|
||
"""nvidia-smi 查 GPU 利用率与显存。容器无 GPU 访问时返回说明文字。"""
|
||
try:
|
||
proc = subprocess.run(
|
||
["nvidia-smi", "--query-gpu=index,name,utilization.gpu,memory.used,memory.total",
|
||
"--format=csv,noheader,nounits"],
|
||
capture_output=True, text=True, timeout=15,
|
||
)
|
||
if proc.returncode != 0:
|
||
return f"nvidia-smi 退出码 {proc.returncode}: {proc.stderr.strip()[:100]}"
|
||
gpus = []
|
||
for line in proc.stdout.strip().splitlines():
|
||
parts = [p.strip() for p in line.split(",")]
|
||
if len(parts) != 5:
|
||
continue
|
||
idx, name, util, mem_used, mem_total = parts
|
||
mem_pct = float(mem_used) / float(mem_total) * 100 if float(mem_total) > 0 else 0.0
|
||
gpus.append({"name": f"GPU{idx} {name[:24]}", "utilPct": float(util), "memPct": mem_pct,
|
||
"memUsed": f"{float(mem_used)/1024:.1f}G", "memTotal": f"{float(mem_total)/1024:.1f}G"})
|
||
return gpus or "未检测到 GPU"
|
||
except FileNotFoundError:
|
||
return "不可用(容器内无 nvidia-smi,需 compose 配置 GPU)"
|
||
except Exception as err: # noqa: BLE001
|
||
return f"检查失败: {str(err)[:80]}"
|
||
|
||
|
||
def _inspect_container(client: httpx.Client, cid: str) -> dict:
|
||
try:
|
||
resp = client.get(f"http://localhost/containers/{cid}/json", timeout=10)
|
||
resp.raise_for_status()
|
||
return resp.json()
|
||
except Exception: # noqa: BLE001
|
||
return {}
|
||
|
||
|
||
def _check_docker() -> dict:
|
||
"""容器状态汇总 + 异常容器列表(应运行但退出/restarting/unhealthy/OOMKilled)。"""
|
||
if not os.path.exists(DOCKER_SOCK):
|
||
return {"running": None, "issues": [{"name": "Docker", "issue": f"不可用(未挂载 {DOCKER_SOCK})"}]}
|
||
with httpx.Client(transport=httpx.HTTPTransport(uds=DOCKER_SOCK), timeout=10) as client:
|
||
resp = client.get("http://localhost/containers/json", params={"all": "1"})
|
||
resp.raise_for_status()
|
||
containers = resp.json()
|
||
running = sum(1 for c in containers if c.get("State") == "running")
|
||
issues = []
|
||
stopped = 0
|
||
for c in containers:
|
||
name = (c.get("Names") or ["?"])[0].lstrip("/")
|
||
state, status = c.get("State", ""), c.get("Status", "")
|
||
if state == "restarting":
|
||
issues.append({"name": name, "issue": f"反复重启 ({status})"})
|
||
elif state == "running" and "(unhealthy)" in status:
|
||
issues.append({"name": name, "issue": f"健康检查失败 ({status})"})
|
||
elif state in ("exited", "dead"):
|
||
detail = _inspect_container(client, c["Id"])
|
||
policy = ((detail.get("HostConfig") or {}).get("RestartPolicy") or {}).get("Name", "no")
|
||
oom = (detail.get("State") or {}).get("OOMKilled", False)
|
||
finished_at = (detail.get("State") or {}).get("FinishedAt", "")
|
||
if oom and _exited_recently(finished_at):
|
||
issues.append({"name": name, "issue": f"被 OOM 杀死 ({status})"})
|
||
elif policy in ("always", "unless-stopped"):
|
||
if _exited_recently(finished_at):
|
||
issues.append({"name": name, "issue": f"应常驻但刚退出 ({status}, restart={policy})"})
|
||
else:
|
||
stopped += 1 # 数周前人为停用的旧服务,不算异常
|
||
return {"running": running, "total": len(containers), "issues": issues, "stopped": stopped}
|
||
|
||
|
||
def _exited_recently(finished_at: str, hours: int = 24) -> bool:
|
||
try:
|
||
from datetime import timezone
|
||
dt = datetime.fromisoformat(finished_at.replace("Z", "+00:00"))
|
||
return (datetime.now(timezone.utc) - dt).total_seconds() < hours * 3600
|
||
except (ValueError, TypeError):
|
||
return True # 解析失败按新退出处理,宁可多报
|
||
|
||
|
||
def check_prefect_failures(hours: int = 24) -> list[dict]:
|
||
"""近 N 小时 FAILED/CRASHED 的 flow run,附 workflow 名(供日报/设备报告)。"""
|
||
api_url = os.environ.get("PREFECT_API_URL", "http://localhost:4200/api")
|
||
since = (datetime.now().astimezone() - timedelta(hours=hours)).isoformat()
|
||
resp = httpx.post(
|
||
f"{api_url}/flow_runs/filter",
|
||
json={
|
||
"flow_runs": {"state": {"type": {"any_": ["FAILED", "CRASHED"]}}, "start_time": {"after_": since}},
|
||
"limit": 50,
|
||
"sort": "START_TIME_DESC",
|
||
},
|
||
timeout=15,
|
||
)
|
||
resp.raise_for_status()
|
||
runs = resp.json()
|
||
flow_names: dict[str, str] = {}
|
||
flow_ids = list({r.get("flow_id") for r in runs if r.get("flow_id")})
|
||
if flow_ids:
|
||
fresp = httpx.post(f"{api_url}/flows/filter", json={"flows": {"id": {"any_": flow_ids}}}, timeout=15)
|
||
fresp.raise_for_status()
|
||
flow_names = {f["id"]: f["name"] for f in fresp.json()}
|
||
out = []
|
||
for r in runs:
|
||
start = (r.get("start_time") or "")[:16].replace("T", " ")
|
||
out.append({"flow": flow_names.get(r.get("flow_id"), "?"), "name": r.get("name", "?"),
|
||
"state": (r.get("state") or {}).get("type", "?"),
|
||
"message": ((r.get("state") or {}).get("message") or "").strip()[:100], "start": start})
|
||
return out
|
||
|
||
|
||
def _check_services() -> dict:
|
||
from api.ocr import OcrClient
|
||
|
||
ocr_ok = False
|
||
try:
|
||
ocr_ok = OcrClient().health()
|
||
except Exception: # noqa: BLE001
|
||
pass
|
||
pg_ok = False
|
||
try:
|
||
pg_ok = bool(SyncDB().ping())
|
||
except Exception: # noqa: BLE001
|
||
pass
|
||
return {"ocr": ocr_ok, "pg": pg_ok}
|
||
|
||
|
||
def _mark(pct: float | None, threshold: int) -> str:
|
||
"""百分比展示:超阈值用 warning 色加粗。"""
|
||
if pct is None:
|
||
return "—"
|
||
if pct >= threshold:
|
||
return f'<font color="warning">**{pct:.0f}%**</font>'
|
||
return f"{pct:.0f}%"
|
||
|
||
|
||
def _live_disks() -> list[dict]:
|
||
"""实时磁盘(含设备去重汇总),供 总体/根目录/home 行。"""
|
||
paths = DISK_PATHS if os.path.isdir(DISK_PATHS[0][0]) else [("/", "/")]
|
||
mounts = []
|
||
seen: set[tuple[int, int]] = set()
|
||
total_used = total_avail = 0
|
||
for raw, label in paths:
|
||
try:
|
||
du = psutil.disk_usage(raw)
|
||
except Exception: # noqa: BLE001
|
||
continue
|
||
key = (du.total, du.free)
|
||
if key not in seen:
|
||
seen.add(key)
|
||
total_used += du.used
|
||
total_avail += du.free
|
||
mounts.append({"label": label, "pct": du.percent, "used": du.used,
|
||
"free": du.free, "total": du.total})
|
||
overall = {"used": total_used, "free": total_avail,
|
||
"pct": total_used / (total_used + total_avail) * 100 if total_used + total_avail else 0.0}
|
||
return mounts, overall
|
||
|
||
|
||
@flow(name="device-check", log_prints=True)
|
||
def device_check(alarm_threshold: int = 80) -> dict:
|
||
"""设备/系统健康日报:按 24h 采样报 平均/峰值/最低,直接发企微群。单项失败不影响其余。"""
|
||
mdir = _metrics_dir()
|
||
agg = aggregate_samples(read_samples(mdir))
|
||
growth = disk_growth(mdir)
|
||
checks: dict[str, object] = {}
|
||
for name, fn in [("docker", _check_docker), ("prefectFailures", check_prefect_failures),
|
||
("services", _check_services)]:
|
||
try:
|
||
checks[name] = fn()
|
||
except Exception as err: # noqa: BLE001
|
||
checks[name] = None
|
||
print(f"[device-check] {name} 检查失败: {err}")
|
||
try:
|
||
mounts, overall = _live_disks()
|
||
except Exception as err: # noqa: BLE001
|
||
mounts, overall = [], None
|
||
print(f"[device-check] 磁盘检查失败: {err}")
|
||
|
||
threshold = alarm_threshold
|
||
header = f"## 设备日报 {datetime.now():%m-%d %H:%M}(24h 采样 {agg['count']} 次"
|
||
if agg.get("firstTs"):
|
||
first = datetime.fromisoformat(agg["firstTs"])
|
||
if first > datetime.now() - timedelta(hours=23):
|
||
header += f" · 自 {first:%m-%d %H:%M} 起"
|
||
lines = [header + ")"]
|
||
|
||
# ---- 内存 / swap ----
|
||
mem, swap = agg.get("mem"), agg.get("swap")
|
||
if mem:
|
||
total_txt = f"(共 {_gb((agg.get('memTotal') or 0) * (1 << 30))})" if agg.get("memTotal") else ""
|
||
lines.append(f"**内存** 均 {mem['avg']:.0f}% · 峰 {_mark(mem['peak'], threshold)} · 低 {mem['min']:.0f}%{total_txt}")
|
||
else:
|
||
try:
|
||
now_mem = _check_memory()
|
||
lines.append(f"**内存** 当前 {_mark(now_mem['pct'], threshold)}({now_mem['used']}/{now_mem['total']},无采样)")
|
||
mem = {"peak": now_mem["pct"]}
|
||
except Exception: # noqa: BLE001
|
||
lines.append("**内存** 检查失败")
|
||
mem = None
|
||
if swap:
|
||
total_txt = f"(共 {_gb((agg.get('swapTotal') or 0) * (1 << 30))})" if agg.get("swapTotal") else ""
|
||
lines.append(f"**交换内存** 均 {swap['avg']:.0f}% · 峰 {_mark(swap['peak'], threshold)} · 低 {swap['min']:.0f}%{total_txt}")
|
||
else:
|
||
lines.append("**交换内存** 无采样数据")
|
||
swap = None
|
||
|
||
# ---- 磁盘:总体 / 根目录 / home ----
|
||
if overall:
|
||
lines.append(f"**磁盘** 总体 已用 {_gb(overall['used'])} · 剩 {_gb(overall['free'])}"
|
||
f"({_mark(overall['pct'], threshold)})")
|
||
else:
|
||
lines.append("**磁盘** 检查不可用")
|
||
label_map = {"/": "root", "/home": "home"}
|
||
for m in mounts:
|
||
g = growth.get(label_map.get(m["label"], ""))
|
||
growth_txt = f" · 增长最快 {g['dir']} {_fmt_growth(g['growth'])}" if g and g.get("dir") else ""
|
||
lines.append(f"{m['label']} {_mark(m['pct'], threshold)} · 已用 {_gb(m['used'])} · 剩 {_gb(m['free'])}{growth_txt}")
|
||
|
||
# ---- GPU ----
|
||
gpu_stats = agg.get("gpus") or []
|
||
if gpu_stats:
|
||
for g in gpu_stats:
|
||
name = g.get("name") or f"GPU{g['index']}"
|
||
util = g.get("util")
|
||
util_txt = (f"利用率 均 {util['avg']:.0f}% · 峰 {util['peak']:.0f}% · 低 {util['min']:.0f}%"
|
||
if util else "利用率 无数据")
|
||
mem_peak, mem_total = g.get("memPeakUsed"), g.get("memTotal")
|
||
if mem_peak is not None and mem_total:
|
||
mem_txt = (f"显存峰 {_gb(mem_peak * (1 << 30))}/{_gb(mem_total * (1 << 30))}"
|
||
f"({_mark(mem_peak / mem_total * 100, threshold)})")
|
||
else:
|
||
mem_txt = "显存 无数据"
|
||
temp_peak = g.get("tempPeak")
|
||
if temp_peak is None:
|
||
temp_txt = "温度 无数据"
|
||
elif temp_peak >= GPU_TEMP_WARN:
|
||
temp_txt = f'温度峰 {temp_peak:.0f}°C <font color="warning">过热</font>'
|
||
else:
|
||
temp_txt = f"温度峰 {temp_peak:.0f}°C 正常"
|
||
lines.append(f"**GPU** {name} {util_txt} | {mem_txt} | {temp_txt}")
|
||
else:
|
||
gpus_now = _check_gpus()
|
||
if isinstance(gpus_now, list) and gpus_now:
|
||
for g in gpus_now:
|
||
lines.append(f"**GPU** {g['name']} 利用率 {g['utilPct']:.0f}% · 显存 {g['memUsed']}/{g['memTotal']}(无采样)")
|
||
else:
|
||
lines.append(f"**GPU** {gpus_now if isinstance(gpus_now, str) else '未检测到'}")
|
||
|
||
# ---- Docker / 探活 / 失败任务 ----
|
||
docker = checks.get("docker") or {}
|
||
issues = docker.get("issues") or []
|
||
svc = checks.get("services") or {}
|
||
if docker.get("running") is None:
|
||
docker_txt = issues[0]["issue"] if issues else "不可用"
|
||
else:
|
||
n_issues = len(issues)
|
||
docker_txt = "运行 " + f"{docker['running']}/{docker.get('total', '?')} · 异常 "
|
||
docker_txt += f'<font color="warning">{n_issues}</font>' if n_issues else "0"
|
||
if svc:
|
||
docker_txt += " · OCR " + ("正常" if svc.get("ocr") else '<font color="warning">异常</font>')
|
||
docker_txt += " · PG " + ("正常" if svc.get("pg") else '<font color="warning">异常</font>')
|
||
lines.append(f"**Docker** {docker_txt}")
|
||
for it in issues[:8]:
|
||
lines.append(f"{it['name']}:{it['issue']}")
|
||
|
||
failures = checks.get("prefectFailures") or []
|
||
if failures:
|
||
lines.append(f'**任务** 失败 <font color="warning">{len(failures)} 次</font>(24h)')
|
||
for f in failures[:5]:
|
||
lines.append(f"{f['flow']}:{f['name']} {f['state']} {(f.get('start') or '')[5:]}")
|
||
if len(failures) > 5:
|
||
lines.append(f"…共 {len(failures)} 次")
|
||
else:
|
||
lines.append("**任务** 失败 0 次(24h)")
|
||
|
||
mem_peak_for_alarm = mem.get("peak") if mem else None
|
||
swap_peak_for_alarm = swap.get("peak") if swap else None
|
||
alarms = (
|
||
(mem_peak_for_alarm is not None and mem_peak_for_alarm >= threshold)
|
||
or (swap_peak_for_alarm is not None and swap_peak_for_alarm >= threshold)
|
||
or any(m["pct"] >= threshold for m in mounts)
|
||
or any((g.get("memPeakUsed") or 0) / (g.get("memTotal") or 1) * 100 >= threshold for g in gpu_stats)
|
||
or any((g.get("tempPeak") or 0) >= GPU_TEMP_WARN for g in gpu_stats)
|
||
or bool(issues) or bool(failures) or not svc.get("ocr") or not svc.get("pg")
|
||
)
|
||
lines.append('<font color="warning">有项目需要处理,见上</font>' if alarms
|
||
else '<font color="info">一切正常</font>')
|
||
|
||
content = "\n".join(lines)
|
||
sent = send_markdown(content)
|
||
emit_sync_summary("设备健康日报", [
|
||
("采样次数", agg["count"]),
|
||
("内存峰值", f"{mem_peak_for_alarm:.0f}%" if mem_peak_for_alarm is not None else "—"),
|
||
("Docker 异常", len(issues)),
|
||
("失败任务(24h)", len(failures)),
|
||
("OCR", "✅" if svc.get("ocr") else "🔴"), ("PG", "✅" if svc.get("pg") else "🔴"),
|
||
("已发企微", "是" if sent else "否"),
|
||
])
|
||
return {"alarms": bool(alarms), "sent": sent, "samples": agg["count"],
|
||
"dockerIssues": len(issues), "prefectFailures": len(failures)}
|
||
|
||
|
||
if __name__ == "__main__":
|
||
device_check.serve(name="device-check", cron="0 9 * * *")
|