AP-role metrics: client telemetry replaces dead STA/campground metrics
- metrics.lua: drop vif-sta0 station + campground-gateway sections (roof is a dumb AP since 2026-06-07); add skyweave_ap_* families — client count, per-client signal/SNR/bitrates/idle/association-time from iwinfo assoclist, noise floor, txpower, channel. Ping targets now chateau_gateway + talos_via_tunnel + internet (DNS). - skyweave-push.sh: PUT instead of POST so departed-client series and retired families don't linger on the pushgateway; skip push when the local metrics fetch fails. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+7
-1
@@ -1,9 +1,15 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
# Push skyweave metrics to Prometheus pushgateway every 30 seconds.
|
# Push skyweave metrics to Prometheus pushgateway every 30 seconds.
|
||||||
|
# PUT (not POST) so the whole group is replaced each cycle — per-client
|
||||||
|
# series for departed stations don't linger. Skips the push if the local
|
||||||
|
# metrics fetch fails, so a CGI hiccup never blanks the group.
|
||||||
PUSHGW="http://192.168.88.44:9091/metrics/job/rvlink/instance/rvlink"
|
PUSHGW="http://192.168.88.44:9091/metrics/job/rvlink/instance/rvlink"
|
||||||
METRICS_URL="http://127.0.0.1:42069/cgi-bin/skyweave.lua/metrics"
|
METRICS_URL="http://127.0.0.1:42069/cgi-bin/skyweave.lua/metrics"
|
||||||
|
|
||||||
while true; do
|
while true; do
|
||||||
curl -sf "$METRICS_URL" | curl -sf --data-binary @- "$PUSHGW" >/dev/null 2>&1
|
BODY=$(curl -sf "$METRICS_URL")
|
||||||
|
if [ -n "$BODY" ]; then
|
||||||
|
printf '%s\n' "$BODY" | curl -sf -X PUT --data-binary @- "$PUSHGW" >/dev/null 2>&1
|
||||||
|
fi
|
||||||
sleep 30
|
sleep 30
|
||||||
done
|
done
|
||||||
|
|||||||
+126
-71
@@ -1,6 +1,9 @@
|
|||||||
-- skyweave/handlers/metrics.lua
|
-- skyweave/handlers/metrics.lua
|
||||||
-- Prometheus-compatible metrics endpoint for the RVLink roof unit.
|
-- Prometheus-compatible metrics endpoint for the RVLink roof unit.
|
||||||
-- Exposes WiFi signal quality, bitrates, active gateway, system load, memory, uptime, and ping stats.
|
-- AP-role edition (since 2026-06-07 the roof is a dumb outdoor AP, not a
|
||||||
|
-- campground-WiFi station): exposes AP client telemetry (count, per-client
|
||||||
|
-- signal/bitrate), radio noise floor, active gateway, system load, memory,
|
||||||
|
-- uptime, and connectivity checks toward the Chateau / home tunnel / internet.
|
||||||
|
|
||||||
local utils = require("skyweave.utils")
|
local utils = require("skyweave.utils")
|
||||||
|
|
||||||
@@ -33,16 +36,28 @@ local function emit(out, help, type_, name, labels, value)
|
|||||||
table.insert(out, name .. label_str .. " " .. tostring(value))
|
table.insert(out, name .. label_str .. " " .. tostring(value))
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Parse the leading integer dBm from strings like "-65 dBm" or "-65 [-65, -66] dBm".
|
-- Render a metric family with multiple samples: HELP/TYPE once, then all
|
||||||
local function parse_dbm(s)
|
-- sample lines. samples is a list of {labels=..., value=...}; skips entries
|
||||||
if not s then return nil end
|
-- with nil value, skips the family entirely if no samples survive.
|
||||||
return tonumber(s:match("^%s*(-?%d+)"))
|
local function emit_family(out, help, type_, name, samples)
|
||||||
|
local lines = {}
|
||||||
|
for _, s in ipairs(samples) do
|
||||||
|
if s.value ~= nil then
|
||||||
|
local label_str = ""
|
||||||
|
if s.labels and next(s.labels) then
|
||||||
|
local parts = {}
|
||||||
|
for k, v in pairs(s.labels) do
|
||||||
|
table.insert(parts, k .. '="' .. escape_label(v) .. '"')
|
||||||
end
|
end
|
||||||
|
label_str = "{" .. table.concat(parts, ",") .. "}"
|
||||||
-- Parse the leading decimal Mbit/s from strings like "54.0 MBit/s" or "6.0 MBit/s MCS 0".
|
end
|
||||||
local function parse_mbps(s)
|
lines[#lines + 1] = name .. label_str .. " " .. tostring(s.value)
|
||||||
if not s then return nil end
|
end
|
||||||
return tonumber(s:match("^%s*(%d+%.?%d*)"))
|
end
|
||||||
|
if #lines == 0 then return end
|
||||||
|
table.insert(out, "# HELP " .. name .. " " .. help)
|
||||||
|
table.insert(out, "# TYPE " .. name .. " " .. type_)
|
||||||
|
for _, l in ipairs(lines) do table.insert(out, l) end
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Read /proc/uptime and return centiseconds (10ms precision).
|
-- Read /proc/uptime and return centiseconds (10ms precision).
|
||||||
@@ -103,27 +118,97 @@ local function read_file(path)
|
|||||||
return content
|
return content
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- Parse `iwinfo <iface> info` for AP-side radio facts.
|
||||||
|
local function ap_radio_info(iface)
|
||||||
|
local fh = io.popen("iwinfo " .. iface .. " info 2>/dev/null")
|
||||||
|
if not fh then return nil end
|
||||||
|
local out = fh:read("*a"); fh:close()
|
||||||
|
if not out or out == "" then return nil end
|
||||||
|
return {
|
||||||
|
ssid = out:match('ESSID:%s*"([^"]*)"'),
|
||||||
|
channel = tonumber(out:match("Channel:%s*(%d+)")),
|
||||||
|
txpower = tonumber(out:match("Tx%-Power:%s*(%d+)%s*dBm")),
|
||||||
|
noise = tonumber(out:match("Noise:%s*(-?%d+)%s*dBm")),
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Parse `iwinfo <iface> assoclist` into a list of client tables.
|
||||||
|
-- Block format (Chaos Calmer iwinfo):
|
||||||
|
-- 28:05:A5:6F:83:30 -83 dBm / -95 dBm (SNR 12) 1420 ms ago
|
||||||
|
-- RX: 6.0 MBit/s, MCS 0, 20MHz 317 Pkts 22997 Bytes.
|
||||||
|
-- TX: 19.5 MBit/s, MCS 2, 20MHz 84 Pkts 8594 Bytes.
|
||||||
|
-- Connected Time: 00:01:54
|
||||||
|
local function ap_clients(iface)
|
||||||
|
local fh = io.popen("iwinfo " .. iface .. " assoclist 2>/dev/null")
|
||||||
|
if not fh then return {} end
|
||||||
|
local out = fh:read("*a"); fh:close()
|
||||||
|
local clients = {}
|
||||||
|
local cur = nil
|
||||||
|
for line in out:gmatch("[^\n]+") do
|
||||||
|
local mac, sig, inact = line:match(
|
||||||
|
"^(%x%x:%x%x:%x%x:%x%x:%x%x:%x%x)%s+(-?%d+) dBm.-(%d+) ms ago")
|
||||||
|
if mac then
|
||||||
|
cur = {
|
||||||
|
mac = mac,
|
||||||
|
signal = tonumber(sig),
|
||||||
|
snr = tonumber(line:match("SNR (%d+)")),
|
||||||
|
inactive_ms = tonumber(inact),
|
||||||
|
}
|
||||||
|
clients[#clients + 1] = cur
|
||||||
|
elseif cur then
|
||||||
|
local rx = line:match("RX:%s*([%d%.]+) MBit/s")
|
||||||
|
if rx then cur.rx_mbps = tonumber(rx) end
|
||||||
|
local tx = line:match("TX:%s*([%d%.]+) MBit/s")
|
||||||
|
if tx then cur.tx_mbps = tonumber(tx) end
|
||||||
|
local h, m, s = line:match("Connected Time:%s*(%d+):(%d+):(%d+)")
|
||||||
|
if h then
|
||||||
|
cur.connected_s = tonumber(h) * 3600 + tonumber(m) * 60 + tonumber(s)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return clients
|
||||||
|
end
|
||||||
|
|
||||||
function metrics_handler.get_metrics(request_context)
|
function metrics_handler.get_metrics(request_context)
|
||||||
local out = {}
|
local out = {}
|
||||||
|
local iface = "vif-ap0"
|
||||||
|
|
||||||
-- === WiFi upstream connection ===
|
-- === AP radio + clients ===
|
||||||
local ssid = utils.uci_get("wireless.wwan.ssid") or ""
|
local radio = ap_radio_info(iface)
|
||||||
local station = utils.get_station_dump_info("vif-sta0")
|
local clients = ap_clients(iface)
|
||||||
|
local ap_labels = { iface = iface, ssid = (radio and radio.ssid) or "" }
|
||||||
|
|
||||||
if station then
|
emit(out, "Number of stations associated to the roof AP",
|
||||||
local wifi_labels = {
|
"gauge", "skyweave_ap_clients", ap_labels, #clients)
|
||||||
ssid = ssid,
|
if radio then
|
||||||
iface = "vif-sta0",
|
emit(out, "AP radio noise floor in dBm",
|
||||||
bssid = station["bssid"] or "",
|
"gauge", "skyweave_ap_noise_dbm", ap_labels, radio.noise)
|
||||||
}
|
emit(out, "AP transmit power in dBm",
|
||||||
emit(out, "WiFi signal strength of upstream connection in dBm",
|
"gauge", "skyweave_ap_txpower_dbm", ap_labels, radio.txpower)
|
||||||
"gauge", "skyweave_wifi_signal_dbm", wifi_labels, parse_dbm(station["signal"]))
|
emit(out, "AP operating channel",
|
||||||
emit(out, "WiFi transmit bitrate to upstream AP in Mbit/s",
|
"gauge", "skyweave_ap_channel", ap_labels, radio.channel)
|
||||||
"gauge", "skyweave_wifi_tx_bitrate_mbps", wifi_labels, parse_mbps(station["tx bitrate"]))
|
|
||||||
emit(out, "WiFi receive bitrate from upstream AP in Mbit/s",
|
|
||||||
"gauge", "skyweave_wifi_rx_bitrate_mbps", wifi_labels, parse_mbps(station["rx bitrate"]))
|
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local function client_samples(field)
|
||||||
|
local s = {}
|
||||||
|
for _, c in ipairs(clients) do
|
||||||
|
s[#s + 1] = { labels = { mac = c.mac }, value = c[field] }
|
||||||
|
end
|
||||||
|
return s
|
||||||
|
end
|
||||||
|
emit_family(out, "Per-client signal strength at the AP in dBm",
|
||||||
|
"gauge", "skyweave_ap_client_signal_dbm", client_samples("signal"))
|
||||||
|
emit_family(out, "Per-client signal-to-noise ratio in dB",
|
||||||
|
"gauge", "skyweave_ap_client_snr_db", client_samples("snr"))
|
||||||
|
emit_family(out, "Per-client RX bitrate (client to AP) in Mbit/s",
|
||||||
|
"gauge", "skyweave_ap_client_rx_bitrate_mbps", client_samples("rx_mbps"))
|
||||||
|
emit_family(out, "Per-client TX bitrate (AP to client) in Mbit/s",
|
||||||
|
"gauge", "skyweave_ap_client_tx_bitrate_mbps", client_samples("tx_mbps"))
|
||||||
|
emit_family(out, "Per-client idle time in milliseconds",
|
||||||
|
"gauge", "skyweave_ap_client_inactive_ms", client_samples("inactive_ms"))
|
||||||
|
emit_family(out, "Per-client association duration in seconds",
|
||||||
|
"gauge", "skyweave_ap_client_connected_seconds", client_samples("connected_s"))
|
||||||
|
|
||||||
-- === Active default gateway (info-style gauge) ===
|
-- === Active default gateway (info-style gauge) ===
|
||||||
local active_gw, active_iface = utils.get_active_default_gateway()
|
local active_gw, active_iface = utils.get_active_default_gateway()
|
||||||
if active_iface then
|
if active_iface then
|
||||||
@@ -161,71 +246,41 @@ function metrics_handler.get_metrics(request_context)
|
|||||||
-- MemAvailable not present on this kernel; omit to avoid phantom nil metric
|
-- MemAvailable not present on this kernel; omit to avoid phantom nil metric
|
||||||
end
|
end
|
||||||
|
|
||||||
-- === Ping connectivity ===
|
-- === Connectivity checks ===
|
||||||
-- Collect results first, then emit each metric family in one block
|
-- Collect results first, then emit each metric family in one block
|
||||||
-- (# HELP / # TYPE must appear exactly once per metric name per exposition).
|
-- (# HELP / # TYPE must appear exactly once per metric name per exposition).
|
||||||
local ping_targets = {
|
local ping_targets = {
|
||||||
{ host = "192.168.18.1", name = "campground_gateway" },
|
{ host = "192.168.69.1", name = "chateau_gateway" },
|
||||||
{ host = "192.168.88.4", name = "talos_via_tunnel" },
|
{ host = "192.168.88.4", name = "talos_via_tunnel" },
|
||||||
}
|
}
|
||||||
local ping_results = {}
|
local ping_results = {}
|
||||||
for _, t in ipairs(ping_targets) do
|
for _, t in ipairs(ping_targets) do
|
||||||
ping_results[#ping_results + 1] = { target = t, stats = ping_stats(t.host) }
|
ping_results[#ping_results + 1] = { target = t, stats = ping_stats(t.host) }
|
||||||
end
|
end
|
||||||
-- Internet check via DNS (ICMP/HTTP are blocked by campground)
|
-- Internet check via DNS (also exercises the resolver path, not just ICMP)
|
||||||
ping_results[#ping_results + 1] = {
|
ping_results[#ping_results + 1] = {
|
||||||
target = { host = "8.8.8.8", name = "internet" },
|
target = { host = "8.8.8.8", name = "internet" },
|
||||||
stats = dns_check("8.8.8.8", "google.com"),
|
stats = dns_check("8.8.8.8", "google.com"),
|
||||||
}
|
}
|
||||||
|
|
||||||
local function label_str(target, name)
|
local function target_samples(field)
|
||||||
return '{target="' .. escape_label(target) .. '",name="' .. escape_label(name) .. '"}'
|
local s = {}
|
||||||
end
|
|
||||||
|
|
||||||
-- rtt (only emit samples where rtt is available)
|
|
||||||
local rtt_samples = {}
|
|
||||||
for _, r in ipairs(ping_results) do
|
|
||||||
if r.stats and r.stats.rtt_ms then
|
|
||||||
rtt_samples[#rtt_samples + 1] =
|
|
||||||
"skyweave_ping_rtt_ms" .. label_str(r.target.host, r.target.name) ..
|
|
||||||
" " .. tostring(r.stats.rtt_ms)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
if #rtt_samples > 0 then
|
|
||||||
table.insert(out, "# HELP skyweave_ping_rtt_ms ICMP round-trip time in milliseconds")
|
|
||||||
table.insert(out, "# TYPE skyweave_ping_rtt_ms gauge")
|
|
||||||
for _, s in ipairs(rtt_samples) do table.insert(out, s) end
|
|
||||||
end
|
|
||||||
|
|
||||||
-- loss
|
|
||||||
local loss_samples = {}
|
|
||||||
for _, r in ipairs(ping_results) do
|
for _, r in ipairs(ping_results) do
|
||||||
if r.stats then
|
if r.stats then
|
||||||
loss_samples[#loss_samples + 1] =
|
s[#s + 1] = {
|
||||||
"skyweave_ping_loss_pct" .. label_str(r.target.host, r.target.name) ..
|
labels = { target = r.target.host, name = r.target.name },
|
||||||
" " .. tostring(r.stats.loss_pct)
|
value = r.stats[field],
|
||||||
|
}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
if #loss_samples > 0 then
|
return s
|
||||||
table.insert(out, "# HELP skyweave_ping_loss_pct ICMP packet loss percentage (0-100)")
|
|
||||||
table.insert(out, "# TYPE skyweave_ping_loss_pct gauge")
|
|
||||||
for _, s in ipairs(loss_samples) do table.insert(out, s) end
|
|
||||||
end
|
|
||||||
|
|
||||||
-- reachable
|
|
||||||
local reach_samples = {}
|
|
||||||
for _, r in ipairs(ping_results) do
|
|
||||||
if r.stats then
|
|
||||||
reach_samples[#reach_samples + 1] =
|
|
||||||
"skyweave_ping_reachable" .. label_str(r.target.host, r.target.name) ..
|
|
||||||
" " .. tostring(r.stats.reachable)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
if #reach_samples > 0 then
|
|
||||||
table.insert(out, "# HELP skyweave_ping_reachable Host reachability via ICMP (1=up, 0=down)")
|
|
||||||
table.insert(out, "# TYPE skyweave_ping_reachable gauge")
|
|
||||||
for _, s in ipairs(reach_samples) do table.insert(out, s) end
|
|
||||||
end
|
end
|
||||||
|
emit_family(out, "ICMP round-trip time in milliseconds",
|
||||||
|
"gauge", "skyweave_ping_rtt_ms", target_samples("rtt_ms"))
|
||||||
|
emit_family(out, "ICMP packet loss percentage (0-100)",
|
||||||
|
"gauge", "skyweave_ping_loss_pct", target_samples("loss_pct"))
|
||||||
|
emit_family(out, "Host reachability via ICMP (1=up, 0=down)",
|
||||||
|
"gauge", "skyweave_ping_reachable", target_samples("reachable"))
|
||||||
|
|
||||||
return {
|
return {
|
||||||
prometheus_metrics = true,
|
prometheus_metrics = true,
|
||||||
|
|||||||
Reference in New Issue
Block a user