Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
3 changes: 3 additions & 0 deletions apps/web/src/components/settings/SettingsSidebarNav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { useCallback, type ComponentType } from "react";
import {
ArchiveIcon,
ArrowLeftIcon,
ChartColumnIcon,
GitBranchIcon,
KeyboardIcon,
Link2Icon,
Expand All @@ -25,6 +26,7 @@ export type SettingsSectionPath =
| "/settings/providers"
| "/settings/source-control"
| "/settings/connections"
| "/settings/analytics"
| "/settings/archived";

export const SETTINGS_NAV_ITEMS: ReadonlyArray<{
Expand All @@ -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 },
];

Expand Down
5 changes: 5 additions & 0 deletions apps/web/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
21 changes: 21 additions & 0 deletions apps/web/src/routeTree.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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',
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -130,6 +139,7 @@ export interface FileRouteTypes {
| '/'
| '/pair'
| '/settings'
| '/settings/analytics'
| '/settings/archived'
| '/settings/connections'
| '/settings/general'
Expand All @@ -142,6 +152,7 @@ export interface FileRouteTypes {
to:
| '/pair'
| '/settings'
| '/settings/analytics'
| '/settings/archived'
| '/settings/connections'
| '/settings/general'
Expand All @@ -156,6 +167,7 @@ export interface FileRouteTypes {
| '/_chat'
| '/pair'
| '/settings'
| '/settings/analytics'
| '/settings/archived'
| '/settings/connections'
| '/settings/general'
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -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
Expand All @@ -286,6 +306,7 @@ interface SettingsRouteChildren {
}

const SettingsRouteChildren: SettingsRouteChildren = {
SettingsAnalyticsRoute: SettingsAnalyticsRoute,
SettingsArchivedRoute: SettingsArchivedRoute,
SettingsConnectionsRoute: SettingsConnectionsRoute,
SettingsGeneralRoute: SettingsGeneralRoute,
Expand Down
7 changes: 7 additions & 0 deletions apps/web/src/routes/settings.analytics.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { createFileRoute } from "@tanstack/react-router";

import { StatsDashboard } from "../ru-fork/stats";

export const Route = createFileRoute("/settings/analytics")({
component: StatsDashboard,
});
68 changes: 68 additions & 0 deletions apps/web/src/ru-fork/stats/components/KpiStrip.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-7">
{tiles.map((tile) => {
const Icon = tile.icon;
return (
<div
key={tile.label}
className="flex flex-col gap-1 rounded-xl border border-border bg-card px-3 py-2.5 shadow-xs/5"
>
<div className="flex items-center gap-1.5">
<Icon className={cn("size-3.5", tile.accent ?? "text-muted-foreground/60")} />
<span className="truncate text-[11px] font-medium uppercase tracking-wide text-muted-foreground/70">
{tile.label}
</span>
</div>
<div className="flex items-baseline gap-1.5">
<span className="font-mono text-xl font-semibold tabular-nums text-foreground">{tile.value}</span>
{tile.delta !== undefined ? <DeltaChip value={tile.delta} invert={tile.deltaInvert ?? false} /> : null}
</div>
{tile.sub ? <span className="truncate text-[11px] text-muted-foreground/60">{tile.sub}</span> : null}
</div>
);
})}
</div>
);
}
80 changes: 80 additions & 0 deletions apps/web/src/ru-fork/stats/components/RefreshControl.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="flex items-center gap-2">
<span className="hidden text-xs text-muted-foreground/70 sm:inline">обновлено {relativeTime}</span>
<Button
size="sm"
variant="outline"
onClick={() => startRefresh()}
disabled={isRefreshing}
aria-label="Обновить статистику"
>
<RefreshCwIcon className={cn(isRefreshing && "animate-spin")} />
<span className="hidden sm:inline">Обновить</span>
</Button>
<FilterSelect
ariaLabel="Интервал обновления"
icon={TimerIcon}
value={String(refreshIntervalMin)}
onChange={(nextValue) => setRefreshIntervalMin(Number(nextValue))}
options={REFRESH_INTERVAL_OPTIONS.map((option) => ({ value: String(option.value), label: option.label }))}
/>
</div>
);
}
Loading
Loading