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
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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user