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:
parent
f2e6927436
commit
721f973271
12 changed files with 1674 additions and 1599 deletions
|
|
@ -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,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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()}
|
||||
/>
|
||||
}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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
|
|
@ -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}
|
||||
/>
|
||||
}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue