From 5a61ce33b493ea5d77a3df2e90bae167399bddd5 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Tue, 1 Sep 2026 14:20:09 +0700 Subject: [PATCH] =?UTF-8?q?feat(topology):=20=D0=B2=D1=8B=D0=BD=D0=B5?= =?UTF-8?q?=D1=81=D1=82=D0=B8=20=D1=81=D0=B5=D1=80=D0=B2=D0=B8=D1=81=20CFD?= =?UTF-8?q?M=20=D0=B2=20=D0=BE=D1=82=D0=B4=D0=B5=D0=BB=D1=8C=D0=BD=D1=8B?= =?UTF-8?q?=D0=B9=20=D1=83=D0=B7=D0=B5=D0=BB=20=D1=81=D0=BE=20=D1=81=D0=B2?= =?UTF-8?q?=D1=8F=D0=B7=D1=8F=D0=BC=D0=B8=20=D1=87=D0=BB=D0=B5=D0=BD=D1=81?= =?UTF-8?q?=D1=82=D0=B2=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- .../src/components/topology/add-vps-sheet.tsx | 2 +- .../components/topology/cfdm-services.test.ts | 28 +- .../src/components/topology/cfdm-services.ts | 18 ++ .../web/src/components/topology/edge-utils.ts | 34 +++ .../topology/edges/topology-edge.tsx | 3 + .../topology/element-edit-sheet.tsx | 67 ++++- .../src/components/topology/group-utils.ts | 2 +- .../web/src/components/topology/node-types.ts | 2 + .../topology/nodes/service-node.tsx | 50 ++++ .../components/topology/nodes/vps-node.tsx | 17 +- .../topology/place-cfdm-service.test.ts | 168 ++++++++--- .../components/topology/place-cfdm-service.ts | 271 ++++++++++++------ .../components/topology/topology-editor.tsx | 57 +++- apps/web/src/components/topology/types.ts | 32 ++- 14 files changed, 600 insertions(+), 151 deletions(-) create mode 100644 apps/web/src/components/topology/nodes/service-node.tsx diff --git a/apps/web/src/components/topology/add-vps-sheet.tsx b/apps/web/src/components/topology/add-vps-sheet.tsx index c826c5d..134a23c 100644 --- a/apps/web/src/components/topology/add-vps-sheet.tsx +++ b/apps/web/src/components/topology/add-vps-sheet.tsx @@ -110,7 +110,7 @@ export function AddVpsSheet({ title={tab === 'cfdm' ? 'Добавить сервис CFDM' : 'Добавить VPS на схему'} description={ tab === 'cfdm' - ? 'Серверы сервиса появятся в группе. Тип резервирования — из CFDM.' + ? 'Сервис появится как узел. Серверы свяжутся пунктиром членства — один VPS может быть в нескольких сервисах.' : 'Сервер появится на канве. Позицию и связи можно настроить вручную.' } submitLabel={`Добавить${submitCount ? ` (${submitCount})` : ''}`} diff --git a/apps/web/src/components/topology/cfdm-services.test.ts b/apps/web/src/components/topology/cfdm-services.test.ts index f1843a3..bee9a45 100644 --- a/apps/web/src/components/topology/cfdm-services.test.ts +++ b/apps/web/src/components/topology/cfdm-services.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import type { VpsDomain } from '@/types/entities' -import { aggregateCfdmServices } from './cfdm-services' +import { aggregateCfdmServices, servicesForVps } from './cfdm-services' function domain(partial: Partial & Pick): VpsDomain { return { @@ -68,6 +68,32 @@ describe('aggregateCfdmServices', () => { expect(dns?.lbMode).toBe('round_robin') }) + it('lists services a VPS belongs to', () => { + const rows: VpsDomain[] = [ + domain({ + id: 'a', + cfdmServiceId: 10, + cfdmBindingId: 1, + vpsId: 'vps-1', + matchStatus: 'matched', + lbMode: 'failover', + }), + domain({ + id: 'b', + cfdmServiceId: 11, + cfdmBindingId: 3, + serviceName: 'DNS', + serviceSlug: 'dns', + fqdn: 'ns.example.com', + vpsId: 'vps-1', + matchStatus: 'matched', + lbMode: 'round_robin', + }), + ] + const chips = servicesForVps(aggregateCfdmServices(rows), 'vps-1') + expect(chips.map((c) => c.name).sort()).toEqual(['DNS', 'VPN']) + }) + it('deduplicates the same vpsId across bindings of one service', () => { const rows: VpsDomain[] = [ domain({ diff --git a/apps/web/src/components/topology/cfdm-services.ts b/apps/web/src/components/topology/cfdm-services.ts index 6fd5a95..49a1ede 100644 --- a/apps/web/src/components/topology/cfdm-services.ts +++ b/apps/web/src/components/topology/cfdm-services.ts @@ -70,3 +70,21 @@ export function aggregateCfdmServices(domains: VpsDomain[]): CfdmTopologyService })) .sort((a, b) => a.name.localeCompare(b.name, 'ru')) } + +export function serviceFqdnMeta(service: Pick): { + fqdn: string + extraFqdns: string[] +} { + const [primary, ...rest] = service.fqdns + return { fqdn: primary ?? '', extraFqdns: rest } +} + +/** Сервисы CFDM, в которых состоит данный VPS (для чипов на карточке). */ +export function servicesForVps( + services: CfdmTopologyService[], + vpsId: string, +): Pick[] { + return services + .filter((s) => s.matchedVpsIds.includes(vpsId)) + .map((s) => ({ serviceId: s.serviceId, name: s.name, lbMode: s.lbMode })) +} diff --git a/apps/web/src/components/topology/edge-utils.ts b/apps/web/src/components/topology/edge-utils.ts index c0d27c2..5ba3bd4 100644 --- a/apps/web/src/components/topology/edge-utils.ts +++ b/apps/web/src/components/topology/edge-utils.ts @@ -47,3 +47,37 @@ export function createConnectedEdge(connection: { data, ) } + +export function membershipEdgeData(): TopologyEdgeData { + return { + relation: 'membership', + label: '', + lineStyle: 'dashed', + direction: 'none', + protocol: '', + localIp: '', + remoteIp: '', + notes: '', + } +} + +export function createMembershipEdge( + serviceNodeId: string, + vpsNodeId: string, +): Edge { + const data = membershipEdgeData() + return applyEdgeVisuals( + { + id: `e-membership-${serviceNodeId}-${vpsNodeId}`, + source: serviceNodeId, + target: vpsNodeId, + data, + deletable: false, + }, + data, + ) +} + +export function isMembershipEdge(edge: Edge): boolean { + return edge.data?.relation === 'membership' +} diff --git a/apps/web/src/components/topology/edges/topology-edge.tsx b/apps/web/src/components/topology/edges/topology-edge.tsx index 3171a99..6b59e92 100644 --- a/apps/web/src/components/topology/edges/topology-edge.tsx +++ b/apps/web/src/components/topology/edges/topology-edge.tsx @@ -43,6 +43,7 @@ function TopologyEdgeComponent(props: EdgeProps>) { const title = data?.label?.trim() ?? '' const protocol = data?.protocol?.trim() ?? '' const tunnelIps = formatEdgeTunnelIps(data ?? {}) + const membership = relation === 'membership' return ( <> @@ -56,6 +57,7 @@ function TopologyEdgeComponent(props: EdgeProps>) { strokeWidth: selected ? 2.5 : 1.5, }} /> + {membership ? null : (
>) { ) : null}
+ )} ) } diff --git a/apps/web/src/components/topology/element-edit-sheet.tsx b/apps/web/src/components/topology/element-edit-sheet.tsx index f837296..302c537 100644 --- a/apps/web/src/components/topology/element-edit-sheet.tsx +++ b/apps/web/src/components/topology/element-edit-sheet.tsx @@ -11,6 +11,7 @@ import { lbModeLabel, type GroupNodeData, type NoteNodeData, + type ServiceNodeData, type ShapeKind, type ShapeNodeData, type TopologyNodeType, @@ -20,13 +21,18 @@ export type EditableElement = | { kind: 'shape'; id: string; data: ShapeNodeData } | { kind: 'note'; id: string; data: NoteNodeData } | { kind: 'group'; id: string; data: GroupNodeData } + | { kind: 'service'; id: string; data: ServiceNodeData } interface ElementEditSheetProps { element: EditableElement | null open: boolean locked?: boolean onOpenChange: (open: boolean) => void - onSave: (id: string, type: TopologyNodeType, data: ShapeNodeData | NoteNodeData | GroupNodeData) => void + onSave: ( + id: string, + type: TopologyNodeType, + data: ShapeNodeData | NoteNodeData | GroupNodeData | ServiceNodeData, + ) => void } export function ElementEditSheet({ @@ -52,6 +58,10 @@ export function ElementEditSheet({ setText(element.data.text ?? '') setLabel('') setNotes('') + } else if (element.kind === 'service') { + setLabel(element.data.label ?? '') + setNotes('') + setText('') } else { setLabel(element.data.label ?? '') setNotes(element.data.notes ?? '') @@ -64,7 +74,9 @@ export function ElementEditSheet({ ? 'Параметры блока' : element?.kind === 'note' ? 'Параметры заметки' - : 'Параметры группы' + : element?.kind === 'service' + ? 'Параметры сервиса CFDM' + : 'Параметры группы' return ( - {element.data.lbMode ? ( - - - {lbModeLabel(element.data.lbMode)} - - - ) : null}