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
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:
@@ -1,15 +1,11 @@
|
||||
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 { CountedLineTabs } from '@/components/counted-line-tabs'
|
||||
import { EmptyState } from '@/components/empty-state'
|
||||
import { TaggedInput, isValidIpv4 } from '@/components/tagged-input'
|
||||
import { ServiceBindingIpInput } from '@/components/service-binding-ip-input'
|
||||
import {
|
||||
HealthCheckConfigFields,
|
||||
type LbAndHealthConfig,
|
||||
type LbMode,
|
||||
type HealthCheckType,
|
||||
import type {
|
||||
LbMode,
|
||||
HealthCheckType,
|
||||
} from '@/components/health-check-config-fields'
|
||||
import type {
|
||||
CreateServiceWithConfigInput,
|
||||
@@ -38,7 +34,6 @@ import {
|
||||
ItemGroup,
|
||||
} from '@cfdm/ui/components/item'
|
||||
import { LoadingButton } from '@/components/loading-button'
|
||||
import { TabsContent } from '@cfdm/ui/components/tabs'
|
||||
import {
|
||||
Select,
|
||||
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({
|
||||
mode,
|
||||
service,
|
||||
@@ -182,7 +204,6 @@ export function ServiceEditSheet({
|
||||
const [bindings, setBindings] = useState<ServiceBindingDraft[]>([])
|
||||
const [lbWeight, setLbWeight] = useState(1)
|
||||
const [lbPriority, setLbPriority] = useState(1)
|
||||
const [activeTab, setActiveTab] = useState('general')
|
||||
|
||||
const groupItems = useMemo(
|
||||
() => [
|
||||
@@ -194,7 +215,6 @@ export function ServiceEditSheet({
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
setActiveTab('general')
|
||||
if (mode === 'edit' && service) {
|
||||
setName(service.name)
|
||||
setSlug(service.slug)
|
||||
@@ -228,35 +248,7 @@ export function ServiceEditSheet({
|
||||
[knownDomains],
|
||||
)
|
||||
|
||||
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 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
|
||||
})
|
||||
}
|
||||
const extraBindings = bindings.slice(1)
|
||||
|
||||
function handleCommonFqdnChange(value: string) {
|
||||
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) {
|
||||
if (index === 0) setCommonFqdn(fqdn)
|
||||
setBindings((current) =>
|
||||
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 {
|
||||
return serviceGroupId === 'none' ? null : Number(serviceGroupId)
|
||||
}
|
||||
@@ -362,36 +327,11 @@ export function ServiceEditSheet({
|
||||
const trimmed = commonFqdn.trim()
|
||||
if (!trimmed) return current
|
||||
if (current.length === 0) {
|
||||
const draft = emptyBindingDraft(trimmed)
|
||||
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 [withPoolIps(emptyBindingDraft(trimmed), ips)]
|
||||
}
|
||||
return current.map((item, index) => {
|
||||
if (index !== 0) return item
|
||||
const next = { ...item, fqdn: trimmed }
|
||||
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
|
||||
return withPoolIps({ ...item, fqdn: trimmed }, ips)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -403,7 +343,6 @@ export function ServiceEditSheet({
|
||||
new Set(normalizedFqdns).size !== normalizedFqdns.length
|
||||
if (hasDuplicateFqdn) {
|
||||
toast.error('Укажите уникальные FQDN — дубликаты привязок недопустимы')
|
||||
setActiveTab('bindings')
|
||||
return
|
||||
}
|
||||
const groupId = resolveServiceGroupId()
|
||||
@@ -450,28 +389,16 @@ export function ServiceEditSheet({
|
||||
<SheetHeader className="shrink-0 border-b pb-4">
|
||||
<SheetTitle>{isCreate ? 'Новый сервис' : 'Редактирование сервиса'}</SheetTitle>
|
||||
<SheetDescription>
|
||||
Общий домен и IP задаются у сервиса. Дополнительные FQDN — на вкладке
|
||||
привязок; зона определяется из FQDN автоматически.
|
||||
Общий домен и IP задаются у сервиса. Дополнительные FQDN — ниже, зона
|
||||
определяется автоматически.
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-4 py-4">
|
||||
<CountedLineTabs
|
||||
tabs={[
|
||||
{ id: 'general', label: 'Основное' },
|
||||
{
|
||||
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">
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-6 overflow-y-auto px-4 py-4">
|
||||
<section className="flex flex-col gap-3">
|
||||
<h3 className="text-sm font-medium">Сервис</h3>
|
||||
<FieldGroup className="flex flex-col gap-3">
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<Field>
|
||||
<FieldLabel htmlFor="edit-service-name">Название</FieldLabel>
|
||||
<Input
|
||||
@@ -490,211 +417,173 @@ export function ServiceEditSheet({
|
||||
onChange={(e) => setSlug(e.target.value)}
|
||||
/>
|
||||
</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>
|
||||
|
||||
{bindings.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={Link2Icon}
|
||||
title="Нет привязок"
|
||||
description="Необязательно. Можно добавить несколько FQDN: api.ivx.su и www.other.su — зоны определятся автоматически."
|
||||
centered={false}
|
||||
action={
|
||||
<Button type="button" variant="outline" size="sm" onClick={handleAddBinding}>
|
||||
<PlusIcon data-icon="inline-start" />
|
||||
Добавить привязку
|
||||
</Button>
|
||||
<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)}
|
||||
/>
|
||||
) : (
|
||||
<ItemGroup className="gap-2">
|
||||
{bindings.map((binding, index) => {
|
||||
const showLbBlock =
|
||||
(binding.record_type === 'A' && binding.target_ips.length > 0) ||
|
||||
(binding.record_type === 'CNAME' && binding.target_cname.trim().length > 0)
|
||||
const showMeta =
|
||||
binding.record_type === 'A' &&
|
||||
binding.target_ips.length > 1 &&
|
||||
binding.lb_mode !== 'round_robin'
|
||||
const parsedZone = parseFqdn(binding.fqdn, zoneHints)
|
||||
return (
|
||||
<Item key={`binding-${index}`} variant="outline" className="items-stretch">
|
||||
<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>
|
||||
)}
|
||||
</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>
|
||||
</section>
|
||||
|
||||
{showLbBlock ? (
|
||||
<HealthCheckConfigFields
|
||||
value={{
|
||||
lb_mode: binding.lb_mode,
|
||||
enabled: binding.health.enabled,
|
||||
type: binding.health.type,
|
||||
port: binding.health.port,
|
||||
path: binding.health.path,
|
||||
expected_status: binding.health.expected_status,
|
||||
interval_sec: binding.health.interval_sec,
|
||||
timeout_ms: binding.health.timeout_ms,
|
||||
verify_tls: binding.health.verify_tls,
|
||||
provider: binding.health.provider ?? 'local',
|
||||
}}
|
||||
onChange={(next) => handleBindingHealthChange(index, next)}
|
||||
lbModeLabel="Режим балансировки"
|
||||
showLbMode={
|
||||
binding.record_type === 'A' && binding.target_ips.length > 1
|
||||
}
|
||||
idPrefix={`binding-${index}-health`}
|
||||
/>
|
||||
) : null}
|
||||
</ItemContent>
|
||||
</Item>
|
||||
)
|
||||
})}
|
||||
</ItemGroup>
|
||||
)}
|
||||
</TabsContent>
|
||||
</CountedLineTabs>
|
||||
<section className="flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h3 className="text-sm font-medium">Доп. FQDN</h3>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleAddExtraBinding}
|
||||
>
|
||||
<PlusIcon data-icon="inline-start" />
|
||||
Добавить
|
||||
</Button>
|
||||
</div>
|
||||
{extraBindings.length === 0 ? (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Нет дополнительных FQDN
|
||||
</p>
|
||||
) : (
|
||||
<ItemGroup className="gap-2">
|
||||
{extraBindings.map((binding, extraIndex) => {
|
||||
const index = extraIndex + 1
|
||||
const parsedZone = parseFqdn(binding.fqdn, zoneHints)
|
||||
return (
|
||||
<Item
|
||||
key={`extra-binding-${index}`}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="items-stretch"
|
||||
>
|
||||
<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>
|
||||
|
||||
<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
|
||||
|
||||
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 min-w-0 items-center gap-2.5">
|
||||
<IconTile
|
||||
@@ -139,7 +142,7 @@ export function ServiceCatalogSection({
|
||||
</Button>
|
||||
</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) => (
|
||||
<ServiceUnitCard
|
||||
key={service.id}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { MoreHorizontalIcon, ServerIcon } from 'lucide-react'
|
||||
|
||||
@@ -23,7 +22,6 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@cfdm/ui/components/dropdown-menu'
|
||||
import { Separator } from '@cfdm/ui/components/separator'
|
||||
import { Switch } from '@cfdm/ui/components/switch'
|
||||
|
||||
interface ServiceUnitCardProps {
|
||||
@@ -42,18 +40,19 @@ export function ServiceUnitCard({
|
||||
onToggleService,
|
||||
}: ServiceUnitCardProps) {
|
||||
return (
|
||||
<Frame dense spacing="sm" className="w-full">
|
||||
<FrameHeader className="flex-row items-start justify-between gap-3">
|
||||
<div className="flex min-w-0 items-start gap-3">
|
||||
<Frame dense spacing="sm" className="h-full min-w-0">
|
||||
<FrameHeader className="flex-row items-start justify-between gap-2">
|
||||
<div className="flex min-w-0 items-start gap-2">
|
||||
<IconTile
|
||||
variant="elevated"
|
||||
className="size-10.5 text-muted-foreground"
|
||||
size="sm"
|
||||
className="text-muted-foreground"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<ServerIcon />
|
||||
</IconTile>
|
||||
<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
|
||||
to="/services/$serviceId"
|
||||
params={{ serviceId: String(service.id) }}
|
||||
@@ -62,12 +61,12 @@ export function ServiceUnitCard({
|
||||
{service.name}
|
||||
</Link>
|
||||
</FrameTitle>
|
||||
<FrameDescription className="truncate font-mono">
|
||||
<FrameDescription className="truncate font-mono text-xs">
|
||||
{service.slug}
|
||||
</FrameDescription>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<HealthCheckBadge
|
||||
status={service.health_status ?? 'unknown'}
|
||||
latencyMs={service.health_latency_ms}
|
||||
@@ -122,41 +121,14 @@ export function ServiceUnitCard({
|
||||
</div>
|
||||
</FrameHeader>
|
||||
|
||||
<FramePanel className="p-0 shadow-none!">
|
||||
<Separator />
|
||||
<ServiceLabeledRow label="Общий домен">
|
||||
<ServiceFqdnList
|
||||
copyable
|
||||
service={service}
|
||||
emptyLabel="Не задан"
|
||||
textClassName="text-foreground text-sm"
|
||||
/>
|
||||
</ServiceLabeledRow>
|
||||
<Separator />
|
||||
<ServiceLabeledRow label="IP">
|
||||
<ServiceIpList
|
||||
copyable
|
||||
ips={service.ips ?? []}
|
||||
emptyLabel="Нет IP"
|
||||
textClassName="text-foreground text-sm"
|
||||
/>
|
||||
</ServiceLabeledRow>
|
||||
<FramePanel className="flex flex-col gap-1 pt-0 shadow-none!">
|
||||
<ServiceFqdnList
|
||||
copyable
|
||||
service={service}
|
||||
emptyLabel="Не задан"
|
||||
/>
|
||||
<ServiceIpList copyable ips={service.ips ?? []} />
|
||||
</FramePanel>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user