Compare commits
34
Commits
d717719e35
...
0de0538218
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0de0538218 | ||
|
|
836c8a2936 | ||
|
|
a954591493 | ||
|
|
f681f72e8c | ||
|
|
4c7dfc45d6 | ||
|
|
a6f202f659 | ||
|
|
bbb6de29be | ||
|
|
af9bd44d44 | ||
|
|
b1315ffe0a | ||
|
|
63da57687d | ||
|
|
b87d7c6fad | ||
|
|
db1d26d324 | ||
|
|
a51b5f3594 | ||
|
|
6310ed8e02 | ||
|
|
7ef42ee7e8 | ||
|
|
ee5d5b228b | ||
|
|
e43cdc1b85 | ||
|
|
525eff0d09 | ||
|
|
8b38021200 | ||
|
|
bc0da072ee | ||
|
|
b2611d951b | ||
|
|
3e57728ddc | ||
|
|
3026f1d1ce | ||
|
|
6fbd1a9113 | ||
|
|
8408155be6 | ||
|
|
24e1b8f4ef | ||
|
|
0b4507e2d2 | ||
|
|
6bf270d715 | ||
|
|
92a986c858 | ||
|
|
a97ec8c121 | ||
|
|
c161eaf84a | ||
|
|
a4752d4379 | ||
|
|
f4517f03fc | ||
|
|
524cbba5e7 |
No files matched your search
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"codegraph": {
|
||||
"type": "stdio",
|
||||
"command": "codegraph",
|
||||
"args": [
|
||||
"serve",
|
||||
"--mcp",
|
||||
"--path",
|
||||
"C:\\Users\\shats\\Dev\\vps-tracker"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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).
|
||||
@@ -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)
|
||||
@@ -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).
|
||||
@@ -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).
|
||||
@@ -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
|
||||
```
|
||||
@@ -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
|
||||
@@ -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).
|
||||
@@ -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).
|
||||
@@ -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`
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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`
|
||||
@@ -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).
|
||||
@@ -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).
|
||||
@@ -0,0 +1,188 @@
|
||||
---
|
||||
description: Definitive guidelines for writing robust, performant, and secure SQLite code. Focuses on schema design, query optimization, and transaction management.
|
||||
globs: **/*
|
||||
---
|
||||
# sqlite Best Practices
|
||||
|
||||
> В проекте используется **better-sqlite3** через Drizzle (`packages/db`). WASM-`sql.js` выводится из эксплуатации. См. [`backend-drizzle.mdc`](backend-drizzle.mdc).
|
||||
|
||||
SQLite is the go-to embedded SQL engine for local, reliable storage. Adhere to these rules to ensure your SQLite code is maintainable, performant, and secure.
|
||||
|
||||
## 1. Data Modeling & Schema Design
|
||||
|
||||
Design your schema for integrity and performance from day one.
|
||||
|
||||
* **Primary Keys**: Always use `INTEGER PRIMARY KEY AUTOINCREMENT` for ID columns. This optimizes `rowid` lookups and simplifies ID generation.
|
||||
* ❌ BAD:
|
||||
```sql
|
||||
CREATE TABLE users (
|
||||
id TEXT PRIMARY KEY, -- Manual UUIDs or similar
|
||||
name TEXT NOT NULL
|
||||
);
|
||||
```
|
||||
* ✅ GOOD:
|
||||
```sql
|
||||
CREATE TABLE users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL
|
||||
);
|
||||
```
|
||||
|
||||
* **Data Types & Constraints**: Declare appropriate data types and enforce integrity with `NOT NULL`, `UNIQUE`, and `FOREIGN KEY` constraints.
|
||||
* ❌ BAD:
|
||||
```sql
|
||||
CREATE TABLE products (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT, -- Allows NULL, no uniqueness
|
||||
price REAL
|
||||
);
|
||||
```
|
||||
* ✅ GOOD:
|
||||
```sql
|
||||
CREATE TABLE products (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
price REAL NOT NULL,
|
||||
stock INTEGER DEFAULT 0,
|
||||
category_id INTEGER,
|
||||
FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE SET NULL
|
||||
);
|
||||
```
|
||||
|
||||
* **Naming Conventions**: Use `lower_case_snake_case` for all table, column, and index names. Avoid SQLite keywords as identifiers.
|
||||
* ❌ BAD: `CREATE TABLE My_Users ( UserId INTEGER PRIMARY KEY );`
|
||||
* ✅ GOOD: `CREATE TABLE my_users ( user_id INTEGER PRIMARY KEY );`
|
||||
|
||||
## 2. Performance Considerations
|
||||
|
||||
Optimize for speed by minimizing I/O and leveraging the SQLite engine.
|
||||
|
||||
* **Enable WAL Mode**: Always enable Write-Ahead Logging for better concurrency and write performance.
|
||||
* ❌ BAD: Default journal mode (`DELETE`).
|
||||
* ✅ GOOD (at database open or once):
|
||||
```sql
|
||||
PRAGMA journal_mode = WAL;
|
||||
```
|
||||
|
||||
* **Relax Synchronous Mode**: When using WAL, set `synchronous` to `NORMAL` for faster commits, accepting minimal risk of data loss on power failure (not app crash).
|
||||
* ❌ BAD: Default `synchronous = FULL`.
|
||||
* ✅ GOOD (at database open or once):
|
||||
```sql
|
||||
PRAGMA synchronous = NORMAL;
|
||||
```
|
||||
|
||||
* **Indexes**: Create indexes on columns frequently used in `WHERE`, `ORDER BY`, `GROUP BY`, or `JOIN` clauses. Avoid over-indexing.
|
||||
* ❌ BAD:
|
||||
```sql
|
||||
SELECT * FROM users WHERE email = 'test@example.com'; -- No index on email
|
||||
```
|
||||
* ✅ GOOD:
|
||||
```sql
|
||||
CREATE INDEX idx_users_email ON users(email);
|
||||
SELECT id, name FROM users WHERE email = 'test@example.com';
|
||||
```
|
||||
* **Multi-column Indexes**: For queries filtering/sorting on multiple columns, create a multi-column index matching the query order.
|
||||
```sql
|
||||
CREATE INDEX idx_products_category_price ON products(category_id, price);
|
||||
SELECT * FROM products WHERE category_id = 1 ORDER BY price DESC;
|
||||
```
|
||||
|
||||
* **Query Optimization**: Select only the columns you need. Push filtering, sorting, and aggregation into SQL.
|
||||
* ❌ BAD:
|
||||
```sql
|
||||
SELECT * FROM products; -- Fetch all columns
|
||||
-- Then filter/sort in application code
|
||||
```
|
||||
* ✅ GOOD:
|
||||
```sql
|
||||
SELECT id, name, price FROM products WHERE stock > 0 ORDER BY price ASC LIMIT 10;
|
||||
```
|
||||
|
||||
## 3. Transactions & Concurrency
|
||||
|
||||
Ensure data consistency and improve write performance with explicit transactions.
|
||||
|
||||
* **Wrap Writes in Transactions**: Group multiple `INSERT`, `UPDATE`, `DELETE` operations within a single transaction. This significantly reduces disk I/O.
|
||||
* ❌ BAD:
|
||||
```sql
|
||||
INSERT INTO logs (action) VALUES ('User created');
|
||||
INSERT INTO users (name) VALUES ('New User');
|
||||
INSERT INTO logs (action) VALUES ('User name updated');
|
||||
UPDATE users SET name = 'Updated User' WHERE id = 1;
|
||||
```
|
||||
* ✅ GOOD:
|
||||
```sql
|
||||
BEGIN;
|
||||
INSERT INTO logs (action) VALUES ('User created');
|
||||
INSERT INTO users (name) VALUES ('New User');
|
||||
INSERT INTO logs (action) VALUES ('User name updated');
|
||||
UPDATE users SET name = 'Updated User' WHERE id = 1;
|
||||
COMMIT;
|
||||
```
|
||||
|
||||
* **Error Handling**: Use `ROLLBACK` to revert all changes if any operation within a transaction fails.
|
||||
* ✅ GOOD:
|
||||
```sql
|
||||
BEGIN;
|
||||
-- Perform operations
|
||||
INSERT INTO users (name) VALUES ('Valid User');
|
||||
INSERT INTO users (name) VALUES (NULL); -- This will fail due to NOT NULL
|
||||
-- If an error occurs, catch it and:
|
||||
ROLLBACK;
|
||||
-- Else:
|
||||
COMMIT;
|
||||
```
|
||||
|
||||
## 4. Security Best Practices
|
||||
|
||||
Prevent common vulnerabilities like SQL injection.
|
||||
|
||||
* **Prepared Statements**: Always use prepared statements with bound parameters. NEVER concatenate user input directly into SQL queries.
|
||||
* ❌ BAD:
|
||||
```sql
|
||||
String name = userInput.getName();
|
||||
String sql = "INSERT INTO users (name) VALUES ('" + name + "');"; // SQL Injection risk!
|
||||
```
|
||||
* ✅ GOOD (using a typical API pattern):
|
||||
```sql
|
||||
PreparedStatement stmt = connection.prepareStatement("INSERT INTO users (name) VALUES (?);");
|
||||
stmt.setString(1, userInput.getName());
|
||||
stmt.executeUpdate();
|
||||
```
|
||||
|
||||
* **Enable Foreign Key Enforcement**: Always enable foreign key constraints at runtime. SQLite defaults to `OFF` for backward compatibility.
|
||||
* ❌ BAD: Forgetting to enable foreign keys, leading to orphaned records.
|
||||
* ✅ GOOD (at database open or once per connection):
|
||||
```sql
|
||||
PRAGMA foreign_keys = ON;
|
||||
```
|
||||
|
||||
* **File Permissions**: Store database files in write-protected directories and set restrictive file permissions to limit unauthorized access. This is OS-specific but critical.
|
||||
|
||||
## 5. Common Pitfalls & Gotchas
|
||||
|
||||
Avoid these common mistakes that lead to bugs and performance issues.
|
||||
|
||||
* **Forgetting `PRAGMA foreign_keys = ON;`**: This is the most common pitfall. Always enable it.
|
||||
* **Selecting `*`**: Only retrieve the columns you actually need.
|
||||
* **Application-level Filtering/Sorting**: Delegate these operations to SQL for better performance, especially on large datasets.
|
||||
* **Not Using Transactions**: Leads to slow writes and potential data inconsistencies.
|
||||
* **Using SQLite for High-Concurrency Writes**: SQLite is a single-writer database. If multiple processes need to write concurrently, consider a client-server RDBMS.
|
||||
|
||||
## 6. Testing Approaches
|
||||
|
||||
Ensure your data access logic is robust and correct.
|
||||
|
||||
* **In-Memory Databases**: Use `:memory:` databases for fast, isolated unit and integration tests of your data access layer.
|
||||
* ✅ GOOD (example in Python, similar patterns exist in other languages):
|
||||
```python
|
||||
import sqlite3
|
||||
conn = sqlite3.connect(':memory:')
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("CREATE TABLE test_data (id INTEGER PRIMARY KEY, value TEXT)")
|
||||
# ... run tests ...
|
||||
conn.close() # Database vanishes
|
||||
```
|
||||
|
||||
* **Seed Data**: Create consistent, reproducible test data for your tests.
|
||||
* **Mocking**: For higher-level tests, mock your database interactions to focus on business logic.
|
||||
@@ -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`
|
||||
@@ -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
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"plugins": {
|
||||
"cloudflare": {
|
||||
"enabled": true
|
||||
},
|
||||
"claude-plugins-official/typescript-lsp": {
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
}
|
||||
+6
-7
@@ -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
|
||||
@@ -13,6 +13,14 @@ data
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# pnpm
|
||||
.pnpm-store
|
||||
coverage
|
||||
*.tsbuildinfo
|
||||
|
||||
# Understand Anything (knowledge graph)
|
||||
.understand-anything/
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
|
||||
@@ -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)
|
||||
+35
-25
@@ -1,35 +1,45 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
# Stage 1: Сборка фронтенда
|
||||
############################
|
||||
# Stage 1: build web + api + prune to prod deps
|
||||
############################
|
||||
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
|
||||
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 2: Production dependencies + очистка node_modules
|
||||
FROM node:22-alpine AS deps
|
||||
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
|
||||
COPY apps ./apps
|
||||
COPY packages ./packages
|
||||
RUN pnpm --filter web build \
|
||||
&& pnpm --filter api build \
|
||||
&& pnpm prune --prod \
|
||||
&& rm -rf apps/web/src apps/api/src \
|
||||
apps/web/node_modules/.vite-temp \
|
||||
apps/web/tsconfig.json apps/api/tsconfig.json \
|
||||
&& find node_modules apps/*/node_modules packages/*/node_modules -type f \( \
|
||||
-name '*.md' -o -name '*.map' -o -name '*.ts' -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 2>/dev/null || true \
|
||||
&& find node_modules apps/*/node_modules packages/*/node_modules -type d -empty -delete 2>/dev/null || true
|
||||
|
||||
# Stage 3: Минимальный runtime (без npm/yarn/corepack)
|
||||
############################
|
||||
# Stage 2: minimal runtime (alpine + nodejs only)
|
||||
############################
|
||||
FROM alpine:3.21
|
||||
RUN apk add --no-cache nodejs
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY --from=build /app/dist ./dist
|
||||
COPY server ./server
|
||||
ENV NODE_ENV=production \
|
||||
PORT=3001 \
|
||||
RUNTIME=fastify
|
||||
# Single layer for app code + pruned prod node_modules (symlinks resolved by COPY)
|
||||
COPY --from=build /app ./
|
||||
EXPOSE 3001
|
||||
CMD ["node", "server/index.js"]
|
||||
# RUNTIME=express — legacy fallback (apps/api/index.js)
|
||||
CMD ["sh", "-c", "if [ \"$RUNTIME\" = \"express\" ]; then node apps/api/index.js; else node apps/api/dist/index.js; fi"]
|
||||
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.
@@ -12,16 +12,16 @@ import { MIGRATIONS } from './migrations.js'
|
||||
import { seed, isDbEmpty } from './seed.js'
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
// db/ is in server/, so .. = server, .. again = project root
|
||||
export const DB_PATH = join(__dirname, '..', '..', 'data', 'vps-tracker.db')
|
||||
const SEED_DIR = join(__dirname, '..', '..', 'public', 'data')
|
||||
// db/ is in apps/api/, so .. = apps/api, ../.. = apps, ../../.. = project root
|
||||
export const DB_PATH = join(__dirname, '..', '..', '..', 'data', 'vps-tracker.db')
|
||||
const SEED_DIR = join(__dirname, '..', '..', '..', 'apps', 'web', 'public', 'data')
|
||||
|
||||
let dbInstance = null
|
||||
/** @type {import('sql.js').SqlJsStatic | null} */
|
||||
let sqlJsFactory = null
|
||||
|
||||
export async function initDb() {
|
||||
const dataDir = join(__dirname, '..', '..', 'data')
|
||||
const dataDir = join(__dirname, '..', '..', '..', 'data')
|
||||
if (!existsSync(dataDir)) {
|
||||
mkdirSync(dataDir, { recursive: true })
|
||||
}
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -6,7 +6,7 @@ import { fileURLToPath } from 'url'
|
||||
import { initDb } from './db.js'
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
const distPath = join(__dirname, '..', 'dist')
|
||||
const distPath = join(__dirname, '..', 'web', 'dist')
|
||||
import dataRouter from './routes/data.js'
|
||||
import migrateRouter from './routes/migrate.js'
|
||||
import vpsRouter from './routes/vps.js'
|
||||
@@ -18,6 +18,7 @@ import settingsRouter from './routes/settings.js'
|
||||
import syncRouter from './routes/sync.js'
|
||||
import projectsRouter from './routes/projects.js'
|
||||
import backupRouter from './routes/backup.js'
|
||||
import ratesProxyRouter from './routes/rates-proxy.js'
|
||||
|
||||
const app = express()
|
||||
const PORT = process.env.PORT || 3001
|
||||
@@ -39,6 +40,7 @@ app.use(express.json({ limit: '50mb' }))
|
||||
app.use('/api/sync', syncRouter)
|
||||
app.use('/api/projects', projectsRouter)
|
||||
app.use('/api/backup', backupRouter)
|
||||
app.use('/api/rates-proxy', ratesProxyRouter)
|
||||
|
||||
if (existsSync(distPath)) {
|
||||
app.use(express.static(distPath))
|
||||
@@ -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.
@@ -0,0 +1,22 @@
|
||||
import { Router } from 'express'
|
||||
|
||||
const router = Router()
|
||||
|
||||
router.get('/', async (req, res) => {
|
||||
const url = req.query.url
|
||||
if (!url) {
|
||||
return res.status(400).json({ error: 'Missing url parameter' })
|
||||
}
|
||||
try {
|
||||
const response = await fetch(url, { signal: AbortSignal.timeout(10_000) })
|
||||
if (!response.ok) {
|
||||
return res.status(502).json({ error: `Upstream returned ${response.status}` })
|
||||
}
|
||||
const data = await response.json()
|
||||
res.json(data)
|
||||
} catch (err) {
|
||||
res.status(502).json({ error: err.message || 'Failed to fetch rates' })
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -0,0 +1,87 @@
|
||||
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'
|
||||
import { migrateRoutes } from './routes/migrate.js'
|
||||
import { startScheduler } from './services/scheduler.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)
|
||||
await app.register(migrateRoutes)
|
||||
|
||||
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()
|
||||
startScheduler()
|
||||
try {
|
||||
await app.listen({ port, host: '0.0.0.0' })
|
||||
} catch (err) {
|
||||
app.log.error(err)
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
void start()
|
||||
@@ -0,0 +1,77 @@
|
||||
import type { FastifyPluginAsync } from 'fastify'
|
||||
import { desc } from 'drizzle-orm'
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import { getDb, getDbPath, reloadDatabaseFromBuffer, schema } from '@cfdm/db'
|
||||
import { getSnapshot } from '@cfdm/db/repositories/snapshot'
|
||||
|
||||
import { importJsonSnapshot, type BackupPayload } from '../services/backup-import.js'
|
||||
import { restartScheduler } from '../services/scheduler.js'
|
||||
|
||||
const BACKUP_VERSION = 1
|
||||
|
||||
export const backupRoutes: FastifyPluginAsync = async (app) => {
|
||||
app.addContentTypeParser('application/octet-stream', { parseAs: 'buffer' }, (_req, body, done) => {
|
||||
done(null, body)
|
||||
})
|
||||
|
||||
app.get('/api/backup/json', async (_req, reply) => {
|
||||
const syncLog = getDb()
|
||||
.select()
|
||||
.from(schema.syncLog)
|
||||
.orderBy(desc(schema.syncLog.startedAt))
|
||||
.limit(500)
|
||||
.all()
|
||||
const snapshot = {
|
||||
backupVersion: BACKUP_VERSION,
|
||||
exportedAt: new Date().toISOString(),
|
||||
...getSnapshot(),
|
||||
syncLog,
|
||||
}
|
||||
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: 'Неверное тело запроса' } })
|
||||
}
|
||||
try {
|
||||
importJsonSnapshot(payload as BackupPayload)
|
||||
restartScheduler()
|
||||
return { ok: true }
|
||||
} catch (err) {
|
||||
req.log.error(err)
|
||||
const message = err instanceof Error ? err.message : 'Импорт не удался'
|
||||
return reply.code(500).send({ error: { code: 'INTERNAL_ERROR', message } })
|
||||
}
|
||||
})
|
||||
|
||||
app.post('/api/backup/database', async (req, reply) => {
|
||||
const buf = req.body as Buffer
|
||||
if (!Buffer.isBuffer(buf) || !buf.length) {
|
||||
return reply.code(400).send({ error: { code: 'VALIDATION', message: 'Пустой файл' } })
|
||||
}
|
||||
try {
|
||||
reloadDatabaseFromBuffer(buf)
|
||||
restartScheduler()
|
||||
return { ok: true }
|
||||
} catch (err) {
|
||||
req.log.error(err)
|
||||
const message = err instanceof Error ? err.message : 'Восстановление не удалось'
|
||||
return reply.code(500).send({ error: { code: 'INTERNAL_ERROR', message } })
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
import type { FastifyPluginAsync } from 'fastify'
|
||||
import { consolidateAllProviderApiSources, getSqlite } from '@cfdm/db'
|
||||
|
||||
export const migrateRoutes: FastifyPluginAsync = async (app) => {
|
||||
app.post('/api/migrate', async (req, reply) => {
|
||||
const data = req.body
|
||||
if (!data || typeof data !== 'object') {
|
||||
return reply.code(400).send({ error: { code: 'VALIDATION', message: 'Invalid payload' } })
|
||||
}
|
||||
|
||||
const payload = data as Record<string, unknown>
|
||||
const sqlite = getSqlite()
|
||||
|
||||
try {
|
||||
if (Array.isArray(payload.providers) && payload.providers.length > 0) {
|
||||
const stmt = sqlite.prepare(
|
||||
`INSERT OR REPLACE INTO providers (id, name, website, contact, baseCurrency, usdRate, eurRate, notes, apiType, apiBaseUrl) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
for (const raw of payload.providers) {
|
||||
const p = typeof raw === 'object' && raw !== null ? (raw as Record<string, unknown>) : {}
|
||||
stmt.run(
|
||||
p.id ?? '',
|
||||
p.name ?? '',
|
||||
p.website ?? '',
|
||||
p.contact ?? '',
|
||||
p.baseCurrency ?? '',
|
||||
p.usdRate ?? '',
|
||||
p.eurRate ?? '',
|
||||
p.notes ?? '',
|
||||
p.apiType ?? '',
|
||||
p.apiBaseUrl ?? '',
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(payload.providerAccounts) && payload.providerAccounts.length > 0) {
|
||||
const stmt = sqlite.prepare(
|
||||
`INSERT OR REPLACE INTO provider_accounts (id, providerId, name, panelUrl, currency, billingMode, notes, apiType, apiBaseUrl, apiCredentials) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
for (const raw of payload.providerAccounts) {
|
||||
const acc = typeof raw === 'object' && raw !== null ? (raw as Record<string, unknown>) : {}
|
||||
stmt.run(
|
||||
acc.id ?? '',
|
||||
acc.providerId ?? '',
|
||||
acc.name ?? '',
|
||||
acc.panelUrl ?? '',
|
||||
acc.currency ?? '',
|
||||
acc.billingMode ?? '',
|
||||
acc.notes ?? '',
|
||||
acc.apiType ?? '',
|
||||
acc.apiBaseUrl ?? '',
|
||||
acc.apiCredentials ?? '',
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(payload.vps) && payload.vps.length > 0) {
|
||||
const sql = `INSERT OR REPLACE INTO vps (id, ip, ipv6, additionalIps, dns, providerId, providerAccountId, country, city, datacenter, os, vcpu, ramGb, diskGb, diskType, virtualization, bandwidthTb, sshPort, rootUser, purpose, environment, project, projectId, monitoringEnabled, backupEnabled, status, tariffType, currency, dailyRate, monthlyRate, createdAt, paidUntil, notes, userOverrides) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
const stmt = sqlite.prepare(sql)
|
||||
for (const raw of payload.vps) {
|
||||
const v = typeof raw === 'object' && raw !== null ? (raw as Record<string, unknown>) : {}
|
||||
const additionalIps = Array.isArray(v.additionalIps) ? JSON.stringify(v.additionalIps) : '[]'
|
||||
const dailyRate = v.dailyRate === '' || v.dailyRate == null ? null : Number(v.dailyRate)
|
||||
const monthlyRate = v.monthlyRate === '' || v.monthlyRate == null ? null : Number(v.monthlyRate)
|
||||
const userOverrides = Array.isArray(v.userOverrides)
|
||||
? JSON.stringify(v.userOverrides)
|
||||
: String(v.userOverrides ?? '[]')
|
||||
stmt.run(
|
||||
v.id ?? '',
|
||||
v.ip ?? '',
|
||||
v.ipv6 ?? '',
|
||||
additionalIps,
|
||||
v.dns ?? '',
|
||||
v.providerId ?? '',
|
||||
v.providerAccountId ?? '',
|
||||
v.country ?? '',
|
||||
v.city ?? '',
|
||||
v.datacenter ?? '',
|
||||
v.os ?? '',
|
||||
v.vcpu ?? 0,
|
||||
v.ramGb ?? 0,
|
||||
v.diskGb ?? 0,
|
||||
v.diskType ?? '',
|
||||
v.virtualization ?? '',
|
||||
v.bandwidthTb ?? 0,
|
||||
v.sshPort ?? 22,
|
||||
v.rootUser ?? '',
|
||||
v.purpose ?? '',
|
||||
v.environment ?? '',
|
||||
v.project ?? '',
|
||||
v.projectId ?? null,
|
||||
v.monitoringEnabled ? 1 : 0,
|
||||
v.backupEnabled ? 1 : 0,
|
||||
v.status ?? 'active',
|
||||
v.tariffType ?? '',
|
||||
v.currency ?? '',
|
||||
dailyRate,
|
||||
monthlyRate,
|
||||
v.createdAt ?? '',
|
||||
v.paidUntil ?? '',
|
||||
v.notes ?? '',
|
||||
userOverrides,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(payload.payments) && payload.payments.length > 0) {
|
||||
const stmt = sqlite.prepare(
|
||||
`INSERT OR REPLACE INTO payments (id, type, date, amount, currency, providerAccountId, vpsId, note) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
for (const raw of payload.payments) {
|
||||
const pm = typeof raw === 'object' && raw !== null ? (raw as Record<string, unknown>) : {}
|
||||
stmt.run(
|
||||
pm.id ?? '',
|
||||
pm.type ?? '',
|
||||
pm.date ?? '',
|
||||
Number(pm.amount) || 0,
|
||||
pm.currency ?? '',
|
||||
pm.providerAccountId ?? '',
|
||||
pm.vpsId ?? '',
|
||||
pm.note ?? '',
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(payload.balanceLedger) && payload.balanceLedger.length > 0) {
|
||||
const stmt = sqlite.prepare(
|
||||
`INSERT OR REPLACE INTO balance_ledger (id, type, date, amount, currency, direction, providerAccountId, vpsId, note) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
for (const raw of payload.balanceLedger) {
|
||||
const bl = typeof raw === 'object' && raw !== null ? (raw as Record<string, unknown>) : {}
|
||||
stmt.run(
|
||||
bl.id ?? '',
|
||||
bl.type ?? '',
|
||||
bl.date ?? '',
|
||||
Number(bl.amount) || 0,
|
||||
bl.currency ?? '',
|
||||
bl.direction ?? '',
|
||||
bl.providerAccountId ?? '',
|
||||
bl.vpsId ?? '',
|
||||
bl.note ?? '',
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const settingsList = Array.isArray(payload.settings)
|
||||
? payload.settings
|
||||
: payload.settings
|
||||
? [payload.settings]
|
||||
: []
|
||||
if (settingsList.length > 0) {
|
||||
const stmt = sqlite.prepare(
|
||||
`INSERT OR REPLACE INTO settings (id, baseCurrency, ratesUrl, autoConvert, ratesUpdatedAt, syncEnabled, syncIntervalMinutes) VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
for (const raw of settingsList) {
|
||||
const s = typeof raw === 'object' && raw !== null ? (raw as Record<string, unknown>) : {}
|
||||
stmt.run(
|
||||
s.id ?? 'settings-main',
|
||||
s.baseCurrency ?? 'RUB',
|
||||
s.ratesUrl ?? '',
|
||||
s.autoConvert !== false ? 1 : 0,
|
||||
s.ratesUpdatedAt ?? '',
|
||||
s.syncEnabled ? 1 : 0,
|
||||
s.syncIntervalMinutes ?? 60,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
consolidateAllProviderApiSources(sqlite)
|
||||
return { ok: true }
|
||||
} catch (err) {
|
||||
req.log.error(err)
|
||||
const message = err instanceof Error ? err.message : 'Migration failed'
|
||||
return reply.code(500).send({ error: { code: 'INTERNAL_ERROR', message } })
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
}
|
||||
@@ -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))
|
||||
})
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
}
|
||||
@@ -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' } })
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { FastifyPluginAsync } from 'fastify'
|
||||
import { settingsRepository } from '@cfdm/db/repositories/settings'
|
||||
import { settingsSchema } from '@cfdm/shared/contracts/settings'
|
||||
|
||||
import { restartScheduler } from '../services/scheduler.js'
|
||||
import { sendTelegramMessage } from '../services/telegram.js'
|
||||
|
||||
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'
|
||||
const result = settingsRepository.upsert(id, parsed.data)
|
||||
restartScheduler()
|
||||
return reply.code(201).send(result)
|
||||
})
|
||||
|
||||
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 } })
|
||||
}
|
||||
const result = settingsRepository.upsert(req.params.id, parsed.data)
|
||||
restartScheduler()
|
||||
return result
|
||||
})
|
||||
|
||||
app.post('/api/settings/telegram/test', async () => {
|
||||
const settings = settingsRepository.getRow('settings-main')
|
||||
if (!settings?.telegramBotToken?.trim() || !settings.telegramChatId?.trim()) {
|
||||
return { ok: false, error: 'Укажите токен бота и chat ID в настройках' }
|
||||
}
|
||||
await sendTelegramMessage(
|
||||
settings.telegramBotToken,
|
||||
settings.telegramChatId,
|
||||
'✅ VPS Tracker: тестовое сообщение',
|
||||
settings.telegramMessageThreadId,
|
||||
)
|
||||
return { ok: true }
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import type { FastifyPluginAsync } from 'fastify'
|
||||
import { desc, eq } from 'drizzle-orm'
|
||||
import { getDb, schema } from '@cfdm/db'
|
||||
import { providerAccountsRepository } from '@cfdm/db/repositories/provider-accounts'
|
||||
import { providersRepository } from '@cfdm/db/repositories/providers'
|
||||
|
||||
import {
|
||||
billmanagerAccountRowForSync,
|
||||
fetchDashboardInfo,
|
||||
runBillmanagerAccountSync,
|
||||
testConnection,
|
||||
} from '../services/billmanager/index.js'
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
const BILLMANAGER_SETUP_ERROR =
|
||||
'Укажите в настройках хостера тип API BILLmanager и URL; в аккаунте — логин и пароль API'
|
||||
|
||||
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 }; Body: { onlyTariffs?: boolean } }>(
|
||||
'/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
|
||||
const syncRow = billmanagerAccountRowForSync(account, provider)
|
||||
if (!syncRow) {
|
||||
return reply.code(400).send({ error: { code: 'VALIDATION', message: BILLMANAGER_SETUP_ERROR } })
|
||||
}
|
||||
|
||||
const onlyTariffs = Boolean(req.body?.onlyTariffs)
|
||||
const opts = onlyTariffs ? { skipVpsPayments: true } : { skipTariffs: true }
|
||||
|
||||
try {
|
||||
const result = await runBillmanagerAccountSync(syncRow, opts)
|
||||
return {
|
||||
ok: true,
|
||||
synced: {
|
||||
vpsCount: result.vpsCount,
|
||||
paymentsCount: result.paymentsCount,
|
||||
tariffsCount: result.tariffsCount ?? 0,
|
||||
},
|
||||
}
|
||||
} catch (err) {
|
||||
req.log.error(err)
|
||||
const message = err instanceof Error ? err.message : 'Sync failed'
|
||||
return reply.code(500).send({ ok: false, error: message })
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
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' } })
|
||||
}
|
||||
const provider = account.providerId ? providersRepository.get(account.providerId) : undefined
|
||||
const syncRow = billmanagerAccountRowForSync(account, provider)
|
||||
if (!syncRow) {
|
||||
return reply.code(400).send({ error: { code: 'VALIDATION', message: BILLMANAGER_SETUP_ERROR } })
|
||||
}
|
||||
|
||||
try {
|
||||
const info = await fetchDashboardInfo(syncRow.apiBaseUrl, String(syncRow.apiCredentials).trim(), {
|
||||
fallbackCurrency: account.currency,
|
||||
})
|
||||
getDb()
|
||||
.update(schema.providerAccounts)
|
||||
.set({
|
||||
balanceApi: info.balance,
|
||||
balanceCurrency: info.currency || 'RUB',
|
||||
balanceUpdatedAt: new Date().toISOString(),
|
||||
enoughmoneyto: info.enoughmoneyto || '',
|
||||
})
|
||||
.where(eq(schema.providerAccounts.id, req.params.accountId))
|
||||
.run()
|
||||
return { ok: true, balance: info }
|
||||
} catch (err) {
|
||||
req.log.error(err)
|
||||
const message = err instanceof Error ? err.message : 'Failed to fetch balance'
|
||||
return reply.code(500).send({ ok: false, error: message })
|
||||
}
|
||||
})
|
||||
|
||||
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({ ok: false, error: 'Укажите URL и учётные данные' })
|
||||
}
|
||||
try {
|
||||
const result = await testConnection(apiBaseUrl.trim(), apiCredentials.trim())
|
||||
return result
|
||||
} catch (err) {
|
||||
req.log.error(err)
|
||||
const message = err instanceof Error ? err.message : 'Ошибка проверки'
|
||||
return reply.code(500).send({ ok: false, error: message })
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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' } })
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
import { getDb, getSqlite, schema, consolidateAllProviderApiSources } from '@cfdm/db'
|
||||
|
||||
export interface BackupPayload {
|
||||
providers?: unknown[]
|
||||
serverProjects?: unknown[]
|
||||
providerAccounts?: unknown[]
|
||||
settings?: unknown[] | unknown
|
||||
vps?: unknown[]
|
||||
payments?: unknown[]
|
||||
balanceLedger?: unknown[]
|
||||
activeTariffs?: unknown[]
|
||||
tariffSyncOptions?: unknown[]
|
||||
syncLog?: unknown[]
|
||||
}
|
||||
|
||||
function asObject(v: unknown): Record<string, unknown> {
|
||||
return typeof v === 'object' && v !== null ? (v as Record<string, unknown>) : {}
|
||||
}
|
||||
|
||||
export function importJsonSnapshot(data: BackupPayload): void {
|
||||
const db = getDb()
|
||||
const sqlite = getSqlite()
|
||||
|
||||
sqlite.exec('BEGIN')
|
||||
try {
|
||||
db.delete(schema.syncLog).run()
|
||||
db.delete(schema.tariffSyncOptions).run()
|
||||
db.delete(schema.activeTariffs).run()
|
||||
db.delete(schema.balanceLedger).run()
|
||||
db.delete(schema.payments).run()
|
||||
db.delete(schema.vps).run()
|
||||
db.delete(schema.providerAccounts).run()
|
||||
db.delete(schema.serverProjects).run()
|
||||
db.delete(schema.providers).run()
|
||||
db.delete(schema.settings).run()
|
||||
|
||||
for (const raw of Array.isArray(data.providers) ? data.providers : []) {
|
||||
const p = asObject(raw)
|
||||
db.insert(schema.providers)
|
||||
.values({
|
||||
id: String(p.id ?? ''),
|
||||
name: String(p.name ?? ''),
|
||||
website: String(p.website ?? ''),
|
||||
contact: String(p.contact ?? ''),
|
||||
baseCurrency: String(p.baseCurrency ?? ''),
|
||||
usdRate: String(p.usdRate ?? ''),
|
||||
eurRate: String(p.eurRate ?? ''),
|
||||
notes: String(p.notes ?? ''),
|
||||
apiType: String(p.apiType ?? ''),
|
||||
apiBaseUrl: String(p.apiBaseUrl ?? ''),
|
||||
})
|
||||
.run()
|
||||
}
|
||||
|
||||
for (const raw of Array.isArray(data.serverProjects) ? data.serverProjects : []) {
|
||||
const sp = asObject(raw)
|
||||
db.insert(schema.serverProjects)
|
||||
.values({
|
||||
id: String(sp.id ?? ''),
|
||||
name: String(sp.name ?? ''),
|
||||
color: sp.color != null ? String(sp.color) : null,
|
||||
sortOrder: Number(sp.sortOrder) || 0,
|
||||
notes: sp.notes != null ? String(sp.notes) : null,
|
||||
createdAt: sp.createdAt != null ? String(sp.createdAt) : null,
|
||||
})
|
||||
.run()
|
||||
}
|
||||
|
||||
for (const raw of Array.isArray(data.providerAccounts) ? data.providerAccounts : []) {
|
||||
const acc = asObject(raw)
|
||||
const alertRaw = acc.balance_alert_below ?? acc.balanceAlertBelow
|
||||
const alertBelow =
|
||||
alertRaw != null && alertRaw !== '' && Number.isFinite(Number(alertRaw)) ? Number(alertRaw) : null
|
||||
db.insert(schema.providerAccounts)
|
||||
.values({
|
||||
id: String(acc.id ?? ''),
|
||||
providerId: String(acc.providerId ?? ''),
|
||||
name: String(acc.name ?? ''),
|
||||
panelUrl: String(acc.panelUrl ?? ''),
|
||||
currency: String(acc.currency ?? ''),
|
||||
billingMode: String(acc.billingMode ?? ''),
|
||||
notes: String(acc.notes ?? ''),
|
||||
apiType: String(acc.apiType ?? ''),
|
||||
apiBaseUrl: String(acc.apiBaseUrl ?? ''),
|
||||
apiCredentials: String(acc.apiCredentials ?? ''),
|
||||
balanceApi: acc.balance_api != null ? Number(acc.balance_api) : acc.balanceApi != null ? Number(acc.balanceApi) : null,
|
||||
balanceCurrency:
|
||||
acc.balance_currency != null ? String(acc.balance_currency) : acc.balanceCurrency != null ? String(acc.balanceCurrency) : null,
|
||||
balanceUpdatedAt:
|
||||
acc.balance_updated_at != null ? String(acc.balance_updated_at) : acc.balanceUpdatedAt != null ? String(acc.balanceUpdatedAt) : null,
|
||||
enoughmoneyto: acc.enoughmoneyto != null ? String(acc.enoughmoneyto) : null,
|
||||
balanceAlertBelow: alertBelow,
|
||||
})
|
||||
.run()
|
||||
}
|
||||
|
||||
consolidateAllProviderApiSources(sqlite)
|
||||
|
||||
const settingsList = Array.isArray(data.settings)
|
||||
? data.settings
|
||||
: data.settings
|
||||
? [data.settings]
|
||||
: []
|
||||
for (const raw of settingsList) {
|
||||
const s = asObject(raw)
|
||||
let customFields = s.customFields
|
||||
if (Array.isArray(customFields)) customFields = JSON.stringify(customFields)
|
||||
db.insert(schema.settings)
|
||||
.values({
|
||||
id: String(s.id ?? 'settings-main'),
|
||||
baseCurrency: String(s.baseCurrency ?? 'RUB'),
|
||||
ratesUrl: String(s.ratesUrl ?? ''),
|
||||
autoConvert: s.autoConvert !== false && s.autoConvert !== 0 ? 1 : 0,
|
||||
ratesUpdatedAt: String(s.ratesUpdatedAt ?? ''),
|
||||
syncEnabled: s.syncEnabled ? 1 : 0,
|
||||
syncIntervalMinutes: Number(s.syncIntervalMinutes) || 60,
|
||||
syncTariffsIntervalMinutes: Number(s.syncTariffsIntervalMinutes) || 1440,
|
||||
telegramBotToken: String(s.telegramBotToken ?? ''),
|
||||
telegramChatId: String(s.telegramChatId ?? ''),
|
||||
telegramMessageThreadId: String(s.telegramMessageThreadId ?? ''),
|
||||
notifyPaymentExpiryEnabled: s.notifyPaymentExpiryEnabled ? 1 : 0,
|
||||
notifyNewTariffsEnabled: s.notifyNewTariffsEnabled ? 1 : 0,
|
||||
customFields: customFields != null ? String(customFields) : null,
|
||||
notifyLowBalanceEnabled: s.notifyLowBalanceEnabled ? 1 : 0,
|
||||
notifySyncDigestEnabled: s.notifySyncDigestEnabled ? 1 : 0,
|
||||
})
|
||||
.run()
|
||||
}
|
||||
|
||||
for (const raw of Array.isArray(data.vps) ? data.vps : []) {
|
||||
const v = asObject(raw)
|
||||
const additionalIps = Array.isArray(v.additionalIps) ? JSON.stringify(v.additionalIps) : '[]'
|
||||
const dailyRate = v.dailyRate === '' || v.dailyRate == null ? null : Number(v.dailyRate)
|
||||
const monthlyRate = v.monthlyRate === '' || v.monthlyRate == null ? null : Number(v.monthlyRate)
|
||||
const userOverrides = Array.isArray(v.userOverrides)
|
||||
? JSON.stringify(v.userOverrides)
|
||||
: String(v.userOverrides ?? '[]')
|
||||
db.insert(schema.vps)
|
||||
.values({
|
||||
id: String(v.id ?? ''),
|
||||
ip: String(v.ip ?? ''),
|
||||
ipv6: String(v.ipv6 ?? ''),
|
||||
additionalIps,
|
||||
dns: String(v.dns ?? ''),
|
||||
providerId: String(v.providerId ?? ''),
|
||||
providerAccountId: String(v.providerAccountId ?? ''),
|
||||
country: String(v.country ?? ''),
|
||||
city: String(v.city ?? ''),
|
||||
datacenter: String(v.datacenter ?? ''),
|
||||
os: String(v.os ?? ''),
|
||||
vcpu: Number(v.vcpu) || 0,
|
||||
ramGb: Number(v.ramGb) || 0,
|
||||
diskGb: Number(v.diskGb) || 0,
|
||||
diskType: String(v.diskType ?? ''),
|
||||
virtualization: String(v.virtualization ?? ''),
|
||||
bandwidthTb: Number(v.bandwidthTb) || 0,
|
||||
sshPort: Number(v.sshPort) || 22,
|
||||
rootUser: String(v.rootUser ?? ''),
|
||||
purpose: String(v.purpose ?? ''),
|
||||
environment: String(v.environment ?? ''),
|
||||
project: String(v.project ?? ''),
|
||||
projectId: v.projectId != null && v.projectId !== '' ? String(v.projectId) : null,
|
||||
monitoringEnabled: v.monitoringEnabled ? 1 : 0,
|
||||
backupEnabled: v.backupEnabled ? 1 : 0,
|
||||
status: String(v.status ?? 'active'),
|
||||
tariffType: String(v.tariffType ?? ''),
|
||||
currency: String(v.currency ?? ''),
|
||||
dailyRate,
|
||||
monthlyRate,
|
||||
createdAt: String(v.createdAt ?? ''),
|
||||
paidUntil: String(v.paidUntil ?? ''),
|
||||
notes: String(v.notes ?? ''),
|
||||
userOverrides,
|
||||
})
|
||||
.run()
|
||||
}
|
||||
|
||||
for (const raw of Array.isArray(data.payments) ? data.payments : []) {
|
||||
const pm = asObject(raw)
|
||||
db.insert(schema.payments)
|
||||
.values({
|
||||
id: String(pm.id ?? ''),
|
||||
type: String(pm.type ?? ''),
|
||||
date: String(pm.date ?? ''),
|
||||
amount: Number(pm.amount) || 0,
|
||||
currency: String(pm.currency ?? ''),
|
||||
providerAccountId: String(pm.providerAccountId ?? ''),
|
||||
vpsId: pm.vpsId != null ? String(pm.vpsId) : null,
|
||||
note: String(pm.note ?? ''),
|
||||
})
|
||||
.run()
|
||||
}
|
||||
|
||||
for (const raw of Array.isArray(data.balanceLedger) ? data.balanceLedger : []) {
|
||||
const bl = asObject(raw)
|
||||
db.insert(schema.balanceLedger)
|
||||
.values({
|
||||
id: String(bl.id ?? ''),
|
||||
type: String(bl.type ?? ''),
|
||||
date: String(bl.date ?? ''),
|
||||
amount: Number(bl.amount) || 0,
|
||||
currency: String(bl.currency ?? ''),
|
||||
direction: String(bl.direction ?? ''),
|
||||
providerAccountId: String(bl.providerAccountId ?? ''),
|
||||
vpsId: bl.vpsId != null ? String(bl.vpsId) : null,
|
||||
note: String(bl.note ?? ''),
|
||||
})
|
||||
.run()
|
||||
}
|
||||
|
||||
for (const raw of Array.isArray(data.activeTariffs) ? data.activeTariffs : []) {
|
||||
const t = asObject(raw)
|
||||
db.insert(schema.activeTariffs)
|
||||
.values({
|
||||
id: String(t.id ?? ''),
|
||||
providerAccountId: String(t.providerAccountId ?? ''),
|
||||
providerId: String(t.providerId ?? ''),
|
||||
externalId: String(t.externalId ?? ''),
|
||||
datacenterKey: String(t.datacenterKey ?? ''),
|
||||
datacenterName: String(t.datacenterName ?? ''),
|
||||
name: String(t.name ?? ''),
|
||||
desc: String(t.desc ?? ''),
|
||||
vcpu: Number(t.vcpu) || 0,
|
||||
ramGb: Number(t.ramGb) || 0,
|
||||
diskGb: Number(t.diskGb) || 0,
|
||||
diskType: String(t.diskType ?? ''),
|
||||
virtualization: String(t.virtualization ?? ''),
|
||||
channel: String(t.channel ?? ''),
|
||||
location: String(t.location ?? ''),
|
||||
country: String(t.country ?? ''),
|
||||
cpuModel: String(t.cpuModel ?? ''),
|
||||
orderAvailable: t.orderAvailable ? 1 : 0,
|
||||
price: String(t.price ?? ''),
|
||||
syncedAt: String(t.syncedAt ?? ''),
|
||||
})
|
||||
.run()
|
||||
}
|
||||
|
||||
for (const raw of Array.isArray(data.tariffSyncOptions) ? data.tariffSyncOptions : []) {
|
||||
const o = asObject(raw)
|
||||
const dcs = typeof o.datacenters === 'string' ? o.datacenters : JSON.stringify(o.datacenters || [])
|
||||
const pers = typeof o.periods === 'string' ? o.periods : JSON.stringify(o.periods || [])
|
||||
db.insert(schema.tariffSyncOptions)
|
||||
.values({
|
||||
providerAccountId: String(o.providerAccountId ?? ''),
|
||||
datacenters: dcs,
|
||||
periods: pers,
|
||||
syncedAt: String(o.syncedAt ?? ''),
|
||||
})
|
||||
.run()
|
||||
}
|
||||
|
||||
for (const raw of Array.isArray(data.syncLog) ? data.syncLog : []) {
|
||||
const log = asObject(raw)
|
||||
db.insert(schema.syncLog)
|
||||
.values({
|
||||
id: String(log.id ?? ''),
|
||||
accountId: String(log.accountId ?? ''),
|
||||
startedAt: String(log.startedAt ?? ''),
|
||||
finishedAt: log.finishedAt != null ? String(log.finishedAt) : null,
|
||||
status: log.status != null ? String(log.status) : null,
|
||||
vpsCount: log.vpsCount != null ? Number(log.vpsCount) : null,
|
||||
paymentsCount: log.paymentsCount != null ? Number(log.paymentsCount) : null,
|
||||
error: log.error != null ? String(log.error) : null,
|
||||
summary:
|
||||
typeof log.summary === 'string'
|
||||
? log.summary
|
||||
: log.summary
|
||||
? JSON.stringify(log.summary)
|
||||
: null,
|
||||
})
|
||||
.run()
|
||||
}
|
||||
|
||||
sqlite.exec('COMMIT')
|
||||
} catch (err) {
|
||||
sqlite.exec('ROLLBACK')
|
||||
throw err
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* BILLmanager 6 API HTTP client
|
||||
* @see https://www.ispsystem.com/docs/b6c/developer-section/working-with-api/guide-to-ispsystem-software-api
|
||||
*/
|
||||
|
||||
interface BillmanagerErrorResponse {
|
||||
error?: { msg?: string; $t?: string }
|
||||
}
|
||||
|
||||
export async function billmanagerRequest(
|
||||
baseUrl: string,
|
||||
authinfo: string,
|
||||
func: string,
|
||||
params: Record<string, string | number | undefined | null> = {},
|
||||
): Promise<Record<string, unknown>> {
|
||||
const url = new URL(baseUrl)
|
||||
url.searchParams.set('authinfo', authinfo)
|
||||
url.searchParams.set('out', 'bjson')
|
||||
url.searchParams.set('func', func)
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
if (v != null && v !== '') url.searchParams.set(k, String(v))
|
||||
}
|
||||
const res = await fetch(url.toString(), { method: 'GET' })
|
||||
if (!res.ok) {
|
||||
throw new Error(`BILLmanager API HTTP ${res.status}: ${res.statusText}`)
|
||||
}
|
||||
const data = (await res.json()) as BillmanagerErrorResponse & Record<string, unknown>
|
||||
if (data.error) {
|
||||
throw new Error(data.error.msg || data.error.$t || 'BILLmanager API error')
|
||||
}
|
||||
return data
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { schema } from '@cfdm/db'
|
||||
|
||||
type AccountRow = typeof schema.providerAccounts.$inferSelect
|
||||
type ProviderRow = typeof schema.providers.$inferSelect
|
||||
|
||||
export interface BillmanagerSyncAccount extends AccountRow {
|
||||
apiType: 'billmanager'
|
||||
apiBaseUrl: string
|
||||
}
|
||||
|
||||
export function resolveBillmanagerApi(
|
||||
accountRow: AccountRow | null | undefined,
|
||||
providerRow: ProviderRow | null | undefined,
|
||||
): { apiType: string; apiBaseUrl: string } {
|
||||
const apiType = String(providerRow?.apiType || accountRow?.apiType || '').trim()
|
||||
const apiBaseUrl = String(providerRow?.apiBaseUrl || accountRow?.apiBaseUrl || '').trim()
|
||||
return { apiType, apiBaseUrl }
|
||||
}
|
||||
|
||||
export function billmanagerAccountRowForSync(
|
||||
accountRow: AccountRow | null | undefined,
|
||||
providerRow: ProviderRow | null | undefined,
|
||||
): BillmanagerSyncAccount | null {
|
||||
if (!accountRow) return null
|
||||
const { apiType, apiBaseUrl } = resolveBillmanagerApi(accountRow, providerRow)
|
||||
const cred = String(accountRow.apiCredentials || '').trim()
|
||||
if (apiType !== 'billmanager' || !apiBaseUrl || !cred) return null
|
||||
return { ...accountRow, apiType: 'billmanager', apiBaseUrl }
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* BILLmanager 6 API adapter
|
||||
* @see https://www.ispsystem.com/docs/b6c/developer-section/working-with-api/guide-to-ispsystem-software-api
|
||||
*/
|
||||
|
||||
export {
|
||||
testConnection,
|
||||
fetchVds,
|
||||
fetchDashboardInfo,
|
||||
fetchPayments,
|
||||
fetchVdsOrderPricelist,
|
||||
fetchVdsOrderPricelistAllDatacenters,
|
||||
} from './operations.js'
|
||||
export { syncFromBillmanager } from './sync.js'
|
||||
export { runBillmanagerAccountSync } from './sync-job.js'
|
||||
export { billmanagerAccountRowForSync, resolveBillmanagerApi } from './context.js'
|
||||
export type { BillmanagerSyncAccount } from './context.js'
|
||||
export type {
|
||||
SyncFromBillmanagerOptions,
|
||||
SyncFromBillmanagerResult,
|
||||
SyncSummary,
|
||||
} from './sync.js'
|
||||
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* BILLmanager API response → vps-tracker model mappers
|
||||
*/
|
||||
|
||||
import { parsePricelist } from './parsers.js'
|
||||
|
||||
const VDS_STATUS_MAP: Record<number, string> = {
|
||||
1: 'active',
|
||||
2: 'active',
|
||||
3: 'paused',
|
||||
4: 'archived',
|
||||
5: 'active',
|
||||
}
|
||||
|
||||
export interface MappedVps {
|
||||
externalId: string
|
||||
ip: string
|
||||
dns: string
|
||||
ipv6: string
|
||||
additionalIps: string[]
|
||||
providerId: string
|
||||
providerAccountId: string
|
||||
country: string
|
||||
city: string
|
||||
datacenter: string
|
||||
os: string
|
||||
vcpu: number
|
||||
ramGb: number
|
||||
diskGb: number
|
||||
diskType: string
|
||||
virtualization: string
|
||||
bandwidthTb: number
|
||||
sshPort: number
|
||||
rootUser: string
|
||||
purpose: string
|
||||
environment: string
|
||||
project: string
|
||||
monitoringEnabled: boolean
|
||||
backupEnabled: boolean
|
||||
status: string
|
||||
tariffType: string
|
||||
currency: string
|
||||
dailyRate: null
|
||||
monthlyRate: number | null
|
||||
createdAt: string
|
||||
paidUntil: string
|
||||
notes: string
|
||||
}
|
||||
|
||||
export interface MappedPayment {
|
||||
externalId: string
|
||||
type: string
|
||||
date: string
|
||||
amount: number
|
||||
currency: string
|
||||
providerAccountId: string
|
||||
vpsId: null
|
||||
note: string
|
||||
}
|
||||
|
||||
export function mapVdsToVps(
|
||||
item: Record<string, string>,
|
||||
providerId: string,
|
||||
providerAccountId: string,
|
||||
): MappedVps {
|
||||
const status =
|
||||
VDS_STATUS_MAP[Number(item.item_status_orig ?? item.item_status)] ?? 'active'
|
||||
const costStr = String(item.cost || '').replace(/[^\d.-]/g, '')
|
||||
const cost = parseFloat(costStr) || parseFloat(item.item_cost) || 0
|
||||
const createdate = item.createdate || ''
|
||||
const expiredate = item.real_expiredate || item.expiredate || ''
|
||||
const ip = (item.ip || '').trim()
|
||||
const domain = (item.domain || '').trim()
|
||||
const datacenter = (item.datacentername || item.datacenter || '').trim()
|
||||
const ostempl = (item.ostempl || '').trim()
|
||||
const currency = (item.currency_str || 'RUB').toString().trim() || 'RUB'
|
||||
|
||||
const pricelist = item.pricelist || item.tariff || item.plan || ''
|
||||
const parsed = parsePricelist(pricelist)
|
||||
|
||||
return {
|
||||
externalId: String(item.id || ''),
|
||||
ip: ip || domain || `bm-${item.id}`,
|
||||
dns: domain || ip || '',
|
||||
ipv6: '',
|
||||
additionalIps: [],
|
||||
providerId,
|
||||
providerAccountId,
|
||||
country: '',
|
||||
city: '',
|
||||
datacenter,
|
||||
os: ostempl,
|
||||
vcpu: parsed.vcpu || 0,
|
||||
ramGb: parsed.ramGb || 0,
|
||||
diskGb: parsed.diskGb || 0,
|
||||
diskType: parsed.diskType || 'NVMe',
|
||||
virtualization: parsed.virtualization || 'KVM',
|
||||
bandwidthTb: 0,
|
||||
sshPort: 22,
|
||||
rootUser: 'root',
|
||||
purpose: '',
|
||||
environment: 'prod',
|
||||
project: '',
|
||||
monitoringEnabled: false,
|
||||
backupEnabled: false,
|
||||
status,
|
||||
tariffType: 'monthly',
|
||||
currency: currency || 'RUB',
|
||||
dailyRate: null,
|
||||
monthlyRate: cost || null,
|
||||
createdAt: createdate ? createdate.slice(0, 10) : new Date().toISOString().slice(0, 10),
|
||||
paidUntil: expiredate ? expiredate.slice(0, 10) : '',
|
||||
notes: `bm-${item.id}`,
|
||||
}
|
||||
}
|
||||
|
||||
export function mapPaymentToPayment(
|
||||
item: Record<string, string>,
|
||||
providerAccountId: string,
|
||||
): MappedPayment | null {
|
||||
const rawAmount = item.subaccountamount_iso || item.paymethodamount_iso || '0'
|
||||
const amount = parseFloat(String(rawAmount).replace(/[^\d.-]/g, '')) || 0
|
||||
const createDate = item.create_date || item.createdate || ''
|
||||
const dateStr = createDate ? String(createDate).slice(0, 10) : new Date().toISOString().slice(0, 10)
|
||||
const statusNum = Number(item.status_orig ?? item.real_status ?? item.status)
|
||||
if (statusNum !== 4) return null
|
||||
return {
|
||||
externalId: String(item.id || ''),
|
||||
type: 'provider_balance_topup',
|
||||
date: dateStr,
|
||||
amount,
|
||||
currency:
|
||||
(String(item.subaccountamount_iso || item.paymethodamount_iso || '').match(/([A-Z]{3})\b/) || [])[1] ||
|
||||
'USD',
|
||||
providerAccountId,
|
||||
vpsId: null,
|
||||
note: `BILLmanager #${item.number || item.id}`,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* BILLmanager API operations — fetch VDS, payments, dashboard, tariffs
|
||||
*/
|
||||
|
||||
import { billmanagerRequest } from './client.js'
|
||||
import {
|
||||
elemToObject,
|
||||
extractList,
|
||||
extractTariflist,
|
||||
parseDatacenterName,
|
||||
parseTariffDesc,
|
||||
} from './parsers.js'
|
||||
|
||||
export interface DashboardInfo {
|
||||
balance: number
|
||||
currency: string
|
||||
enoughmoneyto: string
|
||||
realbalance: string
|
||||
}
|
||||
|
||||
export interface TariffItem {
|
||||
externalId: string
|
||||
name: string
|
||||
desc: string
|
||||
vcpu: number
|
||||
ramGb: number
|
||||
diskGb: number
|
||||
diskType: string
|
||||
virtualization: string
|
||||
channel: string
|
||||
location: string
|
||||
cpuModel: string
|
||||
orderAvailable: boolean
|
||||
price: string
|
||||
datacenterKey?: string
|
||||
datacenterName?: string
|
||||
country?: string
|
||||
}
|
||||
|
||||
export async function fetchVds(baseUrl: string, authinfo: string): Promise<Record<string, string>[]> {
|
||||
const data = await billmanagerRequest(baseUrl, authinfo, 'vds')
|
||||
const elems = extractList(data, 'vds')
|
||||
return elems.map((e) => elemToObject(e))
|
||||
}
|
||||
|
||||
export async function fetchDashboardInfo(
|
||||
baseUrl: string,
|
||||
authinfo: string,
|
||||
opts: { fallbackCurrency?: string | null } = {},
|
||||
): Promise<DashboardInfo> {
|
||||
const data = await billmanagerRequest(baseUrl, authinfo, 'dashboard.info', {
|
||||
dashboard: 'info',
|
||||
sfrom: 'ajax',
|
||||
})
|
||||
const elems =
|
||||
extractList(data, 'dashboard') ||
|
||||
(Array.isArray(data.elem) ? (data.elem as unknown[]) : [])
|
||||
const item = elems.length > 0 ? elemToObject(elems[0] as never) : {}
|
||||
const balanceStr = String(item.realbalance || item.balance || item.available || '0')
|
||||
const amount = parseFloat(balanceStr.replace(/[^\d.,-]/g, '').replace(',', '.')) || 0
|
||||
let currency = (balanceStr.match(/([A-Z]{3})\b/) || [])[1]
|
||||
if (!currency) {
|
||||
if (balanceStr.includes('€') || balanceStr.includes('EUR')) currency = 'EUR'
|
||||
else if (balanceStr.includes('$') || balanceStr.includes('USD')) currency = 'USD'
|
||||
else if (balanceStr.includes('₽') || balanceStr.includes('RUB')) currency = 'RUB'
|
||||
else if (balanceStr.includes('£')) currency = 'GBP'
|
||||
else currency = opts.fallbackCurrency || 'RUB'
|
||||
}
|
||||
return {
|
||||
balance: amount,
|
||||
currency: currency || opts.fallbackCurrency || 'RUB',
|
||||
enoughmoneyto: item.enoughmoneyto || '',
|
||||
realbalance: item.realbalance || '',
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchPayments(
|
||||
baseUrl: string,
|
||||
authinfo: string,
|
||||
opts: {
|
||||
createdatestart?: string
|
||||
createdateend?: string
|
||||
createdate?: string
|
||||
filter?: string
|
||||
status?: string | number
|
||||
} = {},
|
||||
): Promise<Record<string, string>[]> {
|
||||
const params: Record<string, string> = {}
|
||||
if (opts.createdatestart) params.createdatestart = opts.createdatestart
|
||||
if (opts.createdateend) params.createdateend = opts.createdateend
|
||||
if (opts.createdate === 'other') params.createdate = 'other'
|
||||
if (opts.filter === 'on') params.filter = 'on'
|
||||
if (opts.status != null) params.status = String(opts.status)
|
||||
const data = await billmanagerRequest(baseUrl, authinfo, 'payment', params)
|
||||
const elems = extractList(data, 'payment')
|
||||
return elems.map((e) => elemToObject(e))
|
||||
}
|
||||
|
||||
export async function fetchVdsOrderPricelist(
|
||||
baseUrl: string,
|
||||
authinfo: string,
|
||||
opts: { plid?: string; period?: string; datacenter?: string } = {},
|
||||
): Promise<{ tariffItems: TariffItem[]; slist: Record<string, unknown> }> {
|
||||
const params: Record<string, string> = {
|
||||
plid: opts.plid || '',
|
||||
sfrom: 'ajax',
|
||||
}
|
||||
if (opts.period) params.period = opts.period
|
||||
if (opts.datacenter) params.datacenter = opts.datacenter
|
||||
|
||||
const data = await billmanagerRequest(baseUrl, authinfo, 'vds.order', params)
|
||||
|
||||
const tariflist = extractTariflist(data)
|
||||
const listNode = (data.list as Record<string, unknown>) ?? (data.doc as Record<string, unknown>)?.list
|
||||
const slist = ((listNode as Record<string, unknown>)?.slist ?? data.slist ?? {}) as Record<string, unknown>
|
||||
if (tariflist.length === 0) return { tariffItems: [], slist }
|
||||
|
||||
const tariffItems = tariflist.map((rawItem) => {
|
||||
const item = elemToObject(rawItem)
|
||||
const parsed = parseTariffDesc(item.desc || '')
|
||||
const descClean = (item.desc || '').replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim()
|
||||
const name = parsed.name || parsed.cpuModel || item.price?.split(' ')[0] || '—'
|
||||
return {
|
||||
externalId: String(item.pricelist || ''),
|
||||
name,
|
||||
desc: descClean,
|
||||
vcpu: parsed.vcpu,
|
||||
ramGb: parsed.ramGb,
|
||||
diskGb: parsed.diskGb,
|
||||
diskType: parsed.diskType,
|
||||
virtualization: parsed.virtualization,
|
||||
channel: parsed.channel,
|
||||
location: parsed.location,
|
||||
cpuModel: parsed.cpuModel,
|
||||
orderAvailable: (item.order_available || '').toLowerCase() === 'on',
|
||||
price: item.price || '',
|
||||
}
|
||||
})
|
||||
|
||||
return { tariffItems, slist }
|
||||
}
|
||||
|
||||
export async function fetchVdsOrderPricelistAllDatacenters(
|
||||
baseUrl: string,
|
||||
authinfo: string,
|
||||
): Promise<{ tariffItems: TariffItem[]; slist: Record<string, unknown> }> {
|
||||
const initial = await fetchVdsOrderPricelist(baseUrl, authinfo)
|
||||
const slist = initial.slist || {}
|
||||
const datacenters = Array.isArray(slist.datacenter) ? slist.datacenter : []
|
||||
|
||||
if (datacenters.length === 0) {
|
||||
return { tariffItems: initial.tariffItems, slist }
|
||||
}
|
||||
|
||||
const allTariffItems: TariffItem[] = []
|
||||
|
||||
for (let i = 0; i < datacenters.length; i++) {
|
||||
const dc = datacenters[i] as Record<string, unknown>
|
||||
const dcKey = String(dc.k ?? dc.key ?? '')
|
||||
const dcName = String(dc.v ?? dc.value ?? dc.name ?? '')
|
||||
const { country, location } = parseDatacenterName(dcName)
|
||||
|
||||
const result =
|
||||
i === 0 ? initial : await fetchVdsOrderPricelist(baseUrl, authinfo, { datacenter: dcKey })
|
||||
for (const t of result.tariffItems) {
|
||||
allTariffItems.push({
|
||||
...t,
|
||||
datacenterKey: dcKey,
|
||||
datacenterName: dcName,
|
||||
country,
|
||||
location: location || dcName,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return { tariffItems: allTariffItems, slist }
|
||||
}
|
||||
|
||||
export async function testConnection(
|
||||
baseUrl: string,
|
||||
authinfo: string,
|
||||
): Promise<{ ok: boolean; error?: string; vdsCount?: number }> {
|
||||
if (!baseUrl?.trim() || !authinfo?.trim()) {
|
||||
return { ok: false, error: 'Укажите URL и учётные данные' }
|
||||
}
|
||||
try {
|
||||
const items = await fetchVds(baseUrl.trim(), authinfo.trim())
|
||||
return { ok: true, vdsCount: items?.length ?? 0 }
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Ошибка подключения'
|
||||
return { ok: false, error: message }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { mapVdsToVps } from './mappers.js'
|
||||
import { parsePricelist, parseTariffDesc } from './parsers.js'
|
||||
|
||||
describe('parsePricelist', () => {
|
||||
it('parses CPU/RAM/disk from pricelist string', () => {
|
||||
expect(parsePricelist('KVM SSD Start (1 CPU/768 MB RAM/7 GB SSD)')).toEqual({
|
||||
vcpu: 1,
|
||||
ramGb: 1,
|
||||
diskGb: 7,
|
||||
diskType: 'SSD',
|
||||
virtualization: 'KVM',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseTariffDesc', () => {
|
||||
it('parses Selectel-style HTML description', () => {
|
||||
const result = parseTariffDesc('Start<br/>Процессор: 2 ядра; Память: 4 GB; Диск: 40 GB NVMe')
|
||||
expect(result.vcpu).toBe(2)
|
||||
expect(result.ramGb).toBe(4)
|
||||
expect(result.diskGb).toBe(40)
|
||||
expect(result.diskType).toBe('NVMe')
|
||||
})
|
||||
})
|
||||
|
||||
describe('mapVdsToVps', () => {
|
||||
it('maps active VDS with monthly cost', () => {
|
||||
const vps = mapVdsToVps(
|
||||
{
|
||||
id: '42',
|
||||
ip: '203.0.113.10',
|
||||
domain: 'vps.example.com',
|
||||
item_status: '2',
|
||||
cost: '500.00 RUB / Месяц',
|
||||
pricelist: 'KVM (2 CPU/2048 MB RAM/20 GB NVMe)',
|
||||
createdate: '2024-01-15',
|
||||
expiredate: '2025-01-15',
|
||||
currency_str: 'RUB',
|
||||
},
|
||||
'prov-1',
|
||||
'acc-1',
|
||||
)
|
||||
expect(vps.externalId).toBe('42')
|
||||
expect(vps.ip).toBe('203.0.113.10')
|
||||
expect(vps.status).toBe('active')
|
||||
expect(vps.monthlyRate).toBe(500)
|
||||
expect(vps.vcpu).toBe(2)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,244 @@
|
||||
/**
|
||||
* BILLmanager API response parsers
|
||||
*/
|
||||
|
||||
type BillmanagerElem = Record<string, unknown> | Array<Record<string, unknown>>
|
||||
|
||||
export function extractList(data: Record<string, unknown>, key: string): BillmanagerElem[] {
|
||||
if (Array.isArray(data.elem)) return data.elem as BillmanagerElem[]
|
||||
const dataNode = data.data as Record<string, unknown> | undefined
|
||||
if (dataNode?.elem) {
|
||||
return Array.isArray(dataNode.elem) ? (dataNode.elem as BillmanagerElem[]) : [dataNode.elem as BillmanagerElem]
|
||||
}
|
||||
const doc = (data.doc as Record<string, unknown>) || data
|
||||
let list = doc[key] as Record<string, unknown> | BillmanagerElem[] | undefined
|
||||
if (!list) return []
|
||||
if (Array.isArray(list)) return list
|
||||
if (list.elem) {
|
||||
return Array.isArray(list.elem) ? (list.elem as BillmanagerElem[]) : [list.elem as BillmanagerElem]
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
export function elemToObject(elem: BillmanagerElem | null | undefined): Record<string, string> {
|
||||
if (!elem) return {}
|
||||
if (Array.isArray(elem)) {
|
||||
const obj: Record<string, string> = {}
|
||||
for (const e of elem) {
|
||||
const name = (e.$name || e.name) as string | undefined
|
||||
const val = e.$t ?? e.$
|
||||
if (name) {
|
||||
obj[name] =
|
||||
typeof val === 'object' && val !== null
|
||||
? String((val as Record<string, unknown>).$t ?? (val as Record<string, unknown>).$ ?? JSON.stringify(val))
|
||||
: String(val ?? '')
|
||||
}
|
||||
}
|
||||
return obj
|
||||
}
|
||||
return Object.fromEntries(
|
||||
Object.entries(elem).map(([k, v]) => [k, v == null ? '' : String(v)]),
|
||||
)
|
||||
}
|
||||
|
||||
export function parsePricelist(pricelist: unknown): {
|
||||
vcpu: number
|
||||
ramGb: number
|
||||
diskGb: number
|
||||
diskType: string
|
||||
virtualization: string
|
||||
} {
|
||||
const s = String(pricelist || '')
|
||||
let vcpu = 0
|
||||
let ramGb = 0
|
||||
let diskGb = 0
|
||||
let diskType = 'NVMe'
|
||||
let virtualization = 'KVM'
|
||||
|
||||
const cpuMatch = s.match(/(\d+)\s*(?:CPU|СPU)/i)
|
||||
if (cpuMatch) vcpu = parseInt(cpuMatch[1], 10) || 0
|
||||
|
||||
const ramMbMatch = s.match(/(\d+)\s*MB\s*RAM/i)
|
||||
if (ramMbMatch) ramGb = Math.max(1, Math.round((parseInt(ramMbMatch[1], 10) || 0) / 1024))
|
||||
else {
|
||||
const ramGbMatch = s.match(/(\d+)\s*GB\s*RAM/i)
|
||||
if (ramGbMatch) ramGb = parseInt(ramGbMatch[1], 10) || 0
|
||||
}
|
||||
|
||||
const diskMatch = s.match(/(\d+)\s*GB\s*(SSD|NVMe|HDD)/i)
|
||||
if (diskMatch) {
|
||||
diskGb = parseInt(diskMatch[1], 10) || 0
|
||||
diskType = diskMatch[2] || 'NVMe'
|
||||
}
|
||||
|
||||
if (/\bKVM\b/i.test(s)) virtualization = 'KVM'
|
||||
else if (/\bOpenVZ\b/i.test(s)) virtualization = 'OpenVZ'
|
||||
else if (/\bLXC\b/i.test(s)) virtualization = 'LXC'
|
||||
|
||||
return { vcpu, ramGb, diskGb, diskType, virtualization }
|
||||
}
|
||||
|
||||
export function parseTariffDesc(desc: unknown): {
|
||||
name: string
|
||||
vcpu: number
|
||||
ramGb: number
|
||||
diskGb: number
|
||||
diskType: string
|
||||
virtualization: string
|
||||
channel: string
|
||||
location: string
|
||||
cpuModel: string
|
||||
} {
|
||||
const s = String(desc || '')
|
||||
let name = ''
|
||||
let vcpu = 0
|
||||
let ramGb = 0
|
||||
let diskGb = 0
|
||||
let diskType = 'SSD'
|
||||
let virtualization = 'KVM'
|
||||
let channel = ''
|
||||
let location = ''
|
||||
let cpuModel = ''
|
||||
|
||||
const firstLine = s.split(/\r?\n|<br\s*\/?>/i)[0]?.trim() || ''
|
||||
name = firstLine.replace(/<[^>]+>/g, '').trim()
|
||||
|
||||
const cpuMatch = s.match(/Процессор:\s*(\d+)\s*(?:ядро|ядра|ядер)/i)
|
||||
if (cpuMatch) vcpu = parseInt(cpuMatch[1], 10) || 0
|
||||
|
||||
const ramMbMatch = s.match(/Память:\s*(\d+)\s*MB/i)
|
||||
if (ramMbMatch) ramGb = Math.max(0.5, Math.round(((parseInt(ramMbMatch[1], 10) || 0) / 1024) * 10) / 10)
|
||||
else {
|
||||
const ramGbMatch = s.match(/Память:\s*(\d+)\s*GB/i)
|
||||
if (ramGbMatch) ramGb = parseInt(ramGbMatch[1], 10) || 0
|
||||
}
|
||||
|
||||
const diskMatch = s.match(/Диск:\s*(\d+)\s*GB\s*(SSD|SAS|HDD|NVMe)/i)
|
||||
if (diskMatch) {
|
||||
diskGb = parseInt(diskMatch[1], 10) || 0
|
||||
diskType = diskMatch[2] || 'SSD'
|
||||
}
|
||||
|
||||
const channelMatch = s.match(/Канал:\s*(\d+Mb\/s)/i)
|
||||
if (channelMatch) channel = channelMatch[1]
|
||||
|
||||
if (/\bKVM\b/i.test(s)) virtualization = 'KVM'
|
||||
else if (/\bOpenVZ\b/i.test(s)) virtualization = 'OpenVZ'
|
||||
else if (/\bLXC\b/i.test(s)) virtualization = 'LXC'
|
||||
|
||||
if (!channel) {
|
||||
const netMatch = s.match(/(?:Публичная сеть|Канал)[:\s]*\*?\*?(\d+)\s*Мбит/i)
|
||||
if (netMatch) channel = `${netMatch[1]} Мбит/с`
|
||||
}
|
||||
if (!location) {
|
||||
const locMatch = s.match(/Локация[:\s]*([^;]+)/i)
|
||||
if (locMatch) location = locMatch[1].replace(/<[^>]+>/g, '').trim()
|
||||
}
|
||||
if (!cpuModel) {
|
||||
const procMatch = s.match(/Процессор[:\s]*([^;]+?)(?:\s+до\s|$)/i)
|
||||
if (procMatch) cpuModel = procMatch[1].replace(/<[^>]+>/g, '').trim()
|
||||
}
|
||||
if (!diskType || diskType === 'SSD') {
|
||||
if (/\bNVMe\b/i.test(s)) diskType = 'NVMe'
|
||||
else if (/\bSAS\b/i.test(s)) diskType = 'SAS'
|
||||
else if (/\bHDD\b/i.test(s)) diskType = 'HDD'
|
||||
else if (/\bSSD\b/i.test(s)) diskType = 'SSD'
|
||||
}
|
||||
|
||||
return { name, vcpu, ramGb, diskGb, diskType, virtualization, channel, location, cpuModel }
|
||||
}
|
||||
|
||||
export function parseDatacenterName(dcName: unknown): { country: string; location: string } {
|
||||
const s = String(dcName || '').trim()
|
||||
if (!s) return { country: '', location: '' }
|
||||
|
||||
const COUNTRY_CODE_MAP: Record<string, string> = {
|
||||
DE: 'Германия',
|
||||
FI: 'Финляндия',
|
||||
RU: 'Россия',
|
||||
FR: 'Франция',
|
||||
GB: 'Великобритания',
|
||||
NL: 'Нидерланды',
|
||||
US: 'США',
|
||||
SE: 'Швеция',
|
||||
NO: 'Норвегия',
|
||||
BE: 'Бельгия',
|
||||
CH: 'Швейцария',
|
||||
CZ: 'Чехия',
|
||||
CA: 'Канада',
|
||||
LV: 'Латвия',
|
||||
LT: 'Литва',
|
||||
EE: 'Эстония',
|
||||
PL: 'Польша',
|
||||
IT: 'Италия',
|
||||
DK: 'Дания',
|
||||
AU: 'Австралия',
|
||||
ES: 'Испания',
|
||||
SG: 'Сингапур',
|
||||
}
|
||||
|
||||
const codeMatch = s.match(/\[([A-Z]{2})\]\s*([^|]+)/)
|
||||
if (codeMatch) {
|
||||
const code = codeMatch[1]
|
||||
const loc = codeMatch[2].trim()
|
||||
return { country: COUNTRY_CODE_MAP[code] || code, location: loc }
|
||||
}
|
||||
|
||||
const dcMatch = s.match(/(?:\d+\s+)?Датацентр\s+([^,]+),\s*(.+)/i)
|
||||
if (dcMatch) {
|
||||
return { country: dcMatch[1].trim(), location: dcMatch[2].trim() }
|
||||
}
|
||||
|
||||
const commaMatch = s.match(/^([^,]+),\s*(.+)$/)
|
||||
if (commaMatch) {
|
||||
return { country: commaMatch[1].trim(), location: commaMatch[2].trim() }
|
||||
}
|
||||
|
||||
const countryOnly = [
|
||||
'Россия', 'Чехия', 'Нидерланды', 'Франция', 'Великобритания', 'Германия',
|
||||
'Финляндия', 'Швеция', 'Норвегия', 'Бельгия', 'Швейцария', 'Канада',
|
||||
'Латвия', 'Литва', 'Эстония', 'Польша', 'Италия', 'Дания', 'Австралия',
|
||||
'Испания', 'Сингапур', 'США', 'Азия', 'Европа',
|
||||
]
|
||||
for (const c of countryOnly) {
|
||||
if (s === c || s.startsWith(c + ',') || s.startsWith(c + ' ')) {
|
||||
const rest = s.slice(c.length).replace(/^[,\s]+/, '')
|
||||
return { country: c, location: rest }
|
||||
}
|
||||
}
|
||||
|
||||
if (/ММТС|Adman|Москва/i.test(s)) {
|
||||
return { country: 'Россия', location: s }
|
||||
}
|
||||
if (/Европа/i.test(s)) {
|
||||
return { country: 'Европа', location: s.replace(/Европа\s*/i, '').trim() || s }
|
||||
}
|
||||
|
||||
const pipeMatch = s.match(/^([^|]+)\s*\|/)
|
||||
if (pipeMatch) {
|
||||
const part = pipeMatch[1].trim()
|
||||
for (const c of countryOnly) {
|
||||
if (part.includes(c)) return { country: c, location: '' }
|
||||
}
|
||||
return { country: part, location: '' }
|
||||
}
|
||||
|
||||
return { country: s, location: '' }
|
||||
}
|
||||
|
||||
export function extractTariflist(data: Record<string, unknown>): BillmanagerElem[] {
|
||||
if (!data) return []
|
||||
const doc = data.doc as Record<string, unknown> | undefined
|
||||
const listNode = (data.list as Record<string, unknown>) ?? doc?.list ?? doc
|
||||
if (!listNode || typeof listNode !== 'object') return []
|
||||
|
||||
let list = (listNode as Record<string, unknown>).tariflist
|
||||
?? (listNode as Record<string, unknown>).tarifflist
|
||||
?? (listNode as Record<string, unknown>).pricelist
|
||||
if (Array.isArray(list)) return list as BillmanagerElem[]
|
||||
|
||||
const elems = (listNode as Record<string, unknown>).elem
|
||||
if (elems) return Array.isArray(elems) ? (elems as BillmanagerElem[]) : [elems as BillmanagerElem]
|
||||
|
||||
return []
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Запуск синхронизации BILLmanager с записью в sync_log
|
||||
*/
|
||||
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { getDb, schema } from '@cfdm/db'
|
||||
|
||||
import type { BillmanagerSyncAccount } from './context.js'
|
||||
import { syncFromBillmanager, type SyncFromBillmanagerOptions, type SyncFromBillmanagerResult } from './sync.js'
|
||||
|
||||
export interface RunBillmanagerAccountSyncResult extends SyncFromBillmanagerResult {
|
||||
ok: true
|
||||
logId: string
|
||||
}
|
||||
|
||||
export async function runBillmanagerAccountSync(
|
||||
account: BillmanagerSyncAccount,
|
||||
opts: SyncFromBillmanagerOptions = {},
|
||||
): Promise<RunBillmanagerAccountSyncResult> {
|
||||
const db = getDb()
|
||||
const logId = `sync-${account.id}-${Date.now()}`
|
||||
|
||||
db.insert(schema.syncLog)
|
||||
.values({
|
||||
id: logId,
|
||||
accountId: account.id,
|
||||
startedAt: new Date().toISOString(),
|
||||
status: 'running',
|
||||
})
|
||||
.run()
|
||||
|
||||
try {
|
||||
const result = await syncFromBillmanager(account, opts)
|
||||
const summaryPayload = {
|
||||
...(result.syncSummary || {}),
|
||||
vpsCount: result.vpsCount,
|
||||
paymentsCount: result.paymentsCount,
|
||||
tariffsCount: result.tariffsCount ?? 0,
|
||||
}
|
||||
db.update(schema.syncLog)
|
||||
.set({
|
||||
finishedAt: new Date().toISOString(),
|
||||
status: 'ok',
|
||||
vpsCount: result.vpsCount,
|
||||
paymentsCount: result.paymentsCount,
|
||||
summary: JSON.stringify(summaryPayload),
|
||||
})
|
||||
.where(eq(schema.syncLog.id, logId))
|
||||
.run()
|
||||
return { ok: true, logId, ...result }
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Unknown error'
|
||||
db.update(schema.syncLog)
|
||||
.set({
|
||||
finishedAt: new Date().toISOString(),
|
||||
status: 'error',
|
||||
error: message,
|
||||
summary: JSON.stringify({ error: message }),
|
||||
})
|
||||
.where(eq(schema.syncLog.id, logId))
|
||||
.run()
|
||||
throw err
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
/**
|
||||
* Sync BILLmanager data into vps-tracker DB (Drizzle / @cfdm/db)
|
||||
*/
|
||||
|
||||
import { and, eq, like, or } from 'drizzle-orm'
|
||||
import { getDb, schema } from '@cfdm/db'
|
||||
|
||||
import type { BillmanagerSyncAccount } from './context.js'
|
||||
import { mapPaymentToPayment, mapVdsToVps } from './mappers.js'
|
||||
import {
|
||||
fetchDashboardInfo,
|
||||
fetchPayments,
|
||||
fetchVds,
|
||||
fetchVdsOrderPricelistAllDatacenters,
|
||||
type DashboardInfo,
|
||||
type TariffItem,
|
||||
} from './operations.js'
|
||||
|
||||
export interface SyncFromBillmanagerOptions {
|
||||
skipTariffs?: boolean
|
||||
skipVpsPayments?: boolean
|
||||
}
|
||||
|
||||
export interface SyncSummary {
|
||||
added: { id: string; label: string }[]
|
||||
updated: { id: string; label: string; fields: string[] }[]
|
||||
paymentsAdded: number
|
||||
tariffsOnly?: boolean
|
||||
}
|
||||
|
||||
export interface SyncFromBillmanagerResult {
|
||||
vpsCount: number
|
||||
paymentsCount: number
|
||||
tariffsCount: number
|
||||
newTariffs: { name: string; price: string; providerId: string }[]
|
||||
balance: DashboardInfo | null
|
||||
syncSummary: SyncSummary
|
||||
}
|
||||
|
||||
const SYNC_UPDATE_FIELDS = [
|
||||
'country',
|
||||
'city',
|
||||
'datacenter',
|
||||
'os',
|
||||
'notes',
|
||||
'status',
|
||||
'tariffType',
|
||||
'currency',
|
||||
'dailyRate',
|
||||
'monthlyRate',
|
||||
'paidUntil',
|
||||
] as const
|
||||
|
||||
function normVal(v: unknown): string {
|
||||
if (v == null || v === '') return ''
|
||||
if (typeof v === 'number') return Number.isFinite(v) ? String(v) : ''
|
||||
return String(v)
|
||||
}
|
||||
|
||||
export async function syncFromBillmanager(
|
||||
account: BillmanagerSyncAccount,
|
||||
opts: SyncFromBillmanagerOptions = {},
|
||||
): Promise<SyncFromBillmanagerResult> {
|
||||
const { skipTariffs = false, skipVpsPayments = false } = opts
|
||||
const { apiBaseUrl, apiCredentials, providerId, id: accountId } = account
|
||||
if (!apiBaseUrl?.trim() || !apiCredentials?.trim()) {
|
||||
throw new Error('API URL and credentials are required')
|
||||
}
|
||||
const authinfo = apiCredentials.trim()
|
||||
const db = getDb()
|
||||
|
||||
const fetchVpsPayments = !skipVpsPayments
|
||||
const fetchTariffs = !skipTariffs
|
||||
|
||||
const [vdsItems, paymentItems, dashboardInfo, tariffResult] = await Promise.all([
|
||||
fetchVpsPayments ? fetchVds(apiBaseUrl, authinfo) : [],
|
||||
fetchVpsPayments ? fetchPayments(apiBaseUrl, authinfo, {}) : [],
|
||||
fetchVpsPayments
|
||||
? fetchDashboardInfo(apiBaseUrl, authinfo, { fallbackCurrency: account.currency }).catch(() => null)
|
||||
: null,
|
||||
fetchTariffs
|
||||
? fetchVdsOrderPricelistAllDatacenters(apiBaseUrl, authinfo).catch((err) => {
|
||||
console.warn('fetchVdsOrderPricelistAllDatacenters failed:', err instanceof Error ? err.message : err)
|
||||
return { tariffItems: [] as TariffItem[], slist: {} as Record<string, unknown> }
|
||||
})
|
||||
: { tariffItems: [] as TariffItem[], slist: {} as Record<string, unknown> },
|
||||
])
|
||||
const { tariffItems = [], slist = {} } = tariffResult || {}
|
||||
|
||||
let vpsCount = 0
|
||||
const syncSummary: SyncSummary = { added: [], updated: [], paymentsAdded: 0 }
|
||||
|
||||
if (fetchVpsPayments) {
|
||||
for (const item of vdsItems) {
|
||||
const vps = mapVdsToVps(item, providerId, accountId)
|
||||
const id = `vps-bm-${accountId}-${vps.externalId}`
|
||||
const additionalIps = JSON.stringify(vps.additionalIps || [])
|
||||
const dailyRate = vps.dailyRate
|
||||
const monthlyRate = vps.monthlyRate
|
||||
const paidUntil = vps.paidUntil || ''
|
||||
const notes = vps.notes ? `${vps.notes} [bm-${vps.externalId}]` : `bm-${vps.externalId}`
|
||||
|
||||
const existing = db
|
||||
.select()
|
||||
.from(schema.vps)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.vps.providerAccountId, accountId),
|
||||
or(eq(schema.vps.ip, vps.ip), like(schema.vps.notes, `%bm-${vps.externalId}%`)),
|
||||
),
|
||||
)
|
||||
.get()
|
||||
|
||||
if (existing) {
|
||||
let userOverrides: string[] = []
|
||||
try {
|
||||
userOverrides = existing.userOverrides ? JSON.parse(existing.userOverrides) : []
|
||||
} catch {
|
||||
userOverrides = []
|
||||
}
|
||||
const merged = {
|
||||
ip: vps.ip,
|
||||
ipv6: vps.ipv6,
|
||||
additionalIps,
|
||||
dns: vps.dns,
|
||||
country: vps.country,
|
||||
city: vps.city,
|
||||
datacenter: vps.datacenter,
|
||||
os: vps.os,
|
||||
status: vps.status,
|
||||
tariffType: vps.tariffType,
|
||||
currency: vps.currency,
|
||||
dailyRate,
|
||||
monthlyRate,
|
||||
paidUntil,
|
||||
notes,
|
||||
}
|
||||
for (const f of SYNC_UPDATE_FIELDS) {
|
||||
if (userOverrides.includes(f)) {
|
||||
merged[f] = existing[f as keyof typeof existing] as never
|
||||
}
|
||||
}
|
||||
const compareFields = ['ip', 'ipv6', 'dns', ...SYNC_UPDATE_FIELDS] as const
|
||||
const changedFields = compareFields.filter(
|
||||
(f) => normVal(merged[f as keyof typeof merged]) !== normVal(existing[f as keyof typeof existing]),
|
||||
)
|
||||
if (changedFields.length > 0) {
|
||||
const label = merged.dns || merged.ip || existing.id
|
||||
syncSummary.updated.push({ id: existing.id, label, fields: [...changedFields] })
|
||||
}
|
||||
db.update(schema.vps)
|
||||
.set({
|
||||
ip: merged.ip,
|
||||
ipv6: merged.ipv6,
|
||||
additionalIps: merged.additionalIps,
|
||||
dns: merged.dns,
|
||||
country: merged.country,
|
||||
city: merged.city,
|
||||
datacenter: merged.datacenter,
|
||||
os: merged.os,
|
||||
status: merged.status,
|
||||
tariffType: merged.tariffType,
|
||||
currency: merged.currency,
|
||||
dailyRate: merged.dailyRate,
|
||||
monthlyRate: merged.monthlyRate,
|
||||
paidUntil: merged.paidUntil,
|
||||
notes: merged.notes,
|
||||
})
|
||||
.where(eq(schema.vps.id, existing.id))
|
||||
.run()
|
||||
} else {
|
||||
const label = vps.dns || vps.ip || id
|
||||
syncSummary.added.push({ id, label })
|
||||
db.insert(schema.vps)
|
||||
.values({
|
||||
id,
|
||||
ip: vps.ip,
|
||||
ipv6: vps.ipv6,
|
||||
additionalIps,
|
||||
dns: vps.dns,
|
||||
providerId: vps.providerId,
|
||||
providerAccountId: vps.providerAccountId,
|
||||
country: vps.country,
|
||||
city: vps.city,
|
||||
datacenter: vps.datacenter,
|
||||
os: vps.os,
|
||||
vcpu: vps.vcpu,
|
||||
ramGb: vps.ramGb,
|
||||
diskGb: vps.diskGb,
|
||||
diskType: vps.diskType,
|
||||
virtualization: vps.virtualization,
|
||||
bandwidthTb: vps.bandwidthTb,
|
||||
sshPort: vps.sshPort,
|
||||
rootUser: vps.rootUser,
|
||||
purpose: vps.purpose,
|
||||
environment: vps.environment,
|
||||
project: vps.project,
|
||||
projectId: null,
|
||||
monitoringEnabled: vps.monitoringEnabled ? 1 : 0,
|
||||
backupEnabled: vps.backupEnabled ? 1 : 0,
|
||||
status: vps.status,
|
||||
tariffType: vps.tariffType,
|
||||
currency: vps.currency,
|
||||
dailyRate,
|
||||
monthlyRate,
|
||||
createdAt: vps.createdAt,
|
||||
paidUntil,
|
||||
notes,
|
||||
userOverrides: '[]',
|
||||
})
|
||||
.run()
|
||||
}
|
||||
vpsCount++
|
||||
}
|
||||
}
|
||||
|
||||
let paymentsCount = 0
|
||||
if (fetchVpsPayments) {
|
||||
const existingPaymentRows = db
|
||||
.select({ note: schema.payments.note })
|
||||
.from(schema.payments)
|
||||
.where(eq(schema.payments.providerAccountId, accountId))
|
||||
.all()
|
||||
const existingPayments = new Set(
|
||||
existingPaymentRows.map((r) => r.note).filter((n): n is string => Boolean(n)),
|
||||
)
|
||||
|
||||
for (const item of paymentItems) {
|
||||
const payment = mapPaymentToPayment(item, accountId)
|
||||
if (!payment || payment.amount <= 0) continue
|
||||
const note = payment.note
|
||||
if (existingPayments.has(note)) continue
|
||||
const payId = `pay-bm-${accountId}-${payment.externalId}`
|
||||
db.insert(schema.payments)
|
||||
.values({
|
||||
id: payId,
|
||||
type: payment.type,
|
||||
date: payment.date,
|
||||
amount: payment.amount,
|
||||
currency: payment.currency,
|
||||
providerAccountId: payment.providerAccountId,
|
||||
vpsId: payment.vpsId,
|
||||
note,
|
||||
})
|
||||
.run()
|
||||
existingPayments.add(note)
|
||||
paymentsCount++
|
||||
syncSummary.paymentsAdded += 1
|
||||
}
|
||||
}
|
||||
|
||||
if (fetchVpsPayments && dashboardInfo) {
|
||||
db.update(schema.providerAccounts)
|
||||
.set({
|
||||
balanceApi: dashboardInfo.balance,
|
||||
balanceCurrency: dashboardInfo.currency || 'RUB',
|
||||
balanceUpdatedAt: new Date().toISOString(),
|
||||
enoughmoneyto: dashboardInfo.enoughmoneyto || '',
|
||||
})
|
||||
.where(eq(schema.providerAccounts.id, accountId))
|
||||
.run()
|
||||
}
|
||||
|
||||
let tariffsCount = 0
|
||||
const newTariffs: { name: string; price: string; providerId: string }[] = []
|
||||
if (fetchTariffs) {
|
||||
const existingTariffIds = new Set(
|
||||
db
|
||||
.select({ id: schema.activeTariffs.id })
|
||||
.from(schema.activeTariffs)
|
||||
.where(eq(schema.activeTariffs.providerAccountId, accountId))
|
||||
.all()
|
||||
.map((r) => r.id),
|
||||
)
|
||||
const syncedAt = new Date().toISOString()
|
||||
db.delete(schema.activeTariffs)
|
||||
.where(eq(schema.activeTariffs.providerAccountId, accountId))
|
||||
.run()
|
||||
|
||||
for (const t of tariffItems) {
|
||||
const dcKey = t.datacenterKey ?? ''
|
||||
const dcName = t.datacenterName ?? ''
|
||||
const tariffId = dcKey
|
||||
? `tariff-bm-${accountId}-${t.externalId}-${dcKey}`
|
||||
: `tariff-bm-${accountId}-${t.externalId}`
|
||||
if (!existingTariffIds.has(tariffId)) {
|
||||
newTariffs.push({ name: t.name || '', price: t.price || '', providerId })
|
||||
}
|
||||
db.insert(schema.activeTariffs)
|
||||
.values({
|
||||
id: tariffId,
|
||||
providerAccountId: accountId,
|
||||
providerId,
|
||||
externalId: t.externalId,
|
||||
datacenterKey: dcKey,
|
||||
datacenterName: dcName,
|
||||
name: t.name || '',
|
||||
desc: t.desc || '',
|
||||
vcpu: t.vcpu || 0,
|
||||
ramGb: t.ramGb || 0,
|
||||
diskGb: t.diskGb || 0,
|
||||
diskType: t.diskType || 'SSD',
|
||||
virtualization: t.virtualization || 'KVM',
|
||||
channel: t.channel || '',
|
||||
location: t.location || '',
|
||||
country: t.country || '',
|
||||
cpuModel: t.cpuModel || '',
|
||||
orderAvailable: t.orderAvailable ? 1 : 0,
|
||||
price: t.price || '',
|
||||
syncedAt,
|
||||
})
|
||||
.run()
|
||||
tariffsCount++
|
||||
}
|
||||
|
||||
if (Object.keys(slist).length > 0) {
|
||||
const datacenters = Array.isArray(slist.datacenter) ? JSON.stringify(slist.datacenter) : '[]'
|
||||
const periods = Array.isArray(slist.period) ? JSON.stringify(slist.period) : '[]'
|
||||
db.insert(schema.tariffSyncOptions)
|
||||
.values({
|
||||
providerAccountId: accountId,
|
||||
datacenters,
|
||||
periods,
|
||||
syncedAt,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: schema.tariffSyncOptions.providerAccountId,
|
||||
set: { datacenters, periods, syncedAt },
|
||||
})
|
||||
.run()
|
||||
}
|
||||
}
|
||||
|
||||
if (!fetchVpsPayments) {
|
||||
syncSummary.tariffsOnly = true
|
||||
}
|
||||
|
||||
return { vpsCount, paymentsCount, tariffsCount, newTariffs, balance: dashboardInfo, syncSummary }
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
import { and, desc, eq, sql } from 'drizzle-orm'
|
||||
import { getDb, schema } from '@cfdm/db'
|
||||
import { settingsRepository } from '@cfdm/db/repositories/settings'
|
||||
|
||||
import { billmanagerAccountRowForSync } from './billmanager/context.js'
|
||||
import { runBillmanagerAccountSync } from './billmanager/sync-job.js'
|
||||
import { sendTelegramMessage } from './telegram.js'
|
||||
|
||||
let syncIntervalId: ReturnType<typeof setInterval> | null = null
|
||||
let syncTariffsIntervalId: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
const UPCOMING_DAYS = 7
|
||||
const SETTINGS_ID = 'settings-main'
|
||||
|
||||
type AccountRow = typeof schema.providerAccounts.$inferSelect
|
||||
type VpsRow = typeof schema.vps.$inferSelect
|
||||
type PaymentRow = typeof schema.payments.$inferSelect
|
||||
type LedgerRow = typeof schema.balanceLedger.$inferSelect
|
||||
|
||||
function getBillmanagerAccounts(): NonNullable<ReturnType<typeof billmanagerAccountRowForSync>>[] {
|
||||
const db = getDb()
|
||||
const rows = db
|
||||
.all<AccountRow>(sql`
|
||||
SELECT pa.* FROM provider_accounts pa
|
||||
INNER JOIN providers p ON p.id = pa.providerId
|
||||
WHERE lower(trim(COALESCE(p.apiType, ''))) = 'billmanager'
|
||||
AND length(trim(COALESCE(p.apiBaseUrl, ''))) > 0
|
||||
AND pa.apiCredentials IS NOT NULL AND length(trim(pa.apiCredentials)) > 0
|
||||
`)
|
||||
const providers = db.select().from(schema.providers).all()
|
||||
const providerById = new Map(providers.map((p) => [p.id, p]))
|
||||
return rows
|
||||
.map((a) => billmanagerAccountRowForSync(a, providerById.get(a.providerId)))
|
||||
.filter((a): a is NonNullable<typeof a> => a != null)
|
||||
}
|
||||
|
||||
function getAccountBalance(
|
||||
accountId: string,
|
||||
providerAccounts: AccountRow[],
|
||||
balanceLedger: LedgerRow[],
|
||||
): number {
|
||||
const account = providerAccounts.find((a) => a.id === accountId)
|
||||
if (account?.balanceApi != null && Number.isFinite(Number(account.balanceApi))) {
|
||||
return Number(account.balanceApi)
|
||||
}
|
||||
const rows = balanceLedger.filter((row) => row.providerAccountId === accountId)
|
||||
const credits = rows
|
||||
.filter((row) => row.direction === 'credit')
|
||||
.reduce((acc, row) => acc + Number(row.amount || 0), 0)
|
||||
const debits = rows
|
||||
.filter((row) => row.direction === 'debit')
|
||||
.reduce((acc, row) => acc + Number(row.amount || 0), 0)
|
||||
return credits - debits
|
||||
}
|
||||
|
||||
function getPaidUntilDate(
|
||||
vps: VpsRow,
|
||||
providerAccounts: AccountRow[],
|
||||
payments: PaymentRow[],
|
||||
balanceLedger: LedgerRow[],
|
||||
now: Date,
|
||||
): Date | null {
|
||||
if (vps.status !== 'active') return null
|
||||
const account = providerAccounts.find((a) => a.id === vps.providerAccountId)
|
||||
const tariffType = vps.tariffType || (Number(vps.dailyRate || 0) > 0 ? 'daily' : 'monthly')
|
||||
const isDailyBilling = tariffType === 'daily' || account?.billingMode === 'daily'
|
||||
|
||||
let paidUntilFromApi: Date | null = null
|
||||
if (vps.paidUntil) {
|
||||
const d = new Date(vps.paidUntil)
|
||||
paidUntilFromApi = Number.isNaN(d.getTime()) ? null : d
|
||||
}
|
||||
|
||||
const isPaidUntilNextDay =
|
||||
paidUntilFromApi &&
|
||||
(() => {
|
||||
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
||||
const diffDays = Math.round((paidUntilFromApi.getTime() - today.getTime()) / (24 * 60 * 60 * 1000))
|
||||
return diffDays >= 0 && diffDays <= 2
|
||||
})()
|
||||
|
||||
const shouldCalculateFromBalance = isDailyBilling || isPaidUntilNextDay
|
||||
if (!shouldCalculateFromBalance && paidUntilFromApi) return paidUntilFromApi
|
||||
|
||||
const dailyRate = Number(vps.dailyRate || 0)
|
||||
const monthlyRate = Number(vps.monthlyRate || 0)
|
||||
const burnRate = tariffType === 'daily' ? dailyRate : monthlyRate / 30
|
||||
if (!Number.isFinite(burnRate) || burnRate <= 0) return paidUntilFromApi
|
||||
|
||||
const accountBalance = getAccountBalance(vps.providerAccountId ?? '', providerAccounts, balanceLedger)
|
||||
const activeInAccount = getDb()
|
||||
.select({ id: schema.vps.id })
|
||||
.from(schema.vps)
|
||||
.where(
|
||||
and(eq(schema.vps.providerAccountId, vps.providerAccountId ?? ''), eq(schema.vps.status, 'active')),
|
||||
)
|
||||
.all().length
|
||||
const allocatedBalance = activeInAccount > 0 ? Math.max(0, accountBalance) / activeInAccount : 0
|
||||
const directPayments = payments
|
||||
.filter((p) => p.vpsId === vps.id && p.type === 'direct_vps_payment')
|
||||
.reduce((acc, p) => acc + Number(p.amount || 0), 0)
|
||||
const funds = directPayments + allocatedBalance
|
||||
const coveredDays = Math.floor(funds / burnRate)
|
||||
if (!Number.isFinite(coveredDays) || coveredDays <= 0) return paidUntilFromApi
|
||||
|
||||
const paidUntil = new Date(now)
|
||||
paidUntil.setDate(paidUntil.getDate() + coveredDays)
|
||||
return paidUntil
|
||||
}
|
||||
|
||||
async function sendPaymentExpiryNotifications(): Promise<void> {
|
||||
const settings = settingsRepository.getRow(SETTINGS_ID)
|
||||
if (
|
||||
!settings?.notifyPaymentExpiryEnabled ||
|
||||
!settings.telegramBotToken?.trim() ||
|
||||
!settings.telegramChatId?.trim()
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
const db = getDb()
|
||||
const vpsList = db.select().from(schema.vps).orderBy(desc(schema.vps.createdAt)).all()
|
||||
const providerAccounts = db.select().from(schema.providerAccounts).all()
|
||||
const payments = db.select().from(schema.payments).all()
|
||||
const balanceLedger = db.select().from(schema.balanceLedger).all()
|
||||
const providers = db.select().from(schema.providers).all()
|
||||
|
||||
const now = new Date()
|
||||
const threshold = new Date(now)
|
||||
threshold.setDate(threshold.getDate() + UPCOMING_DAYS)
|
||||
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
||||
|
||||
const upcoming: { vps: VpsRow; paidUntil: Date; provider: string }[] = []
|
||||
for (const vps of vpsList) {
|
||||
if (vps.status !== 'active') continue
|
||||
const paidUntil = getPaidUntilDate(vps, providerAccounts, payments, balanceLedger, now)
|
||||
if (!paidUntil || paidUntil > threshold || paidUntil < todayStart) continue
|
||||
const provider = providers.find((p) => p.id === vps.providerId)
|
||||
upcoming.push({ vps, paidUntil, provider: provider?.name || '-' })
|
||||
}
|
||||
upcoming.sort((a, b) => a.paidUntil.getTime() - b.paidUntil.getTime())
|
||||
if (upcoming.length === 0) return
|
||||
|
||||
const lines = upcoming.slice(0, 10).map(({ vps, paidUntil, provider }) => {
|
||||
const dateStr = paidUntil.toLocaleDateString('ru-RU')
|
||||
return `• ${vps.dns || vps.ip} (${provider}) — до ${dateStr}`
|
||||
})
|
||||
const text = `⚠️ <b>Истекает оплата</b> (ближайшие ${UPCOMING_DAYS} дней):\n\n${lines.join('\n')}`
|
||||
await sendTelegramMessage(
|
||||
settings.telegramBotToken,
|
||||
settings.telegramChatId,
|
||||
text,
|
||||
settings.telegramMessageThreadId,
|
||||
)
|
||||
}
|
||||
|
||||
export async function runScheduledSync(): Promise<void> {
|
||||
try {
|
||||
const settings = settingsRepository.getRow(SETTINGS_ID)
|
||||
if (!settings?.syncEnabled) return
|
||||
|
||||
const accounts = getBillmanagerAccounts()
|
||||
const digestLines: string[] = []
|
||||
const lowBalanceLines: string[] = []
|
||||
const token = settings.telegramBotToken?.trim()
|
||||
const chatId = settings.telegramChatId?.trim()
|
||||
const canTg = Boolean(token && chatId)
|
||||
|
||||
for (const account of accounts) {
|
||||
try {
|
||||
const result = await runBillmanagerAccountSync(account, { skipTariffs: true })
|
||||
const s = result.syncSummary
|
||||
const parts: string[] = []
|
||||
if (s.added?.length) parts.push(`+${s.added.length} VPS`)
|
||||
if (s.updated?.length) parts.push(`изм. ${s.updated.length}`)
|
||||
if (result.paymentsCount) parts.push(`платежи +${result.paymentsCount}`)
|
||||
digestLines.push(`✓ ${account.name}: ${parts.length ? parts.join(', ') : 'без изменений'}`)
|
||||
|
||||
const apiBal = result.balance?.balance
|
||||
const threshold = account.balanceAlertBelow
|
||||
if (
|
||||
canTg &&
|
||||
settings.notifyLowBalanceEnabled &&
|
||||
threshold != null &&
|
||||
Number.isFinite(Number(threshold)) &&
|
||||
apiBal != null &&
|
||||
Number.isFinite(Number(apiBal)) &&
|
||||
Number(apiBal) < Number(threshold)
|
||||
) {
|
||||
const cur = result.balance?.currency || account.balanceCurrency || account.currency || ''
|
||||
lowBalanceLines.push(`• ${account.name}: ${apiBal} ${cur} (порог ${threshold})`)
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'ошибка'
|
||||
digestLines.push(`✗ ${account.name}: ${message}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (canTg && settings.notifySyncDigestEnabled && digestLines.length > 0) {
|
||||
await sendTelegramMessage(token!, chatId!, `📋 <b>Синхронизация VPS</b>\n\n${digestLines.join('\n')}`, settings.telegramMessageThreadId)
|
||||
}
|
||||
if (canTg && settings.notifyLowBalanceEnabled && lowBalanceLines.length > 0) {
|
||||
await sendTelegramMessage(token!, chatId!, `💰 <b>Низкий баланс</b>\n\n${lowBalanceLines.join('\n')}`, settings.telegramMessageThreadId)
|
||||
}
|
||||
|
||||
if (settings.notifyPaymentExpiryEnabled) {
|
||||
await sendPaymentExpiryNotifications()
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Scheduled sync error:', err instanceof Error ? err.message : err)
|
||||
}
|
||||
}
|
||||
|
||||
export async function runScheduledSyncTariffs(): Promise<void> {
|
||||
try {
|
||||
const settings = settingsRepository.getRow(SETTINGS_ID)
|
||||
if (!settings?.syncEnabled) return
|
||||
|
||||
const accounts = getBillmanagerAccounts()
|
||||
const providers = getDb().select().from(schema.providers).all()
|
||||
|
||||
for (const account of accounts) {
|
||||
try {
|
||||
const result = await runBillmanagerAccountSync(account, { skipVpsPayments: true })
|
||||
const newTariffs = result.newTariffs || []
|
||||
if (
|
||||
newTariffs.length > 0 &&
|
||||
settings.notifyNewTariffsEnabled &&
|
||||
settings.telegramBotToken?.trim() &&
|
||||
settings.telegramChatId?.trim()
|
||||
) {
|
||||
const provider = providers.find((p) => p.id === account.providerId)
|
||||
const providerName = provider?.name || account.name || '-'
|
||||
const lines = newTariffs.slice(0, 15).map((t) => `• ${t.name || '—'} — ${t.price || '—'}`)
|
||||
await sendTelegramMessage(
|
||||
settings.telegramBotToken,
|
||||
settings.telegramChatId,
|
||||
`🆕 <b>Новые тарифы</b> (${providerName}):\n\n${lines.join('\n')}`,
|
||||
settings.telegramMessageThreadId,
|
||||
)
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(`Sync tariffs failed for account ${account.id}:`, err instanceof Error ? err.message : err)
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Scheduled sync tariffs error:', err instanceof Error ? err.message : err)
|
||||
}
|
||||
}
|
||||
|
||||
export function startScheduler(): void {
|
||||
if (syncIntervalId) clearInterval(syncIntervalId)
|
||||
syncIntervalId = null
|
||||
if (syncTariffsIntervalId) clearInterval(syncTariffsIntervalId)
|
||||
syncTariffsIntervalId = null
|
||||
|
||||
try {
|
||||
const settings = settingsRepository.getRow(SETTINGS_ID)
|
||||
if (!settings?.syncEnabled) return
|
||||
|
||||
const interval = Math.max(15, Number(settings.syncIntervalMinutes) || 60)
|
||||
const tariffsInterval = Math.max(60, Number(settings.syncTariffsIntervalMinutes) || 1440)
|
||||
syncIntervalId = setInterval(() => void runScheduledSync(), interval * 60 * 1000)
|
||||
syncTariffsIntervalId = setInterval(() => void runScheduledSyncTariffs(), tariffsInterval * 60 * 1000)
|
||||
console.log(
|
||||
`Scheduled sync enabled: VPS/payments every ${interval} min, tariffs every ${tariffsInterval} min`,
|
||||
)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
export function stopScheduler(): void {
|
||||
if (syncIntervalId) clearInterval(syncIntervalId)
|
||||
syncIntervalId = null
|
||||
if (syncTariffsIntervalId) clearInterval(syncTariffsIntervalId)
|
||||
syncTariffsIntervalId = null
|
||||
}
|
||||
|
||||
export function restartScheduler(): void {
|
||||
stopScheduler()
|
||||
startScheduler()
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Telegram Bot API — отправка уведомлений
|
||||
*/
|
||||
|
||||
export async function sendTelegramMessage(
|
||||
token: string,
|
||||
chatIds: string | string[],
|
||||
text: string,
|
||||
messageThreadId?: string | number | null,
|
||||
): Promise<void> {
|
||||
if (!token?.trim() || !text?.trim()) return
|
||||
const ids = Array.isArray(chatIds)
|
||||
? chatIds
|
||||
: String(chatIds || '')
|
||||
.split(',')
|
||||
.map((id) => id.trim())
|
||||
.filter(Boolean)
|
||||
if (ids.length === 0) return
|
||||
|
||||
const threadId = messageThreadId != null && messageThreadId !== '' ? Number(messageThreadId) : null
|
||||
const payload: Record<string, unknown> = {
|
||||
text,
|
||||
parse_mode: 'HTML',
|
||||
disable_web_page_preview: true,
|
||||
}
|
||||
if (Number.isFinite(threadId)) payload.message_thread_id = threadId
|
||||
|
||||
const url = `https://api.telegram.org/bot${token.trim()}/sendMessage`
|
||||
for (const chatId of ids) {
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ...payload, chat_id: chatId }),
|
||||
})
|
||||
const data = (await res.json().catch(() => ({}))) as { ok?: boolean; description?: string }
|
||||
if (!data.ok) {
|
||||
console.warn(`Telegram sendMessage failed for chat ${chatId}:`, data.description || res.statusText)
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
console.warn(`Telegram sendMessage error for chat ${chatId}:`, message)
|
||||
}
|
||||
}
|
||||
}
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -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.
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"$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",
|
||||
"registries": {
|
||||
"@reui": "https://reui.io/r/{style}/{name}.json"
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"hooks": "@/hooks",
|
||||
"lib": "@/lib",
|
||||
"utils": "@cfdm/ui/lib/utils",
|
||||
"ui": "@cfdm/ui/components"
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<html lang="ru" suppressHydrationWarning>
|
||||
<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>
|
||||
@@ -0,0 +1,54 @@
|
||||
{
|
||||
"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:*",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/modifiers": "^9.0.0",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@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",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@tanstack/react-virtual": "^3.14.4",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"date-fns": "^4.4.0",
|
||||
"lucide-react": "^0.468.0",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "^19.2.0",
|
||||
"react-day-picker": "^10.0.1",
|
||||
"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,128 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import { CheckIcon } from 'lucide-react'
|
||||
|
||||
import { cn } from '@cfdm/ui/lib/utils'
|
||||
import {
|
||||
Autocomplete,
|
||||
AutocompleteContent,
|
||||
AutocompleteEmpty,
|
||||
AutocompleteInput,
|
||||
AutocompleteItem,
|
||||
AutocompleteList,
|
||||
} from '@/components/reui/autocomplete'
|
||||
|
||||
export interface AutoCompleteOption {
|
||||
value: string
|
||||
label: string
|
||||
/** Левый префикс (например флаг страны). */
|
||||
leading?: React.ReactNode
|
||||
}
|
||||
|
||||
interface AutoCompleteInputProps {
|
||||
id?: string
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
options: AutoCompleteOption[]
|
||||
placeholder?: string
|
||||
searchPlaceholder?: string
|
||||
emptyText?: string
|
||||
className?: string
|
||||
/** Показывать ли выбранный leading в поле (например флаг). */
|
||||
showLeadingInInput?: boolean
|
||||
/** Разрешать ли произвольный ввод (не только из списка). По умолчанию true. */
|
||||
allowFreeText?: boolean
|
||||
}
|
||||
|
||||
export function AutoCompleteInput({
|
||||
id,
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
placeholder = 'Выбрать…',
|
||||
searchPlaceholder,
|
||||
emptyText = 'Ничего не найдено',
|
||||
className,
|
||||
showLeadingInInput = true,
|
||||
allowFreeText = true,
|
||||
}: AutoCompleteInputProps) {
|
||||
const trimmedValue = value.trim()
|
||||
|
||||
const selected = React.useMemo(
|
||||
() => options.find((o) => o.value.toLowerCase() === trimmedValue.toLowerCase()),
|
||||
[options, trimmedValue],
|
||||
)
|
||||
const leading = showLeadingInInput ? selected?.leading : undefined
|
||||
|
||||
const inputPlaceholder = searchPlaceholder ?? placeholder
|
||||
|
||||
const handleValueChange = React.useCallback(
|
||||
(inputVal: string) => {
|
||||
const q = inputVal.trim()
|
||||
const match = options.find(
|
||||
(o) =>
|
||||
o.label.toLowerCase() === q.toLowerCase() ||
|
||||
o.value.toLowerCase() === q.toLowerCase(),
|
||||
)
|
||||
if (match) {
|
||||
onChange(match.value)
|
||||
return
|
||||
}
|
||||
if (allowFreeText) {
|
||||
onChange(inputVal)
|
||||
}
|
||||
},
|
||||
[options, onChange, allowFreeText],
|
||||
)
|
||||
|
||||
return (
|
||||
<Autocomplete
|
||||
items={options}
|
||||
value={value}
|
||||
onValueChange={handleValueChange}
|
||||
itemToStringValue={(item) => item.label}
|
||||
mode="list"
|
||||
autoHighlight
|
||||
openOnInputClick
|
||||
>
|
||||
<div className="relative w-full">
|
||||
{leading ? (
|
||||
<span className="pointer-events-none absolute start-2.5 top-1/2 z-10 size-4 -translate-y-1/2 [&_svg]:size-full">
|
||||
{leading}
|
||||
</span>
|
||||
) : null}
|
||||
<AutocompleteInput
|
||||
id={id}
|
||||
placeholder={trimmedValue ? undefined : inputPlaceholder}
|
||||
showTrigger
|
||||
showClear={Boolean(trimmedValue)}
|
||||
className={cn(leading && 'ps-8', className)}
|
||||
/>
|
||||
</div>
|
||||
<AutocompleteContent>
|
||||
<AutocompleteEmpty>{emptyText}</AutocompleteEmpty>
|
||||
<AutocompleteList>
|
||||
{(item) => {
|
||||
const isSelected = item.value.toLowerCase() === trimmedValue.toLowerCase()
|
||||
return (
|
||||
<AutocompleteItem
|
||||
key={item.value}
|
||||
value={item}
|
||||
className="gap-2.5 px-2 py-1.5"
|
||||
>
|
||||
{item.leading ? (
|
||||
<span className="relative z-1 size-4 shrink-0">{item.leading}</span>
|
||||
) : null}
|
||||
<span className="relative z-1 min-w-0 flex-1 truncate">{item.label}</span>
|
||||
{isSelected ? (
|
||||
<CheckIcon className="relative z-1 size-4 shrink-0 opacity-60" />
|
||||
) : null}
|
||||
</AutocompleteItem>
|
||||
)
|
||||
}}
|
||||
</AutocompleteList>
|
||||
</AutocompleteContent>
|
||||
</Autocomplete>
|
||||
)
|
||||
}
|
||||
@@ -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,22 @@
|
||||
import { cn } from '@cfdm/ui/lib/utils'
|
||||
import { getCountryFlagUrl, resolveCountryCode } from '@/lib/format'
|
||||
|
||||
interface CountryFlagProps {
|
||||
code?: string
|
||||
country?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function CountryFlag({ code, country, className }: CountryFlagProps) {
|
||||
const resolvedCode = code ?? resolveCountryCode(country)
|
||||
const url = getCountryFlagUrl(resolvedCode)
|
||||
if (!url) return null
|
||||
|
||||
return (
|
||||
<img
|
||||
src={url}
|
||||
alt=""
|
||||
className={cn('size-4 shrink-0 rounded-full object-cover', className)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
import { useState, type ReactNode } from 'react'
|
||||
import {
|
||||
useReactTable,
|
||||
getCoreRowModel,
|
||||
getSortedRowModel,
|
||||
getPaginationRowModel,
|
||||
flexRender,
|
||||
type ColumnDef,
|
||||
type SortingState,
|
||||
} from '@tanstack/react-table'
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@cfdm/ui/components/card'
|
||||
import { cn } from '@cfdm/ui/lib/utils'
|
||||
import {
|
||||
DataGrid,
|
||||
DataGridContainer,
|
||||
} from '@/components/reui/data-grid/data-grid'
|
||||
import { DataGridTable } from '@/components/reui/data-grid/data-grid-table'
|
||||
import { DataGridTableVirtual } from '@/components/reui/data-grid/data-grid-table-virtual'
|
||||
import { DataGridScrollArea } from '@/components/reui/data-grid/data-grid-scroll-area'
|
||||
import { DataGridPagination } from '@/components/reui/data-grid/data-grid-pagination'
|
||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||
import { EmptyState } from './empty-state'
|
||||
import type { DataTableColumn } from './data-table-card'
|
||||
|
||||
const PAGINATION_LABELS = {
|
||||
rowsPerPageLabel: 'Строк на странице',
|
||||
info: '{from}–{to} из {count}',
|
||||
previousPageLabel: 'Предыдущая страница',
|
||||
nextPageLabel: 'Следующая страница',
|
||||
} as const
|
||||
|
||||
function resolveHeaderTitle(header: ReactNode, headerTitle?: string): string {
|
||||
if (headerTitle) return headerTitle
|
||||
if (typeof header === 'string') return header
|
||||
return ''
|
||||
}
|
||||
|
||||
export interface DataGridCardProps<TData extends object> {
|
||||
title?: ReactNode
|
||||
description?: ReactNode
|
||||
actions?: ReactNode
|
||||
columns: ColumnDef<TData, unknown>[]
|
||||
data: TData[]
|
||||
/** Ключ строки — функция, возвращающая уникальный id. */
|
||||
rowId?: (row: TData, index: number) => string
|
||||
emptyTitle?: string
|
||||
emptyDescription?: string
|
||||
emptyAction?: ReactNode
|
||||
onRowClick?: (row: TData) => void
|
||||
/** Включить пагинацию. По умолчанию true. */
|
||||
pagination?: boolean
|
||||
/** Размер страницы. По умолчанию 10. */
|
||||
pageSize?: number
|
||||
/** Footer-контент (итоги). */
|
||||
footerContent?: ReactNode
|
||||
/** Плотный layout. */
|
||||
dense?: boolean
|
||||
/** Колонка действий закреплена справа. */
|
||||
pinLastColumn?: boolean
|
||||
/** Сортировка по умолчанию. */
|
||||
initialSorting?: SortingState
|
||||
/** Включить виртуализацию строк (для тяжёлых таблиц). Требует height. */
|
||||
virtualization?: boolean
|
||||
/** Высота viewport для виртуализации (px). По умолчанию 480. */
|
||||
height?: number
|
||||
className?: string
|
||||
}
|
||||
|
||||
function DataGridPaginationBar() {
|
||||
return (
|
||||
<div className="px-4 py-2.5">
|
||||
<DataGridPagination {...PAGINATION_LABELS} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DataGridCardBody<TData extends object>({
|
||||
table,
|
||||
data,
|
||||
emptyTitle,
|
||||
onRowClick,
|
||||
dense,
|
||||
virtualization,
|
||||
height,
|
||||
footerContent,
|
||||
showPagination,
|
||||
}: {
|
||||
table: ReturnType<typeof useReactTable<TData>>
|
||||
data: TData[]
|
||||
emptyTitle: string
|
||||
onRowClick?: (row: TData) => void
|
||||
dense: boolean
|
||||
virtualization: boolean
|
||||
height: number
|
||||
footerContent?: ReactNode
|
||||
showPagination: boolean
|
||||
}) {
|
||||
return (
|
||||
<DataGridContainer border={false}>
|
||||
<DataGrid
|
||||
table={table}
|
||||
recordCount={data.length}
|
||||
onRowClick={onRowClick}
|
||||
emptyMessage={emptyTitle}
|
||||
tableLayout={{
|
||||
dense,
|
||||
rowBorder: true,
|
||||
headerSticky: true,
|
||||
headerBackground: true,
|
||||
headerBorder: true,
|
||||
width: 'auto',
|
||||
columnsVisibility: false,
|
||||
columnsResizable: false,
|
||||
columnsPinnable: false,
|
||||
columnsMovable: false,
|
||||
rowsDraggable: false,
|
||||
rowsPinnable: false,
|
||||
}}
|
||||
>
|
||||
{virtualization ? (
|
||||
<>
|
||||
<DataGridScrollArea orientation="vertical" style={{ height }}>
|
||||
<DataGridTableVirtual height={height} footerContent={footerContent} />
|
||||
</DataGridScrollArea>
|
||||
{showPagination ? <DataGridPaginationBar /> : null}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<DataGridTable footerContent={footerContent} />
|
||||
{showPagination ? <DataGridPaginationBar /> : null}
|
||||
</>
|
||||
)}
|
||||
</DataGrid>
|
||||
</DataGridContainer>
|
||||
)
|
||||
}
|
||||
|
||||
export function DataGridCard<TData extends object>({
|
||||
title,
|
||||
description,
|
||||
actions,
|
||||
columns,
|
||||
data,
|
||||
rowId,
|
||||
emptyTitle = 'Нет записей',
|
||||
emptyDescription,
|
||||
emptyAction,
|
||||
onRowClick,
|
||||
pagination,
|
||||
pageSize = 10,
|
||||
footerContent,
|
||||
dense = false,
|
||||
pinLastColumn = false,
|
||||
initialSorting,
|
||||
virtualization = false,
|
||||
height = 480,
|
||||
className,
|
||||
}: DataGridCardProps<TData>) {
|
||||
const [sorting, setSorting] = useState<SortingState>(initialSorting ?? [])
|
||||
|
||||
const lastColId = pinLastColumn ? columns[columns.length - 1]?.id ?? '' : ''
|
||||
|
||||
const showPagination = pagination ?? true
|
||||
|
||||
const table = useReactTable<TData>({
|
||||
data,
|
||||
columns,
|
||||
state: { sorting },
|
||||
onSortingChange: setSorting,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getPaginationRowModel: showPagination ? getPaginationRowModel() : undefined,
|
||||
initialState: {
|
||||
...(showPagination ? { pagination: { pageIndex: 0, pageSize } } : {}),
|
||||
...(pinLastColumn && lastColId ? { columnPinning: { right: [lastColId] } } : {}),
|
||||
},
|
||||
getRowId: rowId
|
||||
? (row, index) => rowId(row, index)
|
||||
: undefined,
|
||||
enableColumnPinning: pinLastColumn,
|
||||
})
|
||||
|
||||
const hasHeader = Boolean(title || description || actions)
|
||||
|
||||
if (data.length === 0) {
|
||||
if (!hasHeader) {
|
||||
return (
|
||||
<div className={className}>
|
||||
<EmptyState title={emptyTitle} description={emptyDescription} action={emptyAction} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className={cn('ring-0 shadow-none', className)}>
|
||||
<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>
|
||||
<EmptyState title={emptyTitle} description={emptyDescription} action={emptyAction} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
const gridBody = (
|
||||
<DataGridCardBody
|
||||
table={table}
|
||||
data={data}
|
||||
emptyTitle={emptyTitle}
|
||||
onRowClick={onRowClick}
|
||||
dense={dense}
|
||||
virtualization={virtualization}
|
||||
height={height}
|
||||
footerContent={footerContent}
|
||||
showPagination={showPagination}
|
||||
/>
|
||||
)
|
||||
|
||||
if (!hasHeader) {
|
||||
return <div className={className}>{gridBody}</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className={cn('ring-0 shadow-none', className)}>
|
||||
<CardHeader className="flex flex-row items-center justify-between gap-2 border-b border-border/50 pb-4">
|
||||
<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="p-0 pt-2">{gridBody}</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
/** Хелпер для конвертации DataTableColumn<T> → ColumnDef<T> с DataGridColumnHeader. */
|
||||
export function columnDefFromDataTable<T>(
|
||||
cols: DataTableColumn<T>[],
|
||||
): ColumnDef<T, unknown>[] {
|
||||
return cols.map((c) => {
|
||||
const title = resolveHeaderTitle(c.header, c.headerTitle)
|
||||
const Icon = c.icon
|
||||
const sortable = c.sortable ?? Boolean(Icon)
|
||||
|
||||
return {
|
||||
id: c.key,
|
||||
...(sortable
|
||||
? {
|
||||
accessorFn: c.sortValue
|
||||
? (row: T) => c.sortValue!(row)
|
||||
: (row: T) => (row as Record<string, unknown>)[c.key] as string | number,
|
||||
}
|
||||
: {}),
|
||||
header: Icon
|
||||
? ({ column }) => (
|
||||
<DataGridColumnHeader
|
||||
column={column}
|
||||
title={title}
|
||||
icon={<Icon />}
|
||||
/>
|
||||
)
|
||||
: () => c.header,
|
||||
cell: ({ row }) => c.cell(row.original, row.index),
|
||||
enableSorting: sortable,
|
||||
meta: {
|
||||
headerTitle: title || undefined,
|
||||
cellClassName: c.className,
|
||||
headerClassName: c.headerClassName,
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** re-export flexRender для удобства использования в колонках. */
|
||||
export { flexRender }
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import { cn } from '@cfdm/ui/lib/utils'
|
||||
|
||||
export function dataGridCellStack(
|
||||
primary: ReactNode,
|
||||
secondary?: ReactNode,
|
||||
className?: string,
|
||||
) {
|
||||
return (
|
||||
<div className={cn('flex flex-col', className)}>
|
||||
<span className="font-medium">{primary}</span>
|
||||
{secondary ? <span className="text-xs text-muted-foreground">{secondary}</span> : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function dataGridCellWithIcon(
|
||||
icon: ReactNode,
|
||||
children: ReactNode,
|
||||
className?: string,
|
||||
) {
|
||||
return (
|
||||
<div className={cn('flex items-center gap-2', className)}>
|
||||
<span className="shrink-0 text-muted-foreground">{icon}</span>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function dataGridCellWithFlag(
|
||||
flag: ReactNode,
|
||||
primary: ReactNode,
|
||||
secondary?: ReactNode,
|
||||
) {
|
||||
return (
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span className="flex size-4 shrink-0 items-center justify-center">{flag}</span>
|
||||
{secondary ? dataGridCellStack(primary, secondary) : <span className="font-medium">{primary}</span>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { LucideIcon } from 'lucide-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
|
||||
icon?: LucideIcon
|
||||
sortable?: boolean
|
||||
sortValue?: (row: T) => string | number
|
||||
headerTitle?: string
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { useState } from 'react'
|
||||
import { format, parseISO, isValid } from 'date-fns'
|
||||
import { ru } from 'date-fns/locale'
|
||||
import { CalendarIcon, XIcon } from 'lucide-react'
|
||||
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import { Calendar } from '@cfdm/ui/components/calendar'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@cfdm/ui/components/popover'
|
||||
import { cn } from '@cfdm/ui/lib/utils'
|
||||
|
||||
interface FormDatePickerProps {
|
||||
id?: string
|
||||
value?: string
|
||||
onChange: (value: string) => void
|
||||
placeholder?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function FormDatePicker({
|
||||
id,
|
||||
value = '',
|
||||
onChange,
|
||||
placeholder = 'Выберите дату',
|
||||
className,
|
||||
}: FormDatePickerProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const parsed = value ? parseISO(value) : undefined
|
||||
const selected = parsed && isValid(parsed) ? parsed : undefined
|
||||
const label = selected
|
||||
? format(selected, 'd MMMM yyyy', { locale: ru })
|
||||
: placeholder
|
||||
|
||||
return (
|
||||
<div className={cn('relative w-full', className)}>
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<Button
|
||||
id={id}
|
||||
type="button"
|
||||
variant="outline"
|
||||
className={cn(
|
||||
'w-full justify-start pe-9 font-normal',
|
||||
!selected && 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
<CalendarIcon data-icon="inline-start" />
|
||||
{label}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<PopoverContent align="start" className="w-auto p-0">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={selected}
|
||||
onSelect={(date) => {
|
||||
if (date) {
|
||||
onChange(format(date, 'yyyy-MM-dd'))
|
||||
setOpen(false)
|
||||
}
|
||||
}}
|
||||
defaultMonth={selected}
|
||||
locale={ru}
|
||||
weekStartsOn={1}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
{selected ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="absolute end-1 top-1/2 -translate-y-1/2"
|
||||
aria-label="Очистить дату"
|
||||
onClick={() => onChange('')}
|
||||
>
|
||||
<XIcon />
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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,73 @@
|
||||
import { useEffect } from 'react'
|
||||
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',
|
||||
})
|
||||
|
||||
// Re-sync form values when the sheet opens or defaultValues change (e.g. edit vs create).
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
form.reset(defaultValues as DefaultValues<TField>)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, defaultValues])
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -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,136 @@
|
||||
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'
|
||||
|
||||
import { ModeToggle } from '@/components/mode-toggle'
|
||||
|
||||
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>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton size="lg" render={<Link to="/dashboard" />}>
|
||||
<div
|
||||
className="flex aspect-square size-8 items-center justify-center rounded-md bg-primary text-primary-foreground"
|
||||
aria-hidden
|
||||
>
|
||||
<Server className="size-4" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5 leading-none">
|
||||
<span className="font-semibold">VPS Tracker</span>
|
||||
<span className="text-xs text-muted-foreground">Учёт виртуальных серверов</span>
|
||||
</div>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</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>
|
||||
<div className="ml-auto">
|
||||
<ModeToggle />
|
||||
</div>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
Loaded 100 of 255 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user