Initial commit

This commit is contained in:
Jarek Krochmalski
2025-12-28 21:16:03 +01:00
commit 62e3c6439e
552 changed files with 104858 additions and 0 deletions

View File

@@ -0,0 +1,575 @@
<script lang="ts" module>
export interface GridItemLayout {
id: number;
x: number;
y: number;
w: number;
h: number;
}
</script>
<script lang="ts">
import { untrack } from 'svelte';
interface Props {
items: GridItemLayout[];
cols?: number;
rowHeight?: number;
gap?: number;
minW?: number;
maxW?: number;
minH?: number;
maxH?: number;
onchange?: (items: GridItemLayout[]) => void;
onitemclick?: (id: number) => void;
children: import('svelte').Snippet<[{ item: GridItemLayout; width: number; height: number }]>;
}
let {
items = $bindable([]),
cols = 4,
rowHeight = 175,
gap = 10,
minW = 1,
maxW = 2,
minH = 1,
maxH = 4,
onchange,
onitemclick,
children
}: Props = $props();
// Drag threshold - if mouse moves less than this, treat as click
const DRAG_THRESHOLD = 5;
let containerRef: HTMLDivElement;
let containerWidth = $state(0);
// Calculate column width based on container
const colWidth = $derived(containerWidth > 0 ? (containerWidth - (cols - 1) * gap) / cols : 280);
// Calculate max rows based on items
const maxRow = $derived(
items.length > 0 ? Math.max(...items.map((item) => item.y + item.h)) : 1
);
// Grid height based on content
const gridHeight = $derived(maxRow * rowHeight + (maxRow - 1) * gap);
// Dragging state
let dragItem = $state<GridItemLayout | null>(null);
let dragStartX = $state(0);
let dragStartY = $state(0);
let dragOffsetX = $state(0);
let dragOffsetY = $state(0);
// Resizing state
let resizeItem = $state<GridItemLayout | null>(null);
let resizeStartW = $state(0);
let resizeStartH = $state(0);
let resizeStartX = $state(0);
let resizeStartY = $state(0);
// Pixel-based resize offsets for smooth visual feedback
let resizePixelDeltaX = $state(0);
let resizePixelDeltaY = $state(0);
// Preview position during drag/resize
let previewX = $state(0);
let previewY = $state(0);
let previewW = $state(1);
let previewH = $state(1);
// Store original positions before drag/resize for real-time displacement
let originalPositions = $state<Map<number, { x: number; y: number }>>(new Map());
let lastPreviewX = $state(-1);
let lastPreviewY = $state(-1);
let lastPreviewW = $state(-1);
let lastPreviewH = $state(-1);
let dragActuallyMoved = $state(false);
// Convert pixel position to grid units
function pixelToGrid(px: number, cellSize: number): number {
return Math.round(px / (cellSize + gap));
}
// Convert grid units to pixel position
function gridToPixel(grid: number, cellSize: number): number {
return grid * (cellSize + gap);
}
// Get item position in pixels
function getItemStyle(item: GridItemLayout): string {
const left = gridToPixel(item.x, colWidth);
const top = gridToPixel(item.y, rowHeight);
const width = item.w * colWidth + (item.w - 1) * gap;
const height = item.h * rowHeight + (item.h - 1) * gap;
return `left: ${left}px; top: ${top}px; width: ${width}px; height: ${height}px;`;
}
// Check for collisions with other items
function hasCollision(
item: GridItemLayout,
testItem: { x: number; y: number; w: number; h: number }
): boolean {
if (item.id === dragItem?.id || item.id === resizeItem?.id) return false;
return !(
testItem.x + testItem.w <= item.x ||
testItem.x >= item.x + item.w ||
testItem.y + testItem.h <= item.y ||
testItem.y >= item.y + item.h
);
}
// Find valid position avoiding collisions
function findValidPosition(
x: number,
y: number,
w: number,
h: number,
excludeId: number
): { x: number; y: number } {
// Clamp to grid bounds
x = Math.max(0, Math.min(x, cols - w));
y = Math.max(0, y);
const testItem = { x, y, w, h };
// Check for collisions
const hasAnyCollision = items.some((item) => item.id !== excludeId && hasCollision(item, testItem));
if (!hasAnyCollision) {
return { x, y };
}
// If collision, try to find nearby valid position
for (let offsetY = 0; offsetY <= 10; offsetY++) {
for (let offsetX = -2; offsetX <= 2; offsetX++) {
const testX = Math.max(0, Math.min(x + offsetX, cols - w));
const testY = Math.max(0, y + offsetY);
const test = { x: testX, y: testY, w, h };
const collision = items.some((item) => item.id !== excludeId && hasCollision(item, test));
if (!collision) {
return { x: testX, y: testY };
}
}
}
return { x, y };
}
// Push colliding items down (returns new array)
function pushCollidingItems(movedItem: GridItemLayout, sourceItems: GridItemLayout[]): GridItemLayout[] {
const newItems = sourceItems.map(item => ({ ...item }));
let changed = true;
let iterations = 0;
const maxIterations = 100; // Prevent infinite loops
while (changed && iterations < maxIterations) {
changed = false;
iterations++;
for (const item of newItems) {
if (item.id === movedItem.id) continue;
if (hasCollision(item, movedItem)) {
// Push this item down
item.y = movedItem.y + movedItem.h;
changed = true;
}
}
}
return newItems;
}
// Push items in real-time during drag/resize
function pushItemsRealTime(ghostItem: { x: number; y: number; w: number; h: number }, excludeId: number) {
// First restore original positions
const baseItems = items.map(item => {
const orig = originalPositions.get(item.id);
if (orig && item.id !== excludeId) {
return { ...item, x: orig.x, y: orig.y };
}
return { ...item };
});
// Then push items based on ghost position
const movedItem = { ...ghostItem, id: excludeId };
items = pushCollidingItems(movedItem as GridItemLayout, baseItems);
}
// Drag handlers
function handleDragStart(e: PointerEvent, item: GridItemLayout) {
if (e.button !== 0) return;
e.preventDefault();
dragItem = item;
dragStartX = e.clientX;
dragStartY = e.clientY;
dragOffsetX = 0;
dragOffsetY = 0;
dragActuallyMoved = false;
previewX = item.x;
previewY = item.y;
previewW = item.w;
previewH = item.h;
lastPreviewX = item.x;
lastPreviewY = item.y;
// Store original positions for all items
originalPositions = new Map(items.map(i => [i.id, { x: i.x, y: i.y }]));
(e.target as HTMLElement).setPointerCapture(e.pointerId);
window.addEventListener('pointermove', handleDragMove);
window.addEventListener('pointerup', handleDragEnd);
}
function handleDragMove(e: PointerEvent) {
if (!dragItem) return;
dragOffsetX = e.clientX - dragStartX;
dragOffsetY = e.clientY - dragStartY;
// Check if movement exceeds threshold (for click vs drag detection)
if (!dragActuallyMoved && (Math.abs(dragOffsetX) > DRAG_THRESHOLD || Math.abs(dragOffsetY) > DRAG_THRESHOLD)) {
dragActuallyMoved = true;
}
// Calculate new grid position
const startLeft = gridToPixel(dragItem.x, colWidth);
const startTop = gridToPixel(dragItem.y, rowHeight);
const newLeft = startLeft + dragOffsetX;
const newTop = startTop + dragOffsetY;
const newX = pixelToGrid(newLeft, colWidth);
const newY = pixelToGrid(newTop, rowHeight);
// Clamp to valid range
previewX = Math.max(0, Math.min(newX, cols - dragItem.w));
previewY = Math.max(0, newY);
// Push items in real-time if preview position changed
if (previewX !== lastPreviewX || previewY !== lastPreviewY) {
lastPreviewX = previewX;
lastPreviewY = previewY;
pushItemsRealTime({ x: previewX, y: previewY, w: dragItem.w, h: dragItem.h }, dragItem.id);
}
}
function handleDragEnd(e: PointerEvent) {
if (!dragItem) return;
const clickedItemId = dragItem.id;
// If we didn't actually drag (just clicked), treat as a click
if (!dragActuallyMoved) {
window.removeEventListener('pointermove', handleDragMove);
window.removeEventListener('pointerup', handleDragEnd);
dragItem = null;
dragOffsetX = 0;
dragOffsetY = 0;
originalPositions = new Map();
onitemclick?.(clickedItemId);
return;
}
// Update item position (items are already pushed during drag)
items = items.map((item) =>
item.id === dragItem!.id ? { ...item, x: previewX, y: previewY } : item
);
onchange?.(items);
window.removeEventListener('pointermove', handleDragMove);
window.removeEventListener('pointerup', handleDragEnd);
dragItem = null;
dragOffsetX = 0;
dragOffsetY = 0;
originalPositions = new Map();
}
// Resize handlers
function handleResizeStart(e: PointerEvent, item: GridItemLayout) {
if (e.button !== 0) return;
e.preventDefault();
e.stopPropagation();
resizeItem = item;
resizeStartW = item.w;
resizeStartH = item.h;
resizeStartX = e.clientX;
resizeStartY = e.clientY;
resizePixelDeltaX = 0;
resizePixelDeltaY = 0;
previewX = item.x;
previewY = item.y;
previewW = item.w;
previewH = item.h;
lastPreviewW = item.w;
lastPreviewH = item.h;
// Store original positions for all items
originalPositions = new Map(items.map(i => [i.id, { x: i.x, y: i.y }]));
(e.target as HTMLElement).setPointerCapture(e.pointerId);
window.addEventListener('pointermove', handleResizeMove);
window.addEventListener('pointerup', handleResizeEnd);
}
function handleResizeMove(e: PointerEvent) {
if (!resizeItem) return;
const deltaX = e.clientX - resizeStartX;
const deltaY = e.clientY - resizeStartY;
// Track raw pixel delta for smooth visual feedback
resizePixelDeltaX = deltaX;
resizePixelDeltaY = deltaY;
// Calculate new size in grid units
const newW = Math.round(resizeStartW + deltaX / (colWidth + gap));
const newH = Math.round(resizeStartH + deltaY / (rowHeight + gap));
// Clamp to min/max
previewW = Math.max(minW, Math.min(maxW, newW, cols - resizeItem.x));
previewH = Math.max(minH, Math.min(maxH, newH));
// Push items in real-time if preview size changed
if (previewW !== lastPreviewW || previewH !== lastPreviewH) {
lastPreviewW = previewW;
lastPreviewH = previewH;
pushItemsRealTime({ x: resizeItem.x, y: resizeItem.y, w: previewW, h: previewH }, resizeItem.id);
}
}
function handleResizeEnd(e: PointerEvent) {
if (!resizeItem) return;
// Update item size (items are already pushed during resize)
items = items.map((item) =>
item.id === resizeItem!.id ? { ...item, w: previewW, h: previewH } : item
);
onchange?.(items);
window.removeEventListener('pointermove', handleResizeMove);
window.removeEventListener('pointerup', handleResizeEnd);
resizeItem = null;
resizePixelDeltaX = 0;
resizePixelDeltaY = 0;
originalPositions = new Map();
}
// Observe container width
$effect(() => {
if (!containerRef) return;
const observer = new ResizeObserver((entries) => {
containerWidth = entries[0].contentRect.width;
});
observer.observe(containerRef);
return () => observer.disconnect();
});
</script>
<div
class="draggable-grid"
bind:this={containerRef}
style="height: {gridHeight}px;"
>
{#each items as item (item.id)}
{@const isDragTarget = dragItem?.id === item.id}
{@const isDragging = isDragTarget && dragActuallyMoved}
{@const isResizing = resizeItem?.id === item.id}
{@const isActive = isDragging || isResizing}
{@const itemWidth = item.w * colWidth + (item.w - 1) * gap}
{@const itemHeight = item.h * rowHeight + (item.h - 1) * gap}
<!-- Preview placeholder during drag/resize -->
{#if isDragging || isResizing}
<div
class="grid-item-preview"
data-size="{isResizing ? previewW : previewW}×{isResizing ? previewH : previewH}"
style="left: {gridToPixel(isResizing ? item.x : previewX, colWidth)}px; top: {gridToPixel(isResizing ? item.y : previewY, rowHeight)}px; width: {previewW * colWidth + (previewW - 1) * gap}px; height: {previewH * rowHeight + (previewH - 1) * gap}px;"
></div>
{/if}
<!-- Actual item -->
{@const baseWidth = item.w * colWidth + (item.w - 1) * gap}
{@const baseHeight = item.h * rowHeight + (item.h - 1) * gap}
{@const minPixelW = minW * colWidth + (minW - 1) * gap}
{@const maxPixelW = Math.min(maxW, cols - item.x) * colWidth + (Math.min(maxW, cols - item.x) - 1) * gap}
{@const minPixelH = minH * rowHeight + (minH - 1) * gap}
{@const maxPixelH = maxH * rowHeight + (maxH - 1) * gap}
{@const currentWidth = isResizing ? Math.max(minPixelW, Math.min(maxPixelW, baseWidth + resizePixelDeltaX)) : baseWidth}
{@const currentHeight = isResizing ? Math.max(minPixelH, Math.min(maxPixelH, baseHeight + resizePixelDeltaY)) : baseHeight}
<div
class="grid-item"
class:dragging={isDragging}
class:resizing={isResizing}
style="left: {gridToPixel(item.x, colWidth)}px; top: {gridToPixel(item.y, rowHeight)}px; width: {currentWidth}px; height: {currentHeight}px; {isDragTarget ? `transform: translate(${dragOffsetX}px, ${dragOffsetY}px);` : ''}"
onpointerdown={(e) => {
// Check if clicking on resize handle area (bottom-right 28x28 corner)
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
const isInResizeArea = x > rect.width - 28 && y > rect.height - 28;
if (isInResizeArea) {
handleResizeStart(e, item);
} else {
handleDragStart(e, item);
}
}}
>
<!-- Content -->
<div class="grid-item-content">
{@render children({ item, width: itemWidth, height: itemHeight })}
</div>
<!-- Resize handle visual indicator -->
<div class="tile-resize-handle">
<svg viewBox="0 0 10 10" fill="currentColor">
<path d="M9 1v8H1" stroke="currentColor" stroke-width="1.5" fill="none" stroke-linecap="round"/>
</svg>
</div>
</div>
{/each}
</div>
<style>
.draggable-grid {
position: relative;
width: 100%;
min-height: 300px;
}
.grid-item {
position: absolute;
backface-visibility: hidden;
-webkit-backface-visibility: hidden;
cursor: pointer;
touch-action: none;
}
.grid-item.dragging {
z-index: 100;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
cursor: grabbing;
}
.grid-item.dragging,
.grid-item.dragging * {
cursor: grabbing !important;
}
.grid-item.resizing {
z-index: 100;
}
.grid-item.resizing,
.grid-item.resizing * {
cursor: grabbing !important;
}
.grid-item.dragging *,
.grid-item.resizing * {
user-select: none;
-webkit-user-select: none;
}
.grid-item-content {
width: 100%;
height: 100%;
overflow: hidden;
}
.tile-resize-handle {
position: absolute;
bottom: 0;
right: 0;
width: 28px;
height: 28px;
cursor: grab;
z-index: 20;
display: flex;
align-items: flex-end;
justify-content: flex-end;
padding: 6px;
opacity: 0;
transition: opacity 0.2s ease;
touch-action: none;
user-select: none;
-webkit-user-select: none;
background: transparent;
}
.tile-resize-handle svg {
width: 12px;
height: 12px;
color: hsl(var(--muted-foreground) / 0.6);
transition: color 0.2s ease;
pointer-events: none;
}
.grid-item:hover .tile-resize-handle {
opacity: 1;
}
.tile-resize-handle:hover svg {
color: hsl(var(--primary));
}
.grid-item-preview {
position: absolute;
background: hsl(var(--primary) / 0.12);
border: 2px dashed hsl(var(--primary) / 0.6);
border-radius: 8px;
z-index: 0;
transition: left 0.15s ease, top 0.15s ease, width 0.15s ease, height 0.15s ease;
box-shadow:
inset 0 0 20px hsl(var(--primary) / 0.1),
0 0 15px hsl(var(--primary) / 0.15);
}
.grid-item-preview::before {
content: '';
position: absolute;
inset: 0;
border-radius: 6px;
background: repeating-linear-gradient(
45deg,
transparent,
transparent 8px,
hsl(var(--primary) / 0.05) 8px,
hsl(var(--primary) / 0.05) 16px
);
animation: stripes 0.5s linear infinite;
}
@keyframes stripes {
0% {
background-position: 0 0;
}
100% {
background-position: 22.6px 0;
}
}
/* Size indicator badge on preview */
.grid-item-preview::after {
content: attr(data-size);
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: hsl(var(--primary));
color: hsl(var(--primary-foreground));
padding: 4px 10px;
border-radius: 6px;
font-size: 12px;
font-weight: 600;
white-space: nowrap;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
}
</style>

View File

@@ -0,0 +1,707 @@
<script lang="ts">
import * as Card from '$lib/components/ui/card';
import { Wifi, WifiOff, ShieldCheck, Activity, Cpu, Settings, Unplug, Icon, Route, UndoDot, CircleArrowUp, CircleFadingArrowUp } from 'lucide-svelte';
import { whale } from '@lucide/lab';
import { getIconComponent } from '$lib/utils/icons';
import { goto } from '$app/navigation';
import { canAccess } from '$lib/stores/auth';
import type { EnvironmentStats } from '../api/dashboard/stats/+server';
import {
DashboardHeader,
DashboardLabels,
DashboardContainerStats,
DashboardHealthBanner,
DashboardCpuMemoryBars,
DashboardResourceStats,
DashboardEventsSummary,
DashboardRecentEvents,
DashboardTopContainers,
DashboardDiskUsage,
DashboardCpuMemoryCharts,
DashboardOfflineState
} from '.';
interface Props {
stats: EnvironmentStats;
width?: number;
height?: number;
oneventsclick?: () => void;
}
let { stats, width = 1, height = 1, oneventsclick }: Props = $props();
const EnvIcon = $derived(getIconComponent(stats.icon));
// Specific tile size conditionals for easy customization
const is1x1 = $derived(width === 1 && height === 1);
const is1x2 = $derived(width === 1 && height === 2);
const is1x3 = $derived(width === 1 && height === 3);
const is1x4 = $derived(width === 1 && height >= 4);
const is2x1 = $derived(width >= 2 && height === 1);
const is2x2 = $derived(width >= 2 && height === 2);
const is2x3 = $derived(width >= 2 && height === 3);
const is2x4 = $derived(width >= 2 && height >= 4);
// Helper flags
const isMini = $derived(is1x1 || is2x1);
const isWide = $derived(width >= 2);
// Show offline when online is explicitly false
// Only delay showing offline if online is undefined (truly unknown state during initial load)
const isStillLoading = $derived(stats.loading && Object.values(stats.loading).some(v => v === true));
const showOffline = $derived(stats.online === false || (!stats.online && !isStillLoading));
</script>
<Card.Root
class="hover:shadow-[inset_0_0_0_2px_hsl(var(--primary)/0.2)] transition-all duration-200 h-full overflow-hidden {showOffline ? 'opacity-60' : ''}"
>
<!-- ==================== 1x1 TILE ==================== -->
{#if is1x1}
<Card.Header class="pb-2 overflow-hidden">
<!-- Unified header row -->
<div class="flex items-center justify-between gap-2 w-full overflow-hidden">
<!-- Left: Icons + Name/Host -->
<div class="flex items-center gap-2 min-w-0 overflow-hidden flex-1">
<div class="p-1.5 rounded-lg shrink-0 {stats.online ? 'bg-primary/10' : 'bg-muted'}">
<EnvIcon class="w-4 h-4 {stats.online ? 'text-primary' : 'text-muted-foreground'}" />
</div>
{#if stats.connectionType === 'socket' || !stats.connectionType}
<span title="Unix socket connection" class="shrink-0">
<Unplug class="w-4 h-4 text-cyan-500 glow-cyan" />
</span>
{:else if stats.connectionType === 'direct'}
<span title="Direct Docker connection" class="shrink-0">
<Icon iconNode={whale} class="w-4 h-4 text-blue-500 glow-blue" />
</span>
{:else if stats.connectionType === 'hawser-standard'}
<span title="Hawser agent (standard mode)" class="shrink-0">
<Route class="w-4 h-4 text-purple-500 glow-purple" />
</span>
{:else if stats.connectionType === 'hawser-edge'}
<span title="Hawser agent (edge mode)" class="shrink-0">
<UndoDot class="w-4 h-4 text-green-500 glow-green" />
</span>
{/if}
<div class="min-w-0 overflow-hidden">
<div class="flex items-center gap-1.5">
<span class="font-medium text-sm truncate">{stats.name}</span>
{#if !showOffline}
<Wifi class="w-3 h-3 text-green-500 shrink-0" />
{:else}
<WifiOff class="w-3 h-3 text-red-500 shrink-0" />
{/if}
</div>
<span class="text-xs text-muted-foreground truncate block" title={stats.connectionType === 'socket' ? (stats.socketPath || '/var/run/docker.sock') : stats.connectionType === 'hawser-edge' ? 'Edge connection' : (stats.port ? `${stats.host}:${stats.port}` : stats.host || 'Unknown host')}>
{stats.connectionType === 'socket' ? (stats.socketPath || '/var/run/docker.sock') :
stats.connectionType === 'hawser-edge' ? 'Edge connection' :
(stats.port ? `${stats.host}:${stats.port}` : stats.host || 'Unknown host')}
</span>
</div>
</div>
<!-- Right: Status icons + Settings -->
<div class="flex items-center gap-2 shrink-0">
{#if stats.updateCheckEnabled}
<span title={stats.updateCheckAutoUpdate ? "Auto-update enabled" : "Update check enabled (notify only)"}>
{#if stats.updateCheckAutoUpdate}
<CircleArrowUp class="w-4 h-4 text-green-500 glow-green" />
{:else}
<CircleFadingArrowUp class="w-4 h-4 text-green-500 glow-green" />
{/if}
</span>
{/if}
{#if stats.scannerEnabled}
<span title="Vulnerability scanning enabled">
<ShieldCheck class="w-4 h-4 text-green-500 glow-green" />
</span>
{/if}
{#if stats.collectActivity}
<span title="Activity collection enabled">
<Activity class="w-4 h-4 text-amber-500 glow-amber" />
</span>
{/if}
{#if stats.collectMetrics}
<span title="Metrics collection enabled">
<Cpu class="w-4 h-4 text-sky-400 glow-sky" />
</span>
{/if}
{#if $canAccess('environments', 'edit')}
<button
onpointerdown={(e) => e.stopPropagation()}
onclick={(e) => { e.stopPropagation(); goto(`/settings?tab=environments&edit=${stats.id}`); }}
class="p-0.5 rounded hover:bg-muted transition-colors"
title="Edit environment settings"
>
<Settings class="w-3.5 h-3.5 text-muted-foreground hover:text-foreground" />
</button>
{/if}
</div>
</div>
</Card.Header>
<DashboardLabels labels={stats.labels} />
<Card.Content class="overflow-hidden">
{#if !showOffline}
<div class="space-y-2">
<DashboardContainerStats containers={stats.containers} loading={stats.loading?.containers} />
<DashboardHealthBanner unhealthy={stats.containers.unhealthy} restarting={stats.containers.restarting} />
</div>
{:else}
<DashboardOfflineState error={stats.error} compact={isMini} />
{/if}
</Card.Content>
<!-- ==================== 2x1 TILE ==================== -->
{:else if is2x1}
<Card.Header class="pb-2 overflow-hidden">
<!-- Unified header row -->
<div class="flex items-center justify-between gap-2 w-full overflow-hidden">
<!-- Left: Icons + Name/Host -->
<div class="flex items-center gap-2 min-w-0 overflow-hidden flex-1">
<div class="p-1.5 rounded-lg shrink-0 {stats.online ? 'bg-primary/10' : 'bg-muted'}">
<EnvIcon class="w-4 h-4 {stats.online ? 'text-primary' : 'text-muted-foreground'}" />
</div>
{#if stats.connectionType === 'socket' || !stats.connectionType}
<span title="Unix socket connection" class="shrink-0">
<Unplug class="w-4 h-4 text-cyan-500 glow-cyan" />
</span>
{:else if stats.connectionType === 'direct'}
<span title="Direct Docker connection" class="shrink-0">
<Icon iconNode={whale} class="w-4 h-4 text-blue-500 glow-blue" />
</span>
{:else if stats.connectionType === 'hawser-standard'}
<span title="Hawser agent (standard mode)" class="shrink-0">
<Route class="w-4 h-4 text-purple-500 glow-purple" />
</span>
{:else if stats.connectionType === 'hawser-edge'}
<span title="Hawser agent (edge mode)" class="shrink-0">
<UndoDot class="w-4 h-4 text-green-500 glow-green" />
</span>
{/if}
<div class="min-w-0 overflow-hidden">
<div class="flex items-center gap-1.5">
<span class="font-medium text-sm truncate">{stats.name}</span>
{#if !showOffline}
<Wifi class="w-3 h-3 text-green-500 shrink-0" />
{:else}
<WifiOff class="w-3 h-3 text-red-500 shrink-0" />
{/if}
</div>
<span class="text-xs text-muted-foreground truncate block" title={stats.connectionType === 'socket' ? (stats.socketPath || '/var/run/docker.sock') : stats.connectionType === 'hawser-edge' ? 'Edge connection' : (stats.port ? `${stats.host}:${stats.port}` : stats.host || 'Unknown host')}>
{stats.connectionType === 'socket' ? (stats.socketPath || '/var/run/docker.sock') :
stats.connectionType === 'hawser-edge' ? 'Edge connection' :
(stats.port ? `${stats.host}:${stats.port}` : stats.host || 'Unknown host')}
</span>
</div>
</div>
<!-- Right: Status icons + Settings -->
<div class="flex items-center gap-2 shrink-0">
{#if stats.updateCheckEnabled}
<span title={stats.updateCheckAutoUpdate ? "Auto-update enabled" : "Update check enabled (notify only)"}>
{#if stats.updateCheckAutoUpdate}
<CircleArrowUp class="w-4 h-4 text-green-500 glow-green" />
{:else}
<CircleFadingArrowUp class="w-4 h-4 text-green-500 glow-green" />
{/if}
</span>
{/if}
{#if stats.scannerEnabled}
<span title="Vulnerability scanning enabled">
<ShieldCheck class="w-4 h-4 text-green-500 glow-green" />
</span>
{/if}
{#if stats.collectActivity}
<span title="Activity collection enabled">
<Activity class="w-4 h-4 text-amber-500 glow-amber" />
</span>
{/if}
{#if stats.collectMetrics}
<span title="Metrics collection enabled">
<Cpu class="w-4 h-4 text-sky-400 glow-sky" />
</span>
{/if}
{#if $canAccess('environments', 'edit')}
<button
onpointerdown={(e) => e.stopPropagation()}
onclick={(e) => { e.stopPropagation(); goto(`/settings?tab=environments&edit=${stats.id}`); }}
class="p-0.5 rounded hover:bg-muted transition-colors"
title="Edit environment settings"
>
<Settings class="w-3.5 h-3.5 text-muted-foreground hover:text-foreground" />
</button>
{/if}
</div>
</div>
</Card.Header>
<DashboardLabels labels={stats.labels} />
<Card.Content class="overflow-hidden">
{#if !showOffline}
<div class="flex gap-4">
<div class="space-y-2">
<DashboardContainerStats containers={stats.containers} loading={stats.loading?.containers} />
<DashboardHealthBanner unhealthy={stats.containers.unhealthy} restarting={stats.containers.restarting} />
</div>
{#if stats.recentEvents}
<div class="border-l border-border/50 pl-4 flex-1">
<DashboardRecentEvents events={stats.recentEvents} limit={2} onclick={oneventsclick} />
</div>
{/if}
</div>
{:else}
<DashboardOfflineState error={stats.error} compact={isMini} />
{/if}
</Card.Content>
<!-- ==================== 1x2 TILE ==================== -->
{:else if is1x2}
<Card.Header class="pb-2 overflow-hidden">
<!-- Unified header row -->
<div class="flex items-center justify-between gap-2 w-full overflow-hidden">
<!-- Left: Icons + Name/Host -->
<div class="flex items-center gap-2 min-w-0 overflow-hidden flex-1">
<div class="p-1.5 rounded-lg shrink-0 {stats.online ? 'bg-primary/10' : 'bg-muted'}">
<EnvIcon class="w-4 h-4 {stats.online ? 'text-primary' : 'text-muted-foreground'}" />
</div>
{#if stats.connectionType === 'socket' || !stats.connectionType}
<span title="Unix socket connection" class="shrink-0">
<Unplug class="w-4 h-4 text-cyan-500 glow-cyan" />
</span>
{:else if stats.connectionType === 'direct'}
<span title="Direct Docker connection" class="shrink-0">
<Icon iconNode={whale} class="w-4 h-4 text-blue-500 glow-blue" />
</span>
{:else if stats.connectionType === 'hawser-standard'}
<span title="Hawser agent (standard mode)" class="shrink-0">
<Route class="w-4 h-4 text-purple-500 glow-purple" />
</span>
{:else if stats.connectionType === 'hawser-edge'}
<span title="Hawser agent (edge mode)" class="shrink-0">
<UndoDot class="w-4 h-4 text-green-500 glow-green" />
</span>
{/if}
<div class="min-w-0 overflow-hidden">
<div class="flex items-center gap-1.5">
<span class="font-medium text-sm truncate">{stats.name}</span>
{#if !showOffline}
<Wifi class="w-3 h-3 text-green-500 shrink-0" />
{:else}
<WifiOff class="w-3 h-3 text-red-500 shrink-0" />
{/if}
</div>
<span class="text-xs text-muted-foreground truncate block" title={stats.connectionType === 'socket' ? (stats.socketPath || '/var/run/docker.sock') : stats.connectionType === 'hawser-edge' ? 'Edge connection' : (stats.port ? `${stats.host}:${stats.port}` : stats.host || 'Unknown host')}>
{stats.connectionType === 'socket' ? (stats.socketPath || '/var/run/docker.sock') :
stats.connectionType === 'hawser-edge' ? 'Edge connection' :
(stats.port ? `${stats.host}:${stats.port}` : stats.host || 'Unknown host')}
</span>
</div>
</div>
<!-- Right: Status icons + Settings -->
<div class="flex items-center gap-2 shrink-0">
{#if stats.updateCheckEnabled}
<span title={stats.updateCheckAutoUpdate ? "Auto-update enabled" : "Update check enabled (notify only)"}>
{#if stats.updateCheckAutoUpdate}
<CircleArrowUp class="w-4 h-4 text-green-500 glow-green" />
{:else}
<CircleFadingArrowUp class="w-4 h-4 text-green-500 glow-green" />
{/if}
</span>
{/if}
{#if stats.scannerEnabled}
<span title="Vulnerability scanning enabled">
<ShieldCheck class="w-4 h-4 text-green-500 glow-green" />
</span>
{/if}
{#if stats.collectActivity}
<span title="Activity collection enabled">
<Activity class="w-4 h-4 text-amber-500 glow-amber" />
</span>
{/if}
{#if stats.collectMetrics}
<span title="Metrics collection enabled">
<Cpu class="w-4 h-4 text-sky-400 glow-sky" />
</span>
{/if}
{#if $canAccess('environments', 'edit')}
<button
onpointerdown={(e) => e.stopPropagation()}
onclick={(e) => { e.stopPropagation(); goto(`/settings?tab=environments&edit=${stats.id}`); }}
class="p-0.5 rounded hover:bg-muted transition-colors"
title="Edit environment settings"
>
<Settings class="w-3.5 h-3.5 text-muted-foreground hover:text-foreground" />
</button>
{/if}
</div>
</div>
</Card.Header>
<DashboardLabels labels={stats.labels} />
<Card.Content class="overflow-auto" style="max-height: calc(100% - 60px);">
{#if !showOffline}
<div class="space-y-3">
<DashboardContainerStats containers={stats.containers} loading={stats.loading?.containers} />
<DashboardHealthBanner unhealthy={stats.containers.unhealthy} restarting={stats.containers.restarting} />
{#if stats.collectMetrics && stats.metrics}
<DashboardCpuMemoryBars metrics={stats.metrics} collectMetrics={stats.collectMetrics} />
{/if}
<DashboardResourceStats images={stats.images} volumes={stats.volumes} networks={stats.networks} stacks={stats.stacks} loading={stats.loading} />
<DashboardEventsSummary today={stats.events.today} total={stats.events.total} />
</div>
{:else}
<DashboardOfflineState error={stats.error} compact={isMini} />
{/if}
</Card.Content>
<!-- ==================== 1x3 TILE ==================== -->
{:else if is1x3}
<Card.Header class="pb-2 overflow-hidden">
<!-- Unified header row -->
<div class="flex items-center justify-between gap-2 w-full overflow-hidden">
<!-- Left: Icons + Name/Host -->
<div class="flex items-center gap-2 min-w-0 overflow-hidden flex-1">
<div class="p-1.5 rounded-lg shrink-0 {stats.online ? 'bg-primary/10' : 'bg-muted'}">
<EnvIcon class="w-4 h-4 {stats.online ? 'text-primary' : 'text-muted-foreground'}" />
</div>
{#if stats.connectionType === 'socket' || !stats.connectionType}
<span title="Unix socket connection" class="shrink-0">
<Unplug class="w-4 h-4 text-cyan-500 glow-cyan" />
</span>
{:else if stats.connectionType === 'direct'}
<span title="Direct Docker connection" class="shrink-0">
<Icon iconNode={whale} class="w-4 h-4 text-blue-500 glow-blue" />
</span>
{:else if stats.connectionType === 'hawser-standard'}
<span title="Hawser agent (standard mode)" class="shrink-0">
<Route class="w-4 h-4 text-purple-500 glow-purple" />
</span>
{:else if stats.connectionType === 'hawser-edge'}
<span title="Hawser agent (edge mode)" class="shrink-0">
<UndoDot class="w-4 h-4 text-green-500 glow-green" />
</span>
{/if}
<div class="min-w-0 overflow-hidden">
<div class="flex items-center gap-1.5">
<span class="font-medium text-sm truncate">{stats.name}</span>
{#if !showOffline}
<Wifi class="w-3 h-3 text-green-500 shrink-0" />
{:else}
<WifiOff class="w-3 h-3 text-red-500 shrink-0" />
{/if}
</div>
<span class="text-xs text-muted-foreground truncate block" title={stats.connectionType === 'socket' ? (stats.socketPath || '/var/run/docker.sock') : stats.connectionType === 'hawser-edge' ? 'Edge connection' : (stats.port ? `${stats.host}:${stats.port}` : stats.host || 'Unknown host')}>
{stats.connectionType === 'socket' ? (stats.socketPath || '/var/run/docker.sock') :
stats.connectionType === 'hawser-edge' ? 'Edge connection' :
(stats.port ? `${stats.host}:${stats.port}` : stats.host || 'Unknown host')}
</span>
</div>
</div>
<!-- Right: Status icons + Settings -->
<div class="flex items-center gap-2 shrink-0">
{#if stats.updateCheckEnabled}
<span title={stats.updateCheckAutoUpdate ? "Auto-update enabled" : "Update check enabled (notify only)"}>
{#if stats.updateCheckAutoUpdate}
<CircleArrowUp class="w-4 h-4 text-green-500 glow-green" />
{:else}
<CircleFadingArrowUp class="w-4 h-4 text-green-500 glow-green" />
{/if}
</span>
{/if}
{#if stats.scannerEnabled}
<span title="Vulnerability scanning enabled">
<ShieldCheck class="w-4 h-4 text-green-500 glow-green" />
</span>
{/if}
{#if stats.collectActivity}
<span title="Activity collection enabled">
<Activity class="w-4 h-4 text-amber-500 glow-amber" />
</span>
{/if}
{#if stats.collectMetrics}
<span title="Metrics collection enabled">
<Cpu class="w-4 h-4 text-sky-400 glow-sky" />
</span>
{/if}
{#if $canAccess('environments', 'edit')}
<button
onpointerdown={(e) => e.stopPropagation()}
onclick={(e) => { e.stopPropagation(); goto(`/settings?tab=environments&edit=${stats.id}`); }}
class="p-0.5 rounded hover:bg-muted transition-colors"
title="Edit environment settings"
>
<Settings class="w-3.5 h-3.5 text-muted-foreground hover:text-foreground" />
</button>
{/if}
</div>
</div>
</Card.Header>
<DashboardLabels labels={stats.labels} />
<Card.Content class="overflow-auto" style="max-height: calc(100% - 60px);">
{#if !showOffline}
<div class="space-y-3">
<DashboardContainerStats containers={stats.containers} loading={stats.loading?.containers} />
<DashboardHealthBanner unhealthy={stats.containers.unhealthy} restarting={stats.containers.restarting} />
{#if stats.collectMetrics && stats.metrics}
<DashboardCpuMemoryBars metrics={stats.metrics} collectMetrics={stats.collectMetrics} />
{/if}
<DashboardResourceStats images={stats.images} volumes={stats.volumes} networks={stats.networks} stacks={stats.stacks} loading={stats.loading} />
<DashboardEventsSummary today={stats.events.today} total={stats.events.total} />
{#if stats.recentEvents}
<DashboardRecentEvents events={stats.recentEvents} limit={8} onclick={oneventsclick} />
{/if}
</div>
{:else}
<DashboardOfflineState error={stats.error} compact={isMini} />
{/if}
</Card.Content>
<!-- ==================== 1x4 TILE ==================== -->
{:else if is1x4}
<Card.Header class="pb-2 overflow-hidden">
<!-- Unified header row -->
<div class="flex items-center justify-between gap-2 w-full overflow-hidden">
<!-- Left: Icons + Name/Host -->
<div class="flex items-center gap-2 min-w-0 overflow-hidden flex-1">
<div class="p-1.5 rounded-lg shrink-0 {stats.online ? 'bg-primary/10' : 'bg-muted'}">
<EnvIcon class="w-4 h-4 {stats.online ? 'text-primary' : 'text-muted-foreground'}" />
</div>
{#if stats.connectionType === 'socket' || !stats.connectionType}
<span title="Unix socket connection" class="shrink-0">
<Unplug class="w-4 h-4 text-cyan-500 glow-cyan" />
</span>
{:else if stats.connectionType === 'direct'}
<span title="Direct Docker connection" class="shrink-0">
<Icon iconNode={whale} class="w-4 h-4 text-blue-500 glow-blue" />
</span>
{:else if stats.connectionType === 'hawser-standard'}
<span title="Hawser agent (standard mode)" class="shrink-0">
<Route class="w-4 h-4 text-purple-500 glow-purple" />
</span>
{:else if stats.connectionType === 'hawser-edge'}
<span title="Hawser agent (edge mode)" class="shrink-0">
<UndoDot class="w-4 h-4 text-green-500 glow-green" />
</span>
{/if}
<div class="min-w-0 overflow-hidden">
<div class="flex items-center gap-1.5">
<span class="font-medium text-sm truncate">{stats.name}</span>
{#if !showOffline}
<Wifi class="w-3 h-3 text-green-500 shrink-0" />
{:else}
<WifiOff class="w-3 h-3 text-red-500 shrink-0" />
{/if}
</div>
<span class="text-xs text-muted-foreground truncate block" title={stats.connectionType === 'socket' ? (stats.socketPath || '/var/run/docker.sock') : stats.connectionType === 'hawser-edge' ? 'Edge connection' : (stats.port ? `${stats.host}:${stats.port}` : stats.host || 'Unknown host')}>
{stats.connectionType === 'socket' ? (stats.socketPath || '/var/run/docker.sock') :
stats.connectionType === 'hawser-edge' ? 'Edge connection' :
(stats.port ? `${stats.host}:${stats.port}` : stats.host || 'Unknown host')}
</span>
</div>
</div>
<!-- Right: Status icons + Settings -->
<div class="flex items-center gap-2 shrink-0">
{#if stats.updateCheckEnabled}
<span title={stats.updateCheckAutoUpdate ? "Auto-update enabled" : "Update check enabled (notify only)"}>
{#if stats.updateCheckAutoUpdate}
<CircleArrowUp class="w-4 h-4 text-green-500 glow-green" />
{:else}
<CircleFadingArrowUp class="w-4 h-4 text-green-500 glow-green" />
{/if}
</span>
{/if}
{#if stats.scannerEnabled}
<span title="Vulnerability scanning enabled">
<ShieldCheck class="w-4 h-4 text-green-500 glow-green" />
</span>
{/if}
{#if stats.collectActivity}
<span title="Activity collection enabled">
<Activity class="w-4 h-4 text-amber-500 glow-amber" />
</span>
{/if}
{#if stats.collectMetrics}
<span title="Metrics collection enabled">
<Cpu class="w-4 h-4 text-sky-400 glow-sky" />
</span>
{/if}
{#if $canAccess('environments', 'edit')}
<button
onpointerdown={(e) => e.stopPropagation()}
onclick={(e) => { e.stopPropagation(); goto(`/settings?tab=environments&edit=${stats.id}`); }}
class="p-0.5 rounded hover:bg-muted transition-colors"
title="Edit environment settings"
>
<Settings class="w-3.5 h-3.5 text-muted-foreground hover:text-foreground" />
</button>
{/if}
</div>
</div>
</Card.Header>
<DashboardLabels labels={stats.labels} />
<Card.Content class="overflow-auto" style="max-height: calc(100% - 60px);">
{#if !showOffline}
<div class="space-y-3">
<DashboardContainerStats containers={stats.containers} loading={stats.loading?.containers} />
<DashboardHealthBanner unhealthy={stats.containers.unhealthy} restarting={stats.containers.restarting} />
{#if stats.collectMetrics && stats.metrics}
<DashboardCpuMemoryBars metrics={stats.metrics} collectMetrics={stats.collectMetrics} />
{/if}
<DashboardResourceStats images={stats.images} volumes={stats.volumes} networks={stats.networks} stacks={stats.stacks} loading={stats.loading} />
<DashboardEventsSummary today={stats.events.today} total={stats.events.total} />
{#if stats.recentEvents}
<DashboardRecentEvents events={stats.recentEvents} limit={8} onclick={oneventsclick} />
{/if}
<DashboardTopContainers containers={stats.topContainers} limit={8} loading={stats.loading?.topContainers} />
</div>
{:else}
<DashboardOfflineState error={stats.error} compact={isMini} />
{/if}
</Card.Content>
<!-- ==================== 2x2 TILE ==================== -->
{:else if is2x2}
<Card.Header class="pb-2">
<DashboardHeader
name={stats.name}
host={stats.host}
port={stats.port}
icon={stats.icon}
socketPath={stats.socketPath}
online={stats.online}
scannerEnabled={stats.scannerEnabled}
collectActivity={stats.collectActivity}
collectMetrics={stats.collectMetrics}
updateCheckEnabled={stats.updateCheckEnabled}
updateCheckAutoUpdate={stats.updateCheckAutoUpdate}
connectionType={stats.connectionType}
environmentId={stats.id}
{width}
{height}
/>
</Card.Header>
<DashboardLabels labels={stats.labels} />
<Card.Content class="overflow-auto" style="max-height: calc(100% - 60px);">
{#if !showOffline}
<div class="grid grid-cols-2 gap-4">
<!-- Left column -->
<div class="space-y-3">
<DashboardContainerStats containers={stats.containers} loading={stats.loading?.containers} />
<DashboardHealthBanner unhealthy={stats.containers.unhealthy} restarting={stats.containers.restarting} />
{#if stats.metrics}
<DashboardCpuMemoryBars metrics={stats.metrics} collectMetrics={stats.collectMetrics} />
{/if}
<DashboardResourceStats images={stats.images} volumes={stats.volumes} networks={stats.networks} stacks={stats.stacks} loading={stats.loading} />
<DashboardEventsSummary today={stats.events.today} total={stats.events.total} />
</div>
<!-- Right column -->
<div class="space-y-3 border-l border-border/50 pl-4">
<DashboardTopContainers containers={stats.topContainers} limit={8} loading={stats.loading?.topContainers} />
</div>
</div>
{:else}
<DashboardOfflineState error={stats.error} compact={isMini} />
{/if}
</Card.Content>
<!-- ==================== 2x3 TILE ==================== -->
{:else if is2x3}
<Card.Header class="pb-2">
<DashboardHeader
name={stats.name}
host={stats.host}
port={stats.port}
icon={stats.icon}
socketPath={stats.socketPath}
online={stats.online}
scannerEnabled={stats.scannerEnabled}
collectActivity={stats.collectActivity}
collectMetrics={stats.collectMetrics}
updateCheckEnabled={stats.updateCheckEnabled}
updateCheckAutoUpdate={stats.updateCheckAutoUpdate}
connectionType={stats.connectionType}
environmentId={stats.id}
{width}
{height}
/>
</Card.Header>
<DashboardLabels labels={stats.labels} />
<Card.Content class="overflow-auto" style="max-height: calc(100% - 60px);">
{#if !showOffline}
<div class="grid grid-cols-2 gap-4">
<!-- Left column -->
<div class="space-y-3">
<DashboardContainerStats containers={stats.containers} loading={stats.loading?.containers} />
<DashboardHealthBanner unhealthy={stats.containers.unhealthy} restarting={stats.containers.restarting} />
{#if stats.metrics}
<DashboardCpuMemoryBars metrics={stats.metrics} collectMetrics={stats.collectMetrics} />
{/if}
<DashboardResourceStats images={stats.images} volumes={stats.volumes} networks={stats.networks} stacks={stats.stacks} loading={stats.loading} />
<DashboardEventsSummary today={stats.events.today} total={stats.events.total} />
{#if stats.recentEvents}
<DashboardRecentEvents events={stats.recentEvents} limit={5} onclick={oneventsclick} />
{/if}
</div>
<!-- Right column -->
<div class="space-y-3 border-l border-border/50 pl-4">
<DashboardTopContainers containers={stats.topContainers} limit={10} loading={stats.loading?.topContainers} />
{#if stats.collectMetrics && stats.metrics && stats.metricsHistory}
<DashboardCpuMemoryCharts metricsHistory={stats.metricsHistory} metrics={stats.metrics} />
{/if}
</div>
</div>
{:else}
<DashboardOfflineState error={stats.error} compact={isMini} />
{/if}
</Card.Content>
<!-- ==================== 2x4 TILE ==================== -->
{:else if is2x4}
<Card.Header class="pb-2">
<DashboardHeader
name={stats.name}
host={stats.host}
port={stats.port}
icon={stats.icon}
socketPath={stats.socketPath}
online={stats.online}
scannerEnabled={stats.scannerEnabled}
collectActivity={stats.collectActivity}
collectMetrics={stats.collectMetrics}
updateCheckEnabled={stats.updateCheckEnabled}
updateCheckAutoUpdate={stats.updateCheckAutoUpdate}
connectionType={stats.connectionType}
environmentId={stats.id}
{width}
{height}
/>
</Card.Header>
<DashboardLabels labels={stats.labels} />
<Card.Content class="overflow-auto" style="max-height: calc(100% - 60px);">
{#if !showOffline}
<div class="grid grid-cols-2 gap-4">
<!-- Left column -->
<div class="space-y-3">
<DashboardContainerStats containers={stats.containers} loading={stats.loading?.containers} />
<DashboardHealthBanner unhealthy={stats.containers.unhealthy} restarting={stats.containers.restarting} />
{#if stats.metrics}
<DashboardCpuMemoryBars metrics={stats.metrics} collectMetrics={stats.collectMetrics} />
{/if}
<DashboardResourceStats images={stats.images} volumes={stats.volumes} networks={stats.networks} stacks={stats.stacks} loading={stats.loading} />
<DashboardEventsSummary today={stats.events.today} total={stats.events.total} />
{#if stats.recentEvents}
<DashboardRecentEvents events={stats.recentEvents} limit={10} onclick={oneventsclick} />
{/if}
<DashboardTopContainers containers={stats.topContainers} limit={10} loading={stats.loading?.topContainers} />
</div>
<!-- Right column -->
<div class="space-y-3 border-l border-border/50 pl-4">
{#if stats.collectMetrics && stats.metrics && stats.metricsHistory}
<DashboardCpuMemoryCharts metricsHistory={stats.metricsHistory} metrics={stats.metrics} />
{/if}
<DashboardDiskUsage imagesSize={stats.images.totalSize} volumesSize={stats.volumes.totalSize} containersSize={stats.containersSize} buildCacheSize={stats.buildCacheSize} showPieChart={true} loading={stats.loading?.diskUsage} />
</div>
</div>
{:else}
<DashboardOfflineState error={stats.error} compact={isMini} />
{/if}
</Card.Content>
{/if}
</Card.Root>

View File

@@ -0,0 +1,639 @@
<script lang="ts">
import * as Card from '$lib/components/ui/card';
interface Props {
name?: string;
host?: string;
width?: number;
height?: number;
}
let { name, host, width = 1, height = 2 }: Props = $props();
// Size conditionals
const is1x1 = $derived(width === 1 && height === 1);
const is2x1 = $derived(width >= 2 && height === 1);
const is1x2 = $derived(width === 1 && height === 2);
const is1x3 = $derived(width === 1 && height === 3);
const is1x4 = $derived(width === 1 && height >= 4);
const is2x2 = $derived(width >= 2 && height === 2);
const is2x3 = $derived(width >= 2 && height === 3);
const is2x4 = $derived(width >= 2 && height >= 4);
const isMini = $derived(is1x1 || is2x1);
const isWide = $derived(width >= 2);
</script>
<!-- Skeleton shimmer animation -->
<style>
@keyframes shimmer {
0% {
background-position: -200% 0;
}
100% {
background-position: 200% 0;
}
}
.skeleton {
background: linear-gradient(
90deg,
hsl(var(--muted)) 25%,
hsl(var(--muted-foreground) / 0.1) 50%,
hsl(var(--muted)) 75%
);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
}
</style>
<Card.Root class="h-full overflow-hidden">
<!-- ==================== 1x1 TILE SKELETON ==================== -->
{#if is1x1}
<div class="flex flex-col justify-between p-2.5 h-full">
<div class="flex items-center justify-between gap-2">
<!-- Header skeleton -->
<div class="flex items-center gap-1.5 min-w-0 flex-1">
<div class="skeleton w-6 h-6 rounded-md shrink-0"></div>
<div class="min-w-0 flex-1">
{#if name}
<div class="font-medium text-xs truncate">{name}</div>
{:else}
<div class="skeleton h-3 w-16 rounded"></div>
{/if}
</div>
</div>
<!-- Container counts skeleton -->
<div class="flex items-center gap-1.5 shrink-0">
{#each [1, 2, 3, 4, 5] as _}
<div class="skeleton h-3 w-5 rounded"></div>
{/each}
</div>
<!-- Status icons skeleton -->
<div class="flex items-center gap-1 shrink-0">
<div class="skeleton w-3.5 h-3.5 rounded-full"></div>
<div class="skeleton w-3.5 h-3.5 rounded-full"></div>
</div>
</div>
<!-- CPU/Memory bars skeleton -->
<div class="flex items-center gap-3 mt-1.5">
<div class="flex items-center gap-1.5 flex-1">
<div class="skeleton w-3 h-3 rounded"></div>
<div class="skeleton h-1.5 rounded-full flex-1"></div>
<div class="skeleton w-6 h-3 rounded"></div>
</div>
<div class="flex items-center gap-1.5 flex-1">
<div class="skeleton w-3 h-3 rounded"></div>
<div class="skeleton h-1.5 rounded-full flex-1"></div>
<div class="skeleton w-6 h-3 rounded"></div>
</div>
</div>
</div>
<!-- ==================== 2x1 TILE SKELETON ==================== -->
{:else if is2x1}
<div class="flex flex-col justify-between p-2.5 h-full">
<div class="flex items-center justify-between gap-2">
<!-- Header skeleton -->
<div class="flex items-center gap-1.5 min-w-0 flex-1">
<div class="skeleton w-6 h-6 rounded-md shrink-0"></div>
<div class="min-w-0 flex-1">
{#if name}
<div class="font-medium text-xs truncate">{name}</div>
{:else}
<div class="skeleton h-3 w-20 rounded"></div>
{/if}
</div>
</div>
<!-- Container counts skeleton -->
<div class="flex items-center gap-1.5 shrink-0">
{#each [1, 2, 3, 4, 5] as _}
<div class="skeleton h-3 w-5 rounded"></div>
{/each}
</div>
<!-- Status icons skeleton -->
<div class="flex items-center gap-1 shrink-0">
<div class="skeleton w-3.5 h-3.5 rounded-full"></div>
<div class="skeleton w-3.5 h-3.5 rounded-full"></div>
</div>
</div>
<!-- CPU/Memory bars skeleton -->
<div class="flex items-center gap-3 mt-1.5">
<div class="flex items-center gap-1.5 flex-1">
<div class="skeleton w-3 h-3 rounded"></div>
<div class="skeleton h-1.5 rounded-full flex-1"></div>
<div class="skeleton w-6 h-3 rounded"></div>
</div>
<div class="flex items-center gap-1.5 flex-1">
<div class="skeleton w-3 h-3 rounded"></div>
<div class="skeleton h-1.5 rounded-full flex-1"></div>
<div class="skeleton w-6 h-3 rounded"></div>
</div>
</div>
</div>
<!-- ==================== 1x2 TILE SKELETON ==================== -->
{:else if is1x2}
<Card.Header class="pb-2">
<!-- Header skeleton -->
<div class="flex items-center gap-2 min-w-0 flex-1">
<div class="skeleton p-1.5 rounded-lg w-8 h-8"></div>
<div class="min-w-0 flex-1 space-y-1">
{#if name}
<div class="font-medium text-sm truncate">{name}</div>
<div class="text-xs text-muted-foreground truncate">{host || 'Connecting...'}</div>
{:else}
<div class="skeleton h-4 w-24 rounded"></div>
<div class="skeleton h-3 w-32 rounded"></div>
{/if}
</div>
</div>
</Card.Header>
<Card.Content class="space-y-3 overflow-auto" style="max-height: calc(100% - 60px);">
<!-- Container stats skeleton -->
<div class="grid grid-cols-6 gap-1">
{#each [1, 2, 3, 4, 5, 6] as _}
<div class="skeleton h-5 rounded"></div>
{/each}
</div>
<!-- Health banner skeleton -->
<div class="skeleton h-7 rounded-md"></div>
<!-- CPU/Memory bars skeleton -->
<div class="space-y-2 pt-1 border-t border-border/50">
<div class="space-y-1">
<div class="flex items-center justify-between">
<div class="skeleton h-3 w-12 rounded"></div>
<div class="skeleton h-3 w-10 rounded"></div>
</div>
<div class="skeleton h-1.5 rounded-full"></div>
</div>
<div class="space-y-1">
<div class="flex items-center justify-between">
<div class="skeleton h-3 w-16 rounded"></div>
<div class="skeleton h-3 w-14 rounded"></div>
</div>
<div class="skeleton h-1.5 rounded-full"></div>
</div>
</div>
<!-- Resource stats skeleton -->
<div class="grid grid-cols-2 gap-x-4 gap-y-2">
{#each [1, 2, 3, 4] as _}
<div class="flex items-center justify-between">
<div class="skeleton h-3 w-14 rounded"></div>
<div class="skeleton h-3 w-6 rounded"></div>
</div>
{/each}
</div>
<!-- Events summary skeleton -->
<div class="flex items-center justify-between">
<div class="skeleton h-3 w-20 rounded"></div>
<div class="skeleton h-3 w-12 rounded"></div>
</div>
</Card.Content>
<!-- ==================== 1x3 TILE SKELETON ==================== -->
{:else if is1x3}
<Card.Header class="pb-2">
<div class="flex items-center gap-2 min-w-0 flex-1">
<div class="skeleton p-1.5 rounded-lg w-8 h-8"></div>
<div class="min-w-0 flex-1 space-y-1">
{#if name}
<div class="font-medium text-sm truncate">{name}</div>
<div class="text-xs text-muted-foreground truncate">{host || 'Connecting...'}</div>
{:else}
<div class="skeleton h-4 w-24 rounded"></div>
<div class="skeleton h-3 w-32 rounded"></div>
{/if}
</div>
</div>
</Card.Header>
<Card.Content class="space-y-3 overflow-auto" style="max-height: calc(100% - 60px);">
<!-- Container stats skeleton -->
<div class="grid grid-cols-6 gap-1">
{#each [1, 2, 3, 4, 5, 6] as _}
<div class="skeleton h-5 rounded"></div>
{/each}
</div>
<!-- Health banner skeleton -->
<div class="skeleton h-7 rounded-md"></div>
<!-- CPU/Memory bars skeleton -->
<div class="space-y-2 pt-1 border-t border-border/50">
<div class="space-y-1">
<div class="flex items-center justify-between">
<div class="skeleton h-3 w-12 rounded"></div>
<div class="skeleton h-3 w-10 rounded"></div>
</div>
<div class="skeleton h-1.5 rounded-full"></div>
</div>
<div class="space-y-1">
<div class="flex items-center justify-between">
<div class="skeleton h-3 w-16 rounded"></div>
<div class="skeleton h-3 w-14 rounded"></div>
</div>
<div class="skeleton h-1.5 rounded-full"></div>
</div>
</div>
<!-- Resource stats skeleton -->
<div class="grid grid-cols-2 gap-x-4 gap-y-2">
{#each [1, 2, 3, 4] as _}
<div class="flex items-center justify-between">
<div class="skeleton h-3 w-14 rounded"></div>
<div class="skeleton h-3 w-6 rounded"></div>
</div>
{/each}
</div>
<!-- Events summary skeleton -->
<div class="flex items-center justify-between">
<div class="skeleton h-3 w-20 rounded"></div>
<div class="skeleton h-3 w-12 rounded"></div>
</div>
<!-- Recent events skeleton -->
<div class="pt-2 border-t border-border/50">
<div class="skeleton h-3 w-24 rounded mb-2"></div>
<div class="space-y-1.5">
{#each [1, 2, 3, 4, 5, 6, 7, 8] as _}
<div class="flex items-center gap-2">
<div class="skeleton w-3 h-3 rounded"></div>
<div class="skeleton h-3 flex-1 rounded"></div>
<div class="skeleton h-3 w-10 rounded"></div>
</div>
{/each}
</div>
</div>
</Card.Content>
<!-- ==================== 1x4 TILE SKELETON ==================== -->
{:else if is1x4}
<Card.Header class="pb-2">
<div class="flex items-center gap-2 min-w-0 flex-1">
<div class="skeleton p-1.5 rounded-lg w-8 h-8"></div>
<div class="min-w-0 flex-1 space-y-1">
{#if name}
<div class="font-medium text-sm truncate">{name}</div>
<div class="text-xs text-muted-foreground truncate">{host || 'Connecting...'}</div>
{:else}
<div class="skeleton h-4 w-24 rounded"></div>
<div class="skeleton h-3 w-32 rounded"></div>
{/if}
</div>
</div>
</Card.Header>
<Card.Content class="space-y-3 overflow-auto" style="max-height: calc(100% - 60px);">
<!-- Container stats skeleton -->
<div class="grid grid-cols-6 gap-1">
{#each [1, 2, 3, 4, 5, 6] as _}
<div class="skeleton h-5 rounded"></div>
{/each}
</div>
<!-- Health banner skeleton -->
<div class="skeleton h-7 rounded-md"></div>
<!-- CPU/Memory bars skeleton -->
<div class="space-y-2 pt-1 border-t border-border/50">
<div class="space-y-1">
<div class="flex items-center justify-between">
<div class="skeleton h-3 w-12 rounded"></div>
<div class="skeleton h-3 w-10 rounded"></div>
</div>
<div class="skeleton h-1.5 rounded-full"></div>
</div>
<div class="space-y-1">
<div class="flex items-center justify-between">
<div class="skeleton h-3 w-16 rounded"></div>
<div class="skeleton h-3 w-14 rounded"></div>
</div>
<div class="skeleton h-1.5 rounded-full"></div>
</div>
</div>
<!-- Resource stats skeleton -->
<div class="grid grid-cols-2 gap-x-4 gap-y-2">
{#each [1, 2, 3, 4] as _}
<div class="flex items-center justify-between">
<div class="skeleton h-3 w-14 rounded"></div>
<div class="skeleton h-3 w-6 rounded"></div>
</div>
{/each}
</div>
<!-- Events summary skeleton -->
<div class="flex items-center justify-between">
<div class="skeleton h-3 w-20 rounded"></div>
<div class="skeleton h-3 w-12 rounded"></div>
</div>
<!-- Recent events skeleton -->
<div class="pt-2 border-t border-border/50">
<div class="skeleton h-3 w-24 rounded mb-2"></div>
<div class="space-y-1.5">
{#each [1, 2, 3, 4, 5, 6, 7, 8] as _}
<div class="flex items-center gap-2">
<div class="skeleton w-3 h-3 rounded"></div>
<div class="skeleton h-3 flex-1 rounded"></div>
<div class="skeleton h-3 w-10 rounded"></div>
</div>
{/each}
</div>
</div>
<!-- Top containers skeleton -->
<div class="pt-2 border-t border-border/50">
<div class="skeleton h-3 w-32 rounded mb-2"></div>
<div class="space-y-1.5">
{#each [1, 2, 3, 4, 5, 6, 7, 8] as _}
<div class="grid grid-cols-[1fr_auto_auto] gap-x-3 items-center">
<div class="skeleton h-3 rounded"></div>
<div class="skeleton h-3 w-10 rounded"></div>
<div class="skeleton h-3 w-10 rounded"></div>
</div>
{/each}
</div>
</div>
</Card.Content>
<!-- ==================== 2x2 TILE SKELETON ==================== -->
{:else if is2x2}
<Card.Header class="pb-2">
<div class="flex items-center gap-2 min-w-0 flex-1">
<div class="skeleton p-1.5 rounded-lg w-8 h-8"></div>
<div class="min-w-0 flex-1 space-y-1">
{#if name}
<div class="font-medium text-sm truncate">{name}</div>
<div class="text-xs text-muted-foreground truncate">{host || 'Connecting...'}</div>
{:else}
<div class="skeleton h-4 w-24 rounded"></div>
<div class="skeleton h-3 w-32 rounded"></div>
{/if}
</div>
</div>
</Card.Header>
<Card.Content class="overflow-auto" style="max-height: calc(100% - 60px);">
<div class="grid grid-cols-2 gap-4">
<!-- Left column -->
<div class="space-y-3">
<!-- Container stats skeleton -->
<div class="grid grid-cols-6 gap-1">
{#each [1, 2, 3, 4, 5, 6] as _}
<div class="skeleton h-5 rounded"></div>
{/each}
</div>
<!-- Health banner skeleton -->
<div class="skeleton h-7 rounded-md"></div>
<!-- CPU/Memory bars skeleton -->
<div class="space-y-2 pt-1 border-t border-border/50">
<div class="space-y-1">
<div class="flex items-center justify-between">
<div class="skeleton h-3 w-12 rounded"></div>
<div class="skeleton h-3 w-10 rounded"></div>
</div>
<div class="skeleton h-1.5 rounded-full"></div>
</div>
<div class="space-y-1">
<div class="flex items-center justify-between">
<div class="skeleton h-3 w-16 rounded"></div>
<div class="skeleton h-3 w-14 rounded"></div>
</div>
<div class="skeleton h-1.5 rounded-full"></div>
</div>
</div>
<!-- Resource stats skeleton -->
<div class="grid grid-cols-2 gap-x-4 gap-y-2">
{#each [1, 2, 3, 4] as _}
<div class="flex items-center justify-between">
<div class="skeleton h-3 w-14 rounded"></div>
<div class="skeleton h-3 w-6 rounded"></div>
</div>
{/each}
</div>
<!-- Events summary skeleton -->
<div class="flex items-center justify-between">
<div class="skeleton h-3 w-20 rounded"></div>
<div class="skeleton h-3 w-12 rounded"></div>
</div>
</div>
<!-- Right column -->
<div class="space-y-3 border-l border-border/50 pl-4">
<!-- Top containers skeleton -->
<div class="pt-2 border-t border-border/50">
<div class="skeleton h-3 w-32 rounded mb-2"></div>
<div class="space-y-1.5">
{#each [1, 2, 3, 4, 5, 6, 7, 8] as _}
<div class="grid grid-cols-[1fr_auto_auto] gap-x-3 items-center">
<div class="skeleton h-3 rounded"></div>
<div class="skeleton h-3 w-10 rounded"></div>
<div class="skeleton h-3 w-10 rounded"></div>
</div>
{/each}
</div>
</div>
</div>
</div>
</Card.Content>
<!-- ==================== 2x3 TILE SKELETON ==================== -->
{:else if is2x3}
<Card.Header class="pb-2">
<div class="flex items-center gap-2 min-w-0 flex-1">
<div class="skeleton p-1.5 rounded-lg w-8 h-8"></div>
<div class="min-w-0 flex-1 space-y-1">
{#if name}
<div class="font-medium text-sm truncate">{name}</div>
<div class="text-xs text-muted-foreground truncate">{host || 'Connecting...'}</div>
{:else}
<div class="skeleton h-4 w-24 rounded"></div>
<div class="skeleton h-3 w-32 rounded"></div>
{/if}
</div>
</div>
</Card.Header>
<Card.Content class="overflow-auto" style="max-height: calc(100% - 60px);">
<div class="grid grid-cols-2 gap-4">
<!-- Left column -->
<div class="space-y-3">
<!-- Container stats skeleton -->
<div class="grid grid-cols-6 gap-1">
{#each [1, 2, 3, 4, 5, 6] as _}
<div class="skeleton h-5 rounded"></div>
{/each}
</div>
<!-- Health banner skeleton -->
<div class="skeleton h-7 rounded-md"></div>
<!-- CPU/Memory bars skeleton -->
<div class="space-y-2 pt-1 border-t border-border/50">
<div class="space-y-1">
<div class="flex items-center justify-between">
<div class="skeleton h-3 w-12 rounded"></div>
<div class="skeleton h-3 w-10 rounded"></div>
</div>
<div class="skeleton h-1.5 rounded-full"></div>
</div>
<div class="space-y-1">
<div class="flex items-center justify-between">
<div class="skeleton h-3 w-16 rounded"></div>
<div class="skeleton h-3 w-14 rounded"></div>
</div>
<div class="skeleton h-1.5 rounded-full"></div>
</div>
</div>
<!-- Resource stats skeleton -->
<div class="grid grid-cols-2 gap-x-4 gap-y-2">
{#each [1, 2, 3, 4] as _}
<div class="flex items-center justify-between">
<div class="skeleton h-3 w-14 rounded"></div>
<div class="skeleton h-3 w-6 rounded"></div>
</div>
{/each}
</div>
<!-- Events summary skeleton -->
<div class="flex items-center justify-between">
<div class="skeleton h-3 w-20 rounded"></div>
<div class="skeleton h-3 w-12 rounded"></div>
</div>
<!-- Recent events skeleton -->
<div class="pt-2 border-t border-border/50">
<div class="skeleton h-3 w-24 rounded mb-2"></div>
<div class="space-y-1.5">
{#each [1, 2, 3, 4, 5] as _}
<div class="flex items-center gap-2">
<div class="skeleton w-3 h-3 rounded"></div>
<div class="skeleton h-3 flex-1 rounded"></div>
<div class="skeleton h-3 w-10 rounded"></div>
</div>
{/each}
</div>
</div>
</div>
<!-- Right column -->
<div class="space-y-3 border-l border-border/50 pl-4">
<!-- Top containers skeleton -->
<div class="pt-2 border-t border-border/50">
<div class="skeleton h-3 w-32 rounded mb-2"></div>
<div class="space-y-1.5">
{#each [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] as _}
<div class="grid grid-cols-[1fr_auto_auto] gap-x-3 items-center">
<div class="skeleton h-3 rounded"></div>
<div class="skeleton h-3 w-10 rounded"></div>
<div class="skeleton h-3 w-10 rounded"></div>
</div>
{/each}
</div>
</div>
<!-- Charts skeleton -->
<div class="pt-2 border-t border-border/50">
<div class="skeleton h-3 w-28 rounded mb-2"></div>
<div class="skeleton h-24 rounded"></div>
</div>
</div>
</div>
</Card.Content>
<!-- ==================== 2x4 TILE SKELETON ==================== -->
{:else if is2x4}
<Card.Header class="pb-2">
<div class="flex items-center gap-2 min-w-0 flex-1">
<div class="skeleton p-1.5 rounded-lg w-8 h-8"></div>
<div class="min-w-0 flex-1 space-y-1">
{#if name}
<div class="font-medium text-sm truncate">{name}</div>
<div class="text-xs text-muted-foreground truncate">{host || 'Connecting...'}</div>
{:else}
<div class="skeleton h-4 w-24 rounded"></div>
<div class="skeleton h-3 w-32 rounded"></div>
{/if}
</div>
</div>
</Card.Header>
<Card.Content class="overflow-auto" style="max-height: calc(100% - 60px);">
<div class="grid grid-cols-2 gap-4">
<!-- Left column -->
<div class="space-y-3">
<!-- Container stats skeleton -->
<div class="grid grid-cols-6 gap-1">
{#each [1, 2, 3, 4, 5, 6] as _}
<div class="skeleton h-5 rounded"></div>
{/each}
</div>
<!-- Health banner skeleton -->
<div class="skeleton h-7 rounded-md"></div>
<!-- CPU/Memory bars skeleton -->
<div class="space-y-2 pt-1 border-t border-border/50">
<div class="space-y-1">
<div class="flex items-center justify-between">
<div class="skeleton h-3 w-12 rounded"></div>
<div class="skeleton h-3 w-10 rounded"></div>
</div>
<div class="skeleton h-1.5 rounded-full"></div>
</div>
<div class="space-y-1">
<div class="flex items-center justify-between">
<div class="skeleton h-3 w-16 rounded"></div>
<div class="skeleton h-3 w-14 rounded"></div>
</div>
<div class="skeleton h-1.5 rounded-full"></div>
</div>
</div>
<!-- Resource stats skeleton -->
<div class="grid grid-cols-2 gap-x-4 gap-y-2">
{#each [1, 2, 3, 4] as _}
<div class="flex items-center justify-between">
<div class="skeleton h-3 w-14 rounded"></div>
<div class="skeleton h-3 w-6 rounded"></div>
</div>
{/each}
</div>
<!-- Events summary skeleton -->
<div class="flex items-center justify-between">
<div class="skeleton h-3 w-20 rounded"></div>
<div class="skeleton h-3 w-12 rounded"></div>
</div>
<!-- Recent events skeleton -->
<div class="pt-2 border-t border-border/50">
<div class="skeleton h-3 w-24 rounded mb-2"></div>
<div class="space-y-1.5">
{#each [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] as _}
<div class="flex items-center gap-2">
<div class="skeleton w-3 h-3 rounded"></div>
<div class="skeleton h-3 flex-1 rounded"></div>
<div class="skeleton h-3 w-10 rounded"></div>
</div>
{/each}
</div>
</div>
<!-- Top containers skeleton -->
<div class="pt-2 border-t border-border/50">
<div class="skeleton h-3 w-32 rounded mb-2"></div>
<div class="space-y-1.5">
{#each [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] as _}
<div class="grid grid-cols-[1fr_auto_auto] gap-x-3 items-center">
<div class="skeleton h-3 rounded"></div>
<div class="skeleton h-3 w-10 rounded"></div>
<div class="skeleton h-3 w-10 rounded"></div>
</div>
{/each}
</div>
</div>
</div>
<!-- Right column -->
<div class="space-y-3 border-l border-border/50 pl-4">
<!-- Charts skeleton -->
<div class="pt-2 border-t border-border/50">
<div class="skeleton h-3 w-28 rounded mb-2"></div>
<div class="skeleton h-32 rounded"></div>
</div>
<!-- Disk usage skeleton -->
<div class="pt-2 border-t border-border/50">
<div class="flex items-center justify-between mb-2">
<div class="skeleton h-3 w-20 rounded"></div>
<div class="skeleton h-3 w-14 rounded"></div>
</div>
<div class="flex items-center gap-4">
<div class="skeleton w-24 h-24 rounded-full shrink-0"></div>
<div class="flex-1 space-y-1.5">
{#each [1, 2, 3, 4] as _}
<div class="flex items-center gap-1.5">
<div class="skeleton w-2 h-2 rounded-full"></div>
<div class="skeleton h-3 w-16 rounded"></div>
<div class="skeleton h-3 w-12 rounded ml-auto"></div>
</div>
{/each}
</div>
</div>
</div>
</div>
</div>
</Card.Content>
{/if}
</Card.Root>

View File

@@ -0,0 +1,150 @@
<script lang="ts">
import {
Play,
Square,
Pause,
RefreshCw,
AlertTriangle,
Loader2
} from 'lucide-svelte';
interface ContainerCounts {
running: number;
stopped: number;
paused: number;
restarting: number;
unhealthy: number;
total: number;
}
interface Props {
containers: ContainerCounts;
compact?: boolean;
loading?: boolean;
}
let { containers, compact = false, loading = false }: Props = $props();
// Only show skeleton if loading AND we don't have data yet
// This prevents blinking when refreshing with existing data
const hasData = $derived(containers && (containers.total > 0 || containers.running > 0 || containers.stopped > 0));
const showSkeleton = $derived(loading && !hasData);
</script>
{#if showSkeleton && compact}
<!-- Compact skeleton view -->
<div class="flex items-center gap-1.5 shrink-0">
<div class="flex items-center gap-0.5">
<Play class="w-3 h-3 text-muted-foreground/50" />
<div class="skeleton w-3 h-3 rounded"></div>
</div>
<div class="flex items-center gap-0.5">
<Square class="w-3 h-3 text-muted-foreground/50" />
<div class="skeleton w-3 h-3 rounded"></div>
</div>
<div class="flex items-center gap-0.5">
<Pause class="w-3 h-3 text-muted-foreground/50" />
<div class="skeleton w-3 h-3 rounded"></div>
</div>
<div class="flex items-center gap-0.5">
<RefreshCw class="w-3 h-3 text-muted-foreground/50" />
<div class="skeleton w-3 h-3 rounded"></div>
</div>
<div class="flex items-center gap-0.5">
<AlertTriangle class="w-3 h-3 text-muted-foreground/50" />
<div class="skeleton w-3 h-3 rounded"></div>
</div>
</div>
{:else if showSkeleton}
<!-- Full skeleton grid view -->
<div class="grid grid-cols-6 gap-1 min-h-5">
<div class="flex items-center gap-1">
<Play class="w-3.5 h-3.5 text-muted-foreground/50" />
<div class="skeleton w-4 h-4 rounded"></div>
</div>
<div class="flex items-center gap-1">
<Square class="w-3.5 h-3.5 text-muted-foreground/50" />
<div class="skeleton w-4 h-4 rounded"></div>
</div>
<div class="flex items-center gap-1">
<Pause class="w-3.5 h-3.5 text-muted-foreground/50" />
<div class="skeleton w-4 h-4 rounded"></div>
</div>
<div class="flex items-center gap-1">
<RefreshCw class="w-3.5 h-3.5 text-muted-foreground/50" />
<div class="skeleton w-4 h-4 rounded"></div>
</div>
<div class="flex items-center gap-1">
<AlertTriangle class="w-3.5 h-3.5 text-muted-foreground/50" />
<div class="skeleton w-4 h-4 rounded"></div>
</div>
<div class="flex items-center gap-1">
<span class="text-xs text-muted-foreground/50">Total</span>
<div class="skeleton w-4 h-4 rounded"></div>
</div>
</div>
{:else if compact}
<!-- Compact view for mini tiles -->
<div class="flex items-center gap-1.5 shrink-0">
<div class="flex items-center gap-0.5" title="Running">
<Play class="w-3 h-3 text-emerald-500" />
<span class="text-2xs font-medium">{containers.running}</span>
</div>
<div class="flex items-center gap-0.5" title="Stopped">
<Square class="w-3 h-3 text-muted-foreground" />
<span class="text-2xs font-medium">{containers.stopped}</span>
</div>
<div class="flex items-center gap-0.5" title="Paused">
<Pause class="w-3 h-3 text-amber-500" />
<span class="text-2xs font-medium">{containers.paused}</span>
</div>
<div class="flex items-center gap-0.5" title="Restarting">
<RefreshCw class="w-3 h-3 {containers.restarting > 0 ? 'text-red-500 animate-spin' : 'text-emerald-500'}" />
<span class="text-2xs font-medium">{containers.restarting}</span>
</div>
<div class="flex items-center gap-0.5" title="Unhealthy">
<AlertTriangle class="w-3 h-3 {containers.unhealthy > 0 ? 'text-red-500' : 'text-emerald-500'}" />
<span class="text-2xs font-medium">{containers.unhealthy}</span>
</div>
</div>
{:else}
<!-- Full grid view -->
<div class="grid grid-cols-6 gap-1 min-h-5">
<div class="flex items-center gap-1" title="Running containers">
<Play class="w-3.5 h-3.5 text-emerald-500" />
<span class="text-sm font-medium">{containers.running}</span>
</div>
<div class="flex items-center gap-1" title="Stopped containers">
<Square class="w-3.5 h-3.5 text-muted-foreground" />
<span class="text-sm font-medium">{containers.stopped}</span>
</div>
<div class="flex items-center gap-1" title="Paused containers">
<Pause class="w-3.5 h-3.5 text-amber-500" />
<span class="text-sm font-medium">{containers.paused}</span>
</div>
<div class="flex items-center gap-1" title="Restarting containers">
<RefreshCw class="w-3.5 h-3.5 {containers.restarting > 0 ? 'text-red-500 animate-spin' : 'text-emerald-500'}" />
<span class="text-sm font-medium">{containers.restarting}</span>
</div>
<div class="flex items-center gap-1" title="Unhealthy containers">
<AlertTriangle class="w-3.5 h-3.5 {containers.unhealthy > 0 ? 'text-red-500' : 'text-emerald-500'}" />
<span class="text-sm font-medium">{containers.unhealthy}</span>
</div>
<div class="flex items-center gap-1" title="Total containers">
<span class="text-xs text-muted-foreground">Total</span>
<span class="text-sm font-medium">{containers.total}</span>
</div>
</div>
{/if}
<style>
@keyframes shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
.skeleton {
background: linear-gradient(90deg, hsl(var(--muted)) 25%, hsl(var(--muted-foreground) / 0.1) 50%, hsl(var(--muted)) 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
}
</style>

View File

@@ -0,0 +1,176 @@
<script lang="ts">
import { Cpu, MemoryStick, Loader2 } from 'lucide-svelte';
interface Metrics {
cpuPercent?: number;
memoryPercent?: number;
memoryUsed?: number;
}
interface Props {
metrics?: Metrics;
compact?: boolean;
showMemoryUsed?: boolean;
collectMetrics?: boolean;
loading?: boolean;
}
let { metrics, compact = false, showMemoryUsed = true, collectMetrics = true, loading = false }: Props = $props();
// Safe accessors with defaults - clamp to valid ranges
const cpuPercent = $derived(Math.max(0, Math.min(100, metrics?.cpuPercent ?? 0)) || 0);
const memoryPercent = $derived(Math.max(0, Math.min(100, metrics?.memoryPercent ?? 0)) || 0);
const memoryUsed = $derived(Math.max(0, metrics?.memoryUsed ?? 0) || 0);
const hasMetrics = $derived(
metrics &&
(Number.isFinite(metrics.cpuPercent) || Number.isFinite(metrics.memoryPercent))
);
// Only show skeleton if loading AND we don't have metrics yet
const showSkeleton = $derived(loading && !hasMetrics);
function formatBytes(bytes: number): string {
if (!Number.isFinite(bytes) || 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));
if (i < 0 || i >= sizes.length) return '0 B';
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
}
function getProgressColor(percent: number): string {
if (percent >= 90) return 'bg-red-500';
if (percent >= 70) return 'bg-amber-500';
return 'bg-emerald-500';
}
</script>
{#if showSkeleton}
<!-- Skeleton loading state -->
{#if compact}
<div class="flex items-center gap-3">
<div class="flex items-center gap-1.5 flex-1">
<Cpu class="w-3 h-3 text-muted-foreground/50 shrink-0" />
<div class="h-1.5 bg-muted rounded-full overflow-hidden flex-1">
<div class="skeleton h-full w-1/2 rounded-full"></div>
</div>
<div class="skeleton w-8 h-3 rounded"></div>
</div>
<div class="flex items-center gap-1.5 flex-1">
<MemoryStick class="w-3 h-3 text-muted-foreground/50 shrink-0" />
<div class="h-1.5 bg-muted rounded-full overflow-hidden flex-1">
<div class="skeleton h-full w-2/3 rounded-full"></div>
</div>
<div class="skeleton w-8 h-3 rounded"></div>
</div>
</div>
{:else}
<div class="space-y-2 pt-1 border-t border-border/50">
<div class="space-y-1">
<div class="flex items-center justify-between text-xs">
<span class="flex items-center gap-1 text-muted-foreground/50">
<Cpu class="w-3 h-3" /> CPU <Loader2 class="w-3 h-3 animate-spin" />
</span>
<div class="skeleton w-10 h-3.5 rounded"></div>
</div>
<div class="h-1.5 bg-muted rounded-full overflow-hidden">
<div class="skeleton h-full w-1/3 rounded-full"></div>
</div>
</div>
<div class="space-y-1">
<div class="flex items-center justify-between text-xs">
<span class="flex items-center gap-1 text-muted-foreground/50">
<MemoryStick class="w-3 h-3" /> Memory <Loader2 class="w-3 h-3 animate-spin" />
</span>
<div class="skeleton w-16 h-3.5 rounded"></div>
</div>
<div class="h-1.5 bg-muted rounded-full overflow-hidden">
<div class="skeleton h-full w-1/2 rounded-full"></div>
</div>
</div>
</div>
{/if}
{:else if !collectMetrics}
<!-- Metrics collection disabled -->
<div class="text-xs text-muted-foreground text-center py-1">
Metrics collection disabled
</div>
{:else if !hasMetrics}
<!-- No metrics available -->
<div class="text-xs text-muted-foreground text-center py-1">
No metrics available
</div>
{:else if compact}
<!-- Compact horizontal bars for mini tiles -->
<div class="flex items-center gap-3">
<div class="flex items-center gap-1.5 flex-1">
<Cpu class="w-3 h-3 text-muted-foreground shrink-0" />
<div class="h-1.5 bg-muted rounded-full overflow-hidden flex-1">
<div
class="h-full rounded-full transition-all {getProgressColor(cpuPercent)}"
style="width: {Math.min(cpuPercent, 100)}%"
></div>
</div>
<span class="text-2xs font-medium w-8 text-right">{cpuPercent.toFixed(0)}%</span>
</div>
<div class="flex items-center gap-1.5 flex-1">
<MemoryStick class="w-3 h-3 text-muted-foreground shrink-0" />
<div class="h-1.5 bg-muted rounded-full overflow-hidden flex-1">
<div
class="h-full rounded-full transition-all {getProgressColor(memoryPercent)}"
style="width: {Math.min(memoryPercent, 100)}%"
></div>
</div>
<span class="text-2xs font-medium w-8 text-right">{memoryPercent.toFixed(0)}%</span>
</div>
</div>
{:else}
<!-- Full stacked bars for standard tiles -->
<div class="space-y-2 pt-1 border-t border-border/50">
<div class="space-y-1">
<div class="flex items-center justify-between text-xs">
<span class="flex items-center gap-1 text-muted-foreground">
<Cpu class="w-3 h-3" /> CPU
</span>
<span class="font-medium">{cpuPercent.toFixed(1)}%</span>
</div>
<div class="h-1.5 bg-muted rounded-full overflow-hidden">
<div
class="h-full rounded-full transition-all {getProgressColor(cpuPercent)}"
style="width: {Math.min(cpuPercent, 100)}%"
></div>
</div>
</div>
<div class="space-y-1">
<div class="flex items-center justify-between text-xs">
<span class="flex items-center gap-1 text-muted-foreground">
<MemoryStick class="w-3 h-3" /> Memory
</span>
<span class="font-medium">
{memoryPercent.toFixed(1)}%
{#if showMemoryUsed}
<span class="text-muted-foreground">({formatBytes(memoryUsed)})</span>
{/if}
</span>
</div>
<div class="h-1.5 bg-muted rounded-full overflow-hidden">
<div
class="h-full rounded-full transition-all {getProgressColor(memoryPercent)}"
style="width: {Math.min(memoryPercent, 100)}%"
></div>
</div>
</div>
</div>
{/if}
<style>
@keyframes shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
.skeleton {
background: linear-gradient(90deg, hsl(var(--muted)) 25%, hsl(var(--muted-foreground) / 0.1) 50%, hsl(var(--muted)) 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
}
</style>

View File

@@ -0,0 +1,142 @@
<script lang="ts">
import { Cpu } from 'lucide-svelte';
import { Chart, Svg, Area } from 'layerchart';
import { scaleTime } from 'd3-scale';
interface MetricsHistory {
cpu_percent: number;
memory_percent: number;
timestamp: string;
}
interface Metrics {
cpuPercent?: number;
memoryPercent?: number;
memoryUsed?: number;
}
interface Props {
metricsHistory?: MetricsHistory[];
metrics?: Metrics;
}
let { metricsHistory, metrics }: Props = $props();
// Safe accessors with defaults - clamp to valid ranges
const cpuPercent = $derived(Math.max(0, Math.min(100, metrics?.cpuPercent ?? 0)) || 0);
const memoryPercent = $derived(Math.max(0, Math.min(100, metrics?.memoryPercent ?? 0)) || 0);
const memoryUsed = $derived(Math.max(0, metrics?.memoryUsed ?? 0) || 0);
const hasMetrics = $derived(
metrics &&
(Number.isFinite(metrics.cpuPercent) || Number.isFinite(metrics.memoryPercent))
);
function formatBytes(bytes: number): string {
if (!Number.isFinite(bytes) || 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));
if (i < 0 || i >= sizes.length) return '0 B';
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
}
function getProgressColor(percent: number): string {
if (percent >= 90) return 'bg-red-500';
if (percent >= 70) return 'bg-amber-500';
return 'bg-emerald-500';
}
const cpuChartData = $derived(
metricsHistory?.map(m => ({
date: new Date(m.timestamp),
value: m.cpu_percent
})) ?? []
);
const memoryChartData = $derived(
metricsHistory?.map(m => ({
date: new Date(m.timestamp),
value: m.memory_percent
})) ?? []
);
const hasHistory = $derived(metricsHistory && metricsHistory.length > 1);
</script>
{#if !hasMetrics}
<!-- No metrics available -->
<div class="pt-2 border-t border-border/50">
<div class="text-xs text-muted-foreground text-center py-1">
No metrics available
</div>
</div>
{:else}
<div class="pt-2 border-t border-border/50">
<div class="flex items-center gap-1.5 text-xs text-muted-foreground mb-2">
<Cpu class="w-3 h-3" />
<span class="font-medium">CPU & Memory history</span>
</div>
<!-- CPU chart -->
<div class="mb-3">
<div class="flex items-center justify-between text-xs mb-1">
<span class="text-muted-foreground">CPU</span>
<span class="font-medium">{cpuPercent.toFixed(1)}%</span>
</div>
{#if hasHistory}
<div class="h-12 w-full">
<Chart
data={cpuChartData}
x="date"
xScale={scaleTime()}
y="value"
yDomain={[0, 100]}
padding={{ left: 0, right: 0, top: 2, bottom: 2 }}
>
<Svg>
<Area line={{ class: 'stroke stroke-emerald-500' }} class="fill-emerald-500/30" />
</Svg>
</Chart>
</div>
{:else}
<div class="h-3 bg-muted rounded-full overflow-hidden">
<div
class="h-full rounded-full transition-all {getProgressColor(cpuPercent)}"
style="width: {Math.min(cpuPercent, 100)}%"
></div>
</div>
{/if}
</div>
<!-- Memory chart -->
<div class="mb-3">
<div class="flex items-center justify-between text-xs mb-1">
<span class="text-muted-foreground">Memory</span>
<span class="font-medium">{memoryPercent.toFixed(1)}% ({formatBytes(memoryUsed)})</span>
</div>
{#if hasHistory}
<div class="h-12 w-full">
<Chart
data={memoryChartData}
x="date"
xScale={scaleTime()}
y="value"
yDomain={[0, 100]}
padding={{ left: 0, right: 0, top: 2, bottom: 2 }}
>
<Svg>
<Area line={{ class: 'stroke stroke-blue-500' }} class="fill-blue-500/30" />
</Svg>
</Chart>
</div>
{:else}
<div class="h-3 bg-muted rounded-full overflow-hidden">
<div
class="h-full rounded-full transition-all {getProgressColor(memoryPercent)}"
style="width: {Math.min(memoryPercent, 100)}%"
></div>
</div>
{/if}
</div>
</div>
{/if}

View File

@@ -0,0 +1,213 @@
<script lang="ts">
import { HardDrive, Image, Database, Box, Hammer, Loader2 } from 'lucide-svelte';
import { Chart, Svg, Pie, Arc } from 'layerchart';
interface Props {
imagesSize: number;
volumesSize: number;
containersSize?: number;
buildCacheSize?: number;
withBorder?: boolean;
showPieChart?: boolean;
loading?: boolean;
}
let { imagesSize, volumesSize, containersSize = 0, buildCacheSize = 0, withBorder = true, showPieChart = false, loading = false }: Props = $props();
const totalSize = $derived(imagesSize + volumesSize + containersSize + buildCacheSize);
// Only show skeleton if loading AND we don't have data yet
const showSkeleton = $derived(loading && totalSize === 0);
// Count how many categories have data for grid layout
const categoryCount = $derived(
[imagesSize, volumesSize, containersSize, buildCacheSize].filter(v => v > 0).length
);
// Pie chart data - only include non-zero values
const pieData = $derived(
[
{ key: 'images', label: 'Images', value: imagesSize, color: '#0ea5e9' },
{ key: 'containers', label: 'Containers', value: containersSize, color: '#10b981' },
{ key: 'volumes', label: 'Volumes', value: volumesSize, color: '#f59e0b' },
{ key: 'buildCache', label: 'Build cache', value: buildCacheSize, color: '#8b5cf6' }
].filter(d => d.value > 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];
}
function getPercentage(value: number): number {
if (totalSize === 0) return 0;
return (value / totalSize) * 100;
}
</script>
{#if showSkeleton}
<div class="{withBorder ? 'pt-2 border-t border-border/50' : ''}">
<div class="flex items-center gap-1.5 text-xs text-muted-foreground mb-2">
<HardDrive class="w-3 h-3" />
<span class="font-medium">Disk usage</span>
<Loader2 class="w-3 h-3 animate-spin" />
<div class="skeleton w-12 h-3.5 rounded ml-auto"></div>
</div>
<div class="h-2 rounded-full overflow-hidden flex bg-muted mb-2">
<div class="skeleton h-full w-1/4 rounded-full"></div>
<div class="skeleton h-full w-1/6 rounded-full ml-0.5"></div>
<div class="skeleton h-full w-1/5 rounded-full ml-0.5"></div>
</div>
<div class="grid grid-cols-2 gap-x-3 gap-y-1.5 text-xs">
<div class="flex items-center gap-1.5">
<div class="w-2 h-2 rounded-full bg-muted shrink-0"></div>
<Image class="w-3 h-3 text-muted-foreground/50 shrink-0" />
<span class="text-muted-foreground/50">Images</span>
<div class="skeleton w-10 h-3 rounded ml-auto"></div>
</div>
<div class="flex items-center gap-1.5">
<div class="w-2 h-2 rounded-full bg-muted shrink-0"></div>
<Database class="w-3 h-3 text-muted-foreground/50 shrink-0" />
<span class="text-muted-foreground/50">Volumes</span>
<div class="skeleton w-10 h-3 rounded ml-auto"></div>
</div>
</div>
</div>
{:else if totalSize > 0}
<div class="{withBorder ? 'pt-2 border-t border-border/50' : ''}">
<div class="flex items-center gap-1.5 text-xs text-muted-foreground mb-2">
<HardDrive class="w-3 h-3" />
<span class="font-medium">Disk usage</span>
<span class="ml-auto font-medium text-foreground">{formatBytes(totalSize)}</span>
</div>
{#if showPieChart && pieData.length > 0}
<!-- Pie chart visualization -->
<div class="flex items-center gap-4 mb-2">
<div class="w-24 h-24 shrink-0">
<Chart
data={pieData}
x="value"
y="key"
>
<Svg center>
<Pie
innerRadius={0.5}
padAngle={0.02}
cornerRadius={2}
>
{#snippet children({ arcs })}
{#each arcs as arc}
<Arc
startAngle={arc.startAngle}
endAngle={arc.endAngle}
innerRadius={0.5}
padAngle={0.02}
cornerRadius={2}
fill={arc.data.color}
class="transition-opacity hover:opacity-80"
/>
{/each}
{/snippet}
</Pie>
</Svg>
</Chart>
</div>
<!-- Legend with values (vertical) -->
<div class="flex flex-col gap-1.5 text-xs flex-1">
{#each pieData as item}
<div class="flex items-center gap-1.5">
<div class="w-2 h-2 rounded-full shrink-0" style="background-color: {item.color}"></div>
<span class="text-muted-foreground truncate">{item.label}</span>
<span class="ml-auto font-medium tabular-nums">{formatBytes(item.value)}</span>
</div>
{/each}
</div>
</div>
{:else}
<!-- Stacked bar showing proportions -->
<div class="h-2 rounded-full overflow-hidden flex bg-muted mb-2">
{#if imagesSize > 0}
<div
class="bg-sky-500 h-full transition-all duration-300"
style="width: {getPercentage(imagesSize)}%"
title="Images: {formatBytes(imagesSize)}"
></div>
{/if}
{#if containersSize > 0}
<div
class="bg-emerald-500 h-full transition-all duration-300"
style="width: {getPercentage(containersSize)}%"
title="Containers: {formatBytes(containersSize)}"
></div>
{/if}
{#if volumesSize > 0}
<div
class="bg-amber-500 h-full transition-all duration-300"
style="width: {getPercentage(volumesSize)}%"
title="Volumes: {formatBytes(volumesSize)}"
></div>
{/if}
{#if buildCacheSize > 0}
<div
class="bg-violet-500 h-full transition-all duration-300"
style="width: {getPercentage(buildCacheSize)}%"
title="Build cache: {formatBytes(buildCacheSize)}"
></div>
{/if}
</div>
<!-- Legend with values -->
<div class="grid {categoryCount > 2 ? 'grid-cols-2' : 'grid-cols-' + categoryCount} gap-x-3 gap-y-1.5 text-xs">
{#if imagesSize > 0}
<div class="flex items-center gap-1.5">
<div class="w-2 h-2 rounded-full bg-sky-500 shrink-0"></div>
<Image class="w-3 h-3 text-muted-foreground shrink-0" />
<span class="text-muted-foreground truncate">Images</span>
<span class="ml-auto font-medium tabular-nums">{formatBytes(imagesSize)}</span>
</div>
{/if}
{#if containersSize > 0}
<div class="flex items-center gap-1.5">
<div class="w-2 h-2 rounded-full bg-emerald-500 shrink-0"></div>
<Box class="w-3 h-3 text-muted-foreground shrink-0" />
<span class="text-muted-foreground truncate">Containers</span>
<span class="ml-auto font-medium tabular-nums">{formatBytes(containersSize)}</span>
</div>
{/if}
{#if volumesSize > 0}
<div class="flex items-center gap-1.5">
<div class="w-2 h-2 rounded-full bg-amber-500 shrink-0"></div>
<Database class="w-3 h-3 text-muted-foreground shrink-0" />
<span class="text-muted-foreground truncate">Volumes</span>
<span class="ml-auto font-medium tabular-nums">{formatBytes(volumesSize)}</span>
</div>
{/if}
{#if buildCacheSize > 0}
<div class="flex items-center gap-1.5">
<div class="w-2 h-2 rounded-full bg-violet-500 shrink-0"></div>
<Hammer class="w-3 h-3 text-muted-foreground shrink-0" />
<span class="text-muted-foreground truncate">Build cache</span>
<span class="ml-auto font-medium tabular-nums">{formatBytes(buildCacheSize)}</span>
</div>
{/if}
</div>
{/if}
</div>
{/if}
<style>
@keyframes shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
.skeleton {
background: linear-gradient(90deg, hsl(var(--muted)) 25%, hsl(var(--muted-foreground) / 0.1) 50%, hsl(var(--muted)) 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
}
</style>

View File

@@ -0,0 +1,21 @@
<script lang="ts">
import { Activity } from 'lucide-svelte';
interface Props {
today: number;
total: number;
}
let { today, total }: Props = $props();
</script>
{#if total > 0}
<div class="flex items-center justify-between text-xs pt-1 border-t border-border/50">
<span class="flex items-center gap-1 text-muted-foreground">
<Activity class="w-3 h-3" /> Events
</span>
<span class="font-medium">
{today} today <span class="text-muted-foreground">/ {total} total</span>
</span>
</div>
{/if}

View File

@@ -0,0 +1,174 @@
<script lang="ts">
import {
ShieldCheck,
Activity,
Cpu,
Wifi,
WifiOff,
Settings,
Route,
UndoDot,
Unplug,
Icon,
CircleArrowUp,
CircleFadingArrowUp
} from 'lucide-svelte';
import { whale } from '@lucide/lab';
import { getIconComponent } from '$lib/utils/icons';
import { goto } from '$app/navigation';
import { canAccess } from '$lib/stores/auth';
import type { Component } from 'svelte';
type ConnectionType = 'socket' | 'direct' | 'hawser-standard' | 'hawser-edge';
interface Props {
name: string;
host?: string;
port?: number | null;
icon: string;
socketPath?: string;
online: boolean;
scannerEnabled: boolean;
collectActivity: boolean;
collectMetrics: boolean;
updateCheckEnabled?: boolean;
updateCheckAutoUpdate?: boolean;
connectionType?: ConnectionType;
environmentId: number;
width?: number;
height?: number;
compact?: boolean;
}
let {
name,
host,
port = null,
icon,
socketPath,
online,
scannerEnabled,
collectActivity,
collectMetrics,
updateCheckEnabled = false,
updateCheckAutoUpdate = false,
connectionType = 'socket',
environmentId,
width = 1,
height = 1,
compact = false
}: Props = $props();
// Format host with port for display
const hostDisplay = $derived(
connectionType === 'socket' ? (socketPath || '/var/run/docker.sock') :
connectionType === 'hawser-edge' ? 'Edge connection' :
(port ? `${host}:${port}` : host || 'Unknown host')
);
const EnvIcon = $derived(getIconComponent(icon)) as Component;
const canEdit = $derived($canAccess('environments', 'edit'));
function openSettings(e: MouseEvent) {
e.stopPropagation();
goto(`/settings?tab=environments&edit=${environmentId}`);
}
function stopPointerPropagation(e: PointerEvent) {
e.stopPropagation();
}
</script>
{#if compact}
<!-- Compact header for mini tiles -->
<div class="flex items-center gap-2 min-w-0 flex-1">
<div class="p-1.5 rounded-lg {online ? 'bg-primary/10' : 'bg-muted'}">
<EnvIcon class="w-4 h-4 {online ? 'text-primary' : 'text-muted-foreground'}" />
</div>
<div class="min-w-0 flex-1">
<div class="flex items-center gap-1.5">
<span class="font-medium text-sm truncate">{name}</span>
{#if online}
<Wifi class="w-3 h-3 text-green-500 shrink-0" />
{:else}
<WifiOff class="w-3 h-3 text-red-500 shrink-0" />
{/if}
</div>
<span class="text-xs text-muted-foreground truncate block">{hostDisplay}</span>
</div>
</div>
{:else}
<!-- Full header for standard tiles -->
<div class="flex items-center justify-between">
<div class="flex items-center gap-2 min-w-0 flex-1">
<div class="p-1.5 rounded-lg {online ? 'bg-primary/10' : 'bg-muted'}">
<EnvIcon class="w-4 h-4 {online ? 'text-primary' : 'text-muted-foreground'}" />
</div>
{#if connectionType === 'socket' || !connectionType}
<span title="Unix socket connection">
<Unplug class="w-4 h-4 text-cyan-500 glow-cyan" />
</span>
{:else if connectionType === 'direct'}
<span title="Direct Docker connection">
<Icon iconNode={whale} class="w-4 h-4 text-blue-500 glow-blue" />
</span>
{:else if connectionType === 'hawser-standard'}
<span title="Hawser agent (standard mode)">
<Route class="w-4 h-4 text-purple-500 glow-purple" />
</span>
{:else if connectionType === 'hawser-edge'}
<span title="Hawser agent (edge mode)">
<UndoDot class="w-4 h-4 text-green-500 glow-green" />
</span>
{/if}
<div class="min-w-0 flex-1">
<div class="flex items-center gap-1.5">
<span class="font-medium text-sm truncate">{name}</span>
{#if online}
<Wifi class="w-3 h-3 text-green-500 shrink-0" />
{:else}
<WifiOff class="w-3 h-3 text-red-500 shrink-0" />
{/if}
</div>
<span class="text-xs text-muted-foreground truncate block">{hostDisplay}</span>
</div>
</div>
<div class="flex items-center gap-1.5">
{#if updateCheckEnabled}
<span title={updateCheckAutoUpdate ? "Auto-update enabled" : "Update check enabled (notify only)"}>
{#if updateCheckAutoUpdate}
<CircleArrowUp class="w-4 h-4 text-green-500 glow-green" />
{:else}
<CircleFadingArrowUp class="w-4 h-4 text-green-500 glow-green" />
{/if}
</span>
{/if}
{#if scannerEnabled}
<span title="Vulnerability scanning enabled">
<ShieldCheck class="w-4 h-4 text-green-500 glow-green" />
</span>
{/if}
{#if collectActivity}
<span title="Activity collection enabled">
<Activity class="w-4 h-4 text-amber-500 glow-amber" />
</span>
{/if}
{#if collectMetrics}
<span title="Metrics collection enabled">
<Cpu class="w-4 h-4 text-sky-400 glow-sky" />
</span>
{/if}
{#if canEdit}
<button
onpointerdown={stopPointerPropagation}
onclick={openSettings}
class="p-0.5 rounded hover:bg-muted transition-colors"
title="Edit environment settings"
>
<Settings class="w-3.5 h-3.5 text-muted-foreground hover:text-foreground" />
</button>
{/if}
</div>
</div>
{/if}

View File

@@ -0,0 +1,27 @@
<script lang="ts">
import {
AlertTriangle,
RefreshCw,
CircleCheck
} from 'lucide-svelte';
interface Props {
unhealthy: number;
restarting: number;
}
let { unhealthy, restarting }: Props = $props();
</script>
<div class="flex items-center gap-2 px-2 py-1.5 rounded-md {unhealthy > 0 ? 'bg-amber-500/10 text-amber-600 dark:text-amber-400' : restarting > 0 ? 'bg-red-500/10 text-red-600 dark:text-red-400' : 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400'}">
{#if unhealthy > 0}
<AlertTriangle class="w-3.5 h-3.5" />
<span class="text-xs font-medium truncate">{unhealthy} unhealthy</span>
{:else if restarting > 0}
<RefreshCw class="w-3.5 h-3.5 animate-spin" />
<span class="text-xs font-medium truncate">{restarting} restarting</span>
{:else}
<CircleCheck class="w-3.5 h-3.5" />
<span class="text-xs font-medium truncate">All containers healthy</span>
{/if}
</div>

View File

@@ -0,0 +1,45 @@
<script lang="ts">
import { getLabelColors } from '$lib/utils/label-colors';
interface Props {
labels?: string[];
compact?: boolean;
unified?: boolean;
}
let { labels = [], compact = false, unified = false }: Props = $props();
</script>
{#if labels && labels.length > 0}
{#if compact}
<div class="flex flex-wrap gap-0.5 pl-6 mt-0.5">
{#each labels as label}
{@const colors = getLabelColors(label)}
<span class="px-1.5 py-0.5 text-[11px] rounded-sm font-medium leading-tight"
style="background-color: {colors.bgColor}; color: {colors.color}">
{label}
</span>
{/each}
</div>
{:else if unified}
<div class="flex flex-wrap gap-0.5 px-4 -mt-0.5 mb-1">
{#each labels as label}
{@const colors = getLabelColors(label)}
<span class="px-1.5 py-0.5 text-[11px] rounded-sm font-medium leading-tight"
style="background-color: {colors.bgColor}; color: {colors.color}">
{label}
</span>
{/each}
</div>
{:else}
<div class="flex flex-wrap gap-0.5 pl-6 pr-4 -mt-8 -mb-4">
{#each labels as label}
{@const colors = getLabelColors(label)}
<span class="px-1.5 py-0.5 text-[11px] rounded-sm font-medium leading-tight"
style="background-color: {colors.bgColor}; color: {colors.color}">
{label}
</span>
{/each}
</div>
{/if}
{/if}

View File

@@ -0,0 +1,25 @@
<script lang="ts">
import { WifiOff } from 'lucide-svelte';
interface Props {
error?: string;
compact?: boolean;
}
let { error, compact = false }: Props = $props();
</script>
{#if compact}
<div class="flex items-center gap-2 text-muted-foreground py-1">
<WifiOff class="w-4 h-4 opacity-50" />
<span class="text-xs">Offline</span>
</div>
{:else}
<div class="flex flex-col items-center justify-center py-8 text-muted-foreground">
<WifiOff class="w-8 h-8 mb-2 opacity-50" />
<span class="text-sm">Environment offline</span>
{#if error}
<span class="text-xs mt-1 text-red-500/70">{error}</span>
{/if}
</div>
{/if}

View File

@@ -0,0 +1,133 @@
<script lang="ts">
import {
Activity,
Play,
Square,
Skull,
Zap,
RotateCcw,
Pause,
CirclePlay,
Trash2,
Plus,
Pencil,
AlertTriangle,
Heart
} from 'lucide-svelte';
import type { Component } from 'svelte';
import { formatDateTime } from '$lib/stores/settings';
interface Event {
container_name: string;
action: string;
timestamp: string;
}
interface Props {
events: Event[];
limit?: number;
onclick?: () => void;
}
let { events, limit = 8, onclick }: Props = $props();
function getActionIcon(action: string): Component {
switch (action) {
case 'create': return Plus;
case 'start': return Play;
case 'stop': return Square;
case 'die': return Skull;
case 'kill': return Zap;
case 'restart': return RotateCcw;
case 'pause': return Pause;
case 'unpause': return CirclePlay;
case 'destroy': return Trash2;
case 'rename': return Pencil;
case 'update': return Pencil;
case 'oom': return AlertTriangle;
case 'health_status': return Heart;
default: return Activity;
}
}
function getActionColor(action: string): string {
switch (action) {
case 'create':
case 'start':
case 'unpause':
return 'text-emerald-600 dark:text-emerald-400';
case 'stop':
case 'die':
case 'kill':
case 'destroy':
case 'oom':
return 'text-rose-600 dark:text-rose-400';
case 'restart':
case 'pause':
case 'update':
case 'rename':
return 'text-amber-600 dark:text-amber-400';
case 'health_status':
return 'text-sky-600 dark:text-sky-400';
default:
return 'text-slate-600 dark:text-slate-400';
}
}
function formatTime(timestamp: string): string {
const date = new Date(timestamp);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
if (diffMins < 1) return 'now';
if (diffMins < 60) return `${diffMins}m`;
const diffHours = Math.floor(diffMins / 60);
if (diffHours < 24) return `${diffHours}h`;
const diffDays = Math.floor(diffHours / 24);
return `${diffDays}d`;
}
</script>
{#if events && events.length > 0}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
class="pt-2 border-t border-border/50 {onclick ? 'cursor-pointer hover:bg-muted/50 -mx-2 px-2 rounded transition-colors' : ''}"
onclick={(e) => {
if (onclick) {
e.stopPropagation();
onclick();
}
}}
onpointerdown={(e) => {
if (onclick) {
e.stopPropagation();
}
}}
>
<div class="flex items-center gap-1.5 text-xs text-muted-foreground mb-2">
<Activity class="w-3 h-3" />
<span class="font-medium">Recent events</span>
</div>
<!-- Grid layout with fixed columns: timestamp, action icon, container name -->
<div class="grid grid-cols-[auto_auto_1fr] gap-x-2 gap-y-1 text-xs">
{#each events.slice(0, limit) as event}
<!-- Timestamp -->
<span class="text-muted-foreground text-2xs" title={formatDateTime(event.timestamp)}>
{formatTime(event.timestamp)}
</span>
<!-- Action icon -->
<div class="flex items-center justify-center {getActionColor(event.action)}" title={event.action}>
<svelte:component this={getActionIcon(event.action)} class="w-3 h-3" />
</div>
<!-- Container name -->
<span class="truncate text-foreground" title={event.container_name}>
{event.container_name}
</span>
{/each}
</div>
</div>
{/if}

View File

@@ -0,0 +1,87 @@
<script lang="ts">
import {
Image,
HardDrive,
Network,
Layers,
Loader2
} from 'lucide-svelte';
import type { LoadingStates } from '../api/dashboard/stats/+server';
interface Props {
images: { total: number };
volumes: { total: number };
networks: { total: number };
stacks: { total: number; running: number; partial: number; stopped: number };
loading?: LoadingStates;
}
let { images, volumes, networks, stacks, loading }: Props = $props();
// Only show skeleton if loading AND we don't have data yet
// This prevents blinking when refreshing with existing data
const showImagesSkeleton = $derived(loading?.images && images.total === 0);
const showStacksSkeleton = $derived(loading?.stacks && stacks.total === 0);
const showVolumesSkeleton = $derived(loading?.volumes && volumes.total === 0);
const showNetworksSkeleton = $derived(loading?.networks && networks.total === 0);
</script>
<div class="grid grid-cols-2 gap-x-4 gap-y-2 text-xs">
<div class="flex items-center justify-between">
<span class="flex items-center gap-1 text-muted-foreground">
<Image class="w-3 h-3" /> Images
</span>
{#if showImagesSkeleton}
<div class="skeleton w-4 h-3.5 rounded"></div>
{:else}
<span class="font-medium">{images.total}</span>
{/if}
</div>
<div class="flex items-center justify-between">
<span class="flex items-center gap-1 text-muted-foreground">
<Layers class="w-3 h-3" /> Stacks
</span>
{#if showStacksSkeleton}
<div class="skeleton w-12 h-3.5 rounded"></div>
{:else}
<span class="font-medium">
{stacks.total}
{#if stacks.total > 0}
<span class="text-emerald-500">{stacks.running}</span>/<span class="text-amber-500">{stacks.partial}</span>/<span class="text-red-500">{stacks.stopped}</span>
{/if}
</span>
{/if}
</div>
<div class="flex items-center justify-between">
<span class="flex items-center gap-1 text-muted-foreground">
<HardDrive class="w-3 h-3" /> Volumes
</span>
{#if showVolumesSkeleton}
<div class="skeleton w-4 h-3.5 rounded"></div>
{:else}
<span class="font-medium">{volumes.total}</span>
{/if}
</div>
<div class="flex items-center justify-between">
<span class="flex items-center gap-1 text-muted-foreground">
<Network class="w-3 h-3" /> Networks
</span>
{#if showNetworksSkeleton}
<div class="skeleton w-4 h-3.5 rounded"></div>
{:else}
<span class="font-medium">{networks.total}</span>
{/if}
</div>
</div>
<style>
@keyframes shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
.skeleton {
background: linear-gradient(90deg, hsl(var(--muted)) 25%, hsl(var(--muted-foreground) / 0.1) 50%, hsl(var(--muted)) 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
}
</style>

View File

@@ -0,0 +1,47 @@
<script lang="ts">
import {
ShieldCheck,
Activity,
WifiOff,
CircleArrowUp,
CircleFadingArrowUp
} from 'lucide-svelte';
interface Props {
online: boolean;
scannerEnabled: boolean;
collectActivity: boolean;
updateCheckEnabled?: boolean;
updateCheckAutoUpdate?: boolean;
compact?: boolean;
}
let { online, scannerEnabled, collectActivity, updateCheckEnabled = false, updateCheckAutoUpdate = false, compact = false }: Props = $props();
</script>
<div class="{compact ? 'flex items-center gap-1 shrink-0' : 'flex items-center gap-1.5 shrink-0'}">
{#if updateCheckEnabled}
<span title={updateCheckAutoUpdate ? "Auto-update enabled" : "Update check enabled (notify only)"}>
{#if updateCheckAutoUpdate}
<CircleArrowUp class="{compact ? 'w-3.5 h-3.5 glow-green-sm' : 'w-4 h-4 glow-green'} text-green-500" />
{:else}
<CircleFadingArrowUp class="{compact ? 'w-3.5 h-3.5 glow-green-sm' : 'w-4 h-4 glow-green'} text-green-500" />
{/if}
</span>
{/if}
{#if scannerEnabled}
<span title="Vulnerability scanning enabled">
<ShieldCheck class="{compact ? 'w-3.5 h-3.5 glow-green-sm' : 'w-4 h-4 glow-green'} text-green-500" />
</span>
{/if}
{#if collectActivity}
<span title="Activity collection enabled">
<Activity class="{compact ? 'w-3.5 h-3.5 glow-amber-sm' : 'w-4 h-4 glow-amber'} text-amber-500" />
</span>
{/if}
{#if !online && compact}
<span title="Offline">
<WifiOff class="w-3.5 h-3.5 text-red-500 shrink-0" />
</span>
{/if}
</div>

View File

@@ -0,0 +1,86 @@
<script lang="ts">
import { Box, Cpu, MemoryStick, Loader2 } from 'lucide-svelte';
interface Container {
name: string;
cpuPercent: number;
memoryPercent: number;
}
interface Props {
containers: Container[];
limit?: number;
loading?: boolean;
}
let { containers, limit = 5, loading = false }: Props = $props();
// Only show skeleton if loading AND we don't have data yet
const showSkeleton = $derived(loading && (!containers || containers.length === 0));
</script>
{#if showSkeleton}
<div class="pt-2 border-t border-border/50">
<div class="flex items-center gap-1.5 text-xs text-muted-foreground mb-2">
<Box class="w-3 h-3" />
<span class="font-medium">Top containers by CPU</span>
<Loader2 class="w-3 h-3 animate-spin" />
</div>
<!-- Skeleton rows -->
<div class="grid grid-cols-[1fr_auto_auto] gap-x-3 gap-y-1.5 text-xs items-center">
{#each Array(Math.min(limit, 5)) as _, i}
<div class="skeleton h-3.5 rounded" style="width: {70 - i * 10}%"></div>
<div class="flex items-center gap-0.5">
<Cpu class="w-3 h-3 text-muted-foreground/50 shrink-0" />
<div class="skeleton w-8 h-3.5 rounded"></div>
</div>
<div class="flex items-center gap-0.5">
<MemoryStick class="w-3 h-3 text-muted-foreground/50 shrink-0" />
<div class="skeleton w-8 h-3.5 rounded"></div>
</div>
{/each}
</div>
</div>
{:else if containers && containers.length > 0}
<div class="pt-2 border-t border-border/50">
<div class="flex items-center gap-1.5 text-xs text-muted-foreground mb-2">
<Box class="w-3 h-3" />
<span class="font-medium">Top containers by CPU</span>
</div>
<!-- Grid layout with fixed columns: container name, CPU, Memory -->
<div class="grid grid-cols-[1fr_auto_auto] gap-x-3 gap-y-1.5 text-xs items-center">
{#each containers.slice(0, limit) as container}
<!-- Container name -->
<span class="truncate text-foreground" title={container.name}>
{container.name}
</span>
<!-- CPU -->
<span class="flex items-center gap-0.5 text-muted-foreground whitespace-nowrap" title="CPU">
<Cpu class="w-3 h-3 shrink-0" />
<span class="tabular-nums">{container.cpuPercent.toFixed(1)}%</span>
</span>
<!-- Memory -->
<span class="flex items-center gap-0.5 text-muted-foreground whitespace-nowrap" title="Memory">
<MemoryStick class="w-3 h-3 shrink-0" />
<span class="tabular-nums">{container.memoryPercent.toFixed(1)}%</span>
</span>
{/each}
</div>
</div>
{:else}
<div class="text-xs text-muted-foreground">
No running containers
</div>
{/if}
<style>
@keyframes shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
.skeleton {
background: linear-gradient(90deg, hsl(var(--muted)) 25%, hsl(var(--muted-foreground) / 0.1) 50%, hsl(var(--muted)) 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
}
</style>

14
routes/dashboard/index.ts Normal file
View File

@@ -0,0 +1,14 @@
// Dashboard tile section components
export { default as DashboardHeader } from './dashboard-header.svelte';
export { default as DashboardLabels } from './dashboard-labels.svelte';
export { default as DashboardContainerStats } from './dashboard-container-stats.svelte';
export { default as DashboardHealthBanner } from './dashboard-health-banner.svelte';
export { default as DashboardCpuMemoryBars } from './dashboard-cpu-memory-bars.svelte';
export { default as DashboardResourceStats } from './dashboard-resource-stats.svelte';
export { default as DashboardEventsSummary } from './dashboard-events-summary.svelte';
export { default as DashboardRecentEvents } from './dashboard-recent-events.svelte';
export { default as DashboardTopContainers } from './dashboard-top-containers.svelte';
export { default as DashboardDiskUsage } from './dashboard-disk-usage.svelte';
export { default as DashboardCpuMemoryCharts } from './dashboard-cpu-memory-charts.svelte';
export { default as DashboardOfflineState } from './dashboard-offline-state.svelte';
export { default as DashboardStatusIcons } from './dashboard-status-icons.svelte';