feat: add human-readable RAID recovery formatting with German time units and improved member detection from mdstat

Add format_transfer_rate() to convert mdstat rates (e.g., 133628K/sec) into readable values like "130.5 MB/s" using 1024-based units. Add format_remaining_time() to convert mdstat durations (e.g., 450.1min) into compact German format like "7 Std. 30 Min." with day/hour/minute components. Update raid_status() to parse member devices directly from array_line in /proc/mdstat to avoid cross
This commit is contained in:
2026-08-16 12:10:17 +02:00
parent 5ddf506f20
commit 0c922d4f5d
3 changed files with 67 additions and 9 deletions
+60 -4
View File
@@ -240,6 +240,45 @@ def _flatten_lsblk(nodes):
result.extend(_flatten_lsblk(node.get("children")))
return result
def format_transfer_rate(value):
"""Convert mdstat rates such as 133628K/sec into a readable value."""
if not value or value == "":
return ""
match = re.match(r"([\d.]+)\s*([KMGTP]?)(?:B)?/sec", value, re.IGNORECASE)
if not match:
return value
amount = float(match.group(1))
unit = match.group(2).upper()
multiplier = {"": 1, "K": 1024, "M": 1024 ** 2, "G": 1024 ** 3, "T": 1024 ** 4, "P": 1024 ** 5}.get(unit, 1)
bytes_per_second = amount * multiplier
units = ((1024 ** 3, "GB/s"), (1024 ** 2, "MB/s"), (1024, "KB/s"))
for threshold, label in units:
if bytes_per_second >= threshold:
return f"{bytes_per_second / threshold:.1f} {label}"
return f"{bytes_per_second:.0f} B/s"
def format_remaining_time(value):
"""Convert mdstat values such as 450.1min into a compact German duration."""
if not value or value == "":
return ""
match = re.match(r"([\d.]+)(min|sec|hours?|days?)", value, re.IGNORECASE)
if not match:
return value
amount = float(match.group(1))
unit = match.group(2).lower()
total_minutes = amount / 60 if unit.startswith("sec") else amount if unit.startswith("min") else amount * 60 if unit.startswith("hour") else amount * 1440
total_minutes = max(0, round(total_minutes))
days, remainder = divmod(total_minutes, 1440)
hours, minutes = divmod(remainder, 60)
parts = []
if days:
parts.append(f"{days} T")
if hours or days:
parts.append(f"{hours} Std.")
if minutes and len(parts) < 2:
parts.append(f"{minutes} Min.")
return " ".join(parts) or "< 1 Min."
def raid_status():
mdstat, mdstat_error = "", ""
try:
@@ -249,7 +288,15 @@ def raid_status():
array_name = Path(RAID_DEVICE).name
array_line = next((line for line in mdstat.splitlines() if line.startswith(f"{array_name} :")), "")
profile_match = re.search(r"(raid\d+)", array_line)
members_match = re.search(r"\[(\d+)/(\d+)\]\s+\[([^\]]+)\]", mdstat)
# Match the member count on this RAID's own line. Searching all of
# /proc/mdstat can accidentally pick a different array's [x/y] value.
members_match = re.search(r"\[(\d+)/(\d+)\]\s+\[([^\]]+)\]", array_line)
mdstat_members = []
for member_name, slot in re.findall(r"\b([A-Za-z0-9][A-Za-z0-9._-]*)\[(\d+)\]", array_line):
member_suffix = re.search(rf"\b{re.escape(member_name)}\[{re.escape(slot)}\]\(([^)]+)\)", array_line)
member_state = member_suffix.group(1).lower() if member_suffix else "active"
member_status = "failed" if "f" in member_state else ("spare" if "s" in member_state else "active")
mdstat_members.append({"device": f"/dev/{member_name}", "slot": slot, "state": member_state, "status": member_status})
progress_match = re.search(r"(?:recovery|resync|reshape|check)\s*=\s*([\d.]+)%", mdstat, re.IGNORECASE)
finish_match = re.search(r"finish=([^\s]+)", mdstat)
speed_match = re.search(r"speed=([^\s]+)", mdstat)
@@ -272,6 +319,10 @@ def raid_status():
if match:
state_text, device = match.group(2), match.group(3)
devices.append({"device": device, "slot": match.group(1), "state": state_text, "status": "active" if "active" in state_text.lower() else ("spare" if "spare" in state_text.lower() else "failed")})
known_devices = {Path(disk["device"]).name for disk in devices}
for disk in mdstat_members:
if Path(disk["device"]).name not in known_devices:
devices.append(disk)
lsblk_output, _ = _read_command(["lsblk", "-J", "-b", "-o", "NAME,SIZE,MODEL,SERIAL,TYPE,PATH"])
try:
block_devices = {item.get("path") or f"/dev/{item.get('name')}": item for item in _flatten_lsblk(json.loads(lsblk_output).get("blockdevices", []))}
@@ -308,11 +359,16 @@ def raid_status():
status = "ok"
else:
status = "failed"
mdstat_active = int(members_match.group(1)) if members_match else 0
mdstat_total = int(members_match.group(2)) if members_match else 0
active_devices = mdstat_active or detail.get("active_devices", 0)
raid_devices = mdstat_total or detail.get("raid_devices", 0) or len(devices)
failed_devices = detail.get("failed_devices", 0) or max(0, raid_devices - active_devices)
return {"device": RAID_DEVICE, "status": status, "label": {"ok": "OK", "degraded": "DEGRADED", "failed": "FAILED", "rebuilding": "REBUILDING"}.get(status, "UNKNOWN"),
"level": detail.get("level", ""), "raid_devices": detail.get("raid_devices") or (int(members_match.group(2)) if members_match else 0),
"active_devices": detail.get("active_devices") or (int(members_match.group(1)) if members_match else 0), "failed_devices": detail.get("failed_devices", 0),
"level": detail.get("level", ""), "raid_devices": raid_devices,
"active_devices": active_devices, "failed_devices": failed_devices,
"devices": devices, "recovery": {"active": rebuilding, "percent": float(progress_match.group(1)) if progress_match else 0,
"finish": finish_match.group(1) if finish_match else "", "speed": speed_match.group(1) if speed_match else ""},
"finish": format_remaining_time(finish_match.group(1)) if finish_match else "", "speed": format_transfer_rate(speed_match.group(1)) if speed_match else ""},
"error": None if array_line or mdadm_output else (mdadm_error or mdstat_error or "RAID-Status nicht verfügbar"), "updated_at": time.time()}
def datastore_snapshot():