// Package textfile renders and writes the Prometheus textfile that // node-exporter's textfile collector scrapes. package textfile import ( "fmt" "os" "path/filepath" "strings" "git.mial.net/mokhtar/x1208-exporter/internal/sensor" ) // Render returns the exposition text for the last complete sample. // // The first four metrics are byte-identical to the pre-daemon exporter. Their // names and value formatting are referenced by Grafana alerts and by the // dashboard, so they must not change. The golden test locks this. // // healthy reports the outcome of the most recent read attempt; failures counts // failed attempts since process start. Both are additive and safe to scrape. // // When a read fails the caller re-renders the PREVIOUS sample with healthy=0. // rpi_ups_last_update_seconds therefore stops advancing, which is what the // RpiUpsExporterStale alert measures, while the failure stays visible instead // of being hidden behind a frozen file. func Render(s sensor.Reading, healthy bool, failures uint64) string { ac := 0 if s.ACPresent { ac = 1 } health := 0 if healthy { health = 1 } var b strings.Builder b.WriteString("# HELP rpi_ups_last_update_seconds Unix timestamp of last successful exporter run.\n") b.WriteString("# TYPE rpi_ups_last_update_seconds gauge\n") fmt.Fprintf(&b, "rpi_ups_last_update_seconds %d\n", s.At.Unix()) b.WriteString("# HELP rpi_ups_ac_power AC adapter present (1=plugged in, 0=on battery).\n") b.WriteString("# TYPE rpi_ups_ac_power gauge\n") fmt.Fprintf(&b, "rpi_ups_ac_power %d\n", ac) b.WriteString("# HELP rpi_ups_voltage_volts Battery cell voltage from MAX17040 fuel gauge.\n") b.WriteString("# TYPE rpi_ups_voltage_volts gauge\n") fmt.Fprintf(&b, "rpi_ups_voltage_volts %.3f\n", s.Volts) b.WriteString("# HELP rpi_ups_battery_percent Battery state of charge from MAX17040 fuel gauge.\n") b.WriteString("# TYPE rpi_ups_battery_percent gauge\n") fmt.Fprintf(&b, "rpi_ups_battery_percent %.2f\n", s.SOC) b.WriteString("# HELP rpi_ups_sensor_healthy Whether the most recent UPS read succeeded (1=yes, 0=no).\n") b.WriteString("# TYPE rpi_ups_sensor_healthy gauge\n") fmt.Fprintf(&b, "rpi_ups_sensor_healthy %d\n", health) b.WriteString("# HELP rpi_ups_read_failures_total Cumulative failed UPS reads since exporter start.\n") b.WriteString("# TYPE rpi_ups_read_failures_total counter\n") fmt.Fprintf(&b, "rpi_ups_read_failures_total %d\n", failures) return b.String() } // WriteAtomic writes content to path via a temporary file and a rename, so a // scrape never observes a half-written file. func WriteAtomic(path, content string) error { dir := filepath.Dir(path) if err := os.MkdirAll(dir, 0o755); err != nil { return err } tmp, err := os.CreateTemp(dir, ".x1208.*.tmp") if err != nil { return err } tmpPath := tmp.Name() defer os.Remove(tmpPath) if _, err := tmp.WriteString(content); err != nil { tmp.Close() return err } if err := tmp.Chmod(0o644); err != nil { tmp.Close() return err } if err := tmp.Close(); err != nil { return err } return os.Rename(tmpPath, path) }