mirror of
https://github.com/khoaliber/dockhand.git
synced 2026-03-12 21:31:00 +00:00
Initial commit
This commit is contained in:
575
routes/dashboard/DraggableGrid.svelte
Normal file
575
routes/dashboard/DraggableGrid.svelte
Normal 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>
|
||||
707
routes/dashboard/EnvironmentTile.svelte
Normal file
707
routes/dashboard/EnvironmentTile.svelte
Normal 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>
|
||||
639
routes/dashboard/EnvironmentTileSkeleton.svelte
Normal file
639
routes/dashboard/EnvironmentTileSkeleton.svelte
Normal 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>
|
||||
150
routes/dashboard/dashboard-container-stats.svelte
Normal file
150
routes/dashboard/dashboard-container-stats.svelte
Normal 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>
|
||||
176
routes/dashboard/dashboard-cpu-memory-bars.svelte
Normal file
176
routes/dashboard/dashboard-cpu-memory-bars.svelte
Normal 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>
|
||||
142
routes/dashboard/dashboard-cpu-memory-charts.svelte
Normal file
142
routes/dashboard/dashboard-cpu-memory-charts.svelte
Normal 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}
|
||||
213
routes/dashboard/dashboard-disk-usage.svelte
Normal file
213
routes/dashboard/dashboard-disk-usage.svelte
Normal 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>
|
||||
21
routes/dashboard/dashboard-events-summary.svelte
Normal file
21
routes/dashboard/dashboard-events-summary.svelte
Normal 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}
|
||||
174
routes/dashboard/dashboard-header.svelte
Normal file
174
routes/dashboard/dashboard-header.svelte
Normal 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}
|
||||
27
routes/dashboard/dashboard-health-banner.svelte
Normal file
27
routes/dashboard/dashboard-health-banner.svelte
Normal 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>
|
||||
45
routes/dashboard/dashboard-labels.svelte
Normal file
45
routes/dashboard/dashboard-labels.svelte
Normal 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}
|
||||
25
routes/dashboard/dashboard-offline-state.svelte
Normal file
25
routes/dashboard/dashboard-offline-state.svelte
Normal 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}
|
||||
133
routes/dashboard/dashboard-recent-events.svelte
Normal file
133
routes/dashboard/dashboard-recent-events.svelte
Normal 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}
|
||||
87
routes/dashboard/dashboard-resource-stats.svelte
Normal file
87
routes/dashboard/dashboard-resource-stats.svelte
Normal 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>
|
||||
47
routes/dashboard/dashboard-status-icons.svelte
Normal file
47
routes/dashboard/dashboard-status-icons.svelte
Normal 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>
|
||||
86
routes/dashboard/dashboard-top-containers.svelte
Normal file
86
routes/dashboard/dashboard-top-containers.svelte
Normal 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
14
routes/dashboard/index.ts
Normal 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';
|
||||
Reference in New Issue
Block a user