Skip to content

Commit e29e8d1

Browse files
committed
fix: move update to a badge + popover
1 parent f8c0653 commit e29e8d1

File tree

10 files changed

+186
-10
lines changed

10 files changed

+186
-10
lines changed

api/dev/configs/api.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{
2-
"version": "4.25.2",
2+
"version": "4.25.3",
33
"extraOrigins": [],
44
"sandbox": true,
55
"ssoSubIds": [],

api/generated-schema.graphql

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -868,6 +868,9 @@ type DockerMutations {
868868

869869
"""Unpause (Resume) a container"""
870870
unpause(id: PrefixedID!): DockerContainer!
871+
872+
"""Update a container to the latest image"""
873+
updateContainer(id: PrefixedID!): DockerContainer!
871874
}
872875

873876
type VmMutations {

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,4 +48,13 @@ export class DockerMutationsResolver {
4848
public async unpause(@Args('id', { type: () => PrefixedID }) id: string) {
4949
return this.dockerService.unpause(id);
5050
}
51+
52+
@ResolveField(() => DockerContainer, { description: 'Update a container to the latest image' })
53+
@UsePermissions({
54+
action: AuthAction.UPDATE_ANY,
55+
resource: Resource.DOCKER,
56+
})
57+
public async updateContainer(@Args('id', { type: () => PrefixedID }) id: string) {
58+
return this.dockerService.updateContainer(id);
59+
}
5160
}

api/src/unraid-api/graph/resolvers/docker/docker.service.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { readFile } from 'fs/promises';
1111

1212
import { type Cache } from 'cache-manager';
1313
import Docker from 'dockerode';
14+
import { execa } from 'execa';
1415

1516
import { pubsub, PUBSUB_CHANNEL } from '@app/core/pubsub.js';
1617
import { catchHandlers } from '@app/core/utils/misc/catch-handlers.js';
@@ -336,6 +337,45 @@ export class DockerService implements OnApplicationBootstrap {
336337
return updatedContainer;
337338
}
338339

340+
public async updateContainer(id: string): Promise<DockerContainer> {
341+
const containers = await this.getContainers({ skipCache: true });
342+
const container = containers.find((c) => c.id === id);
343+
if (!container) {
344+
throw new Error(`Container ${id} not found`);
345+
}
346+
347+
const containerName = container.names?.[0]?.replace(/^\//, '');
348+
if (!containerName) {
349+
throw new Error(`Container ${id} has no name`);
350+
}
351+
352+
this.logger.log(`Updating container ${containerName} (${id})`);
353+
354+
try {
355+
await execa(
356+
'/usr/local/emhttp/plugins/dynamix.docker.manager/scripts/update_container',
357+
[encodeURIComponent(containerName)],
358+
{ shell: 'bash' }
359+
);
360+
} catch (error) {
361+
this.logger.error(`Failed to update container ${containerName}:`, error);
362+
throw new Error(`Failed to update container ${containerName}`);
363+
}
364+
365+
await this.clearContainerCache();
366+
this.logger.debug(`Invalidated container caches after updating ${id}`);
367+
368+
const updatedContainers = await this.getContainers({ skipCache: true });
369+
const updatedContainer = updatedContainers.find((c) => c.id === id);
370+
if (!updatedContainer) {
371+
throw new Error(`Container ${id} not found after update`);
372+
}
373+
374+
const appInfo = await this.getAppInfo();
375+
await pubsub.publish(PUBSUB_CHANNEL.INFO, appInfo);
376+
return updatedContainer;
377+
}
378+
339379
private async handleDockerListError(error: unknown): Promise<never> {
340380
await this.notifyDockerListError(error);
341381
catchHandlers.docker(error as NodeJS.ErrnoException);

web/components.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,7 @@ declare module 'vue' {
130130
UpdateExpirationAction: typeof import('./src/components/Registration/UpdateExpirationAction.vue')['default']
131131
UpdateIneligible: typeof import('./src/components/UpdateOs/UpdateIneligible.vue')['default']
132132
'UpdateOs.standalone': typeof import('./src/components/UpdateOs.standalone.vue')['default']
133+
UPopover: typeof import('./../node_modules/.pnpm/@[email protected]_@[email protected]_@[email protected][email protected]_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/components/Popover.vue')['default']
133134
UptimeExpire: typeof import('./src/components/UserProfile/UptimeExpire.vue')['default']
134135
USelectMenu: typeof import('./../node_modules/.pnpm/@[email protected]_@[email protected]_@[email protected][email protected]_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/components/SelectMenu.vue')['default']
135136
'UserProfile.standalone': typeof import('./src/components/UserProfile.standalone.vue')['default']

web/src/assets/main.css

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@
9393
.unapi [role="dialog"] button,
9494
.unapi [data-radix-collection-item] button {
9595
margin: 0 !important;
96-
background: transparent !important;
96+
/* background: transparent !important; */
9797
border: none !important;
9898
}
9999

web/src/components/Docker/DockerContainersTable.vue

Lines changed: 95 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { SET_DOCKER_FOLDER_CHILDREN } from '@/components/Docker/docker-set-folde
1414
import { START_DOCKER_CONTAINER } from '@/components/Docker/docker-start-container.mutation';
1515
import { STOP_DOCKER_CONTAINER } from '@/components/Docker/docker-stop-container.mutation';
1616
import { UNPAUSE_DOCKER_CONTAINER } from '@/components/Docker/docker-unpause-container.mutation';
17+
import { UPDATE_DOCKER_CONTAINER } from '@/components/Docker/docker-update-container.mutation';
1718
import { ContainerState } from '@/composables/gql/graphql';
1819
import { useContainerActions } from '@/composables/useContainerActions';
1920
import { useDockerEditNavigation } from '@/composables/useDockerEditNavigation';
@@ -52,6 +53,7 @@ const UDropdownMenu = resolveComponent('UDropdownMenu');
5253
const UModal = resolveComponent('UModal');
5354
const USkeleton = resolveComponent('USkeleton') as Component;
5455
const UIcon = resolveComponent('UIcon');
56+
const UPopover = resolveComponent('UPopover');
5557
const rowActionDropdownUi = {
5658
content: 'overflow-x-hidden z-50',
5759
item: 'bg-transparent hover:bg-transparent focus:bg-transparent border-0 ring-0 outline-none shadow-none data-[state=checked]:bg-transparent',
@@ -288,10 +290,79 @@ const columns = computed<TableColumn<TreeRow<DockerContainer>>[]>(() => {
288290
class: 'w-5 h-5 mr-2 flex-shrink-0 text-gray-500',
289291
});
290292
293+
const hasUpdate =
294+
row.original.type === 'container' &&
295+
(row.original.meta?.isUpdateAvailable || row.original.meta?.isRebuildReady);
296+
297+
const updateBadge = hasUpdate
298+
? h(
299+
UPopover,
300+
{
301+
'data-stop-row-click': 'true',
302+
},
303+
{
304+
default: () =>
305+
h(
306+
UBadge,
307+
{
308+
color: 'warning',
309+
variant: 'subtle',
310+
size: 'sm',
311+
class: 'ml-2 cursor-pointer',
312+
'data-stop-row-click': 'true',
313+
},
314+
() => 'Update'
315+
),
316+
content: () =>
317+
h('div', { class: 'min-w-[280px] max-w-sm p-4' }, [
318+
h('div', { class: 'space-y-3' }, [
319+
h('div', { class: 'space-y-1.5' }, [
320+
h('h4', { class: 'font-semibold text-sm' }, 'Update Container'),
321+
h('p', { class: 'text-sm text-gray-600 dark:text-gray-400' }, row.original.name),
322+
]),
323+
h(
324+
'p',
325+
{ class: 'text-sm text-gray-700 dark:text-gray-300' },
326+
row.original.meta?.isUpdateAvailable
327+
? 'A new image version is available. Would you like to update this container?'
328+
: 'The container configuration has changed. Would you like to rebuild this container?'
329+
),
330+
h('div', { class: 'flex gap-2 justify-end pt-1' }, [
331+
h(
332+
UButton,
333+
{
334+
color: 'neutral',
335+
variant: 'outline',
336+
size: 'sm',
337+
onClick: (e: Event) => {
338+
e.stopPropagation();
339+
},
340+
},
341+
() => 'Cancel'
342+
),
343+
h(
344+
UButton,
345+
{
346+
size: 'sm',
347+
onClick: async (e: Event) => {
348+
e.stopPropagation();
349+
await handleUpdateContainer(row.original as TreeRow<DockerContainer>);
350+
},
351+
},
352+
() => 'Update'
353+
),
354+
]),
355+
]),
356+
]),
357+
}
358+
)
359+
: null;
360+
291361
return h('div', { class: 'truncate flex items-center', 'data-row-id': row.original.id }, [
292362
indent,
293363
iconElement,
294364
h('span', { class: 'max-w-[40ch] truncate font-medium' }, row.original.name),
365+
updateBadge,
295366
]);
296367
},
297368
meta: { class: { td: 'w-[40ch] truncate', th: 'w-[45ch]' } },
@@ -359,12 +430,6 @@ const columns = computed<TableColumn<TreeRow<DockerContainer>>[]>(() => {
359430
cell: ({ row }) =>
360431
row.original.type === 'folder' ? '' : h('span', null, String(row.getValue('autoStart') || '')),
361432
},
362-
{
363-
accessorKey: 'updates',
364-
header: 'Updates',
365-
cell: ({ row }) =>
366-
row.original.type === 'folder' ? '' : h('span', null, String(row.getValue('updates') || '')),
367-
},
368433
{
369434
accessorKey: 'uptime',
370435
header: 'Uptime',
@@ -419,7 +484,6 @@ function applyDefaultColumnVisibility(isCompact: boolean) {
419484
lanPort: false,
420485
volumes: false,
421486
autoStart: false,
422-
updates: false,
423487
uptime: false,
424488
actions: false,
425489
};
@@ -433,7 +497,6 @@ function applyDefaultColumnVisibility(isCompact: boolean) {
433497
lanPort: true,
434498
volumes: false,
435499
autoStart: true,
436-
updates: true,
437500
uptime: false,
438501
};
439502
}
@@ -511,6 +574,7 @@ const { mutate: startContainerMutation } = useMutation(START_DOCKER_CONTAINER);
511574
const { mutate: stopContainerMutation } = useMutation(STOP_DOCKER_CONTAINER);
512575
const { mutate: pauseContainerMutation } = useMutation(PAUSE_DOCKER_CONTAINER);
513576
const { mutate: unpauseContainerMutation } = useMutation(UNPAUSE_DOCKER_CONTAINER);
577+
const { mutate: updateContainerMutation } = useMutation(UPDATE_DOCKER_CONTAINER);
514578
515579
declare global {
516580
interface Window {
@@ -525,6 +589,29 @@ function showToast(message: string) {
525589
window.toast?.success(message);
526590
}
527591
592+
async function handleUpdateContainer(row: TreeRow<DockerContainer>) {
593+
if (!row.containerId) return;
594+
595+
setRowsBusy([row.id], true);
596+
597+
try {
598+
await updateContainerMutation(
599+
{ id: row.containerId },
600+
{
601+
refetchQueries: [{ query: GET_DOCKER_CONTAINERS, variables: { skipCache: true } }],
602+
awaitRefetchQueries: true,
603+
}
604+
);
605+
showToast(`Successfully updated ${row.name}`);
606+
} catch (error) {
607+
window.toast?.error?.(`Failed to update ${row.name}`, {
608+
description: error instanceof Error ? error.message : 'Unknown error',
609+
});
610+
} finally {
611+
setRowsBusy([row.id], false);
612+
}
613+
}
614+
528615
const folderOps = useFolderOperations({
529616
rootFolderId,
530617
folderChildrenIds,
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { gql } from '@apollo/client';
2+
3+
export const UPDATE_DOCKER_CONTAINER = gql`
4+
mutation UpdateDockerContainer($id: PrefixedID!) {
5+
docker {
6+
updateContainer(id: $id) {
7+
id
8+
names
9+
state
10+
isUpdateAvailable
11+
isRebuildReady
12+
}
13+
}
14+
}
15+
`;

web/src/composables/gql/gql.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ type Documents = {
4242
"\n mutation StartDockerContainer($id: PrefixedID!) {\n docker {\n start(id: $id) {\n id\n names\n state\n }\n }\n }\n": typeof types.StartDockerContainerDocument,
4343
"\n mutation StopDockerContainer($id: PrefixedID!) {\n docker {\n stop(id: $id) {\n id\n names\n state\n }\n }\n }\n": typeof types.StopDockerContainerDocument,
4444
"\n mutation UnpauseDockerContainer($id: PrefixedID!) {\n docker {\n unpause(id: $id) {\n id\n names\n state\n }\n }\n }\n": typeof types.UnpauseDockerContainerDocument,
45+
"\n mutation UpdateDockerContainer($id: PrefixedID!) {\n docker {\n updateContainer(id: $id) {\n id\n names\n state\n isUpdateAvailable\n isRebuildReady\n }\n }\n }\n": typeof types.UpdateDockerContainerDocument,
4546
"\n query LogFiles {\n logFiles {\n name\n path\n size\n modifiedAt\n }\n }\n": typeof types.LogFilesDocument,
4647
"\n query LogFileContent($path: String!, $lines: Int, $startLine: Int) {\n logFile(path: $path, lines: $lines, startLine: $startLine) {\n path\n content\n totalLines\n startLine\n }\n }\n": typeof types.LogFileContentDocument,
4748
"\n subscription LogFileSubscription($path: String!) {\n logFile(path: $path) {\n path\n content\n totalLines\n }\n }\n": typeof types.LogFileSubscriptionDocument,
@@ -104,6 +105,7 @@ const documents: Documents = {
104105
"\n mutation StartDockerContainer($id: PrefixedID!) {\n docker {\n start(id: $id) {\n id\n names\n state\n }\n }\n }\n": types.StartDockerContainerDocument,
105106
"\n mutation StopDockerContainer($id: PrefixedID!) {\n docker {\n stop(id: $id) {\n id\n names\n state\n }\n }\n }\n": types.StopDockerContainerDocument,
106107
"\n mutation UnpauseDockerContainer($id: PrefixedID!) {\n docker {\n unpause(id: $id) {\n id\n names\n state\n }\n }\n }\n": types.UnpauseDockerContainerDocument,
108+
"\n mutation UpdateDockerContainer($id: PrefixedID!) {\n docker {\n updateContainer(id: $id) {\n id\n names\n state\n isUpdateAvailable\n isRebuildReady\n }\n }\n }\n": types.UpdateDockerContainerDocument,
107109
"\n query LogFiles {\n logFiles {\n name\n path\n size\n modifiedAt\n }\n }\n": types.LogFilesDocument,
108110
"\n query LogFileContent($path: String!, $lines: Int, $startLine: Int) {\n logFile(path: $path, lines: $lines, startLine: $startLine) {\n path\n content\n totalLines\n startLine\n }\n }\n": types.LogFileContentDocument,
109111
"\n subscription LogFileSubscription($path: String!) {\n logFile(path: $path) {\n path\n content\n totalLines\n }\n }\n": types.LogFileSubscriptionDocument,
@@ -264,6 +266,10 @@ export function graphql(source: "\n mutation StopDockerContainer($id: PrefixedI
264266
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
265267
*/
266268
export function graphql(source: "\n mutation UnpauseDockerContainer($id: PrefixedID!) {\n docker {\n unpause(id: $id) {\n id\n names\n state\n }\n }\n }\n"): (typeof documents)["\n mutation UnpauseDockerContainer($id: PrefixedID!) {\n docker {\n unpause(id: $id) {\n id\n names\n state\n }\n }\n }\n"];
269+
/**
270+
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
271+
*/
272+
export function graphql(source: "\n mutation UpdateDockerContainer($id: PrefixedID!) {\n docker {\n updateContainer(id: $id) {\n id\n names\n state\n isUpdateAvailable\n isRebuildReady\n }\n }\n }\n"): (typeof documents)["\n mutation UpdateDockerContainer($id: PrefixedID!) {\n docker {\n updateContainer(id: $id) {\n id\n names\n state\n isUpdateAvailable\n isRebuildReady\n }\n }\n }\n"];
267273
/**
268274
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
269275
*/

0 commit comments

Comments
 (0)