mirror of
https://github.com/unraid/api.git
synced 2026-05-05 14:41:54 -05:00
277ac42046
## 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>
176 lines
4.5 KiB
Vue
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>
|