feat(web): добавить поддержку быстрого доступа на дашборде и обновить настройки интерфейса
Docker / build (push) Failing after 19s
Docker / build (push) Failing after 19s
- Внедрён компонент QuickActionGrid для отображения часто используемых разделов на дашборде, управляемый настройкой showQuickActions. - Добавлен новый параметр showQuickActions в схему настроек для управления видимостью быстрого доступа. - Обновлён интерфейс настроек для включения переключателя отображения быстрого доступа. - Добавлен компонент SystemMonitorPopover в заголовок приложения для мониторинга системы. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -45,6 +45,7 @@ import { useQuery } from '@tanstack/react-query'
|
|||||||
import { useState, type ReactNode } from 'react'
|
import { useState, type ReactNode } from 'react'
|
||||||
|
|
||||||
import { ModeToggle } from '@/components/mode-toggle'
|
import { ModeToggle } from '@/components/mode-toggle'
|
||||||
|
import { SystemMonitorPopover } from '@/components/layout/system-monitor-popover'
|
||||||
import { AppSwitcher } from '@/components/app-switcher'
|
import { AppSwitcher } from '@/components/app-switcher'
|
||||||
import { GlobalSearch, GlobalSearchTrigger, useGlobalSearchHotkey } from '@/components/global-search'
|
import { GlobalSearch, GlobalSearchTrigger, useGlobalSearchHotkey } from '@/components/global-search'
|
||||||
import { dashboardStatsQueryOptions } from '@/queries/dashboard'
|
import { dashboardStatsQueryOptions } from '@/queries/dashboard'
|
||||||
@@ -230,6 +231,7 @@ export function AppShell({ children }: { children: ReactNode }) {
|
|||||||
{stats.issuesCount} проблем
|
{stats.issuesCount} проблем
|
||||||
</Badge>
|
</Badge>
|
||||||
) : null}
|
) : null}
|
||||||
|
<SystemMonitorPopover />
|
||||||
<ModeToggle />
|
<ModeToggle />
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -0,0 +1,247 @@
|
|||||||
|
import { useMemo, type CSSProperties, type ReactNode } from 'react'
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import {
|
||||||
|
Activity,
|
||||||
|
ListChecks,
|
||||||
|
RefreshCw,
|
||||||
|
Server,
|
||||||
|
Wallet,
|
||||||
|
} from 'lucide-react'
|
||||||
|
|
||||||
|
import { Badge } from '@/components/reui/badge'
|
||||||
|
import { cn } from '@cfdm/ui/lib/utils'
|
||||||
|
import { Item, ItemMedia } from '@cfdm/ui/components/item'
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from '@cfdm/ui/components/popover'
|
||||||
|
import { Progress } from '@cfdm/ui/components/progress'
|
||||||
|
|
||||||
|
import { api } from '@/lib/api-client'
|
||||||
|
import { dashboardStatsQueryOptions } from '@/queries/dashboard'
|
||||||
|
import { snapshotQueryOptions } from '@/queries/snapshot'
|
||||||
|
|
||||||
|
type MonitorMetric = {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
value: string
|
||||||
|
unit: string
|
||||||
|
percent: number
|
||||||
|
icon: ReactNode
|
||||||
|
tone: 'success' | 'warning' | 'destructive' | 'info'
|
||||||
|
alert: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function toneColor(tone: MonitorMetric['tone']) {
|
||||||
|
switch (tone) {
|
||||||
|
case 'success':
|
||||||
|
return 'var(--color-success)'
|
||||||
|
case 'warning':
|
||||||
|
return 'var(--color-warning)'
|
||||||
|
case 'destructive':
|
||||||
|
return 'var(--color-destructive)'
|
||||||
|
default:
|
||||||
|
return 'var(--color-info)'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function MetricCell({ metric }: { metric: MonitorMetric }) {
|
||||||
|
const color = toneColor(metric.tone)
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2 p-3">
|
||||||
|
<div className="flex items-center justify-between gap-1">
|
||||||
|
<div className="flex min-w-0 items-center gap-1.5">
|
||||||
|
<Item
|
||||||
|
className="flex size-5 shrink-0 items-center justify-center p-0"
|
||||||
|
style={{ backgroundColor: `${color}18` }}
|
||||||
|
>
|
||||||
|
<ItemMedia variant="icon" className="size-auto" style={{ color }}>
|
||||||
|
{metric.icon}
|
||||||
|
</ItemMedia>
|
||||||
|
</Item>
|
||||||
|
<span className="text-muted-foreground truncate text-[11px]">{metric.label}</span>
|
||||||
|
</div>
|
||||||
|
<span className="shrink-0 text-xs font-semibold tabular-nums" style={{ color }}>
|
||||||
|
{metric.value}
|
||||||
|
<span className="text-muted-foreground ml-0.5 text-[10px] font-normal">{metric.unit}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Progress
|
||||||
|
value={metric.percent}
|
||||||
|
className="**:data-[slot=progress-indicator]:bg-(--bar-color) **:data-[slot=progress-track]:h-1"
|
||||||
|
style={{ '--bar-color': color } as CSSProperties}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isStaleSync(lastAt: string | null | undefined): boolean {
|
||||||
|
if (!lastAt) return true
|
||||||
|
const ts = new Date(lastAt).getTime()
|
||||||
|
if (Number.isNaN(ts)) return true
|
||||||
|
return Date.now() - ts > 24 * 60 * 60 * 1000
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Live system monitor popover (app-shell pattern, VPS Tracker API data). */
|
||||||
|
export function SystemMonitorPopover() {
|
||||||
|
const statsQ = useQuery({ ...dashboardStatsQueryOptions(), refetchInterval: 30_000 })
|
||||||
|
const snapQ = useQuery({ ...snapshotQueryOptions(), refetchInterval: 30_000 })
|
||||||
|
const syncQ = useQuery({
|
||||||
|
queryKey: ['sync', 'status'],
|
||||||
|
queryFn: () => api.fetchSyncStatus() as Promise<Array<{ status?: string; ok?: boolean }>>,
|
||||||
|
refetchInterval: 30_000,
|
||||||
|
})
|
||||||
|
const notifyQ = useQuery({
|
||||||
|
queryKey: ['notifications', 'log', 'monitor'],
|
||||||
|
queryFn: () => api.fetchNotificationLog(20),
|
||||||
|
refetchInterval: 30_000,
|
||||||
|
})
|
||||||
|
|
||||||
|
const stats = statsQ.data
|
||||||
|
const vps = snapQ.data?.vps ?? []
|
||||||
|
const downCount = vps.filter((v) => {
|
||||||
|
const status = (v as { lastHealthStatus?: string }).lastHealthStatus
|
||||||
|
return status === 'down'
|
||||||
|
}).length
|
||||||
|
const issuesCount = stats?.issuesCount ?? 0
|
||||||
|
const runwayDays = stats?.minRunwayDays
|
||||||
|
const runwayLow = runwayDays != null && runwayDays < 14
|
||||||
|
const lowBalance = (stats?.lowBalanceAccountCount ?? 0) > 0
|
||||||
|
const staleSync =
|
||||||
|
(stats?.staleSyncAccountCount ?? 0) > 0 || isStaleSync(stats?.lastGlobalSyncAt)
|
||||||
|
const recentSyncFailed = (syncQ.data ?? []).some(
|
||||||
|
(row) =>
|
||||||
|
String(row.status ?? '').toLowerCase() === 'failed' ||
|
||||||
|
String(row.status ?? '').toLowerCase() === 'error' ||
|
||||||
|
row.ok === false,
|
||||||
|
)
|
||||||
|
const syncAlert = staleSync || recentSyncFailed
|
||||||
|
const failedNotifications = (notifyQ.data ?? []).filter(
|
||||||
|
(n) => String(n.status ?? '').toLowerCase() === 'failed',
|
||||||
|
).length
|
||||||
|
const apiOk = Boolean(statsQ.data) || Boolean(snapQ.data)
|
||||||
|
|
||||||
|
const metrics = useMemo<MonitorMetric[]>(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
id: 'sync',
|
||||||
|
label: 'Синк',
|
||||||
|
value: syncAlert ? '!' : 'OK',
|
||||||
|
unit: '',
|
||||||
|
percent: syncAlert ? 35 : 100,
|
||||||
|
icon: <RefreshCw aria-hidden />,
|
||||||
|
tone: syncAlert ? (recentSyncFailed ? 'destructive' : 'warning') : 'success',
|
||||||
|
alert: syncAlert,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'inventory',
|
||||||
|
label: 'Инвентарь',
|
||||||
|
value: String(issuesCount),
|
||||||
|
unit: 'шт.',
|
||||||
|
percent: Math.min(100, issuesCount * 15),
|
||||||
|
icon: <ListChecks aria-hidden />,
|
||||||
|
tone: issuesCount > 0 ? 'warning' : 'success',
|
||||||
|
alert: issuesCount > 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'runway',
|
||||||
|
label: 'Runway',
|
||||||
|
value: runwayDays != null ? String(runwayDays) : '—',
|
||||||
|
unit: runwayDays != null ? 'дн' : '',
|
||||||
|
percent:
|
||||||
|
runwayDays == null
|
||||||
|
? 0
|
||||||
|
: Math.min(100, Math.round((runwayDays / 30) * 100)),
|
||||||
|
icon: <Wallet aria-hidden />,
|
||||||
|
tone: runwayLow || lowBalance ? 'warning' : 'success',
|
||||||
|
alert: runwayLow || lowBalance,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'vps-down',
|
||||||
|
label: 'VPS down',
|
||||||
|
value: String(downCount),
|
||||||
|
unit: 'шт.',
|
||||||
|
percent: Math.min(100, downCount * 25),
|
||||||
|
icon: <Server aria-hidden />,
|
||||||
|
tone: downCount > 0 ? 'destructive' : 'success',
|
||||||
|
alert: downCount > 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[downCount, issuesCount, lowBalance, recentSyncFailed, runwayDays, runwayLow, syncAlert],
|
||||||
|
)
|
||||||
|
|
||||||
|
const spiking = metrics.some((m) => m.alert) || !apiOk || failedNotifications > 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger
|
||||||
|
render={
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Монитор системы"
|
||||||
|
className={cn(
|
||||||
|
'relative inline-flex h-8 items-center gap-1.5 rounded-md border px-2 transition-colors outline-none',
|
||||||
|
'border-border hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span className="relative flex size-3.5 items-center justify-center">
|
||||||
|
<Activity
|
||||||
|
aria-hidden
|
||||||
|
className={cn(
|
||||||
|
'size-3.5 transition-colors',
|
||||||
|
spiking ? 'text-destructive' : 'text-muted-foreground',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{spiking ? (
|
||||||
|
<span className="bg-destructive/25 absolute inset-0 animate-ping rounded-full" aria-hidden />
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
<span className="text-foreground hidden text-xs font-medium sm:inline">Система</span>
|
||||||
|
<Badge
|
||||||
|
variant={spiking ? 'destructive-light' : 'success-light'}
|
||||||
|
size="xs"
|
||||||
|
className="h-4 px-1.5 text-[10px]"
|
||||||
|
>
|
||||||
|
{spiking ? 'Внимание' : 'Норма'}
|
||||||
|
</Badge>
|
||||||
|
</PopoverTrigger>
|
||||||
|
|
||||||
|
<PopoverContent align="end" sideOffset={8} className="w-80 gap-0! space-y-0! p-0!">
|
||||||
|
<div className="border-border flex items-center justify-between border-b px-3 py-2.5">
|
||||||
|
<span className="text-foreground text-xs font-medium">Монитор VPS Tracker</span>
|
||||||
|
<span className="text-muted-foreground text-[11px] tabular-nums">
|
||||||
|
{new Date().toLocaleTimeString('ru-RU')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2">
|
||||||
|
{metrics.map((metric, i) => (
|
||||||
|
<div
|
||||||
|
key={metric.id}
|
||||||
|
className={cn(
|
||||||
|
i % 2 === 1 && 'border-border border-l',
|
||||||
|
i >= 2 && 'border-border border-t',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<MetricCell metric={metric} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="border-border text-muted-foreground border-t px-3 py-2 text-[11px]">
|
||||||
|
API:{' '}
|
||||||
|
<span className="text-foreground font-medium">{apiOk ? 'OK' : '—'}</span>
|
||||||
|
{' · '}
|
||||||
|
Уведомлений с ошибкой:{' '}
|
||||||
|
<span className="text-foreground font-medium tabular-nums">{failedNotifications}</span>
|
||||||
|
{stats?.lastGlobalSyncAt ? (
|
||||||
|
<>
|
||||||
|
{' · '}
|
||||||
|
Синк:{' '}
|
||||||
|
<span className="text-foreground font-medium tabular-nums">
|
||||||
|
{new Date(stats.lastGlobalSyncAt).toLocaleString('ru-RU')}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -6,6 +6,10 @@ export {
|
|||||||
type KpiStatVariant,
|
type KpiStatVariant,
|
||||||
type OpsKpiCard,
|
type OpsKpiCard,
|
||||||
} from './kpi-stat-grid'
|
} from './kpi-stat-grid'
|
||||||
|
export {
|
||||||
|
QuickActionGrid,
|
||||||
|
type QuickActionItem,
|
||||||
|
} from './quick-action-grid'
|
||||||
export { OpsDashboard } from './ops-dashboard'
|
export { OpsDashboard } from './ops-dashboard'
|
||||||
export { DetailPanel, type DetailMetricCard } from './detail-panel'
|
export { DetailPanel, type DetailMetricCard } from './detail-panel'
|
||||||
export { SettingsShell, type SettingsTabConfig } from './settings-shell'
|
export { SettingsShell, type SettingsTabConfig } from './settings-shell'
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import type { ReactNode } from 'react'
|
||||||
|
import { Link } from '@tanstack/react-router'
|
||||||
|
import {
|
||||||
|
Frame,
|
||||||
|
FrameDescription,
|
||||||
|
FrameHeader,
|
||||||
|
FramePanel,
|
||||||
|
FrameTitle,
|
||||||
|
} from '@/components/reui/frame'
|
||||||
|
import { Badge } from '@/components/reui/badge'
|
||||||
|
import { Item, ItemMedia } from '@cfdm/ui/components/item'
|
||||||
|
import { cn } from '@cfdm/ui/lib/utils'
|
||||||
|
|
||||||
|
export interface QuickActionItem {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
to: string
|
||||||
|
search?: Record<string, unknown>
|
||||||
|
icon?: ReactNode
|
||||||
|
iconClassName?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface QuickActionGridProps {
|
||||||
|
actions: QuickActionItem[]
|
||||||
|
title?: string
|
||||||
|
description?: string
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_ICON_CLASS = 'text-muted-foreground [&_svg]:text-current'
|
||||||
|
|
||||||
|
function kpiCols(count: number): string {
|
||||||
|
if (count <= 1) return 'grid-cols-1'
|
||||||
|
if (count === 2) return 'grid-cols-1 @xl:grid-cols-2'
|
||||||
|
if (count === 3) return 'grid-cols-1 @3xl:grid-cols-3'
|
||||||
|
if (count === 4) return 'grid-cols-1 @3xl:grid-cols-2 @6xl:grid-cols-4'
|
||||||
|
if (count === 5) return 'grid-cols-2 @3xl:grid-cols-3 xl:grid-cols-5'
|
||||||
|
if (count === 6) return 'grid-cols-2 sm:grid-cols-3 xl:grid-cols-6'
|
||||||
|
return 'grid-cols-2 sm:grid-cols-3 lg:grid-cols-4'
|
||||||
|
}
|
||||||
|
|
||||||
|
function QuickActionBody({ action }: { action: QuickActionItem }) {
|
||||||
|
return (
|
||||||
|
<div className="relative z-10 flex h-full items-start gap-3">
|
||||||
|
{action.icon ? (
|
||||||
|
<Item
|
||||||
|
className={cn(
|
||||||
|
'border-background bg-muted flex size-10.5 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-4',
|
||||||
|
action.iconClassName ?? DEFAULT_ICON_CLASS,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ItemMedia variant="icon" className="size-auto">
|
||||||
|
{action.icon}
|
||||||
|
</ItemMedia>
|
||||||
|
</Item>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<span className="text-foreground text-sm font-medium">{action.title}</span>
|
||||||
|
<Badge variant="outline" size="sm" className="shrink-0">
|
||||||
|
Перейти
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="text-muted-foreground line-clamp-2 text-xs leading-relaxed">
|
||||||
|
{action.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* KPI-like quick actions strip (horizontal Frame tiles).
|
||||||
|
* Preview: https://reui.io/preview/base/stats-12
|
||||||
|
*/
|
||||||
|
export function QuickActionGrid({
|
||||||
|
actions,
|
||||||
|
title = 'Быстрые действия',
|
||||||
|
description,
|
||||||
|
className,
|
||||||
|
}: QuickActionGridProps) {
|
||||||
|
if (actions.length === 0) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Frame dense spacing="sm" className={cn('@container w-full', className)}>
|
||||||
|
{(title || description) && (
|
||||||
|
<FrameHeader>
|
||||||
|
{title ? <FrameTitle>{title}</FrameTitle> : null}
|
||||||
|
{description ? <FrameDescription>{description}</FrameDescription> : null}
|
||||||
|
</FrameHeader>
|
||||||
|
)}
|
||||||
|
<div className={cn('grid gap-2', kpiCols(actions.length))}>
|
||||||
|
{actions.map((action) => (
|
||||||
|
<FramePanel
|
||||||
|
key={action.id}
|
||||||
|
className="relative isolate flex h-full flex-col hover:bg-muted/40 focus-within:ring-ring cursor-pointer transition-colors focus-within:ring-2"
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
to={action.to}
|
||||||
|
search={action.search}
|
||||||
|
className="focus-visible:outline-none"
|
||||||
|
aria-label={`${action.title}: ${action.description}`}
|
||||||
|
>
|
||||||
|
<QuickActionBody action={action} />
|
||||||
|
</Link>
|
||||||
|
</FramePanel>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Frame>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -112,6 +112,7 @@ export const settingsSchema = z.object({
|
|||||||
(fields) => new Set(fields.map((f) => f.key)).size === fields.length,
|
(fields) => new Set(fields.map((f) => f.key)).size === fields.length,
|
||||||
'Ключи кастомных полей должны быть уникальными',
|
'Ключи кастомных полей должны быть уникальными',
|
||||||
),
|
),
|
||||||
|
showQuickActions: z.boolean().optional().default(true),
|
||||||
})
|
})
|
||||||
|
|
||||||
export {
|
export {
|
||||||
|
|||||||
@@ -13,11 +13,15 @@ import {
|
|||||||
CoinsIcon,
|
CoinsIcon,
|
||||||
ClockIcon,
|
ClockIcon,
|
||||||
DownloadIcon,
|
DownloadIcon,
|
||||||
|
CreditCardIcon,
|
||||||
|
ChartColumnBigIcon,
|
||||||
|
ChartBarIcon,
|
||||||
|
PlugIcon,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
|
||||||
import { snapshotQueryOptions, ratesQueryOptions } from '@/queries/snapshot'
|
import { snapshotQueryOptions, ratesQueryOptions } from '@/queries/snapshot'
|
||||||
import { dashboardStatsQueryOptions } from '@/queries/dashboard'
|
import { dashboardStatsQueryOptions } from '@/queries/dashboard'
|
||||||
import { OpsDashboard, type KpiStatCard } from '@/components/reui-kit'
|
import { OpsDashboard, QuickActionGrid, type KpiStatCard, type QuickActionItem } from '@/components/reui-kit'
|
||||||
import { QueryState } from '@/components/query-state'
|
import { QueryState } from '@/components/query-state'
|
||||||
import { DataGridCard, columnDefFromDataGrid } from '@/components/data-grid-card'
|
import { DataGridCard, columnDefFromDataGrid } from '@/components/data-grid-card'
|
||||||
import type { DataGridColumn } from '@/components/data-grid-types'
|
import type { DataGridColumn } from '@/components/data-grid-types'
|
||||||
@@ -45,6 +49,57 @@ import type { Vps } from '@/types/entities'
|
|||||||
const DASHBOARD_TAB_TRIGGER_CLASS =
|
const DASHBOARD_TAB_TRIGGER_CLASS =
|
||||||
'flex-none rounded-none border-0 border-b-2 border-transparent px-3 pb-2.5 pt-2 shadow-none after:hidden data-active:border-foreground data-active:bg-transparent data-active:shadow-none dark:data-active:border-foreground dark:data-active:bg-transparent'
|
'flex-none rounded-none border-0 border-b-2 border-transparent px-3 pb-2.5 pt-2 shadow-none after:hidden data-active:border-foreground data-active:bg-transparent data-active:shadow-none dark:data-active:border-foreground dark:data-active:bg-transparent'
|
||||||
|
|
||||||
|
const DASHBOARD_QUICK_ACTIONS: QuickActionItem[] = [
|
||||||
|
{
|
||||||
|
id: 'vps',
|
||||||
|
title: 'VPS',
|
||||||
|
description: 'Список серверов, оплата и здоровье.',
|
||||||
|
to: '/vps',
|
||||||
|
icon: <ServerIcon aria-hidden />,
|
||||||
|
iconClassName: 'text-primary',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'accounts',
|
||||||
|
title: 'Аккаунты',
|
||||||
|
description: 'Аккаунты хостеров и баланс API.',
|
||||||
|
to: '/accounts',
|
||||||
|
icon: <WalletIcon aria-hidden />,
|
||||||
|
iconClassName: 'text-success',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'payments',
|
||||||
|
title: 'Платежи',
|
||||||
|
description: 'Пополнения и оплаты VPS.',
|
||||||
|
to: '/payments',
|
||||||
|
icon: <CreditCardIcon aria-hidden />,
|
||||||
|
iconClassName: 'text-info',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'reports',
|
||||||
|
title: 'Отчёты',
|
||||||
|
description: 'Сводки по расходам и балансам.',
|
||||||
|
to: '/reports',
|
||||||
|
icon: <ChartColumnBigIcon aria-hidden />,
|
||||||
|
iconClassName: 'text-warning',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'resources',
|
||||||
|
title: 'Ресурсы',
|
||||||
|
description: 'CPU, RAM и диск по инвентарю.',
|
||||||
|
to: '/resources',
|
||||||
|
icon: <ChartBarIcon aria-hidden />,
|
||||||
|
iconClassName: 'text-focus',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'integrations',
|
||||||
|
title: 'Интеграции',
|
||||||
|
description: 'App switcher и внешние связки.',
|
||||||
|
to: '/settings/integrations',
|
||||||
|
icon: <PlugIcon aria-hidden />,
|
||||||
|
iconClassName: 'text-muted-foreground',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
export const Route = createFileRoute('/_auth/dashboard')({
|
export const Route = createFileRoute('/_auth/dashboard')({
|
||||||
loader: ({ context: { queryClient } }) =>
|
loader: ({ context: { queryClient } }) =>
|
||||||
Promise.all([
|
Promise.all([
|
||||||
@@ -401,10 +456,14 @@ function DashboardPage() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{snap.settings?.[0]?.showQuickActions !== false ? (
|
||||||
|
<QuickActionGrid
|
||||||
|
actions={DASHBOARD_QUICK_ACTIONS}
|
||||||
|
description="Частые разделы учёта и аналитики"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<Button variant="outline" size="sm" render={<Link to="/resources" />}>
|
|
||||||
Ресурсы
|
|
||||||
</Button>
|
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import { LoadingButton } from '@/components/loading-button'
|
|||||||
import { SelectField } from '@/components/select-field'
|
import { SelectField } from '@/components/select-field'
|
||||||
import { FormField } from '@/components/form-field'
|
import { FormField } from '@/components/form-field'
|
||||||
import { Button } from '@cfdm/ui/components/button'
|
import { Button } from '@cfdm/ui/components/button'
|
||||||
|
import { Switch } from '@cfdm/ui/components/switch'
|
||||||
import { settingsSchema, type SettingsFormValues } from '@/lib/schemas'
|
import { settingsSchema, type SettingsFormValues } from '@/lib/schemas'
|
||||||
import { CustomFieldsEditor } from '@/components/domain/custom-fields-editor'
|
import { CustomFieldsEditor } from '@/components/domain/custom-fields-editor'
|
||||||
import type { NotificationLogRow, Settings } from '@/types/entities'
|
import type { NotificationLogRow, Settings } from '@/types/entities'
|
||||||
@@ -77,6 +78,7 @@ function settingsToFormValues(s: Settings): SettingsFormValues {
|
|||||||
webhookEnabled: s.webhookEnabled === true,
|
webhookEnabled: s.webhookEnabled === true,
|
||||||
customFields: parseCustomFieldDefs(s.customFields),
|
customFields: parseCustomFieldDefs(s.customFields),
|
||||||
telegramMessageThreadId: s.telegramMessageThreadId ?? '',
|
telegramMessageThreadId: s.telegramMessageThreadId ?? '',
|
||||||
|
showQuickActions: s.showQuickActions !== false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -330,6 +332,34 @@ function SettingsPage() {
|
|||||||
onSubmit={(e) => void form.handleSubmit((values) => upsertMut.mutate(values))(e)}
|
onSubmit={(e) => void form.handleSubmit((values) => upsertMut.mutate(values))(e)}
|
||||||
>
|
>
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Интерфейс</CardTitle>
|
||||||
|
<CardDescription>Блоки на дашборде</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<FieldGroup>
|
||||||
|
<FormField label="Быстрые действия" htmlFor="set-qa">
|
||||||
|
<Controller
|
||||||
|
control={form.control}
|
||||||
|
name="showQuickActions"
|
||||||
|
render={({ field }) => (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Switch
|
||||||
|
id="set-qa"
|
||||||
|
checked={field.value !== false}
|
||||||
|
onCheckedChange={field.onChange}
|
||||||
|
/>
|
||||||
|
<span className="text-muted-foreground text-sm">
|
||||||
|
Показывать на дашборде
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
</FieldGroup>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Валюта и курсы</CardTitle>
|
<CardTitle>Валюта и курсы</CardTitle>
|
||||||
|
|||||||
@@ -134,6 +134,7 @@ export interface Settings {
|
|||||||
integrationTokenSet?: boolean
|
integrationTokenSet?: boolean
|
||||||
integrationLastSyncAt?: string
|
integrationLastSyncAt?: string
|
||||||
cfdmApiUrl?: string
|
cfdmApiUrl?: string
|
||||||
|
showQuickActions?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface VpsDomain {
|
export interface VpsDomain {
|
||||||
|
|||||||
@@ -32,11 +32,25 @@ Ops / list / dashboard / detail / settings — только **Frame**, не shad
|
|||||||
|-----------|------|
|
|-----------|------|
|
||||||
| `ResourcePage` | Frame + line tabs + Filters + DataGrid |
|
| `ResourcePage` | Frame + line tabs + Filters + DataGrid |
|
||||||
| `KpiStatGrid` | horizontal compact hybrid KPI tiles (`variant`, Badge) |
|
| `KpiStatGrid` | horizontal compact hybrid KPI tiles (`variant`, Badge) |
|
||||||
| `OpsDashboard` | KPI + charts + attention queue |
|
| `QuickActionGrid` | KPI-like quick action tiles under/after KPI (gated by `showQuickActions`) |
|
||||||
|
| `OpsDashboard` | KPI + charts + attention queue (+ optional `afterKpi`) |
|
||||||
| `SettingsShell` | settings nav + Outlet |
|
| `SettingsShell` | settings nav + Outlet |
|
||||||
| `DetailPanel` | detail Frame sections |
|
| `DetailPanel` | detail Frame sections |
|
||||||
| `filter-utils` | apply/clear ReUI Filters |
|
| `filter-utils` | apply/clear ReUI Filters |
|
||||||
|
|
||||||
|
## Dashboard layout
|
||||||
|
|
||||||
|
| App | Section order |
|
||||||
|
|-----|---------------|
|
||||||
|
| EvoBGP / CFDM | KPI → **QuickActionGrid** → charts / rest |
|
||||||
|
| vps-tracker | banner → KPI → charts → attention → **QuickActionGrid** → CSV |
|
||||||
|
|
||||||
|
Gating: DB preference `showQuickActions` / `show_quick_actions` / `ui_show_quick_actions` (default `true`).
|
||||||
|
|
||||||
|
## System monitor
|
||||||
|
|
||||||
|
`SystemMonitorPopover` in app header next to `ModeToggle` (EvoBGP pattern: pill «Система» + Норма/Внимание). Preview shell: https://reui.io/preview/base/app-shell-12
|
||||||
|
|
||||||
## MCP workflow
|
## MCP workflow
|
||||||
|
|
||||||
1. MCP `user-reui` — `search` / `get_block` / `get_component` with `surface: "frame"`
|
1. MCP `user-reui` — `search` / `get_block` / `get_component` with `surface: "frame"`
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ export type SettingsDto = Omit<
|
|||||||
| 'integrationEnabled'
|
| 'integrationEnabled'
|
||||||
| 'customFields'
|
| 'customFields'
|
||||||
| 'appSwitcherJson'
|
| 'appSwitcherJson'
|
||||||
|
| 'showQuickActions'
|
||||||
> & {
|
> & {
|
||||||
telegramBotTokenSet: boolean
|
telegramBotTokenSet: boolean
|
||||||
integrationTokenSet: boolean
|
integrationTokenSet: boolean
|
||||||
@@ -56,6 +57,7 @@ export type SettingsDto = Omit<
|
|||||||
notifyVpsDownEnabled: boolean
|
notifyVpsDownEnabled: boolean
|
||||||
webhookEnabled: boolean
|
webhookEnabled: boolean
|
||||||
integrationEnabled: boolean
|
integrationEnabled: boolean
|
||||||
|
showQuickActions: boolean
|
||||||
notifyIntervalMinutes: number
|
notifyIntervalMinutes: number
|
||||||
uptimeCheckIntervalMinutes: number
|
uptimeCheckIntervalMinutes: number
|
||||||
customFields: unknown[]
|
customFields: unknown[]
|
||||||
@@ -81,7 +83,8 @@ function toDto(row: Row | undefined): SettingsDto | undefined {
|
|||||||
customFields = []
|
customFields = []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const { telegramBotToken, integrationToken, appSwitcherJson, ...rest } = row
|
const { telegramBotToken, integrationToken, appSwitcherJson, showQuickActions: _showQa, ...rest } =
|
||||||
|
row
|
||||||
return {
|
return {
|
||||||
...rest,
|
...rest,
|
||||||
telegramBotTokenSet: Boolean(telegramBotToken?.trim()),
|
telegramBotTokenSet: Boolean(telegramBotToken?.trim()),
|
||||||
@@ -95,6 +98,7 @@ function toDto(row: Row | undefined): SettingsDto | undefined {
|
|||||||
notifyVpsDownEnabled: Boolean(row.notifyVpsDownEnabled),
|
notifyVpsDownEnabled: Boolean(row.notifyVpsDownEnabled),
|
||||||
webhookEnabled: Boolean(row.webhookEnabled),
|
webhookEnabled: Boolean(row.webhookEnabled),
|
||||||
integrationEnabled: Boolean(row.integrationEnabled),
|
integrationEnabled: Boolean(row.integrationEnabled),
|
||||||
|
showQuickActions: row.showQuickActions == null ? true : Boolean(row.showQuickActions),
|
||||||
notifyIntervalMinutes: Number(row.notifyIntervalMinutes) || 60,
|
notifyIntervalMinutes: Number(row.notifyIntervalMinutes) || 60,
|
||||||
uptimeCheckIntervalMinutes: Number(row.uptimeCheckIntervalMinutes) || 5,
|
uptimeCheckIntervalMinutes: Number(row.uptimeCheckIntervalMinutes) || 5,
|
||||||
customFields: Array.isArray(customFields) ? customFields : [],
|
customFields: Array.isArray(customFields) ? customFields : [],
|
||||||
@@ -135,6 +139,7 @@ interface SettingsInput {
|
|||||||
integrationEnabled?: boolean
|
integrationEnabled?: boolean
|
||||||
integrationLastSyncAt?: string
|
integrationLastSyncAt?: string
|
||||||
cfdmApiUrl?: string
|
cfdmApiUrl?: string
|
||||||
|
showQuickActions?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildValues(id: string, existing: Row | undefined, r: SettingsInput) {
|
function buildValues(id: string, existing: Row | undefined, r: SettingsInput) {
|
||||||
@@ -238,6 +243,16 @@ function buildValues(id: string, existing: Row | undefined, r: SettingsInput) {
|
|||||||
? r.integrationLastSyncAt || ''
|
? r.integrationLastSyncAt || ''
|
||||||
: existing?.integrationLastSyncAt ?? '',
|
: existing?.integrationLastSyncAt ?? '',
|
||||||
cfdmApiUrl: r.cfdmApiUrl !== undefined ? r.cfdmApiUrl || '' : existing?.cfdmApiUrl ?? '',
|
cfdmApiUrl: r.cfdmApiUrl !== undefined ? r.cfdmApiUrl || '' : existing?.cfdmApiUrl ?? '',
|
||||||
|
showQuickActions:
|
||||||
|
r.showQuickActions !== undefined
|
||||||
|
? r.showQuickActions
|
||||||
|
? 1
|
||||||
|
: 0
|
||||||
|
: existing?.showQuickActions == null
|
||||||
|
? 1
|
||||||
|
: existing.showQuickActions
|
||||||
|
? 1
|
||||||
|
: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ const COLUMN_MIGRATIONS: string[] = [
|
|||||||
`ALTER TABLE settings ADD COLUMN integrationEnabled INTEGER`,
|
`ALTER TABLE settings ADD COLUMN integrationEnabled INTEGER`,
|
||||||
`ALTER TABLE settings ADD COLUMN integrationLastSyncAt TEXT`,
|
`ALTER TABLE settings ADD COLUMN integrationLastSyncAt TEXT`,
|
||||||
`ALTER TABLE settings ADD COLUMN cfdmApiUrl TEXT`,
|
`ALTER TABLE settings ADD COLUMN cfdmApiUrl TEXT`,
|
||||||
|
`ALTER TABLE settings ADD COLUMN showQuickActions INTEGER`,
|
||||||
`ALTER TABLE vps_domains ADD COLUMN targetIps TEXT`,
|
`ALTER TABLE vps_domains ADD COLUMN targetIps TEXT`,
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -133,6 +133,7 @@ export const settings = sqliteTable('settings', {
|
|||||||
integrationEnabled: integer('integrationEnabled'),
|
integrationEnabled: integer('integrationEnabled'),
|
||||||
integrationLastSyncAt: text('integrationLastSyncAt'),
|
integrationLastSyncAt: text('integrationLastSyncAt'),
|
||||||
cfdmApiUrl: text('cfdmApiUrl'),
|
cfdmApiUrl: text('cfdmApiUrl'),
|
||||||
|
showQuickActions: integer('showQuickActions'),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const vpsDomains = sqliteTable('vps_domains', {
|
export const vpsDomains = sqliteTable('vps_domains', {
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export const settingsSchema = z.object({
|
|||||||
appSwitcher: appSwitcherConfigSchema.optional(),
|
appSwitcher: appSwitcherConfigSchema.optional(),
|
||||||
integrationToken: z.string().optional(),
|
integrationToken: z.string().optional(),
|
||||||
integrationEnabled: z.boolean().optional(),
|
integrationEnabled: z.boolean().optional(),
|
||||||
|
showQuickActions: z.boolean().optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
export type Settings = z.infer<typeof settingsSchema>
|
export type Settings = z.infer<typeof settingsSchema>
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { Progress as ProgressPrimitive } from "@base-ui/react/progress"
|
||||||
|
|
||||||
|
import { cn } from "@cfdm/ui/lib/utils"
|
||||||
|
|
||||||
|
function Progress({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
value,
|
||||||
|
...props
|
||||||
|
}: ProgressPrimitive.Root.Props) {
|
||||||
|
return (
|
||||||
|
<ProgressPrimitive.Root
|
||||||
|
value={value}
|
||||||
|
data-slot="progress"
|
||||||
|
className={cn("flex flex-wrap gap-3", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<ProgressTrack>
|
||||||
|
<ProgressIndicator />
|
||||||
|
</ProgressTrack>
|
||||||
|
</ProgressPrimitive.Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProgressTrack({ className, ...props }: ProgressPrimitive.Track.Props) {
|
||||||
|
return (
|
||||||
|
<ProgressPrimitive.Track
|
||||||
|
className={cn(
|
||||||
|
"relative flex h-1 w-full items-center overflow-x-hidden rounded-full bg-muted",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
data-slot="progress-track"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProgressIndicator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: ProgressPrimitive.Indicator.Props) {
|
||||||
|
return (
|
||||||
|
<ProgressPrimitive.Indicator
|
||||||
|
data-slot="progress-indicator"
|
||||||
|
className={cn("h-full bg-primary transition-all", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProgressLabel({ className, ...props }: ProgressPrimitive.Label.Props) {
|
||||||
|
return (
|
||||||
|
<ProgressPrimitive.Label
|
||||||
|
className={cn("text-sm font-medium", className)}
|
||||||
|
data-slot="progress-label"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProgressValue({ className, ...props }: ProgressPrimitive.Value.Props) {
|
||||||
|
return (
|
||||||
|
<ProgressPrimitive.Value
|
||||||
|
className={cn(
|
||||||
|
"ml-auto text-sm text-muted-foreground tabular-nums",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
data-slot="progress-value"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Progress,
|
||||||
|
ProgressTrack,
|
||||||
|
ProgressIndicator,
|
||||||
|
ProgressLabel,
|
||||||
|
ProgressValue,
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Switch as SwitchPrimitive } from "@base-ui/react/switch"
|
||||||
|
|
||||||
|
import { cn } from "@cfdm/ui/lib/utils"
|
||||||
|
|
||||||
|
function Switch({
|
||||||
|
className,
|
||||||
|
size = "default",
|
||||||
|
...props
|
||||||
|
}: SwitchPrimitive.Root.Props & {
|
||||||
|
size?: "sm" | "default"
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<SwitchPrimitive.Root
|
||||||
|
data-slot="switch"
|
||||||
|
data-size={size}
|
||||||
|
className={cn(
|
||||||
|
"peer group/switch relative inline-flex shrink-0 items-center rounded-full border border-transparent transition-all outline-none after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-[size=default]:h-[18.4px] data-[size=default]:w-[32px] data-[size=sm]:h-[14px] data-[size=sm]:w-[24px] dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:bg-primary data-unchecked:bg-input dark:data-unchecked:bg-input/80 data-disabled:cursor-not-allowed data-disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SwitchPrimitive.Thumb
|
||||||
|
data-slot="switch-thumb"
|
||||||
|
className="pointer-events-none block rounded-full bg-background ring-0 transition-transform group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 group-data-[size=default]/switch:data-checked:translate-x-[calc(100%-2px)] group-data-[size=sm]/switch:data-checked:translate-x-[calc(100%-2px)] dark:data-checked:bg-primary-foreground group-data-[size=default]/switch:data-unchecked:translate-x-0 group-data-[size=sm]/switch:data-unchecked:translate-x-0 dark:data-unchecked:bg-foreground"
|
||||||
|
/>
|
||||||
|
</SwitchPrimitive.Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Switch }
|
||||||
Reference in New Issue
Block a user