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.
386 lines
12 KiB
TypeScript
386 lines
12 KiB
TypeScript
"use client"
|
|
|
|
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"
|
|
import { Badge } from "@/components/reui/badge"
|
|
import { IconTile } from "@/components/reui/icon-tile"
|
|
import {
|
|
Frame,
|
|
FrameHeader,
|
|
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,
|
|
InputGroupInput,
|
|
} from "@/components/ui/input-group"
|
|
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/docs/components/base/frame · https://reui.io/docs/components/base/badge */
|
|
export const ALL_SERVERS_ID = "all"
|
|
|
|
export interface ServerTileItem {
|
|
id: string
|
|
name: string
|
|
country?: string
|
|
status?: ServerStatus
|
|
type?: ServerType
|
|
count?: number
|
|
enabled?: boolean
|
|
selectable?: boolean
|
|
title?: string
|
|
site?: string
|
|
host?: string
|
|
meta?: string
|
|
}
|
|
|
|
function typeBadgeVariant(
|
|
type: ServerType,
|
|
): "focus-light" | "info-light" | "success-light" {
|
|
if (type === "jump-host") return "focus-light"
|
|
if (type === "home-router") return "success-light"
|
|
return "info-light"
|
|
}
|
|
|
|
function typeBadgeLabel(type: ServerType): string {
|
|
if (type === "jump-host") return "JH"
|
|
if (type === "home-router") return "HR"
|
|
return "EN"
|
|
}
|
|
|
|
function ServerTypeBadge({ type }: { type: ServerType }) {
|
|
return (
|
|
<Badge variant={typeBadgeVariant(type)} size="xs" className="font-mono">
|
|
{typeBadgeLabel(type)}
|
|
</Badge>
|
|
)
|
|
}
|
|
|
|
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 ?? "", 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,
|
|
onSelect,
|
|
allCount = 0,
|
|
showHeader = true,
|
|
showAll = true,
|
|
showCount = true,
|
|
showType = true,
|
|
headerRight,
|
|
loading = false,
|
|
className,
|
|
}: {
|
|
items: ServerTileItem[]
|
|
selectedId: string
|
|
onSelect: (id: string) => void
|
|
allCount?: number
|
|
showHeader?: boolean
|
|
showAll?: boolean
|
|
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">
|
|
<FrameHeader className="flex flex-col gap-2 border-b px-3 py-2">
|
|
{showHeader ? (
|
|
<div className="flex items-center justify-between gap-2">
|
|
<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>
|
|
) : null}
|
|
<InputGroup>
|
|
<InputGroupAddon>
|
|
<SearchIcon className="size-3.5" />
|
|
</InputGroupAddon>
|
|
<InputGroupInput
|
|
value={query}
|
|
onChange={(e) => setQuery(e.target.value)}
|
|
placeholder="Поиск…"
|
|
aria-label="Поиск сервера"
|
|
disabled={loading}
|
|
/>
|
|
</InputGroup>
|
|
</FrameHeader>
|
|
<ScrollArea className="min-h-0 flex-1">
|
|
<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
|
|
item={allItem}
|
|
selected={resolvedId === ALL_SERVERS_ID}
|
|
onSelect={() => onSelect(ALL_SERVERS_ID)}
|
|
showCount={showCount}
|
|
showType={false}
|
|
icon={
|
|
<IconTile variant="elevated" size="sm" aria-hidden="true">
|
|
<LayersIcon className="text-muted-foreground" />
|
|
</IconTile>
|
|
}
|
|
/>
|
|
) : null}
|
|
{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">
|
|
{loading
|
|
? "Загрузка серверов…"
|
|
: items.length === 0
|
|
? "Нет доступных серверов"
|
|
: "Ничего не найдено"}
|
|
</p>
|
|
) : null}
|
|
</div>
|
|
</ScrollArea>
|
|
</FramePanel>
|
|
</Frame>
|
|
)
|
|
}
|
|
|
|
function ServerTileButton({
|
|
item,
|
|
selected,
|
|
onSelect,
|
|
showCount,
|
|
showType,
|
|
showSite = true,
|
|
icon,
|
|
}: {
|
|
item: ServerTileItem
|
|
selected: boolean
|
|
onSelect: () => void
|
|
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 (
|
|
<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(
|
|
"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",
|
|
)}
|
|
>
|
|
<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}
|
|
</Item>
|
|
)
|
|
}
|
|
|
|
export { ServerTileRail, ServerTypeBadge, hostnameOf }
|