mirror of
https://github.com/unraid/api.git
synced 2026-01-24 01:18:39 -06:00
1100 lines
35 KiB
Vue
1100 lines
35 KiB
Vue
<script setup lang="ts">
|
|
import { computed, h, ref, resolveComponent } from 'vue';
|
|
import { useMutation } from '@vue/apollo-composable';
|
|
|
|
// removed unused Button import
|
|
import { GET_DOCKER_CONTAINERS } from '@/components/Docker/docker-containers.query';
|
|
import { CREATE_DOCKER_FOLDER } from '@/components/Docker/docker-create-folder.mutation';
|
|
import { DELETE_DOCKER_ENTRIES } from '@/components/Docker/docker-delete-entries.mutation';
|
|
import { MOVE_DOCKER_ENTRIES_TO_FOLDER } from '@/components/Docker/docker-move-entries.mutation';
|
|
import { PAUSE_DOCKER_CONTAINER } from '@/components/Docker/docker-pause-container.mutation';
|
|
import { SET_DOCKER_FOLDER_CHILDREN } from '@/components/Docker/docker-set-folder-children.mutation';
|
|
import { START_DOCKER_CONTAINER } from '@/components/Docker/docker-start-container.mutation';
|
|
import { STOP_DOCKER_CONTAINER } from '@/components/Docker/docker-stop-container.mutation';
|
|
import { UNPAUSE_DOCKER_CONTAINER } from '@/components/Docker/docker-unpause-container.mutation';
|
|
import { ContainerState } from '@/composables/gql/graphql';
|
|
|
|
import type {
|
|
DockerContainer,
|
|
ResolvedOrganizerEntry,
|
|
ResolvedOrganizerFolder,
|
|
} from '@/composables/gql/graphql';
|
|
import type { TableColumn } from '@nuxt/ui';
|
|
import type { Component, VNode } from 'vue';
|
|
|
|
interface Props {
|
|
containers: DockerContainer[];
|
|
organizerRoot?: ResolvedOrganizerFolder;
|
|
loading?: boolean;
|
|
}
|
|
|
|
const props = withDefaults(defineProps<Props>(), {
|
|
loading: false,
|
|
});
|
|
|
|
const UButton = resolveComponent('UButton');
|
|
const UCheckbox = resolveComponent('UCheckbox');
|
|
const UBadge = resolveComponent('UBadge');
|
|
const UInput = resolveComponent('UInput');
|
|
const UDropdownMenu = resolveComponent('UDropdownMenu');
|
|
const UContextMenu = resolveComponent('UContextMenu');
|
|
const UModal = resolveComponent('UModal');
|
|
const USkeleton = resolveComponent('USkeleton') as Component;
|
|
|
|
function formatPorts(container?: DockerContainer | null): string {
|
|
if (!container) return '';
|
|
return container.ports
|
|
.map((port) => {
|
|
if (port.publicPort && port.privatePort) {
|
|
return `${port.publicPort}:${port.privatePort}/${port.type}`;
|
|
}
|
|
if (port.privatePort) {
|
|
return `${port.privatePort}/${port.type}`;
|
|
}
|
|
return '';
|
|
})
|
|
.filter(Boolean)
|
|
.join(', ');
|
|
}
|
|
|
|
type TreeRow = {
|
|
id: string;
|
|
type: 'folder' | 'container';
|
|
name: string;
|
|
state?: string;
|
|
ports?: string;
|
|
autoStart?: string;
|
|
updates?: string;
|
|
children?: TreeRow[];
|
|
containerId?: string;
|
|
};
|
|
|
|
function toContainerTreeRow(meta: DockerContainer | null | undefined, fallbackName?: string): TreeRow {
|
|
const name = meta?.names?.[0]?.replace(/^\//, '') || fallbackName || 'Unknown';
|
|
const updatesParts: string[] = [];
|
|
if (meta?.isUpdateAvailable) updatesParts.push('Update');
|
|
if (meta?.isRebuildReady) updatesParts.push('Rebuild');
|
|
return {
|
|
id: meta?.id || name,
|
|
type: 'container',
|
|
name,
|
|
state: meta?.state ?? '',
|
|
ports: formatPorts(meta || undefined),
|
|
autoStart: meta?.autoStart ? 'On' : 'Off',
|
|
updates: updatesParts.join(' / ') || '—',
|
|
containerId: meta?.id,
|
|
};
|
|
}
|
|
|
|
function buildTree(entry: ResolvedOrganizerEntry): TreeRow | null {
|
|
if (entry.__typename === 'ResolvedOrganizerFolder') {
|
|
const folder = entry as ResolvedOrganizerFolder;
|
|
return {
|
|
id: folder.id,
|
|
type: 'folder',
|
|
name: folder.name,
|
|
children: (folder.children || []).map((child) => buildTree(child)).filter(Boolean) as TreeRow[],
|
|
};
|
|
}
|
|
if (entry.__typename === 'OrganizerContainerResource') {
|
|
const meta = entry.meta as DockerContainer | null | undefined;
|
|
const row = toContainerTreeRow(meta, entry.name || undefined);
|
|
row.id = entry.id;
|
|
row.containerId = meta?.id;
|
|
return row;
|
|
}
|
|
return {
|
|
id: entry.id as string,
|
|
type: 'container',
|
|
name: (entry as unknown as { name?: string }).name || 'Unknown',
|
|
state: '',
|
|
ports: '',
|
|
autoStart: 'Off',
|
|
updates: '—',
|
|
};
|
|
}
|
|
|
|
const treeData = computed<TreeRow[]>(() => {
|
|
if (props.organizerRoot) {
|
|
const root = props.organizerRoot;
|
|
return (root.children || []).map((child) => buildTree(child)).filter(Boolean) as TreeRow[];
|
|
}
|
|
return props.containers.map((container) => toContainerTreeRow(container));
|
|
});
|
|
|
|
type DropdownMenuItem = { label: string; icon: string; onSelect: (e?: Event) => void; as?: string };
|
|
type DropdownMenuItems = DropdownMenuItem[][];
|
|
|
|
function wrapCell(row: { original: TreeRow }, child: VNode) {
|
|
const isBusy = busyRowIds.value.has((row.original as TreeRow).id);
|
|
const content = h(
|
|
'div',
|
|
{
|
|
'data-row-id': row.original.id,
|
|
class: `block w-full h-full px-3 py-2 ${isBusy ? 'opacity-50 pointer-events-none select-none' : ''}`,
|
|
},
|
|
[child]
|
|
);
|
|
if ((row.original as TreeRow).type === 'container') {
|
|
return h(
|
|
UContextMenu,
|
|
{
|
|
items: getRowActionItems(row.original as TreeRow),
|
|
size: 'md',
|
|
ui: {
|
|
content: 'overflow-x-hidden z-50',
|
|
item: 'bg-transparent hover:bg-transparent focus:bg-transparent border-0 ring-0 outline-none shadow-none data-[state=checked]:bg-transparent',
|
|
},
|
|
},
|
|
{ default: () => content }
|
|
);
|
|
}
|
|
return content;
|
|
}
|
|
|
|
const columns = computed<TableColumn<TreeRow>[]>(() => {
|
|
const cols: TableColumn<TreeRow>[] = [
|
|
{
|
|
id: 'select',
|
|
header: ({ table }) =>
|
|
h(UCheckbox, {
|
|
modelValue: table.getIsSomePageRowsSelected()
|
|
? 'indeterminate'
|
|
: table.getIsAllPageRowsSelected(),
|
|
'onUpdate:modelValue': (value: boolean | 'indeterminate') =>
|
|
table.toggleAllPageRowsSelected(!!value),
|
|
'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') => row.toggleSelected(!!value),
|
|
'aria-label': 'Select row',
|
|
})
|
|
);
|
|
case 'folder':
|
|
return wrapCell(
|
|
row,
|
|
h(UButton, {
|
|
color: 'neutral',
|
|
size: 'md',
|
|
variant: 'ghost',
|
|
icon: 'i-lucide-chevron-down',
|
|
square: true,
|
|
'aria-label': 'Expand',
|
|
class: 'p-0',
|
|
ui: {
|
|
leadingIcon: [
|
|
'transition-transform mt-0.5 -rotate-90',
|
|
row.getIsExpanded() ? 'duration-200 rotate-0' : '',
|
|
],
|
|
},
|
|
onClick: () => row.toggleExpanded(),
|
|
})
|
|
);
|
|
default:
|
|
return h('span');
|
|
}
|
|
},
|
|
enableSorting: false,
|
|
enableHiding: false,
|
|
meta: { class: { th: 'w-10', td: 'w-10' } },
|
|
},
|
|
{
|
|
accessorKey: 'name',
|
|
header: 'Name',
|
|
cell: ({ row }) => {
|
|
const depth = row.depth;
|
|
const indent = h('span', { class: 'inline-block', style: { width: `calc(${depth} * 1rem)` } });
|
|
const isFolder = (row.original as TreeRow).type === 'folder';
|
|
const content = h(
|
|
'div',
|
|
{ class: 'truncate flex items-center', 'data-row-id': row.original.id },
|
|
[
|
|
indent,
|
|
h('span', { class: 'max-w-[40ch] truncate font-medium' }, row.original.name),
|
|
isFolder ? h('span') : null,
|
|
]
|
|
);
|
|
return wrapCell(row, content);
|
|
},
|
|
meta: { class: { td: 'w-[40ch] truncate', th: 'w-[45ch]' } },
|
|
},
|
|
{
|
|
accessorKey: 'state',
|
|
header: 'State',
|
|
cell: ({ row }) => {
|
|
if (row.original.type === 'folder') return '';
|
|
const state = row.original.state ?? '';
|
|
const isBusy = busyRowIds.value.has(row.original.id);
|
|
const color = {
|
|
[ContainerState.RUNNING]: 'success' as const,
|
|
[ContainerState.PAUSED]: 'warning' as const,
|
|
[ContainerState.EXITED]: 'neutral' as const,
|
|
}[state];
|
|
if (isBusy) {
|
|
return wrapCell(row, h(USkeleton, { class: 'h-5 w-20' }));
|
|
}
|
|
return wrapCell(
|
|
row,
|
|
h(UBadge, { color }, () => state)
|
|
);
|
|
},
|
|
},
|
|
{
|
|
accessorKey: 'ports',
|
|
header: 'Ports',
|
|
cell: ({ row }) =>
|
|
row.original.type === 'folder'
|
|
? ''
|
|
: wrapCell(row, h('span', null, String(row.getValue('ports') || ''))),
|
|
},
|
|
{
|
|
accessorKey: 'autoStart',
|
|
header: 'Auto Start',
|
|
cell: ({ row }) =>
|
|
row.original.type === 'folder'
|
|
? ''
|
|
: wrapCell(row, h('span', null, String(row.getValue('autoStart') || ''))),
|
|
},
|
|
{
|
|
accessorKey: 'updates',
|
|
header: 'Updates',
|
|
cell: ({ row }) =>
|
|
row.original.type === 'folder'
|
|
? ''
|
|
: wrapCell(row, h('span', null, String(row.getValue('updates') || ''))),
|
|
},
|
|
{
|
|
id: 'actions',
|
|
header: '',
|
|
cell: ({ row }) => {
|
|
if ((row.original as TreeRow).type === 'folder') return '';
|
|
const items = getRowActionItems(row.original as TreeRow);
|
|
return wrapCell(
|
|
row,
|
|
h(
|
|
UDropdownMenu,
|
|
{
|
|
items,
|
|
size: 'md',
|
|
ui: {
|
|
content: 'overflow-x-hidden z-50',
|
|
item: 'bg-transparent hover:bg-transparent focus:bg-transparent border-0 ring-0 outline-none shadow-none data-[state=checked]:bg-transparent',
|
|
},
|
|
},
|
|
{
|
|
default: () =>
|
|
h(UButton, {
|
|
color: 'neutral',
|
|
variant: 'ghost',
|
|
icon: 'i-lucide-more-vertical',
|
|
square: true,
|
|
'aria-label': 'Row actions',
|
|
}),
|
|
}
|
|
)
|
|
);
|
|
},
|
|
enableSorting: false,
|
|
enableHiding: false,
|
|
meta: { class: { th: 'w-8', td: 'w-8 text-right' } },
|
|
},
|
|
];
|
|
return cols;
|
|
});
|
|
|
|
const rowSelection = ref<Record<string, boolean>>({});
|
|
type NuxtUITableRef = { table?: { getSelectedRowModel: () => { rows: unknown[] } } } | null;
|
|
const tableRef = ref<NuxtUITableRef>(null);
|
|
const selectedCount = computed<number>(() => {
|
|
return Object.values(rowSelection.value).filter(Boolean).length;
|
|
});
|
|
|
|
const globalFilter = ref('');
|
|
|
|
const emit = defineEmits<{
|
|
(e: 'created-folder'): void;
|
|
}>();
|
|
|
|
const { mutate: createFolderMutation, loading: creating } = useMutation(CREATE_DOCKER_FOLDER);
|
|
const { mutate: moveEntriesMutation, loading: moving } = useMutation(MOVE_DOCKER_ENTRIES_TO_FOLDER);
|
|
const { mutate: deleteEntriesMutation, loading: deleting } = useMutation(DELETE_DOCKER_ENTRIES);
|
|
const { mutate: setFolderChildrenMutation } = useMutation(SET_DOCKER_FOLDER_CHILDREN);
|
|
const { mutate: startContainerMutation } = useMutation(START_DOCKER_CONTAINER);
|
|
const { mutate: stopContainerMutation } = useMutation(STOP_DOCKER_CONTAINER);
|
|
const { mutate: pauseContainerMutation } = useMutation(PAUSE_DOCKER_CONTAINER);
|
|
const { mutate: unpauseContainerMutation } = useMutation(UNPAUSE_DOCKER_CONTAINER);
|
|
|
|
const moveOpen = ref(false);
|
|
const selectedFolderId = ref<string>('');
|
|
const pendingMoveSourceIds = ref<string[]>([]);
|
|
const expandedFolders = ref<Set<string>>(new Set());
|
|
const renamingFolderId = ref<string>('');
|
|
const renameValue = ref<string>('');
|
|
const newTreeFolderName = ref<string>('');
|
|
|
|
const rootFolderId = computed<string>(() => props.organizerRoot?.id || '');
|
|
// Busy/disabled rows while performing start/stop
|
|
const busyRowIds = ref<Set<string>>(new Set());
|
|
|
|
function setRowsBusy(ids: string[], busy: boolean) {
|
|
const next = new Set(busyRowIds.value);
|
|
for (const id of ids) {
|
|
if (busy) next.add(id);
|
|
else next.delete(id);
|
|
}
|
|
busyRowIds.value = next;
|
|
}
|
|
|
|
function getRowById(targetId: string): TreeRow | undefined {
|
|
function walk(rows: TreeRow[]): TreeRow | undefined {
|
|
for (const r of rows) {
|
|
if (r.id === targetId) return r;
|
|
if (r.children?.length) {
|
|
const found = walk(r.children as TreeRow[]);
|
|
if (found) return found;
|
|
}
|
|
}
|
|
return undefined;
|
|
}
|
|
return walk(treeData.value);
|
|
}
|
|
|
|
function classifyStartStop(ids: string[]) {
|
|
const toStart: { id: string; containerId: string; name: string }[] = [];
|
|
const toStop: { id: string; containerId: string; name: string }[] = [];
|
|
for (const id of ids) {
|
|
const row = getRowById(id);
|
|
if (!row || row.type !== 'container') continue;
|
|
const containerId = row.containerId || row.id;
|
|
const state = row.state as string | undefined;
|
|
const name = row.name;
|
|
if (state === ContainerState.RUNNING) toStop.push({ id, containerId, name });
|
|
else toStart.push({ id, containerId, name });
|
|
}
|
|
return { toStart, toStop };
|
|
}
|
|
|
|
async function runStartStopBatch(
|
|
toStart: { id: string; containerId: string; name: string }[],
|
|
toStop: { id: string; containerId: string; name: string }[]
|
|
) {
|
|
const totalOps = toStop.length + toStart.length;
|
|
let completed = 0;
|
|
// Execute sequentially; attach refetch to the final operation only
|
|
for (const item of toStop) {
|
|
completed++;
|
|
const isLast = completed === totalOps;
|
|
await stopContainerMutation(
|
|
{ id: item.containerId },
|
|
isLast
|
|
? {
|
|
refetchQueries: [{ query: GET_DOCKER_CONTAINERS, variables: { skipCache: true } }],
|
|
awaitRefetchQueries: true,
|
|
}
|
|
: { awaitRefetchQueries: false }
|
|
);
|
|
}
|
|
for (const item of toStart) {
|
|
completed++;
|
|
const isLast = completed === totalOps;
|
|
await startContainerMutation(
|
|
{ id: item.containerId },
|
|
isLast
|
|
? {
|
|
refetchQueries: [{ query: GET_DOCKER_CONTAINERS, variables: { skipCache: true } }],
|
|
awaitRefetchQueries: true,
|
|
}
|
|
: { awaitRefetchQueries: false }
|
|
);
|
|
}
|
|
}
|
|
|
|
async function handleRowStartStop(row: TreeRow) {
|
|
if (row.type !== 'container') return;
|
|
const containerId = row.containerId || row.id;
|
|
if (!containerId) return;
|
|
setRowsBusy([row.id], true);
|
|
try {
|
|
const isRunning = row.state === ContainerState.RUNNING;
|
|
const mutate = isRunning ? stopContainerMutation : startContainerMutation;
|
|
await mutate(
|
|
{ id: containerId },
|
|
{
|
|
refetchQueries: [{ query: GET_DOCKER_CONTAINERS, variables: { skipCache: true } }],
|
|
awaitRefetchQueries: true,
|
|
}
|
|
);
|
|
} finally {
|
|
setRowsBusy([row.id], false);
|
|
}
|
|
}
|
|
|
|
// Pause/Resume single row
|
|
async function handleRowPauseResume(row: TreeRow) {
|
|
if (row.type !== 'container') return;
|
|
const containerId = row.containerId || row.id;
|
|
if (!containerId) return;
|
|
setRowsBusy([row.id], true);
|
|
try {
|
|
const isPaused = row.state === ContainerState.PAUSED;
|
|
const mutate = isPaused ? unpauseContainerMutation : pauseContainerMutation;
|
|
await mutate(
|
|
{ id: containerId },
|
|
{
|
|
refetchQueries: [{ query: GET_DOCKER_CONTAINERS, variables: { skipCache: true } }],
|
|
awaitRefetchQueries: true,
|
|
}
|
|
);
|
|
} finally {
|
|
setRowsBusy([row.id], false);
|
|
}
|
|
}
|
|
|
|
// Bulk Pause/Resume handling with mixed confirmation
|
|
const confirmPauseResumeOpen = ref(false);
|
|
const confirmToPause = ref<{ name: string }[]>([]);
|
|
const confirmToResume = ref<{ name: string }[]>([]);
|
|
let pendingPauseResumeIds: string[] = [];
|
|
|
|
function classifyPauseResume(ids: string[]) {
|
|
const toPause: { id: string; containerId: string; name: string }[] = [];
|
|
const toResume: { id: string; containerId: string; name: string }[] = [];
|
|
for (const id of ids) {
|
|
const row = getRowById(id);
|
|
if (!row || row.type !== 'container') continue;
|
|
const containerId = row.containerId || row.id;
|
|
const state = row.state as string | undefined;
|
|
const name = row.name;
|
|
if (state === ContainerState.PAUSED) toResume.push({ id, containerId, name });
|
|
else if (state === ContainerState.RUNNING) toPause.push({ id, containerId, name });
|
|
}
|
|
return { toPause, toResume };
|
|
}
|
|
|
|
async function runPauseResumeBatch(
|
|
toPause: { id: string; containerId: string; name: string }[],
|
|
toResume: { id: string; containerId: string; name: string }[]
|
|
) {
|
|
const totalOps = toPause.length + toResume.length;
|
|
let completed = 0;
|
|
for (const item of toPause) {
|
|
completed++;
|
|
const isLast = completed === totalOps;
|
|
await pauseContainerMutation(
|
|
{ id: item.containerId },
|
|
isLast
|
|
? {
|
|
refetchQueries: [{ query: GET_DOCKER_CONTAINERS, variables: { skipCache: true } }],
|
|
awaitRefetchQueries: true,
|
|
}
|
|
: { awaitRefetchQueries: false }
|
|
);
|
|
}
|
|
for (const item of toResume) {
|
|
completed++;
|
|
const isLast = completed === totalOps;
|
|
await unpauseContainerMutation(
|
|
{ id: item.containerId },
|
|
isLast
|
|
? {
|
|
refetchQueries: [{ query: GET_DOCKER_CONTAINERS, variables: { skipCache: true } }],
|
|
awaitRefetchQueries: true,
|
|
}
|
|
: { awaitRefetchQueries: false }
|
|
);
|
|
}
|
|
}
|
|
|
|
function openPauseResume(ids?: string[]) {
|
|
const sources = ids ?? getSelectedContainerIds();
|
|
if (sources.length === 0) return;
|
|
const { toPause, toResume } = classifyPauseResume(sources);
|
|
const isMixed = toPause.length > 0 && toResume.length > 0;
|
|
if (isMixed) {
|
|
pendingPauseResumeIds = sources;
|
|
confirmToPause.value = toPause.map((i) => ({ name: i.name }));
|
|
confirmToResume.value = toResume.map((i) => ({ name: i.name }));
|
|
confirmPauseResumeOpen.value = true;
|
|
return;
|
|
}
|
|
setRowsBusy(sources, true);
|
|
runPauseResumeBatch(toPause, toResume)
|
|
.then(() => showToast('Action completed'))
|
|
.finally(() => {
|
|
setRowsBusy(sources, false);
|
|
rowSelection.value = {};
|
|
});
|
|
}
|
|
|
|
async function confirmPauseResume(close: () => void) {
|
|
const { toPause, toResume } = classifyPauseResume(pendingPauseResumeIds);
|
|
setRowsBusy(pendingPauseResumeIds, true);
|
|
try {
|
|
await runPauseResumeBatch(toPause, toResume);
|
|
showToast('Action completed');
|
|
rowSelection.value = {};
|
|
} finally {
|
|
setRowsBusy(pendingPauseResumeIds, false);
|
|
confirmPauseResumeOpen.value = false;
|
|
pendingPauseResumeIds = [];
|
|
close();
|
|
}
|
|
}
|
|
|
|
// Bulk Start/Stop handling with mixed confirmation
|
|
const confirmStartStopOpen = ref(false);
|
|
const confirmToStart = ref<{ name: string }[]>([]);
|
|
const confirmToStop = ref<{ name: string }[]>([]);
|
|
let pendingStartStopIds: string[] = [];
|
|
|
|
function openStartStop(ids?: string[]) {
|
|
const sources = ids ?? getSelectedContainerIds();
|
|
if (sources.length === 0) return;
|
|
const { toStart, toStop } = classifyStartStop(sources);
|
|
const isMixed = toStart.length > 0 && toStop.length > 0;
|
|
if (isMixed) {
|
|
pendingStartStopIds = sources;
|
|
confirmToStart.value = toStart.map((i) => ({ name: i.name }));
|
|
confirmToStop.value = toStop.map((i) => ({ name: i.name }));
|
|
confirmStartStopOpen.value = true;
|
|
return;
|
|
}
|
|
// Single-type selection: run immediately with busy rows
|
|
setRowsBusy(sources, true);
|
|
runStartStopBatch(toStart, toStop)
|
|
.then(() => showToast('Action completed'))
|
|
.finally(() => {
|
|
setRowsBusy(sources, false);
|
|
rowSelection.value = {};
|
|
});
|
|
}
|
|
|
|
async function confirmStartStop(close: () => void) {
|
|
const { toStart, toStop } = classifyStartStop(pendingStartStopIds);
|
|
setRowsBusy(pendingStartStopIds, true);
|
|
try {
|
|
await runStartStopBatch(toStart, toStop);
|
|
showToast('Action completed');
|
|
rowSelection.value = {};
|
|
} finally {
|
|
setRowsBusy(pendingStartStopIds, false);
|
|
confirmStartStopOpen.value = false;
|
|
pendingStartStopIds = [];
|
|
close();
|
|
}
|
|
}
|
|
|
|
type FolderNode = { id: string; name: string; children: FolderNode[] };
|
|
|
|
function buildFolderOnlyTree(entry?: ResolvedOrganizerFolder | null): FolderNode | null {
|
|
if (!entry) return null;
|
|
const folders: FolderNode[] = [];
|
|
for (const child of entry.children || []) {
|
|
if ((child as ResolvedOrganizerEntry).__typename === 'ResolvedOrganizerFolder') {
|
|
const sub = buildFolderOnlyTree(child as ResolvedOrganizerFolder);
|
|
if (sub) folders.push(sub);
|
|
}
|
|
}
|
|
return { id: entry.id, name: entry.name, children: folders };
|
|
}
|
|
|
|
const folderTree = computed<FolderNode | null>(() => buildFolderOnlyTree(props.organizerRoot));
|
|
|
|
type FlatFolderRow = { id: string; name: string; depth: number; hasChildren: boolean };
|
|
|
|
function flattenVisibleFolders(
|
|
node: FolderNode | null,
|
|
depth = 0,
|
|
out: FlatFolderRow[] = []
|
|
): FlatFolderRow[] {
|
|
if (!node) return out;
|
|
out.push({ id: node.id, name: node.name, depth, hasChildren: node.children.length > 0 });
|
|
if (expandedFolders.value.has(node.id)) {
|
|
for (const child of node.children) flattenVisibleFolders(child, depth + 1, out);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
const visibleFolders = computed<FlatFolderRow[]>(() => flattenVisibleFolders(folderTree.value));
|
|
|
|
const parentById = computed<Record<string, string>>(() => {
|
|
const map: Record<string, string> = {};
|
|
function walk(node?: ResolvedOrganizerFolder | null, parentId?: string) {
|
|
if (!node) return;
|
|
if (parentId) map[node.id] = parentId;
|
|
for (const child of node.children || []) {
|
|
if ((child as ResolvedOrganizerEntry).__typename === 'ResolvedOrganizerFolder') {
|
|
walk(child as ResolvedOrganizerFolder, node.id);
|
|
}
|
|
}
|
|
}
|
|
walk(props.organizerRoot, undefined);
|
|
return map;
|
|
});
|
|
|
|
const folderChildrenIds = computed<Record<string, string[]>>(() => {
|
|
const map: Record<string, string[]> = {};
|
|
function walk(node?: ResolvedOrganizerFolder | null) {
|
|
if (!node) return;
|
|
map[node.id] = (node.children || []).map((c) => {
|
|
const entry = c as ResolvedOrganizerEntry;
|
|
return (entry as { id: string }).id;
|
|
});
|
|
for (const child of node.children || []) {
|
|
if ((child as ResolvedOrganizerEntry).__typename === 'ResolvedOrganizerFolder') {
|
|
walk(child as ResolvedOrganizerFolder);
|
|
}
|
|
}
|
|
}
|
|
walk(props.organizerRoot);
|
|
return map;
|
|
});
|
|
|
|
function getSelectedEntryIds(): string[] {
|
|
return Object.entries(rowSelection.value)
|
|
.filter(([, selected]) => !!selected)
|
|
.map(([id]) => id);
|
|
}
|
|
|
|
function openMoveModal(ids?: string[]) {
|
|
const sources = ids ?? getSelectedEntryIds();
|
|
console.log('sources', sources);
|
|
if (sources.length === 0) return;
|
|
pendingMoveSourceIds.value = sources;
|
|
selectedFolderId.value = rootFolderId.value || '';
|
|
expandedFolders.value = new Set([rootFolderId.value]);
|
|
renamingFolderId.value = '';
|
|
renameValue.value = '';
|
|
newTreeFolderName.value = '';
|
|
moveOpen.value = true;
|
|
}
|
|
|
|
async function confirmMove(close: () => void) {
|
|
const ids = pendingMoveSourceIds.value;
|
|
if (ids.length === 0) return;
|
|
if (!selectedFolderId.value) return;
|
|
await moveEntriesMutation(
|
|
{ destinationFolderId: selectedFolderId.value, sourceEntryIds: ids },
|
|
{
|
|
refetchQueries: [{ query: GET_DOCKER_CONTAINERS, variables: { skipCache: true } }],
|
|
awaitRefetchQueries: true,
|
|
}
|
|
);
|
|
rowSelection.value = {};
|
|
showToast('Moved to folder');
|
|
close();
|
|
}
|
|
|
|
async function handleCreateFolderInTree() {
|
|
const name = newTreeFolderName.value.trim();
|
|
if (!name) return;
|
|
await createFolderMutation(
|
|
{
|
|
name,
|
|
parentId: selectedFolderId.value || rootFolderId.value,
|
|
childrenIds: pendingMoveSourceIds.value,
|
|
},
|
|
{
|
|
refetchQueries: [{ query: GET_DOCKER_CONTAINERS, variables: { skipCache: true } }],
|
|
awaitRefetchQueries: true,
|
|
}
|
|
);
|
|
emit('created-folder');
|
|
showToast('Folder created');
|
|
newTreeFolderName.value = '';
|
|
expandedFolders.value.add(selectedFolderId.value || rootFolderId.value);
|
|
}
|
|
|
|
function startRenameFolder(id: string, currentName: string) {
|
|
if (!id || id === rootFolderId.value) return;
|
|
renamingFolderId.value = id;
|
|
renameValue.value = currentName;
|
|
}
|
|
|
|
async function commitRenameFolder(id: string) {
|
|
const newName = renameValue.value.trim();
|
|
if (!id || !newName || newName === id) {
|
|
renamingFolderId.value = '';
|
|
renameValue.value = '';
|
|
return;
|
|
}
|
|
const parentId = parentById.value[id] || rootFolderId.value;
|
|
const children = folderChildrenIds.value[id] || [];
|
|
// 1) Create new folder with same children under same parent
|
|
await createFolderMutation(
|
|
{ name: newName, parentId, childrenIds: children },
|
|
{ awaitRefetchQueries: true }
|
|
);
|
|
// 2) Clear old folder children to avoid cascading deletion of descendants
|
|
await setFolderChildrenMutation({ folderId: id, childrenIds: [] }, { awaitRefetchQueries: true });
|
|
// 3) Delete the now-empty old folder
|
|
await deleteEntriesMutation(
|
|
{ entryIds: [id] },
|
|
{
|
|
refetchQueries: [{ query: GET_DOCKER_CONTAINERS, variables: { skipCache: true } }],
|
|
awaitRefetchQueries: true,
|
|
}
|
|
);
|
|
renamingFolderId.value = '';
|
|
renameValue.value = '';
|
|
selectedFolderId.value = newName;
|
|
showToast('Folder renamed');
|
|
}
|
|
|
|
async function handleDeleteFolder() {
|
|
const id = selectedFolderId.value;
|
|
if (!id || id === rootFolderId.value) return;
|
|
if (!confirm('Delete this folder? Contents will move to root.')) return;
|
|
await deleteEntriesMutation(
|
|
{ entryIds: [id] },
|
|
{
|
|
refetchQueries: [{ query: GET_DOCKER_CONTAINERS, variables: { skipCache: true } }],
|
|
awaitRefetchQueries: true,
|
|
}
|
|
);
|
|
selectedFolderId.value = rootFolderId.value;
|
|
showToast('Folder deleted');
|
|
}
|
|
|
|
function toggleExpandFolder(id: string) {
|
|
const set = new Set(expandedFolders.value);
|
|
if (set.has(id)) set.delete(id);
|
|
else set.add(id);
|
|
expandedFolders.value = set;
|
|
}
|
|
|
|
// removed unused handleCreateFolder; creation handled in modal
|
|
|
|
function getSelectedContainerIds(): string[] {
|
|
const collected = new Set<string>();
|
|
|
|
function collectContainers(row: TreeRow, includeAll: boolean): void {
|
|
const isSelected = !!rowSelection.value[row.id];
|
|
const shouldInclude = includeAll || isSelected;
|
|
|
|
if (row.type === 'container') {
|
|
if (shouldInclude) collected.add(row.id);
|
|
return;
|
|
}
|
|
// folder
|
|
const children = row.children || [];
|
|
const propagate = shouldInclude; // selecting a folder selects all descendants
|
|
for (const child of children) collectContainers(child as TreeRow, propagate);
|
|
}
|
|
|
|
for (const root of treeData.value) collectContainers(root, false);
|
|
return Array.from(collected);
|
|
}
|
|
|
|
declare global {
|
|
interface Window {
|
|
toast?: {
|
|
success: (title: string, options?: { description?: string }) => void;
|
|
error?: (title: string, options?: { description?: string }) => void;
|
|
};
|
|
}
|
|
}
|
|
|
|
function showToast(message: string) {
|
|
window.toast?.success(message);
|
|
}
|
|
|
|
function handleBulkAction(action: string) {
|
|
const ids = getSelectedContainerIds();
|
|
console.log('ids', ids);
|
|
if (ids.length === 0) return;
|
|
if (action === 'Start / Stop') {
|
|
openStartStop(ids);
|
|
return;
|
|
}
|
|
if (action === 'Pause / Resume') {
|
|
openPauseResume(ids);
|
|
return;
|
|
}
|
|
showToast(`${action} (${ids.length})`);
|
|
}
|
|
|
|
// helper removed; no longer used
|
|
|
|
// removed unused types
|
|
// ContextRef type removed; no longer used
|
|
|
|
// no-op: replaced by row-wrapped UContextMenu
|
|
|
|
// Removed programmatic context menu open logic in favor of wrapping row with UContextMenu
|
|
|
|
function handleRowAction(row: TreeRow, action: string) {
|
|
if (row.type !== 'container') return;
|
|
if (action === 'Start / Stop') {
|
|
handleRowStartStop(row);
|
|
return;
|
|
}
|
|
if (action === 'Pause / Resume') {
|
|
handleRowPauseResume(row);
|
|
return;
|
|
}
|
|
showToast(`${action}: ${row.name}`);
|
|
}
|
|
|
|
const bulkItems = computed<DropdownMenuItems>(() => [
|
|
[
|
|
{
|
|
label: 'Move to folder',
|
|
icon: 'i-lucide-folder',
|
|
as: 'button',
|
|
onSelect: () => openMoveModal(),
|
|
},
|
|
{
|
|
label: 'Start / Stop',
|
|
icon: 'i-lucide-power',
|
|
as: 'button',
|
|
onSelect: () => handleBulkAction('Start / Stop'),
|
|
},
|
|
{
|
|
label: 'Pause / Resume',
|
|
icon: 'i-lucide-pause',
|
|
as: 'button',
|
|
onSelect: () => handleBulkAction('Pause / Resume'),
|
|
},
|
|
],
|
|
]);
|
|
|
|
function getRowActionItems(row: TreeRow): DropdownMenuItems {
|
|
return [
|
|
[
|
|
{
|
|
label: 'Move to folder',
|
|
icon: 'i-lucide-folder',
|
|
as: 'button',
|
|
onSelect: () => openMoveModal([row.id]),
|
|
},
|
|
{
|
|
label: 'Start / Stop',
|
|
icon: 'i-lucide-power',
|
|
as: 'button',
|
|
onSelect: () => handleRowAction(row, 'Start / Stop'),
|
|
},
|
|
{
|
|
label: 'Pause / Resume',
|
|
icon: 'i-lucide-pause',
|
|
as: 'button',
|
|
onSelect: () => handleRowAction(row, 'Pause / Resume'),
|
|
},
|
|
],
|
|
[
|
|
{
|
|
label: 'Manage Settings',
|
|
icon: 'i-lucide-settings',
|
|
as: 'button',
|
|
onSelect: () => handleRowAction(row, 'Manage Settings'),
|
|
},
|
|
],
|
|
];
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<div class="w-full">
|
|
<div class="mb-3 flex items-center gap-2">
|
|
<UInput v-model="globalFilter" class="max-w-sm min-w-[12ch]" placeholder="Filter..." />
|
|
<UDropdownMenu
|
|
:items="bulkItems"
|
|
size="md"
|
|
:ui="{
|
|
content: 'overflow-x-hidden z-40',
|
|
item: 'bg-transparent hover:bg-transparent focus:bg-transparent border-0 ring-0 outline-none shadow-none data-[state=checked]:bg-transparent',
|
|
}"
|
|
>
|
|
<UButton
|
|
color="primary"
|
|
variant="outline"
|
|
size="md"
|
|
trailing-icon="i-lucide-chevron-down"
|
|
:disabled="selectedCount === 0"
|
|
>
|
|
Actions ({{ selectedCount }})
|
|
</UButton>
|
|
</UDropdownMenu>
|
|
</div>
|
|
<UTable
|
|
ref="tableRef"
|
|
v-model:row-selection="rowSelection"
|
|
v-model:global-filter="globalFilter"
|
|
:data="treeData"
|
|
:columns="columns"
|
|
:get-row-id="(row: any) => row.id"
|
|
:get-sub-rows="(row: any) => row.children"
|
|
:column-filters-options="{ filterFromLeafRows: true }"
|
|
:loading="loading"
|
|
:ui="{ td: 'p-0 empty:p-0' }"
|
|
sticky
|
|
class="flex-1"
|
|
/>
|
|
<div v-if="!loading && treeData.length === 0" class="py-8 text-center text-gray-500">
|
|
No containers found
|
|
</div>
|
|
|
|
<UModal
|
|
v-model:open="moveOpen"
|
|
title="Move to folder"
|
|
:ui="{ footer: 'justify-end', overlay: 'z-50', content: 'z-50' }"
|
|
>
|
|
<template #body>
|
|
<div class="space-y-3">
|
|
<div class="flex items-center gap-2">
|
|
<UInput v-model="newTreeFolderName" placeholder="New folder name" class="flex-1" />
|
|
<UButton
|
|
size="sm"
|
|
color="neutral"
|
|
variant="outline"
|
|
:disabled="!newTreeFolderName.trim()"
|
|
@click="handleCreateFolderInTree"
|
|
>Create</UButton
|
|
>
|
|
<UButton
|
|
size="sm"
|
|
color="neutral"
|
|
variant="outline"
|
|
:disabled="!selectedFolderId || selectedFolderId === rootFolderId"
|
|
@click="handleDeleteFolder"
|
|
>Delete</UButton
|
|
>
|
|
</div>
|
|
|
|
<div class="border-default rounded border">
|
|
<div
|
|
v-for="row in visibleFolders"
|
|
:key="row.id"
|
|
:data-id="row.id"
|
|
class="flex items-center gap-2 px-2 py-1 hover:bg-gray-50 dark:hover:bg-gray-800"
|
|
>
|
|
<UButton
|
|
v-if="row.hasChildren"
|
|
color="neutral"
|
|
size="xs"
|
|
variant="ghost"
|
|
icon="i-lucide-chevron-right"
|
|
:class="expandedFolders.has(row.id) ? 'rotate-90' : ''"
|
|
square
|
|
@click="toggleExpandFolder(row.id)"
|
|
/>
|
|
<span v-else class="inline-block w-5" />
|
|
|
|
<input type="radio" :value="row.id" v-model="selectedFolderId" class="accent-primary" />
|
|
|
|
<div
|
|
:style="{ paddingLeft: `calc(${row.depth} * 0.75rem)` }"
|
|
class="flex min-w-0 flex-1 items-center gap-2"
|
|
>
|
|
<span class="i-lucide-folder text-gray-500" />
|
|
<template v-if="renamingFolderId === row.id">
|
|
<input
|
|
v-model="renameValue"
|
|
class="border-default bg-default flex-1 rounded border px-2 py-1"
|
|
@keydown.enter.prevent="commitRenameFolder(row.id)"
|
|
@keydown.esc.prevent="
|
|
renamingFolderId = '';
|
|
renameValue = '';
|
|
"
|
|
@blur="commitRenameFolder(row.id)"
|
|
autofocus
|
|
/>
|
|
</template>
|
|
<template v-else>
|
|
<span class="truncate">{{ row.name }}</span>
|
|
</template>
|
|
</div>
|
|
|
|
<UDropdownMenu
|
|
:items="[
|
|
[
|
|
{
|
|
label: 'Rename',
|
|
icon: 'i-lucide-pencil',
|
|
as: 'button',
|
|
onSelect: () => startRenameFolder(row.id, row.name),
|
|
},
|
|
],
|
|
]"
|
|
:ui="{ content: 'z-50' }"
|
|
>
|
|
<UButton color="neutral" variant="ghost" icon="i-lucide-more-vertical" square />
|
|
</UDropdownMenu>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
<template #footer="{ close }">
|
|
<UButton color="neutral" variant="outline" @click="close">Cancel</UButton>
|
|
<UButton
|
|
:loading="moving || creating || deleting"
|
|
:disabled="!selectedFolderId"
|
|
@click="confirmMove(close)"
|
|
>
|
|
Confirm
|
|
</UButton>
|
|
</template>
|
|
</UModal>
|
|
|
|
<UModal
|
|
v-model:open="confirmStartStopOpen"
|
|
title="Confirm actions"
|
|
:ui="{ footer: 'justify-end', overlay: 'z-50', content: 'z-50' }"
|
|
>
|
|
<template #body>
|
|
<div class="space-y-3">
|
|
<div v-if="confirmToStop.length" class="space-y-1">
|
|
<div class="text-sm font-medium">Will stop</div>
|
|
<ul class="list-disc pl-5 text-sm text-gray-600 dark:text-gray-300">
|
|
<li v-for="item in confirmToStop" :key="item.name" class="truncate">{{ item.name }}</li>
|
|
</ul>
|
|
</div>
|
|
<div v-if="confirmToStart.length" class="space-y-1">
|
|
<div class="text-sm font-medium">Will start</div>
|
|
<ul class="list-disc pl-5 text-sm text-gray-600 dark:text-gray-300">
|
|
<li v-for="item in confirmToStart" :key="item.name" class="truncate">{{ item.name }}</li>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
<template #footer="{ close }">
|
|
<UButton color="neutral" variant="outline" @click="close">Cancel</UButton>
|
|
<UButton @click="confirmStartStop(close)">Confirm</UButton>
|
|
</template>
|
|
</UModal>
|
|
|
|
<UModal
|
|
v-model:open="confirmPauseResumeOpen"
|
|
title="Confirm actions"
|
|
:ui="{ footer: 'justify-end', overlay: 'z-50', content: 'z-50' }"
|
|
>
|
|
<template #body>
|
|
<div class="space-y-3">
|
|
<div v-if="confirmToPause.length" class="space-y-1">
|
|
<div class="text-sm font-medium">Will pause</div>
|
|
<ul class="list-disc pl-5 text-sm text-gray-600 dark:text-gray-300">
|
|
<li v-for="item in confirmToPause" :key="item.name" class="truncate">{{ item.name }}</li>
|
|
</ul>
|
|
</div>
|
|
<div v-if="confirmToResume.length" class="space-y-1">
|
|
<div class="text-sm font-medium">Will resume</div>
|
|
<ul class="list-disc pl-5 text-sm text-gray-600 dark:text-gray-300">
|
|
<li v-for="item in confirmToResume" :key="item.name" class="truncate">{{ item.name }}</li>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
<template #footer="{ close }">
|
|
<UButton color="neutral" variant="outline" @click="close">Cancel</UButton>
|
|
<UButton @click="confirmPauseResume(close)">Confirm</UButton>
|
|
</template>
|
|
</UModal>
|
|
</div>
|
|
</template>
|