feat(ui): integrate KpiStatGrid for enhanced statistics display
Docker images / prepare-release (push) Successful in 9s
Docker images / backend-image (push) Successful in 1m43s
Docker images / frontend-image (push) Successful in 2m58s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 39s
Docker images / publish-release (push) Successful in 8s
Docker images / prepare-release (push) Successful in 9s
Docker images / backend-image (push) Successful in 1m43s
Docker images / frontend-image (push) Successful in 2m58s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 39s
Docker images / publish-release (push) Successful in 8s
Replaced existing KPI display implementations across multiple pages with the new KpiStatGrid component for a more consistent and visually appealing presentation of statistics. Updated the Backups, BGP, Certificates, Communities, Containers, Dashboard, and Data Collection pages to utilize the KpiStatGrid, improving the overall user experience and maintainability of the codebase. Additionally, added new dependencies in package.json for required libraries.
This commit is contained in:
+224
-75
@@ -1,6 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo, useState, type ReactNode } from "react"
|
||||
import { useEffect, useMemo, useRef, useState, type KeyboardEvent, type ReactNode } from "react"
|
||||
import type { ServerStatus, ServerType } from "@/lib/data"
|
||||
import { Flag } from "@/components/flag"
|
||||
import { StatusDot } from "@/components/status-dot"
|
||||
@@ -12,7 +12,15 @@ import {
|
||||
FramePanel,
|
||||
FrameTitle,
|
||||
} from "@/components/reui/frame"
|
||||
import {
|
||||
Item,
|
||||
ItemActions,
|
||||
ItemContent,
|
||||
ItemMedia,
|
||||
ItemTitle,
|
||||
} from "@/components/ui/item"
|
||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||
import { Spinner } from "@/components/ui/spinner"
|
||||
import {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
@@ -21,7 +29,7 @@ import {
|
||||
import { cn } from "@/lib/utils"
|
||||
import { LayersIcon, SearchIcon, ServerIcon } from "lucide-react"
|
||||
|
||||
/** Preview: https://reui.io/preview/base/list-9 · https://reui.io/docs/components/base/icon-tile · https://reui.io/preview/base/components/c-input-group-1 */
|
||||
/** Preview: https://reui.io/preview/base/list-9 · https://reui.io/docs/components/base/icon-tile · https://reui.io/docs/components/base/frame · https://reui.io/docs/components/base/badge */
|
||||
export const ALL_SERVERS_ID = "all"
|
||||
|
||||
export interface ServerTileItem {
|
||||
@@ -34,6 +42,9 @@ export interface ServerTileItem {
|
||||
enabled?: boolean
|
||||
selectable?: boolean
|
||||
title?: string
|
||||
site?: string
|
||||
host?: string
|
||||
meta?: string
|
||||
}
|
||||
|
||||
function typeBadgeVariant(
|
||||
@@ -58,12 +69,22 @@ function ServerTypeBadge({ type }: { type: ServerType }) {
|
||||
)
|
||||
}
|
||||
|
||||
function hostnameOf(item: ServerTileItem): string {
|
||||
return item.name || item.host || ""
|
||||
}
|
||||
|
||||
function matchesQuery(item: ServerTileItem, q: string): boolean {
|
||||
if (!q) return true
|
||||
const hay = [item.name, item.title ?? ""].join(" ").toLowerCase()
|
||||
const hay = [item.name, item.title ?? "", item.host ?? "", item.site ?? "", item.country ?? ""]
|
||||
.join(" ")
|
||||
.toLowerCase()
|
||||
return hay.includes(q)
|
||||
}
|
||||
|
||||
function isSelectable(item: ServerTileItem): boolean {
|
||||
return item.selectable !== false
|
||||
}
|
||||
|
||||
function ServerTileRail({
|
||||
items,
|
||||
selectedId,
|
||||
@@ -74,6 +95,7 @@ function ServerTileRail({
|
||||
showCount = true,
|
||||
showType = true,
|
||||
headerRight,
|
||||
loading = false,
|
||||
className,
|
||||
}: {
|
||||
items: ServerTileItem[]
|
||||
@@ -85,18 +107,106 @@ function ServerTileRail({
|
||||
showCount?: boolean
|
||||
showType?: boolean
|
||||
headerRight?: ReactNode
|
||||
loading?: boolean
|
||||
className?: string
|
||||
}) {
|
||||
const [query, setQuery] = useState("")
|
||||
const q = query.trim().toLowerCase()
|
||||
const listRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const filtered = useMemo(
|
||||
() => items.filter((item) => matchesQuery(item, q)),
|
||||
[items, q],
|
||||
)
|
||||
|
||||
const uniqueSites = useMemo(() => {
|
||||
const sites = new Set(
|
||||
filtered.map((item) => item.site?.trim()).filter((site): site is string => Boolean(site)),
|
||||
)
|
||||
return sites.size
|
||||
}, [filtered])
|
||||
|
||||
const groupBySite = uniqueSites >= 2 && !q
|
||||
|
||||
const groups = useMemo(() => {
|
||||
if (!groupBySite) {
|
||||
return [{ site: "", items: filtered }]
|
||||
}
|
||||
const bySite = new Map<string, ServerTileItem[]>()
|
||||
for (const item of filtered) {
|
||||
const site = item.site?.trim() || "—"
|
||||
const list = bySite.get(site)
|
||||
if (list) list.push(item)
|
||||
else bySite.set(site, [item])
|
||||
}
|
||||
return [...bySite.entries()].map(([site, siteItems]) => ({ site, items: siteItems }))
|
||||
}, [filtered, groupBySite])
|
||||
|
||||
const showAllTile = showAll && !q
|
||||
|
||||
const resolvedId = useMemo(() => {
|
||||
if (showAll && selectedId === ALL_SERVERS_ID) return ALL_SERVERS_ID
|
||||
if (items.some((item) => item.id === selectedId)) return selectedId
|
||||
if (showAll) return ALL_SERVERS_ID
|
||||
return items.find(isSelectable)?.id ?? items[0]?.id ?? ""
|
||||
}, [items, selectedId, showAll])
|
||||
|
||||
useEffect(() => {
|
||||
if (resolvedId && resolvedId !== selectedId) onSelect(resolvedId)
|
||||
}, [onSelect, resolvedId, selectedId])
|
||||
|
||||
const selectableIds = useMemo(() => {
|
||||
const ids: string[] = []
|
||||
if (showAllTile) ids.push(ALL_SERVERS_ID)
|
||||
for (const item of filtered) {
|
||||
if (isSelectable(item)) ids.push(item.id)
|
||||
}
|
||||
return ids
|
||||
}, [filtered, showAllTile])
|
||||
|
||||
function moveSelection(delta: number) {
|
||||
if (selectableIds.length === 0) return
|
||||
const idx = selectableIds.indexOf(resolvedId)
|
||||
const nextIdx =
|
||||
idx < 0
|
||||
? delta > 0 ? 0 : selectableIds.length - 1
|
||||
: Math.min(selectableIds.length - 1, Math.max(0, idx + delta))
|
||||
const nextId = selectableIds[nextIdx]
|
||||
if (nextId) onSelect(nextId)
|
||||
}
|
||||
|
||||
function handleListKeyDown(event: KeyboardEvent<HTMLDivElement>) {
|
||||
if (loading) return
|
||||
if (event.key === "ArrowDown") {
|
||||
event.preventDefault()
|
||||
moveSelection(1)
|
||||
return
|
||||
}
|
||||
if (event.key === "ArrowUp") {
|
||||
event.preventDefault()
|
||||
moveSelection(-1)
|
||||
return
|
||||
}
|
||||
if (event.key === "Home") {
|
||||
event.preventDefault()
|
||||
if (selectableIds[0]) onSelect(selectableIds[0])
|
||||
return
|
||||
}
|
||||
if (event.key === "End") {
|
||||
event.preventDefault()
|
||||
const last = selectableIds[selectableIds.length - 1]
|
||||
if (last) onSelect(last)
|
||||
}
|
||||
}
|
||||
|
||||
const allItem: ServerTileItem = {
|
||||
id: ALL_SERVERS_ID,
|
||||
name: "Все серверы",
|
||||
title: "Все серверы",
|
||||
count: allCount,
|
||||
meta: String(allCount),
|
||||
}
|
||||
|
||||
return (
|
||||
<Frame dense spacing="sm" className={cn("flex h-full min-h-0 w-full flex-col", className)}>
|
||||
<FramePanel className="flex min-h-0 flex-1 flex-col gap-0 p-0">
|
||||
@@ -106,10 +216,16 @@ function ServerTileRail({
|
||||
<FrameTitle className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Серверы
|
||||
</FrameTitle>
|
||||
<span className="flex items-center gap-1.5">
|
||||
{loading ? <Spinner className="size-3.5 text-muted-foreground" /> : null}
|
||||
{headerRight}
|
||||
</span>
|
||||
</div>
|
||||
) : headerRight || loading ? (
|
||||
<div className="flex justify-end gap-1.5">
|
||||
{loading ? <Spinner className="size-3.5 text-muted-foreground" /> : null}
|
||||
{headerRight}
|
||||
</div>
|
||||
) : headerRight ? (
|
||||
<div className="flex justify-end">{headerRight}</div>
|
||||
) : null}
|
||||
<InputGroup>
|
||||
<InputGroupAddon>
|
||||
@@ -120,18 +236,27 @@ function ServerTileRail({
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Поиск…"
|
||||
aria-label="Поиск сервера"
|
||||
disabled={loading}
|
||||
/>
|
||||
</InputGroup>
|
||||
</FrameHeader>
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
<div className="flex flex-col gap-0.5 p-1.5" role="listbox" aria-label="Серверы">
|
||||
<div
|
||||
ref={listRef}
|
||||
className="flex flex-col gap-0.5 p-1.5"
|
||||
role="listbox"
|
||||
aria-label="Серверы"
|
||||
aria-activedescendant={resolvedId ? `server-tile-${resolvedId}` : undefined}
|
||||
tabIndex={0}
|
||||
onKeyDown={handleListKeyDown}
|
||||
>
|
||||
{showAllTile ? (
|
||||
<ServerTileButton
|
||||
selected={selectedId === ALL_SERVERS_ID}
|
||||
item={allItem}
|
||||
selected={resolvedId === ALL_SERVERS_ID}
|
||||
onSelect={() => onSelect(ALL_SERVERS_ID)}
|
||||
title="Все серверы"
|
||||
name="Все"
|
||||
count={showCount ? allCount : undefined}
|
||||
showCount={showCount}
|
||||
showType={false}
|
||||
icon={
|
||||
<IconTile variant="elevated" size="sm" aria-hidden="true">
|
||||
<LayersIcon className="text-muted-foreground" />
|
||||
@@ -139,32 +264,42 @@ function ServerTileRail({
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
{filtered.map((item) => (
|
||||
<ServerTileButton
|
||||
key={item.id}
|
||||
selected={selectedId === item.id}
|
||||
onSelect={() => onSelect(item.id)}
|
||||
title={item.title ?? item.name}
|
||||
name={item.name}
|
||||
count={showCount ? item.count : undefined}
|
||||
enabled={item.enabled}
|
||||
selectable={item.selectable}
|
||||
status={item.status}
|
||||
type={showType ? item.type : undefined}
|
||||
icon={
|
||||
<IconTile variant="elevated" size="sm" aria-hidden="true">
|
||||
{item.country ? (
|
||||
<Flag code={item.country} size={16} />
|
||||
) : (
|
||||
<ServerIcon className="text-muted-foreground" />
|
||||
)}
|
||||
</IconTile>
|
||||
}
|
||||
/>
|
||||
{groups.map((group) => (
|
||||
<div key={group.site || "__flat__"} className="flex flex-col gap-0.5">
|
||||
{groupBySite && group.site ? (
|
||||
<p className="sticky top-0 z-[1] bg-background/95 px-2 py-1 text-[10px] font-bold uppercase tracking-wide text-muted-foreground backdrop-blur-sm">
|
||||
{group.site}
|
||||
</p>
|
||||
) : null}
|
||||
{group.items.map((item) => (
|
||||
<ServerTileButton
|
||||
key={item.id}
|
||||
item={item}
|
||||
selected={resolvedId === item.id}
|
||||
onSelect={() => onSelect(item.id)}
|
||||
showCount={showCount}
|
||||
showType={showType}
|
||||
showSite={!groupBySite}
|
||||
icon={
|
||||
<IconTile variant="elevated" size="sm" aria-hidden="true">
|
||||
{item.country ? (
|
||||
<Flag code={item.country} size={16} />
|
||||
) : (
|
||||
<ServerIcon className="text-muted-foreground" />
|
||||
)}
|
||||
</IconTile>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
{filtered.length === 0 && !showAllTile ? (
|
||||
<p className="px-2 py-3 text-center text-xs text-muted-foreground">
|
||||
{items.length === 0 ? "Нет доступных серверов" : "Ничего не найдено"}
|
||||
{loading
|
||||
? "Загрузка серверов…"
|
||||
: items.length === 0
|
||||
? "Нет доступных серверов"
|
||||
: "Ничего не найдено"}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -175,62 +310,76 @@ function ServerTileRail({
|
||||
}
|
||||
|
||||
function ServerTileButton({
|
||||
item,
|
||||
selected,
|
||||
onSelect,
|
||||
title,
|
||||
name,
|
||||
count,
|
||||
enabled = true,
|
||||
selectable = true,
|
||||
status,
|
||||
type,
|
||||
showCount,
|
||||
showType,
|
||||
showSite = true,
|
||||
icon,
|
||||
}: {
|
||||
item: ServerTileItem
|
||||
selected: boolean
|
||||
onSelect: () => void
|
||||
title: string
|
||||
name: string
|
||||
count?: number
|
||||
enabled?: boolean
|
||||
selectable?: boolean
|
||||
status?: ServerStatus
|
||||
type?: ServerType
|
||||
showCount: boolean
|
||||
showType: boolean
|
||||
showSite?: boolean
|
||||
icon: ReactNode
|
||||
}) {
|
||||
const isAll = item.id === ALL_SERVERS_ID
|
||||
const selectable = isSelectable(item)
|
||||
const hostname = isAll ? "Все серверы" : hostnameOf(item)
|
||||
const meta = item.meta ?? (showCount && item.count != null ? String(item.count) : undefined)
|
||||
const showStatus = Boolean(item.status && item.status !== "online")
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={selected}
|
||||
title={title}
|
||||
disabled={!selectable}
|
||||
onClick={onSelect}
|
||||
<Item
|
||||
id={`server-tile-${item.id}`}
|
||||
size="xs"
|
||||
variant={selected ? "muted" : "default"}
|
||||
render={
|
||||
<button
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={selected}
|
||||
title={item.title ?? hostname}
|
||||
disabled={!selectable}
|
||||
onClick={onSelect}
|
||||
/>
|
||||
}
|
||||
className={cn(
|
||||
"flex h-11 w-full items-center gap-2 rounded-md px-2 text-left transition-colors",
|
||||
selected
|
||||
? "bg-muted ring-1 ring-border"
|
||||
: "hover:bg-muted/60",
|
||||
!enabled && !selected && "opacity-40",
|
||||
"h-11 min-h-11 flex-nowrap rounded-md py-0",
|
||||
selected && "ring-1 ring-border",
|
||||
item.enabled === false && !selected && "opacity-40",
|
||||
!selectable && "cursor-not-allowed opacity-40 hover:bg-transparent",
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
<span className="flex min-w-0 flex-1 items-center gap-1.5">
|
||||
{status ? <StatusDot status={status} /> : null}
|
||||
<span className="min-w-0 truncate font-mono text-[11px] font-medium">{name}</span>
|
||||
{type ? <ServerTypeBadge type={type} /> : null}
|
||||
</span>
|
||||
{count != null ? (
|
||||
<Badge
|
||||
variant={selected ? "secondary" : "outline"}
|
||||
size="xs"
|
||||
className="tabular-nums"
|
||||
>
|
||||
{count}
|
||||
</Badge>
|
||||
<ItemMedia>{icon}</ItemMedia>
|
||||
<ItemContent className="min-w-0">
|
||||
<ItemTitle className="max-w-full min-w-0 gap-1.5 font-mono text-[11px]">
|
||||
{showStatus ? <StatusDot status={item.status!} /> : null}
|
||||
<span className="min-w-0 truncate">{hostname}</span>
|
||||
{!isAll && showSite && item.site ? (
|
||||
<Badge variant="outline" size="xs" className="shrink-0 font-mono uppercase">
|
||||
{item.site}
|
||||
</Badge>
|
||||
) : null}
|
||||
{!isAll && showType && item.type ? <ServerTypeBadge type={item.type} /> : null}
|
||||
</ItemTitle>
|
||||
</ItemContent>
|
||||
{meta ? (
|
||||
<ItemActions>
|
||||
<Badge
|
||||
variant={selected ? "secondary" : "outline"}
|
||||
size="xs"
|
||||
className="tabular-nums"
|
||||
>
|
||||
{meta}
|
||||
</Badge>
|
||||
</ItemActions>
|
||||
) : null}
|
||||
</button>
|
||||
</Item>
|
||||
)
|
||||
}
|
||||
|
||||
export { ServerTileRail, ServerTypeBadge }
|
||||
export { ServerTileRail, ServerTypeBadge, hostnameOf }
|
||||
|
||||
Reference in New Issue
Block a user