diff --git a/apps/web/package.json b/apps/web/package.json index e712d090152..37974bcb445 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -37,6 +37,7 @@ "react": "19.2.5", "react-dom": "19.2.5", "react-markdown": "10.1.0", + "recharts": "^3.8.1", "remark-gfm": "4.0.1", "tailwind-merge": "3.4.0", "zustand": "5.0.11" diff --git a/apps/web/src/components/settings/SettingsSidebarNav.tsx b/apps/web/src/components/settings/SettingsSidebarNav.tsx index a3c87bea786..815769c555c 100644 --- a/apps/web/src/components/settings/SettingsSidebarNav.tsx +++ b/apps/web/src/components/settings/SettingsSidebarNav.tsx @@ -2,6 +2,7 @@ import { useCallback, type ComponentType } from "react"; import { ArchiveIcon, ArrowLeftIcon, + ChartColumnIcon, GitBranchIcon, KeyboardIcon, Link2Icon, @@ -25,6 +26,7 @@ export type SettingsSectionPath = | "/settings/providers" | "/settings/source-control" | "/settings/connections" + | "/settings/analytics" | "/settings/archived"; export const SETTINGS_NAV_ITEMS: ReadonlyArray<{ @@ -37,6 +39,7 @@ export const SETTINGS_NAV_ITEMS: ReadonlyArray<{ // { label: "Провайдеры", to: "/settings/providers", icon: BotIcon }, { label: "Контроль версий", to: "/settings/source-control", icon: GitBranchIcon }, { label: "Подключения", to: "/settings/connections", icon: Link2Icon }, + { label: "Аналитика", to: "/settings/analytics", icon: ChartColumnIcon }, { label: "Архив", to: "/settings/archived", icon: ArchiveIcon }, ]; diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 6bec71a491b..e5f9e04c870 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -42,6 +42,11 @@ --color-card: var(--card); --color-foreground: var(--foreground); --color-background: var(--background); + --color-chart-1: var(--chart-1); + --color-chart-2: var(--chart-2); + --color-chart-3: var(--chart-3); + --color-chart-4: var(--chart-4); + --color-chart-5: var(--chart-5); --radius-sm: calc(var(--radius) - 4px); --radius-md: calc(var(--radius) - 2px); --radius-lg: var(--radius); diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index 85a2f9ef8fa..4d43b8d6a41 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -19,6 +19,7 @@ import { Route as SettingsKeybindingsRouteImport } from './routes/settings.keybi import { Route as SettingsGeneralRouteImport } from './routes/settings.general' import { Route as SettingsConnectionsRouteImport } from './routes/settings.connections' import { Route as SettingsArchivedRouteImport } from './routes/settings.archived' +import { Route as SettingsAnalyticsRouteImport } from './routes/settings.analytics' import { Route as ChatDraftDraftIdRouteImport } from './routes/_chat.draft.$draftId' import { Route as ChatEnvironmentIdThreadIdRouteImport } from './routes/_chat.$environmentId.$threadId' @@ -71,6 +72,11 @@ const SettingsArchivedRoute = SettingsArchivedRouteImport.update({ path: '/archived', getParentRoute: () => SettingsRoute, } as any) +const SettingsAnalyticsRoute = SettingsAnalyticsRouteImport.update({ + id: '/analytics', + path: '/analytics', + getParentRoute: () => SettingsRoute, +} as any) const ChatDraftDraftIdRoute = ChatDraftDraftIdRouteImport.update({ id: '/draft/$draftId', path: '/draft/$draftId', @@ -87,6 +93,7 @@ export interface FileRoutesByFullPath { '/': typeof ChatIndexRoute '/pair': typeof PairRoute '/settings': typeof SettingsRouteWithChildren + '/settings/analytics': typeof SettingsAnalyticsRoute '/settings/archived': typeof SettingsArchivedRoute '/settings/connections': typeof SettingsConnectionsRoute '/settings/general': typeof SettingsGeneralRoute @@ -99,6 +106,7 @@ export interface FileRoutesByFullPath { export interface FileRoutesByTo { '/pair': typeof PairRoute '/settings': typeof SettingsRouteWithChildren + '/settings/analytics': typeof SettingsAnalyticsRoute '/settings/archived': typeof SettingsArchivedRoute '/settings/connections': typeof SettingsConnectionsRoute '/settings/general': typeof SettingsGeneralRoute @@ -114,6 +122,7 @@ export interface FileRoutesById { '/_chat': typeof ChatRouteWithChildren '/pair': typeof PairRoute '/settings': typeof SettingsRouteWithChildren + '/settings/analytics': typeof SettingsAnalyticsRoute '/settings/archived': typeof SettingsArchivedRoute '/settings/connections': typeof SettingsConnectionsRoute '/settings/general': typeof SettingsGeneralRoute @@ -130,6 +139,7 @@ export interface FileRouteTypes { | '/' | '/pair' | '/settings' + | '/settings/analytics' | '/settings/archived' | '/settings/connections' | '/settings/general' @@ -142,6 +152,7 @@ export interface FileRouteTypes { to: | '/pair' | '/settings' + | '/settings/analytics' | '/settings/archived' | '/settings/connections' | '/settings/general' @@ -156,6 +167,7 @@ export interface FileRouteTypes { | '/_chat' | '/pair' | '/settings' + | '/settings/analytics' | '/settings/archived' | '/settings/connections' | '/settings/general' @@ -245,6 +257,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof SettingsArchivedRouteImport parentRoute: typeof SettingsRoute } + '/settings/analytics': { + id: '/settings/analytics' + path: '/analytics' + fullPath: '/settings/analytics' + preLoaderRoute: typeof SettingsAnalyticsRouteImport + parentRoute: typeof SettingsRoute + } '/_chat/draft/$draftId': { id: '/_chat/draft/$draftId' path: '/draft/$draftId' @@ -277,6 +296,7 @@ const ChatRouteChildren: ChatRouteChildren = { const ChatRouteWithChildren = ChatRoute._addFileChildren(ChatRouteChildren) interface SettingsRouteChildren { + SettingsAnalyticsRoute: typeof SettingsAnalyticsRoute SettingsArchivedRoute: typeof SettingsArchivedRoute SettingsConnectionsRoute: typeof SettingsConnectionsRoute SettingsGeneralRoute: typeof SettingsGeneralRoute @@ -286,6 +306,7 @@ interface SettingsRouteChildren { } const SettingsRouteChildren: SettingsRouteChildren = { + SettingsAnalyticsRoute: SettingsAnalyticsRoute, SettingsArchivedRoute: SettingsArchivedRoute, SettingsConnectionsRoute: SettingsConnectionsRoute, SettingsGeneralRoute: SettingsGeneralRoute, diff --git a/apps/web/src/routes/settings.analytics.tsx b/apps/web/src/routes/settings.analytics.tsx new file mode 100644 index 00000000000..dd796adc6f0 --- /dev/null +++ b/apps/web/src/routes/settings.analytics.tsx @@ -0,0 +1,7 @@ +import { createFileRoute } from "@tanstack/react-router"; + +import { StatsDashboard } from "../ru-fork/stats"; + +export const Route = createFileRoute("/settings/analytics")({ + component: StatsDashboard, +}); diff --git a/apps/web/src/ru-fork/stats/components/KpiStrip.tsx b/apps/web/src/ru-fork/stats/components/KpiStrip.tsx new file mode 100644 index 00000000000..f824912d20d --- /dev/null +++ b/apps/web/src/ru-fork/stats/components/KpiStrip.tsx @@ -0,0 +1,68 @@ +/** + * ru-fork: Analytics — the top KPI strip (headline numbers + trend chips). + * + * @module ru-fork/stats/components/KpiStrip + */ +import type { ComponentType } from "react"; +import { + ActivityIcon, + AlertTriangleIcon, + CoinsIcon, + FolderGitIcon, + GaugeIcon, + MessagesSquareIcon, + WrenchIcon, +} from "lucide-react"; + +import { cn } from "~/lib/utils"; +import { formatDuration, formatInt, formatPct, formatTokens } from "../model/format"; +import type { KpiSet } from "../model/types"; +import { DeltaChip } from "./primitives"; + +interface Tile { + readonly icon: ComponentType<{ className?: string }>; + readonly label: string; + readonly value: string; + readonly sub?: string; + readonly delta?: number; + readonly deltaInvert?: boolean; + readonly accent?: string; +} + +export function KpiStrip({ kpis }: { kpis: KpiSet }) { + const tiles: readonly Tile[] = [ + { icon: CoinsIcon, label: "Всего токенов", value: formatTokens(kpis.totalTokens), sub: `${formatTokens(kpis.inputTokens)} ввод`, delta: kpis.tokensDeltaPct, accent: "text-chart-1" }, + { icon: ActivityIcon, label: "Запросов API", value: formatInt(kpis.apiCalls), sub: `${formatTokens(kpis.outputTokens)} вывод` }, + { icon: WrenchIcon, label: "Вызовов инструментов", value: formatInt(kpis.toolCalls) }, + { icon: MessagesSquareIcon, label: "Сессий", value: formatInt(kpis.sessions) }, + { icon: FolderGitIcon, label: "Проектов", value: formatInt(kpis.projects) }, + { icon: AlertTriangleIcon, label: "Ошибок", value: formatInt(kpis.errors), sub: formatPct(kpis.errorRatePct) + " ошибок" }, + { icon: GaugeIcon, label: "Ср. задержка", value: formatDuration(kpis.avgLatencyMs) }, + ]; + + return ( +
+ {tiles.map((tile) => { + const Icon = tile.icon; + return ( +
+
+ + + {tile.label} + +
+
+ {tile.value} + {tile.delta !== undefined ? : null} +
+ {tile.sub ? {tile.sub} : null} +
+ ); + })} +
+ ); +} diff --git a/apps/web/src/ru-fork/stats/components/RefreshControl.tsx b/apps/web/src/ru-fork/stats/components/RefreshControl.tsx new file mode 100644 index 00000000000..4b0a1b4c387 --- /dev/null +++ b/apps/web/src/ru-fork/stats/components/RefreshControl.tsx @@ -0,0 +1,80 @@ +/** + * ru-fork: Analytics — refresh cadence control. Manual refresh icon (re-rolls + * the demo data so it feels live), the "обновлено N назад" stamp, and the + * update-interval setting (default 30 мин) that also drives an auto-refresh tick. + * + * @module ru-fork/stats/components/RefreshControl + */ +import { useEffect, useState } from "react"; +import { RefreshCwIcon, TimerIcon } from "lucide-react"; + +import { cn } from "~/lib/utils"; +import { Button } from "~/components/ui/button"; +import { REFRESH_INTERVAL_OPTIONS, useStatsStore } from "../store"; +import { FilterSelect } from "./primitives"; + +const RELATIVE_TIME_TICK_MS = 15_000; +const REFRESH_SPINNER_MS = 700; +const MILLISECONDS_PER_MINUTE = 60_000; + +function useRelativeTime(sinceMs: number): string { + const [, forceRerender] = useState(0); + useEffect(() => { + const intervalId = setInterval(() => forceRerender((counter) => counter + 1), RELATIVE_TIME_TICK_MS); + return () => clearInterval(intervalId); + }, []); + + const elapsedSeconds = Math.max(0, Math.floor((Date.now() - sinceMs) / 1000)); + if (elapsedSeconds < 30) return "только что"; + if (elapsedSeconds < 90) return "минуту назад"; + const elapsedMinutes = Math.round(elapsedSeconds / 60); + if (elapsedMinutes < 60) return `${elapsedMinutes} мин назад`; + const elapsedHours = Math.round(elapsedMinutes / 60); + return `${elapsedHours} ч назад`; +} + +export function RefreshControl() { + const isRefreshing = useStatsStore((state) => state.isRefreshing); + const lastRefreshedAtMs = useStatsStore((state) => state.lastRefreshedAtMs); + const refreshIntervalMin = useStatsStore((state) => state.refreshIntervalMin); + const startRefresh = useStatsStore((state) => state.startRefresh); + const finishRefresh = useStatsStore((state) => state.finishRefresh); + const setRefreshIntervalMin = useStatsStore((state) => state.setRefreshIntervalMin); + const relativeTime = useRelativeTime(lastRefreshedAtMs); + + // Resolve the spinner shortly after a refresh starts. + useEffect(() => { + if (!isRefreshing) return; + const timeoutId = setTimeout(() => finishRefresh(), REFRESH_SPINNER_MS); + return () => clearTimeout(timeoutId); + }, [isRefreshing, finishRefresh]); + + // Auto-refresh on the configured cadence. + useEffect(() => { + const intervalId = setInterval(() => startRefresh(), refreshIntervalMin * MILLISECONDS_PER_MINUTE); + return () => clearInterval(intervalId); + }, [refreshIntervalMin, startRefresh]); + + return ( +
+ обновлено {relativeTime} + + setRefreshIntervalMin(Number(nextValue))} + options={REFRESH_INTERVAL_OPTIONS.map((option) => ({ value: String(option.value), label: option.label }))} + /> +
+ ); +} diff --git a/apps/web/src/ru-fork/stats/components/SessionDetailSheet.tsx b/apps/web/src/ru-fork/stats/components/SessionDetailSheet.tsx new file mode 100644 index 00000000000..461071f2ecd --- /dev/null +++ b/apps/web/src/ru-fork/stats/components/SessionDetailSheet.tsx @@ -0,0 +1,116 @@ +/** + * ru-fork: Analytics — single-session drill-down (opened from the table row). + * + * @module ru-fork/stats/components/SessionDetailSheet + */ +import { Badge } from "~/components/ui/badge"; +import { Sheet, SheetDescription, SheetHeader, SheetPanel, SheetPopup, SheetTitle } from "~/components/ui/sheet"; +import { formatDateTime, formatDuration, formatInt, formatTokens } from "../model/format"; +import type { StatsSession, StatsView } from "../model/types"; +import { findSessionById, useStatsStore } from "../store"; +import { BarRow } from "./primitives"; + +export function SessionDetailSheet({ view }: { view: StatsView }) { + const selectedSessionId = useStatsStore((state) => state.selectedSessionId); + const selectSession = useStatsStore((state) => state.selectSession); + const session = findSessionById(view, selectedSessionId); + + return ( + !isOpen && selectSession(null)}> + + {session ? : null} + + + ); +} + +function SessionDetailBody({ session }: { session: StatsSession }) { + const visibleTotal = session.tokens.input + session.tokens.output + session.tokens.thinking; + const toolEntries = Object.entries(session.toolCounts).toSorted((first, second) => second[1] - first[1]); + const maxToolCalls = toolEntries[0]?.[1] ?? 1; + const errorEntries = Object.entries(session.errorTypes); + + return ( + <> + + {session.projectLabel} + {formatDateTime(session.startedAt)} +
+ {session.branch} + {session.model.replace("qwen/", "")} + {session.projectKind === "temp" ? песочница : null} + {session.isBackground ? фон : null} +
+
+ +
+ + + + + + +
+ +
+

Состав токенов

+
+ + + +
+
+ Ввод {formatTokens(session.tokens.input)} + Вывод {formatTokens(session.tokens.output)} + Размышл. {formatTokens(session.tokens.thinking)} +
+
+ +
+

+ Инструменты ({formatInt(toolEntries.reduce((runningTotal, entry) => runningTotal + entry[1], 0))}) +

+
+ {toolEntries.map(([toolName, callCount]) => ( + + ))} + {toolEntries.length === 0 ? ( +

Без вызовов инструментов

+ ) : null} +
+
+ + {errorEntries.length ? ( +
+

Ошибки

+ {errorEntries.map(([errorType, count]) => ( +
+ + {errorType} + {count} +
+ ))} +
+ ) : null} +
+ + ); +} + +function SessionStat({ label, value }: { label: string; value: string }) { + return ( +
+
{label}
+
{value}
+
+ ); +} diff --git a/apps/web/src/ru-fork/stats/components/StatsDashboard.tsx b/apps/web/src/ru-fork/stats/components/StatsDashboard.tsx new file mode 100644 index 00000000000..036d94204e3 --- /dev/null +++ b/apps/web/src/ru-fork/stats/components/StatsDashboard.tsx @@ -0,0 +1,109 @@ +/** + * ru-fork: Analytics dashboard — the Settings → «Аналитика» panel. + * + * Read-only, fake-data view (no server). Composes the filter bar, KPI strip and + * the widget grid; every card can expand into a drill-down sheet, and the + * sessions table opens a per-session sheet. All numbers derive from {@link + * useStatsView} so a filter change re-renders the whole board consistently. + * + * @module ru-fork/stats/components/StatsDashboard + */ +import { ChartColumnIcon } from "lucide-react"; + +import { ScrollArea } from "~/components/ui/scroll-area"; +import { useStatsStore, useStatsView } from "../store"; +import { KpiStrip } from "./KpiStrip"; +import { RefreshControl } from "./RefreshControl"; +import { SessionDetailSheet } from "./SessionDetailSheet"; +import { StatsFilterBar } from "./StatsFilterBar"; +import { WidgetDetailSheet } from "./WidgetDetailSheet"; +import { ActivityHeatmapCard } from "./widgets/ActivityHeatmapCard"; +import { ModelsCard } from "./widgets/ModelsCard"; +import { ProjectsCard } from "./widgets/ProjectsCard"; +import { ReliabilityCard } from "./widgets/ReliabilityCard"; +import { SessionsTableCard } from "./widgets/SessionsTableCard"; +import { TokenCompositionCard } from "./widgets/TokenCompositionCard"; +import { ToolsCard } from "./widgets/ToolsCard"; +import { UsageOverTimeCard } from "./widgets/UsageOverTimeCard"; + +export function StatsDashboard() { + const view = useStatsView(); + const granularity = useStatsStore((state) => state.granularity); + const setGranularity = useStatsStore((state) => state.setGranularity); + const setFilters = useStatsStore((state) => state.setFilters); + const openWidgetDetail = useStatsStore((state) => state.openWidgetDetail); + const selectSession = useStatsStore((state) => state.selectSession); + + return ( + +
+
+
+ +
+

Аналитика

+

Использование CLI по всем проектам

+
+
+
+ +
+
+ + + + +
+
+ openWidgetDetail("usage")} + /> +
+
+ openWidgetDetail("composition")} /> +
+ +
+ openWidgetDetail("models")} /> +
+
+ setFilters({ projectId })} + onExpand={() => openWidgetDetail("projects")} + /> +
+
+ openWidgetDetail("tools")} /> +
+ +
+ openWidgetDetail("reliability")} + /> +
+
+ openWidgetDetail("activity")} /> +
+ +
+ openWidgetDetail("usage")} + /> +
+
+
+ + + +
+ ); +} diff --git a/apps/web/src/ru-fork/stats/components/StatsFilterBar.tsx b/apps/web/src/ru-fork/stats/components/StatsFilterBar.tsx new file mode 100644 index 00000000000..3a0448653cd --- /dev/null +++ b/apps/web/src/ru-fork/stats/components/StatsFilterBar.tsx @@ -0,0 +1,96 @@ +/** + * ru-fork: Analytics — the global filter bar. Every control writes to the store; + * the whole dashboard recomputes from the narrowed session set. + * + * @module ru-fork/stats/components/StatsFilterBar + */ +import { BotIcon, CalendarRangeIcon, FolderIcon, GitBranchIcon, RotateCcwIcon } from "lucide-react"; + +import { Button } from "~/components/ui/button"; +import { Switch } from "~/components/ui/switch"; +import { BRANCH_OPTIONS, MODEL_OPTIONS, PROJECT_OPTIONS, RANGE_OPTIONS } from "../model/fakeData"; +import type { RangeDays, StatsFilters, TrafficFilter } from "../model/types"; +import { useStatsStore } from "../store"; +import { FilterSelect, Segmented } from "./primitives"; + +const TRAFFIC_OPTIONS: readonly { value: TrafficFilter; label: string }[] = [ + { value: "all", label: "Все" }, + { value: "turns", label: "Диалог" }, + { value: "background", label: "Фон" }, +]; + +const DEFAULT_FILTERS: StatsFilters = { + rangeDays: 30, + projectId: "all", + model: "all", + branch: "all", + includeTemp: false, + traffic: "all", +}; + +function parseRangeDays(raw: string): RangeDays { + const parsed = Number(raw); + if (parsed === 7 || parsed === 14 || parsed === 30 || parsed === 48) return parsed; + return DEFAULT_FILTERS.rangeDays; +} + +export function StatsFilterBar() { + const filters = useStatsStore((state) => state.filters); + const setFilters = useStatsStore((state) => state.setFilters); + const resetFilters = useStatsStore((state) => state.resetFilters); + + const hasActiveFilters = + filters.rangeDays !== DEFAULT_FILTERS.rangeDays || + filters.projectId !== DEFAULT_FILTERS.projectId || + filters.model !== DEFAULT_FILTERS.model || + filters.branch !== DEFAULT_FILTERS.branch || + filters.includeTemp !== DEFAULT_FILTERS.includeTemp || + filters.traffic !== DEFAULT_FILTERS.traffic; + + return ( +
+ setFilters({ rangeDays: parseRangeDays(nextValue) })} + options={RANGE_OPTIONS.map((option) => ({ value: String(option.value), label: option.label }))} + /> + setFilters({ projectId })} + options={PROJECT_OPTIONS} + /> + setFilters({ model })} + options={MODEL_OPTIONS} + /> + setFilters({ branch })} + options={BRANCH_OPTIONS} + /> + + setFilters({ traffic })} /> + + + + {hasActiveFilters ? ( + + ) : null} +
+ ); +} diff --git a/apps/web/src/ru-fork/stats/components/WidgetDetailSheet.tsx b/apps/web/src/ru-fork/stats/components/WidgetDetailSheet.tsx new file mode 100644 index 00000000000..9c1b6b71e24 --- /dev/null +++ b/apps/web/src/ru-fork/stats/components/WidgetDetailSheet.tsx @@ -0,0 +1,289 @@ +/** + * ru-fork: Analytics — widget drill-down. Each dashboard card's expand button + * opens this sheet with the full, un-truncated breakdown for that metric. + * + * @module ru-fork/stats/components/WidgetDetailSheet + */ +import type { ReactNode } from "react"; + +import { Sheet, SheetDescription, SheetHeader, SheetPanel, SheetPopup, SheetTitle } from "~/components/ui/sheet"; +import { TOOL_GROUP_LABEL } from "../model/catalog"; +import { formatDayLabel, formatInt, formatPct, formatTokens, WEEKDAY_LABELS } from "../model/format"; +import type { NamedTokenSlice, StatsView, ToolGroup, ToolStat } from "../model/types"; +import { useStatsStore, type WidgetId } from "../store"; +import { BarRow, chartBackground } from "./primitives"; + +interface WidgetMeta { + readonly title: string; + readonly description: string; +} + +const WIDGET_META: Record = { + usage: { title: "Расход токенов", description: "Полная разбивка по периодам" }, + composition: { title: "Состав токенов", description: "Ввод, вывод, размышления, кэш" }, + models: { title: "Модели", description: "Расход и сессии по моделям" }, + projects: { title: "Проекты", description: "Все проекты за период, включая ветки" }, + branches: { title: "Ветки", description: "Расход по git-веткам" }, + tools: { title: "Инструменты", description: "Вызовы и успешность по категориям" }, + reliability: { title: "Надёжность", description: "Задержки, ошибки и одобрения" }, + activity: { title: "Активность", description: "Распределение по дням и часам" }, +}; + +export function WidgetDetailSheet({ view }: { view: StatsView }) { + const openWidget = useStatsStore((state) => state.openWidget); + const closeWidget = useStatsStore((state) => state.openWidgetDetail); + const meta = openWidget ? WIDGET_META[openWidget] : null; + + return ( + !isOpen && closeWidget(null)}> + + {meta && openWidget ? ( + <> + + {meta.title} + {meta.description} + + {renderWidgetBody(openWidget, view)} + + ) : null} + + + ); +} + +function renderWidgetBody(widget: WidgetId, view: StatsView): ReactNode { + switch (widget) { + case "usage": + case "composition": + return ; + case "models": + return ; + case "projects": + return ( + <> + + + + + + + + ); + case "branches": + return ; + case "tools": + return ; + case "reliability": + return ; + case "activity": + return ; + default: + return null; + } +} + +function DetailSection({ title, children }: { title: string; children: ReactNode }) { + return ( +
+

{title}

+ {children} +
+ ); +} + +function UsageDetail({ view }: { view: StatsView }) { + const { kpis } = view; + return ( + <> +
+ + + + +
+ + + + + + + + + + + + + {view.series.map((bucket) => ( + + + + + + + + ))} + +
ПериодВводВыводВсегоЗапросы
{formatDayLabel(bucket.bucketKey)}{formatTokens(bucket.input)}{formatTokens(bucket.output)}{formatTokens(bucket.total)}{bucket.calls}
+
+ + ); +} + +function SliceBars({ rows }: { rows: readonly NamedTokenSlice[] }) { + const maxTokens = rows[0]?.tokens ?? 1; + return ( +
+ {rows.map((slice, sliceIndex) => ( + + ))} +
+ ); +} + +function SliceTable({ rows, unitLabel }: { rows: readonly NamedTokenSlice[]; unitLabel: string }) { + const grandTotal = rows.reduce((runningTotal, slice) => runningTotal + slice.tokens, 0); + return ( + + + + + + + + + + + {rows.map((slice) => ( + + + + + + + ))} + + + + + + +
{unitLabel}ТокеныСессииДоля
{slice.label}{formatTokens(slice.tokens)}{formatInt(slice.sessions)}{formatPct(slice.sharePct, 0)}
Итого{formatTokens(grandTotal)} + 100%
+ ); +} + +function ToolsDetail({ view }: { view: StatsView }) { + const toolsByGroup = new Map(); + for (const tool of view.tools) { + const groupTools = toolsByGroup.get(tool.group) ?? []; + groupTools.push(tool); + toolsByGroup.set(tool.group, groupTools); + } + const maxCalls = view.tools[0]?.calls ?? 1; + return ( + <> + {Array.from(toolsByGroup.entries()).map(([group, groupTools]) => ( + +
+ {groupTools.map((tool) => ( + + ))} +
+
+ ))} + + ); +} + +function ReliabilityDetail({ view }: { view: StatsView }) { + const maxLatencyCount = Math.max(...view.latency.map((bucket) => bucket.count), 1); + return ( + <> + +
+ {view.latency.map((bucket, bucketIndex) => ( + + ))} +
+
+ + + + {view.errors.map((errorStat) => ( + + + + + + ))} + {view.errors.length === 0 ? ( + + + + ) : null} + +
{errorStat.type}{errorStat.count}{formatPct(errorStat.sharePct, 0)}
Ошибок за период нет
+
+ +
+ + + +
+
+ + ); +} + +function ActivityDetail({ view }: { view: StatsView }) { + const tokensByWeekday = WEEKDAY_LABELS.map((label, weekdayIndex) => ({ + label, + tokens: view.heatmap + .filter((cell) => cell.weekday === weekdayIndex) + .reduce((runningTotal, cell) => runningTotal + cell.tokens, 0), + })); + const maxWeekdayTokens = Math.max(...tokensByWeekday.map((weekday) => weekday.tokens), 1); + return ( + +
+ {tokensByWeekday.map((weekday, weekdayIndex) => ( + + ))} +
+
+ ); +} + +function DetailStat({ label, value }: { label: string; value: string }) { + return ( +
+
{label}
+
{value}
+
+ ); +} diff --git a/apps/web/src/ru-fork/stats/components/chart.tsx b/apps/web/src/ru-fork/stats/components/chart.tsx new file mode 100644 index 00000000000..53b4e1a87d9 --- /dev/null +++ b/apps/web/src/ru-fork/stats/components/chart.tsx @@ -0,0 +1,248 @@ +"use client"; + +/** + * Chart primitive — the shadcn/basecn `ChartContainer` pattern wrapping Recharts. + * + * Series colors come from `ChartConfig` as `var(--chart-N)` (or any token); the + * container injects them as `--color-` CSS variables so Recharts elements + * reference `var(--color-)`. Because the source values are theme tokens, + * every chart recolors automatically across all themes + dark/light with no + * per-chart color code. Tooltip chrome is our own Tailwind, not Recharts'. + */ + +import * as React from "react"; +import * as RechartsPrimitive from "recharts"; + +import { cn } from "~/lib/utils"; + +type ChartThemeName = "light" | "dark"; +const THEME_NAMES: readonly ChartThemeName[] = ["light", "dark"]; +const THEME_SELECTOR_PREFIX: Record = { light: "", dark: ".dark" }; + +export interface ChartSeriesConfig { + readonly label?: React.ReactNode; + readonly icon?: React.ComponentType; + readonly color?: string; + readonly theme?: Record; +} + +export type ChartConfig = Record; + +interface ChartContextValue { + readonly config: ChartConfig; +} + +const ChartContext = React.createContext(null); + +export function useChart(): ChartContextValue { + const context = React.useContext(ChartContext); + if (!context) { + throw new Error("useChart must be used within a "); + } + return context; +} + +interface ChartContainerProps extends React.ComponentProps<"div"> { + readonly config: ChartConfig; + readonly children: React.ComponentProps["children"]; +} + +function ChartContainer({ id, className, children, config, ...divProps }: ChartContainerProps) { + const generatedId = React.useId(); + const chartId = `chart-${id ?? generatedId.replace(/:/g, "")}`; + const contextValue = React.useMemo(() => ({ config }), [config]); + + return ( + +
+ + {children} +
+
+ ); +} + +function colorForSeries(seriesConfig: ChartSeriesConfig, themeName: ChartThemeName): string | undefined { + return seriesConfig.theme?.[themeName] ?? seriesConfig.color; +} + +function ChartStyle({ chartId, config }: { chartId: string; config: ChartConfig }) { + const colorizedSeries = Object.entries(config).filter( + ([, seriesConfig]) => seriesConfig.color !== undefined || seriesConfig.theme !== undefined, + ); + if (colorizedSeries.length === 0) return null; + + const styleText = THEME_NAMES.map((themeName) => { + const variableLines = colorizedSeries + .map(([seriesKey, seriesConfig]) => { + const color = colorForSeries(seriesConfig, themeName); + return color ? ` --color-${seriesKey}: ${color};` : null; + }) + .filter((line): line is string => line !== null) + .join("\n"); + return `${THEME_SELECTOR_PREFIX[themeName]} [data-chart=${chartId}] {\n${variableLines}\n}`; + }).join("\n"); + + return