mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-23 19:57:09 +00:00
616 lines
17 KiB
Go
616 lines
17 KiB
Go
package hostagent
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
agentshost "github.com/rcourtman/pulse-go-rewrite/pkg/agents/host"
|
|
)
|
|
|
|
const hostAgentUnraidVersionPath = "/etc/unraid-version"
|
|
const hostAgentUnraidDisksINIPath = "/var/local/emhttp/disks.ini"
|
|
|
|
var commonUnraidMdcmdPaths = []string{
|
|
"/usr/local/sbin/mdcmd",
|
|
"/usr/sbin/mdcmd",
|
|
"/sbin/mdcmd",
|
|
"/usr/local/bin/mdcmd",
|
|
"/usr/bin/mdcmd",
|
|
"/bin/mdcmd",
|
|
}
|
|
|
|
// CollectUnraidStorage returns best-effort Unraid array topology for hosts
|
|
// running Unraid. Non-Unraid hosts return nil with no error.
|
|
func CollectUnraidStorage(ctx context.Context, collector SystemCollector) (*agentshost.UnraidStorage, error) {
|
|
if collector == nil || collector.GOOS() != "linux" {
|
|
return nil, nil
|
|
}
|
|
|
|
if _, err := collector.Stat(hostAgentUnraidVersionPath); err != nil {
|
|
if os.IsNotExist(err) {
|
|
return nil, nil
|
|
}
|
|
return nil, fmt.Errorf("stat %s: %w", hostAgentUnraidVersionPath, err)
|
|
}
|
|
|
|
mdcmdPath, err := resolveUnraidMdcmdBinary(collector)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
statusCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
|
|
defer cancel()
|
|
|
|
output, err := collector.CommandCombinedOutput(statusCtx, mdcmdPath, "status")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("run mdcmd status: %w", err)
|
|
}
|
|
|
|
storage, err := parseUnraidStatusOutput(output)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if data, readErr := collector.ReadFile(hostAgentUnraidDisksINIPath); readErr == nil {
|
|
storage = mergeUnraidDiskINI(storage, parseUnraidDisksINI(string(data)))
|
|
}
|
|
return storage, nil
|
|
}
|
|
|
|
func resolveUnraidMdcmdBinary(collector SystemCollector) (string, error) {
|
|
for _, candidate := range commonUnraidMdcmdPaths {
|
|
if _, err := collector.Stat(candidate); err == nil {
|
|
return candidate, nil
|
|
}
|
|
}
|
|
|
|
path, err := collector.LookPath("mdcmd")
|
|
if err != nil {
|
|
return "", fmt.Errorf("mdcmd binary not found in PATH or common locations")
|
|
}
|
|
if !filepath.IsAbs(path) {
|
|
return "", fmt.Errorf("mdcmd path is not absolute: %q", path)
|
|
}
|
|
if _, err := collector.Stat(path); err != nil {
|
|
return "", fmt.Errorf("mdcmd path unavailable: %w", err)
|
|
}
|
|
return path, nil
|
|
}
|
|
|
|
func parseUnraidStatusOutput(output string) (*agentshost.UnraidStorage, error) {
|
|
fields := parseUnraidKeyValueOutput(output)
|
|
if len(fields) == 0 {
|
|
return nil, fmt.Errorf("mdcmd status returned no key=value fields")
|
|
}
|
|
|
|
storage := &agentshost.UnraidStorage{
|
|
ArrayState: strings.ToUpper(strings.TrimSpace(fields["mdState"])),
|
|
ArrayStarted: strings.EqualFold(strings.TrimSpace(fields["mdState"]), "STARTED"),
|
|
SyncAction: normalizeUnraidSyncAction(fields["mdResyncAction"]),
|
|
SyncProgress: unraidSyncProgress(fields),
|
|
SyncErrors: parseUnraidInt64Field(fields, "mdResyncCorr"),
|
|
NumProtected: parseUnraidIntField(fields, "mdNumProtected"),
|
|
NumDisabled: parseUnraidIntField(fields, "mdNumDisabled"),
|
|
NumInvalid: parseUnraidIntField(fields, "mdNumInvalid"),
|
|
NumMissing: parseUnraidIntField(fields, "mdNumMissing"),
|
|
}
|
|
|
|
indexes := collectUnraidIndexes(fields)
|
|
disks := make([]agentshost.UnraidDisk, 0, len(indexes))
|
|
for _, idx := range indexes {
|
|
disk := agentshost.UnraidDisk{
|
|
Name: strings.TrimSpace(fields[fmt.Sprintf("diskName.%d", idx)]),
|
|
Device: normalizeBlockDevice(firstNonEmpty(fields[fmt.Sprintf("rdevName.%d", idx)], fields[fmt.Sprintf("diskDevice.%d", idx)])),
|
|
RawStatus: strings.TrimSpace(firstNonEmpty(fields[fmt.Sprintf("rdevStatus.%d", idx)], fields[fmt.Sprintf("diskState.%d", idx)])),
|
|
Serial: strings.TrimSpace(firstNonEmpty(fields[fmt.Sprintf("rdevSerial.%d", idx)], fields[fmt.Sprintf("diskSerial.%d", idx)], fields[fmt.Sprintf("rdevId.%d", idx)], fields[fmt.Sprintf("diskId.%d", idx)])),
|
|
Filesystem: strings.TrimSpace(firstNonEmpty(fields[fmt.Sprintf("diskFsType.%d", idx)], fields[fmt.Sprintf("fsType.%d", idx)])),
|
|
SizeBytes: parseUnraidKiBAsBytes(firstNonEmpty(fields[fmt.Sprintf("diskSize.%d", idx)], fields[fmt.Sprintf("rdevSize.%d", idx)])),
|
|
Slot: idx,
|
|
}
|
|
disk.Role = inferUnraidDiskRole(disk.Name, idx)
|
|
disk.Status = normalizeUnraidDiskStatus(disk.RawStatus, disk.Device)
|
|
if isUnraidEmptySlot(disk) {
|
|
continue
|
|
}
|
|
if disk.Name == "" {
|
|
disk.Name = defaultUnraidDiskName(disk.Role, idx)
|
|
}
|
|
if disk.Name == "" && disk.Device == "" && disk.RawStatus == "" && disk.Serial == "" && disk.SizeBytes == 0 {
|
|
continue
|
|
}
|
|
disks = append(disks, disk)
|
|
}
|
|
|
|
if len(disks) > 0 {
|
|
storage.Disks = disks
|
|
}
|
|
|
|
return storage, nil
|
|
}
|
|
|
|
func parseUnraidKeyValueOutput(output string) map[string]string {
|
|
lines := strings.Split(output, "\n")
|
|
fields := make(map[string]string, len(lines))
|
|
for _, line := range lines {
|
|
line = strings.TrimSpace(line)
|
|
if line == "" || !strings.Contains(line, "=") {
|
|
continue
|
|
}
|
|
parts := strings.SplitN(line, "=", 2)
|
|
key := strings.TrimSpace(parts[0])
|
|
value := strings.TrimSpace(parts[1])
|
|
if key == "" {
|
|
continue
|
|
}
|
|
fields[key] = value
|
|
}
|
|
return fields
|
|
}
|
|
|
|
type unraidINISection struct {
|
|
name string
|
|
fields map[string]string
|
|
}
|
|
|
|
func parseUnraidDisksINI(input string) []agentshost.UnraidDisk {
|
|
sections := parseUnraidINISections(input)
|
|
disks := make([]agentshost.UnraidDisk, 0, len(sections))
|
|
for _, section := range sections {
|
|
if section.name == "" {
|
|
continue
|
|
}
|
|
fields := section.fields
|
|
idx := parseUnraidIntField(fields, "idx")
|
|
name := firstNonEmpty(fields["name"], section.name)
|
|
role := normalizeUnraidDiskRole(firstNonEmpty(fields["type"], inferUnraidDiskRole(name, idx)))
|
|
device := normalizeBlockDevice(fields["device"])
|
|
rawStatus := strings.TrimSpace(fields["status"])
|
|
disk := agentshost.UnraidDisk{
|
|
Name: strings.TrimSpace(name),
|
|
Device: device,
|
|
Role: role,
|
|
Status: normalizeUnraidDiskStatus(rawStatus, device),
|
|
RawStatus: rawStatus,
|
|
Filesystem: strings.TrimSpace(fields["fsType"]),
|
|
Transport: normalizeUnraidTransport(fields["transport"], fields["rotational"]),
|
|
SizeBytes: parseUnraidSizeBytes(fields),
|
|
UsedBytes: parseUnraidKiBAsBytes(fields["fsUsed"]),
|
|
FreeBytes: parseUnraidKiBAsBytes(fields["fsFree"]),
|
|
Temperature: parseUnraidTemperature(fields["temp"]),
|
|
SpunDown: parseUnraidBool(fields["spundown"]),
|
|
ReadCount: parseFirstInt64(fields["numReads"]),
|
|
WriteCount: parseFirstInt64(fields["numWrites"]),
|
|
ErrorCount: parseFirstInt64(fields["numErrors"]),
|
|
Slot: idx,
|
|
}
|
|
disk.Model, disk.Serial = parseUnraidDiskIdentity(firstNonEmpty(fields["id"], fields["idSb"]))
|
|
if disk.Serial == "" {
|
|
disk.Serial = strings.TrimSpace(firstNonEmpty(fields["id"], fields["idSb"]))
|
|
}
|
|
if isUnraidEmptySlot(disk) {
|
|
continue
|
|
}
|
|
if disk.Name == "" {
|
|
disk.Name = defaultUnraidDiskName(disk.Role, idx)
|
|
}
|
|
if disk.Name == "" && disk.Device == "" && disk.RawStatus == "" && disk.Serial == "" && disk.SizeBytes == 0 {
|
|
continue
|
|
}
|
|
disks = append(disks, disk)
|
|
}
|
|
return disks
|
|
}
|
|
|
|
func parseUnraidINISections(input string) []unraidINISection {
|
|
lines := strings.Split(input, "\n")
|
|
sections := make([]unraidINISection, 0)
|
|
var current *unraidINISection
|
|
for _, rawLine := range lines {
|
|
line := strings.TrimSpace(rawLine)
|
|
if line == "" || strings.HasPrefix(line, ";") || strings.HasPrefix(line, "#") {
|
|
continue
|
|
}
|
|
if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") {
|
|
name := strings.TrimSpace(strings.TrimSuffix(strings.TrimPrefix(line, "["), "]"))
|
|
name = strings.Trim(name, `"`)
|
|
sections = append(sections, unraidINISection{name: name, fields: make(map[string]string)})
|
|
current = §ions[len(sections)-1]
|
|
continue
|
|
}
|
|
if current == nil || !strings.Contains(line, "=") {
|
|
continue
|
|
}
|
|
parts := strings.SplitN(line, "=", 2)
|
|
key := strings.TrimSpace(parts[0])
|
|
value := strings.TrimSpace(parts[1])
|
|
if unquoted, err := strconv.Unquote(value); err == nil {
|
|
value = unquoted
|
|
}
|
|
if key != "" {
|
|
current.fields[key] = value
|
|
}
|
|
}
|
|
return sections
|
|
}
|
|
|
|
func mergeUnraidDiskINI(storage *agentshost.UnraidStorage, iniDisks []agentshost.UnraidDisk) *agentshost.UnraidStorage {
|
|
if storage == nil {
|
|
storage = &agentshost.UnraidStorage{}
|
|
}
|
|
if len(iniDisks) == 0 {
|
|
return storage
|
|
}
|
|
|
|
merged := make([]agentshost.UnraidDisk, 0, len(storage.Disks)+len(iniDisks))
|
|
bySlot := make(map[int]int, len(storage.Disks))
|
|
for _, disk := range storage.Disks {
|
|
bySlot[disk.Slot] = len(merged)
|
|
merged = append(merged, disk)
|
|
}
|
|
|
|
for _, disk := range iniDisks {
|
|
if pos, ok := bySlot[disk.Slot]; ok {
|
|
merged[pos] = mergeUnraidDisk(merged[pos], disk)
|
|
continue
|
|
}
|
|
bySlot[disk.Slot] = len(merged)
|
|
merged = append(merged, disk)
|
|
}
|
|
|
|
sort.SliceStable(merged, func(i, j int) bool {
|
|
return merged[i].Slot < merged[j].Slot
|
|
})
|
|
storage.Disks = merged
|
|
return storage
|
|
}
|
|
|
|
func mergeUnraidDisk(base, incoming agentshost.UnraidDisk) agentshost.UnraidDisk {
|
|
out := base
|
|
if strings.TrimSpace(incoming.Name) != "" {
|
|
out.Name = strings.TrimSpace(incoming.Name)
|
|
}
|
|
if strings.TrimSpace(incoming.Device) != "" {
|
|
out.Device = strings.TrimSpace(incoming.Device)
|
|
}
|
|
if strings.TrimSpace(incoming.Role) != "" {
|
|
out.Role = strings.TrimSpace(incoming.Role)
|
|
}
|
|
if strings.TrimSpace(incoming.Status) != "" {
|
|
out.Status = strings.TrimSpace(incoming.Status)
|
|
}
|
|
if strings.TrimSpace(incoming.RawStatus) != "" {
|
|
out.RawStatus = strings.TrimSpace(incoming.RawStatus)
|
|
}
|
|
if strings.TrimSpace(incoming.Model) != "" {
|
|
out.Model = strings.TrimSpace(incoming.Model)
|
|
}
|
|
if strings.TrimSpace(incoming.Serial) != "" {
|
|
out.Serial = strings.TrimSpace(incoming.Serial)
|
|
}
|
|
if strings.TrimSpace(incoming.Filesystem) != "" {
|
|
out.Filesystem = strings.TrimSpace(incoming.Filesystem)
|
|
}
|
|
if strings.TrimSpace(incoming.Transport) != "" {
|
|
out.Transport = strings.TrimSpace(incoming.Transport)
|
|
}
|
|
if incoming.SizeBytes > 0 {
|
|
out.SizeBytes = incoming.SizeBytes
|
|
}
|
|
if incoming.UsedBytes > 0 {
|
|
out.UsedBytes = incoming.UsedBytes
|
|
}
|
|
if incoming.FreeBytes > 0 {
|
|
out.FreeBytes = incoming.FreeBytes
|
|
}
|
|
if incoming.Temperature > 0 {
|
|
out.Temperature = incoming.Temperature
|
|
}
|
|
out.SpunDown = incoming.SpunDown
|
|
if incoming.ReadCount > 0 {
|
|
out.ReadCount = incoming.ReadCount
|
|
}
|
|
if incoming.WriteCount > 0 {
|
|
out.WriteCount = incoming.WriteCount
|
|
}
|
|
if incoming.ErrorCount > 0 {
|
|
out.ErrorCount = incoming.ErrorCount
|
|
}
|
|
if incoming.Slot > 0 || out.Slot == 0 {
|
|
out.Slot = incoming.Slot
|
|
}
|
|
return out
|
|
}
|
|
|
|
func collectUnraidIndexes(fields map[string]string) []int {
|
|
indexes := make(map[int]struct{})
|
|
prefixes := []string{
|
|
"diskName.",
|
|
"diskSize.",
|
|
"diskState.",
|
|
"diskFsType.",
|
|
"diskId.",
|
|
"rdevName.",
|
|
"rdevStatus.",
|
|
"rdevSerial.",
|
|
"rdevId.",
|
|
}
|
|
|
|
for key := range fields {
|
|
for _, prefix := range prefixes {
|
|
if !strings.HasPrefix(key, prefix) {
|
|
continue
|
|
}
|
|
idx, err := strconv.Atoi(strings.TrimPrefix(key, prefix))
|
|
if err != nil {
|
|
continue
|
|
}
|
|
indexes[idx] = struct{}{}
|
|
break
|
|
}
|
|
}
|
|
|
|
out := make([]int, 0, len(indexes))
|
|
for idx := range indexes {
|
|
out = append(out, idx)
|
|
}
|
|
sort.Ints(out)
|
|
return out
|
|
}
|
|
|
|
func normalizeBlockDevice(device string) string {
|
|
device = strings.TrimSpace(device)
|
|
if device == "" {
|
|
return ""
|
|
}
|
|
if strings.HasPrefix(device, "/dev/") {
|
|
return device
|
|
}
|
|
return "/dev/" + device
|
|
}
|
|
|
|
func inferUnraidDiskRole(name string, idx int) string {
|
|
name = strings.ToLower(strings.TrimSpace(name))
|
|
switch {
|
|
case name == "parity" || name == "parity1" || strings.HasPrefix(name, "parity"):
|
|
return "parity"
|
|
case strings.HasPrefix(name, "disk"):
|
|
return "data"
|
|
case strings.HasPrefix(name, "cache") || strings.HasPrefix(name, "pool"):
|
|
return "cache"
|
|
case name == "flash":
|
|
return "flash"
|
|
case idx == 0:
|
|
return "parity"
|
|
case idx == 29:
|
|
return "parity"
|
|
case idx > 0 && idx < 29:
|
|
return "data"
|
|
case idx >= 30:
|
|
return "cache"
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
func normalizeUnraidDiskRole(role string) string {
|
|
role = strings.ToLower(strings.TrimSpace(role))
|
|
switch role {
|
|
case "parity", "data", "cache", "flash":
|
|
return role
|
|
case "array":
|
|
return "data"
|
|
default:
|
|
return role
|
|
}
|
|
}
|
|
|
|
func defaultUnraidDiskName(role string, idx int) string {
|
|
switch role {
|
|
case "parity":
|
|
if idx <= 0 {
|
|
return "parity"
|
|
}
|
|
return fmt.Sprintf("parity%d", idx)
|
|
case "data":
|
|
return fmt.Sprintf("disk%d", idx)
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
func isUnraidEmptySlot(disk agentshost.UnraidDisk) bool {
|
|
rawStatus := strings.ToUpper(strings.TrimSpace(disk.RawStatus))
|
|
status := strings.ToLower(strings.TrimSpace(disk.Status))
|
|
if !strings.Contains(rawStatus, "DISK_NP") && status != "missing" {
|
|
return false
|
|
}
|
|
return strings.TrimSpace(disk.Name) == "" &&
|
|
strings.TrimSpace(disk.Device) == "" &&
|
|
strings.TrimSpace(disk.Serial) == "" &&
|
|
strings.TrimSpace(disk.Filesystem) == "" &&
|
|
disk.SizeBytes == 0
|
|
}
|
|
|
|
func normalizeUnraidDiskStatus(raw string, device string) string {
|
|
status := strings.ToUpper(strings.TrimSpace(raw))
|
|
switch {
|
|
case status == "":
|
|
if device != "" {
|
|
return "online"
|
|
}
|
|
return ""
|
|
case strings.Contains(status, "DISK_OK") || status == "OK":
|
|
return "online"
|
|
case strings.Contains(status, "DISK_DSBL") || strings.Contains(status, "DISABLED"):
|
|
return "disabled"
|
|
case strings.Contains(status, "DISK_NP") || strings.Contains(status, "MISSING") || strings.Contains(status, "NOT_INSTALLED"):
|
|
return "missing"
|
|
case strings.Contains(status, "DISK_INVALID") || strings.Contains(status, "INVALID"):
|
|
return "invalid"
|
|
case strings.Contains(status, "DISK_WRONG") || strings.Contains(status, "WRONG"):
|
|
return "wrong"
|
|
case strings.Contains(status, "DISK_ERROR") || strings.Contains(status, "ERROR"):
|
|
return "error"
|
|
default:
|
|
return strings.ToLower(status)
|
|
}
|
|
}
|
|
|
|
func normalizeUnraidSyncAction(action string) string {
|
|
action = strings.ToLower(strings.TrimSpace(action))
|
|
switch action {
|
|
case "", "idle", "none", "unknown":
|
|
return ""
|
|
default:
|
|
return action
|
|
}
|
|
}
|
|
|
|
func unraidSyncProgress(fields map[string]string) float64 {
|
|
pos := parseFirstInt64(fields["mdResyncPos"], fields["mdResync"])
|
|
size := parseFirstInt64(fields["mdResyncSize"], fields["mdSize"])
|
|
if pos > 0 && size > 0 {
|
|
progress := (float64(pos) / float64(size)) * 100
|
|
if progress < 0 {
|
|
return 0
|
|
}
|
|
if progress > 100 {
|
|
return 100
|
|
}
|
|
return progress
|
|
}
|
|
if direct, ok := parseFloatField(fields, "mdResyncPct"); ok {
|
|
if direct < 0 {
|
|
return 0
|
|
}
|
|
if direct > 100 {
|
|
return 100
|
|
}
|
|
return direct
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func parseUnraidIntField(fields map[string]string, key string) int {
|
|
value := strings.TrimSpace(fields[key])
|
|
if value == "" {
|
|
return 0
|
|
}
|
|
parsed, err := strconv.Atoi(value)
|
|
if err != nil {
|
|
return 0
|
|
}
|
|
return parsed
|
|
}
|
|
|
|
func parseUnraidInt64Field(fields map[string]string, key string) int64 {
|
|
return parseFirstInt64(fields[key])
|
|
}
|
|
|
|
func parseFirstInt64(values ...string) int64 {
|
|
for _, value := range values {
|
|
value = strings.TrimSpace(value)
|
|
if value == "" {
|
|
continue
|
|
}
|
|
parsed, err := strconv.ParseInt(value, 10, 64)
|
|
if err == nil {
|
|
return parsed
|
|
}
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func parseUnraidKiBAsBytes(value string) int64 {
|
|
parsed := parseFirstInt64(value)
|
|
if parsed <= 0 {
|
|
return 0
|
|
}
|
|
return parsed * 1024
|
|
}
|
|
|
|
func parseUnraidSizeBytes(fields map[string]string) int64 {
|
|
sectors := parseFirstInt64(fields["sectors"])
|
|
sectorSize := parseFirstInt64(fields["sector_size"])
|
|
if sectors > 0 && sectorSize > 0 {
|
|
return sectors * sectorSize
|
|
}
|
|
return parseUnraidKiBAsBytes(firstNonEmpty(fields["size"], fields["sizeSb"]))
|
|
}
|
|
|
|
func parseUnraidTemperature(value string) int {
|
|
value = strings.TrimSpace(value)
|
|
if value == "" || value == "*" || strings.EqualFold(value, "na") {
|
|
return 0
|
|
}
|
|
parsed, err := strconv.Atoi(value)
|
|
if err != nil || parsed < 0 {
|
|
return 0
|
|
}
|
|
return parsed
|
|
}
|
|
|
|
func parseUnraidBool(value string) bool {
|
|
switch strings.ToLower(strings.TrimSpace(value)) {
|
|
case "1", "true", "yes", "on":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func normalizeUnraidTransport(transport string, rotational string) string {
|
|
transport = strings.ToLower(strings.TrimSpace(transport))
|
|
switch transport {
|
|
case "ata":
|
|
return "sata"
|
|
case "nvme", "sata", "sas", "usb":
|
|
return transport
|
|
}
|
|
if strings.TrimSpace(rotational) == "0" {
|
|
return "ssd"
|
|
}
|
|
return transport
|
|
}
|
|
|
|
func parseUnraidDiskIdentity(id string) (string, string) {
|
|
id = strings.TrimSpace(id)
|
|
if id == "" {
|
|
return "", ""
|
|
}
|
|
idx := strings.LastIndex(id, "_")
|
|
if idx <= 0 || idx >= len(id)-1 {
|
|
return strings.ReplaceAll(id, "_", " "), ""
|
|
}
|
|
model := strings.ReplaceAll(strings.TrimSpace(id[:idx]), "_", " ")
|
|
serial := strings.TrimSpace(id[idx+1:])
|
|
return model, serial
|
|
}
|
|
|
|
func parseFloatField(fields map[string]string, key string) (float64, bool) {
|
|
value := strings.TrimSpace(fields[key])
|
|
if value == "" {
|
|
return 0, false
|
|
}
|
|
parsed, err := strconv.ParseFloat(value, 64)
|
|
if err != nil {
|
|
return 0, false
|
|
}
|
|
return parsed, true
|
|
}
|
|
|
|
func firstNonEmpty(values ...string) string {
|
|
for _, value := range values {
|
|
value = strings.TrimSpace(value)
|
|
if value != "" {
|
|
return value
|
|
}
|
|
}
|
|
return ""
|
|
}
|