1
0
Fork 1
mirror of https://github.com/thatmattlove/hyperglass.git synced 2026-05-15 20:08:01 +00:00

fix(ui): improve BGP table field display formatting

- Fix MED field showing N/A when value is 0 (now correctly shows 0)
- Hide Originator field when empty instead of showing N/A
- Hide Age field when not available instead of showing N/A
- Add HideableField component for fields that should be hidden when empty
- Update string output formatting to skip hidden fields in text export

Resolves display issues where valid zero values and unavailable fields
were showing as N/A instead of proper formatting or being hidden.
This commit is contained in:
Wilhelm Schonfeldt 2025-09-26 13:30:44 +02:00
parent 716687db63
commit 4414d2ec03
4 changed files with 63 additions and 39 deletions

View file

@ -31,77 +31,81 @@ class MikrotikGarbageOutput(OutputPlugin):
"""Clean MikroTik traceroute output specifically."""
if not raw_output or not raw_output.strip():
return ""
lines = raw_output.splitlines()
cleaned_lines = []
found_header = False
hop_data = {} # IP -> (line, sent_count)
for line in lines:
stripped = line.strip()
# Skip empty lines
if not stripped:
continue
# Skip interactive paging prompts
if "-- [Q quit|C-z pause]" in stripped or "-- [Q quit|D dump|C-z pause]" in stripped:
continue
# Skip command echo lines
if "tool traceroute" in stripped:
continue
# Look for the header line (ADDRESS LOSS SENT LAST AVG BEST WORST)
if "ADDRESS" in stripped and "LOSS" in stripped and "SENT" in stripped:
if not found_header:
cleaned_lines.append(line)
found_header = True
continue
# Only process data lines after we've found the header
if found_header and stripped:
# Try to extract IP address (IPv4 or IPv6) from the line
ipv4_match = re.match(r'^(\d+\.\d+\.\d+\.\d+)', stripped)
ipv6_match = re.match(r'^([0-9a-fA-F:]+)', stripped) if not ipv4_match else None
ipv4_match = re.match(r"^(\d+\.\d+\.\d+\.\d+)", stripped)
ipv6_match = re.match(r"^([0-9a-fA-F:]+)", stripped) if not ipv4_match else None
if ipv4_match or ipv6_match:
ip = ipv4_match.group(1) if ipv4_match else ipv6_match.group(1)
# Extract the SENT count from the line (look for pattern like "0% 3" or "100% 2")
sent_match = re.search(r'\s+(\d+)%\s+(\d+)\s+', stripped)
sent_match = re.search(r"\s+(\d+)%\s+(\d+)\s+", stripped)
sent_count = int(sent_match.group(2)) if sent_match else 0
# Keep the line with the highest SENT count (most complete data)
if ip not in hop_data or sent_count > hop_data[ip][1]:
hop_data[ip] = (line, sent_count)
elif sent_count == hop_data[ip][1] and "timeout" not in stripped and "timeout" in hop_data[ip][0]:
elif (
sent_count == hop_data[ip][1]
and "timeout" not in stripped
and "timeout" in hop_data[ip][0]
):
# If SENT counts are equal, prefer non-timeout over timeout
hop_data[ip] = (line, sent_count)
elif "100%" in stripped and "timeout" in stripped:
# Skip standalone timeout lines without IP
continue
# Reconstruct the output with only the best results
if found_header and hop_data:
result_lines = [cleaned_lines[0]] # Header
# Sort by the order IPs first appeared, but use the best data for each
seen_ips = []
for line in lines:
stripped = line.strip()
if found_header:
ipv4_match = re.match(r'^(\d+\.\d+\.\d+\.\d+)', stripped)
ipv6_match = re.match(r'^([0-9a-fA-F:]+)', stripped) if not ipv4_match else None
ipv4_match = re.match(r"^(\d+\.\d+\.\d+\.\d+)", stripped)
ipv6_match = re.match(r"^([0-9a-fA-F:]+)", stripped) if not ipv4_match else None
if ipv4_match or ipv6_match:
ip = ipv4_match.group(1) if ipv4_match else ipv6_match.group(1)
if ip not in seen_ips and ip in hop_data:
seen_ips.append(ip)
result_lines.append(hop_data[ip][0])
return "\n".join(result_lines)
return raw_output
def process(self, *, output: OutputType, query: "Query") -> Series[str]:
@ -113,7 +117,7 @@ class MikrotikGarbageOutput(OutputPlugin):
# If output is already processed/structured (not raw strings), pass it through unchanged
if not isinstance(output, (tuple, list)):
return output
# Check if the tuple/list contains non-string objects (structured data)
if output and not isinstance(output[0], str):
return output
@ -125,16 +129,18 @@ class MikrotikGarbageOutput(OutputPlugin):
if not isinstance(raw_output, str):
cleaned_outputs.append(raw_output)
continue
# Se a saída já estiver vazia, não há nada a fazer.
if not raw_output or not raw_output.strip():
cleaned_outputs.append("")
continue
# Check if this is traceroute output and handle it specially
if ("tool traceroute" in raw_output or
("ADDRESS" in raw_output and "LOSS" in raw_output and "SENT" in raw_output) or
"-- [Q quit|C-z pause]" in raw_output):
if (
"tool traceroute" in raw_output
or ("ADDRESS" in raw_output and "LOSS" in raw_output and "SENT" in raw_output)
or "-- [Q quit|C-z pause]" in raw_output
):
cleaned_output = self._clean_traceroute_output(raw_output)
cleaned_outputs.append(cleaned_output)
continue
@ -143,7 +149,7 @@ class MikrotikGarbageOutput(OutputPlugin):
lines = raw_output.splitlines()
filtered_lines = []
in_flags_section = False
for line in lines:
stripped_line = line.strip()

View file

@ -1,4 +1,4 @@
import { MonoField, Active, Weight, Age, Communities, RPKIState, ASPath } from './fields';
import { MonoField, Active, Weight, Age, Communities, RPKIState, ASPath, HideableField } from './fields';
import type { CellRenderProps } from '~/types';
@ -18,7 +18,7 @@ export const Cell = (props: CellProps): JSX.Element => {
peer_rid: <MonoField v={data.value} />,
source_as: <MonoField v={data.value} />,
active: <Active isActive={data.value} />,
source_rid: <MonoField v={data.value} />,
source_rid: <HideableField v={data.value} />,
local_preference: <MonoField v={data.value} />,
communities: <Communities communities={data.value} />,
as_path: <ASPath path={data.value} active={data.row.values.active} />,

View file

@ -51,8 +51,8 @@ dayjs.extend(utcPlugin);
export const MonoField = (props: MonoFieldProps): JSX.Element => {
const { v, ...rest } = props;
// Handle empty or undefined values
if (!v || (typeof v === 'string' && v.trim() === '')) {
// Handle empty or undefined values, but not zero values
if (v === null || v === undefined || (typeof v === 'string' && v.trim() === '')) {
return (
<Text as="span" fontSize="sm" fontFamily="mono" color="gray.500" {...rest}>
N/A
@ -85,15 +85,9 @@ export const Active = (props: ActiveProps): JSX.Element => {
export const Age = (props: AgeProps): JSX.Element => {
const { inSeconds, ...rest } = props;
// Handle case where age is not available (e.g., MikroTik)
// Handle case where age is not available (e.g., MikroTik) - hide the field entirely
if (inSeconds === -1) {
return (
<Tooltip hasArrow label="Age information not available" placement="right">
<Text fontSize="sm" color="gray.500" {...rest}>
N/A
</Text>
</Tooltip>
);
return <></>;
}
const now = dayjs.utc();
@ -265,3 +259,18 @@ const _RPKIState: React.ForwardRefRenderFunction<HTMLDivElement, RPKIStateProps>
};
export const RPKIState = forwardRef<HTMLDivElement, RPKIStateProps>(_RPKIState);
export const HideableField = (props: MonoFieldProps): JSX.Element => {
const { v, ...rest } = props;
// Hide the field entirely if value is empty or undefined
if (v === null || v === undefined || (typeof v === 'string' && v.trim() === '')) {
return <></>;
}
return (
<Text as="span" fontSize="sm" fontFamily="mono" {...rest}>
{v}
</Text>
);
};

View file

@ -102,6 +102,15 @@ export function useTableToString(
const [header, accessor, align] = field;
if (align !== null) {
let value = route[accessor];
// Handle fields that should be hidden when empty/not available
if ((accessor === 'source_rid' || accessor === 'age') &&
(value === null || value === undefined ||
(typeof value === 'string' && value.trim() === '') ||
(accessor === 'age' && value === -1))) {
continue; // Skip this field entirely
}
const fmtFunc = getFmtFunc(accessor) as (v: typeof value) => string;
value = fmtFunc(value);
if (accessor === 'prefix') {