new beta 1.2.4.1

This commit is contained in:
MacRimi
2026-07-23 22:55:28 +02:00
parent 37f06b9e88
commit 91d503ea67
48 changed files with 1052 additions and 328 deletions
+208 -20
View File
@@ -32,6 +32,20 @@ type NIC = {
rx: number // MB/s
tx: number
status?: "up" | "down" // present → drives the active state instead of the rate
// Set when this NIC is enslaved to a bond. The diagram then routes it
// through the bond node instead of straight to the host.
bond?: string
// Current role INSIDE the bond, re-read on every poll so a failover
// flips the labels. "member" = every slave transmits (LACP, balance-*),
// so no active/standby distinction exists.
role?: "active" | "standby" | "member"
}
type Bond = {
id: string
mode?: string // "active-backup", "balance-alb", "802.3ad", …
rx: number
tx: number
status?: "up" | "down"
}
type Bridge = {
id: string
@@ -48,6 +62,7 @@ type Guest = {
}
export type NetworkFlowData = {
nics: NIC[]
bonds?: Bond[]
bridges: Bridge[]
consumers: Guest[] // includes the host pseudo-entry
}
@@ -57,6 +72,9 @@ const ICONS: Record<string, string> = {
nic: `<path d="m15 20 3-3h2a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h2l3 3z"/><path d="M6 8v1"/><path d="M10 8v1"/><path d="M14 8v1"/><path d="M18 8v1"/>`,
bridge: `<rect x="16" y="16" width="6" height="6" rx="1"/><rect x="2" y="16" width="6" height="6" rx="1"/><rect x="9" y="2" width="6" height="6" rx="1"/><path d="M5 16v-3a1 1 0 0 1 1-1h12a1 1 0 0 1 1 1v3"/><path d="M12 12V8"/>`,
host: `<rect x="4" y="4" width="16" height="16" rx="2"/><rect x="9" y="9" width="6" height="6" rx="1"/><path d="M15 2v2"/><path d="M15 20v2"/><path d="M2 15h2"/><path d="M2 9h2"/><path d="M20 15h2"/><path d="M20 9h2"/><path d="M9 2v2"/><path d="M9 20v2"/>`,
// Router — same glyph the interface cards use for a bond, so the node
// in the diagram and the row in the list read as the same thing.
bond: `<rect width="20" height="8" x="2" y="14" rx="2"/><path d="M6.01 18H6"/><path d="M10.01 18H10"/><path d="M15 10v4"/><path d="M17.84 7.17a4 4 0 0 0-5.66 0"/><path d="M20.66 4.34a8 8 0 0 0-11.31 0"/>`,
lxc: `<path d="M22 7.7c0-.6-.4-1.2-.8-1.5l-6.3-3.9a1.72 1.72 0 0 0-1.7 0l-10.3 6c-.5.2-.9.8-.9 1.4v6.6c0 .5.4 1.2.8 1.5l6.3 3.9a1.72 1.72 0 0 0 1.7 0l10.3-6c.5-.3.9-1 .9-1.5Z"/><path d="M10 21.9V14L2.1 9.1"/><path d="m10 14 11.9-6.9"/><path d="M14 19.8v-8.1"/><path d="M18 17.5V9.4"/>`,
vm: `<rect width="20" height="8" x="2" y="2" rx="2"/><rect width="20" height="8" x="2" y="14" rx="2"/><path d="M6 6h.01"/><path d="M6 18h.01"/>`,
}
@@ -66,10 +84,58 @@ const COLORS = {
lxc: "var(--cyan-500, #06b6d4)",
vm: "var(--purple-500, #a855f7)",
nic: "var(--amber-500, #f59e0b)",
// Warm like the NICs (both live on the physical side of the topology)
// but clearly its own hue, so the aggregation node doesn't read as
// just another NIC.
bond: "var(--orange-500, #f97316)",
bridge: "var(--cyan-500, #06b6d4)",
gray: "#525252",
}
// Bond membership resolved once and shared by both layouts. Only bonds
// that actually have a rendered slave are kept — an orphan bond node
// with nothing feeding it would be noise.
function resolveBonds(data: NetworkFlowData): {
bonds: Bond[]
bondOfNic: Map<string, string>
nics: NIC[]
} {
const declared = data.bonds || []
const byId = new Map(declared.map((b) => [b.id, b]))
const bondOfNic = new Map<string, string>()
data.nics.forEach((n) => {
if (n.bond && byId.has(n.bond)) bondOfNic.set(n.id, n.bond)
})
const bonds = declared.filter((b) =>
data.nics.some((n) => bondOfNic.get(n.id) === b.id)
)
// Group slaves of the same bond together so its node sits next to the
// NICs feeding it and the links don't cross unrelated ones. Array sort
// is stable, so NICs within a group keep their backend order.
const nics = [...data.nics].sort((a, b) => {
const ba = bondOfNic.get(a.id) || ""
const bb = bondOfNic.get(b.id) || ""
if (ba === bb) return 0
if (!ba) return 1 // unbonded NICs last
if (!bb) return -1
return ba < bb ? -1 : 1
})
return { bonds, bondOfNic, nics }
}
// Sub-label under a NIC. In active-backup the role is the useful bit
// (which cable is actually carrying traffic right now); in every other
// mode all slaves transmit, so the link speed stays.
function nicSubLabel(n: NIC): string {
if (n.status === "down") return "down"
const role = n.role === "standby" || n.role === "active" ? n.role : ""
if (!role) return n.link
// A NIC that doesn't report a negotiated speed renders its link as
// "—"; pairing that with the role would read as "— · active".
if (!n.link || n.link === "—") return role
return `${n.link} · ${role}`
}
function fmt(v: number): string {
if (!v) return "0 B/s"
// Below 1 KB/s show B/s — the previous "—" hid real-but-low traffic.
@@ -152,6 +218,7 @@ function renderHorizontal(data: NetworkFlowData, W: number): { svg: string; puls
const top = activeConsumers(data.consumers)
const bridges = visibleBridges(data.bridges, top)
const host = data.consumers.find((c) => c.kind === "host")
const { bonds, bondOfNic, nics } = resolveBonds(data)
const tight = W < 1100
const nicX = tight ? 70 : 90
@@ -165,6 +232,11 @@ function renderHorizontal(data: NetworkFlowData, W: number): { svg: string; puls
const radHost = tight ? 38 : 44
const radBridge = tight ? 22 : 26
const radGuest = 22
const radBond = tight ? 22 : 26
// Bond column sits halfway between the NICs and the host — the gap
// between those two columns was already wide enough to host it, so
// nothing downstream (bridges, bus, guests) has to move.
const bondX = Math.round((nicX + hostX) / 2)
// More vertical breathing room around each guest. The previous 110
// crammed circle+label+sub close to the bus; bumping to 135 lets
@@ -226,7 +298,7 @@ function renderHorizontal(data: NetworkFlowData, W: number): { svg: string; puls
return { b, guests, buses, sectionTop, sectionBot, bridgeY }
})
const nicCount = data.nics.length
const nicCount = nics.length
// Vertical pitch per NIC = circle + label + sub + breathing
// room. Was 118; bumped to 132 because the new 9-px-wide trunk
// line passing near sibling NICs ate into the label/sub area
@@ -255,18 +327,56 @@ function renderHorizontal(data: NetworkFlowData, W: number): { svg: string; puls
const linksPulse: PulseData[] = []
const nodes: string[] = []
data.nics.forEach((n, i) => {
const nicYById = new Map<string, number>()
nics.forEach((n, i) => {
const y = nicY0 + i * nicSpacing
nicYById.set(n.id, y)
// NIC is "active" when the kernel reports the link up — independent
// of current rate (a NIC with 100 B/s of background traffic should
// not render as gray).
const active = n.status ? n.status === "up" : n.rx + n.tx > 0
const stroke = active ? COLORS.nic : COLORS.gray
nodes.push(`<g data-node-id="${n.id}" data-node-kind="nic" style="cursor:pointer;opacity:${active ? 1 : 0.45}">
// A healthy standby is NOT a fault: it keeps its colour and is only
// slightly dimmed, so it stays clearly distinct from a down link.
const opacity = !active ? 0.45 : n.role === "standby" ? 0.72 : 1
nodes.push(`<g data-node-id="${n.id}" data-node-kind="nic" style="cursor:pointer;opacity:${opacity}">
<circle class="nf-circle" cx="${nicX}" cy="${y}" r="${radNic}" stroke="${stroke}" />
${svgIcon("nic", nicX, y, 18, stroke)}
<text class="nf-label" x="${nicX}" y="${y + radNic + 14}">${n.id}</text>
<text class="nf-sub" x="${nicX}" y="${y + radNic + 26}">${n.link}</text>
<text class="nf-sub" x="${nicX}" y="${y + radNic + 26}">${nicSubLabel(n)}</text>
</g>`)
})
// Bond node — vertically centred on the slaves feeding it, but lifted
// when unbonded NICs are also present. Those run their own trunk up to
// the host, and orthLink turns them at the midpoint of the NIC→host
// gap — which is the bond's own column. Their vertical leg therefore
// occupies everything BELOW hostY at that x, so the bond's three-line
// label has to sit above hostY to stay out of it. With every NIC in a
// bond there is no such trunk and the node keeps its natural centre.
const BOND_TEXT_BLOCK = 38 // label + mode + rate under the circle
const hasLooseNics = nics.some((n) => !bondOfNic.has(n.id))
const bondCeiling = hostY - radBond - BOND_TEXT_BLOCK - 16
const bondYById = new Map<string, number>()
bonds.forEach((b) => {
const ys = nics
.filter((n) => bondOfNic.get(n.id) === b.id)
.map((n) => nicYById.get(n.id) as number)
if (!ys.length) return
const centre = ys.reduce((a, v) => a + v, 0) / ys.length
const y = Math.max(
radBond + 8,
hasLooseNics ? Math.min(centre, bondCeiling) : centre,
)
bondYById.set(b.id, y)
const up = b.status ? b.status === "up" : true
const stroke = up ? COLORS.bond : COLORS.gray
nodes.push(`<g data-node-id="${b.id}" data-node-kind="bond" style="cursor:pointer;opacity:${up ? 1 : 0.45}">
<circle class="nf-circle" cx="${bondX}" cy="${y}" r="${radBond}" stroke="${stroke}" />
${svgIcon("bond", bondX, y, 16, stroke)}
<text class="nf-label" x="${bondX}" y="${y + radBond + 14}">${b.id}</text>
<text class="nf-sub" x="${bondX}" y="${y + radBond + 26}">${b.mode || "bond"}</text>
<text class="nf-sub" x="${bondX}" y="${y + radBond + 38}">${fmt(b.rx + b.tx)}</text>
</g>`)
})
@@ -431,13 +541,18 @@ function renderHorizontal(data: NetworkFlowData, W: number): { svg: string; puls
// background noise floor so the animation stays solid.
const NIC_PULSE_MIN_MBPS = 0.002 // 2 KB/s
data.nics.forEach((n, i) => {
const y = nicY0 + i * nicSpacing
nics.forEach((n) => {
const y = nicYById.get(n.id) as number
const active = n.status ? n.status === "up" : n.rx + n.tx > 0
if (!active) return
// Add a 5-px gap between each circle and the trunk line so the
// 9-px-wide static lane doesn't visually overlap the node ring.
const path = orthLink(nicX + radNic + 5, y, hostX - radHost - 5, hostY, 14)
// A slave stops at its bond; only a NIC with no bond goes straight
// to the host. Add a 5-px gap between each circle and the trunk
// line so the static lane doesn't visually overlap the node ring.
const bondId = bondOfNic.get(n.id)
const bondY = bondId !== undefined ? bondYById.get(bondId) : undefined
const path = bondY !== undefined
? orthLink(nicX + radNic + 5, y, bondX - radBond - 5, bondY, 14)
: orthLink(nicX + radNic + 5, y, hostX - radHost - 5, hostY, 14)
linksStatic.push(`<path class="nf-link" d="${path}" stroke-width="${TRUNK_WIDTH}" />`)
// Per-NIC pulse speed mirrors the per-guest logic: more traffic
// on this NIC → faster pulse on its line. Each NIC reads
@@ -447,6 +562,17 @@ function renderHorizontal(data: NetworkFlowData, W: number): { svg: string; puls
if (n.tx > NIC_PULSE_MIN_MBPS) linksPulse.push({ d: path, type: "tx", strokeWidth: TRUNK_PULSE_WIDTH, animDur: nicDur, key: `nic-tx-${n.id}` })
})
// Bond → host: one aggregated lane carrying the sum of its slaves.
bonds.forEach((b) => {
const y = bondYById.get(b.id)
if (y === undefined) return
const path = orthLink(bondX + radBond + 5, y, hostX - radHost - 5, hostY, 14)
linksStatic.push(`<path class="nf-link" d="${path}" stroke-width="${TRUNK_WIDTH}" />`)
const dur = durFor(b.rx + b.tx)
if (b.rx > NIC_PULSE_MIN_MBPS) linksPulse.push({ d: path, type: "rx", strokeWidth: TRUNK_PULSE_WIDTH, animDur: dur, key: `bond-rx-${b.id}` })
if (b.tx > NIC_PULSE_MIN_MBPS) linksPulse.push({ d: path, type: "tx", strokeWidth: TRUNK_PULSE_WIDTH, animDur: dur, key: `bond-tx-${b.id}` })
})
// Static svg = lines + nodes. Pulses are returned SEPARATELY as
// data so NetworkFlow can render them as JSX <path>s. That keeps
// each pulse's DOM node stable across re-renders → CSS animations
@@ -473,6 +599,7 @@ function renderVertical(data: NetworkFlowData): { svg: string; pulses: PulseData
const top = activeConsumers(data.consumers)
const bridges = visibleBridges(data.bridges, top)
const host = data.consumers.find((c) => c.kind === "host")
const { bonds, bondOfNic, nics } = resolveBonds(data)
const linksStatic: string[] = []
const linksPulse: PulseData[] = []
@@ -496,6 +623,17 @@ function renderVertical(data: NetworkFlowData): { svg: string; pulses: PulseData
const RAD_NIC = 22
const RAD_BRIDGE = 24
const RAD_GUEST = 20
const RAD_BOND = 22
// Extra rows the bond level needs when at least one bond exists:
// the gap between where the NIC lines start and the bond circle, and
// the space its three texts (name / mode / rate) occupy underneath.
const BOND_ROW_GAP = 48
const BOND_TEXT_H = 44
// Same split the NICs use (see NIC_PATH_START_OFFSET / NIC_VERTICAL_LEG):
// the link starts BELOW the rate text instead of on top of it, then runs
// a visible stretch before curving toward the host.
const BOND_PATH_START_OFFSET = BOND_TEXT_H + 14
const BOND_VERTICAL_LEG = 40
const NIC_TOP_Y = 6
const NIC_PITCH_X_PREFERRED = 88
const NIC_LEFT_MARGIN = 8
@@ -563,7 +701,7 @@ function renderVertical(data: NetworkFlowData): { svg: string; pulses: PulseData
// sits directly above the host — straight vertical path, no weird
// S-curve. When the row is too wide to be centred there without
// falling off the canvas, it slides right (or left, clamped).
const nicCount = data.nics.length
const nicCount = nics.length
const fitWidth = W - 2 * (NIC_LEFT_MARGIN + RAD_NIC)
const dynamicPitch = nicCount > 1
? Math.min(NIC_PITCH_X_PREFERRED, fitWidth / (nicCount - 1))
@@ -574,7 +712,7 @@ function renderVertical(data: NetworkFlowData): { svg: string; pulses: PulseData
const idealStart = HOST_X - rowWidth / 2
const startX = Math.max(minStart, Math.min(maxStart, idealStart))
const nicCy = NIC_TOP_Y + RAD_NIC
const nicGeom = data.nics.map((n, i) => ({
const nicGeom = nics.map((n, i) => ({
n, cx: startX + i * dynamicPitch, cy: nicCy, r: RAD_NIC,
}))
@@ -583,14 +721,39 @@ function renderVertical(data: NetworkFlowData): { svg: string; pulses: PulseData
// NIC path (NIC_VERTICAL_LEG) is what makes the line visibly run
// a stretch BEFORE turning into the curve.
const nicPathStartY = nicCy + RAD_NIC + NIC_PATH_START_OFFSET
const convergeY = nicPathStartY + NIC_VERTICAL_LEG
// ─── 1b. Bond row — between the NICs and the convergence row ──
// Each bond sits horizontally at the centroid of its own slaves, so
// its links stay short and don't cross a neighbouring bond's.
const hasBonds = bonds.length > 0
const bondCy = nicPathStartY + BOND_ROW_GAP + RAD_BOND
const bondGeom = hasBonds
? bonds.map((b) => {
const xs = nicGeom
.filter(({ n }) => bondOfNic.get(n.id) === b.id)
.map(({ cx }) => cx)
return { b, cx: xs.reduce((a, v) => a + v, 0) / xs.length, cy: bondCy, r: RAD_BOND }
})
: []
const bondCxById = new Map(bondGeom.map(({ b, cx }) => [b.id, cx]))
// Slaves bend into their bond one row above the bond circle.
const bondConvergeY = bondCy - RAD_BOND - 20
// With a bond level present the shared convergence row moves below
// the bond's texts; without bonds the geometry is exactly as before.
const convergeY = hasBonds
? bondCy + RAD_BOND + BOND_PATH_START_OFFSET + BOND_VERTICAL_LEG
: nicPathStartY + NIC_VERTICAL_LEG
const hostY = convergeY + HOST_GAP_FROM_CONVERGE + RAD_HOST
const hostTopY = hostY - RAD_HOST - 4
// NIC → host orth+Q via shared convergence row.
// NIC → bond (if enslaved) or NIC → host, both orth+Q.
nicGeom.forEach(({ n, cx, cy, r }) => {
const startX = cx, startY = cy + r + NIC_PATH_START_OFFSET
const pathD = vPath(startX, startY, HOST_X, hostTopY, convergeY)
const bondId = bondOfNic.get(n.id)
const bondCx = bondId !== undefined ? bondCxById.get(bondId) : undefined
const pathD = bondCx !== undefined
? vPath(startX, startY, bondCx, bondCy - RAD_BOND - 4, bondConvergeY)
: vPath(startX, startY, HOST_X, hostTopY, convergeY)
linksStatic.push(`<path class="nf-link" d="${pathD}" stroke-width="2.5" />`)
const active = n.status ? n.status === "up" : n.rx + n.tx > 0
if (active) {
@@ -600,11 +763,30 @@ function renderVertical(data: NetworkFlowData): { svg: string; pulses: PulseData
}
const isDown = n.status === "down"
const color = isDown ? COLORS.gray : COLORS.nic
nodes.push(`<g data-node-id="${n.id}" data-node-kind="nic" style="cursor:pointer;opacity:${isDown ? 0.45 : 1}">
const opacity = isDown ? 0.45 : n.role === "standby" ? 0.72 : 1
nodes.push(`<g data-node-id="${n.id}" data-node-kind="nic" style="cursor:pointer;opacity:${opacity}">
<circle class="nf-circle" cx="${cx}" cy="${cy}" r="${r}" stroke="${color}" />
${svgIcon("nic", cx, cy, 13, color)}
<text class="nf-label" x="${cx}" y="${cy + r + LABEL_OFFSET_Y}" text-anchor="middle" style="font-size:12.5px">${n.id}</text>
<text class="nf-sub" x="${cx}" y="${cy + r + SUB_OFFSET_Y}" text-anchor="middle" style="font-size:12.5px">${isDown ? "down" : n.link}</text>
<text class="nf-sub" x="${cx}" y="${cy + r + SUB_OFFSET_Y}" text-anchor="middle" style="font-size:11px">${nicSubLabel(n)}</text>
</g>`)
})
// Bond node + its aggregated link down to the host.
bondGeom.forEach(({ b, cx, cy, r }) => {
const pathD = vPath(cx, cy + r + BOND_PATH_START_OFFSET, HOST_X, hostTopY, convergeY)
linksStatic.push(`<path class="nf-link" d="${pathD}" stroke-width="2.5" />`)
const rate = b.rx + b.tx
if (b.rx > 0.001) linksPulse.push({ d: pathD, type: "rx", strokeWidth: 3, key: `m-bond-rx-${b.id}`, rate })
if (b.tx > 0.001) linksPulse.push({ d: pathD, type: "tx", strokeWidth: 3, key: `m-bond-tx-${b.id}`, rate })
const isDown = b.status === "down"
const color = isDown ? COLORS.gray : COLORS.bond
nodes.push(`<g data-node-id="${b.id}" data-node-kind="bond" style="cursor:pointer;opacity:${isDown ? 0.45 : 1}">
<circle class="nf-circle" cx="${cx}" cy="${cy}" r="${r}" stroke="${color}" />
${svgIcon("bond", cx, cy, 13, color)}
<text class="nf-label" x="${cx}" y="${cy + r + LABEL_OFFSET_Y}" text-anchor="middle" style="font-size:12.5px">${b.id}</text>
<text class="nf-sub" x="${cx}" y="${cy + r + SUB_OFFSET_Y}" text-anchor="middle" style="font-size:11px">${b.mode || "bond"}</text>
<text class="nf-sub" x="${cx}" y="${cy + r + BOND_TEXT_H}" text-anchor="middle" style="font-size:11px">${fmt(rate)}</text>
</g>`)
})
@@ -752,7 +934,7 @@ export function NetworkFlow({
// Fires when the user taps/clicks any circle in the diagram. The
// parent component looks up the matching NetworkInterface and
// opens the per-interface details modal.
onNodeClick?: (name: string, kind: "nic" | "host" | "bridge" | "lxc" | "vm") => void
onNodeClick?: (name: string, kind: "nic" | "host" | "bond" | "bridge" | "lxc" | "vm") => void
}) {
const ref = useRef<HTMLDivElement>(null)
const [width, setWidth] = useState(1320)
@@ -785,14 +967,20 @@ export function NetworkFlow({
return SB.find((b) => b >= raw) ?? 2.5
}
const sig = (r: number) => (r > 0.005 ? 1 : 0)
// bond + role are part of the signature so a failover (active ↔
// standby, or a slave leaving the bond) redraws the diagram on the
// very next poll instead of being masked by the rate bucketing.
const nics = data.nics.map((n) =>
`${n.id}:${n.status || ""}:${bucket(n.rx)}:${bucket(n.tx)}:${sig(n.rx)}:${sig(n.tx)}`
`${n.id}:${n.status || ""}:${n.bond || ""}:${n.role || ""}:${bucket(n.rx)}:${bucket(n.tx)}:${sig(n.rx)}:${sig(n.tx)}`
).join("|")
const bonds = (data.bonds || []).map((b) =>
`${b.id}:${b.mode || ""}:${b.status || ""}:${bucket(b.rx)}:${bucket(b.tx)}:${sig(b.rx)}:${sig(b.tx)}`
).join("|")
const guests = data.consumers.map((c) =>
`${c.id}:${c.bridge}:${c.kind}:${c.offline ? 1 : 0}:${bucket(c.rx)}:${bucket(c.tx)}:${sig(c.rx)}:${sig(c.tx)}`
).join("|")
const bridges = data.bridges.map((b) => `${b.id}:${b.parent || ""}`).join("|")
return `${mode}|${width}|${nics}||${guests}||${bridges}`
return `${mode}|${width}|${nics}||${bonds}||${guests}||${bridges}`
}, [data, mode, width])
const { svgContent, pulses, viewBox, height } = useMemo(() => {
@@ -896,7 +1084,7 @@ export function NetworkFlow({
if (!hit) return
const id = hit.getAttribute("data-node-id") || ""
const kind = (hit.getAttribute("data-node-kind") || "") as
"nic" | "host" | "bridge" | "lxc" | "vm"
"nic" | "host" | "bond" | "bridge" | "lxc" | "vm"
if (id && kind) onNodeClick(id, kind)
}}
>
+81 -59
View File
@@ -18,6 +18,9 @@ interface NetworkData {
interfaces: NetworkInterface[]
physical_interfaces?: NetworkInterface[]
bridge_interfaces?: NetworkInterface[]
// Bond masters. Also present inside `interfaces` for backward
// compatibility; this list is what the topology diagram consumes.
bond_interfaces?: NetworkInterface[]
vm_lxc_interfaces?: NetworkInterface[]
traffic: {
bytes_sent: number
@@ -79,11 +82,26 @@ interface NetworkInterface {
// operator can see "this NIC → vmbr0" at a glance.
used_by_bridges?: string[]
bond_mode?: string
// Kernel's human-readable mode, e.g. "fault-tolerance (active-backup)".
// bond_mode holds the short form ("active-backup") that matches
// /etc/network/interfaces and the Proxmox UI.
bond_mode_detail?: string | null
bond_slaves?: string[]
bond_active_slave?: string | null
// True only for modes where a slave really sits idle (active-backup).
bond_supports_failover?: boolean
bond_slave_status?: Record<string, string>
// Set on a physical NIC that is enslaved to a bond.
bond_master?: string
bond_role?: "active" | "standby" | "member"
bond_link?: string
// Master device resolved from /sys/class/net/<iface>/master — the
// bridge for a guest tap, the bond for a slave NIC.
bridge_owner?: string
bridge_members?: string[]
bridge_physical_interface?: string
bridge_bond_slaves?: string[]
bridge_vlan_interface?: string | null
packet_loss_in?: number
packet_loss_out?: number
vmid?: number
@@ -509,6 +527,7 @@ export function NetworkMetrics() {
const allInterfaces = [
...(networkData.physical_interfaces || []),
...(networkData.bond_interfaces || []),
...(networkData.bridge_interfaces || []),
...(networkData.vm_lxc_interfaces || []),
]
@@ -788,6 +807,7 @@ export function NetworkMetrics() {
const toMBps = (bps?: number) => (bps || 0) / (1024 * 1024)
const allIfaces = [
...(networkData.physical_interfaces || []),
...(networkData.bond_interfaces || []),
...(networkData.bridge_interfaces || []),
...(networkData.vm_lxc_interfaces || []),
]
@@ -797,7 +817,24 @@ export function NetworkMetrics() {
link: formatSpeed(p.speed),
rx: toMBps(p.rx_Bps),
tx: toMBps(p.tx_Bps),
status: (p.status || "").toLowerCase() === "up" ? "up" : "down",
// A slave whose MII status is down has no carrier even though
// the interface itself stays administratively up — the bond
// driver's view is the accurate one here.
status:
p.bond_link === "down"
? "down"
: (p.status || "").toLowerCase() === "up"
? "up"
: "down",
bond: p.bond_master,
role: p.bond_role,
})),
bonds: (networkData.bond_interfaces || []).map((b) => ({
id: b.name,
mode: b.bond_mode && b.bond_mode !== "unknown" ? b.bond_mode : undefined,
rx: toMBps(b.rx_Bps),
tx: toMBps(b.tx_Bps),
status: (b.status || "").toLowerCase() === "up" ? "up" : "down",
})),
bridges: (networkData.bridge_interfaces || []).map((b) => ({
id: b.name,
@@ -918,24 +955,6 @@ export function NetworkMetrics() {
{interface_.bridge_physical_interface && (
<div className="text-sm text-blue-500 font-medium flex items-center gap-1 flex-wrap break-all">
{interface_.bridge_physical_interface}
{interface_.bridge_physical_interface.startsWith("bond") &&
networkData.physical_interfaces && (
<>
{(() => {
const bondInterface = networkData.physical_interfaces.find(
(iface) => iface.name === interface_.bridge_physical_interface,
)
if (bondInterface?.bond_slaves && bondInterface.bond_slaves.length > 0) {
return (
<span className="text-muted-foreground text-xs break-all">
({bondInterface.bond_slaves.join(", ")})
</span>
)
}
return null
})()}
</>
)}
{interface_.bridge_bond_slaves && interface_.bridge_bond_slaves.length > 0 && (
<span className="text-muted-foreground text-xs break-all">
({interface_.bridge_bond_slaves.join(", ")})
@@ -1125,6 +1144,7 @@ export function NetworkMetrics() {
const currentInterfaceData = modalNetworkData
? [
...(modalNetworkData.physical_interfaces || []),
...(modalNetworkData.bond_interfaces || []),
...(modalNetworkData.bridge_interfaces || []),
...(modalNetworkData.vm_lxc_interfaces || []),
].find((iface) => iface.name === selectedInterface.name)
@@ -1154,35 +1174,10 @@ export function NetworkMetrics() {
<div className="font-medium text-blue-500 text-lg break-all">
{displayInterface.bridge_physical_interface}
</div>
{displayInterface.bridge_physical_interface.startsWith("bond") &&
modalNetworkData?.physical_interfaces && (
<>
{(() => {
const bondInterface = modalNetworkData.physical_interfaces.find(
(iface) => iface.name === displayInterface.bridge_physical_interface,
)
if (bondInterface?.bond_slaves && bondInterface.bond_slaves.length > 0) {
return (
<div className="mt-2">
<div className="text-sm text-muted-foreground mb-2">Bond Members</div>
<div className="flex flex-wrap gap-2">
{bondInterface.bond_slaves.map((slave, idx) => (
<Badge
key={idx}
variant="outline"
className="bg-purple-500/10 text-purple-500 border-purple-500/20"
>
{slave}
</Badge>
))}
</div>
</div>
)
}
return null
})()}
</>
)}
{/* Slaves come from the bridge's own payload
(bridge_bond_slaves); the bond master is not
part of physical_interfaces, so looking it up
there never matched. */}
{displayInterface.bridge_bond_slaves && displayInterface.bridge_bond_slaves.length > 0 && (
<div className="mt-2">
<div className="text-sm text-muted-foreground mb-2">Bond Members</div>
@@ -1418,26 +1413,53 @@ export function NetworkMetrics() {
<div className="space-y-3">
<div>
<div className="text-sm text-muted-foreground">Bonding Mode</div>
<div className="font-medium">{displayInterface.bond_mode || "Unknown"}</div>
<div className="font-medium">
{displayInterface.bond_mode || "Unknown"}
{displayInterface.bond_mode_detail &&
displayInterface.bond_mode_detail !== displayInterface.bond_mode && (
<span className="text-muted-foreground font-normal">
{" "}
({displayInterface.bond_mode_detail})
</span>
)}
</div>
</div>
{displayInterface.bond_active_slave && (
<div>
<div className="text-sm text-muted-foreground">Active Slave</div>
<div className="text-sm text-muted-foreground">
{displayInterface.bond_supports_failover ? "Active Slave" : "Primary Slave"}
</div>
<div className="font-medium">{displayInterface.bond_active_slave}</div>
</div>
)}
<div>
<div className="text-sm text-muted-foreground mb-2">Slave Interfaces</div>
<div className="flex flex-wrap gap-2">
{displayInterface.bond_slaves.map((slave, idx) => (
<Badge
key={idx}
variant="outline"
className="bg-purple-500/10 text-purple-500 border-purple-500/20"
>
{slave}
</Badge>
))}
{displayInterface.bond_slaves.map((slave, idx) => {
// Only active-backup has a real standby. In every
// other mode all slaves transmit, so we just show
// the link state.
const link = displayInterface.bond_slave_status?.[slave]
const isDown = link === "down"
const role = isDown
? "down"
: displayInterface.bond_supports_failover
? slave === displayInterface.bond_active_slave
? "active"
: "standby"
: null
const tone = isDown
? "bg-red-500/10 text-red-500 border-red-500/20"
: role === "standby"
? "bg-yellow-500/10 text-yellow-500 border-yellow-500/20"
: "bg-purple-500/10 text-purple-500 border-purple-500/20"
return (
<Badge key={idx} variant="outline" className={tone}>
{slave}
{role && <span className="ml-1 opacity-70">· {role}</span>}
</Badge>
)
})}
</div>
</div>
</div>
+337 -67
View File
@@ -2903,9 +2903,10 @@ def get_system_disks():
pass
try:
# 4. Check LVM physical volumes
# 4. Check LVM physical volumes. `--devices` (added when a disk
# is parked) keeps pvs from scanning — and waking — standby HDDs.
result = subprocess.run(
['pvs', '--noheadings', '-o', 'pv_name,vg_name'],
['pvs', *_lvm_device_args(), '--noheadings', '-o', 'pv_name,vg_name'],
capture_output=True, text=True, timeout=5
)
if result.returncode == 0:
@@ -3653,11 +3654,98 @@ def _smart_data_useful(d: dict) -> bool:
)
_standby_cache: dict[str, tuple] = {}
_STANDBY_CACHE_TTL = 15
def _hdd_in_standby(disk_name: str) -> bool:
"""True if `disk_name` is a spinning disk currently parked.
The check is smartctl's CHECK POWER MODE probe (`-n standby`), which
the drive answers without spinning up. Restricted to rotational,
non-NVMe devices so SSDs and NVMe which have no standby state
skip the extra probe. Any error falls through to False so the caller
behaves exactly as before. Result cached briefly so one /api/storage
poll doesn't re-probe the same disk from several call sites. See
issue #232.
"""
if disk_name.startswith('nvme'):
return False
now = time.time()
hit = _standby_cache.get(disk_name)
if hit and now - hit[0] < _STANDBY_CACHE_TTL:
return hit[1]
try:
with open(f'/sys/block/{disk_name}/queue/rotational') as f:
if f.read().strip() != '1':
_standby_cache[disk_name] = (now, False)
return False
except OSError:
return False
parked = False
try:
r = subprocess.run(
['smartctl', '-n', 'standby', '-i', f'/dev/{disk_name}'],
capture_output=True, text=True, timeout=_SMART_TIMEOUT,
)
parked = r.returncode == 2
except (subprocess.SubprocessError, OSError):
parked = False
_standby_cache[disk_name] = (now, parked)
return parked
def _lvm_device_args() -> list:
"""Build the `--devices` argument that keeps an LVM tool (pvs/lvs/
vgs) off parked disks.
LVM tools scan every block device by default, which spins up a
standby HDD (issue #232). When any rotational disk is asleep, hand
LVM an explicit list of every block node EXCEPT those belonging to a
parked disk, so it never opens the sleeping drives. Each partition
must be listed too `--devices /dev/sda` alone does not cover a PV
on /dev/sda3. Returns [] when nothing is parked, so hosts without
standby disks keep the plain pvs/lvs/vgs call unchanged.
"""
try:
r = subprocess.run(
['lsblk', '-rno', 'NAME,TYPE,PKNAME'],
capture_output=True, text=True, timeout=5)
if r.returncode != 0:
return []
nodes = []
for line in r.stdout.strip().split('\n'):
parts = line.split()
if len(parts) < 2 or parts[1] not in ('disk', 'part'):
continue
name = parts[0]
base = name if parts[1] == 'disk' else (parts[2] if len(parts) > 2 else name)
nodes.append((name, base))
standby_bases = {
name for name, base in nodes
if name == base and _hdd_in_standby(name)
}
if not standby_bases:
return []
devices = [f'/dev/{name}' for name, base in nodes if base not in standby_bases]
return ['--devices', ','.join(devices)] if devices else []
except (subprocess.SubprocessError, OSError):
return []
def get_smart_data(disk_name):
"""Cached wrapper around the underlying smartctl probe.
Three short-circuits stack on top of the original 14-variant
Four short-circuits stack on top of the original 14-variant
fallback chain:
0. Standby guard a parked HDD is left alone. The full `smartctl
-a` probe is a media read that spins the disk back up, so on a
cache miss we first check the power mode (no spin-up) and, if
the drive is asleep, return the last good payload instead of
waking it. This is the fix for issue #232: with a browser on
the dashboard, /api/storage polls often enough that the 30 s
cache kept expiring and re-waking disks that hdparm / hd-idle
had just parked.
1. Result cache within 30 s of a successful probe, return the
memoised payload immediately. This is the by-far hottest path
because the dashboard polls /api/storage every few seconds.
@@ -3673,6 +3761,17 @@ def get_smart_data(disk_name):
if cached and now - cached[0] < _SMART_RESULT_TTL:
return dict(cached[1])
if _hdd_in_standby(disk_name):
# Keep serving the last known values (temperature blanked, since
# we don't have a fresh one) so the card stays populated while
# the disk sleeps. The Storage view paints its own Standby badge
# from the temperature poller's flag. Don't refresh the cache
# timestamp: the moment the disk is active again we re-probe.
base = dict(cached[1]) if cached else _smart_default_payload()
base['standby'] = True
base['temperature'] = 0
return base
retry_at = _smart_fail_backoff.get(disk_name, 0)
if retry_at > now:
return dict(cached[1]) if cached else _smart_default_payload()
@@ -4712,22 +4811,43 @@ def _ethtool_max_speed(iface_name):
return 0
# A bond master always exposes /sys/class/net/<x>/bonding/. This is the
# kernel's own marker, so it catches bonds whose name isn't "bondN"
# (Proxmox lets you name them freely in /etc/network/interfaces).
def _is_bond_master(iface_name):
try:
return os.path.isdir(f'/sys/class/net/{iface_name}/bonding')
except Exception:
return False
# A physical NIC has a backing device symlink. Covers PCI, USB and any
# renamed NIC (e.g. a udev-renamed "nic0"), which a name-prefix check
# would miss.
def _is_physical_dev(iface_name):
try:
return os.path.exists(f'/sys/class/net/{iface_name}/device')
except Exception:
return False
def get_interface_type(interface_name):
"""Detect the type of network interface"""
try:
# Skip loopback
if interface_name == 'lo':
return 'skip'
if interface_name.startswith(('veth', 'tap')):
return 'vm_lxc'
# Skip other virtual interfaces
if interface_name.startswith(('tun', 'vnet', 'docker', 'virbr')):
return 'skip'
# Check if it's a bond
if interface_name.startswith('bond'):
# Check if it's a bond — sysfs first (name-agnostic), then the
# "bondN" convention as fallback when /sys isn't readable.
if _is_bond_master(interface_name) or interface_name.startswith('bond'):
return 'bond'
# Check if it's a bridge (but not virbr which we skip above)
@@ -4756,36 +4876,75 @@ def get_interface_type(interface_name):
pass
return 'skip'
# Only active-backup (and the *-alb/tlb primary) have a real
# active/standby distinction. In 802.3ad and balance-rr/xor EVERY slave
# transmits, so labelling the others "standby" would be wrong — hence
# the role is only assigned for active-backup.
_BOND_FAILOVER_MODES = ('active-backup',)
def get_bond_info(bond_name):
"""Get detailed information about a bonding interface"""
"""Get detailed information about a bonding interface.
sysfs is the primary source (short mode name, live active_slave);
/proc/net/bonding/<bond> is parsed too because it's the only place
that reports per-slave MII status, which tells a genuinely dead
link apart from a healthy standby.
"""
bond_info = {
'mode': 'unknown',
'mode': 'unknown', # short kernel name, e.g. "active-backup"
'mode_detail': None, # human name, e.g. "fault-tolerance (active-backup)"
'slaves': [],
'active_slave': None
'active_slave': None,
'slave_status': {}, # {slave: 'up'|'down'}
'supports_failover': False,
}
try:
# ── sysfs (authoritative, name-agnostic) ──────────────
mode = _read_sysfs(f'/sys/class/net/{bond_name}/bonding/mode')
if mode:
# "balance-alb 6" → "balance-alb"
bond_info['mode'] = mode.split()[0]
slaves = _read_sysfs(f'/sys/class/net/{bond_name}/bonding/slaves')
if slaves:
bond_info['slaves'] = slaves.split()
active = _read_sysfs(f'/sys/class/net/{bond_name}/bonding/active_slave')
if active:
bond_info['active_slave'] = active
# ── /proc/net/bonding (per-slave MII + pretty mode) ───
bond_file = f'/proc/net/bonding/{bond_name}'
if os.path.exists(bond_file):
with open(bond_file, 'r') as f:
content = f.read()
# Parse bonding mode
for line in content.split('\n'):
if 'Bonding Mode:' in line:
bond_info['mode'] = line.split(':', 1)[1].strip()
elif 'Slave Interface:' in line:
slave_name = line.split(':', 1)[1].strip()
bond_info['slaves'].append(slave_name)
elif 'Currently Active Slave:' in line:
bond_info['active_slave'] = line.split(':', 1)[1].strip()
# print(f"[v0] Bond {bond_name} info: mode={bond_info['mode']}, slaves={bond_info['slaves']}")
pass
current_slave = None
proc_slaves = []
for line in content.split('\n'):
if 'Bonding Mode:' in line:
bond_info['mode_detail'] = line.split(':', 1)[1].strip()
elif 'Slave Interface:' in line:
current_slave = line.split(':', 1)[1].strip()
proc_slaves.append(current_slave)
elif 'Currently Active Slave:' in line:
val = line.split(':', 1)[1].strip()
if val and val.lower() != 'none' and not bond_info['active_slave']:
bond_info['active_slave'] = val
elif 'MII Status:' in line and current_slave:
bond_info['slave_status'][current_slave] = line.split(':', 1)[1].strip().lower()
# Fallbacks when sysfs was unreadable.
if not bond_info['slaves']:
bond_info['slaves'] = proc_slaves
if bond_info['mode'] == 'unknown' and bond_info['mode_detail']:
bond_info['mode'] = bond_info['mode_detail']
bond_info['supports_failover'] = bond_info['mode'] in _BOND_FAILOVER_MODES
except Exception as e:
# print(f"[v0] Error reading bond info for {bond_name}: {e}")
pass
return bond_info
def get_bridge_info(bridge_name):
@@ -4795,7 +4954,10 @@ def get_bridge_info(bridge_name):
'physical_interface': None,
'physical_duplex': 'unknown', # Added physical_duplex field
# Added bond_slaves to show physical interfaces
'bond_slaves': []
'bond_slaves': [],
# Set when the bridge port is a VLAN sub-interface (bond0.10);
# physical_interface then holds the base device (bond0).
'vlan_interface': None
}
try:
@@ -4805,19 +4967,32 @@ def get_bridge_info(bridge_name):
members = os.listdir(brif_path)
bridge_info['members'] = members
for member in members:
# Guest taps and firewall veth pairs are never the bridge's
# uplink — skipping them up front means the uplink is found
# regardless of the (arbitrary) order listdir returns.
uplinks = [m for m in members
if not m.startswith(('veth', 'tap', 'fwpr', 'fwln'))]
for member in uplinks:
# A VLAN sub-interface (bond0.10 / eno1.10) is just a tag
# on top of the real uplink — resolve to the base device
# so the topology still shows the bond or NIC underneath.
base = member.split('.', 1)[0] if '.' in member else member
if base != member:
bridge_info['vlan_interface'] = member
# Check if member is a bond first
if member.startswith('bond'):
bridge_info['physical_interface'] = member
# print(f"[v0] Bridge {bridge_name} connected to bond: {member}")
if _is_bond_master(base) or base.startswith('bond'):
bridge_info['physical_interface'] = base
# print(f"[v0] Bridge {bridge_name} connected to bond: {base}")
pass
bond_info = get_bond_info(member)
bond_info = get_bond_info(base)
if bond_info['slaves']:
bridge_info['bond_slaves'] = bond_info['slaves']
# print(f"[v0] Bond {member} slaves: {bond_info['slaves']}")
# print(f"[v0] Bond {base} slaves: {bond_info['slaves']}")
pass
# Get duplex from bond's active slave
if bond_info['active_slave']:
try:
@@ -4831,17 +5006,19 @@ def get_bridge_info(bridge_name):
# print(f"[v0] Error getting duplex for bond slave {bond_info['active_slave']}: {e}")
pass
break
# Check if member is a physical interface
elif member.startswith(('enp', 'eth', 'eno', 'ens', 'wlan', 'wlp')):
bridge_info['physical_interface'] = member
# print(f"[v0] Bridge {bridge_name} physical interface: {member}")
# Check if member is a physical interface. The device
# symlink is the reliable marker — a NIC renamed to
# "nic0" has no recognisable prefix but still has it.
elif _is_physical_dev(base) or base.startswith(('enp', 'eth', 'eno', 'ens', 'wlan', 'wlp')):
bridge_info['physical_interface'] = base
# print(f"[v0] Bridge {bridge_name} physical interface: {base}")
pass
# Get duplex from physical interface
try:
net_if_stats = psutil.net_if_stats()
if member in net_if_stats:
stats = net_if_stats[member]
if base in net_if_stats:
stats = net_if_stats[base]
bridge_info['physical_duplex'] = 'full' if stats.duplex == 2 else 'half' if stats.duplex == 1 else 'unknown'
# print(f"[v0] Physical interface {member} duplex: {bridge_info['physical_duplex']}")
pass
@@ -4866,6 +5043,10 @@ def get_network_info():
'interfaces': [],
'physical_interfaces': [], # Added separate list for physical interfaces
'bridge_interfaces': [], # Added separate list for bridge interfaces
# Bond masters, so the Network Flow diagram can draw them as
# the intermediate node between their slaves and the host.
# They stay in `interfaces` too, for backward compatibility.
'bond_interfaces': [],
'vm_lxc_interfaces': [],
'traffic': {'bytes_sent': 0, 'bytes_recv': 0, 'packets_sent': 0, 'packets_recv': 0},
# 'hostname': socket.gethostname(),
@@ -5043,9 +5224,12 @@ def get_network_info():
if interface_type == 'bond':
bond_info = get_bond_info(interface_name)
interface_info['bond_mode'] = bond_info['mode']
interface_info['bond_mode_detail'] = bond_info['mode_detail']
interface_info['bond_slaves'] = bond_info['slaves']
interface_info['bond_active_slave'] = bond_info['active_slave']
interface_info['bond_supports_failover'] = bond_info['supports_failover']
interface_info['bond_slave_status'] = bond_info['slave_status']
if interface_type == 'bridge':
bridge_info = get_bridge_info(interface_name)
interface_info['bridge_members'] = bridge_info['members']
@@ -5065,7 +5249,9 @@ def get_network_info():
else:
# Keep other types in the general interfaces list for backward compatibility
network_data['interfaces'].append(interface_info)
if interface_type == 'bond':
network_data['bond_interfaces'].append(interface_info)
network_data['physical_active_count'] = physical_active_count
network_data['physical_total_count'] = physical_total_count
network_data['bridge_active_count'] = bridge_active_count
@@ -5073,6 +5259,35 @@ def get_network_info():
network_data['vm_lxc_active_count'] = vm_lxc_active_count
network_data['vm_lxc_total_count'] = vm_lxc_total_count
# Tag every bond slave with its master and its CURRENT role. The
# role is read fresh on each poll (never pinned to a NIC), so a
# failover flips active/standby on the next refresh.
# active/standby → only for active-backup, the one mode where
# a slave really sits idle.
# member → LACP, balance-rr/xor/tlb/alb: every slave
# transmits, so no standby label.
# `bond_link` comes from /proc/net/bonding's per-slave MII status
# and distinguishes a dead link from a healthy standby.
slave_meta = {}
for bond in network_data['bond_interfaces']:
failover = bond.get('bond_supports_failover')
active = bond.get('bond_active_slave')
statuses = bond.get('bond_slave_status') or {}
for slave in bond.get('bond_slaves') or []:
if failover:
role = 'active' if slave == active else 'standby'
else:
role = 'member'
slave_meta[slave] = {
'bond_master': bond['name'],
'bond_role': role,
'bond_link': statuses.get(slave, 'unknown'),
}
for phy in network_data['physical_interfaces']:
meta = slave_meta.get(phy['name'])
if meta:
phy.update(meta)
# Map physical NICs ↔ bridges using them. For a bridge with a
# direct NIC parent, that NIC gets the bridge in its list. For
# bridges sitting on top of a bond, every bond slave gets the
@@ -5135,6 +5350,7 @@ def get_network_info():
'interfaces': [],
'physical_interfaces': [],
'bridge_interfaces': [],
'bond_interfaces': [],
'vm_lxc_interfaces': [],
'traffic': {'bytes_sent': 0, 'bytes_recv': 0, 'packets_sent': 0, 'packets_recv': 0},
'active_count': 0,
@@ -7598,29 +7814,23 @@ def _get_hardware_info_uncached():
except:
pass
# Parse SATA version from smartctl output
# SATA version + form factor from one `smartctl -i`.
# `-n standby` so a parked HDD isn't handed even an
# IDENTIFY command (issue #232) — exit code 2 leaves
# both fields None and the disk asleep. Merged from
# two separate calls that parsed the same output.
sata_version = None
form_factor = None
try:
result_smart = subprocess.run(['smartctl', '-i', f'/dev/{disk_name}'],
capture_output=True, text=True, timeout=5)
result_smart = subprocess.run(
['smartctl', '-n', 'standby', '-i', f'/dev/{disk_name}'],
capture_output=True, text=True, timeout=5)
if result_smart.returncode == 0:
for line in result_smart.stdout.split('\n'):
if 'SATA Version is:' in line:
sata_version = line.split(':', 1)[1].strip()
break
except:
pass
# Parse form factor from smartctl output
form_factor = None
try:
result_smart = subprocess.run(['smartctl', '-i', f'/dev/{disk_name}'],
capture_output=True, text=True, timeout=5)
if result_smart.returncode == 0:
for line in result_smart.stdout.split('\n'):
if 'Form Factor:' in line:
elif 'Form Factor:' in line:
form_factor = line.split(':', 1)[1].strip()
break
except:
pass
@@ -12702,6 +12912,21 @@ _BACKUP_DEFAULT_DUMP_DIRS = ('/var/lib/vz/dump',)
_BACKUP_FILENAME_RE = re.compile(r'^([A-Za-z0-9._-]+)-(\d{8}_\d{6})\.tar(\.zst|\.gz)?$')
def _shell_env_line(line: str) -> str:
"""Turn a raw ``KEY=value`` pair into a shell-safe assignment.
The job .env is `source`d by run_scheduled_backup.sh as root, so an
unquoted value is not just cosmetic: globs and spaces split the
assignment into a command, and `$(...)` or backticks in a password
run at source time. backup_scheduler.sh quotes with `printf %q`;
this is the same guarantee for the lines written from the API.
"""
if not line or line.startswith('#') or '=' not in line:
return line
key, val = line.split('=', 1)
return f'{key}={shlex.quote(val)}'
def _parse_job_env(file_path: str) -> dict:
"""Parse a /var/lib/proxmenux/backup-jobs/*.env file (shell KEY=value
format with optional quoting) into a Python dict. Returns {} on any
@@ -12716,9 +12941,22 @@ def _parse_job_env(file_path: str) -> dict:
key, val = line.split('=', 1)
key = key.strip()
val = val.strip()
# Strip shell quoting if balanced
if len(val) >= 2 and val[0] == val[-1] and val[0] in ('"', "'"):
val = val[1:-1]
# Unquote the way the shell would — `shlex` handles both
# writers, `printf %q` from the TUI and shlex.quote()
# from the API. Exactly one token means the value was
# properly quoted; anything else is a legacy unquoted
# multi-word value already on disk, kept verbatim rather
# than truncated to its first word.
try:
parts = shlex.split(val)
if len(parts) == 1:
val = parts[0]
elif not parts:
val = ''
except ValueError:
# Unbalanced quoting — fall back to a naive strip.
if len(val) >= 2 and val[0] == val[-1] and val[0] in ('"', "'"):
val = val[1:-1]
out[key] = val
except OSError:
pass
@@ -13412,7 +13650,10 @@ def _list_pbs_destinations() -> list:
repos.append({
'name': name,
'repository': repo,
'fingerprint': None,
# A manual destination never reaches
# storage.cfg, so the sidecar is the only
# place its fingerprint exists.
'fingerprint': _resolve_pbs_fingerprint(name) or None,
'source': 'manual',
})
seen.add((name, repo))
@@ -14947,6 +15188,29 @@ def _resolve_pbs_password(repo_name: str) -> str:
return ''
def _fingerprint_for_repository(repo: str) -> str:
"""Resolve the fingerprint from a `<user>@<server>:<datastore>`
repository string rather than a destination *name*.
Job payloads carry the repository, so this maps it back to the
destination before delegating to `_resolve_pbs_fingerprint`. A job
written without a `PBS_FINGERPRINT=` line fails on every run against
a self-signed PBS the client refuses the connection instead of
trusting the certificate.
"""
if not repo:
return ''
try:
for dest in _list_pbs_destinations():
if dest.get('repository') == repo:
fp = dest.get('fingerprint') or _resolve_pbs_fingerprint(dest.get('name') or '')
if fp:
return fp
except Exception:
pass
return ''
def _resolve_pbs_fingerprint(repo_name: str) -> str:
"""Return the PBS fingerprint for `repo_name`. Symmetric to
_resolve_pbs_password: first the proxmenux manual sidecar
@@ -15343,6 +15607,8 @@ def api_host_backups_job_create():
import socket
pbs_backup_id = f'hostcfg-{socket.gethostname()}'
pbs_fingerprint = (payload.get('pbs_fingerprint') or '').strip()
if not pbs_fingerprint:
pbs_fingerprint = _fingerprint_for_repository(pbs_repo)
# Client-side encryption. Three modes:
# - "none" — no --keyfile flag, plain backup
# - "new" — generate a fresh per-host keyfile (default,
@@ -15448,7 +15714,7 @@ def api_host_backups_job_create():
os.makedirs(_BACKUP_JOBS_DIR, exist_ok=True)
with open(env_file, 'w') as f:
for ln in lines:
f.write(ln + '\n')
f.write(_shell_env_line(ln) + '\n')
os.chmod(env_file, 0o600)
with open(f'{_BACKUP_JOBS_DIR}/{job_id}.paths', 'w') as f:
for p in resolved_paths:
@@ -15832,6 +16098,8 @@ def api_host_backups_job_update(job_id):
import socket
pbs_backup_id = f'hostcfg-{socket.gethostname()}'
pbs_fingerprint = (payload.get('pbs_fingerprint') or existing.get('PBS_FINGERPRINT') or '').strip()
if not pbs_fingerprint:
pbs_fingerprint = _fingerprint_for_repository(pbs_repo)
# Encryption: matches job-create + manual-run. The Web sends
# `pbs_encrypt_mode` ("none" / "new" / "existing"); older
# clients may still send the legacy `pbs_encrypt` bool.
@@ -15909,7 +16177,7 @@ def api_host_backups_job_update(job_id):
tmp_env = f'{env_file}.tmp.{os.getpid()}'
with open(tmp_env, 'w') as f:
for ln in lines:
f.write(ln + '\n')
f.write(_shell_env_line(ln) + '\n')
os.chmod(tmp_env, 0o600)
os.replace(tmp_env, env_file)
paths_file = f'{_BACKUP_JOBS_DIR}/{job_id}.paths'
@@ -16044,6 +16312,8 @@ def api_host_backups_manual_run():
# form when they need a separate retention bucket.
pbs_backup_id = f'hostcfg-{socket.gethostname()}'
pbs_fingerprint = (payload.get('pbs_fingerprint') or '').strip()
if not pbs_fingerprint:
pbs_fingerprint = _fingerprint_for_repository(pbs_repo)
# Encryption mode: matches the job-create endpoint exactly.
# Reads the modern `pbs_encrypt_mode` string ("none"/"new"/
# "existing") first, falls back to legacy `pbs_encrypt` bool
@@ -16111,7 +16381,7 @@ def api_host_backups_manual_run():
os.makedirs(_BACKUP_JOBS_DIR, exist_ok=True)
with open(env_file, 'w') as f:
for ln in lines:
f.write(ln + '\n')
f.write(_shell_env_line(ln) + '\n')
os.chmod(env_file, 0o600)
with open(paths_file, 'w') as f:
for p in resolved_paths:
+73 -2
View File
@@ -115,6 +115,75 @@ def _disk_base_for_sysfs(name: str) -> str:
return name
_standby_cache: dict = {}
_STANDBY_CACHE_TTL = 15
def _hdd_in_standby(disk_name: str) -> bool:
"""True if `disk_name` is a spinning disk currently parked.
CHECK POWER MODE probe (`smartctl -n standby`) answered without
spinning the drive up. Rotational, non-NVMe only. Mirrors the helper
in flask_server.py; see issue #232.
"""
base = _disk_base_for_sysfs(disk_name)
if base.startswith('nvme'):
return False
now = time.time()
hit = _standby_cache.get(base)
if hit and now - hit[0] < _STANDBY_CACHE_TTL:
return hit[1]
try:
with open(f'/sys/block/{base}/queue/rotational') as f:
if f.read().strip() != '1':
_standby_cache[base] = (now, False)
return False
except OSError:
return False
parked = False
try:
r = subprocess.run(
['smartctl', '-n', 'standby', '-i', f'/dev/{base}'],
capture_output=True, text=True, timeout=5)
parked = r.returncode == 2
except Exception:
parked = False
_standby_cache[base] = (now, parked)
return parked
def _lvm_device_args() -> list:
"""`--devices` list scoping pvs/lvs/vgs off parked disks so they
aren't spun up by the LVM scan (issue #232). Empty when nothing is
parked, so the plain command runs unchanged. Each partition is
listed too a whole-disk path alone doesn't cover a PV on a
partition."""
try:
r = subprocess.run(
['lsblk', '-rno', 'NAME,TYPE,PKNAME'],
capture_output=True, text=True, timeout=5)
if r.returncode != 0:
return []
nodes = []
for line in r.stdout.strip().split('\n'):
parts = line.split()
if len(parts) < 2 or parts[1] not in ('disk', 'part'):
continue
name = parts[0]
base = name if parts[1] == 'disk' else (parts[2] if len(parts) > 2 else name)
nodes.append((name, base))
standby_bases = {
name for name, base in nodes
if name == base and _hdd_in_standby(name)
}
if not standby_bases:
return []
devices = [f'/dev/{name}' for name, base in nodes if base not in standby_bases]
return ['--devices', ','.join(devices)] if devices else []
except Exception:
return []
def _is_disk_removable(disk_name: str) -> bool:
"""True if `/sys/block/<disk>/removable` reads 1. USB-attached storage."""
try:
@@ -2199,8 +2268,10 @@ class HealthMonitor:
if result_which.returncode != 0:
return {'status': 'OK'} # LVM not installed
# `--devices` keeps lvs from scanning parked HDDs (issue #232).
dev_args = _lvm_device_args()
result = subprocess.run(
['lvs', '--noheadings', '--options', 'lv_name,vg_name,lv_attr'],
['lvs', *dev_args, '--noheadings', '--options', 'lv_name,vg_name,lv_attr'],
capture_output=True,
text=True,
timeout=3
@@ -2224,7 +2295,7 @@ class HealthMonitor:
if not volumes:
# Check if any VGs exist to determine if LVM is truly unconfigured or just inactive
vg_result = subprocess.run(
['vgs', '--noheadings', '--options', 'vg_name'],
['vgs', *dev_args, '--noheadings', '--options', 'vg_name'],
capture_output=True,
text=True,
timeout=3
+26 -4
View File
@@ -48,6 +48,9 @@ _FN_DEF_RE = re.compile(r"^(?P<name>[a-zA-Z_][a-zA-Z0-9_]*)\s*\(\)\s*\{\s*$")
_VERSION_RE = re.compile(r'local\s+FUNC_VERSION\s*=\s*"([0-9]+(?:\.[0-9]+)+)"')
_DESC_RE = re.compile(r"#\s*description\s*:\s*([^\n]+)")
_REGISTER_RE = re.compile(r'\bregister_tool\s+"([^"]+)"\s+true\b')
# Matches a heredoc opener and captures its terminator word. Handles
# `<<EOF`, `<<-EOF`, `<< EOF`, `<<'EOF'` and `<<"EOF"`.
_HEREDOC_RE = re.compile(r'<<[-~]?\s*["\']?([A-Za-z_][A-Za-z0-9_]*)["\']?')
# In-memory cache of the last scan. Sprint 12A uses a single startup scan
# plus on-demand re-scan via the API; no automatic refresh.
@@ -122,12 +125,31 @@ def parse_post_install_script(path: Path) -> dict[str, dict[str, str]]:
continue
func_name = match.group("name")
# Find the matching closing brace at column 0. Bash post-install
# scripts use the convention `}` on its own line at the start of
# the line to close top-level functions, so we scan until that.
# Find the matching closing brace at column 0. Top-level post-install
# functions close with `}` alone on a line. A bare scan for that line
# breaks when the body embeds a heredoc whose content also has `}` at
# column 0 — e.g. a logrotate stanza written via `cat >... <<EOF`.
# That truncated the body before its `register_tool` call, so tools
# like log2ram / network_optimization were never registered and never
# flagged as updatable. Track heredoc state and ignore `}` inside one.
body_start = i + 1
body_end = body_start
while body_end < len(lines) and not lines[body_end].rstrip() == "}":
heredoc_term = None
while body_end < len(lines):
current = lines[body_end]
if heredoc_term is not None:
# Inside a heredoc — only its terminator line ends it.
if current.strip() == heredoc_term:
heredoc_term = None
body_end += 1
continue
if current.rstrip() == "}":
break
# A line may open a heredoc (use the last opener on the line, so
# `cmd <<A | cmd <<B` picks B, matching shell behaviour).
openers = _HEREDOC_RE.findall(current)
if openers:
heredoc_term = openers[-1]
body_end += 1
body = "\n".join(lines[body_start:body_end])