Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions api/dev/configs/api.json
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
{
"version": "4.29.2",
"version": "4.35.0",
"extraOrigins": [],
"sandbox": false,
"ssoSubIds": [],
"plugins": [
"unraid-api-plugin-connect"
]
}
}
85 changes: 85 additions & 0 deletions api/src/unraid-api/cli/generated/graphql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1311,31 +1311,69 @@ export type InfoNetworkInterface = Node & {
__typename?: 'InfoNetworkInterface';
/** Interface description/label */
description?: Maybe<Scalars['String']['output']>;
/** Link duplex mode */
duplex?: Maybe<Scalars['String']['output']>;
/** IPv4 Gateway */
gateway?: Maybe<Scalars['String']['output']>;
id: Scalars['PrefixedID']['output'];
/** Whether this is an internal interface */
internal?: Maybe<Scalars['Boolean']['output']>;
/** IPv4 Address */
ipAddress?: Maybe<Scalars['String']['output']>;
/** IPv4 addresses assigned to this interface */
ipv4Addresses: Array<InfoNetworkIpv4Address>;
/** IPv6 Address */
ipv6Address?: Maybe<Scalars['String']['output']>;
/** IPv6 addresses assigned to this interface */
ipv6Addresses: Array<InfoNetworkIpv6Address>;
/** IPv6 Gateway */
ipv6Gateway?: Maybe<Scalars['String']['output']>;
/** IPv6 Netmask */
ipv6Netmask?: Maybe<Scalars['String']['output']>;
/** MAC Address */
macAddress?: Maybe<Scalars['String']['output']>;
/** Maximum transmission unit */
mtu?: Maybe<Scalars['Int']['output']>;
/** Interface name (e.g. eth0) */
name: Scalars['String']['output'];
/** IPv4 Netmask */
netmask?: Maybe<Scalars['String']['output']>;
/** Operational state */
operstate?: Maybe<Scalars['String']['output']>;
/** IPv4 Protocol mode */
protocol?: Maybe<Scalars['String']['output']>;
/** Link speed in Mbps */
speed?: Maybe<Scalars['Int']['output']>;
/** Connection status */
status?: Maybe<Scalars['String']['output']>;
/** Interface type */
type?: Maybe<Scalars['String']['output']>;
/** Using DHCP for IPv4 */
useDhcp?: Maybe<Scalars['Boolean']['output']>;
/** Using DHCP for IPv6 */
useDhcp6?: Maybe<Scalars['Boolean']['output']>;
/** Whether this is a virtual interface */
virtual?: Maybe<Scalars['Boolean']['output']>;
/** VLAN identifier parsed from the interface name */
vlanId?: Maybe<Scalars['Int']['output']>;
};

/** 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<Scalars['Int']['output']>;
};

export type InfoOs = Node & {
Expand Down Expand Up @@ -1576,6 +1614,8 @@ export type Metrics = Node & {
id: Scalars['PrefixedID']['output'];
/** Current memory utilization metrics */
memory?: Maybe<MemoryUtilization>;
/** Current network metrics for all interfaces */
network: Array<NetworkMetrics>;
/** Temperature metrics */
temperature?: Maybe<TemperatureMetrics>;
};
Expand Down Expand Up @@ -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<Scalars['String']['output']>;
/** 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<Scalars['Float']['output']>;
};

export type Node = {
id: Scalars['PrefixedID']['output'];
};
Expand Down Expand Up @@ -1985,12 +2058,21 @@ export type OnboardingInternalBootContext = {
assignableDisks: Array<Disk>;
bootEligible?: Maybe<Scalars['Boolean']['output']>;
bootedFromFlashWithInternalBootSetup: Scalars['Boolean']['output'];
driveWarnings: Array<OnboardingInternalBootDriveWarning>;
enableBootTransfer?: Maybe<Scalars['String']['output']>;
poolNames: Array<Scalars['String']['output']>;
reservedNames: Array<Scalars['String']['output']>;
shareNames: Array<Scalars['String']['output']>;
};

/** Warning metadata for an assignable internal boot drive */
export type OnboardingInternalBootDriveWarning = {
__typename?: 'OnboardingInternalBootDriveWarning';
device: Scalars['String']['output'];
diskId: Scalars['String']['output'];
warnings: Array<Scalars['String']['output']>;
};

/** Result of attempting internal boot pool setup */
export type OnboardingInternalBootResult = {
__typename?: 'OnboardingInternalBootResult';
Expand Down Expand Up @@ -2318,6 +2400,8 @@ export type Query = {
me: UserAccount;
metrics: Metrics;
network: Network;
/** Network interfaces */
networkInterfaces: Array<InfoNetworkInterface>;
/** Get all notifications */
notifications: Notifications;
/** Get the full OIDC configuration (admin only) */
Expand Down Expand Up @@ -2727,6 +2811,7 @@ export type Subscription = {
systemMetricsCpu: CpuUtilization;
systemMetricsCpuTelemetry: CpuPackages;
systemMetricsMemory: MemoryUtilization;
systemMetricsNetwork: Array<NetworkMetrics>;
systemMetricsTemperature?: Maybe<TemperatureMetrics>;
upsUpdates: UpsDevice;
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ describe('DockerMutationsResolver', () => {
useValue: {
start: vi.fn(),
stop: vi.fn(),
restart: vi.fn(),
},
},
],
Expand Down Expand Up @@ -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',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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' })
Comment thread
rkozyak marked this conversation as resolved.
@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,
Expand Down
81 changes: 81 additions & 0 deletions api/src/unraid-api/graph/resolvers/docker/docker.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ const { mockDockerInstance, mockListContainers, mockGetContainer, mockListNetwor
stop: vi.fn(),
pause: vi.fn(),
unpause: vi.fn(),
restart: vi.fn(),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
inspect: vi.fn(),
};

Expand Down Expand Up @@ -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: [],
Expand Down Expand Up @@ -168,13 +173,16 @@ describe('DockerService', () => {
let service: DockerService;

beforeEach(async () => {
(pubsub.publish as ReturnType<typeof vi.fn>).mockClear();

// Reset mocks before each test
mockListContainers.mockReset();
mockListNetworks.mockReset();
mockContainer.start.mockReset();
mockContainer.stop.mockReset();
mockContainer.pause.mockReset();
mockContainer.unpause.mockReset();
mockContainer.restart.mockReset();
mockContainer.inspect.mockReset();

statMock.mockReset();
Expand Down Expand Up @@ -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());
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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 });
});
});
});
Loading
Loading