Compare commits

...
34 Commits
Author SHA1 Message Date
DenozordecandCursor 0de0538218 feat(api): завершение миграции на Fastify — sync, scheduler, backup
Docker / build (push) Has been cancelled
Портированы BILLmanager sync/test/balance, планировщик и Telegram,
импорт/экспорт бэкапов и migrate API; Docker по умолчанию на fastify.
Добавлен .understand-anything/ в gitignore, исправлен path codegraph в MCP.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-27 23:22:59 +07:00
DenozordecandCursor 836c8a2936 feat(web): связка страны и города в форме VPS
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-27 02:18:18 +07:00
DenozordecandCursor a954591493 feat(web): ReUI Autocomplete для страны/города и вертикальная раскладка локации
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-27 02:11:45 +07:00
DenozordecandCursor f681f72e8c fix(web): флаги стран, FormDatePicker и плоские таблицы без двойной рамки
SVG-флаги через flagcdn и resolveCountryCode; поле «Оплачено до» — компактный Popover+Calendar на русском; DataGridCard без вложенной обводки.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-27 01:59:50 +07:00
DenozordecandCursor 4c7dfc45d6 feat(web): иконки заголовков и rich cells во всех таблицах
Lucide-иконки в заголовках, двухстрочные ячейки аккаунтов, флаги локации VPS, favicon хостеров.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-27 01:42:45 +07:00
DenozordecandCursor a6f202f659 feat(web): ReUI DataGrid — Column Icons, оболочка и хелперы ячеек
DataGridColumnHeader в columnDefFromDataTable, bordered-контейнер, русская пагинация, хелперы dataGridCellStack/WithIcon/WithFlag.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-27 01:42:39 +07:00
DenozordecandCursor bbb6de29be fix(web): поиск VPS на отдельной строке над фильтрами
Строка поиска вынесена выше кнопки «Фильтр» и чипов активных фильтров для более понятной иерархии тулбара.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-27 01:27:58 +07:00
DenozordecandCursor af9bd44d44 fix(web): кнопка «Фильтр» в тулбаре VPS — явный trigger для ReUI Filters
Без пропа trigger компонент Filters рендерил пустую кнопку, из-за чего фильтры были невидимы на странице.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-27 00:51:20 +07:00
DenozordecandCursor b1315ffe0a feat(web): миграция таблиц на @reui/data-grid (TanStack Table + virtualization + pinning)
- Установлен @reui/data-grid через shadcn CLI: зависимости @tanstack/react-table,
  @tanstack/react-virtual, @dnd-kit/* добавлены в apps/web
- Поправлены type-only импорты во всех файлах data-grid (verbatimModuleSyntax),
  убран unused параметр table в DataGridTableVirtualBody
- Создана shared-обёртка apps/web/src/components/data-grid-card.tsx (замена
  DataTableCard) с типизированными ColumnDef<TData>, сортировкой, пагинацией
  (>25 строк), column pinning, optional virtualization (DataGridScrollArea +
  DataGridTableVirtual), footer-контентом и хелпером columnDefFromDataTable
- Мигрированы все 7 страниц: vps, dashboard, payments, balance, accounts,
  tariffs, providers — DataTableCard/TableCard+Table → DataGridCard
- Virtualization включена для VPS и payments при >200 строк (height 560)
- Column pinning последней колонки (actions) — vps, payments, balance, accounts,
  providers
- Footer rows с итогами: balance (приходы/списания/итого), payments (по валютам)
- build + tsc --noEmit без ошибок

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-27 00:06:59 +07:00
DenozordecandCursor 63da57687d feat(web): фильтры VPS в стиле reui.io + ReUI-компоненты в форме
- Регистр @reui в components.json (URL https://reui.io/r/{style}/{name}.json)
- Установлены @reui/filters, @reui/autocomplete, @reui/number-field,
  @reui/date-selector и shadcn slider через shadcn CLI
- Поправлены TS-импорты в ReUI-исходниках (type-only) и удалены неиспользуемые
  объявления; в packages/ui добавлены react-day-picker и date-fns для calendar
- Новый VpsFiltersToolbar: тулбар с поиском, кнопкой «Фильтр» (ReUI Filters),
  chips с операторами, Popover «Вид» (группировка/компактность/пресеты)
- vps-filters.tsx переписан под массивы значений (мультивыбор) с поддержкой
  операторов is_any_of / contains / gte; applyVpsFilters принимает как state,
  так и Filter[] (stateToActiveFilters)
- В форме VPS vcpu/ramGb/diskGb/monthlyRate/dailyRate — @reui/number-field
  через Controller, paidUntil — @reui/date-selector (одиночная дата)
- AutoCompleteInput: отступы флага size-4 leading-none, CommandList py-1,
  CommandItem gap-2.5, PopoverContent min-w-[220px]
- build + tsc --noEmit без ошибок

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-26 23:53:16 +07:00
DenozordecandCursor b87d7c6fad fix(web): AutoCompleteInput — canonical shadcn Combobox, без браузерных подсказок
- переделан по документации shadcn: Button trigger + Popover + Command
  с CommandInput внутри (вместо нативного Input в триггере)
- CommandInput с autoComplete="off" / autoCorrect="off" / spellCheck=false —
  браузерные подсказки больше не появляются
- поддержан произвольный ввод: пункт «Использовать: <запрос>» при отсутствии
  совпадений

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-26 23:03:20 +07:00
DenozordecandCursor db1d26d324 feat(web): стандартизированный справочник стран и городов в @cfdm/shared/geo
- packages/shared/src/geo: страны ISO 3166-1 alpha-2 + топ-города по странам
- vps.tsx: countryOptions/cityOptions из справочника + существующих VPS,
  города фильтруются по выбранной стране
- auto-complete-input: убран onBlur-хак с setTimeout, фильтрация по
  основному полю, initialFocus=false чтобы popover не закрывался
- vite.config.ts + tsconfig.json: алиас @cfdm/shared/geo

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-26 22:56:54 +07:00
DenozordecandCursor a51b5f3594 feat(web): страна/город в форме и фильтрах VPS — автокомплит + флаг
- Новый компонент AutoCompleteInput (Popover + Command/cmdk):
  ввод с подсказками, поддержка leading-иконки (эмодзи-флаг).
- Страна: в списке вариантов и в самом инпуте показывается
  эмодзи-флаг через getCountryFlagEmoji.
- Город: автокомплит без флага.
- Источник данных — уникальные значения из snapshot.vps
  (server-side API для стран/городов отсутствует).
- Подключено в форме VPS и в фильтрах страницы.
- Добавлены shadcn-компоненты command, popover, input-group;
  зависимость cmdk в packages/ui.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-26 22:39:33 +07:00
DenozordecandCursor 6310ed8e02 fix(web): форма VPS — возвращены поля Страна/Город/Дата-центр
Поля country/city/datacenter были потеряны при миграции формы на shadcn.
Добавлены в zod-схему, EMPTY_FORM, openEdit и в JSX формы (грид из 3
колонок после «Проект»).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-26 22:36:17 +07:00
DenozordecandCursor 7ef42ee7e8 fix(web): VPS — корректная валюта тарифа и конвертация курсов в таблице
Раньше в колонке тарифа вызывался formatInBaseCurrency с ratesData=null,
поэтому 0.8 EUR отображались как «0,80 ₽» (без конвертации, но в базовой
валюте). Теперь:
- ratesData грузится через ratesQueryOptions (как на dashboard/reports);
- валюта тарифа определяется через effectiveVpsTariffCurrency (приоритет
  baseCurrency провайдера, затем vps.currency);
- форматирование через formatInProviderCurrency — учитывает курсы
  провайдера (usdRate/eurRate) и глобальные курсы, при их отсутствии
  показывает сумму в исходной валюте, а не в базовой.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-26 22:33:44 +07:00
DenozordecandCursor ee5d5b228b fix(web): VPS — дробные значения в полях ставок (step="any")
Ставка/мес и Ставка/день теперь принимают дробные значения (например,
0.8 EUR), а не только целые. Раньше browser-native валидация type=number
со step=1 блокировала сохранение.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-26 22:28:00 +07:00
DenozordecandCursor e43cdc1b85 fix(ui): позиция выпадающего списка Select — снизу, а не поверх триггера
В SelectContent по умолчанию было alignItemWithTrigger=true (стиль macOS:
поповер перекрывает триггер) и align=center. Заменил на align=start и
alignItemWithTrigger=false — меню появляется под триггером, как в
стандартном shadcn.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-26 22:22:26 +07:00
DenozordecandCursor 525eff0d09 feat(web): фильтры на странице VPS — полный набор + пресеты
Восстановлены фильтры, потерянные при миграции на shadcn:
поиск, хостер/аккаунт, проект (с опцией «Без проекта»), статус,
окружение, тип тарифа, мониторинг/бэкап, страна/город/дата-центр,
минимумы vCPU/RAM/Disk, группировка по проекту, компактная таблица.
Пресеты сохраняются в localStorage. Добавлен shadcn-компонент checkbox.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-26 17:31:45 +07:00
DenozordecandCursor 8b38021200 docker: оптимизация образа — 2 стадии вместо 3, один COPY-слой в runtime
- убран отдельный deps-stage: build + pnpm prune --prod в одном RUN
- удаление src/tsconfig/.vite-temp и лишних файлов в node_modules (md/map/ts/LICENSE/...)
- runtime: alpine + nodejs, один COPY --from=build /app (symlink’и pnpm сохраняются)
- итого: 2 стадии, ~1 COPY-слой в runtime вместо ~17

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-26 16:08:34 +07:00
DenozordecandCursor bc0da072ee fix(web): SelectField использует стандартные shadcn Select-компоненты с w-full
- SelectField переписан на переиспользование Select/SelectTrigger/
  SelectValue/SelectContent/SelectItem из @cfdm/ui (вместо дубля стилей)
- triggerClassName по умолчанию w-full — триггер виден в формах
  (для horizontal field в settings передаётся w-32)
- items prop в Select.Root — SelectValue рендерит label выбранного элемента

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-26 15:57:11 +07:00
DenozordecandCursor b2611d951b fix(web): селекты показывают label вместо технического значения; редактирование VPS подтягивает данные
- SelectField: обёртка над base-ui Select с items prop
  (Select.Value рендерит label выбранного элемента, а не raw value)
- все Select в forms (vps/providers/accounts/payments/balance/settings)
  переписаны на SelectField
- FormSheetRhf: useEffect reset(defaultValues) при открытии sheet
  — теперь форма редактирования заполняется текущими данными

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-26 15:54:35 +07:00
DenozordecandCursor 3e57728ddc feat(web): тёмная тема (system/manual) и фикс логотипа в icon-режиме сайдбара
- ThemeProvider (next-themes, attribute=class, defaultTheme=system)
- ModeToggle в header: светлая / тёмная / системная
- suppressHydrationWarning на <html> против FOUC
- логотип в SidebarHeader через SidebarMenuButton size="lg"
  (корректное схлопывание в icon-режиме, aspect-square для иконки)

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-26 15:50:30 +07:00
DenozordecandCursor 3026f1d1ce fix(docker): копирование apps/api/db.js и routes/ в образ; пути к web/dist и seed
- Dockerfile: добавлены COPY для db.js, routes/, apps/web/public (seed)
- apps/api/index.js: distPath → ../web/dist (раньше ../dist)
- apps/api/db/index.js: DB_PATH/SEED_DIR/dataDir — на 3 уровня вверх
  (apps/api/db/ → корень) и seed из apps/web/public/data

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-26 13:51:19 +07:00
DenozordecandCursor 6fbd1a9113 refactor(repo): переход на pnpm monorepo с shadcn/ui и Fastify+Drizzle
Frontend:
- apps/web (Vite+TS, TanStack Router/Query, shadcn/ui @cfdm/ui base-nova)
- 10 страниц в routes/_auth/, Recharts через shadcn Chart, lucide-react
- формы на RHF + Zod (FormSheet/FormField)
- удалены Tabler, Chart.js, react-router-dom

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

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

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

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-26 13:42:05 +07:00
Denozordec 8408155be6 feat(accounts): «Хостер / аккаунт» в журнале синхронизации; журнал баланса — только имя аккаунта
Made-with: Cursor
2026-03-23 23:14:39 +07:00
Denozordec 24e1b8f4ef fix(currency): глобальный курс при нулевой сумме (не «курсы не загружены»)
Made-with: Cursor
2026-03-23 23:05:34 +07:00
Denozordec 0b4507e2d2 fix(rates): fallback на прямой fetch если прокси недоступен
Made-with: Cursor
2026-03-23 23:00:52 +07:00
Denozordec 6bf270d715 feat(dashboard): подпись «хостер / аккаунт» в таблице остатков
Made-with: Cursor
2026-03-23 22:53:44 +07:00
Denozordec 92a986c858 fix(rates): серверный прокси /api/rates-proxy и бейдж «Курсы не загружены»
- Добавлен /api/rates-proxy для загрузки курсов без CORS
- Фронтенд загружает курсы через прокси, а не напрямую
- convertWithProviderRate возвращает source=no-rates при отсутствии ratesData
- ConvertedAmount показывает «Курсы не загружены» (жёлтый) вместо ложного «Глобальный курс»

Made-with: Cursor
2026-03-23 21:45:02 +07:00
Denozordec a97ec8c121 refactor(currency): валюта тарифа = валюта хостера, не VPS
- effectiveVpsTariffCurrency всегда берёт provider.baseCurrency
- Убран селектор валюты из формы VPS (подпись из хостера)
- DashboardPage, ResourcesPage, ReportsPage: вместо item.currency — baseCurrency провайдера
- Конвертация по глобальным курсам теперь корректно находит EUR/USD

Made-with: Cursor
2026-03-23 21:32:46 +07:00
Denozordec c161eaf84a fix(currency): глобальные курсы без явного курса хостера и нормализация Valute ЦБ
- parseProviderFxRate: пусто/auto/невалид → конвертация по ссылке из настроек
- normalizeRatesPayload + App: JSON ЦБ с Valute → base+rates как у latest.js
- Подсказка в форме хостера про курсы USD/EUR

Made-with: Cursor
2026-03-23 21:25:01 +07:00
Denozordec a4752d4379 fix(currency): глобальная конвертация и effectiveVpsTariffCurrency для BM
- baseCurrency хостера не подставлять при пустой валюте VPS, если есть API (дефолт RUB)
- Подстановка валюты хостера только для провайдеров без API
- RUR → RUB для курсов ЦБ; проверка курсов через Number.isFinite

Made-with: Cursor
2026-03-23 21:17:19 +07:00
Denozordec f4517f03fc feat(balance): подпись аккаунта «хостер / имя» как в фильтрах VPS
- Вынесен accountSelectLabel в src/lib/account-select-label.js
- Модалка списания, таблица баланса и журнал на BalancePage
- VpsPage переведён на общий модуль

Made-with: Cursor
2026-03-23 21:06:52 +07:00
Denozordec 524cbba5e7 fix(vps): валюта тарифа — effectiveVpsTariffCurrency и поле в форме
- Для хостеров без API USD в VPS трактуется как дефолт формы, берётся baseCurrency хостера
- В форме VPS добавлен выбор валюты тарифа; при смене хостера подставляется его валюта
- Прогноз по проектам и ConvertedAmount для тарифа используют effectiveVpsTariffCurrency

Made-with: Cursor
2026-03-23 21:03:34 +07:00
255 changed files with 30799 additions and 12004 deletions

No files matched your search

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