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:
@@ -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>
|
||||||
@@ -26,6 +26,7 @@
|
|||||||
import { Alert, AlertDescription, AlertTitle } from '$lib/ui/core/alert/index.js';
|
import { Alert, AlertDescription, AlertTitle } from '$lib/ui/core/alert/index.js';
|
||||||
import { Skeleton } from '$lib/ui/core/skeleton/index.js';
|
import { Skeleton } from '$lib/ui/core/skeleton/index.js';
|
||||||
import CardSkeleton from '$lib/ui/patterns/feedback/card-skeleton.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 PageHeader from '$lib/ui/app/page-header/page-header.svelte';
|
||||||
import { notifyApiError } from '$lib/ui/app/toast.js';
|
import { notifyApiError } from '$lib/ui/app/toast.js';
|
||||||
import OverviewRecentJobsCard from '$lib/components/overview/OverviewRecentJobsCard.svelte';
|
import OverviewRecentJobsCard from '$lib/components/overview/OverviewRecentJobsCard.svelte';
|
||||||
@@ -38,7 +39,6 @@
|
|||||||
import Radio from '@lucide/svelte/icons/radio';
|
import Radio from '@lucide/svelte/icons/radio';
|
||||||
import Activity from '@lucide/svelte/icons/activity';
|
import Activity from '@lucide/svelte/icons/activity';
|
||||||
import Clock from '@lucide/svelte/icons/clock';
|
import Clock from '@lucide/svelte/icons/clock';
|
||||||
import ArrowRight from '@lucide/svelte/icons/arrow-right';
|
|
||||||
import LayoutDashboard from '@lucide/svelte/icons/layout-dashboard';
|
import LayoutDashboard from '@lucide/svelte/icons/layout-dashboard';
|
||||||
import Info from '@lucide/svelte/icons/info';
|
import Info from '@lucide/svelte/icons/info';
|
||||||
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
|
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
|
||||||
@@ -251,50 +251,12 @@
|
|||||||
</Alert>
|
</Alert>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
<KpiMetricsGrid
|
||||||
{#if initialLoading}
|
cards={kpiCards}
|
||||||
{#each Array(5) as _, i (i)}
|
loading={initialLoading}
|
||||||
<CardSkeleton />
|
skeletonCount={5}
|
||||||
{/each}
|
class="sm:grid-cols-2 lg:grid-cols-3"
|
||||||
{: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>
|
|
||||||
|
|
||||||
<div class="grid gap-4 lg:grid-cols-2">
|
<div class="grid gap-4 lg:grid-cols-2">
|
||||||
<OverviewRecentJobsCard
|
<OverviewRecentJobsCard
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
import { Alert, AlertDescription, AlertTitle } from '$lib/ui/core/alert/index.js';
|
import { Alert, AlertDescription, AlertTitle } from '$lib/ui/core/alert/index.js';
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '$lib/ui/core/tabs/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 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 PageHeader from '$lib/ui/app/page-header/page-header.svelte';
|
||||||
import { notifyApiError } from '$lib/ui/app/toast.js';
|
import { notifyApiError } from '$lib/ui/app/toast.js';
|
||||||
import DirectoriesCommunitiesCard from '$lib/components/directories/DirectoriesCommunitiesCard.svelte';
|
import DirectoriesCommunitiesCard from '$lib/components/directories/DirectoriesCommunitiesCard.svelte';
|
||||||
@@ -169,53 +170,12 @@
|
|||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
|
|
||||||
<div class="grid gap-4 sm:grid-cols-3">
|
<KpiMetricsGrid
|
||||||
{#if initialLoading}
|
cards={kpiCards}
|
||||||
{#each Array(3) as _, i (i)}
|
loading={initialLoading}
|
||||||
<CardSkeleton />
|
skeletonCount={3}
|
||||||
{/each}
|
class="sm:grid-cols-3"
|
||||||
{: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>
|
|
||||||
|
|
||||||
<Tabs value="communities">
|
<Tabs value="communities">
|
||||||
<TabsList>
|
<TabsList>
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
import { Checkbox } from '$lib/ui/core/checkbox/index.js';
|
import { Checkbox } from '$lib/ui/core/checkbox/index.js';
|
||||||
import AppDataTable from '$lib/ui/patterns/data-table/app-data-table.svelte';
|
import AppDataTable from '$lib/ui/patterns/data-table/app-data-table.svelte';
|
||||||
import CardSkeleton from '$lib/ui/patterns/feedback/card-skeleton.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 PageHeader from '$lib/ui/app/page-header/page-header.svelte';
|
||||||
import { confirm } from '$lib/ui/patterns/confirm/confirm-state.svelte.js';
|
import { confirm } from '$lib/ui/patterns/confirm/confirm-state.svelte.js';
|
||||||
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
|
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
|
||||||
@@ -217,36 +218,12 @@
|
|||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
|
|
||||||
<div class="grid gap-4 sm:grid-cols-3">
|
<KpiMetricsGrid
|
||||||
{#if initialLoading}
|
cards={kpiCards}
|
||||||
{#each Array(3) as _, i (i)}
|
loading={initialLoading}
|
||||||
<CardSkeleton />
|
skeletonCount={3}
|
||||||
{/each}
|
class="sm:grid-cols-3"
|
||||||
{: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>
|
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader class="flex flex-wrap items-center justify-between gap-2 border-b py-3">
|
<CardHeader class="flex flex-wrap items-center justify-between gap-2 border-b py-3">
|
||||||
|
|||||||
@@ -46,6 +46,7 @@
|
|||||||
TableRow
|
TableRow
|
||||||
} from '$lib/ui/core/table/index.js';
|
} from '$lib/ui/core/table/index.js';
|
||||||
import CardSkeleton from '$lib/ui/patterns/feedback/card-skeleton.svelte';
|
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 ScrollPreBlock from '$lib/components/app/scroll-pre-block.svelte';
|
||||||
import PageHeader from '$lib/ui/app/page-header/page-header.svelte';
|
import PageHeader from '$lib/ui/app/page-header/page-header.svelte';
|
||||||
import { cn } from '$lib/utils.js';
|
import { cn } from '$lib/utils.js';
|
||||||
@@ -223,6 +224,7 @@
|
|||||||
badge: version ? 'Загружена' : '—',
|
badge: version ? 'Загружена' : '—',
|
||||||
badgeVariant: version ? ('outline' as const) : ('secondary' as const),
|
badgeVariant: version ? ('outline' as const) : ('secondary' as const),
|
||||||
badgeClass: undefined,
|
badgeClass: undefined,
|
||||||
|
valueClass: 'font-mono text-xl',
|
||||||
error: versionError,
|
error: versionError,
|
||||||
href: undefined
|
href: undefined
|
||||||
}
|
}
|
||||||
@@ -342,66 +344,12 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
<KpiMetricsGrid
|
||||||
{#if initialLoading}
|
cards={kpiCards}
|
||||||
{#each Array(4) as _, i (i)}
|
loading={initialLoading}
|
||||||
<CardSkeleton />
|
skeletonCount={4}
|
||||||
{/each}
|
class="sm:grid-cols-2 xl:grid-cols-4"
|
||||||
{: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>
|
|
||||||
|
|
||||||
<div class="grid gap-4 lg:grid-cols-2">
|
<div class="grid gap-4 lg:grid-cols-2">
|
||||||
{#if initialLoading}
|
{#if initialLoading}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
import { Alert, AlertDescription, AlertTitle } from '$lib/ui/core/alert/index.js';
|
import { Alert, AlertDescription, AlertTitle } from '$lib/ui/core/alert/index.js';
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '$lib/ui/core/tabs/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 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 PageHeader from '$lib/ui/app/page-header/page-header.svelte';
|
||||||
import { notifyApiError } from '$lib/ui/app/toast.js';
|
import { notifyApiError } from '$lib/ui/app/toast.js';
|
||||||
import NetworkPeersCard from '$lib/components/network/NetworkPeersCard.svelte';
|
import NetworkPeersCard from '$lib/components/network/NetworkPeersCard.svelte';
|
||||||
@@ -179,36 +180,12 @@
|
|||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
|
|
||||||
<div class="grid gap-4 sm:grid-cols-3">
|
<KpiMetricsGrid
|
||||||
{#if initialLoading}
|
cards={kpiCards}
|
||||||
{#each Array(3) as _, i (i)}
|
loading={initialLoading}
|
||||||
<CardSkeleton />
|
skeletonCount={3}
|
||||||
{/each}
|
class="sm:grid-cols-3"
|
||||||
{: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>
|
|
||||||
|
|
||||||
<Tabs value="peers">
|
<Tabs value="peers">
|
||||||
<TabsList>
|
<TabsList>
|
||||||
|
|||||||
@@ -60,6 +60,7 @@
|
|||||||
} from '$lib/components/operations/types.js';
|
} from '$lib/components/operations/types.js';
|
||||||
import ScrollPreBlock from '$lib/components/app/scroll-pre-block.svelte';
|
import ScrollPreBlock from '$lib/components/app/scroll-pre-block.svelte';
|
||||||
import CardSkeleton from '$lib/ui/patterns/feedback/card-skeleton.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 { confirm } from '$lib/ui/patterns/confirm/confirm-state.svelte.js';
|
||||||
import PageHeader from '$lib/ui/app/page-header/page-header.svelte';
|
import PageHeader from '$lib/ui/app/page-header/page-header.svelte';
|
||||||
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
|
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
|
||||||
@@ -891,53 +892,12 @@
|
|||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
|
|
||||||
<div class="grid gap-4 sm:grid-cols-3">
|
<KpiMetricsGrid
|
||||||
{#if initialLoading}
|
cards={kpiCards}
|
||||||
{#each Array(3) as _, i (i)}
|
loading={initialLoading}
|
||||||
<CardSkeleton />
|
skeletonCount={3}
|
||||||
{/each}
|
class="sm:grid-cols-3"
|
||||||
{: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>
|
|
||||||
|
|
||||||
<OperationsQuickActions
|
<OperationsQuickActions
|
||||||
{applying}
|
{applying}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '$lib/ui/core/tabs/index.js';
|
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 AppDataTable from '$lib/ui/patterns/data-table/app-data-table.svelte';
|
||||||
import CardSkeleton from '$lib/ui/patterns/feedback/card-skeleton.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 PageHeader from '$lib/ui/app/page-header/page-header.svelte';
|
||||||
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
|
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
|
||||||
import { cn } from '$lib/utils.js';
|
import { cn } from '$lib/utils.js';
|
||||||
@@ -234,53 +235,12 @@
|
|||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
|
|
||||||
<div class="grid gap-4 sm:grid-cols-3">
|
<KpiMetricsGrid
|
||||||
{#if initialLoading}
|
cards={kpiCards}
|
||||||
{#each Array(3) as _, i (i)}
|
loading={initialLoading}
|
||||||
<CardSkeleton />
|
skeletonCount={3}
|
||||||
{/each}
|
class="sm:grid-cols-3"
|
||||||
{: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>
|
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
|
|||||||
Reference in New Issue
Block a user