feat(services): add load balancing mode and active IPs to service view
quality / commitlint (push) Skipped
CD / update-wiki (push) Successful in 7s
quality / changes (push) Successful in 9s
quality / docker-check (push) Skipped
quality / web (push) Successful in 1m15s
quality / api (push) Successful in 49s
CD / quality (push) Successful in 2m17s
CD / publish (push) Successful in 1m41s
quality / commitlint (push) Skipped
CD / update-wiki (push) Successful in 7s
quality / changes (push) Successful in 9s
quality / docker-check (push) Skipped
quality / web (push) Successful in 1m15s
quality / api (push) Successful in 49s
CD / quality (push) Successful in 2m17s
CD / publish (push) Successful in 1m41s
- Introduced `lb_mode` to specify the load balancing strategy for services, supporting options like round robin, failover, and weighted. - Added `active_ips` to track currently active IPs for each service, enhancing visibility into service health and configuration. - Updated relevant components to display load balancing mode and active IPs, improving user experience and service management capabilities.
This commit is contained in:
@@ -312,6 +312,14 @@ async function buildView(db: Db, serviceId: number): Promise<ServiceView> {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const activeIps = new Set<string>();
|
||||||
|
for (const binding of bindings) {
|
||||||
|
const { config, rows } = getBindingLbState(db, binding.id);
|
||||||
|
for (const ip of selectActiveIpsByMode(config, rows)) {
|
||||||
|
activeIps.add(ip);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: service.id,
|
id: service.id,
|
||||||
name: service.name,
|
name: service.name,
|
||||||
@@ -330,6 +338,8 @@ async function buildView(db: Db, serviceId: number): Promise<ServiceView> {
|
|||||||
health_status: "unknown",
|
health_status: "unknown",
|
||||||
health_latency_ms: null,
|
health_latency_ms: null,
|
||||||
ip_health: [],
|
ip_health: [],
|
||||||
|
lb_mode: bindings[0]?.lb_mode ?? "round_robin",
|
||||||
|
active_ips: [...activeIps],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -123,11 +123,12 @@ describe("create service then list groups", () => {
|
|||||||
expect(httpParsed.success, JSON.stringify(httpParsed.error?.issues)).toBe(
|
expect(httpParsed.success, JSON.stringify(httpParsed.error?.issues)).toBe(
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
expect(
|
const listed = httpParsed.data!.groups
|
||||||
httpParsed.data!.groups
|
.find((g) => g.id === group.id)
|
||||||
.find((g) => g.id === group.id)
|
?.services.find((s) => s.id === created.id);
|
||||||
?.services.some((s) => s.id === created.id),
|
expect(listed).toBeDefined();
|
||||||
).toBe(true);
|
expect(listed?.lb_mode).toBe("round_robin");
|
||||||
|
expect(listed?.active_ips).toEqual(["1.2.3.4"]);
|
||||||
|
|
||||||
await app.close();
|
await app.close();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { CheckIcon, CopyIcon } from 'lucide-react'
|
|||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
import { HealthCheckBadge } from '@/components/health-check-badge'
|
import { HealthCheckBadge } from '@/components/health-check-badge'
|
||||||
|
import { StatusBadge } from '@/components/status-badge'
|
||||||
import { Badge } from '@/components/reui/badge'
|
import { Badge } from '@/components/reui/badge'
|
||||||
import { TruncatedText } from '@/components/truncated-text'
|
import { TruncatedText } from '@/components/truncated-text'
|
||||||
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
|
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
|
||||||
@@ -138,6 +139,9 @@ interface ServiceIpListProps {
|
|||||||
onToggleIp?: (ip: string, enabled: boolean) => void
|
onToggleIp?: (ip: string, enabled: boolean) => void
|
||||||
/** Invisible icon-sm slot so IP Switch lines up with the card overflow menu. */
|
/** Invisible icon-sm slot so IP Switch lines up with the card overflow menu. */
|
||||||
alignWithMenu?: boolean
|
alignWithMenu?: boolean
|
||||||
|
lbMode?: ServiceView['lb_mode']
|
||||||
|
activeIps?: string[]
|
||||||
|
ipWeights?: Record<string, number>
|
||||||
className?: string
|
className?: string
|
||||||
emptyLabel?: string
|
emptyLabel?: string
|
||||||
copyable?: boolean
|
copyable?: boolean
|
||||||
@@ -152,6 +156,9 @@ export function ServiceIpList({
|
|||||||
ipToggleDisabled = false,
|
ipToggleDisabled = false,
|
||||||
onToggleIp,
|
onToggleIp,
|
||||||
alignWithMenu = false,
|
alignWithMenu = false,
|
||||||
|
lbMode,
|
||||||
|
activeIps = [],
|
||||||
|
ipWeights = {},
|
||||||
className,
|
className,
|
||||||
emptyLabel = 'Нет IP',
|
emptyLabel = 'Нет IP',
|
||||||
copyable = false,
|
copyable = false,
|
||||||
@@ -169,6 +176,8 @@ export function ServiceIpList({
|
|||||||
const visible = onToggleIp ? ips : ips.slice(0, VISIBLE_IP_LIMIT)
|
const visible = onToggleIp ? ips : ips.slice(0, VISIBLE_IP_LIMIT)
|
||||||
const extraCount = ips.length - visible.length
|
const extraCount = ips.length - visible.length
|
||||||
const showMenuSlot = Boolean(onToggleIp && alignWithMenu)
|
const showMenuSlot = Boolean(onToggleIp && alignWithMenu)
|
||||||
|
const markActive = lbMode === 'failover' || lbMode === 'weighted'
|
||||||
|
const activeSet = new Set(activeIps)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ItemGroup className={cn('gap-1', className)}>
|
<ItemGroup className={cn('gap-1', className)}>
|
||||||
@@ -206,6 +215,18 @@ export function ServiceIpList({
|
|||||||
{ip}
|
{ip}
|
||||||
</TruncatedText>
|
</TruncatedText>
|
||||||
{copyable ? <CopyFqdnButton value={ip} /> : null}
|
{copyable ? <CopyFqdnButton value={ip} /> : null}
|
||||||
|
{markActive && activeSet.has(ip) ? (
|
||||||
|
<StatusBadge status="active" className="shrink-0" />
|
||||||
|
) : null}
|
||||||
|
{lbMode === 'weighted' ? (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
size="xs"
|
||||||
|
className="shrink-0 tabular-nums"
|
||||||
|
>
|
||||||
|
w{ipWeights[ip] ?? 1}
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</ItemContent>
|
</ItemContent>
|
||||||
{onToggleIp ? (
|
{onToggleIp ? (
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
import { Link } from '@tanstack/react-router'
|
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 { Badge } from '@/components/reui/badge'
|
||||||
import {
|
import {
|
||||||
@@ -35,6 +42,7 @@ import {
|
|||||||
TooltipProvider,
|
TooltipProvider,
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from '@cfdm/ui/components/tooltip'
|
} from '@cfdm/ui/components/tooltip'
|
||||||
|
import { cn } from '@cfdm/ui/lib/utils'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Compact service card — settings-8 DNA (Badge + copy + Switch + menu).
|
* Compact service card — settings-8 DNA (Badge + copy + Switch + menu).
|
||||||
@@ -42,6 +50,55 @@ import {
|
|||||||
* Frame: https://reui.io/docs/components/base/frame
|
* Frame: https://reui.io/docs/components/base/frame
|
||||||
* IconTile: https://reui.io/docs/components/base/icon-tile
|
* 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 (
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger
|
||||||
|
render={
|
||||||
|
<IconTile
|
||||||
|
variant="elevated"
|
||||||
|
size="xs"
|
||||||
|
className={cn('shrink-0', meta.className)}
|
||||||
|
aria-label={meta.label}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Icon aria-hidden="true" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>{meta.label}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
interface ServiceUnitCardProps {
|
interface ServiceUnitCardProps {
|
||||||
service: ServiceView
|
service: ServiceView
|
||||||
togglingId: number | null
|
togglingId: number | null
|
||||||
@@ -73,22 +130,25 @@ export function ServiceUnitCard({
|
|||||||
<IconTile
|
<IconTile
|
||||||
variant="elevated"
|
variant="elevated"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="text-muted-foreground"
|
className="text-foreground"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
>
|
>
|
||||||
<ServerIcon />
|
<ServerIcon />
|
||||||
</IconTile>
|
</IconTile>
|
||||||
</ItemMedia>
|
</ItemMedia>
|
||||||
<ItemContent className="min-w-0 gap-px">
|
<ItemContent className="min-w-0 gap-px">
|
||||||
<FrameTitle className="min-w-0 truncate text-sm">
|
<div className="flex min-w-0 items-center gap-1.5">
|
||||||
<Link
|
<FrameTitle className="min-w-0 truncate text-base font-semibold">
|
||||||
to="/services/$serviceId"
|
<Link
|
||||||
params={{ serviceId: String(service.id) }}
|
to="/services/$serviceId"
|
||||||
className="hover:underline"
|
params={{ serviceId: String(service.id) }}
|
||||||
>
|
className="hover:underline"
|
||||||
{service.name}
|
>
|
||||||
</Link>
|
{service.name}
|
||||||
</FrameTitle>
|
</Link>
|
||||||
|
</FrameTitle>
|
||||||
|
<LbModeTile mode={service.lb_mode} />
|
||||||
|
</div>
|
||||||
<div className="flex min-w-0 items-center gap-1">
|
<div className="flex min-w-0 items-center gap-1">
|
||||||
<FrameDescription className="min-w-0 truncate font-mono text-xs">
|
<FrameDescription className="min-w-0 truncate font-mono text-xs">
|
||||||
{primaryDomain}
|
{primaryDomain}
|
||||||
@@ -182,6 +242,9 @@ export function ServiceUnitCard({
|
|||||||
ipEnabled={service.ip_enabled ?? {}}
|
ipEnabled={service.ip_enabled ?? {}}
|
||||||
ipToggleDisabled={togglingId === service.id}
|
ipToggleDisabled={togglingId === service.id}
|
||||||
togglingIp={togglingIp}
|
togglingIp={togglingIp}
|
||||||
|
lbMode={service.lb_mode}
|
||||||
|
activeIps={service.active_ips}
|
||||||
|
ipWeights={service.domains[0]?.target_ip_weights}
|
||||||
onToggleIp={(ip, enabled) =>
|
onToggleIp={(ip, enabled) =>
|
||||||
onToggleServiceIp(service.id, ip, enabled)
|
onToggleServiceIp(service.id, ip, enabled)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -133,6 +133,8 @@ export const serviceViewSchema = serviceSchema.extend({
|
|||||||
health_latency_ms: z.number().nullable().default(null),
|
health_latency_ms: z.number().nullable().default(null),
|
||||||
ip_health: z.array(serviceIpHealthSchema).default([]),
|
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: z.enum(['round_robin', 'failover', 'weighted']).catch('round_robin'),
|
||||||
|
active_ips: z.array(z.string()).default([]),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const serviceGroupViewSchema = serviceGroupSchema.extend({
|
export const serviceGroupViewSchema = serviceGroupSchema.extend({
|
||||||
|
|||||||
Vendored
+26
@@ -176,6 +176,8 @@ interface ServiceView$1 {
|
|||||||
health_latency_ms: number | null;
|
health_latency_ms: number | null;
|
||||||
ip_health: ServiceIpHealth$1[];
|
ip_health: ServiceIpHealth$1[];
|
||||||
ip_enabled: Record<string, boolean>;
|
ip_enabled: Record<string, boolean>;
|
||||||
|
lb_mode: LbMode;
|
||||||
|
active_ips: string[];
|
||||||
}
|
}
|
||||||
interface SyncJob {
|
interface SyncJob {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -824,6 +826,12 @@ declare const serviceViewSchema: z.ZodObject<{
|
|||||||
colo: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
colo: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
||||||
}, z.core.$strip>>>;
|
}, z.core.$strip>>>;
|
||||||
ip_enabled: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodBoolean>>;
|
ip_enabled: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodBoolean>>;
|
||||||
|
lb_mode: z.ZodCatch<z.ZodEnum<{
|
||||||
|
round_robin: "round_robin";
|
||||||
|
failover: "failover";
|
||||||
|
weighted: "weighted";
|
||||||
|
}>>;
|
||||||
|
active_ips: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
||||||
}, z.core.$strip>;
|
}, z.core.$strip>;
|
||||||
declare const serviceGroupViewSchema: z.ZodObject<{
|
declare const serviceGroupViewSchema: z.ZodObject<{
|
||||||
id: z.ZodNumber;
|
id: z.ZodNumber;
|
||||||
@@ -1013,6 +1021,12 @@ declare const serviceGroupViewSchema: z.ZodObject<{
|
|||||||
colo: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
colo: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
||||||
}, z.core.$strip>>>;
|
}, z.core.$strip>>>;
|
||||||
ip_enabled: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodBoolean>>;
|
ip_enabled: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodBoolean>>;
|
||||||
|
lb_mode: z.ZodCatch<z.ZodEnum<{
|
||||||
|
round_robin: "round_robin";
|
||||||
|
failover: "failover";
|
||||||
|
weighted: "weighted";
|
||||||
|
}>>;
|
||||||
|
active_ips: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
||||||
}, z.core.$strip>>>;
|
}, z.core.$strip>>>;
|
||||||
health_status: z.ZodDefault<z.ZodEnum<{
|
health_status: z.ZodDefault<z.ZodEnum<{
|
||||||
unknown: "unknown";
|
unknown: "unknown";
|
||||||
@@ -1211,6 +1225,12 @@ declare const serviceGroupsResponseSchema: z.ZodObject<{
|
|||||||
colo: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
colo: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
||||||
}, z.core.$strip>>>;
|
}, z.core.$strip>>>;
|
||||||
ip_enabled: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodBoolean>>;
|
ip_enabled: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodBoolean>>;
|
||||||
|
lb_mode: z.ZodCatch<z.ZodEnum<{
|
||||||
|
round_robin: "round_robin";
|
||||||
|
failover: "failover";
|
||||||
|
weighted: "weighted";
|
||||||
|
}>>;
|
||||||
|
active_ips: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
||||||
}, z.core.$strip>>>;
|
}, z.core.$strip>>>;
|
||||||
health_status: z.ZodDefault<z.ZodEnum<{
|
health_status: z.ZodDefault<z.ZodEnum<{
|
||||||
unknown: "unknown";
|
unknown: "unknown";
|
||||||
@@ -1360,6 +1380,12 @@ declare const serviceGroupsResponseSchema: z.ZodObject<{
|
|||||||
colo: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
colo: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
||||||
}, z.core.$strip>>>;
|
}, z.core.$strip>>>;
|
||||||
ip_enabled: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodBoolean>>;
|
ip_enabled: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodBoolean>>;
|
||||||
|
lb_mode: z.ZodCatch<z.ZodEnum<{
|
||||||
|
round_robin: "round_robin";
|
||||||
|
failover: "failover";
|
||||||
|
weighted: "weighted";
|
||||||
|
}>>;
|
||||||
|
active_ips: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
||||||
}, z.core.$strip>>>;
|
}, z.core.$strip>>>;
|
||||||
}, z.core.$strip>;
|
}, z.core.$strip>;
|
||||||
declare const domainSchema: z.ZodObject<{
|
declare const domainSchema: z.ZodObject<{
|
||||||
|
|||||||
Vendored
+3
-1
@@ -409,7 +409,9 @@ var serviceViewSchema = serviceSchema.extend({
|
|||||||
health_status: ipHealthStateSchema.default("unknown"),
|
health_status: ipHealthStateSchema.default("unknown"),
|
||||||
health_latency_ms: z.number().nullable().default(null),
|
health_latency_ms: z.number().nullable().default(null),
|
||||||
ip_health: z.array(serviceIpHealthSchema).default([]),
|
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({
|
var serviceGroupViewSchema = serviceGroupSchema.extend({
|
||||||
services: z.array(serviceViewSchema).default([]),
|
services: z.array(serviceViewSchema).default([]),
|
||||||
|
|||||||
@@ -205,6 +205,8 @@ export const serviceViewSchema = serviceSchema.extend({
|
|||||||
health_latency_ms: z.number().nullable().default(null),
|
health_latency_ms: z.number().nullable().default(null),
|
||||||
ip_health: z.array(serviceIpHealthSchema).default([]),
|
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([]),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const serviceGroupViewSchema = serviceGroupSchema.extend({
|
export const serviceGroupViewSchema = serviceGroupSchema.extend({
|
||||||
|
|||||||
@@ -222,6 +222,8 @@ export interface ServiceView {
|
|||||||
health_latency_ms: number | null;
|
health_latency_ms: number | null;
|
||||||
ip_health: ServiceIpHealth[];
|
ip_health: ServiceIpHealth[];
|
||||||
ip_enabled: Record<string, boolean>;
|
ip_enabled: Record<string, boolean>;
|
||||||
|
lb_mode: LbMode;
|
||||||
|
active_ips: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GroupWithStats extends Group {
|
export interface GroupWithStats extends Group {
|
||||||
|
|||||||
Reference in New Issue
Block a user