feat(billmanager): профиль FirstByte — ДЦ, оплата и specs VPS
Docker / build (push) Failing after 19s
Docker / build (push) Failing after 19s
Парсинг страны/города и paidUntil из list vds; CPU/RAM/диск из vds.order по pricelist_id, иначе vds.edit. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it, vi, afterEach } from 'vitest'
|
||||
|
||||
import { elemToObject, extractList } from './parsers.js'
|
||||
import { elemToObject, extractList, parseDatacenterName } from './parsers.js'
|
||||
import { mapVdsToVps } from './mappers.js'
|
||||
import {
|
||||
DEFAULT_PROFILE,
|
||||
@@ -9,6 +9,10 @@ import {
|
||||
waicoreOverrides,
|
||||
} from './profiles/index.js'
|
||||
import { fetchVds, mapVdsWithProfile } from './operations.js'
|
||||
import {
|
||||
enrichMappedVpsFromTariffs,
|
||||
parseSpecsFromVdsEdit,
|
||||
} from './vds-specs.js'
|
||||
|
||||
/** Minimal Waicore-style bjson (no credentials / addon noise). */
|
||||
const WAICORE_VDS_FIXTURE = {
|
||||
@@ -33,6 +37,44 @@ const WAICORE_VDS_FIXTURE = {
|
||||
],
|
||||
}
|
||||
|
||||
const FIRSTBYTE_VDS_FIXTURE = {
|
||||
func: 'vds',
|
||||
elem: [
|
||||
{
|
||||
id: '4478979',
|
||||
ip: '45.95.202.221',
|
||||
domain: 'auth.shnt.top',
|
||||
expiredate: '2026-10-15',
|
||||
real_expiredate: '2026-10-15',
|
||||
billdaily: 'off',
|
||||
datacentername: '1 Датацентр Россия, Москва',
|
||||
ostempl: 'Debian-13-amd64',
|
||||
pricelist: 'MSK-KVM-SSD-START',
|
||||
pricelist_id: '5228',
|
||||
currency_str: 'RUB',
|
||||
createdate: '2026-07-15',
|
||||
item_status_orig: '2',
|
||||
cost: '75.00 RUB / Месяц',
|
||||
},
|
||||
{
|
||||
id: '4208964',
|
||||
ip: '193.168.227.234',
|
||||
domain: 'vt.shnt.top',
|
||||
expiredate: 'Ежедневное списание',
|
||||
real_expiredate: '2026-07-20',
|
||||
billdaily: 'on',
|
||||
datacentername: '1 Датацентр Россия, Москва',
|
||||
ostempl: 'Debian-13-amd64',
|
||||
pricelist: 'MSK-KVM-SAS-1',
|
||||
pricelist_id: '564',
|
||||
currency_str: 'RUB',
|
||||
createdate: '2026-04-21',
|
||||
item_status_orig: '2',
|
||||
cost: '129.00 RUB / Месяц',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
describe('billmanager profiles', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
@@ -53,6 +95,13 @@ describe('billmanager profiles', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('resolve: firstbyte hostname → firstbyte profile', () => {
|
||||
const p = resolveBillmanagerProfile('https://my.firstbyte.ru/')
|
||||
expect(p.id).toBe('firstbyte')
|
||||
expect(p.funcs.listVds).toBe('vds')
|
||||
expect(p.map.enrichVds).toBeTypeOf('function')
|
||||
})
|
||||
|
||||
it('resolve: unknown hoster → default', () => {
|
||||
const p = resolveBillmanagerProfile('https://bill.hoster.ru/')
|
||||
expect(p.id).toBe('default')
|
||||
@@ -67,6 +116,13 @@ describe('billmanager profiles', () => {
|
||||
expect(merged.map.vds).toBe(DEFAULT_PROFILE.map.vds)
|
||||
})
|
||||
|
||||
it('parseDatacenterName: FirstByte «1 Датацентр Россия, Москва»', () => {
|
||||
expect(parseDatacenterName('1 Датацентр Россия, Москва')).toEqual({
|
||||
country: 'Россия',
|
||||
location: 'Москва',
|
||||
})
|
||||
})
|
||||
|
||||
it('Waicore fixture elem → MappedVps fields', () => {
|
||||
const profile = resolveBillmanagerProfile('https://my.waicore.com/')
|
||||
const elems = extractList(WAICORE_VDS_FIXTURE, profile.extract.listVdsKey)
|
||||
@@ -81,6 +137,82 @@ describe('billmanager profiles', () => {
|
||||
expect(vps.status).toBe('active')
|
||||
})
|
||||
|
||||
it('FirstByte: country/city + paidUntil for monthly and daily', () => {
|
||||
const profile = resolveBillmanagerProfile('https://bill.firstbyte.ru/')
|
||||
const elems = extractList(FIRSTBYTE_VDS_FIXTURE, profile.extract.listVdsKey)
|
||||
expect(elems).toHaveLength(2)
|
||||
|
||||
const monthly = mapVdsWithProfile(
|
||||
profile,
|
||||
elemToObject(elems[0]!),
|
||||
'prov-fb',
|
||||
'acc-fb',
|
||||
)
|
||||
expect(monthly.country).toBe('Россия')
|
||||
expect(monthly.city).toBe('Москва')
|
||||
expect(monthly.datacenter).toBe('1 Датацентр Россия, Москва')
|
||||
expect(monthly.paidUntil).toBe('2026-10-15')
|
||||
expect(monthly.tariffType).toBe('monthly')
|
||||
expect(monthly.diskType).toBe('SSD')
|
||||
expect(monthly.virtualization).toBe('KVM')
|
||||
expect(monthly.vcpu).toBe(0)
|
||||
|
||||
const daily = mapVdsWithProfile(
|
||||
profile,
|
||||
elemToObject(elems[1]!),
|
||||
'prov-fb',
|
||||
'acc-fb',
|
||||
)
|
||||
expect(daily.country).toBe('Россия')
|
||||
expect(daily.city).toBe('Москва')
|
||||
expect(daily.paidUntil).toBe('2026-07-20')
|
||||
expect(daily.tariffType).toBe('daily')
|
||||
expect(daily.diskType).toBe('SAS')
|
||||
})
|
||||
|
||||
it('FirstByte: specs from vds.order by pricelist_id', () => {
|
||||
const profile = resolveBillmanagerProfile('https://my.firstbyte.ru/')
|
||||
expect(profile.options?.fetchVdsEditForSpecs).toBe(true)
|
||||
|
||||
const item = elemToObject(FIRSTBYTE_VDS_FIXTURE.elem[0]!)
|
||||
let mapped = mapVdsWithProfile(profile, item, 'p', 'a')
|
||||
mapped = enrichMappedVpsFromTariffs(item, mapped, [
|
||||
{
|
||||
externalId: '5228',
|
||||
name: 'START',
|
||||
desc: 'START<br/>Процессор: 1 ядро; Память: 1 GB; Диск: 15 GB SSD',
|
||||
vcpu: 1,
|
||||
ramGb: 1,
|
||||
diskGb: 15,
|
||||
diskType: 'SSD',
|
||||
virtualization: 'KVM',
|
||||
},
|
||||
])
|
||||
expect(mapped.vcpu).toBe(1)
|
||||
expect(mapped.ramGb).toBe(1)
|
||||
expect(mapped.diskGb).toBe(15)
|
||||
})
|
||||
|
||||
it('parseSpecsFromVdsEdit reads addon labels', () => {
|
||||
const specs = parseSpecsFromVdsEdit({
|
||||
model: {
|
||||
addon_11: '2',
|
||||
addon_12: '2048',
|
||||
addon_13: '40',
|
||||
pricelist: 'MSK-KVM-SSD-START',
|
||||
},
|
||||
messages: [
|
||||
{ name: 'addon_11', msg: 'Количество ядер процессора' },
|
||||
{ name: 'addon_12', msg: 'Память (MB)' },
|
||||
{ name: 'addon_13', msg: 'Диск SSD (GB)' },
|
||||
],
|
||||
})
|
||||
expect(specs.vcpu).toBe(2)
|
||||
expect(specs.ramGb).toBe(2)
|
||||
expect(specs.diskGb).toBe(40)
|
||||
expect(specs.diskType).toBe('SSD')
|
||||
})
|
||||
|
||||
it('fetchVds for waicore URL uses func=vds.vps', async () => {
|
||||
const calls: string[] = []
|
||||
vi.stubGlobal(
|
||||
|
||||
@@ -44,3 +44,10 @@ export const myHosterOverrides: BillmanagerProfileOverrides = {
|
||||
`resolveBillmanagerProfile(apiBaseUrl)` → `merge(DEFAULT, override)` или `DEFAULT`.
|
||||
|
||||
Sync и operations **не** содержат `if (hoster)` — только профиль.
|
||||
|
||||
## Готовые профили
|
||||
|
||||
| id | Match | Что переопределяет |
|
||||
|----|-------|-------------------|
|
||||
| `waicore` | `waicore.com` / keyword | `funcs.listVds = vds.vps` |
|
||||
| `firstbyte` | `firstbyte.ru`, `firstbyte.club`, `1byte.ru` | `enrichVds`: страна/город, `paidUntil` ISO, `billdaily`→daily, diskType/KVM из имени; `options.fetchVdsEditForSpecs`; sync: specs из `vds.order` по `pricelist_id`, иначе `vds.edit` |
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { parseDatacenterName } from '../parsers.js'
|
||||
import type { MappedVps } from '../mappers.js'
|
||||
import type { BillmanagerProfileOverrides } from './types.js'
|
||||
|
||||
/** YYYY-MM-DD only — skip labels like «Ежедневное списание». */
|
||||
function pickPaidUntilDate(item: Record<string, string>): string {
|
||||
for (const key of ['real_expiredate', 'expiredate'] as const) {
|
||||
const raw = String(item[key] ?? '').trim()
|
||||
const m = raw.match(/^(\d{4}-\d{2}-\d{2})/)
|
||||
if (m) return m[1]!
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
/** Disk / virt hints from FirstByte tariff codes (MSK-KVM-SSD-START, KVM-SAS-1). */
|
||||
export function inferFirstbyteHardwareHints(item: Record<string, string>): {
|
||||
diskType?: string
|
||||
virtualization?: string
|
||||
} {
|
||||
const hay = `${item.pricelist || ''} ${item.intname || ''} ${item.name || ''}`
|
||||
let diskType: string | undefined
|
||||
let virtualization: string | undefined
|
||||
if (/\bNVMe\b/i.test(hay)) diskType = 'NVMe'
|
||||
else if (/\bSAS\b/i.test(hay)) diskType = 'SAS'
|
||||
else if (/\bHDD\b/i.test(hay)) diskType = 'HDD'
|
||||
else if (/\bSSD\b/i.test(hay)) diskType = 'SSD'
|
||||
if (/\bKVM\b/i.test(hay)) virtualization = 'KVM'
|
||||
else if (/\bOpenVZ\b/i.test(hay)) virtualization = 'OpenVZ'
|
||||
else if (/\bLXC\b/i.test(hay)) virtualization = 'LXC'
|
||||
return { diskType, virtualization }
|
||||
}
|
||||
|
||||
/**
|
||||
* FirstByte — standard func=vds, but list has no CPU/RAM/disk numbers.
|
||||
*
|
||||
* Sync additionally fills specs via:
|
||||
* 1) vds.order match by pricelist_id (`enrichMappedVpsFromTariffs`)
|
||||
* 2) func=vds.edit&elid= (`options.fetchVdsEditForSpecs`)
|
||||
*
|
||||
* This enrich: geo, paidUntil, daily billing, diskType/KVM from tariff name.
|
||||
*/
|
||||
export function enrichFirstbyteVds(
|
||||
item: Record<string, string>,
|
||||
mapped: MappedVps,
|
||||
): MappedVps {
|
||||
const dcRaw = (item.datacentername || item.datacenter || mapped.datacenter || '').trim()
|
||||
const { country, location } = parseDatacenterName(dcRaw)
|
||||
const paidUntil = pickPaidUntilDate(item) || mapped.paidUntil
|
||||
const tariffType =
|
||||
String(item.billdaily || '').toLowerCase() === 'on' ? 'daily' : mapped.tariffType
|
||||
const hints = inferFirstbyteHardwareHints(item)
|
||||
|
||||
return {
|
||||
...mapped,
|
||||
country: country || mapped.country,
|
||||
city: location || mapped.city,
|
||||
datacenter: dcRaw || mapped.datacenter,
|
||||
paidUntil,
|
||||
tariffType,
|
||||
diskType: mapped.diskGb ? mapped.diskType : hints.diskType || mapped.diskType,
|
||||
virtualization: hints.virtualization || mapped.virtualization,
|
||||
}
|
||||
}
|
||||
|
||||
export const firstbyteOverrides: BillmanagerProfileOverrides = {
|
||||
id: 'firstbyte',
|
||||
match: {
|
||||
hostnames: ['firstbyte.ru', 'firstbyte.club', '1byte.ru'],
|
||||
keywords: ['firstbyte'],
|
||||
},
|
||||
map: {
|
||||
enrichVds: enrichFirstbyteVds,
|
||||
},
|
||||
options: {
|
||||
fetchVdsEditForSpecs: true,
|
||||
},
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
export { DEFAULT_PROFILE } from './default.js'
|
||||
export { enrichFirstbyteVds, firstbyteOverrides } from './firstbyte.js'
|
||||
export { mergeProfile } from './merge.js'
|
||||
export { PROFILE_OVERRIDES, resolveBillmanagerProfile } from './registry.js'
|
||||
export type {
|
||||
|
||||
@@ -46,5 +46,9 @@ export function mergeProfile(
|
||||
...overrides.requestParams?.orderPricelist,
|
||||
},
|
||||
},
|
||||
options: {
|
||||
...defaults.options,
|
||||
...overrides.options,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { DEFAULT_PROFILE } from './default.js'
|
||||
import { firstbyteOverrides } from './firstbyte.js'
|
||||
import { mergeProfile } from './merge.js'
|
||||
import type { BillmanagerProfile, BillmanagerProfileOverrides } from './types.js'
|
||||
import { waicoreOverrides } from './waicore.js'
|
||||
@@ -7,7 +8,10 @@ import { waicoreOverrides } from './waicore.js'
|
||||
* Hoster override list — first match wins.
|
||||
* Add new hosters here after creating profiles/<id>.ts.
|
||||
*/
|
||||
export const PROFILE_OVERRIDES: BillmanagerProfileOverrides[] = [waicoreOverrides]
|
||||
export const PROFILE_OVERRIDES: BillmanagerProfileOverrides[] = [
|
||||
waicoreOverrides,
|
||||
firstbyteOverrides,
|
||||
]
|
||||
|
||||
function matchesUrl(url: string, override: BillmanagerProfileOverrides): boolean {
|
||||
const match = override.match
|
||||
|
||||
@@ -48,6 +48,14 @@ export type BillmanagerRequestParams = {
|
||||
orderPricelist?: Record<string, string | number>
|
||||
}
|
||||
|
||||
export type BillmanagerProfileOptions = {
|
||||
/**
|
||||
* When list `func=vds` has no CPU/RAM/disk — after tariff match,
|
||||
* call `func=vds.edit&elid=` per VPS (FirstByte and similar).
|
||||
*/
|
||||
fetchVdsEditForSpecs?: boolean
|
||||
}
|
||||
|
||||
export type BillmanagerProfile = {
|
||||
id: string
|
||||
match: BillmanagerMatch
|
||||
@@ -55,6 +63,7 @@ export type BillmanagerProfile = {
|
||||
extract: BillmanagerExtract
|
||||
map: BillmanagerMap
|
||||
requestParams?: BillmanagerRequestParams
|
||||
options?: BillmanagerProfileOptions
|
||||
}
|
||||
|
||||
/** Deep-partial for hoster override files (only divergences). */
|
||||
@@ -65,4 +74,5 @@ export type BillmanagerProfileOverrides = {
|
||||
extract?: Partial<BillmanagerExtract>
|
||||
map?: Partial<BillmanagerMap>
|
||||
requestParams?: BillmanagerRequestParams
|
||||
options?: BillmanagerProfileOptions
|
||||
}
|
||||
|
||||
@@ -18,6 +18,12 @@ import {
|
||||
type TariffItem,
|
||||
} from './operations.js'
|
||||
import { resolveBillmanagerProfile } from './profiles/index.js'
|
||||
import {
|
||||
applyEditSpecs,
|
||||
enrichMappedVpsFromTariffs,
|
||||
fetchVdsEditSpecs,
|
||||
needsHardwareSpecs,
|
||||
} from './vds-specs.js'
|
||||
|
||||
export interface SyncFromBillmanagerOptions {
|
||||
skipTariffs?: boolean
|
||||
@@ -54,12 +60,33 @@ const SYNC_UPDATE_FIELDS = [
|
||||
'paidUntil',
|
||||
] as const
|
||||
|
||||
const SYNC_HARDWARE_FIELDS = ['vcpu', 'ramGb', 'diskGb', 'diskType', 'virtualization'] 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)
|
||||
}
|
||||
|
||||
async function mapVdsWithSpecs(
|
||||
profile: ReturnType<typeof resolveBillmanagerProfile>,
|
||||
item: Record<string, string>,
|
||||
providerId: string,
|
||||
accountId: string,
|
||||
tariffItems: TariffItem[],
|
||||
apiBaseUrl: string,
|
||||
authinfo: string,
|
||||
) {
|
||||
let vps = mapVdsWithProfile(profile, item, providerId, accountId)
|
||||
vps = enrichMappedVpsFromTariffs(item, vps, tariffItems)
|
||||
if (needsHardwareSpecs(vps) && profile.options?.fetchVdsEditForSpecs) {
|
||||
const elid = String(item.id || vps.externalId || '').trim()
|
||||
const specs = await fetchVdsEditSpecs(apiBaseUrl, authinfo, elid)
|
||||
if (specs) vps = applyEditSpecs(vps, specs)
|
||||
}
|
||||
return vps
|
||||
}
|
||||
|
||||
export async function syncFromBillmanager(
|
||||
account: BillmanagerSyncAccount,
|
||||
opts: SyncFromBillmanagerOptions = {},
|
||||
@@ -111,7 +138,15 @@ export async function syncFromBillmanager(
|
||||
|
||||
if (fetchVpsPayments) {
|
||||
for (const item of vdsItems) {
|
||||
const vps = mapVdsWithProfile(profile, item, providerId, accountId)
|
||||
const vps = await mapVdsWithSpecs(
|
||||
profile,
|
||||
item,
|
||||
providerId,
|
||||
accountId,
|
||||
tariffItems,
|
||||
apiBaseUrl,
|
||||
authinfo,
|
||||
)
|
||||
const id = `vps-bm-${accountId}-${vps.externalId}`
|
||||
const additionalIps = JSON.stringify(vps.additionalIps || [])
|
||||
const dailyRate = vps.dailyRate
|
||||
@@ -153,13 +188,41 @@ export async function syncFromBillmanager(
|
||||
monthlyRate,
|
||||
paidUntil,
|
||||
notes,
|
||||
vcpu: vps.vcpu,
|
||||
ramGb: vps.ramGb,
|
||||
diskGb: vps.diskGb,
|
||||
diskType: vps.diskType,
|
||||
virtualization: vps.virtualization,
|
||||
}
|
||||
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
|
||||
for (const f of SYNC_HARDWARE_FIELDS) {
|
||||
if (userOverrides.includes(f)) {
|
||||
merged[f] = existing[f as keyof typeof existing] as never
|
||||
} else {
|
||||
// Keep previous non-zero hardware if API still returns empty
|
||||
const next = merged[f]
|
||||
const prev = existing[f as keyof typeof existing]
|
||||
if (
|
||||
(next == null || next === 0 || next === '') &&
|
||||
prev != null &&
|
||||
prev !== 0 &&
|
||||
prev !== ''
|
||||
) {
|
||||
merged[f] = prev as never
|
||||
}
|
||||
}
|
||||
}
|
||||
const compareFields = [
|
||||
'ip',
|
||||
'ipv6',
|
||||
'dns',
|
||||
...SYNC_UPDATE_FIELDS,
|
||||
...SYNC_HARDWARE_FIELDS,
|
||||
] as const
|
||||
const changedFields = compareFields.filter(
|
||||
(f) => normVal(merged[f as keyof typeof merged]) !== normVal(existing[f as keyof typeof existing]),
|
||||
)
|
||||
@@ -184,6 +247,11 @@ export async function syncFromBillmanager(
|
||||
monthlyRate: merged.monthlyRate,
|
||||
paidUntil: merged.paidUntil,
|
||||
notes: merged.notes,
|
||||
vcpu: merged.vcpu,
|
||||
ramGb: merged.ramGb,
|
||||
diskGb: merged.diskGb,
|
||||
diskType: merged.diskType,
|
||||
virtualization: merged.virtualization,
|
||||
})
|
||||
.where(eq(schema.vps.id, existing.id))
|
||||
.run()
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
/**
|
||||
* Enrich VPS hardware specs when list `func=vds` has no CPU/RAM/disk
|
||||
* (typical for FirstByte: only pricelist / pricelist_id).
|
||||
*/
|
||||
|
||||
import { billmanagerRequest } from './client.js'
|
||||
import { elemToObject } from './parsers.js'
|
||||
import type { MappedVps } from './mappers.js'
|
||||
|
||||
/** Minimal tariff shape for matching (from vds.order). */
|
||||
export type TariffSpecsSource = {
|
||||
externalId: string
|
||||
name: string
|
||||
desc: string
|
||||
vcpu: number
|
||||
ramGb: number
|
||||
diskGb: number
|
||||
diskType: string
|
||||
virtualization: string
|
||||
}
|
||||
|
||||
export function needsHardwareSpecs(mapped: MappedVps): boolean {
|
||||
return !mapped.vcpu || !mapped.ramGb || !mapped.diskGb
|
||||
}
|
||||
|
||||
/** Match VDS list row → tariff from vds.order by pricelist_id / name. */
|
||||
export function findTariffForVdsItem(
|
||||
item: Record<string, string>,
|
||||
tariffs: TariffSpecsSource[],
|
||||
): TariffSpecsSource | undefined {
|
||||
if (!tariffs.length) return undefined
|
||||
|
||||
const id = String(item.pricelist_id || '').trim()
|
||||
if (id) {
|
||||
const byId = tariffs.find((t) => String(t.externalId) === id)
|
||||
if (byId && (byId.vcpu || byId.ramGb || byId.diskGb)) return byId
|
||||
}
|
||||
|
||||
const names = [
|
||||
String(item.pricelist || '').trim(),
|
||||
String(item.intname || '').trim(),
|
||||
].filter(Boolean)
|
||||
|
||||
for (const raw of names) {
|
||||
const needle = raw.toLowerCase()
|
||||
const hit = tariffs.find((t) => {
|
||||
if (t.vcpu || t.ramGb || t.diskGb) {
|
||||
const tName = (t.name || '').toLowerCase()
|
||||
const tDesc = (t.desc || '').toLowerCase()
|
||||
return (
|
||||
tName === needle ||
|
||||
tName.includes(needle) ||
|
||||
needle.includes(tName) ||
|
||||
tDesc.includes(needle)
|
||||
)
|
||||
}
|
||||
return false
|
||||
})
|
||||
if (hit) return hit
|
||||
}
|
||||
|
||||
// Same id even if specs empty (caller may still use name hints)
|
||||
if (id) return tariffs.find((t) => String(t.externalId) === id)
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function applyTariffSpecs(mapped: MappedVps, tariff: TariffSpecsSource): MappedVps {
|
||||
return {
|
||||
...mapped,
|
||||
vcpu: mapped.vcpu || tariff.vcpu || 0,
|
||||
ramGb: mapped.ramGb || tariff.ramGb || 0,
|
||||
diskGb: mapped.diskGb || tariff.diskGb || 0,
|
||||
diskType:
|
||||
mapped.diskGb && mapped.diskType
|
||||
? mapped.diskType
|
||||
: tariff.diskType || mapped.diskType,
|
||||
virtualization: mapped.virtualization || tariff.virtualization || 'KVM',
|
||||
}
|
||||
}
|
||||
|
||||
export function enrichMappedVpsFromTariffs(
|
||||
item: Record<string, string>,
|
||||
mapped: MappedVps,
|
||||
tariffs: TariffSpecsSource[],
|
||||
): MappedVps {
|
||||
if (!needsHardwareSpecs(mapped)) return mapped
|
||||
const tariff = findTariffForVdsItem(item, tariffs)
|
||||
return tariff ? applyTariffSpecs(mapped, tariff) : mapped
|
||||
}
|
||||
|
||||
function flattenModel(data: Record<string, unknown>): Record<string, string> {
|
||||
const raw =
|
||||
(data.model as Record<string, unknown> | undefined) ??
|
||||
((data.doc as Record<string, unknown> | undefined)?.model as
|
||||
| Record<string, unknown>
|
||||
| undefined) ??
|
||||
data
|
||||
return elemToObject(raw)
|
||||
}
|
||||
|
||||
function parseRamToGb(raw: string): number {
|
||||
const s = String(raw || '').trim()
|
||||
if (!s) return 0
|
||||
const n = parseFloat(s.replace(/[^\d.]/g, ''))
|
||||
if (!Number.isFinite(n) || n <= 0) return 0
|
||||
// BM often stores RAM in MB for addons
|
||||
if (/mb/i.test(s) || (!/gb/i.test(s) && n >= 128)) {
|
||||
return Math.max(0.5, Math.round((n / 1024) * 10) / 10)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
function parseDiskGb(raw: string): number {
|
||||
const n = parseFloat(String(raw || '').replace(/[^\d.]/g, ''))
|
||||
return Number.isFinite(n) && n > 0 ? n : 0
|
||||
}
|
||||
|
||||
function labelForAddon(
|
||||
key: string,
|
||||
data: Record<string, unknown>,
|
||||
): string {
|
||||
const messages = data.messages ?? (data.doc as Record<string, unknown> | undefined)?.messages
|
||||
if (Array.isArray(messages)) {
|
||||
for (const m of messages) {
|
||||
if (!m || typeof m !== 'object') continue
|
||||
const row = m as Record<string, unknown>
|
||||
const name = String(row.name ?? row.k ?? '')
|
||||
if (name === key || name.endsWith(key)) {
|
||||
return String(row.msg ?? row.v ?? row.$ ?? '')
|
||||
}
|
||||
}
|
||||
}
|
||||
// metadata on fields sometimes: metadata.addon_N
|
||||
const meta = data.metadata as Record<string, unknown> | undefined
|
||||
if (meta && typeof meta[key] === 'object' && meta[key]) {
|
||||
const m = meta[key] as Record<string, unknown>
|
||||
return String(m.label ?? m.name ?? m.msg ?? '')
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
/** Parse CPU/RAM/disk from `func=vds.edit&elid=` form response. */
|
||||
export function parseSpecsFromVdsEdit(
|
||||
data: Record<string, unknown>,
|
||||
): Pick<MappedVps, 'vcpu' | 'ramGb' | 'diskGb' | 'diskType' | 'virtualization'> {
|
||||
const model = flattenModel(data)
|
||||
let vcpu = 0
|
||||
let ramGb = 0
|
||||
let diskGb = 0
|
||||
let diskType = ''
|
||||
let virtualization = ''
|
||||
|
||||
for (const key of ['ncpu', 'cpu', 'vcpu', 'cpus', 'cpu_count'] as const) {
|
||||
const n = parseInt(String(model[key] || ''), 10)
|
||||
if (n > 0) {
|
||||
vcpu = n
|
||||
break
|
||||
}
|
||||
}
|
||||
for (const key of ['ram', 'memory', 'mem', 'ram_mb', 'ram_gb'] as const) {
|
||||
if (model[key]) {
|
||||
ramGb = parseRamToGb(model[key]!)
|
||||
if (ramGb) break
|
||||
}
|
||||
}
|
||||
for (const key of ['disc', 'disk', 'hdd', 'disksize', 'disk_gb', 'hdd_gb'] as const) {
|
||||
if (model[key]) {
|
||||
diskGb = parseDiskGb(model[key]!)
|
||||
if (diskGb) break
|
||||
}
|
||||
}
|
||||
|
||||
for (const [key, val] of Object.entries(model)) {
|
||||
if (!key.startsWith('addon_') || !val) continue
|
||||
const label = labelForAddon(key, data).toLowerCase()
|
||||
const n = parseFloat(String(val).replace(/[^\d.]/g, ''))
|
||||
if (!Number.isFinite(n) || n <= 0) continue
|
||||
|
||||
if (/ядер|процессор|cpu|v?cpu|core/i.test(label) && !vcpu) {
|
||||
vcpu = Math.round(n)
|
||||
} else if (/память|memory|ram|озу/i.test(label) && !ramGb) {
|
||||
ramGb = parseRamToGb(String(val))
|
||||
} else if (/диск|disk|hdd|ssd|nvme|sas|хранилище/i.test(label) && !diskGb) {
|
||||
diskGb = parseDiskGb(String(val))
|
||||
if (/nvme/i.test(label)) diskType = 'NVMe'
|
||||
else if (/sas/i.test(label)) diskType = 'SAS'
|
||||
else if (/hdd/i.test(label)) diskType = 'HDD'
|
||||
else if (/ssd/i.test(label)) diskType = 'SSD'
|
||||
}
|
||||
}
|
||||
|
||||
const hay = `${model.pricelist || ''} ${model.intname || ''} ${model.name || ''}`
|
||||
if (/\bKVM\b/i.test(hay)) virtualization = 'KVM'
|
||||
else if (/\bOpenVZ\b/i.test(hay)) virtualization = 'OpenVZ'
|
||||
else if (/\bLXC\b/i.test(hay)) virtualization = 'LXC'
|
||||
if (!diskType) {
|
||||
if (/\bNVMe\b/i.test(hay)) diskType = 'NVMe'
|
||||
else if (/\bSAS\b/i.test(hay)) diskType = 'SAS'
|
||||
else if (/\bHDD\b/i.test(hay)) diskType = 'HDD'
|
||||
else if (/\bSSD\b/i.test(hay)) diskType = 'SSD'
|
||||
}
|
||||
|
||||
return {
|
||||
vcpu,
|
||||
ramGb,
|
||||
diskGb,
|
||||
diskType: diskType || 'SSD',
|
||||
virtualization: virtualization || 'KVM',
|
||||
}
|
||||
}
|
||||
|
||||
export function applyEditSpecs(
|
||||
mapped: MappedVps,
|
||||
specs: ReturnType<typeof parseSpecsFromVdsEdit>,
|
||||
): MappedVps {
|
||||
return {
|
||||
...mapped,
|
||||
vcpu: mapped.vcpu || specs.vcpu || 0,
|
||||
ramGb: mapped.ramGb || specs.ramGb || 0,
|
||||
diskGb: mapped.diskGb || specs.diskGb || 0,
|
||||
diskType: mapped.diskGb ? mapped.diskType : specs.diskType || mapped.diskType,
|
||||
virtualization: mapped.virtualization || specs.virtualization || 'KVM',
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchVdsEditSpecs(
|
||||
baseUrl: string,
|
||||
authinfo: string,
|
||||
elid: string,
|
||||
): Promise<ReturnType<typeof parseSpecsFromVdsEdit> | null> {
|
||||
const id = String(elid || '').trim()
|
||||
if (!id) return null
|
||||
try {
|
||||
const data = await billmanagerRequest(baseUrl, authinfo, 'vds.edit', { elid: id })
|
||||
const specs = parseSpecsFromVdsEdit(data)
|
||||
if (!specs.vcpu && !specs.ramGb && !specs.diskGb) return null
|
||||
return specs
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
'fetchVdsEditSpecs failed:',
|
||||
id,
|
||||
err instanceof Error ? err.message : err,
|
||||
)
|
||||
return null
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user