feat: monitors, UniFi and Traefik keep collecting through a storage stall (#5995)
The same Holding as the SNMP poll, for the rest of Steward's own collectors: - Monitors: the list is read within 10 s or the last one read stands in; a start the database doesn't take is kept in memory, so a monitor isn't checked twice; each result is held and stored at the time it was checked. A stall shows on the monitors as the checks it saw, not a gap. - UniFi and Traefik: each poll's writes are held (hold_write) and run oldest first in one transaction; every reading is stamped with its scrape. - A held round the database refuses (as opposed to can't be reached) is stored alone and let go, so it can't hold up every round after it. The Prometheus scrape is left as it is: its one target, Fabled Archives, keeps its own database on the same NFS. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -75,6 +75,7 @@ async def _do_scrape(app: "Quart") -> None:
|
||||
fetch_router_domains,
|
||||
)
|
||||
from steward.core.alerts import MetricBatch
|
||||
from steward.core.holding import hold_write
|
||||
|
||||
metrics_url: str = app.config["PLUGINS"]["traefik"]["metrics_url"]
|
||||
|
||||
@@ -106,81 +107,83 @@ async def _do_scrape(app: "Quart") -> None:
|
||||
|
||||
scraped_at = datetime.now(timezone.utc)
|
||||
|
||||
async with app.db_sessionmaker() as session:
|
||||
async with session.begin():
|
||||
batch = MetricBatch()
|
||||
# ── Per-service metrics ───────────────────────────────────────────
|
||||
for router_name, metrics in router_metrics.items():
|
||||
row = TraefikMetric(
|
||||
router_name=router_name,
|
||||
scraped_at=scraped_at,
|
||||
request_rate=metrics["request_rate"],
|
||||
error_rate_4xx_pct=metrics["error_rate_4xx_pct"],
|
||||
error_rate_5xx_pct=metrics["error_rate_5xx_pct"],
|
||||
latency_p50_ms=metrics["latency_p50_ms"],
|
||||
latency_p95_ms=metrics["latency_p95_ms"],
|
||||
latency_p99_ms=metrics["latency_p99_ms"],
|
||||
response_bytes_rate=metrics["response_bytes_rate"],
|
||||
)
|
||||
session.add(row)
|
||||
async def write(session) -> None:
|
||||
batch = MetricBatch()
|
||||
# ── Per-service metrics ───────────────────────────────────────────
|
||||
for router_name, metrics in router_metrics.items():
|
||||
row = TraefikMetric(
|
||||
router_name=router_name,
|
||||
scraped_at=scraped_at,
|
||||
request_rate=metrics["request_rate"],
|
||||
error_rate_4xx_pct=metrics["error_rate_4xx_pct"],
|
||||
error_rate_5xx_pct=metrics["error_rate_5xx_pct"],
|
||||
latency_p50_ms=metrics["latency_p50_ms"],
|
||||
latency_p95_ms=metrics["latency_p95_ms"],
|
||||
latency_p99_ms=metrics["latency_p99_ms"],
|
||||
response_bytes_rate=metrics["response_bytes_rate"],
|
||||
)
|
||||
session.add(row)
|
||||
|
||||
for metric_name, value in [
|
||||
("request_rate", metrics["request_rate"]),
|
||||
("error_rate_4xx_pct", metrics["error_rate_4xx_pct"]),
|
||||
("error_rate_5xx_pct", metrics["error_rate_5xx_pct"]),
|
||||
("latency_p50_ms", metrics["latency_p50_ms"]),
|
||||
("latency_p95_ms", metrics["latency_p95_ms"]),
|
||||
("latency_p99_ms", metrics["latency_p99_ms"]),
|
||||
("response_bytes_rate", metrics["response_bytes_rate"]),
|
||||
]:
|
||||
if value is None: # no responses since the last scrape: no latency
|
||||
continue
|
||||
# Stamped with the scrape, so a router's latest figures
|
||||
# (routes.latest_per_router) all carry the same moment.
|
||||
batch.add(
|
||||
source_module="traefik",
|
||||
resource_name=router_name,
|
||||
metric_name=metric_name,
|
||||
value=value,
|
||||
recorded_at=scraped_at,
|
||||
)
|
||||
|
||||
# ── 4xx and 5xx responses by code (#5711) ─────────────────────────
|
||||
for router_name, codes in status_codes.items():
|
||||
for code, count in codes.items():
|
||||
session.add(TraefikStatusCode(router_name=router_name, scraped_at=scraped_at,
|
||||
code=code, count=count))
|
||||
|
||||
# ── Global stats ──────────────────────────────────────────────────
|
||||
session.add(TraefikGlobalStat(scraped_at=scraped_at, **global_stats))
|
||||
|
||||
# ── TLS certs (upsert by serial) ──────────────────────────────────
|
||||
for cert in cert_list:
|
||||
existing = await session.get(TraefikCert, cert["serial"])
|
||||
if existing:
|
||||
existing.cn = cert["cn"]
|
||||
existing.sans = cert["sans"]
|
||||
existing.not_after = cert["not_after"]
|
||||
existing.scraped_at = scraped_at
|
||||
else:
|
||||
session.add(TraefikCert(
|
||||
serial=cert["serial"],
|
||||
cn=cert["cn"],
|
||||
sans=cert["sans"],
|
||||
not_after=cert["not_after"],
|
||||
scraped_at=scraped_at,
|
||||
))
|
||||
|
||||
# Emit cert_expiry_days for alert rules
|
||||
days_remaining = (cert["not_after"] - scraped_at).total_seconds() / 86400.0
|
||||
for metric_name, value in [
|
||||
("request_rate", metrics["request_rate"]),
|
||||
("error_rate_4xx_pct", metrics["error_rate_4xx_pct"]),
|
||||
("error_rate_5xx_pct", metrics["error_rate_5xx_pct"]),
|
||||
("latency_p50_ms", metrics["latency_p50_ms"]),
|
||||
("latency_p95_ms", metrics["latency_p95_ms"]),
|
||||
("latency_p99_ms", metrics["latency_p99_ms"]),
|
||||
("response_bytes_rate", metrics["response_bytes_rate"]),
|
||||
]:
|
||||
if value is None: # no responses since the last scrape: no latency
|
||||
continue
|
||||
# Stamped with the scrape, so a router's latest figures
|
||||
# (routes.latest_per_router) all carry the same moment.
|
||||
batch.add(
|
||||
source_module="traefik",
|
||||
resource_name=cert["cn"] or cert["serial"],
|
||||
metric_name="cert_expiry_days",
|
||||
value=max(0.0, days_remaining),
|
||||
resource_name=router_name,
|
||||
metric_name=metric_name,
|
||||
value=value,
|
||||
recorded_at=scraped_at,
|
||||
)
|
||||
|
||||
await batch.record(session)
|
||||
# ── 4xx and 5xx responses by code (#5711) ─────────────────────────
|
||||
for router_name, codes in status_codes.items():
|
||||
for code, count in codes.items():
|
||||
session.add(TraefikStatusCode(router_name=router_name, scraped_at=scraped_at,
|
||||
code=code, count=count))
|
||||
|
||||
# ── Global stats ──────────────────────────────────────────────────
|
||||
session.add(TraefikGlobalStat(scraped_at=scraped_at, **global_stats))
|
||||
|
||||
# ── TLS certs (upsert by serial) ──────────────────────────────────
|
||||
for cert in cert_list:
|
||||
existing = await session.get(TraefikCert, cert["serial"])
|
||||
if existing:
|
||||
existing.cn = cert["cn"]
|
||||
existing.sans = cert["sans"]
|
||||
existing.not_after = cert["not_after"]
|
||||
existing.scraped_at = scraped_at
|
||||
else:
|
||||
session.add(TraefikCert(
|
||||
serial=cert["serial"],
|
||||
cn=cert["cn"],
|
||||
sans=cert["sans"],
|
||||
not_after=cert["not_after"],
|
||||
scraped_at=scraped_at,
|
||||
))
|
||||
|
||||
# Emit cert_expiry_days for alert rules
|
||||
days_remaining = (cert["not_after"] - scraped_at).total_seconds() / 86400.0
|
||||
batch.add(
|
||||
source_module="traefik",
|
||||
resource_name=cert["cn"] or cert["serial"],
|
||||
metric_name="cert_expiry_days",
|
||||
value=max(0.0, days_remaining),
|
||||
recorded_at=scraped_at,
|
||||
)
|
||||
|
||||
await batch.record(session)
|
||||
|
||||
await hold_write(app, "Traefik scrape", write)
|
||||
|
||||
logger.debug(
|
||||
"Traefik scrape: %d router(s), %d cert(s)",
|
||||
|
||||
+272
-266
@@ -5,6 +5,7 @@ import logging
|
||||
from datetime import datetime, timezone
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from steward.core.holding import hold_write
|
||||
from steward.core.scheduler import ScheduledTask
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -130,68 +131,72 @@ async def _do_poll(app: "Quart") -> None:
|
||||
top_clients_json=json.dumps(top_clients_data),
|
||||
)
|
||||
|
||||
async with app.db_sessionmaker() as session:
|
||||
async with session.begin():
|
||||
session.add(wan_stat)
|
||||
session.add(client_snapshot)
|
||||
async def write(session) -> None:
|
||||
session.add(wan_stat)
|
||||
session.add(client_snapshot)
|
||||
|
||||
# ── Devices (upsert by MAC) ───────────────────────────────────────
|
||||
for d in devices:
|
||||
mac = d.get("mac", "")
|
||||
if not mac:
|
||||
continue
|
||||
last_seen_ts = d.get("last_seen")
|
||||
last_seen_dt = (
|
||||
datetime.fromtimestamp(last_seen_ts, tz=timezone.utc)
|
||||
if last_seen_ts else None
|
||||
)
|
||||
existing = await session.get(UnifiDevice, mac)
|
||||
if existing:
|
||||
existing.name = d.get("name")
|
||||
existing.model = d.get("model")
|
||||
existing.device_type = d.get("type")
|
||||
existing.ip = d.get("ip")
|
||||
existing.state = d.get("state", 0)
|
||||
existing.uptime_seconds = d.get("uptime")
|
||||
existing.last_seen = last_seen_dt
|
||||
existing.version = d.get("version")
|
||||
existing.scraped_at = scraped_at
|
||||
else:
|
||||
session.add(UnifiDevice(
|
||||
mac=mac,
|
||||
name=d.get("name"),
|
||||
model=d.get("model"),
|
||||
device_type=d.get("type"),
|
||||
ip=d.get("ip"),
|
||||
state=d.get("state", 0),
|
||||
uptime_seconds=d.get("uptime"),
|
||||
last_seen=last_seen_dt,
|
||||
version=d.get("version"),
|
||||
scraped_at=scraped_at,
|
||||
))
|
||||
# ── Devices (upsert by MAC) ───────────────────────────────────────
|
||||
for d in devices:
|
||||
mac = d.get("mac", "")
|
||||
if not mac:
|
||||
continue
|
||||
last_seen_ts = d.get("last_seen")
|
||||
last_seen_dt = (
|
||||
datetime.fromtimestamp(last_seen_ts, tz=timezone.utc)
|
||||
if last_seen_ts else None
|
||||
)
|
||||
existing = await session.get(UnifiDevice, mac)
|
||||
if existing:
|
||||
existing.name = d.get("name")
|
||||
existing.model = d.get("model")
|
||||
existing.device_type = d.get("type")
|
||||
existing.ip = d.get("ip")
|
||||
existing.state = d.get("state", 0)
|
||||
existing.uptime_seconds = d.get("uptime")
|
||||
existing.last_seen = last_seen_dt
|
||||
existing.version = d.get("version")
|
||||
existing.scraped_at = scraped_at
|
||||
else:
|
||||
session.add(UnifiDevice(
|
||||
mac=mac,
|
||||
name=d.get("name"),
|
||||
model=d.get("model"),
|
||||
device_type=d.get("type"),
|
||||
ip=d.get("ip"),
|
||||
state=d.get("state", 0),
|
||||
uptime_seconds=d.get("uptime"),
|
||||
last_seen=last_seen_dt,
|
||||
version=d.get("version"),
|
||||
scraped_at=scraped_at,
|
||||
))
|
||||
|
||||
# ── Alert metrics ─────────────────────────────────────────────────
|
||||
batch = MetricBatch()
|
||||
if wan_stat.latency_ms is not None:
|
||||
batch.add(
|
||||
source_module="unifi",
|
||||
resource_name="wan",
|
||||
metric_name="latency_ms",
|
||||
value=wan_stat.latency_ms,
|
||||
)
|
||||
# ── Alert metrics ─────────────────────────────────────────────────
|
||||
batch = MetricBatch()
|
||||
if wan_stat.latency_ms is not None:
|
||||
batch.add(
|
||||
source_module="unifi",
|
||||
resource_name="wan",
|
||||
metric_name="is_up",
|
||||
value=1.0 if wan_stat.is_up else 0.0,
|
||||
metric_name="latency_ms",
|
||||
value=wan_stat.latency_ms,
|
||||
recorded_at=scraped_at,
|
||||
)
|
||||
batch.add(
|
||||
source_module="unifi",
|
||||
resource_name="clients",
|
||||
metric_name="total_clients",
|
||||
value=float(len(clients)),
|
||||
)
|
||||
await batch.record(session)
|
||||
batch.add(
|
||||
source_module="unifi",
|
||||
resource_name="wan",
|
||||
metric_name="is_up",
|
||||
value=1.0 if wan_stat.is_up else 0.0,
|
||||
recorded_at=scraped_at,
|
||||
)
|
||||
batch.add(
|
||||
source_module="unifi",
|
||||
resource_name="clients",
|
||||
metric_name="total_clients",
|
||||
value=float(len(clients)),
|
||||
recorded_at=scraped_at,
|
||||
)
|
||||
await batch.record(session)
|
||||
|
||||
await hold_write(app, "UniFi poll", write)
|
||||
|
||||
logger.debug(
|
||||
"UniFi poll: wan=%s, clients=%d, devices=%d",
|
||||
@@ -310,227 +315,228 @@ async def _do_expanded(app: "Quart", scraped_at: datetime) -> None:
|
||||
top_dpi_clients = sorted(client_dpi.values(), key=lambda x: x["bytes"], reverse=True)[:10]
|
||||
|
||||
# ── Write to DB ───────────────────────────────────────────────────────────
|
||||
async with app.db_sessionmaker() as session:
|
||||
async with session.begin():
|
||||
# WLAN stats
|
||||
for row in wlan_rows:
|
||||
session.add(row)
|
||||
async def write(session) -> None:
|
||||
# WLAN stats
|
||||
for row in wlan_rows:
|
||||
session.add(row)
|
||||
|
||||
# DPI snapshot (only if we got data)
|
||||
if cat_summary or top_dpi_clients:
|
||||
session.add(UnifiDpiSnapshot(
|
||||
scraped_at=scraped_at,
|
||||
by_category_json=json.dumps(cat_summary),
|
||||
by_client_json=json.dumps(top_dpi_clients),
|
||||
# DPI snapshot (only if we got data)
|
||||
if cat_summary or top_dpi_clients:
|
||||
session.add(UnifiDpiSnapshot(
|
||||
scraped_at=scraped_at,
|
||||
by_category_json=json.dumps(cat_summary),
|
||||
by_client_json=json.dumps(top_dpi_clients),
|
||||
))
|
||||
|
||||
# Events (upsert by _id)
|
||||
_category_map = {
|
||||
"wlan": "wlan", "wireless": "wlan",
|
||||
"wired": "wired",
|
||||
"ugw": "wan", "wan": "wan",
|
||||
"ids": "ids",
|
||||
"system": "system",
|
||||
"ap": "ap",
|
||||
}
|
||||
for ev in events:
|
||||
uid = ev.get("_id", "")
|
||||
if not uid:
|
||||
continue
|
||||
ts_raw = ev.get("datetime") or ev.get("time")
|
||||
try:
|
||||
if isinstance(ts_raw, (int, float)):
|
||||
event_time = datetime.fromtimestamp(ts_raw, tz=timezone.utc)
|
||||
else:
|
||||
event_time = datetime.fromisoformat(str(ts_raw).replace("Z", "+00:00"))
|
||||
except Exception:
|
||||
event_time = scraped_at
|
||||
subsystem = ev.get("subsystem", "system")
|
||||
category = _category_map.get(subsystem, subsystem[:16])
|
||||
details = {k: v for k, v in ev.items()
|
||||
if k not in ("_id", "datetime", "time", "key", "subsystem", "msg", "user", "src_ip")}
|
||||
existing = await session.get(UnifiEvent, uid)
|
||||
if not existing:
|
||||
session.add(UnifiEvent(
|
||||
unifi_id=uid,
|
||||
event_time=event_time,
|
||||
event_key=ev.get("key", ""),
|
||||
category=category,
|
||||
message=ev.get("msg", ""),
|
||||
source_mac=ev.get("user") or ev.get("src_mac"),
|
||||
source_ip=ev.get("src_ip"),
|
||||
details_json=json.dumps(details),
|
||||
))
|
||||
|
||||
# Events (upsert by _id)
|
||||
_category_map = {
|
||||
"wlan": "wlan", "wireless": "wlan",
|
||||
"wired": "wired",
|
||||
"ugw": "wan", "wan": "wan",
|
||||
"ids": "ids",
|
||||
"system": "system",
|
||||
"ap": "ap",
|
||||
}
|
||||
for ev in events:
|
||||
uid = ev.get("_id", "")
|
||||
if not uid:
|
||||
continue
|
||||
ts_raw = ev.get("datetime") or ev.get("time")
|
||||
# Alarms (upsert)
|
||||
for al in alarms:
|
||||
uid = al.get("_id", "")
|
||||
if not uid:
|
||||
continue
|
||||
ts_raw = al.get("datetime") or al.get("time")
|
||||
try:
|
||||
if isinstance(ts_raw, (int, float)):
|
||||
alarm_time = datetime.fromtimestamp(ts_raw, tz=timezone.utc)
|
||||
else:
|
||||
alarm_time = datetime.fromisoformat(str(ts_raw).replace("Z", "+00:00"))
|
||||
except Exception:
|
||||
alarm_time = scraped_at
|
||||
existing = await session.get(UnifiAlarm, uid)
|
||||
if existing:
|
||||
existing.archived = al.get("archived", False)
|
||||
existing.scraped_at = scraped_at
|
||||
else:
|
||||
session.add(UnifiAlarm(
|
||||
unifi_id=uid,
|
||||
alarm_time=alarm_time,
|
||||
alarm_key=al.get("key", ""),
|
||||
message=al.get("msg", ""),
|
||||
archived=al.get("archived", False),
|
||||
scraped_at=scraped_at,
|
||||
))
|
||||
|
||||
# Known clients (upsert by MAC)
|
||||
for kc in known_clients:
|
||||
mac = kc.get("mac", "")
|
||||
if not mac:
|
||||
continue
|
||||
def _ts(val):
|
||||
if not val:
|
||||
return None
|
||||
try:
|
||||
if isinstance(ts_raw, (int, float)):
|
||||
event_time = datetime.fromtimestamp(ts_raw, tz=timezone.utc)
|
||||
else:
|
||||
event_time = datetime.fromisoformat(str(ts_raw).replace("Z", "+00:00"))
|
||||
if isinstance(val, (int, float)):
|
||||
return datetime.fromtimestamp(val, tz=timezone.utc)
|
||||
return datetime.fromisoformat(str(val).replace("Z", "+00:00"))
|
||||
except Exception:
|
||||
event_time = scraped_at
|
||||
subsystem = ev.get("subsystem", "system")
|
||||
category = _category_map.get(subsystem, subsystem[:16])
|
||||
details = {k: v for k, v in ev.items()
|
||||
if k not in ("_id", "datetime", "time", "key", "subsystem", "msg", "user", "src_ip")}
|
||||
existing = await session.get(UnifiEvent, uid)
|
||||
if not existing:
|
||||
session.add(UnifiEvent(
|
||||
unifi_id=uid,
|
||||
event_time=event_time,
|
||||
event_key=ev.get("key", ""),
|
||||
category=category,
|
||||
message=ev.get("msg", ""),
|
||||
source_mac=ev.get("user") or ev.get("src_mac"),
|
||||
source_ip=ev.get("src_ip"),
|
||||
details_json=json.dumps(details),
|
||||
))
|
||||
return None
|
||||
existing = await session.get(UnifiKnownClient, mac)
|
||||
if existing:
|
||||
existing.hostname = kc.get("hostname")
|
||||
existing.name = kc.get("name")
|
||||
existing.ip = kc.get("ip")
|
||||
existing.mac_vendor = kc.get("oui")
|
||||
existing.note = kc.get("note")
|
||||
existing.is_blocked = kc.get("blocked", False)
|
||||
existing.first_seen = _ts(kc.get("first_seen"))
|
||||
existing.last_seen = _ts(kc.get("last_seen"))
|
||||
existing.scraped_at = scraped_at
|
||||
else:
|
||||
session.add(UnifiKnownClient(
|
||||
mac=mac,
|
||||
hostname=kc.get("hostname"),
|
||||
name=kc.get("name"),
|
||||
ip=kc.get("ip"),
|
||||
mac_vendor=kc.get("oui"),
|
||||
note=kc.get("note"),
|
||||
is_blocked=kc.get("blocked", False),
|
||||
first_seen=_ts(kc.get("first_seen")),
|
||||
last_seen=_ts(kc.get("last_seen")),
|
||||
scraped_at=scraped_at,
|
||||
))
|
||||
|
||||
# Alarms (upsert)
|
||||
for al in alarms:
|
||||
uid = al.get("_id", "")
|
||||
if not uid:
|
||||
continue
|
||||
ts_raw = al.get("datetime") or al.get("time")
|
||||
try:
|
||||
if isinstance(ts_raw, (int, float)):
|
||||
alarm_time = datetime.fromtimestamp(ts_raw, tz=timezone.utc)
|
||||
else:
|
||||
alarm_time = datetime.fromisoformat(str(ts_raw).replace("Z", "+00:00"))
|
||||
except Exception:
|
||||
alarm_time = scraped_at
|
||||
existing = await session.get(UnifiAlarm, uid)
|
||||
if existing:
|
||||
existing.archived = al.get("archived", False)
|
||||
existing.scraped_at = scraped_at
|
||||
# Speedtest (upsert by run_at)
|
||||
if speedtest:
|
||||
ts_raw = speedtest.get("rundate") or speedtest.get("time")
|
||||
try:
|
||||
if isinstance(ts_raw, (int, float)):
|
||||
run_at = datetime.fromtimestamp(ts_raw, tz=timezone.utc)
|
||||
else:
|
||||
session.add(UnifiAlarm(
|
||||
unifi_id=uid,
|
||||
alarm_time=alarm_time,
|
||||
alarm_key=al.get("key", ""),
|
||||
message=al.get("msg", ""),
|
||||
archived=al.get("archived", False),
|
||||
scraped_at=scraped_at,
|
||||
))
|
||||
run_at = datetime.fromisoformat(str(ts_raw).replace("Z", "+00:00"))
|
||||
except Exception:
|
||||
run_at = scraped_at
|
||||
existing = await session.get(UnifiSpeedtestResult, run_at)
|
||||
if not existing:
|
||||
session.add(UnifiSpeedtestResult(
|
||||
run_at=run_at,
|
||||
download_mbps=speedtest.get("xput_download"),
|
||||
upload_mbps=speedtest.get("xput_upload"),
|
||||
latency_ms=speedtest.get("latency"),
|
||||
server_name=speedtest.get("server_name"),
|
||||
))
|
||||
|
||||
# Known clients (upsert by MAC)
|
||||
for kc in known_clients:
|
||||
mac = kc.get("mac", "")
|
||||
if not mac:
|
||||
continue
|
||||
def _ts(val):
|
||||
if not val:
|
||||
return None
|
||||
try:
|
||||
if isinstance(val, (int, float)):
|
||||
return datetime.fromtimestamp(val, tz=timezone.utc)
|
||||
return datetime.fromisoformat(str(val).replace("Z", "+00:00"))
|
||||
except Exception:
|
||||
return None
|
||||
existing = await session.get(UnifiKnownClient, mac)
|
||||
if existing:
|
||||
existing.hostname = kc.get("hostname")
|
||||
existing.name = kc.get("name")
|
||||
existing.ip = kc.get("ip")
|
||||
existing.mac_vendor = kc.get("oui")
|
||||
existing.note = kc.get("note")
|
||||
existing.is_blocked = kc.get("blocked", False)
|
||||
existing.first_seen = _ts(kc.get("first_seen"))
|
||||
existing.last_seen = _ts(kc.get("last_seen"))
|
||||
existing.scraped_at = scraped_at
|
||||
else:
|
||||
session.add(UnifiKnownClient(
|
||||
mac=mac,
|
||||
hostname=kc.get("hostname"),
|
||||
name=kc.get("name"),
|
||||
ip=kc.get("ip"),
|
||||
mac_vendor=kc.get("oui"),
|
||||
note=kc.get("note"),
|
||||
is_blocked=kc.get("blocked", False),
|
||||
first_seen=_ts(kc.get("first_seen")),
|
||||
last_seen=_ts(kc.get("last_seen")),
|
||||
scraped_at=scraped_at,
|
||||
))
|
||||
# Networks (upsert by UniFi ID)
|
||||
for net in networks:
|
||||
uid = net.get("_id", "")
|
||||
if not uid:
|
||||
continue
|
||||
existing = await session.get(UnifiNetwork, uid)
|
||||
if existing:
|
||||
existing.name = net.get("name", "")
|
||||
existing.purpose = net.get("purpose")
|
||||
existing.vlan = net.get("vlan")
|
||||
existing.ip_subnet = net.get("ip_subnet")
|
||||
existing.dhcp_enabled = net.get("dhcpd_enabled", False)
|
||||
existing.is_guest = net.get("is_guest", False)
|
||||
existing.scraped_at = scraped_at
|
||||
else:
|
||||
session.add(UnifiNetwork(
|
||||
unifi_id=uid,
|
||||
name=net.get("name", ""),
|
||||
purpose=net.get("purpose"),
|
||||
vlan=net.get("vlan"),
|
||||
ip_subnet=net.get("ip_subnet"),
|
||||
dhcp_enabled=net.get("dhcpd_enabled", False),
|
||||
is_guest=net.get("is_guest", False),
|
||||
scraped_at=scraped_at,
|
||||
))
|
||||
|
||||
# Speedtest (upsert by run_at)
|
||||
if speedtest:
|
||||
ts_raw = speedtest.get("rundate") or speedtest.get("time")
|
||||
try:
|
||||
if isinstance(ts_raw, (int, float)):
|
||||
run_at = datetime.fromtimestamp(ts_raw, tz=timezone.utc)
|
||||
else:
|
||||
run_at = datetime.fromisoformat(str(ts_raw).replace("Z", "+00:00"))
|
||||
except Exception:
|
||||
run_at = scraped_at
|
||||
existing = await session.get(UnifiSpeedtestResult, run_at)
|
||||
if not existing:
|
||||
session.add(UnifiSpeedtestResult(
|
||||
run_at=run_at,
|
||||
download_mbps=speedtest.get("xput_download"),
|
||||
upload_mbps=speedtest.get("xput_upload"),
|
||||
latency_ms=speedtest.get("latency"),
|
||||
server_name=speedtest.get("server_name"),
|
||||
))
|
||||
# Port forwards (upsert by UniFi ID)
|
||||
for pf in port_forwards:
|
||||
uid = pf.get("_id", "")
|
||||
if not uid:
|
||||
continue
|
||||
existing = await session.get(UnifiPortForward, uid)
|
||||
if existing:
|
||||
existing.name = pf.get("name")
|
||||
existing.proto = pf.get("proto", "tcp")
|
||||
existing.dst_port = pf.get("dst_port", "")
|
||||
existing.fwd_ip = pf.get("fwd", "")
|
||||
existing.fwd_port = pf.get("fwd_port", "")
|
||||
existing.src_filter = pf.get("src")
|
||||
existing.enabled = pf.get("enabled", True)
|
||||
existing.scraped_at = scraped_at
|
||||
else:
|
||||
session.add(UnifiPortForward(
|
||||
unifi_id=uid,
|
||||
name=pf.get("name"),
|
||||
proto=pf.get("proto", "tcp"),
|
||||
dst_port=pf.get("dst_port", ""),
|
||||
fwd_ip=pf.get("fwd", ""),
|
||||
fwd_port=pf.get("fwd_port", ""),
|
||||
src_filter=pf.get("src"),
|
||||
enabled=pf.get("enabled", True),
|
||||
scraped_at=scraped_at,
|
||||
))
|
||||
|
||||
# Networks (upsert by UniFi ID)
|
||||
for net in networks:
|
||||
uid = net.get("_id", "")
|
||||
if not uid:
|
||||
continue
|
||||
existing = await session.get(UnifiNetwork, uid)
|
||||
if existing:
|
||||
existing.name = net.get("name", "")
|
||||
existing.purpose = net.get("purpose")
|
||||
existing.vlan = net.get("vlan")
|
||||
existing.ip_subnet = net.get("ip_subnet")
|
||||
existing.dhcp_enabled = net.get("dhcpd_enabled", False)
|
||||
existing.is_guest = net.get("is_guest", False)
|
||||
existing.scraped_at = scraped_at
|
||||
else:
|
||||
session.add(UnifiNetwork(
|
||||
unifi_id=uid,
|
||||
name=net.get("name", ""),
|
||||
purpose=net.get("purpose"),
|
||||
vlan=net.get("vlan"),
|
||||
ip_subnet=net.get("ip_subnet"),
|
||||
dhcp_enabled=net.get("dhcpd_enabled", False),
|
||||
is_guest=net.get("is_guest", False),
|
||||
scraped_at=scraped_at,
|
||||
))
|
||||
# Firewall rules (upsert by UniFi ID)
|
||||
for rule in fw_rules:
|
||||
uid = rule.get("_id", "")
|
||||
if not uid:
|
||||
continue
|
||||
existing = await session.get(UnifiFwRule, uid)
|
||||
if existing:
|
||||
existing.name = rule.get("name", "")
|
||||
existing.ruleset = rule.get("ruleset", "")
|
||||
existing.rule_index = rule.get("rule_index")
|
||||
existing.action = rule.get("action", "")
|
||||
existing.protocol = rule.get("protocol")
|
||||
existing.enabled = rule.get("enabled", True)
|
||||
existing.src_address = rule.get("src_address")
|
||||
existing.dst_address = rule.get("dst_address")
|
||||
existing.scraped_at = scraped_at
|
||||
else:
|
||||
session.add(UnifiFwRule(
|
||||
unifi_id=uid,
|
||||
name=rule.get("name", ""),
|
||||
ruleset=rule.get("ruleset", ""),
|
||||
rule_index=rule.get("rule_index"),
|
||||
action=rule.get("action", ""),
|
||||
protocol=rule.get("protocol"),
|
||||
enabled=rule.get("enabled", True),
|
||||
src_address=rule.get("src_address"),
|
||||
dst_address=rule.get("dst_address"),
|
||||
scraped_at=scraped_at,
|
||||
))
|
||||
|
||||
# Port forwards (upsert by UniFi ID)
|
||||
for pf in port_forwards:
|
||||
uid = pf.get("_id", "")
|
||||
if not uid:
|
||||
continue
|
||||
existing = await session.get(UnifiPortForward, uid)
|
||||
if existing:
|
||||
existing.name = pf.get("name")
|
||||
existing.proto = pf.get("proto", "tcp")
|
||||
existing.dst_port = pf.get("dst_port", "")
|
||||
existing.fwd_ip = pf.get("fwd", "")
|
||||
existing.fwd_port = pf.get("fwd_port", "")
|
||||
existing.src_filter = pf.get("src")
|
||||
existing.enabled = pf.get("enabled", True)
|
||||
existing.scraped_at = scraped_at
|
||||
else:
|
||||
session.add(UnifiPortForward(
|
||||
unifi_id=uid,
|
||||
name=pf.get("name"),
|
||||
proto=pf.get("proto", "tcp"),
|
||||
dst_port=pf.get("dst_port", ""),
|
||||
fwd_ip=pf.get("fwd", ""),
|
||||
fwd_port=pf.get("fwd_port", ""),
|
||||
src_filter=pf.get("src"),
|
||||
enabled=pf.get("enabled", True),
|
||||
scraped_at=scraped_at,
|
||||
))
|
||||
|
||||
# Firewall rules (upsert by UniFi ID)
|
||||
for rule in fw_rules:
|
||||
uid = rule.get("_id", "")
|
||||
if not uid:
|
||||
continue
|
||||
existing = await session.get(UnifiFwRule, uid)
|
||||
if existing:
|
||||
existing.name = rule.get("name", "")
|
||||
existing.ruleset = rule.get("ruleset", "")
|
||||
existing.rule_index = rule.get("rule_index")
|
||||
existing.action = rule.get("action", "")
|
||||
existing.protocol = rule.get("protocol")
|
||||
existing.enabled = rule.get("enabled", True)
|
||||
existing.src_address = rule.get("src_address")
|
||||
existing.dst_address = rule.get("dst_address")
|
||||
existing.scraped_at = scraped_at
|
||||
else:
|
||||
session.add(UnifiFwRule(
|
||||
unifi_id=uid,
|
||||
name=rule.get("name", ""),
|
||||
ruleset=rule.get("ruleset", ""),
|
||||
rule_index=rule.get("rule_index"),
|
||||
action=rule.get("action", ""),
|
||||
protocol=rule.get("protocol"),
|
||||
enabled=rule.get("enabled", True),
|
||||
src_address=rule.get("src_address"),
|
||||
dst_address=rule.get("dst_address"),
|
||||
scraped_at=scraped_at,
|
||||
))
|
||||
await hold_write(app, "UniFi poll", write)
|
||||
|
||||
logger.debug(
|
||||
"UniFi expanded: wlans=%d, events=%d, alarms=%d, known_clients=%d, "
|
||||
|
||||
Reference in New Issue
Block a user