table > select & status badge

This commit is contained in:
Pujit Mehrotra
2025-09-17 11:59:48 -04:00
parent 66625ded6a
commit 23fdeea251
14 changed files with 1208 additions and 0 deletions
@@ -0,0 +1,170 @@
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,
data: {
containers: 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: 'VerticalLayout',
elements: [
{
type: 'Control',
scope: '#/properties/containers',
options: {
detail: {
type: 'HorizontalLayout',
elements: [
{
type: 'Control',
scope: '#/properties/name',
options: {
trim: true,
readonly: true,
},
},
{
type: 'Control',
scope: '#/properties/state',
options: {
readonly: true,
format: 'badge',
},
},
{
type: 'Control',
scope: '#/properties/status',
options: {
readonly: true,
},
},
{
type: 'Control',
scope: '#/properties/image',
options: {
readonly: true,
trim: true,
},
},
{
type: 'Control',
scope: '#/properties/ports',
options: {
readonly: true,
},
},
{
type: 'Control',
scope: '#/properties/autoStart',
options: {
readonly: true,
},
},
{
type: 'Control',
scope: '#/properties/network',
options: {
readonly: true,
},
},
],
},
},
},
],
};
}
}
+3
View File
@@ -1085,6 +1085,9 @@ importers:
'@nuxt/ui':
specifier: 4.0.0-alpha.0
version: 4.0.0-alpha.0(@babel/parser@7.28.4)(@netlify/blobs@9.1.2)(change-case@5.4.4)(db0@0.3.2)(embla-carousel@8.6.0)(focus-trap@7.6.5)(ioredis@5.7.0)(jwt-decode@4.0.0)(magicast@0.3.5)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.9.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))(vue-router@4.5.1(vue@3.5.20(typescript@5.9.2)))(vue@3.5.20(typescript@5.9.2))(zod@3.25.76)
'@tanstack/vue-table':
specifier: ^8.21.3
version: 8.21.3(vue@3.5.20(typescript@5.9.2))
'@unraid/shared-callbacks':
specifier: 1.1.1
version: 1.1.1(@vueuse/core@13.8.0(vue@3.5.20(typescript@5.9.2)))
+5
View File
@@ -45,6 +45,9 @@ declare module 'vue' {
'DevModalTest.standalone': typeof import('./src/components/DevModalTest.standalone.vue')['default']
DevSettings: typeof import('./src/components/DevSettings.vue')['default']
'DevThemeSwitcher.standalone': typeof import('./src/components/DevThemeSwitcher.standalone.vue')['default']
DockerContainerOverview: typeof import('./src/components/Docker/DockerContainerOverview.vue')['default']
'DockerContainerOverview.standalone': typeof import('./src/components/Docker/DockerContainerOverview.standalone.vue')['default']
DockerContainersTable: typeof import('./src/components/Docker/DockerContainersTable.vue')['default']
Downgrade: typeof import('./src/components/UpdateOs/Downgrade.vue')['default']
'DowngradeOs.standalone': typeof import('./src/components/DowngradeOs.standalone.vue')['default']
'DownloadApiLogs.standalone': typeof import('./src/components/DownloadApiLogs.standalone.vue')['default']
@@ -102,6 +105,7 @@ declare module 'vue' {
SsoButtons: typeof import('./src/components/sso/SsoButtons.vue')['default']
SsoProviderButton: typeof import('./src/components/sso/SsoProviderButton.vue')['default']
Status: typeof import('./src/components/UpdateOs/Status.vue')['default']
TableRenderer: typeof import('./src/components/JsonForms/TableRenderer.vue')['default']
'TestThemeSwitcher.standalone': typeof import('./src/components/TestThemeSwitcher.standalone.vue')['default']
'TestUpdateModal.standalone': typeof import('./src/components/UpdateOs/TestUpdateModal.standalone.vue')['default']
'ThemeSwitcher.standalone': typeof import('./src/components/ThemeSwitcher.standalone.vue')['default']
@@ -127,6 +131,7 @@ declare module 'vue' {
USelectMenu: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/components/SelectMenu.vue')['default']
'UserProfile.standalone': typeof import('./src/components/UserProfile.standalone.vue')['default']
USwitch: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/components/Switch.vue')['default']
UTable: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/components/Table.vue')['default']
UTabs: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/components/Tabs.vue')['default']
'WanIpCheck.standalone': typeof import('./src/components/WanIpCheck.standalone.vue')['default']
'WelcomeModal.standalone': typeof import('./src/components/Activation/WelcomeModal.standalone.vue')['default']
+1
View File
@@ -109,6 +109,7 @@
"@jsonforms/vue-vanilla": "3.6.0",
"@jsonforms/vue-vuetify": "3.6.0",
"@nuxt/ui": "4.0.0-alpha.0",
"@tanstack/vue-table": "^8.21.3",
"@unraid/shared-callbacks": "1.1.1",
"@unraid/ui": "link:../unraid-ui",
"@vue/apollo-composable": "4.2.2",
+382
View File
@@ -0,0 +1,382 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>All Components - Unraid Component Test</title>
<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
<style>
body {
font-family: system-ui, -apple-system, sans-serif;
margin: 0;
padding: 20px;
background: #f3f4f6;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
.component-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 20px;
margin-top: 20px;
}
.component-card {
background: white;
border-radius: 8px;
padding: 20px;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
.component-card h3 {
margin: 0 0 10px 0;
color: #1f2937;
font-size: 16px;
font-weight: 600;
}
.component-card .selector {
font-family: monospace;
font-size: 12px;
color: #6b7280;
margin-bottom: 15px;
background: #f3f4f6;
padding: 4px 8px;
border-radius: 4px;
display: inline-block;
}
.component-mount {
min-height: 50px;
border: 1px dashed #e5e7eb;
border-radius: 4px;
padding: 10px;
position: relative;
}
.status {
position: absolute;
top: 5px;
right: 5px;
width: 8px;
height: 8px;
border-radius: 50%;
background: #10b981;
}
.category-header {
background: #1f2937;
color: white;
padding: 10px 15px;
border-radius: 4px;
margin: 30px 0 15px 0;
font-weight: 600;
}
</style>
</head>
<body>
<div class="container">
<!-- Docker -->
<div class="category-header">🐳 Docker</div>
<div class="component-grid">
<div class="component-card" style="grid-column: 1 / -1;">
<h3>Docker Container Overview</h3>
<span class="selector">&lt;unraid-docker-container-overview&gt;</span>
<div class="component-mount">
<unraid-docker-container-overview></unraid-docker-container-overview>
</div>
</div>
</div>
<!-- Authentication & User -->
<div class="category-header">👤 Authentication & User</div>
<div class="component-grid">
<div class="component-card">
<h3>Authentication</h3>
<span class="selector">&lt;unraid-auth&gt;</span>
<div class="component-mount">
<unraid-auth></unraid-auth>
</div>
</div>
<div class="component-card">
<h3>User Profile</h3>
<span class="selector">&lt;unraid-user-profile&gt;</span>
<div class="component-mount">
<unraid-user-profile></unraid-user-profile>
</div>
</div>
<div class="component-card">
<h3>SSO Button</h3>
<span class="selector">&lt;unraid-sso-button&gt;</span>
<div class="component-mount">
<unraid-sso-button></unraid-sso-button>
</div>
</div>
<div class="component-card">
<h3>Registration</h3>
<span class="selector">&lt;unraid-registration&gt;</span>
<div class="component-mount">
<unraid-registration></unraid-registration>
</div>
</div>
</div>
<!-- System & Settings -->
<div class="category-header">⚙️ System & Settings</div>
<div class="component-grid">
<div class="component-card">
<h3>Connect Settings</h3>
<span class="selector">&lt;unraid-connect-settings&gt;</span>
<div class="component-mount">
<unraid-connect-settings></unraid-connect-settings>
</div>
</div>
<div class="component-card">
<h3>Theme Switcher</h3>
<span class="selector">&lt;unraid-theme-switcher&gt;</span>
<div class="component-mount">
<unraid-theme-switcher current="white"></unraid-theme-switcher>
</div>
</div>
<div class="component-card">
<h3>Header OS Version</h3>
<span class="selector">&lt;unraid-header-os-version&gt;</span>
<div class="component-mount">
<unraid-header-os-version></unraid-header-os-version>
</div>
</div>
<div class="component-card">
<h3>WAN IP Check</h3>
<span class="selector">&lt;unraid-wan-ip-check&gt;</span>
<div class="component-mount">
<unraid-wan-ip-check php-wan-ip="192.168.1.1"></unraid-wan-ip-check>
</div>
</div>
</div>
<!-- OS Management -->
<div class="category-header">💿 OS Management</div>
<div class="component-grid">
<div class="component-card">
<h3>Update OS</h3>
<span class="selector">&lt;unraid-update-os&gt;</span>
<div class="component-mount">
<unraid-update-os></unraid-update-os>
</div>
</div>
<div class="component-card">
<h3>Downgrade OS</h3>
<span class="selector">&lt;unraid-downgrade-os&gt;</span>
<div class="component-mount">
<unraid-downgrade-os></unraid-downgrade-os>
</div>
</div>
</div>
<!-- API & Developer -->
<div class="category-header">🔧 API & Developer</div>
<div class="component-grid">
<div class="component-card">
<h3>API Key Manager</h3>
<span class="selector">&lt;unraid-api-key-manager&gt;</span>
<div class="component-mount">
<unraid-api-key-manager></unraid-api-key-manager>
</div>
</div>
<div class="component-card">
<h3>API Key Authorize</h3>
<span class="selector">&lt;unraid-api-key-authorize&gt;</span>
<div class="component-mount">
<unraid-api-key-authorize></unraid-api-key-authorize>
</div>
</div>
<div class="component-card">
<h3>Download API Logs</h3>
<span class="selector">&lt;unraid-download-api-logs&gt;</span>
<div class="component-mount">
<unraid-download-api-logs></unraid-download-api-logs>
</div>
</div>
<div class="component-card">
<h3>Log Viewer</h3>
<span class="selector">&lt;unraid-log-viewer&gt;</span>
<div class="component-mount">
<unraid-log-viewer></unraid-log-viewer>
</div>
</div>
</div>
<!-- UI Components -->
<div class="category-header">🎨 UI Components</div>
<div class="component-grid">
<div class="component-card">
<h3>Modals</h3>
<span class="selector">&lt;unraid-modals&gt;</span>
<div class="component-mount">
<unraid-modals></unraid-modals>
</div>
</div>
<div class="component-card">
<h3>Welcome Modal</h3>
<span class="selector">&lt;unraid-welcome-modal&gt;</span>
<div class="component-mount">
<unraid-welcome-modal></unraid-welcome-modal>
</div>
</div>
<div class="component-card">
<h3>Dev Modal Test</h3>
<span class="selector">&lt;unraid-dev-modal-test&gt;</span>
<div class="component-mount">
<unraid-dev-modal-test></unraid-dev-modal-test>
</div>
</div>
<div class="component-card">
<h3>Toaster</h3>
<span class="selector">&lt;unraid-toaster&gt;</span>
<div class="component-mount">
<unraid-toaster></unraid-toaster>
</div>
</div>
</div>
<!-- Test Controls -->
<div class="category-header">🎮 Test Controls</div>
<div style="background: white; padding: 20px; border-radius: 8px; margin-top: 15px;">
<h3>Language Selection</h3>
<div style="margin-bottom: 20px;">
<unraid-locale-switcher></unraid-locale-switcher>
</div>
<h3>jQuery Interaction Tests</h3>
<div style="display: flex; gap: 10px; flex-wrap: wrap; margin-top: 15px;">
<button id="test-notification" class="test-btn">Trigger Notification</button>
<button id="test-modal" class="test-btn">Open Test Modal</button>
<button id="test-theme" class="test-btn">Toggle Theme</button>
<button id="test-update-profile" class="test-btn">Update Profile Data</button>
<button id="test-settings" class="test-btn">Update Settings</button>
</div>
<div style="margin-top: 20px;">
<h4>Console Output</h4>
<div id="test-output" style="background: #1f2937; color: #10b981; padding: 10px; border-radius: 4px; font-family: monospace; font-size: 12px; min-height: 100px; max-height: 200px; overflow-y: auto;">
> Ready for testing...
</div>
</div>
</div>
</div>
<style>
.test-btn {
padding: 8px 16px;
background: #3b82f6;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
}
.test-btn:hover {
background: #2563eb;
}
</style>
<!-- Load the manifest and inject resources -->
<script src="/test-pages/load-manifest.js"></script>
<script src="/test-pages/test-server-state.js"></script>
<script src="/test-pages/shared-header.js"></script>
<!-- Test interactions -->
<script>
$(document).ready(function() {
const output = $('#test-output');
function log(message) {
// Use shared header's testLog if available, otherwise local log
if (window.testLog) {
window.testLog(message);
}
if (output.length) {
const timestamp = new Date().toLocaleTimeString();
output.append('\n> [' + timestamp + '] ' + message);
output.scrollTop(output[0].scrollHeight);
}
}
// Test notification
$('#test-notification').on('click', function() {
log('Triggering notification...');
const event = new CustomEvent('unraid:notification', {
detail: {
title: 'Test Notification',
message: 'This is a test from jQuery!',
type: 'success'
}
});
document.dispatchEvent(event);
});
// Test modal
$('#test-modal').on('click', function() {
log('Opening test modal...');
// This would trigger the modal system
window.dispatchEvent(new CustomEvent('unraid:open-modal', {
detail: { modalId: 'test-modal' }
}));
});
// Test theme toggle
$('#test-theme').on('click', function() {
log('Toggling theme...');
const currentTheme = $('body').hasClass('dark') ? 'light' : 'dark';
$('body').toggleClass('dark');
log('Theme changed to: ' + currentTheme);
});
// Test profile update
$('#test-update-profile').on('click', function() {
log('Updating profile data...');
const profileData = {
name: 'Test User ' + Math.floor(Math.random() * 100),
email: 'test' + Math.floor(Math.random() * 100) + '@example.com',
username: 'testuser'
};
$('unraid-user-profile').attr('server', JSON.stringify(profileData));
log('Profile updated: ' + JSON.stringify(profileData));
});
// Test settings update
$('#test-settings').on('click', function() {
log('Updating connect settings...');
const settings = {
enabled: Math.random() > 0.5,
url: 'https://connect.unraid.net',
lastSync: new Date().toISOString()
};
$('unraid-connect-settings').attr('initial-settings', JSON.stringify(settings));
log('Settings updated: ' + JSON.stringify(settings));
});
// Listen for component events
$(document).on('unraid:theme-changed', function(e, data) {
log('Theme changed event received: ' + JSON.stringify(data));
});
$(document).on('unraid:settings-saved', function(e, data) {
log('Settings saved event received: ' + JSON.stringify(data));
});
log('Test page initialized - all components loaded');
});
</script>
</body>
</html>
@@ -0,0 +1,28 @@
<script setup lang="ts">
import DockerContainerOverview from '@/components/Docker/DockerContainerOverview.vue';
import DockerEdit from '@/components/Docker/Edit.vue';
import DockerLogs from '@/components/Docker/Logs.vue';
import DockerOverview from '@/components/Docker/Overview.vue';
import DockerPreview from '@/components/Docker/Preview.vue';
const item = { id: '1', label: 'Test', icon: 'test', badge: '1' };
const details = {
network: 'bridge',
lanIpPort: '192.168.1.100:8080',
containerIp: '192.168.1.100',
uptime: '2 hours',
containerPort: '8080',
creationDate: new Date().toISOString(),
containerId: '1234567890',
maintainer: 'John Doe',
};
</script>
<template>
<div class="grid gap-8">
<DockerContainerOverview />
<DockerOverview :item="item" :details="details" />
<DockerPreview :item="item" />
<DockerEdit :item="item" />
<DockerLogs :item="item" />
</div>
</template>
@@ -0,0 +1,66 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useQuery } from '@vue/apollo-composable';
import { Button } from '@unraid/ui';
import { GET_DOCKER_CONTAINERS } from '@/components/Docker/docker-containers.query';
import DockerContainersTable from '@/components/Docker/DockerContainersTable.vue';
import { RefreshCwIcon } from 'lucide-vue-next';
import type {
DockerContainer,
ResolvedOrganizerEntry,
ResolvedOrganizerFolder,
} from '@/composables/gql/graphql';
const { result, loading, error, refetch } = useQuery<{
docker: {
id: string;
organizer: {
views: Array<{
id: string;
name: string;
root: ResolvedOrganizerEntry;
}>;
};
};
}>(GET_DOCKER_CONTAINERS, {
fetchPolicy: 'cache-and-network',
});
const containers = computed<DockerContainer[]>(() => []);
const organizerRoot = computed(
() => result.value?.docker?.organizer?.views?.[0]?.root as ResolvedOrganizerFolder | undefined
);
const handleRefresh = async () => {
await refetch({ skipCache: true });
};
</script>
<template>
<div class="docker-container-overview rounded-lg border bg-white p-6 shadow-sm dark:bg-gray-900">
<div class="mb-4 flex items-center justify-between">
<h2 class="text-xl font-semibold">Docker Containers</h2>
<Button variant="outline" size="sm" @click="handleRefresh" :disabled="loading">
<RefreshCwIcon class="mr-2 h-4 w-4" :class="{ 'animate-spin': loading }" />
Refresh
</Button>
</div>
<div v-if="error" class="text-red-500">Error loading container data: {{ error.message }}</div>
<DockerContainersTable
:containers="containers"
:organizer-root="organizerRoot"
:loading="loading"
@created-folder="handleRefresh"
/>
</div>
</template>
<style scoped>
.docker-container-overview {
min-height: 400px;
}
</style>
@@ -0,0 +1,271 @@
<script setup lang="ts">
import { computed, h, ref, resolveComponent } from 'vue';
import { useMutation } from '@vue/apollo-composable';
import { Button } from '@unraid/ui';
import { GET_DOCKER_CONTAINERS } from '@/components/Docker/docker-containers.query';
import { CREATE_DOCKER_FOLDER } from '@/components/Docker/docker-create-folder.mutation';
import { ContainerState } from '@/composables/gql/graphql';
import type {
DockerContainer,
ResolvedOrganizerEntry,
ResolvedOrganizerFolder,
} from '@/composables/gql/graphql';
import type { TableColumn } from '@nuxt/ui';
interface Props {
containers: DockerContainer[];
organizerRoot?: ResolvedOrganizerFolder;
loading?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
loading: false,
});
const UButton = resolveComponent('UButton');
const UCheckbox = resolveComponent('UCheckbox');
const UBadge = resolveComponent('UBadge');
const UDropdownMenu = resolveComponent('UDropdownMenu');
function formatPorts(container?: DockerContainer | null): string {
if (!container) return '';
return container.ports
.map((port) => {
if (port.publicPort && port.privatePort) {
return `${port.publicPort}:${port.privatePort}/${port.type}`;
}
if (port.privatePort) {
return `${port.privatePort}/${port.type}`;
}
return '';
})
.filter(Boolean)
.join(', ');
}
type TreeRow = {
id: string;
type: 'folder' | 'container';
name: string;
state?: string;
ports?: string;
autoStart?: string;
updates?: string;
children?: TreeRow[];
};
function toContainerTreeRow(meta: DockerContainer | null | undefined, fallbackName?: string): TreeRow {
const name = meta?.names?.[0]?.replace(/^\//, '') || fallbackName || 'Unknown';
const updatesParts: string[] = [];
if (meta?.isUpdateAvailable) updatesParts.push('Update');
if (meta?.isRebuildReady) updatesParts.push('Rebuild');
return {
id: meta?.id || name,
type: 'container',
name,
state: meta?.state ?? '',
ports: formatPorts(meta || undefined),
autoStart: meta?.autoStart ? 'On' : 'Off',
updates: updatesParts.join(' / ') || '—',
};
}
function buildTree(entry: ResolvedOrganizerEntry): TreeRow | null {
if (entry.__typename === 'ResolvedOrganizerFolder') {
const folder = entry as ResolvedOrganizerFolder;
return {
id: folder.id,
type: 'folder',
name: folder.name,
children: (folder.children || []).map((child) => buildTree(child)).filter(Boolean) as TreeRow[],
};
}
if (entry.__typename === 'OrganizerContainerResource') {
const meta = entry.meta as DockerContainer | null | undefined;
const row = toContainerTreeRow(meta, entry.name || undefined);
row.id = entry.id;
return row;
}
return {
id: entry.id as string,
type: 'container',
name: (entry as unknown as { name?: string }).name || 'Unknown',
state: '',
ports: '',
autoStart: 'Off',
updates: '—',
};
}
const treeData = computed<TreeRow[]>(() => {
if (props.organizerRoot) {
const root = props.organizerRoot;
return (root.children || []).map((child) => buildTree(child)).filter(Boolean) as TreeRow[];
}
return props.containers.map((container) => toContainerTreeRow(container));
});
const columns = computed<TableColumn<TreeRow>[]>(() => {
const cols: TableColumn<TreeRow>[] = [
{
id: 'select',
header: ({ table }) =>
h(UCheckbox, {
modelValue: table.getIsSomePageRowsSelected()
? 'indeterminate'
: table.getIsAllPageRowsSelected(),
'onUpdate:modelValue': (value: boolean | 'indeterminate') =>
table.toggleAllPageRowsSelected(!!value),
'aria-label': 'Select all',
}),
cell: ({ row }) => {
switch ((row.original as TreeRow).type) {
case 'container':
return h(UCheckbox, {
modelValue: row.getIsSelected(),
'onUpdate:modelValue': (value: boolean | 'indeterminate') => row.toggleSelected(!!value),
'aria-label': 'Select row',
});
case 'folder':
return h(UButton, {
color: 'neutral',
size: 'md',
variant: 'ghost',
icon: 'i-lucide-chevron-down',
square: true,
'aria-label': 'Expand',
class: 'p-0',
ui: {
leadingIcon: [
'transition-transform mt-0.5 -rotate-90',
row.getIsExpanded() ? 'duration-200 rotate-0' : '',
],
},
onClick: () => row.toggleExpanded(),
});
default:
return h('span');
}
},
enableSorting: false,
enableHiding: false,
meta: { class: { th: 'w-10', td: 'w-10' } },
},
{
id: 'title',
header: 'Name',
cell: ({ row }) => {
const depth = row.depth;
const indent = h('span', { class: 'inline-block', style: { width: `calc(${depth} * 1rem)` } });
const isFolder = (row.original as TreeRow).type === 'folder';
return h('div', { class: 'truncate flex items-center' }, [
indent,
h('span', { class: 'max-w-[40ch] truncate font-medium' }, row.original.name),
isFolder ? h('span') : null,
]);
},
meta: { class: { td: 'w-[40ch] truncate', th: 'w-[45ch]' } },
},
{
accessorKey: 'state',
header: 'State',
cell: ({ row }) => {
if (row.original.type === 'folder') return '';
const state = row.original.state ?? '';
const color = {
[ContainerState.RUNNING]: 'success' as const,
[ContainerState.EXITED]: 'neutral' as const,
}[state];
return h(
UBadge,
{
color,
},
() => state
);
},
},
{
accessorKey: 'ports',
header: 'Ports',
cell: ({ row }) => (row.original.type === 'folder' ? '' : String(row.getValue('ports') || '')),
},
{
accessorKey: 'autoStart',
header: 'Auto Start',
cell: ({ row }) => (row.original.type === 'folder' ? '' : String(row.getValue('autoStart') || '')),
},
{
accessorKey: 'updates',
header: 'Updates',
cell: ({ row }) => (row.original.type === 'folder' ? '' : String(row.getValue('updates') || '')),
},
];
return cols;
});
const rowSelection = ref<Record<string, boolean>>({});
const selectedCount = computed<number>(() => {
const containerIds: string[] = treeData.value
.flatMap(function collect(row): TreeRow[] {
if (row.type === 'container') return [row];
return (row.children || []).flatMap(collect);
})
.map((r) => r.id);
return containerIds.filter((id) => !!rowSelection.value[id]).length;
});
const emit = defineEmits<{
(e: 'created-folder'): void;
}>();
const { mutate: createFolderMutation, loading: creating } = useMutation(CREATE_DOCKER_FOLDER);
async function handleCreateFolder() {
const name = window.prompt('New folder name');
if (!name) return;
const childrenIds = treeData.value
.flatMap(function collect(row): TreeRow[] {
if (row.type === 'container') return [row];
return (row.children || []).flatMap(collect);
})
.filter((r) => rowSelection.value[r.id])
.map((r) => r.id);
if (childrenIds.length === 0) return;
await createFolderMutation(
{ name, childrenIds },
{
refetchQueries: [{ query: GET_DOCKER_CONTAINERS, variables: { skipCache: true } }],
awaitRefetchQueries: true,
}
);
rowSelection.value = {};
emit('created-folder');
}
</script>
<template>
<div class="w-full">
<div class="mb-3 flex items-center gap-2">
<Button size="sm" :disabled="selectedCount === 0 || creating" @click="handleCreateFolder">
Create folder ({{ selectedCount }})
</Button>
</div>
<UTable
ref="table"
v-model:row-selection="rowSelection"
:data="treeData"
:columns="columns"
:get-sub-rows="(row: any) => row.children"
:loading="loading"
:ui="{ td: 'empty:p-0' }"
sticky
class="flex-1"
/>
<div v-if="!loading && treeData.length === 0" class="py-8 text-center text-gray-500">
No containers found
</div>
</div>
</template>
@@ -0,0 +1,144 @@
import { gql } from '@apollo/client';
export const GET_DOCKER_CONTAINERS = gql`
query GetDockerContainers($skipCache: Boolean = false) {
docker {
id
organizer(skipCache: $skipCache) {
version
views {
id
name
root {
__typename
... on ResolvedOrganizerFolder {
id
name
type
children {
__typename
... on ResolvedOrganizerFolder {
id
name
type
children {
__typename
... on ResolvedOrganizerFolder {
id
name
type
children {
__typename
... on ResolvedOrganizerFolder {
id
name
type
}
... on OrganizerContainerResource {
id
name
type
meta {
id
names
state
status
image
ports {
privatePort
publicPort
type
}
autoStart
hostConfig {
networkMode
}
created
isUpdateAvailable
isRebuildReady
}
}
}
}
... on OrganizerContainerResource {
id
name
type
meta {
id
names
state
status
image
ports {
privatePort
publicPort
type
}
autoStart
hostConfig {
networkMode
}
created
isUpdateAvailable
isRebuildReady
}
}
}
}
... on OrganizerContainerResource {
id
name
type
meta {
id
names
state
status
image
ports {
privatePort
publicPort
type
}
autoStart
hostConfig {
networkMode
}
created
isUpdateAvailable
isRebuildReady
}
}
}
}
... on OrganizerContainerResource {
id
name
type
meta {
id
names
state
status
image
ports {
privatePort
publicPort
type
}
autoStart
hostConfig {
networkMode
}
created
isUpdateAvailable
isRebuildReady
}
}
}
}
}
}
}
`;
@@ -0,0 +1,34 @@
import { gql } from '@apollo/client';
export const CREATE_DOCKER_FOLDER = gql`
mutation CreateDockerFolder($name: String!, $parentId: String, $childrenIds: [String!]) {
createDockerFolder(name: $name, parentId: $parentId, childrenIds: $childrenIds) {
version
views {
id
name
root {
__typename
... on ResolvedOrganizerFolder {
id
name
type
children {
__typename
... on ResolvedOrganizerFolder {
id
name
type
}
... on OrganizerContainerResource {
id
name
type
}
}
}
}
}
}
}
`;
@@ -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
}
}
`;
@@ -156,4 +156,9 @@ export const componentMappings: ComponentMapping[] = [
selector: 'unraid-api-status-manager',
appId: 'api-status-manager',
},
{
component: defineAsyncComponent(() => import('../Docker/DockerContainerOverview.standalone.vue')),
selector: 'unraid-docker-container-overview',
appId: 'docker-container-overview',
},
];
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long