From 721f97327128d6a83a3c541bdc9fe51b9790e7e9 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Sun, 7 Dec 2025 12:25:26 +0000 Subject: [PATCH] AI features checkpoint: Host selection, memory sparklines, UI refinements - Extended AI context selection to host rows in HostsOverview - Added resourceId prop to StackedMemoryBar for sparkline support - Relocated guest URL editing from GuestRow name click - Added GuestNotes component with URL field in AI sidebar - Refined host routing in AI service backend - Minor animation and styling improvements --- frontend-modern/src/components/AI/AIChat.tsx | 7 +- .../src/components/AI/GuestNotes.tsx | 136 +- .../src/components/Dashboard/Dashboard.tsx | 11 +- .../src/components/Dashboard/GuestRow.tsx | 284 ++-- .../components/Dashboard/StackedMemoryBar.tsx | 225 +-- .../components/Docker/DockerUnifiedTable.tsx | 1060 +++++++------- .../src/components/Hosts/HostsFilter.tsx | 54 +- .../src/components/Hosts/HostsOverview.tsx | 1212 +++++++++-------- .../components/shared/NodeSummaryTable.tsx | 1 + frontend-modern/src/styles/animations.css | 188 +-- internal/ai/routing.go | 45 +- internal/ai/service.go | 50 +- 12 files changed, 1674 insertions(+), 1599 deletions(-) diff --git a/frontend-modern/src/components/AI/AIChat.tsx b/frontend-modern/src/components/AI/AIChat.tsx index 3894adc..52d4f20 100644 --- a/frontend-modern/src/components/AI/AIChat.tsx +++ b/frontend-modern/src/components/AI/AIChat.tsx @@ -557,10 +557,15 @@ export const AIChat: Component = (props) => { success: result.success, }; + const remainingApprovals = m.pendingApprovals?.filter((a) => a.toolId !== approval.toolId) || []; + return { ...m, - pendingApprovals: m.pendingApprovals?.filter((a) => a.toolId !== approval.toolId), + pendingApprovals: remainingApprovals, toolCalls: [...(m.toolCalls || []), newToolCall], + // Clear the stale "I need approval" content after the last approval is processed + // The tool output will show the result instead + content: remainingApprovals.length === 0 ? '' : m.content, }; }) ); diff --git a/frontend-modern/src/components/AI/GuestNotes.tsx b/frontend-modern/src/components/AI/GuestNotes.tsx index a6b5148..fac1e8f 100644 --- a/frontend-modern/src/components/AI/GuestNotes.tsx +++ b/frontend-modern/src/components/AI/GuestNotes.tsx @@ -23,6 +23,8 @@ interface GuestNotesProps { guestId: string; guestName?: string; guestType?: string; + customUrl?: string; + onCustomUrlUpdate?: (guestId: string, url: string) => void; } const CATEGORY_LABELS: Record = { @@ -101,6 +103,16 @@ export const GuestNotes: Component = (props) => { const [title, setTitle] = createSignal(''); const [content, setContent] = createSignal(''); + // Guest URL state + const [guestUrl, setGuestUrl] = createSignal(props.customUrl || ''); + const [isEditingUrl, setIsEditingUrl] = createSignal(false); + const [isSavingUrl, setIsSavingUrl] = createSignal(false); + + // Sync URL from props + createEffect(() => { + setGuestUrl(props.customUrl || ''); + }); + // File input ref for import let fileInputRef: HTMLInputElement | undefined; @@ -115,11 +127,24 @@ export const GuestNotes: Component = (props) => { const loadKnowledge = async (guestId: string) => { setIsLoading(true); try { - const response = await apiFetch(`/api/ai/knowledge?guest_id=${encodeURIComponent(guestId)}`); - if (response.ok) { - const data = await response.json(); + // Fetch knowledge and metadata in parallel + const [knowledgeResponse, metadataResponse] = await Promise.all([ + apiFetch(`/api/ai/knowledge?guest_id=${encodeURIComponent(guestId)}`), + apiFetch(`/api/guests/metadata/${encodeURIComponent(guestId)}`), + ]); + + if (knowledgeResponse.ok) { + const data = await knowledgeResponse.json(); setKnowledge(data); } + + // Load customUrl from metadata if not provided via props + if (metadataResponse.ok) { + const metadata = await metadataResponse.json(); + if (metadata.customUrl && !props.customUrl) { + setGuestUrl(metadata.customUrl); + } + } } catch (error) { console.error('Failed to load guest knowledge:', error); } finally { @@ -127,6 +152,30 @@ export const GuestNotes: Component = (props) => { } }; + const saveGuestUrl = async () => { + const url = guestUrl().trim(); + setIsSavingUrl(true); + try { + const response = await apiFetch(`/api/guests/metadata/${encodeURIComponent(props.guestId)}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ customUrl: url }), + }); + if (response.ok) { + notificationStore.success(url ? 'Guest URL saved' : 'Guest URL cleared'); + setIsEditingUrl(false); + props.onCustomUrlUpdate?.(props.guestId, url); + } else { + notificationStore.error('Failed to save guest URL'); + } + } catch (error) { + console.error('Failed to save guest URL:', error); + notificationStore.error('Failed to save guest URL'); + } finally { + setIsSavingUrl(false); + } + }; + const saveNote = async () => { if (!title().trim() || !content().trim()) return; @@ -408,6 +457,85 @@ export const GuestNotes: Component = (props) => { {/* Expandable content */}
+ {/* Guest URL field */} +
+
+ + + + + Guest URL + + + + Open + + + + + +
+ + No URL set + }> + {guestUrl()} + + +
+ }> +
+ setGuestUrl(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + saveGuestUrl(); + } else if (e.key === 'Escape') { + e.preventDefault(); + setGuestUrl(props.customUrl || ''); + setIsEditingUrl(false); + } + }} + placeholder="https://192.168.1.100:8080" + class="w-full bg-gray-700 text-xs text-gray-200 rounded px-2 py-1.5 border border-gray-600 placeholder-gray-500 font-mono" + autofocus + /> +
+ + +
+
+ +
+ {/* Search and filter bar - only show if there are notes */}
@@ -437,7 +565,7 @@ export const GuestNotes: Component = (props) => { {/* Notes list */} No saved notes yet. The AI will automatically save useful discoveries.

+

No saved notes yet. Add notes to remember passwords, paths, and configs.

}> 0} fallback={

No notes match your search.

diff --git a/frontend-modern/src/components/Dashboard/Dashboard.tsx b/frontend-modern/src/components/Dashboard/Dashboard.tsx index 055af95..84a5a1a 100644 --- a/frontend-modern/src/components/Dashboard/Dashboard.tsx +++ b/frontend-modern/src/components/Dashboard/Dashboard.tsx @@ -897,10 +897,10 @@ export function Dashboard(props: DashboardProps) { } }; - // Handle row click - add guest to AI context when sidebar is open + // Handle row click - add guest to AI context (works even when sidebar is closed) const handleGuestRowClick = (guest: VM | Container) => { - // Only add to context if AI is enabled and sidebar is open - if (!aiChatStore.enabled || !aiChatStore.isOpen) return; + // Only enable if AI is configured + if (!aiChatStore.enabled) return; const guestId = guest.id || `${guest.instance}-${guest.vmid}`; const guestType = guest.type === 'qemu' ? 'vm' : 'container'; @@ -908,6 +908,7 @@ export function Dashboard(props: DashboardProps) { // Toggle: remove if already in context, add if not if (aiChatStore.hasContextItem(guestId)) { aiChatStore.removeContextItem(guestId); + // If no items left in context and sidebar is open, keep it open for now } else { aiChatStore.addContextItem(guestType, guestId, guest.name, { guestName: guest.name, @@ -917,6 +918,10 @@ export function Dashboard(props: DashboardProps) { node: guest.node, status: guest.status, }); + // Auto-open the sidebar when first item is selected + if (!aiChatStore.isOpen) { + aiChatStore.open(); + } } }; diff --git a/frontend-modern/src/components/Dashboard/GuestRow.tsx b/frontend-modern/src/components/Dashboard/GuestRow.tsx index 33888b5..cdf00a2 100644 --- a/frontend-modern/src/components/Dashboard/GuestRow.tsx +++ b/frontend-modern/src/components/Dashboard/GuestRow.tsx @@ -32,12 +32,7 @@ function getIOColorClass(bytesPerSec: number): string { return 'text-red-600 dark:text-red-400'; } -// Global editing state - use a signal so all components react -const [currentlyEditingGuestId, setCurrentlyEditingGuestId] = createSignal(null); -// Store the editing value globally so it survives re-renders -const editingValues = new Map(); -// Signal to trigger reactivity when editing values change -const [editingValuesVersion, setEditingValuesVersion] = createSignal(0); + const GROUPED_FIRST_CELL_INDENT = 'pl-5 sm:pl-6 lg:pl-8'; const DEFAULT_FIRST_CELL_INDENT = 'pl-4'; @@ -482,7 +477,6 @@ interface GuestRowProps { export function GuestRow(props: GuestRowProps) { const guestId = createMemo(() => buildGuestId(props.guest)); - const isEditingUrl = createMemo(() => currentlyEditingGuestId() === guestId()); // Use breakpoint hook directly for responsive behavior const { isMobile } = useBreakpoint(); @@ -505,12 +499,64 @@ export function GuestRow(props: GuestRowProps) { const [customUrl, setCustomUrl] = createSignal(props.customUrl); const [shouldAnimateIcon, setShouldAnimateIcon] = createSignal(false); - const editingUrlValue = createMemo(() => { - editingValuesVersion(); // Subscribe to changes - return editingValues.get(guestId()) || ''; - }); + const [isEditingUrl, setIsEditingUrl] = createSignal(false); + const [editingUrlValue, setEditingUrlValue] = createSignal(''); + const [isSavingUrl, setIsSavingUrl] = createSignal(false); let urlInputRef: HTMLInputElement | undefined; + // Focus input when editing starts + createEffect(() => { + if (isEditingUrl() && urlInputRef) { + urlInputRef.focus(); + urlInputRef.select(); + } + }); + + const startEditingUrl = (event: MouseEvent) => { + event.stopPropagation(); + setEditingUrlValue(customUrl() || ''); + setIsEditingUrl(true); + }; + + const saveUrl = async () => { + const newUrl = editingUrlValue().trim(); + setIsSavingUrl(true); + + try { + await GuestMetadataAPI.updateMetadata(guestId(), { customUrl: newUrl }); + + const hadUrl = !!customUrl(); + if (!hadUrl && newUrl) { + setShouldAnimateIcon(true); + setTimeout(() => setShouldAnimateIcon(false), 200); + } + + setCustomUrl(newUrl || undefined); + setIsEditingUrl(false); + + if (props.onCustomUrlUpdate) { + props.onCustomUrlUpdate(guestId(), newUrl); + } + + if (newUrl) { + showSuccess('Guest URL saved'); + } else { + showSuccess('Guest URL cleared'); + } + } catch (err: unknown) { + const message = err instanceof Error ? err.message : 'Failed to save guest URL'; + logger.error('Failed to save guest URL:', err); + showError(message); + } finally { + setIsSavingUrl(false); + } + }; + + const cancelEditingUrl = () => { + setIsEditingUrl(false); + setEditingUrlValue(''); + }; + const ipAddresses = createMemo(() => props.guest.ipAddresses ?? []); const networkInterfaces = createMemo(() => props.guest.networkInterfaces ?? []); const hasNetworkInterfaces = createMemo(() => networkInterfaces().length > 0); @@ -519,22 +565,19 @@ export function GuestRow(props: GuestRowProps) { const agentVersion = createMemo(() => props.guest.agentVersion?.trim() ?? ''); const hasOsInfo = createMemo(() => osName().length > 0 || osVersion().length > 0); - // Update custom URL when prop changes, but only if we're not currently editing + // Update custom URL when prop changes createEffect(() => { - // Don't update customUrl from props if this guest is currently being edited - if (currentlyEditingGuestId() !== guestId()) { - const prevUrl = customUrl(); - const newUrl = props.customUrl; + const prevUrl = customUrl(); + const newUrl = props.customUrl; - // Only animate when URL transitions from empty to having a value - if (!prevUrl && newUrl) { - setShouldAnimateIcon(true); - // Remove animation class after it completes - setTimeout(() => setShouldAnimateIcon(false), 200); - } - - setCustomUrl(newUrl); + // Only animate when URL transitions from empty to having a value + if (!prevUrl && newUrl) { + setShouldAnimateIcon(true); + // Remove animation class after it completes + setTimeout(() => setShouldAnimateIcon(false), 200); } + + setCustomUrl(newUrl); }); const cpuPercent = createMemo(() => (props.guest.cpu || 0) * 100); @@ -574,137 +617,7 @@ export function GuestRow(props: GuestRowProps) { }); const memoryTooltip = createMemo(() => memoryExtraLines()?.join('\n') ?? undefined); - const startEditingUrl = (event: MouseEvent) => { - event.stopPropagation(); - const currentEditing = currentlyEditingGuestId(); - if (currentEditing !== null && currentEditing !== guestId()) { - const currentInput = document.querySelector(`input[data-guest-id="${currentEditing}"]`) as HTMLInputElement; - if (currentInput) { - currentInput.blur(); - } - } - - editingValues.set(guestId(), customUrl() || ''); - setEditingValuesVersion(v => v + 1); - setCurrentlyEditingGuestId(guestId()); - }; - - createEffect(() => { - if (isEditingUrl() && urlInputRef) { - urlInputRef.focus(); - urlInputRef.select(); - } - }); - - let isCurrentlyMounted = true; - - createEffect(() => { - if (isEditingUrl() && isCurrentlyMounted) { - const handleGlobalClick = (e: MouseEvent) => { - if (currentlyEditingGuestId() !== guestId()) return; - - const target = e.target as HTMLElement; - const isClickingGuestName = target.closest('[data-guest-name-editable]'); - - if (!target.closest('[data-url-editor]') && !isClickingGuestName) { - e.preventDefault(); - e.stopPropagation(); - e.stopImmediatePropagation(); - cancelEditingUrl(); - } - }; - - const handleGlobalMouseDown = (e: MouseEvent) => { - if (currentlyEditingGuestId() !== guestId()) return; - - const target = e.target as HTMLElement; - const isClickingGuestName = target.closest('[data-guest-name-editable]'); - - if (!target.closest('[data-url-editor]') && !isClickingGuestName) { - e.preventDefault(); - e.stopPropagation(); - e.stopImmediatePropagation(); - } - }; - - document.addEventListener('mousedown', handleGlobalMouseDown, true); - document.addEventListener('click', handleGlobalClick, true); - return () => { - document.removeEventListener('mousedown', handleGlobalMouseDown, true); - document.removeEventListener('click', handleGlobalClick, true); - }; - } - }); - - const saveUrl = async () => { - if (currentlyEditingGuestId() !== guestId()) return; - - const newUrl = (editingValues.get(guestId()) || '').trim(); - - editingValues.delete(guestId()); - setEditingValuesVersion(v => v + 1); - setCurrentlyEditingGuestId(null); - - if (newUrl === (customUrl() || '')) return; - - try { - await GuestMetadataAPI.updateMetadata(guestId(), { customUrl: newUrl }); - - const hadUrl = !!customUrl(); - if (!hadUrl && newUrl) { - setShouldAnimateIcon(true); - setTimeout(() => setShouldAnimateIcon(false), 200); - } - - setCustomUrl(newUrl || undefined); - - if (props.onCustomUrlUpdate) { - props.onCustomUrlUpdate(guestId(), newUrl); - } - - if (newUrl) { - showSuccess('Guest URL saved'); - } else { - showSuccess('Guest URL cleared'); - } - } catch (err: any) { - logger.error('Failed to save guest URL:', err); - showError(err.message || 'Failed to save guest URL'); - } - }; - - const deleteUrl = async () => { - if (currentlyEditingGuestId() !== guestId()) return; - - editingValues.delete(guestId()); - setEditingValuesVersion(v => v + 1); - setCurrentlyEditingGuestId(null); - - if (customUrl()) { - try { - await GuestMetadataAPI.updateMetadata(guestId(), { customUrl: '' }); - setCustomUrl(undefined); - - if (props.onCustomUrlUpdate) { - props.onCustomUrlUpdate(guestId(), ''); - } - - showSuccess('Guest URL removed'); - } catch (err: any) { - logger.error('Failed to remove guest URL:', err); - showError(err.message || 'Failed to remove guest URL'); - } - } - }; - - const cancelEditingUrl = () => { - if (currentlyEditingGuestId() !== guestId()) return; - - editingValues.delete(guestId()); - setEditingValuesVersion(v => v + 1); - setCurrentlyEditingGuestId(null); - }; const diskPercent = createMemo(() => { if (!props.guest.disk || props.guest.disk.total === 0) return 0; @@ -765,7 +678,8 @@ export function GuestRow(props: GuestRowProps) { }); // Check AI context state reactively from the store - const isInAIContext = createMemo(() => aiChatStore.isOpen && aiChatStore.hasContextItem(guestId())); + // Show selection even when sidebar is closed - makes selection persistent + const isInAIContext = createMemo(() => aiChatStore.enabled && aiChatStore.hasContextItem(guestId())); const isAboveInAIContext = createMemo(() => { if (!props.aboveGuestId) return false; return aiChatStore.hasContextItem(props.aboveGuestId); @@ -816,8 +730,7 @@ export function GuestRow(props: GuestRowProps) { const handleRowClick = (e: MouseEvent) => { // Don't trigger if clicking on interactive elements const target = e.target as HTMLElement; - if (target.closest('[data-url-editor]') || - target.closest('[data-prevent-toggle]') || + if (target.closest('[data-prevent-toggle]') || target.closest('a') || target.closest('button') || target.closest('input')) { @@ -846,13 +759,10 @@ export function GuestRow(props: GuestRowProps) { +
{props.guest.name} @@ -862,7 +772,7 @@ export function GuestRow(props: GuestRowProps) { target="_blank" rel="noopener noreferrer" class={`flex-shrink-0 text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 transition-colors ${shouldAnimateIcon() ? 'animate-fadeIn' : ''}`} - title="Open in new tab" + title={`Open ${customUrl()}`} onClick={(event) => event.stopPropagation()} > + {/* Edit URL button - shows on hover */} + {/* Show backup indicator in name cell only if backup column is hidden */} + {/* AI context indicator - shows when row is selected for AI */} + + + + + + +
} > + {/* URL editing mode */}
{ - editingValues.set(guestId(), e.currentTarget.value); - setEditingValuesVersion(v => v + 1); - }} + onInput={(e) => setEditingUrlValue(e.currentTarget.value)} onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); @@ -907,30 +833,31 @@ export function GuestRow(props: GuestRowProps) { } }} onClick={(e) => e.stopPropagation()} - placeholder="https://192.168.1.100:8006" - class="min-w-0 px-2 py-0.5 text-sm border border-blue-500 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500" + placeholder="https://192.168.1.100:8080" + class="w-40 px-2 py-0.5 text-xs border border-blue-500 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-1 focus:ring-blue-500" + disabled={isSavingUrl()} /> @@ -1039,6 +966,7 @@ export function GuestRow(props: GuestRowProps) { balloon={props.guest.memory?.balloon || 0} swapUsed={props.guest.memory?.swapUsed || 0} swapTotal={props.guest.memory?.swapTotal || 0} + resourceId={metricsKey()} /> } > diff --git a/frontend-modern/src/components/Dashboard/StackedMemoryBar.tsx b/frontend-modern/src/components/Dashboard/StackedMemoryBar.tsx index 869885c..788a556 100644 --- a/frontend-modern/src/components/Dashboard/StackedMemoryBar.tsx +++ b/frontend-modern/src/components/Dashboard/StackedMemoryBar.tsx @@ -1,6 +1,9 @@ import { Show, For, createMemo, createSignal, onMount, onCleanup } from 'solid-js'; import { Portal } from 'solid-js/web'; import { formatBytes, formatPercent } from '@/utils/format'; +import { Sparkline } from '@/components/shared/Sparkline'; +import { useMetricsViewMode } from '@/stores/metricsViewMode'; +import { getMetricHistoryForRange, getMetricsVersion } from '@/stores/metricsHistory'; interface StackedMemoryBarProps { used: number; @@ -8,6 +11,7 @@ interface StackedMemoryBarProps { swapUsed?: number; swapTotal?: number; balloon?: number; + resourceId?: string; // Required for sparkline mode to fetch history } // Colors for memory segments @@ -18,6 +22,17 @@ const MEMORY_COLORS = { }; export function StackedMemoryBar(props: StackedMemoryBarProps) { + const { viewMode, timeRange } = useMetricsViewMode(); + + // Get metric history for sparkline + // Depends on metricsVersion to re-fetch when data is seeded (e.g., on time range change) + const metricHistory = createMemo(() => { + // Subscribe to version changes so we re-read when new data is seeded + getMetricsVersion(); + if (viewMode() !== 'sparklines' || !props.resourceId) return []; + return getMetricHistoryForRange(props.resourceId, timeRange()); + }); + const [containerWidth, setContainerWidth] = createSignal(100); const [showTooltip, setShowTooltip] = createSignal(false); const [tooltipPos, setTooltipPos] = createSignal({ x: 0, y: 0 }); @@ -121,115 +136,129 @@ export function StackedMemoryBar(props: StackedMemoryBarProps) { }; return ( -
-
- {/* Stacked segments - use absolute positioning like MetricBar for correct width scaling */} - - {(segment, idx) => { - // Calculate left offset as sum of previous segments - const leftOffset = () => { - let offset = 0; - const segs = segments(); - for (let i = 0; i < idx(); i++) { - offset += segs[i].percent; - } - return offset; - }; - return ( -
- ); - }} - - - {/* Swap Indicator (Thin line at bottom if swap is used) */} - {/* Swap Indicator (Thin line at bottom if swap is used) */} - 0}> +
- - - {/* Label overlay */} - - - {displayLabel()} - - - ({displaySublabel()}) - - - - -
- - {/* Tooltip */} - - -
-
-
- Memory Composition -
+ {/* Stacked segments - use absolute positioning like MetricBar for correct width scaling */} + + {(segment, idx) => { + // Calculate left offset as sum of previous segments + const leftOffset = () => { + let offset = 0; + const segs = segments(); + for (let i = 0; i < idx(); i++) { + offset += segs[i].percent; + } + return offset; + }; + return ( +
+ ); + }} + - {/* RAM Breakdown */} -
- Used - - {formatBytes(props.used, 0)} - -
+ {/* Swap Indicator (Thin line at bottom if swap is used) */} + 0}> +
+ - 0 && (props.balloon || 0) < props.total}> -
- Balloon Limit - - {formatBytes(props.balloon || 0, 0)} + {/* Label overlay */} + + + {displayLabel()} + + + ({displaySublabel()}) -
-
+ + + +
-
- Free - - {formatBytes(props.total - props.used, 0)} - -
+ {/* Tooltip */} + + +
+
+
+ Memory Composition +
- {/* Swap Section */} - -
+ {/* RAM Breakdown */}
- Swap + Used - {formatBytes(props.swapUsed || 0, 0)} / {formatBytes(props.swapTotal || 0, 0)} + {formatBytes(props.used, 0)}
+ + 0 && (props.balloon || 0) < props.total}> +
+ Balloon Limit + + {formatBytes(props.balloon || 0, 0)} + +
+
+ +
+ Free + + {formatBytes(props.total - props.used, 0)} + +
+ + {/* Swap Section */} + +
+
+ Swap + + {formatBytes(props.swapUsed || 0, 0)} / {formatBytes(props.swapTotal || 0, 0)} + +
+
+
-
-
-
-
-
-
+
+ + +
+ } + > + {/* Sparkline mode - full width, flex centered like stacked bars */} +
+ +
+
); } diff --git a/frontend-modern/src/components/Docker/DockerUnifiedTable.tsx b/frontend-modern/src/components/Docker/DockerUnifiedTable.tsx index 86f5540..5f2dc24 100644 --- a/frontend-modern/src/components/Docker/DockerUnifiedTable.tsx +++ b/frontend-modern/src/components/Docker/DockerUnifiedTable.tsx @@ -794,6 +794,8 @@ const DockerContainerRow: Component<{ // Annotations and AI state const [aiEnabled, setAiEnabled] = createSignal(false); + // Check if this container is in AI context + const isInAIContext = createMemo(() => aiChatStore.enabled && aiChatStore.hasContextItem(resourceId())); const [annotations, setAnnotations] = createSignal([]); const [newAnnotation, setNewAnnotation] = createSignal(''); const [saving, setSaving] = createSignal(false); @@ -968,10 +970,28 @@ const DockerContainerRow: Component<{ }); const toggle = (event: MouseEvent) => { - if (!hasDrawerContent()) return; const target = event.target as HTMLElement; if (target.closest('a, button, input, [data-prevent-toggle]')) return; - // Toggle: if this row is currently expanded, close it; otherwise open it (closing any other) + + // If AI is enabled, toggle AI context instead of expanding drawer + if (aiChatStore.enabled) { + if (aiChatStore.hasContextItem(resourceId())) { + aiChatStore.removeContextItem(resourceId()); + } else { + aiChatStore.addContextItem('docker', resourceId(), container.name, { + containerName: container.name, + ...buildContainerContext(), + }); + // Auto-open the sidebar when first item is selected + if (!aiChatStore.isOpen) { + aiChatStore.open(); + } + } + return; + } + + // Standard drawer toggle when AI is not enabled + if (!hasDrawerContent()) return; setCurrentlyExpandedRowId(prev => prev === rowId ? null : rowId); }; @@ -1220,6 +1240,14 @@ const DockerContainerRow: Component<{ {hostDisplayName()}
+ {/* AI context indicator - shows when container is selected for AI */} + + + + + + +
} > @@ -1303,6 +1331,7 @@ const DockerContainerRow: Component<{ balloon={0} swapUsed={0} swapTotal={0} + resourceId={metricsKey} />
@@ -1354,7 +1383,7 @@ const DockerContainerRow: Component<{ return ( <> @@ -1384,399 +1413,399 @@ const DockerContainerRow: Component<{
Summary
-
-
- Runtime - - {runtimeInfo.label} - - {(version) => ( - {version()} +
+
+ Runtime + + {runtimeInfo.label} + + {(version) => ( + {version()} + )} + + +
+
+ Image + + {container.image || '—'} + +
+ + {(name) => ( +
+ Pod + + {name()} + + + infra + + + +
)}
- -
-
- Image - - {container.image || '—'} - -
- - {(name) => ( -
- Pod - - {name()} - - - infra - - - -
- )} -
- - {(project) => ( -
- Compose Project - {project()} -
- )} -
- - {(service) => ( -
- Compose Service - {service()} -
- )} -
- - {(policy) => ( -
- Auto Update - - {policy()} - - {(restart) => ( - restart: {restart()} - )} - - -
- )} -
- - {(userns) => ( -
- User Namespace - {userns()} -
- )} -
-
- State - {statusLabel()} -
-
- Restarts - {restarts()} -
- - {(created) => ( -
- Created -
- {created()} - - {(abs) => ( -
{abs()}
- )} -
-
-
- )} -
- - {(started) => ( -
- Started -
- {started()} - - {(abs) => ( -
{abs()}
- )} -
-
-
- )} -
-
- Uptime - {uptime()} -
-
- -
- Podman hosts report container metrics, but Swarm services and tasks are unavailable. Runtime annotations and compose metadata appear below when present. -
-
-
- 0}> -
-
- Ports -
-
- {container.ports!.map((port) => { - const label = port.publicPort - ? `${port.publicPort}:${port.privatePort}/${port.protocol}` - : `${port.privatePort}/${port.protocol}`; - return ( - - {label} - - ); - })} -
-
-
- - 0}> -
-
- Networks -
-
- {container.networks!.map((network) => ( -
-
{network.name}
-
- - - {network.ipv4} - - - - - {network.ipv6} - - -
-
- ))} -
-
-
- - -
-
- Podman Metadata -
-
- - {(section) => ( -
-
- {section.title} + + {(project) => ( +
+ Compose Project + {project()}
-
- - {(item) => ( -
- {item.label} - - {item.value || '—'} - -
- )} -
-
-
- )} - -
-
- - - -
-
- Block I/O -
-
-
- Read -
-
- {formatBytes(blockIoReadBytes())} -
- -
- {blockIoReadRateLabel()} -
-
-
-
-
- Write -
-
- {formatBytes(blockIoWriteBytes())} -
- -
- {blockIoWriteRateLabel()} -
-
-
-
-
-
-
- - -
-
- Mounts -
-
- - {(mount) => { - const destination = mount.destination || mount.source || mount.name || 'mount'; - const rw = mount.rw === false ? 'read-only' : 'read-write'; - return ( -
-
- - {destination} - - - - {mount.type} - - -
- -
- {mount.source} -
-
-
- - {rw} - - - - mode: {mount.mode} - - - - - {mount.driver} - - - - - {mount.name} - - - - - {mount.propagation} - - -
-
- ); - }} -
-
-
-
- - 0}> -
-
- Labels -
-
- {Object.entries(container.labels!).map(([key, value]) => { - const fullLabel = value ? `${key}: ${value}` : key; - return ( - - {key} - : {value} - - ); - })} -
-
-
- - {/* Annotations & Ask AI row */} - -
-
- AI Context - - saving... + )} -
- - {/* Existing annotations */} - 0}> -
- - {(annotation, index) => ( - - {annotation} - + + {(service) => ( +
+ Compose Service + {service()} +
+ )} +
+ + {(policy) => ( +
+ Auto Update + + {policy()} + + {(restart) => ( + restart: {restart()} + )} + +
+ )} +
+ + {(userns) => ( +
+ User Namespace + {userns()} +
+ )} +
+
+ State + {statusLabel()} +
+
+ Restarts + {restarts()} +
+ + {(created) => ( +
+ Created +
+ {created()} + + {(abs) => ( +
{abs()}
+ )} +
+
+
+ )} +
+ + {(started) => ( +
+ Started +
+ {started()} + + {(abs) => ( +
{abs()}
+ )} +
+
+
+ )} +
+
+ Uptime + {uptime()} +
+
+ +
+ Podman hosts report container metrics, but Swarm services and tasks are unavailable. Runtime annotations and compose metadata appear below when present. +
+
+
+ 0}> +
+
+ Ports +
+
+ {container.ports!.map((port) => { + const label = port.publicPort + ? `${port.publicPort}:${port.privatePort}/${port.protocol}` + : `${port.privatePort}/${port.protocol}`; + return ( + + {label} + + ); + })} +
+
+
+ + 0}> +
+
+ Networks +
+
+ {container.networks!.map((network) => ( +
+
{network.name}
+
+ + + {network.ipv4} + + + + + {network.ipv6} + + +
+
+ ))} +
+
+
+ + +
+
+ Podman Metadata +
+
+ + {(section) => ( +
+
+ {section.title} +
+
+ + {(item) => ( +
+ {item.label} + + {item.value || '—'} + +
+ )} +
+
+
)}
- - - {/* 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" - /> - -
-
-
+
+ + +
+
+ Block I/O +
+
+
+ Read +
+
+ {formatBytes(blockIoReadBytes())} +
+ +
+ {blockIoReadRateLabel()} +
+
+
+
+
+ Write +
+
+ {formatBytes(blockIoWriteBytes())} +
+ +
+ {blockIoWriteRateLabel()} +
+
+
+
+
+
+
+ + +
+
+ Mounts +
+
+ + {(mount) => { + const destination = mount.destination || mount.source || mount.name || 'mount'; + const rw = mount.rw === false ? 'read-only' : 'read-write'; + return ( +
+
+ + {destination} + + + + {mount.type} + + +
+ +
+ {mount.source} +
+
+
+ + {rw} + + + + mode: {mount.mode} + + + + + {mount.driver} + + + + + {mount.name} + + + + + {mount.propagation} + + +
+
+ ); + }} +
+
+
+
+ + 0}> +
+
+ Labels +
+
+ {Object.entries(container.labels!).map(([key, value]) => { + const fullLabel = value ? `${key}: ${value}` : key; + return ( + + {key} + : {value} + + ); + })} +
+
+
+ + {/* 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" + /> + + +
+
+
@@ -1813,6 +1842,23 @@ const DockerServiceRow: Component<{ const hasTasks = () => tasks.length > 0; + // Check if this service is in AI context + const isInAIContext = createMemo(() => aiChatStore.enabled && aiChatStore.hasContextItem(resourceId())); + + // Build context for AI + const buildServiceContext = () => { + const ctx: Record = { + name: service.name, + type: 'Docker Swarm Service', + host: host.hostname, + image: service.image, + mode: service.mode, + replicas: `${service.runningTasks ?? 0}/${service.desiredTasks ?? 0}`, + }; + if (service.stack) ctx.stack = service.stack; + return ctx; + }; + // Update custom URL when prop changes, but only if we're not currently editing createEffect(() => { if (currentlyEditingDockerResourceId() !== resourceId()) { @@ -1838,10 +1884,28 @@ const DockerServiceRow: Component<{ }); const toggle = (event: MouseEvent) => { - if (!hasTasks()) return; const target = event.target as HTMLElement; if (target.closest('a, button, input, [data-prevent-toggle]')) return; - // Toggle: if this row is currently expanded, close it; otherwise open it (closing any other) + + // If AI is enabled, toggle AI context instead of expanding drawer + if (aiChatStore.enabled) { + if (aiChatStore.hasContextItem(resourceId())) { + aiChatStore.removeContextItem(resourceId()); + } else { + aiChatStore.addContextItem('docker', resourceId(), service.name, { + serviceName: service.name, + ...buildServiceContext(), + }); + // Auto-open the sidebar when first item is selected + if (!aiChatStore.isOpen) { + aiChatStore.open(); + } + } + return; + } + + // Standard drawer toggle when AI is not enabled + if (!hasTasks()) return; setCurrentlyExpandedRowId(prev => prev === rowId ? null : rowId); }; @@ -2052,6 +2116,14 @@ const DockerServiceRow: Component<{ {hostDisplayName()}
+ {/* AI context indicator - shows when service is selected for AI */} + + + + + + +
} > @@ -2130,7 +2202,7 @@ const DockerServiceRow: Component<{ return ( <> @@ -2165,111 +2237,111 @@ const DockerServiceRow: Component<{
- - - - - - - - - - - - - - {(task) => { - const container = findContainerForTask(host.containers || [], task); - const cpu = container?.cpuPercent ?? 0; - const mem = container?.memoryPercent ?? 0; - const updated = ensureMs(task.updatedAt ?? task.createdAt ?? task.startedAt); - const taskLabel = () => { - if (task.containerName) return task.containerName; - if (task.containerId) return task.containerId.slice(0, 12); - if (task.slot !== undefined) return `slot-${task.slot}`; - return task.id ?? 'Task'; - }; - const taskTitle = () => { - const label = taskLabel(); - if (task.containerId && task.containerId !== label) { - return `${label} \u2014 ${task.containerId}`; - } - if (task.id && task.id !== label) { - return `${label} \u2014 ${task.id}`; - } - return label; - }; - const state = toLower(task.currentState ?? task.desiredState ?? 'unknown'); - const taskMetricsKey = container?.id ? buildMetricKey('dockerContainer', container.id) : undefined; - const stateClass = () => { - if (state === 'running') { - return 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300'; - } - if (state === 'failed' || state === 'error') { - return 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300'; - } - return 'bg-gray-200 text-gray-600 dark:bg-gray-700 dark:text-gray-300'; - }; - return ( - - - - - - - - + + + + + + + + + + + + + {(task) => { + const container = findContainerForTask(host.containers || [], task); + const cpu = container?.cpuPercent ?? 0; + const mem = container?.memoryPercent ?? 0; + const updated = ensureMs(task.updatedAt ?? task.createdAt ?? task.startedAt); + const taskLabel = () => { + if (task.containerName) return task.containerName; + if (task.containerId) return task.containerId.slice(0, 12); + if (task.slot !== undefined) return `slot-${task.slot}`; + return task.id ?? 'Task'; + }; + const taskTitle = () => { + const label = taskLabel(); + if (task.containerId && task.containerId !== label) { + return `${label} \u2014 ${task.containerId}`; + } + if (task.id && task.id !== label) { + return `${label} \u2014 ${task.id}`; + } + return label; + }; + const state = toLower(task.currentState ?? task.desiredState ?? 'unknown'); + const taskMetricsKey = container?.id ? buildMetricKey('dockerContainer', container.id) : undefined; + const stateClass = () => { + if (state === 'running') { + return 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300'; + } + if (state === 'failed' || state === 'error') { + return 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300'; + } + return 'bg-gray-200 text-gray-600 dark:bg-gray-700 dark:text-gray-300'; + }; + return ( + + + - - ); - }} - - + + + + + + + + ); + }} + +
TaskTypeNodeStateCPUMemoryUpdated
-
- - {taskLabel()} - -
-
- - Task - - - {task.nodeName || task.nodeId || '—'} - - - {task.currentState || task.desiredState || 'Unknown'} - - - 0} fallback={}> - - - - 0} fallback={}> - - - - - {(timestamp) => ( - - {formatRelativeTime(timestamp())} +
TaskTypeNodeStateCPUMemoryUpdated
+
+ + {taskLabel()} + +
+
+ + Task - )} - -
+ {task.nodeName || task.nodeId || '—'} + + + {task.currentState || task.desiredState || 'Unknown'} + + + 0} fallback={}> + + + + 0} fallback={}> + + + + + {(timestamp) => ( + + {formatRelativeTime(timestamp())} + + )} + +
diff --git a/frontend-modern/src/components/Hosts/HostsFilter.tsx b/frontend-modern/src/components/Hosts/HostsFilter.tsx index 244bccb..9472018 100644 --- a/frontend-modern/src/components/Hosts/HostsFilter.tsx +++ b/frontend-modern/src/components/Hosts/HostsFilter.tsx @@ -1,6 +1,8 @@ import { Component, Show, For, createSignal, createMemo, onMount, createEffect, onCleanup } from 'solid-js'; import { Card } from '@/components/shared/Card'; import { SearchTipsPopover } from '@/components/shared/SearchTipsPopover'; +import { ColumnPicker } from '@/components/shared/ColumnPicker'; +import type { ColumnDef } from '@/hooks/useColumnVisibility'; import { STORAGE_KEYS } from '@/utils/localStorage'; import { createSearchHistoryManager } from '@/utils/searchHistory'; @@ -13,6 +15,11 @@ interface HostsFilterProps { onReset?: () => void; activeHostName?: string; onClearHost?: () => void; + // Column visibility + availableColumns?: ColumnDef[]; + isColumnHidden?: (id: string) => boolean; + onColumnToggle?: (id: string) => void; + onColumnReset?: () => void; } export const HostsFilter: Component = (props) => { @@ -301,11 +308,10 @@ export const HostsFilter: Component = (props) => { type="button" aria-pressed={props.statusFilter() === 'all'} onClick={() => props.setStatusFilter('all')} - class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${ - props.statusFilter() === 'all' - ? 'bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm' - : 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100' - }`} + class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${props.statusFilter() === 'all' + ? 'bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm' + : 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100' + }`} title="Show all hosts" > All @@ -316,11 +322,10 @@ export const HostsFilter: Component = (props) => { onClick={() => props.setStatusFilter(props.statusFilter() === 'online' ? 'all' : 'online') } - class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${ - props.statusFilter() === 'online' - ? 'bg-white dark:bg-gray-800 text-green-600 dark:text-green-400 shadow-sm' - : 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100' - }`} + class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${props.statusFilter() === 'online' + ? 'bg-white dark:bg-gray-800 text-green-600 dark:text-green-400 shadow-sm' + : 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100' + }`} title="Show online hosts only" > Online @@ -331,11 +336,10 @@ export const HostsFilter: Component = (props) => { onClick={() => props.setStatusFilter(props.statusFilter() === 'degraded' ? 'all' : 'degraded') } - class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${ - props.statusFilter() === 'degraded' - ? 'bg-white dark:bg-gray-800 text-amber-600 dark:text-amber-400 shadow-sm' - : 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100' - }`} + class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${props.statusFilter() === 'degraded' + ? 'bg-white dark:bg-gray-800 text-amber-600 dark:text-amber-400 shadow-sm' + : 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100' + }`} title="Show degraded hosts only" > Degraded @@ -346,11 +350,10 @@ export const HostsFilter: Component = (props) => { onClick={() => props.setStatusFilter(props.statusFilter() === 'offline' ? 'all' : 'offline') } - class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${ - props.statusFilter() === 'offline' - ? 'bg-white dark:bg-gray-800 text-red-600 dark:text-red-400 shadow-sm' - : 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100' - }`} + class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${props.statusFilter() === 'offline' + ? 'bg-white dark:bg-gray-800 text-red-600 dark:text-red-400 shadow-sm' + : 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100' + }`} title="Show offline hosts only" > Offline @@ -373,6 +376,17 @@ export const HostsFilter: Component = (props) => {
+ {/* Column Picker */} + + + + +