mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-05-19 16:27:37 +00:00
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:
parent
8412cc7ddb
commit
ffbcefee44
9 changed files with 421 additions and 327 deletions
|
|
@ -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'
|
||||
}`}
|
||||
|
|
|
|||
|
|
@ -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}`}>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
};
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue