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 { serviceDisplayFqdns } from '@/lib/service-utils'
import type { ServiceView } from '@/lib/schemas' import type { ServiceView } from '@/lib/schemas'
import { Button } from '@cfdm/ui/components/button' 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 { Switch } from '@cfdm/ui/components/switch'
import { import {
Tooltip, Tooltip,
@@ -17,6 +24,9 @@ import {
} from '@cfdm/ui/components/tooltip' } from '@cfdm/ui/components/tooltip'
import { cn } from '@cfdm/ui/lib/utils' 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({ export function CopyFqdnButton({
value, value,
className, className,
@@ -126,6 +136,8 @@ interface ServiceIpListProps {
togglingIp?: string | null togglingIp?: string | null
ipToggleDisabled?: boolean ipToggleDisabled?: boolean
onToggleIp?: (ip: string, enabled: boolean) => void onToggleIp?: (ip: string, enabled: boolean) => void
/** Invisible icon-sm slot so IP Switch lines up with the card overflow menu. */
alignWithMenu?: boolean
className?: string className?: string
emptyLabel?: string emptyLabel?: string
copyable?: boolean copyable?: boolean
@@ -139,6 +151,7 @@ export function ServiceIpList({
togglingIp = null, togglingIp = null,
ipToggleDisabled = false, ipToggleDisabled = false,
onToggleIp, onToggleIp,
alignWithMenu = false,
className, className,
emptyLabel = 'Нет IP', emptyLabel = 'Нет IP',
copyable = false, copyable = false,
@@ -155,49 +168,69 @@ export function ServiceIpList({
const healthByIp = new Map(ipHealth.map((row) => [row.ip, row])) const healthByIp = new Map(ipHealth.map((row) => [row.ip, row]))
const visible = onToggleIp ? ips : ips.slice(0, VISIBLE_IP_LIMIT) const visible = onToggleIp ? ips : ips.slice(0, VISIBLE_IP_LIMIT)
const extraCount = ips.length - visible.length const extraCount = ips.length - visible.length
const showMenuSlot = Boolean(onToggleIp && alignWithMenu)
return ( return (
<div className={cn('flex min-w-0 flex-col gap-1', className)}> <ItemGroup className={cn('gap-1', className)}>
{visible.map((ip) => { {visible.map((ip) => {
const health = healthByIp.get(ip) const health = healthByIp.get(ip)
const enabled = ipEnabled[ip] !== false const enabled = ipEnabled[ip] !== false
return ( return (
<div key={ip} className="flex min-w-0 items-center gap-1.5"> <Item
<HealthCheckBadge key={ip}
status={health?.status ?? 'unknown'} size="sm"
latencyMs={health?.latency_ms} className="w-full min-w-0 flex-nowrap border-0 p-0"
lastCheckedAt={health?.last_checked_at} >
lastError={health?.last_error} <ItemMedia>
colo={health?.colo} <HealthCheckBadge
provider={health?.provider} status={health?.status ?? 'unknown'}
size="xs" latencyMs={health?.latency_ms}
/> lastCheckedAt={health?.last_checked_at}
<TruncatedText lastError={health?.last_error}
className={cn( colo={health?.colo}
'min-w-0 font-mono text-xs', provider={health?.provider}
enabled ? 'text-muted-foreground' : 'text-muted-foreground/60', size="xs"
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}`
}
/> />
</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} ) : null}
</div> </Item>
) )
})} })}
{extraCount > 0 ? ( {extraCount > 0 ? (
@@ -224,6 +257,6 @@ export function ServiceIpList({
</Tooltip> </Tooltip>
</TooltipProvider> </TooltipProvider>
) : null} ) : null}
</div> </ItemGroup>
) )
} }
@@ -5,7 +5,6 @@ import { Badge } from '@/components/reui/badge'
import { import {
Frame, Frame,
FrameDescription, FrameDescription,
FrameHeader,
FramePanel, FramePanel,
FrameTitle, FrameTitle,
} from '@/components/reui/frame' } from '@/components/reui/frame'
@@ -23,6 +22,12 @@ import {
DropdownMenuItem, DropdownMenuItem,
DropdownMenuTrigger, DropdownMenuTrigger,
} from '@cfdm/ui/components/dropdown-menu' } from '@cfdm/ui/components/dropdown-menu'
import {
Item,
ItemActions,
ItemContent,
ItemMedia,
} from '@cfdm/ui/components/item'
import { Switch } from '@cfdm/ui/components/switch' import { Switch } from '@cfdm/ui/components/switch'
import { import {
Tooltip, Tooltip,
@@ -31,6 +36,12 @@ import {
TooltipTrigger, TooltipTrigger,
} from '@cfdm/ui/components/tooltip' } 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 { interface ServiceUnitCardProps {
service: ServiceView service: ServiceView
togglingId: number | null togglingId: number | null
@@ -55,18 +66,20 @@ export function ServiceUnitCard({
const extraCount = Math.max(0, fqdns.length - 1) const extraCount = Math.max(0, fqdns.length - 1)
return ( return (
<Frame dense spacing="sm" className="h-full min-w-0 overflow-hidden"> <Frame stacked spacing="sm" className="h-full min-w-0">
<FrameHeader className="flex-row items-center justify-between gap-2"> <FramePanel fit>
<div className="flex min-w-0 items-center gap-2"> <Item size="sm" className="w-full min-w-0 flex-nowrap border-0 p-0">
<IconTile <ItemMedia>
variant="elevated" <IconTile
size="sm" variant="elevated"
className="text-muted-foreground" size="sm"
aria-hidden="true" className="text-muted-foreground"
> aria-hidden="true"
<ServerIcon /> >
</IconTile> <ServerIcon />
<div className="flex min-w-0 flex-col gap-px"> </IconTile>
</ItemMedia>
<ItemContent className="min-w-0 gap-px">
<FrameTitle className="min-w-0 truncate text-sm"> <FrameTitle className="min-w-0 truncate text-sm">
<Link <Link
to="/services/$serviceId" to="/services/$serviceId"
@@ -108,61 +121,62 @@ export function ServiceUnitCard({
<CopyFqdnButton value={fqdns.join('\n')} /> <CopyFqdnButton value={fqdns.join('\n')} />
) : null} ) : null}
</div> </div>
</div> </ItemContent>
</div> <ItemActions className="ml-auto shrink-0 gap-1">
<div className="flex shrink-0 items-center gap-1"> <Switch
<Switch size="sm"
size="sm" checked={service.enabled}
checked={service.enabled} disabled={togglingId === service.id}
disabled={togglingId === service.id} onCheckedChange={(checked) =>
onCheckedChange={(checked) => onToggleService(service.id, Boolean(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}`}
/>
} }
> aria-label={
<MoreHorizontalIcon aria-hidden /> service.enabled ? 'Выключить сервис' : 'Включить сервис'
</DropdownMenuTrigger> }
<DropdownMenuContent align="end"> />
<DropdownMenuItem <DropdownMenu>
<DropdownMenuTrigger
render={ render={
<Link <Button
to="/services/$serviceId" type="button"
params={{ serviceId: String(service.id) }} variant="ghost"
size="icon-sm"
aria-label={`Действия ${service.name}`}
/> />
} }
> >
Обзор <MoreHorizontalIcon aria-hidden />
</DropdownMenuItem> </DropdownMenuTrigger>
<DropdownMenuItem onClick={() => onEditService(service)}> <DropdownMenuContent align="end">
Изменить <DropdownMenuItem
</DropdownMenuItem> render={
<DropdownMenuItem <Link
variant="destructive" to="/services/$serviceId"
onClick={() => onDeleteService(service)} params={{ serviceId: String(service.id) }}
> />
Удалить }
</DropdownMenuItem> >
</DropdownMenuContent> Обзор
</DropdownMenu> </DropdownMenuItem>
</div> <DropdownMenuItem onClick={() => onEditService(service)}>
</FrameHeader> Изменить
</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 <ServiceIpList
copyable copyable
alignWithMenu
ips={service.ips ?? []} ips={service.ips ?? []}
ipHealth={service.ip_health ?? []} ipHealth={service.ip_health ?? []}
ipEnabled={service.ip_enabled ?? {}} ipEnabled={service.ip_enabled ?? {}}