mirror of
https://github.com/khoaliber/dockhand.git
synced 2026-03-02 13:17:57 +00:00
1001 lines
31 KiB
Svelte
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>
|