implement pause / resume

This commit is contained in:
Pujit Mehrotra
2025-09-19 11:31:19 -04:00
parent 10f07e9b19
commit 693f4913f6
7 changed files with 284 additions and 0 deletions

View File

@@ -1,8 +1,11 @@
import { Field, ID, Int, ObjectType, registerEnumType } from '@nestjs/graphql';
import { type Layout } from '@jsonforms/core';
import { Node } from '@unraid/shared/graphql.model.js';
import { GraphQLBigInt, GraphQLJSON, GraphQLPort } from 'graphql-scalars';
import { DataSlice } from '@app/unraid-api/types/json-forms.js';
export enum ContainerPortType {
TCP = 'TCP',
UDP = 'UDP',
@@ -29,6 +32,7 @@ export class ContainerPort {
export enum ContainerState {
RUNNING = 'RUNNING',
PAUSED = 'PAUSED',
EXITED = 'EXITED',
}
@@ -172,3 +176,18 @@ export class Docker extends Node {
@Field(() => [DockerNetwork])
networks!: DockerNetwork[];
}
@ObjectType()
export class DockerContainerOverviewForm {
@Field(() => ID)
id!: string;
@Field(() => GraphQLJSON)
dataSchema!: { properties: DataSlice; type: 'object' };
@Field(() => GraphQLJSON)
uiSchema!: Layout;
@Field(() => GraphQLJSON)
data!: Record<string, any>;
}

View File

@@ -32,4 +32,20 @@ export class DockerMutationsResolver {
public async stop(@Args('id', { type: () => PrefixedID }) id: string) {
return this.dockerService.stop(id);
}
@ResolveField(() => DockerContainer, { description: 'Pause (Suspend) a container' })
@UsePermissions({
action: AuthAction.UPDATE_ANY,
resource: Resource.DOCKER,
})
public async pause(@Args('id', { type: () => PrefixedID }) id: string) {
return this.dockerService.pause(id);
}
@ResolveField(() => DockerContainer, { description: 'Unpause (Resume) a container' })
@UsePermissions({
action: AuthAction.UPDATE_ANY,
resource: Resource.DOCKER,
})
public async unpause(@Args('id', { type: () => PrefixedID }) id: string) {
return this.dockerService.unpause(id);
}
}

View File

@@ -243,4 +243,60 @@ export class DockerService {
await pubsub.publish(PUBSUB_CHANNEL.INFO, appInfo);
return updatedContainer;
}
public async pause(id: string): Promise<DockerContainer> {
const container = this.client.getContainer(id);
await container.pause();
await this.cacheManager.del(DockerService.CONTAINER_CACHE_KEY);
this.logger.debug(`Invalidated container cache after pausing ${id}`);
let containers = await this.getContainers({ skipCache: true });
let updatedContainer: DockerContainer | undefined;
for (let i = 0; i < 5; i++) {
await sleep(500);
containers = await this.getContainers({ skipCache: true });
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;
}
public async unpause(id: string): Promise<DockerContainer> {
const container = this.client.getContainer(id);
await container.unpause();
await this.cacheManager.del(DockerService.CONTAINER_CACHE_KEY);
this.logger.debug(`Invalidated container cache after unpausing ${id}`);
let containers = await this.getContainers({ skipCache: true });
let updatedContainer: DockerContainer | undefined;
for (let i = 0; i < 5; i++) {
await sleep(500);
containers = await this.getContainers({ skipCache: true });
updatedContainer = containers.find((c) => c.id === id);
this.logger.debug(
`Container ${id} state after unpause attempt ${i + 1}: ${updatedContainer?.state}`
);
if (updatedContainer?.state === ContainerState.RUNNING) {
break;
}
}
if (!updatedContainer) {
throw new Error(`Container ${id} not found after unpausing`);
}
const appInfo = await this.getAppInfo();
await pubsub.publish(PUBSUB_CHANNEL.INFO, appInfo);
return updatedContainer;
}
}

View File

@@ -7,9 +7,11 @@ import { GET_DOCKER_CONTAINERS } from '@/components/Docker/docker-containers.que
import { CREATE_DOCKER_FOLDER } from '@/components/Docker/docker-create-folder.mutation';
import { DELETE_DOCKER_ENTRIES } from '@/components/Docker/docker-delete-entries.mutation';
import { MOVE_DOCKER_ENTRIES_TO_FOLDER } from '@/components/Docker/docker-move-entries.mutation';
import { PAUSE_DOCKER_CONTAINER } from '@/components/Docker/docker-pause-container.mutation';
import { SET_DOCKER_FOLDER_CHILDREN } from '@/components/Docker/docker-set-folder-children.mutation';
import { START_DOCKER_CONTAINER } from '@/components/Docker/docker-start-container.mutation';
import { STOP_DOCKER_CONTAINER } from '@/components/Docker/docker-stop-container.mutation';
import { UNPAUSE_DOCKER_CONTAINER } from '@/components/Docker/docker-unpause-container.mutation';
import { ContainerState } from '@/composables/gql/graphql';
import type {
@@ -231,6 +233,7 @@ const columns = computed<TableColumn<TreeRow>[]>(() => {
const isBusy = busyRowIds.value.has(row.original.id);
const color = {
[ContainerState.RUNNING]: 'success' as const,
[ContainerState.PAUSED]: 'warning' as const,
[ContainerState.EXITED]: 'neutral' as const,
}[state];
if (isBusy) {
@@ -324,6 +327,8 @@ const { mutate: deleteEntriesMutation, loading: deleting } = useMutation(DELETE_
const { mutate: setFolderChildrenMutation } = useMutation(SET_DOCKER_FOLDER_CHILDREN);
const { mutate: startContainerMutation } = useMutation(START_DOCKER_CONTAINER);
const { mutate: stopContainerMutation } = useMutation(STOP_DOCKER_CONTAINER);
const { mutate: pauseContainerMutation } = useMutation(PAUSE_DOCKER_CONTAINER);
const { mutate: unpauseContainerMutation } = useMutation(UNPAUSE_DOCKER_CONTAINER);
const moveOpen = ref(false);
const selectedFolderId = ref<string>('');
@@ -430,6 +435,118 @@ async function handleRowStartStop(row: TreeRow) {
}
}
// Pause/Resume single row
async function handleRowPauseResume(row: TreeRow) {
if (row.type !== 'container') return;
const containerId = row.containerId || row.id;
if (!containerId) return;
setRowsBusy([row.id], true);
try {
const isPaused = row.state === ContainerState.PAUSED;
const mutate = isPaused ? unpauseContainerMutation : pauseContainerMutation;
await mutate(
{ id: containerId },
{
refetchQueries: [{ query: GET_DOCKER_CONTAINERS, variables: { skipCache: true } }],
awaitRefetchQueries: true,
}
);
} finally {
setRowsBusy([row.id], false);
}
}
// Bulk Pause/Resume handling with mixed confirmation
const confirmPauseResumeOpen = ref(false);
const confirmToPause = ref<{ name: string }[]>([]);
const confirmToResume = ref<{ name: string }[]>([]);
let pendingPauseResumeIds: string[] = [];
function classifyPauseResume(ids: string[]) {
const toPause: { id: string; containerId: string; name: string }[] = [];
const toResume: { id: string; containerId: string; name: string }[] = [];
for (const id of ids) {
const row = getRowById(id);
if (!row || row.type !== 'container') continue;
const containerId = row.containerId || row.id;
const state = row.state as string | undefined;
const name = row.name;
if (state === ContainerState.PAUSED) toResume.push({ id, containerId, name });
else if (state === ContainerState.RUNNING) toPause.push({ id, containerId, name });
}
return { toPause, toResume };
}
async function runPauseResumeBatch(
toPause: { id: string; containerId: string; name: string }[],
toResume: { id: string; containerId: string; name: string }[]
) {
const totalOps = toPause.length + toResume.length;
let completed = 0;
for (const item of toPause) {
completed++;
const isLast = completed === totalOps;
await pauseContainerMutation(
{ id: item.containerId },
isLast
? {
refetchQueries: [{ query: GET_DOCKER_CONTAINERS, variables: { skipCache: true } }],
awaitRefetchQueries: true,
}
: { awaitRefetchQueries: false }
);
}
for (const item of toResume) {
completed++;
const isLast = completed === totalOps;
await unpauseContainerMutation(
{ id: item.containerId },
isLast
? {
refetchQueries: [{ query: GET_DOCKER_CONTAINERS, variables: { skipCache: true } }],
awaitRefetchQueries: true,
}
: { awaitRefetchQueries: false }
);
}
}
function openPauseResume(ids?: string[]) {
const sources = ids ?? getSelectedContainerIds();
if (sources.length === 0) return;
const { toPause, toResume } = classifyPauseResume(sources);
const isMixed = toPause.length > 0 && toResume.length > 0;
if (isMixed) {
pendingPauseResumeIds = sources;
confirmToPause.value = toPause.map((i) => ({ name: i.name }));
confirmToResume.value = toResume.map((i) => ({ name: i.name }));
confirmPauseResumeOpen.value = true;
return;
}
setRowsBusy(sources, true);
runPauseResumeBatch(toPause, toResume)
.then(() => showToast('Action completed'))
.finally(() => {
setRowsBusy(sources, false);
rowSelection.value = {};
});
}
async function confirmPauseResume(close: () => void) {
const { toPause, toResume } = classifyPauseResume(pendingPauseResumeIds);
setRowsBusy(pendingPauseResumeIds, true);
try {
await runPauseResumeBatch(toPause, toResume);
showToast('Action completed');
rowSelection.value = {};
} finally {
setRowsBusy(pendingPauseResumeIds, false);
confirmPauseResumeOpen.value = false;
pendingPauseResumeIds = [];
close();
}
}
// Bulk Start/Stop handling with mixed confirmation
const confirmStartStopOpen = ref(false);
const confirmToStart = ref<{ name: string }[]>([]);
@@ -696,6 +813,10 @@ function handleBulkAction(action: string) {
openStartStop(ids);
return;
}
if (action === 'Pause / Resume') {
openPauseResume(ids);
return;
}
showToast(`${action} (${ids.length})`);
}
@@ -714,6 +835,10 @@ function handleRowAction(row: TreeRow, action: string) {
handleRowStartStop(row);
return;
}
if (action === 'Pause / Resume') {
handleRowPauseResume(row);
return;
}
showToast(`${action}: ${row.name}`);
}
@@ -943,5 +1068,32 @@ function getRowActionItems(row: TreeRow): DropdownMenuItems {
<UButton @click="confirmStartStop(close)">Confirm</UButton>
</template>
</UModal>
<UModal
v-model:open="confirmPauseResumeOpen"
title="Confirm actions"
:ui="{ footer: 'justify-end', overlay: 'z-50', content: 'z-50' }"
>
<template #body>
<div class="space-y-3">
<div v-if="confirmToPause.length" class="space-y-1">
<div class="text-sm font-medium">Will pause</div>
<ul class="list-disc pl-5 text-sm text-gray-600 dark:text-gray-300">
<li v-for="item in confirmToPause" :key="item.name" class="truncate">{{ item.name }}</li>
</ul>
</div>
<div v-if="confirmToResume.length" class="space-y-1">
<div class="text-sm font-medium">Will resume</div>
<ul class="list-disc pl-5 text-sm text-gray-600 dark:text-gray-300">
<li v-for="item in confirmToResume" :key="item.name" class="truncate">{{ item.name }}</li>
</ul>
</div>
</div>
</template>
<template #footer="{ close }">
<UButton color="neutral" variant="outline" @click="close">Cancel</UButton>
<UButton @click="confirmPauseResume(close)">Confirm</UButton>
</template>
</UModal>
</div>
</template>

View File

@@ -0,0 +1,13 @@
import { gql } from '@apollo/client';
export const PAUSE_DOCKER_CONTAINER = gql`
mutation PauseDockerContainer($id: PrefixedID!) {
docker {
pause(id: $id) {
id
names
state
}
}
}
`;

View File

@@ -0,0 +1,13 @@
import { gql } from '@apollo/client';
export const UNPAUSE_DOCKER_CONTAINER = gql`
mutation UnpauseDockerContainer($id: PrefixedID!) {
docker {
unpause(id: $id) {
id
names
state
}
}
}
`;

View File

@@ -525,6 +525,7 @@ export enum ContainerPortType {
export enum ContainerState {
EXITED = 'EXITED',
PAUSED = 'PAUSED',
RUNNING = 'RUNNING'
}
@@ -732,10 +733,19 @@ export type DockerContainerOverviewForm = {
export type DockerMutations = {
__typename?: 'DockerMutations';
/** Pause (Suspend) a container */
pause: DockerContainer;
/** Start a container */
start: DockerContainer;
/** Stop a container */
stop: DockerContainer;
/** Unpause (Resume) a container */
unpause: DockerContainer;
};
export type DockerMutationsPauseArgs = {
id: Scalars['PrefixedID']['input'];
};
@@ -748,6 +758,11 @@ export type DockerMutationsStopArgs = {
id: Scalars['PrefixedID']['input'];
};
export type DockerMutationsUnpauseArgs = {
id: Scalars['PrefixedID']['input'];
};
export type DockerNetwork = Node & {
__typename?: 'DockerNetwork';
attachable: Scalars['Boolean']['output'];