+
+Select a device to inspect the identifiers and properties reported by the Linux kernel, including its bound driver and loadable kernel module when available.
+
+Hardware data and sensor readings are read from the Linux /sys and /proc filesystems.
+
+Friendly PCI and USB names are loaded from the operating system's standard hardware ID databases when those files are installed. No additional command is required for the device inventory.
+
+
diff --git a/hardware-info/images/icon.gif b/hardware-info/images/icon.gif
new file mode 100644
index 000000000..ebe8fd239
Binary files /dev/null and b/hardware-info/images/icon.gif differ
diff --git a/hardware-info/images/icon.svg b/hardware-info/images/icon.svg
new file mode 100644
index 000000000..a13148788
--- /dev/null
+++ b/hardware-info/images/icon.svg
@@ -0,0 +1,11 @@
+
diff --git a/hardware-info/index.cgi b/hardware-info/index.cgi
new file mode 100755
index 000000000..eafeb3356
--- /dev/null
+++ b/hardware-info/index.cgi
@@ -0,0 +1,320 @@
+#!/usr/local/bin/perl
+# Display a read-only system hardware report and device inventory.
+
+use strict;
+use warnings;
+
+require './hardware-info-lib.pl'; ## no critic
+
+our (%in, %text);
+
+ReadParse();
+
+# Collect each group once so tab counts and rows describe the same snapshot.
+my @all_types = hardware_device_types();
+my %devices;
+foreach my $type (@all_types) {
+ my @list = list_hardware_devices($type);
+ $devices{$type} = \@list;
+ }
+# Sensors are optional on virtual machines and systems without hwmon drivers.
+my @types = grep { $_ ne 'sensor' || @{$devices{$_}} } @all_types;
+my $summary = hardware_system_summary($devices{'cpu'});
+
+ui_print_header(undef, $text{'index_title'}, "", "intro", 0, 1);
+
+# The report card and device groups are peers so the page opens with one
+# focused view instead of stacking the summary above every inventory.
+my @tabs = ([ 'system', $text{'type_system'} ], map {
+ [ $_, $text{"type_$_"}.' '.
+ ui_tag('sup', scalar(@{$devices{$_}})) ]
+ } @types);
+my %valid_tabs = map { $_->[0], 1 } @tabs;
+my $requested = defined($in{'type'}) ? $in{'type'} : "";
+my $active = $valid_tabs{$requested} ? $requested : 'system';
+
+print ui_tabs_start(\@tabs, "type", $active, 1);
+foreach my $tab ('system', @types) {
+ print ui_tabs_start_tab("type", $tab);
+ print ui_div($text{"index_${tab}_desc"});
+ if ($tab eq 'system') {
+ print_system_summary($summary);
+ }
+ else {
+ print_device_table($tab, $devices{$tab});
+ }
+ print ui_tabs_end_tab("type", $tab);
+ }
+print ui_tabs_end(1);
+
+ui_print_footer("/", $text{'index'});
+
+# print_system_summary(summary)
+# Renders the system, board, firmware and resource report card.
+sub print_system_summary
+{
+my ($summary) = @_;
+print ui_table_start(undef, "width=100%", 4,
+ [ "width=20%", "width=30%", "width=20%", "width=30%" ]);
+
+my $system = join(" ", grep { defined($_) && $_ ne "" }
+ ($summary->{'system_vendor'}, $summary->{'system_product'},
+ $summary->{'system_version'}));
+my $board = join(" ", grep { defined($_) && $_ ne "" }
+ ($summary->{'board_vendor'}, $summary->{'board_name'},
+ $summary->{'board_version'}));
+my $bios = join(" ", grep { defined($_) && $_ ne "" }
+ ($summary->{'bios_vendor'}, $summary->{'bios_version'},
+ $summary->{'bios_date'}));
+my $chassis_type = hardware_chassis_type_name($summary->{'chassis_type'});
+my $chassis = $chassis_type || $summary->{'chassis_version'} ?
+ join(" ", grep { defined($_) && $_ ne "" }
+ ($summary->{'chassis_vendor'}, $chassis_type,
+ $summary->{'chassis_version'})) : "";
+my $models = @{$summary->{'cpu_models'}} ?
+ join(", ", @{$summary->{'cpu_models'}}) : $text{'unknown'};
+my $processor_count = text(
+ $summary->{'cpu_count'} == 1 ? 'index_processor_count_one' :
+ 'index_processor_count_many', $summary->{'cpu_count'});
+my $package_count = $summary->{'cpu_sockets'} == 1 ?
+ $text{'index_package_count_one'} : $summary->{'cpu_sockets'} > 1 ?
+ text('index_package_count_many', $summary->{'cpu_sockets'}) :
+ $text{'index_package_count_unknown'};
+my $processors = text('index_processors', $processor_count,
+ $package_count, $models);
+my $firmware_mode = $text{'firmware_'.$summary->{'firmware_mode'}};
+my $secure_boot = defined($summary->{'secure_boot'}) ?
+ ($summary->{'secure_boot'} ? $text{'secure_boot_enabled'} :
+ $text{'secure_boot_disabled'}) : $text{'unknown'};
+my $tpm = @{$summary->{'tpms'}} ?
+ text('index_tpm_detected', join(", ", @{$summary->{'tpms'}})) :
+ $text{'index_tpm_missing'};
+
+# Missing optional DMI values are omitted instead of leaving empty labels.
+my @rows = (
+ [ $text{'index_hostname'}, $summary->{'hostname'} ],
+ [ $text{'index_os'}, $summary->{'os'} ],
+ [ $text{'index_kernel'}, $summary->{'kernel'} ],
+ [ $text{'index_architecture'}, $summary->{'architecture'} ],
+ [ $text{'index_system'}, $system ],
+ [ $text{'index_system_serial'}, $summary->{'system_serial'} ],
+ [ $text{'index_board'}, $board ],
+ [ $text{'index_board_serial'}, $summary->{'board_serial'} ],
+ [ $text{'index_bios'}, $bios ],
+ [ $text{'index_firmware_mode'}, $firmware_mode ],
+ [ $text{'index_secure_boot'},
+ $summary->{'firmware_mode'} eq 'uefi' ? $secure_boot : undef ],
+ [ $text{'index_tpm'}, $tpm ],
+ [ $text{'index_memory'}, defined($summary->{'memory'}) ?
+ nice_size($summary->{'memory'}) : undef, 1 ],
+ [ $text{'index_processors_label'}, $processors ],
+ [ $text{'index_uuid'}, $summary->{'system_uuid'} ],
+ [ $text{'index_chassis'}, $chassis ],
+ [ $text{'index_chassis_serial'}, $summary->{'chassis_serial'} ],
+ );
+foreach my $row (@rows) {
+ my ($label, $value, $formatted) = @$row;
+ next if (!defined($value) || $value eq "");
+ print ui_table_row($label, $formatted ? $value : hardware_html($value));
+ }
+print ui_table_end();
+}
+
+# print_device_table(type, devices)
+# Dispatches to the compact table appropriate for an inventory group.
+sub print_device_table
+{
+my ($type, $list) = @_;
+if (!@$list) {
+ print ui_alert($text{"index_empty_$type"}, 'info');
+ return;
+ }
+if ($type eq 'pci') {
+ print_pci_table($list);
+ }
+elsif ($type eq 'usb') {
+ print_usb_table($list);
+ }
+elsif ($type eq 'storage') {
+ print_storage_table($list);
+ }
+elsif ($type eq 'network') {
+ print_network_table($list);
+ }
+elsif ($type eq 'cpu') {
+ print_cpu_table($list);
+ }
+else {
+ print_sensor_table($list);
+ }
+}
+
+# print_pci_table(devices)
+# Shows PCI address, class and kernel binding at a glance.
+sub print_pci_table
+{
+my ($list) = @_;
+print ui_columns_start([
+ $text{'index_device'}, $text{'index_class'}, $text{'index_location'},
+ $text{'index_driver'}, $text{'index_module'},
+ ]);
+foreach my $device (@$list) {
+ print ui_columns_row([
+ hardware_device_link('pci', $device),
+ hardware_html($device->{'class'}),
+ ui_tag('tt', hardware_html($device->{'id'})),
+ hardware_html($device->{'driver'}),
+ hardware_module_links($device->{'modules'}, $device->{'driver'}),
+ ]);
+ }
+print ui_columns_end();
+}
+
+# print_usb_table(devices)
+# Shows physical USB devices and aggregated interface driver bindings.
+sub print_usb_table
+{
+my ($list) = @_;
+print ui_columns_start([
+ $text{'index_device'}, $text{'index_location'}, $text{'index_speed'},
+ $text{'index_driver'}, $text{'index_module'},
+ ]);
+foreach my $device (@$list) {
+ my $location = defined($device->{'bus_number'}) &&
+ defined($device->{'device_number'}) ?
+ text('index_usb_location', $device->{'bus_number'},
+ $device->{'device_number'}) : $device->{'id'};
+ my $speed = $device->{'speed'} ?
+ text('format_mbps', $device->{'speed'}) : "";
+ print ui_columns_row([
+ hardware_device_link('usb', $device),
+ hardware_html($location), hardware_html($speed),
+ hardware_html($device->{'driver'}),
+ hardware_module_links($device->{'modules'}, $device->{'driver'}),
+ ]);
+ }
+print ui_columns_end();
+}
+
+# print_storage_table(devices)
+# Shows whole block devices, capacity, media kind and kernel binding.
+sub print_storage_table
+{
+my ($list) = @_;
+print ui_columns_start([
+ $text{'index_device'}, $text{'index_model'}, $text{'index_capacity'},
+ $text{'index_type'}, $text{'index_driver'}, $text{'index_module'},
+ ]);
+foreach my $device (@$list) {
+ print ui_columns_row([
+ hardware_device_link('storage', $device, $device->{'device'}),
+ hardware_html($device->{'model'}),
+ defined($device->{'size'}) ? nice_size($device->{'size'}) : "",
+ hardware_html($text{'storage_'.$device->{'kind'}}),
+ hardware_html($device->{'driver'}),
+ hardware_module_links($device->{'modules'}, $device->{'driver'}),
+ ]);
+ }
+print ui_columns_end();
+}
+
+# print_sensor_table(devices)
+# Shows only detected hwmon readings, keeping the optional tab compact.
+sub print_sensor_table
+{
+my ($list) = @_;
+print ui_columns_start([
+ $text{'index_sensor'}, $text{'index_chip'}, $text{'index_type'},
+ $text{'index_reading'}, $text{'index_driver'}, $text{'index_module'},
+ ]);
+foreach my $device (@$list) {
+ print ui_columns_row([
+ hardware_device_link('sensor', $device),
+ hardware_html($device->{'chip'}),
+ hardware_html($text{'sensor_'.$device->{'kind'}}),
+ hardware_html($device->{'reading'}),
+ hardware_html($device->{'driver'}),
+ hardware_module_links($device->{'modules'}, $device->{'driver'}),
+ ]);
+ }
+print ui_columns_end();
+}
+
+# print_network_table(devices)
+# Shows all network interfaces, including virtual interfaces, and link state.
+sub print_network_table
+{
+my ($list) = @_;
+print ui_columns_start([
+ $text{'index_interface'}, $text{'index_type'}, $text{'index_address'},
+ $text{'index_state'}, $text{'index_speed'}, $text{'index_driver'},
+ $text{'index_module'},
+ ]);
+foreach my $device (@$list) {
+ my $speed = $device->{'speed'} ?
+ text('format_mbps', $device->{'speed'}) : "";
+ print ui_columns_row([
+ hardware_device_link('network', $device),
+ hardware_html($text{'network_'.$device->{'kind'}}),
+ ui_tag('tt', hardware_html($device->{'address'})),
+ hardware_html($device->{'state'}), hardware_html($speed),
+ hardware_html($device->{'driver'}),
+ hardware_module_links($device->{'modules'}, $device->{'driver'}),
+ ]);
+ }
+print ui_columns_end();
+}
+
+# print_cpu_table(devices)
+# Shows logical processor topology and current operating state.
+sub print_cpu_table
+{
+my ($list) = @_;
+print ui_columns_start([
+ $text{'index_processor'}, $text{'index_model'}, $text{'index_socket'},
+ $text{'index_core'}, $text{'index_state'}, $text{'index_frequency'},
+ ]);
+foreach my $device (@$list) {
+ my $frequency = $device->{'frequency'} ?
+ text('format_mhz', hardware_number($device->{'frequency'})) : "";
+ print ui_columns_row([
+ hardware_device_link('cpu', $device),
+ hardware_html($device->{'model'}),
+ hardware_html($device->{'socket'}), hardware_html($device->{'core'}),
+ $device->{'online'} ? $text{'online'} : $text{'offline'},
+ hardware_html($frequency),
+ ]);
+ }
+print ui_columns_end();
+}
+
+# hardware_device_link(type, device)
+# Returns an escaped link to the exact inventory item.
+sub hardware_device_link
+{
+my ($type, $device, $label) = @_;
+my $url = "view.cgi?type=".urlize($type)."&id=".urlize($device->{'id'});
+$label = $device->{'name'} if (!defined($label));
+return ui_link($url, hardware_html($label));
+}
+
+# hardware_module_links(modules, [driver])
+# Returns links to details for loaded modules associated with a device.
+sub hardware_module_links
+{
+my ($modules, $driver) = @_;
+return $text{'module_builtin'} if ((!$modules || !@$modules) && $driver);
+return "" if (!$modules || !@$modules);
+return join(", ", map {
+ ui_link("module.cgi?name=".urlize($_), ui_tag('tt', hardware_html($_)))
+ } @$modules);
+}
+
+# hardware_html(value)
+# Escapes a possibly missing hardware value for safe table output.
+sub hardware_html
+{
+my ($value) = @_;
+return "" if (!defined($value));
+return html_escape($value);
+}
diff --git a/hardware-info/install_check.pl b/hardware-info/install_check.pl
new file mode 100644
index 000000000..5a7e50e92
--- /dev/null
+++ b/hardware-info/install_check.pl
@@ -0,0 +1,19 @@
+#!/usr/local/bin/perl
+# Decide whether the Linux sysfs hardware inventory is available.
+
+use strict;
+use warnings;
+use lib "..";
+
+use WebminCore;
+
+# is_installed(mode)
+# Returns Webmin's install-check code when the sysfs device tree is present.
+sub is_installed
+{
+return 0 if (!-d "/sys" || !-d "/sys/devices");
+return $_[0] ? 2 : 1;
+}
+
+1;
+
diff --git a/hardware-info/lang/en b/hardware-info/lang/en
new file mode 100644
index 000000000..6748da0ea
--- /dev/null
+++ b/hardware-info/lang/en
@@ -0,0 +1,288 @@
+index_title=Hardware Information
+index_system_desc=Review host, operating system, kernel, platform, boot firmware, security hardware, usable memory, processor and chassis information.
+index_pci_desc=Review PCI devices, their bus addresses and classes, and the kernel drivers and modules associated with them.
+index_usb_desc=Review USB devices, their bus locations and speeds, and the kernel drivers and modules associated with them.
+index_storage_desc=Review storage devices, their capacity and media type, and the kernel drivers and modules associated with them.
+index_network_desc=Review physical and virtual network interfaces, their link state and speed, and the kernel drivers and modules associated with them.
+index_cpu_desc=Review logical processors, including their model, topology, online state and current frequency.
+index_sensor_desc=Review temperature, fan, voltage, current and power readings exposed by Linux hardware-monitor drivers.
+index_hostname=Hostname
+index_os=Operating system
+index_kernel=Kernel
+index_architecture=Architecture
+index_system=System
+index_system_serial=System serial number
+index_board=Mainboard
+index_board_serial=Mainboard serial number
+index_bios=BIOS or firmware
+index_firmware_mode=Firmware interface
+index_secure_boot=Secure Boot
+index_tpm=Trusted Platform Module
+index_tpm_detected=Detected ($1)
+index_tpm_missing=Not detected
+index_memory=Usable memory
+index_processors_label=Processors
+index_processors=$1 in $2 — $3
+index_processor_count_one=1 logical processor
+index_processor_count_many=$1 logical processors
+index_package_count_one=1 physical package
+index_package_count_many=$1 physical packages
+index_package_count_unknown=unknown physical package topology
+index_uuid=System UUID
+index_chassis=Chassis
+index_chassis_serial=Chassis serial number
+index_device=Device
+index_interface=Interface
+index_processor=Processor
+index_class=Class
+index_location=Location
+index_driver=Driver
+index_module=Kernel module
+index_speed=Speed
+index_model=Model
+index_capacity=Capacity
+index_type=Type
+index_address=Address
+index_state=State
+index_socket=Package ID
+index_core=Core
+index_frequency=Frequency
+index_sensor=Sensor
+index_chip=Monitoring device
+index_reading=Reading
+index_usb_location=Bus $1, device $2
+index_empty_pci=No PCI devices were reported by the Linux kernel.
+index_empty_usb=No USB devices were reported by the Linux kernel.
+index_empty_storage=No whole block devices were reported by the Linux kernel.
+index_empty_network=No network interfaces were reported by the Linux kernel.
+index_empty_cpu=No processors were reported by the Linux kernel.
+index_empty_sensor=No hardware-monitor readings were reported by the Linux kernel.
+
+type_system=System Report
+type_pci=PCI Devices
+type_usb=USB Devices
+type_storage=Storage Devices
+type_network=Network Interfaces
+type_cpu=Processors
+type_sensor=Sensors
+
+firmware_uefi=UEFI
+firmware_bios=Legacy BIOS
+firmware_other=Non-UEFI
+secure_boot_enabled=Enabled
+secure_boot_disabled=Disabled
+
+storage_virtual=Virtual device
+storage_optical=Optical drive
+storage_removable=Removable storage
+storage_ssd=Solid-state drive
+storage_disk=Hard disk
+
+network_wireless=Wireless
+network_loopback=Loopback
+network_virtual=Virtual
+network_ethernet=Ethernet
+network_other=Other
+
+view_title=Hardware device $1
+view_error=Failed to display hardware device
+view_etype=Invalid hardware device type
+view_enodevice=The requested hardware device no longer exists or is not available.
+view_details=Device details
+view_driver=Kernel driver and properties
+view_interface=USB interface
+view_interface_name=$1 — $2
+view_interface_class=Class: $1
+view_interface_driver=Driver: $1
+view_interface_module=Kernel module: $1
+view_return=hardware information
+
+detail_address=Address
+detail_class=Class
+detail_class_id=Class ID
+detail_vendor=Vendor
+detail_device=Device
+detail_subsystem_vendor=Subsystem vendor
+detail_subsystem_device=Subsystem device
+detail_revision=Revision
+detail_irq=IRQ
+detail_numa_node=NUMA node
+detail_iommu_group=IOMMU group
+detail_local_cpus=Local processors
+detail_enabled=Enabled
+detail_location=Kernel location
+detail_bus_number=Bus number
+detail_device_number=Device number
+detail_serial=Serial number
+detail_usb_version=USB version
+detail_speed=Speed
+detail_protocol=Protocol
+detail_removable=Removable
+detail_authorized=Authorized
+detail_device_name=Device file
+detail_type=Type
+detail_capacity=Capacity
+detail_model=Model
+detail_firmware_revision=Firmware revision
+detail_wwid=WWID
+detail_transport=Transport
+detail_bus_device=Parent bus device
+detail_state=State
+detail_logical_block_size=Logical block size
+detail_physical_block_size=Physical block size
+detail_rotational=Rotational media
+detail_read_only=Read-only
+detail_discard=Discard/TRIM support
+detail_scheduler=I/O scheduler
+detail_interface=Interface
+detail_carrier=Carrier detected
+detail_duplex=Duplex
+detail_mtu=MTU
+detail_rx_bytes=Received data
+detail_tx_bytes=Transmitted data
+detail_rx_packets=Received packets
+detail_tx_packets=Transmitted packets
+detail_rx_errors=Receive errors
+detail_tx_errors=Transmit errors
+detail_rx_dropped=Dropped receive packets
+detail_tx_dropped=Dropped transmit packets
+detail_processor=Logical processor
+detail_socket=Package ID
+detail_core=Physical core
+detail_online=Online
+detail_frequency=Current frequency
+detail_frequency_driver=Frequency scaling driver
+detail_cpu_family=CPU family
+detail_model_id=Model ID
+detail_stepping=Stepping
+detail_microcode=Microcode
+detail_cache=Cache
+detail_siblings=Threads in package
+detail_cpu_cores=Cores in package
+detail_bogomips=BogoMIPS
+detail_flags=Features and flags
+detail_sensor_chip=Monitoring device
+detail_sensor_type=Reading type
+detail_sensor_reading=Current reading
+detail_driver=Driver
+detail_module=Loadable kernel module
+detail_modalias=Module alias
+
+module_title=Kernel module $1
+module_error=Failed to display kernel module
+module_enotfound=The requested kernel module is not loaded or is not available.
+module_details=Loaded module details
+module_state=State
+module_refcount=Reference count
+module_version=Version
+module_srcversion=Source version
+module_taint=Taint flags
+module_coresize=Core size
+module_initsize=Initialization size
+module_holders=Used by modules
+module_additional=Module metadata and parameters
+module_parameter=Parameter $1
+module_builtin=Built into kernel
+
+format_mbps=$1 Mb/s
+format_mhz=$1 MHz
+format_celsius=$1 °C
+format_rpm=$1 RPM
+format_volts=$1 V
+format_amps=$1 A
+format_watts=$1 W
+sensor_numbered=$1 $2
+sensor_temp=Temperature
+sensor_fan=Fan
+sensor_in=Voltage
+sensor_curr=Current
+sensor_power=Power
+online=Online
+offline=Offline
+unknown=Unknown
+
+chassis_type_3=Desktop
+chassis_type_4=Low-profile desktop
+chassis_type_5=Pizza box
+chassis_type_6=Mini tower
+chassis_type_7=Tower
+chassis_type_8=Portable
+chassis_type_9=Laptop
+chassis_type_10=Notebook
+chassis_type_11=Hand-held
+chassis_type_12=Docking station
+chassis_type_13=All-in-one
+chassis_type_14=Sub-notebook
+chassis_type_15=Space-saving
+chassis_type_16=Lunch box
+chassis_type_17=Main server chassis
+chassis_type_18=Expansion chassis
+chassis_type_19=Sub-chassis
+chassis_type_20=Bus expansion chassis
+chassis_type_21=Peripheral chassis
+chassis_type_22=RAID chassis
+chassis_type_23=Rack-mount chassis
+chassis_type_24=Sealed-case PC
+chassis_type_25=Multi-system chassis
+chassis_type_26=CompactPCI
+chassis_type_27=AdvancedTCA
+chassis_type_28=Blade
+chassis_type_29=Blade enclosure
+chassis_type_30=Tablet
+chassis_type_31=Convertible
+chassis_type_32=Detachable
+chassis_type_33=IoT gateway
+chassis_type_34=Embedded PC
+chassis_type_35=Mini PC
+chassis_type_36=Stick PC
+chassis_type_other=Type $1
+
+pci_class_00=Unclassified device
+pci_class_01=Mass storage controller
+pci_class_02=Network controller
+pci_class_03=Display controller
+pci_class_04=Multimedia controller
+pci_class_05=Memory controller
+pci_class_06=Bridge
+pci_class_07=Communication controller
+pci_class_08=System peripheral
+pci_class_09=Input device controller
+pci_class_0a=Docking station
+pci_class_0b=Processor
+pci_class_0c=Serial bus controller
+pci_class_0d=Wireless controller
+pci_class_0e=Intelligent controller
+pci_class_0f=Satellite communication controller
+pci_class_10=Encryption controller
+pci_class_11=Signal processing controller
+pci_class_12=Processing accelerator
+pci_class_13=Instrumentation device
+pci_class_40=Coprocessor
+pci_class_ff=Unassigned device
+pci_class_other=Other PCI device
+
+usb_class_00=Defined by interface
+usb_class_01=Audio
+usb_class_02=Communications
+usb_class_03=Human interface device
+usb_class_05=Physical
+usb_class_06=Imaging
+usb_class_07=Printer
+usb_class_08=Mass storage
+usb_class_09=Hub
+usb_class_0a=CDC data
+usb_class_0b=Smart card
+usb_class_0d=Content security
+usb_class_0e=Video
+usb_class_0f=Personal healthcare
+usb_class_10=Audio/video
+usb_class_11=Billboard
+usb_class_12=USB Type-C bridge
+usb_class_dc=Diagnostic
+usb_class_e0=Wireless controller
+usb_class_ef=Miscellaneous
+usb_class_fe=Application-specific
+usb_class_ff=Vendor-specific
+usb_class_other=Other USB device
+
+__norefs=1
diff --git a/hardware-info/module.cgi b/hardware-info/module.cgi
new file mode 100755
index 000000000..4e5ae2df5
--- /dev/null
+++ b/hardware-info/module.cgi
@@ -0,0 +1,57 @@
+#!/usr/local/bin/perl
+# Display details about one loaded kernel module associated with a device.
+
+use strict;
+use warnings;
+
+require './hardware-info-lib.pl'; ## no critic
+
+our (%in, %text);
+
+ReadParse();
+error_setup($text{'module_error'});
+
+my $name = defined($in{'name'}) ? $in{'name'} : "";
+my $module = hardware_kernel_module($name);
+error($text{'module_enotfound'}) if (!$module);
+
+ui_print_header(undef, text('module_title', html_escape($module->{'name'})),
+ "", undef, 0, 1);
+
+print ui_table_start($text{'module_details'}, "width=100%", 2,
+ [ "width=30%", undef ]);
+my @fields = qw(state refcount version srcversion taint);
+foreach my $field (@fields) {
+ next if (!defined($module->{$field}) || $module->{$field} eq "");
+ print ui_table_row($text{"module_$field"},
+ html_escape($module->{$field}));
+ }
+print ui_table_row($text{'module_coresize'}, nice_size($module->{'coresize'}))
+ if (defined($module->{'coresize'}) && $module->{'coresize'} =~ /^\d+$/);
+print ui_table_row($text{'module_initsize'}, nice_size($module->{'initsize'}))
+ if (defined($module->{'initsize'}) && $module->{'initsize'} =~ /^\d+$/);
+print ui_table_row($text{'module_holders'},
+ join(", ", map { ui_tag('tt', html_escape($_)) }
+ @{$module->{'holders'}})) if (@{$module->{'holders'}});
+print ui_table_end();
+
+# Keep all supplementary metadata and parameters in one expanded panel.
+my $has_modinfo = $module->{'modinfo'} && keys(%{$module->{'modinfo'}});
+my $has_parameters = keys(%{$module->{'parameters'}});
+if ($has_modinfo || $has_parameters) {
+ print ui_hidden_table_start($text{'module_additional'}, "width=100%", 2,
+ "module_additional", 1, [ "width=30%", undef ]);
+ foreach my $key (sort keys(%{$module->{'modinfo'} || { }})) {
+ print ui_table_row(ui_tag('tt', html_escape($key)),
+ html_escape($module->{'modinfo'}->{$key}));
+ }
+ foreach my $key (sort keys(%{$module->{'parameters'}})) {
+ my $label = text('module_parameter',
+ ui_tag('tt', html_escape($key)));
+ print ui_table_row($label,
+ ui_tag('tt', html_escape($module->{'parameters'}->{$key})));
+ }
+ print ui_hidden_table_end();
+ }
+
+ui_print_footer("index.cgi", $text{'view_return'});
diff --git a/hardware-info/module.info b/hardware-info/module.info
new file mode 100644
index 000000000..91960fdd9
--- /dev/null
+++ b/hardware-info/module.info
@@ -0,0 +1,6 @@
+name=Hardware Information
+category=hardware
+os_support=*-linux
+desc=Hardware Information
+longdesc=View system hardware, firmware and security details, and inspect PCI, USB, storage, network and processor devices, hardware-monitor readings and their kernel drivers.
+readonly=1
diff --git a/hardware-info/t/perlcritic.t b/hardware-info/t/perlcritic.t
new file mode 100644
index 000000000..48d8e8b30
--- /dev/null
+++ b/hardware-info/t/perlcritic.t
@@ -0,0 +1,60 @@
+#!/usr/bin/perl
+use strict;
+use warnings;
+use Test::More;
+
+BEGIN {
+eval { require Perl::Critic; 1 }
+ or plan skip_all => 'Perl::Critic not installed';
+}
+
+use File::Find;
+
+# script_dir()
+# Returns the directory containing this test file.
+sub script_dir
+{
+my $path = $0;
+if ($path =~ m{^/}) {
+ $path =~ s{/[^/]+$}{};
+ return $path;
+ }
+my $cwd = `pwd`;
+chomp($cwd);
+if ($path =~ m{/}) {
+ $path =~ s{/[^/]+$}{};
+ return $cwd.'/'.$path;
+ }
+return $cwd;
+}
+
+my $bindir = script_dir();
+my $module_dir = "$bindir/..";
+my $profile = "$bindir/../../.perlcriticrc";
+if (!-r $profile) {
+plan skip_all => 'Perl::Critic profile not installed';
+}
+chdir($module_dir) or die "chdir: $!";
+
+my @files;
+find(
+ sub {
+ return if -d;
+ return if -l;
+ return unless /\.(pl|cgi)\z/;
+ return if /\.info\.pl\z/;
+ push(@files, $File::Find::name);
+ },
+ '.'
+);
+@files = sort @files;
+plan skip_all => 'no Perl files to check' if (!@files);
+
+my $critic = Perl::Critic->new(-profile => $profile);
+foreach my $file (@files) {
+ my @violations = $critic->critique($file);
+ is(scalar(@violations), 0, "$file perlcritic");
+ diag join("", @violations) if (@violations);
+ }
+
+done_testing();
diff --git a/hardware-info/t/run-tests.t b/hardware-info/t/run-tests.t
new file mode 100644
index 000000000..a4aaab7b7
--- /dev/null
+++ b/hardware-info/t/run-tests.t
@@ -0,0 +1,421 @@
+#!/usr/bin/perl
+use strict;
+use warnings;
+no warnings 'redefine';
+no warnings 'once';
+use Test::More;
+use Cwd qw(abs_path);
+use File::Path qw(make_path);
+use File::Temp qw(tempdir);
+
+# script_dir()
+# Returns the directory containing this test file.
+sub script_dir
+{
+my $path = $0;
+if ($path =~ m{^/}) {
+ $path =~ s{/[^/]+$}{};
+ return $path;
+ }
+my $cwd = `pwd`;
+chomp($cwd);
+if ($path =~ m{/}) {
+ $path =~ s{/[^/]+$}{};
+ return $cwd.'/'.$path;
+ }
+return $cwd;
+}
+
+# write_test_file(file, data)
+# Creates a fixture file and any missing parent directories.
+sub write_test_file
+{
+my ($file, $data) = @_;
+my $directory = $file;
+$directory =~ s{/[^/]+$}{};
+make_path($directory) if (!-d $directory);
+open(my $fh, '>', $file) or die "$file: $!";
+print $fh $data;
+close($fh);
+}
+
+# slurp_test_file(file)
+# Reads a source file for structural UI assertions.
+sub slurp_test_file
+{
+my ($file) = @_;
+open(my $fh, '<', $file) or die "$file: $!";
+local $/;
+my $data = <$fh>;
+close($fh);
+return $data;
+}
+
+my $bindir = script_dir();
+my $rootdir = abs_path("$bindir/../..") or die "rootdir: $!";
+my $confdir = tempdir(CLEANUP => 1);
+my $vardir = tempdir(CLEANUP => 1);
+write_test_file("$confdir/config",
+ "os_type=generic-linux\nos_version=0\n".
+ "real_os_type=Test Linux\nreal_os_version=1\n");
+write_test_file("$confdir/var-path", "$vardir\n");
+$ENV{'WEBMIN_CONFIG'} = $confdir;
+$ENV{'WEBMIN_VAR'} = $vardir;
+$ENV{'FOREIGN_MODULE_NAME'} = 'hardware-info';
+$ENV{'FOREIGN_ROOT_DIRECTORY'} = $rootdir;
+
+chdir("$bindir/..") or die "chdir: $!";
+require './hardware-info-lib.pl'; ## no critic
+
+our ($hardware_sys_root, $hardware_proc_root, $hardware_pci_ids_file,
+ $hardware_usb_ids_file, $hardware_modinfo_command);
+our %hardware_ids_cache;
+
+my $fixture = tempdir(CLEANUP => 1);
+$hardware_sys_root = "$fixture/sys";
+$hardware_proc_root = "$fixture/proc";
+$hardware_pci_ids_file = "$fixture/pci.ids";
+$hardware_usb_ids_file = "$fixture/usb.ids";
+$hardware_modinfo_command = '';
+%hardware_ids_cache = ( );
+
+# Create friendly-name databases used by the PCI and USB inventory parsers.
+write_test_file($hardware_pci_ids_file,
+ "8086 Intel Corporation\n".
+ "\t15f3 Ethernet Controller\n".
+ "\t\t1a2b 3c4d Server Ethernet Adapter\n".
+ "1a2b Example Systems\n".
+ "\t3c4d Server Ethernet Adapter\n");
+write_test_file($hardware_usb_ids_file,
+ "046d Example Peripherals\n".
+ "\tc534 USB Receiver\n");
+
+# Build a PCI function with an e1000e driver/module binding.
+my $pci = "$hardware_sys_root/bus/pci/devices/0000:00:1f.6";
+my $pci_driver = "$hardware_sys_root/bus/pci/drivers/e1000e";
+my $module = "$hardware_sys_root/module/e1000e";
+my $iommu_group = "$hardware_sys_root/kernel/iommu_groups/12";
+make_path($pci, $pci_driver, $module, $iommu_group);
+write_test_file("$pci/vendor", "0x8086\n");
+write_test_file("$pci/device", "0x15f3\n");
+write_test_file("$pci/subsystem_vendor", "0x1a2b\n");
+write_test_file("$pci/subsystem_device", "0x3c4d\n");
+write_test_file("$pci/class", "0x020000\n");
+write_test_file("$pci/revision", "0x03\n");
+write_test_file("$pci/irq", "16\n");
+write_test_file("$pci/uevent",
+ "DRIVER=e1000e\nPCI_SLOT_NAME=0000:00:1f.6\n".
+ "MODALIAS=pci:test\n");
+symlink($pci_driver, "$pci/driver") or die "pci driver symlink: $!";
+symlink($module, "$pci_driver/module") or die "pci module symlink: $!";
+symlink($iommu_group, "$pci/iommu_group") or die "iommu symlink: $!";
+
+# Build a USB parent whose HID driver is bound to its interface.
+my $usb = "$hardware_sys_root/bus/usb/devices/1-2";
+my $usb_interface = "$hardware_sys_root/bus/usb/devices/1-2:1.0";
+my $usb_driver = "$hardware_sys_root/bus/usb/drivers/usbhid";
+my $usb_module = "$hardware_sys_root/module/usbhid";
+make_path($usb, $usb_interface, $usb_driver, $usb_module);
+write_test_file("$usb/idVendor", "046d\n");
+write_test_file("$usb/idProduct", "c534\n");
+write_test_file("$usb/manufacturer", "Example Peripherals\n");
+write_test_file("$usb/product", "USB Receiver\n");
+write_test_file("$usb/busnum", "1\n");
+write_test_file("$usb/devnum", "4\n");
+write_test_file("$usb/speed", "480\n");
+write_test_file("$usb/bDeviceClass", "00\n");
+write_test_file("$usb/uevent", "PRODUCT=46d/c534/2900\n");
+write_test_file("$usb_interface/bInterfaceClass", "03\n");
+write_test_file("$usb_interface/interface", "Keyboard\n");
+symlink($usb_driver, "$usb_interface/driver") or die "usb driver symlink: $!";
+symlink($usb_module, "$usb_driver/module") or die "usb module symlink: $!";
+
+# Build one solid-state block device backed by the sd_mod module.
+my $disk = "$hardware_sys_root/class/block/sda";
+my $disk_driver = "$hardware_sys_root/bus/scsi/drivers/sd";
+my $disk_module = "$hardware_sys_root/module/sd_mod";
+make_path("$disk/device", "$disk/queue", $disk_driver, $disk_module);
+write_test_file("$disk/size", "2097152\n");
+write_test_file("$disk/removable", "0\n");
+write_test_file("$disk/ro", "0\n");
+write_test_file("$disk/device/vendor", "ATA\n");
+write_test_file("$disk/device/model", "Fixture SSD\n");
+write_test_file("$disk/device/serial", "DISK123\n");
+write_test_file("$disk/device/firmware_rev", "FW1.2\n");
+write_test_file("$disk/wwid", "naa.5000123456789abc\n");
+write_test_file("$disk/queue/rotational", "0\n");
+write_test_file("$disk/queue/logical_block_size", "512\n");
+write_test_file("$disk/queue/physical_block_size", "4096\n");
+write_test_file("$disk/queue/discard_max_bytes", "1048576\n");
+write_test_file("$disk/uevent", "DEVNAME=sda\nDEVTYPE=disk\n");
+symlink($disk_driver, "$disk/device/driver") or die "disk driver symlink: $!";
+symlink($disk_module, "$disk_driver/module") or die "disk module symlink: $!";
+symlink("$hardware_sys_root/bus/scsi", "$disk/device/subsystem") or
+ die "disk subsystem symlink: $!";
+
+# Build a physical Ethernet interface and its current counters.
+my $net = "$hardware_sys_root/class/net/eth0";
+my $net_driver = "$hardware_sys_root/bus/pci/drivers/igb";
+my $net_module = "$hardware_sys_root/module/igb";
+make_path("$net/device", "$net/statistics", $net_driver, $net_module);
+write_test_file("$net/type", "1\n");
+write_test_file("$net/address", "00:11:22:33:44:55\n");
+write_test_file("$net/operstate", "up\n");
+write_test_file("$net/speed", "1000\n");
+write_test_file("$net/mtu", "1500\n");
+write_test_file("$net/statistics/rx_bytes", "4096\n");
+write_test_file("$net/statistics/tx_bytes", "2048\n");
+write_test_file("$net/uevent", "INTERFACE=eth0\n");
+symlink($net_driver, "$net/device/driver") or die "net driver symlink: $!";
+symlink($net_module, "$net_driver/module") or die "net module symlink: $!";
+
+# Build standard hwmon temperature and fan readings.
+my $hwmon = "$hardware_sys_root/class/hwmon/hwmon0";
+make_path($hwmon);
+write_test_file("$hwmon/name", "fixture_hwmon\n");
+write_test_file("$hwmon/temp1_label", "CPU package\n");
+write_test_file("$hwmon/temp1_input", "42500\n");
+write_test_file("$hwmon/fan1_label", "System fan\n");
+write_test_file("$hwmon/fan1_input", "1800\n");
+
+# Build two logical processors and system-level memory/DMI fields.
+write_test_file("$hardware_proc_root/cpuinfo", <<'EOF');
+processor : 0
+vendor_id : GenuineFixture
+model name : Fixture CPU 3.00GHz
+physical id : 0
+core id : 0
+cpu MHz : 3000.000
+flags : fpu sse
+
+processor : 1
+vendor_id : GenuineFixture
+model name : Fixture CPU 3.00GHz
+physical id : 0
+core id : 1
+cpu MHz : 2800.000
+flags : fpu sse
+EOF
+write_test_file("$hardware_proc_root/meminfo", "MemTotal: 8388608 kB\n");
+write_test_file("$hardware_sys_root/class/dmi/id/sys_vendor", "Fixture Inc.\n");
+write_test_file("$hardware_sys_root/class/dmi/id/product_name", "Test Server\n");
+write_test_file("$hardware_sys_root/class/dmi/id/product_serial", "SYS123\n");
+write_test_file("$hardware_sys_root/class/dmi/id/chassis_type", "23\n");
+my $secure_boot_var = "$hardware_sys_root/firmware/efi/efivars/".
+ "SecureBoot-8be4df61-93ca-11d2-aa0d-00e098032b8c";
+write_test_file($secure_boot_var, pack("V C", 7, 1));
+make_path("$hardware_sys_root/class/tpm/tpm0");
+
+# Populate metadata and parameters for the linked e1000e module page.
+write_test_file("$module/initstate", "live\n");
+write_test_file("$module/refcnt", "1\n");
+write_test_file("$module/version", "1.2.3\n");
+write_test_file("$module/coresize", "65536\n");
+write_test_file("$module/parameters/InterruptThrottleRate", "3\n");
+make_path("$module/holders");
+
+my @pci_devices = list_pci_devices();
+is(scalar(@pci_devices), 1, 'one PCI function is enumerated');
+is($pci_devices[0]->{'name'}, 'Intel Corporation Ethernet Controller',
+ 'PCI database names are applied');
+is($pci_devices[0]->{'class'}, 'Network controller',
+ 'PCI base class is translated');
+is($pci_devices[0]->{'driver'}, 'e1000e', 'PCI driver is resolved');
+is($pci_devices[0]->{'module'}, 'e1000e', 'PCI kernel module is resolved');
+my %pci_details = map { $_->[0], $_->[1] } @{$pci_devices[0]->{'details'}};
+is($pci_details{'iommu_group'}, '12', 'PCI IOMMU group is retained');
+
+my @usb_devices = list_usb_devices();
+is(scalar(@usb_devices), 1, 'USB interfaces are not duplicated as devices');
+is($usb_devices[0]->{'driver'}, 'usbhid',
+ 'USB interface driver is aggregated into parent');
+is_deeply($usb_devices[0]->{'modules'}, [ 'usbhid' ],
+ 'USB interface module is aggregated into parent');
+is($usb_devices[0]->{'interfaces'}->[0]->{'class'},
+ 'Human interface device', 'USB interface class is translated');
+
+my @storage = list_storage_devices();
+is(scalar(@storage), 1, 'one whole block device is enumerated');
+is($storage[0]->{'size'}, 1024 * 1024 * 1024,
+ 'block sectors are converted to bytes');
+is($storage[0]->{'kind'}, 'ssd', 'non-rotational disk is an SSD');
+is($storage[0]->{'module'}, 'sd_mod', 'storage module is resolved');
+is($storage[0]->{'device'}, '/dev/sda', 'storage device file is retained');
+is($storage[0]->{'model'}, 'ATA Fixture SSD',
+ 'storage model remains separate from its device file');
+my %storage_details = map { $_->[0], $_->[1] }
+ @{$storage[0]->{'details'}};
+is($storage_details{'firmware_revision'}, 'FW1.2',
+ 'storage firmware revision is retained');
+is($storage_details{'wwid'}, 'naa.5000123456789abc',
+ 'storage WWID is retained');
+is($storage_details{'transport'}, 'scsi',
+ 'storage transport is resolved');
+is($storage_details{'discard'}, 1, 'storage discard support is detected');
+
+my @network = list_network_devices();
+is(scalar(@network), 1, 'one network interface is enumerated');
+is($network[0]->{'kind'}, 'ethernet', 'physical ARPHRD interface is Ethernet');
+is($network[0]->{'speed'}, '1000', 'network link speed is read');
+is($network[0]->{'module'}, 'igb', 'network module is resolved');
+
+my @sensors = list_sensor_devices();
+is(scalar(@sensors), 2, 'standard hwmon readings are enumerated');
+is($sensors[0]->{'reading'}, '1800 RPM', 'fan reading keeps its base unit');
+is($sensors[1]->{'reading'}, '42.5 °C',
+ 'temperature reading is converted from millidegrees');
+is(hardware_sensor_reading('in', '11895'), '11.9 V',
+ 'voltage reading is converted from millivolts');
+is(hardware_sensor_reading('curr', '1500'), '1.5 A',
+ 'current reading is converted from milliamps');
+is(hardware_sensor_reading('power', '9706000'), '9.71 W',
+ 'power reading is converted from microwatts');
+ok(!defined(hardware_sensor_reading('temp', 'unavailable')),
+ 'invalid hwmon readings are omitted');
+
+my @cpus = list_cpu_devices();
+is(scalar(@cpus), 2, 'logical processors are enumerated');
+is($cpus[1]->{'core'}, '1', 'processor topology is retained');
+is($cpus[0]->{'model'}, 'Fixture CPU 3.00GHz', 'processor model is retained');
+
+my $summary = hardware_system_summary(\@cpus);
+is($summary->{'system_vendor'}, 'Fixture Inc.', 'DMI vendor is summarized');
+is($summary->{'system_product'}, 'Test Server', 'DMI product is summarized');
+is($summary->{'memory'}, 8 * 1024 * 1024 * 1024,
+ 'usable memory is converted to bytes');
+is($summary->{'cpu_count'}, 2, 'logical processor count is summarized');
+is($summary->{'cpu_sockets'}, 1, 'physical package count is summarized');
+is($summary->{'firmware_mode'}, 'uefi', 'UEFI firmware is detected');
+is($summary->{'secure_boot'}, 1, 'UEFI Secure Boot state is read');
+is_deeply($summary->{'tpms'}, [ 'tpm0' ], 'TPM presence is reported');
+is(hardware_chassis_type_name($summary->{'chassis_type'}),
+ 'Rack-mount chassis', 'SMBIOS chassis type is translated');
+ok(!defined(hardware_chassis_type_name('2')),
+ 'unknown SMBIOS chassis placeholder is omitted');
+
+# ARM cpuinfo commonly exposes IDs without a friendly model on every record.
+{
+ local $hardware_proc_root = "$fixture/arm-proc";
+ local $hardware_sys_root = "$fixture/arm-sys";
+ my $arm_cpuinfo = "";
+ foreach my $id (0 .. 3) {
+ $arm_cpuinfo .= "processor : $id\n".
+ "BogoMIPS : 60.00\n".
+ "CPU implementer : 0x41\n".
+ "CPU architecture : 8\n".
+ "CPU part : 0xd0c\n\n";
+ write_test_file("$hardware_sys_root/devices/system/cpu/cpu$id/".
+ "topology/physical_package_id", "60\n");
+ write_test_file("$hardware_sys_root/devices/system/cpu/cpu$id/".
+ "topology/core_id", "$id\n");
+ }
+ write_test_file("$hardware_proc_root/cpuinfo", $arm_cpuinfo);
+ my @arm_cpus = list_cpu_devices();
+ is(scalar(@arm_cpus), 4, 'ARM logical processors are enumerated');
+ is($arm_cpus[0]->{'model'},
+ 'ARMv8 processor (implementer 0x41, part 0xd0c)',
+ 'ARM CPU identity does not fall back to its numeric processor ID');
+ is($arm_cpus[3]->{'model'}, $arm_cpus[0]->{'model'},
+ 'ARM processor model is consistent across logical CPUs');
+ is($arm_cpus[0]->{'socket'}, '60',
+ 'kernel package identifiers are retained without implying a socket number');
+ my $arm_summary = hardware_system_summary(\@arm_cpus);
+ is_deeply($arm_summary->{'cpu_models'}, [ $arm_cpus[0]->{'model'} ],
+ 'ARM summary contains one accurate processor model');
+ is($arm_summary->{'cpu_sockets'}, 1,
+ 'ARM package count uses unique kernel package identifiers');
+}
+
+my $module_info = hardware_kernel_module('e1000e');
+is($module_info->{'state'}, 'live', 'loaded module state is read');
+is($module_info->{'coresize'}, '65536', 'loaded module size is read');
+is($module_info->{'parameters'}->{'InterruptThrottleRate'}, '3',
+ 'loaded module parameters are read');
+
+# Read-only Webmin users may run modinfo because it only reports metadata.
+my ($modinfo_safe, $safe_module_info);
+{
+ no warnings 'redefine';
+ local *backquote_command = sub {
+ $modinfo_safe = $_[1];
+ $? = 0;
+ return "description: Fixture driver\n";
+ };
+ local $hardware_modinfo_command = '/usr/sbin/modinfo';
+ $safe_module_info = hardware_kernel_module('e1000e');
+}
+is($modinfo_safe, 1, 'modinfo lookup is allowed in Webmin read-only mode');
+is($safe_module_info->{'modinfo'}->{'description'}, 'Fixture driver',
+ 'modinfo metadata is retained for read-only users');
+
+ok(get_hardware_device('pci', '0000:00:1f.6'),
+ 'exact enumerated device IDs can be selected');
+ok(!get_hardware_device('pci', '../module/e1000e'),
+ 'device selection does not accept paths');
+ok(!get_hardware_device('unknown', '0000:00:1f.6'),
+ 'unknown device groups are rejected');
+ok(!hardware_kernel_module('../e1000e'),
+ 'kernel module selection does not accept paths');
+ok(!hardware_kernel_module('..'),
+ 'kernel module selection does not accept parent directory names');
+ok(!hardware_kernel_module('not_loaded'),
+ 'unloaded module names are rejected');
+
+# The index presents its summary as a peer tab and describes every tab.
+my $index_source = slurp_test_file("$bindir/../index.cgi");
+like($index_source,
+ qr/\[\s*'system'\s*,\s*\$text\{'type_system'\}\s*\]/,
+ 'system report is an index tab');
+like($index_source,
+ qr/\$text\{"type_\$_"\}\.\s*' '\.\s*ui_tag\('sup',/s,
+ 'inventory tab counts use superscript markup');
+unlike($index_source, qr/text\('index_tab'/,
+ 'inventory tab counts do not use parenthesized labels');
+like($index_source,
+ qr/foreach my \$tab \('system', \@types\).*?print ui_div\(\$text\{"index_\$\{tab\}_desc"\}\)/s,
+ 'every index tab renders its description');
+like($index_source,
+ qr/if \(\$tab eq 'system'\).*?print_system_summary\(\$summary\)/s,
+ 'system summary is rendered inside its tab');
+like($index_source,
+ qr/my \@types = grep \{ \$_ ne 'sensor' \|\| \@\{\$devices\{\$_\}\} \}/,
+ 'sensor tab is omitted when no readings are available');
+like($index_source,
+ qr/hardware_device_link\('storage', \$device, \$device->\{'device'\}\)/,
+ 'storage table identifies devices by their device files');
+like($index_source,
+ qr/ui_print_header\([^;]*"intro"\s*,\s*0\s*,\s*1\s*\);/s,
+ 'index keeps module help but disables the configuration action');
+
+# Detail pages keep one general table and one expandable supplementary panel,
+# without repeating the index-only help and configuration actions.
+my $view_source = slurp_test_file("$bindir/../view.cgi");
+my $module_source = slurp_test_file("$bindir/../module.cgi");
+my @view_tables = $view_source =~ /\bui_table_start\(/g;
+my @view_hidden = $view_source =~ /\bui_hidden_table_start\(/g;
+my @module_tables = $module_source =~ /\bui_table_start\(/g;
+my @module_hidden = $module_source =~ /\bui_hidden_table_start\(/g;
+is(scalar(@view_tables), 1, 'device page has one general information table');
+is(scalar(@view_hidden), 1, 'device page has one supplementary panel');
+like($view_source,
+ qr/ui_hidden_table_start\([^;]*"driver_properties"\s*,\s*1\s*,/s,
+ 'device supplementary panel is initially expanded');
+unlike($view_source, qr/\bui_columns_start\(/,
+ 'device page has no additional standalone table');
+is(scalar(@module_tables), 1, 'module page has one general information table');
+is(scalar(@module_hidden), 1, 'module page has one supplementary panel');
+like($module_source,
+ qr/ui_hidden_table_start\([^;]*"module_additional"\s*,\s*1\s*,/s,
+ 'module supplementary panel is initially expanded');
+unlike($view_source, qr/ui_print_header\([^;]*"intro"/s,
+ 'device page does not show module help');
+unlike($module_source, qr/ui_print_header\([^;]*"intro"/s,
+ 'module page does not show module help');
+like($view_source,
+ qr/ui_print_header\([^;]*""\s*,\s*undef\s*,\s*0\s*,\s*1\s*\);/s,
+ 'device page disables module help and configuration actions');
+like($module_source,
+ qr/ui_print_header\([^;]*""\s*,\s*undef\s*,\s*0\s*,\s*1\s*\);/s,
+ 'module page disables module help and configuration actions');
+
+done_testing();
diff --git a/hardware-info/view.cgi b/hardware-info/view.cgi
new file mode 100755
index 000000000..100e9ddf1
--- /dev/null
+++ b/hardware-info/view.cgi
@@ -0,0 +1,112 @@
+#!/usr/local/bin/perl
+# Display detailed information for one enumerated hardware device.
+
+use strict;
+use warnings;
+
+require './hardware-info-lib.pl'; ## no critic
+
+our (%in, %text);
+
+ReadParse();
+error_setup($text{'view_error'});
+
+my %valid_types = map { $_, 1 } hardware_device_types();
+my $requested = defined($in{'type'}) ? $in{'type'} : "";
+my $type = $valid_types{$requested} ? $requested : "";
+error($text{'view_etype'}) if (!$type);
+my $id = defined($in{'id'}) ? $in{'id'} : "";
+my $device = get_hardware_device($type, $id);
+error($text{'view_enodevice'}) if (!$device);
+
+ui_print_header($text{"type_$type"},
+ text('view_title', html_escape($device->{'name'})), "", undef, 0, 1);
+
+print ui_table_start($text{'view_details'}, "width=100%", 2,
+ [ "width=30%", undef ]);
+foreach my $detail (@{$device->{'details'}}) {
+ my ($key, $value, $format) = @$detail;
+ next if (!defined($value) || $value eq "");
+ print ui_table_row($text{"detail_$key"} || html_escape($key),
+ format_hardware_value($value, $format));
+ }
+print ui_table_end();
+
+# Keep driver bindings and low-level uevent properties in one expanded panel.
+my $has_driver = $device->{'driver'} || @{$device->{'modules'}} ||
+ $device->{'modalias'};
+my $has_properties = keys(%{$device->{'properties'}});
+my $has_interfaces = $type eq 'usb' && @{$device->{'interfaces'}};
+if ($has_driver || $has_properties || $has_interfaces) {
+ print ui_hidden_table_start($text{'view_driver'}, "width=100%", 2,
+ "driver_properties", 1, [ "width=30%", undef ]);
+ print ui_table_row($text{'detail_driver'},
+ html_escape($device->{'driver'})) if ($device->{'driver'});
+ print ui_table_row($text{'detail_module'},
+ module_links($device->{'modules'}, $device->{'driver'}))
+ if (@{$device->{'modules'}} || $device->{'driver'});
+ print ui_table_row($text{'detail_modalias'},
+ ui_tag('tt', html_escape($device->{'modalias'})))
+ if ($device->{'modalias'});
+
+ # USB interface class and driver bindings belong with other kernel details.
+ foreach my $interface (@{$device->{'interfaces'} || [ ]}) {
+ my $label = $interface->{'name'} ?
+ text('view_interface_name', html_escape($interface->{'id'}),
+ html_escape($interface->{'name'})) :
+ html_escape($interface->{'id'});
+ my @details = (text('view_interface_class',
+ html_escape($interface->{'class'})));
+ push(@details, text('view_interface_driver',
+ html_escape($interface->{'driver'})))
+ if ($interface->{'driver'});
+ push(@details, text('view_interface_module',
+ module_links($interface->{'module'} ?
+ [ $interface->{'module'} ] : [ ],
+ $interface->{'driver'})))
+ if ($interface->{'module'} || $interface->{'driver'});
+ print ui_table_row($label, join(" ", @details));
+ }
+
+ # Raw properties already shown with friendly labels are omitted here.
+ foreach my $key (sort keys(%{$device->{'properties'}})) {
+ next if ($key eq 'DRIVER' && $device->{'driver'});
+ next if ($key eq 'MODALIAS' && $device->{'modalias'});
+ print ui_table_row(ui_tag('tt', html_escape($key)),
+ ui_tag('tt', html_escape($device->{'properties'}->{$key})));
+ }
+ print ui_hidden_table_end();
+ }
+
+ui_print_footer("index.cgi?type=".urlize($type), $text{'view_return'});
+
+# format_hardware_value(value, format)
+# Applies units and localized enums while escaping every raw sysfs value.
+sub format_hardware_value
+{
+my ($value, $format) = @_;
+return nice_size($value) if ($format && $format eq 'size' &&
+ $value =~ /^\d+(?:\.\d+)?$/);
+return $value ? $text{'yes'} : $text{'no'}
+ if ($format && $format eq 'yesno');
+return html_escape($text{"storage_$value"})
+ if ($format && $format eq 'storage_type');
+return html_escape($text{"network_$value"})
+ if ($format && $format eq 'network_type');
+return html_escape(text('format_mbps', $value))
+ if ($format && ($format eq 'network_speed' || $format eq 'usb_speed'));
+return html_escape(text('format_mhz', hardware_number($value)))
+ if ($format && $format eq 'cpu_frequency');
+return html_escape($value);
+}
+
+# module_links(modules, [driver])
+# Links loaded module names to their module metadata pages.
+sub module_links
+{
+my ($modules, $driver) = @_;
+return $text{'module_builtin'} if (!@$modules && $driver);
+return join(", ", map {
+ ui_link("module.cgi?name=".urlize($_), ui_tag('tt', html_escape($_)))
+ } @$modules);
+}
diff --git a/mod_core_list.txt b/mod_core_list.txt
index b2845c430..6e675a72b 100644
--- a/mod_core_list.txt
+++ b/mod_core_list.txt
@@ -1 +1 @@
-acl apache authentic-theme backup-config bind8 change-user cron custom dovecot fail2ban fdisk filemin firewalld fsdump gray-theme htaccess-htpasswd init logrotate logviewer lvm mailboxes mailcap mount mysql net nftables package-updates passwd phpini postfix postgresql proc procmail proftpd quota servers software spam sshd status systemd system-status time updown useradmin usermin webmin webmincron webminlog xterm
+acl apache authentic-theme backup-config bind8 change-user cron custom dovecot fail2ban fdisk filemin firewalld fsdump gray-theme hardware-info htaccess-htpasswd init logrotate logviewer lvm mailboxes mailcap mount mysql net nftables package-updates passwd phpini postfix postgresql proc procmail proftpd quota servers software spam sshd status systemd system-status time updown useradmin usermin webmin webmincron webminlog xterm
diff --git a/mod_full_list.txt b/mod_full_list.txt
index 619767c54..26956da3c 100644
--- a/mod_full_list.txt
+++ b/mod_full_list.txt
@@ -1 +1 @@
-acl adsl-client apache at authentic-theme backup-config bacula-backup bandwidth bind8 bsdexports bsdfdisk change-user cluster-copy cluster-cron cluster-passwd cluster-shell cluster-software cluster-useradmin cluster-usermin cluster-webmin cpan cron custom dfsadmin dhcpd dovecot exim exports fail2ban fdisk fetchmail filemin filter firewall firewall6 firewalld format fsdump gray-theme grub2 heartbeat hpuxexports htaccess-htpasswd idmapd inetd init systemd inittab ipfilter ipfw ipsec iscsi-client iscsi-server iscsi-target iscsi-tgtd kea-dhcp krb5 ldap-client ldap-server ldap-useradmin logrotate logviewer lpadmin lvm mailboxes mailcap man mount mysql net nftables nginx nis openslp package-updates pam pap passwd phpini postfix postgresql ppp-client pptp-client pptp-server proc procmail proftpd qmailadmin quota raid rbac samba sarg sendmail servers sgiexports shell shorewall shorewall6 smart-status smf software spam squid sshd status stunnel syslog syslog-ng system-status tcpwrappers time tunnel updown useradmin usermin webalizer webmin webmincron webminlog xinetd xterm zones
+acl adsl-client apache at authentic-theme backup-config bacula-backup bandwidth bind8 bsdexports bsdfdisk change-user cluster-copy cluster-cron cluster-passwd cluster-shell cluster-software cluster-useradmin cluster-usermin cluster-webmin cpan cron custom dfsadmin dhcpd dovecot exim exports fail2ban fdisk fetchmail filemin filter firewall firewall6 firewalld format fsdump gray-theme grub2 hardware-info heartbeat hpuxexports htaccess-htpasswd idmapd inetd init systemd inittab ipfilter ipfw ipsec iscsi-client iscsi-server iscsi-target iscsi-tgtd kea-dhcp krb5 ldap-client ldap-server ldap-useradmin logrotate logviewer lpadmin lvm mailboxes mailcap man mount mysql net nftables nginx nis openslp package-updates pam pap passwd phpini postfix postgresql ppp-client pptp-client pptp-server proc procmail proftpd qmailadmin quota raid rbac samba sarg sendmail servers sgiexports shell shorewall shorewall6 smart-status smf software spam squid sshd status stunnel syslog syslog-ng system-status tcpwrappers time tunnel updown useradmin usermin webalizer webmin webmincron webminlog xinetd xterm zones