refactor(repo): переход на pnpm monorepo с shadcn/ui и Fastify+Drizzle

Frontend:
- apps/web (Vite+TS, TanStack Router/Query, shadcn/ui @cfdm/ui base-nova)
- 10 страниц в routes/_auth/, Recharts через shadcn Chart, lucide-react
- формы на RHF + Zod (FormSheet/FormField)
- удалены Tabler, Chart.js, react-router-dom

Backend (параллельный трек):
- apps/api (Fastify 5 + Drizzle + better-sqlite3)
- packages/db: Drizzle-схема и repositories по сущностям
- packages/shared: Zod-контракты
- роуты с валидацией и единым форматом ошибок { error: { code, message } }
- sync/backup — заглушки 501 (billmanager-адаптеры переносятся отдельно)
- legacy Express оставлен как runtime по умолчанию (RUNTIME=express)

Infra:
- Dockerfile multi-stage под pnpm workspaces
- .dockerignore и docker-compose обновлены под monorepo

Rules:
- удалены нерелевантные правила (rust, cloudflare, server/frontend-conventions)
- project-structure.mdc и AGENTS.md переписаны под monorepo
- frontend-shadcn.mdc, shadcn-ui-production.mdc, sqlite.mdc обновлены

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Denozordec
2026-06-26 13:42:05 +07:00
co-authored by Cursor
parent 8408155be6
commit 6fbd1a9113
202 changed files with 16734 additions and 12145 deletions
+14
View File
@@ -0,0 +1,14 @@
{
"mcpServers": {
"codegraph": {
"type": "stdio",
"command": "codegraph",
"args": [
"serve",
"--mcp",
"--path",
"C:\\Users\\shats\\Dev\\cloudflare-domain-manager"
]
}
}
}
+56
View File
@@ -0,0 +1,56 @@
---
description: Concise AI assistant — clean code, token efficiency, codebase alignment
alwaysApply: true
---
# AI Coding Assistant
You work inside a real codebase. Be precise, concise, and aligned with existing patterns.
## Clean Code
- Minimal, readable, maintainable code; simple over clever
- Meaningful names; DRY; small single-responsibility functions
- Follow existing project style and patterns
## Token Efficiency
- Do not explain obvious things
- No step-by-step reasoning unless explicitly asked
- Output only what is necessary: code, brief comments when needed
- No long prose, summaries, or repetition
- If unsure — ask a short clarifying question instead of guessing
## Work With Existing Codebase
- Analyze surrounding code before generating new code
- Reuse existing utilities, helpers, and patterns
- Do not reinvent functionality already in the project
- Respect project architecture
## Documentation Awareness
- Check project docs, README, comments, and types before implementing
- If behavior is unclear: infer from types/tests/examples, or ask
- Prefer documented approaches over assumptions
## Output Format
- Default: only code
- If explanation is required — keep it under 35 lines
- Highlight only important decisions
## Refactoring
- Preserve behavior unless told otherwise
- Improve readability and structure; reduce complexity and duplication
## Debugging
- Identify root cause, not symptoms
- Suggest minimal fix; avoid rewriting large parts unless necessary
## Missing Context
- Ask concise, targeted questions
- Do not hallucinate APIs or project structure
+37
View File
@@ -0,0 +1,37 @@
---
description: Backend API — при изменениях, затрагивающих UI, строго следовать shadcn Components/Blocks
globs: apps/api/**/*,packages/shared/**/*
alwaysApply: false
---
# Backend API + shadcn/ui
Fastify backend: `apps/api/`. Контракты — `@cfdm/shared` (Zod). Frontend — TanStack Query.
## Обязательный порядок
1. **Backend** — route, service, Vitest (`app.inject()`)
2. **Схемы** — `@cfdm/shared` (не дублировать в `apps/web/src/lib/schemas.ts`)
3. **UI** — shadcn MCP ([`shadcn-mcp.mdc`](shadcn-mcp.mdc))
## Запрещено на frontend при доработке API
- Новые raw `<table>` / `<select>` / кастомные badge-цвета
- Дублирование Zod schemas в `apps/web` — только re-export из `@cfdm/shared`
- Самописные формы без `Field` + RHF + Zod
## shadcn-паттерны
| API-данные | UI |
|------------|-----|
| Список | `Table` / `DataTableCard` |
| Создание | `Card` + `FieldGroup` + RHF |
| Статус | `Badge` variants |
| Ошибка | `sonner` `toast.error` |
## Согласованность
- JSON поля — snake_case как в существующем API
- Новый endpoint → `queryOptions` в `apps/web/src/queries/`
MCP backend: [`backend-mcp.mdc`](backend-mcp.mdc) · Fastify: [`backend-fastify.mdc`](backend-fastify.mdc)
+26
View File
@@ -0,0 +1,26 @@
---
description: Drizzle ORM + SQLite — schema, migrations, queries
globs: packages/db/**/*,apps/api/src/services/**/*
alwaysApply: false
---
# Backend Drizzle
MCP Context7 (`drizzle-orm`, `drizzle-kit`) — [`backend-mcp.mdc`](backend-mcp.mdc).
## Schema
- `packages/db/src/schema/` — source of truth
- Migrations: `drizzle-kit generate` / `migrate`
- WAL + `foreign_keys` при открытии SQLite
- Индекс `idx_dns_records_domain_cf_id` на `(domain_id, cf_record_id)`
## Queries
- Batch queries (`inArray`, JOINs) — не N+1 loops
- UNIQUE violations → `CONFLICT` (409)
- Multi-step → `db.transaction()`
## SQLite
См. [`sqlite.mdc`](sqlite.mdc).
+43
View File
@@ -0,0 +1,43 @@
---
description: Fastify API — слои, плагины, контракт ошибок
globs: apps/api/**/*
alwaysApply: false
---
# Backend Fastify
Стек: **Node.js 22**, **Fastify 5**, `@fastify/*` plugins, `@cfdm/shared`, `@cfdm/db`.
MCP — [`backend-mcp.mdc`](backend-mcp.mdc).
## Слои
```
routes/ → services/ → @cfdm/db (repositories)
↘ lib/cf-client.ts
```
- Routes — тонкие Fastify plugins (`fastify-plugin`)
- **Запрещено:** SQL в routes, `fetch` к CF вне `cf-client`
## Плагины (официальные)
`@fastify/jwt`, `@fastify/cors`, `@fastify/sensible`, `@fastify/static`, `@fastify/helmet`, `@fastify/rate-limit`, `@fastify/type-provider-zod`, `fastify-plugin`
## Ошибки
Формат: `{ error: { code, message } }`
Коды: `NOT_FOUND`, `VALIDATION_ERROR`, `UNAUTHORIZED`, `FORBIDDEN`, `CONFLICT`, `CLOUDFLARE_ERROR`, `INTERNAL_ERROR`
## Правила
- Zod schemas только из `@cfdm/shared`
- `db.transaction()` для multi-step writes
- Операции >2s → async job (`sync_jobs` + `p-queue`)
- Env через Zod в `config.ts`; prod fail-fast на dev `JWT_SECRET`
- TypeScript strict; `function` для handlers/services
## API + UI
[`backend-api-ui.mdc`](backend-api-ui.mdc)
+28
View File
@@ -0,0 +1,28 @@
---
description: Обязательный порядок MCP перед backend-кодом (Fastify, Drizzle, Cloudflare API)
globs: apps/api/**/*,packages/db/**/*,packages/shared/**/*
alwaysApply: false
---
# Backend MCP — обязательно
Перед **любой** задачей в `apps/api`, `packages/db`, `packages/shared` — сначала MCP, не training data.
## Порядок
| Задача | MCP |
|--------|-----|
| Fastify plugins, routes, hooks | **Context7** `resolve-library-id` → `query-docs` (`fastify`, `@fastify/jwt`, `@fastify/type-provider-zod`) |
| Drizzle schema, queries, migrations | **Context7** (`drizzle-orm`, `drizzle-kit`, `better-sqlite3`) |
| Cloudflare DNS/Zones API | **`plugin-cloudflare-cloudflare-docs`** `search_cloudflare_documentation` |
| API + UI | `packages/shared` → **shadcn MCP** ([`shadcn-mcp.mdc`](shadcn-mcp.mdc)) |
| E2E / cutover | **cursor-ide-browser** |
| Неизвестный инструмент | **user-mcp-on-demand** `search_tools` |
## Запрещено
- Угадывать API Fastify/Drizzle/CF из памяти
- Самописные аналоги `@fastify/*` (CORS, static, JWT, rate-limit)
- Дублировать Zod schemas вне `@cfdm/shared`
Связанные: [`backend-fastify.mdc`](backend-fastify.mdc), [`backend-drizzle.mdc`](backend-drizzle.mdc), [`backend-testing.mdc`](backend-testing.mdc).
+26
View File
@@ -0,0 +1,26 @@
---
description: Backend Vitest + Fastify inject
globs: apps/api/**/*,packages/db/**/*,packages/shared/**/*
alwaysApply: false
---
# Backend Testing
Vitest + `app.inject()` (встроено в Fastify).
## Требования
- Каждый route plugin → минимум 1 integration test
- Sync/DNS → parity fixtures
- `:memory:` SQLite для unit; file DB для integration
- `beforeEach` — fresh schema migrate
## Паттерн
```ts
const app = await buildApp({ db: testDb })
const res = await app.inject({ method: 'GET', url: '/health' })
expect(res.statusCode).toBe(200)
```
См. [`vitest-best-practices.mdc`](vitest-best-practices.mdc).
+36
View File
@@ -0,0 +1,36 @@
---
description: Conventional commits на русском языке
globs: "**/*"
alwaysApply: false
---
# Commit messages (русский)
Формат: `<type>[optional scope]: <описание>`
## Типы
- `feat` — только новая UX-фича для пользователя
- `fix` — исправление бага
- `chore` — конфиг, зависимости, правила, CI
- `refactor` — рефакторинг без изменения поведения
- `docs` — документация
- `test` — тесты
- `perf` — производительность
## Правила
- Subject в **императиве**, без точки в конце
- Subject и body — **на русском**
- Body (опционально) — что и зачем, не как
- Scope в скобках при необходимости: `feat(domains): добавить фильтр по статусу`
## Примеры
```
fix(frontend): заменить raw table на shadcn Table на странице доменов
feat(certificates): добавить предупреждение об истечении срока
chore(rules): консолидировать правила shadcn/ui для Cursor
```
-23
View File
@@ -1,23 +0,0 @@
---
description: Паттерны для React, api, utils
globs: src/**/*.{js,jsx}
alwaysApply: false
---
# Frontend conventions
## React
- Функциональные компоненты
- Страницы в `pages/`, общие компоненты в `components/`
- Данные загружаются в App.jsx через `loadDataSet()`, передаются в страницы как `db` и `actions`
## API
- `src/lib/api.js` — fetchApi, loadDataSet, createRecord, updateRecord, deleteRecord
- Коллекции: vps, providers, providerAccounts, payments, balanceLedger, settings
- Дополнительно: syncAccount, fetchAccountBalance, testApiConnection
## Utils
- `src/lib/utils.js` — форматирование (formatCurrency), конвертация валют, лейблы (paymentTypeLabel), CSV
+103
View File
@@ -0,0 +1,103 @@
---
description: shadcn/ui Monorepo — структура apps/web + packages/ui, CLI workflow, импорты @cfdm/ui
globs: apps/web/**/*,packages/ui/**/*
alwaysApply: false
---
# Frontend Monorepo (shadcn/ui)
**Обязательный стандарт структуры** — [Monorepo docs](https://ui.shadcn.com/docs/monorepo).
## Layout
```
apps/web/ # Vite SPA (routes, queries, domain components)
apps/api/ # Fastify API + static SPA in prod
packages/ui/ # @cfdm/ui — shadcn primitives
packages/shared/ # @cfdm/shared — Zod schemas, parse-fqdn
packages/db/ # @cfdm/db — Drizzle schema, repositories
```
## Два components.json
| Файл | Назначение |
|------|------------|
| [`apps/web/components.json`](apps/web/components.json) | App aliases; `ui` → `@cfdm/ui/components` |
| [`packages/ui/components.json`](packages/ui/components.json) | UI package aliases |
**Синхронизировать:** `style`, `iconLibrary`, `baseColor` в обоих файлах.
## CLI — только из apps/web
```bash
cd apps/web
pnpm dlx shadcn@latest docs button
pnpm dlx shadcn@latest add button
pnpm dlx shadcn@latest add sidebar-07
pnpm dlx shadcn@latest add login-03
pnpm dlx shadcn@latest apply b2fA --only theme -y
```
Перед обновлением существующих компонентов:
```bash
pnpm dlx shadcn@latest add button --dry-run
pnpm dlx shadcn@latest add button --diff
pnpm dlx shadcn@latest info --json
```
## Куда CLI кладёт файлы
| Команда | Куда |
|---------|------|
| `add button` | `packages/ui/src/components/button.tsx` |
| `add login-03` | примитивы → `packages/ui`, block → `apps/web/src/components/` |
## Импорты
```tsx
import { Button } from '@cfdm/ui/components/button'
import { cn } from '@cfdm/ui/lib/utils'
import { useIsMobile } from '@cfdm/ui/hooks/use-mobile'
import '@cfdm/ui/globals.css' // только в main.tsx
```
| Запрещено | Разрешено |
|-----------|-----------|
| `@/components/ui/*` | `@cfdm/ui/components/*` |
| `apps/web/src/components/ui/` | `packages/ui/src/components/` |
| Ручное редактирование `globals.css` | `pnpm dlx shadcn@latest apply b2fA --only theme` |
Community registry: переписывать импорты на `@cfdm/ui/...`.
## Разделение ответственности
- **`packages/ui`** — только output `shadcn add` (примитивы, registry hooks, `cn`)
- **`apps/web/src/components`** — blocks, layout, domain (`login-form`, `app-shell`, `PageHeader`)
## Стили (Tailwind v4 monorepo)
`packages/ui/src/styles/globals.css` — единственный CSS-файл. **Обязательно** `@source` для обоих workspace:
```css
@source "../"; /* packages/ui/src */
@source "../../../apps/web/src"; /* apps/web/src */
```
Без `@source` Tailwind не видит классы из `packages/ui` и `apps/web` — UI ломается (нет sidebar, card, и т.д.).
```bash
pnpm install
pnpm --filter web dev
pnpm --filter web build
```
## Чеклист
- [ ] Два `components.json` согласованы
- [ ] `shadcn add` из `apps/web`
- [ ] UI-импорты через `@cfdm/ui/components/*`
- [ ] Нет `apps/web/src/components/ui/`
- [ ] `pnpm --filter web build` без ошибок
См. также: [`frontend-shadcn.mdc`](frontend-shadcn.mdc), [`vite-tanstack-frontend.mdc`](vite-tanstack-frontend.mdc).
+197
View File
@@ -0,0 +1,197 @@
---
description: Frontend — ТОЛЬКО shadcn/ui docs (Components, Blocks, Installation); best practices, CLI-first
globs: apps/web/**/*,packages/ui/**/*
alwaysApply: false
---
# Frontend — shadcn/ui (обязательно)
**Источник истины — MCP shadcn + официальная документация.** Не выдумывать UI, не писать кастомный CSS, не обходить MCP и CLI.
Monorepo layout — [`frontend-monorepo.mdc`](frontend-monorepo.mdc). MCP workflow — [`shadcn-mcp.mdc`](shadcn-mcp.mdc). UI patterns — [`frontend-ui-patterns.mdc`](frontend-ui-patterns.mdc).
| Документ | URL |
|----------|-----|
| **Components (primary catalog)** | https://ui.shadcn.com/docs/components |
| **Blocks** | https://ui.shadcn.com/blocks |
| **Installation** | https://ui.shadcn.com/docs/installation |
| **Monorepo** | https://ui.shadcn.com/docs/monorepo |
| **Theming** | https://ui.shadcn.com/docs/theming |
| **Dark Mode** | https://ui.shadcn.com/docs/dark-mode |
| **Forms (RHF)** | https://ui.shadcn.com/docs/forms/react-hook-form |
## Шаг 0 — перед любым UI-кодом
0. **Codegraph** `codegraph_explore` — найти существующие реализации
1. **MCP `plugin-shadcn-shadcn`:** `search_items_in_registries` → `get_item_examples_from_registries` → `get_add_command_for_items` ([`shadcn-mcp.mdc`](shadcn-mcp.mdc))
2. Открыть **Components** или **Blocks** — найти готовое решение
3. `cd apps/web && pnpm dlx shadcn@latest docs <component>` — сверить API с [Components](https://ui.shadcn.com/docs/components)
4. Сверить MCP examples ↔ docs API — только потом писать код
5. `cd apps/web && pnpm dlx shadcn@latest search "@shadcn/<query>"` — если MCP не дал результат
**Новая страница** → сначала [Blocks](https://ui.shadcn.com/blocks), потом `pnpm dlx shadcn@latest add <block-id>`.
## Docs workflow
1. MCP `plugin-shadcn-shadcn` — search → examples → add command
2. `pnpm dlx shadcn@latest docs <component>` — fetch URLs, сверить API
3. Skill `.agents/skills/shadcn/SKILL.md` — critical rules
4. Context7 — только TanStack / Recharts
## Shared components (обязательно)
| Component | Файл |
|-----------|------|
| `PageShell` | `page-shell.tsx` |
| `PageHeader` | `page-header.tsx` |
| `EmptyState` | `empty-state.tsx` |
| `QueryState` | `query-state.tsx` |
| `ConfirmDialog` | `confirm-dialog.tsx` |
| `DataTableCard` | `data-table-card.tsx` |
| `SectionCards` | `section-cards.tsx` |
| `StatusBadge` | `status-badge.tsx` |
| `FormSheet` | `form-sheet.tsx` |
| `FormField` | `form-field.tsx` |
| `TableCard` | `table-card.tsx` |
| `LoadingButton` | `loading-button.tsx` |
| `SectionCardsSkeleton` | `section-cards-skeleton.tsx` |
| `TableSkeleton` | `table-skeleton.tsx` |
**Overlay:** Sheet — forms; AlertDialog — destructive confirm.
## Шаг 1 — CLI (обязательно)
```bash
cd apps/web
pnpm dlx shadcn@latest add table select badge card field input button ...
pnpm dlx shadcn@latest add sidebar-07 # layout
pnpm dlx shadcn@latest add dashboard-01 # dashboard
pnpm dlx shadcn@latest add login-03 # auth
pnpm dlx shadcn@latest apply b2fA --only theme -y # тема — ТОЛЬКО так
```
- Копипаст с сайта **без** CLI — запрещено
- `packages/ui/src/components/*` — только registry; domain-логика → `apps/web/src/components/<name>.tsx`
## Шаг 2 — композиция (best practices)
### Приоритет
1. Установленный `@cfdm/ui/components/*`
2. Block из registry (адаптация под TanStack Router)
3. Shared проекта: `PageShell`, `PageHeader`, `EmptyState`, `QueryState`, `ConfirmDialog`, `DataTableCard`, `SectionCards`, `StatusBadge`
4. Domain-обёртка — последний уровень кастомизации
### Запрещено в apps/web
| ❌ | ✅ |
|----|---|
| `<table>`, `<select>`, `<hr>` | `Table`, `Select`, `Separator` из [Components](https://ui.shadcn.com/docs/components) |
| `bg-emerald-*`, `text-blue-500`, hex в className | `bg-primary`, `text-muted-foreground`, `Badge variant` |
| Ручной `globals.css`, `.css` модули | CLI `apply b2fA --only theme` |
| `space-y-*` / `space-x-*` | `flex` + `gap-*` |
| `w-10 h-10` | `size-10` |
| `className` для цветов Button/Badge | `variant`, `size` |
| `useState` для полей формы | `FieldGroup` + RHF + Zod |
| Styled `<Link>` | `Button variant="link"` + `render={<Link />}` |
| `inline style={{}}` в routes | layout Tailwind |
| `animate-pulse` div | `Skeleton` |
| кастомный toast | `sonner` → `toast()` |
| `@/components/ui/*` | `@cfdm/ui/components/*` |
### Устаревшие библиотеки (миграция с Tabler)
| ❌ удалить | ✅ заменить на |
|-----------|----------------|
| `@tabler/core` (CSS-фреймворк) | Tailwind v4 + shadcn tokens |
| `import '@tabler/core/dist/css/tabler.min.css'` в `main.tsx` | `import '@cfdm/ui/globals.css'` |
| `@tabler/icons-react` (`Icon*`) | `lucide-react` (`<PlusIcon />` и т.п.) |
| `chart.js` | `recharts` через shadcn `Chart`/`ChartContainer` |
| `react-router-dom` (`<BrowserRouter>`, `<Routes>`, `useNavigate`) | TanStack Router (`createFileRoute`, `<Link>`, `useNavigate`) |
| Bootstrap-классы Tabler (`page`, `navbar-vertical`, `nav-link`, `container-tight`, `spinner-border`, `d-lg-none`) | shadcn `AppShell` (sidebar-07), `Button`, `Skeleton` |
| Prop-drilling `db` + `actions` из `App.jsx` | `useQuery`/`useMutation` + key factories в `queries/` |
| `useState` + `loadDataSet()` в корне | `QueryClient` + route loaders (`ensureQueryData`) |
### Формы
По https://ui.shadcn.com/docs/forms/react-hook-form:
```tsx
<FieldGroup>
<Field data-invalid={!!errors.name}>
<FieldLabel htmlFor="name">Имя</FieldLabel>
<Input id="name" aria-invalid={!!errors.name} {...register('name')} />
</Field>
</FieldGroup>
```
### Card
`CardHeader` / `CardTitle` / `CardDescription` / `CardContent` / `CardFooter` — полная композиция из docs.
### Таблицы
`Table`, `TableHeader`, `TableBody`, `TableRow`, `TableHead`, `TableCell` — из docs.
Сложная таблица → [Data Table](https://ui.shadcn.com/docs/components/data-table) + block `dashboard-01`.
### Графики
`Chart` + `ChartContainer` + `chartConfig` с `var(--chart-1)` — не raw recharts без обёртки.
### Иконки в Button
```tsx
<Button>
<PlusIcon data-icon="inline-start" />
Создать
</Button>
```
Без `size-4` на иконке внутри shadcn-компонента.
## Стек (не shadcn, но обязателен)
TanStack Router + Query — [`vite-tanstack-frontend.mdc`](vite-tanstack-frontend.mdc).
- Preset: **base-nova** + **neutral** — [`apps/web/components.json`](apps/web/components.json), [`packages/ui/components.json`](packages/ui/components.json)
- `@base-ui/react` → `render` prop (не Radix `asChild`)
- **Не Next.js** — нет Server Components, `'use client'`
## Эталоны проекта
| Зона | Файл | Block |
|------|------|-------|
| Shell | `apps/web/src/components/layout/app-shell.tsx` | [sidebar-07](https://ui.shadcn.com/blocks) |
| Login | `apps/web/src/routes/login.tsx` | [login-03](https://ui.shadcn.com/blocks) |
| Dashboard | `apps/web/src/routes/_auth/index.tsx` | [dashboard-01](https://ui.shadcn.com/blocks) |
| CRUD | `routes/_auth/services.tsx`, `domains/index.tsx` | Card + Field + Table |
## Структура файлов
```
apps/web/src/
components/ ← domain + layout + shared (blocks)
routes/ ← страницы (композиция @cfdm/ui, без raw HTML)
queries/ ← queryOptions (не inline в routes)
lib/schemas.ts ← Zod для форм
packages/ui/src/
components/ ← только CLI (не трогать под кейс)
hooks/ ← registry hooks (use-mobile)
lib/utils.ts ← cn()
styles/globals.css ← только output shadcn CLI
```
## Чеклист перед завершением задачи
- [ ] MCP shadcn: search + examples (+ add command при новых примитивах)
- [ ] Решение есть в https://ui.shadcn.com/docs/components или /blocks
- [ ] Компоненты добавлены через `pnpm dlx shadcn@latest add` из `apps/web`
- [ ] Нет кастомного CSS и raw HTML-примитивов
- [ ] Semantic tokens, `variant`/`size` вместо переопределения className
- [ ] UI-импорты через `@cfdm/ui/components/*`
- [ ] `pnpm --filter web build` без ошибок
## Язык
Ответы пользователю — русский. Commits — [`commit-messages-ru.mdc`](commit-messages-ru.mdc).
+111
View File
@@ -0,0 +1,111 @@
---
description: Единые UI-паттерны web — shared components, docs workflow, матрица стандартизации
globs: apps/web/**/*
alwaysApply: false
---
# Frontend UI Patterns
См. также: [`frontend-shadcn.mdc`](frontend-shadcn.mdc), [`shadcn-mcp.mdc`](shadcn-mcp.mdc), [Components](https://ui.shadcn.com/docs/components), [Blocks](https://ui.shadcn.com/blocks).
## Docs workflow (обязательно)
0. Codegraph `codegraph_explore` — найти существующие shared/domain-компоненты
1. Skill [`.agents/skills/shadcn/SKILL.md`](../.agents/skills/shadcn/SKILL.md) — component selection
2. MCP `plugin-shadcn-shadcn` — search → examples → add command
3. CLI: `cd apps/web && pnpm dlx shadcn@latest docs <component>` → сверить API с [Components](https://ui.shadcn.com/docs/components)
4. Код по examples + docs API (только после совпадения MCP ↔ docs)
5. Context7 — **только** TanStack Router/Query, Recharts (не shadcn primitives)
6. MCP `get_audit_checklist` — перед merge
7. Codegraph `codegraph_status` — Pending sync пустой
## Иерархия компонентов
```
@cfdm/ui/components/* ← только CLI (packages/ui)
apps/web/src/components/ ← shared + domain + layout
page-shell.tsx ← обёртка страницы
page-header.tsx
empty-state.tsx
query-state.tsx
confirm-dialog.tsx
data-table-card.tsx
section-cards.tsx
status-badge.tsx
form-sheet.tsx ← Sheet + RHF FormProvider
form-field.tsx ← Field + Controller + aria-invalid
table-card.tsx ← Card + Table wrapper
loading-button.tsx ← Button + Spinner + label swap
section-cards-skeleton.tsx
table-skeleton.tsx
layout/ ← app-shell, site-header
domain-* ← бизнес-компоненты
```
## Матрица стандартизации
| Элемент | Shared | Primitive |
|---------|--------|-----------|
| Page wrapper | `PageShell` | — |
| Page title | `PageHeader` | — |
| Stat metrics | `SectionCards` | `Card` |
| Data list | `DataTableCard` | `Table`, `InputGroup` |
| Empty | `EmptyState` | `Empty` |
| Loading / Error | `QueryState` | `Skeleton`, `Alert` |
| Status | `StatusBadge` | `Badge` |
| Create/Edit | `FormSheet` + `*-edit-sheet.tsx` | `Sheet`, `Field` |
| Form field | `FormField` | `Field`, `Input`, `Select` |
| Submit button | `LoadingButton` | `Button`, `Spinner` |
| Table wrapper | `TableCard` | `Table`, `Card` |
| Delete confirm | `ConfirmDialog` | `AlertDialog` |
| List row | — | `Item variant="outline" size="sm"` |
| Nav | `AppSidebar` | `Sidebar` |
| Breadcrumbs | `SiteHeader` | `Breadcrumb` |
| Dates | `lib/format.ts` | — |
## Overlay selection
| Сценарий | Компонент |
|----------|-----------|
| Create/edit форма | `Sheet` |
| Destructive confirm | `AlertDialog` via `ConfirmDialog` |
| Modal preview | `Dialog` |
## Block registry
| Зона | Block |
|------|-------|
| Shell | sidebar-07 |
| Dashboard | dashboard-01 |
| Login | login-03 |
## Spacing
```
PageShell: gap-4 md:gap-6, px-4 lg:px-6 py-4 md:py-6
Card grid: gap-4
FieldGroup: gap-4
Item list: gap-2
Toolbar: gap-2
```
**Запрещено:** `space-y-*`, raw colors (`bg-emerald-*`), custom empty divs, page-level Spinner.
## UX/UI 2026 (состояния данных)
Каждый блок: **default, hover, focus, disabled, empty, loading, error**.
- **Loading** — `Skeleton` с размерами финального контента (`QueryState skeleton={…}`), не Spinner на странице
- **Empty** — `EmptyState` с CTA (кнопка создания)
- **Zero-results** — отдельный empty с «Сбросить фильтр» (не «Создать»)
- **Error** — `QueryState` + `onRetry` + иконка + текст
- **Overflow** — `truncate`, `max-w-*`, `Tooltip`; `tabular-nums` для чисел
- **Density** — таблицы `h-10 text-sm`; max 1 primary CTA на экран
- **A11y** — `aria-invalid` на полях, `aria-label`/`sr-only` на icon-only кнопках, цвет не единственный сигнал статуса
## Button hierarchy (max 1 primary per screen)
1. `default` — главный CTA
2. `outline` — вторичные действия
3. `ghost` / `link` — навигация, cancel
4. `destructive` — только с `ConfirmDialog`
+113
View File
@@ -0,0 +1,113 @@
---
description: Gitflow Workflow Rules. These rules should be applied when performing git operations.
globs: ["**/*"]
alwaysApply: false
---
# Gitflow Workflow Rules
## Main Branches
### main (or master)
- Contains production-ready code
- Never commit directly to main
- Only accepts merges from:
- hotfix/* branches
- release/* branches
- Must be tagged with version number after each merge
### develop
- Main development branch
- Contains latest delivered development changes
- Source branch for feature branches
- Never commit directly to develop
## Supporting Branches
### feature/*
- Branch from: develop
- Merge back into: develop
- Naming convention: feature/[issue-id]-descriptive-name
- Example: feature/123-user-authentication
- Must be up-to-date with develop before creating PR
- Delete after merge
### release/*
- Branch from: develop
- Merge back into:
- main
- develop
- Naming convention: release/vX.Y.Z
- Example: release/v1.2.0
- Only bug fixes, documentation, and release-oriented tasks
- No new features
- Delete after merge
### hotfix/*
- Branch from: main
- Merge back into:
- main
- develop
- Naming convention: hotfix/vX.Y.Z
- Example: hotfix/v1.2.1
- Only for urgent production fixes
- Delete after merge
## Commit Messages
- Format: `type(scope): description`
- Types:
- feat: New feature
- fix: Bug fix
- docs: Documentation changes
- style: Formatting, missing semicolons, etc.
- refactor: Code refactoring
- test: Adding tests
- chore: Maintenance tasks
## Version Control
### Semantic Versioning
- MAJOR version for incompatible API changes
- MINOR version for backwards-compatible functionality
- PATCH version for backwards-compatible bug fixes
## Pull Request Rules
1. All changes must go through Pull Requests
2. Required approvals: minimum 1
3. CI checks must pass
4. No direct commits to protected branches (main, develop)
5. Branch must be up to date before merging
6. Delete branch after merge
## Branch Protection Rules
### main & develop
- Require pull request reviews
- Require status checks to pass
- Require branches to be up to date
- Include administrators in restrictions
- No force pushes
- No deletions
## Release Process
1. Create release branch from develop
2. Bump version numbers
3. Fix any release-specific issues
4. Create PR to main
5. After merge to main:
- Tag release
- Merge back to develop
- Delete release branch
## Hotfix Process
1. Create hotfix branch from main
2. Fix the issue
3. Bump patch version
4. Create PR to main
5. After merge to main:
- Tag release
- Merge back to develop
- Delete hotfix branch
+67 -12
View File
@@ -1,26 +1,81 @@
--- ---
description: Структура проекта vps-tracker и соглашения по именованию description: Структура vps-tracker — pnpm monorepo (apps/web, apps/api, packages/ui, packages/shared, packages/db)
alwaysApply: true alwaysApply: true
--- ---
# Структура проекта vps-tracker # Структура проекта vps-tracker
## Папки pnpm workspaces monorepo. Frontend — shadcn/ui + TanStack Router/Query + TS. Backend — Fastify + Drizzle + better-sqlite3 + TS.
- `server/` — Express backend, SQLite (sql.js) ## Layout
- `src/` — React frontend (Vite)
- `server/adapters/` — один адаптер на провайдера API (billmanager) ```
- `server/routes/` — Express роутеры по сущностям vps-tracker/
- `server/db/` — схема, миграции, seed ├── apps/
│ ├── web/ # Vite SPA (TSX) — TanStack Router + Query, shadcn/ui
│ └── api/ # Fastify 5 API (TS) — @fastify/* + Drizzle
├── packages/
│ ├── ui/ # @cfdm/ui — shadcn primitives (output `shadcn add`)
│ ├── shared/ # @cfdm/shared — Zod-схемы контрактов, общие типы
│ └── db/ # @cfdm/db — Drizzle schema, repositories, миграции
├── data/ # SQLite база (том Docker, gitignored)
├── pnpm-workspace.yaml
├── package.json
└── tsconfig.base.json
```
## apps/web
```
apps/web/
├── components.json # ui alias → @cfdm/ui/components
├── vite.config.ts # React + TanStack Router plugin, proxy /api → apps/api
├── tsconfig.json
└── src/
├── main.tsx # QueryClientProvider, createRouter, import '@cfdm/ui/globals.css'
├── routes/ # file-based routes (__root.tsx, _auth/...)
├── queries/ # queryOptions + key factories по сущностям
├── components/ # shared + layout + domain (blocks)
└── lib/ # api-client, queryClient, router, schemas
```
## apps/api
```
apps/api/
└── src/
├── index.ts # buildApp()
├── config.ts # env через Zod
├── routes/ # тонкие Fastify plugins
├── services/ # бизнес-логика + адаптеры (billmanager/*)
└── plugins/ # @fastify/* registration
```
## packages/db
```
packages/db/src/
├── schema/ # Drizzle tables по сущностям
├── repositories/ # typed queries (inArray, JOIN, transaction)
└── migrations/ # drizzle-kit generate/migrate
```
## Именование ## Именование
- Файлы: kebab-case (provider-accounts.js, row-mappers.js) - Файлы: kebab-case (`provider-accounts.ts`, `row-mappers.ts`)
- Роуты: `/api/vps`, `/api/provider-accounts`, `/api/sync/:accountId` - Компоненты: PascalCase (`PageHeader.tsx`)
- Роуты API: `/api/vps`, `/api/provider-accounts`, `/api/sync/:accountId`
- ID записей: `vps-bm-{accountId}-{externalId}`, `pay-bm-{accountId}-{externalId}` - ID записей: `vps-bm-{accountId}-{externalId}`, `pay-bm-{accountId}-{externalId}`
## Barrel exports ## Barrel exports
Модули с несколькими файлами экспортируют через `index.js`: - `packages/ui` — `@cfdm/ui/components/*`, `@cfdm/ui/lib/utils`, `@cfdm/ui/hooks/*`, `@cfdm/ui/globals.css`
- `server/adapters/billmanager/index.js` — testConnection, syncFromBillmanager, fetchDashboardInfo - `packages/shared` — `@cfdm/shared/contracts/*` (Zod), `@cfdm/shared/types/*`
- `server/db/index.js` — initDb, getDb, saveDb - `packages/db` — `@cfdm/db/schema`, `@cfdm/db/repositories/*`
- `apps/api/src/services/billmanager/index.ts` — `testConnection`, `syncFromBillmanager`, `fetchDashboardInfo`
## Скоупы правил
- Frontend (`apps/web`, `packages/ui`) — [`frontend-monorepo.mdc`](frontend-monorepo.mdc), [`frontend-shadcn.mdc`](frontend-shadcn.mdc), [`frontend-ui-patterns.mdc`](frontend-ui-patterns.mdc), [`vite-tanstack-frontend.mdc`](vite-tanstack-frontend.mdc), [`shadcn-mcp.mdc`](shadcn-mcp.mdc), [`shadcn-ui-production.mdc`](shadcn-ui-production.mdc)
- Backend (`apps/api`, `packages/db`, `packages/shared`) — [`backend-fastify.mdc`](backend-fastify.mdc), [`backend-drizzle.mdc`](backend-drizzle.mdc), [`backend-mcp.mdc`](backend-mcp.mdc), [`backend-testing.mdc`](backend-testing.mdc), [`backend-api-ui.mdc`](backend-api-ui.mdc), [`sqlite.mdc`](sqlite.mdc)
- API + UI-связка — [`backend-api-ui.mdc`](backend-api-ui.mdc)
-25
View File
@@ -1,25 +0,0 @@
---
description: Паттерны для Express, db, adapters
globs: server/**/*.js
alwaysApply: false
---
# Server conventions
## Express
- Роутеры в `routes/`, подключаются в `index.js` через `app.use('/api/...', router)`
- Ошибки: `res.status(500).json({ error: err.message })`
- 404: `res.status(404).json({ error: 'Not found' })`
## Database
- Доступ через `getDb()` — обёртка с `prepare().all()`, `prepare().get()`, `run()`
- После `run()` вызывается `saveDb()` автоматически
- Миграции в `db/migrations.js`, добавляют колонки через ALTER TABLE
## Adapters
- Один провайдер = папка в `adapters/` (billmanager)
- Разделение: client (HTTP), parsers, mappers, operations, sync
- Публичный API через `index.js`
+49
View File
@@ -0,0 +1,49 @@
---
description: ВСЕГДА использовать MCP-плагин shadcn UI перед любым UI-кодом
alwaysApply: true
---
# shadcn MCP — обязательно
Перед **любой** задачей с UI (новый экран, компонент, стили, рефакторинг внешнего вида) — **сначала MCP** `plugin-shadcn-shadcn`, не память и не веб-поиск.
CLI и docs — **после** MCP, по команде из `get_add_command_for_items`.
## Порядок (строго)
0. **Codegraph** `codegraph_explore` — найти существующие реализации и shared-обёртки (один вызов перед правками)
1. **`get_project_registries`** — какие registry доступны в проекте
2. **`search_items_in_registries`** — компонент, block, example (`query`: `"card"`, `"tabs demo"`, `"dashboard"`, `"scroll-area"`)
3. **`get_item_examples_from_registries`** — полный код примера перед написанием JSX
4. **`get_add_command_for_items`** — точная CLI-команда `pnpm dlx shadcn@latest add ...`
5. Выполнить add из `apps/web` (см. [`frontend-monorepo.mdc`](frontend-monorepo.mdc))
6. **CLI docs (обязательно):** `cd apps/web && pnpm dlx shadcn@latest docs <component>` — сверить API/props с [ui.shadcn.com/docs/components](https://ui.shadcn.com/docs/components)
7. Сверить examples из MCP с API из docs CLI — реализовать только после совпадения
8. Адаптировать пример под TanStack Router / Query → `apps/web/src/`
9. **Context7** — только TanStack / Recharts / не-shadcn (не заменяет шаги 1–8 для примитивов)
10. **`get_audit_checklist`** — перед merge PR
11. **Codegraph** `codegraph_status` — Pending sync пустой после правок
## Когда вызывать MCP
| Задача | MCP |
|--------|-----|
| Новая страница / layout | `search` → `types: ["block"]` → examples → add block |
| Нет примитива в `@cfdm/ui` | `search` → `get_add_command_for_items` → add |
| Сомнение в API/props | `get_item_examples_from_registries` |
| Ревью UI перед сдачей | `get_audit_checklist` |
## Запрещено
- Писать UI по памяти, не проверив MCP
- Копипаст с ui.shadcn.com без examples/add из MCP
- Самописные примитивы, если есть item в registry
- Пропускать MCP «потому что компонент простой»
## Сервер и инструменты
- **MCP server:** `plugin-shadcn-shadcn`
- **Инструменты:** `get_project_registries`, `search_items_in_registries`, `get_item_examples_from_registries`, `get_add_command_for_items`, `view_items_in_registries`, `list_items_in_registries`, `get_audit_checklist`
- Перед вызовом — прочитать schema в `mcps/plugin-shadcn-shadcn/tools/`
Связанные правила: [`shadcn-ui-production.mdc`](shadcn-ui-production.mdc), [`frontend-shadcn.mdc`](frontend-shadcn.mdc).
+41
View File
@@ -0,0 +1,41 @@
---
description: shadcn/ui — глобальные UI-принципы проекта; frontend см. frontend-shadcn.mdc
alwaysApply: true
---
# shadcn/ui — правила проекта
UI строится **исключительно** по [shadcn/ui](https://ui.shadcn.com/docs/installation): [Components](https://ui.shadcn.com/docs/components), [Blocks](https://ui.shadcn.com/blocks), [Monorepo](https://ui.shadcn.com/docs/monorepo).
**Первый шаг любой UI-задачи — MCP `plugin-shadcn-shadcn`** (см. [`shadcn-mcp.mdc`](shadcn-mcp.mdc)): search → examples → add command → CLI.
## Разработка frontend
**Все правила frontend** — в [`frontend-shadcn.mdc`](frontend-shadcn.mdc), [`frontend-ui-patterns.mdc`](frontend-ui-patterns.mdc) и [`frontend-monorepo.mdc`](frontend-monorepo.mdc) (globs: `apps/web/**`, `packages/ui/**`).
Кратко: docs → CLI из `apps/web` → Block → композиция → `pnpm --filter web build`. Кастомный CSS и самописные примитивы **запрещены**.
## Стек
- Monorepo: `apps/web` + `packages/ui` (`@cfdm/ui`), pnpm workspaces
- Vite + TanStack Router/Query + shadcn **base-nova**
- Конфиг: [`apps/web/components.json`](apps/web/components.json), [`packages/ui/components.json`](packages/ui/components.json)
- Тема: `pnpm dlx shadcn@latest apply b2fA --only theme -y` — единственный способ менять `packages/ui/src/styles/globals.css`
## Backend → UI
При правках API с экранами: [`backend-api-ui.mdc`](backend-api-ui.mdc). Backend: `apps/api` (Fastify + Drizzle).
## Устаревший стек (запрещён в apps/web)
Проект мигрирует с Tabler-стека на shadcn/ui. **Не использовать**:
- `@tabler/core` и `@tabler/core/dist/css/tabler.min.css` — заменить на `@cfdm/ui/globals.css`
- `@tabler/icons-react` — иконки только `lucide-react`
- `chart.js` — графики только `recharts` через shadcn `Chart`/`ChartContainer`
- `react-router-dom` — роутинг только TanStack Router (`createFileRoute`, file-based routes)
- Bootstrap/Tabler utility-классы (`page`, `navbar-vertical`, `nav-link`, `container-tight`, `spinner-border`, `d-lg-none`, `page-wrapper`) — layout через Tailwind + shadcn blocks (sidebar-07)
## Язык
Русский. Commits: [`commit-messages-ru.mdc`](commit-messages-ru.mdc).
+188
View File
@@ -0,0 +1,188 @@
---
description: Definitive guidelines for writing robust, performant, and secure SQLite code. Focuses on schema design, query optimization, and transaction management.
globs: **/*
---
# sqlite Best Practices
> В проекте используется **better-sqlite3** через Drizzle (`packages/db`). WASM-`sql.js` выводится из эксплуатации. См. [`backend-drizzle.mdc`](backend-drizzle.mdc).
SQLite is the go-to embedded SQL engine for local, reliable storage. Adhere to these rules to ensure your SQLite code is maintainable, performant, and secure.
## 1. Data Modeling & Schema Design
Design your schema for integrity and performance from day one.
* **Primary Keys**: Always use `INTEGER PRIMARY KEY AUTOINCREMENT` for ID columns. This optimizes `rowid` lookups and simplifies ID generation.
* ❌ BAD:
```sql
CREATE TABLE users (
id TEXT PRIMARY KEY, -- Manual UUIDs or similar
name TEXT NOT NULL
);
```
* ✅ GOOD:
```sql
CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL
);
```
* **Data Types & Constraints**: Declare appropriate data types and enforce integrity with `NOT NULL`, `UNIQUE`, and `FOREIGN KEY` constraints.
* ❌ BAD:
```sql
CREATE TABLE products (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT, -- Allows NULL, no uniqueness
price REAL
);
```
* ✅ GOOD:
```sql
CREATE TABLE products (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
price REAL NOT NULL,
stock INTEGER DEFAULT 0,
category_id INTEGER,
FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE SET NULL
);
```
* **Naming Conventions**: Use `lower_case_snake_case` for all table, column, and index names. Avoid SQLite keywords as identifiers.
* ❌ BAD: `CREATE TABLE My_Users ( UserId INTEGER PRIMARY KEY );`
* ✅ GOOD: `CREATE TABLE my_users ( user_id INTEGER PRIMARY KEY );`
## 2. Performance Considerations
Optimize for speed by minimizing I/O and leveraging the SQLite engine.
* **Enable WAL Mode**: Always enable Write-Ahead Logging for better concurrency and write performance.
* ❌ BAD: Default journal mode (`DELETE`).
* ✅ GOOD (at database open or once):
```sql
PRAGMA journal_mode = WAL;
```
* **Relax Synchronous Mode**: When using WAL, set `synchronous` to `NORMAL` for faster commits, accepting minimal risk of data loss on power failure (not app crash).
* ❌ BAD: Default `synchronous = FULL`.
* ✅ GOOD (at database open or once):
```sql
PRAGMA synchronous = NORMAL;
```
* **Indexes**: Create indexes on columns frequently used in `WHERE`, `ORDER BY`, `GROUP BY`, or `JOIN` clauses. Avoid over-indexing.
* ❌ BAD:
```sql
SELECT * FROM users WHERE email = 'test@example.com'; -- No index on email
```
* ✅ GOOD:
```sql
CREATE INDEX idx_users_email ON users(email);
SELECT id, name FROM users WHERE email = 'test@example.com';
```
* **Multi-column Indexes**: For queries filtering/sorting on multiple columns, create a multi-column index matching the query order.
```sql
CREATE INDEX idx_products_category_price ON products(category_id, price);
SELECT * FROM products WHERE category_id = 1 ORDER BY price DESC;
```
* **Query Optimization**: Select only the columns you need. Push filtering, sorting, and aggregation into SQL.
* ❌ BAD:
```sql
SELECT * FROM products; -- Fetch all columns
-- Then filter/sort in application code
```
* ✅ GOOD:
```sql
SELECT id, name, price FROM products WHERE stock > 0 ORDER BY price ASC LIMIT 10;
```
## 3. Transactions & Concurrency
Ensure data consistency and improve write performance with explicit transactions.
* **Wrap Writes in Transactions**: Group multiple `INSERT`, `UPDATE`, `DELETE` operations within a single transaction. This significantly reduces disk I/O.
* ❌ BAD:
```sql
INSERT INTO logs (action) VALUES ('User created');
INSERT INTO users (name) VALUES ('New User');
INSERT INTO logs (action) VALUES ('User name updated');
UPDATE users SET name = 'Updated User' WHERE id = 1;
```
* ✅ GOOD:
```sql
BEGIN;
INSERT INTO logs (action) VALUES ('User created');
INSERT INTO users (name) VALUES ('New User');
INSERT INTO logs (action) VALUES ('User name updated');
UPDATE users SET name = 'Updated User' WHERE id = 1;
COMMIT;
```
* **Error Handling**: Use `ROLLBACK` to revert all changes if any operation within a transaction fails.
* ✅ GOOD:
```sql
BEGIN;
-- Perform operations
INSERT INTO users (name) VALUES ('Valid User');
INSERT INTO users (name) VALUES (NULL); -- This will fail due to NOT NULL
-- If an error occurs, catch it and:
ROLLBACK;
-- Else:
COMMIT;
```
## 4. Security Best Practices
Prevent common vulnerabilities like SQL injection.
* **Prepared Statements**: Always use prepared statements with bound parameters. NEVER concatenate user input directly into SQL queries.
* ❌ BAD:
```sql
String name = userInput.getName();
String sql = "INSERT INTO users (name) VALUES ('" + name + "');"; // SQL Injection risk!
```
* ✅ GOOD (using a typical API pattern):
```sql
PreparedStatement stmt = connection.prepareStatement("INSERT INTO users (name) VALUES (?);");
stmt.setString(1, userInput.getName());
stmt.executeUpdate();
```
* **Enable Foreign Key Enforcement**: Always enable foreign key constraints at runtime. SQLite defaults to `OFF` for backward compatibility.
* ❌ BAD: Forgetting to enable foreign keys, leading to orphaned records.
* ✅ GOOD (at database open or once per connection):
```sql
PRAGMA foreign_keys = ON;
```
* **File Permissions**: Store database files in write-protected directories and set restrictive file permissions to limit unauthorized access. This is OS-specific but critical.
## 5. Common Pitfalls & Gotchas
Avoid these common mistakes that lead to bugs and performance issues.
* **Forgetting `PRAGMA foreign_keys = ON;`**: This is the most common pitfall. Always enable it.
* **Selecting `*`**: Only retrieve the columns you actually need.
* **Application-level Filtering/Sorting**: Delegate these operations to SQL for better performance, especially on large datasets.
* **Not Using Transactions**: Leads to slow writes and potential data inconsistencies.
* **Using SQLite for High-Concurrency Writes**: SQLite is a single-writer database. If multiple processes need to write concurrently, consider a client-server RDBMS.
## 6. Testing Approaches
Ensure your data access logic is robust and correct.
* **In-Memory Databases**: Use `:memory:` databases for fast, isolated unit and integration tests of your data access layer.
* ✅ GOOD (example in Python, similar patterns exist in other languages):
```python
import sqlite3
conn = sqlite3.connect(':memory:')
cursor = conn.cursor()
cursor.execute("CREATE TABLE test_data (id INTEGER PRIMARY KEY, value TEXT)")
# ... run tests ...
conn.close() # Database vanishes
```
* **Seed Data**: Create consistent, reproducible test data for your tests.
* **Mocking**: For higher-level tests, mock your database interactions to focus on business logic.
+120
View File
@@ -0,0 +1,120 @@
---
description: Vite + TanStack Router v1 + TanStack Query v5 — routing, loaders, queries, mutations
globs: apps/web/**/*.{tsx,ts}
alwaysApply: false
---
# Vite + TanStack Router + Query
Фронтенд: **Vite SPA**, не Next.js. Нет Server Components, App Router, `'use client'`.
## Структура
```
apps/web/src/
routes/ # file-based routes (__root.tsx, _auth/, ...)
queries/ # queryOptions factories + key factories
lib/ # api-client, queryClient, auth, schemas
components/ # domain + layout (UI primitives → @cfdm/ui)
main.tsx
```
## Архитектура
- **Router** — маршрутизация, URL state, navigation, loaders
- **Query** — server state, cache, mutations
- **Loader** — `queryClient.ensureQueryData()` до рендера → без спиннеров на route data
- **Компоненты** — UI; данные из Query cache
## QueryClient + Router
```ts
// lib/queryClient.ts
export const queryClient = new QueryClient({
defaultOptions: { queries: { staleTime: 60_000 } },
})
// lib/router.ts
export const router = createRouter({
routeTree,
context: { queryClient },
defaultPreload: 'intent',
})
declare module '@tanstack/react-router' {
interface Register { router: typeof router }
}
```
## Query definitions
- `queryOptions` factories в `queries/`, не inline в компонентах
- Key factories: `all` → `lists` / `details` → `list(filters)` / `detail(id)`
```ts
export const serviceKeys = {
all: ['services'] as const,
list: () => [...serviceKeys.all, 'list'] as const,
}
export const servicesQueryOptions = () =>
queryOptions({
queryKey: serviceKeys.list(),
queryFn: () => api.get('/api/v1/services'),
})
```
## Loader + component
```tsx
export const Route = createFileRoute('/_auth/services')({
loader: ({ context: { queryClient } }) =>
queryClient.ensureQueryData(servicesQueryOptions()),
component: ServicesPage,
})
function ServicesPage() {
const { data } = useQuery(servicesQueryOptions()) // из cache loader
return ...
}
```
## Search params
- Zod + `validateSearch`; доступ через `Route.useSearch()`
- Search params = source of truth для фильтров/пагинации
- Передавать в `queryOptions` для query key и fetcher
## Mutations
```ts
onSuccess: (newItem) => {
queryClient.setQueryData(keys.detail(newItem.id), newItem)
queryClient.invalidateQueries({ queryKey: keys.lists() })
}
```
- `setQueryData` + `invalidateQueries`, не только invalidate
- Навигация после create — когда cache уже тёплый
## Routing
- `createFileRoute` для file-based routes
- `<Link>` для внутренней навигации, не `<a href>`
- Pathless layouts: `_auth/` для protected routes
- Auth guard в `beforeLoad` pathless route
## Запреты
- `useEffect` для fetch данных — только loader / `useQuery`
- Inline `queryKey` в компонентах — только factories из `queries/`
- `useQuery` с позиционными аргументами (v5 — только options object)
- `window.location` для search params
## Prefetch
`onMouseEnter` на `<Link>` → `queryClient.prefetchQuery(detailOptions(id))`
## DevTools
Только в dev: `TanStackRouterDevtools`, `ReactQueryDevtools`
+251
View File
@@ -0,0 +1,251 @@
---
description: Opinionated best practices for fast, reliable Vitest unit and integration tests in JS/TS projects.
globs: **/*.{js,ts,jsx,tsx}
alwaysApply: false
---
# Vitest Best Practices
Vitest is the definitive testing framework for our Vite-powered projects. It offers a fast, Jest-compatible API with deep integration into the Vite ecosystem. Adhering to these guidelines ensures our tests are robust, performant, and easy to maintain.
## 1. Code Organization & Naming
**Always co-locate test files with their source.** This improves discoverability and ensures tests are updated alongside their implementation.
* **File Naming**: Use `*.test.{ts,tsx,js,jsx}`.
* **Location**: Place test files directly next to the component or module they test.
❌ BAD:
```
// src/components/Button/Button.tsx
// tests/components/Button.test.tsx
```
✅ GOOD:
```typescript
// src/components/Button/Button.tsx
// src/components/Button/Button.test.tsx
```
## 2. Test Structure & Isolation
**Organize tests logically using `describe` and `it` (or `test`) blocks.** Ensure each test is isolated and deterministic.
* **`describe`**: Group related tests into suites.
* **`it` / `test`**: Define individual test cases. Prefer `it` for consistency with Jest.
* **Hooks (`beforeEach`, `afterEach`)**: Use these for setup and teardown to ensure test isolation.
❌ BAD: (Shared state, no cleanup)
```typescript
let user;
test('creates user', () => {
user = createUser();
expect(user).toBeDefined();
});
test('updates user', () => { // Depends on previous test
user.name = 'New Name';
expect(user.name).toBe('New Name');
});
```
✅ GOOD: (Isolated tests with hooks)
```typescript
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { createUser, deleteUser } from './user-service';
describe('User Service', () => {
let user;
beforeEach(() => {
user = createUser(); // Create a fresh user for each test
});
afterEach(() => {
deleteUser(user.id); // Clean up after each test
});
it('should create a user', () => {
expect(user).toBeDefined();
expect(user.id).toBeTypeOf('string');
});
it('should update a user', () => {
user.name = 'Jane Doe';
expect(user.name).toBe('Jane Doe');
});
});
```
## 3. Asynchronous Testing with `vi.waitFor`
**Always use `vi.waitFor` for polling conditions in asynchronous tests.** Avoid arbitrary `setTimeout` calls or manual polling loops. `vi.waitFor` is designed for reliable synchronization.
❌ BAD: (Flaky, relies on arbitrary timeout)
```typescript
test('data loads after delay', async () => {
let data = null;
fetchData().then(res => (data = res));
await new Promise(resolve => setTimeout(resolve, 100)); // Arbitrary wait
expect(data).toEqual('some data');
});
```
✅ GOOD: (Reliable polling with `vi.waitFor`)
```typescript
import { it, expect, vi } from 'vitest';
import { fetchData } from './api'; // Assume fetchData returns a Promise
it('should load data after delay', async () => {
let data = null;
fetchData().then(res => (data = res));
// Polls until data is not null, with a 2-second timeout
await vi.waitFor(() => expect(data).not.toBeNull(), { timeout: 2000 });
expect(data).toEqual('some data');
});
```
## 4. Mocking Strategies
**Leverage Vitest's `vi` API for all mocking.** This provides Jest-compatible syntax and seamless integration. Always clean up mocks after each test.
* **`vi.fn()`**: Mock individual functions.
* **`vi.spyOn()`**: Spy on existing object methods.
* **`vi.mock()`**: Mock entire modules.
### Function Mocking
❌ BAD: (Manual mock, no easy reset)
```typescript
const originalFetch = global.fetch;
global.fetch = () => Promise.resolve({ json: () => ({ id: 1 }) });
// ... test ...
global.fetch = originalFetch; // Easy to forget cleanup
```
✅ GOOD: (Using `vi.fn` with `afterEach` cleanup)
```typescript
import { it, expect, vi, afterEach } from 'vitest';
import { getUser } from './user-api';
// Mock the module containing fetchUser
vi.mock('./user-api', async (importOriginal) => {
const mod = await importOriginal();
return {
...mod,
fetchUser: vi.fn(), // Mock specific function within the module
};
});
// Import the mocked function after vi.mock
import { fetchUser } from './user-api';
afterEach(() => {
vi.clearAllMocks(); // Clear mock calls after each test to prevent state leakage
});
it('should fetch user data', async () => {
fetchUser.mockResolvedValueOnce({ id: 1, name: 'Test User' });
const user = await getUser(1);
expect(fetchUser).toHaveBeenCalledWith(1);
expect(user.name).toBe('Test User');
});
```
### Module Mocking
**Mock modules at the top of the file.** This ensures the mock is applied before the module under test imports it.
✅ GOOD: (Module mock before imports)
```typescript
import { vi, it, expect } from 'vitest';
// Mock the entire 'lodash' module to control its behavior
vi.mock('lodash', () => ({
debounce: vi.fn((fn) => fn), // Mock debounce to execute immediately
}));
import { debounce } from 'lodash'; // Import the mocked debounce
import { saveInput } from './input-handler'; // Module using debounce
it('should call save function without debounce delay', () => {
saveInput('test');
expect(debounce).toHaveBeenCalledOnce();
});
```
## 5. DOM Environment & Component Testing
**Use `happy-dom` for lightweight DOM environments.** It's generally faster and sufficient for most component tests. Switch to `jsdom` only if specific browser APIs are missing in `happy-dom`.
* Configure in `vite.config.ts` or `vitest.config.ts`.
✅ GOOD: (Configuring `happy-dom`)
```typescript
// vite.config.ts or vitest.config.ts
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'happy-dom', // Use happy-dom for faster DOM mocking
globals: true, // Auto-import test APIs globally (e.g., describe, it, expect)
},
});
```
## 6. Performance & Concurrent Tests
**Utilize `.concurrent` for tests that can run in parallel.** This significantly speeds up test suites where tests are independent.
* Use `it.concurrent` for individual tests.
* Use `describe.concurrent` for entire suites.
* **Important**: When using `.concurrent`, always destructure `expect` from the test context to avoid issues with snapshot and assertion tracking.
❌ BAD: (Sequential tests, slow)
```typescript
describe('My Feature', () => {
it('test A', async () => { /* ... */ });
it('test B', async () => { /* ... */ });
});
```
✅ GOOD: (Concurrent tests, faster)
```typescript
import { describe, it } from 'vitest';
describe.concurrent('My Feature', () => {
it('test A', async ({ expect }) => { // Destructure expect for concurrent tests
expect(1).toBe(1);
});
it.concurrent('test B', async ({ expect }) => { // Destructure expect
expect(2).toBe(2);
});
});
```
## 7. Code Coverage
**Enable V8-based code coverage.** It offers near-zero overhead and integrates seamlessly.
* Add `coverage` configuration to `vite.config.ts` or `vitest.config.ts`.
* Run with `vitest run --coverage`.
✅ GOOD: (V8 coverage configuration)
```typescript
// vite.config.ts or vitest.config.ts
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'happy-dom',
globals: true,
coverage: {
provider: 'v8', // Use V8 for native, fast coverage
reporter: ['text', 'json', 'html'], // Output formats for reports
exclude: ['node_modules/', 'dist/', '.eslintrc.cjs'], // Exclude common directories from coverage
},
},
});
```
+10
View File
@@ -0,0 +1,10 @@
{
"plugins": {
"cloudflare": {
"enabled": true
},
"claude-plugins-official/typescript-lsp": {
"enabled": true
}
}
}
+6 -7
View File
@@ -1,16 +1,15 @@
node_modules node_modules
**/node_modules
dist dist
**/dist
data data
.git .git
.github .github
.cursor .cursor
eslint.config.js **/*.log
*.md coverage
!README.md .DS_Store
Thumbs.db
.env .env
.env.* .env.*
!.env.example !.env.example
coverage
*.log
.DS_Store
Thumbs.db
+5
View File
@@ -13,6 +13,11 @@ data
dist-ssr dist-ssr
*.local *.local
# pnpm
.pnpm-store
coverage
*.tsbuildinfo
# Editor directories and files # Editor directories and files
.vscode/* .vscode/*
!.vscode/extensions.json !.vscode/extensions.json
+62 -28
View File
@@ -4,33 +4,47 @@
VPS Tracker — приложение для учёта виртуальных серверов (VPS), провайдеров, аккаунтов, платежей и балансов. Поддерживает синхронизацию с BILLmanager 6 API. VPS Tracker — приложение для учёта виртуальных серверов (VPS), провайдеров, аккаунтов, платежей и балансов. Поддерживает синхронизацию с BILLmanager 6 API.
## Стек
- **Monorepo:** pnpm workspaces (`apps/*`, `packages/*`)
- **Frontend** (`apps/web`): Vite + React 19 + TypeScript, TanStack Router v1 + Query v5, shadcn/ui (`@cfdm/ui`), Tailwind v4, lucide-react, Recharts (через shadcn Chart), react-hook-form + Zod
- **Backend** (`apps/api`): Fastify 5 + TypeScript, `@fastify/*` plugins, контракты через `@cfdm/shared` (Zod)
- **DB** (`packages/db`): Drizzle ORM + better-sqlite3 (WAL, `foreign_keys=ON`)
- **Тесты:** Vitest (`app.inject()` для backend, `happy-dom` для frontend)
## Структура проекта ## Структура проекта
``` ```
vps-tracker/ vps-tracker/
├── server/ # Express backend ├── apps/
│ ├── index.js # Точка входа, подключение роутов │ ├── web/ # Vite SPA (TSX) — TanStack + shadcn/ui
├── db/ # База данных (SQLite via sql.js) │ └── src/
│ │ ├── schema.js # CREATE TABLE │ │ ├── routes/ # file-based (TanStack Router)
│ │ ├── migrations.js # Миграции схемы │ │ ├── queries/ # queryOptions + key factories
│ │ ├── seed.js # Начальные данные из public/data │ │ ├── components/ # shared + layout + domain
│ │ └── index.js # initDb, getDb, saveDb │ │ └── lib/ # api-client, queryClient, router, schemas
── adapters/ # Интеграции с внешними API ── api/ # Fastify 5 API (TS)
└── billmanager/ # BILLmanager 6 API └── src/
├── client.js # HTTP-запросы ├── routes/ # тонкие plugins
├── parsers.js # Парсинг ответов API ├── services/ # бизнес-логика + billmanager/
├── mappers.js # Маппинг в модель vps-tracker │ └── billmanager/ # client, parsers, mappers, operations, sync
── operations.js # fetchVds, fetchPayments и т.д. ── plugins/ # @fastify/* registration
│ │ ├── sync.js # syncFromBillmanager ├── packages/
│ └── index.js # Barrel export ├── ui/ # @cfdm/ui — shadcn primitives
├── routes/ # Express роутеры │ └── src/
├── utils/ # row-mappers и др. │ ├── components/ # output `shadcn add` (не трогать под кейс)
└── sync-scheduler.js # Планировщик синка │ ├── hooks/ # use-mobile и др.
├── src/ # React frontend (Vite) │ │ ├── lib/utils.ts # cn()
├── pages/ # Страницы приложения │ └── styles/globals.css # только output `shadcn apply --only theme`
│ ├── components/ # UI-компоненты │ ├── shared/ # @cfdm/shared — Zod-контракты, общие типы
│ └── lib/ # api.js, utils.js │ └── db/ # @cfdm/db — Drizzle schema, repositories, миграции
└── public/data/ # JSON для seed (providers, vps, payments...) │ └── src/
│ ├── schema/ # tables по сущностям
│ ├── repositories/ # typed queries
│ └── migrations/ # drizzle-kit
├── data/ # SQLite база (том Docker, gitignored)
├── pnpm-workspace.yaml
└── package.json
``` ```
## Основные сущности ## Основные сущности
@@ -47,11 +61,13 @@ vps-tracker/
## Где искать код по доменам ## Где искать код по доменам
- **Sync (BILLmanager)** — `server/adapters/billmanager/`, `server/routes/sync.js`, `server/sync-scheduler.js` - **Sync (BILLmanager)** — `apps/api/src/services/billmanager/`, `apps/api/src/routes/sync.ts`, scheduler в `apps/api/src/services/`
- **Тарифы** — `server/adapters/billmanager/operations.js` (fetchVdsOrderPricelist), `server/adapters/billmanager/sync.js` - **Тарифы** — `apps/api/src/services/billmanager/operations.ts` (fetchVdsOrderPricelist), `apps/api/src/services/billmanager/sync.ts`
- **VPS CRUD** — `server/routes/vps.js` - **VPS CRUD** — `apps/api/src/routes/vps.ts`, `packages/db/src/repositories/vps.ts`
- **Платежи/баланс** — `server/routes/payments.js`, `server/routes/balance-ledger.js` - **Платежи/баланс** — `apps/api/src/routes/payments.ts`, `apps/api/src/routes/balance-ledger.ts`
- **Курсы валют** — `src/lib/utils.js` (convertCurrency, formatInBaseCurrency), настройки в settings.ratesUrl - **Курсы валют** — `apps/web/src/lib/format.ts` (convertCurrency, formatInBaseCurrency), настройки в `settings.ratesUrl`
- **UI shared** — `apps/web/src/components/` (PageShell, PageHeader, EmptyState, QueryState, ConfirmDialog, DataTableCard, SectionCards, StatusBadge, FormSheet, FormField, TableCard, LoadingButton)
- **UI primitives** — `packages/ui/src/components/*` (только output `shadcn add`)
## BILLmanager API ## BILLmanager API
@@ -60,3 +76,21 @@ vps-tracker/
- [Payments API](https://www.ispsystem.com/docs/b6c/developer-section/billmanager-api/payments-payment) - [Payments API](https://www.ispsystem.com/docs/b6c/developer-section/billmanager-api/payments-payment)
Формат запроса: `?authinfo=user:pass&out=bjson&func=vds|payment|dashboard.info|vds.order` Формат запроса: `?authinfo=user:pass&out=bjson&func=vds|payment|dashboard.info|vds.order`
## Команды
```bash
pnpm install
pnpm --filter web dev # frontend dev
pnpm --filter api dev # backend dev
pnpm --filter web build # production build frontend
pnpm --filter api test # Vitest backend
pnpm --filter web test # Vitest frontend
```
## Соглашения
- UI — только [shadcn/ui](https://ui.shadcn.com) через MCP `plugin-shadcn-shadcn` (см. [`shadcn-mcp.mdc`](.cursor/rules/shadcn-mcp.mdc))
- Коммиты — на русском, см. [`commit-messages-ru.mdc`](.cursor/rules/commit-messages-ru.mdc)
- Gitflow — см. [`gitflow.mdc`](.cursor/rules/gitflow.mdc)
- Структура — см. [`project-structure.mdc`](.cursor/rules/project-structure.mdc)
+61 -24
View File
@@ -1,35 +1,72 @@
# syntax=docker/dockerfile:1 # syntax=docker/dockerfile:1
# Stage 1: Сборка фронтенда ARG NODE_IMAGE=node:22-alpine
FROM node:22-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci
COPY index.html vite.config.js ./
COPY public ./public
COPY src ./src
RUN npm run build
# Stage 2: Production dependencies + очистка node_modules ############################
FROM node:22-alpine AS deps # Stage 1: build web + api
############################
FROM ${NODE_IMAGE} AS build
WORKDIR /app WORKDIR /app
COPY package.json package-lock.json ./ RUN corepack enable
RUN --mount=type=cache,target=/root/.npm npm ci --omit=dev && \ COPY package.json pnpm-workspace.yaml pnpm-lock.yaml tsconfig.base.json ./
find node_modules -type f \( \ COPY apps/web/package.json ./apps/web/
-name '*.md' -o -name '*.ts' -o -name '*.map' -o \ COPY apps/api/package.json ./apps/api/
-name 'LICENSE*' -o -name 'CHANGELOG*' -o -name 'README*' -o \ COPY packages/ui/package.json ./packages/ui/
-name '.npmignore' -o -name '.eslintrc*' -o -name '.travis.yml' -o \ COPY packages/shared/package.json ./packages/shared/
-name 'Makefile' -o -name '*.gyp' -o -name '*.gypi' \ COPY packages/db/package.json ./packages/db/
\) -delete && \ RUN --mount=type=cache,target=/root/.local/share/pnpm pnpm install --frozen-lockfile
find node_modules -type d -empty -delete
# Stage 3: Минимальный runtime (без npm/yarn/corepack) COPY apps ./apps
COPY packages ./packages
RUN pnpm --filter web build \
&& pnpm --filter api build
############################
# Stage 2: production deps
############################
FROM ${NODE_IMAGE} AS deps
WORKDIR /app
RUN corepack enable
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml ./
COPY apps/web/package.json ./apps/web/
COPY apps/api/package.json ./apps/api/
COPY packages/ui/package.json ./packages/ui/
COPY packages/shared/package.json ./packages/shared/
COPY packages/db/package.json ./packages/db/
RUN --mount=type=cache,target=/root/.local/share/pnpm pnpm install --frozen-lockfile --prod \
&& find node_modules -type f \( \
-name '*.md' -o -name '*.map' -o -name 'LICENSE*' -o -name 'CHANGELOG*' \
-o -name 'README*' -o -name '.npmignore' -o -name '.eslintrc*' -o -name '.travis.yml' \
-o -name 'Makefile' -o -name '*.gyp' -o -name '*.gypi' \
\) -delete \
&& find node_modules -type d -empty -delete
############################
# Stage 3: runtime (no npm/pnpm)
############################
FROM alpine:3.21 FROM alpine:3.21
RUN apk add --no-cache nodejs RUN apk add --no-cache nodejs
WORKDIR /app WORKDIR /app
ENV NODE_ENV=production ENV NODE_ENV=production
ENV PORT=3001
COPY --from=deps /app/node_modules ./node_modules COPY --from=deps /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist COPY --from=deps /app/apps/web/node_modules ./apps/web/node_modules
COPY server ./server COPY --from=deps /app/apps/api/node_modules ./apps/api/node_modules
COPY --from=deps /app/packages/ui/node_modules ./packages/ui/node_modules
COPY --from=deps /app/packages/shared/node_modules ./packages/shared/node_modules
COPY --from=deps /app/packages/db/node_modules ./packages/db/node_modules
COPY --from=build /app/apps/web/dist ./apps/web/dist
COPY --from=build /app/apps/api/dist ./apps/api/dist
COPY --from=build /app/apps/api/db ./apps/api/db
COPY --from=build /app/apps/api/adapters ./apps/api/adapters
COPY --from=build /app/apps/api/index.js ./apps/api/index.js
COPY --from=build /app/apps/api/utils ./apps/api/utils
COPY --from=build /app/apps/api/projects-service.js ./apps/api/projects-service.js
COPY --from=build /app/apps/api/sync-scheduler.js ./apps/api/sync-scheduler.js
COPY --from=build /app/apps/api/sync-account-job.js ./apps/api/sync-account-job.js
COPY --from=build /app/apps/api/telegram.js ./apps/api/telegram.js
EXPOSE 3001 EXPOSE 3001
CMD ["node", "server/index.js"] # Default runtime: legacy Express server (sync still pending migration to Fastify).
# Set RUNTIME=fastify to use the new Fastify stack.
ENV RUNTIME=express
CMD ["sh", "-c", "if [ \"$RUNTIME\" = \"fastify\" ]; then node apps/api/dist/index.js; else node apps/api/index.js; fi"]
View File
+35
View File
@@ -0,0 +1,35 @@
{
"name": "@cfdm/api",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts",
"dev:legacy": "node index.js",
"build": "tsc -p tsconfig.json",
"start": "node dist/index.js",
"start:legacy": "node index.js",
"test": "vitest run"
},
"dependencies": {
"@cfdm/db": "workspace:*",
"@cfdm/shared": "workspace:*",
"@fastify/cors": "^11.0.1",
"@fastify/sensible": "^6.0.3",
"@fastify/static": "^8.2.0",
"fastify": "^5.6.1",
"better-sqlite3": "^11.10.0",
"drizzle-orm": "^0.40.0",
"express": "^4.21.1",
"cors": "^2.8.5",
"sql.js": "^1.14.0"
},
"devDependencies": {
"@types/node": "^22.10.0",
"@types/express": "^5.0.0",
"@types/cors": "^2.8.17",
"tsx": "^4.19.0",
"typescript": "^5.9.2",
"vitest": "^3.0.0"
}
}
+83
View File
@@ -0,0 +1,83 @@
import Fastify from 'fastify'
import cors from '@fastify/cors'
import sensible from '@fastify/sensible'
import staticPlugin from '@fastify/static'
import { existsSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { getDb } from '@cfdm/db'
import { dataRoutes } from './routes/data.js'
import { vpsRoutes } from './routes/vps.js'
import { providersRoutes } from './routes/providers.js'
import { providerAccountsRoutes } from './routes/provider-accounts.js'
import { paymentsRoutes } from './routes/payments.js'
import { balanceLedgerRoutes } from './routes/balance-ledger.js'
import { settingsRoutes } from './routes/settings.js'
import { syncRoutes } from './routes/sync.js'
import { projectsRoutes } from './routes/projects.js'
import { backupRoutes } from './routes/backup.js'
import { ratesProxyRoutes } from './routes/rates-proxy.js'
const __dirname = dirname(fileURLToPath(import.meta.url))
export interface BuildAppOptions {
dbPath?: string
staticDir?: string
}
export async function buildApp(opts: BuildAppOptions = {}) {
if (opts.dbPath) process.env.DB_PATH = opts.dbPath
getDb()
const app = Fastify({
logger: process.env.NODE_ENV !== 'production',
})
await app.register(cors, { origin: true })
await app.register(sensible)
await app.register(dataRoutes)
await app.register(vpsRoutes)
await app.register(providersRoutes)
await app.register(providerAccountsRoutes)
await app.register(paymentsRoutes)
await app.register(balanceLedgerRoutes)
await app.register(settingsRoutes)
await app.register(syncRoutes)
await app.register(projectsRoutes)
await app.register(backupRoutes)
await app.register(ratesProxyRoutes)
const staticDir = opts.staticDir ?? join(__dirname, '..', '..', 'web', 'dist')
if (existsSync(staticDir)) {
await app.register(staticPlugin, {
root: staticDir,
prefix: '/',
wildcard: false,
})
app.setNotFoundHandler((req, reply) => {
if (req.url.startsWith('/api')) {
reply.code(404).send({ error: { code: 'NOT_FOUND', message: 'Not found' } })
return
}
reply.sendFile('index.html')
})
}
return app
}
async function start() {
const port = Number(process.env.PORT ?? 3001)
const app = await buildApp()
try {
await app.listen({ port, host: '0.0.0.0' })
} catch (err) {
app.log.error(err)
process.exit(1)
}
}
void start()
+44
View File
@@ -0,0 +1,44 @@
import type { FastifyPluginAsync } from 'fastify'
import { existsSync, readFileSync } from 'node:fs'
import { getDbPath } from '@cfdm/db'
import { getSnapshot } from '@cfdm/db/repositories/snapshot'
const BACKUP_VERSION = 1
export const backupRoutes: FastifyPluginAsync = async (app) => {
app.get('/api/backup/json', async (_req, reply) => {
const snapshot = { backupVersion: BACKUP_VERSION, exportedAt: new Date().toISOString(), ...getSnapshot() }
reply.header('Content-Type', 'application/json; charset=utf-8')
reply.header('Content-Disposition', 'attachment; filename="vps-tracker-backup.json"')
return reply.send(JSON.stringify(snapshot, null, 2))
})
app.get('/api/backup/database', async (_req, reply) => {
const dbPath = getDbPath()
if (!existsSync(dbPath)) {
return reply.code(404).send({ error: { code: 'NOT_FOUND', message: 'Файл базы не найден' } })
}
const buf = readFileSync(dbPath)
reply.header('Content-Type', 'application/octet-stream')
reply.header('Content-Disposition', 'attachment; filename="vps-tracker.db"')
return reply.send(buf)
})
app.post('/api/backup/json', async (req, reply) => {
const payload = req.body
if (!payload || typeof payload !== 'object') {
return reply.code(400).send({ error: { code: 'VALIDATION', message: 'Неверное тело запроса' } })
}
// TODO: implement JSON snapshot import via repositories
return reply.code(501).send({ error: { code: 'NOT_IMPLEMENTED', message: 'JSON import pending migration' } })
})
app.post('/api/backup/database', async (req, reply) => {
const buf = req.body as Buffer
if (!buf || !buf.length) {
return reply.code(400).send({ error: { code: 'VALIDATION', message: 'Пустой файл' } })
}
// TODO: implement DB restore via better-sqlite3 backup API
return reply.code(501).send({ error: { code: 'NOT_IMPLEMENTED', message: 'DB restore pending migration' } })
})
}
+35
View File
@@ -0,0 +1,35 @@
import type { FastifyPluginAsync } from 'fastify'
import { balanceLedgerRepository } from '@cfdm/db/repositories/balance-ledger'
import { balanceLedgerSchema } from '@cfdm/shared/contracts/balance-ledger'
export const balanceLedgerRoutes: FastifyPluginAsync = async (app) => {
app.get('/api/balance-ledger', async () => balanceLedgerRepository.list())
app.post('/api/balance-ledger', async (req, reply) => {
const parsed = balanceLedgerSchema.safeParse(req.body)
if (!parsed.success) {
return reply.code(400).send({ error: { code: 'VALIDATION', message: parsed.error.message } })
}
return reply.code(201).send(balanceLedgerRepository.create(parsed.data))
})
app.put<{ Params: { id: string } }>('/api/balance-ledger/:id', async (req, reply) => {
const parsed = balanceLedgerSchema.partial().safeParse(req.body)
if (!parsed.success) {
return reply.code(400).send({ error: { code: 'VALIDATION', message: parsed.error.message } })
}
const updated = balanceLedgerRepository.update(req.params.id, parsed.data)
if (!updated) {
return reply.code(404).send({ error: { code: 'NOT_FOUND', message: 'Not found' } })
}
return updated
})
app.delete<{ Params: { id: string } }>('/api/balance-ledger/:id', async (req, reply) => {
const ok = balanceLedgerRepository.delete(req.params.id)
if (!ok) {
return reply.code(404).send({ error: { code: 'NOT_FOUND', message: 'Not found' } })
}
return reply.code(204).send()
})
}
+6
View File
@@ -0,0 +1,6 @@
import type { FastifyPluginAsync } from 'fastify'
import { getSnapshot } from '@cfdm/db/repositories/snapshot'
export const dataRoutes: FastifyPluginAsync = async (app) => {
app.get('/api/data', async () => getSnapshot())
}
+35
View File
@@ -0,0 +1,35 @@
import type { FastifyPluginAsync } from 'fastify'
import { paymentsRepository } from '@cfdm/db/repositories/payments'
import { paymentSchema } from '@cfdm/shared/contracts/payment'
export const paymentsRoutes: FastifyPluginAsync = async (app) => {
app.get('/api/payments', async () => paymentsRepository.list())
app.post('/api/payments', async (req, reply) => {
const parsed = paymentSchema.safeParse(req.body)
if (!parsed.success) {
return reply.code(400).send({ error: { code: 'VALIDATION', message: parsed.error.message } })
}
return reply.code(201).send(paymentsRepository.create(parsed.data))
})
app.put<{ Params: { id: string } }>('/api/payments/:id', async (req, reply) => {
const parsed = paymentSchema.partial().safeParse(req.body)
if (!parsed.success) {
return reply.code(400).send({ error: { code: 'VALIDATION', message: parsed.error.message } })
}
const updated = paymentsRepository.update(req.params.id, parsed.data)
if (!updated) {
return reply.code(404).send({ error: { code: 'NOT_FOUND', message: 'Not found' } })
}
return updated
})
app.delete<{ Params: { id: string } }>('/api/payments/:id', async (req, reply) => {
const ok = paymentsRepository.delete(req.params.id)
if (!ok) {
return reply.code(404).send({ error: { code: 'NOT_FOUND', message: 'Not found' } })
}
return reply.code(204).send()
})
}
+30
View File
@@ -0,0 +1,30 @@
import type { FastifyPluginAsync } from 'fastify'
import {
projectsRepository,
projectSuggestions,
resolveOrCreateProject,
normalizeProjectNameInput,
} from '@cfdm/db/repositories/projects'
export const projectsRoutes: FastifyPluginAsync = async (app) => {
app.get('/api/projects', async () => projectsRepository.list())
app.get('/api/projects/suggest', async (req) => {
const q = (req.query as { q?: string })?.q ?? ''
const limit = (req.query as { limit?: string })?.limit
return projectSuggestions(q, limit ? Number(limit) : 20)
})
app.post('/api/projects/resolve-or-create', async (req) => {
const name = (req.body as { name?: unknown })?.name
return resolveOrCreateProject(name)
})
app.post('/api/projects', async (req, reply) => {
const name = normalizeProjectNameInput((req.body as { name?: unknown })?.name)
if (!name) {
return reply.code(400).send({ error: { code: 'VALIDATION', message: 'name is required' } })
}
return reply.code(201).send(resolveOrCreateProject(name))
})
}
+36
View File
@@ -0,0 +1,36 @@
import type { FastifyPluginAsync } from 'fastify'
import { providerAccountsRepository } from '@cfdm/db/repositories/provider-accounts'
import { providerAccountSchema } from '@cfdm/shared/contracts/provider-account'
export const providerAccountsRoutes: FastifyPluginAsync = async (app) => {
app.get('/api/provider-accounts', async () => providerAccountsRepository.list())
app.post('/api/provider-accounts', async (req, reply) => {
const parsed = providerAccountSchema.safeParse(req.body)
if (!parsed.success) {
return reply.code(400).send({ error: { code: 'VALIDATION', message: parsed.error.message } })
}
const created = providerAccountsRepository.create(parsed.data)
return reply.code(201).send(created)
})
app.put<{ Params: { id: string } }>('/api/provider-accounts/:id', async (req, reply) => {
const parsed = providerAccountSchema.partial().safeParse(req.body)
if (!parsed.success) {
return reply.code(400).send({ error: { code: 'VALIDATION', message: parsed.error.message } })
}
const updated = providerAccountsRepository.update(req.params.id, parsed.data)
if (!updated) {
return reply.code(404).send({ error: { code: 'NOT_FOUND', message: 'Not found' } })
}
return updated
})
app.delete<{ Params: { id: string } }>('/api/provider-accounts/:id', async (req, reply) => {
const ok = providerAccountsRepository.delete(req.params.id)
if (!ok) {
return reply.code(404).send({ error: { code: 'NOT_FOUND', message: 'Not found' } })
}
return reply.code(204).send()
})
}
+36
View File
@@ -0,0 +1,36 @@
import type { FastifyPluginAsync } from 'fastify'
import { providersRepository } from '@cfdm/db/repositories/providers'
import { providerSchema } from '@cfdm/shared/contracts/provider'
export const providersRoutes: FastifyPluginAsync = async (app) => {
app.get('/api/providers', async () => providersRepository.list())
app.post('/api/providers', async (req, reply) => {
const parsed = providerSchema.safeParse(req.body)
if (!parsed.success) {
return reply.code(400).send({ error: { code: 'VALIDATION', message: parsed.error.message } })
}
const created = providersRepository.create(parsed.data)
return reply.code(201).send(created)
})
app.put<{ Params: { id: string } }>('/api/providers/:id', async (req, reply) => {
const parsed = providerSchema.partial().safeParse(req.body)
if (!parsed.success) {
return reply.code(400).send({ error: { code: 'VALIDATION', message: parsed.error.message } })
}
const updated = providersRepository.update(req.params.id, parsed.data)
if (!updated) {
return reply.code(404).send({ error: { code: 'NOT_FOUND', message: 'Not found' } })
}
return updated
})
app.delete<{ Params: { id: string } }>('/api/providers/:id', async (req, reply) => {
const ok = providersRepository.delete(req.params.id)
if (!ok) {
return reply.code(404).send({ error: { code: 'NOT_FOUND', message: 'Not found' } })
}
return reply.code(204).send()
})
}
+19
View File
@@ -0,0 +1,19 @@
import type { FastifyPluginAsync } from 'fastify'
export const ratesProxyRoutes: FastifyPluginAsync = async (app) => {
app.get('/api/rates-proxy', async (req, reply) => {
const url = (req.query as { url?: string })?.url
if (!url) {
return reply.code(400).send({ error: { code: 'VALIDATION', message: 'Missing url parameter' } })
}
try {
const response = await fetch(url, { signal: AbortSignal.timeout(10_000) })
if (!response.ok) {
return reply.code(502).send({ error: { code: 'UPSTREAM', message: `Upstream returned ${response.status}` } })
}
return await response.json()
} catch (err) {
return reply.code(502).send({ error: { code: 'UPSTREAM', message: (err as Error).message || 'Failed to fetch rates' } })
}
})
}
+26
View File
@@ -0,0 +1,26 @@
import type { FastifyPluginAsync } from 'fastify'
import { settingsRepository } from '@cfdm/db/repositories/settings'
import { settingsSchema } from '@cfdm/shared/contracts/settings'
export const settingsRoutes: FastifyPluginAsync = async (app) => {
app.get('/api/settings', async () => settingsRepository.list())
app.post('/api/settings', async (req, reply) => {
const parsed = settingsSchema.safeParse(req.body)
if (!parsed.success) {
return reply.code(400).send({ error: { code: 'VALIDATION', message: parsed.error.message } })
}
const id = (req.body as { id?: string })?.id ?? 'settings-main'
return reply.code(201).send(settingsRepository.upsert(id, parsed.data))
})
app.put<{ Params: { id: string } }>('/api/settings/:id', async (req, reply) => {
const parsed = settingsSchema.partial().safeParse(req.body)
if (!parsed.success) {
return reply.code(400).send({ error: { code: 'VALIDATION', message: parsed.error.message } })
}
return settingsRepository.upsert(req.params.id, parsed.data)
})
app.post('/api/settings/telegram/test', async () => ({ ok: true }))
}
+95
View File
@@ -0,0 +1,95 @@
import type { FastifyPluginAsync } from 'fastify'
import { desc } from 'drizzle-orm'
import { getDb, schema } from '@cfdm/db'
import { providerAccountsRepository } from '@cfdm/db/repositories/provider-accounts'
import { providersRepository } from '@cfdm/db/repositories/providers'
interface SyncLogRow {
id: string
accountId: string
startedAt: string
finishedAt: string | null
status: string | null
vpsCount: number | null
paymentsCount: number | null
error: string | null
summary: unknown
}
function mapSyncLog(row: typeof schema.syncLog.$inferSelect): SyncLogRow {
let summaryParsed: unknown = null
if (row.summary) {
try {
summaryParsed = JSON.parse(row.summary)
} catch {
summaryParsed = null
}
}
return {
id: row.id,
accountId: row.accountId,
startedAt: row.startedAt,
finishedAt: row.finishedAt,
status: row.status,
vpsCount: row.vpsCount,
paymentsCount: row.paymentsCount,
error: row.error,
summary: summaryParsed,
}
}
export const syncRoutes: FastifyPluginAsync = async (app) => {
app.get('/api/sync/status', async () => {
const rows = getDb()
.select()
.from(schema.syncLog)
.orderBy(desc(schema.syncLog.startedAt))
.limit(50)
.all()
return rows.map(mapSyncLog)
})
app.post<{ Params: { accountId: string } }>('/api/sync/:accountId', async (req, reply) => {
const account = providerAccountsRepository.getWithCredentials(req.params.accountId)
if (!account) {
return reply.code(404).send({ error: { code: 'NOT_FOUND', message: 'Account not found' } })
}
const provider = account.providerId ? providersRepository.get(account.providerId) : undefined
// TODO: port billmanager sync job
return reply.code(501).send({
accountId: req.params.accountId,
provider: provider?.name ?? null,
status: 'pending-migration',
note: 'Sync job port pending migration from Express adapters',
})
})
app.get<{ Params: { accountId: string } }>('/api/sync/:accountId/balance', async (req, reply) => {
const account = providerAccountsRepository.getWithCredentials(req.params.accountId)
if (!account) {
return reply.code(404).send({ error: { code: 'NOT_FOUND', message: 'Account not found' } })
}
// TODO: port fetchDashboardInfo
return reply.code(501).send({
accountId: req.params.accountId,
status: 'pending-migration',
note: 'Balance fetch pending migration from Express adapters',
})
})
app.post('/api/sync/test-connection', async (req, reply) => {
const { apiBaseUrl, apiCredentials } = (req.body ?? {}) as {
apiBaseUrl?: string
apiCredentials?: string
}
if (!apiBaseUrl?.trim() || !apiCredentials?.trim()) {
return reply.code(400).send({ error: { code: 'VALIDATION', message: 'Укажите URL и учётные данные' } })
}
// TODO: port testConnection
return reply.code(501).send({
ok: false,
status: 'pending-migration',
note: 'Connection test pending migration from Express adapters',
})
})
}
+59
View File
@@ -0,0 +1,59 @@
import type { FastifyPluginAsync } from 'fastify'
import { vpsRepository } from '@cfdm/db/repositories/vps'
import { vpsSchema } from '@cfdm/shared/contracts/vps'
export const vpsRoutes: FastifyPluginAsync = async (app) => {
app.get('/api/vps', async () => vpsRepository.list())
app.post('/api/vps', async (req, reply) => {
const parsed = vpsSchema.safeParse(req.body)
if (!parsed.success) {
return reply.code(400).send({ error: { code: 'VALIDATION', message: parsed.error.message } })
}
return reply.code(201).send(vpsRepository.create(parsed.data))
})
app.put<{ Params: { id: string } }>('/api/vps/:id', async (req, reply) => {
const parsed = vpsSchema.partial().safeParse(req.body)
if (!parsed.success) {
return reply.code(400).send({ error: { code: 'VALIDATION', message: parsed.error.message } })
}
const updated = vpsRepository.update(req.params.id, parsed.data)
if (!updated) {
return reply.code(404).send({ error: { code: 'NOT_FOUND', message: 'Not found' } })
}
return updated
})
app.delete<{ Params: { id: string } }>('/api/vps/:id', async (req, reply) => {
const ok = vpsRepository.delete(req.params.id)
if (!ok) {
return reply.code(404).send({ error: { code: 'NOT_FOUND', message: 'Not found' } })
}
return reply.code(204).send()
})
app.patch('/api/vps/bulk', async (req, reply) => {
const body = req.body as { ids?: string[]; action?: string; value?: unknown }
const ids = Array.isArray(body.ids) ? body.ids : []
if (ids.length === 0) {
return reply.code(400).send({ error: { code: 'VALIDATION', message: 'ids must be a non-empty array' } })
}
if (body.action === 'status') {
const validStatus = ['active', 'paused', 'archived']
const value = String(body.value ?? '')
if (!validStatus.includes(value)) {
return reply.code(400).send({ error: { code: 'VALIDATION', message: 'value must be active, paused, or archived' } })
}
return { updated: vpsRepository.bulkStatus(ids, value), status: value }
}
if (body.action === 'delete') {
return { deleted: vpsRepository.bulkDelete(ids) }
}
if (body.action === 'project') {
const value = body.value == null ? '' : String(body.value)
return vpsRepository.bulkProject(ids, value)
}
return reply.code(400).send({ error: { code: 'VALIDATION', message: 'action must be status, delete, or project' } })
})
}
+14
View File
@@ -0,0 +1,14 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"module": "NodeNext",
"moduleResolution": "NodeNext",
"target": "ES2022",
"outDir": "./dist",
"rootDir": "./src",
"noEmit": false,
"verbatimModuleSyntax": false,
"types": ["node"]
},
"include": ["src/**/*.ts"]
}
+20
View File
@@ -0,0 +1,20 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "base-nova",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "../../packages/ui/src/styles/globals.css",
"baseColor": "neutral",
"cssVariables": true
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/components",
"hooks": "@/hooks",
"lib": "@/lib",
"utils": "@cfdm/ui/lib/utils",
"ui": "@cfdm/ui/components"
}
}
+3 -3
View File
@@ -1,13 +1,13 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="ru">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> <link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>vps-tracker</title> <title>VPS Tracker</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
<script type="module" src="/src/main.jsx"></script> <script type="module" src="/src/main.tsx"></script>
</body> </body>
</html> </html>
+44
View File
@@ -0,0 +1,44 @@
{
"name": "@cfdm/web",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"lint": "eslint .",
"test": "vitest run"
},
"dependencies": {
"@base-ui/react": "^1.0.0",
"@cfdm/shared": "workspace:*",
"@cfdm/ui": "workspace:*",
"@hookform/resolvers": "^3.10.0",
"@tanstack/react-query": "^5.90.2",
"@tanstack/react-query-devtools": "^5.90.2",
"@tanstack/react-router": "^1.130.2",
"@tanstack/react-router-devtools": "^1.130.2",
"lucide-react": "^0.468.0",
"next-themes": "^0.4.6",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-hook-form": "^7.60.0",
"recharts": "3.8.0",
"sonner": "^1.7.0",
"zod": "^3.25.0"
},
"devDependencies": {
"@tailwindcss/vite": "^4.1.0",
"@tanstack/router-plugin": "^1.130.0",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"happy-dom": "^18.0.0",
"tailwindcss": "^4.1.0",
"tw-animate-css": "^1.0.0",
"typescript": "^5.9.2",
"vite": "^7.3.1",
"vitest": "^3.0.0"
}
}

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

@@ -0,0 +1,53 @@
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@cfdm/ui/components/alert-dialog'
import type { ReactElement, ReactNode } from 'react'
interface ConfirmDialogProps {
trigger: ReactElement
title: string
description?: ReactNode
confirmLabel?: string
cancelLabel?: string
destructive?: boolean
onConfirm: () => void
}
export function ConfirmDialog({
trigger,
title,
description,
confirmLabel = 'Подтвердить',
cancelLabel = 'Отмена',
destructive,
onConfirm,
}: ConfirmDialogProps) {
return (
<AlertDialog>
<AlertDialogTrigger render={trigger} />
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{title}</AlertDialogTitle>
{description ? <AlertDialogDescription>{description}</AlertDialogDescription> : null}
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{cancelLabel}</AlertDialogCancel>
<AlertDialogAction
variant={destructive ? 'destructive' : 'default'}
onClick={onConfirm}
>
{confirmLabel}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}
@@ -0,0 +1,82 @@
import type { ReactNode } from 'react'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@cfdm/ui/components/table'
import { TableCard } from './table-card'
import { EmptyState } from './empty-state'
export interface DataTableColumn<T> {
key: string
header: ReactNode
cell: (row: T, index: number) => ReactNode
className?: string
headerClassName?: string
}
interface DataTableCardProps<T> {
title?: ReactNode
description?: ReactNode
actions?: ReactNode
columns: DataTableColumn<T>[]
data: T[]
rowKey: (row: T, index: number) => string
emptyTitle?: string
emptyDescription?: string
emptyAction?: ReactNode
onRowClick?: (row: T) => void
}
export function DataTableCard<T>({
title,
description,
actions,
columns,
data,
rowKey,
emptyTitle = 'Нет записей',
emptyDescription,
emptyAction,
onRowClick,
}: DataTableCardProps<T>) {
return (
<TableCard title={title} description={description} actions={actions}>
{data.length === 0 ? (
<div className="p-4">
<EmptyState title={emptyTitle} description={emptyDescription} action={emptyAction} />
</div>
) : (
<Table>
<TableHeader>
<TableRow>
{columns.map((col) => (
<TableHead key={col.key} className={col.headerClassName}>
{col.header}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{data.map((row, index) => (
<TableRow
key={rowKey(row, index)}
onClick={onRowClick ? () => onRowClick(row) : undefined}
className={onRowClick ? 'cursor-pointer' : undefined}
>
{columns.map((col) => (
<TableCell key={col.key} className={col.className}>
{col.cell(row, index)}
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
)}
</TableCard>
)
}
+182
View File
@@ -0,0 +1,182 @@
import {
Bar,
BarChart,
CartesianGrid,
XAxis,
YAxis,
Cell,
Pie,
PieChart,
Tooltip as RechartsTooltip,
} from 'recharts'
import {
ChartContainer,
ChartTooltipContent,
type ChartConfig,
} from '@cfdm/ui/components/chart'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@cfdm/ui/components/card'
import type { ReactNode } from 'react'
import type { Vps, Provider, Payment, Settings, RatesData } from '@/types/entities'
import { convertCurrency, formatCurrency, monthKey, toIsoCurrency } from '@/lib/format'
import { providerByIdMap } from '@/lib/billmanager'
const EXPENSE_CONFIG: ChartConfig = {
expense: { label: 'Расход', color: 'var(--chart-1)' },
}
export function MonthlyExpenseChart({
vps,
providers,
settings,
ratesData,
className,
}: {
vps: Vps[]
providers: Provider[]
settings: Settings[]
ratesData: RatesData | null
className?: string
}) {
const baseCurrency = (settings[0]?.baseCurrency ?? 'RUB').toUpperCase()
const providerById = providerByIdMap(providers)
const monthlyByAccount = new Map<string, number>()
for (const v of vps) {
if (v.status !== 'active') continue
const provider = providerById.get(v.providerId)
const monthly = Number(v.monthlyRate || 0)
const daily = Number(v.dailyRate || 0)
const burn = v.tariffType === 'daily' ? daily * 30 : monthly
const fromCurrency = toIsoCurrency(provider?.baseCurrency || v.currency || baseCurrency)
const converted = convertCurrency(burn, fromCurrency, baseCurrency, ratesData)
monthlyByAccount.set(v.providerAccountId, (monthlyByAccount.get(v.providerAccountId) ?? 0) + converted)
}
const data = Array.from(monthlyByAccount.entries())
.map(([accountId, value]) => ({
accountId,
name: providerById.get(accountId)?.name ?? accountId,
expense: Math.round(value),
}))
.sort((a, b) => b.expense - a.expense)
.slice(0, 10)
return (
<Card className={className}>
<CardHeader>
<CardTitle>Расходы по хостерам (мес)</CardTitle>
<CardDescription>Топ-10 по monthly rate, в {baseCurrency}</CardDescription>
</CardHeader>
<CardContent>
<ChartContainer config={EXPENSE_CONFIG} className="h-72 w-full">
<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} />
<YAxis tickLine={false} axisLine={false} width={48} />
<RechartsTooltip cursor={false} content={<ChartTooltipContent formatter={(v) => formatCurrency(Number(v), baseCurrency)} />} />
<Bar dataKey="expense" fill="var(--color-expense)" radius={4} />
</BarChart>
</ChartContainer>
</CardContent>
</Card>
)
}
const PIE_COLORS = ['var(--chart-1)', 'var(--chart-2)', 'var(--chart-3)', 'var(--chart-4)', 'var(--chart-5)']
const PAYMENTS_CONFIG: ChartConfig = {
amount: { label: 'Платежи', color: 'var(--chart-2)' },
}
export function PaymentsPieChart({
payments,
settings,
ratesData,
className,
}: {
payments: Payment[]
settings: Settings[]
ratesData: RatesData | null
className?: string
}) {
const baseCurrency = (settings[0]?.baseCurrency ?? 'RUB').toUpperCase()
const byType = new Map<string, number>()
for (const p of payments) {
const converted = convertCurrency(Number(p.amount), toIsoCurrency(p.currency), baseCurrency, ratesData)
byType.set(p.type, (byType.get(p.type) ?? 0) + converted)
}
const data = Array.from(byType.entries()).map(([type, amount]) => ({ type, amount: Math.round(amount) }))
return (
<Card className={className}>
<CardHeader>
<CardTitle>Платежи по типам</CardTitle>
<CardDescription>Структура в {baseCurrency}</CardDescription>
</CardHeader>
<CardContent>
<ChartContainer config={PAYMENTS_CONFIG} className="mx-auto h-72 w-full">
<PieChart>
<RechartsTooltip content={<ChartTooltipContent nameKey="type" formatter={(v) => formatCurrency(Number(v), baseCurrency)} />} />
<Pie data={data} dataKey="amount" nameKey="type" innerRadius={50} outerRadius={90} strokeWidth={2}>
{data.map((_, i) => (
<Cell key={i} fill={PIE_COLORS[i % PIE_COLORS.length]} />
))}
</Pie>
</PieChart>
</ChartContainer>
</CardContent>
</Card>
)
}
export function MonthlyTrendChart({
payments,
settings,
ratesData,
className,
}: {
payments: Payment[]
settings: Settings[]
ratesData: RatesData | null
className?: string
}) {
const baseCurrency = (settings[0]?.baseCurrency ?? 'RUB').toUpperCase()
const byMonth = new Map<string, number>()
for (const p of payments) {
const key = monthKey(p.date)
if (!key) continue
const converted = convertCurrency(Number(p.amount), toIsoCurrency(p.currency), baseCurrency, ratesData)
byMonth.set(key, (byMonth.get(key) ?? 0) + converted)
}
const data = Array.from(byMonth.entries())
.map(([month, amount]) => ({ month, amount: Math.round(amount) }))
.sort((a, b) => a.month.localeCompare(b.month))
.slice(-12)
const trendConfig: ChartConfig = { amount: { label: 'Платежи', color: 'var(--chart-3)' } }
return (
<Card className={className}>
<CardHeader>
<CardTitle>Динамика платежей</CardTitle>
<CardDescription>Последние 12 месяцев, {baseCurrency}</CardDescription>
</CardHeader>
<CardContent>
<ChartContainer config={trendConfig} className="h-72 w-full">
<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} />
<YAxis tickLine={false} axisLine={false} width={48} />
<RechartsTooltip cursor={false} content={<ChartTooltipContent formatter={(v) => formatCurrency(Number(v), baseCurrency)} />} />
<Bar dataKey="amount" fill="var(--color-amount)" radius={4} />
</BarChart>
</ChartContainer>
</CardContent>
</Card>
)
}
export function ChartsGrid({ children }: { children: ReactNode }) {
return <div className="grid gap-4 lg:grid-cols-2">{children}</div>
}
+28
View File
@@ -0,0 +1,28 @@
import type { ReactNode } from 'react'
import { cn } from '@cfdm/ui/lib/utils'
interface EmptyStateProps {
title: string
description?: string
icon?: ReactNode
action?: ReactNode
className?: string
}
export function EmptyState({ title, description, icon, action, className }: EmptyStateProps) {
return (
<div
className={cn(
'flex flex-col items-center justify-center gap-3 rounded-lg border border-dashed p-8 text-center',
className,
)}
>
{icon ? <div className="text-muted-foreground">{icon}</div> : null}
<div className="space-y-1">
<p className="text-sm font-medium">{title}</p>
{description ? <p className="text-sm text-muted-foreground">{description}</p> : null}
</div>
{action ? <div className="mt-2">{action}</div> : null}
</div>
)
}
+22
View File
@@ -0,0 +1,22 @@
import type { ReactNode } from 'react'
import { Field, FieldError, FieldLabel } from '@cfdm/ui/components/field'
interface FormFieldProps {
label: string
htmlFor?: string
error?: string
invalid?: boolean
description?: ReactNode
children: ReactNode
}
export function FormField({ label, htmlFor, error, invalid, description, children }: FormFieldProps) {
return (
<Field data-invalid={invalid || Boolean(error)}>
<FieldLabel htmlFor={htmlFor}>{label}</FieldLabel>
{children}
{description ? <p className="text-sm text-muted-foreground">{description}</p> : null}
{error ? <FieldError>{error}</FieldError> : null}
</Field>
)
}
@@ -0,0 +1,64 @@
import type { ReactNode } from 'react'
import {
useForm,
type DefaultValues,
type FieldValues,
type SubmitHandler,
type UseFormReturn,
} from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import type { ZodType } from 'zod'
import { FormSheet } from './form-sheet'
interface FormSheetRhfProps<TField extends FieldValues> {
open: boolean
onOpenChange: (open: boolean) => void
title: string
description?: string
schema: ZodType<TField>
defaultValues: DefaultValues<TField>
onSubmit: (values: TField) => void
submitting?: boolean
submitLabel?: string
children: (form: UseFormReturn<TField>) => ReactNode
}
export function FormSheetRhf<TField extends FieldValues>({
open,
onOpenChange,
title,
description,
schema,
defaultValues,
onSubmit,
submitting,
submitLabel,
children,
}: FormSheetRhfProps<TField>) {
const form = useForm<TField>({
resolver: zodResolver(schema) as never,
defaultValues: defaultValues as DefaultValues<TField>,
mode: 'onBlur',
})
const submit: SubmitHandler<TField> = (values) => onSubmit(values)
return (
<FormSheet
open={open}
onOpenChange={(o) => {
if (!o) form.reset()
onOpenChange(o)
}}
trigger={null}
title={title}
description={description}
submitLabel={submitLabel}
submitting={submitting}
onSubmit={() => void form.handleSubmit(submit)()}
>
{children(form)}
</FormSheet>
)
}
+65
View File
@@ -0,0 +1,65 @@
import type { ReactElement, ReactNode } from 'react'
import {
Sheet,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
SheetTrigger,
} from '@cfdm/ui/components/sheet'
import { LoadingButton } from './loading-button'
interface FormSheetProps {
trigger?: ReactElement | null
title: string
description?: string
open?: boolean
onOpenChange?: (open: boolean) => void
onSubmit?: () => void
submitLabel?: string
submitting?: boolean
submitDisabled?: boolean
children: ReactNode
}
export function FormSheet({
trigger,
title,
description,
open,
onOpenChange,
onSubmit,
submitLabel = 'Сохранить',
submitting,
submitDisabled,
children,
}: FormSheetProps) {
return (
<Sheet open={open} onOpenChange={onOpenChange}>
{trigger ? <SheetTrigger render={trigger} /> : null}
<SheetContent side="right" className="w-full sm:max-w-md">
<SheetHeader>
<SheetTitle>{title}</SheetTitle>
{description ? <SheetDescription>{description}</SheetDescription> : null}
</SheetHeader>
<form
className="flex flex-1 flex-col gap-4 overflow-y-auto p-4"
onSubmit={(e) => {
e.preventDefault()
onSubmit?.()
}}
>
{children}
{onSubmit ? (
<SheetFooter className="mt-auto pt-4">
<LoadingButton type="submit" loading={submitting} disabled={submitDisabled}>
{submitLabel}
</LoadingButton>
</SheetFooter>
) : null}
</form>
</SheetContent>
</Sheet>
)
}
@@ -0,0 +1,124 @@
import {
LayoutDashboard,
Server,
ServerCog,
Building2,
Wallet,
CreditCard,
Coins,
ChartColumnBig,
ChartBar,
Settings,
} from 'lucide-react'
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarInset,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarProvider,
SidebarTrigger,
} from '@cfdm/ui/components/sidebar'
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbList,
BreadcrumbPage,
} from '@cfdm/ui/components/breadcrumb'
import { Separator } from '@cfdm/ui/components/separator'
import { Link, useRouterState } from '@tanstack/react-router'
import type { ReactNode } from 'react'
interface NavItem {
to: string
label: string
icon: typeof LayoutDashboard
}
const NAV_ITEMS: NavItem[] = [
{ to: '/dashboard', label: 'Дашборд', icon: LayoutDashboard },
{ to: '/vps', label: 'VPS', icon: Server },
{ to: '/tariffs', label: 'Активные тарифы', icon: ServerCog },
{ to: '/providers', label: 'Хостеры', icon: Building2 },
{ to: '/accounts', label: 'Аккаунты хостеров', icon: Wallet },
{ to: '/payments', label: 'Платежи', icon: CreditCard },
{ to: '/balance', label: 'Баланс и списания', icon: Coins },
{ to: '/reports', label: 'Отчёты', icon: ChartColumnBig },
{ to: '/resources', label: 'Ресурсы', icon: ChartBar },
{ to: '/settings', label: 'Настройки', icon: Settings },
]
const ROUTE_LABELS: Record<string, string> = Object.fromEntries(
NAV_ITEMS.map((i) => [i.to, i.label]),
)
export function AppShell({ children }: { children: ReactNode }) {
const pathname = useRouterState({ select: (s) => s.location.pathname })
const activeItem = NAV_ITEMS.find((i) => pathname.startsWith(i.to)) ?? NAV_ITEMS[0]
return (
<SidebarProvider>
<Sidebar collapsible="icon">
<SidebarHeader>
<div className="flex items-center gap-2 px-2 py-1.5">
<div className="flex size-8 items-center justify-center rounded-md bg-primary text-primary-foreground">
<Server className="size-4" />
</div>
<div className="flex flex-col text-left text-sm leading-tight group-data-[collapsible=icon]:hidden">
<span className="font-semibold">VPS Tracker</span>
<span className="text-xs text-muted-foreground">Учёт виртуальных серверов</span>
</div>
</div>
</SidebarHeader>
<SidebarContent>
<SidebarGroup>
<SidebarGroupLabel>Меню</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{NAV_ITEMS.map((item) => {
const Icon = item.icon
const isActive = pathname === item.to || pathname.startsWith(`${item.to}/`)
return (
<SidebarMenuItem key={item.to}>
<SidebarMenuButton
render={<Link to={item.to} />}
isActive={isActive}
tooltip={item.label}
>
<Icon />
<span>{item.label}</span>
</SidebarMenuButton>
</SidebarMenuItem>
)
})}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
<SidebarFooter />
</Sidebar>
<SidebarInset>
<header className="sticky top-0 flex h-16 shrink-0 items-center gap-2 border-b bg-background/95 px-4 backdrop-supports">
<SidebarTrigger />
<Separator orientation="vertical" className="mr-2 data-[orientation=vertical]:h-4" />
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbPage>{ROUTE_LABELS[activeItem.to] ?? ''}</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
</header>
<main className="flex flex-1 flex-col gap-4 p-4 md:gap-6 md:p-6">{children}</main>
</SidebarInset>
</SidebarProvider>
)
}
@@ -0,0 +1,19 @@
import { Button } from '@cfdm/ui/components/button'
import { Loader2Icon } from 'lucide-react'
import type { ButtonHTMLAttributes, ReactNode } from 'react'
type LoadingButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
loading?: boolean
variant?: 'default' | 'outline' | 'secondary' | 'ghost' | 'destructive' | 'link'
size?: 'default' | 'xs' | 'sm' | 'lg' | 'icon' | 'icon-xs' | 'icon-sm' | 'icon-lg'
children: ReactNode
}
export function LoadingButton({ loading, disabled, children, ...props }: LoadingButtonProps) {
return (
<Button disabled={disabled || loading} {...props}>
{loading ? <Loader2Icon className="animate-spin" data-icon="inline-start" /> : null}
{children}
</Button>
)
}
+19
View File
@@ -0,0 +1,19 @@
import type { ReactNode } from 'react'
interface PageHeaderProps {
title: string
description?: string
actions?: ReactNode
}
export function PageHeader({ title, description, actions }: PageHeaderProps) {
return (
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
<div className="space-y-1">
<h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
{description ? <p className="text-sm text-muted-foreground">{description}</p> : null}
</div>
{actions ? <div className="flex items-center gap-2">{actions}</div> : null}
</div>
)
}
+6
View File
@@ -0,0 +1,6 @@
import type { ReactNode } from 'react'
import { cn } from '@cfdm/ui/lib/utils'
export function PageShell({ children, className }: { children: ReactNode; className?: string }) {
return <div className={cn('flex flex-col gap-4 md:gap-6', className)}>{children}</div>
}
+67
View File
@@ -0,0 +1,67 @@
import type { ReactNode } from 'react'
import { AlertCircle, RefreshCwIcon } from 'lucide-react'
import { Button } from '@cfdm/ui/components/button'
import { Skeleton } from '@cfdm/ui/components/skeleton'
import { EmptyState } from './empty-state'
interface QueryStateProps<T> {
data: T | undefined
isLoading: boolean
isError: boolean
error?: unknown
empty?: boolean
emptyTitle?: string
emptyDescription?: string
emptyAction?: ReactNode
onRetry?: () => void
skeleton?: ReactNode
children: (data: T) => ReactNode
}
export function QueryState<T>({
data,
isLoading,
isError,
error,
empty,
emptyTitle = 'Нет данных',
emptyDescription,
emptyAction,
onRetry,
skeleton,
children,
}: QueryStateProps<T>) {
if (isLoading) {
return <>{skeleton ?? <DefaultSkeleton />}</>
}
if (isError) {
return (
<EmptyState
icon={<AlertCircle className="size-8" />}
title="Ошибка загрузки"
description={error instanceof Error ? error.message : 'Не удалось загрузить данные'}
action={
onRetry ? (
<Button variant="outline" size="sm" onClick={onRetry}>
<RefreshCwIcon data-icon="inline-start" />
Повторить
</Button>
) : null
}
/>
)
}
if (empty || data == null) {
return <EmptyState title={emptyTitle} description={emptyDescription} action={emptyAction} />
}
return <>{children(data)}</>
}
function DefaultSkeleton() {
return (
<div className="flex flex-col gap-3">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-32 w-full" />
</div>
)
}
+29
View File
@@ -0,0 +1,29 @@
import type { ReactElement, ReactNode } from 'react'
import { Card, CardContent } from '@cfdm/ui/components/card'
import { cn } from '@cfdm/ui/lib/utils'
export interface SectionCardItem {
label: ReactNode
value: string | number | ReactElement
hint?: ReactNode
icon?: ReactNode
}
export function SectionCards({ items, className }: { items: SectionCardItem[]; className?: string }) {
return (
<div className={cn('grid gap-4 sm:grid-cols-2 lg:grid-cols-4', className)}>
{items.map((item, idx) => (
<Card key={typeof item.label === 'string' ? item.label : idx} className="gap-0">
<CardContent className="flex flex-col gap-1 p-4">
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">{item.label}</span>
{item.icon ? <span className="text-muted-foreground">{item.icon}</span> : null}
</div>
<span className="text-2xl font-semibold tabular-nums">{item.value}</span>
{item.hint ? <span className="text-xs text-muted-foreground">{item.hint}</span> : null}
</CardContent>
</Card>
))}
</div>
)
}
+37
View File
@@ -0,0 +1,37 @@
import { SectionCards } from './section-cards'
import { Skeleton } from '@cfdm/ui/components/skeleton'
import { Card, CardContent } from '@cfdm/ui/components/card'
export function SectionCardsSkeleton({ count = 4 }: { count?: number }) {
return (
<SectionCards
items={Array.from({ length: count }, (_, i) => ({
label: <Skeleton className="h-4 w-24" key={`label-${i}`} />,
value: <Skeleton className="h-7 w-20" key={`value-${i}`} />,
}))}
/>
)
}
export function TableSkeleton({ rows = 6, cols = 4 }: { rows?: number; cols?: number }) {
return (
<Card className="gap-0">
<CardContent className="p-0">
<div className="flex flex-col">
<div className="flex gap-2 border-b p-3">
{Array.from({ length: cols }).map((_, i) => (
<Skeleton className="h-4 flex-1" key={`h-${i}`} />
))}
</div>
{Array.from({ length: rows }).map((_, r) => (
<div className="flex gap-2 border-b p-3" key={`r-${r}`}>
{Array.from({ length: cols }).map((_, c) => (
<Skeleton className="h-4 flex-1" key={`c-${r}-${c}`} />
))}
</div>
))}
</div>
</CardContent>
</Card>
)
}
+21
View File
@@ -0,0 +1,21 @@
import { Badge } from '@cfdm/ui/components/badge'
import type { ComponentProps } from 'react'
type BadgeVariant = NonNullable<ComponentProps<typeof Badge>['variant']>
const STATUS_VARIANT: Record<string, BadgeVariant> = {
active: 'default',
ok: 'default',
paid: 'default',
paused: 'secondary',
archived: 'outline',
error: 'destructive',
running: 'secondary',
overdue: 'destructive',
stale: 'destructive',
}
export function StatusBadge({ status, label }: { status: string; label?: string }) {
const variant = STATUS_VARIANT[status] ?? 'outline'
return <Badge variant={variant}>{label ?? status}</Badge>
}
+29
View File
@@ -0,0 +1,29 @@
import type { ReactNode } from 'react'
import { Card, CardContent, CardHeader, CardTitle } from '@cfdm/ui/components/card'
import { cn } from '@cfdm/ui/lib/utils'
interface TableCardProps {
title?: ReactNode
description?: ReactNode
actions?: ReactNode
children: ReactNode
className?: string
contentClassName?: string
}
export function TableCard({ title, description, actions, children, className, contentClassName }: TableCardProps) {
return (
<Card className={cn('gap-0', className)}>
{(title || actions) && (
<CardHeader className="flex flex-row items-center justify-between gap-2">
<div className="space-y-1">
{title ? <CardTitle>{title}</CardTitle> : null}
{description ? <p className="text-sm text-muted-foreground">{description}</p> : null}
</div>
{actions ? <div className="flex items-center gap-2">{actions}</div> : null}
</CardHeader>
)}
<CardContent className={cn('p-0', contentClassName)}>{children}</CardContent>
</Card>
)
}
+153
View File
@@ -0,0 +1,153 @@
import type {
DataSnapshot,
RatesData,
Settings,
Vps,
Provider,
ProviderAccount,
Payment,
BalanceLedgerRow,
} from '@/types/entities'
const API_BASE = import.meta.env.VITE_API_URL ?? ''
export class ApiError extends Error {
status?: number
constructor(message: string, status?: number) {
super(message)
this.name = 'ApiError'
this.status = status
}
}
async function fetchApi<T>(path: string, options: RequestInit = {}): Promise<T> {
const url = `${API_BASE}${path.startsWith('/') ? path : `/${path}`}`
const res = await fetch(url, {
headers: { 'Content-Type': 'application/json', ...options.headers },
...options,
})
if (!res.ok) {
let message = res.statusText || 'API error'
try {
const data = (await res.json()) as { error?: string }
if (data?.error) message = data.error
} catch {
/* ignore */
}
throw new ApiError(message, res.status)
}
if (res.status === 204) return null as T
return (await res.json()) as T
}
export type CollectionName =
| 'vps'
| 'providers'
| 'providerAccounts'
| 'payments'
| 'balanceLedger'
| 'settings'
const COLLECTION_PATHS: Record<CollectionName, string> = {
vps: '/api/vps',
providers: '/api/providers',
providerAccounts: '/api/provider-accounts',
payments: '/api/payments',
balanceLedger: '/api/balance-ledger',
settings: '/api/settings',
}
function uid(): string {
if (typeof crypto !== 'undefined' && crypto.randomUUID) return crypto.randomUUID()
return `id-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`
}
export const api = {
fetchData: () => fetchApi<DataSnapshot>('/api/data'),
fetchCollection: <T>(name: CollectionName) => fetchApi<T[]>(COLLECTION_PATHS[name]),
create: <T extends { id?: string }>(name: CollectionName, record: T) =>
fetchApi<T[]>(COLLECTION_PATHS[name], {
method: 'POST',
body: JSON.stringify({ ...record, id: record.id || uid() }),
}),
update: <T>(name: CollectionName, id: string, patch: Partial<T>) =>
fetchApi<T[]>(`${COLLECTION_PATHS[name]}/${encodeURIComponent(id)}`, {
method: 'PUT',
body: JSON.stringify(patch),
}),
remove: <T>(name: CollectionName, id: string) =>
fetchApi<T[]>(`${COLLECTION_PATHS[name]}/${encodeURIComponent(id)}`, { method: 'DELETE' }),
bulkUpdateVps: (ids: string[], action: string, value: unknown) =>
fetchApi('/api/vps/bulk', {
method: 'PATCH',
body: JSON.stringify({ ids, action, value }),
}),
syncAccount: (accountId: string, opts: Record<string, unknown> = {}) =>
fetchApi(`/api/sync/${encodeURIComponent(accountId)}`, {
method: 'POST',
body: JSON.stringify(opts),
}),
fetchAccountBalance: (accountId: string) =>
fetchApi<{ balance: number; currency: string }>(
`/api/sync/${encodeURIComponent(accountId)}/balance`,
),
testConnection: (apiBaseUrl: string, apiCredentials: string) =>
fetchApi('/api/sync/test-connection', {
method: 'POST',
body: JSON.stringify({ apiBaseUrl, apiCredentials }),
}),
fetchSyncStatus: () => fetchApi('/api/sync/status'),
sendTelegramTest: () =>
fetchApi('/api/settings/telegram/test', { method: 'POST' }),
fetchProjectSuggestions: (q = '', limit = 25) => {
const params = new URLSearchParams()
if (q) params.set('q', q)
params.set('limit', String(limit))
return fetchApi<string[]>(`/api/projects/suggest?${params.toString()}`)
},
downloadBackupJson: async (): Promise<Blob> => {
const res = await fetch(`${API_BASE}/api/backup/json`)
if (!res.ok) throw new ApiError(res.statusText || 'Ошибка выгрузки', res.status)
return res.blob()
},
downloadBackupDatabase: async (): Promise<Blob> => {
const res = await fetch(`${API_BASE}/api/backup/database`)
if (!res.ok) throw new ApiError(res.statusText || 'Ошибка выгрузки', res.status)
return res.blob()
},
importBackupJson: (payload: unknown) =>
fetchApi('/api/backup/json', { method: 'POST', body: JSON.stringify(payload) }),
importBackupDatabase: async (buffer: ArrayBuffer) => {
const res = await fetch(`${API_BASE}/api/backup/database`, {
method: 'POST',
headers: { 'Content-Type': 'application/octet-stream' },
body: buffer,
})
if (!res.ok) throw new ApiError(res.statusText || 'Ошибка восстановления', res.status)
return res.json()
},
}
export type {
DataSnapshot,
RatesData,
Settings,
Vps,
Provider,
ProviderAccount,
Payment,
BalanceLedgerRow,
}
+45
View File
@@ -0,0 +1,45 @@
import type { ProviderAccount, Provider } from '@/types/entities'
export function providerByIdMap(providers: Provider[]): Map<string, Provider> {
return new Map(providers.map((p) => [p.id, p]))
}
export function billmanagerSyncableAccounts(
providerAccounts: ProviderAccount[],
providers: Provider[],
): ProviderAccount[] {
const pmap = providerByIdMap(providers)
return providerAccounts.filter((a) => {
const p = pmap.get(a.providerId)
return p?.apiType === 'billmanager' && Boolean((p.apiBaseUrl || '').trim()) && a.apiCredentialsSet
})
}
export function accountBillmanagerUiReady(
account: ProviderAccount,
provider?: Provider | null,
): boolean {
return (
provider?.apiType === 'billmanager' &&
Boolean((provider.apiBaseUrl || '').trim()) &&
Boolean(account.apiCredentialsSet)
)
}
export function accountUsesBillmanagerBalanceApi(
account: ProviderAccount,
provider?: Provider | null,
): boolean {
return provider?.apiType === 'billmanager' && account.balance_api != null
}
export function accountSelectLabel(
account: ProviderAccount,
providerById: Map<string, Provider>,
scopedProviderId?: string,
): string {
const name = account.name?.trim() || '—'
if (scopedProviderId) return name
const providerName = providerById.get(account.providerId)?.name ?? '—'
return `${providerName} / ${name}`
}
+264
View File
@@ -0,0 +1,264 @@
import type { Settings, RatesData, Vps, Provider } from '@/types/entities'
export function uid(): string {
if (typeof crypto !== 'undefined' && crypto.randomUUID) return crypto.randomUUID()
return `id-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`
}
export function normalizeWebsiteUrl(website?: string): string {
if (!website) return ''
if (website.startsWith('http://') || website.startsWith('https://')) return website
return `https://${website}`
}
export function faviconUrlFromWebsite(website?: string): string {
const normalized = normalizeWebsiteUrl(website)
if (!normalized) return ''
try {
const { hostname } = new URL(normalized)
return `https://www.google.com/s2/favicons?domain=${hostname}&sz=32`
} catch {
return ''
}
}
const COUNTRY_CODE_BY_NAME: Record<string, string> = {
germany: 'DE', netherlands: 'NL', russia: 'RU', usa: 'US', 'united states': 'US',
ukraine: 'UA', poland: 'PL', france: 'FR', spain: 'ES', italy: 'IT',
estonia: 'EE', finland: 'FI', sweden: 'SE', norway: 'NO', latvia: 'LV',
lithuania: 'LT', czechia: 'CZ', czech: 'CZ', singapore: 'SG', japan: 'JP',
canada: 'CA', brazil: 'BR', turkey: 'TR', georgia: 'GE', kazakhstan: 'KZ',
}
export function getCountryFlagEmoji(country?: string): string {
if (!country) return '🌐'
const code = COUNTRY_CODE_BY_NAME[country.trim().toLowerCase()]
if (!code) return '🌐'
return code.toUpperCase().split('').map((c) => String.fromCodePoint(127397 + c.charCodeAt(0))).join('')
}
const PAYMENT_TYPE_LABELS: Record<string, string> = {
direct_vps_payment: 'Прямой платеж за VPS',
provider_balance_topup: 'Пополнение баланса хостера',
daily_debit: 'Ежедневное списание',
monthly_debit: 'Ежемесячное списание',
}
export function paymentTypeLabel(type: string): string {
return PAYMENT_TYPE_LABELS[type] ?? type
}
const VPS_STATUS_LABELS: Record<string, string> = {
active: 'Активен', paused: 'Приостановлен', archived: 'Архив',
}
export function vpsStatusLabel(status: string): string {
return VPS_STATUS_LABELS[status] ?? status
}
const BILLING_MODE_LABELS: Record<string, string> = {
daily: 'Ежедневно', monthly: 'Ежемесячно',
}
export function billingModeLabel(mode: string): string {
return BILLING_MODE_LABELS[mode] ?? mode
}
const TARIFF_TYPE_LABELS: Record<string, string> = {
daily: 'Суточный', monthly: 'Месячный',
}
export function tariffTypeLabel(type: string): string {
return TARIFF_TYPE_LABELS[type] ?? type
}
const CURRENCY_SYMBOL_MAP: Record<string, string> = {
'€': 'EUR', '$': 'USD', '₽': 'RUB', '£': 'GBP', '¥': 'JPY', '₴': 'UAH', '₸': 'KZT',
}
export function toIsoCurrency(currency?: string | null): string {
if (!currency || typeof currency !== 'string') return 'USD'
const trimmed = currency.trim()
if (CURRENCY_SYMBOL_MAP[trimmed]) return CURRENCY_SYMBOL_MAP[trimmed]
const upper = trimmed.toUpperCase()
if (upper === 'RUR') return 'RUB'
if (trimmed.length === 3 && /^[A-Z]{3}$/i.test(trimmed)) return upper
return 'USD'
}
export function effectiveVpsTariffCurrency(vps: Vps, provider?: Provider | null): string {
const provRaw = (provider?.baseCurrency || '').trim()
if (provRaw) return toIsoCurrency(provRaw)
const ownRaw = (vps?.currency || '').trim()
if (ownRaw) return toIsoCurrency(ownRaw)
return 'RUB'
}
export function formatCurrency(amount: number, currency = 'USD'): string {
const safeAmount = Number.isFinite(Number(amount)) ? Number(amount) : 0
const isoCurrency = toIsoCurrency(currency)
return new Intl.NumberFormat('ru-RU', {
style: 'currency', currency: isoCurrency, minimumFractionDigits: 2,
}).format(safeAmount)
}
export function parseProviderFxRate(raw: unknown): number {
if (raw == null) return NaN
const s = String(raw).trim()
if (!s) return NaN
if (s.toLowerCase() === 'auto') return NaN
const n = Number(s.replace(',', '.'))
if (!Number.isFinite(n) || n <= 0) return NaN
return n
}
export function normalizeRatesPayload(payload: unknown): RatesData | null {
if (!payload || typeof payload !== 'object') return null
const p = payload as Record<string, unknown>
if (p.base && p.rates && typeof p.rates === 'object') return p as unknown as RatesData
const valute = p.Valute as Record<string, { CharCode?: string; Value?: number; Nominal?: number }> | undefined
if (valute && typeof valute === 'object') {
const rates: Record<string, number> = {}
for (const v of Object.values(valute)) {
const code = v?.CharCode
const val = Number(v?.Value)
const nom = Number(v?.Nominal) || 1
if (typeof code === 'string' && code.length === 3 && Number.isFinite(val) && val > 0 && nom > 0) {
rates[code] = nom / val
}
}
if (Object.keys(rates).length === 0) return null
return { base: 'RUB', rates, date: typeof p.Date === 'string' ? p.Date : '' }
}
return null
}
export function convertCurrency(
amount: number,
fromCurrency: string,
toCurrency: string,
ratesData: RatesData | null,
): number {
const safeAmount = Number(amount)
if (!Number.isFinite(safeAmount)) return 0
const from = toIsoCurrency(fromCurrency)
const to = toIsoCurrency(toCurrency)
if (!from || !to || from === to) return safeAmount
if (!ratesData || !ratesData.rates || !ratesData.base) return safeAmount
const apiBase = ratesData.base.toUpperCase()
const rates: Record<string, number> = { ...ratesData.rates, [apiBase]: 1 }
const rateFrom = rates[from]
const rateTo = rates[to]
if (!Number.isFinite(rateFrom) || rateFrom <= 0 || !Number.isFinite(rateTo) || rateTo <= 0) {
return safeAmount
}
return (safeAmount / rateFrom) * rateTo
}
export function formatInBaseCurrency(
amount: number,
currency: string,
appSettings: Settings[] | Settings | null | undefined,
ratesData: RatesData | null,
): string {
const settings: Partial<Settings> = Array.isArray(appSettings)
? (appSettings[0] ?? {})
: (appSettings ?? {})
const baseCurrency = settings.baseCurrency || 'RUB'
const autoConvert = settings.autoConvert !== false
if (!autoConvert) return formatCurrency(amount, currency)
const converted = convertCurrency(amount, currency, baseCurrency, ratesData)
return formatCurrency(converted, baseCurrency)
}
export interface ConvertedWithProvider {
value: number
currency: string
source: 'native' | 'provider' | 'global' | 'no-rates'
}
export function convertWithProviderRate(
amount: number,
currency: string,
provider: Provider | null | undefined,
appSettings: Settings[] | Settings | null,
ratesData: RatesData | null,
): ConvertedWithProvider {
const safeAmount = Number(amount)
const settings: Partial<Settings> = Array.isArray(appSettings)
? (appSettings[0] ?? {})
: (appSettings ?? {})
const appBase = (settings.baseCurrency || 'RUB').toUpperCase()
if (!Number.isFinite(safeAmount)) return { value: 0, currency: appBase, source: 'global' }
const fromCurrency = toIsoCurrency(currency || appBase)
if (fromCurrency === appBase) return { value: safeAmount, currency: appBase, source: 'native' }
const usdRate = parseProviderFxRate(provider?.usdRate)
const eurRate = parseProviderFxRate(provider?.eurRate)
if (fromCurrency === 'USD' && Number.isFinite(usdRate)) {
return { value: safeAmount * usdRate, currency: appBase, source: 'provider' }
}
if (fromCurrency === 'EUR' && Number.isFinite(eurRate)) {
return { value: safeAmount * eurRate, currency: appBase, source: 'provider' }
}
if (!ratesData || !ratesData.rates || !ratesData.base) {
return { value: safeAmount, currency: fromCurrency, source: 'no-rates' }
}
const converted = convertCurrency(safeAmount, fromCurrency, appBase, ratesData)
const oneConverted = convertCurrency(1, fromCurrency, appBase, ratesData)
const globalRatesWork = Number.isFinite(oneConverted) && Math.abs(oneConverted - 1) > 1e-8
return { value: converted, currency: appBase, source: globalRatesWork ? 'global' : 'no-rates' }
}
export function formatInProviderCurrency(
amount: number,
currency: string,
provider: Provider | null | undefined,
appSettings: Settings[] | Settings | null,
ratesData: RatesData | null,
): string {
const converted = convertWithProviderRate(amount, currency, provider, appSettings, ratesData)
return formatCurrency(converted.value, converted.currency)
}
export function monthKey(dateString: string): string {
const date = new Date(dateString)
if (Number.isNaN(date.getTime())) return ''
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`
}
export function toCsv(rows: Record<string, unknown>[]): string {
if (!rows.length) return ''
const headers = Object.keys(rows[0])
const escapeValue = (value: unknown) => {
const str = `${value ?? ''}`
if (str.includes('"') || str.includes(',') || str.includes('\n')) {
return `"${str.replaceAll('"', '""')}"`
}
return str
}
const lines = [headers.join(',')]
for (const row of rows) {
lines.push(headers.map((h) => escapeValue(row[h])).join(','))
}
return lines.join('\n')
}
export function downloadTextFile(fileName: string, content: string): void {
const blob = new Blob([content], { type: 'text/csv;charset=utf-8;' })
const url = URL.createObjectURL(blob)
const anchor = document.createElement('a')
anchor.href = url
anchor.download = fileName
anchor.click()
URL.revokeObjectURL(url)
}
export function downloadBlob(fileName: string, blob: Blob): void {
const url = URL.createObjectURL(blob)
const anchor = document.createElement('a')
anchor.href = url
anchor.download = fileName
anchor.click()
URL.revokeObjectURL(url)
}
@@ -1,119 +1,119 @@
import type {
Vps,
ProviderAccount,
Provider,
Payment,
BalanceLedgerRow,
SyncLogRow,
SyncSummary,
} from '@/types/entities'
import { getPaidUntilDate } from './paid-until' import { getPaidUntilDate } from './paid-until'
const STALE_SYNC_HOURS = 48 const STALE_SYNC_HOURS = 48
function ledgerRowsInAccountCurrency(account, balanceLedger) { function ledgerRowsInAccountCurrency(
account: ProviderAccount,
balanceLedger: BalanceLedgerRow[],
): BalanceLedgerRow[] {
const cur = (account.balance_currency || account.currency || '').trim() const cur = (account.balance_currency || account.currency || '').trim()
const rows = balanceLedger.filter((row) => row.providerAccountId === account.id) const rows = balanceLedger.filter((row) => row.providerAccountId === account.id)
if (!cur) return rows if (!cur) return rows
return rows.filter((row) => !row.currency || row.currency === cur) return rows.filter((row) => !row.currency || row.currency === cur)
} }
function ledgerBalanceInCurrency(account, balanceLedger) { function ledgerBalanceInCurrency(
account: ProviderAccount,
balanceLedger: BalanceLedgerRow[],
): number {
const filtered = ledgerRowsInAccountCurrency(account, balanceLedger) const filtered = ledgerRowsInAccountCurrency(account, balanceLedger)
const credits = filtered const credits = filtered.filter((r) => r.direction === 'credit').reduce((acc, r) => acc + Number(r.amount || 0), 0)
.filter((row) => row.direction === 'credit') const debits = filtered.filter((r) => r.direction === 'debit').reduce((acc, r) => acc + Number(r.amount || 0), 0)
.reduce((acc, row) => acc + Number(row.amount || 0), 0)
const debits = filtered
.filter((row) => row.direction === 'debit')
.reduce((acc, row) => acc + Number(row.amount || 0), 0)
return credits - debits return credits - debits
} }
/** export function accountHasApiLedgerMismatch(
* Сравниваем баланс из API с суммой по balance_ledger. account: ProviderAccount,
* Если в ledger нет ни одной строки по аккаунту (в валюте баланса) не считаем расхождением: balanceLedger: BalanceLedgerRow[],
* пользователь видит только API, а «0 из ledger» не противоречие, а отсутствие учёта. ): boolean {
*/
export function accountHasApiLedgerMismatch(account, balanceLedger) {
if (account.balance_api == null || !Number.isFinite(Number(account.balance_api))) return false if (account.balance_api == null || !Number.isFinite(Number(account.balance_api))) return false
const rows = ledgerRowsInAccountCurrency(account, balanceLedger) const rows = ledgerRowsInAccountCurrency(account, balanceLedger)
if (rows.length === 0) return false if (rows.length === 0) return false
const ledger = ledgerBalanceInCurrency(account, balanceLedger) const ledger = ledgerBalanceInCurrency(account, balanceLedger)
if (!Number.isFinite(ledger)) return false if (!Number.isFinite(ledger)) return false
const api = Number(account.balance_api) const apiBalance = Number(account.balance_api)
const diff = Math.abs(api - ledger) const diff = Math.abs(apiBalance - ledger)
const tol = Math.max(10, Math.abs(api) * 0.05) const tol = Math.max(10, Math.abs(apiBalance) * 0.05)
return diff > tol return diff > tol
} }
export function lastOkSyncFinishedAt(accountId, syncLog) { export function lastOkSyncFinishedAt(
const rows = (syncLog || []).filter( accountId: string,
(r) => r.accountId === accountId && r.status === 'ok' && r.finishedAt, syncLog: SyncLogRow[] = [],
) ): number | null {
let best = null const rows = syncLog.filter((r) => r.accountId === accountId && r.status === 'ok' && r.finishedAt)
let best: number | null = null
for (const r of rows) { for (const r of rows) {
const t = new Date(r.finishedAt).getTime() const t = new Date(r.finishedAt as string).getTime()
if (!Number.isNaN(t) && (!best || t > best)) best = t if (!Number.isNaN(t) && (best == null || t > best)) best = t
} }
return best return best
} }
/** export interface InventoryIssue {
* @param {{ key: string
* vps: object[], title: string
* providerAccounts: object[], count: number
* providers: object[], to: string
* payments: object[], hint?: string
* balanceLedger: object[], }
* syncLog?: object[],
* }} input export interface InventoryHealthInput {
*/ vps: Vps[]
export function computeInventoryHealth(input) { providerAccounts: ProviderAccount[]
providers?: Provider[]
payments: Payment[]
balanceLedger: BalanceLedgerRow[]
syncLog?: SyncLogRow[]
}
export function computeInventoryHealth(input: InventoryHealthInput): InventoryIssue[] {
const { vps, providerAccounts, providers = [], payments, balanceLedger, syncLog = [] } = input const { vps, providerAccounts, providers = [], payments, balanceLedger, syncLog = [] } = input
const providerById = new Map(providers.map((p) => [p.id, p])) const providerById = new Map(providers.map((p) => [p.id, p]))
const now = new Date() const now = new Date()
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()) const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate())
const ctx = { vps, providerAccounts, payments, balanceLedger, now } const ctx = { vps, providerAccounts, payments, balanceLedger, now }
/** @type {{ key: string, title: string, count: number, to: string, hint?: string }[]} */ const issues: InventoryIssue[] = []
const issues = []
const noProject = vps.filter((v) => v.status === 'active' && !(v.project || '').trim()) const noProject = vps.filter((v) => v.status === 'active' && !(v.project || '').trim())
if (noProject.length) { if (noProject.length) {
issues.push({ issues.push({ key: 'no-project', title: 'Активные VPS без проекта', count: noProject.length, to: '/vps?health=no-project' })
key: 'no-project',
title: 'Активные VPS без проекта',
count: noProject.length,
to: '/vps?health=no-project',
})
} }
const noRate = vps.filter((v) => { const noRate = vps.filter((v) => {
if (v.status !== 'active') return false if (v.status !== 'active') return false
const dr = Number(v.dailyRate || 0) const dr = Number(v.dailyRate || 0)
const mr = Number(v.monthlyRate || 0) const mr = Number(v.monthlyRate || 0)
const noMoney = const noMoney = (!Number.isFinite(dr) || dr <= 0) && (!Number.isFinite(mr) || mr <= 0)
(!Number.isFinite(dr) || dr <= 0) && (!Number.isFinite(mr) || mr <= 0)
const noCur = !(v.currency || '').trim() const noCur = !(v.currency || '').trim()
return noMoney || noCur return noMoney || noCur
}) })
if (noRate.length) { if (noRate.length) {
issues.push({ issues.push({ key: 'no-rate', title: 'Нет ставки или валюты', count: noRate.length, to: '/vps?health=no-rate' })
key: 'no-rate',
title: 'Нет ставки или валюты',
count: noRate.length,
to: '/vps?health=no-rate',
})
} }
const paidOverdue = vps.filter((v) => { const paidOverdue = vps.filter((v) => {
if (v.status !== 'active') return false if (v.status !== 'active') return false
const d = getPaidUntilDate(v, ctx) const d = getPaidUntilDate(v, ctx)
return d && d < todayStart return d != null && d < todayStart
}) })
if (paidOverdue.length) { if (paidOverdue.length) {
issues.push({ issues.push({ key: 'paid-overdue', title: 'Просрочена оплата (оценка)', count: paidOverdue.length, to: '/vps?health=paid-overdue' })
key: 'paid-overdue',
title: 'Просрочена оплата (оценка)',
count: paidOverdue.length,
to: '/vps?health=paid-overdue',
})
} }
const bmAccounts = providerAccounts.filter((a) => { const bmAccounts = providerAccounts.filter((a) => {
const p = providerById.get(a.providerId) const p = providerById.get(a.providerId)
return p?.apiType === 'billmanager' && (p.apiBaseUrl || '').trim() && a.apiCredentialsSet return p?.apiType === 'billmanager' && Boolean((p.apiBaseUrl || '').trim()) && a.apiCredentialsSet
}) })
const staleMs = STALE_SYNC_HOURS * 60 * 60 * 1000 const staleMs = STALE_SYNC_HOURS * 60 * 60 * 1000
const staleAccounts = bmAccounts.filter((a) => { const staleAccounts = bmAccounts.filter((a) => {
@@ -145,16 +145,16 @@ export function computeInventoryHealth(input) {
return issues return issues
} }
/** export function getStaleSyncAccountIds(
* @param {object[]} providerAccounts providerAccounts: ProviderAccount[],
* @param {object[]} providers providers: Provider[],
* @param {object[]} syncLog syncLog: SyncLogRow[] = [],
*/ now = new Date(),
export function getStaleSyncAccountIds(providerAccounts, providers, syncLog, now = new Date()) { ): string[] {
const providerById = new Map(providers.map((p) => [p.id, p])) const providerById = new Map(providers.map((p) => [p.id, p]))
const bmAccounts = providerAccounts.filter((a) => { const bmAccounts = providerAccounts.filter((a) => {
const p = providerById.get(a.providerId) const p = providerById.get(a.providerId)
return p?.apiType === 'billmanager' && (p.apiBaseUrl || '').trim() && a.apiCredentialsSet return p?.apiType === 'billmanager' && Boolean((p.apiBaseUrl || '').trim()) && a.apiCredentialsSet
}) })
const staleMs = STALE_SYNC_HOURS * 60 * 60 * 1000 const staleMs = STALE_SYNC_HOURS * 60 * 60 * 1000
return bmAccounts return bmAccounts
@@ -166,21 +166,17 @@ export function getStaleSyncAccountIds(providerAccounts, providers, syncLog, now
.map((a) => a.id) .map((a) => a.id)
} }
/** export function getBalanceMismatchAccountIds(
* @param {object[]} providerAccounts providerAccounts: ProviderAccount[],
* @param {object[]} balanceLedger balanceLedger: BalanceLedgerRow[],
*/ ): string[] {
export function getBalanceMismatchAccountIds(providerAccounts, balanceLedger) {
return providerAccounts.filter((a) => accountHasApiLedgerMismatch(a, balanceLedger)).map((a) => a.id) return providerAccounts.filter((a) => accountHasApiLedgerMismatch(a, balanceLedger)).map((a) => a.id)
} }
/** export function formatSyncSummaryLine(summary: SyncSummary | null | undefined): string {
* @param {object|null} summary
*/
export function formatSyncSummaryLine(summary) {
if (!summary || typeof summary !== 'object') return '' if (!summary || typeof summary !== 'object') return ''
if (summary.error) return String(summary.error) if (summary.error) return String(summary.error)
const parts = [] const parts: string[] = []
if (summary.added?.length) parts.push(`+${summary.added.length} VPS`) if (summary.added?.length) parts.push(`+${summary.added.length} VPS`)
if (summary.updated?.length) parts.push(`~${summary.updated.length} изм.`) if (summary.updated?.length) parts.push(`~${summary.updated.length} изм.`)
if (summary.paymentsAdded) parts.push(`+${summary.paymentsAdded} платежей`) if (summary.paymentsAdded) parts.push(`+${summary.paymentsAdded} платежей`)
+13
View File
@@ -0,0 +1,13 @@
import type { HTMLInputProps } from '@/types/dom'
export const noBrowserSuggestProps: HTMLInputProps = Object.freeze({
autoComplete: 'off',
'data-lpignore': 'true',
'data-1p-ignore': 'true',
'data-bwignore': 'true',
'data-form-type': 'other',
} as HTMLInputProps)
export const passwordCredentialInputProps: HTMLInputProps = Object.freeze({
autoComplete: 'new-password',
} as HTMLInputProps)
@@ -1,28 +1,29 @@
/** import type { Vps, ProviderAccount, Payment, BalanceLedgerRow } from '@/types/entities'
* Дата «оплачено до» для VPS (как на дашборде).
*/
function getAccountBalance(accountId, providerAccounts, balanceLedger) { function getAccountBalance(
accountId: string,
providerAccounts: ProviderAccount[],
balanceLedger: BalanceLedgerRow[],
): number {
const account = providerAccounts.find((a) => a.id === accountId) const account = providerAccounts.find((a) => a.id === accountId)
if (account?.balance_api != null && Number.isFinite(Number(account.balance_api))) { if (account?.balance_api != null && Number.isFinite(Number(account.balance_api))) {
return Number(account.balance_api) return Number(account.balance_api)
} }
const ledgerRows = balanceLedger.filter((row) => row.providerAccountId === accountId) const ledgerRows = balanceLedger.filter((row) => row.providerAccountId === accountId)
const credits = ledgerRows const credits = ledgerRows.filter((r) => r.direction === 'credit').reduce((acc, r) => acc + Number(r.amount || 0), 0)
.filter((row) => row.direction === 'credit') const debits = ledgerRows.filter((r) => r.direction === 'debit').reduce((acc, r) => acc + Number(r.amount || 0), 0)
.reduce((acc, row) => acc + Number(row.amount || 0), 0)
const debits = ledgerRows
.filter((row) => row.direction === 'debit')
.reduce((acc, row) => acc + Number(row.amount || 0), 0)
return credits - debits return credits - debits
} }
/** export interface PaidUntilContext {
* @param {object} item - VPS vps: Vps[]
* @param {{ vps: object[], providerAccounts: object[], payments: object[], balanceLedger: object[], now?: Date }} ctx providerAccounts: ProviderAccount[]
* @returns {Date|null} payments: Payment[]
*/ balanceLedger: BalanceLedgerRow[]
export function getPaidUntilDate(item, ctx) { now?: Date
}
export function getPaidUntilDate(item: Vps, ctx: PaidUntilContext): Date | null {
const { vps, providerAccounts, payments, balanceLedger, now = new Date() } = ctx const { vps, providerAccounts, payments, balanceLedger, now = new Date() } = ctx
if (item.status !== 'active') return null if (item.status !== 'active') return null
const account = providerAccounts.find((a) => a.id === item.providerAccountId) const account = providerAccounts.find((a) => a.id === item.providerAccountId)
@@ -37,19 +38,16 @@ export function getPaidUntilDate(item, ctx) {
: null : null
const isPaidUntilNextDay = const isPaidUntilNextDay =
paidUntilFromApi && paidUntilFromApi != null &&
(() => { (() => {
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()) const today = new Date(now.getFullYear(), now.getMonth(), now.getDate())
const diffMs = paidUntilFromApi - today const diffMs = paidUntilFromApi.getTime() - today.getTime()
const diffDays = Math.round(diffMs / (24 * 60 * 60 * 1000)) const diffDays = Math.round(diffMs / (24 * 60 * 60 * 1000))
return diffDays >= 0 && diffDays <= 2 return diffDays >= 0 && diffDays <= 2
})() })()
const shouldCalculateFromBalance = isDailyBilling || isPaidUntilNextDay const shouldCalculateFromBalance = isDailyBilling || isPaidUntilNextDay
if (!shouldCalculateFromBalance && paidUntilFromApi) return paidUntilFromApi
if (!shouldCalculateFromBalance && paidUntilFromApi) {
return paidUntilFromApi
}
const dailyRate = Number(item.dailyRate || 0) const dailyRate = Number(item.dailyRate || 0)
const monthlyRate = Number(item.monthlyRate || 0) const monthlyRate = Number(item.monthlyRate || 0)
+11
View File
@@ -0,0 +1,11 @@
import { QueryClient } from '@tanstack/react-query'
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 60_000,
retry: 1,
refetchOnWindowFocus: false,
},
},
})
+25
View File
@@ -0,0 +1,25 @@
import { QueryClient } from '@tanstack/react-query'
import {
createRouter as tanstackCreateRouter,
rootRouteId,
} from '@tanstack/react-router'
import { routeTree } from '../routeTree.gen'
import { queryClient } from './queryClient'
declare module '@tanstack/react-router' {
interface Register {
router: ReturnType<typeof createRouter>
}
}
export function createRouter(opts?: { context?: { queryClient: QueryClient } }) {
return tanstackCreateRouter({
routeTree,
context: opts?.context ?? { queryClient },
defaultPreload: 'intent',
scrollRestoration: true,
})
}
export { rootRouteId }
+92
View File
@@ -0,0 +1,92 @@
import { z } from 'zod'
export const vpsStatusSchema = z.enum(['active', 'paused', 'archived'])
export const tariffTypeSchema = z.enum(['daily', 'monthly'])
export const billingModeSchema = z.enum(['daily', 'monthly'])
export const paymentTypeSchema = z.enum([
'direct_vps_payment',
'provider_balance_topup',
'daily_debit',
'monthly_debit',
])
export const ledgerDirectionSchema = z.enum(['credit', 'debit'])
export const apiTypeSchema = z.enum(['billmanager', 'none'])
export const providerSchema = z.object({
id: z.string().min(1).optional(),
name: z.string().min(1, 'Название обязательно'),
website: z.string().url('Невалидный URL').or(z.literal('')).optional(),
apiType: apiTypeSchema,
apiBaseUrl: z.string().optional().default(''),
baseCurrency: z.string().min(1).default('RUB'),
usdRate: z.string().optional().default(''),
eurRate: z.string().optional().default(''),
supportPhone: z.string().optional().default(''),
supportUrl: z.string().optional().default(''),
notes: z.string().optional().default(''),
})
export const providerAccountSchema = z.object({
id: z.string().min(1).optional(),
providerId: z.string().min(1, 'Выберите хостера'),
name: z.string().min(1, 'Название обязательно'),
login: z.string().optional().default(''),
apiCredentials: z.string().optional().default(''),
billingMode: billingModeSchema.default('monthly'),
notes: z.string().optional().default(''),
})
export const vpsSchema = z.object({
id: z.string().min(1).optional(),
ip: z.string().min(1, 'IP обязателен'),
dns: z.string().optional().default(''),
providerId: z.string().min(1, 'Выберите хостера'),
providerAccountId: z.string().min(1, 'Выберите аккаунт'),
vcpu: z.coerce.number().int().min(0).default(1),
ramGb: z.coerce.number().min(0).default(1),
diskGb: z.coerce.number().min(0).default(10),
status: vpsStatusSchema.default('active'),
tariffType: tariffTypeSchema.default('monthly'),
currency: z.string().min(1, 'Валюта обязательна').default('RUB'),
monthlyRate: z.coerce.number().min(0).default(0),
dailyRate: z.coerce.number().min(0).default(0),
paidUntil: z.string().optional().default(''),
project: z.string().optional().default(''),
notes: z.string().optional().default(''),
})
export const paymentSchema = z.object({
id: z.string().min(1).optional(),
type: paymentTypeSchema,
date: z.string().min(1, 'Дата обязательна'),
amount: z.coerce.number().min(0, 'Сумма должна быть ≥ 0'),
currency: z.string().min(1).default('RUB'),
providerAccountId: z.string().min(1, 'Выберите аккаунт'),
note: z.string().optional().default(''),
})
export const balanceLedgerSchema = z.object({
providerAccountId: z.string().min(1, 'Выберите аккаунт'),
direction: ledgerDirectionSchema,
amount: z.coerce.number().min(0, 'Сумма должна быть ≥ 0'),
currency: z.string().min(1).default('RUB'),
date: z.string().min(1, 'Дата обязательна'),
note: z.string().optional().default(''),
})
export const settingsSchema = z.object({
id: z.string().optional(),
baseCurrency: z.string().min(1).default('RUB'),
ratesUrl: z.string().url('Невалидный URL').or(z.literal('')).optional(),
autoConvert: z.boolean().default(true),
syncEnabled: z.boolean().optional().default(true),
telegramChatId: z.string().optional().default(''),
telegramBotToken: z.string().optional().default(''),
})
export type ProviderFormValues = z.infer<typeof providerSchema>
export type ProviderAccountFormValues = z.infer<typeof providerAccountSchema>
export type VpsFormValues = z.infer<typeof vpsSchema>
export type PaymentFormValues = z.infer<typeof paymentSchema>
export type BalanceLedgerFormValues = z.infer<typeof balanceLedgerSchema>
export type SettingsFormValues = z.infer<typeof settingsSchema>
+24
View File
@@ -0,0 +1,24 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { QueryClientProvider } from '@tanstack/react-query'
import { RouterProvider } from '@tanstack/react-router'
import { Toaster } from '@cfdm/ui/components/sonner'
import '@cfdm/ui/globals.css'
import { queryClient } from '@/lib/queryClient'
import { createRouter } from '@/lib/router'
const router = createRouter({ context: { queryClient } })
const rootEl = document.getElementById('root')
if (!rootEl) throw new Error('Root element #root not found')
createRoot(rootEl).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
<Toaster richColors position="top-right" />
</QueryClientProvider>
</StrictMode>,
)
+47
View File
@@ -0,0 +1,47 @@
import { queryClient } from '../lib/queryClient'
import { api } from '../lib/api-client'
export const snapshotKeys = {
all: ['snapshot'] as const,
}
export const snapshotQueryOptions = () => ({
queryKey: snapshotKeys.all,
queryFn: () => api.fetchData(),
staleTime: 30_000,
})
export const ratesKeys = {
all: ['rates'] as const,
}
export const ratesQueryOptions = (ratesUrl?: string) => ({
queryKey: ratesKeys.all,
queryFn: async () => {
if (!ratesUrl) return null
const proxyUrl = `/api/rates-proxy?url=${encodeURIComponent(ratesUrl)}`
try {
const res = await fetch(proxyUrl)
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return await res.json()
} catch {
const direct = await fetch(ratesUrl)
if (!direct.ok) throw new Error(`HTTP ${direct.status}`)
return await direct.json()
}
},
enabled: Boolean(ratesUrl),
})
export const projectsKeys = {
suggest: (q: string) => ['projects', 'suggest', q] as const,
}
export const projectsSuggestQueryOptions = (q: string) => ({
queryKey: projectsKeys.suggest(q),
queryFn: () => api.fetchProjectSuggestions(q),
enabled: q.length >= 2,
})
export type SnapshotFromQuery = Awaited<ReturnType<typeof api.fetchData>>
export { queryClient }

Some files were not shown because too many files have changed in this diff Show More