diff --git a/apps/web/src/components/layout/app-shell.tsx b/apps/web/src/components/layout/app-shell.tsx
index bb35fa7..37833f3 100644
--- a/apps/web/src/components/layout/app-shell.tsx
+++ b/apps/web/src/components/layout/app-shell.tsx
@@ -45,6 +45,7 @@ import { useQuery } from '@tanstack/react-query'
import { useState, type ReactNode } from 'react'
import { ModeToggle } from '@/components/mode-toggle'
+import { SystemMonitorPopover } from '@/components/layout/system-monitor-popover'
import { AppSwitcher } from '@/components/app-switcher'
import { GlobalSearch, GlobalSearchTrigger, useGlobalSearchHotkey } from '@/components/global-search'
import { dashboardStatsQueryOptions } from '@/queries/dashboard'
@@ -230,6 +231,7 @@ export function AppShell({ children }: { children: ReactNode }) {
{stats.issuesCount} проблем
) : null}
+
diff --git a/apps/web/src/components/layout/system-monitor-popover.tsx b/apps/web/src/components/layout/system-monitor-popover.tsx
new file mode 100644
index 0000000..c51f7d1
--- /dev/null
+++ b/apps/web/src/components/layout/system-monitor-popover.tsx
@@ -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 (
+
+
+
+ -
+
+ {metric.icon}
+
+
+ {metric.label}
+
+
+ {metric.value}
+ {metric.unit}
+
+
+
+
+ )
+}
+
+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>,
+ 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(
+ () => [
+ {
+ id: 'sync',
+ label: 'Синк',
+ value: syncAlert ? '!' : 'OK',
+ unit: '',
+ percent: syncAlert ? 35 : 100,
+ icon: ,
+ tone: syncAlert ? (recentSyncFailed ? 'destructive' : 'warning') : 'success',
+ alert: syncAlert,
+ },
+ {
+ id: 'inventory',
+ label: 'Инвентарь',
+ value: String(issuesCount),
+ unit: 'шт.',
+ percent: Math.min(100, issuesCount * 15),
+ icon: ,
+ 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: ,
+ 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: ,
+ 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 (
+
+
+ }
+ >
+
+
+ {spiking ? (
+
+ ) : null}
+
+ Система
+
+ {spiking ? 'Внимание' : 'Норма'}
+
+
+
+
+
+ Монитор VPS Tracker
+
+ {new Date().toLocaleTimeString('ru-RU')}
+
+
+
+ {metrics.map((metric, i) => (
+
= 2 && 'border-border border-t',
+ )}
+ >
+
+
+ ))}
+
+
+ API:{' '}
+ {apiOk ? 'OK' : '—'}
+ {' · '}
+ Уведомлений с ошибкой:{' '}
+ {failedNotifications}
+ {stats?.lastGlobalSyncAt ? (
+ <>
+ {' · '}
+ Синк:{' '}
+
+ {new Date(stats.lastGlobalSyncAt).toLocaleString('ru-RU')}
+
+ >
+ ) : null}
+
+
+
+ )
+}
diff --git a/apps/web/src/components/reui-kit/index.ts b/apps/web/src/components/reui-kit/index.ts
index ea31935..02a5915 100644
--- a/apps/web/src/components/reui-kit/index.ts
+++ b/apps/web/src/components/reui-kit/index.ts
@@ -6,6 +6,10 @@ export {
type KpiStatVariant,
type OpsKpiCard,
} from './kpi-stat-grid'
+export {
+ QuickActionGrid,
+ type QuickActionItem,
+} from './quick-action-grid'
export { OpsDashboard } from './ops-dashboard'
export { DetailPanel, type DetailMetricCard } from './detail-panel'
export { SettingsShell, type SettingsTabConfig } from './settings-shell'
diff --git a/apps/web/src/components/reui-kit/quick-action-grid.tsx b/apps/web/src/components/reui-kit/quick-action-grid.tsx
new file mode 100644
index 0000000..8221473
--- /dev/null
+++ b/apps/web/src/components/reui-kit/quick-action-grid.tsx
@@ -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
+ 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 (
+
+ {action.icon ? (
+
-
+
+ {action.icon}
+
+
+ ) : null}
+
+
+
+ {action.title}
+
+ Перейти
+
+
+
+ {action.description}
+
+
+
+ )
+}
+
+/**
+ * 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 (
+
+ {(title || description) && (
+
+ {title ? {title} : null}
+ {description ? {description} : null}
+
+ )}
+
+ {actions.map((action) => (
+
+
+
+
+
+ ))}
+
+
+ )
+}
diff --git a/apps/web/src/lib/schemas.ts b/apps/web/src/lib/schemas.ts
index 0fc8446..0695dc2 100644
--- a/apps/web/src/lib/schemas.ts
+++ b/apps/web/src/lib/schemas.ts
@@ -112,6 +112,7 @@ export const settingsSchema = z.object({
(fields) => new Set(fields.map((f) => f.key)).size === fields.length,
'Ключи кастомных полей должны быть уникальными',
),
+ showQuickActions: z.boolean().optional().default(true),
})
export {
diff --git a/apps/web/src/routes/_auth/dashboard.tsx b/apps/web/src/routes/_auth/dashboard.tsx
index 2419617..1545076 100644
--- a/apps/web/src/routes/_auth/dashboard.tsx
+++ b/apps/web/src/routes/_auth/dashboard.tsx
@@ -13,11 +13,15 @@ import {
CoinsIcon,
ClockIcon,
DownloadIcon,
+ CreditCardIcon,
+ ChartColumnBigIcon,
+ ChartBarIcon,
+ PlugIcon,
} from 'lucide-react'
import { snapshotQueryOptions, ratesQueryOptions } from '@/queries/snapshot'
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 { DataGridCard, columnDefFromDataGrid } from '@/components/data-grid-card'
import type { DataGridColumn } from '@/components/data-grid-types'
@@ -45,6 +49,57 @@ import type { Vps } from '@/types/entities'
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'
+const DASHBOARD_QUICK_ACTIONS: QuickActionItem[] = [
+ {
+ id: 'vps',
+ title: 'VPS',
+ description: 'Список серверов, оплата и здоровье.',
+ to: '/vps',
+ icon: ,
+ iconClassName: 'text-primary',
+ },
+ {
+ id: 'accounts',
+ title: 'Аккаунты',
+ description: 'Аккаунты хостеров и баланс API.',
+ to: '/accounts',
+ icon: ,
+ iconClassName: 'text-success',
+ },
+ {
+ id: 'payments',
+ title: 'Платежи',
+ description: 'Пополнения и оплаты VPS.',
+ to: '/payments',
+ icon: ,
+ iconClassName: 'text-info',
+ },
+ {
+ id: 'reports',
+ title: 'Отчёты',
+ description: 'Сводки по расходам и балансам.',
+ to: '/reports',
+ icon: ,
+ iconClassName: 'text-warning',
+ },
+ {
+ id: 'resources',
+ title: 'Ресурсы',
+ description: 'CPU, RAM и диск по инвентарю.',
+ to: '/resources',
+ icon: ,
+ iconClassName: 'text-focus',
+ },
+ {
+ id: 'integrations',
+ title: 'Интеграции',
+ description: 'App switcher и внешние связки.',
+ to: '/settings/integrations',
+ icon: ,
+ iconClassName: 'text-muted-foreground',
+ },
+]
+
export const Route = createFileRoute('/_auth/dashboard')({
loader: ({ context: { queryClient } }) =>
Promise.all([
@@ -401,10 +456,14 @@ function DashboardPage() {
}
/>
+ {snap.settings?.[0]?.showQuickActions !== false ? (
+
+ ) : null}
+
-
}>
- Ресурсы
-