diff --git a/apps/api/src/services/service-config-service.ts b/apps/api/src/services/service-config-service.ts index 17d0c23..4f268d9 100644 --- a/apps/api/src/services/service-config-service.ts +++ b/apps/api/src/services/service-config-service.ts @@ -312,6 +312,14 @@ async function buildView(db: Db, serviceId: number): Promise { }; }); + const activeIps = new Set(); + for (const binding of bindings) { + const { config, rows } = getBindingLbState(db, binding.id); + for (const ip of selectActiveIpsByMode(config, rows)) { + activeIps.add(ip); + } + } + return { id: service.id, name: service.name, @@ -330,6 +338,8 @@ async function buildView(db: Db, serviceId: number): Promise { health_status: "unknown", health_latency_ms: null, ip_health: [], + lb_mode: bindings[0]?.lb_mode ?? "round_robin", + active_ips: [...activeIps], }; } diff --git a/apps/api/test/services-create-list.test.ts b/apps/api/test/services-create-list.test.ts index c69712b..522ad46 100644 --- a/apps/api/test/services-create-list.test.ts +++ b/apps/api/test/services-create-list.test.ts @@ -123,11 +123,12 @@ describe("create service then list groups", () => { expect(httpParsed.success, JSON.stringify(httpParsed.error?.issues)).toBe( true, ); - expect( - httpParsed.data!.groups - .find((g) => g.id === group.id) - ?.services.some((s) => s.id === created.id), - ).toBe(true); + const listed = httpParsed.data!.groups + .find((g) => g.id === group.id) + ?.services.find((s) => s.id === created.id); + expect(listed).toBeDefined(); + expect(listed?.lb_mode).toBe("round_robin"); + expect(listed?.active_ips).toEqual(["1.2.3.4"]); await app.close(); }); diff --git a/apps/web/src/components/services/service-fqdn-list.tsx b/apps/web/src/components/services/service-fqdn-list.tsx index bd8f536..70b2172 100644 --- a/apps/web/src/components/services/service-fqdn-list.tsx +++ b/apps/web/src/components/services/service-fqdn-list.tsx @@ -2,6 +2,7 @@ import { CheckIcon, CopyIcon } from 'lucide-react' import { toast } from 'sonner' import { HealthCheckBadge } from '@/components/health-check-badge' +import { StatusBadge } from '@/components/status-badge' import { Badge } from '@/components/reui/badge' import { TruncatedText } from '@/components/truncated-text' import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard' @@ -138,6 +139,9 @@ interface ServiceIpListProps { onToggleIp?: (ip: string, enabled: boolean) => void /** Invisible icon-sm slot so IP Switch lines up with the card overflow menu. */ alignWithMenu?: boolean + lbMode?: ServiceView['lb_mode'] + activeIps?: string[] + ipWeights?: Record className?: string emptyLabel?: string copyable?: boolean @@ -152,6 +156,9 @@ export function ServiceIpList({ ipToggleDisabled = false, onToggleIp, alignWithMenu = false, + lbMode, + activeIps = [], + ipWeights = {}, className, emptyLabel = 'Нет IP', copyable = false, @@ -169,6 +176,8 @@ export function ServiceIpList({ const visible = onToggleIp ? ips : ips.slice(0, VISIBLE_IP_LIMIT) const extraCount = ips.length - visible.length const showMenuSlot = Boolean(onToggleIp && alignWithMenu) + const markActive = lbMode === 'failover' || lbMode === 'weighted' + const activeSet = new Set(activeIps) return ( @@ -206,6 +215,18 @@ export function ServiceIpList({ {ip} {copyable ? : null} + {markActive && activeSet.has(ip) ? ( + + ) : null} + {lbMode === 'weighted' ? ( + + w{ipWeights[ip] ?? 1} + + ) : null} {onToggleIp ? ( diff --git a/apps/web/src/components/services/service-unit-card.tsx b/apps/web/src/components/services/service-unit-card.tsx index 74c7911..b145114 100644 --- a/apps/web/src/components/services/service-unit-card.tsx +++ b/apps/web/src/components/services/service-unit-card.tsx @@ -1,5 +1,12 @@ import { Link } from '@tanstack/react-router' -import { MoreHorizontalIcon, ServerIcon } from 'lucide-react' +import { + GitForkIcon, + MoreHorizontalIcon, + Repeat2Icon, + ScaleIcon, + ServerIcon, + type LucideIcon, +} from 'lucide-react' import { Badge } from '@/components/reui/badge' import { @@ -35,6 +42,7 @@ import { TooltipProvider, TooltipTrigger, } from '@cfdm/ui/components/tooltip' +import { cn } from '@cfdm/ui/lib/utils' /** * Compact service card — settings-8 DNA (Badge + copy + Switch + menu). @@ -42,6 +50,55 @@ import { * Frame: https://reui.io/docs/components/base/frame * IconTile: https://reui.io/docs/components/base/icon-tile */ + +type LbMode = ServiceView['lb_mode'] + +const LB_MODE_META: Record< + LbMode, + { icon: LucideIcon; className: string; label: string } +> = { + round_robin: { + icon: Repeat2Icon, + className: 'text-info', + label: 'Round Robin', + }, + failover: { + icon: GitForkIcon, + className: 'text-warning', + label: 'Failover (приоритет)', + }, + weighted: { + icon: ScaleIcon, + className: 'text-info', + label: 'Weighted (веса)', + }, +} + +function LbModeTile({ mode }: { mode: LbMode }) { + const meta = LB_MODE_META[mode] + const Icon = meta.icon + + return ( + + + + } + > + + {meta.label} + + + ) +} + interface ServiceUnitCardProps { service: ServiceView togglingId: number | null @@ -73,22 +130,25 @@ export function ServiceUnitCard({ - - - {service.name} - - +
+ + + {service.name} + + + +
{primaryDomain} @@ -182,6 +242,9 @@ export function ServiceUnitCard({ ipEnabled={service.ip_enabled ?? {}} ipToggleDisabled={togglingId === service.id} togglingIp={togglingIp} + lbMode={service.lb_mode} + activeIps={service.active_ips} + ipWeights={service.domains[0]?.target_ip_weights} onToggleIp={(ip, enabled) => onToggleServiceIp(service.id, ip, enabled) } diff --git a/apps/web/src/lib/schemas.ts b/apps/web/src/lib/schemas.ts index 8c20bc0..85a1959 100644 --- a/apps/web/src/lib/schemas.ts +++ b/apps/web/src/lib/schemas.ts @@ -133,6 +133,8 @@ export const serviceViewSchema = serviceSchema.extend({ health_latency_ms: z.number().nullable().default(null), ip_health: z.array(serviceIpHealthSchema).default([]), ip_enabled: z.record(z.string(), z.boolean()).default({}), + lb_mode: z.enum(['round_robin', 'failover', 'weighted']).catch('round_robin'), + active_ips: z.array(z.string()).default([]), }) export const serviceGroupViewSchema = serviceGroupSchema.extend({ diff --git a/packages/shared/dist/index.d.ts b/packages/shared/dist/index.d.ts index d3097d4..8474482 100644 --- a/packages/shared/dist/index.d.ts +++ b/packages/shared/dist/index.d.ts @@ -176,6 +176,8 @@ interface ServiceView$1 { health_latency_ms: number | null; ip_health: ServiceIpHealth$1[]; ip_enabled: Record; + lb_mode: LbMode; + active_ips: string[]; } interface SyncJob { id: string; @@ -824,6 +826,12 @@ declare const serviceViewSchema: z.ZodObject<{ colo: z.ZodOptional>; }, z.core.$strip>>>; ip_enabled: z.ZodDefault>; + lb_mode: z.ZodCatch>; + active_ips: z.ZodDefault>; }, z.core.$strip>; declare const serviceGroupViewSchema: z.ZodObject<{ id: z.ZodNumber; @@ -1013,6 +1021,12 @@ declare const serviceGroupViewSchema: z.ZodObject<{ colo: z.ZodOptional>; }, z.core.$strip>>>; ip_enabled: z.ZodDefault>; + lb_mode: z.ZodCatch>; + active_ips: z.ZodDefault>; }, z.core.$strip>>>; health_status: z.ZodDefault>; }, z.core.$strip>>>; ip_enabled: z.ZodDefault>; + lb_mode: z.ZodCatch>; + active_ips: z.ZodDefault>; }, z.core.$strip>>>; health_status: z.ZodDefault>; }, z.core.$strip>>>; ip_enabled: z.ZodDefault>; + lb_mode: z.ZodCatch>; + active_ips: z.ZodDefault>; }, z.core.$strip>>>; }, z.core.$strip>; declare const domainSchema: z.ZodObject<{ diff --git a/packages/shared/dist/index.js b/packages/shared/dist/index.js index 0c7f5bf..c488ea4 100644 --- a/packages/shared/dist/index.js +++ b/packages/shared/dist/index.js @@ -409,7 +409,9 @@ var serviceViewSchema = serviceSchema.extend({ health_status: ipHealthStateSchema.default("unknown"), health_latency_ms: z.number().nullable().default(null), ip_health: z.array(serviceIpHealthSchema).default([]), - ip_enabled: z.record(z.string(), z.boolean()).default({}) + ip_enabled: z.record(z.string(), z.boolean()).default({}), + lb_mode: lbModeSchema.catch("round_robin"), + active_ips: z.array(z.string()).default([]) }); var serviceGroupViewSchema = serviceGroupSchema.extend({ services: z.array(serviceViewSchema).default([]), diff --git a/packages/shared/src/schemas.ts b/packages/shared/src/schemas.ts index c431fa8..6440c32 100644 --- a/packages/shared/src/schemas.ts +++ b/packages/shared/src/schemas.ts @@ -205,6 +205,8 @@ export const serviceViewSchema = serviceSchema.extend({ health_latency_ms: z.number().nullable().default(null), ip_health: z.array(serviceIpHealthSchema).default([]), ip_enabled: z.record(z.string(), z.boolean()).default({}), + lb_mode: lbModeSchema.catch('round_robin'), + active_ips: z.array(z.string()).default([]), }) export const serviceGroupViewSchema = serviceGroupSchema.extend({ diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index cf54e4e..6085eb1 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -222,6 +222,8 @@ export interface ServiceView { health_latency_ms: number | null; ip_health: ServiceIpHealth[]; ip_enabled: Record; + lb_mode: LbMode; + active_ips: string[]; } export interface GroupWithStats extends Group {