Skip to content

Commit b58120c

Browse files
rkozyakelibosley
andauthored
feat: add docker restart mutation (#2022)
Adds a mutation to restart docker containers. Work Intent approved by SimonF CLD-411 <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Restart Docker containers via the API with permission checks, automatic polling/retry to restore running state, and app status published after restart. * GraphQL schema updated: added restart mutation and networkInterfaces; extended container input for disk decryption fields. * **Tests** * Added unit tests for the restart flow and strengthened mocks/timing control to ensure isolated, reliable behavior. * **Chores** * API version bumped (dev config). <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Eli Bosley <eli@bosley.dev>
1 parent a9625ae commit b58120c

8 files changed

Lines changed: 426 additions & 110 deletions

File tree

‎api/dev/configs/api.json‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
{
2-
"version": "4.29.2",
2+
"version": "4.35.0",
33
"extraOrigins": [],
44
"sandbox": false,
55
"ssoSubIds": [],
66
"plugins": [
77
"unraid-api-plugin-connect"
88
]
9-
}
9+
}

‎api/generated-schema.graphql‎

Lines changed: 62 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -1210,6 +1210,16 @@ type ArrayMutations {
12101210
input ArrayStateInput {
12111211
"""Array state"""
12121212
desiredState: ArrayStateInputState!
1213+
1214+
"""
1215+
Optional password used to unlock encrypted array disks when starting the array
1216+
"""
1217+
decryptionPassword: String
1218+
1219+
"""
1220+
Optional keyfile contents used to unlock encrypted array disks when starting the array. Accepts a data URL or raw base64 payload.
1221+
"""
1222+
decryptionKeyfile: String
12131223
}
12141224

12151225
enum ArrayStateInputState {
@@ -1232,6 +1242,9 @@ type DockerMutations {
12321242
"""Stop a container"""
12331243
stop(id: PrefixedID!): DockerContainer!
12341244

1245+
"""Restart a container"""
1246+
restart(id: PrefixedID!): DockerContainer!
1247+
12351248
"""Pause (Suspend) a container"""
12361249
pause(id: PrefixedID!): DockerContainer!
12371250

@@ -2514,6 +2527,52 @@ type LogFileContent {
25142527
startLine: Int
25152528
}
25162529

2530+
type NetworkMetrics implements Node {
2531+
id: PrefixedID!
2532+
2533+
"""Interface identifier"""
2534+
name: String!
2535+
2536+
"""Operational state"""
2537+
operstate: String
2538+
2539+
"""Total received bytes"""
2540+
bytesReceived: BigInt!
2541+
2542+
"""Total transmitted bytes"""
2543+
bytesSent: BigInt!
2544+
2545+
"""Total received packets"""
2546+
packetsReceived: BigInt!
2547+
2548+
"""Total transmitted packets"""
2549+
packetsSent: BigInt!
2550+
2551+
"""Receive errors"""
2552+
receiveErrors: BigInt!
2553+
2554+
"""Transmit errors"""
2555+
transmitErrors: BigInt!
2556+
2557+
"""Dropped receive packets"""
2558+
receiveDropped: BigInt!
2559+
2560+
"""Dropped transmit packets"""
2561+
transmitDropped: BigInt!
2562+
2563+
"""Receive throughput in bytes per second"""
2564+
rxSec: Float!
2565+
2566+
"""Transmit throughput in bytes per second"""
2567+
txSec: Float!
2568+
2569+
"""Estimated link utilization percentage"""
2570+
utilizationPercent: Float
2571+
2572+
"""Metric collection timestamp"""
2573+
lastUpdated: DateTime!
2574+
}
2575+
25172576
type TemperatureReading {
25182577
"""Temperature value"""
25192578
value: Float!
@@ -2614,52 +2673,6 @@ type TemperatureMetrics implements Node {
26142673
summary: TemperatureSummary!
26152674
}
26162675

2617-
type NetworkMetrics implements Node {
2618-
id: PrefixedID!
2619-
2620-
"""Interface identifier"""
2621-
name: String!
2622-
2623-
"""Operational state"""
2624-
operstate: String
2625-
2626-
"""Total received bytes"""
2627-
bytesReceived: BigInt!
2628-
2629-
"""Total transmitted bytes"""
2630-
bytesSent: BigInt!
2631-
2632-
"""Total received packets"""
2633-
packetsReceived: BigInt!
2634-
2635-
"""Total transmitted packets"""
2636-
packetsSent: BigInt!
2637-
2638-
"""Receive errors"""
2639-
receiveErrors: BigInt!
2640-
2641-
"""Transmit errors"""
2642-
transmitErrors: BigInt!
2643-
2644-
"""Dropped receive packets"""
2645-
receiveDropped: BigInt!
2646-
2647-
"""Dropped transmit packets"""
2648-
transmitDropped: BigInt!
2649-
2650-
"""Receive throughput in bytes per second"""
2651-
rxSec: Float!
2652-
2653-
"""Transmit throughput in bytes per second"""
2654-
txSec: Float!
2655-
2656-
"""Estimated link utilization percentage"""
2657-
utilizationPercent: Float
2658-
2659-
"""Metric collection timestamp"""
2660-
lastUpdated: DateTime!
2661-
}
2662-
26632676
"""System metrics including CPU and memory utilization"""
26642677
type Metrics implements Node {
26652678
id: PrefixedID!
@@ -3315,6 +3328,9 @@ type Query {
33153328
isFreshInstall: Boolean!
33163329
publicTheme: Theme!
33173330
info: Info!
3331+
3332+
"""Network interfaces"""
3333+
networkInterfaces: [InfoNetworkInterface!]!
33183334
docker: Docker!
33193335
disks: [Disk!]!
33203336
assignableDisks: [Disk!]!
@@ -3339,9 +3355,6 @@ type Query {
33393355

33403356
"""Validate an OIDC session token (internal use for CLI validation)"""
33413357
validateOidcSession(token: String!): OidcSessionValidation!
3342-
3343-
"""Network interfaces"""
3344-
networkInterfaces: [InfoNetworkInterface!]!
33453358
metrics: Metrics!
33463359

33473360
"""Retrieve current system time configuration"""

‎api/src/unraid-api/cli/generated/graphql.ts‎

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -366,6 +366,10 @@ export enum ArrayState {
366366
}
367367

368368
export type ArrayStateInput = {
369+
/** Optional keyfile contents used to unlock encrypted array disks when starting the array. Accepts a data URL or raw base64 payload. */
370+
decryptionKeyfile?: InputMaybe<Scalars['String']['input']>;
371+
/** Optional password used to unlock encrypted array disks when starting the array */
372+
decryptionPassword?: InputMaybe<Scalars['String']['input']>;
369373
/** Array state */
370374
desiredState: ArrayStateInputState;
371375
};
@@ -948,6 +952,8 @@ export type DockerMutations = {
948952
pause: DockerContainer;
949953
/** Remove a container */
950954
removeContainer: Scalars['Boolean']['output'];
955+
/** Restart a container */
956+
restart: DockerContainer;
951957
/** Start a container */
952958
start: DockerContainer;
953959
/** Stop a container */
@@ -976,6 +982,11 @@ export type DockerMutationsRemoveContainerArgs = {
976982
};
977983

978984

985+
export type DockerMutationsRestartArgs = {
986+
id: Scalars['PrefixedID']['input'];
987+
};
988+
989+
979990
export type DockerMutationsStartArgs = {
980991
id: Scalars['PrefixedID']['input'];
981992
};
@@ -1311,31 +1322,69 @@ export type InfoNetworkInterface = Node & {
13111322
__typename?: 'InfoNetworkInterface';
13121323
/** Interface description/label */
13131324
description?: Maybe<Scalars['String']['output']>;
1325+
/** Link duplex mode */
1326+
duplex?: Maybe<Scalars['String']['output']>;
13141327
/** IPv4 Gateway */
13151328
gateway?: Maybe<Scalars['String']['output']>;
13161329
id: Scalars['PrefixedID']['output'];
1330+
/** Whether this is an internal interface */
1331+
internal?: Maybe<Scalars['Boolean']['output']>;
13171332
/** IPv4 Address */
13181333
ipAddress?: Maybe<Scalars['String']['output']>;
1334+
/** IPv4 addresses assigned to this interface */
1335+
ipv4Addresses: Array<InfoNetworkIpv4Address>;
13191336
/** IPv6 Address */
13201337
ipv6Address?: Maybe<Scalars['String']['output']>;
1338+
/** IPv6 addresses assigned to this interface */
1339+
ipv6Addresses: Array<InfoNetworkIpv6Address>;
13211340
/** IPv6 Gateway */
13221341
ipv6Gateway?: Maybe<Scalars['String']['output']>;
13231342
/** IPv6 Netmask */
13241343
ipv6Netmask?: Maybe<Scalars['String']['output']>;
13251344
/** MAC Address */
13261345
macAddress?: Maybe<Scalars['String']['output']>;
1346+
/** Maximum transmission unit */
1347+
mtu?: Maybe<Scalars['Int']['output']>;
13271348
/** Interface name (e.g. eth0) */
13281349
name: Scalars['String']['output'];
13291350
/** IPv4 Netmask */
13301351
netmask?: Maybe<Scalars['String']['output']>;
1352+
/** Operational state */
1353+
operstate?: Maybe<Scalars['String']['output']>;
13311354
/** IPv4 Protocol mode */
13321355
protocol?: Maybe<Scalars['String']['output']>;
1356+
/** Link speed in Mbps */
1357+
speed?: Maybe<Scalars['Int']['output']>;
13331358
/** Connection status */
13341359
status?: Maybe<Scalars['String']['output']>;
1360+
/** Interface type */
1361+
type?: Maybe<Scalars['String']['output']>;
13351362
/** Using DHCP for IPv4 */
13361363
useDhcp?: Maybe<Scalars['Boolean']['output']>;
13371364
/** Using DHCP for IPv6 */
13381365
useDhcp6?: Maybe<Scalars['Boolean']['output']>;
1366+
/** Whether this is a virtual interface */
1367+
virtual?: Maybe<Scalars['Boolean']['output']>;
1368+
/** VLAN identifier parsed from the interface name */
1369+
vlanId?: Maybe<Scalars['Int']['output']>;
1370+
};
1371+
1372+
/** IPv4 address assigned to a network interface */
1373+
export type InfoNetworkIpv4Address = {
1374+
__typename?: 'InfoNetworkIpv4Address';
1375+
/** IPv4 address */
1376+
address: Scalars['String']['output'];
1377+
/** IPv4 netmask */
1378+
netmask: Scalars['String']['output'];
1379+
};
1380+
1381+
/** IPv6 address assigned to a network interface */
1382+
export type InfoNetworkIpv6Address = {
1383+
__typename?: 'InfoNetworkIpv6Address';
1384+
/** IPv6 address */
1385+
address: Scalars['String']['output'];
1386+
/** IPv6 prefix length */
1387+
prefixLength?: Maybe<Scalars['Int']['output']>;
13391388
};
13401389

13411390
export type InfoOs = Node & {
@@ -1576,6 +1625,8 @@ export type Metrics = Node & {
15761625
id: Scalars['PrefixedID']['output'];
15771626
/** Current memory utilization metrics */
15781627
memory?: Maybe<MemoryUtilization>;
1628+
/** Current network metrics for all interfaces */
1629+
network: Array<NetworkMetrics>;
15791630
/** Temperature metrics */
15801631
temperature?: Maybe<TemperatureMetrics>;
15811632
};
@@ -1827,6 +1878,39 @@ export type Network = Node & {
18271878
id: Scalars['PrefixedID']['output'];
18281879
};
18291880

1881+
export type NetworkMetrics = Node & {
1882+
__typename?: 'NetworkMetrics';
1883+
/** Total received bytes */
1884+
bytesReceived: Scalars['BigInt']['output'];
1885+
/** Total transmitted bytes */
1886+
bytesSent: Scalars['BigInt']['output'];
1887+
id: Scalars['PrefixedID']['output'];
1888+
/** Metric collection timestamp */
1889+
lastUpdated: Scalars['DateTime']['output'];
1890+
/** Interface identifier */
1891+
name: Scalars['String']['output'];
1892+
/** Operational state */
1893+
operstate?: Maybe<Scalars['String']['output']>;
1894+
/** Total received packets */
1895+
packetsReceived: Scalars['BigInt']['output'];
1896+
/** Total transmitted packets */
1897+
packetsSent: Scalars['BigInt']['output'];
1898+
/** Dropped receive packets */
1899+
receiveDropped: Scalars['BigInt']['output'];
1900+
/** Receive errors */
1901+
receiveErrors: Scalars['BigInt']['output'];
1902+
/** Receive throughput in bytes per second */
1903+
rxSec: Scalars['Float']['output'];
1904+
/** Dropped transmit packets */
1905+
transmitDropped: Scalars['BigInt']['output'];
1906+
/** Transmit errors */
1907+
transmitErrors: Scalars['BigInt']['output'];
1908+
/** Transmit throughput in bytes per second */
1909+
txSec: Scalars['Float']['output'];
1910+
/** Estimated link utilization percentage */
1911+
utilizationPercent?: Maybe<Scalars['Float']['output']>;
1912+
};
1913+
18301914
export type Node = {
18311915
id: Scalars['PrefixedID']['output'];
18321916
};
@@ -1985,12 +2069,21 @@ export type OnboardingInternalBootContext = {
19852069
assignableDisks: Array<Disk>;
19862070
bootEligible?: Maybe<Scalars['Boolean']['output']>;
19872071
bootedFromFlashWithInternalBootSetup: Scalars['Boolean']['output'];
2072+
driveWarnings: Array<OnboardingInternalBootDriveWarning>;
19882073
enableBootTransfer?: Maybe<Scalars['String']['output']>;
19892074
poolNames: Array<Scalars['String']['output']>;
19902075
reservedNames: Array<Scalars['String']['output']>;
19912076
shareNames: Array<Scalars['String']['output']>;
19922077
};
19932078

2079+
/** Warning metadata for an assignable internal boot drive */
2080+
export type OnboardingInternalBootDriveWarning = {
2081+
__typename?: 'OnboardingInternalBootDriveWarning';
2082+
device: Scalars['String']['output'];
2083+
diskId: Scalars['String']['output'];
2084+
warnings: Array<Scalars['String']['output']>;
2085+
};
2086+
19942087
/** Result of attempting internal boot pool setup */
19952088
export type OnboardingInternalBootResult = {
19962089
__typename?: 'OnboardingInternalBootResult';
@@ -2318,6 +2411,8 @@ export type Query = {
23182411
me: UserAccount;
23192412
metrics: Metrics;
23202413
network: Network;
2414+
/** Network interfaces */
2415+
networkInterfaces: Array<InfoNetworkInterface>;
23212416
/** Get all notifications */
23222417
notifications: Notifications;
23232418
/** Get the full OIDC configuration (admin only) */
@@ -2727,6 +2822,7 @@ export type Subscription = {
27272822
systemMetricsCpu: CpuUtilization;
27282823
systemMetricsCpuTelemetry: CpuPackages;
27292824
systemMetricsMemory: MemoryUtilization;
2825+
systemMetricsNetwork: Array<NetworkMetrics>;
27302826
systemMetricsTemperature?: Maybe<TemperatureMetrics>;
27312827
upsUpdates: UpsDevice;
27322828
};

‎api/src/unraid-api/graph/resolvers/docker/docker.mutations.resolver.spec.ts‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ describe('DockerMutationsResolver', () => {
2020
useValue: {
2121
start: vi.fn(),
2222
stop: vi.fn(),
23+
restart: vi.fn(),
2324
},
2425
},
2526
],
@@ -54,6 +55,27 @@ describe('DockerMutationsResolver', () => {
5455
expect(dockerService.start).toHaveBeenCalledWith('1');
5556
});
5657

58+
it('should restart', async () => {
59+
const mockContainer: DockerContainer = {
60+
id: '1',
61+
autoStart: false,
62+
command: 'test',
63+
created: 1234567890,
64+
image: 'test-image',
65+
imageId: 'test-image-id',
66+
ports: [],
67+
state: ContainerState.RUNNING,
68+
status: 'Up 2 seconds',
69+
names: ['test-container'],
70+
isOrphaned: false,
71+
};
72+
vi.mocked(dockerService.restart).mockResolvedValue(mockContainer);
73+
74+
const result = await resolver.restart('1');
75+
expect(result).toEqual(mockContainer);
76+
expect(dockerService.restart).toHaveBeenCalledWith('1');
77+
});
78+
5779
it('should stop', async () => {
5880
const mockContainer: DockerContainer = {
5981
id: '1',

0 commit comments

Comments
 (0)