table > select & status badge

fix context menu trigger area and dropdown styles

implement 'move to folder'

implement 'start / stop' bulk & row actions

implement pause / resume

implement column visibility customization

fix(organizer): over-eager addition of untracked containers to root folder

scaffold docker form service

docker container management prototype

use compacted table as sidebar tree

add drag and drop for re-ordering containers & folders

right click to reame & delete folders

add bottom padding to container to make it easier to drag items to bottom in sidebar

click overview row to open details

track active container in query param

refactor: extract composables

refactor: simplify organizer operations

refactor!: rm intermediate tree resolution of organizers

BREAKING CHANGE: ResolvedOrganizerView -> root is replaced by rootId +
flatEntries.

`root` resolved a tree representation of an organizer, but this required
clients to defined and use their own tree operations, thus increasing
client complexity.

Instead, `flatEntries` is more suitable for client operations and only
requires an initial mapping step on the client for efficiency.

fix: rm extra Root layer in table

map containers to their template files

feat: icon support

fix: container state badge

chore: fix formatting

fix: search filtering

fix: context menu

feat: filtering & bulk actions in compact mode

feat: critical notifications

feat: notifyIfUnique service method

tmp: critical notifications system

fix: nuxt ui portal styling

fix: notifications type check

fix api tests

fix: css

Revert "fix: css"

This reverts commit 234c2e2c65.

add docker constants

flatten css scopes

feat: file modification for replacing docker overview table

feat: navigate to container update page

feat: implement manage settings action

fix: column visibility toggle

fix: move update to a badge + popover

feat: save column visibility preferences across visits

fix: add feature flag to containers file mod

fix: circular dependency in docker service

add a flag to opt out of version check in super.shouldApply in file mods

fix: optimistic column toggle update

refactor: optimistic column toggle

feat: container start & stop order

feat: bulk toggle auto-start

fix: add background style reset, apply to button:hover as well

feat: add ENABLE_NEXT_DOCKER_RELEASE=true to staging environment

chore(api): add dev/notifications to gitignore

feat: container update actions

fix: container update logic

feat: bulk container updates

feat: container sizes

fix: container sizes modla overlay

fix: checkbox alignment

fix: revert color in main css

chore: ignore build output in lint & fix

feat: server-side container ip

add docker feature flag to .env.production

fix: container port duplication

feat: multi-value copyable badges

feat: make lanIpPorts a list, not a csv

feat: visit button

feat: include indexed search fields in filter input title

feat: sync userprefs.cfg for rollback compat

feat: port conflicts

refactor: port conflict frontend stuff

feat: update all containers bulk action

feat: docker logs

fix: use container name instead of id in 'view logs' modal

make webgui iframable

feat: re-add compact mode

manage settings > compact view

fix styles

feat: container stats

refactor: docker autostart service

refactor: docker log, event, and port services
This commit is contained in:
Pujit Mehrotra
2025-09-17 11:59:48 -04:00
committed by Ajit Mehrotra
parent 9ef1cf1eca
commit d07aa42063
25 changed files with 3273 additions and 2382 deletions

View File

@@ -3,7 +3,5 @@
"extraOrigins": [],
"sandbox": true,
"ssoSubIds": [],
"plugins": [
"unraid-api-plugin-connect"
]
}
"plugins": ["unraid-api-plugin-connect"]
}

File diff suppressed because it is too large Load Diff

View File

@@ -1886,6 +1886,7 @@ export type Query = {
disk: Disk;
disks: Array<Disk>;
docker: Docker;
dockerContainerOverviewForm: DockerContainerOverviewForm;
flash: Flash;
/** Get JSON Schema for API key creation form */
getApiKeyCreationFormSchema: ApiKeyFormSettings;
@@ -1949,6 +1950,11 @@ export type QueryDiskArgs = {
};
export type QueryDockerContainerOverviewFormArgs = {
skipCache?: Scalars['Boolean']['input'];
};
export type QueryGetPermissionsForRolesArgs = {
roles: Array<Role>;
};

View File

@@ -25,6 +25,7 @@ vi.mock('@nestjs/common', async () => {
debug: vi.fn(),
error: vi.fn(),
log: vi.fn(),
verbose: vi.fn(),
})),
};
});
@@ -60,12 +61,23 @@ vi.mock('./utils/docker-client.js', () => ({
// Mock DockerService
vi.mock('./docker.service.js', () => ({
DockerService: vi.fn().mockImplementation(() => ({
getDockerClient: vi.fn(),
clearContainerCache: vi.fn(),
getAppInfo: vi.fn().mockResolvedValue({ info: { apps: { installed: 1, running: 1 } } }),
})),
}));
const { mockDockerClientInstance } = vi.hoisted(() => {
const mock = {
getEvents: vi.fn(),
} as unknown as Docker;
return { mockDockerClientInstance: mock };
});
// Mock the docker client util
vi.mock('@app/unraid-api/graph/resolvers/docker/utils/docker-client.js', () => ({
getDockerClient: vi.fn().mockReturnValue(mockDockerClientInstance),
}));
describe('DockerEventService', () => {
let service: DockerEventService;
let dockerService: DockerService;

View File

@@ -0,0 +1,110 @@
import { Injectable } from '@nestjs/common';
import { type UISchemaElement } from '@jsonforms/core';
import { DockerContainerOverviewForm } from '@app/unraid-api/graph/resolvers/docker/docker.model.js';
import { DockerService } from '@app/unraid-api/graph/resolvers/docker/docker.service.js';
import { DataSlice } from '@app/unraid-api/types/json-forms.js';
@Injectable()
export class DockerFormService {
constructor(private readonly dockerService: DockerService) {}
async getContainerOverviewForm(skipCache = false): Promise<DockerContainerOverviewForm> {
const containers = await this.dockerService.getContainers({ skipCache });
// Transform containers data for table display
const tableData = containers.map((container) => ({
id: container.id,
name: container.names[0]?.replace(/^\//, '') || 'Unknown',
state: container.state,
status: container.status,
image: container.image,
ports: container.ports
.map((p) => {
if (p.publicPort && p.privatePort) {
return `${p.publicPort}:${p.privatePort}/${p.type}`;
} else if (p.privatePort) {
return `${p.privatePort}/${p.type}`;
}
return '';
})
.filter(Boolean)
.join(', '),
autoStart: container.autoStart,
network: container.hostConfig?.networkMode || 'default',
}));
const dataSchema = this.createDataSchema();
const uiSchema = this.createUiSchema();
return {
id: 'docker-container-overview',
dataSchema: {
type: 'object',
properties: dataSchema,
},
uiSchema: {
type: 'VerticalLayout',
elements: [uiSchema],
},
data: tableData,
};
}
private createDataSchema(): DataSlice {
return {
containers: {
type: 'array',
items: {
type: 'object',
properties: {
id: {
type: 'string',
title: 'ID',
},
name: {
type: 'string',
title: 'Name',
},
state: {
type: 'string',
title: 'State',
enum: ['RUNNING', 'EXITED'],
},
status: {
type: 'string',
title: 'Status',
},
image: {
type: 'string',
title: 'Image',
},
ports: {
type: 'string',
title: 'Ports',
},
autoStart: {
type: 'boolean',
title: 'Auto Start',
},
network: {
type: 'string',
title: 'Network',
},
},
},
},
};
}
private createUiSchema(): UISchemaElement {
return {
type: 'Control',
scope: '#',
options: {
variant: 'table',
},
};
}
}

View File

@@ -0,0 +1 @@
export const DOCKER_SERVICE_TOKEN = Symbol('DOCKER_SERVICE');

View File

@@ -8,10 +8,13 @@ import {
registerEnumType,
} from '@nestjs/graphql';
import { type Layout } from '@jsonforms/core';
import { Node } from '@unraid/shared/graphql.model.js';
import { PrefixedID } from '@unraid/shared/prefixed-id-scalar.js';
import { GraphQLBigInt, GraphQLJSON, GraphQLPort } from 'graphql-scalars';
import { DataSlice } from '@app/unraid-api/types/json-forms.js';
export enum ContainerPortType {
TCP = 'TCP',
UDP = 'UDP',

View File

@@ -5,6 +5,7 @@ import { type Cache } from 'cache-manager';
import Docker from 'dockerode';
import { execa } from 'execa';
import { AppError } from '@app/core/errors/app-error.js';
import { pubsub, PUBSUB_CHANNEL } from '@app/core/pubsub.js';
import { catchHandlers } from '@app/core/utils/misc/catch-handlers.js';
import { sleep } from '@app/core/utils/misc/sleep.js';

View File

@@ -3,6 +3,7 @@ import { Injectable, Logger } from '@nestjs/common';
import type { ContainerListOptions } from 'dockerode';
import { AppError } from '@app/core/errors/app-error.js';
import { DockerTemplateIconService } from '@app/unraid-api/graph/resolvers/docker/docker-template-icon.service.js';
import { DockerContainer } from '@app/unraid-api/graph/resolvers/docker/docker.model.js';
import { DockerService } from '@app/unraid-api/graph/resolvers/docker/docker.service.js';
import { DockerOrganizerConfigService } from '@app/unraid-api/graph/resolvers/docker/organizer/docker-organizer-config.service.js';
@@ -51,7 +52,8 @@ export class DockerOrganizerService {
private readonly logger = new Logger(DockerOrganizerService.name);
constructor(
private readonly dockerConfigService: DockerOrganizerConfigService,
private readonly dockerService: DockerService
private readonly dockerService: DockerService,
private readonly dockerTemplateIconService: DockerTemplateIconService
) {}
async getResources(

View File

@@ -215,11 +215,13 @@ export function enrichFlatEntries(
*
* @param view - The flat organizer view to resolve
* @param resources - The collection of all available resources
* @param iconMap - Optional map of resource IDs to icon URLs
* @returns A resolved view with nested objects instead of ID references
*/
export function resolveOrganizerView(
view: OrganizerView,
resources: OrganizerV1['resources']
resources: OrganizerV1['resources'],
iconMap?: Map<string, string>
): ResolvedOrganizerView {
const flatEntries = enrichFlatEntries(view, resources);
@@ -237,13 +239,17 @@ export function resolveOrganizerView(
* are replaced with actual objects for frontend convenience.
*
* @param organizer - The flat organizer structure to resolve
* @param iconMap - Optional map of resource IDs to icon URLs
* @returns A resolved organizer with nested objects instead of ID references
*/
export function resolveOrganizer(organizer: OrganizerV1): ResolvedOrganizerV1 {
export function resolveOrganizer(
organizer: OrganizerV1,
iconMap?: Map<string, string>
): ResolvedOrganizerV1 {
const resolvedViews: ResolvedOrganizerView[] = [];
for (const [viewId, view] of Object.entries(organizer.views)) {
resolvedViews.push(resolveOrganizerView(view, organizer.resources));
resolvedViews.push(resolveOrganizerView(view, organizer.resources, iconMap));
}
return {

8
pnpm-lock.yaml generated
View File

@@ -12451,8 +12451,8 @@ packages:
vue-component-type-helpers@3.0.6:
resolution: {integrity: sha512-6CRM8X7EJqWCJOiKPvSLQG+hJPb/Oy2gyJx3pLjUEhY7PuaCthQu3e0zAGI1lqUBobrrk9IT0K8sG2GsCluxoQ==}
vue-component-type-helpers@3.1.3:
resolution: {integrity: sha512-V1dOD8XYfstOKCnXbWyEJIrhTBMwSyNjv271L1Jlx9ExpNlCSuqOs3OdWrGJ0V544zXufKbcYabi/o+gK8lyfQ==}
vue-component-type-helpers@3.1.4:
resolution: {integrity: sha512-Uws7Ew1OzTTqHW8ZVl/qLl/HB+jf08M0NdFONbVWAx0N4gMLK8yfZDgeB77hDnBmaigWWEn5qP8T9BG59jIeyQ==}
vue-demi@0.14.10:
resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==}
@@ -16519,7 +16519,7 @@ snapshots:
storybook: 9.1.3(@testing-library/dom@10.4.0)(prettier@3.6.2)(vite@7.1.3(@types/node@22.18.0)(jiti@2.5.1)(lightningcss@1.30.1)(stylus@0.57.0)(terser@5.43.1)(tsx@4.20.5)(yaml@2.8.1))
type-fest: 2.19.0
vue: 3.5.20(typescript@5.9.2)
vue-component-type-helpers: 3.1.3
vue-component-type-helpers: 3.1.4
'@swc/core-darwin-arm64@1.13.5':
optional: true
@@ -25363,7 +25363,7 @@ snapshots:
vue-component-type-helpers@3.0.6: {}
vue-component-type-helpers@3.1.3: {}
vue-component-type-helpers@3.1.4: {}
vue-demi@0.14.10(vue@3.5.20(typescript@5.9.2)):
dependencies:

View File

@@ -482,12 +482,12 @@ const processedColumns = computed<TableColumn<TreeRow<T>>[]>(() => {
const header = wrapColumnHeaderRenderer(originalHeader);
const cell = (col as { cell?: unknown }).cell
? ({ row }: { row: TableInstanceRow<T> }) => {
const cellFn = (col as { cell: (args: unknown) => VNode | string | number }).cell;
const cellFn = (col as { cell: (args: unknown) => VNode | string | number }).cell;
const enhancedRow = enhanceRowInstance(row as unknown as TableInstanceRow<T>);
const content = typeof cellFn === 'function' ? cellFn({ row: enhancedRow }) : cellFn;
return createCellWrapper(enhancedRow, content as VNode, colIndex + baseColumnIndex);
}
const enhancedRow = enhanceRowInstance(row as unknown as TableInstanceRow<T>);
const content = typeof cellFn === 'function' ? cellFn({ row: enhancedRow }) : cellFn;
return createCellWrapper(enhancedRow, content as VNode, colIndex + baseColumnIndex);
}
: undefined;
return {
@@ -541,48 +541,28 @@ function enhanceRowInstance(row: TableInstanceRow<T>): EnhancedRow<T> {
</script>
<template>
<div
class="w-full"
ref="tableContainerRef"
@dragover.capture="handleContainerDragOver"
@drop.capture="handleContainerDrop"
>
<slot
name="toolbar"
:selected-count="selectedCount"
:global-filter="globalFilter"
:column-visibility="columnVisibility"
:column-order="columnOrderState"
:row-selection="rowSelection"
:set-global-filter="setGlobalFilter"
>
<div class="w-full" ref="tableContainerRef" @dragover.capture="handleContainerDragOver"
@drop.capture="handleContainerDrop">
<slot name="toolbar" :selected-count="selectedCount" :global-filter="globalFilter"
:column-visibility="columnVisibility" :column-order="columnOrderState" :row-selection="rowSelection"
:set-global-filter="setGlobalFilter">
<div v-if="!compact" class="mb-3 flex items-center gap-2">
<slot name="toolbar-start" />
<slot name="toolbar-end" />
</div>
</slot>
<UTable
ref="tableRef"
v-model:row-selection="rowSelection"
v-model:column-visibility="columnVisibility"
v-model:column-sizing="columnSizing"
v-model:column-order="columnOrderState"
:data="flattenedData"
:columns="processedColumns"
:get-row-id="(row: any) => row.id"
<UTable ref="tableRef" v-model:row-selection="rowSelection" v-model:column-visibility="columnVisibility"
v-model:column-sizing="columnSizing" v-model:column-order="columnOrderState" :data="flattenedData"
:columns="processedColumns" :get-row-id="(row: any) => row.id"
:get-row-can-select="(row: any) => canSelectRow(row.original)"
:column-filters-options="{ filterFromLeafRows: true }"
:column-sizing-options="{ enableColumnResizing: enableResizing, columnResizeMode: 'onChange' }"
:loading="loading"
:column-sizing-options="{ enableColumnResizing: enableResizing, columnResizeMode: 'onChange' }" :loading="loading"
:ui="{
td: 'p-0 empty:p-0',
thead: compact ? 'hidden' : '',
th: (compact ? 'hidden ' : '') + 'p-0',
}"
sticky
class="base-tree-table flex-1 pb-2"
/>
}" sticky class="base-tree-table flex-1 pb-2" />
<div v-if="!loading && filteredData.length === 0" class="py-8 text-center text-gray-500">
<slot name="empty">No items found</slot>

View File

@@ -183,29 +183,14 @@ onBeforeUnmount(() => {
<template>
<div v-if="hasValues" class="flex flex-wrap items-center gap-1">
<component
:is="UBadge"
v-for="entry in inlineEntries"
:key="entry.key"
:color="getBadgeColor(entry.key)"
:variant="getBadgeVariant(entry.key)"
:size="size"
:title="getBadgeTitle(entry.key)"
:aria-label="getBadgeAriaLabel(entry.key)"
data-stop-row-click="true"
role="button"
tabindex="0"
class="max-w-[20ch] cursor-pointer items-center gap-1 truncate select-none"
@click.stop="handleCopy(entry)"
@keydown="handleBadgeKeydown($event, entry)"
>
<component :is="UBadge" v-for="entry in inlineEntries" :key="entry.key" :color="getBadgeColor(entry.key)"
:variant="getBadgeVariant(entry.key)" :size="size" :title="getBadgeTitle(entry.key)"
:aria-label="getBadgeAriaLabel(entry.key)" data-stop-row-click="true" role="button" tabindex="0"
class="max-w-[20ch] cursor-pointer items-center gap-1 truncate select-none" @click.stop="handleCopy(entry)"
@keydown="handleBadgeKeydown($event, entry)">
<span class="flex min-w-0 items-center gap-1">
<component
:is="UIcon"
v-if="isBadgeCopied(entry.key)"
name="i-lucide-check"
class="h-4 w-4 flex-shrink-0 text-white/90"
/>
<component :is="UIcon" v-if="isBadgeCopied(entry.key)" name="i-lucide-check"
class="h-4 w-4 flex-shrink-0 text-white/90" />
<span class="truncate">{{ entry.value }}</span>
</span>
</component>
@@ -213,42 +198,21 @@ onBeforeUnmount(() => {
<component :is="UPopover" v-if="overflowEntries.length">
<template #default>
<span data-stop-row-click="true" @click.stop>
<component
:is="UBadge"
color="neutral"
variant="outline"
:size="size"
class="cursor-pointer select-none"
>
<component :is="UBadge" color="neutral" variant="outline" :size="size" class="cursor-pointer select-none">
+{{ overflowEntries.length }} more
</component>
</span>
</template>
<template #content>
<div class="max-h-64 max-w-xs space-y-1 overflow-y-auto p-1">
<component
:is="UBadge"
v-for="entry in overflowEntries"
:key="entry.key"
:color="getBadgeColor(entry.key)"
:variant="getBadgeVariant(entry.key)"
:size="size"
:title="getBadgeTitle(entry.key)"
:aria-label="getBadgeAriaLabel(entry.key)"
data-stop-row-click="true"
role="button"
tabindex="0"
<component :is="UBadge" v-for="entry in overflowEntries" :key="entry.key" :color="getBadgeColor(entry.key)"
:variant="getBadgeVariant(entry.key)" :size="size" :title="getBadgeTitle(entry.key)"
:aria-label="getBadgeAriaLabel(entry.key)" data-stop-row-click="true" role="button" tabindex="0"
class="w-full cursor-pointer items-center justify-start gap-1 truncate select-none"
@click.stop="handleCopy(entry)"
@keydown="handleBadgeKeydown($event, entry)"
>
@click.stop="handleCopy(entry)" @keydown="handleBadgeKeydown($event, entry)">
<span class="flex min-w-0 items-center gap-1">
<component
:is="UIcon"
v-if="isBadgeCopied(entry.key)"
name="i-lucide-check"
class="h-4 w-4 flex-shrink-0 text-white/90"
/>
<component :is="UIcon" v-if="isBadgeCopied(entry.key)" name="i-lucide-check"
class="h-4 w-4 flex-shrink-0 text-white/90" />
<span class="truncate">{{ entry.value }}</span>
</span>
</component>

View File

@@ -136,35 +136,20 @@ async function handleRefresh() {
</script>
<template>
<UModal
v-model:open="isOpen"
title="Container Sizes"
:ui="{ footer: 'justify-end', content: 'sm:max-w-4xl' }"
>
<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"
>
<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' }"
>
<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>

View File

@@ -280,10 +280,10 @@ const columns = computed<TableColumn<TreeRow<AutostartEntry>>[]>(() => {
if (!entry) return row.original.name;
const badge = entry.container.state
? h(UBadge, {
label: entry.container.state,
variant: 'subtle',
size: 'sm',
})
label: entry.container.state,
variant: 'subtle',
size: 'sm',
})
: null;
return h('div', { class: 'flex items-center justify-between gap-3 pr-2' }, [
h('span', { class: 'font-medium' }, row.original.name),
@@ -364,46 +364,23 @@ const columns = computed<TableColumn<TreeRow<AutostartEntry>>[]>(() => {
</p>
</div>
<div class="flex items-center gap-2">
<UButton
size="sm"
variant="soft"
icon="i-lucide-toggle-right"
:disabled="!hasSelection || saving"
@click="handleBulkToggle"
>
<UButton size="sm" variant="soft" icon="i-lucide-toggle-right" :disabled="!hasSelection || saving"
@click="handleBulkToggle">
Toggle Auto Start
</UButton>
<UButton
size="sm"
variant="ghost"
icon="i-lucide-arrow-left"
:disabled="saving"
@click="handleClose"
>
<UButton size="sm" variant="ghost" icon="i-lucide-arrow-left" :disabled="saving" @click="handleClose">
Back to Overview
</UButton>
</div>
</div>
<div
v-if="errorMessage"
class="border-destructive/30 bg-destructive/10 text-destructive rounded-md border px-4 py-2 text-sm"
>
<div v-if="errorMessage"
class="border-destructive/30 bg-destructive/10 text-destructive rounded-md border px-4 py-2 text-sm">
{{ errorMessage }}
</div>
<BaseTreeTable
:data="treeData"
:columns="columns"
:loading="saving"
:enable-drag-drop="true"
:busy-row-ids="busyRowIds"
:can-expand="() => false"
:can-select="(row: any) => row.type === 'container'"
:can-drag="() => true"
:can-drop-inside="() => false"
v-model:selected-ids="selectedIds"
@row:drop="handleDrop"
/>
<BaseTreeTable :data="treeData" :columns="columns" :loading="saving" :enable-drag-drop="true"
:busy-row-ids="busyRowIds" :can-expand="() => false" :can-select="(row: any) => row.type === 'container'"
:can-drag="() => true" :can-drop-inside="() => false" v-model:selected-ids="selectedIds" @row:drop="handleDrop" />
</div>
</template>

View File

@@ -411,41 +411,18 @@ const [transitionContainerRef] = useAutoAnimate({
<div class="mb-4 flex flex-wrap items-center justify-between gap-3">
<div class="text-base font-medium">Docker Containers</div>
<div class="flex items-center gap-2">
<UButton
size="xs"
variant="ghost"
icon="i-lucide-refresh-cw"
:loading="loading"
@click="refreshContainers"
/>
<UButton
size="xs"
color="primary"
variant="solid"
icon="i-lucide-plus"
:disabled="props.disabled"
@click="handleAddContainerClick"
>
<UButton size="xs" variant="ghost" icon="i-lucide-refresh-cw" :loading="loading"
@click="refreshContainers" />
<UButton size="xs" color="primary" variant="solid" icon="i-lucide-plus" :disabled="props.disabled"
@click="handleAddContainerClick">
Add Container
</UButton>
<UButton
size="xs"
color="neutral"
variant="outline"
icon="i-lucide-list-checks"
:disabled="loading"
@click="openAutostartSettings"
>
<UButton size="xs" color="neutral" variant="outline" icon="i-lucide-list-checks" :disabled="loading"
@click="openAutostartSettings">
Customize Start Order
</UButton>
<UButton
size="xs"
color="neutral"
variant="outline"
icon="i-lucide-hard-drive"
:disabled="props.disabled"
@click="showSizesModal = true"
>
<UButton size="xs" color="neutral" variant="outline" icon="i-lucide-hard-drive" :disabled="props.disabled"
@click="showSizesModal = true">
Container Size
</UButton>
</div>
@@ -454,56 +431,28 @@ const [transitionContainerRef] = useAutoAnimate({
<DockerOrphanedAlert :orphaned-containers="orphanedContainers" @refresh="refreshContainers" />
</div>
<div v-if="lanPortConflicts.length" class="mb-4">
<DockerPortConflictsAlert
:lan-conflicts="lanPortConflicts"
@container:select="handleConflictContainerAction"
/>
<DockerPortConflictsAlert :lan-conflicts="lanPortConflicts"
@container:select="handleConflictContainerAction" />
</div>
<UAlert
v-if="error"
color="error"
title="Failed to load Docker containers"
:description="error.message"
icon="i-lucide-alert-circle"
class="mb-4"
>
<UAlert v-if="error" color="error" title="Failed to load Docker containers" :description="error.message"
icon="i-lucide-alert-circle" class="mb-4">
<template #actions>
<div class="flex gap-2">
<UButton size="xs" variant="soft" :loading="loading" @click="refetch({ skipCache: true })"
>Retry</UButton
>
<UButton
size="xs"
variant="outline"
:loading="resettingMappings"
@click="handleResetAndRetry"
>Reset &amp; Retry</UButton
>
<UButton size="xs" variant="soft" :loading="loading" @click="refetch({ skipCache: true })">Retry</UButton>
<UButton size="xs" variant="outline" :loading="resettingMappings" @click="handleResetAndRetry">Reset &amp;
Retry</UButton>
</div>
</template>
</UAlert>
<div>
<DockerContainersTable
:containers="containers"
:flat-entries="flatEntries"
:root-folder-id="rootFolderId"
:view-prefs="viewPrefs"
:loading="loading"
:active-id="activeId"
:selected-ids="selectedIds"
@created-folder="refreshContainers"
@row:click="handleTableRowClick"
@update:selectedIds="handleUpdateSelectedIds"
/>
<DockerContainersTable :containers="containers" :flat-entries="flatEntries" :root-folder-id="rootFolderId"
:view-prefs="viewPrefs" :loading="loading" :active-id="activeId" :selected-ids="selectedIds"
@created-folder="refreshContainers" @row:click="handleTableRowClick"
@update:selectedIds="handleUpdateSelectedIds" />
</div>
</template>
<DockerAutostartSettings
v-else
:containers="containers"
:loading="loading"
:refresh="refreshContainers"
@close="closeAutostartSettings"
/>
<DockerAutostartSettings v-else :containers="containers" :loading="loading" :refresh="refreshContainers"
@close="closeAutostartSettings" />
</div>
<div v-else class="grid gap-6 md:grid-cols-[280px_1fr]">
@@ -511,27 +460,14 @@ const [transitionContainerRef] = useAutoAnimate({
<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="refreshContainers"
/>
<UButton size="xs" variant="ghost" icon="i-lucide-refresh-cw" :loading="loading"
@click="refreshContainers" />
</div>
</template>
<USkeleton v-if="loading" class="h-6 w-full" :ui="{ rounded: 'rounded' }" />
<DockerSidebarTree
v-else
:containers="containers"
:flat-entries="flatEntries"
:root-folder-id="rootFolderId"
:selected-ids="selectedIds"
:active-id="activeId"
:disabled="props.disabled || loading"
@item:click="handleSidebarClick"
@item:select="handleSidebarSelect"
/>
<DockerSidebarTree v-else :containers="containers" :flat-entries="flatEntries" :root-folder-id="rootFolderId"
:selected-ids="selectedIds" :active-id="activeId" :disabled="props.disabled || loading"
@item:click="handleSidebarClick" @item:select="handleSidebarSelect" />
</UCard>
<div v-if="shouldUseLegacyEditPage">
@@ -540,50 +476,25 @@ const [transitionContainerRef] = useAutoAnimate({
<div class="flex flex-col gap-3">
<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"
/>
<UButton size="xs" variant="ghost" icon="i-lucide-arrow-left" @click="goBackToOverview" />
<div class="font-medium">
{{ stripLeadingSlash(activeContainer?.names?.[0]) || 'Container' }}
</div>
</div>
<UBadge
v-if="activeContainer?.state"
:label="activeContainer.state"
color="primary"
variant="subtle"
/>
<UBadge v-if="activeContainer?.state" :label="activeContainer.state" color="primary" variant="subtle" />
</div>
<UTabs
v-model="legacyPaneTab"
:items="legacyPaneTabs"
variant="link"
color="primary"
size="md"
:ui="{ list: 'gap-1' }"
/>
<UTabs v-model="legacyPaneTab" :items="legacyPaneTabs" variant="link" color="primary" size="md"
:ui="{ list: 'gap-1' }" />
</div>
</template>
<div
v-show="legacyPaneTab === 'overview'"
:class="['relative', { 'pointer-events-none opacity-50': isDetailsDisabled }]"
>
<div v-show="legacyPaneTab === 'overview'"
:class="['relative', { 'pointer-events-none opacity-50': isDetailsDisabled }]">
<ContainerOverviewCard :container="activeContainer" :loading="isDetailsLoading" />
</div>
<div
v-show="legacyPaneTab === 'settings'"
:class="['relative min-h-[60vh]', { 'pointer-events-none opacity-50': isDetailsDisabled }]"
>
<iframe
v-if="legacyEditUrl"
:key="legacyEditUrl"
:src="legacyEditUrl"
class="h-[70vh] w-full border-0"
loading="lazy"
/>
<div v-show="legacyPaneTab === 'settings'"
:class="['relative min-h-[60vh]', { 'pointer-events-none opacity-50': isDetailsDisabled }]">
<iframe v-if="legacyEditUrl" :key="legacyEditUrl" :src="legacyEditUrl" class="h-[70vh] w-full border-0"
loading="lazy" />
<div v-else class="flex h-[70vh] items-center justify-center text-sm text-neutral-500">
Unable to load container settings for this entry.
</div>
@@ -591,34 +502,17 @@ const [transitionContainerRef] = useAutoAnimate({
<USkeleton class="h-6 w-6" />
</div>
</div>
<div
v-show="legacyPaneTab === 'logs'"
:class="['flex h-[70vh] flex-col', { 'pointer-events-none opacity-50': isDetailsDisabled }]"
>
<LogViewerToolbar
v-model:filter-text="logFilterText"
:show-refresh="false"
@refresh="handleLogRefresh"
/>
<SingleDockerLogViewer
v-if="activeContainer"
ref="logViewerRef"
:container-name="stripLeadingSlash(activeContainer.names?.[0])"
:auto-scroll="logAutoScroll"
:client-filter="logFilterText"
class="h-full flex-1"
/>
<div v-show="legacyPaneTab === 'logs'"
:class="['flex h-[70vh] flex-col', { 'pointer-events-none opacity-50': isDetailsDisabled }]">
<LogViewerToolbar v-model:filter-text="logFilterText" :show-refresh="false" @refresh="handleLogRefresh" />
<SingleDockerLogViewer v-if="activeContainer" ref="logViewerRef"
:container-name="stripLeadingSlash(activeContainer.names?.[0])" :auto-scroll="logAutoScroll"
:client-filter="logFilterText" class="h-full flex-1" />
</div>
<div
v-show="legacyPaneTab === 'console'"
:class="['h-[70vh]', { 'pointer-events-none opacity-50': isDetailsDisabled }]"
>
<DockerConsoleViewer
v-if="activeContainer"
:container-name="activeContainerName"
:shell="activeContainer.shell ?? 'sh'"
class="h-full"
/>
<div v-show="legacyPaneTab === 'console'"
:class="['h-[70vh]', { 'pointer-events-none opacity-50': isDetailsDisabled }]">
<DockerConsoleViewer v-if="activeContainer" :container-name="activeContainerName"
:shell="activeContainer.shell ?? 'sh'" class="h-full" />
</div>
</UCard>
</div>
@@ -628,20 +522,10 @@ const [transitionContainerRef] = useAutoAnimate({
<template #header>
<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"
/>
<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"
/>
<UBadge v-if="activeContainer?.state" :label="activeContainer.state" color="primary" variant="subtle" />
</div>
</template>
<div class="relative">
@@ -680,12 +564,8 @@ const [transitionContainerRef] = useAutoAnimate({
<div class="font-medium">Logs</div>
</template>
<div :class="['h-96', { 'pointer-events-none opacity-50': isDetailsDisabled }]">
<SingleDockerLogViewer
v-if="activeContainer"
:container-name="stripLeadingSlash(activeContainer.names?.[0])"
:auto-scroll="true"
class="h-full"
/>
<SingleDockerLogViewer v-if="activeContainer"
:container-name="stripLeadingSlash(activeContainer.names?.[0])" :auto-scroll="true" class="h-full" />
</div>
</UCard>
</div>

View File

@@ -48,14 +48,8 @@ const handleRefresh = async () => {
<div v-if="error" class="text-red-500">Error loading container data: {{ error.message }}</div>
<DockerContainersTable
:containers="containers"
:flat-entries="flatEntries"
:root-folder-id="rootFolderId"
:view-prefs="viewPrefs"
:loading="loading"
@created-folder="handleRefresh"
/>
<DockerContainersTable :containers="containers" :flat-entries="flatEntries" :root-folder-id="rootFolderId"
:view-prefs="viewPrefs" :loading="loading" @created-folder="handleRefresh" />
</div>
</template>

View File

@@ -625,79 +625,39 @@ const rowActionDropdownUi = {
<template>
<div class="w-full">
<BaseTreeTable
ref="baseTableRef"
:data="treeData"
:columns="columns"
:loading="loading"
:compact="compact"
:active-id="activeId"
:selected-ids="selectedIds"
:busy-row-ids="busyRowIds"
:enable-drag-drop="!!flatEntries"
enable-resizing
v-model:column-sizing="columnSizing"
v-model:column-order="columnOrder"
:searchable-keys="DOCKER_SEARCHABLE_KEYS"
:search-accessor="dockerSearchAccessor"
<BaseTreeTable ref="baseTableRef" :data="treeData" :columns="columns" :loading="loading" :compact="compact"
:active-id="activeId" :selected-ids="selectedIds" :busy-row-ids="busyRowIds" :enable-drag-drop="!!flatEntries"
enable-resizing v-model:column-sizing="columnSizing" v-model:column-order="columnOrder"
:searchable-keys="DOCKER_SEARCHABLE_KEYS" :search-accessor="dockerSearchAccessor"
:can-expand="(row: TreeRow<DockerContainer>) => row.type === 'folder'"
:can-select="(row: TreeRow<DockerContainer>) => row.type === 'container'"
:can-drag="(row: TreeRow<DockerContainer>) => row.type === 'container' || row.type === 'folder'"
:can-drop-inside="
(row: TreeRow<DockerContainer>) => row.type === 'container' || row.type === 'folder'
"
@row:click="handleRowClick"
@row:contextmenu="handleRowContextMenu"
@row:select="handleRowSelect"
@row:drop="handleDropOnRow"
@update:selected-ids="handleUpdateSelectedIds"
>
<template
#toolbar="{
selectedCount: count,
globalFilter: filterText,
setGlobalFilter,
columnOrder: tableColumnOrder,
}"
>
:can-drag="(row: TreeRow<DockerContainer>) => row.type === 'container' || row.type === 'folder'" :can-drop-inside="(row: TreeRow<DockerContainer>) => row.type === 'container' || row.type === 'folder'
" @row:click="handleRowClick" @row:contextmenu="handleRowContextMenu" @row:select="handleRowSelect"
@row:drop="handleDropOnRow" @update:selected-ids="handleUpdateSelectedIds">
<template #toolbar="{
selectedCount: count,
globalFilter: filterText,
setGlobalFilter,
columnOrder: tableColumnOrder,
}">
<div :class="['mb-4 flex flex-wrap items-center gap-2', compact ? 'sm:px-0.5' : '']">
<UInput
:model-value="filterText"
:size="compact ? 'sm' : 'md'"
:class="['max-w-sm flex-1', compact ? 'min-w-[8ch]' : 'min-w-[12ch]']"
:placeholder="dockerFilterHelpText"
:title="dockerFilterHelpText"
@update:model-value="setGlobalFilter"
/>
<TableColumnMenu
v-if="!compact"
:table="baseTableRef"
:column-order="tableColumnOrder"
@change="persistCurrentColumnVisibility"
@update:column-order="(order) => (columnOrder = order)"
/>
<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="neutral"
variant="outline"
:size="compact ? 'sm' : 'md'"
trailing-icon="i-lucide-chevron-down"
>
<UInput :model-value="filterText" :size="compact ? 'sm' : 'md'"
:class="['max-w-sm flex-1', compact ? 'min-w-[8ch]' : 'min-w-[12ch]']" :placeholder="dockerFilterHelpText"
:title="dockerFilterHelpText" @update:model-value="setGlobalFilter" />
<TableColumnMenu v-if="!compact" :table="baseTableRef" :column-order="tableColumnOrder"
@change="persistCurrentColumnVisibility" @update:column-order="(order) => (columnOrder = order)" />
<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="neutral" variant="outline" :size="compact ? 'sm' : 'md'"
trailing-icon="i-lucide-chevron-down">
Actions{{ count > 0 ? ` (${count})` : '' }}
</UButton>
</UDropdownMenu>
</div>
<div
v-if="isUpdatingContainers && activeUpdateSummary"
class="border-primary/30 bg-primary/5 text-primary my-2 flex items-center gap-2 rounded border px-3 py-2 text-sm"
>
<div v-if="isUpdatingContainers && activeUpdateSummary"
class="border-primary/30 bg-primary/5 text-primary my-2 flex items-center gap-2 rounded border px-3 py-2 text-sm">
<span class="i-lucide-loader-2 text-primary animate-spin" />
<span>Updating {{ activeUpdateSummary }}...</span>
</div>
@@ -705,74 +665,42 @@ const rowActionDropdownUi = {
</BaseTreeTable>
<!-- Context Menu -->
<UDropdownMenu
v-model:open="contextMenu.isOpen.value"
:items="contextMenu.items.value"
size="md"
:popper="contextMenu.popperOptions"
:ui="rowActionDropdownUi"
>
<div
class="fixed h-px w-px"
:style="{ top: `${contextMenu.position.value.y}px`, left: `${contextMenu.position.value.x}px` }"
/>
<UDropdownMenu v-model:open="contextMenu.isOpen.value" :items="contextMenu.items.value" size="md"
:popper="contextMenu.popperOptions" :ui="rowActionDropdownUi">
<div class="fixed h-px w-px"
:style="{ top: `${contextMenu.position.value.y}px`, left: `${contextMenu.position.value.x}px` }" />
</UDropdownMenu>
<!-- Logs Modal -->
<DockerLogViewerModal
v-model:open="logs.logsModalOpen.value"
v-model:active-session-id="logs.activeLogSessionId.value"
:sessions="logs.logSessions.value"
:active-session="logs.activeLogSession.value"
@remove-session="logs.removeLogSession"
@toggle-follow="logs.toggleActiveLogFollow"
/>
<DockerLogViewerModal v-model:open="logs.logsModalOpen.value"
v-model:active-session-id="logs.activeLogSessionId.value" :sessions="logs.logSessions.value"
:active-session="logs.activeLogSession.value" @remove-session="logs.removeLogSession"
@toggle-follow="logs.toggleActiveLogFollow" />
<!-- Move to Folder Modal -->
<MoveToFolderModal
:open="folderOps.moveOpen"
:loading="moving || creating || deleting"
:folders="visibleFolders"
:expanded-folders="expandedFolders"
:selected-folder-id="folderOps.selectedFolderId"
:root-folder-id="rootFolderId"
:renaming-folder-id="folderOps.renamingFolderId"
:rename-value="folderOps.renameValue"
@update:open="folderOps.moveOpen = $event"
<MoveToFolderModal :open="folderOps.moveOpen" :loading="moving || creating || deleting" :folders="visibleFolders"
:expanded-folders="expandedFolders" :selected-folder-id="folderOps.selectedFolderId"
:root-folder-id="rootFolderId" :renaming-folder-id="folderOps.renamingFolderId"
:rename-value="folderOps.renameValue" @update:open="folderOps.moveOpen = $event"
@update:selected-folder-id="folderOps.selectedFolderId = $event"
@update:rename-value="folderOps.renameValue = $event"
@toggle-expand="toggleExpandFolder"
@create-folder="handleCreateFolderInMoveModal"
@delete-folder="folderOps.handleDeleteFolder"
@start-rename="folderOps.startRenameFolder"
@commit-rename="folderOps.commitRenameFolder"
@cancel-rename="folderOps.cancelRename"
@confirm="folderOps.confirmMove(() => (folderOps.moveOpen = false))"
/>
@update:rename-value="folderOps.renameValue = $event" @toggle-expand="toggleExpandFolder"
@create-folder="handleCreateFolderInMoveModal" @delete-folder="folderOps.handleDeleteFolder"
@start-rename="folderOps.startRenameFolder" @commit-rename="folderOps.commitRenameFolder"
@cancel-rename="folderOps.cancelRename" @confirm="folderOps.confirmMove(() => (folderOps.moveOpen = false))" />
<!-- Start/Stop Confirm Modal -->
<ConfirmActionsModal
:open="containerActions.confirmStartStopOpen.value"
:groups="confirmStartStopGroups"
<ConfirmActionsModal :open="containerActions.confirmStartStopOpen.value" :groups="confirmStartStopGroups"
@update:open="containerActions.confirmStartStopOpen.value = $event"
@confirm="containerActions.confirmStartStop(() => {})"
/>
@confirm="containerActions.confirmStartStop(() => { })" />
<!-- Pause/Resume Confirm Modal -->
<ConfirmActionsModal
:open="containerActions.confirmPauseResumeOpen.value"
:groups="confirmPauseResumeGroups"
<ConfirmActionsModal :open="containerActions.confirmPauseResumeOpen.value" :groups="confirmPauseResumeGroups"
@update:open="containerActions.confirmPauseResumeOpen.value = $event"
@confirm="containerActions.confirmPauseResume(() => {})"
/>
@confirm="containerActions.confirmPauseResume(() => { })" />
<!-- Remove Container Modal -->
<RemoveContainerModal
:open="removeContainerModalOpen"
:container-name="containerToRemoveName"
:loading="removingContainer"
@update:open="removeContainerModalOpen = $event"
@confirm="handleConfirmRemoveContainer"
/>
<RemoveContainerModal :open="removeContainerModalOpen" :container-name="containerToRemoveName"
:loading="removingContainer" @update:open="removeContainerModalOpen = $event"
@confirm="handleConfirmRemoveContainer" />
</div>
</template>

View File

@@ -48,11 +48,7 @@ function formatContainerConflictLabel(conflict: ContainerPortConflict): string {
<template>
<div class="border-warning/30 bg-warning/10 text-warning rounded-lg border p-4 text-sm">
<div class="flex items-start gap-3">
<UIcon
name="i-lucide-triangle-alert"
class="text-warning mt-1 h-5 w-5 flex-shrink-0"
aria-hidden="true"
/>
<UIcon name="i-lucide-triangle-alert" class="text-warning mt-1 h-5 w-5 flex-shrink-0" aria-hidden="true" />
<div class="w-full space-y-3">
<div>
<p class="text-sm font-semibold">Port conflicts detected ({{ totalPortConflictCount }})</p>
@@ -64,21 +60,13 @@ function formatContainerConflictLabel(conflict: ContainerPortConflict): string {
<div class="space-y-4">
<div v-if="lanConflicts.length" class="space-y-2">
<p class="text-warning/70 text-xs font-semibold tracking-wide uppercase">LAN ports</p>
<div
v-for="conflict in lanConflicts"
:key="`lan-${conflict.lanIpPort}-${conflict.type}`"
class="border-warning/30 bg-card/80 rounded-md border p-3"
>
<div v-for="conflict in lanConflicts" :key="`lan-${conflict.lanIpPort}-${conflict.type}`"
class="border-warning/30 bg-card/80 rounded-md border p-3">
<div class="text-sm font-medium">{{ formatLanConflictLabel(conflict) }}</div>
<div class="mt-2 flex flex-wrap gap-2">
<button
v-for="container in conflict.containers"
:key="container.id"
type="button"
<button v-for="container in conflict.containers" :key="container.id" type="button"
class="border-warning/50 bg-warning/20 text-warning hover:bg-warning/30 focus-visible:ring-warning/50 inline-flex items-center gap-1 rounded-full border px-2 py-1 text-xs font-medium transition focus-visible:ring-2 focus-visible:outline-none"
:title="`Edit ${container.name || 'container'}`"
@click="handleConflictContainerAction(container)"
>
:title="`Edit ${container.name || 'container'}`" @click="handleConflictContainerAction(container)">
<span>{{ container.name || 'Container' }}</span>
<UIcon name="i-lucide-pencil" class="h-3.5 w-3.5" aria-hidden="true" />
</button>
@@ -88,21 +76,13 @@ function formatContainerConflictLabel(conflict: ContainerPortConflict): string {
<div v-if="containerConflicts.length" class="space-y-2">
<p class="text-warning/70 text-xs font-semibold tracking-wide uppercase">Container ports</p>
<div
v-for="conflict in containerConflicts"
:key="`container-${conflict.privatePort}-${conflict.type}`"
class="border-warning/30 bg-card/80 rounded-md border p-3"
>
<div v-for="conflict in containerConflicts" :key="`container-${conflict.privatePort}-${conflict.type}`"
class="border-warning/30 bg-card/80 rounded-md border p-3">
<div class="text-sm font-medium">{{ formatContainerConflictLabel(conflict) }}</div>
<div class="mt-2 flex flex-wrap gap-2">
<button
v-for="container in conflict.containers"
:key="container.id"
type="button"
<button v-for="container in conflict.containers" :key="container.id" type="button"
class="border-warning/50 bg-warning/20 text-warning hover:bg-warning/30 focus-visible:ring-warning/50 inline-flex items-center gap-1 rounded-full border px-2 py-1 text-xs font-medium transition focus-visible:ring-2 focus-visible:outline-none"
:title="`Edit ${container.name || 'container'}`"
@click="handleConflictContainerAction(container)"
>
:title="`Edit ${container.name || 'container'}`" @click="handleConflictContainerAction(container)">
<span>{{ container.name || 'Container' }}</span>
<UIcon name="i-lucide-pencil" class="h-3.5 w-3.5" aria-hidden="true" />
</button>

View File

@@ -0,0 +1,28 @@
import { gql } from '@apollo/client';
export const GET_DOCKER_ACTIVE_CONTAINER = gql`
query GetDockerActiveContainer($id: PrefixedID!) {
docker {
id
containers {
id
names
image
created
state
status
autoStart
ports {
privatePort
publicPort
type
}
hostConfig {
networkMode
}
networkSettings
labels
}
}
}
`;

View File

@@ -0,0 +1,12 @@
import { gql } from '@apollo/client';
export const GET_DOCKER_CONTAINER_OVERVIEW_FORM = gql`
query GetDockerContainerOverviewForm($skipCache: Boolean = false) {
dockerContainerOverviewForm(skipCache: $skipCache) {
id
dataSchema
uiSchema
data
}
}
`;

View File

@@ -0,0 +1,44 @@
import { gql } from '@apollo/client';
export const RENAME_DOCKER_FOLDER = gql`
mutation RenameDockerFolder($folderId: String!, $newName: String!) {
renameDockerFolder(folderId: $folderId, newName: $newName) {
version
views {
id
name
rootId
flatEntries {
id
type
name
parentId
depth
position
path
hasChildren
childrenIds
meta {
id
names
state
status
image
ports {
privatePort
publicPort
type
}
autoStart
hostConfig {
networkMode
}
created
isUpdateAvailable
isRebuildReady
}
}
}
}
}
`;

View File

@@ -0,0 +1,15 @@
import { gql } from '@apollo/client';
export const UPDATE_DOCKER_CONTAINER = gql`
mutation UpdateDockerContainer($id: PrefixedID!) {
docker {
updateContainer(id: $id) {
id
names
state
isUpdateAvailable
isRebuildReady
}
}
}
`;

View File

@@ -142,16 +142,14 @@ const dismissNotification = async (notification: NotificationFragmentFragment) =
const { onResult: onNotificationAdded } = useSubscription(notificationAddedSubscription);
onNotificationAdded(({ data }) => {
if (!data?.notificationAdded) {
if (!data) {
return;
}
// Access raw subscription data directly - don't call useFragment in async callback
const rawNotification = data.notificationAdded as unknown as NotificationFragmentFragment;
const notification = useFragment(NOTIFICATION_FRAGMENT, data.notificationAdded);
if (
!rawNotification ||
(rawNotification.importance !== NotificationImportance.ALERT &&
rawNotification.importance !== NotificationImportance.WARNING)
!notification ||
(notification.importance !== NotificationImportance.ALERT &&
notification.importance !== NotificationImportance.WARNING)
) {
return;
}
@@ -162,7 +160,7 @@ onNotificationAdded(({ data }) => {
return;
}
if (rawNotification.timestamp) {
if (notification.timestamp) {
// Trigger the global toast in tandem with the subscription update.
const funcMapping: Record<
NotificationImportance,
@@ -172,16 +170,16 @@ onNotificationAdded(({ data }) => {
[NotificationImportance.WARNING]: globalThis.toast.warning,
[NotificationImportance.INFO]: globalThis.toast.info,
};
const toast = funcMapping[rawNotification.importance];
const toast = funcMapping[notification.importance];
const createOpener = () => ({
label: 'Open',
onClick: () => rawNotification.link && window.open(rawNotification.link, '_blank', 'noopener'),
onClick: () => notification.link && window.open(notification.link, '_blank', 'noopener'),
});
requestAnimationFrame(() =>
toast(rawNotification.title, {
description: rawNotification.subject,
action: rawNotification.link ? createOpener() : undefined,
toast(notification.title, {
description: notification.subject,
action: notification.link ? createOpener() : undefined,
})
);
}
@@ -195,10 +193,7 @@ onNotificationAdded(({ data }) => {
<AlertTriangle class="h-5 w-5 text-amber-600" aria-hidden="true" />
<h2 class="text-base font-semibold text-gray-900">Warnings & Alerts</h2>
</div>
<span
v-if="!loading"
class="rounded-full bg-amber-100 px-2 py-0.5 text-xs font-medium text-amber-700"
>
<span v-if="!loading" class="rounded-full bg-amber-100 px-2 py-0.5 text-xs font-medium text-amber-700">
{{ totalCount }}
</span>
</header>
@@ -213,18 +208,10 @@ onNotificationAdded(({ data }) => {
</div>
<ul v-else-if="enrichedNotifications.length" class="flex flex-col gap-3">
<li
v-for="{ notification, displayTimestamp, meta } in enrichedNotifications"
:key="notification.id"
class="grid gap-2 rounded-md border border-gray-200 p-3 transition hover:border-amber-300"
>
<li v-for="{ notification, displayTimestamp, meta } in enrichedNotifications" :key="notification.id"
class="grid gap-2 rounded-md border border-gray-200 p-3 transition hover:border-amber-300">
<div class="flex items-start gap-3">
<component
:is="meta.icon"
class="mt-0.5 h-5 w-5 flex-none"
:class="meta.accent"
aria-hidden="true"
/>
<component :is="meta.icon" class="mt-0.5 h-5 w-5 flex-none" :class="meta.accent" aria-hidden="true" />
<div class="flex flex-1 flex-col gap-1">
<div class="flex flex-wrap items-center gap-2">
<span class="rounded-full px-2 py-0.5 text-xs font-medium" :class="meta.badge">
@@ -246,21 +233,14 @@ onNotificationAdded(({ data }) => {
</div>
</div>
<div class="flex flex-wrap items-center gap-2 pt-1">
<a
v-if="notification.link"
:href="notification.link"
<a v-if="notification.link" :href="notification.link"
class="inline-flex items-center gap-1 rounded-md border border-amber-500 px-3 py-1 text-sm font-medium text-amber-700 transition hover:bg-amber-50"
target="_blank"
rel="noreferrer"
>
target="_blank" rel="noreferrer">
View Details
</a>
<button
type="button"
<button type="button"
class="inline-flex items-center gap-1 rounded-md border border-gray-300 px-3 py-1 text-sm font-medium text-gray-700 transition hover:bg-gray-100 disabled:cursor-not-allowed disabled:opacity-50"
:disabled="dismissing.has(notification.id)"
@click="dismissNotification(notification)"
>
:disabled="dismissing.has(notification.id)" @click="dismissNotification(notification)">
{{ dismissing.has(notification.id) ? 'Dismissing' : 'Dismiss' }}
</button>
</div>

View File

@@ -1914,6 +1914,7 @@ export type Query = {
disk: Disk;
disks: Array<Disk>;
docker: Docker;
dockerContainerOverviewForm: DockerContainerOverviewForm;
flash: Flash;
/** Get JSON Schema for API key creation form */
getApiKeyCreationFormSchema: ApiKeyFormSettings;
@@ -1977,6 +1978,11 @@ export type QueryDiskArgs = {
};
export type QueryDockerContainerOverviewFormArgs = {
skipCache?: Scalars['Boolean']['input'];
};
export type QueryGetPermissionsForRolesArgs = {
roles: Array<Role>;
};