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
This commit is contained in:
rcourtman 2025-12-07 12:25:26 +00:00
parent f2e6927436
commit 721f973271
12 changed files with 1674 additions and 1599 deletions

View file

@ -557,10 +557,15 @@ export const AIChat: Component<AIChatProps> = (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,
};
})
);

View file

@ -23,6 +23,8 @@ interface GuestNotesProps {
guestId: string;
guestName?: string;
guestType?: string;
customUrl?: string;
onCustomUrlUpdate?: (guestId: string, url: string) => void;
}
const CATEGORY_LABELS: Record<string, string> = {
@ -101,6 +103,16 @@ export const GuestNotes: Component<GuestNotesProps> = (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<GuestNotesProps> = (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<GuestNotesProps> = (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<GuestNotesProps> = (props) => {
{/* Expandable content */}
<Show when={isExpanded()}>
<div class="mt-2 space-y-2 px-2">
{/* Guest URL field */}
<div class="bg-gray-800/50 rounded p-2 border border-gray-700">
<div class="flex items-center justify-between mb-1">
<span class="text-xs font-medium text-gray-400 flex items-center gap-1">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
Guest URL
</span>
<Show when={guestUrl() && !isEditingUrl()}>
<a
href={guestUrl()}
target="_blank"
rel="noopener noreferrer"
class="text-xs text-blue-400 hover:text-blue-300 flex items-center gap-1"
>
Open
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</a>
</Show>
</div>
<Show when={isEditingUrl()} fallback={
<div class="flex items-center gap-2">
<Show when={guestUrl()} fallback={
<span class="text-xs text-gray-500 italic">No URL set</span>
}>
<span class="text-xs text-gray-300 break-all font-mono">{guestUrl()}</span>
</Show>
<button
onClick={() => setIsEditingUrl(true)}
class="text-xs text-blue-400 hover:text-blue-300 ml-auto"
>
{guestUrl() ? 'Edit' : 'Add'}
</button>
</div>
}>
<div class="space-y-2">
<input
type="text"
value={guestUrl()}
onInput={(e) => 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
/>
<div class="flex gap-2 justify-end">
<button
onClick={() => {
setGuestUrl(props.customUrl || '');
setIsEditingUrl(false);
}}
class="text-xs px-2 py-1 text-gray-400 hover:text-gray-200"
>
Cancel
</button>
<button
onClick={saveGuestUrl}
disabled={isSavingUrl()}
class="text-xs px-3 py-1 bg-blue-600 text-white rounded hover:bg-blue-500 disabled:opacity-50"
>
{isSavingUrl() ? 'Saving...' : 'Save'}
</button>
</div>
</div>
</Show>
</div>
{/* Search and filter bar - only show if there are notes */}
<Show when={hasNotes()}>
<div class="flex gap-2 mb-2">
@ -437,7 +565,7 @@ export const GuestNotes: Component<GuestNotesProps> = (props) => {
{/* Notes list */}
<Show when={hasNotes()} fallback={
<p class="text-xs text-gray-500 italic">No saved notes yet. The AI will automatically save useful discoveries.</p>
<p class="text-xs text-gray-500 italic">No saved notes yet. Add notes to remember passwords, paths, and configs.</p>
}>
<Show when={filteredNotes().length > 0} fallback={
<p class="text-xs text-gray-500 italic">No notes match your search.</p>

View file

@ -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();
}
}
};

View file

@ -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<string | null>(null);
// Store the editing value globally so it survives re-renders
const editingValues = new Map<string, string>();
// 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<string | undefined>(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) {
<Show
when={isEditingUrl()}
fallback={
<div class="flex items-center gap-1.5 min-w-0">
<div class="flex items-center gap-1.5 min-w-0 group/name">
<span
class="text-xs font-medium text-gray-900 dark:text-gray-100 cursor-text select-none whitespace-nowrap"
style="cursor: text;"
title={`${props.guest.name}${customUrl() ? ' - Click to edit URL' : ' - Click to add URL'}`}
onClick={startEditingUrl}
data-guest-name-editable
class="text-xs font-medium text-gray-900 dark:text-gray-100 select-none whitespace-nowrap"
title={props.guest.name}
>
{props.guest.name}
</span>
@ -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()}
>
<svg
@ -880,23 +790,39 @@ export function GuestRow(props: GuestRowProps) {
</svg>
</a>
</Show>
{/* Edit URL button - shows on hover */}
<button
type="button"
onClick={startEditingUrl}
class="flex-shrink-0 opacity-0 group-hover/name:opacity-100 text-gray-400 hover:text-blue-500 dark:hover:text-blue-400 transition-all"
title={customUrl() ? 'Edit URL' : 'Add URL'}
>
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
</svg>
</button>
{/* Show backup indicator in name cell only if backup column is hidden */}
<Show when={!isColVisible('backup')}>
<BackupIndicator lastBackup={props.guest.lastBackup} isTemplate={props.guest.template} />
</Show>
{/* AI context indicator - shows when row is selected for AI */}
<Show when={isInAIContext()}>
<span class="flex-shrink-0 text-purple-500 dark:text-purple-400" title="Selected for AI context">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09zM18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 00-2.456 2.456z" />
</svg>
</span>
</Show>
</div>
}
>
{/* URL editing mode */}
<div class="flex items-center gap-1 min-w-0" data-url-editor>
<input
ref={urlInputRef}
type="text"
value={editingUrlValue()}
data-guest-id={guestId()}
onInput={(e) => {
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()}
/>
<button
type="button"
data-url-editor-button
onClick={(e) => {
e.stopPropagation();
saveUrl();
}}
class="flex-shrink-0 w-6 h-6 flex items-center justify-center text-xs bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors"
title="Save (or press Enter)"
disabled={isSavingUrl()}
class="flex-shrink-0 w-5 h-5 flex items-center justify-center text-xs bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors disabled:opacity-50"
title="Save (Enter)"
>
</button>
<button
type="button"
data-url-editor-button
onClick={(e) => {
e.stopPropagation();
deleteUrl();
cancelEditingUrl();
}}
class="flex-shrink-0 w-6 h-6 flex items-center justify-center text-xs bg-red-600 text-white rounded hover:bg-red-700 transition-colors"
title="Delete URL"
disabled={isSavingUrl()}
class="flex-shrink-0 w-5 h-5 flex items-center justify-center text-xs bg-gray-500 text-white rounded hover:bg-gray-600 transition-colors disabled:opacity-50"
title="Cancel (Esc)"
>
</button>
@ -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()}
/>
}
>

View file

@ -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 (
<div ref={containerRef} class="metric-text w-full h-4 flex items-center justify-center">
<div
class="relative w-full h-full overflow-hidden bg-gray-200 dark:bg-gray-600 rounded cursor-help"
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
{/* Stacked segments - use absolute positioning like MetricBar for correct width scaling */}
<For each={segments()}>
{(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 (
<div
class="absolute top-0 h-full transition-all duration-300"
style={{
left: `${leftOffset()}%`,
width: `${segment.percent}%`,
'background-color': segment.color,
'border-right': idx() < segments().length - 1 ? '1px solid rgba(255,255,255,0.3)' : 'none',
}}
/>
);
}}
</For>
{/* Swap Indicator (Thin line at bottom if swap is used) */}
{/* Swap Indicator (Thin line at bottom if swap is used) */}
<Show when={hasSwap() && (props.swapUsed || 0) > 0}>
<Show
when={viewMode() === 'sparklines' && props.resourceId}
fallback={
<div ref={containerRef} class="metric-text w-full h-4 flex items-center justify-center">
<div
class="absolute bottom-0 left-0 h-[3px] w-full bg-purple-500"
style={{ width: `${Math.min(swapPercent(), 100)}%` }}
/>
</Show>
{/* Label overlay */}
<span class="absolute inset-0 flex items-center justify-center text-[10px] font-semibold text-gray-700 dark:text-gray-100 leading-none pointer-events-none">
<span class="flex items-center gap-1 whitespace-nowrap px-0.5">
<span>{displayLabel()}</span>
<Show when={showSublabel()}>
<span class="metric-sublabel font-normal text-gray-500 dark:text-gray-300">
({displaySublabel()})
</span>
</Show>
</span>
</span>
</div>
{/* Tooltip */}
<Show when={showTooltip()}>
<Portal mount={document.body}>
<div
class="fixed z-[9999] pointer-events-none"
style={{
left: `${tooltipPos().x}px`,
top: `${tooltipPos().y - 8}px`,
transform: 'translate(-50%, -100%)',
}}
class="relative w-full h-full overflow-hidden bg-gray-200 dark:bg-gray-600 rounded cursor-help"
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
<div class="bg-gray-900 dark:bg-gray-800 text-white text-[10px] rounded-md shadow-lg px-2 py-1.5 min-w-[140px] border border-gray-700">
<div class="font-medium mb-1 text-gray-300 border-b border-gray-700 pb-1">
Memory Composition
</div>
{/* Stacked segments - use absolute positioning like MetricBar for correct width scaling */}
<For each={segments()}>
{(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 (
<div
class="absolute top-0 h-full transition-all duration-300"
style={{
left: `${leftOffset()}%`,
width: `${segment.percent}%`,
'background-color': segment.color,
'border-right': idx() < segments().length - 1 ? '1px solid rgba(255,255,255,0.3)' : 'none',
}}
/>
);
}}
</For>
{/* RAM Breakdown */}
<div class="flex justify-between gap-3 py-0.5">
<span class="text-green-400">Used</span>
<span class="whitespace-nowrap text-gray-300">
{formatBytes(props.used, 0)}
</span>
</div>
{/* Swap Indicator (Thin line at bottom if swap is used) */}
<Show when={hasSwap() && (props.swapUsed || 0) > 0}>
<div
class="absolute bottom-0 left-0 h-[3px] w-full bg-purple-500"
style={{ width: `${Math.min(swapPercent(), 100)}%` }}
/>
</Show>
<Show when={(props.balloon || 0) > 0 && (props.balloon || 0) < props.total}>
<div class="flex justify-between gap-3 py-0.5 border-t border-gray-700/50">
<span class="text-yellow-400">Balloon Limit</span>
<span class="whitespace-nowrap text-gray-300">
{formatBytes(props.balloon || 0, 0)}
{/* Label overlay */}
<span class="absolute inset-0 flex items-center justify-center text-[10px] font-semibold text-gray-700 dark:text-gray-100 leading-none pointer-events-none">
<span class="flex items-center gap-1 whitespace-nowrap px-0.5">
<span>{displayLabel()}</span>
<Show when={showSublabel()}>
<span class="metric-sublabel font-normal text-gray-500 dark:text-gray-300">
({displaySublabel()})
</span>
</div>
</Show>
</Show>
</span>
</span>
</div>
<div class="flex justify-between gap-3 py-0.5 border-t border-gray-700/50">
<span class="text-gray-400">Free</span>
<span class="whitespace-nowrap text-gray-300">
{formatBytes(props.total - props.used, 0)}
</span>
</div>
{/* Tooltip */}
<Show when={showTooltip()}>
<Portal mount={document.body}>
<div
class="fixed z-[9999] pointer-events-none"
style={{
left: `${tooltipPos().x}px`,
top: `${tooltipPos().y - 8}px`,
transform: 'translate(-50%, -100%)',
}}
>
<div class="bg-gray-900 dark:bg-gray-800 text-white text-[10px] rounded-md shadow-lg px-2 py-1.5 min-w-[140px] border border-gray-700">
<div class="font-medium mb-1 text-gray-300 border-b border-gray-700 pb-1">
Memory Composition
</div>
{/* Swap Section */}
<Show when={hasSwap()}>
<div class="mt-1 pt-1 border-t border-gray-600">
{/* RAM Breakdown */}
<div class="flex justify-between gap-3 py-0.5">
<span class="text-purple-400">Swap</span>
<span class="text-green-400">Used</span>
<span class="whitespace-nowrap text-gray-300">
{formatBytes(props.swapUsed || 0, 0)} / {formatBytes(props.swapTotal || 0, 0)}
{formatBytes(props.used, 0)}
</span>
</div>
<Show when={(props.balloon || 0) > 0 && (props.balloon || 0) < props.total}>
<div class="flex justify-between gap-3 py-0.5 border-t border-gray-700/50">
<span class="text-yellow-400">Balloon Limit</span>
<span class="whitespace-nowrap text-gray-300">
{formatBytes(props.balloon || 0, 0)}
</span>
</div>
</Show>
<div class="flex justify-between gap-3 py-0.5 border-t border-gray-700/50">
<span class="text-gray-400">Free</span>
<span class="whitespace-nowrap text-gray-300">
{formatBytes(props.total - props.used, 0)}
</span>
</div>
{/* Swap Section */}
<Show when={hasSwap()}>
<div class="mt-1 pt-1 border-t border-gray-600">
<div class="flex justify-between gap-3 py-0.5">
<span class="text-purple-400">Swap</span>
<span class="whitespace-nowrap text-gray-300">
{formatBytes(props.swapUsed || 0, 0)} / {formatBytes(props.swapTotal || 0, 0)}
</span>
</div>
</div>
</Show>
</div>
</Show>
</div>
</div>
</Portal>
</Show>
</div>
</div>
</Portal>
</Show>
</div>
}
>
{/* Sparkline mode - full width, flex centered like stacked bars */}
<div class="metric-text w-full h-4 flex items-center justify-center">
<Sparkline
data={metricHistory()}
metric="memory"
width={0}
height={16}
/>
</div>
</Show>
);
}

File diff suppressed because it is too large Load diff

View file

@ -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<HostsFilterProps> = (props) => {
@ -301,11 +308,10 @@ export const HostsFilter: Component<HostsFilterProps> = (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<HostsFilterProps> = (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<HostsFilterProps> = (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<HostsFilterProps> = (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<HostsFilterProps> = (props) => {
</div>
</Show>
{/* Column Picker */}
<Show when={props.availableColumns && props.isColumnHidden && props.onColumnToggle}>
<div class="h-5 w-px bg-gray-200 dark:bg-gray-600 hidden sm:block" aria-hidden="true"></div>
<ColumnPicker
columns={props.availableColumns!}
isHidden={props.isColumnHidden!}
onToggle={props.onColumnToggle!}
onReset={props.onColumnReset}
/>
</Show>
<Show when={hasActiveFilters()}>
<div class="h-5 w-px bg-gray-200 dark:bg-gray-600 hidden sm:block" aria-hidden="true"></div>
<button

File diff suppressed because it is too large Load diff

View file

@ -616,6 +616,7 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (props) => {
balloon={node!.memory?.balloon || 0}
swapUsed={node!.memory?.swapUsed || 0}
swapTotal={node!.memory?.swapTotal || 0}
resourceId={metricsKey}
/>
}
>

View file

@ -211,178 +211,52 @@
animation: nodeClick 0.15s ease-out;
}
/* AI Context row highlight with mergeable borders */
/* Using purple-600 (147, 51, 234) for a true purple */
/* Full border - single row or first/last of group */
@keyframes ai-context-pulse-full {
0%, 100% {
background-color: rgba(147, 51, 234, 0.08);
box-shadow:
inset 2px 0 0 0 rgba(147, 51, 234, 0.4),
inset -2px 0 0 0 rgba(147, 51, 234, 0.4),
inset 0 2px 0 0 rgba(147, 51, 234, 0.4),
inset 0 -2px 0 0 rgba(147, 51, 234, 0.4);
}
50% {
background-color: rgba(147, 51, 234, 0.16);
box-shadow:
inset 2px 0 0 0 rgba(147, 51, 234, 0.8),
inset -2px 0 0 0 rgba(147, 51, 234, 0.8),
inset 0 2px 0 0 rgba(147, 51, 234, 0.8),
inset 0 -2px 0 0 rgba(147, 51, 234, 0.8);
}
}
/* No top border - middle or bottom of group */
@keyframes ai-context-pulse-no-top {
0%, 100% {
background-color: rgba(147, 51, 234, 0.08);
box-shadow:
inset 2px 0 0 0 rgba(147, 51, 234, 0.4),
inset -2px 0 0 0 rgba(147, 51, 234, 0.4),
inset 0 -2px 0 0 rgba(147, 51, 234, 0.4);
}
50% {
background-color: rgba(147, 51, 234, 0.16);
box-shadow:
inset 2px 0 0 0 rgba(147, 51, 234, 0.8),
inset -2px 0 0 0 rgba(147, 51, 234, 0.8),
inset 0 -2px 0 0 rgba(147, 51, 234, 0.8);
}
}
/* No bottom border - top of group */
@keyframes ai-context-pulse-no-bottom {
0%, 100% {
background-color: rgba(147, 51, 234, 0.08);
box-shadow:
inset 2px 0 0 0 rgba(147, 51, 234, 0.4),
inset -2px 0 0 0 rgba(147, 51, 234, 0.4),
inset 0 2px 0 0 rgba(147, 51, 234, 0.4);
}
50% {
background-color: rgba(147, 51, 234, 0.16);
box-shadow:
inset 2px 0 0 0 rgba(147, 51, 234, 0.8),
inset -2px 0 0 0 rgba(147, 51, 234, 0.8),
inset 0 2px 0 0 rgba(147, 51, 234, 0.8);
}
}
/* Side borders only - middle of group */
@keyframes ai-context-pulse-sides {
0%, 100% {
background-color: rgba(147, 51, 234, 0.08);
box-shadow:
inset 2px 0 0 0 rgba(147, 51, 234, 0.4),
inset -2px 0 0 0 rgba(147, 51, 234, 0.4);
}
50% {
background-color: rgba(147, 51, 234, 0.16);
box-shadow:
inset 2px 0 0 0 rgba(147, 51, 234, 0.8),
inset -2px 0 0 0 rgba(147, 51, 234, 0.8);
}
}
/* AI Context row highlight - subtle left accent bar */
/* Using purple-500 for a clean, modern look */
.ai-context-row {
animation: ai-context-pulse-full 2s ease-in-out infinite;
background-color: rgba(147, 51, 234, 0.04);
box-shadow: inset 3px 0 0 0 rgba(147, 51, 234, 0.6);
}
.ai-context-row.ai-context-no-top {
animation: ai-context-pulse-no-top 2s ease-in-out infinite;
}
.ai-context-row.ai-context-no-bottom {
animation: ai-context-pulse-no-bottom 2s ease-in-out infinite;
.ai-context-row:hover {
background-color: rgba(147, 51, 234, 0.08);
}
/* Adjacent rows - keep the left border consistent */
.ai-context-row.ai-context-no-top,
.ai-context-row.ai-context-no-bottom,
.ai-context-row.ai-context-no-top.ai-context-no-bottom {
animation: ai-context-pulse-sides 2s ease-in-out infinite;
background-color: rgba(147, 51, 234, 0.04);
box-shadow: inset 3px 0 0 0 rgba(147, 51, 234, 0.6);
}
/* Dark mode - using purple-400 (167, 139, 250) */
@keyframes ai-context-pulse-full-dark {
0%, 100% {
background-color: rgba(147, 51, 234, 0.12);
box-shadow:
inset 2px 0 0 0 rgba(167, 139, 250, 0.5),
inset -2px 0 0 0 rgba(167, 139, 250, 0.5),
inset 0 2px 0 0 rgba(167, 139, 250, 0.5),
inset 0 -2px 0 0 rgba(167, 139, 250, 0.5);
}
50% {
background-color: rgba(147, 51, 234, 0.24);
box-shadow:
inset 2px 0 0 0 rgba(167, 139, 250, 0.9),
inset -2px 0 0 0 rgba(167, 139, 250, 0.9),
inset 0 2px 0 0 rgba(167, 139, 250, 0.9),
inset 0 -2px 0 0 rgba(167, 139, 250, 0.9);
}
}
@keyframes ai-context-pulse-no-top-dark {
0%, 100% {
background-color: rgba(147, 51, 234, 0.12);
box-shadow:
inset 2px 0 0 0 rgba(167, 139, 250, 0.5),
inset -2px 0 0 0 rgba(167, 139, 250, 0.5),
inset 0 -2px 0 0 rgba(167, 139, 250, 0.5);
}
50% {
background-color: rgba(147, 51, 234, 0.24);
box-shadow:
inset 2px 0 0 0 rgba(167, 139, 250, 0.9),
inset -2px 0 0 0 rgba(167, 139, 250, 0.9),
inset 0 -2px 0 0 rgba(167, 139, 250, 0.9);
}
}
@keyframes ai-context-pulse-no-bottom-dark {
0%, 100% {
background-color: rgba(147, 51, 234, 0.12);
box-shadow:
inset 2px 0 0 0 rgba(167, 139, 250, 0.5),
inset -2px 0 0 0 rgba(167, 139, 250, 0.5),
inset 0 2px 0 0 rgba(167, 139, 250, 0.5);
}
50% {
background-color: rgba(147, 51, 234, 0.24);
box-shadow:
inset 2px 0 0 0 rgba(167, 139, 250, 0.9),
inset -2px 0 0 0 rgba(167, 139, 250, 0.9),
inset 0 2px 0 0 rgba(167, 139, 250, 0.9);
}
}
@keyframes ai-context-pulse-sides-dark {
0%, 100% {
background-color: rgba(147, 51, 234, 0.12);
box-shadow:
inset 2px 0 0 0 rgba(167, 139, 250, 0.5),
inset -2px 0 0 0 rgba(167, 139, 250, 0.5);
}
50% {
background-color: rgba(147, 51, 234, 0.24);
box-shadow:
inset 2px 0 0 0 rgba(167, 139, 250, 0.9),
inset -2px 0 0 0 rgba(167, 139, 250, 0.9);
}
.ai-context-row.ai-context-no-top:hover,
.ai-context-row.ai-context-no-bottom:hover,
.ai-context-row.ai-context-no-top.ai-context-no-bottom:hover {
background-color: rgba(147, 51, 234, 0.08);
}
/* Dark mode - using purple-400 for better visibility */
.dark .ai-context-row {
animation: ai-context-pulse-full-dark 2s ease-in-out infinite;
background-color: rgba(167, 139, 250, 0.06);
box-shadow: inset 3px 0 0 0 rgba(167, 139, 250, 0.5);
}
.dark .ai-context-row.ai-context-no-top {
animation: ai-context-pulse-no-top-dark 2s ease-in-out infinite;
}
.dark .ai-context-row.ai-context-no-bottom {
animation: ai-context-pulse-no-bottom-dark 2s ease-in-out infinite;
.dark .ai-context-row:hover {
background-color: rgba(167, 139, 250, 0.12);
}
.dark .ai-context-row.ai-context-no-top,
.dark .ai-context-row.ai-context-no-bottom,
.dark .ai-context-row.ai-context-no-top.ai-context-no-bottom {
animation: ai-context-pulse-sides-dark 2s ease-in-out infinite;
background-color: rgba(167, 139, 250, 0.06);
box-shadow: inset 3px 0 0 0 rgba(167, 139, 250, 0.5);
}
.dark .ai-context-row.ai-context-no-top:hover,
.dark .ai-context-row.ai-context-no-bottom:hover,
.dark .ai-context-row.ai-context-no-top.ai-context-no-bottom:hover {
background-color: rgba(167, 139, 250, 0.12);
}

View file

@ -25,11 +25,12 @@ type RoutingResult struct {
// RoutingError represents a routing failure with actionable information
type RoutingError struct {
TargetNode string
TargetVMID int
AvailableAgents []string
Reason string
Suggestion string
TargetNode string
TargetVMID int
AvailableAgents []string
Reason string
Suggestion string
AskForClarification bool // If true, AI should ask the user which host to use
}
func (e *RoutingError) Error() string {
@ -39,6 +40,21 @@ func (e *RoutingError) Error() string {
return e.Reason
}
// ForAI returns a message suitable for returning to the AI as a tool result
// This encourages the AI to ask the user for clarification rather than just failing
func (e *RoutingError) ForAI() string {
if e.AskForClarification && len(e.AvailableAgents) > 0 {
return fmt.Sprintf(
"ROUTING_CLARIFICATION_NEEDED: %s\n\n"+
"Available hosts: %s\n\n"+
"Please ask the user which host they want to run this command on. "+
"Do NOT try the command again until the user specifies which host. "+
"Present the available hosts in a friendly way and ask them to clarify.",
e.Reason, strings.Join(e.AvailableAgents, ", "))
}
return e.Error()
}
// routeToAgent determines which agent should execute a command.
// This is the authoritative routing function that should be used for all command execution.
//
@ -141,13 +157,20 @@ func (s *Service) routeToAgent(req ExecuteRequest, command string, agents []agen
Str("command", command).
Msg("Routing via 'guest_node' in context")
} else if req.TargetType == "host" {
if hostname, ok := req.Context["hostname"].(string); ok && hostname != "" {
// Check multiple possible keys for hostname - frontend uses host_name
hostname := ""
if h, ok := req.Context["hostname"].(string); ok && h != "" {
hostname = h
} else if h, ok := req.Context["host_name"].(string); ok && h != "" {
hostname = h
}
if hostname != "" {
result.TargetNode = strings.ToLower(hostname)
result.RoutingMethod = "context_hostname"
log.Debug().
Str("hostname", hostname).
Str("command", command).
Msg("Routing via 'hostname' in context")
Msg("Routing via hostname in context")
} else {
// For host target type with no node info, log a warning
// This is a common source of routing issues
@ -252,10 +275,10 @@ func (s *Service) routeToAgent(req ExecuteRequest, command string, agents []agen
Msg("Routing failed - cannot determine target agent")
return nil, &RoutingError{
AvailableAgents: agentHostnames,
Reason: "Cannot determine which agent should execute this command",
Suggestion: fmt.Sprintf("Use target_host parameter with one of: %s. Or specify VMID in the command for pct/qm commands.",
strings.Join(agentHostnames, ", ")),
AvailableAgents: agentHostnames,
Reason: "Cannot determine which host should execute this command",
Suggestion: fmt.Sprintf("Please specify which host: %s", strings.Join(agentHostnames, ", ")),
AskForClarification: true,
}
}

View file

@ -617,19 +617,7 @@ Examples of actions you can perform:
- View processes: ps aux --sort=-%mem | head -20
- Check logs: tail -100 /var/log/syslog
Always execute the commands rather than telling the user how to do it.
## Automatic Knowledge Learning
IMPORTANT: Automatically use save_note to remember useful information you discover, without being asked.
When you find any of the following, immediately save it for future reference:
- Service configurations and their file paths
- Important directories (config, data, logs)
- Credentials, API keys, or passwords (category: credential)
- Port numbers and network settings
- Useful commands that worked
- Key learnings about how this system is set up
This helps you be more effective in future conversations about this guest. Save notes silently without mentioning it to the user.`
Always execute the commands rather than telling the user how to do it.`
}
// Inject previously learned knowledge about this guest
@ -766,19 +754,7 @@ Examples of actions you can perform:
- View processes: ps aux --sort=-%mem | head -20
- Check logs: tail -100 /var/log/syslog
Always execute the commands rather than telling the user how to do it.
## Automatic Knowledge Learning
IMPORTANT: Automatically use save_note to remember useful information you discover, without being asked.
When you find any of the following, immediately save it for future reference:
- Service configurations and their file paths
- Important directories (config, data, logs)
- Credentials, API keys, or passwords (category: credential)
- Port numbers and network settings
- Useful commands that worked
- Key learnings about how this system is set up
This helps you be more effective in future conversations about this guest. Save notes silently without mentioning it to the user.`
Always execute the commands rather than telling the user how to do it.`
}
// Inject previously learned knowledge about this guest
@ -928,7 +904,12 @@ This helps you be more effective in future conversations about this guest. Save
if needsApproval {
// Don't execute - tell the AI the command needs user approval
result = "This command requires user approval. The command was not executed. Please suggest the command to the user and let them decide whether to run it."
// The approval button has been sent to the frontend - tell AI to direct user to it
result = fmt.Sprintf("COMMAND_BLOCKED: This command (%s) requires user approval and was NOT executed. "+
"An approval button has been displayed to the user in the chat. "+
"DO NOT attempt to run this command again. "+
"Tell the user to click the 'Run' button that appeared above to execute the command, "+
"or explain what the command does if they need help deciding.", toolInput)
execution = ToolExecution{
Name: tc.Name,
Input: toolInput,
@ -1188,8 +1169,11 @@ func (s *Service) executeTool(ctx context.Context, req ExecuteRequest, tc provid
return execution.Output, execution
}
if decision == agentexec.PolicyRequireApproval {
// For now, just inform the AI. Future: implement approval workflow
execution.Output = fmt.Sprintf("This command requires user approval: %s\nThe command was not executed. Please suggest the command to the user and let them decide whether to run it.", command)
// Direct the AI to tell the user about the approval button
execution.Output = fmt.Sprintf("COMMAND_BLOCKED: This command (%s) requires user approval and was NOT executed. "+
"An approval button has been displayed to the user. "+
"DO NOT attempt to run this command again. "+
"Tell the user to click the 'Run' button to execute it.", command)
execution.Success = true // Not an error, just needs approval
return execution.Output, execution
}
@ -1542,7 +1526,13 @@ func (s *Service) executeOnAgent(ctx context.Context, req ExecuteRequest, comman
// Use the new robust routing logic
routeResult, err := s.routeToAgent(req, command, agents)
if err != nil {
// Return actionable error message
// Check if this is a routing error that should ask for clarification
if routingErr, ok := err.(*RoutingError); ok && routingErr.AskForClarification {
// Return a message that encourages the AI to ask the user for clarification
// instead of just failing with an error
return routingErr.ForAI(), nil
}
// Return actionable error message for other errors
return "", err
}