Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4ca948292d |
@@ -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