Files
cloudflare-domain-manager/apps/web/src/routes/_auth/index.tsx
T
DenozordecandCursor 0ef57c673c
quality / changes (push) Successful in 9s
quality / commitlint (push) Skipped
quality / docker-check (push) Skipped
CD / update-wiki (push) Successful in 6s
quality / web (push) Successful in 1m29s
quality / api (push) Successful in 1m10s
CD / quality (push) Successful in 2m54s
CD / publish (push) Successful in 42s
feat(web): перенести оболочку и карточки на дизайн CF Domain Manager
Собрать панель, списки и настройки в одной оболочке, чтобы статусы, очередь и свежесть данных были видны на доменах, сервисах и группах.

Co-authored-by: Cursor <[email protected]>
2026-11-02 18:33:27 +07:00

411 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { createFileRoute, Link } from '@tanstack/react-router'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useMemo, useState, useEffect } from 'react'
import { toast } from 'sonner'
import {
ActivityIcon,
AlertTriangleIcon,
FolderTreeIcon,
GlobeIcon,
PlugIcon,
HeartPulseIcon,
ServerIcon,
ShieldCheckIcon,
} from 'lucide-react'
import {
certificatesQueryOptions,
certSummaryQueryOptions,
dnsKeys,
domainKeys,
domainsListQueryOptions,
groupsQueryOptions,
opsSummaryQueryOptions,
serviceGroupsQueryOptions,
subdomainKeys,
} from '@/queries'
import { PageShell } from '@/components/page-shell'
import { PageHeader } from '@/components/page-header'
import { LoadingButton } from '@/components/loading-button'
import {
CertStatusChart,
GroupDomainsChart,
OpsDashboard,
QuickActionGrid,
AttentionQueue,
ExpiringCertGrid,
HealthProblemGrid,
UngroupedDomainGrid,
type KpiStatCard,
type QuickActionItem,
} from '@/components/reui-kit'
import { Button } from '@cfdm/ui/components/button'
import { DEBUG_BUILD_STAMP, debugAgentLog } from '@/lib/debug-agent-log'
import { api } from '@/lib/api-client'
import type { ServiceView } from '@/lib/schemas'
export const Route = createFileRoute('/_auth/')({
loader: ({ context: { queryClient } }) =>
Promise.allSettled([
queryClient.ensureQueryData(domainsListQueryOptions()),
queryClient.ensureQueryData(certSummaryQueryOptions()),
queryClient.ensureQueryData(groupsQueryOptions()),
queryClient.ensureQueryData(serviceGroupsQueryOptions()),
queryClient.ensureQueryData(certificatesQueryOptions()),
queryClient.ensureQueryData(opsSummaryQueryOptions()),
]),
component: DashboardPage,
})
function errorMessage(error: unknown) {
return error instanceof Error ? error.message : undefined
}
function DashboardPage() {
const queryClient = useQueryClient()
const {
data: domains,
isLoading: domainsLoading,
isError: domainsFailed,
error: domainsQueryError,
refetch: refetchDomains,
} = useQuery(domainsListQueryOptions())
const { data: summary, isLoading: summaryLoading } = useQuery(certSummaryQueryOptions())
const {
data: certs,
isError: certsFailed,
error: certsQueryError,
refetch: refetchCerts,
} = useQuery(certificatesQueryOptions())
const { data: groups } = useQuery(groupsQueryOptions())
const {
data: serviceData,
isError: servicesFailed,
error: servicesQueryError,
refetch: refetchServices,
} = useQuery(serviceGroupsQueryOptions())
const { data: ops } = useQuery(opsSummaryQueryOptions())
const syncMutation = useMutation({
mutationFn: () => api.post<{ job_id: string }>('/api/v1/sync'),
onSuccess: async () => {
toast.success('Синхронизация завершена')
await Promise.all([
queryClient.invalidateQueries({ queryKey: domainKeys.all }),
queryClient.invalidateQueries({ queryKey: subdomainKeys.all }),
queryClient.invalidateQueries({ queryKey: dnsKeys.all }),
])
},
onError: (error: unknown) => {
toast.error(
error instanceof Error ? error.message : 'Не удалось синхронизировать домены',
)
},
})
const { data: appSettings } = useQuery({
queryKey: ['app-settings'],
queryFn: () => api.get<{ showQuickActions?: boolean }>('/api/v1/settings'),
})
const showQuickActions = appSettings?.showQuickActions !== false
const quickActions: QuickActionItem[] = [
{
id: 'domains',
title: 'Домены',
description: 'Зоны Cloudflare и привязки.',
to: '/domains',
icon: <GlobeIcon aria-hidden />,
iconClassName: 'text-info',
},
{
id: 'groups',
title: 'Группы',
description: 'Группировка доменов.',
to: '/groups',
icon: <FolderTreeIcon aria-hidden />,
iconClassName: 'text-primary',
},
{
id: 'services',
title: 'Сервисы',
description: 'Сервисы и health-check.',
to: '/services',
search: { domainId: undefined },
icon: <ServerIcon aria-hidden />,
iconClassName: 'text-success',
},
{
id: 'certs',
title: 'Сертификаты',
description: 'Статус и сроки SSL.',
to: '/certificates',
icon: <ShieldCheckIcon aria-hidden />,
iconClassName: 'text-warning',
},
{
id: 'integrations',
title: 'Интеграции',
description: 'VPS Tracker и app switcher.',
to: '/settings/integrations',
icon: <PlugIcon aria-hidden />,
iconClassName: 'text-muted-foreground',
},
]
const serviceCount =
(serviceData?.groups.reduce((sum, g) => sum + g.services.length, 0) ?? 0) +
(serviceData?.ungrouped.length ?? 0)
const isLoading = domainsLoading || summaryLoading
const statusChartData = useMemo(
() => (summary ?? []).map(([status, count]) => ({ status, count })),
[summary],
)
const groupChartData = useMemo(() => {
const list = groups ?? []
return list
.map((g) => ({
name: g.name,
count: domains?.filter((d) => d.group_id === g.id).length ?? 0,
}))
.sort((a, b) => b.count - a.count)
.slice(0, 6)
}, [groups, domains])
const [nowMs] = useState(() => Date.now())
const ungroupedCount = useMemo(
() => (domains ?? []).filter((d) => d.group_id == null).length,
[domains],
)
const expiringCerts = useMemo(() => {
const warnMs = 14 * 24 * 60 * 60 * 1000
return (certs ?? [])
.filter((cert) => cert.expires_at)
.map((cert) => ({
cert,
ts: new Date(cert.expires_at as string).getTime(),
}))
.filter((entry) => !Number.isNaN(entry.ts) && entry.ts - nowMs <= warnMs)
.sort((a, b) => a.ts - b.ts)
.map((entry) => entry.cert)
}, [certs, nowMs])
const ungroupedDomains = useMemo(
() => (domains ?? []).filter((domain) => domain.group_id == null),
[domains],
)
const attentionServices = useMemo(() => {
const list: ServiceView[] = []
const pushIfUnhealthy = (service: ServiceView) => {
if (service.health_status === 'down' || service.health_status === 'degraded') {
list.push(service)
}
}
for (const group of serviceData?.groups ?? []) {
for (const service of group.services) pushIfUnhealthy(service)
}
for (const service of serviceData?.ungrouped ?? []) pushIfUnhealthy(service)
return list
}, [serviceData])
const attentionCount = attentionServices.length
const ungroupedServiceCount = serviceData?.ungrouped.length ?? 0
useEffect(() => {
if (isLoading) return
debugAgentLog(
'index.tsx:dashboard-data',
'dashboard chart inputs',
{
buildStamp: DEBUG_BUILD_STAMP,
summaryRaw: summary ?? null,
statusChartLen: statusChartData.length,
statusChartData,
groupChartLen: groupChartData.length,
groupChartData,
domainsLen: domains?.length ?? 0,
groupsLen: groups?.length ?? 0,
},
'C',
)
}, [isLoading, summary, statusChartData, groupChartData, domains, groups])
const kpiCards: KpiStatCard[] = [
{
id: 'domains',
label: 'Домены',
value: domainsFailed && domains == null ? '—' : (domains?.length ?? 0),
hint:
domainsFailed && domains == null
? undefined
: ungroupedCount > 0
? `${ungroupedCount} без группы`
: 'Все в группах',
icon: <GlobeIcon aria-hidden />,
iconClassName: 'text-info',
variant: ungroupedCount > 0 ? 'warning' : 'default',
to: '/domains',
},
{
id: 'services',
label: 'Сервисы',
value: ops?.services ?? serviceCount,
hint: attentionCount
? `${attentionCount} требуют внимания`
: ungroupedServiceCount
? `${ungroupedServiceCount} без группы`
: undefined,
icon: <ServerIcon aria-hidden />,
iconClassName: 'text-success',
variant: attentionCount > 0 ? 'destructive' : ungroupedServiceCount > 0 ? 'warning' : 'default',
to: '/services',
search: { domainId: undefined },
},
{
id: 'nodes',
label: 'Ноды',
value: ops?.nodes ?? 0,
hint: `${ops?.healthy ?? 0} healthy`,
icon: <HeartPulseIcon aria-hidden />,
iconClassName: 'text-info',
to: '/services',
},
{
id: 'unhealthy',
label: 'Unhealthy',
value: ops?.unhealthy ?? 0,
hint: `активных failover: ${ops?.active_failovers ?? 0}`,
variant: (ops?.unhealthy ?? 0) > 0 ? 'destructive' : 'default',
icon: <AlertTriangleIcon aria-hidden />,
iconClassName: 'text-destructive',
},
]
return (
<PageShell>
<PageHeader
title="Панель управления"
description="Обзор доменов, групп, сервисов и сертификатов Cloudflare"
actions={
<LoadingButton
type="button"
onClick={() => syncMutation.mutate()}
isLoading={syncMutation.isPending}
loadingLabel="Синхронизация…"
>
Синхронизировать
</LoadingButton>
}
/>
<OpsDashboard
isLoading={isLoading}
kpiCards={kpiCards}
afterKpi={
showQuickActions ? (
<QuickActionGrid
actions={quickActions}
description="Частые разделы доменов и инфраструктуры"
/>
) : null
}
charts={
<>
{/* health-log — журнал проб сервиса, не ряд Healthy/Unhealthy за 24 часа */}
<CertStatusChart data={statusChartData} />
<GroupDomainsChart data={groupChartData} />
</>
}
queue={
<AttentionQueue
columns={[
{
id: 'health',
title: 'Проблемы health-check',
icon: ActivityIcon,
iconClassName: 'text-destructive [&_svg]:text-current',
count: attentionCount,
countVariant: 'destructive-light',
emptyTitle: 'Нет проблем',
emptyDescription: 'Нет сервисов со статусом Down или Slow',
isError: servicesFailed && serviceData == null,
errorMessage: errorMessage(servicesQueryError),
onRetry: () => {
void refetchServices()
},
emptyAction: (
<Button
variant="outline"
size="sm"
nativeButton={false}
render={<Link to="/services" />}
>
К сервисам
</Button>
),
children: <HealthProblemGrid services={attentionServices} />,
},
{
id: 'certs',
title: 'Истекающие сертификаты',
icon: AlertTriangleIcon,
iconClassName: 'text-warning [&_svg]:text-current',
count: expiringCerts.length,
countVariant: 'warning-light',
emptyTitle: 'Нет истечений',
emptyDescription: 'В пределах 14 дней истечений нет',
isError: certsFailed && certs == null,
errorMessage: errorMessage(certsQueryError),
onRetry: () => {
void refetchCerts()
},
emptyAction: (
<Button
variant="outline"
size="sm"
nativeButton={false}
render={<Link to="/certificates" />}
>
К сертификатам
</Button>
),
children: <ExpiringCertGrid certs={expiringCerts} />,
},
{
id: 'ungrouped',
title: 'Домены без группы',
icon: FolderTreeIcon,
iconClassName: 'text-info [&_svg]:text-current',
count: ungroupedCount,
countVariant: 'warning-light',
emptyTitle: 'Всё сгруппировано',
emptyDescription: 'Все домены в группах',
isError: domainsFailed && domains == null,
errorMessage: errorMessage(domainsQueryError),
onRetry: () => {
void refetchDomains()
},
emptyAction: (
<Button
variant="outline"
size="sm"
nativeButton={false}
render={<Link to="/domains" />}
>
К доменам
</Button>
),
children: <UngroupedDomainGrid domains={ungroupedDomains} />,
},
]}
/>
}
/>
</PageShell>
)
}