feat(ui): refactor ServiceIpList and ServiceUnitCard components for improved layout and functionality
quality / commitlint (push) Skipped
quality / changes (push) Successful in 10s
quality / api (push) Skipped
quality / docker-check (push) Skipped
CD / update-wiki (push) Successful in 4s
quality / web (push) Successful in 53s
CD / quality (push) Successful in 1m9s
CD / publish (push) Successful in 1m37s

- Replaced div elements with Item components in ServiceIpList for better alignment and structure.
- Introduced alignWithMenu prop to align IP Switch with the card menu.
- Updated ServiceUnitCard to utilize Item components, enhancing the visual consistency and responsiveness of the service card layout.
- Improved overall user experience by streamlining component structure and ensuring proper alignment of UI elements.
This commit is contained in:
Denozordec
2026-08-19 22:37:54 +07:00
parent 44d0eb0114
commit 6bced71037
2 changed files with 143 additions and 96 deletions
@@ -8,6 +8,13 @@ import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
import { serviceDisplayFqdns } from '@/lib/service-utils'
import type { ServiceView } from '@/lib/schemas'
import { Button } from '@cfdm/ui/components/button'
import {
Item,
ItemActions,
ItemContent,
ItemGroup,
ItemMedia,
} from '@cfdm/ui/components/item'
import { Switch } from '@cfdm/ui/components/switch'
import {
Tooltip,
@@ -17,6 +24,9 @@ import {
} from '@cfdm/ui/components/tooltip'
import { cn } from '@cfdm/ui/lib/utils'
/** Matches `Button size="icon-sm"` so Switch columns align with the card menu. */
const MENU_SLOT_CLASS = 'size-7 shrink-0'
export function CopyFqdnButton({
value,
className,
@@ -126,6 +136,8 @@ interface ServiceIpListProps {
togglingIp?: string | null
ipToggleDisabled?: boolean
onToggleIp?: (ip: string, enabled: boolean) => void
/** Invisible icon-sm slot so IP Switch lines up with the card overflow menu. */
alignWithMenu?: boolean
className?: string
emptyLabel?: string
copyable?: boolean
@@ -139,6 +151,7 @@ export function ServiceIpList({
togglingIp = null,
ipToggleDisabled = false,
onToggleIp,
alignWithMenu = false,
className,
emptyLabel = 'Нет IP',
copyable = false,
@@ -155,49 +168,69 @@ export function ServiceIpList({
const healthByIp = new Map(ipHealth.map((row) => [row.ip, row]))
const visible = onToggleIp ? ips : ips.slice(0, VISIBLE_IP_LIMIT)
const extraCount = ips.length - visible.length
const showMenuSlot = Boolean(onToggleIp && alignWithMenu)
return (
<div className={cn('flex min-w-0 flex-col gap-1', className)}>
<ItemGroup className={cn('gap-1', className)}>
{visible.map((ip) => {
const health = healthByIp.get(ip)
const enabled = ipEnabled[ip] !== false
return (
<div key={ip} className="flex min-w-0 items-center gap-1.5">
<HealthCheckBadge
status={health?.status ?? 'unknown'}
latencyMs={health?.latency_ms}
lastCheckedAt={health?.last_checked_at}
lastError={health?.last_error}
colo={health?.colo}
provider={health?.provider}
size="xs"
/>
<TruncatedText
className={cn(
'min-w-0 font-mono text-xs',
enabled ? 'text-muted-foreground' : 'text-muted-foreground/60',
textClassName,
)}
>
{ip}
</TruncatedText>
{copyable ? <CopyFqdnButton value={ip} /> : null}
{onToggleIp ? (
<Switch
size="sm"
className="ml-auto shrink-0"
checked={enabled}
disabled={ipToggleDisabled || togglingIp === ip}
onClick={(event) => {
event.stopPropagation()
}}
onCheckedChange={(checked) => onToggleIp(ip, Boolean(checked))}
aria-label={
enabled ? `Выключить IP ${ip}` : `Включить IP ${ip}`
}
<Item
key={ip}
size="sm"
className="w-full min-w-0 flex-nowrap border-0 p-0"
>
<ItemMedia>
<HealthCheckBadge
status={health?.status ?? 'unknown'}
latencyMs={health?.latency_ms}
lastCheckedAt={health?.last_checked_at}
lastError={health?.last_error}
colo={health?.colo}
provider={health?.provider}
size="xs"
/>
</ItemMedia>
<ItemContent className="min-w-0 gap-0">
<div className="flex min-w-0 items-center gap-1.5">
<TruncatedText
className={cn(
'min-w-0 font-mono text-xs',
enabled
? 'text-muted-foreground'
: 'text-muted-foreground/60',
textClassName,
)}
>
{ip}
</TruncatedText>
{copyable ? <CopyFqdnButton value={ip} /> : null}
</div>
</ItemContent>
{onToggleIp ? (
<ItemActions className="ml-auto shrink-0 gap-1">
<Switch
size="sm"
className="shrink-0"
checked={enabled}
disabled={ipToggleDisabled || togglingIp === ip}
onClick={(event) => {
event.stopPropagation()
}}
onCheckedChange={(checked) =>
onToggleIp(ip, Boolean(checked))
}
aria-label={
enabled ? `Выключить IP ${ip}` : `Включить IP ${ip}`
}
/>
{showMenuSlot ? (
<span className={MENU_SLOT_CLASS} aria-hidden="true" />
) : null}
</ItemActions>
) : null}
</div>
</Item>
)
})}
{extraCount > 0 ? (
@@ -224,6 +257,6 @@ export function ServiceIpList({
</Tooltip>
</TooltipProvider>
) : null}
</div>
</ItemGroup>
)
}
@@ -5,7 +5,6 @@ import { Badge } from '@/components/reui/badge'
import {
Frame,
FrameDescription,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
@@ -23,6 +22,12 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from '@cfdm/ui/components/dropdown-menu'
import {
Item,
ItemActions,
ItemContent,
ItemMedia,
} from '@cfdm/ui/components/item'
import { Switch } from '@cfdm/ui/components/switch'
import {
Tooltip,
@@ -31,6 +36,12 @@ import {
TooltipTrigger,
} from '@cfdm/ui/components/tooltip'
/**
* Compact service card — settings-8 DNA (Badge + copy + Switch + menu).
* Preview: https://reui.io/preview/base/settings-8
* Frame: https://reui.io/docs/components/base/frame
* IconTile: https://reui.io/docs/components/base/icon-tile
*/
interface ServiceUnitCardProps {
service: ServiceView
togglingId: number | null
@@ -55,18 +66,20 @@ export function ServiceUnitCard({
const extraCount = Math.max(0, fqdns.length - 1)
return (
<Frame dense spacing="sm" className="h-full min-w-0 overflow-hidden">
<FrameHeader className="flex-row items-center justify-between gap-2">
<div className="flex min-w-0 items-center gap-2">
<IconTile
variant="elevated"
size="sm"
className="text-muted-foreground"
aria-hidden="true"
>
<ServerIcon />
</IconTile>
<div className="flex min-w-0 flex-col gap-px">
<Frame stacked spacing="sm" className="h-full min-w-0">
<FramePanel fit>
<Item size="sm" className="w-full min-w-0 flex-nowrap border-0 p-0">
<ItemMedia>
<IconTile
variant="elevated"
size="sm"
className="text-muted-foreground"
aria-hidden="true"
>
<ServerIcon />
</IconTile>
</ItemMedia>
<ItemContent className="min-w-0 gap-px">
<FrameTitle className="min-w-0 truncate text-sm">
<Link
to="/services/$serviceId"
@@ -108,61 +121,62 @@ export function ServiceUnitCard({
<CopyFqdnButton value={fqdns.join('\n')} />
) : null}
</div>
</div>
</div>
<div className="flex shrink-0 items-center gap-1">
<Switch
size="sm"
checked={service.enabled}
disabled={togglingId === service.id}
onCheckedChange={(checked) =>
onToggleService(service.id, Boolean(checked))
}
aria-label={
service.enabled ? 'Выключить сервис' : 'Включить сервис'
}
/>
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label={`Действия ${service.name}`}
/>
</ItemContent>
<ItemActions className="ml-auto shrink-0 gap-1">
<Switch
size="sm"
checked={service.enabled}
disabled={togglingId === service.id}
onCheckedChange={(checked) =>
onToggleService(service.id, Boolean(checked))
}
>
<MoreHorizontalIcon aria-hidden />
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
aria-label={
service.enabled ? 'Выключить сервис' : 'Включить сервис'
}
/>
<DropdownMenu>
<DropdownMenuTrigger
render={
<Link
to="/services/$serviceId"
params={{ serviceId: String(service.id) }}
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label={`Действия ${service.name}`}
/>
}
>
Обзор
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onEditService(service)}>
Изменить
</DropdownMenuItem>
<DropdownMenuItem
variant="destructive"
onClick={() => onDeleteService(service)}
>
Удалить
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</FrameHeader>
<MoreHorizontalIcon aria-hidden />
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
render={
<Link
to="/services/$serviceId"
params={{ serviceId: String(service.id) }}
/>
}
>
Обзор
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onEditService(service)}>
Изменить
</DropdownMenuItem>
<DropdownMenuItem
variant="destructive"
onClick={() => onDeleteService(service)}
>
Удалить
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</ItemActions>
</Item>
</FramePanel>
<FramePanel className="flex min-w-0 flex-col gap-1 pt-0 shadow-none!">
<FramePanel className="flex min-w-0 flex-col">
<ServiceIpList
copyable
alignWithMenu
ips={service.ips ?? []}
ipHealth={service.ip_health ?? []}
ipEnabled={service.ip_enabled ?? {}}