v1.0.22
This commit is contained in:
1 parent
aefa5e7925
commit
b55e1e5aad
107 files changed
+2193
-443
No files matched your search
@@ -20,6 +20,7 @@
|
||||
maxW?: number;
|
||||
minH?: number;
|
||||
maxH?: number;
|
||||
locked?: boolean;
|
||||
onchange?: (items: GridItemLayout[]) => void;
|
||||
onitemclick?: (id: number) => void;
|
||||
children: import('svelte').Snippet<[{ item: GridItemLayout; width: number; height: number }]>;
|
||||
@@ -34,6 +35,7 @@
|
||||
maxW = 2,
|
||||
minH = 1,
|
||||
maxH = 4,
|
||||
locked = false,
|
||||
onchange,
|
||||
onitemclick,
|
||||
children
|
||||
@@ -202,6 +204,11 @@
|
||||
if (e.button !== 0) return;
|
||||
e.preventDefault();
|
||||
|
||||
if (locked) {
|
||||
onitemclick?.(item.id);
|
||||
return;
|
||||
}
|
||||
|
||||
dragItem = item;
|
||||
dragStartX = e.clientX;
|
||||
dragStartY = e.clientY;
|
||||
@@ -409,6 +416,11 @@
|
||||
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) => {
|
||||
if (locked) {
|
||||
e.preventDefault();
|
||||
onitemclick?.(item.id);
|
||||
return;
|
||||
}
|
||||
// Check if clicking on resize handle area (bottom-right 28x28 corner)
|
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
@@ -428,11 +440,13 @@
|
||||
</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>
|
||||
{#if !locked}
|
||||
<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>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
<script lang="ts">
|
||||
import { Loader2, Circle, Route, UndoDot, Plug, Icon, CircleArrowUp } from 'lucide-svelte';
|
||||
import { whale } from '@lucide/lab';
|
||||
import EnvironmentIcon from '$lib/components/EnvironmentIcon.svelte';
|
||||
import { getLabelColors } from '$lib/utils/label-colors';
|
||||
import { DataGrid } from '$lib/components/data-grid';
|
||||
import type { DataGridRowState, DataGridSortState } from '$lib/components/data-grid/types';
|
||||
import type { ColumnConfig } from '$lib/types';
|
||||
import type { TileItem } from '$lib/stores/dashboard';
|
||||
|
||||
interface Props {
|
||||
tiles: TileItem[];
|
||||
searchQuery?: string;
|
||||
connectionFilter?: string[];
|
||||
onrowclick?: (envId: number) => void;
|
||||
}
|
||||
|
||||
let { tiles, searchQuery = '', connectionFilter = [], onrowclick }: Props = $props();
|
||||
|
||||
// Sort state
|
||||
let sortState = $state<DataGridSortState>({ field: 'name', direction: 'asc' });
|
||||
|
||||
function connectionLabel(type: string | undefined): string {
|
||||
switch (type) {
|
||||
case 'hawser-standard': return 'Standard';
|
||||
case 'hawser-edge': return 'Edge';
|
||||
case 'direct': return 'Direct';
|
||||
case 'socket': return 'Socket';
|
||||
default: return 'Socket';
|
||||
}
|
||||
}
|
||||
|
||||
function formatPercent(value: number | undefined | null): string {
|
||||
if (value == null || value < 0) return '-';
|
||||
return `${value.toFixed(1)}%`;
|
||||
}
|
||||
|
||||
function getSortValue(tile: TileItem, field: string): number | string {
|
||||
const s = tile.stats;
|
||||
if (!s) return '';
|
||||
switch (field) {
|
||||
case 'name': return s.name.toLowerCase();
|
||||
case 'status': return s.online === true ? 0 : s.online === false ? 2 : 1;
|
||||
case 'connection': return s.connectionType || '';
|
||||
case 'host': return s.host || '';
|
||||
case 'containers': return s.containers.running;
|
||||
case 'cpu': return s.metrics?.cpuPercent ?? -1;
|
||||
case 'memory': return s.metrics?.memoryPercent ?? -1;
|
||||
case 'images': return s.images.total;
|
||||
case 'volumes': return s.volumes.total;
|
||||
case 'stacks': return s.stacks.running;
|
||||
case 'updates': return s.containers.pendingUpdates ?? 0;
|
||||
case 'events': return s.events.today;
|
||||
default: return '';
|
||||
}
|
||||
}
|
||||
|
||||
// Filter by search query and connection type
|
||||
const filteredTiles = $derived.by(() => {
|
||||
let result = tiles;
|
||||
|
||||
// Connection type filter
|
||||
if (connectionFilter.length > 0) {
|
||||
result = result.filter(t => {
|
||||
const type = t.stats?.connectionType || 'socket';
|
||||
return connectionFilter.includes(type);
|
||||
});
|
||||
}
|
||||
|
||||
// Search filter
|
||||
const q = searchQuery.trim().toLowerCase();
|
||||
if (q) {
|
||||
result = result.filter(t => {
|
||||
const s = t.stats;
|
||||
if (!s) return false;
|
||||
if (s.name.toLowerCase().includes(q)) return true;
|
||||
if (s.host?.toLowerCase().includes(q)) return true;
|
||||
if (connectionLabel(s.connectionType).toLowerCase().includes(q)) return true;
|
||||
if (s.labels?.some(l => l.toLowerCase().includes(q))) return true;
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
// Sort filtered tiles
|
||||
const sortedTiles = $derived.by(() => {
|
||||
const field = sortState.field || 'name';
|
||||
const dir = sortState.direction || 'asc';
|
||||
return [...filteredTiles].sort((a, b) => {
|
||||
const aVal = getSortValue(a, field);
|
||||
const bVal = getSortValue(b, field);
|
||||
if (aVal < bVal) return dir === 'asc' ? -1 : 1;
|
||||
if (aVal > bVal) return dir === 'asc' ? 1 : -1;
|
||||
return 0;
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="flex-1 min-h-0 flex flex-col overflow-hidden">
|
||||
<!-- DataGrid -->
|
||||
<DataGrid
|
||||
data={sortedTiles}
|
||||
keyField="id"
|
||||
gridId="environments"
|
||||
loading={false}
|
||||
{sortState}
|
||||
onSortChange={(state) => { sortState = state; }}
|
||||
onRowClick={(tile, e) => onrowclick?.(tile.id)}
|
||||
rowHeight={36}
|
||||
>
|
||||
{#snippet cell(column, tile, rowState)}
|
||||
{@const s = tile.stats}
|
||||
{@const isOnline = s?.online === true}
|
||||
{@const isOffline = s?.online === false}
|
||||
|
||||
{#if column.id === 'status'}
|
||||
{#if tile.loading && !s}
|
||||
<Loader2 class="w-3.5 h-3.5 animate-spin text-muted-foreground" />
|
||||
{:else if isOnline}
|
||||
<Circle class="w-3 h-3 fill-emerald-500 text-emerald-500" />
|
||||
{:else if isOffline}
|
||||
<Circle class="w-3 h-3 fill-destructive text-destructive" />
|
||||
{:else}
|
||||
<Circle class="w-3 h-3 fill-amber-500 text-amber-500" />
|
||||
{/if}
|
||||
|
||||
{:else if column.id === 'name'}
|
||||
{#if s}
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<EnvironmentIcon icon={s.icon || 'globe'} envId={s.id} class="w-4 h-4 text-muted-foreground shrink-0" />
|
||||
<span class="font-medium truncate">{s.name}</span>
|
||||
</div>
|
||||
{:else if tile.info}
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<EnvironmentIcon icon={tile.info.icon || 'globe'} envId={tile.info.id} class="w-4 h-4 text-muted-foreground shrink-0" />
|
||||
<span class="font-medium truncate">{tile.info.name}</span>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="h-4 w-32 bg-muted animate-pulse rounded" />
|
||||
{/if}
|
||||
|
||||
{:else if column.id === 'connection'}
|
||||
{#if s}
|
||||
<div class="flex items-center gap-1.5 text-muted-foreground">
|
||||
{#if s.connectionType === 'hawser-standard'}
|
||||
<Route class="w-3.5 h-3.5 shrink-0" />
|
||||
{:else if s.connectionType === 'hawser-edge'}
|
||||
<UndoDot class="w-3.5 h-3.5 shrink-0" />
|
||||
{:else if s.connectionType === 'direct'}
|
||||
<Plug class="w-3.5 h-3.5 shrink-0" />
|
||||
{:else}
|
||||
<Icon iconNode={whale} class="w-3.5 h-3.5 shrink-0" />
|
||||
{/if}
|
||||
<span class="text-xs">{connectionLabel(s.connectionType)}</span>
|
||||
</div>
|
||||
{:else if tile.loading}
|
||||
<div class="h-4 w-16 bg-muted animate-pulse rounded" />
|
||||
{:else}
|
||||
<span class="text-muted-foreground">-</span>
|
||||
{/if}
|
||||
|
||||
{:else if column.id === 'host'}
|
||||
{#if s?.host && !isOffline}
|
||||
<span class="text-xs text-muted-foreground font-mono truncate block" title={s.port ? `${s.host}:${s.port}` : s.host}>
|
||||
{s.host}{s.port ? `:${s.port}` : ''}
|
||||
</span>
|
||||
{:else if s?.socketPath}
|
||||
<span class="text-xs text-muted-foreground font-mono truncate block" title={s.socketPath}>
|
||||
{s.socketPath}
|
||||
</span>
|
||||
{:else if tile.loading}
|
||||
<div class="h-4 w-20 bg-muted animate-pulse rounded" />
|
||||
{:else}
|
||||
<span class="text-muted-foreground">-</span>
|
||||
{/if}
|
||||
|
||||
{:else if column.id === 'containers'}
|
||||
{#if s && !isOffline}
|
||||
<span class="text-emerald-500 font-medium">{s.containers.running}</span>
|
||||
<span class="text-muted-foreground">/ {s.containers.total}</span>
|
||||
{:else if tile.loading}
|
||||
<div class="h-4 w-12 bg-muted animate-pulse rounded" />
|
||||
{:else}
|
||||
<span class="text-muted-foreground">-</span>
|
||||
{/if}
|
||||
|
||||
{:else if column.id === 'updates'}
|
||||
{#if s && !isOffline}
|
||||
{#if s.containers.pendingUpdates > 0}
|
||||
<div class="flex items-center gap-1">
|
||||
<CircleArrowUp class="w-3.5 h-3.5 text-amber-500" />
|
||||
<span class="text-amber-500 font-medium">{s.containers.pendingUpdates}</span>
|
||||
</div>
|
||||
{:else}
|
||||
<span class="text-muted-foreground">0</span>
|
||||
{/if}
|
||||
{:else if tile.loading}
|
||||
<div class="h-4 w-8 bg-muted animate-pulse rounded" />
|
||||
{:else}
|
||||
<span class="text-muted-foreground">-</span>
|
||||
{/if}
|
||||
|
||||
{:else if column.id === 'cpu'}
|
||||
{#if s?.metrics && !isOffline}
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-12 h-1.5 bg-muted rounded-full overflow-hidden">
|
||||
<div
|
||||
class="h-full rounded-full transition-all {s.metrics.cpuPercent > 80 ? 'bg-destructive' : s.metrics.cpuPercent > 60 ? 'bg-amber-500' : 'bg-primary'}"
|
||||
style="width: {Math.min(s.metrics.cpuPercent, 100)}%"
|
||||
/>
|
||||
</div>
|
||||
<span class="text-xs tabular-nums">{formatPercent(s.metrics.cpuPercent)}</span>
|
||||
</div>
|
||||
{:else if tile.loading}
|
||||
<div class="h-4 w-16 bg-muted animate-pulse rounded" />
|
||||
{:else}
|
||||
<span class="text-muted-foreground">-</span>
|
||||
{/if}
|
||||
|
||||
{:else if column.id === 'memory'}
|
||||
{#if s?.metrics && !isOffline}
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-12 h-1.5 bg-muted rounded-full overflow-hidden">
|
||||
<div
|
||||
class="h-full rounded-full transition-all {s.metrics.memoryPercent > 80 ? 'bg-destructive' : s.metrics.memoryPercent > 60 ? 'bg-amber-500' : 'bg-primary'}"
|
||||
style="width: {Math.min(s.metrics.memoryPercent, 100)}%"
|
||||
/>
|
||||
</div>
|
||||
<span class="text-xs tabular-nums">{formatPercent(s.metrics.memoryPercent)}</span>
|
||||
</div>
|
||||
{:else if tile.loading}
|
||||
<div class="h-4 w-16 bg-muted animate-pulse rounded" />
|
||||
{:else}
|
||||
<span class="text-muted-foreground">-</span>
|
||||
{/if}
|
||||
|
||||
{:else if column.id === 'images'}
|
||||
{#if s && !isOffline}
|
||||
{s.images.total}
|
||||
{:else if tile.loading}
|
||||
<div class="h-4 w-8 bg-muted animate-pulse rounded" />
|
||||
{:else}
|
||||
<span class="text-muted-foreground">-</span>
|
||||
{/if}
|
||||
|
||||
{:else if column.id === 'volumes'}
|
||||
{#if s && !isOffline}
|
||||
{s.volumes.total}
|
||||
{:else if tile.loading}
|
||||
<div class="h-4 w-8 bg-muted animate-pulse rounded" />
|
||||
{:else}
|
||||
<span class="text-muted-foreground">-</span>
|
||||
{/if}
|
||||
|
||||
{:else if column.id === 'stacks'}
|
||||
{#if s && !isOffline}
|
||||
<span class="text-emerald-500 font-medium">{s.stacks.running}</span>
|
||||
<span class="text-muted-foreground">/ {s.stacks.total}</span>
|
||||
{:else if tile.loading}
|
||||
<div class="h-4 w-12 bg-muted animate-pulse rounded" />
|
||||
{:else}
|
||||
<span class="text-muted-foreground">-</span>
|
||||
{/if}
|
||||
|
||||
{:else if column.id === 'events'}
|
||||
{#if s && !isOffline}
|
||||
{s.events.today}
|
||||
{:else if tile.loading}
|
||||
<div class="h-4 w-8 bg-muted animate-pulse rounded" />
|
||||
{:else}
|
||||
<span class="text-muted-foreground">-</span>
|
||||
{/if}
|
||||
|
||||
{:else if column.id === 'labels'}
|
||||
{#if s?.labels && s.labels.length > 0}
|
||||
<div class="flex flex-wrap gap-1">
|
||||
{#each s.labels as label}
|
||||
{@const colors = getLabelColors(label)}
|
||||
<span
|
||||
class="px-1.5 py-0.5 text-[11px] rounded-sm font-medium leading-tight whitespace-nowrap"
|
||||
style="background-color: {colors.bgColor}; color: {colors.color}"
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
{/snippet}
|
||||
</DataGrid>
|
||||
</div>
|
||||
@@ -2,7 +2,7 @@
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import { Wifi, WifiOff, ShieldCheck, Activity, Cpu, Settings, Unplug, Icon, Route, UndoDot, CircleArrowUp, CircleFadingArrowUp, Loader2 } from 'lucide-svelte';
|
||||
import { whale } from '@lucide/lab';
|
||||
import { getIconComponent } from '$lib/utils/icons';
|
||||
import EnvironmentIcon from '$lib/components/EnvironmentIcon.svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { canAccess } from '$lib/stores/auth';
|
||||
import type { EnvironmentStats } from '../api/dashboard/stats/+server';
|
||||
@@ -31,8 +31,6 @@
|
||||
|
||||
let { stats, width = 1, height = 1, oneventsclick, showStacksBreakdown = true }: 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);
|
||||
@@ -64,7 +62,7 @@
|
||||
<!-- 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'}" />
|
||||
<EnvironmentIcon icon={stats.icon} envId={stats.id} 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">
|
||||
@@ -160,7 +158,7 @@
|
||||
<!-- 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'}" />
|
||||
<EnvironmentIcon icon={stats.icon} envId={stats.id} 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">
|
||||
@@ -263,7 +261,7 @@
|
||||
<!-- 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'}" />
|
||||
<EnvironmentIcon icon={stats.icon} envId={stats.id} 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">
|
||||
@@ -364,7 +362,7 @@
|
||||
<!-- 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'}" />
|
||||
<EnvironmentIcon icon={stats.icon} envId={stats.id} 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">
|
||||
@@ -468,7 +466,7 @@
|
||||
<!-- 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'}" />
|
||||
<EnvironmentIcon icon={stats.icon} envId={stats.id} 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">
|
||||
|
||||
@@ -15,10 +15,9 @@
|
||||
Loader2
|
||||
} from 'lucide-svelte';
|
||||
import { whale } from '@lucide/lab';
|
||||
import { getIconComponent } from '$lib/utils/icons';
|
||||
import EnvironmentIcon from '$lib/components/EnvironmentIcon.svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { canAccess } from '$lib/stores/auth';
|
||||
import type { Component } from 'svelte';
|
||||
|
||||
type ConnectionType = 'socket' | 'direct' | 'hawser-standard' | 'hawser-edge';
|
||||
|
||||
@@ -71,7 +70,6 @@
|
||||
(port ? `${host}:${port}` : host || 'Unknown host')
|
||||
);
|
||||
|
||||
const EnvIcon = $derived(getIconComponent(icon)) as Component;
|
||||
const canEdit = $derived($canAccess('environments', 'edit'));
|
||||
|
||||
function openSettings(e: MouseEvent) {
|
||||
@@ -88,7 +86,7 @@
|
||||
<!-- 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'}" />
|
||||
<EnvironmentIcon {icon} envId={environmentId} 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">
|
||||
@@ -109,7 +107,7 @@
|
||||
<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'}" />
|
||||
<EnvironmentIcon {icon} envId={environmentId} class="w-4 h-4 {online ? 'text-primary' : 'text-muted-foreground'}" />
|
||||
</div>
|
||||
{#if connectionType === 'socket' || !connectionType}
|
||||
<span title="Unix socket connection">
|
||||
|
||||
Reference in new issue
Block a user