549 lines
18 KiB
Svelte
549 lines
18 KiB
Svelte
<script lang="ts">
|
|
import { onMount } from 'svelte';
|
|
import { Cpu, MemoryStick, Box, Globe, ChevronDown, Check, HardDrive, Clock, Wifi, WifiOff, Route, UndoDot, Icon, AlertCircle, Loader2, Search, Server, X } from 'lucide-svelte';
|
|
import { whale } from '@lucide/lab';
|
|
import { Button } from '$lib/components/ui/button';
|
|
import { currentEnvironment, environments, type Environment } from '$lib/stores/environment';
|
|
import { sseConnected } from '$lib/stores/events';
|
|
import EnvironmentIcon from '$lib/components/EnvironmentIcon.svelte';
|
|
import { toast } from 'svelte-sonner';
|
|
import { themeStore, type FontSize } from '$lib/stores/theme';
|
|
import { getTimeFormat } from '$lib/stores/settings';
|
|
import { formatBytes } from '$lib/utils/format';
|
|
|
|
// Font size scaling for header
|
|
let fontSize = $state<FontSize>('normal');
|
|
themeStore.subscribe(prefs => fontSize = prefs.fontSize);
|
|
|
|
// Derive text and icon size classes based on font size
|
|
const textSizeClass = $derived(() => {
|
|
switch (fontSize) {
|
|
case 'small': return 'text-xs';
|
|
case 'normal': return 'text-xs';
|
|
case 'medium': return 'text-sm';
|
|
case 'large': return 'text-sm';
|
|
case 'xlarge': return 'text-base';
|
|
default: return 'text-xs';
|
|
}
|
|
});
|
|
|
|
const iconSizeClass = $derived(() => {
|
|
switch (fontSize) {
|
|
case 'small': return 'h-3 w-3';
|
|
case 'normal': return 'h-3 w-3';
|
|
case 'medium': return 'h-3.5 w-3.5';
|
|
case 'large': return 'h-4 w-4';
|
|
case 'xlarge': return 'h-4 w-4';
|
|
default: return 'h-3 w-3';
|
|
}
|
|
});
|
|
|
|
const iconSizeLargeClass = $derived(() => {
|
|
switch (fontSize) {
|
|
case 'small': return 'h-3.5 w-3.5';
|
|
case 'normal': return 'h-3.5 w-3.5';
|
|
case 'medium': return 'h-4 w-4';
|
|
case 'large': return 'h-5 w-5';
|
|
case 'xlarge': return 'h-5 w-5';
|
|
default: return 'h-3.5 w-3.5';
|
|
}
|
|
});
|
|
|
|
interface HostInfo {
|
|
hostname: string;
|
|
ipAddress: string;
|
|
platform: string;
|
|
arch: string;
|
|
cpus: number;
|
|
totalMemory: number;
|
|
freeMemory: number;
|
|
uptime: number;
|
|
dockerVersion: string;
|
|
dockerContainers: number;
|
|
dockerContainersRunning: number;
|
|
dockerImages: number;
|
|
environment: Environment & { icon?: string; connectionType?: string; hawserVersion?: string };
|
|
}
|
|
|
|
interface DiskUsageInfo {
|
|
LayersSize: number;
|
|
Images: any[];
|
|
Containers: any[];
|
|
Volumes: any[];
|
|
BuildCache: any[];
|
|
}
|
|
|
|
let hostInfo = $state<HostInfo | null>(null);
|
|
let diskUsage = $state<DiskUsageInfo | null>(null);
|
|
let diskUsageLoading = $state(false);
|
|
let envAbortController: AbortController | null = null; // Aborts ALL requests when switching envs
|
|
let showDropdown = $state(false);
|
|
let searchTerm = $state('');
|
|
let searchInputRef = $state<HTMLInputElement | null>(null);
|
|
let currentEnvId = $state<number | null>(null);
|
|
let lastUpdated = $state<Date>(new Date());
|
|
let isConnected = $state(false);
|
|
let initializedFromStore = false;
|
|
let switchingEnvId = $state<number | null>(null); // Track which env is being switched to
|
|
let offlineEnvIds = $state<Set<number>>(new Set()); // Track offline environments
|
|
|
|
// Abort all pending requests for current environment
|
|
function abortPendingRequests() {
|
|
if (envAbortController) {
|
|
envAbortController.abort();
|
|
envAbortController = null;
|
|
}
|
|
}
|
|
|
|
// Display string for the env hostname / IP in the header (#962).
|
|
// Show both when available; drop only the field that is unknown/empty.
|
|
// Hide the whole block when neither is meaningful (e.g. hawser-edge
|
|
// reports 'unknown' for both).
|
|
const hostLabel = $derived.by(() => {
|
|
if (!hostInfo) return '';
|
|
const isMeaningful = (v: string | undefined) => {
|
|
const t = (v || '').trim();
|
|
return t && t.toLowerCase() !== 'unknown';
|
|
};
|
|
const h = isMeaningful(hostInfo.hostname) ? hostInfo.hostname.trim() : '';
|
|
const ip = isMeaningful(hostInfo.ipAddress) ? hostInfo.ipAddress.trim() : '';
|
|
if (h && ip && h !== ip) return `${h} (${ip})`;
|
|
return h || ip;
|
|
});
|
|
|
|
// Reactive environment list from store
|
|
let envList = $derived($environments);
|
|
const showSearch = $derived(envList.length > 8);
|
|
const filteredEnvList = $derived(
|
|
searchTerm.trim()
|
|
? envList.filter((e: Environment) => e.name.toLowerCase().includes(searchTerm.toLowerCase()))
|
|
: envList
|
|
);
|
|
|
|
// Clear search and focus when dropdown opens/closes
|
|
$effect(() => {
|
|
if (showDropdown && showSearch) {
|
|
// Use tick to wait for DOM render
|
|
setTimeout(() => searchInputRef?.focus(), 0);
|
|
} else {
|
|
searchTerm = '';
|
|
}
|
|
});
|
|
|
|
sseConnected.subscribe(v => isConnected = v);
|
|
|
|
// Subscribe to the store and react to changes (including from command palette)
|
|
currentEnvironment.subscribe(env => {
|
|
if (env) {
|
|
// Only update if different to avoid loops and unnecessary fetches
|
|
// Use Number() for type-safe comparison
|
|
if (Number(env.id) !== Number(currentEnvId)) {
|
|
currentEnvId = env.id;
|
|
// Fetch new host info for the changed environment
|
|
if (initializedFromStore) {
|
|
fetchHostInfo();
|
|
fetchDiskUsage();
|
|
}
|
|
}
|
|
initializedFromStore = true;
|
|
} else if (!env && envList.length > 0 && currentEnvId === null) {
|
|
// Set current env to first if not restored from store
|
|
currentEnvId = envList[0].id;
|
|
}
|
|
});
|
|
|
|
// Watch for when current environment is deleted, all environments removed, or no env selected
|
|
// IMPORTANT: Don't clear state when envList is empty during initial load - wait for environments to load first
|
|
$effect(() => {
|
|
// Skip if environments haven't loaded yet - the store subscription will handle initial setup
|
|
if (envList.length === 0) return;
|
|
|
|
if (currentEnvId === null) {
|
|
// No environment selected - select first one
|
|
currentEnvId = envList[0].id;
|
|
fetchHostInfo();
|
|
fetchDiskUsage();
|
|
} else {
|
|
// Use Number() for type-safe comparison in case of string/number mismatch
|
|
const stillExists = envList.find((e: Environment) => Number(e.id) === Number(currentEnvId));
|
|
if (!stillExists) {
|
|
// Current environment was deleted - select first one
|
|
currentEnvId = envList[0].id;
|
|
fetchHostInfo();
|
|
fetchDiskUsage();
|
|
}
|
|
}
|
|
});
|
|
|
|
async function fetchHostInfo() {
|
|
// Skip if no environment selected or no abort controller
|
|
if (!currentEnvId || !envAbortController) return;
|
|
|
|
try {
|
|
const url = `/api/host?env=${currentEnvId}`;
|
|
const response = await fetch(url, { signal: envAbortController.signal });
|
|
if (response.ok) {
|
|
hostInfo = await response.json();
|
|
lastUpdated = new Date();
|
|
if (hostInfo?.environment) {
|
|
currentEnvId = hostInfo.environment.id;
|
|
// Update the store
|
|
currentEnvironment.set({
|
|
id: hostInfo.environment.id,
|
|
name: hostInfo.environment.name,
|
|
highlightChanges: hostInfo.environment.highlightChanges ?? true
|
|
});
|
|
}
|
|
}
|
|
} catch (error) {
|
|
// Ignore abort errors
|
|
if (error instanceof Error && error.name !== 'AbortError') {
|
|
console.error('Failed to fetch host info:', error);
|
|
}
|
|
}
|
|
}
|
|
|
|
async function fetchDiskUsage() {
|
|
// Skip if no environment selected or no abort controller
|
|
if (!currentEnvId || !envAbortController) return;
|
|
|
|
diskUsage = null;
|
|
diskUsageLoading = true;
|
|
|
|
try {
|
|
const url = `/api/system/disk?env=${currentEnvId}`;
|
|
const response = await fetch(url, { signal: envAbortController.signal });
|
|
if (response.ok) {
|
|
const data = await response.json();
|
|
diskUsage = data.diskUsage;
|
|
}
|
|
} catch (error) {
|
|
// Ignore abort errors
|
|
if (error instanceof Error && error.name !== 'AbortError') {
|
|
console.error('Failed to fetch disk usage:', error);
|
|
}
|
|
diskUsage = null;
|
|
} finally {
|
|
diskUsageLoading = false;
|
|
}
|
|
}
|
|
|
|
// Calculate total disk usage
|
|
let totalDiskUsage = $derived(() => {
|
|
if (!diskUsage) return 0;
|
|
return (diskUsage.LayersSize || 0) +
|
|
(diskUsage.Volumes?.reduce((sum: number, v: any) => sum + (v.UsageData?.Size || 0), 0) || 0);
|
|
});
|
|
|
|
async function switchEnvironment(envId: number) {
|
|
// Don't switch if already on this environment
|
|
if (Number(envId) === Number(currentEnvId)) {
|
|
showDropdown = false;
|
|
return;
|
|
}
|
|
|
|
// Don't switch if already switching
|
|
if (switchingEnvId !== null) {
|
|
return;
|
|
}
|
|
|
|
// IMMEDIATELY abort all pending requests for current environment
|
|
abortPendingRequests();
|
|
|
|
// Clear stale data immediately for instant UI feedback
|
|
diskUsage = null;
|
|
diskUsageLoading = false;
|
|
|
|
const targetEnv = envList.find((e: Environment) => Number(e.id) === Number(envId));
|
|
const envName = targetEnv?.name || `Environment ${envId}`;
|
|
|
|
// Mark as switching and create new abort controller
|
|
switchingEnvId = envId;
|
|
showDropdown = false;
|
|
envAbortController = new AbortController();
|
|
|
|
try {
|
|
// Try to connect to the new environment first
|
|
const url = `/api/host?env=${envId}`;
|
|
const response = await fetch(url, { signal: envAbortController.signal });
|
|
|
|
if (!response.ok) {
|
|
offlineEnvIds.add(envId);
|
|
offlineEnvIds = new Set(offlineEnvIds);
|
|
toast.error(`Cannot switch to "${envName}" - environment is offline`);
|
|
return;
|
|
}
|
|
|
|
const newHostInfo = await response.json();
|
|
|
|
if (newHostInfo.error) {
|
|
offlineEnvIds.add(envId);
|
|
offlineEnvIds = new Set(offlineEnvIds);
|
|
toast.error(`Cannot switch to "${envName}" - ${newHostInfo.error}`);
|
|
return;
|
|
}
|
|
|
|
// Environment is online, proceed with switch
|
|
offlineEnvIds.delete(envId);
|
|
offlineEnvIds = new Set(offlineEnvIds);
|
|
currentEnvId = envId;
|
|
hostInfo = newHostInfo;
|
|
lastUpdated = new Date();
|
|
|
|
// Fetch disk usage (non-blocking, uses shared abort controller)
|
|
fetchDiskUsage();
|
|
|
|
// Update the store
|
|
if (newHostInfo.environment) {
|
|
currentEnvironment.set({
|
|
id: newHostInfo.environment.id,
|
|
name: newHostInfo.environment.name,
|
|
highlightChanges: newHostInfo.environment.highlightChanges ?? true
|
|
});
|
|
}
|
|
} catch (error) {
|
|
// Ignore abort errors
|
|
if (error instanceof Error && error.name === 'AbortError') {
|
|
return;
|
|
}
|
|
offlineEnvIds.add(envId);
|
|
offlineEnvIds = new Set(offlineEnvIds);
|
|
toast.error(`Cannot switch to "${envName}" - connection failed`);
|
|
} finally {
|
|
switchingEnvId = null;
|
|
}
|
|
}
|
|
|
|
function formatMemory(bytes: number): string {
|
|
const gb = bytes / (1024 * 1024 * 1024);
|
|
return `${gb.toFixed(1)} GB`;
|
|
}
|
|
|
|
function formatUptime(seconds: number): string {
|
|
const days = Math.floor(seconds / 86400);
|
|
const hours = Math.floor((seconds % 86400) / 3600);
|
|
if (days > 0) {
|
|
return `${days}d ${hours}h`;
|
|
}
|
|
return `${hours}h`;
|
|
}
|
|
|
|
let memoryPercent = $derived(
|
|
hostInfo ? ((hostInfo.totalMemory - hostInfo.freeMemory) / hostInfo.totalMemory) * 100 : 0
|
|
);
|
|
|
|
let currentTimezone = $derived(
|
|
$environments.find((e: Environment) => Number(e.id) === Number(currentEnvId))?.timezone ?? 'UTC'
|
|
);
|
|
|
|
function formatLastUpdated(date: Date, timezone: string): string {
|
|
return new Intl.DateTimeFormat('en-GB', {
|
|
timeZone: timezone,
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
second: '2-digit',
|
|
hour12: getTimeFormat() === '12h'
|
|
}).format(date);
|
|
}
|
|
|
|
function handleClickOutside(event: MouseEvent) {
|
|
const target = event.target as HTMLElement;
|
|
if (!target.closest('.env-dropdown')) {
|
|
showDropdown = false;
|
|
}
|
|
}
|
|
|
|
onMount(() => {
|
|
// Create initial abort controller
|
|
envAbortController = new AbortController();
|
|
fetchHostInfo();
|
|
fetchDiskUsage();
|
|
// No polling - only fetch on mount and environment switch
|
|
document.addEventListener('click', handleClickOutside);
|
|
return () => {
|
|
abortPendingRequests(); // Abort on destroy
|
|
document.removeEventListener('click', handleClickOutside);
|
|
};
|
|
});
|
|
|
|
</script>
|
|
|
|
<div class="flex items-center gap-3 min-w-0 {textSizeClass()} text-muted-foreground">
|
|
<!-- Environment Selector - always show -->
|
|
<div class="relative env-dropdown">
|
|
<button
|
|
onclick={() => (showDropdown = !showDropdown)}
|
|
class="flex items-center gap-1.5 -ml-1 px-1 py-1 rounded-md hover:bg-muted transition-colors cursor-pointer"
|
|
>
|
|
{#if hostInfo?.environment && Number(hostInfo.environment.id) === Number(currentEnvId)}
|
|
<EnvironmentIcon icon={hostInfo.environment.icon || 'globe'} envId={hostInfo.environment.id} class="{iconSizeLargeClass()} text-primary" />
|
|
<span class="font-medium text-foreground">{hostInfo.environment.name}</span>
|
|
{:else if currentEnvId && envList.length > 0}
|
|
{@const currentEnv = envList.find(e => Number(e.id) === Number(currentEnvId))}
|
|
{#if currentEnv}
|
|
<EnvironmentIcon icon={currentEnv.icon || 'globe'} envId={currentEnv.id} class="{iconSizeLargeClass()} text-primary" />
|
|
<span class="font-medium text-foreground">{currentEnv.name}</span>
|
|
{:else}
|
|
<Globe class="{iconSizeLargeClass()} text-muted-foreground" />
|
|
<span class="font-medium text-foreground">Select environment</span>
|
|
{/if}
|
|
{:else}
|
|
<Globe class="{iconSizeLargeClass()} text-muted-foreground" />
|
|
<span class="font-medium text-foreground">No environments</span>
|
|
{/if}
|
|
<ChevronDown class="{iconSizeClass()}" />
|
|
</button>
|
|
|
|
{#if showDropdown && envList.length > 0}
|
|
<div class="absolute top-full left-0 mt-1 min-w-56 w-max max-w-80 bg-popover border rounded-md shadow-lg z-50">
|
|
{#if showSearch}
|
|
<div class="sticky top-0 bg-popover border-b px-2 py-1.5">
|
|
<div class="relative">
|
|
<Search class="absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground" />
|
|
<input
|
|
bind:this={searchInputRef}
|
|
bind:value={searchTerm}
|
|
type="text"
|
|
placeholder="Search environments..."
|
|
class="w-full pl-7 pr-7 py-1 text-sm bg-transparent border rounded focus:outline-none focus:ring-1 focus:ring-ring"
|
|
onclick={(e) => e.stopPropagation()}
|
|
onkeydown={(e) => {
|
|
if (e.key === 'Escape') {
|
|
if (searchTerm) {
|
|
searchTerm = '';
|
|
} else {
|
|
showDropdown = false;
|
|
}
|
|
}
|
|
}}
|
|
/>
|
|
{#if searchTerm}
|
|
<button
|
|
class="absolute right-1.5 top-1/2 -translate-y-1/2 p-0.5 rounded hover:bg-muted"
|
|
onclick={(e) => { e.stopPropagation(); searchTerm = ''; searchInputRef?.focus(); }}
|
|
>
|
|
<X class="w-3 h-3 text-muted-foreground" />
|
|
</button>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
<div class="py-1 max-h-[calc(100vh-8rem)] overflow-y-auto">
|
|
{#each filteredEnvList as env (env.id)}
|
|
{@const isOffline = offlineEnvIds.has(env.id)}
|
|
{@const isSwitching = switchingEnvId === env.id}
|
|
<button
|
|
onclick={() => switchEnvironment(env.id)}
|
|
disabled={isSwitching}
|
|
class="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted transition-colors text-left cursor-pointer disabled:cursor-wait disabled:opacity-70"
|
|
class:opacity-60={isOffline && !isSwitching}
|
|
>
|
|
{#if isSwitching}
|
|
<Loader2 class="{iconSizeLargeClass()} text-muted-foreground shrink-0 animate-spin" />
|
|
{:else if isOffline}
|
|
<WifiOff class="{iconSizeLargeClass()} text-destructive shrink-0" />
|
|
{:else}
|
|
<EnvironmentIcon icon={env.icon || 'globe'} envId={env.id} class="{iconSizeLargeClass()} text-muted-foreground shrink-0" />
|
|
{/if}
|
|
<span class="flex-1 whitespace-nowrap" class:text-muted-foreground={isOffline}>{env.name}</span>
|
|
{#if isOffline && !isSwitching}
|
|
<span class="text-xs text-destructive">offline</span>
|
|
{:else if Number(env.id) === Number(currentEnvId)}
|
|
<Check class="{iconSizeLargeClass()} text-primary shrink-0" />
|
|
{/if}
|
|
</button>
|
|
{:else}
|
|
<div class="px-3 py-2 text-sm text-muted-foreground">
|
|
No matching environments
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
{#if hostInfo}
|
|
<span class="text-border">|</span>
|
|
|
|
<!-- Hostname / IP (#962) — first info segment after the env dropdown.
|
|
Hidden on narrow viewports to keep the strip readable. -->
|
|
{#if hostLabel}
|
|
<div class="hidden xl:flex items-center gap-1" title="Daemon hostname / IP">
|
|
<Server class="{iconSizeClass()}" />
|
|
<span>{hostLabel}</span>
|
|
</div>
|
|
<span class="hidden xl:inline text-border">|</span>
|
|
{/if}
|
|
|
|
<!-- Platform/OS -->
|
|
<span class="hidden md:inline">{hostInfo.platform} {hostInfo.arch}</span>
|
|
|
|
<span class="hidden md:inline text-border">|</span>
|
|
|
|
<!-- Docker version -->
|
|
<span class="hidden md:inline">Docker {hostInfo.dockerVersion}</span>
|
|
|
|
<span class="hidden md:inline text-border">|</span>
|
|
|
|
<!-- Connection type -->
|
|
<div class="hidden md:flex items-center gap-1">
|
|
{#if hostInfo.environment?.connectionType === 'hawser-standard'}
|
|
<Route class="{iconSizeClass()}" />
|
|
<span>Hawser (standard){hostInfo.environment.hawserVersion ? ` ${hostInfo.environment.hawserVersion}` : ''}</span>
|
|
{:else if hostInfo.environment?.connectionType === 'hawser-edge'}
|
|
<UndoDot class="{iconSizeClass()}" />
|
|
<span>Hawser (edge){hostInfo.environment.hawserVersion ? ` ${hostInfo.environment.hawserVersion}` : ''}</span>
|
|
{:else}
|
|
<Icon iconNode={whale} class="{iconSizeClass()}" />
|
|
<span>Socket</span>
|
|
{/if}
|
|
</div>
|
|
|
|
<span class="hidden md:inline text-border">|</span>
|
|
|
|
<!-- CPU cores -->
|
|
{#if hostInfo.cpus > 0}
|
|
<span class="hidden lg:inline">{hostInfo.cpus} cores</span>
|
|
<span class="hidden lg:inline text-border">|</span>
|
|
{/if}
|
|
|
|
<!-- Memory -->
|
|
{#if hostInfo.totalMemory > 0}
|
|
<span class="hidden lg:inline">{formatBytes(hostInfo.totalMemory)} RAM</span>
|
|
<span class="hidden lg:inline text-border">|</span>
|
|
{/if}
|
|
|
|
<!-- Disk usage - only show when data is available (hide on timeout/error) -->
|
|
{#if diskUsage && !diskUsageLoading}
|
|
<div class="hidden xl:flex items-center gap-1">
|
|
<HardDrive class="{iconSizeClass()}" />
|
|
<span>{formatBytes(totalDiskUsage())}</span>
|
|
</div>
|
|
<span class="hidden xl:inline text-border">|</span>
|
|
{/if}
|
|
|
|
<!-- Uptime - hidden for direct remote connections without Hawser -->
|
|
{#if hostInfo.uptime > 0}
|
|
<div class="hidden xl:flex items-center gap-1">
|
|
<Clock class="{iconSizeClass()}" />
|
|
<span>{formatUptime(hostInfo.uptime)}</span>
|
|
</div>
|
|
<span class="hidden xl:inline text-border">|</span>
|
|
{/if}
|
|
|
|
<!-- Live indicator with timestamp -->
|
|
<div
|
|
class="flex items-center gap-2 {isConnected ? 'text-emerald-500' : 'text-muted-foreground'}"
|
|
title={isConnected ? 'Live updates connected' : 'Live updates disconnected'}
|
|
>
|
|
<span class="text-muted-foreground" title={currentTimezone}>{formatLastUpdated(lastUpdated, currentTimezone)}</span>
|
|
{#if isConnected}
|
|
<Wifi class="{iconSizeLargeClass()}" />
|
|
<span class="font-medium">Live</span>
|
|
{:else}
|
|
<WifiOff class="{iconSizeLargeClass()}" />
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
</div>
|