Files
api/web/src/components/Docker/ContainerSizesModal.vue
T
Pujit Mehrotra 277ac42046 feat: replace docker overview table with web component (7.3+) (#1764)
## Summary

Introduces a new Vue-based Docker container management interface
replacing the legacy webgui table.

### Container Management
- Start, stop, pause, resume, and remove containers via GraphQL
mutations
- Bulk actions for managing multiple containers at once
- Container update detection with one-click updates
- Real-time container statistics (CPU, memory, I/O)

### Organization & Navigation
- Folder-based container organization with drag-and-drop support
- Accessible reordering via keyboard controls
- Customizable column visibility with persistent preferences
- Column resizing and reordering
- Filtering and search across container properties

### Auto-start Configuration
- Dedicated autostart view with delay configuration
- Drag-and-drop reordering of start/stop sequences

### Logs & Console
- Integrated log viewer with filtering and download
- Persistent console sessions with shell selection
- Slideover panel for quick access

### Networking
- Port conflict detection and alerts
- Tailscale integration for container networking status
- LAN IP and port information display

### Additional Features
- Orphaned container detection and cleanup
- Template mapping management
- Critical notifications system
- WebUI visit links with Tailscale support

<sub>PR Summary by Claude Opus 4.5</sub>
2025-12-18 11:11:05 -05:00

176 lines
4.5 KiB
Vue

<script setup lang="ts">
import { computed } from 'vue';
import { useQuery } from '@vue/apollo-composable';
import { GET_DOCKER_CONTAINER_SIZES } from '@/components/Docker/docker-container-sizes.query';
import { stripLeadingSlash } from '@/utils/docker';
import type { GetDockerContainerSizesQuery } from '@/composables/gql/graphql';
export interface Props {
open?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
open: false,
});
const emit = defineEmits<{
'update:open': [value: boolean];
}>();
const isOpen = computed({
get: () => props.open,
set: (value) => emit('update:open', value),
});
const { result, loading, refetch } = useQuery<GetDockerContainerSizesQuery>(
GET_DOCKER_CONTAINER_SIZES,
undefined,
{
fetchPolicy: 'network-only',
enabled: computed(() => isOpen.value),
}
);
const containers = computed(() => result.value?.docker?.containers ?? []);
const tableRows = computed(() => {
return containers.value
.map((container) => {
const primaryName = stripLeadingSlash(container.names?.[0]) || 'Unknown';
const totalBytes = container.sizeRootFs ?? 0;
const writableBytes = container.sizeRw ?? 0;
const logBytes = container.sizeLog ?? 0;
return {
id: container.id,
name: primaryName,
totalBytes,
writableBytes,
logBytes,
};
})
.sort((a, b) => b.totalBytes - a.totalBytes)
.map((entry) => ({
id: entry.id,
name: entry.name,
total: formatBytes(entry.totalBytes),
writable: formatBytes(entry.writableBytes),
log: formatBytes(entry.logBytes),
}));
});
const totals = computed(() => {
const aggregate = containers.value.reduce(
(acc, container) => {
acc.total += container.sizeRootFs ?? 0;
acc.writable += container.sizeRw ?? 0;
acc.log += container.sizeLog ?? 0;
return acc;
},
{ total: 0, writable: 0, log: 0 }
);
return {
total: formatBytes(aggregate.total),
writable: formatBytes(aggregate.writable),
log: formatBytes(aggregate.log),
};
});
const columns = computed(() => [
{
accessorKey: 'name',
header: 'Container',
footer: 'Totals',
},
{
accessorKey: 'total',
header: 'Total',
footer: totals.value.total,
meta: { class: { td: 'text-right font-mono text-sm', th: 'text-right' } },
},
{
accessorKey: 'writable',
header: 'Writable',
footer: totals.value.writable,
meta: { class: { td: 'text-right font-mono text-sm', th: 'text-right' } },
},
{
accessorKey: 'log',
header: 'Log',
footer: totals.value.log,
meta: { class: { td: 'text-right font-mono text-sm', th: 'text-right' } },
},
]);
// Format byte counts into a short human-readable string (e.g. "1.2 GB").
function formatBytes(value?: number | null): string {
if (!Number.isFinite(value ?? NaN) || value === null || value === undefined) {
return '—';
}
if (value === 0) {
return '0 B';
}
const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
let size = value;
let unitIndex = 0;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex += 1;
}
const formatter = new Intl.NumberFormat(undefined, {
maximumFractionDigits: size < 10 ? 2 : 1,
minimumFractionDigits: size < 10 && unitIndex > 0 ? 1 : 0,
});
return `${formatter.format(size)} ${units[unitIndex]}`;
}
async function handleRefresh() {
await refetch();
}
</script>
<template>
<UModal
v-model:open="isOpen"
title="Container Sizes"
:ui="{ footer: 'justify-end', content: 'sm:max-w-4xl' }"
>
<template #body>
<div class="space-y-4">
<div class="flex items-center justify-between gap-3">
<p class="text-muted-foreground text-sm">
Includes total filesystem, writable layer, and log file sizes per container.
</p>
<UButton
size="sm"
variant="outline"
icon="i-lucide-refresh-cw"
:loading="loading"
@click="handleRefresh"
>
Refresh
</UButton>
</div>
<UTable
:data="tableRows"
:columns="columns"
:loading="loading"
sticky="header"
:ui="{ td: 'py-2 px-3', th: 'py-2 px-3 text-left', tfoot: 'bg-muted' }"
>
<template #empty>
<div class="text-muted-foreground py-6 text-center text-sm">No containers found.</div>
</template>
</UTable>
</div>
</template>
</UModal>
</template>