Files
dockhand/routes/+page.svelte
Jarek Krochmalski 62e3c6439e Initial commit
2025-12-28 21:16:03 +01:00

1001 lines
31 KiB
Svelte

<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { browser } from '$app/environment';
import { RefreshCw, LayoutGrid, Loader2, Server, Tags, Square, RectangleVertical, Rows3, LayoutTemplate, Maximize2, Plus } from 'lucide-svelte';
import { toast } from 'svelte-sonner';
import PageHeader from '$lib/components/PageHeader.svelte';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import { goto } from '$app/navigation';
import { Button } from '$lib/components/ui/button';
import EnvironmentTile from './dashboard/EnvironmentTile.svelte';
import EnvironmentTileSkeleton from './dashboard/EnvironmentTileSkeleton.svelte';
import DraggableGrid, { type GridItemLayout } from './dashboard/DraggableGrid.svelte';
import { dashboardPreferences, dashboardData, GRID_COLS, GRID_ROW_HEIGHT, type TileItem } from '$lib/stores/dashboard';
import { currentEnvironment } from '$lib/stores/environment';
import type { EnvironmentStats } from './api/dashboard/stats/+server';
import { getLabelColor, getLabelBgColor } from '$lib/utils/label-colors';
const LABEL_FILTER_STORAGE_KEY = 'dockhand-dashboard-label-filter';
// Real-time event stream for immediate updates
let eventSource: EventSource | null = null;
let eventReconnectTimer: ReturnType<typeof setTimeout> | null = null;
let eventReconnectAttempts = 0;
const MAX_EVENT_RECONNECT_ATTEMPTS = 10;
const BASE_EVENT_RECONNECT_DELAY = 5000;
interface EnvironmentInfo {
id: number;
name: string;
host?: string;
port?: number | null;
icon: string;
socketPath?: string;
collectActivity: boolean;
collectMetrics: boolean;
connectionType?: 'socket' | 'direct' | 'hawser-standard' | 'hawser-edge';
labels?: string[];
}
// Use store data with local reactive state for UI updates
let tiles = $state<TileItem[]>([]);
let gridItems = $state<GridItemLayout[]>([]);
let initialLoading = $state(true);
let refreshing = $state(false);
let prefsLoaded = $state(false);
// Label filtering - load from localStorage
let filterLabels = $state<string[]>([]);
let labelFilterLoaded = $state(false);
// Load saved label filter from localStorage
function loadLabelFilter() {
if (browser) {
try {
const saved = localStorage.getItem(LABEL_FILTER_STORAGE_KEY);
if (saved) {
filterLabels = JSON.parse(saved);
}
} catch {
// Ignore parse errors
}
labelFilterLoaded = true;
}
}
// Save label filter to localStorage when it changes
$effect(() => {
if (browser && labelFilterLoaded) {
localStorage.setItem(LABEL_FILTER_STORAGE_KEY, JSON.stringify(filterLabels));
}
});
// Toggle a label in the filter
function toggleLabel(label: string) {
if (filterLabels.includes(label)) {
filterLabels = filterLabels.filter(l => l !== label);
} else {
filterLabels = [...filterLabels, label];
}
}
// Compute all unique labels from all tiles
const allLabels = $derived.by(() => {
const labelSet = new Set<string>();
for (const tile of tiles) {
const labels = tile.stats?.labels || [];
for (const label of labels) {
labelSet.add(label);
}
}
return Array.from(labelSet).sort();
});
// Validate filterLabels - remove any that don't exist in allLabels
$effect(() => {
if (labelFilterLoaded && filterLabels.length > 0 && tiles.length > 0) {
const validLabels = filterLabels.filter(l => allLabels.includes(l));
if (validLabels.length !== filterLabels.length) {
filterLabels = validLabels;
}
}
});
// Filter grid items based on selected labels
const filteredGridItems = $derived.by(() => {
if (filterLabels.length === 0) {
return gridItems;
}
// Filter to only show tiles whose environments have at least one matching label
return gridItems.filter(item => {
const tile = tiles.find(t => t.id === item.id);
const tileLabels = tile?.stats?.labels || [];
return tileLabels.some(label => filterLabels.includes(label));
});
});
// AbortController for SSE stream cleanup
let abortController: AbortController | null = null;
// Stream connection status
let streamConnected = $state(false);
let streamConnecting = $state(false);
let streamError = $state<string | null>(null);
// Stats stream reconnection
const STREAM_CONNECT_TIMEOUT = 30000; // 30 seconds
const MAX_STREAM_RECONNECT_ATTEMPTS = 5;
const INITIAL_STREAM_RECONNECT_DELAY = 3000;
let streamReconnectAttempts = 0;
let streamReconnectTimer: ReturnType<typeof setTimeout> | null = null;
// Track previous initialized state to detect changes
let wasInitialized = true;
// Subscribe to dashboard data store for cached data
const unsubscribeDashboardData = dashboardData.subscribe(data => {
if (data.tiles.length > 0) {
tiles = data.tiles;
}
if (data.gridItems.length > 0) {
gridItems = data.gridItems;
}
// If cache was just invalidated (transition from true to false), trigger a refresh
if (wasInitialized && !data.initialized && data.tiles.length > 0 && !refreshing) {
fetchStatsStreaming(true);
}
wasInitialized = data.initialized;
});
// Subscribe to preferences store to load saved layout
const unsubscribePrefs = dashboardPreferences.subscribe(prefs => {
if (prefs.gridLayout.length > 0 && tiles.length > 0 && !prefsLoaded) {
// Apply saved layout
gridItems = prefs.gridLayout.map(item => ({
...item,
id: item.id
}));
prefsLoaded = true;
}
});
// Generate default grid layout for tiles
// Default height of 2 shows standard content (CPU/mem, resources, events)
function generateDefaultLayout(tileIds: number[]): GridItemLayout[] {
return tileIds.map((id, index) => ({
id,
x: index % GRID_COLS,
y: Math.floor(index / GRID_COLS) * 2,
w: 1,
h: 2
}));
}
// Merge new tiles with existing layout
function mergeLayout(tileIds: number[], existingLayout: GridItemLayout[]): GridItemLayout[] {
const result: GridItemLayout[] = [];
// Keep existing items in their positions
for (const item of existingLayout) {
if (tileIds.includes(item.id)) {
result.push(item);
}
}
// Add new tiles that don't have positions
const existingIds = new Set(existingLayout.map(item => item.id));
const newIds = tileIds.filter(id => !existingIds.has(id));
// Find next available position for new items
let nextY = result.length > 0 ? Math.max(...result.map(item => item.y + item.h)) : 0;
let nextX = 0;
for (const id of newIds) {
if (nextX >= GRID_COLS) {
nextX = 0;
nextY += 2;
}
result.push({
id,
x: nextX,
y: nextY,
w: 1,
h: 2
});
nextX++;
}
return result;
}
// Schedule stream reconnection with exponential backoff
function scheduleStreamReconnect() {
if (streamReconnectTimer) {
clearTimeout(streamReconnectTimer);
}
if (streamReconnectAttempts >= MAX_STREAM_RECONNECT_ATTEMPTS) {
streamError = 'Connection failed - click refresh to retry';
return;
}
const delay = INITIAL_STREAM_RECONNECT_DELAY * Math.pow(2, streamReconnectAttempts);
streamReconnectAttempts++;
streamReconnectTimer = setTimeout(() => {
fetchStatsStreaming(true);
}, delay);
}
// Reset stream reconnection state
function resetStreamReconnect() {
streamReconnectAttempts = 0;
if (streamReconnectTimer) {
clearTimeout(streamReconnectTimer);
streamReconnectTimer = null;
}
}
async function fetchStatsStreaming(isRefresh = false) {
// Abort any previous streaming request
if (abortController) {
abortController.abort();
}
abortController = new AbortController();
// Set up connection timeout
const timeoutController = new AbortController();
const timeoutId = setTimeout(() => {
timeoutController.abort();
}, STREAM_CONNECT_TIMEOUT);
// Update connection status
streamConnecting = true;
streamError = null;
if (isRefresh) {
refreshing = true;
// Mark all existing tiles as refreshing but keep their data
tiles = tiles.map(t => ({ ...t, loading: true }));
dashboardData.markAllLoading();
} else {
// Only show initial loading if we have no cached data
const cachedData = dashboardData.getData();
if (cachedData.tiles.length === 0) {
initialLoading = true;
tiles = [];
} else {
// We have cached data - do a background refresh
refreshing = true;
dashboardData.markAllLoading();
}
}
try {
const response = await fetch('/api/dashboard/stats/stream', {
signal: timeoutController.signal
});
clearTimeout(timeoutId);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
// Connection established
streamConnected = true;
streamConnecting = false;
resetStreamReconnect();
const reader = response.body?.getReader();
if (!reader) return;
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
let eventType = '';
let eventData = '';
for (const line of lines) {
if (line.startsWith('event: ')) {
eventType = line.slice(7);
} else if (line.startsWith('data: ')) {
eventData = line.slice(6);
if (eventType && eventData) {
try {
const data = JSON.parse(eventData);
if (eventType === 'environments') {
// Create tiles for each environment with initial loading state
const envList = data as (EnvironmentInfo & { loading?: EnvironmentStats['loading'] })[];
const cachedData = dashboardData.getData();
// Only reset tiles if we had no cached data
if (cachedData.tiles.length === 0) {
const newTiles = envList.map(env => ({
id: env.id,
stats: {
id: env.id,
name: env.name,
host: env.host,
port: env.port,
icon: env.icon,
socketPath: env.socketPath,
collectActivity: env.collectActivity ?? false,
collectMetrics: env.collectMetrics ?? true,
connectionType: env.connectionType || 'socket',
labels: env.labels || [],
scannerEnabled: false,
online: false,
containers: { total: 0, running: 0, stopped: 0, paused: 0, restarting: 0, unhealthy: 0 },
images: { total: 0, totalSize: 0 },
volumes: { total: 0, totalSize: 0 },
containersSize: 0,
buildCacheSize: 0,
networks: { total: 0 },
stacks: { total: 0, running: 0, partial: 0, stopped: 0 },
metrics: null,
events: { total: 0, today: 0 },
topContainers: [],
loading: env.loading
} as EnvironmentStats,
info: env,
loading: true
}));
tiles = newTiles;
dashboardData.setTiles(newTiles);
// Generate or merge grid layout
const tileIds = envList.map(env => env.id);
const savedLayout = $dashboardPreferences.gridLayout;
const newGridItems = savedLayout.length > 0
? mergeLayout(tileIds, savedLayout)
: generateDefaultLayout(tileIds);
gridItems = newGridItems;
dashboardData.setGridItems(newGridItems);
} else {
// Set loading states on existing tiles for refresh
// Also update collectActivity, collectMetrics, connectionType, labels, and port from fresh data
tiles = tiles.map(t => {
const envInfo = envList.find(e => e.id === t.id);
if (envInfo && t.stats) {
return {
...t,
stats: {
...t.stats,
port: envInfo.port,
collectActivity: envInfo.collectActivity ?? false,
collectMetrics: envInfo.collectMetrics ?? true,
connectionType: envInfo.connectionType || 'socket',
labels: envInfo.labels || [],
loading: envInfo.loading
},
info: envInfo,
loading: true
};
}
return t;
});
}
} else if (eventType === 'partial') {
// Progressive update - merge partial data into existing stats
// Only apply defined values to avoid overwriting with undefined
const partialStats = data as Partial<EnvironmentStats> & { id: number };
const tile = tiles.find(t => t.id === partialStats.id);
if (tile?.stats) {
// Use direct mutation for Svelte 5 reactivity
for (const [key, value] of Object.entries(partialStats)) {
if (value !== undefined && key !== 'id') {
(tile.stats as any)[key] = value;
}
}
}
// Also update the store
const definedStats: Partial<EnvironmentStats> = {};
for (const [key, value] of Object.entries(partialStats)) {
if (value !== undefined) {
(definedStats as any)[key] = value;
}
}
dashboardData.updateTilePartial(partialStats.id, definedStats);
} else if (eventType === 'stats') {
// Update the tile with actual stats (legacy/fallback)
const stats = data as EnvironmentStats;
tiles = tiles.map(t =>
t.id === stats.id
? { ...t, stats, loading: false }
: t
);
dashboardData.updateTile(stats.id, { stats, loading: false });
} else if (eventType === 'complete') {
// Environment fully loaded - clear loading states
const { id } = data;
const tile = tiles.find(t => t.id === id);
if (tile?.stats) {
// Use direct mutation for Svelte 5 reactivity
tile.stats.loading = undefined;
tile.loading = false;
}
dashboardData.updateTile(id, { loading: false });
} else if (eventType === 'error') {
// Per-environment error
const { id, error } = data;
// Update tile to show offline state
tiles = tiles.map(t => {
if (t.id === id && t.stats) {
return {
...t,
stats: { ...t.stats, online: false, error, loading: undefined },
loading: false
};
}
return t;
});
dashboardData.updateTile(id, { loading: false });
} else if (eventType === 'done') {
initialLoading = false;
refreshing = false;
dashboardData.setInitialized(true);
}
} catch (e) {
console.error('Failed to parse SSE data:', e);
}
}
eventType = '';
eventData = '';
}
}
}
// Stream ended normally - keep connected status since we have data
// (dashboard stream is one-shot, not persistent like EventSource)
} catch (error) {
clearTimeout(timeoutId);
streamConnecting = false;
streamConnected = false;
// Ignore abort errors from our own abortController - these are intentional
if (error instanceof Error && error.name === 'AbortError') {
// Check if this was a timeout (from timeoutController) vs intentional abort
if (timeoutController.signal.aborted) {
streamError = 'Connection timed out';
scheduleStreamReconnect();
}
return;
}
console.error('Failed to fetch dashboard stats:', error);
// Convert technical errors to user-friendly messages
const rawError = error instanceof Error ? error.message : 'Connection failed';
if (rawError.includes('typo') || rawError.includes('verbose')) {
streamError = 'Connection failed';
} else if (rawError.includes('FailedToOpenSocket') || rawError.includes('ECONNREFUSED')) {
streamError = 'Docker not accessible';
} else if (rawError.includes('ECONNRESET') || rawError.includes('closed')) {
streamError = 'Connection lost';
} else {
streamError = 'Connection failed';
}
scheduleStreamReconnect();
} finally {
initialLoading = false;
refreshing = false;
}
}
// Handle grid item changes (position or size)
function handleGridChange(updatedItems: GridItemLayout[]) {
// When filtering is active, merge updated positions back into full gridItems
// This preserves positions of hidden tiles
const updatedMap = new Map(updatedItems.map(item => [item.id, item]));
gridItems = gridItems.map(item => {
const updated = updatedMap.get(item.id);
return updated ? { ...item, x: updated.x, y: updated.y, w: updated.w, h: updated.h } : item;
});
dashboardData.setGridItems(gridItems);
// Save the new layout (convert to store format)
dashboardPreferences.setGridLayout(gridItems.map(item => ({
id: item.id,
x: item.x,
y: item.y,
w: item.w,
h: item.h
})));
}
// Get tile by id
function getTileById(id: number): TileItem | undefined {
return tiles.find(t => t.id === id);
}
// Handle tile click - select environment and navigate to containers
function handleTileClick(envId: number) {
const tile = getTileById(envId);
if (tile?.stats) {
currentEnvironment.set({ id: envId, name: tile.stats.name });
goto('/containers');
}
}
// Handle events section click - select environment and navigate to activity
function handleEventsClick(envId: number) {
const tile = getTileById(envId);
if (tile?.stats) {
currentEnvironment.set({ id: envId, name: tile.stats.name });
goto(`/activity?env=${envId}`);
}
}
// Apply autolayout - arrange all tiles with specified dimensions
function applyAutoLayout(width: number, height: number) {
const tileIds = tiles.map(t => t.id);
const newGridItems: GridItemLayout[] = [];
let x = 0;
let y = 0;
for (const id of tileIds) {
// Check if tile fits in current row
if (x + width > GRID_COLS) {
x = 0;
y += height;
}
newGridItems.push({
id,
x,
y,
w: width,
h: height
});
x += width;
}
gridItems = newGridItems;
dashboardData.setGridItems(newGridItems);
dashboardPreferences.setGridLayout(newGridItems.map(item => ({
id: item.id,
x: item.x,
y: item.y,
w: item.w,
h: item.h
})));
// Remove focus from trigger button
if (document.activeElement instanceof HTMLElement) {
document.activeElement.blur();
}
}
// Actions that affect container counts and should trigger a refresh
const CONTAINER_STATE_ACTIONS = ['start', 'stop', 'die', 'kill', 'restart', 'pause', 'unpause', 'create', 'destroy'];
// Debounce refresh per environment to avoid hammering the API
const pendingRefreshes: Map<number, ReturnType<typeof setTimeout>> = new Map();
// Handle real-time container events for immediate dashboard updates
function handleRealtimeEvent(event: any) {
const envId = event.environmentId;
if (!envId) return;
// Update the tile's event counts and recent events list immediately
tiles = tiles.map(t => {
if (t.id === envId && t.stats) {
const newEvent = {
container_name: event.containerName || 'unknown',
action: event.action,
timestamp: event.timestamp
};
// Add to recent events (keep max 10, newest first)
const updatedRecentEvents = [newEvent, ...(t.stats.recentEvents || [])].slice(0, 10);
// Increment event counts
const isToday = new Date(event.timestamp).toDateString() === new Date().toDateString();
return {
...t,
stats: {
...t.stats,
events: {
total: (t.stats.events?.total || 0) + 1,
today: (t.stats.events?.today || 0) + (isToday ? 1 : 0)
},
recentEvents: updatedRecentEvents
}
};
}
return t;
});
// Also update the store
const tile = tiles.find(t => t.id === envId);
if (tile?.stats) {
dashboardData.updateTilePartial(envId, {
events: tile.stats.events,
recentEvents: tile.stats.recentEvents
});
}
// If this is a container state change, trigger a debounced refresh for the full tile
if (CONTAINER_STATE_ACTIONS.includes(event.action)) {
// Cancel any pending refresh for this environment
const pending = pendingRefreshes.get(envId);
if (pending) {
clearTimeout(pending);
}
// Schedule a refresh after 500ms to batch rapid events
pendingRefreshes.set(envId, setTimeout(() => {
pendingRefreshes.delete(envId);
refreshEnvironmentTile(envId);
}, 500));
}
}
// Refresh a single environment tile
async function refreshEnvironmentTile(envId: number) {
try {
const response = await fetch(`/api/dashboard/stats?env=${envId}`);
if (!response.ok) return;
const stats = await response.json() as EnvironmentStats;
tiles = tiles.map(t =>
t.id === envId
? { ...t, stats, loading: false }
: t
);
dashboardData.updateTile(envId, { stats, loading: false });
} catch {
// Ignore errors - next full refresh will catch up
}
}
// Connect to real-time event stream
function connectEventStream() {
if (eventSource) {
eventSource.close();
}
if (eventReconnectTimer) {
clearTimeout(eventReconnectTimer);
eventReconnectTimer = null;
}
eventSource = new EventSource('/api/activity/events');
eventSource.addEventListener('open', () => {
// Show reconnection success toast if we were reconnecting
if (eventReconnectAttempts > 0) {
toast.success('Live updates reconnected');
}
eventReconnectAttempts = 0; // Reset backoff on successful connection
});
eventSource.addEventListener('activity', (e) => {
try {
const event = JSON.parse(e.data);
handleRealtimeEvent(event);
} catch {
// Ignore parse errors
}
});
// Handle environment status changes (online/offline)
eventSource.addEventListener('env_status', (e) => {
try {
const status = JSON.parse(e.data);
// Update tile online status
tiles = tiles.map(t => {
if (t.id === status.envId && t.stats) {
return {
...t,
stats: {
...t.stats,
online: status.online,
error: status.error
}
};
}
return t;
});
dashboardData.updateTilePartial(status.envId, {
online: status.online,
error: status.error
});
} catch {
// Ignore parse errors
}
});
eventSource.onerror = () => {
eventSource?.close();
eventSource = null;
// Exponential backoff reconnection
if (eventReconnectAttempts < MAX_EVENT_RECONNECT_ATTEMPTS) {
// Show toast only on first disconnect
if (eventReconnectAttempts === 0) {
toast.warning('Live updates disconnected, reconnecting...');
}
const delay = Math.min(BASE_EVENT_RECONNECT_DELAY * Math.pow(2, eventReconnectAttempts), 60000);
eventReconnectAttempts++;
eventReconnectTimer = setTimeout(connectEventStream, delay);
} else {
toast.error('Live updates failed - refresh page to retry');
}
};
}
let refreshInterval: ReturnType<typeof setInterval>;
// Handle tab visibility changes (e.g., user switches back from another tab)
function handleVisibilityChange() {
if (document.visibilityState === 'visible') {
// Tab became visible - check and restore connections
// Clear any pending reconnection timers
if (eventReconnectTimer) {
clearTimeout(eventReconnectTimer);
eventReconnectTimer = null;
}
if (streamReconnectTimer) {
clearTimeout(streamReconnectTimer);
streamReconnectTimer = null;
}
// Reset reconnection counters to give fresh attempts
eventReconnectAttempts = 0;
streamReconnectAttempts = 0;
streamError = null;
// Reconnect event stream if it's closed or in error state
if (!eventSource || eventSource.readyState !== EventSource.OPEN) {
connectEventStream();
}
// Trigger a stats refresh if we haven't refreshed recently
// (the 30s interval may have been paused while backgrounded)
if (!refreshing && !streamConnecting) {
fetchStatsStreaming(true);
}
}
}
onMount(async () => {
// Listen for tab visibility changes to reconnect when user returns
document.addEventListener('visibilitychange', handleVisibilityChange);
// Chrome 77+ Page Lifecycle API - fires when frozen tab is resumed
document.addEventListener('resume', handleVisibilityChange);
// Load label filter from localStorage
loadLabelFilter();
// Load preferences first
await dashboardPreferences.load();
// Check if we have valid cached data (not invalidated)
const cachedData = dashboardData.getData();
if (cachedData.tiles.length > 0 && cachedData.initialized) {
// Use cached data immediately
tiles = cachedData.tiles;
gridItems = cachedData.gridItems;
initialLoading = false;
// Then refresh in background
fetchStatsStreaming(true);
} else {
// No cache or cache invalidated - do initial fetch
await fetchStatsStreaming();
}
// Connect to real-time event stream for immediate updates
connectEventStream();
// Refresh stats every 30 seconds
refreshInterval = setInterval(() => fetchStatsStreaming(true), 30000);
});
onDestroy(() => {
// Remove visibility change listeners
document.removeEventListener('visibilitychange', handleVisibilityChange);
document.removeEventListener('resume', handleVisibilityChange);
// Abort any pending SSE stream
if (abortController) {
abortController.abort();
}
// Clear stream reconnection timer
if (streamReconnectTimer) {
clearTimeout(streamReconnectTimer);
streamReconnectTimer = null;
}
// Close real-time event stream and clear reconnect timer
if (eventReconnectTimer) {
clearTimeout(eventReconnectTimer);
eventReconnectTimer = null;
}
if (eventSource) {
eventSource.close();
eventSource = null;
}
// Clear any pending tile refreshes
for (const timeout of pendingRefreshes.values()) {
clearTimeout(timeout);
}
pendingRefreshes.clear();
if (refreshInterval) {
clearInterval(refreshInterval);
}
unsubscribeDashboardData();
unsubscribePrefs();
});
</script>
<div class="flex flex-col gap-4 h-full overflow-auto pb-4">
<!-- Header -->
<div class="flex flex-wrap justify-between items-center gap-3">
<div class="flex items-center gap-4">
<PageHeader icon={LayoutGrid} title="Environments" />
<!-- Label filter toggles (only show if there are labels) -->
{#if allLabels.length > 0}
<div class="flex items-center gap-1.5">
<button
type="button"
class="px-2.5 py-1 text-xs font-medium rounded transition-colors {filterLabels.length === 0
? 'bg-primary text-primary-foreground'
: 'bg-muted text-muted-foreground hover:bg-muted/80'}"
onclick={() => filterLabels = []}
>
All
</button>
{#each allLabels as label}
{@const isSelected = filterLabels.includes(label)}
<button
type="button"
class="px-2.5 py-1 text-xs font-medium rounded transition-colors border"
style={isSelected
? `background-color: ${getLabelBgColor(label)}; border-color: ${getLabelColor(label)}; color: ${getLabelColor(label)};`
: `background-color: transparent; border-color: hsl(var(--border)); color: hsl(var(--muted-foreground));`}
onclick={() => toggleLabel(label)}
>
{label}
</button>
{/each}
</div>
{/if}
</div>
<div class="flex items-center gap-1">
<!-- Add environment button -->
<button
onclick={() => goto('/settings?tab=environments&new=true')}
class="p-1.5 rounded hover:bg-muted transition-colors"
title="Add environment"
>
<Plus class="w-4 h-4" />
</button>
<!-- Autolayout dropdown -->
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<button
{...props}
class="p-1.5 rounded hover:bg-muted transition-colors"
title="Auto-layout tiles"
>
<LayoutTemplate class="w-4 h-4" />
</button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end" class="w-36">
<DropdownMenu.Item onclick={() => applyAutoLayout(1, 1)} class="flex items-center gap-2 cursor-pointer">
<Square class="w-4 h-4" />
<span>Compact</span>
</DropdownMenu.Item>
<DropdownMenu.Item onclick={() => applyAutoLayout(1, 2)} class="flex items-center gap-2 cursor-pointer">
<RectangleVertical class="w-4 h-4" />
<span>Standard</span>
</DropdownMenu.Item>
<DropdownMenu.Item onclick={() => applyAutoLayout(1, 4)} class="flex items-center gap-2 cursor-pointer">
<Rows3 class="w-4 h-4" />
<span>Detailed</span>
</DropdownMenu.Item>
<DropdownMenu.Item onclick={() => applyAutoLayout(2, 4)} class="flex items-center gap-2 cursor-pointer">
<Maximize2 class="w-4 h-4" />
<span>Full</span>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
<!-- Refresh button -->
<button
onclick={() => fetchStatsStreaming(true)}
class="p-1.5 rounded hover:bg-muted transition-colors"
title="Refresh"
disabled={refreshing}
>
<RefreshCw class="w-4 h-4 {refreshing ? 'animate-spin' : ''}" />
</button>
</div>
</div>
<!-- Initial loading state before any tiles -->
{#if initialLoading && tiles.length === 0}
<div class="flex items-center justify-center gap-2 text-muted-foreground py-8">
<Loader2 class="w-5 h-5 animate-spin text-primary" />
<span class="text-sm">Loading environments...</span>
</div>
{:else if tiles.length === 0}
<!-- No environments -->
<div class="flex flex-col items-center justify-center h-64 text-muted-foreground">
<div class="w-16 h-16 mb-4 rounded-2xl border-2 border-dashed border-muted-foreground/30 flex items-center justify-center">
<Server class="w-8 h-8 opacity-40" />
</div>
<p class="text-lg font-medium text-foreground/70">No environments configured</p>
<p class="text-sm text-muted-foreground mb-4">Add an environment to start managing your Docker hosts</p>
<Button variant="outline" size="sm" onclick={() => goto('/settings?tab=environments')}>
Go to Settings
</Button>
</div>
{:else if filteredGridItems.length === 0}
<!-- Filter shows no results -->
<div class="flex flex-col items-center justify-center h-64 text-muted-foreground">
<div class="w-16 h-16 mb-4 rounded-2xl border-2 border-dashed border-muted-foreground/30 flex items-center justify-center">
<Tags class="w-8 h-8 opacity-40" />
</div>
<p class="text-lg font-medium text-foreground/70">No matching environments</p>
<p class="text-sm text-muted-foreground mb-4">No environments match the selected label filters</p>
<Button variant="outline" size="sm" onclick={() => filterLabels = []}>
Clear filters
</Button>
</div>
{:else}
<!-- Custom Draggable Grid -->
<DraggableGrid
items={filteredGridItems}
cols={GRID_COLS}
rowHeight={GRID_ROW_HEIGHT}
gap={10}
minW={1}
maxW={2}
minH={1}
maxH={4}
onchange={handleGridChange}
onitemclick={handleTileClick}
>
{#snippet children({ item })}
{@const tile = getTileById(item.id)}
{#if tile}
{#if tile.loading && !tile.stats}
<!-- Show skeleton while loading -->
<EnvironmentTileSkeleton
name={tile.info?.name}
host={tile.info?.host}
width={item.w}
height={item.h}
/>
{:else if tile.stats}
<!-- Show actual tile with data -->
<EnvironmentTile stats={tile.stats} width={item.w} height={item.h} oneventsclick={() => handleEventsClick(tile.stats!.id)} />
{/if}
{/if}
{/snippet}
</DraggableGrid>
{/if}
</div>