feat(services): сделать multi-FQDN привязки first-class в UI
Исправить prune при domains: [], показать все FQDN в каталоге/kanban, улучшить sheet привязок и панель на домене; убрать мёртвый код. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1172,97 +1172,95 @@ export async function updateConfig(
|
||||
let removedBindingIds: number[] = [];
|
||||
|
||||
if (req.domains) {
|
||||
if (req.domains.length > 0) {
|
||||
for (const input of req.domains) {
|
||||
const fqdn = input.fqdn.trim();
|
||||
if (!fqdn) continue;
|
||||
const targetCname = bindingTargetCname(input);
|
||||
const targetIps = bindingTargetIps(input);
|
||||
if (!targetCname) {
|
||||
validateTargetIpsInPool(targetIps, ips);
|
||||
} else if (targetIps.length > 0) {
|
||||
throw AppError.validation(
|
||||
`укажите либо IP, либо CNAME для ${fqdn}`,
|
||||
);
|
||||
}
|
||||
|
||||
const { zoneName, hostname } = parseFqdn(fqdn, knownZones);
|
||||
const domainId = await resolveDomainId(db, cf, zoneName);
|
||||
|
||||
const binding =
|
||||
repos.findBinding(db, id, domainId, hostname) ??
|
||||
repos.insertBinding(db, domainId, id, hostname, null);
|
||||
|
||||
keptBindingIds.push(binding.id);
|
||||
|
||||
const targetIpWeights = input.target_ip_weights ?? {};
|
||||
const targetIpPriorities = input.target_ip_priorities ?? {};
|
||||
const bindingIpEntries = (targetCname ? [] : targetIps).map((ip) => ({
|
||||
ip,
|
||||
weight: targetIpWeights[ip] ?? 1,
|
||||
priority: targetIpPriorities[ip] ?? 1,
|
||||
}));
|
||||
repos.replaceBindingIpsWithMeta(db, binding.id, bindingIpEntries);
|
||||
repos.setBindingCnameTarget(db, binding.id, targetCname);
|
||||
|
||||
if (
|
||||
input.lb_mode !== undefined ||
|
||||
input.health_check_enabled !== undefined ||
|
||||
input.health_check_type !== undefined ||
|
||||
input.health_check_port !== undefined ||
|
||||
input.health_check_path !== undefined ||
|
||||
input.health_check_expected_status !== undefined ||
|
||||
input.health_check_interval_sec !== undefined ||
|
||||
input.health_check_timeout_ms !== undefined ||
|
||||
input.health_check_verify_tls !== undefined
|
||||
) {
|
||||
repos.updateBindingLbConfig(db, binding.id, {
|
||||
lb_mode: input.lb_mode,
|
||||
health_check_enabled: input.health_check_enabled,
|
||||
health_check_type: input.health_check_type,
|
||||
health_check_port: input.health_check_port,
|
||||
health_check_path: input.health_check_path,
|
||||
health_check_expected_status: input.health_check_expected_status,
|
||||
health_check_interval_sec: input.health_check_interval_sec,
|
||||
health_check_timeout_ms: input.health_check_timeout_ms,
|
||||
health_check_verify_tls: input.health_check_verify_tls,
|
||||
});
|
||||
}
|
||||
|
||||
if (pushDns) {
|
||||
let effectiveIps = targetIps;
|
||||
const refreshedBinding = repos.getBinding(db, binding.id);
|
||||
if (refreshedBinding.health_check_enabled) {
|
||||
const activeIps = computeActiveIps(db, "binding", binding.id);
|
||||
if (activeIps.length > 0) {
|
||||
effectiveIps = activeIps;
|
||||
}
|
||||
}
|
||||
await syncBindingDns(
|
||||
db,
|
||||
cf,
|
||||
binding.id,
|
||||
domainId,
|
||||
hostname,
|
||||
effectiveIps,
|
||||
targetCname,
|
||||
);
|
||||
}
|
||||
for (const input of req.domains) {
|
||||
const fqdn = input.fqdn.trim();
|
||||
if (!fqdn) continue;
|
||||
const targetCname = bindingTargetCname(input);
|
||||
const targetIps = bindingTargetIps(input);
|
||||
if (!targetCname) {
|
||||
validateTargetIpsInPool(targetIps, ips);
|
||||
} else if (targetIps.length > 0) {
|
||||
throw AppError.validation(
|
||||
`укажите либо IP, либо CNAME для ${fqdn}`,
|
||||
);
|
||||
}
|
||||
|
||||
const removed = repos.bindingsToRemove(db, id, keptBindingIds);
|
||||
removedBindingIds = removed.map((binding) => binding.id);
|
||||
for (const binding of removed) {
|
||||
await cleanupBindingDns(
|
||||
const { zoneName, hostname } = parseFqdn(fqdn, knownZones);
|
||||
const domainId = await resolveDomainId(db, cf, zoneName);
|
||||
|
||||
const binding =
|
||||
repos.findBinding(db, id, domainId, hostname) ??
|
||||
repos.insertBinding(db, domainId, id, hostname, null);
|
||||
|
||||
keptBindingIds.push(binding.id);
|
||||
|
||||
const targetIpWeights = input.target_ip_weights ?? {};
|
||||
const targetIpPriorities = input.target_ip_priorities ?? {};
|
||||
const bindingIpEntries = (targetCname ? [] : targetIps).map((ip) => ({
|
||||
ip,
|
||||
weight: targetIpWeights[ip] ?? 1,
|
||||
priority: targetIpPriorities[ip] ?? 1,
|
||||
}));
|
||||
repos.replaceBindingIpsWithMeta(db, binding.id, bindingIpEntries);
|
||||
repos.setBindingCnameTarget(db, binding.id, targetCname);
|
||||
|
||||
if (
|
||||
input.lb_mode !== undefined ||
|
||||
input.health_check_enabled !== undefined ||
|
||||
input.health_check_type !== undefined ||
|
||||
input.health_check_port !== undefined ||
|
||||
input.health_check_path !== undefined ||
|
||||
input.health_check_expected_status !== undefined ||
|
||||
input.health_check_interval_sec !== undefined ||
|
||||
input.health_check_timeout_ms !== undefined ||
|
||||
input.health_check_verify_tls !== undefined
|
||||
) {
|
||||
repos.updateBindingLbConfig(db, binding.id, {
|
||||
lb_mode: input.lb_mode,
|
||||
health_check_enabled: input.health_check_enabled,
|
||||
health_check_type: input.health_check_type,
|
||||
health_check_port: input.health_check_port,
|
||||
health_check_path: input.health_check_path,
|
||||
health_check_expected_status: input.health_check_expected_status,
|
||||
health_check_interval_sec: input.health_check_interval_sec,
|
||||
health_check_timeout_ms: input.health_check_timeout_ms,
|
||||
health_check_verify_tls: input.health_check_verify_tls,
|
||||
});
|
||||
}
|
||||
|
||||
if (pushDns) {
|
||||
let effectiveIps = targetIps;
|
||||
const refreshedBinding = repos.getBinding(db, binding.id);
|
||||
if (refreshedBinding.health_check_enabled) {
|
||||
const activeIps = computeActiveIps(db, "binding", binding.id);
|
||||
if (activeIps.length > 0) {
|
||||
effectiveIps = activeIps;
|
||||
}
|
||||
}
|
||||
await syncBindingDns(
|
||||
db,
|
||||
cf,
|
||||
binding.id,
|
||||
binding.domain_id,
|
||||
binding.hostname,
|
||||
domainId,
|
||||
hostname,
|
||||
effectiveIps,
|
||||
targetCname,
|
||||
);
|
||||
}
|
||||
repos.deleteBindingsExcept(db, id, keptBindingIds);
|
||||
}
|
||||
|
||||
const removed = repos.bindingsToRemove(db, id, keptBindingIds);
|
||||
removedBindingIds = removed.map((binding) => binding.id);
|
||||
for (const binding of removed) {
|
||||
await cleanupBindingDns(
|
||||
db,
|
||||
cf,
|
||||
binding.id,
|
||||
binding.domain_id,
|
||||
binding.hostname,
|
||||
);
|
||||
}
|
||||
repos.deleteBindingsExcept(db, id, keptBindingIds);
|
||||
} else if (ipsUpdated) {
|
||||
const bindings = repos.listBindingsByService(db, id);
|
||||
for (const binding of bindings) {
|
||||
@@ -1278,8 +1276,11 @@ export async function updateConfig(
|
||||
}
|
||||
|
||||
service = repos.getService(db, id);
|
||||
const remainingBindings = repos.listBindingsByService(db, id);
|
||||
if (shouldPushDns(db, service)) {
|
||||
await syncServiceBindingsToDns(db, cf, id);
|
||||
if (remainingBindings.length > 0) {
|
||||
await syncServiceBindingsToDns(db, cf, id);
|
||||
}
|
||||
await syncGroupDomainForService(db, cf, id);
|
||||
} else if (
|
||||
req.domains &&
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createMemoryDb, repos, runMigrations } from "@cfdm/db";
|
||||
import type { CloudflareClient } from "../src/lib/cf-client.js";
|
||||
import { updateConfig } from "../src/services/service-config-service.js";
|
||||
|
||||
function mockCf(): CloudflareClient {
|
||||
return {
|
||||
listDnsRecords: async () => [],
|
||||
createDnsRecord: async () => ({
|
||||
id: "cf-new",
|
||||
type: "A",
|
||||
name: "api.example.com",
|
||||
content: "10.0.0.1",
|
||||
ttl: 1,
|
||||
proxied: false,
|
||||
}),
|
||||
updateDnsRecord: async () => ({
|
||||
id: "cf-upd",
|
||||
type: "A",
|
||||
name: "api.example.com",
|
||||
content: "10.0.0.1",
|
||||
ttl: 1,
|
||||
proxied: false,
|
||||
}),
|
||||
deleteDnsRecord: async () => undefined,
|
||||
verifyToken: async () => true,
|
||||
listZones: async () => [],
|
||||
} as unknown as CloudflareClient;
|
||||
}
|
||||
|
||||
function setupDb() {
|
||||
const { db, sqlite } = createMemoryDb();
|
||||
runMigrations(sqlite);
|
||||
return db;
|
||||
}
|
||||
|
||||
describe("service bindings prune", () => {
|
||||
it("updateConfig with domains: [] removes all bindings", async () => {
|
||||
const db = setupDb();
|
||||
const cf = mockCf();
|
||||
|
||||
const domain = repos.createDomain(db, null, "example.com", "cf-zone-example");
|
||||
const service = repos.createService(db, "Web", "web");
|
||||
repos.replaceServiceIps(db, service.id, ["10.0.0.1"]);
|
||||
const binding = repos.insertBinding(db, domain.id, service.id, "api", null);
|
||||
repos.replaceBindingIps(db, binding.id, ["10.0.0.1"]);
|
||||
|
||||
expect(repos.listBindingsByService(db, service.id)).toHaveLength(1);
|
||||
|
||||
const view = await updateConfig(db, cf, service.id, {
|
||||
ips: ["10.0.0.1"],
|
||||
domains: [],
|
||||
});
|
||||
|
||||
expect(repos.listBindingsByService(db, service.id)).toHaveLength(0);
|
||||
expect(view.domains).toEqual([]);
|
||||
});
|
||||
|
||||
it("updateConfig keeps multiple FQDN bindings", async () => {
|
||||
const db = setupDb();
|
||||
const cf = mockCf();
|
||||
|
||||
repos.createDomain(db, null, "a.example", "cf-zone-a");
|
||||
repos.createDomain(db, null, "b.example", "cf-zone-b");
|
||||
const service = repos.createService(db, "Edge", "edge");
|
||||
repos.replaceServiceIps(db, service.id, ["10.0.0.2"]);
|
||||
|
||||
const view = await updateConfig(db, cf, service.id, {
|
||||
ips: ["10.0.0.2"],
|
||||
domains: [
|
||||
{ fqdn: "api.a.example", target_ips: ["10.0.0.2"] },
|
||||
{ fqdn: "www.b.example", target_ips: ["10.0.0.2"] },
|
||||
],
|
||||
});
|
||||
|
||||
expect(view.domains).toHaveLength(2);
|
||||
const bindings = repos.listBindingsByService(db, service.id);
|
||||
expect(bindings).toHaveLength(2);
|
||||
expect(bindings.map((b) => b.hostname).sort()).toEqual(["api", "www"]);
|
||||
});
|
||||
});
|
||||
@@ -74,7 +74,6 @@ export default defineConfig([
|
||||
'**/services-board/*-row.tsx',
|
||||
'**/groups-board/*-row.tsx',
|
||||
'**/layout/app-shell.tsx',
|
||||
'**/service-binding-card.tsx',
|
||||
],
|
||||
rules: {
|
||||
'no-restricted-syntax': 'off',
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
import type { ComponentProps } from 'react'
|
||||
import {
|
||||
Avatar,
|
||||
AvatarBadge,
|
||||
AvatarFallback,
|
||||
AvatarGroup,
|
||||
AvatarGroupCount,
|
||||
AvatarImage,
|
||||
} from '@cfdm/ui/components/avatar'
|
||||
import { cn } from '@cfdm/ui/lib/utils'
|
||||
|
||||
export type AppAvatarProps = ComponentProps<typeof Avatar>
|
||||
export type AppAvatarImageProps = ComponentProps<typeof AvatarImage>
|
||||
export type AppAvatarFallbackProps = ComponentProps<typeof AvatarFallback>
|
||||
export type AppAvatarBadgeProps = ComponentProps<typeof AvatarBadge>
|
||||
export type AppAvatarGroupProps = ComponentProps<typeof AvatarGroup>
|
||||
export type AppAvatarGroupCountProps = ComponentProps<typeof AvatarGroupCount>
|
||||
|
||||
export function AppAvatar({ className, ...props }: AppAvatarProps) {
|
||||
return <Avatar className={cn(className)} {...props} />
|
||||
}
|
||||
|
||||
export function AppAvatarImage({ className, ...props }: AppAvatarImageProps) {
|
||||
return <AvatarImage className={cn(className)} {...props} />
|
||||
}
|
||||
|
||||
export function AppAvatarFallback({
|
||||
className,
|
||||
...props
|
||||
}: AppAvatarFallbackProps) {
|
||||
return <AvatarFallback className={cn(className)} {...props} />
|
||||
}
|
||||
|
||||
export function AppAvatarBadge({ className, ...props }: AppAvatarBadgeProps) {
|
||||
return <AvatarBadge className={cn(className)} {...props} />
|
||||
}
|
||||
|
||||
export function AppAvatarGroup({ className, ...props }: AppAvatarGroupProps) {
|
||||
return <AvatarGroup className={cn(className)} {...props} />
|
||||
}
|
||||
|
||||
export function AppAvatarGroupCount({
|
||||
className,
|
||||
...props
|
||||
}: AppAvatarGroupCountProps) {
|
||||
return <AvatarGroupCount className={cn(className)} {...props} />
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import type { ComponentProps } from 'react'
|
||||
import { Separator } from '@cfdm/ui/components/separator'
|
||||
import { cn } from '@cfdm/ui/lib/utils'
|
||||
|
||||
export type AppSeparatorProps = ComponentProps<typeof Separator>
|
||||
|
||||
export function AppSeparator({ className, ...props }: AppSeparatorProps) {
|
||||
return <Separator className={cn(className)} {...props} />
|
||||
}
|
||||
@@ -1,19 +1,8 @@
|
||||
import { useMemo } from 'react'
|
||||
import type { ColumnDef } from '@tanstack/react-table'
|
||||
import { MoreHorizontalIcon, SearchIcon } from 'lucide-react'
|
||||
import { SearchIcon } from 'lucide-react'
|
||||
|
||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||
import { createFilter, type FilterFieldConfig } from '@/components/reui/filters'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import type { ServiceView } from '@/lib/schemas'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import { Switch } from '@cfdm/ui/components/switch'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@cfdm/ui/components/dropdown-menu'
|
||||
|
||||
export interface ServiceCatalogRow {
|
||||
id: number
|
||||
@@ -50,126 +39,18 @@ export function useServiceFilterFields() {
|
||||
{
|
||||
key: 'name',
|
||||
label: 'Название',
|
||||
icon: <SearchIcon className="size-3.5" aria-hidden />,
|
||||
type: 'text',
|
||||
className: 'w-52',
|
||||
placeholder: 'Поиск…',
|
||||
icon: <SearchIcon className="size-4" />,
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
}
|
||||
|
||||
export function serviceFilterFieldValue(item: ServiceCatalogRow, field: string) {
|
||||
switch (field) {
|
||||
case 'name':
|
||||
return `${item.name} ${item.slug} ${item.groupName ?? ''}`.toLowerCase()
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
export function useServiceColumns({
|
||||
onEdit,
|
||||
onDelete,
|
||||
onToggle,
|
||||
togglingId,
|
||||
}: {
|
||||
onEdit: (service: ServiceView) => void
|
||||
onDelete: (service: ServiceView) => void
|
||||
onToggle: (serviceId: number, enabled: boolean) => void
|
||||
togglingId: number | null
|
||||
}) {
|
||||
const columns = useMemo<ColumnDef<ServiceCatalogRow>[]>(
|
||||
() => [
|
||||
{
|
||||
id: 'name',
|
||||
accessorKey: 'name',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Название" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<button
|
||||
type="button"
|
||||
className="text-foreground max-w-64 truncate text-left font-medium hover:underline"
|
||||
onClick={() => onEdit(row.original.service)}
|
||||
>
|
||||
{row.original.name}
|
||||
</button>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'group',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Группа" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{row.original.groupName ?? 'Без группы'}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'enabled',
|
||||
accessorKey: 'enabled',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Статус" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
checked={row.original.enabled}
|
||||
disabled={togglingId === row.original.id}
|
||||
onCheckedChange={(checked) =>
|
||||
onToggle(row.original.id, Boolean(checked))
|
||||
}
|
||||
aria-label={
|
||||
row.original.enabled ? 'Выключить сервис' : 'Включить сервис'
|
||||
}
|
||||
/>
|
||||
<StatusBadge
|
||||
status={row.original.enabled ? 'active' : 'disabled'}
|
||||
label={row.original.enabled ? 'Вкл' : 'Выкл'}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
enableSorting: false,
|
||||
header: () => <span className="sr-only">Действия</span>,
|
||||
cell: ({ row }) => (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-8"
|
||||
aria-label="Действия"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<MoreHorizontalIcon className="size-4" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => onEdit(row.original.service)}>
|
||||
Изменить
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onClick={() => onDelete(row.original.service)}
|
||||
>
|
||||
Удалить
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
),
|
||||
},
|
||||
],
|
||||
[onEdit, onDelete, onToggle, togglingId],
|
||||
)
|
||||
|
||||
return { columns }
|
||||
export function serviceFilterFieldValue(
|
||||
item: ServiceCatalogRow,
|
||||
field: string,
|
||||
): string {
|
||||
if (field === 'name') return item.name.toLowerCase()
|
||||
return ''
|
||||
}
|
||||
|
||||
+38
-7
@@ -24,7 +24,7 @@ import {
|
||||
ItemTitle,
|
||||
} from '@cfdm/ui/components/item'
|
||||
|
||||
interface DomainBindingsCardProps {
|
||||
interface DomainBindingsPanelProps {
|
||||
bindings: ServiceBinding[]
|
||||
}
|
||||
|
||||
@@ -67,11 +67,24 @@ function HostnameIpsHealth({
|
||||
)
|
||||
}
|
||||
|
||||
function uniqueServices(bindings: ServiceBinding[]): string[] {
|
||||
return [...new Set(bindings.map((b) => b.service_name))]
|
||||
function uniqueServiceEntries(
|
||||
bindings: ServiceBinding[],
|
||||
): { serviceId: number; serviceName: string }[] {
|
||||
const seen = new Set<number>()
|
||||
const entries: { serviceId: number; serviceName: string }[] = []
|
||||
for (const binding of bindings) {
|
||||
if (seen.has(binding.service_id)) continue
|
||||
seen.add(binding.service_id)
|
||||
entries.push({
|
||||
serviceId: binding.service_id,
|
||||
serviceName: binding.service_name,
|
||||
})
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
export function DomainBindingsCard({ bindings }: DomainBindingsCardProps) {
|
||||
/** Panel of service bindings grouped by hostname for a domain zone. */
|
||||
export function DomainBindingsPanel({ bindings }: DomainBindingsPanelProps) {
|
||||
const byHostname = groupBindingsByHostname(bindings)
|
||||
const entries = [...byHostname.entries()]
|
||||
|
||||
@@ -103,13 +116,25 @@ export function DomainBindingsCard({ bindings }: DomainBindingsCardProps) {
|
||||
),
|
||||
),
|
||||
]
|
||||
const services = uniqueServiceEntries(groupBindings)
|
||||
return (
|
||||
<div key={hostname}>
|
||||
<Item size="sm" variant="muted" className="border-0 px-0">
|
||||
<ItemContent className="gap-1">
|
||||
<ItemTitle className="font-mono">{hostname}</ItemTitle>
|
||||
<div className="text-muted-foreground text-sm">
|
||||
{uniqueServices(groupBindings).join(', ')}
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{services.map((service) => (
|
||||
<Link
|
||||
key={service.serviceId}
|
||||
to="/services"
|
||||
search={{ serviceId: service.serviceId }}
|
||||
className="inline-flex"
|
||||
>
|
||||
<Badge variant="outline" size="xs">
|
||||
{service.serviceName}
|
||||
</Badge>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
<HostnameIpsHealth
|
||||
bindings={groupBindings}
|
||||
@@ -134,7 +159,12 @@ export function DomainBindingsCard({ bindings }: DomainBindingsCardProps) {
|
||||
variant="link"
|
||||
className="h-auto p-0"
|
||||
nativeButton={false}
|
||||
render={<Link to="/services" search={{ domainId: bindings[0]?.domain_id }} />}
|
||||
render={
|
||||
<Link
|
||||
to="/services"
|
||||
search={{ domainId: bindings[0]?.domain_id }}
|
||||
/>
|
||||
}
|
||||
>
|
||||
К сервисам
|
||||
</Button>
|
||||
@@ -142,3 +172,4 @@ export function DomainBindingsCard({ bindings }: DomainBindingsCardProps) {
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 { serviceDisplayFqdn } from '@/lib/service-utils'
|
||||
import { ServiceFqdnList } from '@/components/services/service-fqdn-list'
|
||||
import type { ServiceView } from '@/lib/schemas'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import {
|
||||
@@ -44,8 +44,6 @@ export function ServiceKanbanCard({
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: ServiceKanbanCardProps) {
|
||||
const fqdn = serviceDisplayFqdn(service)
|
||||
|
||||
return (
|
||||
<Item
|
||||
variant="outline"
|
||||
@@ -80,13 +78,7 @@ export function ServiceKanbanCard({
|
||||
</ItemHeader>
|
||||
|
||||
<ItemContent className="min-w-0 gap-2">
|
||||
{fqdn && fqdn !== '—' ? (
|
||||
<span className="text-muted-foreground truncate font-mono text-xs">
|
||||
{fqdn}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground text-xs">FQDN не задан</span>
|
||||
)}
|
||||
<ServiceFqdnList service={service} />
|
||||
</ItemContent>
|
||||
|
||||
<ItemFooter className="min-w-0 justify-between gap-2">
|
||||
|
||||
@@ -1,127 +0,0 @@
|
||||
import { useDraggable } from '@dnd-kit/core'
|
||||
import { CSS } from '@dnd-kit/utilities'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import {
|
||||
Frame,
|
||||
FrameFooter,
|
||||
FrameHeader,
|
||||
FramePanel,
|
||||
FrameTitle,
|
||||
} from '@/components/reui/frame'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import {
|
||||
Field,
|
||||
FieldGroup,
|
||||
FieldLabel,
|
||||
} from '@cfdm/ui/components/field'
|
||||
import { Input } from '@cfdm/ui/components/input'
|
||||
import { cn } from '@cfdm/ui/lib/utils'
|
||||
import type { ServiceBinding } from '@/lib/schemas'
|
||||
|
||||
interface ServiceBindingCardProps {
|
||||
binding: ServiceBinding
|
||||
onIpChange: (id: number, targetIp: string) => void
|
||||
onHostnameChange: (id: number, hostname: string) => void
|
||||
}
|
||||
|
||||
export function ServiceBindingCard({
|
||||
binding,
|
||||
onIpChange,
|
||||
onHostnameChange,
|
||||
}: ServiceBindingCardProps) {
|
||||
const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({
|
||||
id: String(binding.id),
|
||||
})
|
||||
const [ip, setIp] = useState(binding.target_ip ?? '')
|
||||
const [hostname, setHostname] = useState(binding.hostname)
|
||||
|
||||
useEffect(() => {
|
||||
setIp(binding.target_ip ?? '')
|
||||
setHostname(binding.hostname)
|
||||
}, [binding.target_ip, binding.hostname])
|
||||
|
||||
const style = transform
|
||||
? { transform: CSS.Translate.toString(transform) }
|
||||
: undefined
|
||||
|
||||
return (
|
||||
<Frame
|
||||
ref={setNodeRef}
|
||||
dense
|
||||
spacing="sm"
|
||||
style={style}
|
||||
className={cn(
|
||||
'cursor-grab active:cursor-grabbing',
|
||||
isDragging && 'opacity-60 shadow-lg',
|
||||
)}
|
||||
{...listeners}
|
||||
{...attributes}
|
||||
>
|
||||
<FrameHeader className="flex-row items-start justify-between gap-2">
|
||||
<FrameTitle>{binding.zone_name}</FrameTitle>
|
||||
{binding.group_name ? (
|
||||
<Badge variant="secondary">{binding.group_name}</Badge>
|
||||
) : (
|
||||
<Badge variant="outline">Без группы</Badge>
|
||||
)}
|
||||
</FrameHeader>
|
||||
<FramePanel className="flex flex-col gap-3">
|
||||
<FieldGroup className="flex flex-col gap-3">
|
||||
<Field>
|
||||
<FieldLabel htmlFor={`hostname-${binding.id}`}>Hostname</FieldLabel>
|
||||
<Input
|
||||
id={`hostname-${binding.id}`}
|
||||
value={hostname}
|
||||
placeholder="@"
|
||||
className="font-mono tabular-nums"
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onChange={(e) => setHostname(e.target.value)}
|
||||
onBlur={() => {
|
||||
if (hostname !== binding.hostname) {
|
||||
onHostnameChange(binding.id, hostname)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor={`ip-${binding.id}`}>IPv4</FieldLabel>
|
||||
<Input
|
||||
id={`ip-${binding.id}`}
|
||||
value={ip}
|
||||
placeholder="192.168.1.1"
|
||||
className="font-mono tabular-nums"
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onChange={(e) => setIp(e.target.value)}
|
||||
onBlur={() => {
|
||||
if (ip !== (binding.target_ip ?? '')) {
|
||||
onIpChange(binding.id, ip)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge>{binding.service_name}</Badge>
|
||||
{binding.sync_status && <StatusBadge status={binding.sync_status} />}
|
||||
</div>
|
||||
</FramePanel>
|
||||
<FrameFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
render={
|
||||
<Link
|
||||
to="/domains/$domainId"
|
||||
params={{ domainId: String(binding.domain_id) }}
|
||||
/>
|
||||
}
|
||||
>
|
||||
Домен
|
||||
</Button>
|
||||
</FrameFooter>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
@@ -18,7 +18,9 @@ import type {
|
||||
ServiceView,
|
||||
UpdateServiceConfigInput,
|
||||
} from '@/lib/schemas'
|
||||
import { bindingToFqdn } from '@/lib/parse-fqdn'
|
||||
import { bindingToFqdn, parseFqdn } from '@/lib/parse-fqdn'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
@@ -247,8 +249,7 @@ export function ServiceEditSheet({
|
||||
setBindings((current) => current.filter((_, i) => i !== index))
|
||||
}
|
||||
|
||||
function handleFqdnChange(index: number, tags: string[]) {
|
||||
const fqdn = tags[0] ?? ''
|
||||
function handleFqdnChange(index: number, fqdn: string) {
|
||||
setBindings((current) =>
|
||||
current.map((item, i) => (i === index ? { ...item, fqdn } : item)),
|
||||
)
|
||||
@@ -340,14 +341,22 @@ export function ServiceEditSheet({
|
||||
|
||||
function handleSubmit() {
|
||||
const domains = buildDomainsPayload(bindings)
|
||||
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,
|
||||
...(domains.length > 0 ? { domains } : {}),
|
||||
}
|
||||
if (mode === 'create') {
|
||||
onCreate?.({
|
||||
@@ -385,8 +394,9 @@ export function ServiceEditSheet({
|
||||
<SheetHeader className="shrink-0 border-b pb-4">
|
||||
<SheetTitle>{isCreate ? 'Новый сервис' : 'Редактирование сервиса'}</SheetTitle>
|
||||
<SheetDescription>
|
||||
Настройте параметры сервиса и привязки FQDN → IP или CNAME. Зона определяется из FQDN
|
||||
автоматически.
|
||||
Настройте параметры сервиса и привязки FQDN → IP или CNAME. Один
|
||||
сервис может иметь несколько FQDN в разных зонах; зона определяется
|
||||
из FQDN автоматически.
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
@@ -502,7 +512,7 @@ export function ServiceEditSheet({
|
||||
<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
|
||||
Несколько FQDN в разных зонах → IP или CNAME для DNS Cloudflare
|
||||
</p>
|
||||
<Button type="button" variant="outline" size="sm" onClick={handleAddBinding}>
|
||||
<PlusIcon data-icon="inline-start" />
|
||||
@@ -514,7 +524,7 @@ export function ServiceEditSheet({
|
||||
<EmptyState
|
||||
icon={Link2Icon}
|
||||
title="Нет привязок"
|
||||
description="Необязательно. Пример: newdom.ivx.su — зона ivx.su определится автоматически."
|
||||
description="Необязательно. Можно добавить несколько FQDN: api.ivx.su и www.other.su — зоны определятся автоматически."
|
||||
centered={false}
|
||||
action={
|
||||
<Button type="button" variant="outline" size="sm" onClick={handleAddBinding}>
|
||||
@@ -533,22 +543,25 @@ export function ServiceEditSheet({
|
||||
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-end gap-2">
|
||||
<Field className="min-w-0 flex-1">
|
||||
<FieldLabel htmlFor={`binding-fqdn-${index}`}>FQDN</FieldLabel>
|
||||
<TaggedInput
|
||||
id={`binding-fqdn-${index}`}
|
||||
value={binding.fqdn ? [binding.fqdn] : []}
|
||||
onChange={(tags) => handleFqdnChange(index, tags)}
|
||||
placeholder={
|
||||
zoneHints[0] ? `newdom.${zoneHints[0]}` : 'newdom.ivx.su'
|
||||
}
|
||||
maxItems={1}
|
||||
/>
|
||||
</Field>
|
||||
<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"
|
||||
@@ -560,6 +573,20 @@ export function ServiceEditSheet({
|
||||
<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
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { TruncatedText } from '@/components/truncated-text'
|
||||
import { serviceDisplayFqdns } from '@/lib/service-utils'
|
||||
import type { ServiceView } from '@/lib/schemas'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@cfdm/ui/components/tooltip'
|
||||
import { cn } from '@cfdm/ui/lib/utils'
|
||||
|
||||
interface ServiceFqdnListProps {
|
||||
service: ServiceView
|
||||
className?: string
|
||||
emptyLabel?: string
|
||||
}
|
||||
|
||||
export function ServiceFqdnList({
|
||||
service,
|
||||
className,
|
||||
emptyLabel = 'FQDN не задан',
|
||||
}: ServiceFqdnListProps) {
|
||||
const fqdns = serviceDisplayFqdns(service)
|
||||
if (fqdns.length === 0) {
|
||||
return (
|
||||
<span className={cn('text-muted-foreground text-xs', className)}>
|
||||
{emptyLabel}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
const [first, ...rest] = fqdns
|
||||
const extraCount = rest.length
|
||||
|
||||
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">
|
||||
{first}
|
||||
</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">
|
||||
{fqdns.map((fqdn) => (
|
||||
<li key={fqdn}>{fqdn}</li>
|
||||
))}
|
||||
</ul>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -10,8 +10,8 @@ import {
|
||||
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 { serviceDisplayFqdn } from '@/lib/service-utils'
|
||||
import type { ServiceGroupView, ServiceView } from '@/lib/schemas'
|
||||
import { ServiceFqdnList } from '@/components/services/service-fqdn-list'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -126,9 +126,7 @@ export function createServicesGroupedColumns({
|
||||
<span className="truncate text-sm font-medium">
|
||||
{original.service.name}
|
||||
</span>
|
||||
<span className="text-muted-foreground truncate font-mono text-xs">
|
||||
{serviceDisplayFqdn(original.service)}
|
||||
</span>
|
||||
<ServiceFqdnList service={original.service} emptyLabel="—" />
|
||||
</div>
|
||||
)
|
||||
},
|
||||
|
||||
@@ -1,14 +1,22 @@
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { useEffect, useMemo } from 'react'
|
||||
import { useForm, Controller } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { z } from 'zod'
|
||||
import type { CertMonitoring } from '@cfdm/shared'
|
||||
import type { ServiceView, SubdomainRecord } from '@/lib/schemas'
|
||||
import type { SubdomainServiceLink } from '@/hooks/use-domain-page'
|
||||
import { certMonitoringOptions } from '@/lib/cert-monitoring'
|
||||
import { formatServiceGroupLabel } from '@/lib/service-utils'
|
||||
import { FormSheet } from '@/components/form-sheet'
|
||||
import { FormFieldSimple } from '@/components/form-field'
|
||||
import { LoadingButton } from '@/components/loading-button'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import {
|
||||
Alert,
|
||||
AlertDescription,
|
||||
AlertTitle,
|
||||
} from '@/components/reui/alert'
|
||||
import { FieldGroup } from '@cfdm/ui/components/field'
|
||||
import { Input } from '@cfdm/ui/components/input'
|
||||
import {
|
||||
@@ -34,6 +42,7 @@ interface SubdomainEditSheetProps {
|
||||
services: ServiceView[]
|
||||
serviceGroupById: Map<number, string | null>
|
||||
currentServiceId: string
|
||||
serviceLinks?: SubdomainServiceLink[]
|
||||
open: boolean
|
||||
isSaving: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
@@ -47,6 +56,7 @@ export function SubdomainEditSheet({
|
||||
services,
|
||||
serviceGroupById,
|
||||
currentServiceId,
|
||||
serviceLinks = [],
|
||||
open,
|
||||
isSaving,
|
||||
onOpenChange,
|
||||
@@ -104,6 +114,7 @@ export function SubdomainEditSheet({
|
||||
const certMonitoring = form.watch('certMonitoring')
|
||||
const certHint =
|
||||
certMonitoringOptions.find((o) => o.value === certMonitoring)?.description
|
||||
const hasMultipleServices = mode === 'edit' && serviceLinks.length > 1
|
||||
|
||||
function handleSubmit(values: SubdomainEditValues) {
|
||||
onSubmit({
|
||||
@@ -155,6 +166,34 @@ export function SubdomainEditSheet({
|
||||
</FormFieldSimple>
|
||||
{mode === 'edit' ? (
|
||||
<>
|
||||
{hasMultipleServices ? (
|
||||
<Alert variant="warning">
|
||||
<AlertTitle>Несколько сервисов на hostname</AlertTitle>
|
||||
<AlertDescription className="flex flex-col gap-2">
|
||||
<p>
|
||||
Здесь редактируется основной сервис. Остальные привязки
|
||||
управляются в карточке сервиса.
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{serviceLinks.map((link) => (
|
||||
<Link
|
||||
key={link.serviceId}
|
||||
to="/services"
|
||||
search={{ serviceId: link.serviceId }}
|
||||
className="inline-flex"
|
||||
>
|
||||
<Badge variant="outline" size="xs">
|
||||
{formatServiceGroupLabel(
|
||||
link.groupName,
|
||||
link.serviceName,
|
||||
)}
|
||||
</Badge>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
<FormFieldSimple label="Сервис" htmlFor="subdomain_service">
|
||||
<Controller
|
||||
control={form.control}
|
||||
|
||||
@@ -24,12 +24,13 @@ export function buildServiceGroupNameById(
|
||||
return map
|
||||
}
|
||||
|
||||
export function serviceDisplayFqdns(service: ServiceView): string[] {
|
||||
return (service.domains ?? []).map((binding) => bindingToFqdn(binding))
|
||||
}
|
||||
|
||||
export function serviceDisplayFqdn(service: ServiceView): string {
|
||||
const first = service.domains?.[0]
|
||||
if (first) {
|
||||
return bindingToFqdn(first)
|
||||
}
|
||||
return '—'
|
||||
const fqdns = serviceDisplayFqdns(service)
|
||||
return fqdns[0] ?? '—'
|
||||
}
|
||||
|
||||
export function aggregateServiceSyncStatus(service: ServiceView): string | null {
|
||||
|
||||
@@ -38,7 +38,7 @@ import {
|
||||
SubdomainEditSheet,
|
||||
type SubdomainEditValues,
|
||||
} from '@/components/subdomain-edit-sheet'
|
||||
import { DomainBindingsCard } from '@/components/domain-bindings-card'
|
||||
import { DomainBindingsPanel } from '@/components/domain-bindings-panel'
|
||||
import { DomainAvailabilityPanel } from '@/components/domains/domain-availability-panel'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { certMonitoringLabel, certMonitoringOptions } from '@/lib/cert-monitoring'
|
||||
@@ -326,7 +326,6 @@ function DomainOverviewPage() {
|
||||
<CountedLineTabs
|
||||
tabs={[
|
||||
{ id: 'overview', label: 'Обзор' },
|
||||
{ id: 'dns', label: 'DNS' },
|
||||
{ id: 'availability', label: 'Доступность' },
|
||||
{
|
||||
id: 'subdomains',
|
||||
@@ -381,30 +380,6 @@ function DomainOverviewPage() {
|
||||
</DetailPanel.Section>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="dns" className="flex flex-col gap-4">
|
||||
<DetailPanel.Section
|
||||
title="DNS-записи"
|
||||
description="Управление записями зоны в Cloudflare"
|
||||
>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Полный редактор DNS вынесен на отдельную страницу.
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
nativeButton={false}
|
||||
render={
|
||||
<Link
|
||||
to="/domains/$domainId/dns"
|
||||
params={{ domainId }}
|
||||
search={{ host: undefined }}
|
||||
/>
|
||||
}
|
||||
>
|
||||
Открыть DNS
|
||||
</Button>
|
||||
</DetailPanel.Section>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="availability" className="flex flex-col gap-4">
|
||||
<DomainAvailabilityPanel domainId={id} />
|
||||
</TabsContent>
|
||||
@@ -444,7 +419,7 @@ function DomainOverviewPage() {
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="bindings" className="flex flex-col gap-4">
|
||||
<DomainBindingsCard bindings={bindings} />
|
||||
<DomainBindingsPanel bindings={bindings} />
|
||||
</TabsContent>
|
||||
</CountedLineTabs>
|
||||
|
||||
@@ -457,6 +432,7 @@ function DomainOverviewPage() {
|
||||
currentServiceId={
|
||||
editTarget ? resolveServiceId(editTarget) : 'none'
|
||||
}
|
||||
serviceLinks={editTarget?.serviceLinks ?? []}
|
||||
open={sheetOpen}
|
||||
isSaving={isSheetSaving}
|
||||
onOpenChange={setSheetOpen}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { ServerIcon } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
@@ -46,10 +46,13 @@ import { Button } from '@cfdm/ui/components/button'
|
||||
export const Route = createFileRoute('/_auth/services')({
|
||||
validateSearch: (
|
||||
search: Record<string, unknown>,
|
||||
): { domainId?: number; view?: 'board' } => ({
|
||||
): { domainId?: number; serviceId?: number; view?: 'board' } => ({
|
||||
...(search.domainId != null && search.domainId !== ''
|
||||
? { domainId: Number(search.domainId) }
|
||||
: {}),
|
||||
...(search.serviceId != null && search.serviceId !== ''
|
||||
? { serviceId: Number(search.serviceId) }
|
||||
: {}),
|
||||
...(search.view === 'board' ? { view: 'board' as const } : {}),
|
||||
}),
|
||||
loader: ({ context: { queryClient } }) =>
|
||||
@@ -133,7 +136,7 @@ function flattenServices(
|
||||
}
|
||||
|
||||
function ServicesPage() {
|
||||
const { domainId, view: viewParam } = Route.useSearch()
|
||||
const { domainId, serviceId, view: viewParam } = Route.useSearch()
|
||||
const view = viewParam === 'board' ? 'board' : 'catalog'
|
||||
const navigate = useNavigate({ from: Route.fullPath })
|
||||
const [createSheetOpen, setCreateSheetOpen] = useState(false)
|
||||
@@ -183,6 +186,30 @@ function ServicesPage() {
|
||||
[data, domainId],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (serviceId == null || !data || editingService) return
|
||||
const fromGroups = data.groups
|
||||
.flatMap((group) => group.services)
|
||||
.find((service) => service.id === serviceId)
|
||||
const fromUngrouped = data.ungrouped.find(
|
||||
(service) => service.id === serviceId,
|
||||
)
|
||||
const target = fromGroups ?? fromUngrouped
|
||||
if (!target) return
|
||||
setEditingService(target)
|
||||
}, [serviceId, data, editingService])
|
||||
|
||||
function clearServiceSearch() {
|
||||
if (serviceId == null) return
|
||||
navigate({
|
||||
search: (prev) => {
|
||||
const next = { ...prev }
|
||||
delete next.serviceId
|
||||
return next
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function setView(next: 'catalog' | 'board') {
|
||||
navigate({
|
||||
search: (prev) => ({
|
||||
@@ -236,7 +263,7 @@ function ServicesPage() {
|
||||
if (!hasConfig) return created
|
||||
return api.patch<ServiceView>(`/api/v1/services/${created.id}`, {
|
||||
ips: body.ips,
|
||||
...(body.domains.length > 0 ? { domains: body.domains } : {}),
|
||||
domains: body.domains,
|
||||
service_group_id: body.service_group_id ?? null,
|
||||
})
|
||||
},
|
||||
@@ -256,6 +283,7 @@ function ServicesPage() {
|
||||
onSuccess: () => {
|
||||
invalidateAll()
|
||||
setEditingService(null)
|
||||
clearServiceSearch()
|
||||
toast.success('Сервис сохранён')
|
||||
},
|
||||
onError: (err) => {
|
||||
@@ -272,6 +300,7 @@ function ServicesPage() {
|
||||
invalidateAll()
|
||||
setEditingService(null)
|
||||
setDeletingService(null)
|
||||
clearServiceSearch()
|
||||
toast.success('Сервис удалён')
|
||||
},
|
||||
onError: (err) => {
|
||||
@@ -460,7 +489,10 @@ function ServicesPage() {
|
||||
isSaving={editingService !== null && savingId === editingService.id}
|
||||
isDeleting={editingService !== null && deletingId === editingService.id}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setEditingService(null)
|
||||
if (!open) {
|
||||
setEditingService(null)
|
||||
clearServiceSearch()
|
||||
}
|
||||
}}
|
||||
onSave={handleSave}
|
||||
onDelete={handleDelete}
|
||||
|
||||
@@ -32,6 +32,9 @@ health-check работают на двух уровнях:
|
||||
|
||||
- **Общий домен группы** — A-записи формируются из IP сервисов группы; режим LB
|
||||
и параметры health-check настраиваются в карточке группы.
|
||||
- **Несколько FQDN на сервис** — через `service_bindings` один сервис может быть
|
||||
привязан к нескольким hostname в разных зонах (уникальность
|
||||
`(domain_id, service_id, hostname)`).
|
||||
- **Привязка сервиса с multi-A** — режим LB и health-check настраиваются в карточке
|
||||
сервиса для каждой привязки с несколькими IP; для IP задаются вес/приоритет.
|
||||
|
||||
|
||||
+50
-37
@@ -4,6 +4,7 @@ import {
|
||||
primaryKey,
|
||||
sqliteTable,
|
||||
text,
|
||||
unique,
|
||||
} from "drizzle-orm/sqlite-core";
|
||||
|
||||
export const groups = sqliteTable("groups", {
|
||||
@@ -130,43 +131,55 @@ export const dnsRecords = sqliteTable("dns_records", {
|
||||
.default(sql`datetime('now')`),
|
||||
});
|
||||
|
||||
export const serviceBindings = sqliteTable("service_bindings", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
domain_id: integer("domain_id")
|
||||
.notNull()
|
||||
.references(() => domains.id, { onDelete: "cascade" }),
|
||||
service_id: integer("service_id")
|
||||
.notNull()
|
||||
.references(() => services.id, { onDelete: "cascade" }),
|
||||
hostname: text("hostname").notNull().default("@"),
|
||||
cname_target: text("cname_target"),
|
||||
dns_record_id: integer("dns_record_id").references(() => dnsRecords.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
lb_mode: text("lb_mode").notNull().default("round_robin"),
|
||||
health_check_enabled: integer("health_check_enabled", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false),
|
||||
health_check_type: text("health_check_type").notNull().default("tcp"),
|
||||
health_check_port: integer("health_check_port"),
|
||||
health_check_path: text("health_check_path"),
|
||||
health_check_expected_status: integer("health_check_expected_status"),
|
||||
health_check_interval_sec: integer("health_check_interval_sec")
|
||||
.notNull()
|
||||
.default(30),
|
||||
health_check_timeout_ms: integer("health_check_timeout_ms")
|
||||
.notNull()
|
||||
.default(3000),
|
||||
health_check_verify_tls: integer("health_check_verify_tls", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false),
|
||||
created_at: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`datetime('now')`),
|
||||
updated_at: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`datetime('now')`),
|
||||
});
|
||||
export const serviceBindings = sqliteTable(
|
||||
"service_bindings",
|
||||
{
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
domain_id: integer("domain_id")
|
||||
.notNull()
|
||||
.references(() => domains.id, { onDelete: "cascade" }),
|
||||
service_id: integer("service_id")
|
||||
.notNull()
|
||||
.references(() => services.id, { onDelete: "cascade" }),
|
||||
hostname: text("hostname").notNull().default("@"),
|
||||
cname_target: text("cname_target"),
|
||||
dns_record_id: integer("dns_record_id").references(() => dnsRecords.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
lb_mode: text("lb_mode").notNull().default("round_robin"),
|
||||
health_check_enabled: integer("health_check_enabled", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false),
|
||||
health_check_type: text("health_check_type").notNull().default("tcp"),
|
||||
health_check_port: integer("health_check_port"),
|
||||
health_check_path: text("health_check_path"),
|
||||
health_check_expected_status: integer("health_check_expected_status"),
|
||||
health_check_interval_sec: integer("health_check_interval_sec")
|
||||
.notNull()
|
||||
.default(30),
|
||||
health_check_timeout_ms: integer("health_check_timeout_ms")
|
||||
.notNull()
|
||||
.default(3000),
|
||||
health_check_verify_tls: integer("health_check_verify_tls", {
|
||||
mode: "boolean",
|
||||
})
|
||||
.notNull()
|
||||
.default(false),
|
||||
created_at: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`datetime('now')`),
|
||||
updated_at: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`datetime('now')`),
|
||||
},
|
||||
(table) => [
|
||||
unique("service_bindings_domain_service_hostname").on(
|
||||
table.domain_id,
|
||||
table.service_id,
|
||||
table.hostname,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
export const serviceIps = sqliteTable("service_ips", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
|
||||
Reference in New Issue
Block a user