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
Собрать панель, списки и настройки в одной оболочке, чтобы статусы, очередь и свежесть данных были видны на доменах, сервисах и группах. Co-authored-by: Cursor <[email protected]>
411 lines
13 KiB
TypeScript
411 lines
13 KiB
TypeScript
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>
|
||
)
|
||
}
|