feat(certificates): enhance service certificate management and monitoring
quality / commitlint (push) Skipped
CD / update-wiki (push) Successful in 8s
quality / changes (push) Successful in 10s
quality / docker-check (push) Skipped
quality / web (push) Successful in 1m8s
quality / api (push) Successful in 1m9s
CD / quality (push) Successful in 2m31s
CD / publish (push) Successful in 1m35s

- Added new endpoints for listing and checking service certificates, improving visibility into SSL status.
- Integrated certificate monitoring options into service binding updates, allowing for flexible SSL management.
- Updated the service detail grid to include SSL monitoring controls, enhancing user interaction with certificate settings.
- Refactored related components and schemas to support the new certificate features, ensuring consistency across the application.
- Improved test coverage for certificate functionalities, validating the new features and ensuring reliability.
This commit is contained in:
Denozordec
2026-08-20 00:32:43 +07:00
parent 69119a08a4
commit a458465153
20 changed files with 760 additions and 274 deletions
@@ -1,6 +1,7 @@
import { useMemo } from 'react'
import type { ColumnDef } from '@tanstack/react-table'
import { GlobeIcon, SearchIcon } from 'lucide-react'
import { Link } from '@tanstack/react-router'
import { GlobeIcon, SearchIcon, ServerIcon } from 'lucide-react'
import { Badge } from '@/components/reui/badge'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
@@ -9,6 +10,7 @@ import { StatusBadge } from '@/components/status-badge'
import { renderSingleSelectedLabel } from '@/components/reui-kit/filter-utils'
import type { Certificate } from '@/lib/schemas'
import { formatDate, formatRelative } from '@/lib/format'
import { Button } from '@cfdm/ui/components/button'
export const CERT_TABS = [
{ id: 'all', label: 'Все' },
@@ -41,6 +43,7 @@ export function certTabFilter(item: Certificate, tabId: string) {
export function createDefaultCertFilters() {
return [
createFilter('hostname', 'contains', ['']),
createFilter('service', 'contains', ['']),
createFilter('status', 'is', ['']),
]
}
@@ -56,6 +59,14 @@ export function useCertFilterFields() {
className: 'w-52',
placeholder: 'Поиск по хосту…',
},
{
key: 'service',
label: 'Сервис',
icon: <ServerIcon className="size-3.5" aria-hidden />,
type: 'text',
className: 'w-52',
placeholder: 'Поиск по сервису…',
},
{
key: 'status',
label: 'Статус',
@@ -75,6 +86,8 @@ export function certFilterFieldValue(item: Certificate, field: string) {
switch (field) {
case 'hostname':
return `${item.hostname} ${item.status}`.toLowerCase()
case 'service':
return (item.service_name ?? '').toLowerCase()
case 'status':
return item.status
default:
@@ -82,7 +95,7 @@ export function certFilterFieldValue(item: Certificate, field: string) {
}
}
function certRelativeBadge(status: string, expiresAt: string | null) {
export function certRelativeBadge(status: string, expiresAt: string | null) {
const relative = formatRelative(expiresAt)
if (!expiresAt) {
return <span className="text-muted-foreground tabular-nums"></span>
@@ -112,6 +125,39 @@ export function useCertificateColumns() {
<span className="truncate font-medium">{row.original.hostname}</span>
),
},
{
id: 'service',
accessorFn: (row) => row.service_name ?? '',
header: ({ column }) => (
<DataGridColumnHeader
column={column}
title="Сервис"
icon={<ServerIcon className="size-3.5" />}
/>
),
cell: ({ row }) => {
const serviceId = row.original.service_id
const name = row.original.service_name
if (serviceId == null || !name) {
return <span className="text-muted-foreground"></span>
}
return (
<Button
variant="link"
className="h-auto max-w-full truncate p-0 font-medium"
nativeButton={false}
render={
<Link
to="/services/$serviceId"
params={{ serviceId: String(serviceId) }}
/>
}
>
{name}
</Button>
)
},
},
{
id: 'status',
header: 'Статус',
@@ -1,4 +1,5 @@
import { useMemo, useState, type ReactNode } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import type { ColumnDef } from '@tanstack/react-table'
import {
GlobeIcon,
@@ -6,7 +7,9 @@ import {
PlusIcon,
SearchIcon,
ServerIcon,
ShieldCheckIcon,
} from 'lucide-react'
import { toast } from 'sonner'
import { HealthCheckBadge } from '@/components/health-check-badge'
import { StatusBadge } from '@/components/status-badge'
@@ -15,9 +18,28 @@ import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-colu
import { IconTile } from '@/components/reui/icon-tile'
import { createFilter, type Filter, type FilterFieldConfig } from '@/components/reui/filters'
import { ResourcePage } from '@/components/reui-kit'
import type { ServiceView } from '@/lib/schemas'
import { certRelativeBadge } from '@/components/columns/certificates-columns'
import { certMonitoringOptions } from '@/lib/cert-monitoring'
import { formatDate } from '@/lib/format'
import type { ServiceCertificateRow, ServiceView } from '@/lib/schemas'
import type { CertMonitoring } from '@cfdm/shared'
import {
certKeys,
checkServiceCertificates,
patchBindingCertMonitoring,
serviceCertificatesQueryOptions,
} from '@/queries'
import { Button } from '@cfdm/ui/components/button'
import { Switch } from '@cfdm/ui/components/switch'
import {
ToggleGroup,
ToggleGroupItem,
} from '@cfdm/ui/components/toggle-group'
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@cfdm/ui/components/tooltip'
type HealthStatus = 'up' | 'down' | 'degraded' | 'unknown'
@@ -60,6 +82,7 @@ const TABS = [
{ id: 'ip', label: 'IP' },
{ id: 'fqdn', label: 'FQDN' },
{ id: 'nodes', label: 'Ноды' },
{ id: 'ssl', label: 'SSL' },
] as const
const HEALTH_OPTIONS = [
@@ -208,6 +231,57 @@ export function ServiceDetailGrid({
createFilter('address', 'contains', ['']),
createFilter('health_status', 'is', ['']),
])
const [sslFilters, setSslFilters] = useState<Filter[]>(() => [
createFilter('hostname', 'contains', ['']),
])
const queryClient = useQueryClient()
const certQuery = useQuery(serviceCertificatesQueryOptions(service.id))
const sslRows = certQuery.data ?? []
const patchCertMonitoring = useMutation({
mutationFn: ({
bindingId,
mode,
}: {
bindingId: number
mode: CertMonitoring
}) => patchBindingCertMonitoring(bindingId, mode),
onSuccess: async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: certKeys.byService(service.id) }),
queryClient.invalidateQueries({ queryKey: certKeys.all }),
queryClient.invalidateQueries({ queryKey: certKeys.summary }),
])
toast.success('Режим проверки SSL обновлён')
},
onError: (err) => {
toast.error(
err instanceof Error ? err.message : 'Не удалось обновить режим SSL',
)
},
})
const checkSsl = useMutation({
mutationFn: () => checkServiceCertificates(service.id),
onSuccess: async (result) => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: certKeys.byService(service.id) }),
queryClient.invalidateQueries({ queryKey: certKeys.all }),
queryClient.invalidateQueries({ queryKey: certKeys.summary }),
])
toast.success(
result.checked > 0
? `Проверено FQDN: ${result.checked}`
: 'Нет FQDN для проверки SSL',
)
},
onError: (err) => {
toast.error(
err instanceof Error ? err.message : 'Не удалось проверить SSL',
)
},
})
const ipRows = useMemo(() => buildIpRows(service), [service])
const fqdnRows = useMemo(() => buildFqdnRows(service), [service])
@@ -221,7 +295,9 @@ export function ServiceDetailGrid({
? ipRows.length
: entry.id === 'fqdn'
? fqdnRows.length
: nodeRows.length,
: entry.id === 'ssl'
? sslRows.length
: nodeRows.length,
}))
const ipFilterFields = useMemo<FilterFieldConfig[]>(
@@ -282,6 +358,20 @@ export function ServiceDetailGrid({
[],
)
const sslFilterFields = useMemo<FilterFieldConfig[]>(
() => [
{
key: 'hostname',
label: 'FQDN',
icon: <SearchIcon className="size-3.5" aria-hidden />,
type: 'text',
className: 'w-52',
placeholder: 'Поиск по FQDN…',
},
],
[],
)
const ipColumns = useMemo<ColumnDef<ServiceIpRow>[]>(
() => [
{
@@ -473,6 +563,91 @@ export function ServiceDetailGrid({
[onDeleteNode],
)
const sslColumns = useMemo<ColumnDef<ServiceCertificateRow>[]>(
() => [
{
id: 'hostname',
accessorKey: 'hostname',
header: ({ column }) => (
<DataGridColumnHeader column={column} title="FQDN" />
),
cell: ({ row }) => (
<NameCell
icon={<ShieldCheckIcon />}
label={row.original.hostname}
iconClassName="text-foreground"
/>
),
},
{
id: 'status',
header: 'Статус',
cell: ({ row }) => <StatusBadge status={row.original.status} />,
},
{
id: 'expires_at',
accessorKey: 'expires_at',
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Истекает" />
),
cell: ({ row }) => (
<span className="text-muted-foreground tabular-nums">
{formatDate(row.original.expires_at)}
</span>
),
},
{
id: 'relative',
header: 'Срок',
cell: ({ row }) =>
certRelativeBadge(row.original.status, row.original.expires_at),
},
{
id: 'mode',
header: 'Режим',
cell: ({ row }) => (
<ToggleGroup
variant="outline"
size="sm"
value={[row.original.cert_monitoring]}
onValueChange={(next) => {
const value = Array.isArray(next) ? next[0] : next
if (
typeof value !== 'string' ||
value === row.original.cert_monitoring
) {
return
}
patchCertMonitoring.mutate({
bindingId: row.original.binding_id,
mode: value as CertMonitoring,
})
}}
>
{certMonitoringOptions.map((option) => (
<ToggleGroupItem key={option.value} value={option.value}>
{option.label}
</ToggleGroupItem>
))}
</ToggleGroup>
),
},
{
id: 'last_checked_at',
accessorKey: 'last_checked_at',
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Проверка" />
),
cell: ({ row }) => (
<span className="text-muted-foreground tabular-nums">
{formatDate(row.original.last_checked_at)}
</span>
),
},
],
[patchCertMonitoring],
)
const sharedTabs = {
tabs,
activeTab: tab,
@@ -544,6 +719,52 @@ export function ServiceDetailGrid({
)
}
if (tab === 'ssl') {
return (
<ResourcePage
title="Активы сервиса"
description="IP, FQDN, ноды и SSL этого сервиса"
{...sharedTabs}
filterFields={sslFilterFields}
filters={sslFilters}
onFiltersChange={setSslFilters}
onClearFilters={() =>
setSslFilters([createFilter('hostname', 'contains', [''])])
}
getFilterFieldValue={(item, field) =>
field === 'hostname' ? item.hostname : ''
}
columns={sslColumns}
data={sslRows}
getRowId={(row) => String(row.binding_id)}
isLoading={isLoading || certQuery.isLoading}
primaryAction={
<Tooltip>
<TooltipTrigger
render={
<Button
type="button"
variant="ghost"
size="icon"
aria-label="Проверить SSL"
disabled={checkSsl.isPending || sslRows.length === 0}
onClick={() => checkSsl.mutate()}
/>
}
>
<ShieldCheckIcon className="size-4.5" aria-hidden />
</TooltipTrigger>
<TooltipContent>Проверить</TooltipContent>
</Tooltip>
}
emptyState={{
title: 'Нет FQDN',
description: 'Привяжите домен к сервису, чтобы мониторить SSL.',
}}
/>
)
}
return (
<ResourcePage
title="Активы сервиса"
@@ -3,10 +3,8 @@ 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'
@@ -30,7 +28,6 @@ import {
const subdomainEditSchema = z.object({
name: z.string().min(1, 'Укажите имя'),
serviceId: z.string(),
certMonitoring: z.enum(['auto', 'required', 'skipped']),
})
export type SubdomainEditValues = z.infer<typeof subdomainEditSchema>
@@ -67,7 +64,6 @@ export function SubdomainEditSheet({
defaultValues: {
name: '',
serviceId: 'none',
certMonitoring: 'auto',
},
})
@@ -85,42 +81,27 @@ export function SubdomainEditSheet({
[services, serviceGroupById],
)
const certMonitoringItems = useMemo(
() =>
certMonitoringOptions.map((option) => ({
label: option.label,
value: option.value,
})),
[],
)
useEffect(() => {
if (!open) return
if (mode === 'edit' && subdomain) {
form.reset({
name: subdomain.name,
serviceId: currentServiceId || 'none',
certMonitoring: subdomain.cert_monitoring,
})
return
}
form.reset({
name: '',
serviceId: 'none',
certMonitoring: 'auto',
})
}, [open, mode, subdomain, currentServiceId, form])
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({
name: values.name.trim(),
serviceId: values.serviceId,
certMonitoring: values.certMonitoring as CertMonitoring,
})
}
@@ -218,39 +199,6 @@ export function SubdomainEditSheet({
)}
/>
</FormFieldSimple>
<FormFieldSimple
label="Мониторинг SSL"
htmlFor="subdomain_cert_monitoring"
hint={certHint}
>
<Controller
control={form.control}
name="certMonitoring"
render={({ field }) => (
<Select
items={certMonitoringItems}
value={field.value}
onValueChange={(value) =>
field.onChange((value ?? 'auto') as CertMonitoring)
}
>
<SelectTrigger
id="subdomain_cert_monitoring"
className="w-full"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{certMonitoringOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
</FormFieldSimple>
</>
) : null}
</FieldGroup>