mirror of
https://github.com/unraid/api.git
synced 2026-01-06 08:39:54 -06:00
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:
committed by
Ajit Mehrotra
parent
9ef1cf1eca
commit
d07aa42063
@@ -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
@@ -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>;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
110
api/src/unraid-api/graph/resolvers/docker/docker-form.service.ts
Normal file
110
api/src/unraid-api/graph/resolvers/docker/docker-form.service.ts
Normal 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',
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export const DOCKER_SERVICE_TOKEN = Symbol('DOCKER_SERVICE');
|
||||
@@ -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',
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
8
pnpm-lock.yaml
generated
@@ -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:
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 & 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 &
|
||||
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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
28
web/src/components/Docker/docker-active-container.query.ts
Normal file
28
web/src/components/Docker/docker-active-container.query.ts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
12
web/src/components/Docker/docker-overview.query.ts
Normal file
12
web/src/components/Docker/docker-overview.query.ts
Normal 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
|
||||
}
|
||||
}
|
||||
`;
|
||||
44
web/src/components/Docker/docker-rename-folder.mutation.ts
Normal file
44
web/src/components/Docker/docker-rename-folder.mutation.ts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -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>
|
||||
|
||||
@@ -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>;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user