Usage Breakdown

Liste classee de consommation des ressources avec part relative et indices de tendance. Installez Usage Breakdown depuis le registre VLLNT UI avec la CLI shadcn.

Signaler un bug

Liste classee de consommation des ressources avec part relative et indices de tendance. Fait partie de la famille data dans VLLNT UI, il est publie comme entree de registre lisible par machine — copiez la source directement dans votre application avec la CLI shadcn et possedez-la, sans dependance d'execution a une bibliotheque de composants.

Apercu

Basculez entre clair et sombre pour inspecter l'apercu Storybook integre.

Installation

Ajoutez Usage Breakdown a votre projet avec la CLI shadcn. La source arrive dans votre code, prete a etre adaptee :

pnpm dlx shadcn@latest add https://ui.vllnt.ai/r/usage-breakdown.json

Source

"use client"; import { type ReactNode, useMemo } from "react"; import { ArrowDownRight, ArrowUpRight } from "lucide-react"; import { cn } from "../../lib/utils"; import { Badge } from "../badge/badge"; import { Card, CardContent, CardDescription, CardHeader, CardTitle, } from "../card/card"; export type UsageBreakdownTone = "danger" | "default" | "success" | "warning"; export type UsageBreakdownItem = { description?: string; icon?: ReactNode; id: string; label: string; meta?: string; tone?: UsageBreakdownTone; trend?: { direction: "down" | "up"; label: string; }; value: number; valueLabel?: string; }; export type UsageBreakdownProps = React.ComponentPropsWithoutRef< typeof Card > & { description?: string; emptyMessage?: string; items: UsageBreakdownItem[]; maxItems?: number; title?: string; }; type UsageBreakdownRowProps = { item: UsageBreakdownItem; maxValue: number; rank: number; totalValue: number; }; const toneClasses: Record<UsageBreakdownTone, string> = { danger: "bg-destructive/10 text-destructive border-destructive/20 dark:text-destructive", default: "bg-muted text-muted-foreground border-border", success: "bg-emerald-500/10 text-emerald-700 border-emerald-500/20 dark:text-emerald-300", warning: "bg-amber-500/10 text-amber-700 border-amber-500/20 dark:text-amber-300", }; function formatPercent(value: number): string { if (!Number.isFinite(value)) return "0%"; return `${Math.round(value)}%`; } const LARGE_VALUE_FORMATTER = new Intl.NumberFormat("en-US", { maximumFractionDigits: 0, }); const SMALL_VALUE_FORMATTER = new Intl.NumberFormat("en-US", { maximumFractionDigits: 1, }); function formatValue(value: number): string { return value >= 100 ? LARGE_VALUE_FORMATTER.format(value) : SMALL_VALUE_FORMATTER.format(value); } function getRelativeWidth(value: number, maxValue: number): number { if (maxValue <= 0) return 0; return Math.max((value / maxValue) * 100, 4); } function getShare(value: number, totalValue: number): number { if (totalValue <= 0) return 0; return (value / totalValue) * 100; } function UsageBreakdownTrend({ item }: { item: UsageBreakdownItem }) { if (!item.trend) return null; const trendTone = item.tone ?? "default"; const TrendIcon = item.trend.direction === "down" ? ArrowDownRight : ArrowUpRight; return ( <Badge className={cn("gap-1 border", toneClasses[trendTone])}> <TrendIcon className="size-3.5" /> {item.trend.label} </Badge> ); } function UsageBreakdownMeter({ maxValue, value, }: { maxValue: number; value: number; }) { return ( <div className="space-y-2"> <div className="h-2 overflow-hidden rounded-full bg-muted"> <div aria-hidden="true" className="h-full rounded-full bg-primary transition-[width]" style={{ width: `${getRelativeWidth(value, maxValue)}%` }} /> </div> <div className="flex items-center justify-between text-xs text-muted-foreground"> <span>Relative usage</span> <span>{formatPercent(getShare(value, maxValue))}</span> </div> </div> ); } function UsageBreakdownRow({ item, maxValue, rank, totalValue, }: UsageBreakdownRowProps) { return ( <li className="rounded-lg border bg-background/70 p-4"> <div className="flex items-start gap-3"> <div className="flex size-10 shrink-0 items-center justify-center rounded-md border bg-muted text-sm font-semibold text-muted-foreground"> {item.icon ?? rank} </div> <div className="min-w-0 flex-1 space-y-3"> <div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between"> <div className="min-w-0 space-y-1"> <div className="flex flex-wrap items-center gap-2"> <span className="truncate font-medium text-foreground"> {item.label} </span> {item.meta ? ( <Badge className="border-border" variant="outline"> {item.meta} </Badge> ) : null} <UsageBreakdownTrend item={item} /> </div> {item.description ? ( <p className="text-sm text-muted-foreground"> {item.description} </p> ) : null} </div> <div className="text-left sm:text-right"> <div className="font-semibold text-foreground"> {item.valueLabel ?? formatValue(item.value)} </div> <div className="text-sm text-muted-foreground"> {formatPercent(getShare(item.value, totalValue))} of total </div> </div> </div> <UsageBreakdownMeter maxValue={maxValue} value={item.value} /> </div> </div> </li> ); } function getSortedItems(items: UsageBreakdownItem[], maxItems?: number) { const rankedItems = [...items].sort( (left, right) => right.value - left.value, ); return typeof maxItems === "number" ? rankedItems.slice(0, maxItems) : rankedItems; } const UsageBreakdown = ({ className, description, emptyMessage = "No usage data available.", items, maxItems, ref, title = "Usage breakdown", ...props }: UsageBreakdownProps & { ref?: React.Ref<HTMLDivElement> }) => { const sortedItems = useMemo( () => getSortedItems(items, maxItems), [items, maxItems], ); const totalValue = useMemo( () => sortedItems.reduce((sum, item) => sum + item.value, 0), [sortedItems], ); const maxValue = sortedItems[0]?.value ?? 0; return ( <Card className={cn("w-full", className)} ref={ref} {...props}> <CardHeader> <CardTitle>{title}</CardTitle> {description ? <CardDescription>{description}</CardDescription> : null} </CardHeader> <CardContent> {sortedItems.length === 0 ? ( <div className="rounded-lg border border-dashed px-4 py-8 text-center text-sm text-muted-foreground"> {emptyMessage} </div> ) : ( <ol className="space-y-3"> {sortedItems.map((item, index) => ( <UsageBreakdownRow item={item} key={item.id} maxValue={maxValue} rank={index + 1} totalValue={totalValue} /> ))} </ol> )} </CardContent> </Card> ); }; UsageBreakdown.displayName = "UsageBreakdown"; export { UsageBreakdown };

Stories

Explorez chaque variante et etat dans le Storybook interactif :

Apercu

Basculez entre clair et sombre pour inspecter l'apercu Storybook integre.

Dependances

  • @vllnt/ui@^0.3.0