Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d8fc4ac949 | ||
|
|
d063323402 |
@@ -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 {
|
||||
id: service.id,
|
||||
name: service.name,
|
||||
@@ -330,6 +338,8 @@ async function buildView(db: Db, serviceId: number): Promise<ServiceView> {
|
||||
health_status: "unknown",
|
||||
health_latency_ms: null,
|
||||
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(
|
||||
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();
|
||||
});
|
||||
|
||||
@@ -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<string, number>
|
||||
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 (
|
||||
<ItemGroup className={cn('gap-1', className)}>
|
||||
@@ -206,6 +215,18 @@ export function ServiceIpList({
|
||||
{ip}
|
||||
</TruncatedText>
|
||||
{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>
|
||||
</ItemContent>
|
||||
{onToggleIp ? (
|
||||
|
||||
@@ -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,13 +42,64 @@ 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).
|
||||
* Preview: https://reui.io/preview/base/settings-8
|
||||
* Frame: https://reui.io/docs/components/base/frame
|
||||
* IconTile: https://reui.io/docs/components/base/icon-tile
|
||||
* Header fill: FramePanel `bg-muted` (overrides `--frame-panel-bg`; see frame.tsx).
|
||||
*/
|
||||
|
||||
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 {
|
||||
service: ServiceView
|
||||
togglingId: number | null
|
||||
@@ -67,28 +125,31 @@ export function ServiceUnitCard({
|
||||
|
||||
return (
|
||||
<Frame stacked spacing="sm" className="h-full min-w-0">
|
||||
<FramePanel fit>
|
||||
<FramePanel fit className="bg-muted">
|
||||
<Item size="sm" className="w-full min-w-0 flex-nowrap border-0 p-0">
|
||||
<ItemMedia>
|
||||
<IconTile
|
||||
variant="elevated"
|
||||
size="sm"
|
||||
className="text-muted-foreground"
|
||||
className="text-foreground"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<ServerIcon />
|
||||
</IconTile>
|
||||
</ItemMedia>
|
||||
<ItemContent className="min-w-0 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>
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<FrameTitle className="min-w-0 truncate text-base font-semibold">
|
||||
<Link
|
||||
to="/services/$serviceId"
|
||||
params={{ serviceId: String(service.id) }}
|
||||
className="hover:underline"
|
||||
>
|
||||
{service.name}
|
||||
</Link>
|
||||
</FrameTitle>
|
||||
<LbModeTile mode={service.lb_mode} />
|
||||
</div>
|
||||
<div className="flex min-w-0 items-center gap-1">
|
||||
<FrameDescription className="min-w-0 truncate font-mono text-xs">
|
||||
{primaryDomain}
|
||||
@@ -182,6 +243,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)
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
Vendored
+26
@@ -176,6 +176,8 @@ interface ServiceView$1 {
|
||||
health_latency_ms: number | null;
|
||||
ip_health: ServiceIpHealth$1[];
|
||||
ip_enabled: Record<string, boolean>;
|
||||
lb_mode: LbMode;
|
||||
active_ips: string[];
|
||||
}
|
||||
interface SyncJob {
|
||||
id: string;
|
||||
@@ -824,6 +826,12 @@ declare const serviceViewSchema: z.ZodObject<{
|
||||
colo: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
||||
}, z.core.$strip>>>;
|
||||
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>;
|
||||
declare const serviceGroupViewSchema: z.ZodObject<{
|
||||
id: z.ZodNumber;
|
||||
@@ -1013,6 +1021,12 @@ declare const serviceGroupViewSchema: z.ZodObject<{
|
||||
colo: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
||||
}, z.core.$strip>>>;
|
||||
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>>>;
|
||||
health_status: z.ZodDefault<z.ZodEnum<{
|
||||
unknown: "unknown";
|
||||
@@ -1211,6 +1225,12 @@ declare const serviceGroupsResponseSchema: z.ZodObject<{
|
||||
colo: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
||||
}, z.core.$strip>>>;
|
||||
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>>>;
|
||||
health_status: z.ZodDefault<z.ZodEnum<{
|
||||
unknown: "unknown";
|
||||
@@ -1360,6 +1380,12 @@ declare const serviceGroupsResponseSchema: z.ZodObject<{
|
||||
colo: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
||||
}, z.core.$strip>>>;
|
||||
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 domainSchema: z.ZodObject<{
|
||||
|
||||
Vendored
+3
-1
@@ -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([]),
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -222,6 +222,8 @@ export interface ServiceView {
|
||||
health_latency_ms: number | null;
|
||||
ip_health: ServiceIpHealth[];
|
||||
ip_enabled: Record<string, boolean>;
|
||||
lb_mode: LbMode;
|
||||
active_ips: string[];
|
||||
}
|
||||
|
||||
export interface GroupWithStats extends Group {
|
||||
|
||||
Reference in New Issue
Block a user