fix(veesp): IP и характеристики VPS при синке, валюта аккаунта
Docker / build (push) Failing after 21s

Парсинг object-map ответов API (vms/ips), cpus, memory в МБ, IP-массив.
Приоритет валюты аккаунта над baseCurrency хостера; поле в UI для Veesp.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Denozordec
2026-06-29 11:50:25 +07:00
co-authored by Cursor
parent 87aa6949b3
commit 5983c911f2
12 changed files with 280 additions and 25 deletions
@@ -0,0 +1,21 @@
import { describe, expect, it } from 'vitest'
import { resolveProviderCurrency } from '@cfdm/shared/utils/currency'
import { effectiveAccountBalanceCurrency } from '@cfdm/shared/utils/account-balance'
describe('resolveProviderCurrency', () => {
it('prefers account currency over provider baseCurrency', () => {
expect(resolveProviderCurrency({ baseCurrency: 'EUR' }, 'USD')).toBe('USD')
})
it('falls back to provider when account currency is empty', () => {
expect(resolveProviderCurrency({ baseCurrency: 'EUR' }, '')).toBe('EUR')
})
})
describe('effectiveAccountBalanceCurrency', () => {
it('uses account currency for balance display', () => {
expect(
effectiveAccountBalanceCurrency({ currency: 'USD', balance_currency: 'EUR' }, { baseCurrency: 'EUR' }),
).toBe('USD')
})
})
@@ -80,6 +80,66 @@ describe('mapVpsRecordToVps', () => {
expect(vps.externalId).toBe('32723')
expect(vps.notes).toContain('veesp-32723')
})
it('maps Proxmox VM with cpus, memory in MB and ip array', () => {
const vps = mapVpsRecordToVps(
{
...baseRecord,
vmId: '17228',
vm: {
id: '17228',
label: 'rkn0',
hostname: 'rkn0',
cpus: '2',
memory: 2048,
disk: 40,
ip: ['198.51.100.5'],
template_label: 'Debian 12',
status: 'active',
},
ips: [],
info: null,
},
'prov-1',
'acc-1',
'EUR',
)
expect(vps.externalId).toBe('32723-17228')
expect(vps.ip).toBe('198.51.100.5')
expect(vps.dns).toBe('rkn0')
expect(vps.vcpu).toBe(2)
expect(vps.ramGb).toBe(2)
expect(vps.diskGb).toBe(40)
expect(vps.os).toBe('Debian 12')
})
it('falls back to info and ips when vm is absent', () => {
const vps = mapVpsRecordToVps(
{
...baseRecord,
vmId: null,
vm: null,
ips: [{ ipaddress: '198.51.100.9', main: true }],
info: {
hostname: 'rkn0',
cpus: 1,
memory: 1024,
hdd: 20,
template_label: 'Ubuntu 22',
ip: '198.51.100.9',
},
},
'prov-1',
'acc-1',
'EUR',
)
expect(vps.ip).toBe('198.51.100.9')
expect(vps.dns).toBe('rkn0')
expect(vps.vcpu).toBe(1)
expect(vps.ramGb).toBe(1)
expect(vps.diskGb).toBe(20)
expect(vps.os).toBe('Ubuntu 22')
})
})
describe('mapInvoiceToPayment', () => {
+57 -11
View File
@@ -63,21 +63,57 @@ function ratesFromTotal(
return { tariffType: 'monthly', dailyRate: null, monthlyRate: roundRate(total) }
}
function pickPrimaryIp(ips: VeespVpsRecord['ips'], vm: VeespVpsRecord['vm'], domain?: string): string {
const vmIp = String(vm?.ip ?? vm?.ipv4 ?? '').trim()
function isIpv4(value: string): boolean {
return /^\d{1,3}(\.\d{1,3}){3}$/.test(value)
}
function normalizeIpField(raw: string | string[] | undefined | null): string {
if (raw == null) return ''
if (Array.isArray(raw)) {
for (const item of raw) {
const ip = String(item).trim()
if (isIpv4(ip)) return ip
}
return String(raw[0] ?? '').trim()
}
return String(raw).trim()
}
function ipFromItem(item: { ip?: string; address?: string; ipaddress?: string }): string {
return String(item.ip ?? item.address ?? item.ipaddress ?? '').trim()
}
function parseMemoryGb(raw: string | number | undefined | null): number {
const n = parseNumber(raw)
if (n <= 0) return 0
// Veesp VM/info API returns memory in MB (512, 1024, 2048…)
if (n >= 256) return Math.round((n / 1024) * 10) / 10
return n
}
function pickPrimaryIp(
ips: VeespVpsRecord['ips'],
vm: VeespVpsRecord['vm'],
info: VeespVpsRecord['info'],
domain?: string,
): string {
const vmIp = normalizeIpField(vm?.ip ?? vm?.ipv4)
if (vmIp) return vmIp
const infoIp = normalizeIpField(info?.ip)
if (infoIp) return infoIp
for (const item of ips) {
const ip = String(item.ip ?? item.address ?? '').trim()
const ip = ipFromItem(item)
if (!ip) continue
if (item.main === true || item.main === 1 || String(item.main) === '1') return ip
}
for (const item of ips) {
const ip = String(item.ip ?? item.address ?? '').trim()
const ip = ipFromItem(item)
if (ip) return ip
}
const d = String(domain ?? '').trim()
if (/^\d{1,3}(\.\d{1,3}){3}$/.test(d)) return d
if (isIpv4(d)) return d
return ''
}
@@ -157,13 +193,23 @@ export function mapVpsRecordToVps(
const total = parseNumber(detail.total ?? service.total)
const rates = ratesFromTotal(total, detail.billingcycle ?? service.billingcycle)
const hostname = String(
vm?.hostname ?? vm?.name ?? info?.hostname ?? detail.domain ?? service.domain ?? detail.name ?? service.name ?? '',
vm?.hostname ??
vm?.name ??
vm?.label ??
info?.hostname ??
detail.domain ??
service.domain ??
detail.name ??
service.name ??
'',
).trim()
const ip = pickPrimaryIp(ips, vm, detail.domain ?? service.domain)
const os = String(vm?.os ?? vm?.template ?? info?.os ?? info?.template ?? '').trim()
const vcpu = parseNumber(vm?.cpu ?? vm?.cores ?? info?.cpu)
const ramGb = parseNumber(vm?.ram ?? vm?.memory ?? info?.ram ?? info?.memory)
const diskGb = parseNumber(vm?.disk ?? info?.disk)
const ip = pickPrimaryIp(ips, vm, info, detail.domain ?? service.domain)
const os = String(
vm?.os ?? vm?.template ?? vm?.template_label ?? info?.os ?? info?.template ?? info?.template_label ?? '',
).trim()
const vcpu = parseNumber(vm?.cpu ?? vm?.cores ?? vm?.cpus ?? info?.cpu ?? info?.cpus)
const ramGb = parseMemoryGb(vm?.ram ?? vm?.memory ?? info?.ram ?? info?.ramGb ?? info?.memory)
const diskGb = parseNumber(vm?.disk ?? info?.disk ?? info?.hdd)
const status = mapStatus(
pickServiceStatus(service.status, detail.status),
vm?.status ?? vm?.state,
@@ -0,0 +1,48 @@
import { describe, expect, it } from 'vitest'
import { parseVeespCollection } from './operations.js'
import type { VeespIpItem, VeespVmListItem } from './operations.js'
describe('parseVeespCollection', () => {
it('parses vms object-map from Veesp API', () => {
const json = {
vms: {
'17228': {
label: 'rkn0',
hostname: 'rkn0',
cpus: '2',
memory: 2048,
disk: 40,
ip: ['198.51.100.5'],
template_label: 'Debian 12',
status: 'active',
},
},
}
const vms = parseVeespCollection<VeespVmListItem>(json, 'vms')
expect(vms).toHaveLength(1)
expect(vms[0]?.id).toBe('17228')
expect(vms[0]?.label).toBe('rkn0')
expect(vms[0]?.cpus).toBe('2')
expect(vms[0]?.ip).toEqual(['198.51.100.5'])
})
it('parses ips as string array', () => {
const json = { ips: ['198.51.100.5', '2001:db8::1'] }
const ips = parseVeespCollection<VeespIpItem>(json, 'ips')
expect(ips).toEqual([{ ip: '198.51.100.5' }, { ip: '2001:db8::1' }])
})
it('parses ips object-map', () => {
const json = {
ips: {
'1': { ipaddress: '198.51.100.5', main: '1' },
'2': { ipaddress: '10.0.0.2' },
},
}
const ips = parseVeespCollection<VeespIpItem>(json, 'ips')
expect(ips).toHaveLength(2)
expect(ips[0]?.ipaddress).toBe('198.51.100.5')
expect(ips[0]?.id).toBe('1')
})
})
+55 -5
View File
@@ -41,13 +41,16 @@ export interface VeespVmListItem {
id?: string | number
vmid?: string | number
name?: string
label?: string
hostname?: string
status?: string
state?: string
ip?: string
ipv4?: string
ip?: string | string[]
ipv4?: string | string[]
template?: string
template_label?: string
os?: string
cpus?: string | number
}
export interface VeespVmDetail extends VeespVmListItem {
@@ -61,6 +64,7 @@ export interface VeespVmDetail extends VeespVmListItem {
export interface VeespIpItem {
ip?: string
address?: string
ipaddress?: string
type?: string
main?: boolean | number | string
}
@@ -68,11 +72,16 @@ export interface VeespIpItem {
export interface VeespServiceInfo {
os?: string
template?: string
template_label?: string
cpu?: number | string
cpus?: number | string
ram?: number | string
ramGb?: number | string
memory?: number | string
disk?: number | string
hdd?: number | string
hostname?: string
ip?: string | string[]
}
export interface VeespInvoice {
@@ -176,14 +185,44 @@ export function isVpsService(service: VeespServiceListItem, vpsCategoryIds?: Set
return false
}
function unwrapKeyedList<T extends Record<string, unknown>>(raw: unknown): T[] {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return []
const out: T[] = []
for (const [key, item] of Object.entries(raw as Record<string, unknown>)) {
if (item == null) continue
if (typeof item === 'string') {
out.push({ ip: item, id: key } as unknown as T)
continue
}
if (typeof item !== 'object') continue
const row = item as Record<string, unknown>
out.push({ ...row, id: row.id ?? key } as T)
}
return out
}
function unwrapList<T>(json: unknown, key: string): T[] {
if (Array.isArray(json)) return json as T[]
if (Array.isArray(json)) {
return json.map((item) => {
if (typeof item === 'string') return { ip: item } as T
return item as T
})
}
if (json && typeof json === 'object') {
const obj = json as Record<string, unknown>
const list = obj[key]
if (Array.isArray(list)) return list as T[]
if (Array.isArray(list)) {
return list.map((item) => {
if (typeof item === 'string') return { ip: item } as T
return item as T
})
}
if (list && typeof list === 'object') {
return unwrapKeyedList<T>(list)
}
const vms = obj.vms
if (Array.isArray(vms)) return vms as T[]
if (vms && typeof vms === 'object') return unwrapKeyedList<T>(vms)
}
return []
}
@@ -240,7 +279,13 @@ export async function fetchVmDetail(
const client = clientFor(baseUrl, credentials)
try {
const json = await client.request<unknown>(`/service/${serviceId}/vms/${vmId}`)
return unwrapObject<VeespVmDetail>(json, 'vm') ?? unwrapObject<VeespVmDetail>(json, 'vms') ?? (json as VeespVmDetail)
if (!json || typeof json !== 'object') return null
const obj = json as Record<string, unknown>
const nested = unwrapObject<VeespVmDetail>(json, 'vm') ?? unwrapObject<VeespVmDetail>(json, 'vms')
if (nested) return { ...nested, id: nested.id ?? vmId }
const keyed = unwrapKeyedList<VeespVmDetail>(obj.vms ?? obj.vm ?? obj)
const match = keyed.find((row) => String(row.id ?? row.vmid) === String(vmId))
return match ?? (keyed[0] ? { ...keyed[0], id: keyed[0].id ?? vmId } : null)
} catch {
return null
}
@@ -456,6 +501,11 @@ export async function fetchVpsRecords(
return records
}
/** Парсинг коллекций Veesp API (object-map и массивы) — для тестов и отладки */
export function parseVeespCollection<T>(json: unknown, key: string): T[] {
return unwrapList<T>(json, key)
}
export async function testConnection(
baseUrl: string,
credentials: string,
+14
View File
@@ -143,4 +143,18 @@ describe('syncFromVeesp', () => {
expect(vps.notes).toContain('veesp-100-200')
expect(vps.monthlyRate).toBe(5)
})
it('uses account currency over provider baseCurrency', async () => {
const result = await syncFromVeesp({
...makeAccount(),
currency: 'USD',
providerBaseCurrency: 'EUR',
})
expect(result.vpsCount).toBe(1)
const vps = getSqlite()
.prepare('SELECT currency FROM vps WHERE id = ?')
.get('vps-veesp-acc-veesp-100-200') as { currency: string }
expect(vps.currency).toBe('USD')
})
})
@@ -22,6 +22,7 @@ const EMPTY: ProviderAccountFormValues = {
apiLogin: '',
apiPassword: '',
billingMode: 'monthly',
currency: '',
balanceAlertBelow: '',
notes: '',
}
@@ -182,6 +183,21 @@ export function ProviderAccountEditSheet({
]}
/>
</FormField>
{provider?.apiType === 'veesp' ? (
<FormField
label="Валюта аккаунта"
htmlFor="acc-cur"
error={errors.currency?.message}
description="USD, EUR и т.д. — у каждого аккаунта Veesp может быть своя валюта. Если пусто — берётся валюта хостера."
>
<Input
id="acc-cur"
placeholder="EUR"
aria-invalid={!!errors.currency}
{...register('currency')}
/>
</FormField>
) : null}
<FormField label="Порог низкого баланса" htmlFor="acc-alert" description="Уведомление на дашборде, если баланс API ниже">
<Input
id="acc-alert"
+2 -2
View File
@@ -175,10 +175,10 @@ export function toIsoCurrency(currency?: string | null): string {
}
export function effectiveVpsTariffCurrency(vps: Vps, provider?: Provider | null): string {
const provRaw = (provider?.baseCurrency || '').trim()
if (provRaw) return toIsoCurrency(provRaw)
const ownRaw = (vps?.currency || '').trim()
if (ownRaw) return toIsoCurrency(ownRaw)
const provRaw = (provider?.baseCurrency || '').trim()
if (provRaw) return toIsoCurrency(provRaw)
return 'RUB'
}
+1
View File
@@ -36,6 +36,7 @@ export const providerAccountSchema = z.object({
apiLogin: z.string().optional().default(''),
apiPassword: z.string().optional().default(''),
billingMode: billingModeSchema.default('monthly'),
currency: z.string().optional().default(''),
balanceAlertBelow: z.union([z.coerce.number().min(0), z.literal('')]).optional(),
notes: z.string().optional().default(''),
})
+1
View File
@@ -164,6 +164,7 @@ function AccountsPage() {
apiLogin: a.apiLogin ?? a.login ?? '',
apiPassword: '',
billingMode: a.billingMode ?? 'monthly',
currency: a.currency ?? '',
balanceAlertBelow: ext.balanceAlertBelow != null ? ext.balanceAlertBelow : '',
notes: a.notes ?? '',
}),
+2 -4
View File
@@ -19,7 +19,7 @@ export function accountBalanceCurrency(account: {
return account.balance_currency ?? account.balanceCurrency ?? account.currency ?? 'RUB'
}
/** Валюта баланса с учётом baseCurrency хостера (как effectiveVpsTariffCurrency для тарифов). */
/** Валюта баланса: валюта аккаунта → baseCurrency хостера → balance_currency/currency. */
export function effectiveAccountBalanceCurrency(
account: {
balance_currency?: string
@@ -28,9 +28,7 @@ export function effectiveAccountBalanceCurrency(
},
provider?: { baseCurrency?: string | null } | null,
): string {
const provRaw = (provider?.baseCurrency ?? '').trim()
if (provRaw) return provRaw
return accountBalanceCurrency(account)
return resolveProviderCurrency(provider, account.currency ?? accountBalanceCurrency(account))
}
export function syncFallbackCurrency(
+3 -3
View File
@@ -1,12 +1,12 @@
/** Валюта хостера: настройки провайдера → аккаунт → fallback. */
/** Валюта: явная валюта аккаунта → baseCurrency хостера → fallback. */
export function resolveProviderCurrency(
provider?: { baseCurrency?: string | null } | null,
accountCurrency?: string | null,
fallback = 'RUB',
): string {
const fromProvider = (provider?.baseCurrency ?? '').trim()
if (fromProvider) return fromProvider
const fromAccount = (accountCurrency ?? '').trim()
if (fromAccount) return fromAccount
const fromProvider = (provider?.baseCurrency ?? '').trim()
if (fromProvider) return fromProvider
return fallback
}