fix(web): UX/UI аудит — пагинация, a11y, settings и TruncatedText
Docker / build (push) Failing after 20s

Исправить обрезку Select в пагинации DataGrid, health-mode zero-results, подтверждение импорта бэкапа и унифицировать tooltip на обрезанном тексте.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Denozordec
2026-06-30 12:53:16 +07:00
co-authored by Cursor
parent 447d499a8d
commit 5c1c94bc57
28 changed files with 472 additions and 219 deletions
@@ -72,6 +72,7 @@ export function AccountFiltersToolbar({
<SelectField
triggerClassName="w-full sm:w-48"
placeholder="Все хостеры"
aria-label="Фильтр по хостеру"
value={filters.providerIds[0] ?? null}
onValueChange={(v) => onChange({ ...filters, providerIds: v ? [v] : [] })}
options={providers.map((p) => ({ value: p.id, label: p.name }))}
@@ -79,6 +80,7 @@ export function AccountFiltersToolbar({
<SelectField
triggerClassName="w-full sm:w-40"
placeholder="Любой биллинг"
aria-label="Фильтр по режиму биллинга"
value={filters.billingMode || null}
onValueChange={(v) =>
onChange({
@@ -12,6 +12,7 @@ import {
AutocompleteItem,
AutocompleteList,
} from '@/components/reui/autocomplete'
import { TruncatedText } from '@/components/truncated-text'
export interface AutoCompleteOption {
value: string
@@ -114,7 +115,9 @@ export function AutoCompleteInput({
{item.leading ? (
<span className="relative z-1 size-4 shrink-0">{item.leading}</span>
) : null}
<span className="relative z-1 min-w-0 flex-1 truncate">{item.label}</span>
<span className="relative z-1 min-w-0 flex-1">
<TruncatedText>{item.label}</TruncatedText>
</span>
{isSelected ? (
<CheckIcon className="relative z-1 size-4 shrink-0 opacity-60" />
) : null}
+36 -38
View File
@@ -34,6 +34,9 @@ const PAGINATION_LABELS = {
info: '{from}{to} из {count}',
previousPageLabel: 'Предыдущая страница',
nextPageLabel: 'Следующая страница',
pageLabel: 'Страница {page}',
previousPagesLabel: 'Предыдущие страницы',
nextPagesLabel: 'Следующие страницы',
} as const
function resolveHeaderTitle(header: ReactNode, headerTitle?: string): string {
@@ -140,7 +143,7 @@ function DataGridSectionHeader({
function DataGridPaginationBar() {
return (
<div className="px-4 py-2.5">
<div className="border-t border-border px-4 py-2.5">
<DataGridPagination {...PAGINATION_LABELS} />
</div>
)
@@ -170,46 +173,41 @@ function DataGridCardBody<TData extends object>({
enableColumnVisibility: boolean
}) {
return (
<DataGridContainer border={false}>
<DataGrid
table={table}
recordCount={data.length}
onRowClick={onRowClick}
emptyMessage={emptyTitle}
tableLayout={{
dense,
stripped: true,
rowBorder: true,
headerSticky: true,
headerBackground: true,
headerBorder: true,
width: 'auto',
columnsVisibility: enableColumnVisibility,
columnsResizable: false,
columnsPinnable: false,
columnsMovable: false,
rowsDraggable: false,
rowsPinnable: false,
}}
tableClassNames={{
header: 'text-xs font-medium text-muted-foreground',
}}
>
<DataGrid
table={table}
recordCount={data.length}
onRowClick={onRowClick}
emptyMessage={emptyTitle}
tableLayout={{
dense,
stripped: true,
rowBorder: true,
headerSticky: true,
headerBackground: true,
headerBorder: true,
width: 'auto',
columnsVisibility: enableColumnVisibility,
columnsResizable: false,
columnsPinnable: false,
columnsMovable: false,
rowsDraggable: false,
rowsPinnable: false,
}}
tableClassNames={{
header: 'text-xs font-medium text-muted-foreground',
}}
>
<DataGridContainer border={false}>
{virtualization ? (
<>
<DataGridScrollArea orientation="vertical" style={{ height }}>
<DataGridTableVirtual height={height} footerContent={footerContent} />
</DataGridScrollArea>
{showPagination ? <DataGridPaginationBar /> : null}
</>
<DataGridScrollArea orientation="vertical" style={{ height }}>
<DataGridTableVirtual height={height} footerContent={footerContent} />
</DataGridScrollArea>
) : (
<>
<DataGridTable footerContent={footerContent} />
{showPagination ? <DataGridPaginationBar /> : null}
</>
<DataGridTable footerContent={footerContent} />
)}
</DataGrid>
</DataGridContainer>
</DataGridContainer>
{showPagination ? <DataGridPaginationBar /> : null}
</DataGrid>
)
}
+11 -2
View File
@@ -1,6 +1,7 @@
import type { ReactNode } from 'react'
import { cn } from '@cfdm/ui/lib/utils'
import { TruncatedText } from '@/components/truncated-text'
export function dataGridCellStack(
primary: ReactNode,
@@ -9,9 +10,17 @@ export function dataGridCellStack(
) {
return (
<div className={cn('flex min-w-0 flex-col leading-tight', className)}>
<span className="truncate font-medium">{primary}</span>
{typeof primary === 'string' || typeof primary === 'number' ? (
<TruncatedText className="font-medium">{primary}</TruncatedText>
) : (
<span className="truncate font-medium">{primary}</span>
)}
{secondary ? (
<span className="max-w-[14rem] truncate text-xs text-muted-foreground">{secondary}</span>
typeof secondary === 'string' || typeof secondary === 'number' ? (
<TruncatedText className="max-w-[14rem] text-xs text-muted-foreground">{secondary}</TruncatedText>
) : (
<span className="max-w-[14rem] truncate text-xs text-muted-foreground">{secondary}</span>
)
) : null}
</div>
)
+4 -4
View File
@@ -91,7 +91,7 @@ export function MonthlyExpenseChart({
{data.length === 0 ? (
<ChartEmpty message="Нет данных для графика" />
) : (
<ChartContainer config={EXPENSE_CONFIG} className="h-72 w-full">
<ChartContainer config={EXPENSE_CONFIG} className="h-72 w-full" aria-label="График расходов по VPS">
<BarChart data={data} margin={{ top: 8, right: 8, bottom: 8, left: 8 }}>
<CartesianGrid vertical={false} strokeDasharray="3 3" />
<XAxis dataKey="name" tickLine={false} axisLine={false} tickMargin={8} />
@@ -155,7 +155,7 @@ export function PaymentsPieChart({
{data.length === 0 ? (
<ChartEmpty message="Нет данных о платежах" />
) : (
<ChartContainer config={chartConfig} className="mx-auto h-72 w-full">
<ChartContainer config={chartConfig} className="mx-auto h-72 w-full" aria-label="График платежей по типам">
<PieChart>
<RechartsTooltip
content={
@@ -215,7 +215,7 @@ export function MonthlyTrendChart({
{data.length === 0 ? (
<ChartEmpty message="Нет данных за выбранный период" />
) : (
<ChartContainer config={trendConfig} className="h-72 w-full">
<ChartContainer config={trendConfig} className="h-72 w-full" aria-label="График тренда расходов">
<BarChart data={data} margin={{ top: 8, right: 8, bottom: 8, left: 8 }}>
<CartesianGrid vertical={false} strokeDasharray="3 3" />
<XAxis dataKey="month" tickLine={false} axisLine={false} tickMargin={8} />
@@ -281,7 +281,7 @@ export function ProjectExpenseChart({
{data.length === 0 ? (
<ChartEmpty message="Нет данных для графика" />
) : (
<ChartContainer config={chartConfig} className="h-72 w-full">
<ChartContainer config={chartConfig} className="h-72 w-full" aria-label="График расходов по проектам">
<BarChart data={data} margin={{ top: 8, right: 8, bottom: 8, left: 8 }}>
<CartesianGrid vertical={false} strokeDasharray="3 3" />
<XAxis dataKey="name" tickLine={false} axisLine={false} tickMargin={8} />
@@ -44,6 +44,7 @@ export function VpsBulkToolbar({
<div className="flex items-center gap-1">
<SelectField
placeholder="Проект…"
aria-label="Проект для массового назначения"
value={projectValue}
onValueChange={(v) => setProjectValue(v ?? '')}
options={projectOptions.map((p) => ({ value: p, label: p }))}
+25 -3
View File
@@ -1,4 +1,4 @@
import type { ReactNode } from 'react'
import { cloneElement, isValidElement, type ReactElement, type ReactNode } from 'react'
import { Field, FieldError, FieldLabel } from '@cfdm/ui/components/field'
interface FormFieldProps {
@@ -10,11 +10,33 @@ interface FormFieldProps {
children: ReactNode
}
function withFieldA11y(child: ReactNode, isInvalid: boolean, htmlFor?: string): ReactNode {
if (!isValidElement(child)) return child
const childProps = child.props as Record<string, unknown>
const props: Record<string, unknown> = {}
if (isInvalid) {
props['aria-invalid'] = true
props.invalid = true
}
if (htmlFor && childProps.id == null && childProps.triggerId == null) {
if ('triggerId' in childProps) {
props.triggerId = htmlFor
} else {
props.id = htmlFor
}
}
return Object.keys(props).length > 0 ? cloneElement(child as ReactElement, props) : child
}
export function FormField({ label, htmlFor, error, invalid, description, children }: FormFieldProps) {
const isInvalid = invalid || Boolean(error)
return (
<Field data-invalid={invalid || Boolean(error)}>
<Field data-invalid={isInvalid}>
<FieldLabel htmlFor={htmlFor}>{label}</FieldLabel>
{children}
{withFieldA11y(children, isInvalid, htmlFor)}
{description ? <p className="text-sm text-muted-foreground">{description}</p> : null}
{error ? <FieldError>{error}</FieldError> : null}
</Field>
+5 -4
View File
@@ -22,6 +22,7 @@ import {
} from '@cfdm/ui/components/command'
import { snapshotQueryOptions } from '@/queries/snapshot'
import { providerByIdMap } from '@/lib/billmanager'
import { TruncatedText } from '@/components/truncated-text'
interface GlobalSearchProps {
open: boolean
@@ -73,7 +74,7 @@ export function GlobalSearch({ open, onOpenChange }: GlobalSearchProps) {
onSelect={() => go('/vps/$vpsId', { vpsId: v.id })}
>
<ServerIcon />
<span className="truncate">{v.ip || v.dns || v.id}</span>
<TruncatedText>{v.ip || v.dns || v.id}</TruncatedText>
{v.project ? (
<span className="text-muted-foreground text-xs">{v.project}</span>
) : null}
@@ -89,7 +90,7 @@ export function GlobalSearch({ open, onOpenChange }: GlobalSearchProps) {
onSelect={() => go('/accounts')}
>
<WalletIcon />
<span className="truncate">{a.name}</span>
<TruncatedText>{a.name}</TruncatedText>
{providerById.get(a.providerId)?.name ? (
<span className="text-muted-foreground text-xs">
{providerById.get(a.providerId)?.name}
@@ -109,7 +110,7 @@ export function GlobalSearch({ open, onOpenChange }: GlobalSearchProps) {
onSelect={() => go('/vps', { project: row.name })}
>
<FolderKanbanIcon />
<span className="truncate">{row.name}</span>
<TruncatedText>{row.name}</TruncatedText>
</CommandItem>
)
})}
@@ -119,7 +120,7 @@ export function GlobalSearch({ open, onOpenChange }: GlobalSearchProps) {
{(snapshot?.providers ?? []).map((p) => (
<CommandItem key={p.id} value={p.name} onSelect={() => go('/providers')}>
<Building2Icon />
<span className="truncate">{p.name}</span>
<TruncatedText>{p.name}</TruncatedText>
</CommandItem>
))}
</CommandGroup>
+6 -2
View File
@@ -48,6 +48,7 @@ import { ModeToggle } from '@/components/mode-toggle'
import { GlobalSearch, GlobalSearchTrigger, useGlobalSearchHotkey } from '@/components/global-search'
import { dashboardStatsQueryOptions } from '@/queries/dashboard'
import { formatRelativeSyncTime } from '@/lib/sync-format'
import { TruncatedText } from '@/components/truncated-text'
interface NavItem {
to: string
@@ -200,9 +201,12 @@ export function AppShell({ children }: { children: ReactNode }) {
<SidebarMenuItem>
<SidebarMenuButton render={<Link to="/settings" />} tooltip="Настройки">
<Settings />
<span className="truncate text-xs text-muted-foreground">
<TruncatedText
className="text-xs text-muted-foreground"
tooltip={`Синк: ${formatRelativeSyncTime(stats?.lastGlobalSyncAt)}`}
>
Синк: {formatRelativeSyncTime(stats?.lastGlobalSyncAt)}
</span>
</TruncatedText>
{stats?.staleSyncAccountCount ? (
<Badge variant="outline" className="ml-auto text-xs">
<RefreshCwIcon className="size-3" />
@@ -0,0 +1,18 @@
import { cn } from '@cfdm/ui/lib/utils'
interface ProjectColorDotProps {
color?: string | null
className?: string
}
export function ProjectColorDot({ color, className }: ProjectColorDotProps) {
if (!color) return null
return (
<span
className={cn('inline-block size-2.5 shrink-0 rounded-full', className)}
style={{ backgroundColor: color }}
aria-hidden
/>
)
}
@@ -129,6 +129,7 @@ export function ReportsFiltersToolbar({
<SelectField
triggerClassName="w-full sm:w-44"
placeholder="Период"
aria-label="Период отчёта"
value={filters.period}
onValueChange={(v) =>
onChange({ ...filters, period: (v as ReportsPeriod) ?? '12m' })
@@ -29,6 +29,9 @@ interface DataGridPaginationProps {
rowsPerPageLabel?: string
previousPageLabel?: string
nextPageLabel?: string
pageLabel?: string
previousPagesLabel?: string
nextPagesLabel?: string
ellipsisText?: string
}
@@ -47,6 +50,9 @@ function DataGridPagination(props: DataGridPaginationProps): React.JSX.Element {
rowsPerPageLabel: "Rows per page",
previousPageLabel: "Go to previous page",
nextPageLabel: "Go to next page",
pageLabel: "Page {page}",
previousPagesLabel: "Previous pages",
nextPagesLabel: "Next pages",
ellipsisText: "...",
}
@@ -88,6 +94,8 @@ function DataGridPagination(props: DataGridPaginationProps): React.JSX.Element {
key={i}
size="icon-sm"
variant="ghost"
aria-label={mergedProps.pageLabel?.replace("{page}", String(i + 1))}
aria-current={pageIndex === i ? "page" : undefined}
className={cn(btnBaseClasses, "text-muted-foreground", {
"bg-accent text-accent-foreground": pageIndex === i,
})}
@@ -112,6 +120,7 @@ function DataGridPagination(props: DataGridPaginationProps): React.JSX.Element {
size="icon-sm"
className={btnBaseClasses}
variant="ghost"
aria-label={mergedProps.previousPagesLabel}
onClick={() => table.setPageIndex(currentGroupStart - 1)}
>
{mergedProps.ellipsisText}
@@ -129,6 +138,7 @@ function DataGridPagination(props: DataGridPaginationProps): React.JSX.Element {
className={btnBaseClasses}
variant="ghost"
size="icon-sm"
aria-label={mergedProps.nextPagesLabel}
onClick={() => table.setPageIndex(currentGroupEnd)}
>
{mergedProps.ellipsisText}
@@ -146,7 +156,7 @@ function DataGridPagination(props: DataGridPaginationProps): React.JSX.Element {
mergedProps?.className
)}
>
<div className="order-2 flex flex-wrap items-center space-x-2.5 pb-2.5 sm:order-1 sm:pb-0">
<div className="order-2 flex flex-wrap items-center gap-2.5 pb-2.5 sm:order-1 sm:pb-0">
{isLoading ? (
mergedProps?.sizesSkeleton
) : (
@@ -164,7 +174,7 @@ function DataGridPagination(props: DataGridPaginationProps): React.JSX.Element {
<SelectTrigger className="w-14" size="sm">
<SelectValue />
</SelectTrigger>
<SelectContent side="top" className="min-w-18">
<SelectContent className="min-w-18">
{mergedProps?.sizes?.map((size: number) => (
<SelectItem key={size} value={`${size}`}>
{size}
@@ -184,7 +194,7 @@ function DataGridPagination(props: DataGridPaginationProps): React.JSX.Element {
{paginationInfo}
</div>
{pageCount > 1 && (
<div className="order-1 flex items-center space-x-1 sm:order-2">
<div className="order-1 flex items-center gap-1 sm:order-2">
<Button
size="icon-sm"
variant="ghost"
+12 -3
View File
@@ -1,6 +1,7 @@
import type { ReactElement, ReactNode } from 'react'
import { Card, CardContent } from '@cfdm/ui/components/card'
import { cn } from '@cfdm/ui/lib/utils'
import { TruncatedText } from '@/components/truncated-text'
export interface SectionCardItem {
label: ReactNode
@@ -15,7 +16,7 @@ export interface SectionCardItem {
const VARIANT_CLASS: Record<NonNullable<SectionCardItem['variant']>, string> = {
default: '',
warning: 'border-amber-500/50',
warning: 'border-warning/50',
destructive: 'border-destructive/50',
}
@@ -42,13 +43,21 @@ export function SectionCards({ items, className }: { items: SectionCardItem[]; c
) : null}
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<div className="flex items-center justify-between gap-2">
<span className="truncate text-xs text-muted-foreground">{item.label}</span>
{typeof item.label === 'string' ? (
<TruncatedText className="text-xs text-muted-foreground">{item.label}</TruncatedText>
) : (
<span className="truncate text-xs text-muted-foreground">{item.label}</span>
)}
{item.badge ? <span className="shrink-0">{item.badge}</span> : null}
</div>
<div className="flex min-w-0 items-baseline gap-1.5">
<span className="text-lg font-semibold tabular-nums">{item.value}</span>
{item.hint ? (
<span className="truncate text-xs text-muted-foreground">· {item.hint}</span>
typeof item.hint === 'string' ? (
<TruncatedText className="text-xs text-muted-foreground">· {item.hint}</TruncatedText>
) : (
<span className="truncate text-xs text-muted-foreground">· {item.hint}</span>
)
) : null}
</div>
</div>
+11 -1
View File
@@ -23,6 +23,8 @@ interface SelectFieldProps extends Omit<SelectRootProps<string>, 'items' | 'valu
size?: 'sm' | 'default'
value?: string | null
onValueChange?: (value: string | null) => void
invalid?: boolean
'aria-label'?: string
}
export function SelectField({
@@ -33,6 +35,8 @@ export function SelectField({
size = 'default',
value,
onValueChange,
invalid,
'aria-label': ariaLabel,
...props
}: SelectFieldProps) {
const items = React.useMemo(
@@ -42,7 +46,13 @@ export function SelectField({
return (
<Select items={items} value={value} onValueChange={onValueChange} {...props}>
<SelectTrigger id={triggerId} size={size} className={cn('w-full', triggerClassName)}>
<SelectTrigger
id={triggerId}
size={size}
aria-label={ariaLabel}
aria-invalid={invalid || undefined}
className={cn('w-full', triggerClassName)}
>
<SelectValue placeholder={placeholder} />
</SelectTrigger>
<SelectContent>
@@ -0,0 +1,36 @@
import type { ReactNode } from 'react'
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@cfdm/ui/components/tooltip'
import { cn } from '@cfdm/ui/lib/utils'
interface TruncatedTextProps {
children: ReactNode
className?: string
as?: 'span' | 'p' | 'div'
/** Явный текст подсказки, если children — не строка. */
tooltip?: string
}
export function TruncatedText({ children, className, as: Tag = 'span', tooltip }: TruncatedTextProps) {
const tip =
tooltip ??
(typeof children === 'string' || typeof children === 'number' ? String(children) : null)
if (!tip) {
return <Tag className={cn('truncate', className)}>{children}</Tag>
}
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger render={<Tag className={cn('truncate', className)} />}>{children}</TooltipTrigger>
<TooltipContent>{tip}</TooltipContent>
</Tooltip>
</TooltipProvider>
)
}
@@ -13,6 +13,7 @@ import {
import { PlusIcon } from 'lucide-react'
import { ListFiltersBar, type FilterChip } from '@/components/list-filters-bar'
import { ConfirmDialog } from '@/components/confirm-dialog'
import type { DataGridColumnVisibilityOption } from '@/components/data-grid-card'
import type { VisibilityState } from '@tanstack/react-table'
@@ -652,14 +653,22 @@ export function VpsFiltersToolbar({
>
{p.name}
</button>
<button
type="button"
onClick={() => deletePreset(p.name)}
aria-label="Удалить пресет"
className="text-muted-foreground hover:text-foreground"
>
<Trash2Icon className="size-3.5" />
</button>
<ConfirmDialog
title="Удалить пресет?"
description={`Пресет «${p.name}» будет удалён без возможности восстановления.`}
confirmLabel="Удалить"
destructive
onConfirm={() => deletePreset(p.name)}
trigger={
<button
type="button"
aria-label={`Удалить пресет ${p.name}`}
className="text-muted-foreground hover:text-foreground"
>
<Trash2Icon className="size-3.5" />
</button>
}
/>
</div>
))}
</div>
+12 -3
View File
@@ -1,4 +1,4 @@
import { createFileRoute } from '@tanstack/react-router'
import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useMemo, useState } from 'react'
import {
@@ -91,6 +91,7 @@ function buildSavePayload(r: ProviderAccountFormValues) {
function AccountsPage() {
const { health } = Route.useSearch()
const navigate = useNavigate()
const queryClient = useQueryClient()
const { data: snapshot, isLoading, isError, error, refetch } = useQuery(snapshotQueryOptions())
const [open, setOpen] = useState(false)
@@ -451,8 +452,16 @@ function AccountsPage() {
}
emptyAction={
health || hasActiveAccountFilters(filters) ? (
<Button variant="outline" onClick={() => setFilters(buildDefaultAccountFilters())}>
Сбросить фильтры
<Button
variant="outline"
onClick={() => {
setFilters(buildDefaultAccountFilters())
if (health) {
void navigate({ to: '/accounts', search: {} })
}
}}
>
{health ? 'Выйти из режима и сбросить фильтры' : 'Сбросить фильтры'}
</Button>
) : undefined
}
+17 -1
View File
@@ -10,6 +10,21 @@ import { DataGridCard, columnDefFromDataGrid } from '@/components/data-grid-card
import type { DataGridColumn } from '@/components/data-grid-types'
import { Button } from '@cfdm/ui/components/button'
import { Badge } from '@cfdm/ui/components/badge'
import { TableSkeleton } from '@/components/skeletons'
const AUDIT_ENTITY_LABELS: Record<string, string> = {
vps: 'VPS',
payment: 'Платёж',
providerAccount: 'Аккаунт',
provider: 'Хостер',
settings: 'Настройки',
balanceLedger: 'Баланс',
serverProject: 'Проект',
}
function auditEntityLabel(entity: string): string {
return AUDIT_ENTITY_LABELS[entity] ?? entity
}
interface AuditRow {
id: string
@@ -51,7 +66,7 @@ function AuditPage() {
{
key: 'entity',
header: 'Сущность',
cell: (r) => <Badge variant="outline">{r.entity}</Badge>,
cell: (r) => <Badge variant="outline">{auditEntityLabel(r.entity)}</Badge>,
},
{
key: 'action',
@@ -91,6 +106,7 @@ function AuditPage() {
isError={isError}
error={error}
onRetry={() => refetch()}
skeleton={<TableSkeleton />}
empty={!data?.length}
emptyTitle="Записей нет"
emptyDescription="Изменения VPS появятся здесь после CRUD-операций"
+1 -1
View File
@@ -238,7 +238,7 @@ function BalancePage() {
rowId={(r) => r.id}
pinLastColumn
footerContent={
<div className="flex justify-end gap-6 px-3 py-2 text-sm tabular-nums">
<div className="flex flex-wrap justify-end gap-6 px-3 py-2 text-sm tabular-nums">
<span>
Приходы: <b className="text-foreground">{formatCurrency(totalCredit, baseCurrency)}</b>
</span>
+5 -1
View File
@@ -58,7 +58,7 @@ type InventoryIssue = { key: string; title: string; count: number; to: string; h
function DashboardPage() {
const navigate = useNavigate()
const { data: snapshot, isLoading, isError, error, refetch } = useQuery(snapshotQueryOptions())
const { data: stats } = useQuery(dashboardStatsQueryOptions())
const { data: stats, isLoading: statsLoading } = useQuery(dashboardStatsQueryOptions())
const settings = snapshot?.settings?.[0]
const { data: rawRates } = useQuery(ratesQueryOptions(settings?.ratesUrl))
const ratesData = normalizeRatesPayload(rawRates) ?? rawRates ?? null
@@ -204,6 +204,9 @@ function DashboardPage() {
return (
<div className="flex flex-col gap-4 md:gap-6">
{statsLoading ? (
<SectionCardsSkeleton count={6} />
) : (
<SectionCards
items={[
{
@@ -269,6 +272,7 @@ function DashboardPage() {
},
]}
/>
)}
{issues.length > 0 ? (
<Alert variant="destructive">
@@ -26,16 +26,8 @@ import { ProjectEditSheet, projectFormDefaults } from '@/components/domain/proje
import type { ProjectFormValues } from '@/lib/schemas'
import { Button } from '@cfdm/ui/components/button'
import { Card, CardContent, CardHeader, CardTitle } from '@cfdm/ui/components/card'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@cfdm/ui/components/alert-dialog'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { SectionCardsSkeleton, TableSkeleton } from '@/components/skeletons'
import {
formatCurrency,
normalizeRatesPayload,
@@ -72,7 +64,6 @@ function ProjectDetailPage() {
const { data: rawRates } = useQuery(ratesQueryOptions(settings?.ratesUrl))
const ratesData = normalizeRatesPayload(rawRates) ?? rawRates ?? null
const [editOpen, setEditOpen] = useState(false)
const [deleteOpen, setDeleteOpen] = useState(false)
const project = snapshot ? findProject(snapshot, projectId) : undefined
const projectVps = useMemo(
@@ -218,19 +209,31 @@ function ProjectDetailPage() {
<PencilIcon data-icon="inline-start" />
Изменить
</Button>
<Button
variant="outline"
onClick={() => {
if (projectVps.length > 0) {
{projectVps.length > 0 ? (
<Button
variant="outline"
onClick={() =>
toast.error(`Нельзя удалить: к проекту привязано ${projectVps.length} VPS`)
return
}
setDeleteOpen(true)
}}
>
<Trash2Icon data-icon="inline-start" />
Удалить
</Button>
>
<Trash2Icon data-icon="inline-start" />
Удалить
</Button>
) : (
<ConfirmDialog
title="Удалить проект?"
description={`«${project.name}» будет удалён без возможности восстановления.`}
confirmLabel="Удалить"
destructive
onConfirm={() => delMut.mutate()}
trigger={
<Button variant="outline" disabled={delMut.isPending}>
<Trash2Icon data-icon="inline-start" />
Удалить
</Button>
}
/>
)}
</div>
) : null
}
@@ -241,6 +244,12 @@ function ProjectDetailPage() {
isError={isError}
error={error}
onRetry={() => refetch()}
skeleton={
<div className="flex flex-col gap-4">
<SectionCardsSkeleton count={3} />
<TableSkeleton />
</div>
}
>
{() =>
!project ? (
@@ -313,26 +322,6 @@ function ProjectDetailPage() {
onSubmit={(values) => saveMut.mutate(values)}
submitting={saveMut.isPending}
/>
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Удалить проект?</AlertDialogTitle>
<AlertDialogDescription>
«{project.name}» будет удалён без возможности восстановления.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Отмена</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
onClick={() => delMut.mutate()}
disabled={delMut.isPending}
>
Удалить
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)
}
+2 -3
View File
@@ -27,6 +27,7 @@ import {
import { Button } from '@cfdm/ui/components/button'
import { Badge } from '@cfdm/ui/components/badge'
import { EmptyState } from '@/components/empty-state'
import { ProjectColorDot } from '@/components/project-color-dot'
import { ProjectEditSheet, projectFormDefaults } from '@/components/domain/project-edit-sheet'
import type { ProjectFormValues } from '@/lib/schemas'
import { formatCurrency, normalizeRatesPayload } from '@/lib/format'
@@ -133,9 +134,7 @@ function ProjectsPage() {
icon: FolderKanbanIcon,
cell: (row) => (
<div className="flex items-center gap-2">
{row.color ? (
<span className="size-2.5 shrink-0 rounded-full" style={{ backgroundColor: row.color }} />
) : null}
<ProjectColorDot color={row.color} />
<Button
variant="link"
className="h-auto p-0 font-medium"
+12 -1
View File
@@ -9,6 +9,7 @@ import { PageHeader } from '@/components/page-header'
import { QueryState } from '@/components/query-state'
import { SectionCards } from '@/components/section-cards'
import { SectionCardsSkeleton } from '@/components/skeletons'
import { Skeleton } from '@cfdm/ui/components/skeleton'
import { Card, CardContent, CardHeader, CardTitle } from '@cfdm/ui/components/card'
import { Badge } from '@cfdm/ui/components/badge'
import { Button } from '@cfdm/ui/components/button'
@@ -110,6 +111,7 @@ function RenewalsPage() {
actions={
<SelectField
value={horizon}
aria-label="Горизонт продлений"
onValueChange={(v) => setHorizon((v ?? '30') as Horizon)}
options={[
{ value: '7', label: '7 дней' },
@@ -127,7 +129,16 @@ function RenewalsPage() {
isError={isError}
error={error}
onRetry={() => refetch()}
skeleton={<SectionCardsSkeleton count={3} />}
skeleton={
<div className="flex flex-col gap-4">
<SectionCardsSkeleton count={3} />
<div className="flex flex-col gap-2">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-16 w-full rounded-lg" />
))}
</div>
</div>
}
empty={items.length === 0}
emptyTitle="Нет продлений в выбранном периоде"
emptyDescription="Активные VPS с расчётной датой оплаты не найдены"
+6 -1
View File
@@ -3,6 +3,7 @@ import { useQuery } from '@tanstack/react-query'
import { CpuIcon, MemoryStickIcon, HardDriveIcon } from 'lucide-react'
import { snapshotQueryOptions } from '@/queries/snapshot'
import { EmptyState } from '@/components/empty-state'
import { AnalyticsPage } from '@/components/analytics-page'
import { SectionCards } from '@/components/section-cards'
import { Button } from '@cfdm/ui/components/button'
@@ -102,7 +103,10 @@ function ResourcesPage() {
<CardDescription>Только активные VPS</CardDescription>
</CardHeader>
<CardContent>
<ChartContainer config={RESOURCE_CONFIG} className="h-80 w-full">
{chartData.length === 0 ? (
<EmptyState title="Нет данных для графика" />
) : (
<ChartContainer config={RESOURCE_CONFIG} className="h-80 w-full" aria-label="Ресурсы по хостерам">
<BarChart data={chartData} margin={{ top: 8, right: 8, bottom: 8, left: 8 }}>
<CartesianGrid vertical={false} strokeDasharray="3 3" />
<XAxis dataKey="name" tickLine={false} axisLine={false} tickMargin={8} />
@@ -113,6 +117,7 @@ function ResourcesPage() {
<Bar dataKey="disk" fill="var(--color-disk)" radius={4} />
</BarChart>
</ChartContainer>
)}
</CardContent>
</Card>
</>
+141 -83
View File
@@ -4,7 +4,7 @@ import { useForm, Controller } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { toast } from 'sonner'
import { DownloadIcon, UploadIcon } from 'lucide-react'
import { useMemo } from 'react'
import { useMemo, useCallback } from 'react'
import { snapshotQueryOptions } from '@/queries/snapshot'
import { api, ApiError } from '@/lib/api-client'
@@ -12,6 +12,17 @@ import { PageShell } from '@/components/page-shell'
import { PageHeader } from '@/components/page-header'
import { QueryState } from '@/components/query-state'
import { SectionCardsSkeleton } from '@/components/skeletons'
import { EmptyState } from '@/components/empty-state'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { StatusBadge } from '@/components/status-badge'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@cfdm/ui/components/table'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@cfdm/ui/components/card'
import { FieldGroup } from '@cfdm/ui/components/field'
import { Input } from '@cfdm/ui/components/input'
@@ -32,6 +43,20 @@ export const Route = createFileRoute('/_auth/settings')({
const CURRENCIES = ['RUB', 'USD', 'EUR', 'UAH', 'KZT']
const NOTIFICATION_STATUS_MAP: Record<string, string> = {
sent: 'ok',
failed: 'error',
}
const NOTIFICATION_STATUS_LABELS: Record<string, string> = {
sent: 'Отправлено',
failed: 'Ошибка',
}
function notificationStatusLabel(status: string): string {
return NOTIFICATION_STATUS_LABELS[status] ?? status
}
function settingsToFormValues(s: Settings): SettingsFormValues {
return {
id: s.id,
@@ -162,6 +187,50 @@ function SettingsPage() {
queryFn: () => api.fetchNotificationLog(30),
})
const importJsonMut = useMutation({
mutationFn: (text: string) => api.importBackupJson(JSON.parse(text)),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['snapshot'] })
toast.success('Импорт JSON выполнен')
},
onError: (e: unknown) => toast.error(e instanceof ApiError ? e.message : 'Ошибка импорта'),
})
const importDbMut = useMutation({
mutationFn: (buffer: ArrayBuffer) => api.importBackupDatabase(buffer),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['snapshot'] })
toast.success('Импорт SQLite выполнен')
},
onError: (e: unknown) => toast.error(e instanceof ApiError ? e.message : 'Ошибка импорта'),
})
const pickJsonFile = useCallback(() => {
const input = document.createElement('input')
input.type = 'file'
input.accept = 'application/json,.json'
input.onchange = async () => {
const file = input.files?.[0]
if (!file) return
const text = await file.text()
importJsonMut.mutate(text)
}
input.click()
}, [importJsonMut])
const pickDbFile = useCallback(() => {
const input = document.createElement('input')
input.type = 'file'
input.accept = '.db,application/octet-stream'
input.onchange = async () => {
const file = input.files?.[0]
if (!file) return
const buffer = await file.arrayBuffer()
importDbMut.mutate(buffer)
}
input.click()
}, [importDbMut])
const notificationRows = useMemo(
() => notificationLog as NotificationLogRow[],
[notificationLog],
@@ -209,54 +278,40 @@ function SettingsPage() {
<DownloadIcon data-icon="inline-start" />
SQLite
</Button>
<Button
variant="outline"
onClick={() => {
const input = document.createElement('input')
input.type = 'file'
input.accept = 'application/json,.json'
input.onchange = async () => {
const file = input.files?.[0]
if (!file) return
try {
const text = await file.text()
await api.importBackupJson(JSON.parse(text))
void queryClient.invalidateQueries({ queryKey: ['snapshot'] })
toast.success('Импорт JSON выполнен')
} catch (e) {
toast.error(e instanceof ApiError ? e.message : 'Ошибка импорта')
}
}
input.click()
}}
>
<UploadIcon data-icon="inline-start" />
Импорт JSON
</Button>
<Button
variant="outline"
onClick={() => {
const input = document.createElement('input')
input.type = 'file'
input.accept = '.db,application/octet-stream'
input.onchange = async () => {
const file = input.files?.[0]
if (!file) return
try {
const buffer = await file.arrayBuffer()
await api.importBackupDatabase(buffer)
void queryClient.invalidateQueries({ queryKey: ['snapshot'] })
toast.success('Импорт SQLite выполнен')
} catch (e) {
toast.error(e instanceof ApiError ? e.message : 'Ошибка импорта')
}
}
input.click()
}}
>
<UploadIcon data-icon="inline-start" />
Импорт SQLite
</Button>
<ConfirmDialog
title="Импортировать JSON?"
description="Текущие данные будут перезаписаны содержимым файла резервной копии."
confirmLabel="Выбрать файл"
destructive
onConfirm={pickJsonFile}
trigger={
<LoadingButton
type="button"
variant="outline"
loading={importJsonMut.isPending}
>
<UploadIcon data-icon="inline-start" />
Импорт JSON
</LoadingButton>
}
/>
<ConfirmDialog
title="Импортировать SQLite?"
description="Текущая база данных будет полностью заменена загруженным файлом .db."
confirmLabel="Выбрать файл"
destructive
onConfirm={pickDbFile}
trigger={
<LoadingButton
type="button"
variant="outline"
loading={importDbMut.isPending}
>
<UploadIcon data-icon="inline-start" />
Импорт SQLite
</LoadingButton>
}
/>
</div>
)
@@ -273,7 +328,7 @@ function SettingsPage() {
isError={isError}
error={error}
onRetry={() => refetch()}
skeleton={<SectionCardsSkeleton count={1} />}
skeleton={<SectionCardsSkeleton count={3} />}
>
{() => (
<form
@@ -478,42 +533,45 @@ function SettingsPage() {
</CardHeader>
<CardContent>
{notificationRows.length === 0 ? (
<p className="text-sm text-muted-foreground">Записей пока нет</p>
<EmptyState title="Записей пока нет" />
) : (
<div className="overflow-x-auto rounded-md border">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50 text-left">
<th className="px-3 py-2 font-medium">Время</th>
<th className="px-3 py-2 font-medium">Событие</th>
<th className="px-3 py-2 font-medium">Канал</th>
<th className="px-3 py-2 font-medium">Статус</th>
<th className="px-3 py-2 font-medium">Ошибка</th>
</tr>
</thead>
<tbody>
{notificationRows.map((row) => {
const errorText =
row.status === 'failed' && row.payload?.error != null
? String(row.payload.error)
: ''
return (
<tr key={row.id} className="border-b last:border-0">
<td className="px-3 py-2 whitespace-nowrap text-muted-foreground">
<Table>
<TableHeader>
<TableRow>
<TableHead>Время</TableHead>
<TableHead>Событие</TableHead>
<TableHead>Канал</TableHead>
<TableHead>Статус</TableHead>
<TableHead>Ошибка</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{notificationRows.map((row) => {
const errorText =
row.status === 'failed' && row.payload?.error != null
? String(row.payload.error)
: ''
return (
<TableRow key={row.id}>
<TableCell className="whitespace-nowrap text-muted-foreground">
{new Date(row.createdAt).toLocaleString('ru-RU')}
</td>
<td className="px-3 py-2">{row.event}</td>
<td className="px-3 py-2">{row.channel}</td>
<td className="px-3 py-2">{row.status}</td>
<td className="max-w-xs px-3 py-2 text-xs text-destructive break-words">
</TableCell>
<TableCell>{row.event}</TableCell>
<TableCell>{row.channel}</TableCell>
<TableCell>
<StatusBadge
status={NOTIFICATION_STATUS_MAP[row.status] ?? row.status}
label={notificationStatusLabel(row.status)}
/>
</TableCell>
<TableCell className="max-w-xs break-words text-xs text-destructive">
{errorText || '—'}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
)}
</CardContent>
</Card>
+12 -4
View File
@@ -340,9 +340,13 @@ function TariffsPage() {
<AlertDescription className="flex flex-col gap-1">
{tariffDiffs.slice(0, 5).map((d) => (
<span key={d.vpsId}>
<Link to="/vps/$vpsId" params={{ vpsId: d.vpsId }} className="underline">
<Button
variant="link"
className="h-auto p-0"
render={<Link to="/vps/$vpsId" params={{ vpsId: d.vpsId }} />}
>
{d.vpsLabel}
</Link>
</Button>
{' '}({d.tariffName}): {d.issues.join('; ')}
</span>
))}
@@ -372,9 +376,13 @@ function TariffsPage() {
<AlertDescription className="flex flex-col gap-1">
{tariffDiffs.slice(0, 5).map((d) => (
<span key={d.vpsId}>
<Link to="/vps/$vpsId" params={{ vpsId: d.vpsId }} className="underline">
<Button
variant="link"
className="h-auto p-0"
render={<Link to="/vps/$vpsId" params={{ vpsId: d.vpsId }} />}
>
{d.vpsLabel}
</Link>
</Button>
{' '}({d.tariffName}): {d.issues.join('; ')}
</span>
))}
+17 -6
View File
@@ -14,6 +14,8 @@ import { snapshotQueryOptions, ratesQueryOptions } from '@/queries/snapshot'
import { PageShell } from '@/components/page-shell'
import { PageHeader } from '@/components/page-header'
import { QueryState } from '@/components/query-state'
import { StatusBadge } from '@/components/status-badge'
import { Skeleton } from '@cfdm/ui/components/skeleton'
import { Button } from '@cfdm/ui/components/button'
import { Badge } from '@cfdm/ui/components/badge'
import { Card, CardContent, CardHeader, CardTitle } from '@cfdm/ui/components/card'
@@ -116,9 +118,11 @@ function VpsDetailPage() {
title={vps ? (vps.ip || vps.dns || 'VPS') : 'VPS'}
description={account ? accountSelectLabel(account, providerById) : undefined}
actions={
<Button variant="outline" render={<Link to="/vps" search={{ edit: vpsId }} />}>
Редактировать
</Button>
vps ? (
<Button variant="outline" render={<Link to="/vps" search={{ edit: vpsId }} />}>
Редактировать
</Button>
) : undefined
}
/>
@@ -133,6 +137,15 @@ function VpsDetailPage() {
isError={isError}
error={error}
onRetry={() => refetch()}
skeleton={
<div className="flex flex-col gap-4">
<Skeleton className="h-9 w-48" />
<div className="grid gap-4 md:grid-cols-2">
<Skeleton className="h-40 w-full" />
<Skeleton className="h-40 w-full" />
</div>
</div>
}
empty={!isLoading && !vps}
emptyTitle="VPS не найден"
emptyDescription="Запись могла быть удалена"
@@ -151,9 +164,7 @@ function VpsDetailPage() {
<TabsContent value="overview" className="flex flex-col gap-4">
<div className="flex flex-wrap gap-2">
<Badge variant={row.status === 'active' ? 'default' : 'secondary'}>
{vpsStatusLabel(row.status)}
</Badge>
<StatusBadge status={row.status} label={vpsStatusLabel(row.status)} />
{row.project ? <Badge variant="outline">{row.project}</Badge> : null}
{row.environment ? <Badge variant="outline">{row.environment}</Badge> : null}
</div>
+14 -4
View File
@@ -29,6 +29,7 @@ import {
} from '@/components/vps-filters'
import { VpsFiltersToolbar } from '@/components/vps-filters-toolbar'
import { HealthModeBanner } from '@/components/health-mode-banner'
import { ProjectColorDot } from '@/components/project-color-dot'
import { VpsBulkToolbar } from '@/components/domain/vps-bulk-toolbar'
import type { Vps } from '@/types/entities'
@@ -189,7 +190,7 @@ function VpsPage() {
value: name,
label: name,
leading: color ? (
<span className="size-2.5 shrink-0 rounded-full ring-1 ring-foreground/10" style={{ backgroundColor: color }} />
<ProjectColorDot color={color} className="ring-1 ring-foreground/10" />
) : undefined,
}
})
@@ -531,11 +532,20 @@ function VpsPage() {
>
{(snap) => {
const filtersActive = hasActiveVpsFilters(filters)
const zeroResults = snap.vps.length > 0 && filteredVps.length === 0 && filtersActive
const zeroResults =
snap.vps.length > 0 && filteredVps.length === 0 && (filtersActive || Boolean(health))
const resetFilters = () => {
setFilters(buildDefaultVpsFilters())
if (health) {
void navigate({ to: '/vps', search: {} })
}
}
if (zeroResults) {
return (
<div className="flex flex-col gap-4">
{health ? <HealthModeBanner health={health} exitTo="/vps" /> : null}
<VpsFiltersToolbar
filters={filters}
onChange={setFilters}
@@ -555,8 +565,8 @@ function VpsPage() {
title="Ничего не найдено"
description="По текущим фильтрам VPS не найдены"
action={
<Button variant="outline" onClick={() => setFilters(buildDefaultVpsFilters())}>
Сбросить фильтры
<Button variant="outline" onClick={resetFilters}>
{health ? 'Выйти из режима и сбросить фильтры' : 'Сбросить фильтры'}
</Button>
}
/>