refactor(repo): переход на pnpm monorepo с shadcn/ui и Fastify+Drizzle
Frontend:
- apps/web (Vite+TS, TanStack Router/Query, shadcn/ui @cfdm/ui base-nova)
- 10 страниц в routes/_auth/, Recharts через shadcn Chart, lucide-react
- формы на RHF + Zod (FormSheet/FormField)
- удалены Tabler, Chart.js, react-router-dom
Backend (параллельный трек):
- apps/api (Fastify 5 + Drizzle + better-sqlite3)
- packages/db: Drizzle-схема и repositories по сущностям
- packages/shared: Zod-контракты
- роуты с валидацией и единым форматом ошибок { error: { code, message } }
- sync/backup — заглушки 501 (billmanager-адаптеры переносятся отдельно)
- legacy Express оставлен как runtime по умолчанию (RUNTIME=express)
Infra:
- Dockerfile multi-stage под pnpm workspaces
- .dockerignore и docker-compose обновлены под monorepo
Rules:
- удалены нерелевантные правила (rust, cloudflare, server/frontend-conventions)
- project-structure.mdc и AGENTS.md переписаны под monorepo
- frontend-shadcn.mdc, shadcn-ui-production.mdc, sqlite.mdc обновлены
Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -0,0 +1,252 @@
|
||||
/**
|
||||
* BILLmanager API response parsers
|
||||
*/
|
||||
|
||||
/**
|
||||
* Parse BILLmanager JSON list response.
|
||||
* bjson: { elem: [ {...}, {...} ] } или { data: { elem: [...] } }
|
||||
* xml/json: { doc: { vds: { elem: [...] } } }
|
||||
*/
|
||||
export function extractList(data, key) {
|
||||
if (Array.isArray(data.elem)) return data.elem
|
||||
if (data.data?.elem) return Array.isArray(data.data.elem) ? data.data.elem : [data.data.elem]
|
||||
const doc = data.doc || data
|
||||
let list = doc[key]
|
||||
if (!list) return []
|
||||
if (Array.isArray(list)) return list
|
||||
if (list.elem) {
|
||||
const elems = Array.isArray(list.elem) ? list.elem : [list.elem]
|
||||
return elems
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract flat object from BILLmanager elem (can be array of { $name, $t } or already flat object)
|
||||
*/
|
||||
export function elemToObject(elem) {
|
||||
if (!elem) return {}
|
||||
if (Array.isArray(elem)) {
|
||||
const obj = {}
|
||||
for (const e of elem) {
|
||||
const name = e.$name || e.name
|
||||
const val = e.$t ?? e.$ ?? e
|
||||
if (name) obj[name] = typeof val === 'object' && val !== null ? (val.$t ?? val.$ ?? JSON.stringify(val)) : val
|
||||
}
|
||||
return obj
|
||||
}
|
||||
return typeof elem === 'object' ? elem : {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse pricelist string: "KVM SSD Start (1 CPU/768 MB RAM/7 GB SSD)"
|
||||
* @returns {{ vcpu: number, ramGb: number, diskGb: number, diskType: string, virtualization: string }}
|
||||
*/
|
||||
export function parsePricelist(pricelist) {
|
||||
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 }
|
||||
}
|
||||
|
||||
/**
|
||||
* Парсит описание тарифа из vds.order.pricelist (desc)
|
||||
* Поддерживает два формата: текстовый (Firstbyte) и HTML (Selectel и др.)
|
||||
* @param {string} desc - HTML или текстовое описание тарифа
|
||||
* @returns {{ name: string, vcpu: number, ramGb: number, diskGb: number, diskType: string, virtualization: string, channel: string, location: string, cpuModel: string }}
|
||||
*/
|
||||
export function parseTariffDesc(desc) {
|
||||
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()
|
||||
|
||||
// Формат 1: "Процессор: 1 ядро", "Память: 1 GB", "Диск: 20 GB SSD", "Канал: 200Mb/s"
|
||||
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'
|
||||
|
||||
// Формат 2 (HTML): "Публичная сеть: 250 Мбит/с", "Локация: Москва, Россия", "Процессор: Ryzen 7 5800X", "NVMe накопитель"
|
||||
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 }
|
||||
}
|
||||
|
||||
/**
|
||||
* Парсит название датацентра для извлечения страны и локации
|
||||
* @param {string} dcName - название ДЦ, напр. "1 Датацентр Россия, Москва", "[DE] Франкфурт", "Франция"
|
||||
* @returns {{ country: string, location: string }}
|
||||
*/
|
||||
export function parseDatacenterName(dcName) {
|
||||
const s = String(dcName || '').trim()
|
||||
if (!s) return { country: '', location: '' }
|
||||
|
||||
const COUNTRY_CODE_MAP = {
|
||||
DE: 'Германия',
|
||||
FI: 'Финляндия',
|
||||
RU: 'Россия',
|
||||
FR: 'Франция',
|
||||
GB: 'Великобритания',
|
||||
NL: 'Нидерланды',
|
||||
US: 'США',
|
||||
SE: 'Швеция',
|
||||
NO: 'Норвегия',
|
||||
BE: 'Бельгия',
|
||||
CH: 'Швейцария',
|
||||
CZ: 'Чехия',
|
||||
CA: 'Канада',
|
||||
LV: 'Латвия',
|
||||
LT: 'Литва',
|
||||
EE: 'Эстония',
|
||||
PL: 'Польша',
|
||||
IT: 'Италия',
|
||||
DK: 'Дания',
|
||||
AU: 'Австралия',
|
||||
ES: 'Испания',
|
||||
SG: 'Сингапур',
|
||||
}
|
||||
|
||||
// "[DE] Франкфурт | AMD EPYC" -> country: DE/Германия, location: Франкфурт
|
||||
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 }
|
||||
}
|
||||
|
||||
// "N Датацентр Страна, Город" или "Датацентр Страна, Город"
|
||||
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 }
|
||||
}
|
||||
}
|
||||
|
||||
// "ММТС-9", "Adman", "Европа DC1", "Москва DC3" — по ключевым словам
|
||||
if (/ММТС|Adman|Москва/i.test(s)) {
|
||||
return { country: 'Россия', location: s }
|
||||
}
|
||||
if (/Европа/i.test(s)) {
|
||||
return { country: 'Европа', location: s.replace(/Европа\s*/i, '').trim() || s }
|
||||
}
|
||||
|
||||
// "США | Ryzen 9 9950X" — страна до |
|
||||
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: '' }
|
||||
}
|
||||
|
||||
/**
|
||||
* Извлечь tariflist из ответа vds.order (поддержка разных форматов BILLmanager)
|
||||
* @param {object} data - сырой ответ API
|
||||
* @returns {Array}
|
||||
*/
|
||||
export function extractTariflist(data) {
|
||||
if (!data) return []
|
||||
const listNode = data.list ?? data.doc?.list ?? data.doc
|
||||
if (!listNode) return []
|
||||
|
||||
// tariflist / tarifflist / pricelist — разные варианты названия
|
||||
let list = listNode.tariflist ?? listNode.tarifflist ?? listNode.pricelist
|
||||
if (Array.isArray(list)) return list
|
||||
|
||||
// elem-формат (как в vds, payment)
|
||||
const elems = listNode.elem
|
||||
if (elems) return Array.isArray(elems) ? elems : [elems]
|
||||
|
||||
return []
|
||||
}
|
||||
Reference in New Issue
Block a user