refactor(web): extract shared kpi metrics grid pattern

WEB-18: KpiMetricsGrid в ui/patterns; рефакторинг overview, network, modules, directories, operations, schedule, monitoring.
Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Denozordec
2026-05-20 14:52:44 +07:00
co-authored by Cursor
parent e06ee880b2
commit e68f034966
8 changed files with 162 additions and 306 deletions
@@ -0,0 +1,112 @@
<script lang="ts">
import type { Component } from 'svelte';
import { Badge } from '$lib/ui/core/badge/index.js';
import { Button } from '$lib/ui/core/button/index.js';
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription
} from '$lib/ui/core/card/index.js';
import CardSkeleton from '$lib/ui/patterns/feedback/card-skeleton.svelte';
import { cn } from '$lib/utils.js';
import ArrowRight from '@lucide/svelte/icons/arrow-right';
export type KpiAccent = {
border: string;
bg: string;
iconBg: string;
iconText: string;
};
export type KpiCardItem = {
id: string;
label: string;
value: string;
description: string;
icon: Component;
accent: KpiAccent;
badge: string;
badgeClass?: string;
badgeVariant?: 'default' | 'secondary' | 'destructive' | 'outline';
valueClass?: string;
error?: string | null;
href?: string;
};
type Props = {
cards: KpiCardItem[];
loading?: boolean;
skeletonCount?: number;
class?: string;
};
let { cards, loading = false, skeletonCount = 3, class: className }: Props = $props();
</script>
<div class={cn('grid gap-4', className)}>
{#if loading}
{#each Array(skeletonCount) as _, i (i)}
<CardSkeleton />
{/each}
{:else}
{#each cards as card (card.id)}
{@const Icon = card.icon}
{@const a = card.accent}
<Card
class={cn(
'overflow-hidden border-l-4 shadow-sm',
card.href ? 'transition-colors hover:border-primary/35' : '',
a.border,
a.bg
)}
>
<CardHeader class="pb-2">
{#if card.href}
<div class="flex items-center justify-between gap-2">
<CardDescription class="flex min-w-0 items-center gap-2">
<span
class={cn(
'flex size-9 shrink-0 items-center justify-center rounded-lg',
a.iconBg
)}
aria-hidden="true"
>
<Icon class={cn('size-4', a.iconText)} />
</span>
<span class="truncate">{card.label}</span>
</CardDescription>
<Button variant="ghost" size="icon-sm" href={card.href}>
<ArrowRight class="size-3.5" aria-hidden="true" />
</Button>
</div>
{:else}
<CardDescription class="flex min-w-0 items-center gap-2">
<span
class={cn('flex size-9 shrink-0 items-center justify-center rounded-lg', a.iconBg)}
aria-hidden="true"
>
<Icon class={cn('size-4', a.iconText)} />
</span>
<span class="truncate">{card.label}</span>
</CardDescription>
{/if}
<CardTitle class={cn('font-bold tabular-nums', card.valueClass ?? 'text-3xl')}
>{card.value}</CardTitle
>
</CardHeader>
<CardContent class="space-y-2">
<Badge variant={card.badgeVariant ?? 'outline'} class={card.badgeClass}
>{card.badge}</Badge
>
{#if card.error}
<p class="text-xs text-destructive">{card.error}</p>
{:else}
<p class="text-xs text-muted-foreground">{card.description}</p>
{/if}
</CardContent>
</Card>
{/each}
{/if}
</div>
+7 -45
View File
@@ -26,6 +26,7 @@
import { Alert, AlertDescription, AlertTitle } from '$lib/ui/core/alert/index.js';
import { Skeleton } from '$lib/ui/core/skeleton/index.js';
import CardSkeleton from '$lib/ui/patterns/feedback/card-skeleton.svelte';
import KpiMetricsGrid from '$lib/ui/patterns/kpi/kpi-metrics-grid.svelte';
import PageHeader from '$lib/ui/app/page-header/page-header.svelte';
import { notifyApiError } from '$lib/ui/app/toast.js';
import OverviewRecentJobsCard from '$lib/components/overview/OverviewRecentJobsCard.svelte';
@@ -38,7 +39,6 @@
import Radio from '@lucide/svelte/icons/radio';
import Activity from '@lucide/svelte/icons/activity';
import Clock from '@lucide/svelte/icons/clock';
import ArrowRight from '@lucide/svelte/icons/arrow-right';
import LayoutDashboard from '@lucide/svelte/icons/layout-dashboard';
import Info from '@lucide/svelte/icons/info';
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
@@ -251,50 +251,12 @@
</Alert>
{/if}
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{#if initialLoading}
{#each Array(5) as _, i (i)}
<CardSkeleton />
{/each}
{:else}
{#each kpiCards as card (card.id)}
{@const Icon = card.icon}
{@const a = card.accent}
<Card
class={cn(
'overflow-hidden border-l-4 shadow-sm transition-colors hover:border-primary/35',
a.border,
a.bg
)}
>
<CardHeader class="pb-2">
<div class="flex items-center justify-between gap-2">
<CardDescription class="flex min-w-0 items-center gap-2">
<span
class={cn(
'flex size-9 shrink-0 items-center justify-center rounded-lg',
a.iconBg
)}
aria-hidden="true"
>
<Icon class={cn('size-4', a.iconText)} />
</span>
<span class="truncate">{card.label}</span>
</CardDescription>
<Button variant="ghost" size="icon-sm" href={resolve(card.href)}>
<ArrowRight class="size-3.5" aria-hidden="true" />
</Button>
</div>
<CardTitle class="text-3xl font-bold tabular-nums">{card.value}</CardTitle>
</CardHeader>
<CardContent class="space-y-2">
<Badge variant="outline">{card.badge}</Badge>
<p class="text-xs text-muted-foreground">{card.description}</p>
</CardContent>
</Card>
{/each}
{/if}
</div>
<KpiMetricsGrid
cards={kpiCards}
loading={initialLoading}
skeletonCount={5}
class="sm:grid-cols-2 lg:grid-cols-3"
/>
<div class="grid gap-4 lg:grid-cols-2">
<OverviewRecentJobsCard
+7 -47
View File
@@ -20,6 +20,7 @@
import { Alert, AlertDescription, AlertTitle } from '$lib/ui/core/alert/index.js';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '$lib/ui/core/tabs/index.js';
import CardSkeleton from '$lib/ui/patterns/feedback/card-skeleton.svelte';
import KpiMetricsGrid from '$lib/ui/patterns/kpi/kpi-metrics-grid.svelte';
import PageHeader from '$lib/ui/app/page-header/page-header.svelte';
import { notifyApiError } from '$lib/ui/app/toast.js';
import DirectoriesCommunitiesCard from '$lib/components/directories/DirectoriesCommunitiesCard.svelte';
@@ -169,53 +170,12 @@
</AlertDescription>
</Alert>
<div class="grid gap-4 sm:grid-cols-3">
{#if initialLoading}
{#each Array(3) as _, i (i)}
<CardSkeleton />
{/each}
{:else}
{#each kpiCards as card (card.id)}
{@const Icon = card.icon}
{@const a = card.accent}
<Card
class={cn(
'overflow-hidden border-l-4 shadow-sm transition-colors',
a.border,
a.bg,
card.href ? 'hover:border-primary/35' : ''
)}
>
<CardHeader class="pb-2">
<div class="flex items-center justify-between gap-2">
<CardDescription class="flex min-w-0 items-center gap-2">
<span
class={cn(
'flex size-9 shrink-0 items-center justify-center rounded-lg',
a.iconBg
)}
aria-hidden="true"
>
<Icon class={cn('size-4', a.iconText)} />
</span>
<span class="truncate">{card.label}</span>
</CardDescription>
{#if card.href}
<Button variant="ghost" size="icon-sm" href={resolve(card.href)}>
<ArrowRight class="size-3.5" aria-hidden="true" />
</Button>
{/if}
</div>
<CardTitle class="text-3xl font-bold tabular-nums">{card.value}</CardTitle>
</CardHeader>
<CardContent class="space-y-2">
<Badge variant="outline">{card.badge}</Badge>
<p class="text-xs text-muted-foreground">{card.description}</p>
</CardContent>
</Card>
{/each}
{/if}
</div>
<KpiMetricsGrid
cards={kpiCards}
loading={initialLoading}
skeletonCount={3}
class="sm:grid-cols-3"
/>
<Tabs value="communities">
<TabsList>
+7 -30
View File
@@ -22,6 +22,7 @@
import { Checkbox } from '$lib/ui/core/checkbox/index.js';
import AppDataTable from '$lib/ui/patterns/data-table/app-data-table.svelte';
import CardSkeleton from '$lib/ui/patterns/feedback/card-skeleton.svelte';
import KpiMetricsGrid from '$lib/ui/patterns/kpi/kpi-metrics-grid.svelte';
import PageHeader from '$lib/ui/app/page-header/page-header.svelte';
import { confirm } from '$lib/ui/patterns/confirm/confirm-state.svelte.js';
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
@@ -217,36 +218,12 @@
</AlertDescription>
</Alert>
<div class="grid gap-4 sm:grid-cols-3">
{#if initialLoading}
{#each Array(3) as _, i (i)}
<CardSkeleton />
{/each}
{:else}
{#each kpiCards as card (card.id)}
{@const Icon = card.icon}
{@const a = card.accent}
<Card class={cn('overflow-hidden border-l-4 shadow-sm', a.border, a.bg)}>
<CardHeader class="pb-2">
<CardDescription class="flex min-w-0 items-center gap-2">
<span
class={cn('flex size-9 shrink-0 items-center justify-center rounded-lg', a.iconBg)}
aria-hidden="true"
>
<Icon class={cn('size-4', a.iconText)} />
</span>
<span class="truncate">{card.label}</span>
</CardDescription>
<CardTitle class="text-3xl font-bold tabular-nums">{card.value}</CardTitle>
</CardHeader>
<CardContent class="space-y-2">
<Badge variant="outline">{card.badge}</Badge>
<p class="text-xs text-muted-foreground">{card.description}</p>
</CardContent>
</Card>
{/each}
{/if}
</div>
<KpiMetricsGrid
cards={kpiCards}
loading={initialLoading}
skeletonCount={3}
class="sm:grid-cols-3"
/>
<Card>
<CardHeader class="flex flex-wrap items-center justify-between gap-2 border-b py-3">
+8 -60
View File
@@ -46,6 +46,7 @@
TableRow
} from '$lib/ui/core/table/index.js';
import CardSkeleton from '$lib/ui/patterns/feedback/card-skeleton.svelte';
import KpiMetricsGrid from '$lib/ui/patterns/kpi/kpi-metrics-grid.svelte';
import ScrollPreBlock from '$lib/components/app/scroll-pre-block.svelte';
import PageHeader from '$lib/ui/app/page-header/page-header.svelte';
import { cn } from '$lib/utils.js';
@@ -223,6 +224,7 @@
badge: version ? 'Загружена' : '—',
badgeVariant: version ? ('outline' as const) : ('secondary' as const),
badgeClass: undefined,
valueClass: 'font-mono text-xl',
error: versionError,
href: undefined
}
@@ -342,66 +344,12 @@
{/if}
{/if}
<div class="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
{#if initialLoading}
{#each Array(4) as _, i (i)}
<CardSkeleton />
{/each}
{:else}
{#each kpiCards as card (card.id)}
{@const Icon = card.icon}
{@const a = card.accent}
<Card
class={cn(
'overflow-hidden border-l-4 shadow-sm transition-colors',
a.border,
a.bg,
card.href ? 'hover:border-primary/35' : ''
)}
>
<CardHeader class="pb-2">
<div class="flex items-center justify-between gap-2">
<CardDescription class="flex min-w-0 items-center gap-2">
<span
class={cn(
'flex size-9 shrink-0 items-center justify-center rounded-lg',
a.iconBg
)}
aria-hidden="true"
>
<Icon class={cn('size-4', a.iconText)} />
</span>
<span class="truncate">{card.label}</span>
</CardDescription>
{#if card.href}
<Button variant="ghost" size="icon-sm" href={resolve(card.href)}>
<ArrowRight class="size-3.5" aria-hidden="true" />
</Button>
{/if}
</div>
<CardTitle
class={cn(
'font-bold tabular-nums',
card.id === 'version' ? 'font-mono text-xl' : 'text-3xl'
)}
>
{card.value}
</CardTitle>
</CardHeader>
<CardContent class="space-y-2">
<div class="flex items-center justify-between gap-2">
<Badge variant={card.badgeVariant} class={card.badgeClass}>{card.badge}</Badge>
</div>
{#if card.error}
<p class="text-xs text-destructive">{card.error}</p>
{:else}
<p class="text-xs text-muted-foreground">{card.description}</p>
{/if}
</CardContent>
</Card>
{/each}
{/if}
</div>
<KpiMetricsGrid
cards={kpiCards}
loading={initialLoading}
skeletonCount={4}
class="sm:grid-cols-2 xl:grid-cols-4"
/>
<div class="grid gap-4 lg:grid-cols-2">
{#if initialLoading}
+7 -30
View File
@@ -15,6 +15,7 @@
import { Alert, AlertDescription, AlertTitle } from '$lib/ui/core/alert/index.js';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '$lib/ui/core/tabs/index.js';
import CardSkeleton from '$lib/ui/patterns/feedback/card-skeleton.svelte';
import KpiMetricsGrid from '$lib/ui/patterns/kpi/kpi-metrics-grid.svelte';
import PageHeader from '$lib/ui/app/page-header/page-header.svelte';
import { notifyApiError } from '$lib/ui/app/toast.js';
import NetworkPeersCard from '$lib/components/network/NetworkPeersCard.svelte';
@@ -179,36 +180,12 @@
</AlertDescription>
</Alert>
<div class="grid gap-4 sm:grid-cols-3">
{#if initialLoading}
{#each Array(3) as _, i (i)}
<CardSkeleton />
{/each}
{:else}
{#each kpiCards as card (card.id)}
{@const Icon = card.icon}
{@const a = card.accent}
<Card class={cn('overflow-hidden border-l-4 shadow-sm', a.border, a.bg)}>
<CardHeader class="pb-2">
<CardDescription class="flex min-w-0 items-center gap-2">
<span
class={cn('flex size-9 shrink-0 items-center justify-center rounded-lg', a.iconBg)}
aria-hidden="true"
>
<Icon class={cn('size-4', a.iconText)} />
</span>
<span class="truncate">{card.label}</span>
</CardDescription>
<CardTitle class="text-3xl font-bold tabular-nums">{card.value}</CardTitle>
</CardHeader>
<CardContent class="space-y-2">
<Badge variant="outline" class={card.badgeClass}>{card.badge}</Badge>
<p class="text-xs text-muted-foreground">{card.description}</p>
</CardContent>
</Card>
{/each}
{/if}
</div>
<KpiMetricsGrid
cards={kpiCards}
loading={initialLoading}
skeletonCount={3}
class="sm:grid-cols-3"
/>
<Tabs value="peers">
<TabsList>
+7 -47
View File
@@ -60,6 +60,7 @@
} from '$lib/components/operations/types.js';
import ScrollPreBlock from '$lib/components/app/scroll-pre-block.svelte';
import CardSkeleton from '$lib/ui/patterns/feedback/card-skeleton.svelte';
import KpiMetricsGrid from '$lib/ui/patterns/kpi/kpi-metrics-grid.svelte';
import { confirm } from '$lib/ui/patterns/confirm/confirm-state.svelte.js';
import PageHeader from '$lib/ui/app/page-header/page-header.svelte';
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
@@ -891,53 +892,12 @@
</AlertDescription>
</Alert>
<div class="grid gap-4 sm:grid-cols-3">
{#if initialLoading}
{#each Array(3) as _, i (i)}
<CardSkeleton />
{/each}
{:else}
{#each kpiCards as card (card.id)}
{@const Icon = card.icon}
{@const a = card.accent}
<Card
class={cn(
'overflow-hidden border-l-4 shadow-sm transition-colors',
a.border,
a.bg,
card.href ? 'hover:border-primary/35' : ''
)}
>
<CardHeader class="pb-2">
<div class="flex items-center justify-between gap-2">
<CardDescription class="flex min-w-0 items-center gap-2">
<span
class={cn(
'flex size-9 shrink-0 items-center justify-center rounded-lg',
a.iconBg
)}
aria-hidden="true"
>
<Icon class={cn('size-4', a.iconText)} />
</span>
<span class="truncate">{card.label}</span>
</CardDescription>
{#if card.href}
<Button variant="ghost" size="icon-sm" href={resolve(card.href)}>
<ArrowRight class="size-3.5" aria-hidden="true" />
</Button>
{/if}
</div>
<CardTitle class="text-3xl font-bold tabular-nums">{card.value}</CardTitle>
</CardHeader>
<CardContent class="space-y-2">
<Badge variant="outline" class={card.badgeClass}>{card.badge}</Badge>
<p class="text-xs text-muted-foreground">{card.description}</p>
</CardContent>
</Card>
{/each}
{/if}
</div>
<KpiMetricsGrid
cards={kpiCards}
loading={initialLoading}
skeletonCount={3}
class="sm:grid-cols-3"
/>
<OperationsQuickActions
{applying}
+7 -47
View File
@@ -23,6 +23,7 @@
import { Tabs, TabsContent, TabsList, TabsTrigger } from '$lib/ui/core/tabs/index.js';
import AppDataTable from '$lib/ui/patterns/data-table/app-data-table.svelte';
import CardSkeleton from '$lib/ui/patterns/feedback/card-skeleton.svelte';
import KpiMetricsGrid from '$lib/ui/patterns/kpi/kpi-metrics-grid.svelte';
import PageHeader from '$lib/ui/app/page-header/page-header.svelte';
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
import { cn } from '$lib/utils.js';
@@ -234,53 +235,12 @@
</AlertDescription>
</Alert>
<div class="grid gap-4 sm:grid-cols-3">
{#if initialLoading}
{#each Array(3) as _, i (i)}
<CardSkeleton />
{/each}
{:else}
{#each kpiCards as card (card.id)}
{@const Icon = card.icon}
{@const a = card.accent}
<Card
class={cn(
'overflow-hidden border-l-4 shadow-sm transition-colors',
a.border,
a.bg,
card.href ? 'hover:border-primary/35' : ''
)}
>
<CardHeader class="pb-2">
<div class="flex items-center justify-between gap-2">
<CardDescription class="flex min-w-0 items-center gap-2">
<span
class={cn(
'flex size-9 shrink-0 items-center justify-center rounded-lg',
a.iconBg
)}
aria-hidden="true"
>
<Icon class={cn('size-4', a.iconText)} />
</span>
<span class="truncate">{card.label}</span>
</CardDescription>
{#if card.href}
<Button variant="ghost" size="icon-sm" href={resolve(card.href)}>
<ArrowRight class="size-3.5" aria-hidden="true" />
</Button>
{/if}
</div>
<CardTitle class="text-3xl font-bold tabular-nums">{card.value}</CardTitle>
</CardHeader>
<CardContent class="space-y-2">
<Badge variant="outline" class={card.badgeClass}>{card.badge}</Badge>
<p class="text-xs text-muted-foreground">{card.description}</p>
</CardContent>
</Card>
{/each}
{/if}
</div>
<KpiMetricsGrid
cards={kpiCards}
loading={initialLoading}
skeletonCount={3}
class="sm:grid-cols-3"
/>
<Card>
<CardHeader>