Skip to content

Commit b69e7cc

Browse files
committed
feat: project links
1 parent 734d7c9 commit b69e7cc

File tree

13 files changed

+211
-9
lines changed

13 files changed

+211
-9
lines changed

api/generated-schema.graphql

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1174,6 +1174,18 @@ type DockerContainer implements Node {
11741174
autoStartWait: Int
11751175
templatePath: String
11761176

1177+
"""Project/Product homepage URL"""
1178+
projectUrl: String
1179+
1180+
"""Registry/Docker Hub URL"""
1181+
registryUrl: String
1182+
1183+
"""Support page/thread URL"""
1184+
supportUrl: String
1185+
1186+
"""Icon URL"""
1187+
iconUrl: String
1188+
11771189
"""Whether the container is orphaned (no template found)"""
11781190
isOrphaned: Boolean!
11791191
isUpdateAvailable: Boolean

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

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,16 @@ import { AuthAction, UsePermissions } from '@unraid/shared/use-permissions.direc
77
import { AppError } from '@app/core/errors/app-error.js';
88
import { UseFeatureFlag } from '@app/unraid-api/decorators/use-feature-flag.decorator.js';
99
import { DockerManifestService } from '@app/unraid-api/graph/resolvers/docker/docker-manifest.service.js';
10+
import { DockerTemplateScannerService } from '@app/unraid-api/graph/resolvers/docker/docker-template-scanner.service.js';
1011
import { DockerContainer } from '@app/unraid-api/graph/resolvers/docker/docker.model.js';
1112

1213
@Resolver(() => DockerContainer)
1314
export class DockerContainerResolver {
1415
private readonly logger = new Logger(DockerContainerResolver.name);
15-
constructor(private readonly dockerManifestService: DockerManifestService) {}
16+
constructor(
17+
private readonly dockerManifestService: DockerManifestService,
18+
private readonly dockerTemplateScannerService: DockerTemplateScannerService
19+
) {}
1620

1721
@UseFeatureFlag('ENABLE_NEXT_DOCKER_RELEASE')
1822
@UsePermissions({
@@ -39,6 +43,65 @@ export class DockerContainerResolver {
3943
return this.dockerManifestService.isRebuildReady(container.hostConfig?.networkMode);
4044
}
4145

46+
@UseFeatureFlag('ENABLE_NEXT_DOCKER_RELEASE')
47+
@UsePermissions({
48+
action: AuthAction.READ_ANY,
49+
resource: Resource.DOCKER,
50+
})
51+
@ResolveField(() => String, { nullable: true })
52+
public async projectUrl(@Parent() container: DockerContainer) {
53+
if (!container.templatePath) return null;
54+
const details = await this.dockerTemplateScannerService.getTemplateDetails(
55+
container.templatePath
56+
);
57+
return details?.project || null;
58+
}
59+
60+
@UseFeatureFlag('ENABLE_NEXT_DOCKER_RELEASE')
61+
@UsePermissions({
62+
action: AuthAction.READ_ANY,
63+
resource: Resource.DOCKER,
64+
})
65+
@ResolveField(() => String, { nullable: true })
66+
public async registryUrl(@Parent() container: DockerContainer) {
67+
if (!container.templatePath) return null;
68+
const details = await this.dockerTemplateScannerService.getTemplateDetails(
69+
container.templatePath
70+
);
71+
return details?.registry || null;
72+
}
73+
74+
@UseFeatureFlag('ENABLE_NEXT_DOCKER_RELEASE')
75+
@UsePermissions({
76+
action: AuthAction.READ_ANY,
77+
resource: Resource.DOCKER,
78+
})
79+
@ResolveField(() => String, { nullable: true })
80+
public async supportUrl(@Parent() container: DockerContainer) {
81+
if (!container.templatePath) return null;
82+
const details = await this.dockerTemplateScannerService.getTemplateDetails(
83+
container.templatePath
84+
);
85+
return details?.support || null;
86+
}
87+
88+
@UseFeatureFlag('ENABLE_NEXT_DOCKER_RELEASE')
89+
@UsePermissions({
90+
action: AuthAction.READ_ANY,
91+
resource: Resource.DOCKER,
92+
})
93+
@ResolveField(() => String, { nullable: true })
94+
public async iconUrl(@Parent() container: DockerContainer) {
95+
if (container.labels?.['net.unraid.docker.icon']) {
96+
return container.labels['net.unraid.docker.icon'];
97+
}
98+
if (!container.templatePath) return null;
99+
const details = await this.dockerTemplateScannerService.getTemplateDetails(
100+
container.templatePath
101+
);
102+
return details?.icon || null;
103+
}
104+
42105
@UseFeatureFlag('ENABLE_NEXT_DOCKER_RELEASE')
43106
@UsePermissions({
44107
action: AuthAction.UPDATE_ANY,

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

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,37 @@ export class DockerTemplateScannerService {
120120
return result;
121121
}
122122

123+
async getTemplateDetails(filePath: string): Promise<{
124+
project?: string;
125+
registry?: string;
126+
support?: string;
127+
overview?: string;
128+
icon?: string;
129+
} | null> {
130+
try {
131+
const content = await readFile(filePath, 'utf-8');
132+
const parsed = this.xmlParser.parse(content);
133+
134+
if (!parsed.Container) {
135+
return null;
136+
}
137+
138+
const container = parsed.Container;
139+
return {
140+
project: container.Project,
141+
registry: container.Registry,
142+
support: container.Support,
143+
overview: container.ReadMe || container.Overview,
144+
icon: container.Icon,
145+
};
146+
} catch (error) {
147+
this.logger.warn(
148+
`Failed to parse template ${filePath}: ${error instanceof Error ? error.message : 'Unknown error'}`
149+
);
150+
return null;
151+
}
152+
}
153+
123154
private async loadAllTemplates(result: DockerTemplateSyncResult): Promise<ParsedTemplate[]> {
124155
const allTemplates: ParsedTemplate[] = [];
125156

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

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,18 @@ export class DockerContainer extends Node {
202202
@Field(() => String, { nullable: true })
203203
templatePath?: string;
204204

205+
@Field(() => String, { nullable: true, description: 'Project/Product homepage URL' })
206+
projectUrl?: string;
207+
208+
@Field(() => String, { nullable: true, description: 'Registry/Docker Hub URL' })
209+
registryUrl?: string;
210+
211+
@Field(() => String, { nullable: true, description: 'Support page/thread URL' })
212+
supportUrl?: string;
213+
214+
@Field(() => String, { nullable: true, description: 'Icon URL' })
215+
iconUrl?: string;
216+
205217
@Field(() => Boolean, { description: 'Whether the container is orphaned (no template found)' })
206218
isOrphaned!: boolean;
207219
}

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ describe('DockerMutationsResolver', () => {
4545
state: ContainerState.RUNNING,
4646
status: 'Up 2 hours',
4747
names: ['test-container'],
48+
isOrphaned: false,
4849
};
4950
vi.mocked(dockerService.start).mockResolvedValue(mockContainer);
5051

@@ -65,6 +66,7 @@ describe('DockerMutationsResolver', () => {
6566
state: ContainerState.EXITED,
6667
status: 'Exited',
6768
names: ['test-container'],
69+
isOrphaned: false,
6870
};
6971
vi.mocked(dockerService.stop).mockResolvedValue(mockContainer);
7072

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,7 @@ describe('DockerResolver', () => {
127127
ports: [],
128128
state: ContainerState.EXITED,
129129
status: 'Exited',
130+
isOrphaned: false,
130131
},
131132
{
132133
id: '2',
@@ -139,6 +140,7 @@ describe('DockerResolver', () => {
139140
ports: [],
140141
state: ContainerState.RUNNING,
141142
status: 'Up 2 hours',
143+
isOrphaned: false,
142144
},
143145
];
144146
vi.mocked(dockerService.getContainers).mockResolvedValue(mockContainers);
@@ -168,6 +170,7 @@ describe('DockerResolver', () => {
168170
sizeRootFs: 1024000,
169171
state: ContainerState.EXITED,
170172
status: 'Exited',
173+
isOrphaned: false,
171174
},
172175
];
173176
vi.mocked(dockerService.getContainers).mockResolvedValue(mockContainers);
@@ -210,6 +213,7 @@ describe('DockerResolver', () => {
210213
ports: [],
211214
state: ContainerState.EXITED,
212215
status: 'Exited',
216+
isOrphaned: false,
213217
},
214218
];
215219
vi.mocked(dockerService.getContainers).mockResolvedValue(mockContainers);

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ export class DockerService {
8383
return this.autostartService.getAutoStarts();
8484
}
8585

86-
public transformContainer(container: Docker.ContainerInfo): DockerContainer {
86+
public transformContainer(container: Docker.ContainerInfo): Omit<DockerContainer, 'isOrphaned'> {
8787
const sizeValue = (container as Docker.ContainerInfo & { SizeRootFs?: number }).SizeRootFs;
8888
const primaryName = this.autostartService.getContainerPrimaryName(container) ?? '';
8989
const autoStartEntry = primaryName
@@ -110,7 +110,7 @@ export class DockerService {
110110
};
111111
});
112112

113-
const transformed: DockerContainer = {
113+
const transformed: Omit<DockerContainer, 'isOrphaned'> = {
114114
id: container.Id,
115115
names: container.Names,
116116
image: container.Image,

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ describe('containerToResource', () => {
3939
labels: {
4040
'com.docker.compose.service': 'web',
4141
},
42+
isOrphaned: false,
4243
};
4344

4445
const result = containerToResource(container);
@@ -63,6 +64,7 @@ describe('containerToResource', () => {
6364
state: ContainerState.EXITED,
6465
status: 'Exited (0) 1 hour ago',
6566
autoStart: false,
67+
isOrphaned: false,
6668
};
6769

6870
const result = containerToResource(container);
@@ -84,6 +86,7 @@ describe('containerToResource', () => {
8486
state: ContainerState.EXITED,
8587
status: 'Exited (0) 5 minutes ago',
8688
autoStart: false,
89+
isOrphaned: false,
8790
};
8891

8992
const result = containerToResource(container);
@@ -125,6 +128,7 @@ describe('containerToResource', () => {
125128
maintainer: 'dev-team',
126129
version: '1.0.0',
127130
},
131+
isOrphaned: false,
128132
};
129133

130134
const result = containerToResource(container);

web/src/components/Docker/DockerContainersTable.vue

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ const UInput = resolveComponent('UInput');
7070
const UDropdownMenu = resolveComponent('UDropdownMenu');
7171
const UModal = resolveComponent('UModal');
7272
const USkeleton = resolveComponent('USkeleton') as Component;
73+
const UIcon = resolveComponent('UIcon');
7374
const rowActionDropdownUi = {
7475
content: 'overflow-x-hidden z-50',
7576
item: 'bg-transparent hover:bg-transparent focus:bg-transparent border-0 ring-0 outline-none shadow-none data-[state=checked]:bg-transparent',
@@ -349,6 +350,61 @@ const columns = computed<TableColumn<TreeRow<DockerContainer>>[]>(() => {
349350
cell: ({ row }) =>
350351
row.original.type === 'folder' ? '' : h('span', null, String(row.getValue('version') || '')),
351352
},
353+
{
354+
accessorKey: 'links',
355+
header: 'Links',
356+
cell: ({ row }) => {
357+
if (row.original.type === 'folder') return '';
358+
const meta = row.original.meta;
359+
const projectUrl = meta?.projectUrl;
360+
const registryUrl = meta?.registryUrl;
361+
const supportUrl = meta?.supportUrl;
362+
363+
if (!projectUrl && !registryUrl && !supportUrl) return '';
364+
365+
return h('div', { class: 'flex gap-2 items-center' }, [
366+
projectUrl
367+
? h(
368+
'a',
369+
{
370+
href: projectUrl,
371+
target: '_blank',
372+
title: 'Project Page',
373+
class:
374+
'text-gray-500 hover:text-primary-500 dark:text-gray-400 dark:hover:text-primary-400',
375+
},
376+
h(UIcon, { name: 'i-lucide-globe', class: 'w-4 h-4' })
377+
)
378+
: null,
379+
registryUrl
380+
? h(
381+
'a',
382+
{
383+
href: registryUrl,
384+
target: '_blank',
385+
title: 'Registry',
386+
class:
387+
'text-gray-500 hover:text-primary-500 dark:text-gray-400 dark:hover:text-primary-400',
388+
},
389+
h(UIcon, { name: 'i-lucide-external-link', class: 'w-4 h-4' })
390+
)
391+
: null,
392+
supportUrl
393+
? h(
394+
'a',
395+
{
396+
href: supportUrl,
397+
target: '_blank',
398+
title: 'Support',
399+
class:
400+
'text-gray-500 hover:text-primary-500 dark:text-gray-400 dark:hover:text-primary-400',
401+
},
402+
h(UIcon, { name: 'i-lucide-life-buoy', class: 'w-4 h-4' })
403+
)
404+
: null,
405+
]);
406+
},
407+
},
352408
{
353409
accessorKey: 'network',
354410
header: 'Network',
@@ -428,6 +484,7 @@ function getDefaultColumnVisibility(isCompact: boolean): Record<string, boolean>
428484
cpu: false,
429485
memory: false,
430486
version: false,
487+
links: true,
431488
network: false,
432489
containerIp: false,
433490
containerPort: false,
@@ -443,6 +500,7 @@ function getDefaultColumnVisibility(isCompact: boolean): Record<string, boolean>
443500
cpu: true,
444501
memory: true,
445502
version: false,
503+
links: true,
446504
network: false,
447505
containerIp: false,
448506
containerPort: false,

web/src/components/Docker/DockerOrphanedAlert.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ interface Props {
99
orphanedContainers: DockerContainer[];
1010
}
1111
12-
const props = withDefaults(defineProps<Props>(), {
12+
withDefaults(defineProps<Props>(), {
1313
orphanedContainers: () => [],
1414
});
1515

0 commit comments

Comments
 (0)