Skip to content

Commit 9bcc00f

Browse files
committed
feat: surface orphaned containers
1 parent 4d5c3b8 commit 9bcc00f

File tree

9 files changed

+152
-8
lines changed

9 files changed

+152
-8
lines changed

api/generated-schema.graphql

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -869,6 +869,9 @@ type DockerMutations {
869869
"""Unpause (Resume) a container"""
870870
unpause(id: PrefixedID!): DockerContainer!
871871

872+
"""Remove a container"""
873+
removeContainer(id: PrefixedID!): Boolean!
874+
872875
"""Update auto-start configuration for Docker containers"""
873876
updateAutostartConfiguration(entries: [DockerAutostartEntryInput!]!, persistUserPreferences: Boolean): Boolean!
874877

@@ -1170,6 +1173,9 @@ type DockerContainer implements Node {
11701173
"""Wait time in seconds applied after start"""
11711174
autoStartWait: Int
11721175
templatePath: String
1176+
1177+
"""Whether the container is orphaned (no template found)"""
1178+
isOrphaned: Boolean!
11731179
isUpdateAvailable: Boolean
11741180
isRebuildReady: Boolean
11751181
}

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,9 @@ export class DockerContainer extends Node {
201201

202202
@Field(() => String, { nullable: true })
203203
templatePath?: string;
204+
205+
@Field(() => Boolean, { description: 'Whether the container is orphaned (no template found)' })
206+
isOrphaned!: boolean;
204207
}
205208

206209
@ObjectType({ implements: () => Node })

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
@@ -53,6 +53,15 @@ export class DockerMutationsResolver {
5353
return this.dockerService.unpause(id);
5454
}
5555

56+
@ResolveField(() => Boolean, { description: 'Remove a container' })
57+
@UsePermissions({
58+
action: AuthAction.DELETE_ANY,
59+
resource: Resource.DOCKER,
60+
})
61+
public async removeContainer(@Args('id', { type: () => PrefixedID }) id: string) {
62+
return this.dockerService.removeContainer(id);
63+
}
64+
5665
@ResolveField(() => Boolean, {
5766
description: 'Update auto-start configuration for Docker containers',
5867
})

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

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -182,9 +182,11 @@ export class DockerService {
182182
const config = this.dockerConfigService.getConfig();
183183
const containersWithTemplatePaths = containers.map((c) => {
184184
const containerName = c.names[0]?.replace(/^\//, '').toLowerCase();
185+
const templatePath = config.templateMappings?.[containerName] || undefined;
185186
return {
186187
...c,
187-
templatePath: config.templateMappings?.[containerName] || undefined,
188+
templatePath,
189+
isOrphaned: !templatePath,
188190
};
189191
});
190192

@@ -247,6 +249,21 @@ export class DockerService {
247249
return updatedContainer;
248250
}
249251

252+
public async removeContainer(id: string): Promise<boolean> {
253+
const container = this.client.getContainer(id);
254+
try {
255+
await container.remove({ force: true });
256+
await this.clearContainerCache();
257+
this.logger.debug(`Invalidated container caches after removing ${id}`);
258+
const appInfo = await this.getAppInfo();
259+
await pubsub.publish(PUBSUB_CHANNEL.INFO, appInfo);
260+
return true;
261+
} catch (error) {
262+
this.logger.error(`Failed to remove container ${id}:`, error);
263+
throw new Error(`Failed to remove container ${id}`);
264+
}
265+
}
266+
250267
public async updateAutostartConfiguration(
251268
entries: DockerAutostartEntryInput[],
252269
options?: { persistUserPreferences?: boolean }

web/src/components/Docker/DockerContainerManagement.vue

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import ContainerSizesModal from '@/components/Docker/ContainerSizesModal.vue';
77
import { GET_DOCKER_CONTAINERS } from '@/components/Docker/docker-containers.query';
88
import DockerAutostartSettings from '@/components/Docker/DockerAutostartSettings.vue';
99
import DockerContainersTable from '@/components/Docker/DockerContainersTable.vue';
10+
import DockerOrphanedAlert from '@/components/Docker/DockerOrphanedAlert.vue';
1011
import DockerPortConflictsAlert from '@/components/Docker/DockerPortConflictsAlert.vue';
1112
import DockerSidebarTree from '@/components/Docker/DockerSidebarTree.vue';
1213
import DockerEdit from '@/components/Docker/Edit.vue';
@@ -193,6 +194,10 @@ const viewPrefs = computed(() => result.value?.docker?.organizer?.views?.[0]?.pr
193194
194195
const containers = computed<DockerContainer[]>(() => result.value?.docker?.containers || []);
195196
197+
const orphanedContainers = computed<DockerContainer[]>(() =>
198+
containers.value.filter((c) => c.isOrphaned)
199+
);
200+
196201
const portConflicts = computed<DockerPortConflictsResult | null>(() => {
197202
const dockerData = result.value?.docker;
198203
return dockerData?.portConflicts ?? null;
@@ -372,6 +377,9 @@ const isDetailsDisabled = computed(() => props.disabled || isSwitching.value);
372377
</UButton>
373378
</div>
374379
</div>
380+
<div v-if="orphanedContainers.length" class="mb-4">
381+
<DockerOrphanedAlert :orphaned-containers="orphanedContainers" @refresh="refreshContainers" />
382+
</div>
375383
<div v-if="hasPortConflicts" class="mb-4">
376384
<DockerPortConflictsAlert
377385
:lan-conflicts="lanPortConflicts"
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
<script setup lang="ts">
2+
import { useMutation } from '@vue/apollo-composable';
3+
4+
import gql from 'graphql-tag';
5+
6+
import type { DockerContainer } from '@/composables/gql/graphql';
7+
8+
interface Props {
9+
orphanedContainers: DockerContainer[];
10+
}
11+
12+
const props = withDefaults(defineProps<Props>(), {
13+
orphanedContainers: () => [],
14+
});
15+
16+
const emit = defineEmits<{ (e: 'refresh'): void }>();
17+
18+
const REMOVE_CONTAINER = gql`
19+
mutation RemoveContainer($id: PrefixedID!) {
20+
removeContainer(id: $id)
21+
}
22+
`;
23+
24+
const { mutate: removeContainer, loading: removing } = useMutation(REMOVE_CONTAINER);
25+
26+
async function handleRemove(container: DockerContainer) {
27+
const name = container.names[0]?.replace(/^\//, '') || 'container';
28+
if (!confirm(`Are you sure you want to remove orphaned container "${name}"?`)) return;
29+
30+
try {
31+
await removeContainer({ id: container.id });
32+
emit('refresh');
33+
} catch (e) {
34+
console.error('Failed to remove container', e);
35+
// Simple alert for now, ideally use a toast notification service
36+
alert('Failed to remove container');
37+
}
38+
}
39+
40+
function formatContainerName(container: DockerContainer): string {
41+
return container.names[0]?.replace(/^\//, '') || 'Unknown';
42+
}
43+
</script>
44+
45+
<template>
46+
<div
47+
class="rounded-lg border border-amber-200 bg-amber-50 p-4 text-sm text-amber-900 dark:border-amber-400/50 dark:bg-amber-400/10 dark:text-amber-100"
48+
>
49+
<div class="flex items-start gap-3">
50+
<UIcon
51+
name="i-lucide-triangle-alert"
52+
class="mt-1 h-5 w-5 flex-shrink-0 text-amber-500 dark:text-amber-300"
53+
aria-hidden="true"
54+
/>
55+
<div class="w-full space-y-3">
56+
<div>
57+
<p class="text-sm font-semibold">
58+
Orphaned containers detected ({{ orphanedContainers.length }})
59+
</p>
60+
<p class="text-xs text-amber-900/80 dark:text-amber-100/80">
61+
These containers do not have a corresponding template. You can remove them if they are no
62+
longer needed.
63+
</p>
64+
</div>
65+
<div class="space-y-4">
66+
<div class="space-y-2">
67+
<div
68+
class="rounded-md border border-amber-200/70 bg-white/80 p-3 dark:border-amber-300/30 dark:bg-transparent"
69+
>
70+
<div class="mt-2 flex flex-wrap gap-2">
71+
<button
72+
v-for="container in orphanedContainers"
73+
:key="container.id"
74+
type="button"
75+
class="inline-flex items-center gap-1 rounded-full border border-amber-300 bg-amber-100 px-2 py-1 text-xs font-medium text-amber-900 transition hover:bg-amber-200 focus-visible:ring-2 focus-visible:ring-amber-400 focus-visible:outline-none dark:border-amber-200/40 dark:bg-transparent dark:text-amber-100"
76+
:title="`Remove ${formatContainerName(container)}`"
77+
:disabled="removing"
78+
@click="handleRemove(container)"
79+
>
80+
<span>{{ formatContainerName(container) }}</span>
81+
<UIcon name="i-lucide-trash-2" class="h-3.5 w-3.5" aria-hidden="true" />
82+
</button>
83+
</div>
84+
</div>
85+
</div>
86+
</div>
87+
</div>
88+
</div>
89+
</div>
90+
</template>

web/src/components/Docker/docker-containers.query.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ export const GET_DOCKER_CONTAINERS = gql`
4444
}
4545
networkSettings
4646
mounts
47+
isOrphaned
4748
}
4849
organizer(skipCache: $skipCache) {
4950
version
@@ -85,6 +86,7 @@ export const GET_DOCKER_CONTAINERS = gql`
8586
isUpdateAvailable
8687
isRebuildReady
8788
templatePath
89+
isOrphaned
8890
}
8991
}
9092
}

0 commit comments

Comments
 (0)