From ffbcefee44cd01c2bea778c17a8c71f0a607909a Mon Sep 17 00:00:00 2001 From: rcourtman Date: Thu, 22 Jan 2026 13:53:02 +0000 Subject: [PATCH] feat(ui): restore Docker and dashboard improvements Recovered from git dangling commits (codex snapshots): - DockerHostSummaryTable: Add StackedContainerBar for container counts - DockerUnifiedTable: Column width improvements, AI integration - GuestDrawer: Improved controls layout and styling - GuestRow: Minor improvements - StackedDiskBar: Refactored implementation - HostsOverview: Layout updates - NodeSummaryTable: Improvements - animations.css: Add AI context row highlight styling - types/nodes.ts: Additional type definitions --- .../src/components/Dashboard/GuestDrawer.tsx | 20 +- .../src/components/Dashboard/GuestRow.tsx | 4 +- .../components/Dashboard/StackedDiskBar.tsx | 293 ++++++------------ .../Docker/DockerHostSummaryTable.tsx | 10 +- .../components/Docker/DockerUnifiedTable.tsx | 197 ++++++++++-- .../src/components/Hosts/HostsOverview.tsx | 122 +++++--- .../components/shared/NodeSummaryTable.tsx | 45 +-- frontend-modern/src/styles/animations.css | 49 +++ frontend-modern/src/types/nodes.ts | 8 + 9 files changed, 421 insertions(+), 327 deletions(-) diff --git a/frontend-modern/src/components/Dashboard/GuestDrawer.tsx b/frontend-modern/src/components/Dashboard/GuestDrawer.tsx index 47105d80c..977fa0acf 100644 --- a/frontend-modern/src/components/Dashboard/GuestDrawer.tsx +++ b/frontend-modern/src/components/Dashboard/GuestDrawer.tsx @@ -314,13 +314,13 @@ export const GuestDrawer: Component = (props) => {
{/* Toolbar: Range and View Toggle */} -
-
- View -
+
+
+ Controls +
-
- Range -
+
+ Range +
{(['24h', '7d', '30d', '90d'] as HistoryTimeRange[]).map(r => ( @@ -1216,7 +1284,7 @@ const DockerContainerRow: Component<{ class="truncate" title={container.image || undefined} > - {getShortImageName(container.image)} + {container.image || '—'} + {/* Annotations & Ask AI row */} + +
+
+ AI Context + + saving... + +
+ {/* Existing annotations */} + 0}> +
+ + {(annotation, index) => ( + + {annotation} + + + )} + +
+
- - + {/* Add new annotation */} +
+ setNewAnnotation(e.currentTarget.value)} + onKeyDown={handleKeyDown} + placeholder="Add context for AI (press Enter)..." + class="flex-1 px-2 py-1.5 text-[11px] rounded border border-gray-200 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-200 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-1 focus:ring-purple-500 focus:border-purple-500" + /> + + +
+
+
@@ -2032,15 +2161,11 @@ const DockerServiceRow: Component<{ ); case 'image': return ( -
-
- - {getShortImageName(service.image)} - -
+
+ {service.image || '—'}
); case 'status': @@ -2262,6 +2387,16 @@ const DockerUnifiedTable: Component = (props) => { // Use the breakpoint hook for responsive behavior const { isMobile } = useBreakpoint(); + // AI enabled state - fetched once at the parent level to avoid per-row API calls + const [aiEnabled, setAiEnabled] = createSignal(false); + + // Fetch AI settings once when component mounts + createEffect(() => { + AIAPI.getSettings() + .then((settings) => setAiEnabled(settings.enabled && settings.configured)) + .catch((err) => logger.debug('[DockerUnifiedTable] AI settings check failed:', err)); + }); + // Caches for stable object references to prevent re-animations const rowCache = new Map(); const tasksCache = new Map(); @@ -2604,6 +2739,8 @@ const DockerUnifiedTable: Component = (props) => { onCustomUrlUpdate={props.onCustomUrlUpdate} showHostContext={!grouped} resourceIndentClass={grouped ? GROUPED_RESOURCE_INDENT : UNGROUPED_RESOURCE_INDENT} + aiEnabled={aiEnabled()} + initialNotes={metadata?.notes} batchUpdateState={props.batchUpdateState} /> @@ -2651,11 +2788,7 @@ const DockerUnifiedTable: Component = (props) => { return ( colSortKey && handleSort(colSortKey)} onKeyDown={(e) => e.key === 'Enter' && colSortKey && handleSort(colSortKey)} tabIndex={0} diff --git a/frontend-modern/src/components/Hosts/HostsOverview.tsx b/frontend-modern/src/components/Hosts/HostsOverview.tsx index 1cc632747..d39e1e846 100644 --- a/frontend-modern/src/components/Hosts/HostsOverview.tsx +++ b/frontend-modern/src/components/Hosts/HostsOverview.tsx @@ -17,6 +17,7 @@ import { StackedMemoryBar } from '@/components/Dashboard/StackedMemoryBar'; import { EnhancedCPUBar } from '@/components/Dashboard/EnhancedCPUBar'; import { useBreakpoint, type ColumnPriority } from '@/hooks/useBreakpoint'; import { useColumnVisibility } from '@/hooks/useColumnVisibility'; +import { aiChatStore } from '@/stores/aiChat'; import { STORAGE_KEYS } from '@/utils/localStorage'; import { useResourcesAsLegacy } from '@/hooks/useResources'; import { useAlertsActivation } from '@/stores/alertsActivation'; @@ -40,11 +41,11 @@ export interface HostColumnDef { // Host table column definitions - all essential for horizontal scroll like Docker export const HOST_COLUMNS: HostColumnDef[] = [ // Core columns - all essential (visible on all screens with horizontal scroll) - { id: 'name', label: 'Host', priority: 'essential', width: '210px', sortKey: 'name' }, - { id: 'platform', label: 'Platform', priority: 'essential', width: '110px', sortKey: 'platform' }, + { id: 'name', label: 'Host', priority: 'essential', width: '100px', sortKey: 'name' }, + { id: 'platform', label: 'Platform', priority: 'essential', width: '70px', sortKey: 'platform' }, { id: 'cpu', label: 'CPU', priority: 'essential', width: '60px', sortKey: 'cpu' }, { id: 'memory', label: 'Memory', priority: 'essential', width: '60px', sortKey: 'memory' }, - { id: 'disk', label: 'Disk', priority: 'essential', width: '220px', sortKey: 'disk' }, + { id: 'disk', label: 'Disk', priority: 'essential', width: '60px', sortKey: 'disk' }, // Additional columns - essential but toggleable by user { id: 'temp', label: 'Temp', icon: , priority: 'essential', width: '50px', toggleable: true }, @@ -172,7 +173,6 @@ interface HostSensorSummaryForCell { function HostTemperatureCell(props: { sensors: HostSensorSummaryForCell | null | undefined }) { const [showTooltip, setShowTooltip] = createSignal(false); const [tooltipPos, setTooltipPos] = createSignal({ x: 0, y: 0 }); - const [tooltipDirection, setTooltipDirection] = createSignal<'above' | 'below'>('above'); const alertsActivation = useAlertsActivation(); const threshold = createMemo(() => alertsActivation.getTemperatureThreshold()); let closeTimeout: number | undefined; @@ -240,22 +240,7 @@ function HostTemperatureCell(props: { sensors: HostSensorSummaryForCell | null | const handleMouseEnter = (e: MouseEvent) => { if (closeTimeout) window.clearTimeout(closeTimeout); const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); - const x = rect.left + rect.width / 2; - const y = rect.top; - - // Estimate tooltip height (~400px max) and check if it fits above - const estimatedTooltipHeight = 400; - const spaceAbove = rect.top; - const spaceBelow = window.innerHeight - rect.bottom; - - // Position below if not enough space above, but also check if below has more space - if (spaceAbove < estimatedTooltipHeight && spaceBelow > spaceAbove) { - setTooltipDirection('below'); - setTooltipPos({ x, y: rect.bottom }); - } else { - setTooltipDirection('above'); - setTooltipPos({ x, y }); - } + setTooltipPos({ x: rect.left + rect.width / 2, y: rect.top }); setShowTooltip(true); }; @@ -338,12 +323,8 @@ function HostTemperatureCell(props: { sensors: HostSensorSummaryForCell | null | class="fixed z-[9999]" style={{ left: `${tooltipPos().x}px`, - top: tooltipDirection() === 'above' - ? `${tooltipPos().y - 8}px` - : `${tooltipPos().y + 8}px`, - transform: tooltipDirection() === 'above' - ? 'translate(-50%, -100%)' - : 'translate(-50%, 0)', + top: `${tooltipPos().y - 8}px`, + transform: 'translate(-50%, -100%)', }} onMouseEnter={handleTooltipEnter} onMouseLeave={handleTooltipLeave} @@ -984,26 +965,26 @@ export const HostsOverview: Component = () => { {/* Essential columns */} - handleSort('name')}> + handleSort('name')}> Host {renderSortIndicator('name')} - handleSort('platform')}> + handleSort('platform')}> Platform {renderSortIndicator('platform')} - handleSort('cpu')}> + handleSort('cpu')}> CPU {renderSortIndicator('cpu')} - handleSort('memory')}> + handleSort('memory')}> Memory {renderSortIndicator('memory')} - handleSort('disk')}> + handleSort('disk')}> Disk {renderSortIndicator('disk')} @@ -1106,6 +1087,8 @@ const HostRow: Component = (props) => { // NOTE: Do NOT destructure props.host - it breaks SolidJS reactivity! // Always access props.host directly to ensure updates flow through. + // Check if this host is in AI context + const isInAIContext = createMemo(() => aiChatStore.enabled && aiChatStore.hasContextItem(props.host.id)); // URL editing using shared hook const urlEdit = createUrlEditState(); @@ -1146,7 +1129,39 @@ const HostRow: Component = (props) => { } }; + // Build context for AI - includes routing fields + const buildHostContext = (): Record => ({ + hostName: props.host.displayName || props.host.hostname, + hostname: props.host.hostname, + node: props.host.hostname, // Used by AI for command routing + target_host: props.host.hostname, // Explicit routing hint + platform: props.host.platform, + osName: props.host.osName, + osVersion: props.host.osVersion, + cpuUsage: props.host.cpuUsage ? `${props.host.cpuUsage.toFixed(1)}%` : undefined, + memoryUsage: props.host.memory?.usage ? `${props.host.memory.usage.toFixed(1)}%` : undefined, + uptime: props.host.uptimeSeconds ? formatUptime(props.host.uptimeSeconds) : undefined, + }); + // Handle row click - toggle AI context selection + const handleRowClick = (event: MouseEvent) => { + const target = event.target as HTMLElement; + if (target.closest('a, button, [data-prevent-toggle], [data-url-editor]')) { + return; + } + + // If AI is enabled, toggle AI context + if (aiChatStore.enabled) { + if (aiChatStore.hasContextItem(props.host.id)) { + aiChatStore.removeContextItem(props.host.id); + } else { + aiChatStore.addContextItem('host', props.host.id, props.host.displayName || props.host.hostname, buildHostContext()); + if (!aiChatStore.isOpen) { + aiChatStore.open(); + } + } + } + }; // Reactive getters to ensure values update when WebSocket data changes const hostStatus = createMemo(() => getHostStatusIndicator(props.host)); @@ -1157,15 +1172,17 @@ const HostRow: Component = (props) => { const rowClass = () => { const base = 'transition-all duration-200'; const hover = 'hover:bg-gray-50 dark:hover:bg-gray-800/50'; + const clickable = aiChatStore.enabled ? 'cursor-pointer' : ''; + const aiContext = isInAIContext() ? 'ai-context-row' : ''; const offline = !isOnline() ? 'opacity-60' : ''; - return `${base} ${hover} ${offline}`; + return `${base} ${hover} ${clickable} ${aiContext} ${offline}`; }; return ( <> - + {/* Host Name - always visible */} - +
= (props) => { ariaLabel={hostStatus().label} size="xs" /> -
-
-

+

+
+

{props.host.displayName || props.host.hostname || props.host.id}

-

+

{props.host.hostname}

-

+

Updated {formatRelativeTime(props.host.lastSeen!)}

@@ -1228,18 +1242,25 @@ const HostRow: Component = (props) => { - + {/* AI context indicator */} + + + + + + +
{/* Platform */} - -
-

{props.host.platform || '—'}

+ +
+

{props.host.platform || '—'}

-

+

{props.host.osName} {props.host.osVersion}

@@ -1249,7 +1270,7 @@ const HostRow: Component = (props) => { {/* CPU */} - +
}> = (props) => { {/* Memory */} - +
}> = (props) => { {/* Disk */} - +
}> = (props) => { if (isPVE(item)) { const node = item; if (!node.disk) return undefined; - return `${formatBytes(node.disk.used)}/${formatBytes(node.disk.total)}`; + return `${formatBytes(node.disk.used, 0)}/${formatBytes(node.disk.total, 0)}`; } const pbs = item; if (!pbs.datastores || pbs.datastores.length === 0) return undefined; const totals = getPbsTotals(pbs); - return `${formatBytes(totals.used)}/${formatBytes(totals.total)}`; + return `${formatBytes(totals.used, 0)}/${formatBytes(totals.total, 0)}`; }; const getTemperatureValue = (item: TableItem) => { @@ -346,7 +346,7 @@ export const NodeSummaryTable: Component = (props) => { // Cell class constants for consistency const tdClass = "px-2 py-1 align-middle"; - const metricColumnStyle = { "min-width": "140px", "max-width": "180px" } as const; + const metricColumnStyle = { width: "200px", "min-width": "200px", "max-width": "200px" } as const; return ( @@ -360,41 +360,41 @@ export const NodeSummaryTable: Component = (props) => { > {props.currentTab === 'backups' ? 'Node / PBS' : 'Node'} {renderSortIndicator('name')} - handleSort('uptime')}> + handleSort('uptime')}> Uptime {renderSortIndicator('uptime')} - handleSort('cpu')}> + handleSort('cpu')}> CPU {renderSortIndicator('cpu')} - handleSort('memory')}> + handleSort('memory')}> Memory {renderSortIndicator('memory')} - handleSort('disk')}> + handleSort('disk')}> Disk {renderSortIndicator('disk')} - handleSort('temperature')}> + handleSort('temperature')}> Temp {renderSortIndicator('temperature')} - handleSort('vmCount')}> + handleSort('vmCount')}> VMs {renderSortIndicator('vmCount')} - handleSort('containerCount')}> + handleSort('containerCount')}> CTs {renderSortIndicator('containerCount')} - handleSort('storageCount')}> + handleSort('storageCount')}> Storage {renderSortIndicator('storageCount')} - handleSort('diskCount')}> + handleSort('diskCount')}> Disks {renderSortIndicator('diskCount')} - handleSort('backupCount')}> + handleSort('backupCount')}> Backups {renderSortIndicator('backupCount')} @@ -490,8 +490,8 @@ export const NodeSummaryTable: Component = (props) => { onClick={() => props.onNodeClick(nodeId, isPVEItem ? 'pve' : 'pbs')} > {/* Name */} - -
+ +
= (props) => { ({(node as Node).name}) -