Skip to content

Commit 70987f5

Browse files
committed
Fix image loading flow and update/prune behaviors
1 parent f6fb50f commit 70987f5

6 files changed

Lines changed: 300 additions & 116 deletions

File tree

app/routes/images.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,9 +38,10 @@ def list_images():
3838
"""List all images across all Docker hosts."""
3939
try:
4040
refresh = request.args.get('refresh', '0') == '1'
41+
host_id = request.args.get('host_id', type=int)
4142
if refresh:
4243
clear_image_usage_cache()
43-
images = fetch_all_images()
44+
images = fetch_all_images(host_id)
4445
return jsonify(images)
4546
except Exception as e:
4647
logger.error(f"Failed to list images: {e}")
@@ -132,7 +133,10 @@ def prune_images():
132133
if not client:
133134
return jsonify({'error': 'Cannot connect to Docker host. Check the host URL and socket availability.'}), 400
134135

135-
filters = {'dangling': True} if dangling_only else None
136+
if dangling_only:
137+
filters = {'dangling': ['true']}
138+
else:
139+
filters = {'dangling': ['false']}
136140
result = client.images.prune(filters=filters)
137141
if logs_repo:
138142
reclaimed = result.get('SpaceReclaimed', 0)

app/services/container_service.py

Lines changed: 74 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -351,35 +351,91 @@ def update_container(self, container_id: str, container_name: str, schedule_id:
351351
if not docker_client:
352352
raise Exception("Cannot connect to Docker host")
353353

354-
container = docker_client.containers.get(container_id)
355-
attrs = container.attrs
356-
image_name = attrs.get('Config', {}).get('Image', '')
354+
container, _ = self.resolve_container(docker_client, container_id, container_name)
355+
if not container:
356+
raise docker.errors.NotFound(f"No such container: {container_id}")
357+
358+
container.reload()
359+
attrs = container.attrs or {}
360+
config = attrs.get('Config', {}) or {}
361+
host_config = attrs.get('HostConfig', {}) or {}
362+
image_name = config.get('Image', '')
357363

358364
if not image_name:
359365
return ContainerActionResult(False, "Unable to determine container image")
360366

361-
config = attrs.get('Config', {})
362-
host_config = attrs.get('HostConfig', {})
367+
mounts = []
368+
for mount in attrs.get('Mounts', []) or []:
369+
target = mount.get('Destination')
370+
if not target:
371+
continue
372+
mount_type = mount.get('Type', 'volume')
373+
read_only = not mount.get('RW', True)
374+
mounts.append(
375+
docker.types.Mount(
376+
target=target,
377+
source=mount.get('Source'),
378+
type=mount_type,
379+
read_only=read_only,
380+
propagation=mount.get('Propagation'),
381+
)
382+
)
363383

364-
container_settings = {
365-
'name': container.name,
366-
'image': image_name,
367-
'command': config.get('Cmd'),
368-
'environment': config.get('Env', []),
369-
'volumes': host_config.get('Binds', []),
370-
'ports': config.get('ExposedPorts', {}),
371-
'labels': config.get('Labels', {}),
372-
'restart_policy': host_config.get('RestartPolicy', {}),
373-
'network_mode': host_config.get('NetworkMode'),
374-
'detach': True,
375-
}
384+
exposed_ports = config.get('ExposedPorts') or {}
385+
port_bindings = host_config.get('PortBindings') or None
386+
network_mode = host_config.get('NetworkMode')
387+
388+
new_host_config = docker.types.HostConfig(
389+
binds=host_config.get('Binds'),
390+
port_bindings=port_bindings,
391+
restart_policy=host_config.get('RestartPolicy'),
392+
network_mode=network_mode,
393+
privileged=host_config.get('Privileged', False),
394+
cap_add=host_config.get('CapAdd'),
395+
cap_drop=host_config.get('CapDrop'),
396+
extra_hosts=host_config.get('ExtraHosts'),
397+
devices=host_config.get('Devices'),
398+
mounts=mounts or None,
399+
)
376400

377401
container.stop(timeout=10)
378402
container.remove()
379403

380404
docker_client.images.pull(image_name)
381405

382-
docker_client.containers.run(**container_settings)
406+
create_kwargs = {
407+
'image': image_name,
408+
'name': container.name,
409+
'command': config.get('Cmd'),
410+
'environment': config.get('Env'),
411+
'labels': config.get('Labels'),
412+
'entrypoint': config.get('Entrypoint'),
413+
'working_dir': config.get('WorkingDir'),
414+
'user': config.get('User'),
415+
'hostname': config.get('Hostname'),
416+
'domainname': config.get('Domainname'),
417+
'host_config': new_host_config,
418+
}
419+
420+
if exposed_ports:
421+
create_kwargs['ports'] = list(exposed_ports.keys())
422+
423+
new_container = docker_client.api.create_container(**create_kwargs)
424+
new_container_id = new_container.get('Id')
425+
docker_client.api.start(new_container_id)
426+
427+
networks = (attrs.get('NetworkSettings') or {}).get('Networks') or {}
428+
if networks and network_mode and not str(network_mode).startswith('container:'):
429+
for network_name in networks.keys():
430+
if network_name == network_mode:
431+
continue
432+
try:
433+
docker_client.api.connect_container_to_network(new_container_id, network_name)
434+
except Exception:
435+
pass
436+
437+
if schedule_id:
438+
self.update_schedule_container_id(schedule_id, new_container_id[:12])
383439

384440
message = f"Container {container_name} updated successfully"
385441
self.log_action(schedule_id, container_name, 'update', 'success', message, host_id)

app/services/image_service.py

Lines changed: 42 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -87,47 +87,46 @@ def clear_image_usage_cache(self) -> None:
8787
with self._cache_lock:
8888
self._cache.clear()
8989

90-
def fetch_all_images(self) -> list[dict[str, Any]]:
90+
def fetch_all_images(self, host_id: Optional[int] = None) -> list[dict[str, Any]]:
9191
image_list = []
9292
host_color_map, host_text_color_map = self._get_host_color_maps()
9393

94-
for host_id, host_name, docker_client in self._docker_manager.get_all_clients():
94+
for host_id_row, host_name, docker_client in self._docker_manager.get_all_clients():
95+
if host_id is not None and host_id_row != host_id:
96+
continue
9597
df_images = {}
96-
cached_df = self.get_cached_image_usage(host_id)
98+
cached_df = self.get_cached_image_usage(host_id_row)
9799
cached_from_images = cached_df is not None
98100
if not cached_df:
99-
cached_df = self._get_cached_disk_usage(host_id)
101+
cached_df = self._get_cached_disk_usage(host_id_row)
100102
if cached_df:
101103
df_images = {entry.get('Id'): entry for entry in (cached_df.get('Images', []) or [])}
102104
if not cached_from_images:
103-
self.refresh_image_usage_async(host_id, docker_client, host_name)
105+
self.refresh_image_usage_async(host_id_row, docker_client, host_name)
104106

105-
host_color = host_color_map.get(host_id, self._host_default_color)
106-
host_text_color = host_text_color_map.get(host_id, self._get_contrast_text_color(host_color))
107+
host_color = host_color_map.get(host_id_row, self._host_default_color)
108+
host_text_color = host_text_color_map.get(host_id_row, self._get_contrast_text_color(host_color))
107109

108110
container_repo_map = {}
109111
container_count_map = {}
112+
container_name_count_map = {}
113+
container_map_ready = False
110114
try:
111-
containers = docker_client.containers.list(all=True)
115+
containers = docker_client.api.containers(all=True)
116+
container_map_ready = True
112117
for container in containers:
113-
try:
114-
image_id = container.image.id
115-
if not image_id:
116-
continue
117-
stripped_id = image_id.replace('sha256:', '')
118-
container_count_map[image_id] = container_count_map.get(image_id, 0) + 1
119-
container_count_map[stripped_id] = container_count_map.get(stripped_id, 0) + 1
120-
image_name = None
121-
if container.image.tags:
122-
image_name = container.image.tags[0]
123-
else:
124-
image_name = container.attrs.get('Config', {}).get('Image')
125-
if image_name:
126-
repo, _ = self._split_image_reference(image_name)
127-
container_repo_map[image_id] = repo or container_repo_map.get(image_id)
128-
container_repo_map[stripped_id] = repo or container_repo_map.get(stripped_id)
129-
except Exception:
118+
image_id = container.get('ImageID')
119+
if not image_id:
130120
continue
121+
stripped_id = image_id.replace('sha256:', '')
122+
container_count_map[image_id] = container_count_map.get(image_id, 0) + 1
123+
container_count_map[stripped_id] = container_count_map.get(stripped_id, 0) + 1
124+
image_name = container.get('Image')
125+
if image_name:
126+
container_name_count_map[image_name] = container_name_count_map.get(image_name, 0) + 1
127+
repo, _ = self._split_image_reference(image_name)
128+
container_repo_map[image_id] = repo or container_repo_map.get(image_id)
129+
container_repo_map[stripped_id] = repo or container_repo_map.get(stripped_id)
131130
except Exception as error:
132131
self._logger.debug("Failed to map container images for host %s: %s", host_name, error)
133132

@@ -141,15 +140,21 @@ def fetch_all_images(self) -> list[dict[str, Any]]:
141140
containers_count = entry.get('Containers')
142141
stripped_id = image_id.replace('sha256:', '')
143142
fallback_count = container_count_map.get(image_id) or container_count_map.get(stripped_id)
144-
if containers_count is None:
143+
if containers_count is None or (isinstance(containers_count, int) and containers_count < 0):
145144
containers_count = fallback_count
146145
elif containers_count == 0 and fallback_count:
147146
containers_count = fallback_count
148147
if containers_count is None and container_count_map:
149148
containers_count = 0
149+
if containers_count == 0 and container_name_count_map:
150+
for tag in entry.get('RepoTags') or []:
151+
tag_count = container_name_count_map.get(tag)
152+
if tag_count:
153+
containers_count = tag_count
154+
break
150155
if containers_count is not None and containers_count < 0:
151156
containers_count = None
152-
containers_pending = cached_df is None and containers_count is None and not container_count_map
157+
containers_pending = containers_count is None and not container_map_ready
153158
repo_tags = entry.get('RepoTags') or []
154159
repo_digests = entry.get('RepoDigests') or []
155160
if not repo_tags:
@@ -175,7 +180,7 @@ def fetch_all_images(self) -> list[dict[str, Any]]:
175180
'containers': containers_count,
176181
'containers_pending': containers_pending,
177182
'created': created,
178-
'host_id': host_id,
183+
'host_id': host_id_row,
179184
'host_name': host_name,
180185
'host_color': host_color,
181186
'host_text_color': host_text_color,
@@ -193,15 +198,21 @@ def fetch_all_images(self) -> list[dict[str, Any]]:
193198
containers_count = entry.get('Containers')
194199
stripped_id = image_id.replace('sha256:', '')
195200
fallback_count = container_count_map.get(image_id) or container_count_map.get(stripped_id)
196-
if containers_count is None:
201+
if containers_count is None or (isinstance(containers_count, int) and containers_count < 0):
197202
containers_count = fallback_count
198203
elif containers_count == 0 and fallback_count:
199204
containers_count = fallback_count
200205
if containers_count is None and container_count_map:
201206
containers_count = 0
207+
if containers_count == 0 and container_name_count_map:
208+
for tag in entry.get('RepoTags') or []:
209+
tag_count = container_name_count_map.get(tag)
210+
if tag_count:
211+
containers_count = tag_count
212+
break
202213
if containers_count is not None and containers_count < 0:
203214
containers_count = None
204-
containers_pending = cached_df is None and containers_count is None and not container_count_map
215+
containers_pending = containers_count is None and not container_map_ready
205216
repo_tags = entry.get('RepoTags') or []
206217
repo_digests = entry.get('RepoDigests') or []
207218
if not repo_tags:
@@ -227,7 +238,7 @@ def fetch_all_images(self) -> list[dict[str, Any]]:
227238
'containers': containers_count,
228239
'containers_pending': containers_pending,
229240
'created': created,
230-
'host_id': host_id,
241+
'host_id': host_id_row,
231242
'host_name': host_name,
232243
'host_color': host_color,
233244
'host_text_color': host_text_color,

frontend/src/components/containers/ContainerActions.vue

Lines changed: 32 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,35 @@
11
<template>
22
<div ref="rootEl" class="flex flex-nowrap items-center gap-1" :class="align === 'right' ? 'justify-end' : ''">
3+
<Button size="icon" variant="ghost" aria-label="Restart" title="Restart" @click="runAction('restart')">
4+
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
5+
<path d="M3 12a9 9 0 1 1 3 6.7" />
6+
<path d="M3 21v-6h6" />
7+
</svg>
8+
</Button>
9+
<Button
10+
v-if="canStop"
11+
size="icon"
12+
variant="ghost"
13+
aria-label="Stop"
14+
title="Stop"
15+
@click="runAction('stop')"
16+
>
17+
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="currentColor">
18+
<rect x="6" y="6" width="12" height="12" rx="2" />
19+
</svg>
20+
</Button>
21+
<Button
22+
v-if="canStart"
23+
size="icon"
24+
variant="ghost"
25+
aria-label="Start"
26+
title="Start"
27+
@click="runAction('start')"
28+
>
29+
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="currentColor">
30+
<path d="M8 5v14l11-7z" />
31+
</svg>
32+
</Button>
333
<Button size="icon" variant="ghost" aria-label="Logs" title="Logs" @click="openLogs">
434
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
535
<path d="M4 6h16" />
@@ -8,7 +38,7 @@
838
</svg>
939
</Button>
1040
<Button
11-
v-if="updateAvailable"
41+
v-show="updateAvailable"
1242
size="icon"
1343
variant="ghost"
1444
aria-label="Update"
@@ -22,7 +52,7 @@
2252
</svg>
2353
</Button>
2454
<Button
25-
v-else
55+
v-show="!updateAvailable"
2656
size="icon"
2757
variant="ghost"
2858
aria-label="Check updates"
@@ -35,30 +65,6 @@
3565
<path d="M21 3v6h-6" />
3666
</svg>
3767
</Button>
38-
<Button
39-
v-if="canStop"
40-
size="icon"
41-
variant="ghost"
42-
aria-label="Stop"
43-
title="Stop"
44-
@click="runAction('stop')"
45-
>
46-
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="currentColor">
47-
<rect x="6" y="6" width="12" height="12" rx="2" />
48-
</svg>
49-
</Button>
50-
<Button
51-
v-if="canStart"
52-
size="icon"
53-
variant="ghost"
54-
aria-label="Start"
55-
title="Start"
56-
@click="runAction('start')"
57-
>
58-
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="currentColor">
59-
<path d="M8 5v14l11-7z" />
60-
</svg>
61-
</Button>
6268
<div class="relative">
6369
<Button size="icon" variant="ghost" aria-label="More actions" title="More actions" @click="toggleMenu">
6470
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="currentColor">
@@ -72,7 +78,6 @@
7278
v-if="menuOpen"
7379
class="absolute right-0 z-10 mt-2 w-40 rounded-xl border border-surface-800 bg-surface-900 p-2 text-xs text-surface-200 shadow-xl"
7480
>
75-
<button class="flex w-full items-center gap-2 rounded-lg px-2 py-1 hover:bg-surface-800" @click="runAction('restart')">Restart</button>
7681
<button
7782
v-if="isPaused"
7883
class="flex w-full items-center gap-2 rounded-lg px-2 py-1 hover:bg-surface-800"

0 commit comments

Comments
 (0)