fix(veesp): IP и характеристики VPS при синке, валюта аккаунта
Docker / build (push) Failing after 21s
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:
@@ -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', () => {
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
|
||||
@@ -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(''),
|
||||
})
|
||||
|
||||
@@ -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 ?? '',
|
||||
}),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user