feat(services): сделать multi-FQDN привязки first-class в UI
Исправить prune при domains: [], показать все FQDN в каталоге/kanban, улучшить sheet привязок и панель на домене; убрать мёртвый код. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -1172,7 +1172,6 @@ export async function updateConfig(
|
|||||||
let removedBindingIds: number[] = [];
|
let removedBindingIds: number[] = [];
|
||||||
|
|
||||||
if (req.domains) {
|
if (req.domains) {
|
||||||
if (req.domains.length > 0) {
|
|
||||||
for (const input of req.domains) {
|
for (const input of req.domains) {
|
||||||
const fqdn = input.fqdn.trim();
|
const fqdn = input.fqdn.trim();
|
||||||
if (!fqdn) continue;
|
if (!fqdn) continue;
|
||||||
@@ -1262,7 +1261,6 @@ export async function updateConfig(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
repos.deleteBindingsExcept(db, id, keptBindingIds);
|
repos.deleteBindingsExcept(db, id, keptBindingIds);
|
||||||
}
|
|
||||||
} else if (ipsUpdated) {
|
} else if (ipsUpdated) {
|
||||||
const bindings = repos.listBindingsByService(db, id);
|
const bindings = repos.listBindingsByService(db, id);
|
||||||
for (const binding of bindings) {
|
for (const binding of bindings) {
|
||||||
@@ -1278,8 +1276,11 @@ export async function updateConfig(
|
|||||||
}
|
}
|
||||||
|
|
||||||
service = repos.getService(db, id);
|
service = repos.getService(db, id);
|
||||||
|
const remainingBindings = repos.listBindingsByService(db, id);
|
||||||
if (shouldPushDns(db, service)) {
|
if (shouldPushDns(db, service)) {
|
||||||
|
if (remainingBindings.length > 0) {
|
||||||
await syncServiceBindingsToDns(db, cf, id);
|
await syncServiceBindingsToDns(db, cf, id);
|
||||||
|
}
|
||||||
await syncGroupDomainForService(db, cf, id);
|
await syncGroupDomainForService(db, cf, id);
|
||||||
} else if (
|
} else if (
|
||||||
req.domains &&
|
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',
|
'**/services-board/*-row.tsx',
|
||||||
'**/groups-board/*-row.tsx',
|
'**/groups-board/*-row.tsx',
|
||||||
'**/layout/app-shell.tsx',
|
'**/layout/app-shell.tsx',
|
||||||
'**/service-binding-card.tsx',
|
|
||||||
],
|
],
|
||||||
rules: {
|
rules: {
|
||||||
'no-restricted-syntax': 'off',
|
'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 { useMemo } from 'react'
|
||||||
import type { ColumnDef } from '@tanstack/react-table'
|
import { SearchIcon } from 'lucide-react'
|
||||||
import { MoreHorizontalIcon, SearchIcon } from 'lucide-react'
|
|
||||||
|
|
||||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
|
||||||
import { createFilter, type FilterFieldConfig } from '@/components/reui/filters'
|
import { createFilter, type FilterFieldConfig } from '@/components/reui/filters'
|
||||||
import { StatusBadge } from '@/components/status-badge'
|
|
||||||
import type { ServiceView } from '@/lib/schemas'
|
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 {
|
export interface ServiceCatalogRow {
|
||||||
id: number
|
id: number
|
||||||
@@ -50,126 +39,18 @@ export function useServiceFilterFields() {
|
|||||||
{
|
{
|
||||||
key: 'name',
|
key: 'name',
|
||||||
label: 'Название',
|
label: 'Название',
|
||||||
icon: <SearchIcon className="size-3.5" aria-hidden />,
|
|
||||||
type: 'text',
|
type: 'text',
|
||||||
className: 'w-52',
|
icon: <SearchIcon className="size-4" />,
|
||||||
placeholder: 'Поиск…',
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[],
|
[],
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function serviceFilterFieldValue(item: ServiceCatalogRow, field: string) {
|
export function serviceFilterFieldValue(
|
||||||
switch (field) {
|
item: ServiceCatalogRow,
|
||||||
case 'name':
|
field: string,
|
||||||
return `${item.name} ${item.slug} ${item.groupName ?? ''}`.toLowerCase()
|
): string {
|
||||||
default:
|
if (field === 'name') return item.name.toLowerCase()
|
||||||
return ''
|
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 }
|
|
||||||
}
|
|
||||||
|
|||||||
+38
-7
@@ -24,7 +24,7 @@ import {
|
|||||||
ItemTitle,
|
ItemTitle,
|
||||||
} from '@cfdm/ui/components/item'
|
} from '@cfdm/ui/components/item'
|
||||||
|
|
||||||
interface DomainBindingsCardProps {
|
interface DomainBindingsPanelProps {
|
||||||
bindings: ServiceBinding[]
|
bindings: ServiceBinding[]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,11 +67,24 @@ function HostnameIpsHealth({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function uniqueServices(bindings: ServiceBinding[]): string[] {
|
function uniqueServiceEntries(
|
||||||
return [...new Set(bindings.map((b) => b.service_name))]
|
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 byHostname = groupBindingsByHostname(bindings)
|
||||||
const entries = [...byHostname.entries()]
|
const entries = [...byHostname.entries()]
|
||||||
|
|
||||||
@@ -103,13 +116,25 @@ export function DomainBindingsCard({ bindings }: DomainBindingsCardProps) {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
const services = uniqueServiceEntries(groupBindings)
|
||||||
return (
|
return (
|
||||||
<div key={hostname}>
|
<div key={hostname}>
|
||||||
<Item size="sm" variant="muted" className="border-0 px-0">
|
<Item size="sm" variant="muted" className="border-0 px-0">
|
||||||
<ItemContent className="gap-1">
|
<ItemContent className="gap-1">
|
||||||
<ItemTitle className="font-mono">{hostname}</ItemTitle>
|
<ItemTitle className="font-mono">{hostname}</ItemTitle>
|
||||||
<div className="text-muted-foreground text-sm">
|
<div className="flex flex-wrap items-center gap-1.5">
|
||||||
{uniqueServices(groupBindings).join(', ')}
|
{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>
|
</div>
|
||||||
<HostnameIpsHealth
|
<HostnameIpsHealth
|
||||||
bindings={groupBindings}
|
bindings={groupBindings}
|
||||||
@@ -134,7 +159,12 @@ export function DomainBindingsCard({ bindings }: DomainBindingsCardProps) {
|
|||||||
variant="link"
|
variant="link"
|
||||||
className="h-auto p-0"
|
className="h-auto p-0"
|
||||||
nativeButton={false}
|
nativeButton={false}
|
||||||
render={<Link to="/services" search={{ domainId: bindings[0]?.domain_id }} />}
|
render={
|
||||||
|
<Link
|
||||||
|
to="/services"
|
||||||
|
search={{ domainId: bindings[0]?.domain_id }}
|
||||||
|
/>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
К сервисам
|
К сервисам
|
||||||
</Button>
|
</Button>
|
||||||
@@ -142,3 +172,4 @@ export function DomainBindingsCard({ bindings }: DomainBindingsCardProps) {
|
|||||||
</Frame>
|
</Frame>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3,7 +3,7 @@ import { MoreHorizontalIcon, PencilIcon, Trash2Icon } from 'lucide-react'
|
|||||||
import { Badge } from '@/components/reui/badge'
|
import { Badge } from '@/components/reui/badge'
|
||||||
import { HealthCheckBadge } from '@/components/health-check-badge'
|
import { HealthCheckBadge } from '@/components/health-check-badge'
|
||||||
import { StatusBadge } from '@/components/status-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 type { ServiceView } from '@/lib/schemas'
|
||||||
import { Button } from '@cfdm/ui/components/button'
|
import { Button } from '@cfdm/ui/components/button'
|
||||||
import {
|
import {
|
||||||
@@ -44,8 +44,6 @@ export function ServiceKanbanCard({
|
|||||||
onEdit,
|
onEdit,
|
||||||
onDelete,
|
onDelete,
|
||||||
}: ServiceKanbanCardProps) {
|
}: ServiceKanbanCardProps) {
|
||||||
const fqdn = serviceDisplayFqdn(service)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Item
|
<Item
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -80,13 +78,7 @@ export function ServiceKanbanCard({
|
|||||||
</ItemHeader>
|
</ItemHeader>
|
||||||
|
|
||||||
<ItemContent className="min-w-0 gap-2">
|
<ItemContent className="min-w-0 gap-2">
|
||||||
{fqdn && fqdn !== '—' ? (
|
<ServiceFqdnList service={service} />
|
||||||
<span className="text-muted-foreground truncate font-mono text-xs">
|
|
||||||
{fqdn}
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span className="text-muted-foreground text-xs">FQDN не задан</span>
|
|
||||||
)}
|
|
||||||
</ItemContent>
|
</ItemContent>
|
||||||
|
|
||||||
<ItemFooter className="min-w-0 justify-between gap-2">
|
<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,
|
ServiceView,
|
||||||
UpdateServiceConfigInput,
|
UpdateServiceConfigInput,
|
||||||
} from '@/lib/schemas'
|
} 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 {
|
import {
|
||||||
Sheet,
|
Sheet,
|
||||||
SheetContent,
|
SheetContent,
|
||||||
@@ -247,8 +249,7 @@ export function ServiceEditSheet({
|
|||||||
setBindings((current) => current.filter((_, i) => i !== index))
|
setBindings((current) => current.filter((_, i) => i !== index))
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleFqdnChange(index: number, tags: string[]) {
|
function handleFqdnChange(index: number, fqdn: string) {
|
||||||
const fqdn = tags[0] ?? ''
|
|
||||||
setBindings((current) =>
|
setBindings((current) =>
|
||||||
current.map((item, i) => (i === index ? { ...item, fqdn } : item)),
|
current.map((item, i) => (i === index ? { ...item, fqdn } : item)),
|
||||||
)
|
)
|
||||||
@@ -340,14 +341,22 @@ export function ServiceEditSheet({
|
|||||||
|
|
||||||
function handleSubmit() {
|
function handleSubmit() {
|
||||||
const domains = buildDomainsPayload(bindings)
|
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 groupId = resolveServiceGroupId()
|
||||||
const lbFields = groupHasDomain
|
const lbFields = groupHasDomain
|
||||||
? { lb_weight: lbWeight, lb_priority: lbPriority }
|
? { lb_weight: lbWeight, lb_priority: lbPriority }
|
||||||
: {}
|
: {}
|
||||||
const configPayload = {
|
const configPayload = {
|
||||||
ips,
|
ips,
|
||||||
|
domains,
|
||||||
...lbFields,
|
...lbFields,
|
||||||
...(domains.length > 0 ? { domains } : {}),
|
|
||||||
}
|
}
|
||||||
if (mode === 'create') {
|
if (mode === 'create') {
|
||||||
onCreate?.({
|
onCreate?.({
|
||||||
@@ -385,8 +394,9 @@ export function ServiceEditSheet({
|
|||||||
<SheetHeader className="shrink-0 border-b pb-4">
|
<SheetHeader className="shrink-0 border-b pb-4">
|
||||||
<SheetTitle>{isCreate ? 'Новый сервис' : 'Редактирование сервиса'}</SheetTitle>
|
<SheetTitle>{isCreate ? 'Новый сервис' : 'Редактирование сервиса'}</SheetTitle>
|
||||||
<SheetDescription>
|
<SheetDescription>
|
||||||
Настройте параметры сервиса и привязки FQDN → IP или CNAME. Зона определяется из FQDN
|
Настройте параметры сервиса и привязки FQDN → IP или CNAME. Один
|
||||||
автоматически.
|
сервис может иметь несколько FQDN в разных зонах; зона определяется
|
||||||
|
из FQDN автоматически.
|
||||||
</SheetDescription>
|
</SheetDescription>
|
||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
|
|
||||||
@@ -502,7 +512,7 @@ export function ServiceEditSheet({
|
|||||||
<TabsContent value="bindings" className="flex flex-col gap-4">
|
<TabsContent value="bindings" className="flex flex-col gap-4">
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
FQDN → IP или CNAME для DNS-записей Cloudflare
|
Несколько FQDN в разных зонах → IP или CNAME для DNS Cloudflare
|
||||||
</p>
|
</p>
|
||||||
<Button type="button" variant="outline" size="sm" onClick={handleAddBinding}>
|
<Button type="button" variant="outline" size="sm" onClick={handleAddBinding}>
|
||||||
<PlusIcon data-icon="inline-start" />
|
<PlusIcon data-icon="inline-start" />
|
||||||
@@ -514,7 +524,7 @@ export function ServiceEditSheet({
|
|||||||
<EmptyState
|
<EmptyState
|
||||||
icon={Link2Icon}
|
icon={Link2Icon}
|
||||||
title="Нет привязок"
|
title="Нет привязок"
|
||||||
description="Необязательно. Пример: newdom.ivx.su — зона ivx.su определится автоматически."
|
description="Необязательно. Можно добавить несколько FQDN: api.ivx.su и www.other.su — зоны определятся автоматически."
|
||||||
centered={false}
|
centered={false}
|
||||||
action={
|
action={
|
||||||
<Button type="button" variant="outline" size="sm" onClick={handleAddBinding}>
|
<Button type="button" variant="outline" size="sm" onClick={handleAddBinding}>
|
||||||
@@ -533,22 +543,25 @@ export function ServiceEditSheet({
|
|||||||
binding.record_type === 'A' &&
|
binding.record_type === 'A' &&
|
||||||
binding.target_ips.length > 1 &&
|
binding.target_ips.length > 1 &&
|
||||||
binding.lb_mode !== 'round_robin'
|
binding.lb_mode !== 'round_robin'
|
||||||
|
const parsedZone = parseFqdn(binding.fqdn, zoneHints)
|
||||||
return (
|
return (
|
||||||
<Item key={`binding-${index}`} variant="outline" className="items-stretch">
|
<Item key={`binding-${index}`} variant="outline" className="items-stretch">
|
||||||
<ItemContent className="w-full flex flex-col gap-3">
|
<ItemContent className="w-full flex flex-col gap-3">
|
||||||
<div className="flex items-end gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<Field className="min-w-0 flex-1">
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
<FieldLabel htmlFor={`binding-fqdn-${index}`}>FQDN</FieldLabel>
|
<span className="text-sm font-medium">
|
||||||
<TaggedInput
|
Привязка {index + 1}
|
||||||
id={`binding-fqdn-${index}`}
|
</span>
|
||||||
value={binding.fqdn ? [binding.fqdn] : []}
|
{parsedZone ? (
|
||||||
onChange={(tags) => handleFqdnChange(index, tags)}
|
<Badge variant="outline" size="xs" className="font-mono">
|
||||||
placeholder={
|
{parsedZone.zoneName}
|
||||||
zoneHints[0] ? `newdom.${zoneHints[0]}` : 'newdom.ivx.su'
|
</Badge>
|
||||||
}
|
) : binding.fqdn.trim() ? (
|
||||||
maxItems={1}
|
<Badge variant="warning-light" size="xs">
|
||||||
/>
|
зона не найдена
|
||||||
</Field>
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -560,6 +573,20 @@ export function ServiceEditSheet({
|
|||||||
<Trash2Icon />
|
<Trash2Icon />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</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>
|
<Field>
|
||||||
<FieldLabel htmlFor={`binding-type-${index}`}>Тип записи</FieldLabel>
|
<FieldLabel htmlFor={`binding-type-${index}`}>Тип записи</FieldLabel>
|
||||||
<Select
|
<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 { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||||
import { HealthCheckBadge } from '@/components/health-check-badge'
|
import { HealthCheckBadge } from '@/components/health-check-badge'
|
||||||
import { StatusBadge } from '@/components/status-badge'
|
import { StatusBadge } from '@/components/status-badge'
|
||||||
import { serviceDisplayFqdn } from '@/lib/service-utils'
|
|
||||||
import type { ServiceGroupView, ServiceView } from '@/lib/schemas'
|
import type { ServiceGroupView, ServiceView } from '@/lib/schemas'
|
||||||
|
import { ServiceFqdnList } from '@/components/services/service-fqdn-list'
|
||||||
import { Button } from '@cfdm/ui/components/button'
|
import { Button } from '@cfdm/ui/components/button'
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
@@ -126,9 +126,7 @@ export function createServicesGroupedColumns({
|
|||||||
<span className="truncate text-sm font-medium">
|
<span className="truncate text-sm font-medium">
|
||||||
{original.service.name}
|
{original.service.name}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-muted-foreground truncate font-mono text-xs">
|
<ServiceFqdnList service={original.service} emptyLabel="—" />
|
||||||
{serviceDisplayFqdn(original.service)}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,14 +1,22 @@
|
|||||||
|
import { Link } from '@tanstack/react-router'
|
||||||
import { useEffect, useMemo } from 'react'
|
import { useEffect, useMemo } from 'react'
|
||||||
import { useForm, Controller } from 'react-hook-form'
|
import { useForm, Controller } from 'react-hook-form'
|
||||||
import { zodResolver } from '@hookform/resolvers/zod'
|
import { zodResolver } from '@hookform/resolvers/zod'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import type { CertMonitoring } from '@cfdm/shared'
|
import type { CertMonitoring } from '@cfdm/shared'
|
||||||
import type { ServiceView, SubdomainRecord } from '@/lib/schemas'
|
import type { ServiceView, SubdomainRecord } from '@/lib/schemas'
|
||||||
|
import type { SubdomainServiceLink } from '@/hooks/use-domain-page'
|
||||||
import { certMonitoringOptions } from '@/lib/cert-monitoring'
|
import { certMonitoringOptions } from '@/lib/cert-monitoring'
|
||||||
import { formatServiceGroupLabel } from '@/lib/service-utils'
|
import { formatServiceGroupLabel } from '@/lib/service-utils'
|
||||||
import { FormSheet } from '@/components/form-sheet'
|
import { FormSheet } from '@/components/form-sheet'
|
||||||
import { FormFieldSimple } from '@/components/form-field'
|
import { FormFieldSimple } from '@/components/form-field'
|
||||||
import { LoadingButton } from '@/components/loading-button'
|
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 { FieldGroup } from '@cfdm/ui/components/field'
|
||||||
import { Input } from '@cfdm/ui/components/input'
|
import { Input } from '@cfdm/ui/components/input'
|
||||||
import {
|
import {
|
||||||
@@ -34,6 +42,7 @@ interface SubdomainEditSheetProps {
|
|||||||
services: ServiceView[]
|
services: ServiceView[]
|
||||||
serviceGroupById: Map<number, string | null>
|
serviceGroupById: Map<number, string | null>
|
||||||
currentServiceId: string
|
currentServiceId: string
|
||||||
|
serviceLinks?: SubdomainServiceLink[]
|
||||||
open: boolean
|
open: boolean
|
||||||
isSaving: boolean
|
isSaving: boolean
|
||||||
onOpenChange: (open: boolean) => void
|
onOpenChange: (open: boolean) => void
|
||||||
@@ -47,6 +56,7 @@ export function SubdomainEditSheet({
|
|||||||
services,
|
services,
|
||||||
serviceGroupById,
|
serviceGroupById,
|
||||||
currentServiceId,
|
currentServiceId,
|
||||||
|
serviceLinks = [],
|
||||||
open,
|
open,
|
||||||
isSaving,
|
isSaving,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
@@ -104,6 +114,7 @@ export function SubdomainEditSheet({
|
|||||||
const certMonitoring = form.watch('certMonitoring')
|
const certMonitoring = form.watch('certMonitoring')
|
||||||
const certHint =
|
const certHint =
|
||||||
certMonitoringOptions.find((o) => o.value === certMonitoring)?.description
|
certMonitoringOptions.find((o) => o.value === certMonitoring)?.description
|
||||||
|
const hasMultipleServices = mode === 'edit' && serviceLinks.length > 1
|
||||||
|
|
||||||
function handleSubmit(values: SubdomainEditValues) {
|
function handleSubmit(values: SubdomainEditValues) {
|
||||||
onSubmit({
|
onSubmit({
|
||||||
@@ -155,6 +166,34 @@ export function SubdomainEditSheet({
|
|||||||
</FormFieldSimple>
|
</FormFieldSimple>
|
||||||
{mode === 'edit' ? (
|
{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">
|
<FormFieldSimple label="Сервис" htmlFor="subdomain_service">
|
||||||
<Controller
|
<Controller
|
||||||
control={form.control}
|
control={form.control}
|
||||||
|
|||||||
@@ -24,12 +24,13 @@ export function buildServiceGroupNameById(
|
|||||||
return map
|
return map
|
||||||
}
|
}
|
||||||
|
|
||||||
export function serviceDisplayFqdn(service: ServiceView): string {
|
export function serviceDisplayFqdns(service: ServiceView): string[] {
|
||||||
const first = service.domains?.[0]
|
return (service.domains ?? []).map((binding) => bindingToFqdn(binding))
|
||||||
if (first) {
|
|
||||||
return bindingToFqdn(first)
|
|
||||||
}
|
}
|
||||||
return '—'
|
|
||||||
|
export function serviceDisplayFqdn(service: ServiceView): string {
|
||||||
|
const fqdns = serviceDisplayFqdns(service)
|
||||||
|
return fqdns[0] ?? '—'
|
||||||
}
|
}
|
||||||
|
|
||||||
export function aggregateServiceSyncStatus(service: ServiceView): string | null {
|
export function aggregateServiceSyncStatus(service: ServiceView): string | null {
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ import {
|
|||||||
SubdomainEditSheet,
|
SubdomainEditSheet,
|
||||||
type SubdomainEditValues,
|
type SubdomainEditValues,
|
||||||
} from '@/components/subdomain-edit-sheet'
|
} 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 { DomainAvailabilityPanel } from '@/components/domains/domain-availability-panel'
|
||||||
import { StatusBadge } from '@/components/status-badge'
|
import { StatusBadge } from '@/components/status-badge'
|
||||||
import { certMonitoringLabel, certMonitoringOptions } from '@/lib/cert-monitoring'
|
import { certMonitoringLabel, certMonitoringOptions } from '@/lib/cert-monitoring'
|
||||||
@@ -326,7 +326,6 @@ function DomainOverviewPage() {
|
|||||||
<CountedLineTabs
|
<CountedLineTabs
|
||||||
tabs={[
|
tabs={[
|
||||||
{ id: 'overview', label: 'Обзор' },
|
{ id: 'overview', label: 'Обзор' },
|
||||||
{ id: 'dns', label: 'DNS' },
|
|
||||||
{ id: 'availability', label: 'Доступность' },
|
{ id: 'availability', label: 'Доступность' },
|
||||||
{
|
{
|
||||||
id: 'subdomains',
|
id: 'subdomains',
|
||||||
@@ -381,30 +380,6 @@ function DomainOverviewPage() {
|
|||||||
</DetailPanel.Section>
|
</DetailPanel.Section>
|
||||||
</TabsContent>
|
</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">
|
<TabsContent value="availability" className="flex flex-col gap-4">
|
||||||
<DomainAvailabilityPanel domainId={id} />
|
<DomainAvailabilityPanel domainId={id} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
@@ -444,7 +419,7 @@ function DomainOverviewPage() {
|
|||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="bindings" className="flex flex-col gap-4">
|
<TabsContent value="bindings" className="flex flex-col gap-4">
|
||||||
<DomainBindingsCard bindings={bindings} />
|
<DomainBindingsPanel bindings={bindings} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</CountedLineTabs>
|
</CountedLineTabs>
|
||||||
|
|
||||||
@@ -457,6 +432,7 @@ function DomainOverviewPage() {
|
|||||||
currentServiceId={
|
currentServiceId={
|
||||||
editTarget ? resolveServiceId(editTarget) : 'none'
|
editTarget ? resolveServiceId(editTarget) : 'none'
|
||||||
}
|
}
|
||||||
|
serviceLinks={editTarget?.serviceLinks ?? []}
|
||||||
open={sheetOpen}
|
open={sheetOpen}
|
||||||
isSaving={isSheetSaving}
|
isSaving={isSheetSaving}
|
||||||
onOpenChange={setSheetOpen}
|
onOpenChange={setSheetOpen}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
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 { ServerIcon } from 'lucide-react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import {
|
import {
|
||||||
@@ -46,10 +46,13 @@ import { Button } from '@cfdm/ui/components/button'
|
|||||||
export const Route = createFileRoute('/_auth/services')({
|
export const Route = createFileRoute('/_auth/services')({
|
||||||
validateSearch: (
|
validateSearch: (
|
||||||
search: Record<string, unknown>,
|
search: Record<string, unknown>,
|
||||||
): { domainId?: number; view?: 'board' } => ({
|
): { domainId?: number; serviceId?: number; view?: 'board' } => ({
|
||||||
...(search.domainId != null && search.domainId !== ''
|
...(search.domainId != null && search.domainId !== ''
|
||||||
? { domainId: Number(search.domainId) }
|
? { domainId: Number(search.domainId) }
|
||||||
: {}),
|
: {}),
|
||||||
|
...(search.serviceId != null && search.serviceId !== ''
|
||||||
|
? { serviceId: Number(search.serviceId) }
|
||||||
|
: {}),
|
||||||
...(search.view === 'board' ? { view: 'board' as const } : {}),
|
...(search.view === 'board' ? { view: 'board' as const } : {}),
|
||||||
}),
|
}),
|
||||||
loader: ({ context: { queryClient } }) =>
|
loader: ({ context: { queryClient } }) =>
|
||||||
@@ -133,7 +136,7 @@ function flattenServices(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ServicesPage() {
|
function ServicesPage() {
|
||||||
const { domainId, view: viewParam } = Route.useSearch()
|
const { domainId, serviceId, view: viewParam } = Route.useSearch()
|
||||||
const view = viewParam === 'board' ? 'board' : 'catalog'
|
const view = viewParam === 'board' ? 'board' : 'catalog'
|
||||||
const navigate = useNavigate({ from: Route.fullPath })
|
const navigate = useNavigate({ from: Route.fullPath })
|
||||||
const [createSheetOpen, setCreateSheetOpen] = useState(false)
|
const [createSheetOpen, setCreateSheetOpen] = useState(false)
|
||||||
@@ -183,6 +186,30 @@ function ServicesPage() {
|
|||||||
[data, domainId],
|
[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') {
|
function setView(next: 'catalog' | 'board') {
|
||||||
navigate({
|
navigate({
|
||||||
search: (prev) => ({
|
search: (prev) => ({
|
||||||
@@ -236,7 +263,7 @@ function ServicesPage() {
|
|||||||
if (!hasConfig) return created
|
if (!hasConfig) return created
|
||||||
return api.patch<ServiceView>(`/api/v1/services/${created.id}`, {
|
return api.patch<ServiceView>(`/api/v1/services/${created.id}`, {
|
||||||
ips: body.ips,
|
ips: body.ips,
|
||||||
...(body.domains.length > 0 ? { domains: body.domains } : {}),
|
domains: body.domains,
|
||||||
service_group_id: body.service_group_id ?? null,
|
service_group_id: body.service_group_id ?? null,
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
@@ -256,6 +283,7 @@ function ServicesPage() {
|
|||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
invalidateAll()
|
invalidateAll()
|
||||||
setEditingService(null)
|
setEditingService(null)
|
||||||
|
clearServiceSearch()
|
||||||
toast.success('Сервис сохранён')
|
toast.success('Сервис сохранён')
|
||||||
},
|
},
|
||||||
onError: (err) => {
|
onError: (err) => {
|
||||||
@@ -272,6 +300,7 @@ function ServicesPage() {
|
|||||||
invalidateAll()
|
invalidateAll()
|
||||||
setEditingService(null)
|
setEditingService(null)
|
||||||
setDeletingService(null)
|
setDeletingService(null)
|
||||||
|
clearServiceSearch()
|
||||||
toast.success('Сервис удалён')
|
toast.success('Сервис удалён')
|
||||||
},
|
},
|
||||||
onError: (err) => {
|
onError: (err) => {
|
||||||
@@ -460,7 +489,10 @@ function ServicesPage() {
|
|||||||
isSaving={editingService !== null && savingId === editingService.id}
|
isSaving={editingService !== null && savingId === editingService.id}
|
||||||
isDeleting={editingService !== null && deletingId === editingService.id}
|
isDeleting={editingService !== null && deletingId === editingService.id}
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
if (!open) setEditingService(null)
|
if (!open) {
|
||||||
|
setEditingService(null)
|
||||||
|
clearServiceSearch()
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
onSave={handleSave}
|
onSave={handleSave}
|
||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
|
|||||||
@@ -32,6 +32,9 @@ health-check работают на двух уровнях:
|
|||||||
|
|
||||||
- **Общий домен группы** — A-записи формируются из IP сервисов группы; режим LB
|
- **Общий домен группы** — A-записи формируются из IP сервисов группы; режим LB
|
||||||
и параметры health-check настраиваются в карточке группы.
|
и параметры health-check настраиваются в карточке группы.
|
||||||
|
- **Несколько FQDN на сервис** — через `service_bindings` один сервис может быть
|
||||||
|
привязан к нескольким hostname в разных зонах (уникальность
|
||||||
|
`(domain_id, service_id, hostname)`).
|
||||||
- **Привязка сервиса с multi-A** — режим LB и health-check настраиваются в карточке
|
- **Привязка сервиса с multi-A** — режим LB и health-check настраиваются в карточке
|
||||||
сервиса для каждой привязки с несколькими IP; для IP задаются вес/приоритет.
|
сервиса для каждой привязки с несколькими IP; для IP задаются вес/приоритет.
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
primaryKey,
|
primaryKey,
|
||||||
sqliteTable,
|
sqliteTable,
|
||||||
text,
|
text,
|
||||||
|
unique,
|
||||||
} from "drizzle-orm/sqlite-core";
|
} from "drizzle-orm/sqlite-core";
|
||||||
|
|
||||||
export const groups = sqliteTable("groups", {
|
export const groups = sqliteTable("groups", {
|
||||||
@@ -130,7 +131,9 @@ export const dnsRecords = sqliteTable("dns_records", {
|
|||||||
.default(sql`datetime('now')`),
|
.default(sql`datetime('now')`),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const serviceBindings = sqliteTable("service_bindings", {
|
export const serviceBindings = sqliteTable(
|
||||||
|
"service_bindings",
|
||||||
|
{
|
||||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||||
domain_id: integer("domain_id")
|
domain_id: integer("domain_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
@@ -157,7 +160,9 @@ export const serviceBindings = sqliteTable("service_bindings", {
|
|||||||
health_check_timeout_ms: integer("health_check_timeout_ms")
|
health_check_timeout_ms: integer("health_check_timeout_ms")
|
||||||
.notNull()
|
.notNull()
|
||||||
.default(3000),
|
.default(3000),
|
||||||
health_check_verify_tls: integer("health_check_verify_tls", { mode: "boolean" })
|
health_check_verify_tls: integer("health_check_verify_tls", {
|
||||||
|
mode: "boolean",
|
||||||
|
})
|
||||||
.notNull()
|
.notNull()
|
||||||
.default(false),
|
.default(false),
|
||||||
created_at: text("created_at")
|
created_at: text("created_at")
|
||||||
@@ -166,7 +171,15 @@ export const serviceBindings = sqliteTable("service_bindings", {
|
|||||||
updated_at: text("updated_at")
|
updated_at: text("updated_at")
|
||||||
.notNull()
|
.notNull()
|
||||||
.default(sql`datetime('now')`),
|
.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", {
|
export const serviceIps = sqliteTable("service_ips", {
|
||||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||||
|
|||||||
Reference in New Issue
Block a user