feat(api, web): интеграция RuVDS API v2
Docker / build (push) Failing after 21s

Синк VPS, баланса, платежей и тарифов через Bearer-токен из личного кабинета.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Denozordec
2026-06-29 14:10:15 +07:00
co-authored by Cursor
parent ce6414c65f
commit 9a29d88b95
19 changed files with 1526 additions and 12 deletions
+71
View File
@@ -39,6 +39,17 @@ vi.mock('../services/veesp/sync.js', () => ({
}),
}))
vi.mock('../services/ruvds/sync.js', () => ({
syncFromRuvds: vi.fn().mockResolvedValue({
vpsCount: 3,
paymentsCount: 2,
tariffsCount: 2,
newTariffs: [],
balance: { balance: 2000, currency: 'RUB', enoughmoneyto: '' },
syncSummary: { added: [], updated: [], paymentsAdded: 2 },
}),
}))
describe('sync routes — 4vps', () => {
let app: Awaited<ReturnType<typeof buildApp>>
@@ -277,3 +288,63 @@ describe('sync routes — veesp', () => {
vi.unstubAllGlobals()
})
})
describe('sync routes — ruvds', () => {
let app: Awaited<ReturnType<typeof buildApp>>
beforeEach(async () => {
resetTestDb()
seedTestProvider('prov-ruvds')
providersRepository.update('prov-ruvds', {
apiType: 'ruvds',
apiBaseUrl: 'https://api.ruvds.com',
})
providerAccountsRepository.create({
id: 'acc-ruvds',
providerId: 'prov-ruvds',
name: 'RuVDS',
apiCredentials: 'apiv2.test-token',
})
app = await buildApp()
})
afterEach(async () => {
await app.close()
closeDb()
})
it('POST /api/sync/:accountId syncs ruvds account', async () => {
const res = await app.inject({
method: 'POST',
url: '/api/sync/acc-ruvds',
payload: {},
})
expect(res.statusCode).toBe(200)
const body = res.json() as { ok?: boolean; synced?: { vpsCount?: number } }
expect(body.ok).toBe(true)
expect(body.synced?.vpsCount).toBe(3)
})
it('POST /api/sync/test-connection uses apiType ruvds', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ amount: 100, currency: 1, type: 'default' }),
}),
)
const res = await app.inject({
method: 'POST',
url: '/api/sync/test-connection',
payload: {
apiBaseUrl: 'https://api.ruvds.com',
apiCredentials: 'apiv2.test-token',
apiType: 'ruvds',
},
})
expect(res.statusCode).toBe(200)
expect((res.json() as { ok?: boolean }).ok).toBe(true)
vi.unstubAllGlobals()
})
})
+11
View File
@@ -4,21 +4,25 @@ import { billmanagerAccountRowForSync } from '../billmanager/context.js'
import { fourvpsAccountRowForSync } from '../fourvps/context.js'
import { userApiAccountRowForSync } from '../userapi/context.js'
import { veespAccountRowForSync } from '../veesp/context.js'
import { ruvdsAccountRowForSync } from '../ruvds/context.js'
import type { BillmanagerSyncAccount } from '../billmanager/context.js'
import type { FourvpsSyncAccount } from '../fourvps/context.js'
import type { UserApiSyncAccount } from '../userapi/context.js'
import type { VeespSyncAccount } from '../veesp/context.js'
import type { RuvdsSyncAccount } from '../ruvds/context.js'
import { billmanagerAdapter } from './billmanager-adapter.js'
import { fourvpsAdapter } from './fourvps-adapter.js'
import { userapiAdapter } from './userapi-adapter.js'
import { veespAdapter } from './veesp-adapter.js'
import { ruvdsAdapter } from './ruvds-adapter.js'
import type { ProviderAdapter } from './types.js'
export { billmanagerAdapter } from './billmanager-adapter.js'
export { fourvpsAdapter } from './fourvps-adapter.js'
export { userapiAdapter } from './userapi-adapter.js'
export { veespAdapter } from './veesp-adapter.js'
export { ruvdsAdapter } from './ruvds-adapter.js'
export const manualAdapter: ProviderAdapter = {
type: 'manual',
@@ -43,6 +47,7 @@ const adapters: Record<string, ProviderAdapter> = {
macloud: userapiAdapter,
vdsina: userapiAdapter,
veesp: veespAdapter,
ruvds: ruvdsAdapter,
manual: manualAdapter,
none: manualAdapter,
}
@@ -60,6 +65,7 @@ export type SyncReadyAccount =
| FourvpsSyncAccount
| UserApiSyncAccount
| VeespSyncAccount
| RuvdsSyncAccount
export function resolveSyncAccount(
accountRow: AccountRow | null | undefined,
@@ -86,6 +92,10 @@ export function resolveSyncAccount(
const account = veespAccountRowForSync(accountRow, providerRow)
return account ? { apiType, account } : null
}
if (apiType === 'ruvds') {
const account = ruvdsAccountRowForSync(accountRow, providerRow)
return account ? { apiType, account } : null
}
return null
}
@@ -95,4 +105,5 @@ export const SYNC_SETUP_ERRORS: Record<string, string> = {
macloud: 'Укажите тип API Маклауд и URL; в аккаунте — API Token',
vdsina: 'Укажите тип API VDSina и URL; в аккаунте — API Token',
veesp: 'Укажите тип API Veesp и URL; в аккаунте — email и пароль client area',
ruvds: 'Укажите тип API RuVDS и URL; в аккаунте — API-токен из настроек ЛК',
}
@@ -0,0 +1,45 @@
import { syncFromRuvds } from '../ruvds/sync.js'
import { fetchBalance, testConnection } from '../ruvds/operations.js'
import type { RuvdsSyncAccount } from '../ruvds/context.js'
import { ruvdsCredentialsString } from '../ruvds/context.js'
import { syncFallbackCurrency } from '@cfdm/shared/utils/account-balance'
import type { ProviderAdapter, SyncResult } from './types.js'
export const ruvdsAdapter: ProviderAdapter = {
type: 'ruvds',
async testConnection(apiBaseUrl: string, apiCredentials: string) {
const result = await testConnection(apiBaseUrl, apiCredentials)
return { ok: result.ok, message: result.error }
},
async syncAccount(
account: RuvdsSyncAccount,
options?: { skipTariffs?: boolean; skipVpsPayments?: boolean },
): Promise<SyncResult> {
const result = await syncFromRuvds(account, options)
return {
vpsCount: result.vpsCount,
paymentsCount: result.paymentsCount,
tariffsCount: result.tariffsCount,
balance: result.balance,
syncSummary: result.syncSummary,
newTariffs: result.newTariffs,
}
},
async fetchBalance(account: RuvdsSyncAccount) {
const fallbackCurrency = syncFallbackCurrency(account)
const info = await fetchBalance(
account.apiBaseUrl,
ruvdsCredentialsString(account),
fallbackCurrency,
)
return {
balance: info.balance,
currency: info.currency || fallbackCurrency,
enoughmoneyto: info.enoughmoneyto || '',
}
},
}
@@ -0,0 +1,41 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { RuvdsClient } from './client.js'
describe('RuvdsClient', () => {
afterEach(() => {
vi.unstubAllGlobals()
})
it('sends Bearer authorization header', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ amount: 100, currency: 1 }),
})
vi.stubGlobal('fetch', fetchMock)
const client = new RuvdsClient('https://api.ruvds.com', 'test-token')
await client.request('/v2/balance')
expect(fetchMock).toHaveBeenCalledOnce()
const init = fetchMock.mock.calls[0][1] as RequestInit
expect(init.headers).toMatchObject({
Authorization: 'Bearer test-token',
Accept: 'application/json',
})
})
it('throws on 401 with message from API', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: false,
status: 401,
json: async () => ({ id: 'unauthorized', message: 'Invalid token' }),
}),
)
const client = new RuvdsClient('https://api.ruvds.com', 'bad-token')
await expect(client.request('/v2/balance')).rejects.toThrow('Invalid token')
})
})
+104
View File
@@ -0,0 +1,104 @@
/**
* RuVDS API v2 HTTP client
* @see https://ruvds.com/api-docs
*/
import type { RuvdsApiErrorBody } from './types.js'
export class RuvdsApiError extends Error {
constructor(message: string) {
super(message)
this.name = 'RuvdsApiError'
}
}
function joinUrl(baseUrl: string, path: string): string {
const base = baseUrl.replace(/\/+$/, '')
const p = path.startsWith('/') ? path : `/${path}`
return `${base}${p}`
}
export interface RuvdsRequestOptions {
method?: 'GET' | 'POST' | 'PUT' | 'DELETE'
query?: Record<string, string | number | boolean | undefined>
body?: Record<string, unknown>
}
function extractErrorMessage(json: unknown, status: number): string {
if (json && typeof json === 'object') {
const obj = json as RuvdsApiErrorBody
if (typeof obj.message === 'string' && obj.message.trim()) return obj.message
if (typeof obj.id === 'string' && obj.id.trim()) return obj.id
}
return `RuVDS API HTTP ${status}`
}
async function sleep(ms: number): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, ms))
}
export class RuvdsClient {
constructor(
readonly baseUrl: string,
readonly token: string,
) {}
async request<T = unknown>(path: string, opts: RuvdsRequestOptions = {}): Promise<T> {
const { method = 'GET', query, body } = opts
const url = new URL(joinUrl(this.baseUrl, path))
if (query) {
for (const [key, value] of Object.entries(query)) {
if (value != null && value !== '') {
url.searchParams.set(key, String(value))
}
}
}
const headers: Record<string, string> = {
Accept: 'application/json',
Authorization: `Bearer ${this.token.trim()}`,
}
if (body && method !== 'GET') {
headers['Content-Type'] = 'application/json'
}
const init: RequestInit = { method, headers }
if (body && method !== 'GET') {
init.body = JSON.stringify(body)
}
let lastError: Error | null = null
for (let attempt = 0; attempt < 3; attempt++) {
if (attempt > 0) await sleep(500 * attempt)
const res = await fetch(url.toString(), init)
let json: unknown
try {
json = await res.json()
} catch {
if (!res.ok) throw new RuvdsApiError(`RuVDS API HTTP ${res.status}`)
return undefined as T
}
if (res.status === 429 && attempt < 2) {
lastError = new RuvdsApiError('Превышен лимит запросов RuVDS API')
continue
}
if (!res.ok) {
throw new RuvdsApiError(extractErrorMessage(json, res.status))
}
return json as T
}
throw lastError ?? new RuvdsApiError('RuVDS API request failed')
}
}
export function createRuvdsClient(baseUrl: string, token: string): RuvdsClient {
const trimmed = token.trim()
if (!trimmed) throw new RuvdsApiError('Укажите API-токен RuVDS')
return new RuvdsClient(baseUrl.trim(), trimmed)
}
+42
View File
@@ -0,0 +1,42 @@
import type { schema } from '@cfdm/db'
import { parseRuvdsToken } from '@cfdm/shared/utils/api-credentials'
type AccountRow = typeof schema.providerAccounts.$inferSelect
type ProviderRow = typeof schema.providers.$inferSelect
export interface RuvdsSyncAccount extends AccountRow {
apiType: 'ruvds'
apiBaseUrl: string
apiToken: string
providerBaseCurrency?: string | null
}
export function resolveRuvdsApi(
accountRow: AccountRow | null | undefined,
providerRow: ProviderRow | null | undefined,
): { apiType: string; apiBaseUrl: string } {
const apiType = String(providerRow?.apiType || accountRow?.apiType || '').trim()
const apiBaseUrl = String(providerRow?.apiBaseUrl || accountRow?.apiBaseUrl || '').trim()
return { apiType, apiBaseUrl }
}
export function ruvdsAccountRowForSync(
accountRow: AccountRow | null | undefined,
providerRow: ProviderRow | null | undefined,
): RuvdsSyncAccount | null {
if (!accountRow) return null
const { apiType, apiBaseUrl } = resolveRuvdsApi(accountRow, providerRow)
const apiToken = parseRuvdsToken(accountRow.apiCredentials)
if (apiType !== 'ruvds' || !apiBaseUrl || !apiToken) return null
return {
...accountRow,
apiType: 'ruvds',
apiBaseUrl,
apiToken,
providerBaseCurrency: providerRow?.baseCurrency ?? null,
}
}
export function ruvdsCredentialsString(account: RuvdsSyncAccount): string {
return account.apiToken
}
+11
View File
@@ -0,0 +1,11 @@
export { createRuvdsClient, RuvdsApiError } from './client.js'
export type { RuvdsSyncAccount } from './context.js'
export { ruvdsAccountRowForSync, ruvdsCredentialsString } from './context.js'
export {
fetchAllPayments,
fetchAllServers,
fetchBalance,
fetchTariffList,
testConnection,
} from './operations.js'
export { syncFromRuvds } from './sync.js'
@@ -0,0 +1,69 @@
import { describe, expect, it } from 'vitest'
import { buildLookupMaps, mapPaymentToPayment, mapServerToVps } from './mappers.js'
import type { RuvdsPayment, RuvdsServer } from './types.js'
const server: RuvdsServer = {
virtual_server_id: 38420,
status: 'active',
datacenter: 1,
cpu: 2,
ram: 4,
drive: 40,
payment_period: 3,
os_id: 18,
paid_till: '2027-06-15T12:00:00Z',
user_comment: 'Prod VPS',
network_v4: [{ ip_address: '198.51.100.10' }, { ip_address: '198.51.100.11' }],
}
const lookups = buildLookupMaps(
[{ id: 1, name: 'Rucloud: Россия, Москва', country: 'RU' }],
[{ id: 18, name: 'Ubuntu 22.04' }],
new Map([[38420, 900]]),
)
describe('mapServerToVps', () => {
it('maps server fields and notes marker', () => {
const vps = mapServerToVps(server, 'prov-ruvds', 'acc-ruvds', 'RUB', lookups)
expect(vps.externalId).toBe('38420')
expect(vps.ip).toBe('198.51.100.10')
expect(vps.additionalIps).toEqual(['198.51.100.11'])
expect(vps.vcpu).toBe(2)
expect(vps.ramGb).toBe(4)
expect(vps.diskGb).toBe(40)
expect(vps.paidUntil).toBe('2027-06-15')
expect(vps.os).toBe('Ubuntu 22.04')
expect(vps.monthlyRate).toBe(300)
expect(vps.notes).toContain('ruvds-38420')
expect(vps.datacenter).toContain('Rucloud')
})
})
describe('mapPaymentToPayment', () => {
it('maps income payments only', () => {
const payment: RuvdsPayment = {
dt: '2026-01-10T10:00:00Z',
direction: 1,
amount: 500,
currency: 1,
pay_source: 'card',
}
const mapped = mapPaymentToPayment(payment, 'acc-ruvds', 'RUB')
expect(mapped).not.toBeNull()
expect(mapped?.type).toBe('topup')
expect(mapped?.amount).toBe(500)
expect(mapped?.currency).toBe('RUB')
expect(mapped?.note).toContain('ruvds-')
})
it('skips debit payments', () => {
const payment: RuvdsPayment = {
dt: '2026-01-10T10:00:00Z',
direction: 2,
amount: 100,
currency: 1,
}
expect(mapPaymentToPayment(payment, 'acc-ruvds', 'RUB')).toBeNull()
})
})
+224
View File
@@ -0,0 +1,224 @@
/**
* RuVDS API v2 → vps-tracker model mappers
*/
import { createHash } from 'node:crypto'
import type { RuvdsDatacenter, RuvdsOsItem, RuvdsPayment, RuvdsServer } from './types.js'
import { ruvdsCurrencyCode } from './operations.js'
const STATUS_MAP: Record<string, string> = {
active: 'active',
new: 'active',
notpaid: 'paused',
blocked: 'paused',
deleted: 'archived',
removed: 'archived',
}
function dateToIso(value: string | undefined | null): string {
if (!value) return ''
return String(value).slice(0, 10)
}
function roundRate(n: number): number {
return Math.round(n * 100) / 100
}
function paymentPeriodMonths(period: number | undefined): number {
switch (period) {
case 2:
return 1
case 3:
return 3
case 4:
return 6
case 5:
return 12
default:
return 1
}
}
function ratesFromCost(
costRub: number | null | undefined,
paymentPeriod: number | undefined,
): { tariffType: string; dailyRate: number | null; monthlyRate: number | null } {
if (costRub == null || !Number.isFinite(costRub) || costRub <= 0) {
return { tariffType: 'monthly', dailyRate: null, monthlyRate: null }
}
const months = paymentPeriodMonths(paymentPeriod)
const monthlyRate = roundRate(costRub / months)
return { tariffType: 'monthly', dailyRate: null, monthlyRate }
}
function parseDatacenterName(name: string): { city: string; country: string; datacenter: string } {
const trimmed = name.trim()
if (!trimmed) return { city: '', country: 'RU', datacenter: '' }
const parts = trimmed.split(':').map((p) => p.trim())
if (parts.length >= 2) {
const location = parts[1]
const comma = location.split(',').map((p) => p.trim())
const country = comma.length >= 2 ? comma[0] : 'RU'
const city = comma.length >= 2 ? comma[1] : location
return { city, country: country.length <= 3 ? country.toUpperCase() : 'RU', datacenter: trimmed }
}
return { city: '', country: 'RU', datacenter: trimmed }
}
export interface MappedVps {
externalId: string
ip: string
dns: string
ipv6: string
additionalIps: string[]
providerId: string
providerAccountId: string
country: string
city: string
datacenter: string
os: string
vcpu: number
ramGb: number
diskGb: number
diskType: string
virtualization: string
bandwidthTb: number
sshPort: number
rootUser: string
purpose: string
environment: string
project: string
monitoringEnabled: boolean
backupEnabled: boolean
status: string
tariffType: string
currency: string
dailyRate: number | null
monthlyRate: number | null
createdAt: string
paidUntil: string
notes: string
}
export interface MappedPayment {
externalId: string
type: string
date: string
amount: number
currency: string
providerAccountId: string
vpsId: null
note: string
}
export interface RuvdsLookupMaps {
datacenters: Map<number, RuvdsDatacenter>
os: Map<number, RuvdsOsItem>
costs: Map<number, number>
}
export function buildLookupMaps(
datacenters: RuvdsDatacenter[],
osList: RuvdsOsItem[],
costs: Map<number, number>,
): RuvdsLookupMaps {
return {
datacenters: new Map(datacenters.map((d) => [d.id, d])),
os: new Map(osList.map((o) => [o.id, o])),
costs,
}
}
export function mapServerToVps(
server: RuvdsServer,
providerId: string,
providerAccountId: string,
fallbackCurrency: string,
lookups: RuvdsLookupMaps,
): MappedVps {
const externalId = String(server.virtual_server_id)
const networks = server.network_v4 ?? []
const ipv4List = networks.map((n) => String(n.ip_address || '').trim()).filter(Boolean)
const ip = ipv4List[0] ?? ''
const additionalIps = ipv4List.slice(1)
const dc = server.datacenter != null ? lookups.datacenters.get(server.datacenter) : undefined
const dcParsed = parseDatacenterName(dc?.name ?? '')
const osItem = server.os_id != null ? lookups.os.get(server.os_id) : undefined
const status = STATUS_MAP[String(server.status ?? '').toLowerCase()] ?? 'active'
const diskGb =
(Number(server.drive) || 0) + (Number(server.additional_drive) || 0) || Number(server.drive) || 0
const costRub = lookups.costs.get(server.virtual_server_id)
const { tariffType, dailyRate, monthlyRate } = ratesFromCost(costRub, server.payment_period)
const comment = String(server.user_comment ?? '').trim()
const marker = `ruvds-${externalId}`
const notes = comment ? `${comment} [${marker}]` : `[${marker}]`
const dns = comment || `RU${externalId}`
return {
externalId,
ip,
dns,
ipv6: '',
additionalIps,
providerId,
providerAccountId,
country: (dc?.country ?? dcParsed.country).toUpperCase() || 'RU',
city: dcParsed.city,
datacenter: dcParsed.datacenter,
os: osItem?.name ?? '',
vcpu: Number(server.cpu) || 0,
ramGb: Number(server.ram) || 0,
diskGb,
diskType: 'SSD',
virtualization: 'KVM',
bandwidthTb: 0,
sshPort: 22,
rootUser: 'root',
purpose: '',
environment: '',
project: '',
monitoringEnabled: false,
backupEnabled: false,
status,
tariffType,
currency: fallbackCurrency,
dailyRate,
monthlyRate,
createdAt: '',
paidUntil: dateToIso(server.paid_till),
notes,
}
}
function paymentExternalId(payment: RuvdsPayment): string {
const raw = `${payment.dt}|${payment.direction}|${payment.amount}|${payment.pay_source ?? ''}`
return createHash('sha256').update(raw).digest('hex').slice(0, 16)
}
export function mapPaymentToPayment(
payment: RuvdsPayment,
providerAccountId: string,
fallbackCurrency: string,
): MappedPayment | null {
if (payment.direction !== 1) return null
const amount = Number(payment.amount)
if (!Number.isFinite(amount) || amount <= 0) return null
const externalId = paymentExternalId(payment)
const currency = ruvdsCurrencyCode(payment.currency, fallbackCurrency)
const date = dateToIso(payment.dt) || new Date().toISOString().slice(0, 10)
const source = String(payment.pay_source ?? '').trim()
return {
externalId,
type: 'topup',
date,
amount,
currency,
providerAccountId,
vpsId: null,
note: source ? `ruvds-${externalId} ${source}` : `ruvds-${externalId}`,
}
}
+229
View File
@@ -0,0 +1,229 @@
/**
* RuVDS API v2 operations
*/
import { parseRuvdsToken } from '@cfdm/shared/utils/api-credentials'
import { createRuvdsClient, RuvdsApiError, type RuvdsClient } from './client.js'
import type {
RuvdsBalanceResponse,
RuvdsDatacenter,
RuvdsOsItem,
RuvdsPagination,
RuvdsPayment,
RuvdsServer,
RuvdsTariffsResponse,
} from './types.js'
const CURRENCY_MAP: Record<number, string> = {
1: 'RUB',
3: 'USD',
4: 'EUR',
}
export function ruvdsCurrencyCode(currencyId: number | undefined | null, fallback = 'RUB'): string {
if (currencyId == null) return fallback
return CURRENCY_MAP[currencyId] ?? fallback
}
function clientFor(baseUrl: string, credentials: string): RuvdsClient {
return createRuvdsClient(baseUrl, parseRuvdsToken(credentials))
}
async function fetchAllPages<T>(
client: RuvdsClient,
path: string,
extract: (json: Record<string, unknown>) => T[],
query: Record<string, string | number | boolean | undefined> = {},
): Promise<T[]> {
const items: T[] = []
let page = 1
let lastPage = 1
while (page <= lastPage) {
const json = (await client.request<Record<string, unknown>>(path, {
query: { ...query, page, per_page: 100 },
})) as Record<string, unknown>
items.push(...extract(json))
const pagination = json.pagination as RuvdsPagination | undefined
lastPage = pagination?.last_page ?? page
if (!pagination?.next_page) break
page = pagination.next_page
}
return items
}
export interface RuvdsBalanceResult {
balance: number
currency: string
enoughmoneyto: string
}
export interface RuvdsTariffItem {
externalId: string
datacenterKey: string
datacenterName: string
name: string
desc: string
vcpu: number
ramGb: number
diskGb: number
diskType: string
virtualization: string
channel: string
location: string
country: string
cpuModel: string
orderAvailable: boolean
price: string
}
export async function testConnection(
baseUrl: string,
credentials: string,
): Promise<{ ok: boolean; error?: string }> {
try {
await fetchBalance(baseUrl, credentials)
return { ok: true }
} catch (err) {
const message = err instanceof RuvdsApiError ? err.message : String(err)
return { ok: false, error: message }
}
}
export async function fetchBalance(
baseUrl: string,
credentials: string,
fallbackCurrency = 'RUB',
): Promise<RuvdsBalanceResult> {
const client = clientFor(baseUrl, credentials)
const json = await client.request<RuvdsBalanceResponse>('/v2/balance')
return {
balance: Number(json.amount) || 0,
currency: ruvdsCurrencyCode(json.currency, fallbackCurrency),
enoughmoneyto: '',
}
}
export async function fetchAllServers(baseUrl: string, credentials: string): Promise<RuvdsServer[]> {
const client = clientFor(baseUrl, credentials)
return fetchAllPages<RuvdsServer>(
client,
'/v2/servers',
(json) => (Array.isArray(json.servers) ? (json.servers as RuvdsServer[]) : []),
{ get_paid_till: true, get_network: true },
)
}
export async function fetchServerCost(
baseUrl: string,
credentials: string,
serverId: number,
): Promise<number | null> {
const client = clientFor(baseUrl, credentials)
try {
const json = await client.request<{ cost_rub?: number }>(`/v2/servers/${serverId}/cost`)
const cost = json.cost_rub
return cost != null && Number.isFinite(cost) ? cost : null
} catch {
return null
}
}
export async function fetchAllPayments(baseUrl: string, credentials: string): Promise<RuvdsPayment[]> {
const client = clientFor(baseUrl, credentials)
return fetchAllPages<RuvdsPayment>(
client,
'/v2/payments',
(json) => (Array.isArray(json.payments) ? (json.payments as RuvdsPayment[]) : []),
)
}
export async function fetchTariffs(baseUrl: string, credentials: string): Promise<RuvdsTariffsResponse> {
const client = clientFor(baseUrl, credentials)
return client.request<RuvdsTariffsResponse>('/v2/tariffs')
}
export async function fetchDatacenters(baseUrl: string, credentials: string): Promise<RuvdsDatacenter[]> {
const client = clientFor(baseUrl, credentials)
const json = await client.request<{ datacenters?: RuvdsDatacenter[] }>('/v2/datacenters')
return Array.isArray(json.datacenters) ? json.datacenters : []
}
export async function fetchOsList(baseUrl: string, credentials: string): Promise<RuvdsOsItem[]> {
const client = clientFor(baseUrl, credentials)
const json = await client.request<{ os?: RuvdsOsItem[] }>('/v2/os')
return Array.isArray(json.os) ? json.os : []
}
export async function fetchTariffList(
baseUrl: string,
credentials: string,
): Promise<RuvdsTariffItem[]> {
const [tariffs, datacenters] = await Promise.all([
fetchTariffs(baseUrl, credentials),
fetchDatacenters(baseUrl, credentials).catch(() => [] as RuvdsDatacenter[]),
])
const dc = datacenters[0]
const dcKey = dc ? String(dc.id) : ''
const dcName = dc?.name ?? 'RuVDS'
const items: RuvdsTariffItem[] = []
for (const t of tariffs.vps ?? []) {
if (t.is_active === false) continue
const cpuPrice = t.cpu ?? 0
const ramPrice = t.ram ?? 0
const sampleCpu = 2
const sampleRam = 2
const sampleDisk = 40
const drivePrice = tariffs.drive?.find((d) => d.is_active !== false)?.price ?? 5.5
const monthly = Math.round((cpuPrice * sampleCpu + ramPrice * sampleRam + drivePrice * sampleDisk) * 100) / 100
items.push({
externalId: String(t.id),
datacenterKey: dcKey,
datacenterName: dcName,
name: t.name || `Tariff ${t.id}`,
desc: `CPU ${cpuPrice} RUB/core, RAM ${ramPrice} RUB/GB`,
vcpu: sampleCpu,
ramGb: sampleRam,
diskGb: sampleDisk,
diskType: 'SSD',
virtualization: 'KVM',
channel: '',
location: dcName,
country: (dc?.country ?? 'RU').toUpperCase(),
cpuModel: '',
orderAvailable: true,
price: monthly > 0 ? `${monthly} RUB/mo` : '',
})
}
return items
}
const COST_CONCURRENCY = 4
export async function enrichServersWithCost(
baseUrl: string,
credentials: string,
servers: RuvdsServer[],
): Promise<Map<number, number>> {
const costById = new Map<number, number>()
const ids = servers.map((s) => s.virtual_server_id).filter((id) => id > 0)
if (ids.length === 0) return costById
for (let i = 0; i < ids.length; i += COST_CONCURRENCY) {
const batch = ids.slice(i, i + COST_CONCURRENCY)
const results = await Promise.all(
batch.map(async (id) => {
const cost = await fetchServerCost(baseUrl, credentials, id)
return { id, cost }
}),
)
for (const { id, cost } of results) {
if (cost != null) costById.set(id, cost)
}
}
return costById
}
+160
View File
@@ -0,0 +1,160 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { closeDb, getSqlite } from '@cfdm/db'
import { resetTestDb } from '@cfdm/db/test-setup'
import { providerAccountsRepository } from '@cfdm/db/repositories/provider-accounts'
import { syncFromRuvds } from './sync.js'
import type { RuvdsSyncAccount } from './context.js'
vi.mock('./operations.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('./operations.js')>()
return {
...actual,
fetchAllServers: vi.fn(),
fetchBalance: vi.fn(),
fetchTariffList: vi.fn(),
fetchAllPayments: vi.fn(),
fetchDatacenters: vi.fn(),
fetchOsList: vi.fn(),
enrichServersWithCost: vi.fn(),
}
})
import {
enrichServersWithCost,
fetchAllPayments,
fetchAllServers,
fetchBalance,
fetchDatacenters,
fetchOsList,
fetchTariffList,
} from './operations.js'
function makeAccount(): RuvdsSyncAccount {
return {
id: 'acc-ruvds',
providerId: 'prov-ruvds',
name: 'RuVDS',
panelUrl: '',
currency: '',
billingMode: 'monthly',
notes: '',
apiType: 'ruvds',
apiBaseUrl: 'https://api.ruvds.com',
apiCredentials: 'apiv2.test-token',
apiToken: 'apiv2.test-token',
balanceApi: null,
balanceCurrency: null,
balanceUpdatedAt: null,
enoughmoneyto: '',
balanceAlertBelow: null,
providerBaseCurrency: 'RUB',
}
}
describe('syncFromRuvds', () => {
beforeEach(() => {
resetTestDb()
getSqlite().exec(
`INSERT INTO providers (id, name, apiType, apiBaseUrl, baseCurrency) VALUES ('prov-ruvds', 'RuVDS', 'ruvds', 'https://api.ruvds.com', 'RUB')`,
)
providerAccountsRepository.create({
id: 'acc-ruvds',
providerId: 'prov-ruvds',
name: 'RuVDS',
apiCredentials: 'apiv2.test-token',
})
vi.mocked(fetchAllServers).mockResolvedValue([
{
virtual_server_id: 1001,
status: 'active',
datacenter: 1,
cpu: 2,
ram: 2,
drive: 40,
payment_period: 2,
os_id: 18,
paid_till: '2027-03-01T00:00:00Z',
user_comment: 'web',
network_v4: [{ ip_address: '203.0.113.5' }],
},
])
vi.mocked(fetchBalance).mockResolvedValue({
balance: 1500,
currency: 'RUB',
enoughmoneyto: '',
})
vi.mocked(fetchTariffList).mockResolvedValue([
{
externalId: '14',
datacenterKey: '1',
datacenterName: 'Moscow',
name: 'Regular',
desc: 'CPU 79 RUB/core',
vcpu: 2,
ramGb: 2,
diskGb: 40,
diskType: 'SSD',
virtualization: 'KVM',
channel: '',
location: 'Moscow',
country: 'RU',
cpuModel: '',
orderAvailable: true,
price: '500 RUB/mo',
},
])
vi.mocked(fetchAllPayments).mockResolvedValue([
{
dt: '2026-01-01T00:00:00Z',
direction: 1,
amount: 1000,
currency: 1,
pay_source: 'card',
},
])
vi.mocked(fetchDatacenters).mockResolvedValue([
{ id: 1, name: 'Rucloud: Россия, Москва', country: 'RU' },
])
vi.mocked(fetchOsList).mockResolvedValue([{ id: 18, name: 'Ubuntu 22.04' }])
vi.mocked(enrichServersWithCost).mockResolvedValue(new Map([[1001, 500]]))
})
afterEach(() => {
closeDb()
})
it('syncs vps, balance, payments and tariffs', async () => {
const result = await syncFromRuvds(makeAccount())
expect(result.vpsCount).toBe(1)
expect(result.paymentsCount).toBe(1)
expect(result.tariffsCount).toBe(1)
expect(result.balance?.balance).toBe(1500)
expect(result.syncSummary.added).toHaveLength(1)
expect(result.syncSummary.added[0].id).toBe('vps-ruvds-acc-ruvds-1001')
})
it('updates existing vps by notes marker', async () => {
await syncFromRuvds(makeAccount())
vi.mocked(fetchAllServers).mockResolvedValue([
{
virtual_server_id: 1001,
status: 'active',
datacenter: 1,
cpu: 4,
ram: 4,
drive: 80,
payment_period: 2,
os_id: 18,
paid_till: '2028-01-01T00:00:00Z',
network_v4: [{ ip_address: '203.0.113.5' }],
},
])
vi.mocked(enrichServersWithCost).mockResolvedValue(new Map([[1001, 800]]))
const result = await syncFromRuvds(makeAccount())
expect(result.vpsCount).toBe(1)
expect(result.syncSummary.updated.length).toBeGreaterThan(0)
})
})
+361
View File
@@ -0,0 +1,361 @@
/**
* Sync RuVDS API v2 data into vps-tracker DB
*/
import { and, eq, like, or } from 'drizzle-orm'
import { getDb, schema } from '@cfdm/db'
import type { RuvdsSyncAccount } from './context.js'
import { ruvdsCredentialsString } from './context.js'
import { syncFallbackCurrency } from '@cfdm/shared/utils/account-balance'
import { buildLookupMaps, mapPaymentToPayment, mapServerToVps } from './mappers.js'
import {
enrichServersWithCost,
fetchAllPayments,
fetchAllServers,
fetchDatacenters,
fetchOsList,
fetchTariffList,
fetchBalance,
type RuvdsBalanceResult,
} from './operations.js'
export interface SyncFromRuvdsOptions {
skipTariffs?: boolean
skipVpsPayments?: boolean
}
export interface SyncSummary {
added: { id: string; label: string }[]
updated: { id: string; label: string; fields: string[] }[]
paymentsAdded: number
tariffsOnly?: boolean
}
export interface SyncFromRuvdsResult {
vpsCount: number
paymentsCount: number
tariffsCount: number
newTariffs: { name: string; price: string; providerId: string }[]
balance: RuvdsBalanceResult | null
syncSummary: SyncSummary
}
const SYNC_UPDATE_FIELDS = [
'country',
'city',
'datacenter',
'os',
'notes',
'status',
'tariffType',
'currency',
'dailyRate',
'monthlyRate',
'paidUntil',
] as const
const SYNC_SPEC_FIELDS = ['vcpu', 'ramGb', 'diskGb'] 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)
}
export async function syncFromRuvds(
account: RuvdsSyncAccount,
opts: SyncFromRuvdsOptions = {},
): Promise<SyncFromRuvdsResult> {
const { skipTariffs = false, skipVpsPayments = false } = opts
const { apiBaseUrl, providerId, id: accountId } = account
const credentials = ruvdsCredentialsString(account)
if (!apiBaseUrl?.trim() || !credentials) {
throw new Error('API URL and token are required')
}
const db = getDb()
const fetchVpsData = !skipVpsPayments
const fetchTariffs = !skipTariffs
const [servers, balanceInfo, tariffItems, payments, datacenters, osList] = await Promise.all([
fetchVpsData ? fetchAllServers(apiBaseUrl, credentials) : [],
fetchVpsData
? fetchBalance(apiBaseUrl, credentials, syncFallbackCurrency(account)).catch(() => null)
: null,
fetchTariffs ? fetchTariffList(apiBaseUrl, credentials).catch(() => []) : [],
fetchVpsData ? fetchAllPayments(apiBaseUrl, credentials).catch(() => []) : [],
fetchVpsData ? fetchDatacenters(apiBaseUrl, credentials).catch(() => []) : [],
fetchVpsData ? fetchOsList(apiBaseUrl, credentials).catch(() => []) : [],
])
const costs = fetchVpsData
? await enrichServersWithCost(apiBaseUrl, credentials, servers)
: new Map<number, number>()
const lookups = buildLookupMaps(datacenters, osList, costs)
const fallbackCurrency = syncFallbackCurrency(account, {
balanceCurrency: balanceInfo?.currency,
})
let vpsCount = 0
const syncSummary: SyncSummary = { added: [], updated: [], paymentsAdded: 0 }
if (fetchVpsData) {
for (const server of servers) {
const vps = mapServerToVps(server, providerId, accountId, fallbackCurrency, lookups)
const id = `vps-ruvds-${accountId}-${vps.externalId}`
const additionalIps = JSON.stringify(vps.additionalIps || [])
const dailyRate = vps.dailyRate
const monthlyRate = vps.monthlyRate
const paidUntil = vps.paidUntil || ''
const notes = vps.notes
const existing = db
.select()
.from(schema.vps)
.where(
and(
eq(schema.vps.providerAccountId, accountId),
or(
...(vps.ip ? [eq(schema.vps.ip, vps.ip)] : []),
like(schema.vps.notes, `%ruvds-${vps.externalId}%`),
),
),
)
.get()
if (existing) {
let userOverrides: string[] = []
try {
userOverrides = existing.userOverrides ? JSON.parse(existing.userOverrides) : []
} catch {
userOverrides = []
}
const merged = {
ip: vps.ip,
ipv6: vps.ipv6,
additionalIps,
dns: vps.dns,
country: vps.country,
city: vps.city,
datacenter: vps.datacenter,
os: vps.os,
vcpu: vps.vcpu,
ramGb: vps.ramGb,
diskGb: vps.diskGb,
status: vps.status,
tariffType: vps.tariffType,
currency: vps.currency,
dailyRate,
monthlyRate,
paidUntil,
notes,
}
for (const f of SYNC_UPDATE_FIELDS) {
if (userOverrides.includes(f)) {
merged[f] = existing[f as keyof typeof existing] as never
}
}
for (const f of SYNC_SPEC_FIELDS) {
if (userOverrides.includes(f)) {
merged[f] = existing[f as keyof typeof existing] as never
}
}
const compareFields = [
'ip',
'ipv6',
'dns',
...SYNC_SPEC_FIELDS,
...SYNC_UPDATE_FIELDS,
] as const
const changedFields = compareFields.filter(
(f) => normVal(merged[f as keyof typeof merged]) !== normVal(existing[f as keyof typeof existing]),
)
if (changedFields.length > 0) {
const label = merged.dns || merged.ip || existing.id
syncSummary.updated.push({ id: existing.id, label, fields: [...changedFields] })
}
db.update(schema.vps)
.set({
ip: merged.ip,
ipv6: merged.ipv6,
additionalIps: merged.additionalIps,
dns: merged.dns,
country: merged.country,
city: merged.city,
datacenter: merged.datacenter,
os: merged.os,
vcpu: merged.vcpu,
ramGb: merged.ramGb,
diskGb: merged.diskGb,
status: merged.status,
tariffType: merged.tariffType,
currency: merged.currency,
dailyRate: merged.dailyRate,
monthlyRate: merged.monthlyRate,
paidUntil: merged.paidUntil,
notes: merged.notes,
})
.where(eq(schema.vps.id, existing.id))
.run()
} else {
const label = vps.dns || vps.ip || id
syncSummary.added.push({ id, label })
db.insert(schema.vps)
.values({
id,
ip: vps.ip,
ipv6: vps.ipv6,
additionalIps,
dns: vps.dns,
providerId: vps.providerId,
providerAccountId: vps.providerAccountId,
country: vps.country,
city: vps.city,
datacenter: vps.datacenter,
os: vps.os,
vcpu: vps.vcpu,
ramGb: vps.ramGb,
diskGb: vps.diskGb,
diskType: vps.diskType,
virtualization: vps.virtualization,
bandwidthTb: vps.bandwidthTb,
sshPort: vps.sshPort,
rootUser: vps.rootUser,
purpose: vps.purpose,
environment: vps.environment,
project: vps.project,
projectId: null,
monitoringEnabled: vps.monitoringEnabled ? 1 : 0,
backupEnabled: vps.backupEnabled ? 1 : 0,
status: vps.status,
tariffType: vps.tariffType,
currency: vps.currency,
dailyRate,
monthlyRate,
createdAt: vps.createdAt || new Date().toISOString().slice(0, 10),
paidUntil,
notes,
userOverrides: '[]',
})
.run()
}
vpsCount++
}
}
let paymentsCount = 0
if (fetchVpsData) {
const existingPaymentRows = db
.select({ note: schema.payments.note })
.from(schema.payments)
.where(eq(schema.payments.providerAccountId, accountId))
.all()
const existingPayments = new Set(
existingPaymentRows.map((r) => r.note).filter((n): n is string => Boolean(n)),
)
for (const item of payments) {
const payment = mapPaymentToPayment(item, accountId, fallbackCurrency)
if (!payment) continue
const note = payment.note
if (existingPayments.has(note)) continue
const payId = `pay-ruvds-${accountId}-${payment.externalId}`
db.insert(schema.payments)
.values({
id: payId,
type: payment.type,
date: payment.date,
amount: payment.amount,
currency: payment.currency,
providerAccountId: payment.providerAccountId,
vpsId: payment.vpsId,
note,
})
.run()
existingPayments.add(note)
paymentsCount++
syncSummary.paymentsAdded += 1
}
}
if (fetchVpsData && balanceInfo) {
db.update(schema.providerAccounts)
.set({
balanceApi: balanceInfo.balance,
balanceCurrency: balanceInfo.currency || fallbackCurrency,
balanceUpdatedAt: new Date().toISOString(),
enoughmoneyto: balanceInfo.enoughmoneyto || '',
})
.where(eq(schema.providerAccounts.id, accountId))
.run()
}
let tariffsCount = 0
const newTariffs: { name: string; price: string; providerId: string }[] = []
if (fetchTariffs) {
const existingTariffIds = new Set(
db
.select({ id: schema.activeTariffs.id })
.from(schema.activeTariffs)
.where(eq(schema.activeTariffs.providerAccountId, accountId))
.all()
.map((r) => r.id),
)
const syncedAt = new Date().toISOString()
db.delete(schema.activeTariffs)
.where(eq(schema.activeTariffs.providerAccountId, accountId))
.run()
for (const t of tariffItems) {
const dcKey = t.datacenterKey ?? ''
const dcName = t.datacenterName ?? ''
const tariffId = dcKey
? `tariff-ruvds-${accountId}-${t.externalId}-${dcKey}`
: `tariff-ruvds-${accountId}-${t.externalId}`
if (!existingTariffIds.has(tariffId)) {
newTariffs.push({ name: t.name || '', price: t.price || '', providerId })
}
db.insert(schema.activeTariffs)
.values({
id: tariffId,
providerAccountId: accountId,
providerId,
externalId: t.externalId,
datacenterKey: dcKey,
datacenterName: dcName,
name: t.name || '',
desc: t.desc || '',
vcpu: t.vcpu || 0,
ramGb: t.ramGb || 0,
diskGb: t.diskGb || 0,
diskType: t.diskType || 'NVMe',
virtualization: t.virtualization || 'KVM',
channel: t.channel || '',
location: t.location || '',
country: t.country || '',
cpuModel: t.cpuModel || '',
orderAvailable: t.orderAvailable ? 1 : 0,
price: t.price || '',
syncedAt,
})
.run()
tariffsCount++
}
}
if (!fetchVpsData) {
syncSummary.tariffsOnly = true
}
return {
vpsCount,
paymentsCount,
tariffsCount,
newTariffs,
balance: balanceInfo,
syncSummary,
}
}
+115
View File
@@ -0,0 +1,115 @@
export interface RuvdsPagination {
page: number
per_page: number
previous_page: number | null
next_page: number | null
last_page: number
total_entries: number
}
export interface RuvdsNetworkV4 {
ip_address: string
netmask?: string
gateway?: string
}
export interface RuvdsServer {
virtual_server_id: number
status?: string
create_progress?: number
datacenter?: number
tariff_id?: number
payment_period?: number
os_id?: number
template_id?: string | null
cpu?: number
ram?: number
vram?: number
drive?: number
drive_tariff_id?: number
additional_drive?: number | null
additional_drive_tariff_id?: number | null
ip?: number
ddos_protection?: number
user_comment?: string
paid_till?: string
network_v4?: RuvdsNetworkV4[]
}
export interface RuvdsServersResponse {
servers: RuvdsServer[]
pagination?: RuvdsPagination
}
export interface RuvdsBalanceResponse {
amount: number
currency: number
type?: string
}
export interface RuvdsPayment {
dt: string
direction: number
pay_source?: string
amount: number
currency: number
type?: string
}
export interface RuvdsPaymentsResponse {
payments: RuvdsPayment[]
pagination?: RuvdsPagination
}
export interface RuvdsVpsTariff {
id: number
name: string
cpu?: number
ram?: number
vram?: number
ip?: number
is_active?: boolean
}
export interface RuvdsDriveTariff {
id: number
name: string
price?: number
is_active?: boolean
}
export interface RuvdsTariffsResponse {
vps?: RuvdsVpsTariff[]
drive?: RuvdsDriveTariff[]
additional_drive?: RuvdsDriveTariff[]
additional_service?: { id: number; name: string; price?: number; is_active?: boolean }[]
payment_period_discount?: { payment_period: number; discount: number }[]
}
export interface RuvdsDatacenter {
id: number
name: string
country?: string
}
export interface RuvdsDatacentersResponse {
datacenters?: RuvdsDatacenter[]
}
export interface RuvdsOsItem {
id: number
name: string
}
export interface RuvdsOsResponse {
os?: RuvdsOsItem[]
}
export interface RuvdsServerCostResponse {
cost_rub?: number
}
export interface RuvdsApiErrorBody {
id?: string
message?: string
}
+1 -1
View File
@@ -34,7 +34,7 @@ function getSyncableAccounts(): SyncableAccountEntry[] {
.all<AccountRow>(sql`
SELECT pa.* FROM provider_accounts pa
INNER JOIN providers p ON p.id = pa.providerId
WHERE lower(trim(COALESCE(p.apiType, ''))) IN ('billmanager', '4vps', 'macloud', 'vdsina', 'veesp')
WHERE lower(trim(COALESCE(p.apiType, ''))) IN ('billmanager', '4vps', 'macloud', 'vdsina', 'veesp', 'ruvds')
AND length(trim(COALESCE(p.apiBaseUrl, ''))) > 0
AND pa.apiCredentials IS NOT NULL AND length(trim(pa.apiCredentials)) > 0
`)
@@ -14,7 +14,7 @@ import { providerAccountSchema, type ProviderAccountFormValues } from '@/lib/sch
import type { BillingMode, Provider } from '@/types/entities'
import { billingModeLabel } from '@/lib/format'
import { api, ApiError } from '@/lib/api-client'
import { accountCredentialLabels, isUserApiType } from '@/lib/provider-sync'
import { accountCredentialLabels, isTokenApiType } from '@/lib/provider-sync'
const EMPTY: ProviderAccountFormValues = {
providerId: '',
@@ -85,8 +85,10 @@ export function ProviderAccountEditSheet({
onOpenChange={onOpenChange}
title={isEdit ? 'Редактировать аккаунт' : 'Новый аккаунт'}
description={
isUserApiType(initialProvider?.apiType)
? 'API Token хранится на сервере и используется для синка с UserAPI'
isTokenApiType(initialProvider?.apiType)
? initialProvider?.apiType === 'ruvds'
? 'API-токен RuVDS хранится на сервере (создаётся в настройках ЛК RuVDS)'
: 'API Token хранится на сервере и используется для синка с UserAPI'
: initialProvider?.apiType === '4vps'
? 'Panel ID и API Key хранятся на сервере и используются для синка с 4VPS'
: initialProvider?.apiType === 'veesp'
@@ -105,10 +107,12 @@ export function ProviderAccountEditSheet({
const apiBaseUrl = (provider?.apiBaseUrl ?? '').trim()
const apiLogin = watch('apiLogin')?.trim() ?? ''
const apiPassword = watch('apiPassword')?.trim() ?? ''
const apiCredentials = buildApiCredentials(apiLogin, apiPassword)
const canTest = Boolean(apiBaseUrl && apiCredentials)
const credLabels = accountCredentialLabels(provider?.apiType)
const tokenOnly = isUserApiType(provider?.apiType)
const tokenOnly = isTokenApiType(provider?.apiType)
const apiCredentials = tokenOnly
? apiPassword
: buildApiCredentials(apiLogin, apiPassword)
const canTest = Boolean(apiBaseUrl && (tokenOnly ? apiPassword : apiCredentials))
return (
<>
@@ -6,7 +6,7 @@ import { SelectField } from '@/components/select-field'
import type { ZodType } from 'zod'
import { providerSchema, type ProviderFormValues } from '@/lib/schemas'
import type { ApiType } from '@/types/entities'
import { isUserApiType, USER_API_DEFAULT_BASE_URL, VEESP_DEFAULT_BASE_URL } from '@cfdm/shared/contracts/provider'
import { isUserApiType, RUVDS_DEFAULT_BASE_URL, USER_API_DEFAULT_BASE_URL, VEESP_DEFAULT_BASE_URL } from '@cfdm/shared/contracts/provider'
const EMPTY: ProviderFormValues = {
name: '',
@@ -60,6 +60,8 @@ export function ProviderEditSheet({
? 'Базовый URL API, например https://4vps.su/api'
: apiType === 'veesp'
? 'Базовый URL Veesp API, например https://secure.veesp.com/api'
: apiType === 'ruvds'
? 'Базовый URL RuVDS API v2, например https://api.ruvds.com'
: isUserApiType(apiType)
? `Базовый URL UserAPI, например ${USER_API_DEFAULT_BASE_URL[apiType]}`
: 'Один URL на хостера для BILLmanager'
@@ -68,6 +70,8 @@ export function ProviderEditSheet({
? 'https://4vps.su/api'
: apiType === 'veesp'
? VEESP_DEFAULT_BASE_URL
: apiType === 'ruvds'
? RUVDS_DEFAULT_BASE_URL
: isUserApiType(apiType)
? USER_API_DEFAULT_BASE_URL[apiType]
: undefined
@@ -91,6 +95,7 @@ export function ProviderEditSheet({
{ value: 'macloud', label: 'Маклауд' },
{ value: 'vdsina', label: 'VDSina' },
{ value: 'veesp', label: 'Veesp' },
{ value: 'ruvds', label: 'RuVDS' },
{ value: 'none', label: 'Нет' },
]}
/>
+9 -2
View File
@@ -1,8 +1,8 @@
import { isSyncApiType, isUserApiType } from '@cfdm/shared/contracts/provider'
import { isSyncApiType, isTokenApiType, isUserApiType } from '@cfdm/shared/contracts/provider'
import type { ProviderAccount, Provider } from '@/types/entities'
export { isSyncApiType, isUserApiType }
export { isSyncApiType, isTokenApiType, isUserApiType }
export function providerByIdMap(providers: Provider[]): Map<string, Provider> {
return new Map(providers.map((p) => [p.id, p]))
@@ -76,6 +76,13 @@ export function accountCredentialLabels(apiType?: string | null): {
loginPlaceholder: '[email protected]',
}
}
if (String(apiType).toLowerCase() === 'ruvds') {
return {
loginLabel: '',
passwordLabel: 'API Token',
loginPlaceholder: '',
}
}
if (isUserApiType(apiType)) {
return {
loginLabel: '',
+12 -2
View File
@@ -1,10 +1,10 @@
import { z } from 'zod'
export const API_TYPES = ['billmanager', '4vps', 'macloud', 'vdsina', 'veesp', 'none'] as const
export const API_TYPES = ['billmanager', '4vps', 'macloud', 'vdsina', 'veesp', 'ruvds', 'none'] as const
export type ApiType = (typeof API_TYPES)[number]
export const apiTypeSchema = z.enum(API_TYPES).optional().default('none')
export const SYNC_API_TYPES = ['billmanager', '4vps', 'macloud', 'vdsina', 'veesp'] as const
export const SYNC_API_TYPES = ['billmanager', '4vps', 'macloud', 'vdsina', 'veesp', 'ruvds'] as const
export type SyncApiType = (typeof SYNC_API_TYPES)[number]
export const USER_API_TYPES = ['macloud', 'vdsina'] as const
@@ -17,6 +17,16 @@ export const USER_API_DEFAULT_BASE_URL: Record<UserApiType, string> = {
export const VEESP_DEFAULT_BASE_URL = 'https://secure.veesp.com/api'
export const RUVDS_DEFAULT_BASE_URL = 'https://api.ruvds.com'
export const TOKEN_API_TYPES = ['macloud', 'vdsina', 'ruvds'] as const
export type TokenApiType = (typeof TOKEN_API_TYPES)[number]
export function isTokenApiType(apiType?: string | null): apiType is TokenApiType {
const key = String(apiType || '').toLowerCase()
return (TOKEN_API_TYPES as readonly string[]).includes(key)
}
export function isUserApiType(apiType?: string | null): apiType is UserApiType {
const key = String(apiType || '').toLowerCase()
return (USER_API_TYPES as readonly string[]).includes(key)
@@ -39,6 +39,11 @@ export function parseUserApiToken(credentials: string | null | undefined): strin
return String(credentials ?? '').trim()
}
/** Bearer token для RuVDS API v2. */
export function parseRuvdsToken(credentials: string | null | undefined): string {
return String(credentials ?? '').trim()
}
/** Собрать 4VPS-креды: panelId + API key. */
export function buildFourVpsCredentials(panelId: string, apiKey: string): string {
const pid = panelId.trim()