Repository: sonic-net/sonic-buildimage
Pull request #22723: [dhcp_relay] Add show CLI for DHCP per-interface counter
Author: login: yaqiangz
Labels: name: Included in 202505 Branch
name: Request for 202505 Branch
name: Approved for 202505 Branch
Created: 2025-05-27T13:47:03Z | Merged: 2025-05-29T15:47:33Z

<!--
     Please make sure you've read and understood our contributing guidelines:
     https://github.com/Azure/SONiC/blob/gh-pages/CONTRIBUTING.md

     ** Make sure all your commits include a signature generated with `git commit -s` **

     If this is a bug fix, make sure your description includes "fixes #xxxx", or
     "closes #xxxx" or "resolves #xxxx"

     Please provide the following information:
-->

#### Why I did it
DHCPv4 per-interface counter is added by https://github.com/sonic-net/sonic-dhcpmon/pull/36. Current PR is to add CLI to show counter.
HLD: https://github.com/sonic-net/SONiC/pull/1861

##### Work item tracking
- Microsoft ADO **(number only)**: 32751340

#### How I did it
1. Add show cli for per-interface counter
2. Add uts for it

#### How to verify it
1. UT passed
2. Manually test
2.1 interface not specified
```
admin@sonic:~$ show dhcp_relay ipv4 counters
+----------------+-----------+---------+----------+-------+---------+---------+-----+-----+---------+--------+-------+
| Vlan100 (TX)   | Intf Type | Unknown | Discover | Offer | Request | Decline | Ack | Nak | Release | Inform | Bootp |
+----------------+-----------+---------+----------+-------+---------+---------+-----+-----+---------+--------+-------+
| Vlan100        | VLAN      | 0       | 0        | 0     | 0       | 0       | 0   | 0   | 0       | 0      | 0     |
| eth0           | MGMT      | 0       | 0        | 0     | 0       | 0       | 0   | 0   | 0       | 0      | 0     |
| Ethernet0      | Downlink  | 0       | 0        | 0     | 0       | 0       | 0   | 0   | 0       | 0      | 0     |
| Ethernet1      | Downlink  | 0       | 0        | 0     | 0       | 0       | 0   | 0   | 0       | 0      | 0     |
| Ethernet2      | Downlink  | 0       | 0        | 0     | 0       | 0       | 0   | 0   | 0       | 0      | 0     |
| Ethernet48     | Uplink    | 0       | 0        | 0     | 0       | 0       | 0   | 0   | 0       | 0      | 0     |
| Ethernet49     | Uplink    | 0       | 0        | 0     | 0       | 0       | 0   | 0   | 0       | 0      | 0     |
| Ethernet50     | Uplink    | 0       | 11       | 0     | 0       | 0       | 0   | 0   | 0       | 0      | 0     |
| Ethernet51     | Uplink    | 0       | 22       | 0     | 0       | 0       | 0   | 0   | 0       | 0      | 0     |
| PortChannel101 | Uplink    | 0       | 0        | 0     | 0       | 0       | 0   | 0   | 0       | 0      | 0     |
| PortChannel103 | Uplink    | 0       | 0        | 0     | 0       | 0       | 0   | 0   | 0       | 0      | 0     |
| PortChannel105 | Uplink    | 0       | 11       | 0     | 0       | 0       | 0   | 0   | 0       | 0      | 0     |
| PortChannel106 | Uplink    | 0       | 22       | 0     | 0       | 0       | 0   | 0   | 0       | 0      | 0     |
+----------------+-----------+---------+----------+-------+---------+---------+-----+-----+---------+--------+-------+

+----------------+-----------+---------+----------+-------+---------+---------+-----+-----+---------+--------+-------+
| Vlan100 (RX)   | Intf Type | Unknown | Discover | Offer | Request | Decline | Ack | Nak | Release | Inform | Bootp |
+----------------+-----------+---------+----------+-------+---------+---------+-----+-----+---------+--------+-------+
| Vlan100        | VLAN      | 0       | 11       | 0     | 0       | 0       | 0   | 0   | 0       | 0      | 0     |
| eth0           | MGMT      | 0       | 0        | 0     | 0       | 0       | 0   | 0   | 0       | 0      | 0     |
| Ethernet0      | Downlink  | 0       | 5        | 0     | 0       | 0       | 0   | 0   | 0       | 0      | 0     |
| Ethernet1      | Downlink  | 0       | 3        | 0     | 0       | 0       | 0   | 0   | 0       | 0      | 0     |
| Ethernet2      | Downlink  | 0       | 3        | 0     | 0       | 0       | 0   | 0   | 0       | 0      | 0     |
| Ethernet48     | Uplink    | 0       | 0        | 0     | 0       | 0       | 0   | 0   | 0       | 0      | 0     |
| Ethernet49     | Uplink    | 0       | 0        | 0     | 0       | 0       | 0   | 0   | 0       | 0      | 0     |
| Ethernet50     | Uplink    | 0       | 0        | 0     | 0       | 0       | 0   | 0   | 0       | 0      | 0     |
| Ethernet51     | Uplink    | 0       | 0        | 0     | 0       | 0       | 0   | 0   | 0       | 0      | 0     |
| PortChannel101 | Uplink    | 0       | 0        | 0     | 0       | 0       | 0   | 0   | 0       | 0      | 0     |
| PortChannel103 | Uplink    | 0       | 0        | 0     | 0       | 0       | 0   | 0   | 0       | 0      | 0     |
| PortChannel105 | Uplink    | 0       | 0        | 0     | 0       | 0       | 0   | 0   | 0       | 0      | 0     |
| PortChannel106 | Uplink    | 0       | 0        | 0     | 0       | 0       | 0   | 0   | 0       | 0      | 0     |
+----------------+-----------+---------+----------+-------+---------+---------+-----+-----+---------+--------+-------+
```
2.2 Interface specified
```
admin@sonic:~$ show dhcp_relay ipv4 counters Vlan100
+----------------+-----------+---------+----------+-------+---------+---------+-----+-----+---------+--------+-------+
| Vlan100 (TX)   | Intf Type | Unknown | Discover | Offer | Request | Decline | Ack | Nak | Release | Inform | Bootp |
+----------------+-----------+---------+----------+-------+---------+---------+-----+-----+---------+--------+-------+
| Vlan100        | VLAN      | 0       | 0        | 0     | 0       | 0       | 0   | 0   | 0       | 0      | 0     |
| eth0           | MGMT      | 0       | 0        | 0     | 0       | 0       | 0   | 0   | 0       | 0      | 0     |
| Ethernet0      | Downlink  | 0       | 0        | 0     | 0       | 0       | 0   | 0   | 0       | 0      | 0     |
| Ethernet1      | Downlink  | 0       | 0        | 0     | 0       | 0       | 0   | 0   | 0       | 0      | 0     |
| Ethernet2      | Downlink  | 0       | 0        | 0     | 0       | 0       | 0   | 0   | 0       | 0      | 0     |
| Ethernet48     | Uplink    | 0       | 0        | 0     | 0       | 0       | 0   | 0   | 0       | 0      | 0     |
| Ethernet49     | Uplink    | 0       | 0        | 0     | 0       | 0       | 0   | 0   | 0       | 0      | 0     |
| Ethernet50     | Uplink    | 0       | 11       | 0     | 0       | 0       | 0   | 0   | 0       | 0      | 0     |
| Ethernet51     | Uplink    | 0       | 22       | 0     | 0       | 0       | 0   | 0   | 0       | 0      | 0     |
| PortChannel101 | Uplink    | 0       | 0        | 0     | 0       | 0       | 0   | 0   | 0       | 0      | 0     |
| PortChannel103 | Uplink    | 0       | 0        | 0     | 0       | 0       | 0   | 0   | 0       | 0      | 0     |
| PortChannel105 | Uplink    | 0       | 11       | 0     | 0       | 0       | 0   | 0   | 0       | 0      | 0     |
| PortChannel106 | Uplink    | 0       | 22       | 0     | 0       | 0       | 0   | 0   | 0       | 0      | 0     |
+----------------+-----------+---------+----------+-------+---------+---------+-----+-----+---------+--------+-------+

+----------------+-----------+---------+----------+-------+---------+---------+-----+-----+---------+--------+-------+
| Vlan100 (RX)   | Intf Type | Unknown | Discover | Offer | Request | Decline | Ack | Nak | Release | Inform | Bootp |
+----------------+-----------+---------+----------+-------+---------+---------+-----+-----+---------+--------+-------+
| Vlan100        | VLAN      | 0       | 11       | 0     | 0       | 0       | 0   | 0   | 0       | 0      | 0     |
| eth0           | MGMT      | 0       | 0        | 0     | 0       | 0       | 0   | 0   | 0       | 0      | 0     |
| Ethernet0      | Downlink  | 0       | 5        | 0     | 0       | 0       | 0   | 0   | 0       | 0      | 0     |
| Ethernet1      | Downlink  | 0       | 3        | 0     | 0       | 0       | 0   | 0   | 0       | 0      | 0     |
| Ethernet2      | Downlink  | 0       | 3        | 0     | 0       | 0       | 0   | 0   | 0       | 0      | 0     |
| Ethernet48     | Uplink    | 0       | 0        | 0     | 0       | 0       | 0   | 0   | 0       | 0      | 0     |
| Ethernet49     | Uplink    | 0       | 0        | 0     | 0       | 0       | 0   | 0   | 0       | 0      | 0     |
| Ethernet50     | Uplink    | 0       | 0        | 0     | 0       | 0       | 0   | 0   | 0       | 0      | 0     |
| Ethernet51     | Uplink    | 0       | 0        | 0     | 0       | 0       | 0   | 0   | 0       | 0      | 0     |
| PortChannel101 | Uplink    | 0       | 0        | 0     | 0       | 0       | 0   | 0   | 0       | 0      | 0     |
| PortChannel103 | Uplink    | 0       | 0        | 0     | 0       | 0       | 0   | 0   | 0       | 0      | 0     |
| PortChannel105 | Uplink    | 0       | 0        | 0     | 0       | 0       | 0   | 0   | 0       | 0      | 0     |
| PortChannel106 | Uplink    | 0       | 0        | 0     | 0       | 0       | 0   | 0   | 0       | 0      | 0     |
+----------------+-----------+---------+----------+-------+---------+---------+-----+-----+---------+--------+-------+
```
2.3 type specified
```
admin@sonic:~$ show dhcp_relay ipv4 counters Vlan100 --type Discover
+--------------------+-----------+----+----+
| Vlan100 (Discover) | Intf Type | TX | RX |
+--------------------+-----------+----+----+
| Vlan100            | VLAN      | 0  | 11 |
| eth0               | MGMT      | 0  | 0  |
| Ethernet0          | Downlink  | 0  | 5  |
| Ethernet1          | Downlink  | 0  | 3  |
| Ethernet2          | Downlink  | 0  | 3  |
| Ethernet48         | Uplink    | 0  | 0  |
| Ethernet49         | Uplink    | 0  | 0  |
| Ethernet50         | Uplink    | 11 | 0  |
| Ethernet51         | Uplink    | 22 | 0  |
| PortChannel101     | Uplink    | 0  | 0  |
| PortChannel103     | Uplink    | 0  | 0  |
| PortChannel105     | Uplink    | 11 | 0  |
| PortChannel106     | Uplink    | 22 | 0  |
+--------------------+-----------+----+----+
```
2.4 dir specified
```
admin@sonic:~$ show dhcp_relay ipv4 counters Vlan100 --dir RX
+----------------+-----------+---------+----------+-------+---------+---------+-----+-----+---------+--------+-------+
| Vlan100 (RX)   | Intf Type | Unknown | Discover | Offer | Request | Decline | Ack | Nak | Release | Inform | Bootp |
+----------------+-----------+---------+----------+-------+---------+---------+-----+-----+---------+--------+-------+
| Vlan100        | VLAN      | 0       | 11       | 0     | 0       | 0       | 0   | 0   | 0       | 0      | 0     |
| eth0           | MGMT      | 0       | 0        | 0     | 0       | 0       | 0   | 0   | 0       | 0      | 0     |
| Ethernet0      | Downlink  | 0       | 5        | 0     | 0       | 0       | 0   | 0   | 0       | 0      | 0     |
| Ethernet1      | Downlink  | 0       | 3        | 0     | 0       | 0       | 0   | 0   | 0       | 0      | 0     |
| Ethernet2      | Downlink  | 0       | 3        | 0     | 0       | 0       | 0   | 0   | 0       | 0      | 0     |
| Ethernet48     | Uplink    | 0       | 0        | 0     | 0       | 0       | 0   | 0   | 0       | 0      | 0     |
| Ethernet49     | Uplink    | 0       | 0        | 0     | 0       | 0       | 0   | 0   | 0       | 0      | 0     |
| Ethernet50     | Uplink    | 0       | 0        | 0     | 0       | 0       | 0   | 0   | 0       | 0      | 0     |
| Ethernet51     | Uplink    | 0       | 0        | 0     | 0       | 0       | 0   | 0   | 0       | 0      | 0     |
| PortChannel101 | Uplink    | 0       | 0        | 0     | 0       | 0       | 0   | 0   | 0       | 0      | 0     |
| PortChannel103 | Uplink    | 0       | 0        | 0     | 0       | 0       | 0   | 0   | 0       | 0      | 0     |
| PortChannel105 | Uplink    | 0       | 0        | 0     | 0       | 0       | 0   | 0   | 0       | 0      | 0     |
| PortChannel106 | Uplink    | 0       | 0        | 0     | 0       | 0       | 0   | 0   | 0       | 0      | 0     |
+----------------+-----------+---------+----------+-------+---------+---------+-----+-----+---------+--------+-------+
```
2.5 type and dir specified
```
admin@sonic:~$ show dhcp_relay ipv4 counters Vlan100 --dir RX --type Discover
+--------------------+-----------+----+
| Vlan100 (Discover) | Intf Type | RX |
+--------------------+-----------+----+
| Vlan100            | VLAN      | 11 |
| eth0               | MGMT      | 0  |
| Ethernet0          | Downlink  | 5  |
| Ethernet1          | Downlink  | 3  |
| Ethernet2          | Downlink  | 3  |
| Ethernet48         | Uplink    | 0  |
| Ethernet49         | Uplink    | 0  |
| Ethernet50         | Uplink    | 0  |
| Ethernet51         | Uplink    | 0  |
| PortChannel101     | Uplink    | 0  |
| PortChannel103     | Uplink    | 0  |
| PortChannel105     | Uplink    | 0  |
| PortChannel106     | Uplink    | 0  |
+--------------------+-----------+----+
```


<!--
If PR needs to be backported, then the PR must be tested against the base branch and the earliest backport release branch and provide tested image version on these two branches. For example, if the PR is requested for master, 202211 and 202012, then the requester needs to provide test results on master and 202012.
-->

#### Which release branch to backport (provide reason below if selected)

<!--
- Note we only backport fixes to a release branch, *not* features!
- Please also provide a reason for the backporting below.
- e.g.
- [x] 202006
-->

- [ ] 202205
- [ ] 202211
- [ ] 202305
- [ ] 202311
- [ ] 202405
- [ ] 202411
- [x] 202505

#### Tested branch (Please provide the tested image version)

<!--
- Please provide tested image version
- e.g.
- [x] 20201231.100
-->

- [ ] <!-- image version 1 -->
- [ ] <!-- image version 2 -->

#### Description for the changelog
<!--
Write a short (one line) summary that describes the changes in this
pull request for inclusion in the changelog:
-->

<!--
 Ensure to add label/tag for the feature raised. example - PR#2174 under sonic-utilities repo. where, Generic Config and Update feature has been labelled as GCU.
-->

#### Link to config_db schema for YANG module changes
<!--
Provide a link to config_db schema for the table for which YANG model
is defined
Link should point to correct section on https://github.com/Azure/sonic-buildimage/blob/master/src/sonic-yang-models/doc/Configuration.md
-->

#### A picture of a cute animal (not mandatory but encouraged)




Diff:
diff --git a/dockers/docker-dhcp-relay/cli-plugin-tests/test_show_dhcp4relay_counters.py b/dockers/docker-dhcp-relay/cli-plugin-tests/test_show_dhcp4relay_counters.py
new file mode 100644
index 00000000000..576e40fbbf4
--- /dev/null
+++ b/dockers/docker-dhcp-relay/cli-plugin-tests/test_show_dhcp4relay_counters.py
@@ -0,0 +1,380 @@
+import click
+import copy
+import pytest
+import sys
+sys.path.append("../cli/show/plugins/")
+import show_dhcp_relay
+
+from unittest.mock import patch, call, MagicMock, ANY
+from click.testing import CliRunner
+
+SUPPORTED_DHCPV4_TYPE = [
+    "Unknown", "Discover", "Offer", "Request", "Decline", "Ack", "Nak", "Release", "Inform", "Bootp"
+]
+SUPPORTED_DIR = ["TX", "RX"]
+
+
+def test_plugin_registration():
+    cli = MagicMock()
+    show_dhcp_relay.register(cli)
+
+
+@pytest.mark.parametrize("valid", [True, False])
+@pytest.mark.parametrize("vlan_interface", ["Vlan1000", ""])
+def test_is_vlan_interface_valid(valid, vlan_interface):
+    mock_db = MagicMock()
+    mock_db.exists.return_value = valid
+    result = show_dhcp_relay.is_vlan_interface_valid(vlan_interface, mock_db)
+    if vlan_interface:
+        assert result == valid
+    else:
+        assert result
+
+
+def test_get_vlan_members_from_config_db():
+    mock_db = MagicMock()
+    mock_db.keys.return_value = [
+        "VLAN_MEMBER|Vlan1000|Ethernet1",
+        "VLAN_MEMBER|Vlan1000|Ethernet2",
+        "VLAN_MEMBER|Vlan2000|Ethernet3"
+    ]
+    result = show_dhcp_relay.get_vlan_members_from_config_db(mock_db, "Vlan1000")
+    assert result == {
+        "Vlan1000": set(["Ethernet1", "Ethernet2"])
+    }
+
+
+@pytest.mark.parametrize("get, expected_res", [
+    (None, 0),
+    ("{'Unknown':'0','Discover':'1','Offer':'0'}", "1"),
+    ("{'Unknown':'0','Request':'1','Offer':'0'}", "0")
+])
+def test_get_count_from_db(get, expected_res):
+    mock_db = MagicMock()
+    mock_db.get.return_value = get
+    res = show_dhcp_relay.get_count_from_db(mock_db, "", "", "Discover")
+    assert res == expected_res
+
+
+@pytest.mark.parametrize("vlan, dirs_count", [
+    ("Vlan1000", {"RX": "1", "TX": "5"}),
+    ("Vlan1000", {"RX": "3"})
+])
+def test_append_vlan_count_with_type_specified(vlan, dirs_count):
+    def mock_get_count_from_db(db, key, current_dir, current_type):
+        return dirs_count[current_dir]
+
+    with patch.object(show_dhcp_relay, "get_count_from_db", side_effect=mock_get_count_from_db):
+        dummy_summary = "dummy"
+        data = {
+            dummy_summary: [],
+            "Intf Type": []
+        }
+        for dir in dirs_count.keys():
+            data[dir] = []
+        expected_res = copy.deepcopy(data)
+        show_dhcp_relay.append_vlan_count_with_type_specified(data, MagicMock, list(dirs_count.keys()), vlan, "",
+                                                              dummy_summary)
+        expected_res["Intf Type"].append("VLAN")
+        expected_res[dummy_summary].append(vlan)
+        for dir, count in dirs_count.items():
+            expected_res[dir].append(count)
+        assert expected_res == data
+
+
+@pytest.mark.parametrize("interface_name, vlan_interface, expected_result", [
+    ("Ethernet1", "Vlan1000", "Downlink"),
+    ("Ethernet4", "Vlan1000", "Uplink"),
+    ("Ethernet5", "Vlan3000", "Unknown"),
+    ("eth0", "Vlan3000", "MGMT")
+])
+def test_get_interfaces_type(interface_name, vlan_interface, expected_result):
+    vlan_members = {
+        "Vlan1000": set(["Ethernet1", "Ethernet2"]),
+        "Vlan2000": set(["Ethernet3"])
+    }
+    mgmt_intfs = ["eth0"]
+    result = show_dhcp_relay.get_interfaces_type(interface_name, mgmt_intfs, vlan_interface, vlan_members)
+    assert result == expected_result
+
+
+@pytest.mark.parametrize("interfaces, dirs_count, vlan_interface", [
+    (["Ethernet1", "Ethernet2", "Ethernet3", "Ethernet4"], {"RX": "1", "TX": "5"}, "Vlan1000"),
+    (["eth0", "Ethernet3", "Ethernet4", "Ethernet5"], {"RX": "3 ", "TX": "5"}, "Vlan3000"),
+])
+def test_append_interfaces_count_with_type_specified(interfaces, dirs_count, vlan_interface):
+    def mock_get_count_from_db(db, key, current_dir, current_type):
+        return dirs_count[current_dir]
+
+    vlan_members = {
+        "Vlan1000": set(["Ethernet1", "Ethernet2"]),
+        "Vlan2000": set(["Ethernet3"])
+    }
+    mgmt_intfs = ["eth0"]
+    with patch.object(show_dhcp_relay, "get_count_from_db", side_effect=mock_get_count_from_db):
+        dummy_summary = "dummy"
+        data = {
+            dummy_summary: [],
+            "Intf Type": []
+        }
+        for dir in dirs_count.keys():
+            data[dir] = []
+        expected_data = copy.deepcopy(data)
+        mock_db = MagicMock()
+        mock_db.keys.return_value = ["DHCPV4_COUNTER_TABLE:" + vlan_interface + ":" + intf for intf in interfaces]
+        show_dhcp_relay.append_interfaces_count_with_type_specified(data, mock_db, list(dirs_count.keys()),
+                                                                    vlan_interface, "", dummy_summary, vlan_members,
+                                                                    mgmt_intfs)
+        expected_data[dummy_summary] = interfaces
+        for intf in interfaces:
+            if intf in mgmt_intfs:
+                expected_data["Intf Type"].append("MGMT")
+            elif vlan_interface not in vlan_members:
+                expected_data["Intf Type"].append("Unknown")
+            elif intf in vlan_members[vlan_interface]:
+                expected_data["Intf Type"].append("Downlink")
+            else:
+                expected_data["Intf Type"].append("Uplink")
+            for dir in dirs_count.keys():
+                expected_data[dir].append(dirs_count[dir])
+        assert expected_data == data
+
+
+def test_generate_output_with_type_specified():
+    expected_output = """\
++------------------+-----------+----+----+
+| Vlan1000 (Offer) | Intf Type | RX | TX |
++------------------+-----------+----+----+
+| Vlan1000         | VLAN      | 5  | 5  |
+| Ethernet0        | Downlink  | 10 | 10 |
+| Ethernet1        | Uplink    | 20 | 20 |
++------------------+-----------+----+----+
+"""
+
+    def mock_append_vlan(data, db, dirs, vlan_interface, current_type, summary):
+        data[summary].append("Vlan1000")
+        data["Intf Type"].append("VLAN")
+        for dir in dirs:
+            data[dir].append("5")
+
+    def mock_append_interfaces(data, db, dirs, vlan_interface, current_type, summary, vlan_members, mgmt_intfs):
+        data[summary].extend(["Ethernet0", "Ethernet1"])
+        data["Intf Type"].extend(["Downlink", "Uplink"])
+        for dir in dirs:
+            data[dir].extend(["10", "20"])
+
+    with patch.object(show_dhcp_relay, "append_vlan_count_with_type_specified", side_effect=mock_append_vlan), \
+         patch.object(show_dhcp_relay, "append_interfaces_count_with_type_specified",
+                      side_effect=mock_append_interfaces):
+        output = show_dhcp_relay.generate_output_with_type_specified(MagicMock(), [], ["Offer"], ["RX", "TX"],
+                                                                     "Vlan1000", [])
+        assert output == expected_output
+
+
+@pytest.mark.parametrize("vlan, types_count", [
+    ("Vlan1000", {"Discover": "1", "Offer": "5"}),
+    ("Vlan1000", {"Ack": "3"})
+])
+def test_append_vlan_count_without_type_specified(vlan, types_count):
+    def mock_get_count_from_db(db, key, current_dir, current_type):
+        return types_count[current_type]
+
+    with patch.object(show_dhcp_relay, "get_count_from_db", side_effect=mock_get_count_from_db):
+        dummy_summary = "dummy"
+        data = {
+            dummy_summary: [],
+            "Intf Type": []
+        }
+        for type in types_count.keys():
+            data[type] = []
+        expected_res = copy.deepcopy(data)
+        show_dhcp_relay.append_vlan_count_without_type_specified(data, MagicMock(), "", vlan, list(types_count.keys()),
+                                                                 dummy_summary)
+        expected_res["Intf Type"].append("VLAN")
+        expected_res[dummy_summary].append(vlan)
+        for type, count in types_count.items():
+            expected_res[type].append(count)
+        assert expected_res == data
+
+
+def test_natural_sort_key():
+    assert show_dhcp_relay.natural_sort_key("Ethernet12") == ["ethernet", 12, ""]
+
+
+@pytest.mark.parametrize("types_count", [{"Discover": "5", "Offer": "10"}, {"Request": "3", "Ack": "5"}])
+def test_append_interfaces_count_without_type_specified(types_count):
+    def mock_get_count_from_db(db, key, current_dir, current_type):
+        return types_count[current_type]
+
+    mock_db = MagicMock()
+    interfaces = ["Ethernet0", "Ethernet1"]
+    mock_db.keys.return_value = ["DHCPV4_COUNTER_TABLE:Vlan1000:{}".format(intf) for intf in interfaces]
+    dummy_summary = "dummy"
+    data = {
+        dummy_summary: [],
+        "Intf Type": []
+    }
+    for type in types_count.keys():
+        data[type] = []
+    with patch.object(show_dhcp_relay, "get_interfaces_type", return_value="Downlink"), \
+         patch.object(show_dhcp_relay, "get_count_from_db", side_effect=mock_get_count_from_db):
+        expected_data = copy.deepcopy(data)
+        show_dhcp_relay.append_interfaces_count_without_type_specified(data, mock_db, "", "", list(types_count.keys()),
+                                                                       dummy_summary, {}, [])
+        expected_data[dummy_summary].extend(interfaces)
+        expected_data["Intf Type"].extend(["Downlink", "Downlink"])
+        for type, count in types_count.items():
+            expected_data[type].extend([count, count])
+        assert expected_data == data
+
+
+def test_generate_output_without_type_specified():
+    expected_output = """\
++---------------+-----------+----------+-------+
+| Vlan1000 (RX) | Intf Type | Discover | Offer |
++---------------+-----------+----------+-------+
+| Vlan1000      | VLAN      | 10       | 10    |
+| Ethernet0     | Downlink  | 20       | 20    |
+| Ethernet1     | Uplink    | 30       | 30    |
++---------------+-----------+----------+-------+
+"""
+
+    def mock_append_vlan(data, db, dir, vlan_interface, types, summary):
+        data[summary].append("Vlan1000")
+        data["Intf Type"].append("VLAN")
+        for type in types:
+            data[type].append("10")
+
+    def mock_append_interfaces(data, db, dir, vlan_interface, types, summary, vlan_members,
+                               mgmt_intfs):
+        data[summary].extend(["Ethernet0", "Ethernet1"])
+        data["Intf Type"].extend(["Downlink", "Uplink"])
+        for type in types:
+            data[type].extend(["20", "30"])
+    with patch.object(show_dhcp_relay, "append_vlan_count_without_type_specified", side_effect=mock_append_vlan), \
+            patch.object(show_dhcp_relay, "append_interfaces_count_without_type_specified",
+                         side_effect=mock_append_interfaces):
+        output = show_dhcp_relay.generate_output_without_type_specified(MagicMock(), {}, ["Discover", "Offer"], "RX",
+                                                                        "Vlan1000", [])
+        assert expected_output == output
+
+
+def test_get_mgmt_intfs():
+    mock_db = MagicMock()
+    mock_db.keys.return_value = [
+        "MGMT_PORT|eth0",
+        "MGMT_PORT|eth1"
+    ]
+    result = show_dhcp_relay.get_mgmt_intfs_from_config_db(mock_db)
+    assert result == ["eth0", "eth1"]
+
+
+@pytest.mark.parametrize("vlan, expected_result", [
+    ("", set(["Vlan1000", "Vlan2000", "Vlan100"])),
+    ("Vlan1000", set(["Vlan1000"])),
+    ("Vlan100", set(["Vlan100"]))
+])
+def test_get_vlans_from_counters_db(vlan, expected_result):
+    mock_db = MagicMock()
+    mock_db.keys.return_value = [
+        "DHCPV4_COUNTER_TABLE:Vlan1000",
+        "DHCPV4_COUNTER_TABLE:Vlan100",
+        "DHCPV4_COUNTER_TABLE:Vlan1000:Ethernet0",
+        "DHCPV4_COUNTER_TABLE:Vlan2000"
+    ]
+    result = show_dhcp_relay.get_vlans_from_counters_db(vlan, mock_db)
+    assert result == expected_result
+
+
+def test_print_dhcpv4_relay_counter_data_with_type_specified():
+    mock_vlans = ["Vlan1000", "Vlan2000"]
+    mock_vlan_members = {"Vlan1000": set(["Ethernet0"]), "Vlan2000": set(["Ethernet1"])}
+    mock_mgmt_intfs = ["eth0"]
+    types = ["Discover"]
+    dirs = ["RX", "TX"]
+    with patch.object(show_dhcp_relay, "get_vlans_from_counters_db", return_value=mock_vlans), \
+         patch.object(show_dhcp_relay, "get_vlan_members_from_config_db", return_value=mock_vlan_members), \
+         patch.object(show_dhcp_relay, "get_mgmt_intfs_from_config_db", return_value=mock_mgmt_intfs), \
+         patch.object(show_dhcp_relay, "generate_output_with_type_specified",
+                      return_value="") as mock_generate_with_type_specified, \
+         patch.object(show_dhcp_relay,
+                      "generate_output_without_type_specified",
+                      return_value="") as mock_generate_withtout_type_specified:
+        show_dhcp_relay.print_dhcpv4_relay_counter_data("", dirs, types, MagicMock())
+
+        # Verify calls for generate_output_with_type_specified
+        expected_calls = [
+            call(ANY, mock_vlan_members, types, dirs, vlan, mock_mgmt_intfs)
+            for vlan in mock_vlans
+        ]
+        mock_generate_with_type_specified.assert_has_calls(expected_calls)
+
+        # Verify calls for generate_output_without_type_specified
+        mock_generate_withtout_type_specified.assert_not_called()
+
+
+def test_print_dhcpv4_relay_counter_data_without_type_specified():
+    mock_vlans = ["Vlan1000", "Vlan2000"]
+    mock_vlan_members = {"Vlan1000": set(["Ethernet0"]), "Vlan2000": set(["Ethernet1"])}
+    mock_mgmt_intfs = ["eth0"]
+    types = ["Discover", "Offer", "Request", "ack"]
+    dirs = ["RX", "TX"]
+    with patch.object(show_dhcp_relay, "get_vlans_from_counters_db", return_value=mock_vlans), \
+         patch.object(show_dhcp_relay, "get_vlan_members_from_config_db", return_value=mock_vlan_members), \
+         patch.object(show_dhcp_relay, "get_mgmt_intfs_from_config_db", return_value=mock_mgmt_intfs), \
+         patch.object(show_dhcp_relay, "generate_output_with_type_specified",
+                      return_value="") as mock_generate_with_type_specified, \
+         patch.object(show_dhcp_relay,
+                      "generate_output_without_type_specified",
+                      return_value="") as mock_generate_withtout_type_specified:
+        show_dhcp_relay.print_dhcpv4_relay_counter_data("", dirs, types, MagicMock())
+
+        # Verify calls for generate_output_with_type_specified
+        mock_generate_with_type_specified.assert_not_called()
+
+        # Verify calls for generate_output_without_type_specified
+        calls = []
+        for vlan in mock_vlans:
+            for dir in dirs:
+                calls.append(call(ANY, mock_vlan_members, types, dir, vlan, mock_mgmt_intfs))
+        mock_generate_withtout_type_specified.assert_has_calls(calls)
+
+
+def test_dhcp_relay_ip4counters_vlan_invalid():
+    runner = CliRunner()
+    with patch.object(show_dhcp_relay, "is_vlan_interface_valid", return_value=False) as mock_check, \
+         patch.object(click, "echo") as mock_echo, \
+         patch.object(show_dhcp_relay, "print_dhcpv4_relay_counter_data") as mock_print:
+        vlan = "Vlan1000"
+        result = runner.invoke(show_dhcp_relay.dhcp_relay.commands["ipv4"].commands["counters"], vlan)
+        assert result.exit_code == 0
+        mock_check.assert_called_once_with(vlan, ANY)
+        mock_echo.assert_called_once_with("Vlan interface {} doesn't have any count data".format(vlan))
+        mock_print.assert_not_called()
+
+
+@pytest.mark.parametrize("dir, type, expected_dirs, expected_types", [
+    (None, None, SUPPORTED_DIR, SUPPORTED_DHCPV4_TYPE),
+    (None, "Discover", SUPPORTED_DIR, ["Discover"]),
+    ("RX", None, ["RX"], SUPPORTED_DHCPV4_TYPE),
+    ("RX", "Offer", ["RX"], ["Offer"])
+])
+def test_dhcp_relay_ip4counters(dir, type, expected_dirs, expected_types):
+    runner = CliRunner()
+    with patch.object(show_dhcp_relay, "is_vlan_interface_valid", return_value=True), \
+         patch.object(show_dhcp_relay, "print_dhcpv4_relay_counter_data") as mock_print:
+        args = []
+        if dir:
+            args.append("--dir={}".format(dir))
+        if type:
+            args.append("--type={}".format(type))
+        result = runner.invoke(show_dhcp_relay.dhcp_relay.commands["ipv4"].commands["counters"], args)
+        assert result.exit_code == 0
+        mock_print.assert_called_once_with("", expected_dirs, expected_types, ANY)
+
+
+@pytest.mark.parametrize("args", ["--dir=fail", "--type=fail"])
+def test_dhcp_relay_ip4counters_incorrect_dir(args):
+    runner = CliRunner()
+    result = runner.invoke(show_dhcp_relay.dhcp_relay.commands["ipv4"].commands["counters"], args)
+    assert result.exit_code != 0
diff --git a/dockers/docker-dhcp-relay/cli/show/plugins/show_dhcp_relay.py b/dockers/docker-dhcp-relay/cli/show/plugins/show_dhcp_relay.py
index 7a3ea15d5f4..cdcad466ebf 100644
--- a/dockers/docker-dhcp-relay/cli/show/plugins/show_dhcp_relay.py
+++ b/dockers/docker-dhcp-relay/cli/show/plugins/show_dhcp_relay.py
@@ -1,4 +1,6 @@
 import click
+import json
+import re
 from natsort import natsorted
 from tabulate import tabulate
 import show.vlan as show_vlan
@@ -20,6 +22,15 @@
 VLAN = "VLAN"
 DHCPV6_SERVERS = "dhcpv6_servers"
 DHCPV4_SERVERS = "dhcp_servers"
+SUPPORTED_DHCPV4_TYPE = [
+    "Unknown", "Discover", "Offer", "Request", "Decline", "Ack", "Nak", "Release", "Inform", "Bootp"
+]
+SUPPORTED_DIR = ["TX", "RX"]
+DHCPV4_COUNTER_TABLE_PREFIX = "DHCPV4_COUNTER_TABLE"
+VLAN_MEMBER_TABLE_PREFIX = "VLAN_MEMBER"
+COUNTERS_DB_SEPRATOR = ":"
+CONFIG_DB_SEPRATOR = "|"
+MGMT_PORT_TABLE = "MGMT_PORT"
 config_db = ConfigDBConnector()
 
 
@@ -165,6 +176,180 @@ def get_dhcp_relay(table_name, entry_name, with_header):
             print(output)
 
 
+def is_vlan_interface_valid(vlan_interface, db):
+    # If not vlan interface specified, treat it as valid
+    if vlan_interface == "":
+        return True
+    if not db.exists(db.COUNTERS_DB, DHCPV4_COUNTER_TABLE_PREFIX + COUNTERS_DB_SEPRATOR + vlan_interface):
+        return False
+    return True
+
+
+def get_vlan_members_from_config_db(db, vlan_interface):
+    vlan_members = {}
+    for key in db.keys(db.CONFIG_DB, VLAN_MEMBER_TABLE_PREFIX + CONFIG_DB_SEPRATOR + vlan_interface + "*"):
+        splits = key.split(CONFIG_DB_SEPRATOR)
+        if not splits[1].endswith(vlan_interface):
+            continue
+        if splits[1] not in vlan_members:
+            vlan_members[splits[1]] = set()
+        vlan_members[splits[1]].add(splits[2])
+    return vlan_members
+
+
+def get_count_from_db(db, key, dir, type):
+    count_str = db.get(db.COUNTERS_DB, key, dir)
+    count = 0
+    if count_str is not None:
+        try:
+            count = json.loads(count_str.replace("\'", "\"")).get(type, "0")
+        except json.JSONDecodeError:
+            pass
+    return count
+
+
+def append_vlan_count_with_type_specified(data, db, dirs, vlan_interface, current_type, summary):
+    key = DHCPV4_COUNTER_TABLE_PREFIX + COUNTERS_DB_SEPRATOR + vlan_interface
+    data[summary].append(vlan_interface)
+    data["Intf Type"].append("VLAN")
+    for current_dir in dirs:
+        count = get_count_from_db(db, key, current_dir, current_type)
+        data[current_dir].append(count)
+
+
+def get_interfaces_type(interface_name, mgmt_intfs, vlan_interface, vlan_members):
+    if interface_name in mgmt_intfs:
+        interface_type = "MGMT"
+    elif vlan_interface not in vlan_members:
+        interface_type = "Unknown"
+    elif interface_name in vlan_members[vlan_interface]:
+        interface_type = "Downlink"
+    else:
+        interface_type = "Uplink"
+    return interface_type
+
+
+def append_interfaces_count_with_type_specified(data, db, dirs, vlan_interface, current_type, summary, vlan_members,
+                                                mgmt_intfs):
+    key_pattern = DHCPV4_COUNTER_TABLE_PREFIX + COUNTERS_DB_SEPRATOR + vlan_interface + COUNTERS_DB_SEPRATOR + "*"
+    keys = sorted(db.keys(db.COUNTERS_DB, key_pattern), key=natural_sort_key)
+    for key in keys:
+        interface_name = key.split(COUNTERS_DB_SEPRATOR)[2]
+        data[summary].append(interface_name)
+        interface_type = get_interfaces_type(interface_name, mgmt_intfs, vlan_interface, vlan_members)
+        data["Intf Type"].append(interface_type)
+        for current_dir in dirs:
+            count = get_count_from_db(db, key, current_dir, current_type)
+            data[current_dir].append(count)
+
+
+def generate_output_with_type_specified(db, vlan_members, types, dirs, vlan_interface, mgmt_intfs):
+    # If type specified, then there would be only 1 type
+    current_type = types[0]
+    summary = "{} ({})".format(vlan_interface, current_type)
+
+    # Data need to be printed
+    data = {
+        summary: [],
+        "Intf Type": []
+    }
+    for dir in dirs:
+        data.setdefault(dir, [])
+
+    # Get Vlan interface count
+    append_vlan_count_with_type_specified(data, db, dirs, vlan_interface, current_type, summary)
+    # Get physical interfaces count
+    append_interfaces_count_with_type_specified(data, db, dirs, vlan_interface, current_type, summary,
+                                                vlan_members, mgmt_intfs)
+    return tabulate(data, tablefmt="pretty", stralign="left", headers="keys") + "\n"
+
+
+def append_vlan_count_without_type_specified(data, db, dir, vlan_interface, types, summary):
+    key = DHCPV4_COUNTER_TABLE_PREFIX + COUNTERS_DB_SEPRATOR + vlan_interface
+    data[summary].append(vlan_interface)
+    data["Intf Type"].append("VLAN")
+    for current_type in types:
+        count = get_count_from_db(db, key, dir, current_type)
+        data[current_type].append(count)
+
+
+def natural_sort_key(string):
+    return [int(text) if text.isdigit() else text.lower() for text in re.split(r'(\d+)', string)]
+
+
+def append_interfaces_count_without_type_specified(data, db, dir, vlan_interface, types, summary, vlan_members,
+                                                   mgmt_intfs):
+    key_pattern = DHCPV4_COUNTER_TABLE_PREFIX + COUNTERS_DB_SEPRATOR + vlan_interface + COUNTERS_DB_SEPRATOR + "*"
+    keys = sorted(db.keys(db.COUNTERS_DB, key_pattern), key=natural_sort_key)
+    for key in keys:
+        interface_name = key.split(COUNTERS_DB_SEPRATOR)[2]
+        data[summary].append(interface_name)
+        interface_type = get_interfaces_type(interface_name, mgmt_intfs, vlan_interface, vlan_members)
+        data["Intf Type"].append(interface_type)
+        for current_type in types:
+            count = get_count_from_db(db, key, dir, current_type)
+            data[current_type].append(count)
+
+
+def generate_output_without_type_specified(db, vlan_members, types, dir, vlan_interface, mgmt_intfs):
+    summary = "{} ({})".format(vlan_interface, dir)
+    data = {
+        summary: [],
+        "Intf Type": []
+    }
+    for type in types:
+        data.setdefault(type, [])
+
+    # Get vlan interface count
+    append_vlan_count_without_type_specified(data, db, dir, vlan_interface, types, summary)
+    # Get physical interfaces count
+    append_interfaces_count_without_type_specified(data, db, dir, vlan_interface, types, summary, vlan_members,
+                                                   mgmt_intfs)
+    return tabulate(data, tablefmt="pretty", stralign="left", headers="keys") + "\n"
+
+
+def get_mgmt_intfs_from_config_db(db):
+    mgmt_intfs = [
+        key.split(CONFIG_DB_SEPRATOR)[1] for key in db.keys(db.CONFIG_DB, MGMT_PORT_TABLE + CONFIG_DB_SEPRATOR + "*")
+        if len(key.split(CONFIG_DB_SEPRATOR)) > 1
+    ]
+    return mgmt_intfs
+
+
+def get_vlans_from_counters_db(vlan_interface, db):
+    # Get vlan set from COUNTERS_DB
+    counters_key = DHCPV4_COUNTER_TABLE_PREFIX + COUNTERS_DB_SEPRATOR + vlan_interface + "*"
+    vlans = set()
+    for key in db.keys(db.COUNTERS_DB, counters_key):
+        splits = key.split(COUNTERS_DB_SEPRATOR)
+        if vlan_interface == "" or vlan_interface == splits[1]:
+            vlans.add(splits[1])
+    return vlans
+
+
+def print_dhcpv4_relay_counter_data(vlan_interface, dirs, types, db):
+    # Get vlan set from COUNTERS_DB
+    vlans = get_vlans_from_counters_db(vlan_interface, db)
+
+    # Get vlan members
+    vlan_members = get_vlan_members_from_config_db(db, vlan_interface)
+
+    # Get mgmt interfaces
+    mgmt_intfs = get_mgmt_intfs_from_config_db(db)
+
+    # If user specify type
+    if len(types) == 1:
+        for vlan in vlans:
+            output = generate_output_with_type_specified(db, vlan_members, types, dirs, vlan, mgmt_intfs)
+            print(output)
+    # type not specified
+    else:
+        for vlan in vlans:
+            for dir in dirs:
+                output = generate_output_without_type_specified(db, vlan_members, types, dir, vlan, mgmt_intfs)
+                print(output)
+
+
 @dhcp_relay_helper.command('ipv6')
 def get_dhcpv6_helper_address():
     """Parse through DHCP_RELAY table for each interface in config_db.json and print dhcpv6 helpers in table format"""
@@ -174,12 +359,12 @@ def get_dhcpv6_helper_address():
 def get_data(table_data, vlan):
     vlan_data = table_data.get(vlan, {})
     helpers_data = vlan_data.get('dhcpv6_servers')
-    addr = {vlan:[]}
+    addr = {vlan: []}
     output = ''
     if helpers_data is not None:
         for ip in helpers_data:
             addr[vlan].append(ip)
-        output = tabulate({'Interface':[vlan], vlan:addr.get(vlan)}, tablefmt='simple', stralign='right') + '\n'
+        output = tabulate({'Interface': [vlan], vlan: addr.get(vlan)}, tablefmt='simple', stralign='right') + '\n'
     return output
 
 
@@ -215,6 +400,20 @@ def dhcp_relay_ip6counters(interface):
     ipv6_counters(interface)
 
 
+@dhcp_relay_ipv4.command("counters")
+@clicommon.pass_db
+@click.argument("vlan_interface", required=False, default="")
+@click.option('--dir', type=click.Choice(SUPPORTED_DIR), required=False)
+@click.option('--type', type=click.Choice(SUPPORTED_DHCPV4_TYPE), required=False)
+def dhcp_relay_ip4counters(db, vlan_interface, dir, type):
+    if not is_vlan_interface_valid(vlan_interface, db.db):
+        click.echo("Vlan interface {} doesn't have any count data".format(vlan_interface))
+        return
+    dirs = [dir] if dir else SUPPORTED_DIR
+    types = [type] if type else SUPPORTED_DHCPV4_TYPE
+    print_dhcpv4_relay_counter_data(vlan_interface, dirs, types, db.db)
+
+
 def register(cli):
     cli.add_command(dhcp6relay_counters)
     cli.add_command(dhcp_relay_helper)


Reviews:
user: login: Blueve
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-29T03:09:32Z
user: login: yaqiangz
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-29T04:09:40Z
user: login: Blueve
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-29T04:16:41Z

Review comments:
diff_hunk: @@ -0,0 +1,387 @@
+import click
+import copy
+import pytest
+import sys
+sys.path.append("../cli/show/plugins/")
+import show_dhcp_relay
+
+from unittest.mock import patch, call, MagicMock, ANY
+from click.testing import CliRunner
+
+SUPPORTED_DHCPV4_TYPE = [
+    "Unknown", "Discover", "Offer", "Request", "Decline", "Ack", "Nak", "Release", "Inform", "Bootp"
+]
+SUPPORTED_DIR = ["TX", "RX"]
+
+
+def test_plugin_registration():
+    cli = MagicMock()
+    show_dhcp_relay.register(cli)
+
+
+@pytest.mark.parametrize("valid", [True, False])
+@pytest.mark.parametrize("vlan_interface", ["Vlan1000", ""])
+def test_is_vlan_interface_valid(valid, vlan_interface):
+    mock_db = MagicMock()
+    mock_db.exists.return_value = valid
+    result = show_dhcp_relay.is_vlan_interface_valid(vlan_interface, mock_db)
+    if vlan_interface:
+        assert result == valid
+    else:
+        assert result
+
+
+def test_get_vlan_members_from_config_db():
+    mock_db = MagicMock()
+    mock_db.keys.return_value = [
+        "VLAN_MEMBER|Vlan1000|Ethernet1",
+        "VLAN_MEMBER|Vlan1000|Ethernet2",
+        "VLAN_MEMBER|Vlan2000|Ethernet3"
+    ]
+    result = show_dhcp_relay.get_vlan_members_from_config_db(mock_db, "Vlan1000")
+    assert result == {
+        "Vlan1000": set(["Ethernet1", "Ethernet2"])
+    }
+
+
+@pytest.mark.parametrize("get, expected_res", [
+    (None, 0),
+    ("{'Unknown':'0','Discover':'1','Offer':'0'}", "1"),
+    ("{'Unknown':'0','Request':'1','Offer':'0'}", "0")
+])
+def test_get_count_from_db(get, expected_res):
+    mock_db = MagicMock()
+    mock_db.get.return_value = get
+    res = show_dhcp_relay.get_count_from_db(mock_db, "", "", "Discover")
+    assert res == expected_res
+
+
+@pytest.mark.parametrize("vlan, dirs_count", [
+    ("Vlan1000", {"RX": "1", "TX": "5"}),
+    ("Vlan1000", {"RX": "3"})
+])
+def test_append_vlan_count_with_type_specified(vlan, dirs_count):
+    def mock_get_count_from_db(db, key, current_dir, current_type):
+        return dirs_count[current_dir]
+
+    with patch.object(show_dhcp_relay, "get_count_from_db", side_effect=mock_get_count_from_db):
+        dummy_summary = "dummy"
+        data = {
+            dummy_summary: [],
+            "Interface Type": []
+        }
+        for dir in dirs_count.keys():
+            data[dir] = []
+        expected_res = copy.deepcopy(data)
+        show_dhcp_relay.append_vlan_count_with_type_specified(data, MagicMock, list(dirs_count.keys()), vlan, "",
+                                                              dummy_summary)
+        expected_res["Interface Type"].append("VLAN")
+        expected_res[dummy_summary].append(vlan)
+        for dir, count in dirs_count.items():
+            expected_res[dir].append(count)
+        assert expected_res == data
+
+
+@pytest.mark.parametrize("interface_name, vlan_interface, expected_result", [
+    ("Ethernet1", "Vlan1000", "Downlink"),
+    ("Ethernet4", "Vlan1000", "Uplink"),
+    ("Ethernet5", "Vlan3000", "Unknown"),
+    ("eth0", "Vlan3000", "MGMT")
+])
+def test_get_interfaces_type(interface_name, vlan_interface, expected_result):
+    vlan_members = {
+        "Vlan1000": set(["Ethernet1", "Ethernet2"]),
+        "Vlan2000": set(["Ethernet3"])
+    }
+    mgmt_intfs = ["eth0"]
+    result = show_dhcp_relay.get_interfaces_type(interface_name, mgmt_intfs, vlan_interface, vlan_members)
+    assert result == expected_result
+
+
+@pytest.mark.parametrize("interfaces, dirs_count, vlan_interface", [
+    (["Ethernet1", "Ethernet2", "Ethernet3", "Ethernet4"], {"RX": "1", "TX": "5"}, "Vlan1000"),
+    (["eth0", "Ethernet3", "Ethernet4", "Ethernet5"], {"RX": "3 ", "TX": "5"}, "Vlan3000"),
+])
+def test_append_interfaces_count_with_type_specified(interfaces, dirs_count, vlan_interface):
+    def mock_get_count_from_db(db, key, current_dir, current_type):
+        return dirs_count[current_dir]
+
+    vlan_members = {
+        "Vlan1000": set(["Ethernet1", "Ethernet2"]),
+        "Vlan2000": set(["Ethernet3"])
+    }
+    mgmt_intfs = ["eth0"]
+    with patch.object(show_dhcp_relay, "get_count_from_db", side_effect=mock_get_count_from_db):
+        dummy_summary = "dummy"
+        data = {
+            dummy_summary: [],
+            "Interface Type": []
+        }
+        for dir in dirs_count.keys():
+            data[dir] = []
+        expected_data = copy.deepcopy(data)
+        mock_db = MagicMock()
+        mock_db.keys.return_value = ["DHCPV4_COUNTER_TABLE:" + vlan_interface + ":" + intf for intf in interfaces]
+        show_dhcp_relay.append_interfaces_count_with_type_specified(data, mock_db, list(dirs_count.keys()),
+                                                                    vlan_interface, "", dummy_summary, vlan_members,
+                                                                    mgmt_intfs)
+        expected_data[dummy_summary] = interfaces
+        for intf in interfaces:
+            if intf in mgmt_intfs:
+                expected_data["Interface Type"].append("MGMT")
+            elif vlan_interface not in vlan_members:
+                expected_data["Interface Type"].append("Unknown")
+            elif intf in vlan_members[vlan_interface]:
+                expected_data["Interface Type"].append("Downlink")
+            else:
+                expected_data["Interface Type"].append("Uplink")
+            for dir in dirs_count.keys():
+                expected_data[dir].append(dirs_count[dir])
+        assert expected_data == data
+
+
+def test_generate_output_with_type_specified():
+    expected_output = """\
++--------------------+------------------+------+------+
+|   Vlan1000 (Offer) |   Interface Type |   RX |   TX |
++====================+==================+======+======+
+|           Vlan1000 |             VLAN |    5 |    5 |
++--------------------+------------------+------+------+
path: dockers/docker-dhcp-relay/cli-plugin-tests/test_show_dhcp4relay_counters.py
user: login: Blueve
body: The output is not aligned with HLD, can you remove those additional lines `+--------------------+------------------+------+------+` ?
```
| Ethernet1     | Downlink  | 0   | 2   | 0   | 2   | 0   | 0   | 0   | 0   | 0   |
| Ethernet2     | Downlink  | 0   | 2   | 0   | 2   | 0   | 0   | 0   | 0   | 0   |
| Ethernet46    | Uplink    | 0   | 0   | 2   | 0   | 2   | 0   | 0   | 0   | 0   |
| Ethernet47    | Uplink    | 0   | 0   | 2   | 0   | 2   | 0   | 0   | 0   | 0   |
| PortChannel1  | Uplink    | 0   | 0   | 2   | 0   | 2   | 0   | 0   | 0   | 0   |
| PortChannel2  | Uplink    | 0   | 0   | 2   | 0   | 2   | 0   | 0   | 0   | 0   |
```
created_at: 2025-05-29T03:09:32Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,387 @@
+import click
+import copy
+import pytest
+import sys
+sys.path.append("../cli/show/plugins/")
+import show_dhcp_relay
+
+from unittest.mock import patch, call, MagicMock, ANY
+from click.testing import CliRunner
+
+SUPPORTED_DHCPV4_TYPE = [
+    "Unknown", "Discover", "Offer", "Request", "Decline", "Ack", "Nak", "Release", "Inform", "Bootp"
+]
+SUPPORTED_DIR = ["TX", "RX"]
+
+
+def test_plugin_registration():
+    cli = MagicMock()
+    show_dhcp_relay.register(cli)
+
+
+@pytest.mark.parametrize("valid", [True, False])
+@pytest.mark.parametrize("vlan_interface", ["Vlan1000", ""])
+def test_is_vlan_interface_valid(valid, vlan_interface):
+    mock_db = MagicMock()
+    mock_db.exists.return_value = valid
+    result = show_dhcp_relay.is_vlan_interface_valid(vlan_interface, mock_db)
+    if vlan_interface:
+        assert result == valid
+    else:
+        assert result
+
+
+def test_get_vlan_members_from_config_db():
+    mock_db = MagicMock()
+    mock_db.keys.return_value = [
+        "VLAN_MEMBER|Vlan1000|Ethernet1",
+        "VLAN_MEMBER|Vlan1000|Ethernet2",
+        "VLAN_MEMBER|Vlan2000|Ethernet3"
+    ]
+    result = show_dhcp_relay.get_vlan_members_from_config_db(mock_db, "Vlan1000")
+    assert result == {
+        "Vlan1000": set(["Ethernet1", "Ethernet2"])
+    }
+
+
+@pytest.mark.parametrize("get, expected_res", [
+    (None, 0),
+    ("{'Unknown':'0','Discover':'1','Offer':'0'}", "1"),
+    ("{'Unknown':'0','Request':'1','Offer':'0'}", "0")
+])
+def test_get_count_from_db(get, expected_res):
+    mock_db = MagicMock()
+    mock_db.get.return_value = get
+    res = show_dhcp_relay.get_count_from_db(mock_db, "", "", "Discover")
+    assert res == expected_res
+
+
+@pytest.mark.parametrize("vlan, dirs_count", [
+    ("Vlan1000", {"RX": "1", "TX": "5"}),
+    ("Vlan1000", {"RX": "3"})
+])
+def test_append_vlan_count_with_type_specified(vlan, dirs_count):
+    def mock_get_count_from_db(db, key, current_dir, current_type):
+        return dirs_count[current_dir]
+
+    with patch.object(show_dhcp_relay, "get_count_from_db", side_effect=mock_get_count_from_db):
+        dummy_summary = "dummy"
+        data = {
+            dummy_summary: [],
+            "Interface Type": []
+        }
+        for dir in dirs_count.keys():
+            data[dir] = []
+        expected_res = copy.deepcopy(data)
+        show_dhcp_relay.append_vlan_count_with_type_specified(data, MagicMock, list(dirs_count.keys()), vlan, "",
+                                                              dummy_summary)
+        expected_res["Interface Type"].append("VLAN")
+        expected_res[dummy_summary].append(vlan)
+        for dir, count in dirs_count.items():
+            expected_res[dir].append(count)
+        assert expected_res == data
+
+
+@pytest.mark.parametrize("interface_name, vlan_interface, expected_result", [
+    ("Ethernet1", "Vlan1000", "Downlink"),
+    ("Ethernet4", "Vlan1000", "Uplink"),
+    ("Ethernet5", "Vlan3000", "Unknown"),
+    ("eth0", "Vlan3000", "MGMT")
+])
+def test_get_interfaces_type(interface_name, vlan_interface, expected_result):
+    vlan_members = {
+        "Vlan1000": set(["Ethernet1", "Ethernet2"]),
+        "Vlan2000": set(["Ethernet3"])
+    }
+    mgmt_intfs = ["eth0"]
+    result = show_dhcp_relay.get_interfaces_type(interface_name, mgmt_intfs, vlan_interface, vlan_members)
+    assert result == expected_result
+
+
+@pytest.mark.parametrize("interfaces, dirs_count, vlan_interface", [
+    (["Ethernet1", "Ethernet2", "Ethernet3", "Ethernet4"], {"RX": "1", "TX": "5"}, "Vlan1000"),
+    (["eth0", "Ethernet3", "Ethernet4", "Ethernet5"], {"RX": "3 ", "TX": "5"}, "Vlan3000"),
+])
+def test_append_interfaces_count_with_type_specified(interfaces, dirs_count, vlan_interface):
+    def mock_get_count_from_db(db, key, current_dir, current_type):
+        return dirs_count[current_dir]
+
+    vlan_members = {
+        "Vlan1000": set(["Ethernet1", "Ethernet2"]),
+        "Vlan2000": set(["Ethernet3"])
+    }
+    mgmt_intfs = ["eth0"]
+    with patch.object(show_dhcp_relay, "get_count_from_db", side_effect=mock_get_count_from_db):
+        dummy_summary = "dummy"
+        data = {
+            dummy_summary: [],
+            "Interface Type": []
+        }
+        for dir in dirs_count.keys():
+            data[dir] = []
+        expected_data = copy.deepcopy(data)
+        mock_db = MagicMock()
+        mock_db.keys.return_value = ["DHCPV4_COUNTER_TABLE:" + vlan_interface + ":" + intf for intf in interfaces]
+        show_dhcp_relay.append_interfaces_count_with_type_specified(data, mock_db, list(dirs_count.keys()),
+                                                                    vlan_interface, "", dummy_summary, vlan_members,
+                                                                    mgmt_intfs)
+        expected_data[dummy_summary] = interfaces
+        for intf in interfaces:
+            if intf in mgmt_intfs:
+                expected_data["Interface Type"].append("MGMT")
+            elif vlan_interface not in vlan_members:
+                expected_data["Interface Type"].append("Unknown")
+            elif intf in vlan_members[vlan_interface]:
+                expected_data["Interface Type"].append("Downlink")
+            else:
+                expected_data["Interface Type"].append("Uplink")
+            for dir in dirs_count.keys():
+                expected_data[dir].append(dirs_count[dir])
+        assert expected_data == data
+
+
+def test_generate_output_with_type_specified():
+    expected_output = """\
++--------------------+------------------+------+------+
+|   Vlan1000 (Offer) |   Interface Type |   RX |   TX |
++====================+==================+======+======+
+|           Vlan1000 |             VLAN |    5 |    5 |
++--------------------+------------------+------+------+
path: dockers/docker-dhcp-relay/cli-plugin-tests/test_show_dhcp4relay_counters.py
user: login: yaqiangz
body: Fixed
created_at: 2025-05-29T04:09:40Z
author_association: CONTRIBUTOR

PR comments:
user: login: mssonicbld
created_at: 2025-05-27T13:47:11Z
body: /azp run Azure.sonic-buildimage
author_association: COLLABORATOR
user: login: azure-pipelines[bot]
created_at: 2025-05-27T13:47:25Z
body: <samp>
Azure Pipelines successfully started running 1 pipeline(s).<br>

</samp>
author_association: NONE
user: login: mssonicbld
created_at: 2025-05-29T01:24:41Z
body: /azp run Azure.sonic-buildimage
author_association: COLLABORATOR
user: login: azure-pipelines[bot]
created_at: 2025-05-29T01:24:52Z
body: <samp>
Azure Pipelines successfully started running 1 pipeline(s).<br>

</samp>
author_association: NONE
user: login: mssonicbld
created_at: 2025-05-29T02:29:01Z
body: /azp run Azure.sonic-buildimage
author_association: COLLABORATOR
user: login: azure-pipelines[bot]
created_at: 2025-05-29T02:29:13Z
body: <samp>
Azure Pipelines successfully started running 1 pipeline(s).<br>

</samp>
author_association: NONE
user: login: mssonicbld
created_at: 2025-05-29T04:09:25Z
body: /azp run Azure.sonic-buildimage
author_association: COLLABORATOR
user: login: azure-pipelines[bot]
created_at: 2025-05-29T04:09:36Z
body: <samp>
Azure Pipelines successfully started running 1 pipeline(s).<br>

</samp>
author_association: NONE
user: login: mssonicbld
created_at: 2025-05-30T02:26:36Z
body: Cherry-pick PR to 202505: https://github.com/sonic-net/sonic-buildimage/pull/22787
author_association: COLLABORATOR