mirror of
https://github.com/unraid/api.git
synced 2026-01-23 17:08:36 -06:00
click overview row to open details
This commit is contained in:
@@ -1,8 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { computed, ref } from 'vue';
|
||||
import { useQuery } from '@vue/apollo-composable';
|
||||
|
||||
import { GET_DOCKER_CONTAINERS } from '@/components/Docker/docker-containers.query';
|
||||
import DockerContainersTable from '@/components/Docker/DockerContainersTable.vue';
|
||||
import DockerSidebarTree from '@/components/Docker/DockerSidebarTree.vue';
|
||||
import DockerEdit from '@/components/Docker/Edit.vue';
|
||||
import DockerLogs from '@/components/Docker/Logs.vue';
|
||||
@@ -28,7 +29,7 @@ const selectedIds = ref<string[]>([]);
|
||||
const activeId = ref<string | null>(null);
|
||||
const isSwitching = ref(false);
|
||||
|
||||
const { result, loading, error, refetch } = useQuery<{
|
||||
const { result, loading, refetch } = useQuery<{
|
||||
docker: {
|
||||
id: string;
|
||||
organizer: {
|
||||
@@ -42,12 +43,15 @@ const { result, loading, error, refetch } = useQuery<{
|
||||
};
|
||||
}>(GET_DOCKER_CONTAINERS, {
|
||||
fetchPolicy: 'cache-and-network',
|
||||
variables: { skipCache: true },
|
||||
});
|
||||
|
||||
const organizerRoot = computed(
|
||||
() => result.value?.docker?.organizer?.views?.[0]?.root as ResolvedOrganizerFolder | undefined
|
||||
);
|
||||
|
||||
const containers = computed<DockerContainer[]>(() => result.value?.docker?.containers || []);
|
||||
|
||||
function findContainerResourceById(
|
||||
entry: ResolvedOrganizerEntry | undefined,
|
||||
id: string
|
||||
@@ -63,27 +67,28 @@ function findContainerResourceById(
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function findFirstContainerId(entry?: ResolvedOrganizerEntry | null): string | null {
|
||||
if (!entry) return null;
|
||||
if (entry.__typename === 'OrganizerContainerResource') return entry.id;
|
||||
if (entry.__typename === 'ResolvedOrganizerFolder') {
|
||||
for (const child of entry.children) {
|
||||
const found = findFirstContainerId(child);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
function handleTableRowClick(payload: {
|
||||
id: string;
|
||||
type: 'container' | 'folder';
|
||||
name: string;
|
||||
containerId?: string;
|
||||
}) {
|
||||
if (payload.type !== 'container') return;
|
||||
if (activeId.value === payload.id) return;
|
||||
isSwitching.value = true;
|
||||
activeId.value = payload.id;
|
||||
setTimeout(() => {
|
||||
isSwitching.value = false;
|
||||
}, 150);
|
||||
}
|
||||
|
||||
watch(
|
||||
() => organizerRoot.value,
|
||||
(root) => {
|
||||
if (!activeId.value) {
|
||||
activeId.value = findFirstContainerId(root);
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
function handleUpdateSelectedIds(ids: string[]) {
|
||||
selectedIds.value = ids;
|
||||
}
|
||||
|
||||
function goBackToOverview() {
|
||||
activeId.value = null;
|
||||
}
|
||||
|
||||
function handleSidebarClick(item: { id: string }) {
|
||||
if (activeId.value === item.id) return;
|
||||
@@ -143,89 +148,117 @@ const isDetailsDisabled = computed(() => props.disabled || isSwitching.value);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="grid gap-6 md:grid-cols-[280px_1fr]">
|
||||
<UCard>
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="font-medium">Containers</div>
|
||||
<UButton
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
icon="i-lucide-refresh-cw"
|
||||
:loading="loading"
|
||||
@click="
|
||||
() => {
|
||||
refetch({ skipCache: true });
|
||||
}
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<USkeleton v-if="loading && !organizerRoot" class="h-6 w-full" :ui="{ rounded: 'rounded' }" />
|
||||
<DockerSidebarTree
|
||||
v-else
|
||||
:root="organizerRoot"
|
||||
:selected-ids="selectedIds"
|
||||
<div>
|
||||
<div v-if="!activeId">
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<div class="text-base font-medium">Docker Containers</div>
|
||||
<UButton
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
icon="i-lucide-refresh-cw"
|
||||
:loading="loading"
|
||||
@click="refetch({ skipCache: true })"
|
||||
/>
|
||||
</div>
|
||||
<DockerContainersTable
|
||||
:containers="containers"
|
||||
:organizer-root="organizerRoot"
|
||||
:loading="loading"
|
||||
:active-id="activeId"
|
||||
:disabled="props.disabled || loading"
|
||||
@item:click="handleSidebarClick"
|
||||
@item:select="handleSidebarSelect"
|
||||
:selected-ids="selectedIds"
|
||||
@created-folder="() => refetch({ skipCache: true })"
|
||||
@row:click="handleTableRowClick"
|
||||
@update:selectedIds="handleUpdateSelectedIds"
|
||||
/>
|
||||
</UCard>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<UCard class="mb-4">
|
||||
<div v-else class="grid gap-6 md:grid-cols-[280px_1fr]">
|
||||
<UCard>
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="font-medium">Overview</div>
|
||||
<UBadge
|
||||
v-if="activeContainer?.state"
|
||||
:label="activeContainer.state"
|
||||
color="primary"
|
||||
variant="subtle"
|
||||
<div class="font-medium">Containers</div>
|
||||
<UButton
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
icon="i-lucide-refresh-cw"
|
||||
:loading="loading"
|
||||
@click="refetch({ skipCache: true })"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<div class="relative">
|
||||
<div v-if="isDetailsLoading" class="pointer-events-none opacity-50">
|
||||
<DockerOverview :item="detailsItem" :details="details" />
|
||||
</div>
|
||||
<DockerOverview v-else :item="detailsItem" :details="details" />
|
||||
<div v-if="isDetailsLoading" class="absolute inset-0 grid place-items-center">
|
||||
<ULoader class="h-6 w-6" />
|
||||
</div>
|
||||
</div>
|
||||
<USkeleton v-if="loading && !organizerRoot" class="h-6 w-full" :ui="{ rounded: 'rounded' }" />
|
||||
<DockerSidebarTree
|
||||
v-else
|
||||
:root="organizerRoot"
|
||||
:selected-ids="selectedIds"
|
||||
:active-id="activeId"
|
||||
:disabled="props.disabled || loading"
|
||||
@item:click="handleSidebarClick"
|
||||
@item:select="handleSidebarSelect"
|
||||
/>
|
||||
</UCard>
|
||||
|
||||
<div class="3xl:grid-cols-2 grid gap-4">
|
||||
<UCard>
|
||||
<div>
|
||||
<UCard class="mb-4">
|
||||
<template #header>
|
||||
<div class="font-medium">Preview</div>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<UButton
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
icon="i-lucide-arrow-left"
|
||||
@click="goBackToOverview"
|
||||
/>
|
||||
<div class="font-medium">Overview</div>
|
||||
</div>
|
||||
<UBadge
|
||||
v-if="activeContainer?.state"
|
||||
:label="activeContainer.state"
|
||||
color="primary"
|
||||
variant="subtle"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<div :class="{ 'pointer-events-none opacity-50': isDetailsDisabled }">
|
||||
<DockerPreview :item="detailsItem" :port="details?.containerPort || undefined" />
|
||||
<div class="relative">
|
||||
<div v-if="isDetailsLoading" class="pointer-events-none opacity-50">
|
||||
<DockerOverview :item="detailsItem" :details="details" />
|
||||
</div>
|
||||
<DockerOverview v-else :item="detailsItem" :details="details" />
|
||||
<div v-if="isDetailsLoading" class="absolute inset-0 grid place-items-center">
|
||||
<USkeleton class="h-6 w-6" />
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
|
||||
<UCard>
|
||||
<div class="3xl:grid-cols-2 grid gap-4">
|
||||
<UCard>
|
||||
<template #header>
|
||||
<div class="font-medium">Preview</div>
|
||||
</template>
|
||||
<div :class="{ 'pointer-events-none opacity-50': isDetailsDisabled }">
|
||||
<DockerPreview :item="detailsItem" :port="details?.containerPort || undefined" />
|
||||
</div>
|
||||
</UCard>
|
||||
|
||||
<UCard>
|
||||
<template #header>
|
||||
<div class="font-medium">Edit</div>
|
||||
</template>
|
||||
<div :class="{ 'pointer-events-none opacity-50': isDetailsDisabled }">
|
||||
<DockerEdit :item="detailsItem" />
|
||||
</div>
|
||||
</UCard>
|
||||
</div>
|
||||
|
||||
<UCard class="mt-4">
|
||||
<template #header>
|
||||
<div class="font-medium">Edit</div>
|
||||
<div class="font-medium">Logs</div>
|
||||
</template>
|
||||
<div :class="{ 'pointer-events-none opacity-50': isDetailsDisabled }">
|
||||
<DockerEdit :item="detailsItem" />
|
||||
<DockerLogs :item="detailsItem" />
|
||||
</div>
|
||||
</UCard>
|
||||
</div>
|
||||
|
||||
<UCard class="mt-4">
|
||||
<template #header>
|
||||
<div class="font-medium">Logs</div>
|
||||
</template>
|
||||
<div :class="{ 'pointer-events-none opacity-50': isDetailsDisabled }">
|
||||
<DockerLogs :item="detailsItem" />
|
||||
</div>
|
||||
</UCard>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -21,10 +21,10 @@ const details = {
|
||||
<template>
|
||||
<div class="grid gap-8">
|
||||
<DockerContainerManagement />
|
||||
<DockerContainerOverview />
|
||||
<DockerOverview :item="item" :details="details" />
|
||||
<!-- <DockerContainerOverview /> -->
|
||||
<!-- <DockerOverview :item="item" :details="details" />
|
||||
<DockerPreview :item="item" />
|
||||
<DockerEdit :item="item" />
|
||||
<DockerLogs :item="item" />
|
||||
<DockerLogs :item="item" /> -->
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -197,7 +197,14 @@ function wrapCell(row: { original: TreeRow; depth?: number }, child: VNode) {
|
||||
? 'ring-2 ring-primary/40'
|
||||
: ''
|
||||
}`,
|
||||
onClick: () => {
|
||||
onClick: (e: MouseEvent) => {
|
||||
const target = e.target as HTMLElement | null;
|
||||
if (
|
||||
target &&
|
||||
target.closest('input,button,textarea,a,[role=checkbox],[role=button],[data-stop-row-click]')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const r = row.original as TreeRow;
|
||||
emit('row:click', { id: r.id, type: r.type, name: r.name, containerId: r.containerId });
|
||||
},
|
||||
@@ -268,39 +275,58 @@ const columns = computed<TableColumn<TreeRow>[]>(() => {
|
||||
const cols: TableColumn<TreeRow>[] = [
|
||||
{
|
||||
id: 'select',
|
||||
header: ({ table }) =>
|
||||
props.compact
|
||||
? ''
|
||||
: h(UCheckbox, {
|
||||
modelValue: table.getIsSomePageRowsSelected()
|
||||
? 'indeterminate'
|
||||
: table.getIsAllPageRowsSelected(),
|
||||
'onUpdate:modelValue': (value: boolean | 'indeterminate') =>
|
||||
table.toggleAllPageRowsSelected(!!value),
|
||||
'aria-label': 'Select all',
|
||||
}),
|
||||
header: () => {
|
||||
if (props.compact) return '';
|
||||
const containers = flattenContainerRows(treeData.value);
|
||||
const totalSelectable = containers.length;
|
||||
const selectedIds = Object.entries(rowSelection.value)
|
||||
.filter(([, selected]) => selected)
|
||||
.map(([id]) => id);
|
||||
const selectedSet = new Set(selectedIds);
|
||||
const selectedCount = containers.reduce(
|
||||
(count, row) => (selectedSet.has(row.id) ? count + 1 : count),
|
||||
0
|
||||
);
|
||||
const allSelected = totalSelectable > 0 && selectedCount === totalSelectable;
|
||||
const someSelected = selectedCount > 0 && !allSelected;
|
||||
return h(UCheckbox, {
|
||||
modelValue: someSelected ? 'indeterminate' : allSelected,
|
||||
'onUpdate:modelValue': () => {
|
||||
const target = someSelected || allSelected ? false : true;
|
||||
const next: Record<string, boolean> = {};
|
||||
if (target) {
|
||||
for (const row of containers) next[row.id] = true;
|
||||
}
|
||||
rowSelection.value = next;
|
||||
},
|
||||
'aria-label': 'Select all',
|
||||
});
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
switch ((row.original as TreeRow).type) {
|
||||
case 'container':
|
||||
return wrapCell(
|
||||
row,
|
||||
h(UCheckbox, {
|
||||
modelValue: row.getIsSelected(),
|
||||
'onUpdate:modelValue': (value: boolean | 'indeterminate') => {
|
||||
const next = !!value;
|
||||
row.toggleSelected(next);
|
||||
const r = row.original as TreeRow;
|
||||
emit('row:select', {
|
||||
id: r.id,
|
||||
type: r.type,
|
||||
name: r.name,
|
||||
containerId: r.containerId,
|
||||
selected: next,
|
||||
});
|
||||
},
|
||||
'aria-label': 'Select row',
|
||||
onClick: (e: Event) => e.stopPropagation(),
|
||||
})
|
||||
h('span', { 'data-stop-row-click': 'true' }, [
|
||||
h(UCheckbox, {
|
||||
modelValue: row.getIsSelected(),
|
||||
'onUpdate:modelValue': (value: boolean | 'indeterminate') => {
|
||||
const next = !!value;
|
||||
row.toggleSelected(next);
|
||||
const r = row.original as TreeRow;
|
||||
emit('row:select', {
|
||||
id: r.id,
|
||||
type: r.type,
|
||||
name: r.name,
|
||||
containerId: r.containerId,
|
||||
selected: next,
|
||||
});
|
||||
},
|
||||
'aria-label': 'Select row',
|
||||
role: 'checkbox',
|
||||
onClick: (e: Event) => e.stopPropagation(),
|
||||
}),
|
||||
])
|
||||
);
|
||||
case 'folder':
|
||||
return wrapCell(
|
||||
@@ -525,6 +551,13 @@ const emit = defineEmits<{
|
||||
(e: 'update:selectedIds', value: string[]): void;
|
||||
}>();
|
||||
|
||||
function arraysEqualAsSets(a: string[], b: string[]): boolean {
|
||||
if (a.length !== b.length) return false;
|
||||
const setA = new Set(a);
|
||||
for (const id of b) if (!setA.has(id)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function flattenContainerRows(rows: TreeRow[]): TreeRow[] {
|
||||
const out: TreeRow[] = [];
|
||||
for (const r of rows) {
|
||||
@@ -534,11 +567,21 @@ function flattenContainerRows(rows: TreeRow[]): TreeRow[] {
|
||||
return out;
|
||||
}
|
||||
|
||||
// Sync external selectedIds into table rowSelection
|
||||
// Sync external selectedIds into table rowSelection (driven only by prop changes)
|
||||
watch(
|
||||
[() => props.selectedIds, treeData],
|
||||
() => {
|
||||
const target = new Set(props.selectedIds || []);
|
||||
() => props.selectedIds,
|
||||
(newVal) => {
|
||||
if (
|
||||
arraysEqualAsSets(
|
||||
newVal || [],
|
||||
Object.entries(rowSelection.value)
|
||||
.filter(([, s]) => s)
|
||||
.map(([id]) => id)
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const target = new Set(newVal || []);
|
||||
const next: Record<string, boolean> = {};
|
||||
for (const r of flattenContainerRows(treeData.value)) {
|
||||
next[r.id] = target.has(r.id);
|
||||
@@ -548,6 +591,20 @@ watch(
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
// When tree changes, preserve existing selection for still-present rows
|
||||
watch(
|
||||
treeData,
|
||||
() => {
|
||||
const valid = new Set(flattenContainerRows(treeData.value).map((r) => r.id));
|
||||
const next: Record<string, boolean> = {};
|
||||
for (const [id, selected] of Object.entries(rowSelection.value)) {
|
||||
if (valid.has(id) && selected) next[id] = true;
|
||||
}
|
||||
rowSelection.value = next;
|
||||
},
|
||||
{ deep: false }
|
||||
);
|
||||
|
||||
// Emit external selectedIds when selection changes
|
||||
watch(
|
||||
rowSelection,
|
||||
@@ -1368,6 +1425,7 @@ async function deleteFolderById(id: string) {
|
||||
:columns="columns"
|
||||
:get-row-id="(row: any) => row.id"
|
||||
:get-sub-rows="(row: any) => row.children"
|
||||
:get-row-can-select="(row: any) => row.original.type === 'container'"
|
||||
:column-filters-options="{ filterFromLeafRows: true }"
|
||||
:loading="loading"
|
||||
:ui="{ td: 'p-0 empty:p-0', thead: compact ? 'hidden' : '', th: compact ? 'hidden' : '' }"
|
||||
|
||||
Reference in New Issue
Block a user