From 0b27126eef4c1696f3ccabeb5e8b8c8c7868d82a Mon Sep 17 00:00:00 2001 From: Richard <61517945+rkozyak@users.noreply.github.com> Date: Thu, 4 Jun 2026 08:18:52 -0400 Subject: [PATCH 1/7] feat(api): add docker restart mutation --- .../docker/docker.mutations.resolver.spec.ts | 22 ++++++++++++++++ .../docker/docker.mutations.resolver.ts | 9 +++++++ .../resolvers/docker/docker.service.spec.ts | 1 + .../graph/resolvers/docker/docker.service.ts | 26 +++++++++++++++++++ 4 files changed, 58 insertions(+) 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..a99edd1f56 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(), }; 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..c71e2dbde7 100644 --- a/api/src/unraid-api/graph/resolvers/docker/docker.service.ts +++ b/api/src/unraid-api/graph/resolvers/docker/docker.service.ts @@ -299,6 +299,32 @@ export class DockerService { return updatedContainer; } + public async restart(id: string): Promise { + const container = this.client.getContainer(id); + await container.restart({ t: 10 }); + + 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 restart attempt ${i + 1}: ${updatedContainer?.state}` + ); + if (updatedContainer?.state === ContainerState.RUNNING) { + break; + } + } + + if (!updatedContainer) { + throw new Error(`Container ${id} not found after restarting`); + } + const appInfo = await this.getAppInfo(); + await pubsub.publish(PUBSUB_CHANNEL.INFO, appInfo); + return updatedContainer; + } + public async unpause(id: string): Promise { const container = this.client.getContainer(id); await container.unpause(); From 6173ddc46ac0a94f925cfcf750a836a95a068458 Mon Sep 17 00:00:00 2001 From: Richard <61517945+rkozyak@users.noreply.github.com> Date: Thu, 4 Jun 2026 08:55:24 -0400 Subject: [PATCH 2/7] reset restart mock --- api/src/unraid-api/graph/resolvers/docker/docker.service.spec.ts | 1 + 1 file changed, 1 insertion(+) 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 a99edd1f56..140773ed90 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 @@ -176,6 +176,7 @@ describe('DockerService', () => { mockContainer.stop.mockReset(); mockContainer.pause.mockReset(); mockContainer.unpause.mockReset(); + mockContainer.restart.mockReset(); mockContainer.inspect.mockReset(); statMock.mockReset(); From 70c61f66cd985e5bff5bc5055fa8cf76d1d9b650 Mon Sep 17 00:00:00 2001 From: Richard <61517945+rkozyak@users.noreply.github.com> Date: Thu, 4 Jun 2026 12:43:12 -0400 Subject: [PATCH 3/7] increase polling duration and added extra tests --- .../resolvers/docker/docker.service.spec.ts | 49 +++++++++++++++++++ .../graph/resolvers/docker/docker.service.ts | 4 +- 2 files changed, 52 insertions(+), 1 deletion(-) 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 140773ed90..0f972b01ab 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 @@ -78,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: [], @@ -377,4 +381,49 @@ 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); + + const result = await service.restart('abc123'); + + expect(mockContainer.restart).toHaveBeenCalledWith({ t: 10 }); + expect(result).toMatchObject({ id: 'abc123', state: ContainerState.RUNNING }); + expect(pubsub.publish).toHaveBeenCalledWith(PUBSUB_CHANNEL.INFO, expect.anything()); + }); + + it('throws when the container cannot be found after restart', async () => { + mockListContainers.mockResolvedValue([]); + mockContainer.restart.mockResolvedValue(undefined); + + 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); + + const result = await service.restart('abc123'); + + expect(result).toMatchObject({ id: 'abc123', state: ContainerState.EXITED }); + expect(pubsub.publish).toHaveBeenCalledWith(PUBSUB_CHANNEL.INFO, expect.anything()); + }); + }); }); 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 c71e2dbde7..ccc1370670 100644 --- a/api/src/unraid-api/graph/resolvers/docker/docker.service.ts +++ b/api/src/unraid-api/graph/resolvers/docker/docker.service.ts @@ -305,7 +305,7 @@ export class DockerService { let containers: DockerContainer[]; let updatedContainer: DockerContainer | undefined; - for (let i = 0; i < 5; i++) { + for (let i = 0; i < 20; i++) { await sleep(500); containers = await this.getContainers(); updatedContainer = containers.find((c) => c.id === id); @@ -319,6 +319,8 @@ export class DockerService { if (!updatedContainer) { throw new Error(`Container ${id} not found after restarting`); + } else if (updatedContainer.state !== 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); From 11c2d77d45746ecb9af5e5bc4ceadf5205ffc5c8 Mon Sep 17 00:00:00 2001 From: Richard <61517945+rkozyak@users.noreply.github.com> Date: Thu, 4 Jun 2026 13:01:18 -0400 Subject: [PATCH 4/7] clear mock between tests --- .../unraid-api/graph/resolvers/docker/docker.service.spec.ts | 2 ++ 1 file changed, 2 insertions(+) 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 0f972b01ab..c86cc5f391 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 @@ -173,6 +173,8 @@ describe('DockerService', () => { let service: DockerService; beforeEach(async () => { + (pubsub.publish as ReturnType).mockClear(); + // Reset mocks before each test mockListContainers.mockReset(); mockListNetworks.mockReset(); From a63498fdccf0ee3488e21e1dea5587094f560cf1 Mon Sep 17 00:00:00 2001 From: Richard <61517945+rkozyak@users.noreply.github.com> Date: Thu, 4 Jun 2026 16:02:49 -0400 Subject: [PATCH 5/7] regenerated schema --- api/dev/configs/api.json | 4 +- api/generated-schema.graphql | 336 +++++++---------------------------- 2 files changed, 65 insertions(+), 275 deletions(-) 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/generated-schema.graphql b/api/generated-schema.graphql index db6919a533..d5a2edefad 100644 --- a/api/generated-schema.graphql +++ b/api/generated-schema.graphql @@ -1210,6 +1210,16 @@ type ArrayMutations { input ArrayStateInput { """Array state""" desiredState: ArrayStateInputState! + + """ + Optional password used to unlock encrypted array disks when starting the array + """ + decryptionPassword: String + + """ + Optional keyfile contents used to unlock encrypted array disks when starting the array. Accepts a data URL or raw base64 payload. + """ + decryptionKeyfile: String } enum ArrayStateInputState { @@ -1232,6 +1242,9 @@ type DockerMutations { """Stop a container""" stop(id: PrefixedID!): DockerContainer! + """Restart a container""" + restart(id: PrefixedID!): DockerContainer! + """Pause (Suspend) a container""" pause(id: PrefixedID!): DockerContainer! @@ -2514,6 +2527,52 @@ type LogFileContent { startLine: Int } +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! +} + type TemperatureReading { """Temperature value""" value: Float! @@ -2614,52 +2673,6 @@ 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! @@ -3106,160 +3119,6 @@ type Plugin { hasCliModule: Boolean } -type AccessUrl { - type: URL_TYPE! - name: String - ipv4: URL - ipv6: URL -} - -enum URL_TYPE { - LAN - WIREGUARD - WAN - MDNS - OTHER - DEFAULT -} - -""" -A field whose value conforms to the standard URL format as specified in RFC3986: https://www.ietf.org/rfc/rfc3986.txt. -""" -scalar URL - -type AccessUrlObject { - ipv4: String - ipv6: String - type: URL_TYPE! - name: String -} - -type ApiKeyResponse { - valid: Boolean! - error: String -} - -type MinigraphqlResponse { - status: MinigraphStatus! - timeout: Int - error: String -} - -"""The status of the minigraph""" -enum MinigraphStatus { - PRE_INIT - CONNECTING - CONNECTED - PING_FAILURE - ERROR_RETRYING -} - -type CloudResponse { - status: String! - ip: String - error: String -} - -type RelayResponse { - status: String! - timeout: String - error: String -} - -type Cloud { - error: String - apiKey: ApiKeyResponse! - relay: RelayResponse - minigraphql: MinigraphqlResponse! - cloud: CloudResponse! - allowedOrigins: [String!]! -} - -type RemoteAccess { - """The type of WAN access used for Remote Access""" - accessType: WAN_ACCESS_TYPE! - - """The type of port forwarding used for Remote Access""" - forwardType: WAN_FORWARD_TYPE - - """The port used for Remote Access""" - port: Int -} - -enum WAN_ACCESS_TYPE { - DYNAMIC - ALWAYS - DISABLED -} - -enum WAN_FORWARD_TYPE { - UPNP - STATIC -} - -type DynamicRemoteAccessStatus { - """The type of dynamic remote access that is enabled""" - enabledType: DynamicRemoteAccessType! - - """The type of dynamic remote access that is currently running""" - runningType: DynamicRemoteAccessType! - - """Any error message associated with the dynamic remote access""" - error: String -} - -enum DynamicRemoteAccessType { - STATIC - UPNP - DISABLED -} - -type ConnectSettingsValues { - """The type of WAN access used for Remote Access""" - accessType: WAN_ACCESS_TYPE! - - """The type of port forwarding used for Remote Access""" - forwardType: WAN_FORWARD_TYPE - - """The port used for Remote Access""" - port: Int -} - -type ConnectSettings implements Node { - id: PrefixedID! - - """The data schema for the Connect settings""" - dataSchema: JSON! - - """The UI schema for the Connect settings""" - uiSchema: JSON! - - """The values for the Connect settings""" - values: ConnectSettingsValues! -} - -type Connect implements Node { - id: PrefixedID! - - """The status of dynamic remote access""" - dynamicRemoteAccess: DynamicRemoteAccessStatus! - - """The settings for the Connect instance""" - settings: ConnectSettings! -} - -type Network implements Node { - id: PrefixedID! - accessUrls: [AccessUrl!] -} - -input AccessUrlObjectInput { - ipv4: String - ipv6: String - type: URL_TYPE! - name: String -} - "\n### Description:\n\nID scalar type that prefixes the underlying ID with the server identifier on output and strips it on input.\n\nWe use this scalar type to ensure that the ID is unique across all servers, allowing the same underlying resource ID to be used across different server instances.\n\n#### Input Behavior:\n\nWhen providing an ID as input (e.g., in arguments or input objects), the server identifier prefix (':') is optional.\n\n- If the prefix is present (e.g., '123:456'), it will be automatically stripped, and only the underlying ID ('456') will be used internally.\n- If the prefix is absent (e.g., '456'), the ID will be used as-is.\n\nThis makes it flexible for clients, as they don't strictly need to know or provide the server ID.\n\n#### Output Behavior:\n\nWhen an ID is returned in the response (output), it will *always* be prefixed with the current server's unique identifier (e.g., '123:456').\n\n#### Example:\n\nNote: The server identifier is '123' in this example.\n\n##### Input (Prefix Optional):\n```graphql\n# Both of these are valid inputs resolving to internal ID '456'\n{\n someQuery(id: \"123:456\") { ... }\n anotherQuery(id: \"456\") { ... }\n}\n```\n\n##### Output (Prefix Always Added):\n```graphql\n# Assuming internal ID is '456'\n{\n \"data\": {\n \"someResource\": {\n \"id\": \"123:456\" \n }\n }\n}\n```\n " scalar PrefixedID @@ -3315,6 +3174,9 @@ type Query { isFreshInstall: Boolean! publicTheme: Theme! info: Info! + + """Network interfaces""" + networkInterfaces: [InfoNetworkInterface!]! docker: Docker! disks: [Disk!]! assignableDisks: [Disk!]! @@ -3339,9 +3201,6 @@ 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""" @@ -3364,10 +3223,6 @@ type Query { """List all installed plugins with their metadata""" plugins: [Plugin!]! - remoteAccess: RemoteAccess! - connect: Connect! - network: Network! - cloud: Cloud! } type Mutation { @@ -3442,11 +3297,6 @@ type Mutation { Remove one or more plugins from the API. Returns false if restart was triggered automatically, true if manual restart is required. """ removePlugin(input: PluginManagementInput!): Boolean! - updateApiSettings(input: ConnectSettingsInput!): ConnectSettingsValues! - connectSignIn(input: ConnectSignInInput!): Boolean! - connectSignOut: Boolean! - setupRemoteAccess(input: SetupRemoteAccessInput!): Boolean! - enableDynamicRemoteAccess(input: EnableDynamicRemoteAccessInput!): Boolean! } input NotificationData { @@ -3632,66 +3482,6 @@ input PluginManagementInput { restart: Boolean! = true } -input ConnectSettingsInput { - """The type of WAN access to use for Remote Access""" - accessType: WAN_ACCESS_TYPE - - """The type of port forwarding to use for Remote Access""" - forwardType: WAN_FORWARD_TYPE - - """ - The port to use for Remote Access. Not required for UPNP forwardType. Required for STATIC forwardType. Ignored if accessType is DISABLED or forwardType is UPNP. - """ - port: Int -} - -input ConnectSignInInput { - """The API key for authentication""" - apiKey: String! - - """User information for the sign-in""" - userInfo: ConnectUserInfoInput -} - -input ConnectUserInfoInput { - """The preferred username of the user""" - preferred_username: String! - - """The email address of the user""" - email: String! - - """The avatar URL of the user""" - avatar: String -} - -input SetupRemoteAccessInput { - """The type of WAN access to use for Remote Access""" - accessType: WAN_ACCESS_TYPE! - - """The type of port forwarding to use for Remote Access""" - forwardType: WAN_FORWARD_TYPE - - """ - The port to use for Remote Access. Not required for UPNP forwardType. Required for STATIC forwardType. Ignored if accessType is DISABLED or forwardType is UPNP. - """ - port: Int -} - -input EnableDynamicRemoteAccessInput { - """The AccessURL Input for dynamic remote access""" - url: AccessUrlInput! - - """Whether to enable or disable dynamic remote access""" - enabled: Boolean! -} - -input AccessUrlInput { - type: URL_TYPE! - name: String - ipv4: URL - ipv6: URL -} - type Subscription { displaySubscription: InfoDisplay! notificationAdded: Notification! @@ -3710,4 +3500,4 @@ type Subscription { systemMetricsTemperature: TemperatureMetrics upsUpdates: UPSDevice! pluginInstallUpdates(operationId: ID!): PluginInstallEvent! -} +} \ No newline at end of file From e24cf891fbf6540c8d73c149efcaa4b6a9a414eb Mon Sep 17 00:00:00 2001 From: Richard <61517945+rkozyak@users.noreply.github.com> Date: Thu, 4 Jun 2026 17:01:13 -0400 Subject: [PATCH 6/7] perf(api): avoid full container list polling during docker mutations - Replaced getContainers() poll in stop/pause/restart/unpause with a single container.inspect() poll - Added waitForContainerState() that polls a container via inspect - Added finalizeMutation() so each mutation only calls getContainers() once - All docker tests pass - Verified on personal unraid server via api and in GraphQL sandbox for restart, start, and stop mutations --- .../resolvers/docker/docker.service.spec.ts | 28 ++++ .../graph/resolvers/docker/docker.service.ts | 144 ++++++++---------- 2 files changed, 90 insertions(+), 82 deletions(-) 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 c86cc5f391..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 @@ -403,17 +403,32 @@ describe('DockerService', () => { 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(); }); @@ -421,11 +436,24 @@ describe('DockerService', () => { 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 ccc1370670..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,108 +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(); - - 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`); - } - const appInfo = await this.getAppInfo(); - await pubsub.publish(PUBSUB_CHANNEL.INFO, appInfo); - return updatedContainer; + await this.waitForContainerState(container, id, ContainerState.PAUSED, 'pause', 5); + return this.finalizeMutation(id, 'pausing'); } public async restart(id: string): Promise { const container = this.client.getContainer(id); await container.restart({ t: 10 }); - - let containers: DockerContainer[]; - let updatedContainer: DockerContainer | undefined; - for (let i = 0; i < 20; i++) { - await sleep(500); - containers = await this.getContainers(); - updatedContainer = containers.find((c) => c.id === id); - this.logger.debug( - `Container ${id} state after restart attempt ${i + 1}: ${updatedContainer?.state}` - ); - if (updatedContainer?.state === ContainerState.RUNNING) { - break; - } - } - - if (!updatedContainer) { - throw new Error(`Container ${id} not found after restarting`); - } else if (updatedContainer.state !== ContainerState.RUNNING) { + 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; } From 0284210e0989e4664773f9d43390dc6ac0a37c2c Mon Sep 17 00:00:00 2001 From: Richard <61517945+rkozyak@users.noreply.github.com> Date: Fri, 5 Jun 2026 07:30:16 -0400 Subject: [PATCH 7/7] restore generated schema Restore generated-schema.graphql as it was generated locally on macOS without the connect plugin. Regenerated codegen output from original schema --- api/generated-schema.graphql | 336 ++++++++++++++++---- api/src/unraid-api/cli/generated/graphql.ts | 85 +++++ web/src/composables/gql/graphql.ts | 76 +++++ 3 files changed, 434 insertions(+), 63 deletions(-) diff --git a/api/generated-schema.graphql b/api/generated-schema.graphql index d5a2edefad..db6919a533 100644 --- a/api/generated-schema.graphql +++ b/api/generated-schema.graphql @@ -1210,16 +1210,6 @@ type ArrayMutations { input ArrayStateInput { """Array state""" desiredState: ArrayStateInputState! - - """ - Optional password used to unlock encrypted array disks when starting the array - """ - decryptionPassword: String - - """ - Optional keyfile contents used to unlock encrypted array disks when starting the array. Accepts a data URL or raw base64 payload. - """ - decryptionKeyfile: String } enum ArrayStateInputState { @@ -1242,9 +1232,6 @@ type DockerMutations { """Stop a container""" stop(id: PrefixedID!): DockerContainer! - """Restart a container""" - restart(id: PrefixedID!): DockerContainer! - """Pause (Suspend) a container""" pause(id: PrefixedID!): DockerContainer! @@ -2527,52 +2514,6 @@ type LogFileContent { startLine: Int } -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! -} - type TemperatureReading { """Temperature value""" value: Float! @@ -2673,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! @@ -3119,6 +3106,160 @@ type Plugin { hasCliModule: Boolean } +type AccessUrl { + type: URL_TYPE! + name: String + ipv4: URL + ipv6: URL +} + +enum URL_TYPE { + LAN + WIREGUARD + WAN + MDNS + OTHER + DEFAULT +} + +""" +A field whose value conforms to the standard URL format as specified in RFC3986: https://www.ietf.org/rfc/rfc3986.txt. +""" +scalar URL + +type AccessUrlObject { + ipv4: String + ipv6: String + type: URL_TYPE! + name: String +} + +type ApiKeyResponse { + valid: Boolean! + error: String +} + +type MinigraphqlResponse { + status: MinigraphStatus! + timeout: Int + error: String +} + +"""The status of the minigraph""" +enum MinigraphStatus { + PRE_INIT + CONNECTING + CONNECTED + PING_FAILURE + ERROR_RETRYING +} + +type CloudResponse { + status: String! + ip: String + error: String +} + +type RelayResponse { + status: String! + timeout: String + error: String +} + +type Cloud { + error: String + apiKey: ApiKeyResponse! + relay: RelayResponse + minigraphql: MinigraphqlResponse! + cloud: CloudResponse! + allowedOrigins: [String!]! +} + +type RemoteAccess { + """The type of WAN access used for Remote Access""" + accessType: WAN_ACCESS_TYPE! + + """The type of port forwarding used for Remote Access""" + forwardType: WAN_FORWARD_TYPE + + """The port used for Remote Access""" + port: Int +} + +enum WAN_ACCESS_TYPE { + DYNAMIC + ALWAYS + DISABLED +} + +enum WAN_FORWARD_TYPE { + UPNP + STATIC +} + +type DynamicRemoteAccessStatus { + """The type of dynamic remote access that is enabled""" + enabledType: DynamicRemoteAccessType! + + """The type of dynamic remote access that is currently running""" + runningType: DynamicRemoteAccessType! + + """Any error message associated with the dynamic remote access""" + error: String +} + +enum DynamicRemoteAccessType { + STATIC + UPNP + DISABLED +} + +type ConnectSettingsValues { + """The type of WAN access used for Remote Access""" + accessType: WAN_ACCESS_TYPE! + + """The type of port forwarding used for Remote Access""" + forwardType: WAN_FORWARD_TYPE + + """The port used for Remote Access""" + port: Int +} + +type ConnectSettings implements Node { + id: PrefixedID! + + """The data schema for the Connect settings""" + dataSchema: JSON! + + """The UI schema for the Connect settings""" + uiSchema: JSON! + + """The values for the Connect settings""" + values: ConnectSettingsValues! +} + +type Connect implements Node { + id: PrefixedID! + + """The status of dynamic remote access""" + dynamicRemoteAccess: DynamicRemoteAccessStatus! + + """The settings for the Connect instance""" + settings: ConnectSettings! +} + +type Network implements Node { + id: PrefixedID! + accessUrls: [AccessUrl!] +} + +input AccessUrlObjectInput { + ipv4: String + ipv6: String + type: URL_TYPE! + name: String +} + "\n### Description:\n\nID scalar type that prefixes the underlying ID with the server identifier on output and strips it on input.\n\nWe use this scalar type to ensure that the ID is unique across all servers, allowing the same underlying resource ID to be used across different server instances.\n\n#### Input Behavior:\n\nWhen providing an ID as input (e.g., in arguments or input objects), the server identifier prefix (':') is optional.\n\n- If the prefix is present (e.g., '123:456'), it will be automatically stripped, and only the underlying ID ('456') will be used internally.\n- If the prefix is absent (e.g., '456'), the ID will be used as-is.\n\nThis makes it flexible for clients, as they don't strictly need to know or provide the server ID.\n\n#### Output Behavior:\n\nWhen an ID is returned in the response (output), it will *always* be prefixed with the current server's unique identifier (e.g., '123:456').\n\n#### Example:\n\nNote: The server identifier is '123' in this example.\n\n##### Input (Prefix Optional):\n```graphql\n# Both of these are valid inputs resolving to internal ID '456'\n{\n someQuery(id: \"123:456\") { ... }\n anotherQuery(id: \"456\") { ... }\n}\n```\n\n##### Output (Prefix Always Added):\n```graphql\n# Assuming internal ID is '456'\n{\n \"data\": {\n \"someResource\": {\n \"id\": \"123:456\" \n }\n }\n}\n```\n " scalar PrefixedID @@ -3174,9 +3315,6 @@ type Query { isFreshInstall: Boolean! publicTheme: Theme! info: Info! - - """Network interfaces""" - networkInterfaces: [InfoNetworkInterface!]! docker: Docker! disks: [Disk!]! assignableDisks: [Disk!]! @@ -3201,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""" @@ -3223,6 +3364,10 @@ type Query { """List all installed plugins with their metadata""" plugins: [Plugin!]! + remoteAccess: RemoteAccess! + connect: Connect! + network: Network! + cloud: Cloud! } type Mutation { @@ -3297,6 +3442,11 @@ type Mutation { Remove one or more plugins from the API. Returns false if restart was triggered automatically, true if manual restart is required. """ removePlugin(input: PluginManagementInput!): Boolean! + updateApiSettings(input: ConnectSettingsInput!): ConnectSettingsValues! + connectSignIn(input: ConnectSignInInput!): Boolean! + connectSignOut: Boolean! + setupRemoteAccess(input: SetupRemoteAccessInput!): Boolean! + enableDynamicRemoteAccess(input: EnableDynamicRemoteAccessInput!): Boolean! } input NotificationData { @@ -3482,6 +3632,66 @@ input PluginManagementInput { restart: Boolean! = true } +input ConnectSettingsInput { + """The type of WAN access to use for Remote Access""" + accessType: WAN_ACCESS_TYPE + + """The type of port forwarding to use for Remote Access""" + forwardType: WAN_FORWARD_TYPE + + """ + The port to use for Remote Access. Not required for UPNP forwardType. Required for STATIC forwardType. Ignored if accessType is DISABLED or forwardType is UPNP. + """ + port: Int +} + +input ConnectSignInInput { + """The API key for authentication""" + apiKey: String! + + """User information for the sign-in""" + userInfo: ConnectUserInfoInput +} + +input ConnectUserInfoInput { + """The preferred username of the user""" + preferred_username: String! + + """The email address of the user""" + email: String! + + """The avatar URL of the user""" + avatar: String +} + +input SetupRemoteAccessInput { + """The type of WAN access to use for Remote Access""" + accessType: WAN_ACCESS_TYPE! + + """The type of port forwarding to use for Remote Access""" + forwardType: WAN_FORWARD_TYPE + + """ + The port to use for Remote Access. Not required for UPNP forwardType. Required for STATIC forwardType. Ignored if accessType is DISABLED or forwardType is UPNP. + """ + port: Int +} + +input EnableDynamicRemoteAccessInput { + """The AccessURL Input for dynamic remote access""" + url: AccessUrlInput! + + """Whether to enable or disable dynamic remote access""" + enabled: Boolean! +} + +input AccessUrlInput { + type: URL_TYPE! + name: String + ipv4: URL + ipv6: URL +} + type Subscription { displaySubscription: InfoDisplay! notificationAdded: Notification! @@ -3500,4 +3710,4 @@ type Subscription { systemMetricsTemperature: TemperatureMetrics upsUpdates: UPSDevice! pluginInstallUpdates(operationId: ID!): PluginInstallEvent! -} \ 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/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; };