feat(ui): restore Docker and dashboard improvements

Recovered from git dangling commits (codex snapshots):
- DockerHostSummaryTable: Add StackedContainerBar for container counts
- DockerUnifiedTable: Column width improvements, AI integration
- GuestDrawer: Improved controls layout and styling
- GuestRow: Minor improvements
- StackedDiskBar: Refactored implementation
- HostsOverview: Layout updates
- NodeSummaryTable: Improvements
- animations.css: Add AI context row highlight styling
- types/nodes.ts: Additional type definitions
This commit is contained in:
rcourtman 2026-01-22 13:53:02 +00:00
parent 8412cc7ddb
commit ffbcefee44
9 changed files with 421 additions and 327 deletions

View file

@ -314,13 +314,13 @@ export const GuestDrawer: Component<GuestDrawerProps> = (props) => {
<Show when={activeTab() === 'history'}>
<div class="space-y-6">
{/* Toolbar: Range and View Toggle */}
<div class="flex flex-wrap items-center gap-2 bg-gray-50 dark:bg-gray-800/50 p-2 rounded-lg border border-gray-100 dark:border-gray-700/50 shadow-sm">
<div class="flex items-center gap-2">
<span class="text-[10px] font-semibold text-gray-400 uppercase tracking-widest">View</span>
<div class="flex bg-gray-100 dark:bg-gray-700 rounded-md p-0.5">
<div class="flex flex-col gap-4 bg-gray-50 dark:bg-gray-800/50 p-3 rounded-xl border border-gray-100 dark:border-gray-700/50 shadow-sm">
<div class="flex items-center justify-between">
<span class="text-xs font-bold text-gray-400 uppercase tracking-widest">Controls</span>
<div class="flex bg-gray-100 dark:bg-gray-700 rounded-lg p-0.5">
<button
onClick={() => setViewMode('unified')}
class={`px-2 py-0.5 text-[10px] font-semibold rounded-md transition-all ${viewMode() === 'unified'
class={`px-3 py-1 text-[10px] font-bold rounded-md transition-all ${viewMode() === 'unified'
? 'bg-white dark:bg-gray-600 text-blue-600 dark:text-blue-400 shadow-sm'
: 'text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'
}`}
@ -329,7 +329,7 @@ export const GuestDrawer: Component<GuestDrawerProps> = (props) => {
</button>
<button
onClick={() => setViewMode('split')}
class={`px-2 py-0.5 text-[10px] font-semibold rounded-md transition-all ${viewMode() === 'split'
class={`px-3 py-1 text-[10px] font-bold rounded-md transition-all ${viewMode() === 'split'
? 'bg-white dark:bg-gray-600 text-blue-600 dark:text-blue-400 shadow-sm'
: 'text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'
}`}
@ -339,13 +339,13 @@ export const GuestDrawer: Component<GuestDrawerProps> = (props) => {
</div>
</div>
<div class="flex items-center gap-2 sm:ml-auto">
<span class="text-[10px] font-semibold text-gray-400 uppercase tracking-widest">Range</span>
<div class="flex bg-gray-100 dark:bg-gray-700 rounded-md p-0.5">
<div class="flex items-center justify-between pt-3 border-t border-gray-100 dark:border-gray-700/30">
<span class="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">Range</span>
<div class="flex bg-gray-100 dark:bg-gray-700 rounded-lg p-0.5">
{(['24h', '7d', '30d', '90d'] as HistoryTimeRange[]).map(r => (
<button
onClick={() => setHistoryRange(r)}
class={`px-2.5 py-1 text-[10px] font-semibold rounded-md transition-all ${historyRange() === r
class={`px-4 py-1.5 text-xs font-medium rounded-md transition-all ${historyRange() === r
? 'bg-white dark:bg-gray-600 text-blue-600 dark:text-blue-400 shadow-sm'
: 'text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'
}`}

View file

@ -449,7 +449,7 @@ export const GUEST_COLUMNS: GuestColumnDef[] = [
{ id: 'node', label: 'Node', icon: <svg class="w-3.5 h-3.5 block" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01" /></svg>, priority: 'essential', width: '55px', toggleable: true, sortKey: 'node' },
// Supplementary - visible on lg+ (Now essential), user toggleable
{ id: 'backup', label: 'Backup', icon: <svg class="w-3.5 h-3.5 block" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" /></svg>, priority: 'essential', width: '90px', toggleable: true },
{ id: 'backup', label: 'Backup', icon: <svg class="w-3.5 h-3.5 block" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" /></svg>, priority: 'essential', width: '50px', toggleable: true },
{ id: 'tags', label: 'Tags', icon: <svg class="w-3.5 h-3.5 block" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z" /></svg>, priority: 'essential', width: '60px', toggleable: true },
// Detailed - visible on xl+ (Now essential), user toggleable
@ -484,7 +484,6 @@ interface GuestRowProps {
isGroupedView?: boolean;
/** IDs of columns that should be visible */
visibleColumnIds?: string[];
onClick?: () => void;
}
export function GuestRow(props: GuestRowProps) {
@ -766,7 +765,6 @@ export function GuestRow(props: GuestRowProps) {
class={rowClass()}
style={rowStyle()}
data-guest-id={guestId()}
onClick={() => props.onClick?.()}
>
{/* Name - always visible */}
<td class={`pr-2 py-1 align-middle whitespace-nowrap ${props.isGroupedView ? GROUPED_FIRST_CELL_INDENT : DEFAULT_FIRST_CELL_INDENT}`}>

View file

@ -9,8 +9,6 @@ interface StackedDiskBarProps {
disks?: Disk[];
/** Aggregate disk data (fallback when disks array unavailable) */
aggregateDisk?: Disk;
/** Display mode for multi-disk hosts */
mode?: 'stacked' | 'aggregate' | 'mini';
/** Baseline anomaly if detected */
anomaly?: AnomalyReport | null;
}
@ -80,9 +78,10 @@ export function StackedDiskBar(props: StackedDiskBarProps) {
return disks && disks.length > 1;
});
const aggregateMode = createMemo(() => props.mode === 'aggregate');
const miniMode = createMemo(() => props.mode === 'mini');
const useStackedSegments = createMemo(() => hasMultipleDisks() && !aggregateMode() && !miniMode());
const hasSingleDisk = createMemo(() => {
const disks = props.disks;
return disks && disks.length === 1;
});
// Calculate total capacity across all disks
const totalCapacity = createMemo(() => {
@ -105,11 +104,23 @@ export function StackedDiskBar(props: StackedDiskBarProps) {
return (totalUsed() / total) * 100;
});
const barPercent = createMemo(() => Math.min(overallPercent(), 100));
// For single disk or aggregate, get the percentage
const singleDiskPercent = createMemo(() => {
if (hasSingleDisk() && props.disks?.[0]) {
const disk = props.disks[0];
if (!disk.total || disk.total <= 0) return 0;
return (disk.used / disk.total) * 100;
}
if (props.aggregateDisk) {
if (!props.aggregateDisk.total || props.aggregateDisk.total <= 0) return 0;
return (props.aggregateDisk.used / props.aggregateDisk.total) * 100;
}
return 0;
});
// Calculate segment widths for stacked bar (each disk's used space as % of total capacity)
const segments = createMemo(() => {
if (!useStackedSegments() || !props.disks) return [];
if (!hasMultipleDisks() || !props.disks) return [];
const total = totalCapacity();
if (total <= 0) return [];
@ -136,58 +147,8 @@ export function StackedDiskBar(props: StackedDiskBarProps) {
return props.disks && props.disks.length > 0;
});
const miniDisks = createMemo(() => {
if (!props.disks) return [];
return props.disks.map((disk, idx) => {
const percent = disk.total > 0 ? (disk.used / disk.total) * 100 : 0;
const label = disk.mountpoint || disk.device || `Disk ${idx + 1}`;
return {
label,
percent,
color: getUsageColor(percent),
};
});
});
const maxDiskInfo = createMemo(() => {
if (!props.disks || props.disks.length === 0) return null;
let maxPercent = -1;
let maxLabel = '';
for (const disk of props.disks) {
const percent = disk.total > 0 ? (disk.used / disk.total) * 100 : 0;
if (percent > maxPercent) {
maxPercent = percent;
maxLabel = disk.mountpoint || disk.device || 'Disk';
}
}
if (maxPercent < 0) return null;
return { percent: maxPercent, label: maxLabel };
});
const maxLabelShort = createMemo(() => {
const info = maxDiskInfo();
if (!info) return '';
return `max ${formatPercent(info.percent)}`;
});
const maxLabelFull = createMemo(() => {
const info = maxDiskInfo();
if (!info) return '';
if (info.label) return `Max ${formatPercent(info.percent)} (${info.label})`;
return `Max ${formatPercent(info.percent)}`;
});
const barColor = createMemo(() => {
const info = maxDiskInfo();
if (aggregateMode() && hasMultipleDisks() && info) {
return getUsageColor(info.percent);
}
return getUsageColor(overallPercent());
});
// Generate tooltip content
const tooltipContent = createMemo(() => {
const useUsageColors = aggregateMode() || miniMode();
if (hasDisks() && props.disks) {
return props.disks.map((disk, idx) => {
const percent = disk.total > 0 ? (disk.used / disk.total) * 100 : 0;
@ -197,13 +158,9 @@ export function StackedDiskBar(props: StackedDiskBarProps) {
used: formatBytes(disk.used, 0),
total: formatBytes(disk.total, 0),
percent: formatPercent(percent),
color: useUsageColors
? getUsageColor(percent)
: percent >= 90
? getUsageColor(90)
: percent >= 80
? getUsageColor(80)
: SEGMENT_COLORS[idx % SEGMENT_COLORS.length],
color: percent >= 90 ? getUsageColor(90) :
percent >= 80 ? getUsageColor(80) :
SEGMENT_COLORS[idx % SEGMENT_COLORS.length],
};
});
}
@ -223,7 +180,10 @@ export function StackedDiskBar(props: StackedDiskBarProps) {
// Label for the bar
const displayLabel = createMemo(() => {
return formatPercent(overallPercent());
if (hasMultipleDisks()) {
return formatPercent(overallPercent());
}
return formatPercent(singleDiskPercent());
});
// Sublabel showing used/total
@ -231,27 +191,12 @@ export function StackedDiskBar(props: StackedDiskBarProps) {
return `${formatBytes(totalUsed(), 0)}/${formatBytes(totalCapacity(), 0)}`;
});
const showMaxLabel = createMemo(() => {
if (!aggregateMode() || !hasMultipleDisks()) return false;
const shortLabel = maxLabelShort();
if (!shortLabel) return false;
const fullText = `${displayLabel()} ${shortLabel}`;
return containerWidth() >= estimateTextWidth(fullText);
});
// Check if sublabel fits
const showSublabel = createMemo(() => {
const baseText = `${displayLabel()}${showMaxLabel() ? ` ${maxLabelShort()}` : ''}`;
const fullText = `${baseText} (${displaySublabel()})`;
const fullText = `${displayLabel()} (${displaySublabel()})`;
return containerWidth() >= estimateTextWidth(fullText);
});
const containerClass = createMemo(() => {
return miniMode() && hasDisks()
? 'metric-text w-full'
: 'metric-text w-full h-4 flex items-center justify-center';
});
const handleMouseEnter = (e: MouseEvent) => {
if (tooltipContent().length > 0) {
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
@ -265,108 +210,67 @@ export function StackedDiskBar(props: StackedDiskBarProps) {
};
return (
<div ref={containerRef} class={containerClass()}>
<Show
when={miniMode() && hasDisks()}
fallback={
<div
class="relative w-full h-full overflow-hidden bg-gray-200 dark:bg-gray-600 rounded"
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
{/* Stacked segments for multiple disks */}
<Show when={useStackedSegments()}>
<div class="absolute top-0 left-0 h-full w-full flex">
<For each={segments()}>
{(segment, idx) => (
<div
class="h-full"
style={{
width: `${segment.widthPercent}%`,
'background-color': segment.color,
'border-right': idx() < segments().length - 1 ? '1px solid rgba(255,255,255,0.3)' : 'none',
}}
/>
)}
</For>
</div>
</Show>
{/* Single bar for aggregate or single disk */}
<Show when={!useStackedSegments()}>
<div
class="absolute top-0 left-0 h-full"
style={{
width: `${barPercent()}%`,
'background-color': barColor(),
}}
/>
</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">
<span class="flex items-center gap-1 whitespace-nowrap px-0.5">
<span>{displayLabel()}</span>
<Show when={showMaxLabel()}>
<span
class="text-[8px] font-normal text-gray-500 dark:text-gray-400"
title={maxLabelFull()}
>
{maxLabelShort()}
</span>
</Show>
<Show when={showSublabel()}>
<span class="metric-sublabel font-normal text-gray-500 dark:text-gray-300">
({displaySublabel()})
</span>
</Show>
<Show when={useStackedSegments()}>
<span class="text-[8px] font-normal text-gray-500 dark:text-gray-400">
[{props.disks?.length}]
</span>
</Show>
{/* Anomaly indicator */}
<Show when={props.anomaly && anomalyRatio()}>
<span
class={`ml-0.5 font-bold animate-pulse ${anomalySeverityClass[props.anomaly!.severity] || 'text-yellow-400'}`}
title={props.anomaly!.description}
>
{anomalyRatio()}
</span>
</Show>
</span>
</span>
</div>
}
<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"
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
<div class="w-full" onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave}>
<div
class="grid gap-1"
style={{
'grid-template-columns': `repeat(${miniDisks().length}, minmax(0, 1fr))`,
}}
>
<For each={miniDisks()}>
{(disk) => (
<div class="flex flex-col items-stretch gap-0.5">
<span class="text-[8px] text-gray-500 dark:text-gray-400 truncate" title={disk.label}>
{disk.label}
</span>
<div class="relative h-2.5 rounded-sm bg-gray-300/70 dark:bg-gray-500/70 overflow-hidden">
<div
class="h-full"
style={{
width: `${Math.min(disk.percent, 100)}%`,
'background-color': disk.color,
}}
/>
</div>
</div>
{/* Stacked segments for multiple disks */}
<Show when={hasMultipleDisks()}>
<div class="absolute top-0 left-0 h-full w-full flex">
<For each={segments()}>
{(segment, idx) => (
<div
class="h-full"
style={{
width: `${segment.widthPercent}%`,
'background-color': segment.color,
'border-right': idx() < segments().length - 1 ? '1px solid rgba(255,255,255,0.3)' : 'none',
}}
/>
)}
</For>
</div>
</div>
</Show>
</Show>
{/* Single bar for single disk or aggregate */}
<Show when={!hasMultipleDisks()}>
<div
class="absolute top-0 left-0 h-full"
style={{
width: `${Math.min(singleDiskPercent(), 100)}%`,
'background-color': getUsageColor(singleDiskPercent()),
}}
/>
</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">
<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>
<Show when={hasMultipleDisks()}>
<span class="text-[8px] font-normal text-gray-500 dark:text-gray-400">
[{props.disks?.length}]
</span>
</Show>
{/* Anomaly indicator */}
<Show when={props.anomaly && anomalyRatio()}>
<span
class={`ml-0.5 font-bold animate-pulse ${anomalySeverityClass[props.anomaly!.severity] || 'text-yellow-400'}`}
title={props.anomaly!.description}
>
{anomalyRatio()}
</span>
</Show>
</span>
</span>
</div>
{/* Tooltip for disk breakdown */}
<Show when={showTooltip() && tooltipContent().length > 0}>
@ -385,27 +289,16 @@ export function StackedDiskBar(props: StackedDiskBarProps) {
</div>
<For each={tooltipContent()}>
{(item, idx) => (
<div class="flex flex-col gap-1 py-0.5" classList={{ 'border-t border-gray-700/50': idx() > 0 }}>
<div class="flex justify-between gap-3">
<span
class="truncate max-w-[100px]"
style={{ color: item.color }}
>
{item.label}
</span>
<span class="whitespace-nowrap text-gray-300">
{item.percent} ({item.used}/{item.total})
</span>
</div>
<div class="h-1.5 w-full rounded bg-gray-700/70 overflow-hidden">
<div
class="h-full"
style={{
width: item.percent,
'background-color': item.color,
}}
/>
</div>
<div class="flex justify-between gap-3 py-0.5" classList={{ 'border-t border-gray-700/50': idx() > 0 }}>
<span
class="truncate max-w-[100px]"
style={{ color: item.color }}
>
{item.label}
</span>
<span class="whitespace-nowrap text-gray-300">
{item.percent} ({item.used}/{item.total})
</span>
</div>
)}
</For>

View file

@ -1,4 +1,5 @@
import { Component, For, Show, createMemo, createSignal } from 'solid-js';
import { StackedContainerBar } from './StackedContainerBar';
import type { DockerHost } from '@/types/api';
import { Card } from '@/components/shared/Card';
import { renderDockerStatusBadge } from './DockerStatusBadge';
@ -429,9 +430,12 @@ export const DockerHostSummaryTable: Component<DockerHostSummaryTableProps> = (p
when={summary.totalCount > 0}
fallback={<span class="text-xs text-gray-400 dark:text-gray-500"></span>}
>
<span class="text-xs font-semibold text-gray-700 dark:text-gray-200">
{summary.totalCount}
</span>
<StackedContainerBar
running={summary.runningCount}
stopped={summary.stoppedCount}
error={summary.errorCount}
total={summary.totalCount}
/>
</Show>
</div>
</td>

View file

@ -10,6 +10,8 @@ import type { DockerHostMetadata } from '@/api/dockerHostMetadata';
import { resolveHostRuntime } from './runtimeDisplay';
import { showSuccess, showError } from '@/utils/toast';
import { logger } from '@/utils/logger';
import { aiChatStore } from '@/stores/aiChat';
import { AIAPI } from '@/api/ai';
import { buildMetricKey } from '@/utils/metricsKeys';
import { StatusDot } from '@/components/shared/StatusDot';
import {
@ -42,15 +44,6 @@ const typeBadgeClass = (type: 'container' | 'service' | 'task' | 'unknown') => {
}
};
// Extract just the image name + tag from a full image path
// e.g., "ghcr.io/org/image-name:tag" → "image-name:tag"
const getShortImageName = (fullImage: string | undefined): string => {
if (!fullImage) return '—';
// Get everything after the last slash
const lastSlash = fullImage.lastIndexOf('/');
return lastSlash >= 0 ? fullImage.slice(lastSlash + 1) : fullImage;
};
type StatsFilter =
| { type: 'host-status'; value: string }
| { type: 'container-state'; value: string }
@ -139,9 +132,9 @@ interface DockerColumnDef extends ColumnConfig {
// - supplementary: Visible on large screens and up (lg: 1024px+)
// - detailed: Visible on extra large screens and up (xl: 1280px+)
export const DOCKER_COLUMNS: DockerColumnDef[] = [
{ id: 'resource', label: 'Resource', priority: 'essential', minWidth: 'auto', flex: 1, sortKey: 'resource', width: '18%' },
{ id: 'resource', label: 'Resource', priority: 'essential', minWidth: 'auto', flex: 1, sortKey: 'resource', width: '25%' },
{ id: 'type', label: 'Type', priority: 'essential', minWidth: 'auto', maxWidth: 'auto', sortKey: 'type', width: '70px' },
{ id: 'image', label: 'Image / Stack', priority: 'essential', minWidth: '80px', maxWidth: '200px', sortKey: 'image', width: '12%' },
{ id: 'image', label: 'Image / Stack', priority: 'essential', minWidth: '80px', maxWidth: '200px', sortKey: 'image', width: '15%' },
{ id: 'status', label: 'Status', priority: 'essential', minWidth: 'auto', maxWidth: 'auto', sortKey: 'status', width: '90px' },
// Metric columns - need fixed width to match progress bar max-width (140px + padding)
// Note: Disk column removed - Docker API rarely provides this data
@ -809,6 +802,8 @@ const DockerContainerRow: Component<{
onCustomUrlUpdate?: (resourceId: string, url: string) => void;
showHostContext?: boolean;
resourceIndentClass?: string;
aiEnabled?: boolean;
initialNotes?: string[];
batchUpdateState?: Record<string, 'updating' | 'queued' | 'error'>;
}> = (props) => {
const { host, container } = props.row;
@ -836,6 +831,79 @@ const DockerContainerRow: Component<{
return props.batchUpdateState[key];
});
// Annotations state - use props passed from parent to avoid per-row API calls
const aiEnabled = () => props.aiEnabled ?? false;
const [annotations, setAnnotations] = createSignal<string[]>(props.initialNotes ?? []);
const [newAnnotation, setNewAnnotation] = createSignal('');
const [saving, setSaving] = createSignal(false);
// Update annotations if props change (e.g., parent re-fetches metadata)
createEffect(() => {
const notes = props.initialNotes;
if (notes && Array.isArray(notes)) {
setAnnotations(notes);
}
});
const saveAnnotations = async (newAnnotations: string[]) => {
setSaving(true);
try {
await DockerMetadataAPI.updateMetadata(resourceId(), { notes: newAnnotations });
logger.debug('[DockerContainer] Annotations saved');
} catch (err) {
logger.error('[DockerContainer] Failed to save annotations:', err);
} finally {
setSaving(false);
}
};
const addAnnotation = () => {
const text = newAnnotation().trim();
if (!text) return;
const updated = [...annotations(), text];
setAnnotations(updated);
setNewAnnotation('');
saveAnnotations(updated);
};
const removeAnnotation = (index: number) => {
const updated = annotations().filter((_, i) => i !== index);
setAnnotations(updated);
saveAnnotations(updated);
};
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
addAnnotation();
}
};
const buildContainerContext = () => {
const ctx: Record<string, unknown> = {
name: container.name,
type: 'Docker Container',
host: host.hostname,
status: container.status || container.state,
image: container.image,
};
if (container.cpuPercent !== undefined) ctx.cpu_usage = formatPercent(container.cpuPercent);
if (container.memoryUsageBytes !== undefined) ctx.memory_used = formatBytes(container.memoryUsageBytes);
if (container.memoryLimitBytes !== undefined) ctx.memory_limit = formatBytes(container.memoryLimitBytes);
if (container.memoryPercent !== undefined) ctx.memory_usage = formatPercent(container.memoryPercent);
if (container.uptimeSeconds) ctx.uptime = formatUptime(container.uptimeSeconds);
if (container.ports?.length) ctx.ports = container.ports.map(p => p.publicPort ? `${p.publicPort}:${p.privatePort}/${p.protocol}` : `${p.privatePort}/${p.protocol}`);
if (annotations().length > 0) ctx.user_notes = annotations().join('; ');
return ctx;
};
const handleAskAI = () => {
aiChatStore.openForTarget('container', resourceId(), {
containerName: container.name,
...buildContainerContext(),
});
};
const writableLayerBytes = createMemo(() => container.writableLayerBytes ?? 0);
const rootFilesystemBytes = createMemo(() => container.rootFilesystemBytes ?? 0);
const hasDiskStats = createMemo(() => writableLayerBytes() > 0 || rootFilesystemBytes() > 0);
@ -1140,15 +1208,15 @@ const DockerContainerRow: Component<{
<div class="flex-1 min-w-0 truncate">
<div class="flex items-center gap-1.5 flex-1 min-w-0 group/name">
<span
class="text-sm font-semibold text-gray-900 dark:text-gray-100 select-none truncate min-w-[60px] flex-shrink"
class="text-sm font-semibold text-gray-900 dark:text-gray-100 select-none truncate"
title={containerTitle()}
>
{container.name || container.id}
</span>
<Show when={podName()}>
{(name) => (
<span class="inline-flex items-center gap-0.5 rounded bg-purple-100 px-1 py-0.5 text-[10px] font-medium text-purple-700 dark:bg-purple-900/40 dark:text-purple-200 flex-shrink-0 max-w-[90px]" title={`Pod: ${name()}`}>
<span class="truncate">{name()}</span>
<span class="inline-flex items-center gap-1 rounded bg-purple-100 px-1.5 py-0.5 text-[10px] font-medium text-purple-700 dark:bg-purple-900/40 dark:text-purple-200 flex-shrink-0">
Pod: {name()}
<Show when={isPodInfra()}>
<span class="rounded bg-purple-200 px-1 py-0.5 text-[9px] uppercase text-purple-800 dark:bg-purple-800/50 dark:text-purple-200 ml-1">
infra
@ -1184,7 +1252,7 @@ const DockerContainerRow: Component<{
</button>
<Show when={props.showHostContext}>
<span
class="inline-flex items-center gap-0.5 rounded bg-gray-100 px-1 py-0.5 text-[10px] font-medium text-gray-600 dark:bg-gray-800 dark:text-gray-300 flex-shrink-0 max-w-[80px]"
class="inline-flex items-center gap-1 rounded bg-gray-100 px-1.5 py-0.5 text-[10px] font-medium text-gray-600 dark:bg-gray-800 dark:text-gray-300 flex-shrink-0 max-w-[120px]"
title={`Host: ${hostDisplayName()}`}
>
<StatusDot variant={hostStatus().variant} title={hostStatus().label} ariaLabel={hostStatus().label} size="xs" />
@ -1216,7 +1284,7 @@ const DockerContainerRow: Component<{
class="truncate"
title={container.image || undefined}
>
{getShortImageName(container.image)}
{container.image || '—'}
</span>
<UpdateButton
updateStatus={container.updateStatus}
@ -1720,10 +1788,71 @@ const DockerContainerRow: Component<{
</div>
</Show>
{/* Annotations & Ask AI row */}
<Show when={aiEnabled()}>
<div class="mt-3 pt-3 border-t border-gray-200 dark:border-gray-700 w-full space-y-2">
<div class="flex items-center gap-1.5">
<span class="text-[10px] font-medium text-gray-500 dark:text-gray-400">AI Context</span>
<Show when={saving()}>
<span class="text-[9px] text-gray-400">saving...</span>
</Show>
</div>
{/* Existing annotations */}
<Show when={annotations().length > 0}>
<div class="flex flex-wrap gap-1.5">
<For each={annotations()}>
{(annotation, index) => (
<span class="inline-flex items-center gap-1 px-2 py-1 text-[11px] rounded-md bg-purple-100 text-purple-800 dark:bg-purple-900/40 dark:text-purple-200">
<span class="max-w-[300px] truncate">{annotation}</span>
<button
type="button"
onClick={() => removeAnnotation(index())}
class="ml-0.5 p-0.5 rounded hover:bg-purple-200 dark:hover:bg-purple-800 transition-colors"
title="Remove"
>
<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="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</span>
)}
</For>
</div>
</Show>
{/* Add new annotation */}
<div class="flex items-center gap-2">
<input
type="text"
value={newAnnotation()}
onInput={(e) => setNewAnnotation(e.currentTarget.value)}
onKeyDown={handleKeyDown}
placeholder="Add context for AI (press Enter)..."
class="flex-1 px-2 py-1.5 text-[11px] rounded border border-gray-200 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-200 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-1 focus:ring-purple-500 focus:border-purple-500"
/>
<button
type="button"
onClick={addAnnotation}
disabled={!newAnnotation().trim()}
class="px-2 py-1.5 text-[11px] rounded border border-purple-300 dark:border-purple-600 text-purple-700 dark:text-purple-300 hover:bg-purple-50 dark:hover:bg-purple-900/30 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
Add
</button>
<button
type="button"
onClick={handleAskAI}
class="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-gradient-to-r from-purple-500 to-pink-500 text-white text-[11px] font-medium shadow-sm hover:from-purple-600 hover:to-pink-600 transition-all"
title={`Ask AI about ${container.name}`}
>
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.75 3.104v5.714a2.25 2.25 0 01-.659 1.591L5 14.5M9.75 3.104c-.251.023-.501.05-.75.082m.75-.082a24.301 24.301 0 014.5 0m0 0v5.714c0 .597.237 1.17.659 1.591L19.8 15.3M14.25 3.104c.251.023.501.05.75.082M19.8 15.3l-1.57.393A9.065 9.065 0 0112 15a9.065 9.065 0 00-6.23.693L5 14.5m14.8.8l1.402 1.402c1.232 1.232.65 3.318-1.067 3.611l-2.576.43a18.003 18.003 0 01-5.118 0l-2.576-.43c-1.717-.293-2.299-2.379-1.067-3.611L5 14.5" />
</svg>
Ask AI
</button>
</div>
</div>
</Show>
</div>
</div>
</td>
@ -2032,15 +2161,11 @@ const DockerServiceRow: Component<{
);
case 'image':
return (
<div class="px-2 py-0.5 text-xs text-gray-700 dark:text-gray-300 overflow-hidden">
<div class="flex items-center gap-1.5 min-w-0">
<span
class="truncate"
title={service.image || undefined}
>
{getShortImageName(service.image)}
</span>
</div>
<div
class="px-2 py-0.5 text-xs text-gray-700 dark:text-gray-300 truncate max-w-[200px]"
title={service.image || undefined}
>
{service.image || '—'}
</div>
);
case 'status':
@ -2262,6 +2387,16 @@ const DockerUnifiedTable: Component<DockerUnifiedTableProps> = (props) => {
// Use the breakpoint hook for responsive behavior
const { isMobile } = useBreakpoint();
// AI enabled state - fetched once at the parent level to avoid per-row API calls
const [aiEnabled, setAiEnabled] = createSignal(false);
// Fetch AI settings once when component mounts
createEffect(() => {
AIAPI.getSettings()
.then((settings) => setAiEnabled(settings.enabled && settings.configured))
.catch((err) => logger.debug('[DockerUnifiedTable] AI settings check failed:', err));
});
// Caches for stable object references to prevent re-animations
const rowCache = new Map<string, DockerRow>();
const tasksCache = new Map<string, DockerTask[]>();
@ -2604,6 +2739,8 @@ const DockerUnifiedTable: Component<DockerUnifiedTableProps> = (props) => {
onCustomUrlUpdate={props.onCustomUrlUpdate}
showHostContext={!grouped}
resourceIndentClass={grouped ? GROUPED_RESOURCE_INDENT : UNGROUPED_RESOURCE_INDENT}
aiEnabled={aiEnabled()}
initialNotes={metadata?.notes}
batchUpdateState={props.batchUpdateState}
/>
@ -2651,11 +2788,7 @@ const DockerUnifiedTable: Component<DockerUnifiedTableProps> = (props) => {
return (
<th
class={`${isResource ? 'pl-4 sm:pl-5 lg:pl-6 pr-2' : 'px-2'} py-1 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 text-left font-medium whitespace-nowrap`}
style={{
width: (col.id === 'cpu' || col.id === 'memory')
? (isMobile() ? '60px' : col.width)
: col.width
}}
style={{ width: col.width }}
onClick={() => colSortKey && handleSort(colSortKey)}
onKeyDown={(e) => e.key === 'Enter' && colSortKey && handleSort(colSortKey)}
tabIndex={0}

View file

@ -17,6 +17,7 @@ import { StackedMemoryBar } from '@/components/Dashboard/StackedMemoryBar';
import { EnhancedCPUBar } from '@/components/Dashboard/EnhancedCPUBar';
import { useBreakpoint, type ColumnPriority } from '@/hooks/useBreakpoint';
import { useColumnVisibility } from '@/hooks/useColumnVisibility';
import { aiChatStore } from '@/stores/aiChat';
import { STORAGE_KEYS } from '@/utils/localStorage';
import { useResourcesAsLegacy } from '@/hooks/useResources';
import { useAlertsActivation } from '@/stores/alertsActivation';
@ -40,11 +41,11 @@ export interface HostColumnDef {
// Host table column definitions - all essential for horizontal scroll like Docker
export const HOST_COLUMNS: HostColumnDef[] = [
// Core columns - all essential (visible on all screens with horizontal scroll)
{ id: 'name', label: 'Host', priority: 'essential', width: '210px', sortKey: 'name' },
{ id: 'platform', label: 'Platform', priority: 'essential', width: '110px', sortKey: 'platform' },
{ id: 'name', label: 'Host', priority: 'essential', width: '100px', sortKey: 'name' },
{ id: 'platform', label: 'Platform', priority: 'essential', width: '70px', sortKey: 'platform' },
{ id: 'cpu', label: 'CPU', priority: 'essential', width: '60px', sortKey: 'cpu' },
{ id: 'memory', label: 'Memory', priority: 'essential', width: '60px', sortKey: 'memory' },
{ id: 'disk', label: 'Disk', priority: 'essential', width: '220px', sortKey: 'disk' },
{ id: 'disk', label: 'Disk', priority: 'essential', width: '60px', sortKey: 'disk' },
// Additional columns - essential but toggleable by user
{ id: 'temp', label: 'Temp', icon: <svg class="w-3.5 h-3.5 block" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" /></svg>, priority: 'essential', width: '50px', toggleable: true },
@ -172,7 +173,6 @@ interface HostSensorSummaryForCell {
function HostTemperatureCell(props: { sensors: HostSensorSummaryForCell | null | undefined }) {
const [showTooltip, setShowTooltip] = createSignal(false);
const [tooltipPos, setTooltipPos] = createSignal({ x: 0, y: 0 });
const [tooltipDirection, setTooltipDirection] = createSignal<'above' | 'below'>('above');
const alertsActivation = useAlertsActivation();
const threshold = createMemo(() => alertsActivation.getTemperatureThreshold());
let closeTimeout: number | undefined;
@ -240,22 +240,7 @@ function HostTemperatureCell(props: { sensors: HostSensorSummaryForCell | null |
const handleMouseEnter = (e: MouseEvent) => {
if (closeTimeout) window.clearTimeout(closeTimeout);
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
const x = rect.left + rect.width / 2;
const y = rect.top;
// Estimate tooltip height (~400px max) and check if it fits above
const estimatedTooltipHeight = 400;
const spaceAbove = rect.top;
const spaceBelow = window.innerHeight - rect.bottom;
// Position below if not enough space above, but also check if below has more space
if (spaceAbove < estimatedTooltipHeight && spaceBelow > spaceAbove) {
setTooltipDirection('below');
setTooltipPos({ x, y: rect.bottom });
} else {
setTooltipDirection('above');
setTooltipPos({ x, y });
}
setTooltipPos({ x: rect.left + rect.width / 2, y: rect.top });
setShowTooltip(true);
};
@ -338,12 +323,8 @@ function HostTemperatureCell(props: { sensors: HostSensorSummaryForCell | null |
class="fixed z-[9999]"
style={{
left: `${tooltipPos().x}px`,
top: tooltipDirection() === 'above'
? `${tooltipPos().y - 8}px`
: `${tooltipPos().y + 8}px`,
transform: tooltipDirection() === 'above'
? 'translate(-50%, -100%)'
: 'translate(-50%, 0)',
top: `${tooltipPos().y - 8}px`,
transform: 'translate(-50%, -100%)',
}}
onMouseEnter={handleTooltipEnter}
onMouseLeave={handleTooltipLeave}
@ -984,26 +965,26 @@ export const HostsOverview: Component = () => {
<thead>
<tr class="bg-gray-50 dark:bg-gray-700/50 text-gray-600 dark:text-gray-300 border-b border-gray-200 dark:border-gray-700">
{/* Essential columns */}
<th class={`${thClass} text-left pl-4`} style={{ "width": isMobile() ? "140px" : "210px" }} onClick={() => handleSort('name')}>
<th class={`${thClass} text-left pl-4`} onClick={() => handleSort('name')}>
Host {renderSortIndicator('name')}
</th>
<Show when={isColVisible('platform')}>
<th class={thClass} style={{ "width": isMobile() ? "100px" : "120px" }} onClick={() => handleSort('platform')}>
<th class={thClass} onClick={() => handleSort('platform')}>
Platform {renderSortIndicator('platform')}
</th>
</Show>
<Show when={isColVisible('cpu')}>
<th class={thClass} style={{ "width": isMobile() ? "60px" : "140px" }} onClick={() => handleSort('cpu')}>
<th class={thClass} style={{ "min-width": isMobile() ? "60px" : "140px" }} onClick={() => handleSort('cpu')}>
CPU {renderSortIndicator('cpu')}
</th>
</Show>
<Show when={isColVisible('memory')}>
<th class={thClass} style={{ "width": isMobile() ? "60px" : "140px" }} onClick={() => handleSort('memory')}>
<th class={thClass} style={{ "min-width": isMobile() ? "60px" : "140px" }} onClick={() => handleSort('memory')}>
Memory {renderSortIndicator('memory')}
</th>
</Show>
<Show when={isColVisible('disk')}>
<th class={thClass} style={{ "width": isMobile() ? "90px" : "220px" }} onClick={() => handleSort('disk')}>
<th class={thClass} style={{ "min-width": isMobile() ? "60px" : "140px" }} onClick={() => handleSort('disk')}>
Disk {renderSortIndicator('disk')}
</th>
</Show>
@ -1106,6 +1087,8 @@ const HostRow: Component<HostRowProps> = (props) => {
// NOTE: Do NOT destructure props.host - it breaks SolidJS reactivity!
// Always access props.host directly to ensure updates flow through.
// Check if this host is in AI context
const isInAIContext = createMemo(() => aiChatStore.enabled && aiChatStore.hasContextItem(props.host.id));
// URL editing using shared hook
const urlEdit = createUrlEditState();
@ -1146,7 +1129,39 @@ const HostRow: Component<HostRowProps> = (props) => {
}
};
// Build context for AI - includes routing fields
const buildHostContext = (): Record<string, unknown> => ({
hostName: props.host.displayName || props.host.hostname,
hostname: props.host.hostname,
node: props.host.hostname, // Used by AI for command routing
target_host: props.host.hostname, // Explicit routing hint
platform: props.host.platform,
osName: props.host.osName,
osVersion: props.host.osVersion,
cpuUsage: props.host.cpuUsage ? `${props.host.cpuUsage.toFixed(1)}%` : undefined,
memoryUsage: props.host.memory?.usage ? `${props.host.memory.usage.toFixed(1)}%` : undefined,
uptime: props.host.uptimeSeconds ? formatUptime(props.host.uptimeSeconds) : undefined,
});
// Handle row click - toggle AI context selection
const handleRowClick = (event: MouseEvent) => {
const target = event.target as HTMLElement;
if (target.closest('a, button, [data-prevent-toggle], [data-url-editor]')) {
return;
}
// If AI is enabled, toggle AI context
if (aiChatStore.enabled) {
if (aiChatStore.hasContextItem(props.host.id)) {
aiChatStore.removeContextItem(props.host.id);
} else {
aiChatStore.addContextItem('host', props.host.id, props.host.displayName || props.host.hostname, buildHostContext());
if (!aiChatStore.isOpen) {
aiChatStore.open();
}
}
}
};
// Reactive getters to ensure values update when WebSocket data changes
const hostStatus = createMemo(() => getHostStatusIndicator(props.host));
@ -1157,15 +1172,17 @@ const HostRow: Component<HostRowProps> = (props) => {
const rowClass = () => {
const base = 'transition-all duration-200';
const hover = 'hover:bg-gray-50 dark:hover:bg-gray-800/50';
const clickable = aiChatStore.enabled ? 'cursor-pointer' : '';
const aiContext = isInAIContext() ? 'ai-context-row' : '';
const offline = !isOnline() ? 'opacity-60' : '';
return `${base} ${hover} ${offline}`;
return `${base} ${hover} ${clickable} ${aiContext} ${offline}`;
};
return (
<>
<tr class={rowClass()}>
<tr class={rowClass()} onClick={handleRowClick}>
{/* Host Name - always visible */}
<td class="pl-4 pr-2 py-1 align-middle overflow-hidden">
<td class="pl-4 pr-2 py-1 align-middle">
<div class="flex items-center gap-2 min-w-0">
<StatusDot
variant={hostStatus().variant}
@ -1173,21 +1190,18 @@ const HostRow: Component<HostRowProps> = (props) => {
ariaLabel={hostStatus().label}
size="xs"
/>
<div class="min-w-0 flex items-center gap-1.5 group/name flex-1">
<div class="min-w-0 flex-1">
<p
class="text-sm font-semibold text-gray-900 dark:text-gray-100 truncate"
title={props.host.displayName || props.host.hostname || props.host.id}
>
<div class="min-w-0 flex items-center gap-1.5 group/name">
<div>
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100 whitespace-nowrap">
{props.host.displayName || props.host.hostname || props.host.id}
</p>
<Show when={props.host.displayName && props.host.displayName !== props.host.hostname}>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5 truncate" title={props.host.hostname}>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5 whitespace-nowrap">
{props.host.hostname}
</p>
</Show>
<Show when={props.host.lastSeen}>
<p class="text-[10px] text-gray-500 dark:text-gray-400 mt-0.5 truncate">
<p class="text-[10px] text-gray-500 dark:text-gray-400 mt-0.5 whitespace-nowrap">
Updated {formatRelativeTime(props.host.lastSeen!)}
</p>
</Show>
@ -1228,18 +1242,25 @@ const HostRow: Component<HostRowProps> = (props) => {
<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>
{/* AI context indicator */}
<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>
</div>
</td>
{/* Platform */}
<Show when={props.isColVisible('platform')}>
<td class="px-2 py-1 align-middle overflow-hidden">
<div class="text-xs text-gray-700 dark:text-gray-300 min-w-0">
<p class="font-medium capitalize truncate" title={props.host.platform || '—'}>{props.host.platform || '—'}</p>
<td class="px-2 py-1 align-middle">
<div class="text-xs text-gray-700 dark:text-gray-300">
<p class="font-medium capitalize whitespace-nowrap">{props.host.platform || '—'}</p>
<Show when={props.host.osName}>
<p class="text-[10px] text-gray-500 dark:text-gray-400 mt-0.5 truncate" title={`${props.host.osName} ${props.host.osVersion}`.trim()}>
<p class="text-[10px] text-gray-500 dark:text-gray-400 mt-0.5 whitespace-nowrap">
{props.host.osName} {props.host.osVersion}
</p>
</Show>
@ -1249,7 +1270,7 @@ const HostRow: Component<HostRowProps> = (props) => {
{/* CPU */}
<Show when={props.isColVisible('cpu')}>
<td class="px-2 py-1 align-middle">
<td class="px-2 py-1 align-middle" style={{ "min-width": props.isMobile() ? "60px" : "140px", "width": props.isMobile() ? undefined : "140px", "max-width": props.isMobile() ? undefined : "140px" }}>
<Show when={isOnline()} fallback={<div class="flex justify-center"><span class="text-xs text-gray-400"></span></div>}>
<EnhancedCPUBar
usage={cpuPercent()}
@ -1262,7 +1283,7 @@ const HostRow: Component<HostRowProps> = (props) => {
{/* Memory */}
<Show when={props.isColVisible('memory')}>
<td class="px-2 py-1 align-middle">
<td class="px-2 py-1 align-middle" style={{ "min-width": props.isMobile() ? "60px" : "140px", "width": props.isMobile() ? undefined : "140px", "max-width": props.isMobile() ? undefined : "140px" }}>
<Show when={isOnline()} fallback={<div class="flex justify-center"><span class="text-xs text-gray-400"></span></div>}>
<StackedMemoryBar
used={props.host.memory?.used || 0}
@ -1277,11 +1298,10 @@ const HostRow: Component<HostRowProps> = (props) => {
{/* Disk */}
<Show when={props.isColVisible('disk')}>
<td class="px-2 py-1 align-middle">
<td class="px-2 py-1 align-middle" style={{ "min-width": props.isMobile() ? "60px" : "140px", "width": props.isMobile() ? undefined : "140px", "max-width": props.isMobile() ? undefined : "140px" }}>
<Show when={isOnline()} fallback={<div class="flex justify-center"><span class="text-xs text-gray-400"></span></div>}>
<StackedDiskBar
disks={props.host.disks}
mode="mini"
aggregateDisk={{
total: diskStats().total,
used: diskStats().used,

View file

@ -200,12 +200,12 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (props) => {
if (isPVE(item)) {
const node = item;
if (!node.disk) return undefined;
return `${formatBytes(node.disk.used)}/${formatBytes(node.disk.total)}`;
return `${formatBytes(node.disk.used, 0)}/${formatBytes(node.disk.total, 0)}`;
}
const pbs = item;
if (!pbs.datastores || pbs.datastores.length === 0) return undefined;
const totals = getPbsTotals(pbs);
return `${formatBytes(totals.used)}/${formatBytes(totals.total)}`;
return `${formatBytes(totals.used, 0)}/${formatBytes(totals.total, 0)}`;
};
const getTemperatureValue = (item: TableItem) => {
@ -346,7 +346,7 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (props) => {
// Cell class constants for consistency
const tdClass = "px-2 py-1 align-middle";
const metricColumnStyle = { "min-width": "140px", "max-width": "180px" } as const;
const metricColumnStyle = { width: "200px", "min-width": "200px", "max-width": "200px" } as const;
return (
<Card padding="none" tone="glass" class="mb-4 overflow-hidden">
@ -360,41 +360,41 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (props) => {
>
{props.currentTab === 'backups' ? 'Node / PBS' : 'Node'} {renderSortIndicator('name')}
</th>
<th class={thClass} style={{ width: '80px', "min-width": '80px', "max-width": '80px' }} onClick={() => handleSort('uptime')}>
<th class={thClass} style={{ "min-width": '80px' }} onClick={() => handleSort('uptime')}>
Uptime {renderSortIndicator('uptime')}
</th>
<th class={thClass} style={isMobile() ? { "min-width": "80px" } : { "min-width": "140px", "max-width": "180px" }} onClick={() => handleSort('cpu')}>
<th class={thClass} style={isMobile() ? { "min-width": "80px" } : { width: "140px", "min-width": "140px", "max-width": "140px" }} onClick={() => handleSort('cpu')}>
CPU {renderSortIndicator('cpu')}
</th>
<th class={thClass} style={isMobile() ? { "min-width": "80px" } : { "min-width": "140px", "max-width": "180px" }} onClick={() => handleSort('memory')}>
<th class={thClass} style={isMobile() ? { "min-width": "80px" } : { width: "140px", "min-width": "140px", "max-width": "140px" }} onClick={() => handleSort('memory')}>
Memory {renderSortIndicator('memory')}
</th>
<th class={thClass} style={isMobile() ? { "min-width": "80px" } : { "min-width": "140px", "max-width": "180px" }} onClick={() => handleSort('disk')}>
<th class={thClass} style={isMobile() ? { "min-width": "80px" } : { width: "140px", "min-width": "140px", "max-width": "140px" }} onClick={() => handleSort('disk')}>
Disk {renderSortIndicator('disk')}
</th>
<Show when={hasAnyTemperatureData()}>
<th class={thClass} style={{ width: '60px', "min-width": '60px', "max-width": '60px' }} onClick={() => handleSort('temperature')}>
<th class={thClass} style={{ "min-width": '60px' }} onClick={() => handleSort('temperature')}>
Temp {renderSortIndicator('temperature')}
</th>
</Show>
<Show when={props.currentTab === 'dashboard'}>
<th class={thClass} style={{ width: '50px', "min-width": '50px', "max-width": '50px' }} onClick={() => handleSort('vmCount')}>
<th class={thClass} style={{ "min-width": '50px' }} onClick={() => handleSort('vmCount')}>
VMs {renderSortIndicator('vmCount')}
</th>
<th class={thClass} style={{ width: '50px', "min-width": '50px', "max-width": '50px' }} onClick={() => handleSort('containerCount')}>
<th class={thClass} style={{ "min-width": '50px' }} onClick={() => handleSort('containerCount')}>
CTs {renderSortIndicator('containerCount')}
</th>
</Show>
<Show when={props.currentTab === 'storage'}>
<th class={thClass} style={{ width: '70px', "min-width": '70px', "max-width": '70px' }} onClick={() => handleSort('storageCount')}>
<th class={thClass} style={{ "min-width": '70px' }} onClick={() => handleSort('storageCount')}>
Storage {renderSortIndicator('storageCount')}
</th>
<th class={thClass} style={{ width: '60px', "min-width": '60px', "max-width": '60px' }} onClick={() => handleSort('diskCount')}>
<th class={thClass} style={{ "min-width": '60px' }} onClick={() => handleSort('diskCount')}>
Disks {renderSortIndicator('diskCount')}
</th>
</Show>
<Show when={props.currentTab === 'backups'}>
<th class={thClass} style={{ width: '70px', "min-width": '70px', "max-width": '70px' }} onClick={() => handleSort('backupCount')}>
<th class={thClass} style={{ "min-width": '70px' }} onClick={() => handleSort('backupCount')}>
Backups {renderSortIndicator('backupCount')}
</th>
</Show>
@ -490,8 +490,8 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (props) => {
onClick={() => props.onNodeClick(nodeId, isPVEItem ? 'pve' : 'pbs')}
>
{/* Name */}
<td class={`pr-2 py-1 align-middle overflow-hidden ${showAlertHighlight() ? 'pl-4' : 'pl-3'}`}>
<div class="flex items-center gap-1.5 min-w-0">
<td class={`pr-2 py-1 align-middle ${showAlertHighlight() ? 'pl-4' : 'pl-3'}`}>
<div class="flex items-center gap-1.5">
<StatusDot
variant={statusIndicator().variant}
title={statusIndicator().label}
@ -516,7 +516,7 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (props) => {
({(node as Node).name})
</span>
</Show>
<div class="hidden xl:flex items-center gap-1.5 ml-1 flex-shrink min-w-0 overflow-hidden">
<div class="hidden xl:flex items-center gap-1.5 ml-1">
<Show when={isPVEItem}>
<span class="text-[9px] px-1 py-0 rounded font-medium bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400">
PVE
@ -545,17 +545,6 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (props) => {
+Agent
</span>
</Show>
<Show when={isPVEItem && online && node!.pendingUpdates !== undefined && node!.pendingUpdates > 0}>
<span
class={`text-[9px] px-1 py-0 rounded font-medium whitespace-nowrap ${(node!.pendingUpdates ?? 0) >= 10
? 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400'
: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400'
}`}
title={`${node!.pendingUpdates} pending apt update${node!.pendingUpdates !== 1 ? 's' : ''}`}
>
{node!.pendingUpdates} updates
</span>
</Show>
<Show when={isPBSItem}>
<span class="text-[9px] px-1 py-0 rounded font-medium bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400">
PBS
@ -609,7 +598,7 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (props) => {
value={memoryPercentValue}
type="memory"
resourceId={metricsKey}
sublabel={pbs!.memoryTotal ? `${formatBytes(pbs!.memoryUsed)}/${formatBytes(pbs!.memoryTotal)}` : undefined}
sublabel={pbs!.memoryTotal ? `${formatBytes(pbs!.memoryUsed, 0)}/${formatBytes(pbs!.memoryTotal, 0)}` : undefined}
isRunning={online}
showMobile={false}
/>

View file

@ -211,3 +211,52 @@
animation: nodeClick 0.15s ease-out;
}
/* AI Context row highlight - subtle left accent bar */
/* Using purple-500 for a clean, modern look */
.ai-context-row {
background-color: rgba(147, 51, 234, 0.04);
box-shadow: inset 3px 0 0 0 rgba(147, 51, 234, 0.6);
}
.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 {
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: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 {
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: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 {
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

@ -1,5 +1,12 @@
import type { Temperature } from '@/types/api';
export type TemperatureTransport =
| 'disabled'
| 'socket-proxy'
| 'https-proxy'
| 'ssh'
| 'ssh-blocked';
// Node configuration types
export interface ClusterEndpoint {
@ -86,6 +93,7 @@ export type NodeConfig = (PVENodeConfig | PBSNodeConfig | PMGNodeConfig) & {
status?: 'connected' | 'disconnected' | 'offline' | 'error' | 'pending';
temperature?: Temperature;
displayName?: string;
temperatureTransport?: TemperatureTransport;
source?: 'agent' | 'script' | ''; // How this node was registered
};