mirror of
https://github.com/khoaliber/dockhand.git
synced 2026-03-02 21:19:05 +00:00
467 lines
15 KiB
Svelte
467 lines
15 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 } 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 { getIconComponent } from '$lib/utils/icons';
|
|
import { toast } from 'svelte-sonner';
|
|
import { themeStore, type FontSize } from '$lib/stores/theme';
|
|
|
|
// 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 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;
|
|
}
|
|
}
|
|
|
|
// Reactive environment list from store
|
|
let envList = $derived($environments);
|
|
|
|
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);
|
|
});
|
|
|
|
function formatBytes(bytes: number): string {
|
|
if (bytes === 0) return '0 B';
|
|
const k = 1024;
|
|
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
|
|
}
|
|
|
|
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
|
|
);
|
|
|
|
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();
|
|
const hostInterval = setInterval(fetchHostInfo, 30000);
|
|
const diskInterval = setInterval(fetchDiskUsage, 30000);
|
|
document.addEventListener('click', handleClickOutside);
|
|
return () => {
|
|
abortPendingRequests(); // Abort on destroy
|
|
clearInterval(hostInterval);
|
|
clearInterval(diskInterval);
|
|
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)}
|
|
{@const EnvIcon = getIconComponent(hostInfo.environment.icon || 'globe')}
|
|
<EnvIcon 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}
|
|
{@const EnvIcon = getIconComponent(currentEnv.icon || 'globe')}
|
|
<EnvIcon 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">
|
|
<div class="py-1">
|
|
{#each envList as env (env.id)}
|
|
{@const EnvIcon = getIconComponent(env.icon || 'globe')}
|
|
{@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}
|
|
<EnvIcon 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>
|
|
{/each}
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
{#if hostInfo}
|
|
<span class="text-border">|</span>
|
|
|
|
<!-- 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">{lastUpdated.toLocaleTimeString()}</span>
|
|
{#if isConnected}
|
|
<Wifi class="{iconSizeLargeClass()}" />
|
|
<span class="font-medium">Live</span>
|
|
{:else}
|
|
<WifiOff class="{iconSizeLargeClass()}" />
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
</div>
|