diff --git a/api/generated-schema.graphql b/api/generated-schema.graphql index 9fe342c7ba..db6919a533 100644 --- a/api/generated-schema.graphql +++ b/api/generated-schema.graphql @@ -1841,6 +1841,24 @@ type InfoMemory implements Node { layout: [MemoryLayout!]! } +"""IPv4 address assigned to a network interface""" +type InfoNetworkIpv4Address { + """IPv4 address""" + address: String! + + """IPv4 netmask""" + netmask: String! +} + +"""IPv6 address assigned to a network interface""" +type InfoNetworkIpv6Address { + """IPv6 address""" + address: String! + + """IPv6 prefix length""" + prefixLength: Int +} + type InfoNetworkInterface implements Node { id: PrefixedID! @@ -1853,6 +1871,36 @@ type InfoNetworkInterface implements Node { """MAC Address""" macAddress: String + """Maximum transmission unit""" + mtu: Int + + """Link speed in Mbps""" + speed: Int + + """Link duplex mode""" + duplex: String + + """Whether this is an internal interface""" + internal: Boolean + + """Whether this is a virtual interface""" + virtual: Boolean + + """Operational state""" + operstate: String + + """Interface type""" + type: String + + """VLAN identifier parsed from the interface name""" + vlanId: Int + + """IPv4 addresses assigned to this interface""" + ipv4Addresses: [InfoNetworkIpv4Address!]! + + """IPv6 addresses assigned to this interface""" + ipv6Addresses: [InfoNetworkIpv6Address!]! + """Connection status""" status: String @@ -2566,6 +2614,52 @@ type TemperatureMetrics implements Node { summary: TemperatureSummary! } +type NetworkMetrics implements Node { + id: PrefixedID! + + """Interface identifier""" + name: String! + + """Operational state""" + operstate: String + + """Total received bytes""" + bytesReceived: BigInt! + + """Total transmitted bytes""" + bytesSent: BigInt! + + """Total received packets""" + packetsReceived: BigInt! + + """Total transmitted packets""" + packetsSent: BigInt! + + """Receive errors""" + receiveErrors: BigInt! + + """Transmit errors""" + transmitErrors: BigInt! + + """Dropped receive packets""" + receiveDropped: BigInt! + + """Dropped transmit packets""" + transmitDropped: BigInt! + + """Receive throughput in bytes per second""" + rxSec: Float! + + """Transmit throughput in bytes per second""" + txSec: Float! + + """Estimated link utilization percentage""" + utilizationPercent: Float + + """Metric collection timestamp""" + lastUpdated: DateTime! +} + """System metrics including CPU and memory utilization""" type Metrics implements Node { id: PrefixedID! @@ -2578,6 +2672,9 @@ type Metrics implements Node { """Temperature metrics""" temperature: TemperatureMetrics + + """Current network metrics for all interfaces""" + network: [NetworkMetrics!]! } type SensorConfig { @@ -3242,6 +3339,9 @@ type Query { """Validate an OIDC session token (internal use for CLI validation)""" validateOidcSession(token: String!): OidcSessionValidation! + + """Network interfaces""" + networkInterfaces: [InfoNetworkInterface!]! metrics: Metrics! """Retrieve current system time configuration""" @@ -3606,6 +3706,7 @@ type Subscription { systemMetricsCpu: CpuUtilization! systemMetricsCpuTelemetry: CpuPackages! systemMetricsMemory: MemoryUtilization! + systemMetricsNetwork: [NetworkMetrics!]! systemMetricsTemperature: TemperatureMetrics upsUpdates: UPSDevice! pluginInstallUpdates(operationId: ID!): PluginInstallEvent! diff --git a/api/src/unraid-api/graph/resolvers/info/network/network.model.ts b/api/src/unraid-api/graph/resolvers/info/network/network.model.ts index c0d7da4c63..dd77e04065 100644 --- a/api/src/unraid-api/graph/resolvers/info/network/network.model.ts +++ b/api/src/unraid-api/graph/resolvers/info/network/network.model.ts @@ -2,6 +2,24 @@ import { Field, Int, ObjectType } from '@nestjs/graphql'; import { Node } from '@unraid/shared/graphql.model.js'; +@ObjectType({ description: 'IPv4 address assigned to a network interface' }) +export class InfoNetworkIpv4Address { + @Field({ description: 'IPv4 address' }) + address!: string; + + @Field({ description: 'IPv4 netmask' }) + netmask!: string; +} + +@ObjectType({ description: 'IPv6 address assigned to a network interface' }) +export class InfoNetworkIpv6Address { + @Field({ description: 'IPv6 address' }) + address!: string; + + @Field(() => Int, { nullable: true, description: 'IPv6 prefix length' }) + prefixLength?: number; +} + @ObjectType({ implements: () => Node }) export class InfoNetworkInterface extends Node { @Field({ description: 'Interface name (e.g. eth0)' }) @@ -13,6 +31,36 @@ export class InfoNetworkInterface extends Node { @Field({ nullable: true, description: 'MAC Address' }) macAddress?: string; + @Field(() => Int, { nullable: true, description: 'Maximum transmission unit' }) + mtu?: number; + + @Field(() => Int, { nullable: true, description: 'Link speed in Mbps' }) + speed?: number; + + @Field({ nullable: true, description: 'Link duplex mode' }) + duplex?: string; + + @Field({ nullable: true, description: 'Whether this is an internal interface' }) + internal?: boolean; + + @Field({ nullable: true, description: 'Whether this is a virtual interface' }) + virtual?: boolean; + + @Field({ nullable: true, description: 'Operational state' }) + operstate?: string; + + @Field({ nullable: true, description: 'Interface type' }) + type?: string; + + @Field(() => Int, { nullable: true, description: 'VLAN identifier parsed from the interface name' }) + vlanId?: number; + + @Field(() => [InfoNetworkIpv4Address], { description: 'IPv4 addresses assigned to this interface' }) + ipv4Addresses!: InfoNetworkIpv4Address[]; + + @Field(() => [InfoNetworkIpv6Address], { description: 'IPv6 addresses assigned to this interface' }) + ipv6Addresses!: InfoNetworkIpv6Address[]; + @Field({ nullable: true, description: 'Connection status' }) status?: string; diff --git a/api/src/unraid-api/graph/resolvers/info/network/network.resolver.ts b/api/src/unraid-api/graph/resolvers/info/network/network.resolver.ts index a07e1bff11..64751c313b 100644 --- a/api/src/unraid-api/graph/resolvers/info/network/network.resolver.ts +++ b/api/src/unraid-api/graph/resolvers/info/network/network.resolver.ts @@ -11,11 +11,23 @@ import { NetworkService } from '@app/unraid-api/graph/resolvers/info/network/net export class InfoNetworkResolver { constructor(private readonly networkService: NetworkService) {} - @ResolveField(() => [InfoNetworkInterface], { description: 'Network interfaces' }) + @Query(() => [InfoNetworkInterface], { description: 'Network interfaces' }) + @UsePermissions({ + action: AuthAction.READ_ANY, + resource: Resource.INFO, + }) async networkInterfaces(): Promise { return this.networkService.getNetworkInterfaces(); } + @ResolveField(() => [InfoNetworkInterface], { + name: 'networkInterfaces', + description: 'Network interfaces', + }) + async infoNetworkInterfaces(): Promise { + return this.networkService.getNetworkInterfaces(); + } + @ResolveField(() => InfoNetworkInterface, { nullable: true, description: 'Primary management interface', diff --git a/api/src/unraid-api/graph/resolvers/info/network/network.service.ts b/api/src/unraid-api/graph/resolvers/info/network/network.service.ts index 4838972ce0..b60508e43f 100644 --- a/api/src/unraid-api/graph/resolvers/info/network/network.service.ts +++ b/api/src/unraid-api/graph/resolvers/info/network/network.service.ts @@ -2,7 +2,6 @@ import { Injectable } from '@nestjs/common'; import { networkInterfaces } from 'systeminformation'; -import { getters } from '@app/store/index.js'; import { InfoNetworkInterface } from '@app/unraid-api/graph/resolvers/info/network/network.model.js'; @Injectable() @@ -18,6 +17,30 @@ export class NetworkService { name: iface.iface, description: iface.ifaceName, // Label macAddress: iface.mac, + mtu: iface.mtu ?? undefined, + speed: iface.speed ?? undefined, + duplex: iface.duplex, + internal: iface.internal, + virtual: iface.virtual, + operstate: iface.operstate, + type: iface.type, + vlanId: this.parseVlanId(iface.iface), + ipv4Addresses: iface.ip4 + ? [ + { + address: iface.ip4, + netmask: iface.ip4subnet, + }, + ] + : [], + ipv6Addresses: iface.ip6 + ? [ + { + address: iface.ip6, + prefixLength: this.parseIpv6PrefixLength(iface.ip6subnet), + }, + ] + : [], status: iface.operstate, protocol: iface.ip4 ? (iface.ip6 ? 'ipv4+ipv6' : 'ipv4') : iface.ip6 ? 'ipv6' : 'none', ipAddress: iface.ip4, @@ -27,7 +50,7 @@ export class NetworkService { ipv6Address: iface.ip6, ipv6Netmask: iface.ip6subnet, useDhcp6: false, - } as InfoNetworkInterface; + } satisfies InfoNetworkInterface; }); } @@ -58,6 +81,38 @@ export class NetworkService { netmask: primary.ip4subnet, useDhcp: primary.dhcp, ipv6Address: primary.ip6, - } as InfoNetworkInterface; + ipv4Addresses: primary.ip4 + ? [ + { + address: primary.ip4, + netmask: primary.ip4subnet, + }, + ] + : [], + ipv6Addresses: primary.ip6 + ? [ + { + address: primary.ip6, + prefixLength: this.parseIpv6PrefixLength(primary.ip6subnet), + }, + ] + : [], + } satisfies InfoNetworkInterface; + } + + private parseVlanId(iface: string): number | undefined { + const match = iface.match(/\.(\d+)$/); + if (!match) return undefined; + + const vlanId = Number(match[1]); + return Number.isInteger(vlanId) ? vlanId : undefined; + } + + private parseIpv6PrefixLength(subnet: string): number | undefined { + const trimmed = subnet.trim(); + if (!/^\d+$/.test(trimmed)) return undefined; + + const parsed = Number(trimmed); + return Number.isInteger(parsed) && parsed >= 0 && parsed <= 128 ? parsed : undefined; } } diff --git a/api/src/unraid-api/graph/resolvers/metrics/metrics.model.ts b/api/src/unraid-api/graph/resolvers/metrics/metrics.model.ts index 438d800fbb..4e65711470 100644 --- a/api/src/unraid-api/graph/resolvers/metrics/metrics.model.ts +++ b/api/src/unraid-api/graph/resolvers/metrics/metrics.model.ts @@ -4,6 +4,7 @@ import { Node } from '@unraid/shared/graphql.model.js'; import { CpuUtilization } from '@app/unraid-api/graph/resolvers/info/cpu/cpu.model.js'; import { MemoryUtilization } from '@app/unraid-api/graph/resolvers/info/memory/memory.model.js'; +import { NetworkMetrics } from '@app/unraid-api/graph/resolvers/metrics/network/network.model.js'; import { TemperatureMetrics } from '@app/unraid-api/graph/resolvers/metrics/temperature/temperature.model.js'; @ObjectType({ @@ -25,4 +26,9 @@ export class Metrics extends Node { description: 'Temperature metrics', }) temperature?: TemperatureMetrics; + + @Field(() => [NetworkMetrics], { + description: 'Current network metrics for all interfaces', + }) + network!: NetworkMetrics[]; } diff --git a/api/src/unraid-api/graph/resolvers/metrics/metrics.module.ts b/api/src/unraid-api/graph/resolvers/metrics/metrics.module.ts index 00ef567969..2934cda407 100644 --- a/api/src/unraid-api/graph/resolvers/metrics/metrics.module.ts +++ b/api/src/unraid-api/graph/resolvers/metrics/metrics.module.ts @@ -3,12 +3,13 @@ import { Module } from '@nestjs/common'; import { CpuModule } from '@app/unraid-api/graph/resolvers/info/cpu/cpu.module.js'; import { MemoryService } from '@app/unraid-api/graph/resolvers/info/memory/memory.service.js'; import { MetricsResolver } from '@app/unraid-api/graph/resolvers/metrics/metrics.resolver.js'; +import { NetworkMetricsService } from '@app/unraid-api/graph/resolvers/metrics/network/network.service.js'; import { TemperatureModule } from '@app/unraid-api/graph/resolvers/metrics/temperature/temperature.module.js'; import { ServicesModule } from '@app/unraid-api/graph/services/services.module.js'; @Module({ imports: [ServicesModule, CpuModule, TemperatureModule], - providers: [MetricsResolver, MemoryService], + providers: [MetricsResolver, MemoryService, NetworkMetricsService], exports: [MetricsResolver], }) export class MetricsModule {} diff --git a/api/src/unraid-api/graph/resolvers/metrics/metrics.resolver.integration.spec.ts b/api/src/unraid-api/graph/resolvers/metrics/metrics.resolver.integration.spec.ts index 1269629e53..1d69f8d37a 100644 --- a/api/src/unraid-api/graph/resolvers/metrics/metrics.resolver.integration.spec.ts +++ b/api/src/unraid-api/graph/resolvers/metrics/metrics.resolver.integration.spec.ts @@ -11,6 +11,8 @@ import { CpuService } from '@app/unraid-api/graph/resolvers/info/cpu/cpu.service import { MemoryUtilization } from '@app/unraid-api/graph/resolvers/info/memory/memory.model.js'; import { MemoryService } from '@app/unraid-api/graph/resolvers/info/memory/memory.service.js'; import { MetricsResolver } from '@app/unraid-api/graph/resolvers/metrics/metrics.resolver.js'; +import { NetworkMetrics } from '@app/unraid-api/graph/resolvers/metrics/network/network.model.js'; +import { NetworkMetricsService } from '@app/unraid-api/graph/resolvers/metrics/network/network.service.js'; import { TemperatureConfigService } from '@app/unraid-api/graph/resolvers/metrics/temperature/temperature-config.service.js'; import { TemperatureService } from '@app/unraid-api/graph/resolvers/metrics/temperature/temperature.service.js'; import { SubscriptionHelperService } from '@app/unraid-api/graph/services/subscription-helper.service.js'; @@ -29,6 +31,7 @@ describe('MetricsResolver Integration Tests', () => { CpuService, CpuTopologyService, MemoryService, + NetworkMetricsService, { provide: TemperatureService, useValue: { @@ -114,6 +117,42 @@ describe('MetricsResolver Integration Tests', () => { expect(result.percentTotal).toBeGreaterThanOrEqual(0); expect(result.percentTotal).toBeLessThanOrEqual(100); }); + + it('should return network metrics', async () => { + const networkService = module.get(NetworkMetricsService); + + vi.spyOn(networkService, 'getNetworkMetrics').mockResolvedValue([ + { + id: 'metrics/network/eth0', + name: 'eth0', + operstate: 'up', + bytesReceived: 1024, + bytesSent: 2048, + packetsReceived: 10, + packetsSent: 20, + receiveErrors: 0, + transmitErrors: 0, + receiveDropped: 0, + transmitDropped: 0, + rxSec: 100, + txSec: 200, + utilizationPercent: 0.0024, + lastUpdated: new Date('2026-01-01T00:00:00.000Z'), + }, + ] satisfies NetworkMetrics[]); + + const result = await metricsResolver.network(); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual( + expect.objectContaining({ + id: 'metrics/network/eth0', + name: 'eth0', + rxSec: 100, + txSec: 200, + }) + ); + }); }); describe('Polling Mechanism', () => { @@ -176,6 +215,53 @@ describe('MetricsResolver Integration Tests', () => { expect(executionCount).toBeLessThanOrEqual(2); // Allow for initial execution }); + it('should publish network metrics to pubsub', async () => { + const publishSpy = vi.spyOn(pubsub, 'publish'); + const trackerService = module.get(SubscriptionTrackerService); + const networkService = module.get(NetworkMetricsService); + + vi.spyOn(networkService, 'getNetworkMetrics').mockResolvedValue([ + { + id: 'metrics/network/eth0', + name: 'eth0', + operstate: 'up', + bytesReceived: 1024, + bytesSent: 2048, + packetsReceived: 10, + packetsSent: 20, + receiveErrors: 0, + transmitErrors: 0, + receiveDropped: 0, + transmitDropped: 0, + rxSec: 100, + txSec: 200, + utilizationPercent: 0.0024, + lastUpdated: new Date('2026-01-01T00:00:00.000Z'), + }, + ] satisfies NetworkMetrics[]); + + trackerService.subscribe(PUBSUB_CHANNEL.NETWORK_UTILIZATION); + + await new Promise((resolve) => setTimeout(resolve, 2300)); + + expect(publishSpy).toHaveBeenCalledWith( + PUBSUB_CHANNEL.NETWORK_UTILIZATION, + expect.objectContaining({ + systemMetricsNetwork: expect.arrayContaining([ + expect.objectContaining({ + id: 'metrics/network/eth0', + name: 'eth0', + rxSec: 100, + txSec: 200, + }), + ]), + }) + ); + + trackerService.unsubscribe(PUBSUB_CHANNEL.NETWORK_UTILIZATION); + publishSpy.mockRestore(); + }); + it('should publish CPU metrics to pubsub', async () => { const publishSpy = vi.spyOn(pubsub, 'publish'); const trackerService = module.get(SubscriptionTrackerService); @@ -307,6 +393,7 @@ describe('MetricsResolver Integration Tests', () => { // Start polling trackerService.subscribe(PUBSUB_CHANNEL.CPU_UTILIZATION); trackerService.subscribe(PUBSUB_CHANNEL.MEMORY_UTILIZATION); + trackerService.subscribe(PUBSUB_CHANNEL.NETWORK_UTILIZATION); // Wait a bit for subscriptions to be fully set up await new Promise((resolve) => setTimeout(resolve, 100)); @@ -316,6 +403,9 @@ describe('MetricsResolver Integration Tests', () => { expect(subscriptionManager.isSubscriptionActive(PUBSUB_CHANNEL.MEMORY_UTILIZATION)).toBe( true ); + expect(subscriptionManager.isSubscriptionActive(PUBSUB_CHANNEL.NETWORK_UTILIZATION)).toBe( + true + ); // Clean up the module await module.close(); @@ -325,6 +415,9 @@ describe('MetricsResolver Integration Tests', () => { expect(subscriptionManager.isSubscriptionActive(PUBSUB_CHANNEL.MEMORY_UTILIZATION)).toBe( false ); + expect(subscriptionManager.isSubscriptionActive(PUBSUB_CHANNEL.NETWORK_UTILIZATION)).toBe( + false + ); }); }); }); diff --git a/api/src/unraid-api/graph/resolvers/metrics/metrics.resolver.spec.ts b/api/src/unraid-api/graph/resolvers/metrics/metrics.resolver.spec.ts index 488c303a31..9a079104b3 100644 --- a/api/src/unraid-api/graph/resolvers/metrics/metrics.resolver.spec.ts +++ b/api/src/unraid-api/graph/resolvers/metrics/metrics.resolver.spec.ts @@ -9,6 +9,7 @@ import { CpuTopologyService } from '@app/unraid-api/graph/resolvers/info/cpu/cpu import { CpuService } from '@app/unraid-api/graph/resolvers/info/cpu/cpu.service.js'; import { MemoryService } from '@app/unraid-api/graph/resolvers/info/memory/memory.service.js'; import { MetricsResolver } from '@app/unraid-api/graph/resolvers/metrics/metrics.resolver.js'; +import { NetworkMetricsService } from '@app/unraid-api/graph/resolvers/metrics/network/network.service.js'; import { TemperatureConfigService } from '@app/unraid-api/graph/resolvers/metrics/temperature/temperature-config.service.js'; import { TemperatureMetrics, @@ -33,6 +34,7 @@ describe('MetricsResolver', () => { let resolver: MetricsResolver; let cpuService: CpuService; let memoryService: MemoryService; + let networkMetricsService: NetworkMetricsService; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ @@ -89,6 +91,30 @@ describe('MetricsResolver', () => { }), }, }, + { + provide: NetworkMetricsService, + useValue: { + getNetworkMetrics: vi.fn().mockResolvedValue([ + { + id: 'metrics/network/eth0', + name: 'eth0', + operstate: 'up', + bytesReceived: 1024, + bytesSent: 2048, + packetsReceived: 10, + packetsSent: 20, + receiveErrors: 0, + transmitErrors: 0, + receiveDropped: 0, + transmitDropped: 0, + rxSec: 100, + txSec: 200, + utilizationPercent: 0.0024, + lastUpdated: new Date('2026-01-01T00:00:00.000Z'), + }, + ]), + }, + }, { provide: SubscriptionTrackerService, useValue: { @@ -125,6 +151,7 @@ describe('MetricsResolver', () => { resolver = module.get(MetricsResolver); cpuService = module.get(CpuService); memoryService = module.get(MemoryService); + networkMetricsService = module.get(NetworkMetricsService); }); describe('metrics', () => { @@ -194,6 +221,33 @@ describe('MetricsResolver', () => { }); }); + describe('network', () => { + it('should return network metrics data', async () => { + const result = await resolver.network(); + + expect(networkMetricsService.getNetworkMetrics).toHaveBeenCalled(); + expect(result).toEqual([ + expect.objectContaining({ + id: 'metrics/network/eth0', + name: 'eth0', + bytesReceived: 1024, + bytesSent: 2048, + rxSec: 100, + txSec: 200, + utilizationPercent: 0.0024, + }), + ]); + }); + + it('should handle network service errors gracefully', async () => { + vi.mocked(networkMetricsService.getNetworkMetrics).mockRejectedValueOnce( + new Error('Network error') + ); + + await expect(resolver.network()).rejects.toThrow('Network error'); + }); + }); + describe('onModuleInit', () => { it('should register CPU and memory polling topics', () => { const subscriptionTracker = { @@ -209,6 +263,10 @@ describe('MetricsResolver', () => { getMetrics: vi.fn().mockResolvedValue(null), } satisfies Pick; + const networkMetricsServiceMock = { + getNetworkMetrics: vi.fn().mockResolvedValue([]), + } satisfies Pick; + const configServiceMock = { get: vi.fn((key: string, defaultValue?: unknown) => defaultValue), }; @@ -221,6 +279,7 @@ describe('MetricsResolver', () => { cpuService, cpuTopologyServiceMock as unknown as CpuTopologyService, memoryService, + networkMetricsServiceMock as unknown as NetworkMetricsService, temperatureServiceMock as unknown as TemperatureService, subscriptionTracker as unknown as SubscriptionTrackerService, {} as unknown as SubscriptionHelperService, @@ -230,7 +289,7 @@ describe('MetricsResolver', () => { testModule.onModuleInit(); - expect(subscriptionTracker.registerTopic).toHaveBeenCalledTimes(4); + expect(subscriptionTracker.registerTopic).toHaveBeenCalledTimes(5); expect(subscriptionTracker.registerTopic).toHaveBeenCalledWith( 'CPU_UTILIZATION', expect.any(Function), @@ -241,6 +300,11 @@ describe('MetricsResolver', () => { expect.any(Function), 2000 ); + expect(subscriptionTracker.registerTopic).toHaveBeenCalledWith( + 'NETWORK_UTILIZATION', + expect.any(Function), + 2000 + ); }); it('should skip publishing temperature metrics when payload is null', async () => { @@ -261,6 +325,7 @@ describe('MetricsResolver', () => { {} as CpuService, {} as CpuTopologyService, {} as MemoryService, + {} as NetworkMetricsService, temperatureServiceMock, subscriptionTracker, {} as SubscriptionHelperService, @@ -304,6 +369,7 @@ describe('MetricsResolver', () => { {} as CpuService, {} as CpuTopologyService, {} as MemoryService, + {} as NetworkMetricsService, temperatureServiceMock, subscriptionTracker, {} as SubscriptionHelperService, diff --git a/api/src/unraid-api/graph/resolvers/metrics/metrics.resolver.ts b/api/src/unraid-api/graph/resolvers/metrics/metrics.resolver.ts index 794e3f4cea..3861b6e368 100644 --- a/api/src/unraid-api/graph/resolvers/metrics/metrics.resolver.ts +++ b/api/src/unraid-api/graph/resolvers/metrics/metrics.resolver.ts @@ -12,6 +12,8 @@ import { CpuService } from '@app/unraid-api/graph/resolvers/info/cpu/cpu.service import { MemoryUtilization } from '@app/unraid-api/graph/resolvers/info/memory/memory.model.js'; import { MemoryService } from '@app/unraid-api/graph/resolvers/info/memory/memory.service.js'; import { Metrics } from '@app/unraid-api/graph/resolvers/metrics/metrics.model.js'; +import { NetworkMetrics } from '@app/unraid-api/graph/resolvers/metrics/network/network.model.js'; +import { NetworkMetricsService } from '@app/unraid-api/graph/resolvers/metrics/network/network.service.js'; import { TemperatureConfigInput } from '@app/unraid-api/graph/resolvers/metrics/temperature/temperature-config.input.js'; import { TemperatureConfigService } from '@app/unraid-api/graph/resolvers/metrics/temperature/temperature-config.service.js'; import { TemperatureMetrics } from '@app/unraid-api/graph/resolvers/metrics/temperature/temperature.model.js'; @@ -26,6 +28,7 @@ export class MetricsResolver implements OnModuleInit { private readonly cpuService: CpuService, private readonly cpuTopologyService: CpuTopologyService, private readonly memoryService: MemoryService, + private readonly networkMetricsService: NetworkMetricsService, private readonly temperatureService: TemperatureService, private readonly subscriptionTracker: SubscriptionTrackerService, private readonly subscriptionHelper: SubscriptionHelperService, @@ -86,6 +89,15 @@ export class MetricsResolver implements OnModuleInit { 2000 ); + this.subscriptionTracker.registerTopic( + PUBSUB_CHANNEL.NETWORK_UTILIZATION, + async () => { + const payload = await this.networkMetricsService.getNetworkMetrics(); + pubsub.publish(PUBSUB_CHANNEL.NETWORK_UTILIZATION, { systemMetricsNetwork: payload }); + }, + 2000 + ); + const { enabled, polling_interval } = this.temperatureConfigService.getConfig(); if (enabled) { @@ -125,6 +137,11 @@ export class MetricsResolver implements OnModuleInit { return this.memoryService.generateMemoryLoad(); } + @ResolveField(() => [NetworkMetrics]) + public async network(): Promise { + return this.networkMetricsService.getNetworkMetrics(); + } + @Subscription(() => CpuUtilization, { name: 'systemMetricsCpu', resolve: (value) => value.systemMetricsCpu, @@ -161,6 +178,18 @@ export class MetricsResolver implements OnModuleInit { return this.subscriptionHelper.createTrackedSubscription(PUBSUB_CHANNEL.MEMORY_UTILIZATION); } + @Subscription(() => [NetworkMetrics], { + name: 'systemMetricsNetwork', + resolve: (value) => value.systemMetricsNetwork, + }) + @UsePermissions({ + action: AuthAction.READ_ANY, + resource: Resource.INFO, + }) + public async systemMetricsNetworkSubscription() { + return this.subscriptionHelper.createTrackedSubscription(PUBSUB_CHANNEL.NETWORK_UTILIZATION); + } + @ResolveField(() => TemperatureMetrics, { nullable: true }) public async temperature(): Promise { return this.temperatureService.getMetrics(); diff --git a/api/src/unraid-api/graph/resolvers/metrics/network/network.model.ts b/api/src/unraid-api/graph/resolvers/metrics/network/network.model.ts new file mode 100644 index 0000000000..4ce2b61fbe --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/metrics/network/network.model.ts @@ -0,0 +1,49 @@ +import { Field, Float, GraphQLISODateTime, ObjectType } from '@nestjs/graphql'; + +import { Node } from '@unraid/shared/graphql.model.js'; +import { GraphQLBigInt } from 'graphql-scalars'; + +@ObjectType({ implements: () => Node }) +export class NetworkMetrics extends Node { + @Field({ description: 'Interface identifier' }) + name!: string; + + @Field({ nullable: true, description: 'Operational state' }) + operstate?: string; + + @Field(() => GraphQLBigInt, { description: 'Total received bytes' }) + bytesReceived!: number; + + @Field(() => GraphQLBigInt, { description: 'Total transmitted bytes' }) + bytesSent!: number; + + @Field(() => GraphQLBigInt, { description: 'Total received packets' }) + packetsReceived!: number; + + @Field(() => GraphQLBigInt, { description: 'Total transmitted packets' }) + packetsSent!: number; + + @Field(() => GraphQLBigInt, { description: 'Receive errors' }) + receiveErrors!: number; + + @Field(() => GraphQLBigInt, { description: 'Transmit errors' }) + transmitErrors!: number; + + @Field(() => GraphQLBigInt, { description: 'Dropped receive packets' }) + receiveDropped!: number; + + @Field(() => GraphQLBigInt, { description: 'Dropped transmit packets' }) + transmitDropped!: number; + + @Field(() => Float, { description: 'Receive throughput in bytes per second' }) + rxSec!: number; + + @Field(() => Float, { description: 'Transmit throughput in bytes per second' }) + txSec!: number; + + @Field(() => Float, { nullable: true, description: 'Estimated link utilization percentage' }) + utilizationPercent?: number; + + @Field(() => GraphQLISODateTime, { description: 'Metric collection timestamp' }) + lastUpdated!: Date; +} diff --git a/api/src/unraid-api/graph/resolvers/metrics/network/network.service.spec.ts b/api/src/unraid-api/graph/resolvers/metrics/network/network.service.spec.ts new file mode 100644 index 0000000000..6cb59ed4fd --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/metrics/network/network.service.spec.ts @@ -0,0 +1,106 @@ +import { readFile } from 'fs/promises'; + +import { networkInterfaces, networkStats, Systeminformation } from 'systeminformation'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { NetworkMetricsService } from '@app/unraid-api/graph/resolvers/metrics/network/network.service.js'; + +vi.mock('fs/promises', () => ({ + readFile: vi.fn(), +})); + +vi.mock('systeminformation', () => ({ + networkInterfaces: vi.fn(), + networkStats: vi.fn(), +})); + +describe('NetworkMetricsService', () => { + let service: NetworkMetricsService; + + beforeEach(() => { + service = new NetworkMetricsService(); + vi.resetAllMocks(); + }); + + it('maps network stats and packet counters to GraphQL metrics', async () => { + vi.mocked(networkStats).mockResolvedValue([ + { + iface: 'eth0', + operstate: 'up', + rx_bytes: 1024, + tx_bytes: 2048, + rx_dropped: 1, + tx_dropped: 2, + rx_errors: 3, + tx_errors: 4, + rx_sec: 100, + tx_sec: 200, + ms: 2000, + }, + ] satisfies Systeminformation.NetworkStatsData[]); + vi.mocked(networkInterfaces).mockResolvedValue([ + { + iface: 'eth0', + speed: 1000, + }, + ] as Systeminformation.NetworkInterfacesData[]); + vi.mocked(readFile).mockResolvedValueOnce('10\n').mockResolvedValueOnce('20\n'); + + const result = await service.getNetworkMetrics(); + + expect(result).toEqual([ + expect.objectContaining({ + id: 'metrics/network/eth0', + name: 'eth0', + operstate: 'up', + bytesReceived: 1024, + bytesSent: 2048, + packetsReceived: 10, + packetsSent: 20, + receiveDropped: 1, + transmitDropped: 2, + receiveErrors: 3, + transmitErrors: 4, + rxSec: 100, + txSec: 200, + utilizationPercent: 0.00024, + }), + ]); + expect(result[0].lastUpdated).toBeInstanceOf(Date); + }); + + it('falls back to zero packet counters and undefined utilization when speed is missing', async () => { + vi.mocked(networkStats).mockResolvedValue([ + { + iface: 'br0', + operstate: 'unknown', + rx_bytes: 0, + tx_bytes: 0, + rx_dropped: 0, + tx_dropped: 0, + rx_errors: 0, + tx_errors: 0, + rx_sec: 0, + tx_sec: 0, + ms: 2000, + }, + ] satisfies Systeminformation.NetworkStatsData[]); + vi.mocked(networkInterfaces).mockResolvedValue([ + { + iface: 'br0', + speed: null, + }, + ] as Systeminformation.NetworkInterfacesData[]); + vi.mocked(readFile).mockRejectedValue(new Error('not available')); + + const result = await service.getNetworkMetrics(); + + expect(result[0]).toEqual( + expect.objectContaining({ + packetsReceived: 0, + packetsSent: 0, + utilizationPercent: undefined, + }) + ); + }); +}); diff --git a/api/src/unraid-api/graph/resolvers/metrics/network/network.service.ts b/api/src/unraid-api/graph/resolvers/metrics/network/network.service.ts new file mode 100644 index 0000000000..46b35ef026 --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/metrics/network/network.service.ts @@ -0,0 +1,94 @@ +import { Injectable } from '@nestjs/common'; +import { readFile } from 'fs/promises'; + +import { networkInterfaces, networkStats, Systeminformation } from 'systeminformation'; + +import { NetworkMetrics } from '@app/unraid-api/graph/resolvers/metrics/network/network.model.js'; + +type PacketCounters = { + packetsReceived: number; + packetsSent: number; +}; + +@Injectable() +export class NetworkMetricsService { + async getNetworkMetrics(): Promise { + const [stats, interfaces] = await Promise.all([networkStats(), networkInterfaces()]); + const speedByInterface = new Map( + interfaces.map((networkInterface) => [networkInterface.iface, networkInterface.speed]) + ); + const collectedAt = new Date(); + + return Promise.all( + stats.map(async (stat) => { + const counters = await this.getPacketCounters(stat.iface); + const speed = speedByInterface.get(stat.iface); + + return this.toNetworkMetrics(stat, counters, speed, collectedAt); + }) + ); + } + + private async getPacketCounters(iface: string): Promise { + const [received, sent] = await Promise.all([ + this.readCounter(iface, 'rx_packets'), + this.readCounter(iface, 'tx_packets'), + ]); + + return { + packetsReceived: received, + packetsSent: sent, + }; + } + + private async readCounter(iface: string, counter: 'rx_packets' | 'tx_packets'): Promise { + try { + const rawValue = await readFile(`/sys/class/net/${iface}/statistics/${counter}`, 'utf8'); + const parsedValue = Number(rawValue.trim()); + + return Number.isFinite(parsedValue) ? parsedValue : 0; + } catch { + return 0; + } + } + + private toNetworkMetrics( + stat: Systeminformation.NetworkStatsData, + counters: PacketCounters, + speed: number | null | undefined, + collectedAt: Date + ): NetworkMetrics { + const rxSec = stat.rx_sec ?? 0; + const txSec = stat.tx_sec ?? 0; + + return { + id: `metrics/network/${stat.iface}`, + name: stat.iface, + operstate: stat.operstate, + bytesReceived: Math.floor(stat.rx_bytes), + bytesSent: Math.floor(stat.tx_bytes), + packetsReceived: counters.packetsReceived, + packetsSent: counters.packetsSent, + receiveErrors: Math.floor(stat.rx_errors), + transmitErrors: Math.floor(stat.tx_errors), + receiveDropped: Math.floor(stat.rx_dropped), + transmitDropped: Math.floor(stat.tx_dropped), + rxSec, + txSec, + utilizationPercent: this.calculateUtilizationPercent(rxSec, txSec, speed), + lastUpdated: collectedAt, + }; + } + + private calculateUtilizationPercent( + rxSec: number, + txSec: number, + speed: number | null | undefined + ): number | undefined { + if (!speed || speed <= 0) { + return undefined; + } + + return ((rxSec + txSec) * 8 * 100) / (speed * 1_000_000); + } +} diff --git a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.resolver.integration.spec.ts b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.resolver.integration.spec.ts index 4d1c5a6575..46167e95df 100644 --- a/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.resolver.integration.spec.ts +++ b/api/src/unraid-api/graph/resolvers/metrics/temperature/temperature.resolver.integration.spec.ts @@ -7,6 +7,7 @@ import { CpuTopologyService } from '@app/unraid-api/graph/resolvers/info/cpu/cpu import { CpuService } from '@app/unraid-api/graph/resolvers/info/cpu/cpu.service.js'; import { MemoryService } from '@app/unraid-api/graph/resolvers/info/memory/memory.service.js'; import { MetricsResolver } from '@app/unraid-api/graph/resolvers/metrics/metrics.resolver.js'; +import { NetworkMetricsService } from '@app/unraid-api/graph/resolvers/metrics/network/network.service.js'; import { TemperatureConfigService } from '@app/unraid-api/graph/resolvers/metrics/temperature/temperature-config.service.js'; import { SensorType, @@ -107,6 +108,12 @@ describe('Temperature GraphQL Integration', () => { getUtilization: vi.fn().mockResolvedValue({}), }, }, + { + provide: NetworkMetricsService, + useValue: { + getNetworkMetrics: vi.fn().mockResolvedValue([]), + }, + }, { provide: TemperatureService, useValue: { diff --git a/packages/unraid-shared/src/pubsub/graphql.pubsub.ts b/packages/unraid-shared/src/pubsub/graphql.pubsub.ts index 0359617f96..de3578bdcd 100644 --- a/packages/unraid-shared/src/pubsub/graphql.pubsub.ts +++ b/packages/unraid-shared/src/pubsub/graphql.pubsub.ts @@ -10,6 +10,7 @@ export enum GRAPHQL_PUBSUB_CHANNEL { DISPLAY = "DISPLAY", INFO = "INFO", MEMORY_UTILIZATION = "MEMORY_UTILIZATION", + NETWORK_UTILIZATION = "NETWORK_UTILIZATION", NOTIFICATION = "NOTIFICATION", NOTIFICATION_ADDED = "NOTIFICATION_ADDED", NOTIFICATION_OVERVIEW = "NOTIFICATION_OVERVIEW",