mirror of
https://github.com/unraid/api.git
synced 2026-04-25 00:39:09 -05:00
table > select & status badge
This commit is contained in:
@@ -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,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
Generated
+3
@@ -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)))
|
||||
|
||||
Vendored
+5
@@ -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']
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"><unraid-docker-container-overview></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"><unraid-auth></span>
|
||||
<div class="component-mount">
|
||||
<unraid-auth></unraid-auth>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="component-card">
|
||||
<h3>User Profile</h3>
|
||||
<span class="selector"><unraid-user-profile></span>
|
||||
<div class="component-mount">
|
||||
<unraid-user-profile></unraid-user-profile>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="component-card">
|
||||
<h3>SSO Button</h3>
|
||||
<span class="selector"><unraid-sso-button></span>
|
||||
<div class="component-mount">
|
||||
<unraid-sso-button></unraid-sso-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="component-card">
|
||||
<h3>Registration</h3>
|
||||
<span class="selector"><unraid-registration></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"><unraid-connect-settings></span>
|
||||
<div class="component-mount">
|
||||
<unraid-connect-settings></unraid-connect-settings>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="component-card">
|
||||
<h3>Theme Switcher</h3>
|
||||
<span class="selector"><unraid-theme-switcher></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"><unraid-header-os-version></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"><unraid-wan-ip-check></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"><unraid-update-os></span>
|
||||
<div class="component-mount">
|
||||
<unraid-update-os></unraid-update-os>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="component-card">
|
||||
<h3>Downgrade OS</h3>
|
||||
<span class="selector"><unraid-downgrade-os></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"><unraid-api-key-manager></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"><unraid-api-key-authorize></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"><unraid-download-api-logs></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"><unraid-log-viewer></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"><unraid-modals></span>
|
||||
<div class="component-mount">
|
||||
<unraid-modals></unraid-modals>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="component-card">
|
||||
<h3>Welcome Modal</h3>
|
||||
<span class="selector"><unraid-welcome-modal></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"><unraid-dev-modal-test></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"><unraid-toaster></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
Reference in New Issue
Block a user