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.externalId).toBe('32723')
expect(vps.notes).toContain('veesp-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', () => { describe('mapInvoiceToPayment', () => {
+57 -11
View File
@@ -63,21 +63,57 @@ function ratesFromTotal(
return { tariffType: 'monthly', dailyRate: null, monthlyRate: roundRate(total) } return { tariffType: 'monthly', dailyRate: null, monthlyRate: roundRate(total) }
} }
function pickPrimaryIp(ips: VeespVpsRecord['ips'], vm: VeespVpsRecord['vm'], domain?: string): string { function isIpv4(value: string): boolean {
const vmIp = String(vm?.ip ?? vm?.ipv4 ?? '').trim() 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 if (vmIp) return vmIp
const infoIp = normalizeIpField(info?.ip)
if (infoIp) return infoIp
for (const item of ips) { for (const item of ips) {
const ip = String(item.ip ?? item.address ?? '').trim() const ip = ipFromItem(item)
if (!ip) continue if (!ip) continue
if (item.main === true || item.main === 1 || String(item.main) === '1') return ip if (item.main === true || item.main === 1 || String(item.main) === '1') return ip
} }
for (const item of ips) { for (const item of ips) {
const ip = String(item.ip ?? item.address ?? '').trim() const ip = ipFromItem(item)
if (ip) return ip if (ip) return ip
} }
const d = String(domain ?? '').trim() const d = String(domain ?? '').trim()
if (/^\d{1,3}(\.\d{1,3}){3}$/.test(d)) return d if (isIpv4(d)) return d
return '' return ''
} }
@@ -157,13 +193,23 @@ export function mapVpsRecordToVps(
const total = parseNumber(detail.total ?? service.total) const total = parseNumber(detail.total ?? service.total)
const rates = ratesFromTotal(total, detail.billingcycle ?? service.billingcycle) const rates = ratesFromTotal(total, detail.billingcycle ?? service.billingcycle)
const hostname = String( 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() ).trim()
const ip = pickPrimaryIp(ips, vm, detail.domain ?? service.domain) const ip = pickPrimaryIp(ips, vm, info, detail.domain ?? service.domain)
const os = String(vm?.os ?? vm?.template ?? info?.os ?? info?.template ?? '').trim() const os = String(
const vcpu = parseNumber(vm?.cpu ?? vm?.cores ?? info?.cpu) vm?.os ?? vm?.template ?? vm?.template_label ?? info?.os ?? info?.template ?? info?.template_label ?? '',
const ramGb = parseNumber(vm?.ram ?? vm?.memory ?? info?.ram ?? info?.memory) ).trim()
const diskGb = parseNumber(vm?.disk ?? info?.disk) 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( const status = mapStatus(
pickServiceStatus(service.status, detail.status), pickServiceStatus(service.status, detail.status),
vm?.status ?? vm?.state, 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 id?: string | number
vmid?: string | number vmid?: string | number
name?: string name?: string
label?: string
hostname?: string hostname?: string
status?: string status?: string
state?: string state?: string
ip?: string ip?: string | string[]
ipv4?: string ipv4?: string | string[]
template?: string template?: string
template_label?: string
os?: string os?: string
cpus?: string | number
} }
export interface VeespVmDetail extends VeespVmListItem { export interface VeespVmDetail extends VeespVmListItem {
@@ -61,6 +64,7 @@ export interface VeespVmDetail extends VeespVmListItem {
export interface VeespIpItem { export interface VeespIpItem {
ip?: string ip?: string
address?: string address?: string
ipaddress?: string
type?: string type?: string
main?: boolean | number | string main?: boolean | number | string
} }
@@ -68,11 +72,16 @@ export interface VeespIpItem {
export interface VeespServiceInfo { export interface VeespServiceInfo {
os?: string os?: string
template?: string template?: string
template_label?: string
cpu?: number | string cpu?: number | string
cpus?: number | string
ram?: number | string ram?: number | string
ramGb?: number | string
memory?: number | string memory?: number | string
disk?: number | string disk?: number | string
hdd?: number | string
hostname?: string hostname?: string
ip?: string | string[]
} }
export interface VeespInvoice { export interface VeespInvoice {
@@ -176,14 +185,44 @@ export function isVpsService(service: VeespServiceListItem, vpsCategoryIds?: Set
return false 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[] { 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') { if (json && typeof json === 'object') {
const obj = json as Record<string, unknown> const obj = json as Record<string, unknown>
const list = obj[key] 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 const vms = obj.vms
if (Array.isArray(vms)) return vms as T[] if (Array.isArray(vms)) return vms as T[]
if (vms && typeof vms === 'object') return unwrapKeyedList<T>(vms)
} }
return [] return []
} }
@@ -240,7 +279,13 @@ export async function fetchVmDetail(
const client = clientFor(baseUrl, credentials) const client = clientFor(baseUrl, credentials)
try { try {
const json = await client.request<unknown>(`/service/${serviceId}/vms/${vmId}`) 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 { } catch {
return null return null
} }
@@ -456,6 +501,11 @@ export async function fetchVpsRecords(
return records return records
} }
/** Парсинг коллекций Veesp API (object-map и массивы) — для тестов и отладки */
export function parseVeespCollection<T>(json: unknown, key: string): T[] {
return unwrapList<T>(json, key)
}
export async function testConnection( export async function testConnection(
baseUrl: string, baseUrl: string,
credentials: string, credentials: string,
+14
View File
@@ -143,4 +143,18 @@ describe('syncFromVeesp', () => {
expect(vps.notes).toContain('veesp-100-200') expect(vps.notes).toContain('veesp-100-200')
expect(vps.monthlyRate).toBe(5) 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: '', apiLogin: '',
apiPassword: '', apiPassword: '',
billingMode: 'monthly', billingMode: 'monthly',
currency: '',
balanceAlertBelow: '', balanceAlertBelow: '',
notes: '', notes: '',
} }
@@ -182,6 +183,21 @@ export function ProviderAccountEditSheet({
]} ]}
/> />
</FormField> </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 ниже"> <FormField label="Порог низкого баланса" htmlFor="acc-alert" description="Уведомление на дашборде, если баланс API ниже">
<Input <Input
id="acc-alert" 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 { export function effectiveVpsTariffCurrency(vps: Vps, provider?: Provider | null): string {
const provRaw = (provider?.baseCurrency || '').trim()
if (provRaw) return toIsoCurrency(provRaw)
const ownRaw = (vps?.currency || '').trim() const ownRaw = (vps?.currency || '').trim()
if (ownRaw) return toIsoCurrency(ownRaw) if (ownRaw) return toIsoCurrency(ownRaw)
const provRaw = (provider?.baseCurrency || '').trim()
if (provRaw) return toIsoCurrency(provRaw)
return 'RUB' return 'RUB'
} }
+1
View File
@@ -36,6 +36,7 @@ export const providerAccountSchema = z.object({
apiLogin: z.string().optional().default(''), apiLogin: z.string().optional().default(''),
apiPassword: z.string().optional().default(''), apiPassword: z.string().optional().default(''),
billingMode: billingModeSchema.default('monthly'), billingMode: billingModeSchema.default('monthly'),
currency: z.string().optional().default(''),
balanceAlertBelow: z.union([z.coerce.number().min(0), z.literal('')]).optional(), balanceAlertBelow: z.union([z.coerce.number().min(0), z.literal('')]).optional(),
notes: z.string().optional().default(''), notes: z.string().optional().default(''),
}) })
+1
View File
@@ -164,6 +164,7 @@ function AccountsPage() {
apiLogin: a.apiLogin ?? a.login ?? '', apiLogin: a.apiLogin ?? a.login ?? '',
apiPassword: '', apiPassword: '',
billingMode: a.billingMode ?? 'monthly', billingMode: a.billingMode ?? 'monthly',
currency: a.currency ?? '',
balanceAlertBelow: ext.balanceAlertBelow != null ? ext.balanceAlertBelow : '', balanceAlertBelow: ext.balanceAlertBelow != null ? ext.balanceAlertBelow : '',
notes: a.notes ?? '', notes: a.notes ?? '',
}), }),
+2 -4
View File
@@ -19,7 +19,7 @@ export function accountBalanceCurrency(account: {
return account.balance_currency ?? account.balanceCurrency ?? account.currency ?? 'RUB' return account.balance_currency ?? account.balanceCurrency ?? account.currency ?? 'RUB'
} }
/** Валюта баланса с учётом baseCurrency хостера (как effectiveVpsTariffCurrency для тарифов). */ /** Валюта баланса: валюта аккаунта → baseCurrency хостера → balance_currency/currency. */
export function effectiveAccountBalanceCurrency( export function effectiveAccountBalanceCurrency(
account: { account: {
balance_currency?: string balance_currency?: string
@@ -28,9 +28,7 @@ export function effectiveAccountBalanceCurrency(
}, },
provider?: { baseCurrency?: string | null } | null, provider?: { baseCurrency?: string | null } | null,
): string { ): string {
const provRaw = (provider?.baseCurrency ?? '').trim() return resolveProviderCurrency(provider, account.currency ?? accountBalanceCurrency(account))
if (provRaw) return provRaw
return accountBalanceCurrency(account)
} }
export function syncFallbackCurrency( export function syncFallbackCurrency(
+3 -3
View File
@@ -1,12 +1,12 @@
/** Валюта хостера: настройки провайдера → аккаунт → fallback. */ /** Валюта: явная валюта аккаунта → baseCurrency хостера → fallback. */
export function resolveProviderCurrency( export function resolveProviderCurrency(
provider?: { baseCurrency?: string | null } | null, provider?: { baseCurrency?: string | null } | null,
accountCurrency?: string | null, accountCurrency?: string | null,
fallback = 'RUB', fallback = 'RUB',
): string { ): string {
const fromProvider = (provider?.baseCurrency ?? '').trim()
if (fromProvider) return fromProvider
const fromAccount = (accountCurrency ?? '').trim() const fromAccount = (accountCurrency ?? '').trim()
if (fromAccount) return fromAccount if (fromAccount) return fromAccount
const fromProvider = (provider?.baseCurrency ?? '').trim()
if (fromProvider) return fromProvider
return fallback return fallback
} }