diff --git a/api/dev/configs/api.json b/api/dev/configs/api.json index d57ab4fd43..cade101923 100644 --- a/api/dev/configs/api.json +++ b/api/dev/configs/api.json @@ -1,9 +1,9 @@ { - "version": "4.29.2", + "version": "4.35.0", "extraOrigins": [], "sandbox": false, "ssoSubIds": [], "plugins": [ "unraid-api-plugin-connect" ] -} +} \ No newline at end of file diff --git a/api/src/unraid-api/cli/generated/graphql.ts b/api/src/unraid-api/cli/generated/graphql.ts index 0b0c497690..72e1f31bb2 100644 --- a/api/src/unraid-api/cli/generated/graphql.ts +++ b/api/src/unraid-api/cli/generated/graphql.ts @@ -1311,31 +1311,69 @@ export type InfoNetworkInterface = Node & { __typename?: 'InfoNetworkInterface'; /** Interface description/label */ description?: Maybe; + /** Link duplex mode */ + duplex?: Maybe; /** IPv4 Gateway */ gateway?: Maybe; id: Scalars['PrefixedID']['output']; + /** Whether this is an internal interface */ + internal?: Maybe; /** IPv4 Address */ ipAddress?: Maybe; + /** IPv4 addresses assigned to this interface */ + ipv4Addresses: Array; /** IPv6 Address */ ipv6Address?: Maybe; + /** IPv6 addresses assigned to this interface */ + ipv6Addresses: Array; /** IPv6 Gateway */ ipv6Gateway?: Maybe; /** IPv6 Netmask */ ipv6Netmask?: Maybe; /** MAC Address */ macAddress?: Maybe; + /** Maximum transmission unit */ + mtu?: Maybe; /** Interface name (e.g. eth0) */ name: Scalars['String']['output']; /** IPv4 Netmask */ netmask?: Maybe; + /** Operational state */ + operstate?: Maybe; /** IPv4 Protocol mode */ protocol?: Maybe; + /** Link speed in Mbps */ + speed?: Maybe; /** Connection status */ status?: Maybe; + /** Interface type */ + type?: Maybe; /** Using DHCP for IPv4 */ useDhcp?: Maybe; /** Using DHCP for IPv6 */ useDhcp6?: Maybe; + /** Whether this is a virtual interface */ + virtual?: Maybe; + /** VLAN identifier parsed from the interface name */ + vlanId?: Maybe; +}; + +/** IPv4 address assigned to a network interface */ +export type InfoNetworkIpv4Address = { + __typename?: 'InfoNetworkIpv4Address'; + /** IPv4 address */ + address: Scalars['String']['output']; + /** IPv4 netmask */ + netmask: Scalars['String']['output']; +}; + +/** IPv6 address assigned to a network interface */ +export type InfoNetworkIpv6Address = { + __typename?: 'InfoNetworkIpv6Address'; + /** IPv6 address */ + address: Scalars['String']['output']; + /** IPv6 prefix length */ + prefixLength?: Maybe; }; export type InfoOs = Node & { @@ -1576,6 +1614,8 @@ export type Metrics = Node & { id: Scalars['PrefixedID']['output']; /** Current memory utilization metrics */ memory?: Maybe; + /** Current network metrics for all interfaces */ + network: Array; /** Temperature metrics */ temperature?: Maybe; }; @@ -1827,6 +1867,39 @@ export type Network = Node & { id: Scalars['PrefixedID']['output']; }; +export type NetworkMetrics = Node & { + __typename?: 'NetworkMetrics'; + /** Total received bytes */ + bytesReceived: Scalars['BigInt']['output']; + /** Total transmitted bytes */ + bytesSent: Scalars['BigInt']['output']; + id: Scalars['PrefixedID']['output']; + /** Metric collection timestamp */ + lastUpdated: Scalars['DateTime']['output']; + /** Interface identifier */ + name: Scalars['String']['output']; + /** Operational state */ + operstate?: Maybe; + /** Total received packets */ + packetsReceived: Scalars['BigInt']['output']; + /** Total transmitted packets */ + packetsSent: Scalars['BigInt']['output']; + /** Dropped receive packets */ + receiveDropped: Scalars['BigInt']['output']; + /** Receive errors */ + receiveErrors: Scalars['BigInt']['output']; + /** Receive throughput in bytes per second */ + rxSec: Scalars['Float']['output']; + /** Dropped transmit packets */ + transmitDropped: Scalars['BigInt']['output']; + /** Transmit errors */ + transmitErrors: Scalars['BigInt']['output']; + /** Transmit throughput in bytes per second */ + txSec: Scalars['Float']['output']; + /** Estimated link utilization percentage */ + utilizationPercent?: Maybe; +}; + export type Node = { id: Scalars['PrefixedID']['output']; }; @@ -1985,12 +2058,21 @@ export type OnboardingInternalBootContext = { assignableDisks: Array; bootEligible?: Maybe; bootedFromFlashWithInternalBootSetup: Scalars['Boolean']['output']; + driveWarnings: Array; enableBootTransfer?: Maybe; poolNames: Array; reservedNames: Array; shareNames: Array; }; +/** Warning metadata for an assignable internal boot drive */ +export type OnboardingInternalBootDriveWarning = { + __typename?: 'OnboardingInternalBootDriveWarning'; + device: Scalars['String']['output']; + diskId: Scalars['String']['output']; + warnings: Array; +}; + /** Result of attempting internal boot pool setup */ export type OnboardingInternalBootResult = { __typename?: 'OnboardingInternalBootResult'; @@ -2318,6 +2400,8 @@ export type Query = { me: UserAccount; metrics: Metrics; network: Network; + /** Network interfaces */ + networkInterfaces: Array; /** Get all notifications */ notifications: Notifications; /** Get the full OIDC configuration (admin only) */ @@ -2727,6 +2811,7 @@ export type Subscription = { systemMetricsCpu: CpuUtilization; systemMetricsCpuTelemetry: CpuPackages; systemMetricsMemory: MemoryUtilization; + systemMetricsNetwork: Array; systemMetricsTemperature?: Maybe; upsUpdates: UpsDevice; }; diff --git a/api/src/unraid-api/graph/resolvers/docker/docker.mutations.resolver.spec.ts b/api/src/unraid-api/graph/resolvers/docker/docker.mutations.resolver.spec.ts index 6dafe8bb53..fe7ea04ec1 100644 --- a/api/src/unraid-api/graph/resolvers/docker/docker.mutations.resolver.spec.ts +++ b/api/src/unraid-api/graph/resolvers/docker/docker.mutations.resolver.spec.ts @@ -20,6 +20,7 @@ describe('DockerMutationsResolver', () => { useValue: { start: vi.fn(), stop: vi.fn(), + restart: vi.fn(), }, }, ], @@ -54,6 +55,27 @@ describe('DockerMutationsResolver', () => { expect(dockerService.start).toHaveBeenCalledWith('1'); }); + it('should restart', async () => { + const mockContainer: DockerContainer = { + id: '1', + autoStart: false, + command: 'test', + created: 1234567890, + image: 'test-image', + imageId: 'test-image-id', + ports: [], + state: ContainerState.RUNNING, + status: 'Up 2 seconds', + names: ['test-container'], + isOrphaned: false, + }; + vi.mocked(dockerService.restart).mockResolvedValue(mockContainer); + + const result = await resolver.restart('1'); + expect(result).toEqual(mockContainer); + expect(dockerService.restart).toHaveBeenCalledWith('1'); + }); + it('should stop', async () => { const mockContainer: DockerContainer = { id: '1', diff --git a/api/src/unraid-api/graph/resolvers/docker/docker.mutations.resolver.ts b/api/src/unraid-api/graph/resolvers/docker/docker.mutations.resolver.ts index d2423b5cc0..fc06f270c6 100644 --- a/api/src/unraid-api/graph/resolvers/docker/docker.mutations.resolver.ts +++ b/api/src/unraid-api/graph/resolvers/docker/docker.mutations.resolver.ts @@ -36,6 +36,15 @@ export class DockerMutationsResolver { public async stop(@Args('id', { type: () => PrefixedID }) id: string) { return this.dockerService.stop(id); } + @ResolveField(() => DockerContainer, { description: 'Restart a container' }) + @UsePermissions({ + action: AuthAction.UPDATE_ANY, + resource: Resource.DOCKER, + }) + public async restart(@Args('id', { type: () => PrefixedID }) id: string) { + return this.dockerService.restart(id); + } + @ResolveField(() => DockerContainer, { description: 'Pause (Suspend) a container' }) @UsePermissions({ action: AuthAction.UPDATE_ANY, diff --git a/api/src/unraid-api/graph/resolvers/docker/docker.service.spec.ts b/api/src/unraid-api/graph/resolvers/docker/docker.service.spec.ts index f9ab378a42..09519e4180 100644 --- a/api/src/unraid-api/graph/resolvers/docker/docker.service.spec.ts +++ b/api/src/unraid-api/graph/resolvers/docker/docker.service.spec.ts @@ -38,6 +38,7 @@ const { mockDockerInstance, mockListContainers, mockGetContainer, mockListNetwor stop: vi.fn(), pause: vi.fn(), unpause: vi.fn(), + restart: vi.fn(), inspect: vi.fn(), }; @@ -77,6 +78,10 @@ vi.mock('execa', () => ({ execa: vi.fn(), })); +vi.mock('@app/core/utils/misc/sleep.js', () => ({ + sleep: vi.fn().mockResolvedValue(undefined), +})); + const { mockEmhttpGetter } = vi.hoisted(() => ({ mockEmhttpGetter: vi.fn().mockReturnValue({ networks: [], @@ -168,6 +173,8 @@ describe('DockerService', () => { let service: DockerService; beforeEach(async () => { + (pubsub.publish as ReturnType).mockClear(); + // Reset mocks before each test mockListContainers.mockReset(); mockListNetworks.mockReset(); @@ -175,6 +182,7 @@ describe('DockerService', () => { mockContainer.stop.mockReset(); mockContainer.pause.mockReset(); mockContainer.unpause.mockReset(); + mockContainer.restart.mockReset(); mockContainer.inspect.mockReset(); statMock.mockReset(); @@ -375,4 +383,77 @@ describe('DockerService', () => { ); }); }); + + describe('restart', () => { + const containerInfo = { + Id: 'abc123', + Names: ['/test-container'], + Image: 'test-image', + ImageID: 'sha256:abc', + Command: 'test', + Created: 1700000000, + Status: 'Up', + Ports: [], + Labels: {}, + HostConfig: { NetworkMode: 'bridge' }, + NetworkSettings: { Networks: {} }, + Mounts: [], + }; + + it('returns the container when it reaches RUNNING state', async () => { + mockListContainers.mockResolvedValue([{ ...containerInfo, State: 'running' }]); + mockContainer.restart.mockResolvedValue(undefined); + mockContainer.inspect.mockResolvedValue({ State: { Status: 'running' } }); + + const result = await service.restart('abc123'); + + expect(mockContainer.restart).toHaveBeenCalledWith({ t: 10 }); + expect(mockContainer.inspect).toHaveBeenCalled(); + expect(result).toMatchObject({ id: 'abc123', state: ContainerState.RUNNING }); + expect(pubsub.publish).toHaveBeenCalledWith(PUBSUB_CHANNEL.INFO, expect.anything()); + }); + + it('stops polling as soon as the target state is observed', async () => { + mockListContainers.mockResolvedValue([{ ...containerInfo, State: 'running' }]); + mockContainer.restart.mockResolvedValue(undefined); + mockContainer.inspect + .mockResolvedValueOnce({ State: { Status: 'restarting' } }) + .mockResolvedValueOnce({ State: { Status: 'running' } }); + + await service.restart('abc123'); + + expect(mockContainer.inspect).toHaveBeenCalledTimes(2); + }); + + it('throws when the container cannot be found after restart', async () => { + mockListContainers.mockResolvedValue([]); + mockContainer.restart.mockResolvedValue(undefined); + mockContainer.inspect.mockResolvedValue({ State: { Status: 'running' } }); + + await expect(service.restart('abc123')).rejects.toThrow(); + }); + + it('warns and returns the container when it does not reach RUNNING state after retries', async () => { + mockListContainers.mockResolvedValue([{ ...containerInfo, State: 'exited' }]); + mockContainer.restart.mockResolvedValue(undefined); + mockContainer.inspect.mockResolvedValue({ State: { Status: 'exited' } }); + + const result = await service.restart('abc123'); + + expect(result).toMatchObject({ id: 'abc123', state: ContainerState.EXITED }); + expect(pubsub.publish).toHaveBeenCalledWith(PUBSUB_CHANNEL.INFO, expect.anything()); + }); + + it('tolerates inspect failures during polling', async () => { + mockListContainers.mockResolvedValue([{ ...containerInfo, State: 'running' }]); + mockContainer.restart.mockResolvedValue(undefined); + mockContainer.inspect + .mockRejectedValueOnce(new Error('temporary failure')) + .mockResolvedValueOnce({ State: { Status: 'running' } }); + + const result = await service.restart('abc123'); + + expect(result).toMatchObject({ id: 'abc123', state: ContainerState.RUNNING }); + }); + }); }); diff --git a/api/src/unraid-api/graph/resolvers/docker/docker.service.ts b/api/src/unraid-api/graph/resolvers/docker/docker.service.ts index 1ea534d9de..d699c2a108 100644 --- a/api/src/unraid-api/graph/resolvers/docker/docker.service.ts +++ b/api/src/unraid-api/graph/resolvers/docker/docker.service.ts @@ -199,14 +199,7 @@ export class DockerService { public async start(id: string): Promise { const container = this.client.getContainer(id); await container.start(); - const containers = await this.getContainers(); - const updatedContainer = containers.find((c) => c.id === id); - if (!updatedContainer) { - throw new Error(`Container ${id} not found after starting`); - } - const appInfo = await this.getAppInfo(); - await pubsub.publish(PUBSUB_CHANNEL.INFO, appInfo); - return updatedContainer; + return this.finalizeMutation(id, 'starting'); } public async removeContainer(id: string, options?: { withImage?: boolean }): Promise { @@ -248,80 +241,95 @@ export class DockerService { public async stop(id: string): Promise { const container = this.client.getContainer(id); await container.stop({ t: 10 }); - - let containers = await this.getContainers(); - let updatedContainer: DockerContainer | undefined; - for (let i = 0; i < 5; i++) { - await sleep(500); - containers = await this.getContainers(); - updatedContainer = containers.find((c) => c.id === id); - this.logger.debug( - `Container ${id} state after stop attempt ${i + 1}: ${updatedContainer?.state}` - ); - if (updatedContainer?.state === ContainerState.EXITED) { - break; - } - } - - if (!updatedContainer) { - throw new Error(`Container ${id} not found after stopping`); - } else if (updatedContainer.state !== ContainerState.EXITED) { + const finalState = await this.waitForContainerState( + container, + id, + ContainerState.EXITED, + 'stop', + 5 + ); + if (finalState && finalState !== ContainerState.EXITED) { this.logger.warn(`Container ${id} did not reach EXITED state after stop command.`); } - const appInfo = await this.getAppInfo(); - await pubsub.publish(PUBSUB_CHANNEL.INFO, appInfo); - return updatedContainer; + return this.finalizeMutation(id, 'stopping'); } public async pause(id: string): Promise { const container = this.client.getContainer(id); await container.pause(); + await this.waitForContainerState(container, id, ContainerState.PAUSED, 'pause', 5); + return this.finalizeMutation(id, 'pausing'); + } - let containers: DockerContainer[]; - let updatedContainer: DockerContainer | undefined; - for (let i = 0; i < 5; i++) { - await sleep(500); - containers = await this.getContainers(); - updatedContainer = containers.find((c) => c.id === id); - this.logger.debug( - `Container ${id} state after pause attempt ${i + 1}: ${updatedContainer?.state}` - ); - if (updatedContainer?.state === ContainerState.PAUSED) { - break; - } - } - - if (!updatedContainer) { - throw new Error(`Container ${id} not found after pausing`); + public async restart(id: string): Promise { + const container = this.client.getContainer(id); + await container.restart({ t: 10 }); + const finalState = await this.waitForContainerState( + container, + id, + ContainerState.RUNNING, + 'restart', + 20 + ); + if (finalState && finalState !== ContainerState.RUNNING) { + this.logger.warn(`Container ${id} did not reach RUNNING state after restart command.`); } - const appInfo = await this.getAppInfo(); - await pubsub.publish(PUBSUB_CHANNEL.INFO, appInfo); - return updatedContainer; + return this.finalizeMutation(id, 'restarting'); } public async unpause(id: string): Promise { const container = this.client.getContainer(id); await container.unpause(); + await this.waitForContainerState(container, id, ContainerState.RUNNING, 'unpause', 5); + return this.finalizeMutation(id, 'unpausing'); + } - let containers: DockerContainer[]; - let updatedContainer: DockerContainer | undefined; - for (let i = 0; i < 5; i++) { + /** Polls a container via `inspect()` until it reaches `targetState` or `attempts` polls have been made */ + private async waitForContainerState( + container: Docker.Container, + id: string, + targetState: ContainerState, + operationName: string, + attempts: number + ): Promise { + let lastState: ContainerState | undefined; + for (let i = 0; i < attempts; i++) { await sleep(500); - containers = await this.getContainers(); - updatedContainer = containers.find((c) => c.id === id); + try { + const info = await container.inspect(); + const statusStr = info.State?.Status ?? ''; + lastState = + ContainerState[statusStr.toUpperCase() as keyof typeof ContainerState] ?? + ContainerState.EXITED; + } catch (error) { + this.logger.debug( + `Inspect failed during ${operationName} attempt ${i + 1} for ${id}: ${this.getDockerErrorMessage(error)}` + ); + } this.logger.debug( - `Container ${id} state after unpause attempt ${i + 1}: ${updatedContainer?.state}` + `Container ${id} state after ${operationName} attempt ${i + 1}: ${lastState}` ); - if (updatedContainer?.state === ContainerState.RUNNING) { - break; + if (lastState === targetState) { + return lastState; } } + return lastState; + } + private async finalizeMutation(id: string, operationName: string): Promise { + const containers = await this.getContainers(); + const updatedContainer = containers.find((c) => c.id === id); if (!updatedContainer) { - throw new Error(`Container ${id} not found after unpausing`); + throw new Error(`Container ${id} not found after ${operationName}`); } - const appInfo = await this.getAppInfo(); - await pubsub.publish(PUBSUB_CHANNEL.INFO, appInfo); + await pubsub.publish(PUBSUB_CHANNEL.INFO, { + info: { + apps: { + installed: containers.length, + running: containers.filter((c) => c.state === ContainerState.RUNNING).length, + }, + }, + }); return updatedContainer; } diff --git a/web/src/composables/gql/graphql.ts b/web/src/composables/gql/graphql.ts index 91e8636a79..acd94f270c 100644 --- a/web/src/composables/gql/graphql.ts +++ b/web/src/composables/gql/graphql.ts @@ -1311,31 +1311,69 @@ export type InfoNetworkInterface = Node & { __typename?: 'InfoNetworkInterface'; /** Interface description/label */ description?: Maybe; + /** Link duplex mode */ + duplex?: Maybe; /** IPv4 Gateway */ gateway?: Maybe; id: Scalars['PrefixedID']['output']; + /** Whether this is an internal interface */ + internal?: Maybe; /** IPv4 Address */ ipAddress?: Maybe; + /** IPv4 addresses assigned to this interface */ + ipv4Addresses: Array; /** IPv6 Address */ ipv6Address?: Maybe; + /** IPv6 addresses assigned to this interface */ + ipv6Addresses: Array; /** IPv6 Gateway */ ipv6Gateway?: Maybe; /** IPv6 Netmask */ ipv6Netmask?: Maybe; /** MAC Address */ macAddress?: Maybe; + /** Maximum transmission unit */ + mtu?: Maybe; /** Interface name (e.g. eth0) */ name: Scalars['String']['output']; /** IPv4 Netmask */ netmask?: Maybe; + /** Operational state */ + operstate?: Maybe; /** IPv4 Protocol mode */ protocol?: Maybe; + /** Link speed in Mbps */ + speed?: Maybe; /** Connection status */ status?: Maybe; + /** Interface type */ + type?: Maybe; /** Using DHCP for IPv4 */ useDhcp?: Maybe; /** Using DHCP for IPv6 */ useDhcp6?: Maybe; + /** Whether this is a virtual interface */ + virtual?: Maybe; + /** VLAN identifier parsed from the interface name */ + vlanId?: Maybe; +}; + +/** IPv4 address assigned to a network interface */ +export type InfoNetworkIpv4Address = { + __typename?: 'InfoNetworkIpv4Address'; + /** IPv4 address */ + address: Scalars['String']['output']; + /** IPv4 netmask */ + netmask: Scalars['String']['output']; +}; + +/** IPv6 address assigned to a network interface */ +export type InfoNetworkIpv6Address = { + __typename?: 'InfoNetworkIpv6Address'; + /** IPv6 address */ + address: Scalars['String']['output']; + /** IPv6 prefix length */ + prefixLength?: Maybe; }; export type InfoOs = Node & { @@ -1576,6 +1614,8 @@ export type Metrics = Node & { id: Scalars['PrefixedID']['output']; /** Current memory utilization metrics */ memory?: Maybe; + /** Current network metrics for all interfaces */ + network: Array; /** Temperature metrics */ temperature?: Maybe; }; @@ -1827,6 +1867,39 @@ export type Network = Node & { id: Scalars['PrefixedID']['output']; }; +export type NetworkMetrics = Node & { + __typename?: 'NetworkMetrics'; + /** Total received bytes */ + bytesReceived: Scalars['BigInt']['output']; + /** Total transmitted bytes */ + bytesSent: Scalars['BigInt']['output']; + id: Scalars['PrefixedID']['output']; + /** Metric collection timestamp */ + lastUpdated: Scalars['DateTime']['output']; + /** Interface identifier */ + name: Scalars['String']['output']; + /** Operational state */ + operstate?: Maybe; + /** Total received packets */ + packetsReceived: Scalars['BigInt']['output']; + /** Total transmitted packets */ + packetsSent: Scalars['BigInt']['output']; + /** Dropped receive packets */ + receiveDropped: Scalars['BigInt']['output']; + /** Receive errors */ + receiveErrors: Scalars['BigInt']['output']; + /** Receive throughput in bytes per second */ + rxSec: Scalars['Float']['output']; + /** Dropped transmit packets */ + transmitDropped: Scalars['BigInt']['output']; + /** Transmit errors */ + transmitErrors: Scalars['BigInt']['output']; + /** Transmit throughput in bytes per second */ + txSec: Scalars['Float']['output']; + /** Estimated link utilization percentage */ + utilizationPercent?: Maybe; +}; + export type Node = { id: Scalars['PrefixedID']['output']; }; @@ -2327,6 +2400,8 @@ export type Query = { me: UserAccount; metrics: Metrics; network: Network; + /** Network interfaces */ + networkInterfaces: Array; /** Get all notifications */ notifications: Notifications; /** Get the full OIDC configuration (admin only) */ @@ -2736,6 +2811,7 @@ export type Subscription = { systemMetricsCpu: CpuUtilization; systemMetricsCpuTelemetry: CpuPackages; systemMetricsMemory: MemoryUtilization; + systemMetricsNetwork: Array; systemMetricsTemperature?: Maybe; upsUpdates: UpsDevice; };