refactor(services): streamline service edit functionality and improve component structure
quality / commitlint (push) Skipped
CD / update-wiki (push) Successful in 7s
quality / changes (push) Successful in 5s
quality / api (push) Skipped
quality / docker-check (push) Skipped
quality / web (push) Successful in 55s
CD / quality (push) Successful in 1m2s
CD / publish (push) Successful in 1m35s

- Removed unused imports and refactored the ServiceEditSheet component to enhance readability and maintainability.
- Introduced new utility functions for managing service binding drafts and handling common FQDN changes.
- Updated the ServiceCatalogSection to improve layout responsiveness and enhance the display of service units.
- Simplified the ServiceUnitCard component by removing unnecessary elements and optimizing the layout for better user experience.

This commit improves the overall structure and functionality of service management components, making them more efficient and user-friendly.
This commit is contained in:
Denozordec
2026-08-19 14:27:02 +07:00
parent 50c5c21c18
commit 4ca948292d
3 changed files with 239 additions and 375 deletions
+219 -330
View File
@@ -1,15 +1,11 @@
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { Link2Icon, PlusIcon, Trash2Icon } from 'lucide-react' import { PlusIcon, Trash2Icon } from 'lucide-react'
import { ConfirmDialog } from '@/components/confirm-dialog' import { ConfirmDialog } from '@/components/confirm-dialog'
import { CountedLineTabs } from '@/components/counted-line-tabs'
import { EmptyState } from '@/components/empty-state'
import { TaggedInput, isValidIpv4 } from '@/components/tagged-input' import { TaggedInput, isValidIpv4 } from '@/components/tagged-input'
import { ServiceBindingIpInput } from '@/components/service-binding-ip-input' import { ServiceBindingIpInput } from '@/components/service-binding-ip-input'
import { import type {
HealthCheckConfigFields, LbMode,
type LbAndHealthConfig, HealthCheckType,
type LbMode,
type HealthCheckType,
} from '@/components/health-check-config-fields' } from '@/components/health-check-config-fields'
import type { import type {
CreateServiceWithConfigInput, CreateServiceWithConfigInput,
@@ -38,7 +34,6 @@ import {
ItemGroup, ItemGroup,
} from '@cfdm/ui/components/item' } from '@cfdm/ui/components/item'
import { LoadingButton } from '@/components/loading-button' import { LoadingButton } from '@/components/loading-button'
import { TabsContent } from '@cfdm/ui/components/tabs'
import { import {
Select, Select,
SelectContent, SelectContent,
@@ -160,6 +155,33 @@ function buildDomainsPayload(bindings: ServiceBindingDraft[]) {
) )
} }
function emptyBindingDraft(fqdn = ''): ServiceBindingDraft {
return {
fqdn,
record_type: 'A',
target_ips: [],
target_cname: '',
lb_mode: 'round_robin',
health: { ...defaultHealth },
target_ip_weights: {},
target_ip_priorities: {},
}
}
function withPoolIps(draft: ServiceBindingDraft, pool: string[]): ServiceBindingDraft {
if (draft.record_type !== 'A' || draft.target_ips.length > 0 || pool.length === 0) {
return draft
}
return {
...draft,
target_ips: pool,
target_ip_weights: Object.fromEntries(pool.map((ip) => [ip, draft.target_ip_weights[ip] ?? 1])),
target_ip_priorities: Object.fromEntries(
pool.map((ip) => [ip, draft.target_ip_priorities[ip] ?? 1]),
),
}
}
export function ServiceEditSheet({ export function ServiceEditSheet({
mode, mode,
service, service,
@@ -182,7 +204,6 @@ export function ServiceEditSheet({
const [bindings, setBindings] = useState<ServiceBindingDraft[]>([]) const [bindings, setBindings] = useState<ServiceBindingDraft[]>([])
const [lbWeight, setLbWeight] = useState(1) const [lbWeight, setLbWeight] = useState(1)
const [lbPriority, setLbPriority] = useState(1) const [lbPriority, setLbPriority] = useState(1)
const [activeTab, setActiveTab] = useState('general')
const groupItems = useMemo( const groupItems = useMemo(
() => [ () => [
@@ -194,7 +215,6 @@ export function ServiceEditSheet({
useEffect(() => { useEffect(() => {
if (!open) return if (!open) return
setActiveTab('general')
if (mode === 'edit' && service) { if (mode === 'edit' && service) {
setName(service.name) setName(service.name)
setSlug(service.slug) setSlug(service.slug)
@@ -228,35 +248,7 @@ export function ServiceEditSheet({
[knownDomains], [knownDomains],
) )
function emptyBindingDraft(fqdn = ''): ServiceBindingDraft { const extraBindings = bindings.slice(1)
return {
fqdn,
record_type: 'A',
target_ips: [],
target_cname: '',
lb_mode: 'round_robin',
health: { ...defaultHealth },
target_ip_weights: {},
target_ip_priorities: {},
}
}
function handleAddBinding() {
setBindings((current) => [
...current,
emptyBindingDraft(current.length === 0 ? commonFqdn : ''),
])
}
function handleRemoveBinding(index: number) {
setBindings((current) => {
const next = current.filter((_, i) => i !== index)
if (index === 0) {
setCommonFqdn(next[0]?.fqdn ?? '')
}
return next
})
}
function handleCommonFqdnChange(value: string) { function handleCommonFqdnChange(value: string) {
setCommonFqdn(value) setCommonFqdn(value)
@@ -266,8 +258,22 @@ export function ServiceEditSheet({
}) })
} }
function handleAddExtraBinding() {
setBindings((current) => {
const extra = withPoolIps(emptyBindingDraft(), ips)
if (current.length === 0) {
return [emptyBindingDraft(commonFqdn), extra]
}
return [...current, extra]
})
}
function handleRemoveExtraBinding(extraIndex: number) {
const index = extraIndex + 1
setBindings((current) => current.filter((_, i) => i !== index))
}
function handleFqdnChange(index: number, fqdn: string) { function handleFqdnChange(index: number, fqdn: string) {
if (index === 0) setCommonFqdn(fqdn)
setBindings((current) => setBindings((current) =>
current.map((item, i) => (i === index ? { ...item, fqdn } : item)), current.map((item, i) => (i === index ? { ...item, fqdn } : item)),
) )
@@ -313,47 +319,6 @@ export function ServiceEditSheet({
) )
} }
function handleBindingMetaChange(
index: number,
ip: string,
meta: { weight?: number; priority?: number },
) {
setBindings((current) =>
current.map((item, i) => {
if (i !== index) return item
const weights = { ...item.target_ip_weights }
const priorities = { ...item.target_ip_priorities }
if (meta.weight !== undefined) weights[ip] = meta.weight
if (meta.priority !== undefined) priorities[ip] = meta.priority
return { ...item, target_ip_weights: weights, target_ip_priorities: priorities }
}),
)
}
function handleBindingHealthChange(index: number, next: LbAndHealthConfig) {
setBindings((current) =>
current.map((item, i) =>
i === index
? {
...item,
lb_mode: next.lb_mode,
health: {
enabled: next.enabled,
type: next.type,
port: next.port,
path: next.path,
expected_status: next.expected_status,
interval_sec: next.interval_sec,
timeout_ms: next.timeout_ms,
verify_tls: next.verify_tls,
provider: next.provider ?? 'local',
},
}
: item,
),
)
}
function resolveServiceGroupId(): number | null { function resolveServiceGroupId(): number | null {
return serviceGroupId === 'none' ? null : Number(serviceGroupId) return serviceGroupId === 'none' ? null : Number(serviceGroupId)
} }
@@ -362,36 +327,11 @@ export function ServiceEditSheet({
const trimmed = commonFqdn.trim() const trimmed = commonFqdn.trim()
if (!trimmed) return current if (!trimmed) return current
if (current.length === 0) { if (current.length === 0) {
const draft = emptyBindingDraft(trimmed) return [withPoolIps(emptyBindingDraft(trimmed), ips)]
return [
{
...draft,
target_ips: ips,
target_ip_weights: Object.fromEntries(ips.map((ip) => [ip, 1])),
target_ip_priorities: Object.fromEntries(ips.map((ip) => [ip, 1])),
},
]
} }
return current.map((item, index) => { return current.map((item, index) => {
if (index !== 0) return item if (index !== 0) return item
const next = { ...item, fqdn: trimmed } return withPoolIps({ ...item, fqdn: trimmed }, ips)
if (
next.record_type === 'A' &&
next.target_ips.length === 0 &&
ips.length > 0
) {
return {
...next,
target_ips: ips,
target_ip_weights: Object.fromEntries(
ips.map((ip) => [ip, item.target_ip_weights[ip] ?? 1]),
),
target_ip_priorities: Object.fromEntries(
ips.map((ip) => [ip, item.target_ip_priorities[ip] ?? 1]),
),
}
}
return next
}) })
} }
@@ -403,7 +343,6 @@ export function ServiceEditSheet({
new Set(normalizedFqdns).size !== normalizedFqdns.length new Set(normalizedFqdns).size !== normalizedFqdns.length
if (hasDuplicateFqdn) { if (hasDuplicateFqdn) {
toast.error('Укажите уникальные FQDN — дубликаты привязок недопустимы') toast.error('Укажите уникальные FQDN — дубликаты привязок недопустимы')
setActiveTab('bindings')
return return
} }
const groupId = resolveServiceGroupId() const groupId = resolveServiceGroupId()
@@ -450,28 +389,16 @@ export function ServiceEditSheet({
<SheetHeader className="shrink-0 border-b pb-4"> <SheetHeader className="shrink-0 border-b pb-4">
<SheetTitle>{isCreate ? 'Новый сервис' : 'Редактирование сервиса'}</SheetTitle> <SheetTitle>{isCreate ? 'Новый сервис' : 'Редактирование сервиса'}</SheetTitle>
<SheetDescription> <SheetDescription>
Общий домен и IP задаются у сервиса. Дополнительные FQDN — на вкладке Общий домен и IP задаются у сервиса. Дополнительные FQDN — ниже, зона
привязок; зона определяется из FQDN автоматически. определяется автоматически.
</SheetDescription> </SheetDescription>
</SheetHeader> </SheetHeader>
<div className="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-4 py-4"> <div className="flex min-h-0 flex-1 flex-col gap-6 overflow-y-auto px-4 py-4">
<CountedLineTabs <section className="flex flex-col gap-3">
tabs={[ <h3 className="text-sm font-medium">Сервис</h3>
{ id: 'general', label: 'Основное' }, <FieldGroup className="flex flex-col gap-3">
{ <div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
id: 'bindings',
label: 'Привязки',
count: bindings.length > 0 ? bindings.length : undefined,
},
]}
value={activeTab}
onValueChange={setActiveTab}
className="flex w-full flex-col gap-4"
listClassName="mb-0 w-full"
>
<TabsContent value="general" className="flex flex-col gap-4">
<FieldGroup className="flex flex-col gap-4">
<Field> <Field>
<FieldLabel htmlFor="edit-service-name">Название</FieldLabel> <FieldLabel htmlFor="edit-service-name">Название</FieldLabel>
<Input <Input
@@ -490,211 +417,173 @@ export function ServiceEditSheet({
onChange={(e) => setSlug(e.target.value)} onChange={(e) => setSlug(e.target.value)}
/> />
</Field> </Field>
<Field>
<FieldLabel htmlFor="edit-service-group">Группа сервисов</FieldLabel>
<Select
items={groupItems}
value={serviceGroupId}
onValueChange={(value) => setServiceGroupId(value ?? 'none')}
>
<SelectTrigger id="edit-service-group" className="w-full">
<SelectValue placeholder="Без группы" />
</SelectTrigger>
<SelectContent>
{groupItems.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
<Field>
<FieldLabel htmlFor="edit-service-common-domain">
Общий домен (FQDN)
</FieldLabel>
<Input
id="edit-service-common-domain"
className="font-mono"
value={commonFqdn}
placeholder={
zoneHints[0] ? `gw.${zoneHints[0]}` : 'gw.ivx.su'
}
onChange={(e) => handleCommonFqdnChange(e.target.value)}
/>
</Field>
<Field>
<FieldLabel htmlFor="edit-service-ips">IP-адреса сервиса</FieldLabel>
<TaggedInput
id="edit-service-ips"
value={ips}
onChange={setIps}
placeholder="192.168.1.1"
validate={isValidIpv4}
/>
</Field>
</FieldGroup>
</TabsContent>
<TabsContent value="bindings" className="flex flex-col gap-4">
<div className="flex items-center justify-between gap-2">
<p className="text-sm text-muted-foreground">
Несколько FQDN в разных зонах → IP или CNAME для DNS Cloudflare
</p>
<Button type="button" variant="outline" size="sm" onClick={handleAddBinding}>
<PlusIcon data-icon="inline-start" />
Добавить
</Button>
</div> </div>
<Field>
{bindings.length === 0 ? ( <FieldLabel htmlFor="edit-service-group">Группа сервисов</FieldLabel>
<EmptyState <Select
icon={Link2Icon} items={groupItems}
title="Нет привязок" value={serviceGroupId}
description="Необязательно. Можно добавить несколько FQDN: api.ivx.su и www.other.su — зоны определятся автоматически." onValueChange={(value) => setServiceGroupId(value ?? 'none')}
centered={false} >
action={ <SelectTrigger id="edit-service-group" className="w-full">
<Button type="button" variant="outline" size="sm" onClick={handleAddBinding}> <SelectValue placeholder="Без группы" />
<PlusIcon data-icon="inline-start" /> </SelectTrigger>
Добавить привязку <SelectContent>
</Button> {groupItems.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
<Field>
<FieldLabel htmlFor="edit-service-common-domain">
Общий домен (FQDN)
</FieldLabel>
<Input
id="edit-service-common-domain"
className="font-mono"
value={commonFqdn}
placeholder={
zoneHints[0] ? `gw.${zoneHints[0]}` : 'gw.ivx.su'
} }
onChange={(e) => handleCommonFqdnChange(e.target.value)}
/> />
) : ( </Field>
<ItemGroup className="gap-2"> <Field>
{bindings.map((binding, index) => { <FieldLabel htmlFor="edit-service-ips">IP-адреса сервиса</FieldLabel>
const showLbBlock = <TaggedInput
(binding.record_type === 'A' && binding.target_ips.length > 0) || id="edit-service-ips"
(binding.record_type === 'CNAME' && binding.target_cname.trim().length > 0) value={ips}
const showMeta = onChange={setIps}
binding.record_type === 'A' && placeholder="192.168.1.1"
binding.target_ips.length > 1 && validate={isValidIpv4}
binding.lb_mode !== 'round_robin' />
const parsedZone = parseFqdn(binding.fqdn, zoneHints) </Field>
return ( </FieldGroup>
<Item key={`binding-${index}`} variant="outline" className="items-stretch"> </section>
<ItemContent className="w-full flex flex-col gap-3">
<div className="flex items-center justify-between gap-2">
<div className="flex min-w-0 items-center gap-2">
<span className="text-sm font-medium">
Привязка {index + 1}
</span>
{parsedZone ? (
<Badge variant="outline" size="xs" className="font-mono">
{parsedZone.zoneName}
</Badge>
) : binding.fqdn.trim() ? (
<Badge variant="warning-light" size="xs">
зона не найдена
</Badge>
) : null}
</div>
<Button
type="button"
variant="ghost"
size="icon-sm"
className="shrink-0"
aria-label="Удалить привязку"
onClick={() => handleRemoveBinding(index)}
>
<Trash2Icon />
</Button>
</div>
<Field className="min-w-0">
<FieldLabel htmlFor={`binding-fqdn-${index}`}>FQDN</FieldLabel>
<Input
id={`binding-fqdn-${index}`}
className="font-mono"
value={binding.fqdn}
onChange={(event) =>
handleFqdnChange(index, event.target.value)
}
placeholder={
zoneHints[0] ? `newdom.${zoneHints[0]}` : 'newdom.ivx.su'
}
/>
</Field>
<Field>
<FieldLabel htmlFor={`binding-type-${index}`}>Тип записи</FieldLabel>
<Select
items={[
{ label: 'A (IP)', value: 'A' },
{ label: 'CNAME', value: 'CNAME' },
]}
value={binding.record_type}
onValueChange={(value) =>
handleRecordTypeChange(index, (value ?? 'A') as 'A' | 'CNAME')
}
>
<SelectTrigger id={`binding-type-${index}`} className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="A">A (IP)</SelectItem>
<SelectItem value="CNAME">CNAME</SelectItem>
</SelectContent>
</Select>
</Field>
{binding.record_type === 'CNAME' ? (
<Field>
<FieldLabel htmlFor={`binding-cname-${index}`}>
CNAME-цель
</FieldLabel>
<Input
id={`binding-cname-${index}`}
value={binding.target_cname}
placeholder="mmsk.rkns.top"
onChange={(event) => handleCnameChange(index, event.target.value)}
/>
</Field>
) : (
<Field>
<FieldLabel htmlFor={`binding-ip-${index}`}>IP</FieldLabel>
<ServiceBindingIpInput
id={`binding-ip-${index}`}
value={binding.target_ips}
pool={ips}
onChange={(targetIps) => handleIpsChange(index, targetIps)}
showMeta={showLbBlock && showMeta}
weights={binding.target_ip_weights}
priorities={binding.target_ip_priorities}
onMetaChange={(ip, meta) =>
handleBindingMetaChange(index, ip, meta)
}
/>
</Field>
)}
{showLbBlock ? ( <section className="flex flex-col gap-3">
<HealthCheckConfigFields <div className="flex items-center justify-between gap-2">
value={{ <h3 className="text-sm font-medium">Доп. FQDN</h3>
lb_mode: binding.lb_mode, <Button
enabled: binding.health.enabled, type="button"
type: binding.health.type, variant="outline"
port: binding.health.port, size="sm"
path: binding.health.path, onClick={handleAddExtraBinding}
expected_status: binding.health.expected_status, >
interval_sec: binding.health.interval_sec, <PlusIcon data-icon="inline-start" />
timeout_ms: binding.health.timeout_ms, Добавить
verify_tls: binding.health.verify_tls, </Button>
provider: binding.health.provider ?? 'local', </div>
}} {extraBindings.length === 0 ? (
onChange={(next) => handleBindingHealthChange(index, next)} <p className="text-muted-foreground text-sm">
lbModeLabel="Режим балансировки" Нет дополнительных FQDN
showLbMode={ </p>
binding.record_type === 'A' && binding.target_ips.length > 1 ) : (
} <ItemGroup className="gap-2">
idPrefix={`binding-${index}-health`} {extraBindings.map((binding, extraIndex) => {
/> const index = extraIndex + 1
) : null} const parsedZone = parseFqdn(binding.fqdn, zoneHints)
</ItemContent> return (
</Item> <Item
) key={`extra-binding-${index}`}
})} variant="outline"
</ItemGroup> size="sm"
)} className="items-stretch"
</TabsContent> >
</CountedLineTabs> <ItemContent className="flex w-full min-w-0 flex-col gap-2">
<div className="flex items-center gap-2">
{parsedZone ? (
<Badge variant="outline" size="xs" className="font-mono">
{parsedZone.zoneName}
</Badge>
) : binding.fqdn.trim() ? (
<Badge variant="warning-light" size="xs">
зона не найдена
</Badge>
) : (
<span className="text-muted-foreground text-xs">
FQDN
</span>
)}
<Button
type="button"
variant="ghost"
size="icon-sm"
className="ml-auto shrink-0"
aria-label="Удалить FQDN"
onClick={() => handleRemoveExtraBinding(extraIndex)}
>
<Trash2Icon />
</Button>
</div>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-[minmax(0,1fr)_7.5rem]">
<Input
id={`extra-fqdn-${index}`}
className="font-mono"
value={binding.fqdn}
onChange={(event) =>
handleFqdnChange(index, event.target.value)
}
placeholder={
zoneHints[0]
? `api.${zoneHints[0]}`
: 'api.ivx.su'
}
/>
<Select
items={[
{ label: 'A (IP)', value: 'A' },
{ label: 'CNAME', value: 'CNAME' },
]}
value={binding.record_type}
onValueChange={(value) =>
handleRecordTypeChange(
index,
(value ?? 'A') as 'A' | 'CNAME',
)
}
>
<SelectTrigger
id={`extra-type-${index}`}
className="w-full"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="A">A (IP)</SelectItem>
<SelectItem value="CNAME">CNAME</SelectItem>
</SelectContent>
</Select>
</div>
{binding.record_type === 'CNAME' ? (
<Input
id={`extra-cname-${index}`}
value={binding.target_cname}
placeholder="mmsk.rkns.top"
onChange={(event) =>
handleCnameChange(index, event.target.value)
}
/>
) : (
<ServiceBindingIpInput
id={`extra-ip-${index}`}
value={binding.target_ips}
pool={ips}
onChange={(targetIps) =>
handleIpsChange(index, targetIps)
}
/>
)}
</ItemContent>
</Item>
)
})}
</ItemGroup>
)}
</section>
</div> </div>
<SheetFooter className="shrink-0 flex flex-row flex-wrap gap-2 border-t pt-4"> <SheetFooter className="shrink-0 flex flex-row flex-wrap gap-2 border-t pt-4">
@@ -50,7 +50,10 @@ export function ServiceCatalogSection({
const groupId = group?.id ?? null const groupId = group?.id ?? null
return ( return (
<section className="flex w-full flex-col gap-2" aria-labelledby={`group-${groupId ?? 'none'}`}> <section
className="@container flex w-full flex-col gap-2"
aria-labelledby={`group-${groupId ?? 'none'}`}
>
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-2.5"> <div className="flex min-w-0 items-center gap-2.5">
<IconTile <IconTile
@@ -139,7 +142,7 @@ export function ServiceCatalogSection({
</Button> </Button>
</div> </div>
) : ( ) : (
<div className="flex flex-col gap-2"> <div className="grid grid-cols-1 gap-2 @xl:grid-cols-2 @4xl:grid-cols-3">
{services.map((service) => ( {services.map((service) => (
<ServiceUnitCard <ServiceUnitCard
key={service.id} key={service.id}
@@ -1,4 +1,3 @@
import type { ReactNode } from 'react'
import { Link } from '@tanstack/react-router' import { Link } from '@tanstack/react-router'
import { MoreHorizontalIcon, ServerIcon } from 'lucide-react' import { MoreHorizontalIcon, ServerIcon } from 'lucide-react'
@@ -23,7 +22,6 @@ import {
DropdownMenuItem, DropdownMenuItem,
DropdownMenuTrigger, DropdownMenuTrigger,
} from '@cfdm/ui/components/dropdown-menu' } from '@cfdm/ui/components/dropdown-menu'
import { Separator } from '@cfdm/ui/components/separator'
import { Switch } from '@cfdm/ui/components/switch' import { Switch } from '@cfdm/ui/components/switch'
interface ServiceUnitCardProps { interface ServiceUnitCardProps {
@@ -42,18 +40,19 @@ export function ServiceUnitCard({
onToggleService, onToggleService,
}: ServiceUnitCardProps) { }: ServiceUnitCardProps) {
return ( return (
<Frame dense spacing="sm" className="w-full"> <Frame dense spacing="sm" className="h-full min-w-0">
<FrameHeader className="flex-row items-start justify-between gap-3"> <FrameHeader className="flex-row items-start justify-between gap-2">
<div className="flex min-w-0 items-start gap-3"> <div className="flex min-w-0 items-start gap-2">
<IconTile <IconTile
variant="elevated" variant="elevated"
className="size-10.5 text-muted-foreground" size="sm"
className="text-muted-foreground"
aria-hidden="true" aria-hidden="true"
> >
<ServerIcon /> <ServerIcon />
</IconTile> </IconTile>
<div className="flex min-w-0 flex-col gap-px"> <div className="flex min-w-0 flex-col gap-px">
<FrameTitle className="min-w-0 truncate"> <FrameTitle className="min-w-0 truncate text-sm">
<Link <Link
to="/services/$serviceId" to="/services/$serviceId"
params={{ serviceId: String(service.id) }} params={{ serviceId: String(service.id) }}
@@ -62,12 +61,12 @@ export function ServiceUnitCard({
{service.name} {service.name}
</Link> </Link>
</FrameTitle> </FrameTitle>
<FrameDescription className="truncate font-mono"> <FrameDescription className="truncate font-mono text-xs">
{service.slug} {service.slug}
</FrameDescription> </FrameDescription>
</div> </div>
</div> </div>
<div className="flex shrink-0 items-center gap-2"> <div className="flex shrink-0 items-center gap-1">
<HealthCheckBadge <HealthCheckBadge
status={service.health_status ?? 'unknown'} status={service.health_status ?? 'unknown'}
latencyMs={service.health_latency_ms} latencyMs={service.health_latency_ms}
@@ -122,41 +121,14 @@ export function ServiceUnitCard({
</div> </div>
</FrameHeader> </FrameHeader>
<FramePanel className="p-0 shadow-none!"> <FramePanel className="flex flex-col gap-1 pt-0 shadow-none!">
<Separator /> <ServiceFqdnList
<ServiceLabeledRow label="Общий домен"> copyable
<ServiceFqdnList service={service}
copyable emptyLabel="Не задан"
service={service} />
emptyLabel="Не задан" <ServiceIpList copyable ips={service.ips ?? []} />
textClassName="text-foreground text-sm"
/>
</ServiceLabeledRow>
<Separator />
<ServiceLabeledRow label="IP">
<ServiceIpList
copyable
ips={service.ips ?? []}
emptyLabel="Нет IP"
textClassName="text-foreground text-sm"
/>
</ServiceLabeledRow>
</FramePanel> </FramePanel>
</Frame> </Frame>
) )
} }
function ServiceLabeledRow({
label,
children,
}: {
label: string
children: ReactNode
}) {
return (
<div className="flex min-w-0 flex-col gap-1 px-(--frame-panel-header-px) py-(--frame-panel-header-py) sm:flex-row sm:items-center sm:justify-between sm:gap-3">
<span className="text-muted-foreground shrink-0 text-xs">{label}</span>
<div className="min-w-0 sm:flex sm:justify-end">{children}</div>
</div>
)
}