Compare commits

...
3 Commits
Author SHA1 Message Date
Denozordec 4ca948292d 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.
2026-08-19 14:27:02 +07:00
Denozordec 50c5c21c18 feat(services): enhance service edit and display components with common domain support
quality / commitlint (push) Skipped
CD / update-wiki (push) Successful in 6s
quality / changes (push) Successful in 5s
quality / api (push) Skipped
quality / docker-check (push) Skipped
quality / web (push) Successful in 52s
CD / quality (push) Successful in 1m1s
CD / publish (push) Successful in 1m36s
- Added a common FQDN input field in the ServiceEditSheet to streamline domain management for services.
- Updated binding handling to synchronize the common domain across service bindings.
- Improved the ServiceKanbanCard to display both common domain and IP addresses for better visibility.
- Refactored the ServiceFqdnList to support customizable text classes and added a new ServiceIpList component for IP display.

This commit enhances the user experience by simplifying domain management and improving the display of service information.
2026-08-19 13:29:15 +07:00
Denozordec ab4ccbd7a1 feat(services): add copyable FQDN functionality and improve service display
quality / commitlint (push) Skipped
CD / update-wiki (push) Successful in 6s
quality / changes (push) Successful in 5s
quality / api (push) Skipped
quality / docker-check (push) Skipped
quality / web (push) Successful in 52s
CD / quality (push) Successful in 59s
CD / publish (push) Successful in 1m41s
- Enhanced the ServiceFqdnList component to support a copyable feature, allowing users to easily copy FQDNs to the clipboard.
- Updated the ServiceKanbanCard to utilize the new copyable functionality in the FQDN list.
- Refined the services grouped catalog logic to improve data handling and display.
- Removed the obsolete services-grouped-columns file to streamline the codebase.

This commit improves user experience by facilitating easier access to FQDNs and optimizing service management components.
2026-08-19 13:03:52 +07:00
9 changed files with 813 additions and 910 deletions
@@ -3,7 +3,7 @@ import { MoreHorizontalIcon, PencilIcon, Trash2Icon } from 'lucide-react'
import { Badge } from '@/components/reui/badge'
import { HealthCheckBadge } from '@/components/health-check-badge'
import { StatusBadge } from '@/components/status-badge'
import { ServiceFqdnList } from '@/components/services/service-fqdn-list'
import { ServiceFqdnList, ServiceIpList } from '@/components/services/service-fqdn-list'
import type { ServiceView } from '@/lib/schemas'
import { Button } from '@cfdm/ui/components/button'
import {
@@ -78,7 +78,18 @@ export function ServiceKanbanCard({
</ItemHeader>
<ItemContent className="min-w-0 gap-2">
<ServiceFqdnList service={service} />
<div className="flex min-w-0 flex-col gap-0.5">
<span className="text-muted-foreground text-xs">Общий домен</span>
<ServiceFqdnList
copyable
service={service}
emptyLabel="Не задан"
/>
</div>
<div className="flex min-w-0 flex-col gap-0.5">
<span className="text-muted-foreground text-xs">IP</span>
<ServiceIpList copyable ips={service.ips ?? []} />
</div>
</ItemContent>
<ItemFooter className="min-w-0 justify-between gap-2">
+244 -332
View File
@@ -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,
@@ -46,7 +41,6 @@ import {
SelectTrigger,
SelectValue,
} from '@cfdm/ui/components/select'
import { Separator } from '@cfdm/ui/components/separator'
interface BindingHealthConfig {
enabled: boolean
@@ -161,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,
@@ -179,10 +200,10 @@ export function ServiceEditSheet({
const [slug, setSlug] = useState('')
const [serviceGroupId, setServiceGroupId] = useState('none')
const [ips, setIps] = useState<string[]>([])
const [commonFqdn, setCommonFqdn] = useState('')
const [bindings, setBindings] = useState<ServiceBindingDraft[]>([])
const [lbWeight, setLbWeight] = useState(1)
const [lbPriority, setLbPriority] = useState(1)
const [activeTab, setActiveTab] = useState('general')
const groupItems = useMemo(
() => [
@@ -192,16 +213,8 @@ export function ServiceEditSheet({
[groups],
)
const selectedGroup = useMemo(() => {
if (serviceGroupId === 'none') return null
return groups.find((g) => String(g.id) === serviceGroupId) ?? null
}, [groups, serviceGroupId])
const groupHasDomain = Boolean(selectedGroup?.domain?.trim())
useEffect(() => {
if (!open) return
setActiveTab('general')
if (mode === 'edit' && service) {
setName(service.name)
setSlug(service.slug)
@@ -209,7 +222,9 @@ export function ServiceEditSheet({
service.service_group_id != null ? String(service.service_group_id) : 'none',
)
setIps(service.ips ?? [])
setBindings(toBindingDrafts(service))
const drafts = toBindingDrafts(service)
setBindings(drafts)
setCommonFqdn(drafts[0]?.fqdn ?? '')
setLbWeight(service.lb_weight ?? 1)
setLbPriority(service.lb_priority ?? 1)
return
@@ -221,6 +236,7 @@ export function ServiceEditSheet({
defaultGroupId != null ? String(defaultGroupId) : 'none',
)
setIps([])
setCommonFqdn('')
setBindings([])
setLbWeight(1)
setLbPriority(1)
@@ -232,23 +248,28 @@ export function ServiceEditSheet({
[knownDomains],
)
function handleAddBinding() {
setBindings((current) => [
...current,
{
fqdn: '',
record_type: 'A',
target_ips: [],
target_cname: '',
lb_mode: 'round_robin',
health: { ...defaultHealth },
target_ip_weights: {},
target_ip_priorities: {},
},
])
const extraBindings = bindings.slice(1)
function handleCommonFqdnChange(value: string) {
setCommonFqdn(value)
setBindings((current) => {
if (current.length === 0) return current
return current.map((item, i) => (i === 0 ? { ...item, fqdn: value } : item))
})
}
function handleRemoveBinding(index: number) {
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))
}
@@ -298,69 +319,38 @@ 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)
}
function syncCommonDomain(current: ServiceBindingDraft[]): ServiceBindingDraft[] {
const trimmed = commonFqdn.trim()
if (!trimmed) return current
if (current.length === 0) {
return [withPoolIps(emptyBindingDraft(trimmed), ips)]
}
return current.map((item, index) => {
if (index !== 0) return item
return withPoolIps({ ...item, fqdn: trimmed }, ips)
})
}
function handleSubmit() {
const domains = buildDomainsPayload(bindings)
const syncedBindings = syncCommonDomain(bindings)
const domains = buildDomainsPayload(syncedBindings)
const normalizedFqdns = domains.map((d) => d.fqdn.trim().toLowerCase())
const hasDuplicateFqdn =
new Set(normalizedFqdns).size !== normalizedFqdns.length
if (hasDuplicateFqdn) {
toast.error('Укажите уникальные FQDN — дубликаты привязок недопустимы')
setActiveTab('bindings')
return
}
const groupId = resolveServiceGroupId()
const lbFields = groupHasDomain
? { lb_weight: lbWeight, lb_priority: lbPriority }
: {}
const configPayload = {
ips,
domains,
...lbFields,
lb_weight: lbWeight,
lb_priority: lbPriority,
}
if (mode === 'create') {
onCreate?.({
@@ -368,7 +358,8 @@ export function ServiceEditSheet({
slug: slug.trim(),
service_group_id: groupId,
ips,
...lbFields,
lb_weight: lbWeight,
lb_priority: lbPriority,
domains,
})
return
@@ -398,29 +389,16 @@ export function ServiceEditSheet({
<SheetHeader className="shrink-0 border-b pb-4">
<SheetTitle>{isCreate ? 'Новый сервис' : 'Редактирование сервиса'}</SheetTitle>
<SheetDescription>
Настройте параметры сервиса и привязки FQDN IP или CNAME. Один
сервис может иметь несколько 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
@@ -439,239 +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-ips">IP-адреса сервиса</FieldLabel>
<TaggedInput
id="edit-service-ips"
value={ips}
onChange={setIps}
placeholder="192.168.1.1"
validate={isValidIpv4}
/>
</Field>
</FieldGroup>
{groupHasDomain && (
<>
<Separator />
<div className="flex flex-col gap-2">
<p className="text-sm text-muted-foreground">
Балансировка внутри группы «{selectedGroup?.name}»: вес и приоритет
сервиса для общего домена группы.
</p>
<div className="grid grid-cols-2 gap-4">
<Field>
<FieldLabel htmlFor="service-lb-weight">Вес</FieldLabel>
<Input
id="service-lb-weight"
type="number"
inputMode="numeric"
min={1}
max={100}
value={lbWeight}
onChange={(e) =>
setLbWeight(Math.max(1, Number(e.target.value) || 1))
}
/>
</Field>
<Field>
<FieldLabel htmlFor="service-lb-priority">Приоритет</FieldLabel>
<Input
id="service-lb-priority"
type="number"
inputMode="numeric"
min={1}
max={100}
value={lbPriority}
onChange={(e) =>
setLbPriority(Math.max(1, Number(e.target.value) || 1))
}
/>
</Field>
</div>
</div>
</>
)}
</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">
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react'
import { useEffect } from 'react'
import { useForm, Controller } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import {
@@ -12,10 +12,6 @@ import { FormFieldSimple } from '@/components/form-field'
import { LoadingButton } from '@/components/loading-button'
import { AppFieldGroup } from '@/components/app-field'
import { AppInput } from '@/components/app-input'
import {
HealthCheckConfigFields,
type LbAndHealthConfig,
} from '@/components/health-check-config-fields'
import {
Select,
SelectContent,
@@ -44,19 +40,6 @@ interface ServiceGroupEditSheetProps {
onSave?: (id: number, body: CreateServiceGroupInput) => void
}
const defaultLbHealth: LbAndHealthConfig = {
lb_mode: 'round_robin',
enabled: false,
type: 'tcp',
port: null,
path: null,
expected_status: null,
interval_sec: 30,
timeout_ms: 3000,
verify_tls: false,
provider: 'local',
}
export function ServiceGroupEditSheet({
mode,
group,
@@ -74,7 +57,6 @@ export function ServiceGroupEditSheet({
domain: null,
},
})
const [lbHealth, setLbHealth] = useState<LbAndHealthConfig>(defaultLbHealth)
useEffect(() => {
if (!open) return
@@ -82,44 +64,19 @@ export function ServiceGroupEditSheet({
form.reset({
name: group.name,
type: group.type,
domain: group.domain ?? null,
})
setLbHealth({
lb_mode: group.lb_mode,
enabled: group.health_check_enabled,
type: group.health_check_type === 'http' ? 'http' : 'tcp',
port: group.health_check_port,
path: group.health_check_path,
expected_status: group.health_check_expected_status,
interval_sec: group.health_check_interval_sec,
timeout_ms: group.health_check_timeout_ms,
verify_tls: group.health_check_verify_tls,
provider: 'local',
domain: null,
})
} else {
form.reset({ name: '', type: 'custom', domain: null })
setLbHealth(defaultLbHealth)
}
}, [open, mode, group, form])
const domainValue = form.watch('domain')
const hasDomain = Boolean(domainValue?.trim())
function handleSubmit(values: ServiceGroupFormValues) {
const body: CreateServiceGroupInput = {
name: values.name,
type: values.type ?? 'custom',
icon: values.icon,
domain: values.domain?.trim() || null,
lb_mode: lbHealth.lb_mode,
health_check_enabled: lbHealth.enabled,
health_check_type: lbHealth.type,
health_check_port: lbHealth.port,
health_check_path: lbHealth.path,
health_check_expected_status: lbHealth.expected_status,
health_check_interval_sec: lbHealth.interval_sec,
health_check_timeout_ms: lbHealth.timeout_ms,
health_check_verify_tls: lbHealth.verify_tls,
domain: null,
}
if (mode === 'create') {
onCreate?.(body)
@@ -133,7 +90,7 @@ export function ServiceGroupEditSheet({
open={open}
onOpenChange={onOpenChange}
title={mode === 'create' ? 'Новая группа сервисов' : 'Редактировать группу'}
description="Домен группы необязателен. Если указан FQDN (gr.ivx.su, domain.new.ivx.su), он публикуется в Cloudflare отдельно от привязок сервисов."
description="Группа нужна только для сортировки каталога. Общий домен и IP задаются у каждого сервиса."
form={form}
onSubmit={handleSubmit}
contentClassName="gap-6"
@@ -186,26 +143,7 @@ export function ServiceGroupEditSheet({
)}
/>
</FormFieldSimple>
<FormFieldSimple
label="Домен группы (FQDN, необязательно)"
htmlFor="group-domain"
>
<AppInput
id="group-domain"
placeholder="domain.new.ivx.su"
{...form.register('domain')}
/>
</FormFieldSimple>
</AppFieldGroup>
{hasDomain ? (
<HealthCheckConfigFields
value={lbHealth}
onChange={setLbHealth}
lbModeLabel="Режим балансировки общего домена"
idPrefix="group-lb-health"
/>
) : null}
</FormSheet>
)
}
@@ -0,0 +1,160 @@
import { FolderOpenIcon, MoreHorizontalIcon, PlusIcon } from 'lucide-react'
import { Badge } from '@/components/reui/badge'
import { IconTile } from '@/components/reui/icon-tile'
import { ServiceGroupIcon } from '@/components/service-group-icon'
import { ServiceUnitCard } from '@/components/services/service-unit-card'
import type { ServiceGroup, ServiceGroupView, ServiceView } from '@/lib/schemas'
import { Button } from '@cfdm/ui/components/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@cfdm/ui/components/dropdown-menu'
const GROUP_TYPE_LABELS: Record<ServiceGroup['type'], string> = {
vpn: 'VPN',
network: 'Сеть',
internet: 'Интернет',
bgp: 'BGP',
custom: 'Другое',
}
interface ServiceCatalogSectionProps {
group: ServiceGroupView | null
services: ServiceView[]
togglingId: number | null
onEditService: (service: ServiceView) => void
onDeleteService: (service: ServiceView) => void
onToggleService: (serviceId: number, enabled: boolean) => void
onEditGroup: (group: ServiceGroupView) => void
onDeleteGroup: (group: ServiceGroupView) => void
onAddServiceToGroup: (groupId: number | null) => void
}
export function ServiceCatalogSection({
group,
services,
togglingId,
onEditService,
onDeleteService,
onToggleService,
onEditGroup,
onDeleteGroup,
onAddServiceToGroup,
}: ServiceCatalogSectionProps) {
const title = group?.name ?? 'Без группы'
const typeLabel = group ? GROUP_TYPE_LABELS[group.type] : null
const groupId = group?.id ?? null
return (
<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
variant="elevated"
size="sm"
className="text-muted-foreground"
aria-hidden="true"
>
{group ? (
<ServiceGroupIcon type={group.type} />
) : (
<FolderOpenIcon />
)}
</IconTile>
<div className="flex min-w-0 flex-wrap items-center gap-1.5">
<h2
id={`group-${groupId ?? 'none'}`}
className="truncate text-sm font-medium"
>
{title}
</h2>
{typeLabel ? (
<span className="text-muted-foreground text-xs">{typeLabel}</span>
) : null}
<Badge variant="outline" size="xs" className="shrink-0 tabular-nums">
{services.length}
</Badge>
</div>
</div>
<div className="flex shrink-0 items-center gap-1">
<Button
type="button"
size="icon-sm"
variant="ghost"
aria-label={`Добавить сервис в ${title}`}
onClick={() => onAddServiceToGroup(groupId)}
>
<PlusIcon aria-hidden />
</Button>
{group ? (
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label={`Действия группы ${group.name}`}
/>
}
>
<MoreHorizontalIcon aria-hidden />
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => onAddServiceToGroup(group.id)}>
<PlusIcon aria-hidden />
Добавить сервис
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onEditGroup(group)}>
Изменить группу
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
onClick={() => onDeleteGroup(group)}
>
Удалить группу
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : null}
</div>
</div>
{services.length === 0 ? (
<div className="flex flex-wrap items-center justify-between gap-2 py-1">
<span className="text-muted-foreground text-sm">Нет сервисов</span>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => onAddServiceToGroup(groupId)}
>
<PlusIcon data-icon="inline-start" aria-hidden />
Добавить сервис
</Button>
</div>
) : (
<div className="grid grid-cols-1 gap-2 @xl:grid-cols-2 @4xl:grid-cols-3">
{services.map((service) => (
<ServiceUnitCard
key={service.id}
service={service}
togglingId={togglingId}
onEditService={onEditService}
onDeleteService={onDeleteService}
onToggleService={onToggleService}
/>
))}
</div>
)}
</section>
)
}
@@ -1,7 +1,12 @@
import { CheckIcon, CopyIcon } from 'lucide-react'
import { toast } from 'sonner'
import { Badge } from '@/components/reui/badge'
import { TruncatedText } from '@/components/truncated-text'
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 {
Tooltip,
TooltipContent,
@@ -10,16 +15,53 @@ import {
} from '@cfdm/ui/components/tooltip'
import { cn } from '@cfdm/ui/lib/utils'
export function CopyFqdnButton({
value,
className,
}: {
value: string
className?: string
}) {
const { isCopied, copyToClipboard } = useCopyToClipboard({
onCopy: () => toast.success('Скопировано'),
})
return (
<Button
type="button"
variant="ghost"
size="icon-xs"
className={className}
aria-label={isCopied ? 'Скопировано' : `Скопировать ${value}`}
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
copyToClipboard(value)
}}
>
{isCopied ? (
<CheckIcon className="text-success" aria-hidden />
) : (
<CopyIcon aria-hidden />
)}
</Button>
)
}
interface ServiceFqdnListProps {
service: ServiceView
className?: string
emptyLabel?: string
copyable?: boolean
textClassName?: string
}
export function ServiceFqdnList({
service,
className,
emptyLabel = 'FQDN не задан',
emptyLabel = 'Нет FQDN',
copyable = false,
textClassName,
}: ServiceFqdnListProps) {
const fqdns = serviceDisplayFqdns(service)
if (fqdns.length === 0) {
@@ -32,10 +74,16 @@ export function ServiceFqdnList({
const [first, ...rest] = fqdns
const extraCount = rest.length
const copyValue = fqdns.join('\n')
return (
<div className={cn('flex min-w-0 items-center gap-1.5', className)}>
<TruncatedText className="text-muted-foreground min-w-0 font-mono text-xs">
<TruncatedText
className={cn(
'text-muted-foreground min-w-0 font-mono text-xs',
textClassName,
)}
>
{first}
</TruncatedText>
{extraCount > 0 ? (
@@ -62,6 +110,75 @@ export function ServiceFqdnList({
</Tooltip>
</TooltipProvider>
) : null}
{copyable ? <CopyFqdnButton value={copyValue} /> : null}
</div>
)
}
const VISIBLE_IP_LIMIT = 6
interface ServiceIpListProps {
ips: string[]
className?: string
emptyLabel?: string
copyable?: boolean
textClassName?: string
}
export function ServiceIpList({
ips,
className,
emptyLabel = 'Нет IP',
copyable = false,
textClassName,
}: ServiceIpListProps) {
if (ips.length === 0) {
return (
<span className={cn('text-muted-foreground text-xs', className)}>
{emptyLabel}
</span>
)
}
const visible = ips.slice(0, VISIBLE_IP_LIMIT)
const extraCount = ips.length - visible.length
const copyValue = ips.join('\n')
return (
<div className={cn('flex min-w-0 items-center gap-1.5', className)}>
<TruncatedText
className={cn(
'text-muted-foreground min-w-0 font-mono text-xs',
textClassName,
)}
>
{visible.join(' · ')}
</TruncatedText>
{extraCount > 0 ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger
render={
<Badge
variant="outline"
size="xs"
className="shrink-0 tabular-nums"
/>
}
>
ещё {extraCount}
</TooltipTrigger>
<TooltipContent className="max-w-xs">
<ul className="flex flex-col gap-0.5 font-mono text-xs">
{ips.map((ip) => (
<li key={ip}>{ip}</li>
))}
</ul>
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : null}
{copyable ? <CopyFqdnButton value={copyValue} /> : null}
</div>
)
}
@@ -0,0 +1,134 @@
import { Link } from '@tanstack/react-router'
import { MoreHorizontalIcon, ServerIcon } from 'lucide-react'
import { HealthCheckBadge } from '@/components/health-check-badge'
import {
Frame,
FrameDescription,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
import { IconTile } from '@/components/reui/icon-tile'
import {
ServiceFqdnList,
ServiceIpList,
} from '@/components/services/service-fqdn-list'
import type { ServiceView } from '@/lib/schemas'
import { Button } from '@cfdm/ui/components/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@cfdm/ui/components/dropdown-menu'
import { Switch } from '@cfdm/ui/components/switch'
interface ServiceUnitCardProps {
service: ServiceView
togglingId: number | null
onEditService: (service: ServiceView) => void
onDeleteService: (service: ServiceView) => void
onToggleService: (serviceId: number, enabled: boolean) => void
}
export function ServiceUnitCard({
service,
togglingId,
onEditService,
onDeleteService,
onToggleService,
}: ServiceUnitCardProps) {
return (
<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"
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 text-sm">
<Link
to="/services/$serviceId"
params={{ serviceId: String(service.id) }}
className="hover:underline"
>
{service.name}
</Link>
</FrameTitle>
<FrameDescription className="truncate font-mono text-xs">
{service.slug}
</FrameDescription>
</div>
</div>
<div className="flex shrink-0 items-center gap-1">
<HealthCheckBadge
status={service.health_status ?? 'unknown'}
latencyMs={service.health_latency_ms}
size="xs"
/>
<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}`}
/>
}
>
<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>
</div>
</FrameHeader>
<FramePanel className="flex flex-col gap-1 pt-0 shadow-none!">
<ServiceFqdnList
copyable
service={service}
emptyLabel="Не задан"
/>
<ServiceIpList copyable ips={service.ips ?? []} />
</FramePanel>
</Frame>
)
}
@@ -1,10 +1,4 @@
import { useEffect, useMemo, useState, type ReactNode } from 'react'
import {
getCoreRowModel,
getExpandedRowModel,
useReactTable,
type ExpandedState,
} from '@tanstack/react-table'
import { useMemo, useState, type ReactNode } from 'react'
import {
ChevronDownIcon,
FilterIcon,
@@ -14,24 +8,21 @@ import {
ServerIcon,
} from 'lucide-react'
import { DataGrid } from '@/components/reui/data-grid/data-grid'
import { DataGridPagination } from '@/components/reui/data-grid/data-grid-pagination'
import { DataGridScrollArea } from '@/components/reui/data-grid/data-grid-scroll-area'
import { DataGridTable } from '@/components/reui/data-grid/data-grid-table'
import { Filters, type Filter } from '@/components/reui/filters'
import {
Frame,
FrameDescription,
FrameFooter,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
import { CountedLineTabs } from '@/components/counted-line-tabs'
import { EmptyState } from '@/components/empty-state'
import { applyFiltersToData } from '@/components/reui-kit/filter-utils'
import { createServicesGroupedColumns } from '@/components/services/services-grouped-columns'
import type { ServiceCatalogTreeRow } from '@/components/services/services-grouped-columns'
import {
applyFiltersToData,
getActiveFilters,
} from '@/components/reui-kit/filter-utils'
import { ServiceCatalogSection } from '@/components/services/service-catalog-section'
import {
SERVICE_TABS,
createDefaultServiceFilters,
@@ -98,60 +89,51 @@ function catalogTabFilter(row: ServiceCatalogRow, tabId: string) {
return serviceTabFilter(row, tabId)
}
function buildTreeRows(
interface GroupUnitData {
id: string
group: ServiceGroupView | null
services: ServiceView[]
}
function buildGroupUnits(
data: ServiceGroupsResponse,
filteredServiceIds: Set<number>,
domainId?: number,
): ServiceCatalogTreeRow[] {
const rows: ServiceCatalogTreeRow[] = []
domainId: number | undefined,
showEmptyGroups: boolean,
): GroupUnitData[] {
const units: GroupUnitData[] = []
for (const group of data.groups) {
const services = group.services
.filter((s) => serviceMatchesDomain(s, domainId))
.filter((s) => filteredServiceIds.has(s.id))
if (services.length === 0) continue
const matchingDomain = group.services.filter((service) =>
serviceMatchesDomain(service, domainId),
)
const services = matchingDomain.filter((service) =>
filteredServiceIds.has(service.id),
)
rows.push({
kind: 'group',
id: `group-${group.id}`,
group,
subRows: services.map((service) => ({
kind: 'service' as const,
id: `service-${service.id}`,
service,
groupId: group.id,
groupName: group.name,
})),
})
if (services.length === 0) {
if (showEmptyGroups && matchingDomain.length === 0) {
units.push({ id: `group-${group.id}`, group, services: [] })
}
continue
}
units.push({ id: `group-${group.id}`, group, services })
}
const ungrouped = data.ungrouped
.filter((s) => serviceMatchesDomain(s, domainId))
.filter((s) => filteredServiceIds.has(s.id))
.filter((service) => serviceMatchesDomain(service, domainId))
.filter((service) => filteredServiceIds.has(service.id))
if (ungrouped.length > 0) {
rows.push({
kind: 'group',
units.push({
id: 'group-ungrouped',
group: null,
subRows: ungrouped.map((service) => ({
kind: 'service' as const,
id: `service-${service.id}`,
service,
groupId: null,
groupName: null,
})),
services: ungrouped,
})
}
return rows
}
function defaultExpanded(rows: ServiceCatalogTreeRow[]): ExpandedState {
return rows.reduce<Record<string, boolean>>((acc, row) => {
acc[row.id] = true
return acc
}, {})
return units
}
export function ServicesAddMenu({
@@ -229,7 +211,6 @@ export function ServicesGroupedCatalog({
const [filters, setFilters] = useState<Filter[]>(() =>
createDefaultServiceFilters(),
)
const [expanded, setExpanded] = useState<ExpandedState>({})
const filterFields = useServiceFilterFields()
const flatRows = useMemo(() => {
@@ -263,60 +244,25 @@ export function ServicesGroupedCatalog({
return new Set(afterFilters.map((r) => r.id))
}, [flatRows, tab, filters])
const treeData = useMemo(
() => buildTreeRows(data, filteredIds, domainId),
[data, filteredIds, domainId],
const showEmptyGroups =
tab === 'all' && domainId == null && getActiveFilters(filters).length === 0
const units = useMemo(
() => buildGroupUnits(data, filteredIds, domainId, showEmptyGroups),
[data, filteredIds, domainId, showEmptyGroups],
)
const expandedKey = treeData.map((r) => r.id).join(',')
useEffect(() => {
setExpanded(defaultExpanded(treeData))
}, [expandedKey, treeData])
const columns = useMemo(
() =>
createServicesGroupedColumns({
onEditService,
onDeleteService,
onToggleService,
onEditGroup,
onDeleteGroup,
onAddServiceToGroup,
togglingId,
}),
[
onEditService,
onDeleteService,
onToggleService,
onEditGroup,
onDeleteGroup,
onAddServiceToGroup,
togglingId,
],
)
const table = useReactTable({
data: treeData,
columns,
state: { expanded },
onExpandedChange: setExpanded,
getSubRows: (row) => (row.kind === 'group' ? row.subRows : undefined),
getCoreRowModel: getCoreRowModel(),
getExpandedRowModel: getExpandedRowModel(),
getRowId: (row) => row.id,
})
if (isLoading) {
return (
<Frame dense spacing="sm" className="w-full">
<FrameHeader>
<Skeleton className="h-5 w-48" />
<Skeleton className="mt-1 h-4 w-72" />
<Skeleton className="h-4 w-72" />
</FrameHeader>
<FramePanel className="flex flex-col gap-3 p-4">
<Skeleton className="h-9 w-full max-w-md" />
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="h-10 w-full" />
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-36 w-full rounded-xl" />
))}
</FramePanel>
</Frame>
@@ -342,112 +288,106 @@ export function ServicesGroupedCatalog({
}
return (
<DataGrid
table={table}
recordCount={filteredIds.size}
emptyMessage="Нет записей по выбранным фильтрам."
tableLayout={{ dense: true }}
>
<Frame dense spacing="sm" className="w-full">
{!hideHeader ? (
<FrameHeader className="flex-row items-start justify-between gap-3">
<div className="flex min-w-0 flex-col gap-px">
<FrameTitle>Сервисы</FrameTitle>
<FrameDescription>
{domainLabel
? `Каталог сервисов с привязками к ${domainLabel}`
: 'Группы, FQDN и доступность сервисов'}
</FrameDescription>
</div>
{primaryAction ? (
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2">
{primaryAction}
</div>
) : null}
</FrameHeader>
) : null}
<FramePanel className="p-0 shadow-none!">
<div className="px-(--frame-panel-header-px) pt-(--frame-panel-header-py)">
<CountedLineTabs
tabs={ALL_TABS.map((t) => ({
id: t.id,
label: t.label,
count: tabCounts[t.id] ?? 0,
}))}
value={tab}
onValueChange={setTab}
/>
<Frame dense spacing="sm" className="w-full">
{!hideHeader ? (
<FrameHeader className="flex-row items-start justify-between gap-3">
<div className="flex min-w-0 flex-col gap-px">
<FrameTitle>Сервисы</FrameTitle>
<FrameDescription>
{domainLabel
? `Каталог сервисов с привязками к ${domainLabel}`
: 'Группы для сортировки; у каждого сервиса — общий домен и IP'}
</FrameDescription>
</div>
{primaryAction ? (
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2">
{primaryAction}
</div>
) : null}
</FrameHeader>
) : null}
<Separator />
<FramePanel className="p-0 shadow-none!">
<div className="px-(--frame-panel-header-px) pt-(--frame-panel-header-py)">
<CountedLineTabs
tabs={ALL_TABS.map((t) => ({
id: t.id,
label: t.label,
count: tabCounts[t.id] ?? 0,
}))}
value={tab}
onValueChange={setTab}
/>
</div>
<div className="flex flex-wrap items-center justify-between gap-3 px-(--frame-panel-header-px) py-(--frame-panel-header-py)">
<Filters
filters={filters}
fields={filterFields}
onChange={setFilters}
size="default"
trigger={
<Button type="button" variant="outline" aria-label="Фильтры">
<FilterIcon className="size-4" aria-hidden />
Фильтры
<Separator />
<div className="flex flex-wrap items-center justify-between gap-3 px-(--frame-panel-header-px) py-(--frame-panel-header-py)">
<Filters
filters={filters}
fields={filterFields}
onChange={setFilters}
size="default"
trigger={
<Button type="button" variant="outline" aria-label="Фильтры">
<FilterIcon className="size-4" aria-hidden />
Фильтры
</Button>
}
/>
<Button
type="button"
variant="outline"
onClick={() => {
setTab('all')
setFilters(createDefaultServiceFilters())
}}
>
<FunnelXIcon className="size-4" aria-hidden />
Сбросить
</Button>
</div>
<Separator />
{units.length === 0 ? (
<div className="p-6">
<EmptyState
title="Нет совпадений"
description="Измените фильтры или вкладку."
action={
<Button
type="button"
variant="outline"
onClick={() => {
setTab('all')
setFilters(createDefaultServiceFilters())
}}
>
Сбросить
</Button>
}
/>
<Button
type="button"
variant="outline"
onClick={() => {
setTab('all')
setFilters(createDefaultServiceFilters())
}}
>
<FunnelXIcon className="size-4" aria-hidden />
Сбросить
</Button>
</div>
<Separator />
{treeData.length === 0 ? (
<div className="p-6">
<EmptyState
title="Нет совпадений"
description="Измените фильтры или вкладку."
action={
<Button
type="button"
variant="outline"
onClick={() => {
setTab('all')
setFilters(createDefaultServiceFilters())
}}
>
Сбросить
</Button>
}
) : (
<div className="flex flex-col gap-6 px-(--frame-panel-header-px) py-(--frame-panel-header-py)">
{units.map((unit) => (
<ServiceCatalogSection
key={unit.id}
group={unit.group}
services={unit.services}
togglingId={togglingId}
onEditService={onEditService}
onDeleteService={onDeleteService}
onToggleService={onToggleService}
onEditGroup={onEditGroup}
onDeleteGroup={onDeleteGroup}
onAddServiceToGroup={onAddServiceToGroup}
/>
</div>
) : (
<>
<DataGridScrollArea>
<DataGridTable />
</DataGridScrollArea>
<Separator />
<FrameFooter>
<DataGridPagination
sizes={[5, 10, 20, 50]}
rowsPerPageLabel="Строк на странице"
info="{from} - {to} of {count}"
previousPageLabel="Предыдущая"
nextPageLabel="Следующая"
/>
</FrameFooter>
</>
)}
</FramePanel>
</Frame>
</DataGrid>
))}
</div>
)}
</FramePanel>
</Frame>
)
}
@@ -1,308 +0,0 @@
import { useMemo } from 'react'
import { Link } from '@tanstack/react-router'
import type { ColumnDef } from '@tanstack/react-table'
import {
ChevronRightIcon,
FolderIcon,
MoreHorizontalIcon,
PlusIcon,
} from 'lucide-react'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { HealthCheckBadge } from '@/components/health-check-badge'
import { StatusBadge } from '@/components/status-badge'
import type { ServiceGroupView, ServiceView } from '@/lib/schemas'
import { ServiceFqdnList } from '@/components/services/service-fqdn-list'
import { Button } from '@cfdm/ui/components/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@cfdm/ui/components/dropdown-menu'
import { Switch } from '@cfdm/ui/components/switch'
import { Badge } from '@/components/reui/badge'
import { cn } from '@cfdm/ui/lib/utils'
export type ServiceTreeServiceRow = {
kind: 'service'
id: string
service: ServiceView
groupId: number | null
groupName: string | null
}
export type ServiceTreeGroupRow = {
kind: 'group'
id: string
group: ServiceGroupView | null
subRows: ServiceTreeServiceRow[]
}
export type ServiceCatalogTreeRow = ServiceTreeGroupRow | ServiceTreeServiceRow
function isServiceRow(row: ServiceCatalogTreeRow): row is ServiceTreeServiceRow {
return row.kind === 'service'
}
export function createServicesGroupedColumns({
onEditService,
onDeleteService,
onToggleService,
onEditGroup,
onDeleteGroup,
onAddServiceToGroup,
togglingId,
}: {
onEditService: (service: ServiceView) => void
onDeleteService: (service: ServiceView) => void
onToggleService: (serviceId: number, enabled: boolean) => void
onEditGroup: (group: ServiceGroupView) => void
onDeleteGroup: (group: ServiceGroupView) => void
onAddServiceToGroup: (groupId: number | null) => void
togglingId: number | null
}): ColumnDef<ServiceCatalogTreeRow>[] {
return [
{
id: 'name',
accessorFn: (row) =>
isServiceRow(row) ? row.service.name : (row.group?.name ?? 'Без группы'),
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Группа / сервис" />
),
cell: ({ row }) => {
const original = row.original
if (!isServiceRow(original)) {
const title = original.group?.name ?? 'Без группы'
const domain = original.group?.domain
return (
<div className="flex min-w-0 items-center gap-2">
<Button
type="button"
size="icon-sm"
variant="ghost"
aria-label={
row.getIsExpanded() ? `Свернуть ${title}` : `Развернуть ${title}`
}
aria-expanded={row.getIsExpanded()}
className="text-muted-foreground hover:text-foreground size-6 shrink-0 p-0 shadow-none"
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
row.getToggleExpandedHandler()()
}}
>
<ChevronRightIcon
className={cn(
'size-3.5 shrink-0 transition-transform duration-150',
row.getIsExpanded() && 'rotate-90',
)}
aria-hidden
/>
</Button>
<FolderIcon
className="text-muted-foreground size-4 shrink-0"
aria-hidden
/>
<div className="flex min-w-0 flex-col gap-0.5">
<div className="flex min-w-0 items-center gap-2">
<span className="truncate text-sm font-semibold">{title}</span>
<Badge variant="outline" size="xs" className="shrink-0">
{original.subRows.length}
</Badge>
</div>
{domain ? (
<span className="text-muted-foreground truncate font-mono text-xs">
{domain}
</span>
) : null}
</div>
</div>
)
}
return (
<div className="flex min-w-0 flex-col gap-0.5 pl-8">
<span className="truncate text-sm font-medium">
{original.service.name}
</span>
<ServiceFqdnList service={original.service} emptyLabel="—" />
</div>
)
},
enableSorting: false,
minSize: 260,
meta: { headerTitle: 'Группа / сервис', autoSize: true },
},
{
id: 'health',
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Доступность" />
),
cell: ({ row }) => {
const original = row.original
if (!isServiceRow(original)) {
return (
<HealthCheckBadge
status={original.group?.health_status ?? 'unknown'}
latencyMs={original.group?.health_latency_ms}
size="xs"
/>
)
}
return (
<HealthCheckBadge
status={original.service.health_status ?? 'unknown'}
latencyMs={original.service.health_latency_ms}
size="xs"
/>
)
},
size: 120,
enableSorting: false,
},
{
id: 'enabled',
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Статус" />
),
cell: ({ row }) => {
const original = row.original
if (!isServiceRow(original)) return null
return (
<div className="flex items-center gap-2">
<Switch
checked={original.service.enabled}
disabled={togglingId === original.service.id}
onCheckedChange={(checked) =>
onToggleService(original.service.id, Boolean(checked))
}
aria-label={
original.service.enabled
? 'Выключить сервис'
: 'Включить сервис'
}
/>
<StatusBadge
status={original.service.enabled ? 'active' : 'disabled'}
label={original.service.enabled ? 'Вкл' : 'Выкл'}
/>
</div>
)
},
size: 140,
enableSorting: false,
},
{
id: 'actions',
enableSorting: false,
header: () => <span className="sr-only">Действия</span>,
cell: ({ row }) => {
const original = row.original
if (!isServiceRow(original)) {
if (!original.group) {
return (
<Button
type="button"
size="icon-sm"
variant="ghost"
aria-label="Добавить сервис без группы"
onClick={() => onAddServiceToGroup(null)}
>
<PlusIcon aria-hidden />
</Button>
)
}
return (
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label={`Действия группы ${original.group.name}`}
/>
}
>
<MoreHorizontalIcon aria-hidden />
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => onAddServiceToGroup(original.group!.id)}
>
<PlusIcon aria-hidden />
Добавить сервис
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onEditGroup(original.group!)}>
Изменить группу
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
onClick={() => onDeleteGroup(original.group!)}
>
Удалить группу
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}
return (
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label={`Действия ${original.service.name}`}
/>
}
>
<MoreHorizontalIcon aria-hidden />
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
render={
<Link
to="/services/$serviceId"
params={{ serviceId: String(original.service.id) }}
/>
}
>
Обзор
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onEditService(original.service)}>
Изменить
</DropdownMenuItem>
<DropdownMenuItem
variant="destructive"
onClick={() => onDeleteService(original.service)}
>
Удалить
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
},
size: 56,
},
]
}
export function useServicesGroupedColumns(
args: Parameters<typeof createServicesGroupedColumns>[0],
) {
return useMemo(() => createServicesGroupedColumns(args), [
args.onEditService,
args.onDeleteService,
args.onToggleService,
args.onEditGroup,
args.onDeleteGroup,
args.onAddServiceToGroup,
args.togglingId,
])
}
+1 -2
View File
@@ -456,7 +456,6 @@ function ServicesPage() {
board.columns.map((column, index) => ({
id: column.id,
title: column.title,
description: column.domain ?? undefined,
healthStatus: column.group?.health_status,
healthLatencyMs: column.group?.health_latency_ms,
dotClassName: GROUP_DOT_COLORS[index % GROUP_DOT_COLORS.length],
@@ -494,7 +493,7 @@ function ServicesPage() {
const pageDescription = filteredDomain
? `Сервисы с привязками к домену ${filteredDomain.zone_name}`
: 'Группы, FQDN и доступность сервисов'
: 'Группы для сортировки; у каждого сервиса — общий домен и IP'
const sheets = (
<>