#!/usr/bin/env python3
# -*- coding: utf-8; py-indent-offset: 4 -*-
#
# Author:  Linuxfabrik GmbH, Zurich, Switzerland
# Contact: info (at) linuxfabrik (dot) ch
#          https://www.linuxfabrik.ch/
# License: The Unlicense, see LICENSE file.

# https://github.com/Linuxfabrik/monitoring-plugins/blob/main/CONTRIBUTING.md

"""See the check's README for more details."""

import argparse
import json
import re
import sys

import lib.args
import lib.base
import lib.db_sqlite
import lib.huawei_dorado
import lib.lftest
import lib.txt
from lib.globals import STATE_CRIT, STATE_OK, STATE_UNKNOWN, STATE_WARN

__author__ = 'Linuxfabrik GmbH, Zurich/Switzerland'
__version__ = '2026092204'

DESCRIPTION = """Checks the health and link status of the front-end and cluster ports of a Huawei
OceanStor Dorado storage system via the REST API (/fc_port, /eth_port, /sas_port and /bond_port
endpoints).
Alerts when a port reports a non-normal health status, when it negotiated a speed below the one
it is configured or built for, and optionally when a link is down. Reports the link error
counters the appliance keeps as per-second rates. Expansion, management, maintenance and
internal ports are checked on request via --include-backend.
Supports reporting the I/O counters via --performance.
Supports extended reporting via --lengthy."""

DEFAULT_BRIEF = False
DEFAULT_CACHE_EXPIRE = 15  # minutes; default session timeout period is 20 minutes
DEFAULT_CRIT_ERRORS = ''
DEFAULT_DEVICE_ID = ''  # the appliance reports its own at login
DEFAULT_INCLUDE_BACKEND = False
DEFAULT_INSECURE = True
DEFAULT_LENGTHY = False
DEFAULT_LINK_DOWN_SEVERITY = 'ok'
DEFAULT_NO_MATCH_SEVERITY = 'ok'
DEFAULT_NO_PROXY = False
DEFAULT_SCOPE = '0'
DEFAULT_SLOW_PORT_SEVERITY = 'warn'
DEFAULT_TIMEOUT = 3
DEFAULT_WARN_ERRORS = ''

# Local databases holding the previous error counter reading, so the counters can be
# reported as per-second rates rather than as the ever-growing totals the appliance
# keeps ([#320](https://github.com/Linuxfabrik/monitoring-plugins/issues/320)).
# One database per kind of port: `lib.db_sqlite.per_second_deltas()` derives the table
# from the counter names and rebuilds it whenever a row does not fit, and FC, Ethernet
# and SAS ports count different errors. In one shared table every kind wiped the
# baseline of the others, so no port ever got a rate. Verified against
# V700R001C10SPH128 on a Dorado 6000 V6 with 32 FC and 144 Ethernet ports.
DB_FILENAMES = {
    'Ethernet': 'linuxfabrik-monitoring-plugins-huawei-dorado-port-eth.db',
    'FC': 'linuxfabrik-monitoring-plugins-huawei-dorado-port-fc.db',
    'SAS': 'linuxfabrik-monitoring-plugins-huawei-dorado-port-sas.db',
}

# `LOGICTYPE` codes this check covers by default. Taken from the port enumerations of
# the REST Interface References for 6.1.0 and V700R001C10: 0 host port/service port,
# 5 management/service port, 6 maintenance/service port and 13 front-end container
# port serve hosts; 5 and 6 carry host I/O next to their other role. 11 cluster port
# links the controller enclosures of a scale-out array, and losing it matters as much
# as losing a host link.
DEFAULT_LOGIC_TYPES = (0, 5, 6, 11, 13)

# RUNNINGSTATUS code of a port whose link is down. These endpoints use their own
# enumeration: 0 unknown, 10 link up, 11 link down, and 33 to be recovered on
# Ethernet ports.
LINK_DOWN = 11

# What a port's `LOGICTYPE` code means. All port endpoints share one enumeration,
# and each documents the part of it that applies: `fc_port` only 0, `sas_port` only 1,
# `eth_port` 0 to 6, 13 and 14, `serial_port` 0 to 6 and 11. Every code listed here
# other than those in DEFAULT_LOGIC_TYPES is left out unless --include-backend is
# given; a code missing from this list is checked, because a port nothing lets us
# classify must not disappear silently.
#
# 11 is documented for `serial_port` only, but it is what the Ethernet ports of the
# scale-out interconnect report. Verified against V700R001C10SPH128 on a Dorado 6000 V6
# with two controller enclosures: the 16 ports carrying it sit in the same slot of
# every controller (CTE*.IOM2.P0 to P3), name the same port on the other controller
# enclosure as their peer, have no initiator and report a scale-out switching plane
# (`zoneId`, `dswId`), which the reference says is valid only on IP scale-out ports.
LOGIC_TYPES = {
    0: 'Host port',
    1: 'Expansion port',
    2: 'Management port',
    3: 'Internal port',
    4: 'Maintenance port',
    5: 'Management/service port',
    6: 'Maintenance/service port',
    11: 'Cluster port',
    13: 'Front-end container port',
    14: 'Back-end container port',
}

# Performance indicators this object supports, from the vendor's performance
# indicator tables. Read only when --performance is given.
PERFORMANCE_INDICATORS = (18, 19, 21, 22, 23, 24, 25, 26, 27, 28)

# Fields `--match` is applied to.
MATCH_FIELDS = ('UUID', 'LOCATION', 'NAME')

# RUNNINGSTATUS code a healthy port reports: link up (10).
OK_RUNNING_STATUS = (10,)

# The four port endpoints, in the order they are queried. They answer
# with the same fields, so one walk covers all of them. Unlike the other list
# endpoints they do not implement the `range` parameter, which is why they are read
# in one request each.
PORT_ENDPOINTS = (
    ('fc_port', 'FC'),
    ('eth_port', 'Ethernet'),
    ('sas_port', 'SAS'),
    ('bond_port', 'Bond'),
)

# Deliberately not queried: `fcoe_port` and `ib_port`. Neither REST Interface Reference
# documents them; they come from a third-party implementation, and querying an endpoint
# nothing documents would put behaviour into this check that nobody can look up.

# What each kind of port calls its speeds and its link error counters. The names differ
# per endpoint: an Ethernet port reports `SPEED` and `maxSpeed`, an FC and a SAS port
# `RUNSPEED` and `MAXSPEED`, and every kind counts errors the others do not have. A bond
# port is an aggregate of other ports and reports neither, so it has no entry.
#
# Every speed is in Mbit/s. The error counters are cumulative totals since `STARTTIME`,
# which is why they are turned into per-second rates before they are reported.
PORT_FIELDS = {
    'FC': {
        'speed': ('RUNSPEED',),
        'configured_speed': ('CONFSPEED',),
        'max_speed': ('MAXSPEED', 'MAXSUPPORTSPEED'),
        'errors': (
            ('bad_characters', ('BADCHARNUMBER',)),
            ('crc_errors', ('BADCRCNUM',)),
            ('end_of_frame_errors', ('endOfFrameErrors',)),
            ('link_failures', ('LINKFAIL',)),
            ('lost_signals', ('LOSTSIGNALS',)),
            ('lost_sync', ('LOSTSYNC',)),
        ),
    },
    'Ethernet': {
        'speed': ('SPEED',),
        'configured_speed': (),
        'max_speed': ('maxSpeed', 'MAXSPEED'),
        'errors': (
            ('crc_errors', ('crcErrors',)),
            ('error_packets', ('ERRORPACKETS',)),
            ('frame_errors', ('frameErrors',)),
            ('frame_length_errors', ('frameLengthErrors',)),
            ('lost_packets', ('LOSTPACKETS',)),
            ('overflowed_packets', ('OVERFLOWEDPACKETS',)),
        ),
    },
    'SAS': {
        'speed': ('RUNSPEED',),
        'configured_speed': (),
        'max_speed': ('MAXSPEED',),
        'errors': (
            ('disparity_errors', ('DISPARITYERROR',)),
            ('invalid_dwords', ('INVALIDDWORD',)),
            ('lost_dwords', ('LOSSDWORD',)),
            ('phy_reset_errors', ('PHYRESETERRORS',)),
        ),
    },
    'Bond': {},
}


def parse_args():
    """Parse command line arguments using argparse."""
    parser = argparse.ArgumentParser(
        description=DESCRIPTION,
        epilog=lib.args.epilog(__file__),
        formatter_class=lib.args.HelpFormatter,
    )

    parser.add_argument(
        '-V',
        '--version',
        action='version',
        version=f'%(prog)s: v{__version__} by {__author__}',
    )

    parser.add_argument(
        '--always-ok',
        help=lib.args.help('--always-ok'),
        dest='ALWAYS_OK',
        action='store_true',
        default=False,
    )

    parser.add_argument(
        '--brief',
        help=lib.args.help('--brief') + ' Worth setting on an array with many ports.',
        dest='BRIEF',
        action='store_true',
        default=DEFAULT_BRIEF,
    )

    parser.add_argument(
        '--cache-expire',
        help=lib.args.help('--cache-expire') + ' Default: %(default)s',
        dest='CACHE_EXPIRE',
        type=int,
        default=DEFAULT_CACHE_EXPIRE,
    )

    parser.add_argument(
        '--critical-errors',
        help='CRIT threshold for the link errors of a port, as a Nagios range in errors '
        'per second, summed over every error counter that port keeps. '
        'Off by default, because a link that drops the occasional frame is not worth '
        'waking anyone; watch the graph first and set it once you know what your fabric '
        'normally sits at. A healthy link sits at 0. '
        'Example: `--critical-errors=10`',
        dest='CRIT_ERRORS',
        default=DEFAULT_CRIT_ERRORS,
    )

    parser.add_argument(
        '--device-id',
        help='Huawei OceanStor Dorado API device ID. '
        'Optional: the appliance reports its own at login, so this is only '
        'needed to override that answer.',
        dest='DEVICE_ID',
        default=DEFAULT_DEVICE_ID,
    )

    parser.add_argument(
        '--ignore',
        help='Skip ports. '
        + lib.args.help('--ignore-regex')
        + ' The regex is anchored at the start of the string (Python `re.match`) and '
        'is matched against the port identifier, its location and its name, so prefix '
        'with `.*` to match anywhere. Default: %(default)s',
        dest='IGNORE',
        action='append',
        default=None,
    )

    parser.add_argument(
        '--include-backend',
        help='Also check the ports that neither serve hosts nor link the controller '
        'enclosures: the expansion ports towards the disk enclosures, and the '
        'management, maintenance and internal ports. '
        'By default only front-end and cluster ports are checked, because the unused '
        'expansion ports of every disk enclosure report their link as down. '
        'Combine with `--ignore` to leave out the disk enclosures.',
        dest='INCLUDE_BACKEND',
        action='store_true',
        default=DEFAULT_INCLUDE_BACKEND,
    )

    parser.add_argument(
        '--insecure',
        help=lib.args.help('--insecure'),
        dest='INSECURE',
        action='store_true',
        default=DEFAULT_INSECURE,
    )

    parser.add_argument(
        '--lengthy',
        help=lib.args.help('--lengthy'),
        dest='LENGTHY',
        action='store_true',
        default=DEFAULT_LENGTHY,
    )

    parser.add_argument(
        '--link-down-severity',
        help=lib.args.help('--link-down-severity') + ' Default: %(default)s',
        dest='LINK_DOWN_SEVERITY',
        choices=['ok', 'warn', 'crit', 'unknown'],
        default=DEFAULT_LINK_DOWN_SEVERITY,
    )

    parser.add_argument(
        '--match',
        help='Limit to ports. '
        + lib.args.help('--match')
        + ' The regex is anchored at the start of the string (Python `re.match`) and '
        'is matched against the port identifier, its location and its name, so prefix '
        'with `.*` to match anywhere. Default: %(default)s',
        dest='MATCH',
        action='append',
        default=None,
    )

    parser.add_argument(
        '--no-insecure',
        help=lib.args.help('--no-insecure'),
        dest='INSECURE',
        action='store_false',
        default=DEFAULT_INSECURE,
    )

    parser.add_argument(
        '--no-match-severity',
        help=lib.args.help('--no-match-severity') + ' Default: %(default)s',
        dest='NO_MATCH_SEVERITY',
        choices=['ok', 'warn', 'crit', 'unknown'],
        default=DEFAULT_NO_MATCH_SEVERITY,
    )

    parser.add_argument(
        '--no-perfdata',
        help=lib.args.help('--no-perfdata'),
        dest='NO_PERFDATA',
        action='store_true',
        default=False,
    )

    parser.add_argument(
        '--no-proxy',
        help=lib.args.help('--no-proxy'),
        dest='NO_PROXY',
        action='store_true',
        default=DEFAULT_NO_PROXY,
    )

    parser.add_argument(
        '--password',
        help='Huawei OceanStor Dorado API password.',
        dest='PASSWORD',
    )

    parser.add_argument(
        '--password-file',
        help=lib.args.help('--password-file'),
        dest='PASSWORD_FILE',
    )

    parser.add_argument(
        '--performance',
        help='Additionally report the I/O counters of every checked port. '
        'Costs one API request per object, so a large appliance may need a '
        'higher --timeout.',
        dest='PERFORMANCE',
        action='store_true',
        default=False,
    )

    parser.add_argument(
        '--proxy',
        help=lib.args.help('--proxy'),
        dest='PROXY',
        default=None,
    )

    parser.add_argument(
        '--scope',
        help='Huawei OceanStor Dorado API scope.',
        dest='SCOPE',
        default=DEFAULT_SCOPE,
    )

    parser.add_argument(
        '--slow-port-severity',
        help='State to report for a port that negotiated a speed below the one it is '
        'configured for, or below the one it is built for where it is set to '
        'auto-negotiate. A dirty connector, the wrong transceiver or a mismatched switch '
        'port show up this way long before the link drops. '
        'Default: %(default)s',
        dest='SLOW_PORT_SEVERITY',
        choices=['ok', 'warn', 'crit', 'unknown'],
        default=DEFAULT_SLOW_PORT_SEVERITY,
    )

    parser.add_argument(
        '--test',
        help=lib.args.help('--test'),
        dest='TEST',
        type=lib.args.csv,
    )

    parser.add_argument(
        '--timeout',
        help=lib.args.help('--timeout') + ' Default: %(default)s (seconds)',
        dest='TIMEOUT',
        type=int,
        default=DEFAULT_TIMEOUT,
    )

    parser.add_argument(
        '-u',
        '--url',
        help='Huawei OceanStor Dorado API URL.',
        dest='URL',
        required=True,
    )

    parser.add_argument(
        '--username',
        help='Huawei OceanStor Dorado API username.',
        dest='USERNAME',
        required=True,
    )

    parser.add_argument(
        '-v',
        '--verbose',
        help=lib.args.help('--verbose')
        + " Appends what every API request returned, so the appliance's own answers "
        'can be read while working out how it reports something. Session tokens are '
        'redacted. The output is as long as those answers are, so this is a debugging '
        'aid rather than something to leave switched on.',
        dest='VERBOSE',
        action='store_true',
        default=False,
    )

    parser.add_argument(
        '--warning-errors',
        help='WARN threshold for the link errors of a port, as a Nagios range in errors '
        'per second, summed over every error counter that port keeps. '
        'Off by default, because a link that drops the occasional frame is not worth '
        'alerting on; watch the graph first and set it once you know what your fabric '
        'normally sits at. A healthy link sits at 0. '
        'Example: `--warning-errors=1`',
        dest='WARN_ERRORS',
        default=DEFAULT_WARN_ERRORS,
    )

    args, _ = parser.parse_known_args()
    return args


def get_logic_type(port):
    """
    Describe what a port is used for, from its `LOGICTYPE` code.

    Parameters
    ----------
    port : dict
        One port as the API returned it.

    Returns
    -------
    str
        The label from LOGIC_TYPES, `'Unknown (<code>)'` for a code the vendor documents
        nowhere, or an empty string for a port without the field, such as a bond port.

    Examples
    --------
    >>> get_logic_type({'LOGICTYPE': '1'})
    'Expansion port'

    >>> get_logic_type({'LOGICTYPE': '12'})
    'Unknown (12)'
    """
    if port.get('LOGICTYPE') in (None, ''):
        return ''
    code = lib.huawei_dorado.as_code(port.get('LOGICTYPE'))
    return LOGIC_TYPES.get(code, f'Unknown ({port.get("LOGICTYPE")})')


def is_backend(port):
    """
    Tell whether a port is one this check leaves out unless --include-backend is given.

    Parameters
    ----------
    port : dict
        One port as the API returned it.

    Returns
    -------
    bool
        True for a documented `LOGICTYPE` code outside DEFAULT_LOGIC_TYPES.
        False for a front-end or cluster port, for a code nothing documents and for a
        port without the field: a bond port aggregates front-end ports, and a port this
        check cannot classify is better shown than silently dropped.

    Examples
    --------
    >>> is_backend({'LOGICTYPE': '1'})
    True

    >>> is_backend({'LOGICTYPE': '12'})
    False
    """
    code = lib.huawei_dorado.as_code(port.get('LOGICTYPE'))
    return code in LOGIC_TYPES and code not in DEFAULT_LOGIC_TYPES


def get_speeds(port):
    """
    Read a port's operating, configured and maximum speed, all in Mbit/s.

    Parameters
    ----------
    port : dict
        One port as the API returned it, carrying the `kind` `get_ports()`
        tagged it with.

    Returns
    -------
    tuple (int or None, int or None, int or None)
        The operating speed, the speed the port is configured for and the speed it is built
        for. A speed the port does not report, or reports as a placeholder, is `None`.

    Notes
    -----
    - `-1` is what an FC port answers with while it is not working correctly, and `0` is
      what a port with no link reports. Neither is a rate, so both yield `None` and the
      port is not judged to be slow because of them.
    - A configured speed of `0` means auto-negotiation, so there is no configured speed to
      fall short of. It is `None` here, which makes the caller compare against the maximum.

    Examples
    --------
    >>> get_speeds(
    ...     {'kind': 'FC', 'RUNSPEED': '8000', 'CONFSPEED': '0', 'MAXSPEED': '32000'}
    ... )
    (8000, None, 32000)
    """
    fields = PORT_FIELDS.get(port.get('kind'), {})
    speeds = []
    for key in ('speed', 'configured_speed', 'max_speed'):
        names = fields.get(key) or ()
        value = lib.huawei_dorado.as_code(
            lib.huawei_dorado.field(port, *names) if names else None
        )
        speeds.append(value if value and value > 0 else None)
    return tuple(speeds)


def get_error_rates(port, label, testing):
    """
    Return a port's link error counters as per-second rates.

    Parameters
    ----------
    port : dict
        One port as the API returned it.
    label : str
        The port's sanitised UUID, used as the key the previous reading
        is stored under.
    testing : bool
        Whether the check runs against fixture data, in which case no
        persistent state is written.

    Returns
    -------
    dict
        Counter name to rate per second. Empty on the first run, after a counter
        reset, in test mode, and for a kind of port that counts no errors.

    Notes
    -----
    - The appliance reports these as totals since it started counting, which only ever
      grow. A cumulative counter aggregates wrong in every dashboard that touches it, so
      the delta against the previous run is what goes out
      ([#320](https://github.com/Linuxfabrik/monitoring-plugins/issues/320)).
    - A counter the port does not report is left out rather than stored as zero, so a
      firmware without it does not produce a rate of zero that looks like a healthy link.
    - Each kind of port keeps its readings in a database of its own, because the kinds
      count different errors and one table holds one set of columns only.
    """
    fields = PORT_FIELDS.get(port.get('kind'), {})
    counters = {}
    for name, spellings in fields.get('errors') or ():
        value = lib.huawei_dorado.as_code(lib.huawei_dorado.field(port, *spellings))
        if value is not None and value >= 0:
            counters[name] = value
    if not counters or testing:
        return {}
    return (
        lib.db_sqlite.per_second_deltas(DB_FILENAMES[port['kind']], label, counters)
        or {}
    )


def get_ports(args):
    """
    Read the four port endpoints and return their ports as one list.

    Parameters
    ----------
    args : object
        The argument namespace `lib.huawei_dorado.get_data()` reads.

    Returns
    -------
    tuple (list, dict)
        One entry per port, each carrying an extra `kind` field
        naming which of the four endpoints it came from, and the envelope of the last
        endpoint that refused the query so the caller can report the appliance's own words.

    Notes
    -----
    - An endpoint that is not implemented on a given appliance, or that carries no
      port at all, contributes nothing rather than aborting the check. An array
      without SAS ports is a normal array, not a broken query.
    - A refused endpoint is retried once at most. The endpoints that are simply absent
      on a given appliance answer with an error on every run, and the library's default
      of three attempts plus a forced re-login would spend the check's whole time budget
      learning what the first answer already said.
    """
    ports = []
    failed = {}
    for endpoint, kind in PORT_ENDPOINTS:
        result = lib.huawei_dorado.get_data(endpoint, args, max_attempts=1)
        if lib.huawei_dorado.get_error_code(result) not in (0, '0'):
            failed = result
            continue
        for port in result.get('data') or []:
            port['kind'] = kind
            ports.append(port)
    return ports, failed


def main():
    """The main function. This is where the magic happens."""

    # parse the command line
    try:
        args = parse_args()
    except SystemExit:
        sys.exit(STATE_UNKNOWN)

    if args.PASSWORD_FILE:
        args.PASSWORD = lib.args.load_secret(args.PASSWORD_FILE)
    if not args.PASSWORD:
        lib.base.cu('Provide the API password via --password or --password-file.')

    # set default values for append parameters that were not specified
    if args.IGNORE is None:
        args.IGNORE = []
    if args.MATCH is None:
        args.MATCH = []

    if not args.URL.startswith('http'):
        lib.base.cu('--url parameter has to start with "http://" or https://".')

    # fetch data
    failed = {}
    if args.TEST is None:
        ports, failed = get_ports(args)
    else:
        # do not call the command, put in test data
        stdout, _stderr, _retc = lib.lftest.test(args.TEST)
        ports = json.loads(stdout).get('data') or []

    # no valuable result?
    # An array always has ports, so an empty list is a query that never reached them
    # rather than an inventory that is genuinely empty. Where an endpoint
    # said why it refused, that is what the operator needs to read - a guess at the
    # permissions sends them looking in the wrong place.
    if not ports and failed:
        lib.huawei_dorado.assert_ok(failed, 'the ports')
    if not ports:
        lib.base.oao(
            f'{args.URL} reported no ports.'
            ' Verify that the API user is allowed to query them.',
            STATE_UNKNOWN,
        )

    # init some vars
    msg = ''
    state = STATE_OK
    perfdata = ''
    compiled_match_regex = [
        lib.base.coe(item) for item in lib.txt.compile_regex(args.MATCH, '--match')
    ]
    compiled_ignore_regex = [
        lib.base.coe(item) for item in lib.txt.compile_regex(args.IGNORE, '--ignore')
    ]
    link_down_state = lib.base.str2state(args.LINK_DOWN_SEVERITY)
    slow_port_state = lib.base.str2state(args.SLOW_PORT_SEVERITY)

    # analyze data
    table_data = []
    backend_count = 0
    for port in ports:
        # Expansion ports, the scale-out interconnect and the management ports do not
        # serve hosts. The expansion ports alone outnumber the front-end ports on an
        # array with disk enclosures, and every unused one reports its link as down.
        if not args.INCLUDE_BACKEND and is_backend(port):
            backend_count += 1
            continue

        port['UUID'] = lib.huawei_dorado.get_uuid(port)
        # Perfdata labels carry the object's own TYPE:ID, which stays the same when
        # a port is re-cabled, unlike its location.
        label = re.sub(r'\W+', '_', port['UUID'])

        if args.MATCH and not any(
            lib.base.coe(lib.txt.match_regex(pattern, str(port.get(field, ''))))
            for pattern in compiled_match_regex
            for field in MATCH_FIELDS
        ):
            continue

        if args.IGNORE and any(
            lib.base.coe(lib.txt.match_regex(pattern, str(port.get(field, ''))))
            for pattern in compiled_ignore_regex
            for field in MATCH_FIELDS
        ):
            continue

        health_state = lib.huawei_dorado.get_health_status_state(
            port.get('HEALTHSTATUS')
        )
        state = lib.base.get_worst(state, health_state)

        # A port whose link is down looks exactly like a port nobody cabled, so how loud
        # that should be is the operator's call rather than this check's.
        if lib.huawei_dorado.as_code(port.get('RUNNINGSTATUS')) == LINK_DOWN:
            running_state = link_down_state
        else:
            running_state = lib.huawei_dorado.get_running_status_state(
                port.get('RUNNINGSTATUS'), OK_RUNNING_STATUS
            )
        state = lib.base.get_worst(state, running_state)

        # A port that negotiated below what it is configured or built for still carries
        # traffic, which is why nothing else in this check notices it. It is what a dirty
        # connector, the wrong transceiver or a mismatched switch port look like.
        speed, configured_speed, max_speed = get_speeds(port)
        expected_speed = configured_speed or max_speed
        speed_state = STATE_OK
        if speed is not None and expected_speed and speed < expected_speed:
            speed_state = slow_port_state
            state = lib.base.get_worst(state, speed_state)

        # The appliance counts its link errors as totals since it started counting. The
        # rate against the previous run is what a dashboard can be built on, and the
        # first run after an update or a reboot has no previous run to compare against.
        error_rates = get_error_rates(port, label, args.TEST is not None)
        error_state = STATE_OK
        if error_rates and (args.WARN_ERRORS or args.CRIT_ERRORS):
            error_state = lib.base.get_state(
                sum(error_rates.values()),
                args.WARN_ERRORS or None,
                args.CRIT_ERRORS or None,
                _operator='range',
            )
            state = lib.base.get_worst(state, error_state)

        # In Mbit/s, the unit the appliance reports both speeds in and the one
        # huawei-dorado-sfp already graphs its modules at.
        perfdata += lib.base.get_perfdata(
            f'{label}_speed',
            speed,
            uom=None,
            _min=0,
            _max=max_speed,
        )
        for name, rate in sorted(error_rates.items()):
            perfdata += lib.base.get_perfdata(
                f'{label}_{name}_per_second',
                round(rate, 3),
                uom=None,
                _min=0,
            )

        # The counters come from a second endpoint, which a fixture cannot stand
        # in for. `lib.huawei_dorado.get_performance()` covers that path.
        if args.PERFORMANCE and args.TEST is None:
            samples = lib.huawei_dorado.get_performance(
                port['UUID'], PERFORMANCE_INDICATORS, args
            )
            perfdata += lib.huawei_dorado.get_performance_perfdata(label, samples)

        port['row_state'] = lib.base.get_worst(
            lib.base.get_worst(health_state, running_state),
            lib.base.get_worst(speed_state, error_state),
        )
        port['health'] = lib.huawei_dorado.get_health_status(port.get('HEALTHSTATUS'))
        port['logic_type'] = get_logic_type(port)
        port['link'] = lib.huawei_dorado.get_running_status(port.get('RUNNINGSTATUS'))
        # One state per row, in the last column. IcingaWeb replaces a state with an
        # icon, which shifts everything to the right of it, so a second one mid-row
        # would break the table.
        port['state'] = lib.base.state2str(port['row_state'], empty_ok=False)
        # The expected speed is printed next to the operating one, so a slow port shows
        # what it should have negotiated instead of only that something is off.
        port['speed'] = '--' if speed is None else f'{speed}'
        if speed is not None and expected_speed and speed < expected_speed:
            port['speed'] = (
                f'{speed}/{expected_speed}{lib.base.state2str(speed_state, prefix=" ")}'
            )
        port['errors'] = (
            f'{sum(error_rates.values()):.2f}'
            f'{lib.base.state2str(error_state, prefix=" ")}'
            if error_rates
            else '--'
        )

        table_data.append(port)

    # The appliance listed ports and the filters selected none of them.
    if not table_data:
        if args.MATCH:
            msg = f'No ports matched `{", ".join(args.MATCH)}`.'
        else:
            msg = 'Nothing checked.'
        if backend_count:
            msg += (
                f' {backend_count} {lib.txt.pluralize("port", backend_count)}'
                ' checked with `--include-backend` only.'
            )
        lib.base.oao(
            msg,
            lib.base.str2state(args.NO_MATCH_SEVERITY),
            always_ok=args.ALWAYS_OK,
            no_perfdata=args.NO_PERFDATA,
        )

    # build the message
    if state == STATE_CRIT:
        msg += 'There are critical errors.'
    elif state == STATE_WARN:
        msg += 'There are warnings.'
    else:
        msg += 'Everything is ok.'
    msg += '\n\n'

    # build table output
    display_rows = table_data
    if args.BRIEF:
        display_rows = [row for row in table_data if row['row_state'] != STATE_OK]
    if display_rows:
        keys = [
            'UUID',
            'kind',
            'LOCATION',
            'speed',
            'errors',
            'link',
            'health',
            'state',
        ]
        headers = [
            'UUID',
            'Type',
            'Location',
            'Mbit/s',
            'Err/s',
            'Link',
            'Health',
            'State',
        ]
        if args.LENGTHY:
            keys.insert(2, 'logic_type')
            headers.insert(2, 'Logic')

        msg += lib.base.get_table(
            display_rows, keys, header=headers, missing='--', hide_empty=True
        )

    if args.VERBOSE:
        msg += '\n\n' + lib.huawei_dorado.format_responses()

    # over and out
    lib.base.oao(
        msg, state, perfdata, always_ok=args.ALWAYS_OK, no_perfdata=args.NO_PERFDATA
    )


if __name__ == '__main__':
    try:
        main()
    except Exception:
        lib.base.cu()
