fix(web): UX/UI аудит — пагинация, a11y, settings и TruncatedText
Docker / build (push) Failing after 20s
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:
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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 }))}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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-операций"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 с расчётной датой оплаты не найдены"
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user