feat(web): enhance integration settings and UI components
Build, Test, and Push CFDM Docker Image / test (push) Successful in 3m25s
Build, Test, and Push CFDM Docker Image / build-and-push (push) Successful in 1m49s
Build, Test, and Push CFDM Docker Image / update-wiki (push) Successful in 5s
Build, Test, and Push CFDM Docker Image / create-release (push) Has been skipped

- Updated AppSwitcherEditor to include item separators for better visual organization.
- Improved VpsTrackerIntegrationCard with badges indicating synchronization status and token configuration.
- Refactored SettingsShell to streamline layout and enhance accessibility with improved navigation.
- Added detail fields to KPI cards in DashboardPage for clearer information presentation.
- Introduced new components in the ReUI kit for better integration and user experience.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Denozordec
2026-07-10 01:10:34 +07:00
co-authored by Cursor
parent 1de497d4b4
commit 781c88175c
9 changed files with 462 additions and 325 deletions
@@ -4,9 +4,9 @@ import { PlusIcon, Trash2Icon } from 'lucide-react'
import { appSwitcherConfigSchema, type AppSwitcherConfig } from '@cfdm/shared'
import { Button } from '@cfdm/ui/components/button'
import { AppCard, AppCardContent, AppCardDescription, AppCardHeader, AppCardTitle } from '@/components/app-card'
import { FieldGroup } from '@cfdm/ui/components/field'
import { Input } from '@cfdm/ui/components/input'
import { ItemSeparator } from '@cfdm/ui/components/item'
import { FormFieldSimple } from '@/components/form-field'
import { SelectField } from '@/components/select-field'
import { LoadingButton } from '@/components/loading-button'
@@ -33,29 +33,31 @@ export function AppSwitcherEditor({ defaultValues, onSave, isSaving }: AppSwitch
const { fields, append, remove } = useFieldArray({ control: form.control, name: 'apps' })
return (
<AppCard>
<AppCardHeader>
<AppCardTitle>Связанные приложения</AppCardTitle>
<AppCardDescription>URL для переключателя в sidebar</AppCardDescription>
</AppCardHeader>
<AppCardContent>
<form
className="flex flex-col gap-4"
onSubmit={(e) => void form.handleSubmit((values) => onSave(values))(e)}
>
<FieldGroup>
<FormFieldSimple label="Заголовок меню" htmlFor="menu-label">
<Input id="menu-label" {...form.register('menuLabel')} />
</FormFieldSimple>
{fields.map((field, index) => (
<div key={field.id} className="grid gap-3 rounded-lg border p-3 md:grid-cols-2">
<form
className="flex flex-col gap-4"
onSubmit={(e) => void form.handleSubmit((values) => onSave(values))(e)}
>
<FieldGroup>
<FormFieldSimple label="Заголовок меню" htmlFor="menu-label">
<Input id="menu-label" {...form.register('menuLabel')} />
</FormFieldSimple>
<div className="flex flex-col gap-3">
{fields.map((field, index) => (
<div key={field.id}>
{index > 0 ? <ItemSeparator className="my-3" /> : null}
<div className="grid gap-3 sm:grid-cols-2">
<FormFieldSimple label="ID" htmlFor={`app-id-${index}`}>
<Input id={`app-id-${index}`} {...form.register(`apps.${index}.id`)} />
</FormFieldSimple>
<FormFieldSimple label="Название" htmlFor={`app-name-${index}`}>
<Input id={`app-name-${index}`} {...form.register(`apps.${index}.name`)} />
</FormFieldSimple>
<FormFieldSimple label="URL" htmlFor={`app-url-${index}`} className="md:col-span-2">
<FormFieldSimple
label="URL"
htmlFor={`app-url-${index}`}
className="sm:col-span-2"
>
<Input id={`app-url-${index}`} {...form.register(`apps.${index}.url`)} />
</FormFieldSimple>
<FormFieldSimple label="Иконка" htmlFor={`app-icon-${index}`}>
@@ -77,34 +79,42 @@ export function AppSwitcherEditor({ defaultValues, onSave, isSaving }: AppSwitch
size="icon"
disabled={fields.length <= 1}
onClick={() => remove(index)}
aria-label="Удалить приложение"
>
<Trash2Icon className="size-4" />
</Button>
</div>
</div>
))}
<Button
type="button"
variant="outline"
className="w-fit"
onClick={() =>
append({
id: `app-${fields.length + 1}`,
name: 'Приложение',
url: 'http://localhost:3000',
icon: 'server',
})
}
>
<PlusIcon data-icon="inline-start" />
Добавить приложение
</Button>
</FieldGroup>
<LoadingButton type="submit" className="w-fit" isLoading={isSaving} disabled={!form.formState.isDirty}>
Сохранить приложения
</LoadingButton>
</form>
</AppCardContent>
</AppCard>
</div>
))}
</div>
<Button
type="button"
variant="outline"
className="w-fit"
onClick={() =>
append({
id: `app-${fields.length + 1}`,
name: 'Приложение',
url: 'http://localhost:3000',
icon: 'server',
})
}
>
<PlusIcon data-icon="inline-start" />
Добавить приложение
</Button>
</FieldGroup>
<LoadingButton
type="submit"
className="w-fit"
isLoading={isSaving}
disabled={!form.formState.isDirty}
>
Сохранить приложения
</LoadingButton>
</form>
)
}
@@ -3,13 +3,13 @@ import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { toast } from 'sonner'
import { AppCard, AppCardContent, AppCardDescription, AppCardHeader, AppCardTitle } from '@/components/app-card'
import { Badge } from '@/components/reui/badge'
import { FieldGroup } from '@cfdm/ui/components/field'
import { Input } from '@cfdm/ui/components/input'
import { FormFieldSimple } from '@/components/form-field'
import { SelectField } from '@/components/select-field'
import { LoadingButton } from '@/components/loading-button'
import { AppButton } from '@/components/app-button'
import { Button } from '@cfdm/ui/components/button'
import { api } from '@/lib/api-client'
const formSchema = z.object({
@@ -55,72 +55,81 @@ export function VpsTrackerIntegrationCard({
else toast.error(result.error ?? 'Ошибка проверки')
}
const syncBadgeVariant = settings?.vpsTrackerSyncEnabled ? 'success' : 'secondary'
return (
<AppCard>
<AppCardHeader>
<AppCardTitle>VPS Tracker</AppCardTitle>
<AppCardDescription>
Исходящая синхронизация доменов и сервисов. URL API VPS Tracker (обычно порт 3001).
</AppCardDescription>
</AppCardHeader>
<AppCardContent>
<form className="flex flex-col gap-4" onSubmit={(e) => void form.handleSubmit((values) => onSave(values))(e)}>
<FieldGroup>
<FormFieldSimple label="URL VPS Tracker" htmlFor="vps-url">
<Input
id="vps-url"
placeholder="http://192.168.100.67:3001"
{...form.register('vpsTrackerUrl')}
<form
className="flex flex-col gap-4"
onSubmit={(e) => void form.handleSubmit((values) => onSave(values))(e)}
>
<div className="flex flex-wrap items-center gap-2">
<Badge variant={syncBadgeVariant}>
{settings?.vpsTrackerSyncEnabled ? 'Синхронизация включена' : 'Синхронизация выключена'}
</Badge>
{settings?.vpsTrackerIntegrationTokenSet ? (
<Badge variant="info">Токен настроен</Badge>
) : (
<Badge variant="warning">Токен не задан</Badge>
)}
</div>
<FieldGroup>
<FormFieldSimple label="URL VPS Tracker" htmlFor="vps-url">
<Input
id="vps-url"
placeholder="http://192.168.100.67:3001"
{...form.register('vpsTrackerUrl')}
/>
</FormFieldSimple>
<FormFieldSimple label="Integration token" htmlFor="vps-token">
<Input
id="vps-token"
type="password"
autoComplete="new-password"
placeholder={
settings?.vpsTrackerIntegrationTokenSet
? 'Токен установлен — введите новый для замены'
: 'Тот же токен, что в VPS Tracker'
}
{...form.register('vpsTrackerIntegrationToken')}
/>
</FormFieldSimple>
<Controller
control={form.control}
name="vpsTrackerSyncEnabled"
render={({ field }) => (
<FormFieldSimple label="Синхронизация" htmlFor="vps-sync">
<SelectField
triggerId="vps-sync"
triggerClassName="w-32"
value={field.value ? 'on' : 'off'}
onValueChange={(v) => field.onChange((v ?? 'on') === 'on')}
options={[
{ value: 'on', label: 'Вкл' },
{ value: 'off', label: 'Выкл' },
]}
/>
</FormFieldSimple>
<FormFieldSimple label="Integration token" htmlFor="vps-token">
<Input
id="vps-token"
type="password"
autoComplete="new-password"
placeholder={
settings?.vpsTrackerIntegrationTokenSet
? 'Токен установлен — введите новый для замены'
: 'Тот же токен, что в VPS Tracker'
}
{...form.register('vpsTrackerIntegrationToken')}
/>
</FormFieldSimple>
<Controller
control={form.control}
name="vpsTrackerSyncEnabled"
render={({ field }) => (
<FormFieldSimple label="Синхронизация включена" htmlFor="vps-sync">
<SelectField
triggerId="vps-sync"
triggerClassName="w-32"
value={field.value ? 'on' : 'off'}
onValueChange={(v) => field.onChange((v ?? 'on') === 'on')}
options={[
{ value: 'on', label: 'Вкл' },
{ value: 'off', label: 'Выкл' },
]}
/>
</FormFieldSimple>
)}
/>
{settings?.vpsTrackerLastSyncAt ? (
<p className="text-sm text-muted-foreground">
Последний sync:{' '}
{new Date(settings.vpsTrackerLastSyncAt).toLocaleString('ru-RU')}
</p>
) : null}
</FieldGroup>
<div className="flex flex-wrap gap-2">
<LoadingButton type="submit" isLoading={isSaving} disabled={!form.formState.isDirty}>
Сохранить
</LoadingButton>
<AppButton type="button" variant="outline" onClick={() => void handleTest()}>
Проверить связь
</AppButton>
</div>
</form>
</AppCardContent>
</AppCard>
)}
/>
{settings?.vpsTrackerLastSyncAt ? (
<p className="text-muted-foreground text-sm">
Последний sync:{' '}
{new Date(settings.vpsTrackerLastSyncAt).toLocaleString('ru-RU')}
</p>
) : (
<p className="text-muted-foreground text-sm">Синхронизация ещё не выполнялась</p>
)}
</FieldGroup>
<div className="flex flex-wrap gap-2">
<LoadingButton type="submit" isLoading={isSaving} disabled={!form.formState.isDirty}>
Сохранить
</LoadingButton>
<Button type="button" variant="outline" onClick={() => void handleTest()}>
Проверить связь
</Button>
</div>
</form>
)
}
@@ -0,0 +1,184 @@
import { useMemo } from 'react'
import { Bar, BarChart, CartesianGrid, Cell, Pie, PieChart, XAxis } from 'recharts'
import { StatusBadge } from '@/components/status-badge'
import {
Frame,
FrameDescription,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
import {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
type ChartConfig,
} from '@cfdm/ui/components/chart'
import { Separator } from '@cfdm/ui/components/separator'
import { cn } from '@cfdm/ui/lib/utils'
const statusChartConfig = {
count: { label: 'Сертификаты' },
active: { label: 'Активен', color: 'var(--success)' },
ok: { label: 'OK', color: 'var(--success)' },
warning: { label: 'Предупреждение', color: 'var(--warning)' },
pending_push: { label: 'Ожидает', color: 'var(--info)' },
expired: { label: 'Истёк', color: 'var(--destructive)' },
error: { label: 'Ошибка', color: 'var(--destructive)' },
unknown: { label: 'Неизвестно', color: 'var(--muted-foreground)' },
} satisfies ChartConfig
const groupChartConfig = {
count: { label: 'Домены', color: 'var(--chart-1)' },
} satisfies ChartConfig
function statusColor(status: string) {
return (
(statusChartConfig as Record<string, { color?: string }>)[status]?.color ??
'var(--chart-1)'
)
}
interface CertStatusChartProps {
data: { status: string; count: number }[]
}
export function CertStatusChart({ data }: CertStatusChartProps) {
const total = useMemo(
() => data.reduce((sum, entry) => sum + entry.count, 0),
[data],
)
return (
<Frame dense spacing="sm" className="h-full w-full">
<FrameHeader>
<FrameTitle>Статусы сертификатов</FrameTitle>
<FrameDescription>Распределение по последней проверке</FrameDescription>
</FrameHeader>
<FramePanel fit>
{data.length === 0 ? (
<p className="text-muted-foreground text-sm">Нет данных о сертификатах</p>
) : (
<div className="grid w-full gap-6 @md:grid-cols-[9rem_minmax(0,1fr)] @md:items-center">
<div className="relative mx-auto size-36 shrink-0">
<ChartContainer
config={statusChartConfig}
className="aspect-square size-36"
initialDimension={{ width: 144, height: 144 }}
>
<PieChart margin={{ top: 4, right: 4, bottom: 4, left: 4 }}>
<ChartTooltip
content={<ChartTooltipContent nameKey="status" hideLabel />}
/>
<Pie
data={data}
dataKey="count"
nameKey="status"
innerRadius={44}
outerRadius={64}
strokeWidth={2}
stroke="var(--background)"
>
{data.map((entry) => (
<Cell key={entry.status} fill={statusColor(entry.status)} />
))}
</Pie>
</PieChart>
</ChartContainer>
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0 flex items-center justify-center"
>
<div className="flex flex-col items-center">
<span className="text-muted-foreground text-xs">Всего</span>
<span className="text-lg font-semibold tabular-nums">{total}</span>
</div>
</div>
</div>
<ul className="flex min-w-0 flex-col">
{data.map((entry, index) => (
<li key={entry.status}>
<div className="flex items-center justify-between gap-3 py-2">
<div className="flex min-w-0 items-center gap-2">
<span
aria-hidden="true"
className="size-2.5 shrink-0 rounded-full"
style={{ backgroundColor: statusColor(entry.status) }}
/>
<StatusBadge status={entry.status} />
</div>
<span className="text-sm font-medium tabular-nums">{entry.count}</span>
</div>
{index < data.length - 1 ? <Separator /> : null}
</li>
))}
</ul>
</div>
)}
</FramePanel>
</Frame>
)
}
interface GroupDomainsChartProps {
data: { name: string; count: number }[]
}
export function GroupDomainsChart({ data }: GroupDomainsChartProps) {
return (
<Frame dense spacing="sm" className="h-full w-full">
<FrameHeader>
<FrameTitle>Домены по группам</FrameTitle>
<FrameDescription>Топ-6 групп по количеству зон</FrameDescription>
</FrameHeader>
<FramePanel fit>
{data.length === 0 ? (
<p className="text-muted-foreground text-sm">Нет групп с доменами</p>
) : (
<div className="flex w-full flex-col gap-4">
<ChartContainer
config={groupChartConfig}
className="aspect-auto h-52 w-full min-h-52"
initialDimension={{ width: 480, height: 208 }}
>
<BarChart data={data} margin={{ top: 8, right: 8, left: 0, bottom: 0 }}>
<CartesianGrid vertical={false} />
<XAxis
dataKey="name"
tickLine={false}
axisLine={false}
tickMargin={8}
interval={0}
height={40}
tickFormatter={(value: string) =>
value.length > 12 ? `${value.slice(0, 11)}` : value
}
/>
<ChartTooltip content={<ChartTooltipContent nameKey="count" />} />
<Bar dataKey="count" fill="var(--color-count)" radius={4} />
</BarChart>
</ChartContainer>
<ul className="grid gap-2 @sm:grid-cols-2">
{data.map((entry) => (
<li
key={entry.name}
className={cn(
'bg-muted/40 flex items-center justify-between gap-2 rounded-lg border px-3 py-2',
)}
>
<span className="truncate text-sm font-medium">{entry.name}</span>
<span className="text-muted-foreground shrink-0 text-sm tabular-nums">
{entry.count}
</span>
</li>
))}
</ul>
</div>
)}
</FramePanel>
</Frame>
)
}
@@ -1,4 +1,5 @@
export { applyFiltersToData, getActiveFilters, renderSingleSelectedLabel } from './filter-utils'
export { CertStatusChart, GroupDomainsChart } from './dashboard-analytics'
export { ResourcePage, type ResourcePageProps, type ResourcePageTab } from './resource-page'
export { OpsDashboard, OpsDashboardHintLink, type OpsKpiCard } from './ops-dashboard'
export { KanbanBoard, type KanbanBoardProps, type KanbanColumnConfig } from './kanban-board'
@@ -17,6 +17,7 @@ export interface OpsKpiCard {
metricLabel: string
value: string | number
hint?: ReactNode
detail?: ReactNode
icon: ReactNode
iconBg?: string
}
@@ -61,6 +62,9 @@ function KpiCardItem({ card }: { card: OpsKpiCard }) {
</span>
{card.hint}
</div>
{card.detail ? (
<p className="text-muted-foreground text-xs leading-snug">{card.detail}</p>
) : null}
</div>
</FramePanel>
)
@@ -74,7 +78,7 @@ export function OpsDashboard({
queue,
}: OpsDashboardProps) {
return (
<div className="text-foreground @container mx-auto flex w-full flex-col gap-4 md:gap-6">
<div className="text-foreground @container mx-auto flex w-full max-w-7xl flex-col gap-4 md:gap-6">
<header className="px-1">
<h1 className="text-xl font-semibold tracking-tight">{title}</h1>
<p className="text-muted-foreground max-w-2xl text-sm leading-relaxed">
@@ -94,7 +98,7 @@ export function OpsDashboard({
<section
aria-label="Аналитика"
className="grid min-w-0 items-stretch gap-4 @5xl:grid-cols-2"
className="grid min-w-0 auto-rows-fr items-start gap-4 @3xl:grid-cols-2"
>
{charts}
</section>
@@ -4,12 +4,6 @@ import { SettingsIcon } from 'lucide-react'
import { useIsMobile } from '@cfdm/ui/hooks/use-mobile'
import { cn } from '@cfdm/ui/lib/utils'
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from '@cfdm/ui/components/tabs'
import { PageShell } from '@/components/page-shell'
export interface SettingsTabConfig {
@@ -32,23 +26,19 @@ interface SettingsShellProps {
title?: string
description?: string
tabs?: SettingsTabConfig[]
children?: ReactNode
}
export function SettingsShell({
title = 'Настройки',
description = 'Интеграции и конфигурация приложения',
tabs = DEFAULT_TABS,
children,
}: SettingsShellProps) {
const isMobile = useIsMobile()
const pathname = useRouterState({ select: (s) => s.location.pathname })
const activeTab =
tabs.find((tab) => pathname.startsWith(tab.to))?.id ?? tabs[0]?.id ?? ''
return (
<PageShell>
<div className="flex w-full max-w-4xl flex-col gap-6">
<div className="mx-auto flex w-full max-w-4xl flex-col gap-6">
<header className="px-1">
<h1 className="text-xl font-semibold tracking-tight">{title}</h1>
<p className="text-muted-foreground max-w-2xl text-sm leading-relaxed">
@@ -56,45 +46,49 @@ export function SettingsShell({
</p>
</header>
<Tabs value={activeTab} orientation={isMobile ? 'horizontal' : 'vertical'}>
<div
className={cn(
'flex gap-6',
isMobile ? 'flex-col' : 'flex-row items-start',
)}
>
<TabsList
variant="line"
<div
className={cn(
'flex gap-6',
isMobile ? 'flex-col' : 'flex-row items-start',
)}
>
{tabs.length > 1 ? (
<nav
aria-label="Разделы настроек"
className={cn(
'h-auto w-full justify-start gap-1 bg-transparent p-0',
!isMobile && 'w-48 shrink-0 flex-col items-stretch',
'flex gap-1',
isMobile
? 'scrollbar-none -mx-1 overflow-x-auto pb-1'
: 'w-44 shrink-0 flex-col',
)}
>
{tabs.map((tab) => (
<TabsTrigger
key={tab.id}
value={tab.id}
className={cn(
'justify-start gap-2 px-3 py-2',
!isMobile && 'w-full',
)}
render={<Link to={tab.to} />}
>
{tab.icon}
{tab.label}
</TabsTrigger>
))}
</TabsList>
{tabs.map((tab) => {
const isActive = pathname.startsWith(tab.to)
return (
<Link
key={tab.id}
to={tab.to}
className={cn(
'flex items-center gap-2 rounded-lg px-3 py-2 text-sm transition-colors',
isMobile && 'shrink-0',
!isMobile && 'w-full',
isActive
? 'bg-muted text-foreground font-medium'
: 'text-muted-foreground hover:bg-muted/60 hover:text-foreground',
)}
>
{tab.icon}
{tab.label}
</Link>
)
})}
</nav>
) : null}
<div className="min-w-0 flex-1">
{tabs.map((tab) => (
<TabsContent key={tab.id} value={tab.id} className="mt-0">
{children ?? <Outlet />}
</TabsContent>
))}
</div>
<div className="min-w-0 flex-1">
<Outlet />
</div>
</Tabs>
</div>
</div>
</PageShell>
)
+67 -145
View File
@@ -2,16 +2,7 @@ import { createFileRoute, Link } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import { useMemo, useState } from 'react'
import {
Bar,
BarChart,
CartesianGrid,
Cell,
Label,
Pie,
PieChart,
XAxis,
} from 'recharts'
import {
AlertTriangleIcon,
FolderTreeIcon,
GlobeIcon,
ServerIcon,
@@ -25,23 +16,13 @@ import {
serviceGroupsQueryOptions,
} from '@/queries'
import { PageShell } from '@/components/page-shell'
import { OpsDashboard, OpsDashboardHintLink } from '@/components/reui-kit'
import {
Frame,
FrameDescription,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
CertStatusChart,
GroupDomainsChart,
OpsDashboard,
OpsDashboardHintLink,
} from '@/components/reui-kit'
import { StatusBadge } from '@/components/status-badge'
import {
ChartContainer,
ChartLegend,
ChartLegendContent,
ChartTooltip,
ChartTooltipContent,
type ChartConfig,
} from '@cfdm/ui/components/chart'
import {
Item,
ItemContent,
@@ -62,27 +43,14 @@ export const Route = createFileRoute('/_auth/')({
component: DashboardPage,
})
const statusChartConfig = {
count: { label: 'Сертификаты' },
active: { label: 'Активен', color: 'var(--success)' },
ok: { label: 'OK', color: 'var(--success)' },
warning: { label: 'Предупреждение', color: 'var(--warning)' },
pending_push: { label: 'Ожидает', color: 'var(--info)' },
expired: { label: 'Истёк', color: 'var(--destructive)' },
error: { label: 'Ошибка', color: 'var(--destructive)' },
unknown: { label: 'Неизвестно', color: 'var(--muted-foreground)' },
} satisfies ChartConfig
const KPI_ICON_BG = ['bg-chart-1', 'bg-chart-2', 'bg-chart-3', 'bg-chart-4']
const groupChartConfig = {
count: { label: 'Домены', color: 'var(--chart-1)' },
} satisfies ChartConfig
const KPI_ICON_BG = [
'bg-chart-1',
'bg-chart-2',
'bg-chart-3',
'bg-chart-4',
]
function countByStatus(summary: [string, number][] | undefined, statuses: string[]) {
if (!summary) return 0
return summary
.filter(([status]) => statuses.includes(status))
.reduce((sum, [, count]) => sum + count, 0)
}
function DashboardPage() {
const { data: domains, isLoading: domainsLoading } = useQuery(domainsListQueryOptions())
@@ -95,6 +63,9 @@ function DashboardPage() {
(serviceData?.groups.reduce((sum, g) => sum + g.services.length, 0) ?? 0) +
(serviceData?.ungrouped.length ?? 0)
const groupedServiceCount =
serviceData?.groups.reduce((sum, g) => sum + g.services.length, 0) ?? 0
const isLoading = domainsLoading || summaryLoading
const statusChartData = useMemo(
@@ -115,6 +86,11 @@ function DashboardPage() {
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 ?? [])
@@ -130,6 +106,9 @@ function DashboardPage() {
[domains],
)
const certWarnings = countByStatus(summary, ['warning', 'expired', 'error'])
const certOk = countByStatus(summary, ['active', 'ok'])
const kpiCards = [
{
id: 'domains',
@@ -137,6 +116,10 @@ function DashboardPage() {
title: 'Домены',
metricLabel: 'Импортированные зоны',
value: domains?.length ?? 0,
detail:
ungroupedCount > 0
? `${ungroupedCount} без группы`
: 'Все домены распределены по группам',
icon: <GlobeIcon aria-hidden="true" />,
iconBg: KPI_ICON_BG[0],
hint: <OpsDashboardHintLink to="/domains">Управление</OpsDashboardHintLink>,
@@ -147,6 +130,7 @@ function DashboardPage() {
title: 'Группы',
metricLabel: 'Группы доменов',
value: groups?.length ?? 0,
detail: `${groupChartData.filter((g) => g.count > 0).length} с активными зонами`,
icon: <FolderTreeIcon aria-hidden="true" />,
iconBg: KPI_ICON_BG[1],
hint: <OpsDashboardHintLink to="/groups">Канбан</OpsDashboardHintLink>,
@@ -157,6 +141,10 @@ function DashboardPage() {
title: 'Сервисы',
metricLabel: 'Привязки и сервисы',
value: serviceCount,
detail:
serviceData?.ungrouped.length
? `${serviceData.ungrouped.length} без группы · ${groupedServiceCount} в группах`
: `${groupedServiceCount} в группах`,
icon: <ServerIcon aria-hidden="true" />,
iconBg: KPI_ICON_BG[2],
hint: (
@@ -171,6 +159,10 @@ function DashboardPage() {
title: 'Сертификаты',
metricLabel: 'Мониторинг TLS',
value: certs?.length ?? 0,
detail:
certWarnings > 0
? `${certOk} в норме · ${certWarnings} требуют внимания`
: `${certOk} в норме`,
icon: <ShieldCheckIcon aria-hidden="true" />,
iconBg: KPI_ICON_BG[3],
hint: <OpsDashboardHintLink to="/certificates">Мониторинг</OpsDashboardHintLink>,
@@ -191,110 +183,29 @@ function DashboardPage() {
kpiCards={kpiCards}
charts={
<>
<Frame dense spacing="sm" className="h-full w-full">
<FrameHeader>
<FrameTitle>Статусы сертификатов</FrameTitle>
<FrameDescription>Распределение по последней проверке</FrameDescription>
</FrameHeader>
<FramePanel>
{statusChartData.length === 0 ? (
<p className="text-muted-foreground text-sm">Нет данных</p>
) : (
<ChartContainer
config={statusChartConfig}
className="mx-auto aspect-square h-64"
>
<PieChart>
<ChartTooltip
content={<ChartTooltipContent nameKey="status" hideLabel />}
/>
<Pie
data={statusChartData}
dataKey="count"
nameKey="status"
innerRadius={60}
strokeWidth={2}
>
{statusChartData.map((entry) => {
const cfg = (statusChartConfig as Record<string, { color?: string }>)[
entry.status
]
return (
<Cell
key={entry.status}
fill={cfg?.color ?? 'var(--chart-1)'}
/>
)
})}
<Label
content={({ viewBox }) => {
if (!viewBox || !('cx' in viewBox)) return null
const total = statusChartData.reduce(
(sum, entry) => sum + entry.count,
0,
)
return (
<text
x={viewBox.cx}
y={viewBox.cy}
textAnchor="middle"
dominantBaseline="middle"
className="fill-foreground text-2xl font-semibold tabular-nums"
>
{total}
</text>
)
}}
/>
</Pie>
<ChartLegend content={<ChartLegendContent nameKey="status" />} />
</PieChart>
</ChartContainer>
)}
</FramePanel>
</Frame>
<Frame dense spacing="sm" className="h-full w-full">
<FrameHeader>
<FrameTitle>Домены по группам</FrameTitle>
<FrameDescription>Топ-6 групп по количеству зон</FrameDescription>
</FrameHeader>
<FramePanel>
{groupChartData.length === 0 ? (
<p className="text-muted-foreground text-sm">Нет групп</p>
) : (
<ChartContainer
config={groupChartConfig}
className="aspect-auto h-64 w-full"
>
<BarChart data={groupChartData}>
<CartesianGrid vertical={false} />
<XAxis
dataKey="name"
tickLine={false}
axisLine={false}
tickMargin={8}
interval={0}
height={36}
tickFormatter={(value: string) =>
value.length > 10 ? `${value.slice(0, 9)}` : value
}
/>
<ChartTooltip content={<ChartTooltipContent nameKey="count" />} />
<Bar dataKey="count" fill="var(--color-count)" radius={4} />
</BarChart>
</ChartContainer>
)}
</FramePanel>
</Frame>
<CertStatusChart data={statusChartData} />
<GroupDomainsChart data={groupChartData} />
</>
}
queue={
<div className="grid gap-4 lg:grid-cols-2">
<div className="flex flex-col gap-2">
<h3 className="text-sm font-semibold">Истекающие сертификаты</h3>
<div className="flex items-center gap-2">
<AlertTriangleIcon
className="text-warning size-4 shrink-0"
aria-hidden="true"
/>
<h3 className="text-sm font-semibold">Истекающие сертификаты</h3>
{expiringCerts.length > 0 ? (
<span className="text-muted-foreground text-xs tabular-nums">
({expiringCerts.length})
</span>
) : null}
</div>
{expiringCerts.length === 0 ? (
<p className="text-muted-foreground text-sm">В пределах 14 дней истечений нет</p>
<p className="text-muted-foreground text-sm">
В пределах 14 дней истечений нет
</p>
) : (
<ItemGroup className="gap-2">
{expiringCerts.map(({ cert, ts }) => (
@@ -314,7 +225,18 @@ function DashboardPage() {
)}
</div>
<div className="flex flex-col gap-2">
<h3 className="text-sm font-semibold">Домены без группы</h3>
<div className="flex items-center gap-2">
<FolderTreeIcon
className="text-muted-foreground size-4 shrink-0"
aria-hidden="true"
/>
<h3 className="text-sm font-semibold">Домены без группы</h3>
{ungroupedCount > 0 ? (
<span className="text-muted-foreground text-xs tabular-nums">
({ungroupedCount})
</span>
) : null}
</div>
{ungroupedDomains.length === 0 ? (
<p className="text-muted-foreground text-sm">Все домены в группах</p>
) : (
@@ -328,7 +250,7 @@ function DashboardPage() {
<Link
to="/domains/$domainId"
params={{ domainId: String(domain.id) }}
className="text-primary text-xs hover:underline"
className="text-primary text-xs font-medium hover:underline"
>
Открыть
</Link>
@@ -1,5 +1,6 @@
import { createFileRoute } from '@tanstack/react-router'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { LayoutGridIcon } from 'lucide-react'
import { toast } from 'sonner'
import type { AppSettingsPatch } from '@cfdm/shared'
@@ -19,6 +20,8 @@ import {
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
import { Item, ItemMedia } from '@cfdm/ui/components/item'
import { ServerIcon } from 'lucide-react'
type SettingsResponse = AppSettingsView & {
id: string
@@ -55,20 +58,28 @@ function SettingsIntegrationsPage() {
onRetry={() => refetch()}
>
{data ? (
<div className="flex flex-col gap-5">
<div className="flex w-full flex-col gap-5">
<section className="flex flex-col gap-3">
<header className="flex flex-col gap-1">
<div className="flex flex-col gap-1">
<h2 className="text-sm font-semibold">Подключённые приложения</h2>
<p className="text-muted-foreground text-sm">
Переключение между сервисами и интеграция с VPS Tracker
Переключение между сервисами в sidebar
</p>
</header>
<Frame dense spacing="sm">
<FrameHeader>
<FrameTitle>App Switcher</FrameTitle>
<FrameDescription>
Быстрый переход между связанными приложениями
</FrameDescription>
</div>
<Frame>
<FrameHeader className="flex-row items-center gap-3">
<Item className="bg-muted/60 border-background flex size-10 shrink-0 items-center justify-center border-2 p-0 shadow-[0_1px_3px_0_rgba(0,0,0,0.14)] dark:border [&_svg]:size-5">
<ItemMedia variant="icon" className="size-auto">
<LayoutGridIcon aria-hidden="true" />
</ItemMedia>
</Item>
<div className="min-w-0">
<FrameTitle>App Switcher</FrameTitle>
<FrameDescription>
Быстрый переход между связанными приложениями
</FrameDescription>
</div>
</FrameHeader>
<FramePanel>
<AppSwitcherEditor
@@ -81,18 +92,24 @@ function SettingsIntegrationsPage() {
</section>
<section className="flex flex-col gap-3">
<header className="flex flex-col gap-1">
<div className="flex flex-col gap-1">
<h2 className="text-sm font-semibold">Внешние интеграции</h2>
<p className="text-muted-foreground text-sm">
Синхронизация данных с VPS Tracker
Синхронизация доменов и сервисов с VPS Tracker
</p>
</header>
<Frame dense spacing="sm">
<FrameHeader>
<FrameTitle>VPS Tracker</FrameTitle>
<FrameDescription>
URL, токен и расписание синхронизации
</FrameDescription>
</div>
<Frame>
<FrameHeader className="flex-row items-center gap-3">
<Item className="bg-muted/60 border-background flex size-10 shrink-0 items-center justify-center border-2 p-0 shadow-[0_1px_3px_0_rgba(0,0,0,0.14)] dark:border [&_svg]:size-5">
<ItemMedia variant="icon" className="size-auto">
<ServerIcon aria-hidden="true" />
</ItemMedia>
</Item>
<div className="min-w-0">
<FrameTitle>VPS Tracker</FrameTitle>
<FrameDescription>URL, токен и расписание синхронизации</FrameDescription>
</div>
</FrameHeader>
<FramePanel>
<VpsTrackerIntegrationCard
+2 -6
View File
@@ -1,4 +1,4 @@
import { createFileRoute, Outlet } from '@tanstack/react-router'
import { createFileRoute } from '@tanstack/react-router'
import { SettingsShell } from '@/components/reui-kit'
export const Route = createFileRoute('/_auth/settings')({
@@ -6,9 +6,5 @@ export const Route = createFileRoute('/_auth/settings')({
})
function SettingsLayout() {
return (
<SettingsShell>
<Outlet />
</SettingsShell>
)
return <SettingsShell />
}