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 <[email protected]>
This commit is contained in:
DenozordecandCursor committed 2026-06-26 13:42:05 +07:00
1 parent 8408155be6
commit 6fbd1a9113
202 files changed
+16734 -12145

No files matched your search

+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 3–5 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
---
# Структура проекта vps-tracker
## Папки
pnpm workspaces monorepo. Frontend — shadcn/ui + TanStack Router/Query + TS. Backend — Fastify + Drizzle + better-sqlite3 + TS.
- `server/` — Express backend, SQLite (sql.js)
- `src/` — React frontend (Vite)
- `server/adapters/` — один адаптер на провайдера API (billmanager)
- `server/routes/` — Express роутеры по сущностям
- `server/db/` — схема, миграции, seed
## Layout
```
vps-tracker/
├── 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)
- Роуты: `/api/vps`, `/api/provider-accounts`, `/api/sync/:accountId`
- Файлы: kebab-case (`provider-accounts.ts`, `row-mappers.ts`)
- Компоненты: PascalCase (`PageHeader.tsx`)
- Роуты API: `/api/vps`, `/api/provider-accounts`, `/api/sync/:accountId`
- ID записей: `vps-bm-{accountId}-{externalId}`, `pay-bm-{accountId}-{externalId}`
## Barrel exports
Модули с несколькими файлами экспортируют через `index.js`:
- `server/adapters/billmanager/index.js` — testConnection, syncFromBillmanager, fetchDashboardInfo
- `server/db/index.js` — initDb, getDb, saveDb
- `packages/ui` — `@cfdm/ui/components/*`, `@cfdm/ui/lib/utils`, `@cfdm/ui/hooks/*`, `@cfdm/ui/globals.css`
- `packages/shared` — `@cfdm/shared/contracts/*` (Zod), `@cfdm/shared/types/*`
- `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 = '[email protected]'; -- No index on email
```
* ✅ GOOD:
```sql
CREATE INDEX idx_users_email ON users(email);
SELECT id, name FROM users WHERE email = '[email protected]';
```
* **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
dist
**/dist
data
.git
.github
.cursor
eslint.config.js
*.md
!README.md
**/*.log
coverage
.DS_Store
Thumbs.db
.env
.env.*
!.env.example
coverage
*.log
.DS_Store
Thumbs.db
+5
View File
@@ -13,6 +13,11 @@ data
dist-ssr
*.local
# pnpm
.pnpm-store
coverage
*.tsbuildinfo
# Editor directories and files
.vscode/*
!.vscode/extensions.json
+62 -28
View File
@@ -4,33 +4,47 @@
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/
├── server/ # Express backend
│ ├── index.js # Точка входа, подключение роутов
│ ├── db/ # База данных (SQLite via sql.js)
│ │ ├── schema.js # CREATE TABLE
│ │ ├── migrations.js # Миграции схемы
│ │ ├── seed.js # Начальные данные из public/data
│ │ └── index.js # initDb, getDb, saveDb
│ ├── adapters/ # Интеграции с внешними API
│ │ └── billmanager/ # BILLmanager 6 API
│ │ ├── client.js # HTTP-запросы
│ │ ├── parsers.js # Парсинг ответов API
│ │ ├── mappers.js # Маппинг в модель vps-tracker
│ │ ├── operations.js # fetchVds, fetchPayments и т.д.
│ │ ├── sync.js # syncFromBillmanager
│ │ └── index.js # Barrel export
│ ├── routes/ # Express роутеры
│ ├── utils/ # row-mappers и др.
│ └── sync-scheduler.js # Планировщик синка
├── src/ # React frontend (Vite)
│ ├── pages/ # Страницы приложения
│ ├── components/ # UI-компоненты
│ └── lib/ # api.js, utils.js
└── public/data/ # JSON для seed (providers, vps, payments...)
├── apps/
│ ├── web/ # Vite SPA (TSX) — TanStack + shadcn/ui
│ │ └── src/
│ │ ├── routes/ # file-based (TanStack Router)
│ │ ├── queries/ # queryOptions + key factories
│ │ ├── components/ # shared + layout + domain
│ │ └── lib/ # api-client, queryClient, router, schemas
│ └── api/ # Fastify 5 API (TS)
│ └── src/
│ ├── routes/ # тонкие plugins
│ ├── services/ # бизнес-логика + billmanager/
│ │ └── billmanager/ # client, parsers, mappers, operations, sync
│ └── plugins/ # @fastify/* registration
├── packages/
│ ├── ui/ # @cfdm/ui — shadcn primitives
│ │ └── src/
│ │ ├── components/ # output `shadcn add` (не трогать под кейс)
│ │ ├── hooks/ # use-mobile и др.
│ │ ├── lib/utils.ts # cn()
│ │ └── styles/globals.css # только output `shadcn apply --only theme`
│ ├── shared/ # @cfdm/shared — Zod-контракты, общие типы
│ └── db/ # @cfdm/db — Drizzle schema, repositories, миграции
│ └── 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`
- **Тарифы** — `server/adapters/billmanager/operations.js` (fetchVdsOrderPricelist), `server/adapters/billmanager/sync.js`
- **VPS CRUD** — `server/routes/vps.js`
- **Платежи/баланс** — `server/routes/payments.js`, `server/routes/balance-ledger.js`
- **Курсы валют** — `src/lib/utils.js` (convertCurrency, formatInBaseCurrency), настройки в settings.ratesUrl
- **Sync (BILLmanager)** — `apps/api/src/services/billmanager/`, `apps/api/src/routes/sync.ts`, scheduler в `apps/api/src/services/`
- **Тарифы** — `apps/api/src/services/billmanager/operations.ts` (fetchVdsOrderPricelist), `apps/api/src/services/billmanager/sync.ts`
- **VPS CRUD** — `apps/api/src/routes/vps.ts`, `packages/db/src/repositories/vps.ts`
- **Платежи/баланс** — `apps/api/src/routes/payments.ts`, `apps/api/src/routes/balance-ledger.ts`
- **Курсы валют** — `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
@@ -60,3 +76,21 @@ vps-tracker/
- [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`
## Команды
```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
# Stage 1: Сборка фронтенда
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
ARG NODE_IMAGE=node:22-alpine
# Stage 2: Production dependencies + очистка node_modules
FROM node:22-alpine AS deps
############################
# Stage 1: build web + api
############################
FROM ${NODE_IMAGE} AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci --omit=dev && \
find node_modules -type f \( \
-name '*.md' -o -name '*.ts' -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
RUN corepack enable
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml tsconfig.base.json ./
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
# 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
RUN apk add --no-cache nodejs
WORKDIR /app
ENV NODE_ENV=production
ENV PORT=3001
COPY --from=deps /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
COPY server ./server
COPY --from=deps /app/apps/web/node_modules ./apps/web/node_modules
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
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"]
File renamed without changes.
File renamed without changes.
View File
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
+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"
}
}
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
+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' } })
})
}
File renamed without changes.
File renamed without changes.
File renamed without changes.
+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"]
}
File renamed without changes.
File renamed without changes.
+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>
<html lang="en">
<html lang="ru">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>vps-tracker</title>
<title>VPS Tracker</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
<script type="module" src="/src/main.tsx"></script>
</body>
</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"
}
}
File renamed without changes.

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'
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 rows = balanceLedger.filter((row) => row.providerAccountId === account.id)
if (!cur) return rows
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 credits = filtered
.filter((row) => row.direction === 'credit')
.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)
const credits = filtered.filter((r) => r.direction === 'credit').reduce((acc, r) => acc + Number(r.amount || 0), 0)
const debits = filtered.filter((r) => r.direction === 'debit').reduce((acc, r) => acc + Number(r.amount || 0), 0)
return credits - debits
}
/**
* Сравниваем баланс из API с суммой по balance_ledger.
* Если в ledger нет ни одной строки по аккаунту (в валюте баланса) — не считаем расхождением:
* пользователь видит только API, а «0 из ledger» — не противоречие, а отсутствие учёта.
*/
export function accountHasApiLedgerMismatch(account, balanceLedger) {
export function accountHasApiLedgerMismatch(
account: ProviderAccount,
balanceLedger: BalanceLedgerRow[],
): boolean {
if (account.balance_api == null || !Number.isFinite(Number(account.balance_api))) return false
const rows = ledgerRowsInAccountCurrency(account, balanceLedger)
if (rows.length === 0) return false
const ledger = ledgerBalanceInCurrency(account, balanceLedger)
if (!Number.isFinite(ledger)) return false
const api = Number(account.balance_api)
const diff = Math.abs(api - ledger)
const tol = Math.max(10, Math.abs(api) * 0.05)
const apiBalance = Number(account.balance_api)
const diff = Math.abs(apiBalance - ledger)
const tol = Math.max(10, Math.abs(apiBalance) * 0.05)
return diff > tol
}
export function lastOkSyncFinishedAt(accountId, syncLog) {
const rows = (syncLog || []).filter(
(r) => r.accountId === accountId && r.status === 'ok' && r.finishedAt,
)
let best = null
export function lastOkSyncFinishedAt(
accountId: string,
syncLog: SyncLogRow[] = [],
): number | null {
const rows = syncLog.filter((r) => r.accountId === accountId && r.status === 'ok' && r.finishedAt)
let best: number | null = null
for (const r of rows) {
const t = new Date(r.finishedAt).getTime()
if (!Number.isNaN(t) && (!best || t > best)) best = t
const t = new Date(r.finishedAt as string).getTime()
if (!Number.isNaN(t) && (best == null || t > best)) best = t
}
return best
}
/**
* @param {{
* vps: object[],
* providerAccounts: object[],
* providers: object[],
* payments: object[],
* balanceLedger: object[],
* syncLog?: object[],
* }} input
*/
export function computeInventoryHealth(input) {
export interface InventoryIssue {
key: string
title: string
count: number
to: string
hint?: string
}
export interface InventoryHealthInput {
vps: Vps[]
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 providerById = new Map(providers.map((p) => [p.id, p]))
const now = new Date()
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate())
const ctx = { vps, providerAccounts, payments, balanceLedger, now }
/** @type {{ key: string, title: string, count: number, to: string, hint?: string }[]} */
const issues = []
const issues: InventoryIssue[] = []
const noProject = vps.filter((v) => v.status === 'active' && !(v.project || '').trim())
if (noProject.length) {
issues.push({
key: 'no-project',
title: 'Активные VPS без проекта',
count: noProject.length,
to: '/vps?health=no-project',
})
issues.push({ key: 'no-project', title: 'Активные VPS без проекта', count: noProject.length, to: '/vps?health=no-project' })
}
const noRate = vps.filter((v) => {
if (v.status !== 'active') return false
const dr = Number(v.dailyRate || 0)
const mr = Number(v.monthlyRate || 0)
const noMoney =
(!Number.isFinite(dr) || dr <= 0) && (!Number.isFinite(mr) || mr <= 0)
const noMoney = (!Number.isFinite(dr) || dr <= 0) && (!Number.isFinite(mr) || mr <= 0)
const noCur = !(v.currency || '').trim()
return noMoney || noCur
})
if (noRate.length) {
issues.push({
key: 'no-rate',
title: 'Нет ставки или валюты',
count: noRate.length,
to: '/vps?health=no-rate',
})
issues.push({ key: 'no-rate', title: 'Нет ставки или валюты', count: noRate.length, to: '/vps?health=no-rate' })
}
const paidOverdue = vps.filter((v) => {
if (v.status !== 'active') return false
const d = getPaidUntilDate(v, ctx)
return d && d < todayStart
return d != null && d < todayStart
})
if (paidOverdue.length) {
issues.push({
key: 'paid-overdue',
title: 'Просрочена оплата (оценка)',
count: paidOverdue.length,
to: '/vps?health=paid-overdue',
})
issues.push({ key: 'paid-overdue', title: 'Просрочена оплата (оценка)', count: paidOverdue.length, to: '/vps?health=paid-overdue' })
}
const bmAccounts = providerAccounts.filter((a) => {
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 staleAccounts = bmAccounts.filter((a) => {
@@ -145,16 +145,16 @@ export function computeInventoryHealth(input) {
return issues
}
/**
* @param {object[]} providerAccounts
* @param {object[]} providers
* @param {object[]} syncLog
*/
export function getStaleSyncAccountIds(providerAccounts, providers, syncLog, now = new Date()) {
export function getStaleSyncAccountIds(
providerAccounts: ProviderAccount[],
providers: Provider[],
syncLog: SyncLogRow[] = [],
now = new Date(),
): string[] {
const providerById = new Map(providers.map((p) => [p.id, p]))
const bmAccounts = providerAccounts.filter((a) => {
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
return bmAccounts
@@ -166,21 +166,17 @@ export function getStaleSyncAccountIds(providerAccounts, providers, syncLog, now
.map((a) => a.id)
}
/**
* @param {object[]} providerAccounts
* @param {object[]} balanceLedger
*/
export function getBalanceMismatchAccountIds(providerAccounts, balanceLedger) {
export function getBalanceMismatchAccountIds(
providerAccounts: ProviderAccount[],
balanceLedger: BalanceLedgerRow[],
): string[] {
return providerAccounts.filter((a) => accountHasApiLedgerMismatch(a, balanceLedger)).map((a) => a.id)
}
/**
* @param {object|null} summary
*/
export function formatSyncSummaryLine(summary) {
export function formatSyncSummaryLine(summary: SyncSummary | null | undefined): string {
if (!summary || typeof summary !== 'object') return ''
if (summary.error) return String(summary.error)
const parts = []
const parts: string[] = []
if (summary.added?.length) parts.push(`+${summary.added.length} VPS`)
if (summary.updated?.length) parts.push(`~${summary.updated.length} изм.`)
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 @@
/**
* Дата «оплачено до» для VPS (как на дашборде).
*/
import type { Vps, ProviderAccount, Payment, BalanceLedgerRow } from '@/types/entities'
function getAccountBalance(accountId, providerAccounts, balanceLedger) {
function getAccountBalance(
accountId: string,
providerAccounts: ProviderAccount[],
balanceLedger: BalanceLedgerRow[],
): number {
const account = providerAccounts.find((a) => a.id === accountId)
if (account?.balance_api != null && Number.isFinite(Number(account.balance_api))) {
return Number(account.balance_api)
}
const ledgerRows = balanceLedger.filter((row) => row.providerAccountId === accountId)
const credits = ledgerRows
.filter((row) => row.direction === 'credit')
.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)
const credits = ledgerRows.filter((r) => r.direction === 'credit').reduce((acc, r) => acc + Number(r.amount || 0), 0)
const debits = ledgerRows.filter((r) => r.direction === 'debit').reduce((acc, r) => acc + Number(r.amount || 0), 0)
return credits - debits
}
/**
* @param {object} item - VPS
* @param {{ vps: object[], providerAccounts: object[], payments: object[], balanceLedger: object[], now?: Date }} ctx
* @returns {Date|null}
*/
export function getPaidUntilDate(item, ctx) {
export interface PaidUntilContext {
vps: Vps[]
providerAccounts: ProviderAccount[]
payments: Payment[]
balanceLedger: BalanceLedgerRow[]
now?: Date
}
export function getPaidUntilDate(item: Vps, ctx: PaidUntilContext): Date | null {
const { vps, providerAccounts, payments, balanceLedger, now = new Date() } = ctx
if (item.status !== 'active') return null
const account = providerAccounts.find((a) => a.id === item.providerAccountId)
@@ -37,19 +38,16 @@ export function getPaidUntilDate(item, ctx) {
: null
const isPaidUntilNextDay =
paidUntilFromApi &&
paidUntilFromApi != null &&
(() => {
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))
return diffDays >= 0 && diffDays <= 2
})()
const shouldCalculateFromBalance = isDailyBilling || isPaidUntilNextDay
if (!shouldCalculateFromBalance && paidUntilFromApi) {
return paidUntilFromApi
}
if (!shouldCalculateFromBalance && paidUntilFromApi) return paidUntilFromApi
const dailyRate = Number(item.dailyRate || 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 }
Loaded 100 of 202 files, more files were not shown because too many files have changed in this diff. Show more