refactor(repo): переход на pnpm monorepo с shadcn/ui и Fastify+Drizzle
Frontend:
- apps/web (Vite+TS, TanStack Router/Query, shadcn/ui @cfdm/ui base-nova)
- 10 страниц в routes/_auth/, Recharts через shadcn Chart, lucide-react
- формы на RHF + Zod (FormSheet/FormField)
- удалены Tabler, Chart.js, react-router-dom
Backend (параллельный трек):
- apps/api (Fastify 5 + Drizzle + better-sqlite3)
- packages/db: Drizzle-схема и repositories по сущностям
- packages/shared: Zod-контракты
- роуты с валидацией и единым форматом ошибок { error: { code, message } }
- sync/backup — заглушки 501 (billmanager-адаптеры переносятся отдельно)
- legacy Express оставлен как runtime по умолчанию (RUNTIME=express)
Infra:
- Dockerfile multi-stage под pnpm workspaces
- .dockerignore и docker-compose обновлены под monorepo
Rules:
- удалены нерелевантные правила (rust, cloudflare, server/frontend-conventions)
- project-structure.mdc и AGENTS.md переписаны под monorepo
- frontend-shadcn.mdc, shadcn-ui-production.mdc, sqlite.mdc обновлены
Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* BILLmanager 6 API HTTP client
|
||||
* @see https://www.ispsystem.com/docs/b6c/developer-section/working-with-api/guide-to-ispsystem-software-api
|
||||
*/
|
||||
|
||||
/**
|
||||
* @param {string} baseUrl - e.g. https://bill.example.com:1500/billmgr
|
||||
* @param {string} authinfo - username:password
|
||||
* @param {string} func - API function name (vds, payment, dedic)
|
||||
* @param {Record<string, string>} [params] - additional query params
|
||||
* @returns {Promise<any>}
|
||||
*/
|
||||
export async function billmanagerRequest(baseUrl, authinfo, func, params = {}) {
|
||||
const url = new URL(baseUrl)
|
||||
url.searchParams.set('authinfo', authinfo)
|
||||
url.searchParams.set('out', 'bjson')
|
||||
url.searchParams.set('func', func)
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
if (v != null && v !== '') url.searchParams.set(k, String(v))
|
||||
}
|
||||
const res = await fetch(url.toString(), { method: 'GET' })
|
||||
if (!res.ok) {
|
||||
throw new Error(`BILLmanager API HTTP ${res.status}: ${res.statusText}`)
|
||||
}
|
||||
const data = await res.json()
|
||||
if (data.error) {
|
||||
throw new Error(data.error.msg || data.error.$t || 'BILLmanager API error')
|
||||
}
|
||||
return data
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* BILLmanager 6 API adapter
|
||||
* @see https://www.ispsystem.com/docs/b6c/developer-section/working-with-api/guide-to-ispsystem-software-api
|
||||
* @see https://www.ispsystem.com/docs/b6c/developer-section/billmanager-api/virtual-private-servers-vds
|
||||
* @see https://www.ispsystem.com/docs/b6c/developer-section/billmanager-api/payments-payment
|
||||
*/
|
||||
|
||||
export { testConnection, fetchVds, fetchDashboardInfo, fetchPayments, fetchVdsOrderPricelist, fetchVdsOrderPricelistAllDatacenters } from './operations.js'
|
||||
export { syncFromBillmanager } from './sync.js'
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* BILLmanager API response → vps-tracker model mappers
|
||||
*/
|
||||
|
||||
import { parsePricelist } from './parsers.js'
|
||||
|
||||
/** BILLmanager VDS status: 1=ordered, 2=active, 3=suspended, 4=deleted, 5=processing */
|
||||
const VDS_STATUS_MAP = {
|
||||
1: 'active',
|
||||
2: 'active',
|
||||
3: 'paused',
|
||||
4: 'archived',
|
||||
5: 'active',
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} item - BILLmanager vds item
|
||||
* @returns {object} vps-tracker vps shape
|
||||
*/
|
||||
export function mapVdsToVps(item, providerId, providerAccountId) {
|
||||
const status = VDS_STATUS_MAP[Number(item.item_status_orig ?? item.item_status)] ?? 'active'
|
||||
// cost: "100.00 RUB / Месяц" — приоритет для ежемесячного платежа; item_cost — запасной
|
||||
const costStr = String(item.cost || '').replace(/[^\d.-]/g, '')
|
||||
const cost = parseFloat(costStr) || parseFloat(item.item_cost) || 0
|
||||
const createdate = item.createdate || ''
|
||||
const expiredate = item.real_expiredate || item.expiredate || ''
|
||||
const ip = (item.ip || '').trim()
|
||||
const domain = (item.domain || '').trim()
|
||||
const datacenter = (item.datacentername || item.datacenter || '').trim()
|
||||
const ostempl = (item.ostempl || '').trim()
|
||||
const currency = (item.currency_str || 'RUB').toString().trim() || 'RUB'
|
||||
|
||||
const pricelist = item.pricelist || item.tariff || item.plan || ''
|
||||
const parsed = parsePricelist(pricelist)
|
||||
|
||||
return {
|
||||
externalId: String(item.id || ''),
|
||||
ip: ip || domain || `bm-${item.id}`,
|
||||
dns: domain || ip || '',
|
||||
ipv6: '',
|
||||
additionalIps: [],
|
||||
providerId,
|
||||
providerAccountId,
|
||||
country: '',
|
||||
city: '',
|
||||
datacenter,
|
||||
os: ostempl,
|
||||
vcpu: parsed.vcpu || 0,
|
||||
ramGb: parsed.ramGb || 0,
|
||||
diskGb: parsed.diskGb || 0,
|
||||
diskType: parsed.diskType || 'NVMe',
|
||||
virtualization: parsed.virtualization || 'KVM',
|
||||
bandwidthTb: 0,
|
||||
sshPort: 22,
|
||||
rootUser: 'root',
|
||||
purpose: '',
|
||||
environment: 'prod',
|
||||
project: '',
|
||||
monitoringEnabled: false,
|
||||
backupEnabled: false,
|
||||
status,
|
||||
tariffType: 'monthly',
|
||||
currency: currency || 'RUB',
|
||||
dailyRate: null,
|
||||
monthlyRate: cost || null,
|
||||
createdAt: createdate ? createdate.slice(0, 10) : new Date().toISOString().slice(0, 10),
|
||||
paidUntil: expiredate ? expiredate.slice(0, 10) : '',
|
||||
notes: `bm-${item.id}`,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} item - BILLmanager payment item
|
||||
* @returns {object|null} vps-tracker payment shape or null if not credited
|
||||
*/
|
||||
export function mapPaymentToPayment(item, providerAccountId) {
|
||||
const rawAmount = item.subaccountamount_iso || item.paymethodamount_iso || '0'
|
||||
const amount = parseFloat(String(rawAmount).replace(/[^\d.-]/g, '')) || 0
|
||||
const createDate = item.create_date || item.createdate || ''
|
||||
const dateStr = createDate ? String(createDate).slice(0, 10) : new Date().toISOString().slice(0, 10)
|
||||
// status_orig / real_status = "4" (credited), item.status может быть "Зачислен"
|
||||
const statusNum = Number(item.status_orig ?? item.real_status ?? item.status)
|
||||
if (statusNum !== 4) return null
|
||||
return {
|
||||
externalId: String(item.id || ''),
|
||||
type: 'provider_balance_topup',
|
||||
date: dateStr,
|
||||
amount,
|
||||
currency: (String(item.subaccountamount_iso || item.paymethodamount_iso || '').match(/([A-Z]{3})\b/) || [])[1] || 'USD',
|
||||
providerAccountId,
|
||||
vpsId: null,
|
||||
note: `BILLmanager #${item.number || item.id}`,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* BILLmanager API operations — fetch VDS, payments, dashboard, tariffs
|
||||
*/
|
||||
|
||||
import { billmanagerRequest } from './client.js'
|
||||
import { extractList, elemToObject, parseTariffDesc, parseDatacenterName, extractTariflist } from './parsers.js'
|
||||
|
||||
/**
|
||||
* @param {string} baseUrl
|
||||
* @param {string} authinfo
|
||||
* @returns {Promise<object[]>} list of vps objects (raw BILLmanager format)
|
||||
*/
|
||||
export async function fetchVds(baseUrl, authinfo) {
|
||||
const data = await billmanagerRequest(baseUrl, authinfo, 'vds')
|
||||
const elems = extractList(data, 'vds')
|
||||
return elems.map((e) => elemToObject(e))
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить текущий баланс аккаунта (dashboard.info)
|
||||
* @param {string} baseUrl - e.g. https://billing.example.com/billmgr
|
||||
* @param {string} authinfo - username:password
|
||||
* @param {object} [opts] - { fallbackCurrency } — валюта аккаунта, если API не возвращает
|
||||
* @returns {Promise<{ balance: number, currency: string, enoughmoneyto?: string, realbalance?: string }>}
|
||||
*/
|
||||
export async function fetchDashboardInfo(baseUrl, authinfo, opts = {}) {
|
||||
const data = await billmanagerRequest(baseUrl, authinfo, 'dashboard.info', {
|
||||
dashboard: 'info',
|
||||
sfrom: 'ajax',
|
||||
})
|
||||
const elems = extractList(data, 'dashboard') || (Array.isArray(data.elem) ? data.elem : [])
|
||||
const item = elems.length > 0 ? elemToObject(elems[0]) : {}
|
||||
const balanceStr = String(item.realbalance || item.balance || item.available || '0')
|
||||
const amount = parseFloat(balanceStr.replace(/[^\d.,-]/g, '').replace(',', '.')) || 0
|
||||
let currency = (balanceStr.match(/([A-Z]{3})\b/) || [])[1]
|
||||
if (!currency) {
|
||||
if (balanceStr.includes('€') || balanceStr.includes('EUR')) currency = 'EUR'
|
||||
else if (balanceStr.includes('$') || balanceStr.includes('USD')) currency = 'USD'
|
||||
else if (balanceStr.includes('₽') || balanceStr.includes('RUB')) currency = 'RUB'
|
||||
else if (balanceStr.includes('£')) currency = 'GBP'
|
||||
else currency = opts.fallbackCurrency || 'RUB'
|
||||
}
|
||||
return {
|
||||
balance: amount,
|
||||
currency: currency || opts.fallbackCurrency || 'RUB',
|
||||
enoughmoneyto: item.enoughmoneyto || '',
|
||||
realbalance: item.realbalance || '',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} baseUrl - e.g. https://billing.example.com/billmgr
|
||||
* @param {string} authinfo - username:password
|
||||
* @param {object} [opts] - { createdatestart, createdateend } — опционально, не все API поддерживают
|
||||
* @returns {Promise<object[]>} list of payment objects
|
||||
*/
|
||||
export async function fetchPayments(baseUrl, authinfo, opts = {}) {
|
||||
const params = {}
|
||||
if (opts.createdatestart) params.createdatestart = opts.createdatestart
|
||||
if (opts.createdateend) params.createdateend = opts.createdateend
|
||||
if (opts.createdate === 'other') params.createdate = 'other'
|
||||
if (opts.filter === 'on') params.filter = 'on'
|
||||
if (opts.status != null) params.status = String(opts.status)
|
||||
const data = await billmanagerRequest(baseUrl, authinfo, 'payment', params)
|
||||
const elems = extractList(data, 'payment')
|
||||
return elems.map((e) => elemToObject(e))
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить список активных тарифов для заказа VDS (vds.order) для одного датацентра
|
||||
* @param {string} baseUrl
|
||||
* @param {string} authinfo
|
||||
* @param {object} [opts] - { plid, period, datacenter }
|
||||
* @returns {Promise<{ tariffItems: object[], slist: object }>}
|
||||
*/
|
||||
export async function fetchVdsOrderPricelist(baseUrl, authinfo, opts = {}) {
|
||||
const params = {
|
||||
plid: opts.plid || '',
|
||||
sfrom: 'ajax',
|
||||
}
|
||||
if (opts.period) params.period = opts.period
|
||||
if (opts.datacenter) params.datacenter = opts.datacenter
|
||||
|
||||
const data = await billmanagerRequest(baseUrl, authinfo, 'vds.order', params)
|
||||
|
||||
const tariflist = extractTariflist(data)
|
||||
const slist = data?.list?.slist ?? data?.slist ?? {}
|
||||
if (tariflist.length === 0) return { tariffItems: [], slist }
|
||||
|
||||
const tariffItems = tariflist.map((rawItem) => {
|
||||
const item = elemToObject(rawItem)
|
||||
const parsed = parseTariffDesc(item.desc || '')
|
||||
const descClean = (item.desc || '').replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim()
|
||||
const name = parsed.name || parsed.cpuModel || item.price?.split(' ')[0] || '—'
|
||||
return {
|
||||
externalId: String(item.pricelist || ''),
|
||||
name,
|
||||
desc: descClean,
|
||||
vcpu: parsed.vcpu,
|
||||
ramGb: parsed.ramGb,
|
||||
diskGb: parsed.diskGb,
|
||||
diskType: parsed.diskType,
|
||||
virtualization: parsed.virtualization,
|
||||
channel: parsed.channel,
|
||||
location: parsed.location,
|
||||
cpuModel: parsed.cpuModel,
|
||||
orderAvailable: (item.order_available || '').toLowerCase() === 'on',
|
||||
price: item.price || '',
|
||||
}
|
||||
})
|
||||
|
||||
return { tariffItems, slist }
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить тарифы по всем датацентрам — отдельный запрос на каждый ДЦ.
|
||||
* @param {string} baseUrl
|
||||
* @param {string} authinfo
|
||||
* @returns {Promise<{ tariffItems: object[], slist: object }>}
|
||||
*/
|
||||
export async function fetchVdsOrderPricelistAllDatacenters(baseUrl, authinfo) {
|
||||
const initial = await fetchVdsOrderPricelist(baseUrl, authinfo)
|
||||
const slist = initial.slist || {}
|
||||
const datacenters = Array.isArray(slist.datacenter) ? slist.datacenter : []
|
||||
|
||||
if (datacenters.length === 0) {
|
||||
return { tariffItems: initial.tariffItems, slist }
|
||||
}
|
||||
|
||||
const allTariffItems = []
|
||||
|
||||
for (let i = 0; i < datacenters.length; i++) {
|
||||
const dc = datacenters[i]
|
||||
const dcKey = String(dc.k ?? dc.key ?? '')
|
||||
const dcName = String(dc.v ?? dc.value ?? dc.name ?? '')
|
||||
const { country, location } = parseDatacenterName(dcName)
|
||||
|
||||
const result = i === 0 ? initial : await fetchVdsOrderPricelist(baseUrl, authinfo, { datacenter: dcKey })
|
||||
for (const t of result.tariffItems) {
|
||||
allTariffItems.push({
|
||||
...t,
|
||||
datacenterKey: dcKey,
|
||||
datacenterName: dcName,
|
||||
country,
|
||||
location: location || dcName,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return { tariffItems: allTariffItems, slist }
|
||||
}
|
||||
|
||||
/**
|
||||
* Test API connection — запрос списка VDS
|
||||
* @param {string} baseUrl
|
||||
* @param {string} authinfo - username:password
|
||||
* @returns {Promise<{ ok: boolean, error?: string, vdsCount?: number }>}
|
||||
*/
|
||||
export async function testConnection(baseUrl, authinfo) {
|
||||
if (!baseUrl?.trim() || !authinfo?.trim()) {
|
||||
return { ok: false, error: 'Укажите URL и учётные данные' }
|
||||
}
|
||||
try {
|
||||
const items = await fetchVds(baseUrl.trim(), authinfo.trim())
|
||||
return { ok: true, vdsCount: items?.length ?? 0 }
|
||||
} catch (err) {
|
||||
return { ok: false, error: err.message || 'Ошибка подключения' }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
/**
|
||||
* BILLmanager API response parsers
|
||||
*/
|
||||
|
||||
/**
|
||||
* Parse BILLmanager JSON list response.
|
||||
* bjson: { elem: [ {...}, {...} ] } или { data: { elem: [...] } }
|
||||
* xml/json: { doc: { vds: { elem: [...] } } }
|
||||
*/
|
||||
export function extractList(data, key) {
|
||||
if (Array.isArray(data.elem)) return data.elem
|
||||
if (data.data?.elem) return Array.isArray(data.data.elem) ? data.data.elem : [data.data.elem]
|
||||
const doc = data.doc || data
|
||||
let list = doc[key]
|
||||
if (!list) return []
|
||||
if (Array.isArray(list)) return list
|
||||
if (list.elem) {
|
||||
const elems = Array.isArray(list.elem) ? list.elem : [list.elem]
|
||||
return elems
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract flat object from BILLmanager elem (can be array of { $name, $t } or already flat object)
|
||||
*/
|
||||
export function elemToObject(elem) {
|
||||
if (!elem) return {}
|
||||
if (Array.isArray(elem)) {
|
||||
const obj = {}
|
||||
for (const e of elem) {
|
||||
const name = e.$name || e.name
|
||||
const val = e.$t ?? e.$ ?? e
|
||||
if (name) obj[name] = typeof val === 'object' && val !== null ? (val.$t ?? val.$ ?? JSON.stringify(val)) : val
|
||||
}
|
||||
return obj
|
||||
}
|
||||
return typeof elem === 'object' ? elem : {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse pricelist string: "KVM SSD Start (1 CPU/768 MB RAM/7 GB SSD)"
|
||||
* @returns {{ vcpu: number, ramGb: number, diskGb: number, diskType: string, virtualization: string }}
|
||||
*/
|
||||
export function parsePricelist(pricelist) {
|
||||
const s = String(pricelist || '')
|
||||
let vcpu = 0
|
||||
let ramGb = 0
|
||||
let diskGb = 0
|
||||
let diskType = 'NVMe'
|
||||
let virtualization = 'KVM'
|
||||
|
||||
const cpuMatch = s.match(/(\d+)\s*(?:CPU|СPU)/i)
|
||||
if (cpuMatch) vcpu = parseInt(cpuMatch[1], 10) || 0
|
||||
|
||||
const ramMbMatch = s.match(/(\d+)\s*MB\s*RAM/i)
|
||||
if (ramMbMatch) ramGb = Math.max(1, Math.round((parseInt(ramMbMatch[1], 10) || 0) / 1024))
|
||||
else {
|
||||
const ramGbMatch = s.match(/(\d+)\s*GB\s*RAM/i)
|
||||
if (ramGbMatch) ramGb = parseInt(ramGbMatch[1], 10) || 0
|
||||
}
|
||||
|
||||
const diskMatch = s.match(/(\d+)\s*GB\s*(SSD|NVMe|HDD)/i)
|
||||
if (diskMatch) {
|
||||
diskGb = parseInt(diskMatch[1], 10) || 0
|
||||
diskType = diskMatch[2] || 'NVMe'
|
||||
}
|
||||
|
||||
if (/\bKVM\b/i.test(s)) virtualization = 'KVM'
|
||||
else if (/\bOpenVZ\b/i.test(s)) virtualization = 'OpenVZ'
|
||||
else if (/\bLXC\b/i.test(s)) virtualization = 'LXC'
|
||||
|
||||
return { vcpu, ramGb, diskGb, diskType, virtualization }
|
||||
}
|
||||
|
||||
/**
|
||||
* Парсит описание тарифа из vds.order.pricelist (desc)
|
||||
* Поддерживает два формата: текстовый (Firstbyte) и HTML (Selectel и др.)
|
||||
* @param {string} desc - HTML или текстовое описание тарифа
|
||||
* @returns {{ name: string, vcpu: number, ramGb: number, diskGb: number, diskType: string, virtualization: string, channel: string, location: string, cpuModel: string }}
|
||||
*/
|
||||
export function parseTariffDesc(desc) {
|
||||
const s = String(desc || '')
|
||||
let name = ''
|
||||
let vcpu = 0
|
||||
let ramGb = 0
|
||||
let diskGb = 0
|
||||
let diskType = 'SSD'
|
||||
let virtualization = 'KVM'
|
||||
let channel = ''
|
||||
let location = ''
|
||||
let cpuModel = ''
|
||||
|
||||
const firstLine = s.split(/\r?\n|<br\s*\/?>/i)[0]?.trim() || ''
|
||||
name = firstLine.replace(/<[^>]+>/g, '').trim()
|
||||
|
||||
// Формат 1: "Процессор: 1 ядро", "Память: 1 GB", "Диск: 20 GB SSD", "Канал: 200Mb/s"
|
||||
const cpuMatch = s.match(/Процессор:\s*(\d+)\s*(?:ядро|ядра|ядер)/i)
|
||||
if (cpuMatch) vcpu = parseInt(cpuMatch[1], 10) || 0
|
||||
|
||||
const ramMbMatch = s.match(/Память:\s*(\d+)\s*MB/i)
|
||||
if (ramMbMatch) ramGb = Math.max(0.5, Math.round((parseInt(ramMbMatch[1], 10) || 0) / 1024 * 10) / 10)
|
||||
else {
|
||||
const ramGbMatch = s.match(/Память:\s*(\d+)\s*GB/i)
|
||||
if (ramGbMatch) ramGb = parseInt(ramGbMatch[1], 10) || 0
|
||||
}
|
||||
|
||||
const diskMatch = s.match(/Диск:\s*(\d+)\s*GB\s*(SSD|SAS|HDD|NVMe)/i)
|
||||
if (diskMatch) {
|
||||
diskGb = parseInt(diskMatch[1], 10) || 0
|
||||
diskType = diskMatch[2] || 'SSD'
|
||||
}
|
||||
|
||||
const channelMatch = s.match(/Канал:\s*(\d+Mb\/s)/i)
|
||||
if (channelMatch) channel = channelMatch[1]
|
||||
|
||||
if (/\bKVM\b/i.test(s)) virtualization = 'KVM'
|
||||
else if (/\bOpenVZ\b/i.test(s)) virtualization = 'OpenVZ'
|
||||
else if (/\bLXC\b/i.test(s)) virtualization = 'LXC'
|
||||
|
||||
// Формат 2 (HTML): "Публичная сеть: 250 Мбит/с", "Локация: Москва, Россия", "Процессор: Ryzen 7 5800X", "NVMe накопитель"
|
||||
if (!channel) {
|
||||
const netMatch = s.match(/(?:Публичная сеть|Канал)[:\s]*\*?\*?(\d+)\s*Мбит/i)
|
||||
if (netMatch) channel = `${netMatch[1]} Мбит/с`
|
||||
}
|
||||
if (!location) {
|
||||
const locMatch = s.match(/Локация[:\s]*([^;]+)/i)
|
||||
if (locMatch) location = locMatch[1].replace(/<[^>]+>/g, '').trim()
|
||||
}
|
||||
if (!cpuModel) {
|
||||
const procMatch = s.match(/Процессор[:\s]*([^;]+?)(?:\s+до\s|$)/i)
|
||||
if (procMatch) cpuModel = procMatch[1].replace(/<[^>]+>/g, '').trim()
|
||||
}
|
||||
if (!diskType || diskType === 'SSD') {
|
||||
if (/\bNVMe\b/i.test(s)) diskType = 'NVMe'
|
||||
else if (/\bSAS\b/i.test(s)) diskType = 'SAS'
|
||||
else if (/\bHDD\b/i.test(s)) diskType = 'HDD'
|
||||
else if (/\bSSD\b/i.test(s)) diskType = 'SSD'
|
||||
}
|
||||
|
||||
return { name, vcpu, ramGb, diskGb, diskType, virtualization, channel, location, cpuModel }
|
||||
}
|
||||
|
||||
/**
|
||||
* Парсит название датацентра для извлечения страны и локации
|
||||
* @param {string} dcName - название ДЦ, напр. "1 Датацентр Россия, Москва", "[DE] Франкфурт", "Франция"
|
||||
* @returns {{ country: string, location: string }}
|
||||
*/
|
||||
export function parseDatacenterName(dcName) {
|
||||
const s = String(dcName || '').trim()
|
||||
if (!s) return { country: '', location: '' }
|
||||
|
||||
const COUNTRY_CODE_MAP = {
|
||||
DE: 'Германия',
|
||||
FI: 'Финляндия',
|
||||
RU: 'Россия',
|
||||
FR: 'Франция',
|
||||
GB: 'Великобритания',
|
||||
NL: 'Нидерланды',
|
||||
US: 'США',
|
||||
SE: 'Швеция',
|
||||
NO: 'Норвегия',
|
||||
BE: 'Бельгия',
|
||||
CH: 'Швейцария',
|
||||
CZ: 'Чехия',
|
||||
CA: 'Канада',
|
||||
LV: 'Латвия',
|
||||
LT: 'Литва',
|
||||
EE: 'Эстония',
|
||||
PL: 'Польша',
|
||||
IT: 'Италия',
|
||||
DK: 'Дания',
|
||||
AU: 'Австралия',
|
||||
ES: 'Испания',
|
||||
SG: 'Сингапур',
|
||||
}
|
||||
|
||||
// "[DE] Франкфурт | AMD EPYC" -> country: DE/Германия, location: Франкфурт
|
||||
const codeMatch = s.match(/\[([A-Z]{2})\]\s*([^|]+)/)
|
||||
if (codeMatch) {
|
||||
const code = codeMatch[1]
|
||||
const loc = codeMatch[2].trim()
|
||||
return { country: COUNTRY_CODE_MAP[code] || code, location: loc }
|
||||
}
|
||||
|
||||
// "N Датацентр Страна, Город" или "Датацентр Страна, Город"
|
||||
const dcMatch = s.match(/(?:\d+\s+)?Датацентр\s+([^,]+),\s*(.+)/i)
|
||||
if (dcMatch) {
|
||||
return { country: dcMatch[1].trim(), location: dcMatch[2].trim() }
|
||||
}
|
||||
|
||||
// "Страна, Город" (без слова Датацентр)
|
||||
const commaMatch = s.match(/^([^,]+),\s*(.+)$/)
|
||||
if (commaMatch) {
|
||||
return { country: commaMatch[1].trim(), location: commaMatch[2].trim() }
|
||||
}
|
||||
|
||||
// Только страна: "Франция", "Россия", "Германия", "Чехия" и т.д.
|
||||
const countryOnly = [
|
||||
'Россия', 'Чехия', 'Нидерланды', 'Франция', 'Великобритания', 'Германия',
|
||||
'Финляндия', 'Швеция', 'Норвегия', 'Бельгия', 'Швейцария', 'Канада',
|
||||
'Латвия', 'Литва', 'Эстония', 'Польша', 'Италия', 'Дания', 'Австралия',
|
||||
'Испания', 'Сингапур', 'США', 'Азия', 'Европа',
|
||||
]
|
||||
for (const c of countryOnly) {
|
||||
if (s === c || s.startsWith(c + ',') || s.startsWith(c + ' ')) {
|
||||
const rest = s.slice(c.length).replace(/^[,\s]+/, '')
|
||||
return { country: c, location: rest }
|
||||
}
|
||||
}
|
||||
|
||||
// "ММТС-9", "Adman", "Европа DC1", "Москва DC3" — по ключевым словам
|
||||
if (/ММТС|Adman|Москва/i.test(s)) {
|
||||
return { country: 'Россия', location: s }
|
||||
}
|
||||
if (/Европа/i.test(s)) {
|
||||
return { country: 'Европа', location: s.replace(/Европа\s*/i, '').trim() || s }
|
||||
}
|
||||
|
||||
// "США | Ryzen 9 9950X" — страна до |
|
||||
const pipeMatch = s.match(/^([^|]+)\s*\|/)
|
||||
if (pipeMatch) {
|
||||
const part = pipeMatch[1].trim()
|
||||
for (const c of countryOnly) {
|
||||
if (part.includes(c)) return { country: c, location: '' }
|
||||
}
|
||||
return { country: part, location: '' }
|
||||
}
|
||||
|
||||
return { country: s, location: '' }
|
||||
}
|
||||
|
||||
/**
|
||||
* Извлечь tariflist из ответа vds.order (поддержка разных форматов BILLmanager)
|
||||
* @param {object} data - сырой ответ API
|
||||
* @returns {Array}
|
||||
*/
|
||||
export function extractTariflist(data) {
|
||||
if (!data) return []
|
||||
const listNode = data.list ?? data.doc?.list ?? data.doc
|
||||
if (!listNode) return []
|
||||
|
||||
// tariflist / tarifflist / pricelist — разные варианты названия
|
||||
let list = listNode.tariflist ?? listNode.tarifflist ?? listNode.pricelist
|
||||
if (Array.isArray(list)) return list
|
||||
|
||||
// elem-формат (как в vds, payment)
|
||||
const elems = listNode.elem
|
||||
if (elems) return Array.isArray(elems) ? elems : [elems]
|
||||
|
||||
return []
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
/**
|
||||
* Sync BILLmanager data into vps-tracker DB
|
||||
*/
|
||||
|
||||
import { fetchVds, fetchPayments, fetchDashboardInfo, fetchVdsOrderPricelistAllDatacenters } from './operations.js'
|
||||
import { mapVdsToVps, mapPaymentToPayment } from './mappers.js'
|
||||
|
||||
/**
|
||||
* Sync BILLmanager data into vps-tracker DB
|
||||
* @param {object} account - provider_account с apiCredentials и apiBaseUrl (URL с хостера или уже подставленный)
|
||||
* @param {object} db - getDb() wrapper
|
||||
* @param {object} [opts] - { paymentDaysBack, skipTariffs, skipVpsPayments }
|
||||
* @returns {{ vpsCount: number, paymentsCount: number, tariffsCount: number, balance?: object }}
|
||||
*/
|
||||
export async function syncFromBillmanager(account, db, opts = {}) {
|
||||
const { skipTariffs = false, skipVpsPayments = false } = opts
|
||||
const { apiBaseUrl, apiCredentials, providerId, id: accountId } = account
|
||||
if (!apiBaseUrl?.trim() || !apiCredentials?.trim()) {
|
||||
throw new Error('API URL and credentials are required')
|
||||
}
|
||||
const authinfo = apiCredentials.trim()
|
||||
|
||||
const fetchVpsPayments = !skipVpsPayments
|
||||
const fetchTariffs = !skipTariffs
|
||||
|
||||
const [vdsItems, paymentItems, dashboardInfo, tariffResult] = await Promise.all([
|
||||
fetchVpsPayments ? fetchVds(apiBaseUrl, authinfo) : [],
|
||||
fetchVpsPayments ? fetchPayments(apiBaseUrl, authinfo, {}) : [],
|
||||
fetchVpsPayments ? fetchDashboardInfo(apiBaseUrl, authinfo, { fallbackCurrency: account.currency }).catch(() => null) : null,
|
||||
fetchTariffs ? fetchVdsOrderPricelistAllDatacenters(apiBaseUrl, authinfo).catch((err) => {
|
||||
console.warn('fetchVdsOrderPricelistAllDatacenters failed:', err.message)
|
||||
return { tariffItems: [], slist: {} }
|
||||
}) : { tariffItems: [], slist: {} },
|
||||
])
|
||||
const { tariffItems = [], slist = {} } = tariffResult || {}
|
||||
|
||||
let vpsCount = 0
|
||||
/** @type {{ added: { id: string, label: string }[], updated: { id: string, label: string, fields: string[] }[], paymentsAdded: number }} */
|
||||
const syncSummary = { added: [], updated: [], paymentsAdded: 0 }
|
||||
if (fetchVpsPayments) {
|
||||
const vpsInsertSql = `INSERT INTO vps (id, ip, ipv6, additionalIps, dns, providerId, providerAccountId, country, city, datacenter, os, vcpu, ramGb, diskGb, diskType, virtualization, bandwidthTb, sshPort, rootUser, purpose, environment, project, projectId, monitoringEnabled, backupEnabled, status, tariffType, currency, dailyRate, monthlyRate, createdAt, paidUntil, notes, userOverrides)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
const vpsUpdateSql = `UPDATE vps SET ip=?, ipv6=?, additionalIps=?, dns=?, country=?, city=?, datacenter=?, os=?, status=?, tariffType=?, currency=?, dailyRate=?, monthlyRate=?, paidUntil=?, notes=?
|
||||
WHERE id=?`
|
||||
|
||||
const SYNC_UPDATE_FIELDS = ['country', 'city', 'datacenter', 'os', 'notes', 'status', 'tariffType', 'currency', 'dailyRate', 'monthlyRate', 'paidUntil']
|
||||
|
||||
const normVal = (v) => {
|
||||
if (v == null || v === '') return ''
|
||||
if (typeof v === 'number') return Number.isFinite(v) ? String(v) : ''
|
||||
return String(v)
|
||||
}
|
||||
|
||||
for (const item of vdsItems) {
|
||||
const vps = mapVdsToVps(item, providerId, accountId)
|
||||
const id = `vps-bm-${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 ? `${vps.notes} [bm-${vps.externalId}]` : `bm-${vps.externalId}`
|
||||
|
||||
const existing = db.prepare('SELECT * FROM vps WHERE providerAccountId = ? AND (ip = ? OR notes LIKE ?)').get(accountId, vps.ip, `%bm-${vps.externalId}%`)
|
||||
if (existing) {
|
||||
let userOverrides = []
|
||||
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,
|
||||
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]
|
||||
}
|
||||
}
|
||||
const compareFields = ['ip', 'ipv6', 'dns', ...SYNC_UPDATE_FIELDS]
|
||||
const changedFields = compareFields.filter(
|
||||
(f) => normVal(merged[f]) !== normVal(existing[f]),
|
||||
)
|
||||
if (changedFields.length > 0) {
|
||||
const label = merged.dns || merged.ip || existing.id
|
||||
syncSummary.updated.push({ id: existing.id, label, fields: changedFields })
|
||||
}
|
||||
db.run(vpsUpdateSql,
|
||||
merged.ip,
|
||||
merged.ipv6,
|
||||
merged.additionalIps,
|
||||
merged.dns,
|
||||
merged.country,
|
||||
merged.city,
|
||||
merged.datacenter,
|
||||
merged.os,
|
||||
merged.status,
|
||||
merged.tariffType,
|
||||
merged.currency,
|
||||
merged.dailyRate,
|
||||
merged.monthlyRate,
|
||||
merged.paidUntil,
|
||||
merged.notes,
|
||||
existing.id,
|
||||
)
|
||||
} else {
|
||||
const label = vps.dns || vps.ip || id
|
||||
syncSummary.added.push({ id, label })
|
||||
db.run(vpsInsertSql,
|
||||
id,
|
||||
vps.ip,
|
||||
vps.ipv6,
|
||||
additionalIps,
|
||||
vps.dns,
|
||||
vps.providerId,
|
||||
vps.providerAccountId,
|
||||
vps.country,
|
||||
vps.city,
|
||||
vps.datacenter,
|
||||
vps.os,
|
||||
vps.vcpu,
|
||||
vps.ramGb,
|
||||
vps.diskGb,
|
||||
vps.diskType,
|
||||
vps.virtualization,
|
||||
vps.bandwidthTb,
|
||||
vps.sshPort,
|
||||
vps.rootUser,
|
||||
vps.purpose,
|
||||
vps.environment,
|
||||
vps.project,
|
||||
null,
|
||||
vps.monitoringEnabled ? 1 : 0,
|
||||
vps.backupEnabled ? 1 : 0,
|
||||
vps.status,
|
||||
vps.tariffType,
|
||||
vps.currency,
|
||||
dailyRate,
|
||||
monthlyRate,
|
||||
vps.createdAt,
|
||||
paidUntil,
|
||||
notes,
|
||||
'[]',
|
||||
)
|
||||
}
|
||||
vpsCount++
|
||||
}
|
||||
}
|
||||
|
||||
let paymentsCount = 0
|
||||
if (fetchVpsPayments) {
|
||||
const existingPayments = new Set(
|
||||
db.prepare('SELECT note FROM payments WHERE providerAccountId = ?').all(accountId).map((r) => r.note),
|
||||
)
|
||||
const paymentInsertSql = `INSERT INTO payments (id, type, date, amount, currency, providerAccountId, vpsId, note)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
|
||||
for (const item of paymentItems) {
|
||||
const payment = mapPaymentToPayment(item, accountId)
|
||||
if (!payment || payment.amount <= 0) continue
|
||||
const note = payment.note
|
||||
if (existingPayments.has(note)) continue
|
||||
const id = `pay-bm-${accountId}-${payment.externalId}`
|
||||
db.run(paymentInsertSql, id, payment.type, payment.date, payment.amount, payment.currency, payment.providerAccountId, payment.vpsId, note)
|
||||
existingPayments.add(note)
|
||||
paymentsCount++
|
||||
syncSummary.paymentsAdded += 1
|
||||
}
|
||||
}
|
||||
|
||||
if (fetchVpsPayments && dashboardInfo) {
|
||||
db.run(
|
||||
'UPDATE provider_accounts SET balance_api=?, balance_currency=?, balance_updated_at=?, enoughmoneyto=? WHERE id=?',
|
||||
dashboardInfo.balance,
|
||||
dashboardInfo.currency || 'RUB',
|
||||
new Date().toISOString(),
|
||||
dashboardInfo.enoughmoneyto || '',
|
||||
accountId,
|
||||
)
|
||||
}
|
||||
|
||||
let tariffsCount = 0
|
||||
let newTariffs = []
|
||||
if (fetchTariffs) {
|
||||
const existingTariffIds = new Set(
|
||||
db.prepare('SELECT id FROM active_tariffs WHERE providerAccountId = ?').all(accountId).map((r) => r.id),
|
||||
)
|
||||
const syncedAt = new Date().toISOString()
|
||||
db.run('DELETE FROM active_tariffs WHERE providerAccountId = ?', accountId)
|
||||
const tariffInsertSql = `INSERT INTO active_tariffs (id, providerAccountId, providerId, externalId, datacenterKey, datacenterName, name, desc, vcpu, ramGb, diskGb, diskType, virtualization, channel, location, country, cpuModel, orderAvailable, price, syncedAt)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
|
||||
for (const t of tariffItems) {
|
||||
const dcKey = t.datacenterKey ?? ''
|
||||
const dcName = t.datacenterName ?? ''
|
||||
const id = dcKey ? `tariff-bm-${accountId}-${t.externalId}-${dcKey}` : `tariff-bm-${accountId}-${t.externalId}`
|
||||
if (!existingTariffIds.has(id)) {
|
||||
newTariffs.push({ name: t.name || '', price: t.price || '', providerId })
|
||||
}
|
||||
db.run(tariffInsertSql,
|
||||
id,
|
||||
accountId,
|
||||
providerId,
|
||||
t.externalId,
|
||||
dcKey,
|
||||
dcName,
|
||||
t.name || '',
|
||||
t.desc || '',
|
||||
t.vcpu || 0,
|
||||
t.ramGb || 0,
|
||||
t.diskGb || 0,
|
||||
t.diskType || 'SSD',
|
||||
t.virtualization || 'KVM',
|
||||
t.channel || '',
|
||||
t.location || '',
|
||||
t.country || '',
|
||||
t.cpuModel || '',
|
||||
t.orderAvailable ? 1 : 0,
|
||||
t.price || '',
|
||||
syncedAt,
|
||||
)
|
||||
tariffsCount++
|
||||
}
|
||||
|
||||
if (Object.keys(slist).length > 0) {
|
||||
const datacenters = Array.isArray(slist.datacenter) ? JSON.stringify(slist.datacenter) : '[]'
|
||||
const periods = Array.isArray(slist.period) ? JSON.stringify(slist.period) : '[]'
|
||||
db.run(
|
||||
`INSERT OR REPLACE INTO tariff_sync_options (providerAccountId, datacenters, periods, syncedAt) VALUES (?, ?, ?, ?)`,
|
||||
accountId,
|
||||
datacenters,
|
||||
periods,
|
||||
syncedAt,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (!fetchVpsPayments) {
|
||||
syncSummary.tariffsOnly = true
|
||||
}
|
||||
return { vpsCount, paymentsCount, tariffsCount, newTariffs, balance: dashboardInfo, syncSummary }
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
/**
|
||||
* Re-export from db module
|
||||
*/
|
||||
export { initDb, getDb, saveDb, DB_PATH, reloadDatabaseFromBuffer } from './db/index.js'
|
||||
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* Database initialization and access
|
||||
*/
|
||||
|
||||
import initSqlJs from 'sql.js'
|
||||
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs'
|
||||
import { join, dirname } from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
|
||||
import { SCHEMA } from './schema.js'
|
||||
import { MIGRATIONS } from './migrations.js'
|
||||
import { seed, isDbEmpty } from './seed.js'
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
// db/ is in server/, so .. = server, .. again = project root
|
||||
export const DB_PATH = join(__dirname, '..', '..', 'data', 'vps-tracker.db')
|
||||
const SEED_DIR = join(__dirname, '..', '..', 'public', 'data')
|
||||
|
||||
let dbInstance = null
|
||||
/** @type {import('sql.js').SqlJsStatic | null} */
|
||||
let sqlJsFactory = null
|
||||
|
||||
export async function initDb() {
|
||||
const dataDir = join(__dirname, '..', '..', 'data')
|
||||
if (!existsSync(dataDir)) {
|
||||
mkdirSync(dataDir, { recursive: true })
|
||||
}
|
||||
|
||||
const SQL = await initSqlJs()
|
||||
sqlJsFactory = SQL
|
||||
let db
|
||||
|
||||
if (existsSync(DB_PATH)) {
|
||||
const fileBuffer = readFileSync(DB_PATH)
|
||||
db = new SQL.Database(fileBuffer)
|
||||
} else {
|
||||
db = new SQL.Database()
|
||||
}
|
||||
|
||||
db.exec(SCHEMA)
|
||||
|
||||
for (const m of MIGRATIONS) {
|
||||
try {
|
||||
m.run(db)
|
||||
} catch (err) {
|
||||
console.warn(`Migration ${m.name} failed:`, err.message)
|
||||
}
|
||||
}
|
||||
|
||||
if (isDbEmpty(db)) {
|
||||
seed(db, SEED_DIR)
|
||||
const data = db.export()
|
||||
writeFileSync(DB_PATH, Buffer.from(data))
|
||||
}
|
||||
|
||||
dbInstance = db
|
||||
if (existsSync(DB_PATH)) {
|
||||
saveDb()
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
export function getDb() {
|
||||
if (!dbInstance) throw new Error('Database not initialized')
|
||||
return createDbWrapper(dbInstance)
|
||||
}
|
||||
|
||||
function createDbWrapper(db) {
|
||||
return {
|
||||
/** One-off run: prepare, bind, step, free. Use for statements that run once. */
|
||||
run(sql, ...params) {
|
||||
const stmt = db.prepare(sql)
|
||||
stmt.bind(params)
|
||||
stmt.step()
|
||||
const changes = db.getRowsModified()
|
||||
stmt.free()
|
||||
saveDb()
|
||||
return { changes }
|
||||
},
|
||||
prepare(sql) {
|
||||
const stmt = db.prepare(sql)
|
||||
return {
|
||||
all(...params) {
|
||||
stmt.bind(params)
|
||||
const rows = []
|
||||
while (stmt.step()) {
|
||||
rows.push(stmt.getAsObject())
|
||||
}
|
||||
stmt.free()
|
||||
return rows
|
||||
},
|
||||
get(...params) {
|
||||
stmt.bind(params)
|
||||
const row = stmt.step() ? stmt.getAsObject() : null
|
||||
stmt.free()
|
||||
return row
|
||||
},
|
||||
run(...params) {
|
||||
stmt.bind(params)
|
||||
stmt.step()
|
||||
const changes = db.getRowsModified()
|
||||
stmt.free()
|
||||
saveDb()
|
||||
return { changes }
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function saveDb() {
|
||||
if (!dbInstance) return
|
||||
const data = dbInstance.export()
|
||||
writeFileSync(DB_PATH, Buffer.from(data))
|
||||
}
|
||||
|
||||
/**
|
||||
* Заменить in-memory БД из буфера SQLite и сохранить на диск.
|
||||
* @param {Buffer|Uint8Array} buffer
|
||||
*/
|
||||
export async function reloadDatabaseFromBuffer(buffer) {
|
||||
const SQL = sqlJsFactory || (await initSqlJs())
|
||||
sqlJsFactory = SQL
|
||||
if (dbInstance) {
|
||||
dbInstance.close()
|
||||
dbInstance = null
|
||||
}
|
||||
const u8 = buffer instanceof Buffer ? new Uint8Array(buffer) : buffer
|
||||
dbInstance = new SQL.Database(u8)
|
||||
dbInstance.exec(SCHEMA)
|
||||
for (const m of MIGRATIONS) {
|
||||
try {
|
||||
m.run(dbInstance)
|
||||
} catch (err) {
|
||||
console.warn(`Migration ${m.name} after reload failed:`, err.message)
|
||||
}
|
||||
}
|
||||
saveDb()
|
||||
}
|
||||
@@ -0,0 +1,388 @@
|
||||
/**
|
||||
* Database migrations — add columns to existing tables
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'node:crypto'
|
||||
|
||||
/**
|
||||
* Перенос apiType/apiBaseUrl с аккаунтов на хостера (idempotent).
|
||||
* Вызывается из миграции и после импорта бэкапа / старого migrate API.
|
||||
* @param {import('sql.js').Database} db
|
||||
*/
|
||||
function selectAllObjects(db, sql, params = []) {
|
||||
const prepared = db.prepare(sql)
|
||||
if (typeof prepared.all === 'function') {
|
||||
return prepared.all(...params)
|
||||
}
|
||||
const stmt = prepared
|
||||
stmt.bind(params)
|
||||
const rows = []
|
||||
while (stmt.step()) {
|
||||
rows.push(stmt.getAsObject())
|
||||
}
|
||||
stmt.free()
|
||||
return rows
|
||||
}
|
||||
|
||||
export function consolidateProviderApiFromAccounts(db) {
|
||||
const provRows = selectAllObjects(db, 'SELECT id, apiType, apiBaseUrl FROM providers')
|
||||
for (const prov of provRows) {
|
||||
const pid = prov.id
|
||||
if (String(prov.apiType || '').trim() || String(prov.apiBaseUrl || '').trim()) {
|
||||
continue
|
||||
}
|
||||
const accRows = selectAllObjects(
|
||||
db,
|
||||
`SELECT apiBaseUrl FROM provider_accounts
|
||||
WHERE providerId = ?
|
||||
AND length(trim(COALESCE(apiBaseUrl, ''))) > 0
|
||||
AND (
|
||||
lower(trim(COALESCE(apiType, ''))) = 'billmanager'
|
||||
OR instr(lower(trim(COALESCE(apiBaseUrl, ''))), 'billmgr') > 0
|
||||
)
|
||||
ORDER BY id`,
|
||||
[pid],
|
||||
)
|
||||
if (!accRows.length) continue
|
||||
const urls = [...new Set(accRows.map((r) => String(r.apiBaseUrl || '').trim()).filter(Boolean))]
|
||||
if (urls.length > 1) {
|
||||
console.warn(
|
||||
`[vps-tracker] У хостера ${pid} у нескольких аккаунтов разный URL BILLmanager — в настройках хостера взят первый.`,
|
||||
)
|
||||
}
|
||||
const apiBaseUrl = urls[0]
|
||||
db.run(`UPDATE providers SET apiType = ?, apiBaseUrl = ? WHERE id = ?`, 'billmanager', apiBaseUrl, pid)
|
||||
db.run(
|
||||
`UPDATE provider_accounts SET apiType = '', apiBaseUrl = ''
|
||||
WHERE providerId = ?
|
||||
AND length(trim(COALESCE(apiBaseUrl, ''))) > 0
|
||||
AND (
|
||||
lower(trim(COALESCE(apiType, ''))) = 'billmanager'
|
||||
OR instr(lower(trim(COALESCE(apiBaseUrl, ''))), 'billmgr') > 0
|
||||
)`,
|
||||
pid,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Раньше URL BILLmanager часто указывали в «Сайт» хостера или в panelUrl аккаунта, без apiType/apiBaseUrl.
|
||||
*/
|
||||
export function heuristicBillmanagerProviderApi(db) {
|
||||
const tryBillmgrUrl = (raw) => {
|
||||
const t = String(raw || '').trim()
|
||||
if (!t) return ''
|
||||
if (!/^https?:\/\//i.test(t)) return ''
|
||||
if (!/billmgr/i.test(t)) return ''
|
||||
return t.replace(/\/+$/, '')
|
||||
}
|
||||
|
||||
const provRows = selectAllObjects(db, 'SELECT id, website, apiType, apiBaseUrl FROM providers')
|
||||
for (const prov of provRows) {
|
||||
if (String(prov.apiType || '').trim() || String(prov.apiBaseUrl || '').trim()) continue
|
||||
|
||||
let url = tryBillmgrUrl(prov.website)
|
||||
if (!url) {
|
||||
const accRows = selectAllObjects(
|
||||
db,
|
||||
`SELECT panelUrl FROM provider_accounts
|
||||
WHERE providerId = ? AND length(trim(COALESCE(panelUrl, ''))) > 0
|
||||
ORDER BY id`,
|
||||
[prov.id],
|
||||
)
|
||||
for (const row of accRows) {
|
||||
url = tryBillmgrUrl(row.panelUrl)
|
||||
if (url) break
|
||||
}
|
||||
}
|
||||
if (url) {
|
||||
db.run(`UPDATE providers SET apiType = ?, apiBaseUrl = ? WHERE id = ?`, 'billmanager', url, prov.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Перенос API на хостера: с аккаунтов + эвристика по website/panelUrl. */
|
||||
export function consolidateAllProviderApiSources(db) {
|
||||
consolidateProviderApiFromAccounts(db)
|
||||
heuristicBillmanagerProviderApi(db)
|
||||
}
|
||||
|
||||
export const MIGRATIONS = [
|
||||
{
|
||||
name: 'provider_accounts_api',
|
||||
run(db) {
|
||||
try {
|
||||
db.exec('ALTER TABLE provider_accounts ADD COLUMN apiType TEXT')
|
||||
} catch (e) {
|
||||
if (!e.message?.includes('duplicate column')) throw e
|
||||
}
|
||||
try {
|
||||
db.exec('ALTER TABLE provider_accounts ADD COLUMN apiBaseUrl TEXT')
|
||||
} catch (e) {
|
||||
if (!e.message?.includes('duplicate column')) throw e
|
||||
}
|
||||
try {
|
||||
db.exec('ALTER TABLE provider_accounts ADD COLUMN apiCredentials TEXT')
|
||||
} catch (e) {
|
||||
if (!e.message?.includes('duplicate column')) throw e
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'settings_sync',
|
||||
run(db) {
|
||||
try {
|
||||
db.exec('ALTER TABLE settings ADD COLUMN syncEnabled INTEGER')
|
||||
} catch (e) {
|
||||
if (!e.message?.includes('duplicate column')) throw e
|
||||
}
|
||||
try {
|
||||
db.exec('ALTER TABLE settings ADD COLUMN syncIntervalMinutes INTEGER')
|
||||
} catch (e) {
|
||||
if (!e.message?.includes('duplicate column')) throw e
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'vps_paidUntil',
|
||||
run(db) {
|
||||
try {
|
||||
db.exec('ALTER TABLE vps ADD COLUMN paidUntil TEXT')
|
||||
} catch (e) {
|
||||
if (!e.message?.includes('duplicate column')) throw e
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'provider_accounts_balance_api',
|
||||
run(db) {
|
||||
try {
|
||||
db.exec('ALTER TABLE provider_accounts ADD COLUMN balance_api REAL')
|
||||
} catch (e) {
|
||||
if (!e.message?.includes('duplicate column')) throw e
|
||||
}
|
||||
try {
|
||||
db.exec('ALTER TABLE provider_accounts ADD COLUMN balance_currency TEXT')
|
||||
} catch (e) {
|
||||
if (!e.message?.includes('duplicate column')) throw e
|
||||
}
|
||||
try {
|
||||
db.exec('ALTER TABLE provider_accounts ADD COLUMN balance_updated_at TEXT')
|
||||
} catch (e) {
|
||||
if (!e.message?.includes('duplicate column')) throw e
|
||||
}
|
||||
try {
|
||||
db.exec('ALTER TABLE provider_accounts ADD COLUMN enoughmoneyto TEXT')
|
||||
} catch (e) {
|
||||
if (!e.message?.includes('duplicate column')) throw e
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'vps_userOverrides',
|
||||
run(db) {
|
||||
try {
|
||||
db.exec('ALTER TABLE vps ADD COLUMN userOverrides TEXT')
|
||||
} catch (e) {
|
||||
if (!e.message?.includes('duplicate column')) throw e
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'active_tariffs_location_cpu',
|
||||
run(db) {
|
||||
try {
|
||||
db.exec('ALTER TABLE active_tariffs ADD COLUMN location TEXT')
|
||||
} catch (e) {
|
||||
if (!e.message?.includes('duplicate column')) throw e
|
||||
}
|
||||
try {
|
||||
db.exec('ALTER TABLE active_tariffs ADD COLUMN cpuModel TEXT')
|
||||
} catch (e) {
|
||||
if (!e.message?.includes('duplicate column')) throw e
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'settings_customFields',
|
||||
run(db) {
|
||||
try {
|
||||
db.exec('ALTER TABLE settings ADD COLUMN customFields TEXT')
|
||||
} catch (e) {
|
||||
if (!e.message?.includes('duplicate column')) throw e
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'settings_syncTariffsInterval',
|
||||
run(db) {
|
||||
try {
|
||||
db.exec('ALTER TABLE settings ADD COLUMN syncTariffsIntervalMinutes INTEGER')
|
||||
} catch (e) {
|
||||
if (!e.message?.includes('duplicate column')) throw e
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'active_tariffs_country_datacenter',
|
||||
run(db) {
|
||||
try {
|
||||
db.exec('ALTER TABLE active_tariffs ADD COLUMN country TEXT')
|
||||
} catch (e) {
|
||||
if (!e.message?.includes('duplicate column')) throw e
|
||||
}
|
||||
try {
|
||||
db.exec('ALTER TABLE active_tariffs ADD COLUMN datacenterKey TEXT')
|
||||
} catch (e) {
|
||||
if (!e.message?.includes('duplicate column')) throw e
|
||||
}
|
||||
try {
|
||||
db.exec('ALTER TABLE active_tariffs ADD COLUMN datacenterName TEXT')
|
||||
} catch (e) {
|
||||
if (!e.message?.includes('duplicate column')) throw e
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'settings_telegram',
|
||||
run(db) {
|
||||
try {
|
||||
db.exec('ALTER TABLE settings ADD COLUMN telegramBotToken TEXT')
|
||||
} catch (e) {
|
||||
if (!e.message?.includes('duplicate column')) throw e
|
||||
}
|
||||
try {
|
||||
db.exec('ALTER TABLE settings ADD COLUMN telegramChatId TEXT')
|
||||
} catch (e) {
|
||||
if (!e.message?.includes('duplicate column')) throw e
|
||||
}
|
||||
try {
|
||||
db.exec('ALTER TABLE settings ADD COLUMN notifyPaymentExpiryEnabled INTEGER')
|
||||
} catch (e) {
|
||||
if (!e.message?.includes('duplicate column')) throw e
|
||||
}
|
||||
try {
|
||||
db.exec('ALTER TABLE settings ADD COLUMN notifyNewTariffsEnabled INTEGER')
|
||||
} catch (e) {
|
||||
if (!e.message?.includes('duplicate column')) throw e
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'settings_telegram_thread',
|
||||
run(db) {
|
||||
try {
|
||||
db.exec('ALTER TABLE settings ADD COLUMN telegramMessageThreadId TEXT')
|
||||
} catch (e) {
|
||||
if (!e.message?.includes('duplicate column')) throw e
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'server_projects',
|
||||
run(db) {
|
||||
db.run(
|
||||
`CREATE TABLE IF NOT EXISTS server_projects (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
color TEXT,
|
||||
sortOrder INTEGER DEFAULT 0,
|
||||
notes TEXT,
|
||||
createdAt TEXT
|
||||
)`,
|
||||
)
|
||||
try {
|
||||
db.run('ALTER TABLE vps ADD COLUMN projectId TEXT')
|
||||
} catch (e) {
|
||||
if (!String(e.message || e).includes('duplicate column')) throw e
|
||||
}
|
||||
|
||||
const distinctStmt = db.prepare(
|
||||
`SELECT DISTINCT trim(project) AS n FROM vps WHERE length(trim(COALESCE(project, ''))) > 0`,
|
||||
)
|
||||
const seenLower = new Set()
|
||||
const findStmt = db.prepare(
|
||||
'SELECT id FROM server_projects WHERE LOWER(name) = LOWER(?) LIMIT 1',
|
||||
)
|
||||
while (distinctStmt.step()) {
|
||||
const row = distinctStmt.getAsObject()
|
||||
const t = String(row.n ?? '').trim()
|
||||
if (!t) continue
|
||||
const lk = t.toLowerCase()
|
||||
if (seenLower.has(lk)) continue
|
||||
seenLower.add(lk)
|
||||
|
||||
findStmt.bind([t])
|
||||
const exists = Boolean(findStmt.step())
|
||||
findStmt.reset()
|
||||
if (!exists) {
|
||||
const id = `proj-${randomUUID()}`
|
||||
const now = new Date().toISOString()
|
||||
db.run(
|
||||
`INSERT INTO server_projects (id, name, color, sortOrder, notes, createdAt) VALUES (?, ?, NULL, 0, NULL, ?)`,
|
||||
[id, t, now],
|
||||
)
|
||||
}
|
||||
}
|
||||
distinctStmt.free()
|
||||
findStmt.free()
|
||||
|
||||
db.run(`UPDATE vps SET projectId = (
|
||||
SELECT sp.id FROM server_projects sp
|
||||
WHERE LOWER(sp.name) = LOWER(trim(COALESCE(vps.project, '')))
|
||||
LIMIT 1
|
||||
) WHERE length(trim(COALESCE(vps.project, ''))) > 0`)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'sync_log_summary',
|
||||
run(db) {
|
||||
try {
|
||||
db.exec('ALTER TABLE sync_log ADD COLUMN summary TEXT')
|
||||
} catch (e) {
|
||||
if (!String(e.message || e).includes('duplicate column')) throw e
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'settings_notify_balance_digest',
|
||||
run(db) {
|
||||
try {
|
||||
db.exec('ALTER TABLE settings ADD COLUMN notifyLowBalanceEnabled INTEGER')
|
||||
} catch (e) {
|
||||
if (!String(e.message || e).includes('duplicate column')) throw e
|
||||
}
|
||||
try {
|
||||
db.exec('ALTER TABLE settings ADD COLUMN notifySyncDigestEnabled INTEGER')
|
||||
} catch (e) {
|
||||
if (!String(e.message || e).includes('duplicate column')) throw e
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'provider_accounts_balance_alert_below',
|
||||
run(db) {
|
||||
try {
|
||||
db.exec('ALTER TABLE provider_accounts ADD COLUMN balance_alert_below REAL')
|
||||
} catch (e) {
|
||||
if (!String(e.message || e).includes('duplicate column')) throw e
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'providers_api_integration',
|
||||
run(db) {
|
||||
try {
|
||||
db.exec('ALTER TABLE providers ADD COLUMN apiType TEXT')
|
||||
} catch (e) {
|
||||
if (!String(e.message || e).includes('duplicate column')) throw e
|
||||
}
|
||||
try {
|
||||
db.exec('ALTER TABLE providers ADD COLUMN apiBaseUrl TEXT')
|
||||
} catch (e) {
|
||||
if (!String(e.message || e).includes('duplicate column')) throw e
|
||||
}
|
||||
consolidateAllProviderApiSources(db)
|
||||
},
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* SQLite schema for vps-tracker
|
||||
*/
|
||||
|
||||
export const SCHEMA = `
|
||||
CREATE TABLE IF NOT EXISTS providers (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
website TEXT,
|
||||
contact TEXT,
|
||||
baseCurrency TEXT,
|
||||
usdRate TEXT,
|
||||
eurRate TEXT,
|
||||
notes TEXT,
|
||||
apiType TEXT,
|
||||
apiBaseUrl TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS provider_accounts (
|
||||
id TEXT PRIMARY KEY,
|
||||
providerId TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
panelUrl TEXT,
|
||||
currency TEXT,
|
||||
billingMode TEXT,
|
||||
notes TEXT,
|
||||
apiType TEXT,
|
||||
apiBaseUrl TEXT,
|
||||
apiCredentials TEXT,
|
||||
FOREIGN KEY (providerId) REFERENCES providers(id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS server_projects (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
color TEXT,
|
||||
sortOrder INTEGER DEFAULT 0,
|
||||
notes TEXT,
|
||||
createdAt TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS vps (
|
||||
id TEXT PRIMARY KEY,
|
||||
ip TEXT,
|
||||
ipv6 TEXT,
|
||||
additionalIps TEXT,
|
||||
dns TEXT,
|
||||
providerId TEXT,
|
||||
providerAccountId TEXT,
|
||||
country TEXT,
|
||||
city TEXT,
|
||||
datacenter TEXT,
|
||||
os TEXT,
|
||||
vcpu INTEGER,
|
||||
ramGb INTEGER,
|
||||
diskGb INTEGER,
|
||||
diskType TEXT,
|
||||
virtualization TEXT,
|
||||
bandwidthTb INTEGER,
|
||||
sshPort INTEGER,
|
||||
rootUser TEXT,
|
||||
purpose TEXT,
|
||||
environment TEXT,
|
||||
project TEXT,
|
||||
projectId TEXT,
|
||||
monitoringEnabled INTEGER,
|
||||
backupEnabled INTEGER,
|
||||
status TEXT,
|
||||
tariffType TEXT,
|
||||
currency TEXT,
|
||||
dailyRate REAL,
|
||||
monthlyRate REAL,
|
||||
createdAt TEXT,
|
||||
paidUntil TEXT,
|
||||
notes TEXT,
|
||||
userOverrides TEXT,
|
||||
FOREIGN KEY (providerId) REFERENCES providers(id),
|
||||
FOREIGN KEY (providerAccountId) REFERENCES provider_accounts(id),
|
||||
FOREIGN KEY (projectId) REFERENCES server_projects(id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS payments (
|
||||
id TEXT PRIMARY KEY,
|
||||
type TEXT NOT NULL,
|
||||
date TEXT NOT NULL,
|
||||
amount REAL NOT NULL,
|
||||
currency TEXT,
|
||||
providerAccountId TEXT,
|
||||
vpsId TEXT,
|
||||
note TEXT,
|
||||
FOREIGN KEY (providerAccountId) REFERENCES provider_accounts(id),
|
||||
FOREIGN KEY (vpsId) REFERENCES vps(id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS balance_ledger (
|
||||
id TEXT PRIMARY KEY,
|
||||
type TEXT NOT NULL,
|
||||
date TEXT NOT NULL,
|
||||
amount REAL NOT NULL,
|
||||
currency TEXT,
|
||||
direction TEXT,
|
||||
providerAccountId TEXT,
|
||||
vpsId TEXT,
|
||||
note TEXT,
|
||||
FOREIGN KEY (providerAccountId) REFERENCES provider_accounts(id),
|
||||
FOREIGN KEY (vpsId) REFERENCES vps(id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
id TEXT PRIMARY KEY,
|
||||
baseCurrency TEXT,
|
||||
ratesUrl TEXT,
|
||||
autoConvert INTEGER,
|
||||
ratesUpdatedAt TEXT,
|
||||
syncEnabled INTEGER,
|
||||
syncIntervalMinutes INTEGER,
|
||||
syncTariffsIntervalMinutes INTEGER,
|
||||
customFields TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sync_log (
|
||||
id TEXT PRIMARY KEY,
|
||||
accountId TEXT NOT NULL,
|
||||
startedAt TEXT NOT NULL,
|
||||
finishedAt TEXT,
|
||||
status TEXT,
|
||||
vpsCount INTEGER,
|
||||
paymentsCount INTEGER,
|
||||
error TEXT,
|
||||
FOREIGN KEY (accountId) REFERENCES provider_accounts(id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS active_tariffs (
|
||||
id TEXT PRIMARY KEY,
|
||||
providerAccountId TEXT NOT NULL,
|
||||
providerId TEXT NOT NULL,
|
||||
externalId TEXT NOT NULL,
|
||||
datacenterKey TEXT,
|
||||
datacenterName TEXT,
|
||||
name TEXT,
|
||||
desc TEXT,
|
||||
vcpu INTEGER,
|
||||
ramGb REAL,
|
||||
diskGb INTEGER,
|
||||
diskType TEXT,
|
||||
virtualization TEXT,
|
||||
channel TEXT,
|
||||
location TEXT,
|
||||
country TEXT,
|
||||
cpuModel TEXT,
|
||||
orderAvailable INTEGER,
|
||||
price TEXT,
|
||||
syncedAt TEXT,
|
||||
FOREIGN KEY (providerAccountId) REFERENCES provider_accounts(id),
|
||||
FOREIGN KEY (providerId) REFERENCES providers(id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tariff_sync_options (
|
||||
providerAccountId TEXT PRIMARY KEY,
|
||||
datacenters TEXT,
|
||||
periods TEXT,
|
||||
syncedAt TEXT,
|
||||
FOREIGN KEY (providerAccountId) REFERENCES provider_accounts(id)
|
||||
);
|
||||
`
|
||||
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* Seed database with initial data from public/data/*.json
|
||||
*/
|
||||
|
||||
import { readFileSync, existsSync } from 'fs'
|
||||
import { join } from 'path'
|
||||
|
||||
/**
|
||||
* @param {string} path - path to JSON file
|
||||
* @returns {Array}
|
||||
*/
|
||||
function loadJson(path) {
|
||||
if (!existsSync(path)) return []
|
||||
const raw = readFileSync(path, 'utf-8')
|
||||
try {
|
||||
const data = JSON.parse(raw)
|
||||
return Array.isArray(data) ? data : [data]
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} db - raw sql.js database (not wrapper)
|
||||
* @param {string} seedDir - path to public/data
|
||||
*/
|
||||
export function seed(db, seedDir) {
|
||||
const providers = loadJson(join(seedDir, 'providers.json'))
|
||||
if (providers.length === 0) return
|
||||
|
||||
const run = db.run.bind(db)
|
||||
for (const r of providers) {
|
||||
run(
|
||||
'INSERT OR IGNORE INTO providers (id, name, website, contact, baseCurrency, usdRate, eurRate, notes, apiType, apiBaseUrl) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
[
|
||||
r.id,
|
||||
r.name ?? '',
|
||||
r.website ?? '',
|
||||
r.contact ?? '',
|
||||
r.baseCurrency ?? '',
|
||||
r.usdRate ?? '',
|
||||
r.eurRate ?? '',
|
||||
r.notes ?? '',
|
||||
r.apiType ?? '',
|
||||
r.apiBaseUrl ?? '',
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
const providerAccounts = loadJson(join(seedDir, 'provider-accounts.json'))
|
||||
for (const r of providerAccounts) {
|
||||
const legacyType = r.apiType ?? ''
|
||||
const legacyUrl = r.apiBaseUrl ?? ''
|
||||
run(
|
||||
'INSERT OR IGNORE INTO provider_accounts (id, providerId, name, panelUrl, currency, billingMode, notes, apiType, apiBaseUrl, apiCredentials) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
[r.id, r.providerId ?? '', r.name ?? '', r.panelUrl ?? '', r.currency ?? '', r.billingMode ?? '', r.notes ?? '', '', '', r.apiCredentials ?? ''],
|
||||
)
|
||||
if (legacyType === 'billmanager' && String(legacyUrl).trim()) {
|
||||
run(
|
||||
`UPDATE providers SET apiType = 'billmanager', apiBaseUrl = ? WHERE id = ? AND length(trim(COALESCE(apiBaseUrl,''))) = 0`,
|
||||
String(legacyUrl).trim(),
|
||||
r.providerId ?? '',
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const vpsList = loadJson(join(seedDir, 'vps.json'))
|
||||
for (const r of vpsList) {
|
||||
const additionalIps = Array.isArray(r.additionalIps) ? JSON.stringify(r.additionalIps) : '[]'
|
||||
const dailyRate = r.dailyRate === '' || r.dailyRate == null ? null : Number(r.dailyRate)
|
||||
const monthlyRate = r.monthlyRate === '' || r.monthlyRate == null ? null : Number(r.monthlyRate)
|
||||
run(
|
||||
`INSERT OR IGNORE INTO vps (id, ip, ipv6, additionalIps, dns, providerId, providerAccountId, country, city, datacenter, os, vcpu, ramGb, diskGb, diskType, virtualization, bandwidthTb, sshPort, rootUser, purpose, environment, project, projectId, monitoringEnabled, backupEnabled, status, tariffType, currency, dailyRate, monthlyRate, createdAt, paidUntil, notes, userOverrides) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
r.id,
|
||||
r.ip ?? '',
|
||||
r.ipv6 ?? '',
|
||||
additionalIps,
|
||||
r.dns ?? '',
|
||||
r.providerId ?? '',
|
||||
r.providerAccountId ?? '',
|
||||
r.country ?? '',
|
||||
r.city ?? '',
|
||||
r.datacenter ?? '',
|
||||
r.os ?? '',
|
||||
r.vcpu ?? 0,
|
||||
r.ramGb ?? 0,
|
||||
r.diskGb ?? 0,
|
||||
r.diskType ?? '',
|
||||
r.virtualization ?? '',
|
||||
r.bandwidthTb ?? 0,
|
||||
r.sshPort ?? 22,
|
||||
r.rootUser ?? '',
|
||||
r.purpose ?? '',
|
||||
r.environment ?? '',
|
||||
r.project ?? '',
|
||||
r.projectId ?? null,
|
||||
r.monitoringEnabled ? 1 : 0,
|
||||
r.backupEnabled ? 1 : 0,
|
||||
r.status ?? 'active',
|
||||
r.tariffType ?? '',
|
||||
r.currency ?? '',
|
||||
dailyRate,
|
||||
monthlyRate,
|
||||
r.createdAt ?? '',
|
||||
r.paidUntil ?? '',
|
||||
r.notes ?? '',
|
||||
'[]',
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
const payments = loadJson(join(seedDir, 'payments.json'))
|
||||
for (const r of payments) {
|
||||
run(
|
||||
'INSERT OR IGNORE INTO payments (id, type, date, amount, currency, providerAccountId, vpsId, note) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
[r.id, r.type ?? '', r.date ?? '', Number(r.amount) || 0, r.currency ?? '', r.providerAccountId ?? '', r.vpsId ?? '', r.note ?? ''],
|
||||
)
|
||||
}
|
||||
|
||||
const ledger = loadJson(join(seedDir, 'balance-ledger.json'))
|
||||
for (const r of ledger) {
|
||||
run(
|
||||
'INSERT OR IGNORE INTO balance_ledger (id, type, date, amount, currency, direction, providerAccountId, vpsId, note) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
[r.id, r.type ?? '', r.date ?? '', Number(r.amount) || 0, r.currency ?? '', r.direction ?? '', r.providerAccountId ?? '', r.vpsId ?? '', r.note ?? ''],
|
||||
)
|
||||
}
|
||||
|
||||
const settingsList = loadJson(join(seedDir, 'settings.json'))
|
||||
for (const r of settingsList) {
|
||||
run(
|
||||
'INSERT OR IGNORE INTO settings (id, baseCurrency, ratesUrl, autoConvert, ratesUpdatedAt, syncEnabled, syncIntervalMinutes) VALUES (?, ?, ?, ?, ?, ?, ?)',
|
||||
[r.id ?? 'settings-main', r.baseCurrency ?? 'RUB', r.ratesUrl ?? '', r.autoConvert !== false ? 1 : 0, r.ratesUpdatedAt ?? '', r.syncEnabled ? 1 : 0, r.syncIntervalMinutes ?? 60],
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} db - raw sql.js database
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isDbEmpty(db) {
|
||||
const result = db.exec('SELECT COUNT(*) as c FROM providers')
|
||||
if (!result.length || !result[0].values.length) return true
|
||||
return result[0].values[0][0] === 0
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import express from 'express'
|
||||
import cors from 'cors'
|
||||
import { existsSync } from 'fs'
|
||||
import { dirname, join } from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
import { initDb } from './db.js'
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
const distPath = join(__dirname, '..', 'dist')
|
||||
import dataRouter from './routes/data.js'
|
||||
import migrateRouter from './routes/migrate.js'
|
||||
import vpsRouter from './routes/vps.js'
|
||||
import providersRouter from './routes/providers.js'
|
||||
import providerAccountsRouter from './routes/provider-accounts.js'
|
||||
import paymentsRouter from './routes/payments.js'
|
||||
import balanceLedgerRouter from './routes/balance-ledger.js'
|
||||
import settingsRouter from './routes/settings.js'
|
||||
import syncRouter from './routes/sync.js'
|
||||
import projectsRouter from './routes/projects.js'
|
||||
import backupRouter from './routes/backup.js'
|
||||
import ratesProxyRouter from './routes/rates-proxy.js'
|
||||
|
||||
const app = express()
|
||||
const PORT = process.env.PORT || 3001
|
||||
|
||||
app.use(cors())
|
||||
app.use(express.json({ limit: '50mb' }))
|
||||
|
||||
;(async () => {
|
||||
await initDb()
|
||||
|
||||
app.use('/api/data', dataRouter)
|
||||
app.use('/api/migrate', migrateRouter)
|
||||
app.use('/api/vps', vpsRouter)
|
||||
app.use('/api/providers', providersRouter)
|
||||
app.use('/api/provider-accounts', providerAccountsRouter)
|
||||
app.use('/api/payments', paymentsRouter)
|
||||
app.use('/api/balance-ledger', balanceLedgerRouter)
|
||||
app.use('/api/settings', settingsRouter)
|
||||
app.use('/api/sync', syncRouter)
|
||||
app.use('/api/projects', projectsRouter)
|
||||
app.use('/api/backup', backupRouter)
|
||||
app.use('/api/rates-proxy', ratesProxyRouter)
|
||||
|
||||
if (existsSync(distPath)) {
|
||||
app.use(express.static(distPath))
|
||||
app.get(/.*/, (req, res, next) => {
|
||||
if (req.path.startsWith('/api')) return next()
|
||||
res.sendFile(join(distPath, 'index.html'), (err) => (err ? next(err) : undefined))
|
||||
})
|
||||
}
|
||||
|
||||
const { startScheduler } = await import('./sync-scheduler.js')
|
||||
startScheduler()
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Server running at http://localhost:${PORT}`)
|
||||
})
|
||||
})()
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "@cfdm/api",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"dev:legacy": "node index.js",
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"start": "node dist/index.js",
|
||||
"start:legacy": "node index.js",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@cfdm/db": "workspace:*",
|
||||
"@cfdm/shared": "workspace:*",
|
||||
"@fastify/cors": "^11.0.1",
|
||||
"@fastify/sensible": "^6.0.3",
|
||||
"@fastify/static": "^8.2.0",
|
||||
"fastify": "^5.6.1",
|
||||
"better-sqlite3": "^11.10.0",
|
||||
"drizzle-orm": "^0.40.0",
|
||||
"express": "^4.21.1",
|
||||
"cors": "^2.8.5",
|
||||
"sql.js": "^1.14.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.10.0",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/cors": "^2.8.17",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.9.2",
|
||||
"vitest": "^3.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Справочник проектов (пулов): поиск без учёта регистра, автосоздание, подсказки.
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'node:crypto'
|
||||
|
||||
/**
|
||||
* @param {unknown} name
|
||||
* @returns {string}
|
||||
*/
|
||||
export function normalizeProjectNameInput(name) {
|
||||
if (name == null) return ''
|
||||
return String(name).trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ReturnType<import('./db.js').getDb>} db
|
||||
* @param {string} name — уже нормализованное имя (trim)
|
||||
* @returns {{ id: string, name: string, color?: string, sortOrder?: number, notes?: string, createdAt?: string } | null}
|
||||
*/
|
||||
export function findProjectByNameCaseInsensitive(db, name) {
|
||||
const n = normalizeProjectNameInput(name)
|
||||
if (!n) return null
|
||||
return db
|
||||
.prepare(
|
||||
`SELECT * FROM server_projects WHERE LOWER(name) = LOWER(?) LIMIT 1`,
|
||||
)
|
||||
.get(n)
|
||||
}
|
||||
|
||||
/**
|
||||
* Найти существующий проект или создать новую строку.
|
||||
* @param {ReturnType<import('./db.js').getDb>} db
|
||||
* @param {string} name
|
||||
* @returns {{ id: string | null, name: string }}
|
||||
*/
|
||||
export function resolveOrCreateProject(db, name) {
|
||||
const n = normalizeProjectNameInput(name)
|
||||
if (!n) return { id: null, name: '' }
|
||||
const existing = findProjectByNameCaseInsensitive(db, n)
|
||||
if (existing) {
|
||||
return { id: existing.id, name: existing.name }
|
||||
}
|
||||
const id = `proj-${randomUUID()}`
|
||||
const now = new Date().toISOString()
|
||||
db.prepare(
|
||||
`INSERT INTO server_projects (id, name, color, sortOrder, notes, createdAt) VALUES (?, ?, ?, 0, ?, ?)`,
|
||||
).run(id, n, null, null, now)
|
||||
return { id, name: n }
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ReturnType<import('./db.js').getDb>} db
|
||||
* @param {string} q
|
||||
* @param {number} limit
|
||||
* @returns {{ id: string, name: string }[]}
|
||||
*/
|
||||
export function projectSuggestions(db, q, limit = 20) {
|
||||
const term = normalizeProjectNameInput(q)
|
||||
const lim = Math.min(50, Math.max(1, Number(limit) || 20))
|
||||
if (!term) {
|
||||
return db
|
||||
.prepare(`SELECT id, name FROM server_projects ORDER BY name LIMIT ?`)
|
||||
.all(lim)
|
||||
}
|
||||
const esc = term.replace(/\\/g, '\\\\').replace(/%/g, '\\%').replace(/_/g, '\\_')
|
||||
const pattern = `%${esc.toLowerCase()}%`
|
||||
return db
|
||||
.prepare(
|
||||
`SELECT id, name FROM server_projects WHERE LOWER(name) LIKE ? ESCAPE '\\' ORDER BY name LIMIT ?`,
|
||||
)
|
||||
.all(pattern, lim)
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
import { Router } from 'express'
|
||||
import express from 'express'
|
||||
import { readFileSync, existsSync } from 'node:fs'
|
||||
import { getDb, saveDb, DB_PATH, reloadDatabaseFromBuffer } from '../db.js'
|
||||
import { consolidateAllProviderApiSources } from '../db/migrations.js'
|
||||
import { rowToVps } from './vps.js'
|
||||
import { rowToActiveTariff, rowToTariffSyncOptions } from '../utils/row-mappers.js'
|
||||
|
||||
const router = Router()
|
||||
const BACKUP_VERSION = 1
|
||||
|
||||
function buildJsonSnapshot() {
|
||||
saveDb()
|
||||
const db = getDb()
|
||||
const vps = db.prepare('SELECT * FROM vps ORDER BY createdAt DESC').all()
|
||||
const providers = db.prepare('SELECT * FROM providers ORDER BY name').all()
|
||||
const providerAccounts = db.prepare('SELECT * FROM provider_accounts ORDER BY name').all()
|
||||
const payments = db.prepare('SELECT * FROM payments ORDER BY date DESC').all()
|
||||
const balanceLedger = db.prepare('SELECT * FROM balance_ledger ORDER BY date DESC').all()
|
||||
const settingsRows = db.prepare('SELECT * FROM settings ORDER BY id').all()
|
||||
const activeTariffs = db.prepare('SELECT * FROM active_tariffs ORDER BY name').all()
|
||||
const tariffSyncOptions = db.prepare('SELECT * FROM tariff_sync_options').all()
|
||||
let serverProjects = []
|
||||
try {
|
||||
serverProjects = db
|
||||
.prepare('SELECT id, name, color, sortOrder, notes, createdAt FROM server_projects ORDER BY name')
|
||||
.all()
|
||||
} catch {
|
||||
serverProjects = []
|
||||
}
|
||||
let syncLog = []
|
||||
try {
|
||||
syncLog = db.prepare('SELECT * FROM sync_log ORDER BY startedAt DESC LIMIT 500').all()
|
||||
} catch {
|
||||
syncLog = []
|
||||
}
|
||||
|
||||
return {
|
||||
backupVersion: BACKUP_VERSION,
|
||||
exportedAt: new Date().toISOString(),
|
||||
vps: vps.map(rowToVps),
|
||||
serverProjects,
|
||||
providers,
|
||||
providerAccounts,
|
||||
payments,
|
||||
balanceLedger,
|
||||
settings: settingsRows,
|
||||
activeTariffs: activeTariffs.map(rowToActiveTariff),
|
||||
tariffSyncOptions: tariffSyncOptions.map(rowToTariffSyncOptions),
|
||||
syncLog,
|
||||
}
|
||||
}
|
||||
|
||||
router.get('/json', (req, res) => {
|
||||
try {
|
||||
const snapshot = buildJsonSnapshot()
|
||||
res.setHeader('Content-Type', 'application/json; charset=utf-8')
|
||||
res.setHeader('Content-Disposition', 'attachment; filename="vps-tracker-backup.json"')
|
||||
res.send(JSON.stringify(snapshot, null, 2))
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message })
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/database', (req, res) => {
|
||||
try {
|
||||
saveDb()
|
||||
if (!existsSync(DB_PATH)) {
|
||||
return res.status(404).json({ error: 'Файл базы не найден' })
|
||||
}
|
||||
const buf = readFileSync(DB_PATH)
|
||||
res.setHeader('Content-Type', 'application/octet-stream')
|
||||
res.setHeader('Content-Disposition', 'attachment; filename="vps-tracker.db"')
|
||||
res.send(buf)
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message })
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/json', (req, res) => {
|
||||
try {
|
||||
const payload = req.body
|
||||
if (!payload || typeof payload !== 'object') {
|
||||
return res.status(400).json({ error: 'Неверное тело запроса' })
|
||||
}
|
||||
importJsonSnapshot(payload)
|
||||
res.json({ ok: true })
|
||||
} catch (err) {
|
||||
console.error('Backup JSON import error:', err)
|
||||
res.status(500).json({ error: err.message || 'Импорт не удался' })
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* @param {object} data
|
||||
*/
|
||||
function importJsonSnapshot(data) {
|
||||
const db = getDb()
|
||||
const run = (sql, ...params) => db.prepare(sql).run(...params)
|
||||
|
||||
run('DELETE FROM sync_log')
|
||||
run('DELETE FROM tariff_sync_options')
|
||||
run('DELETE FROM active_tariffs')
|
||||
run('DELETE FROM balance_ledger')
|
||||
run('DELETE FROM payments')
|
||||
run('DELETE FROM vps')
|
||||
run('DELETE FROM provider_accounts')
|
||||
run('DELETE FROM server_projects')
|
||||
run('DELETE FROM providers')
|
||||
run('DELETE FROM settings')
|
||||
|
||||
const providers = Array.isArray(data.providers) ? data.providers : []
|
||||
for (const p of providers) {
|
||||
run(
|
||||
`INSERT OR REPLACE INTO providers (id, name, website, contact, baseCurrency, usdRate, eurRate, notes, apiType, apiBaseUrl) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
p.id ?? '',
|
||||
p.name ?? '',
|
||||
p.website ?? '',
|
||||
p.contact ?? '',
|
||||
p.baseCurrency ?? '',
|
||||
p.usdRate ?? '',
|
||||
p.eurRate ?? '',
|
||||
p.notes ?? '',
|
||||
p.apiType ?? '',
|
||||
p.apiBaseUrl ?? '',
|
||||
)
|
||||
}
|
||||
|
||||
const projects = Array.isArray(data.serverProjects) ? data.serverProjects : []
|
||||
for (const sp of projects) {
|
||||
run(
|
||||
`INSERT OR REPLACE INTO server_projects (id, name, color, sortOrder, notes, createdAt) VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
sp.id ?? '',
|
||||
sp.name ?? '',
|
||||
sp.color ?? null,
|
||||
sp.sortOrder ?? 0,
|
||||
sp.notes ?? null,
|
||||
sp.createdAt ?? null,
|
||||
)
|
||||
}
|
||||
|
||||
const accounts = Array.isArray(data.providerAccounts) ? data.providerAccounts : []
|
||||
for (const acc of accounts) {
|
||||
run(
|
||||
`INSERT OR REPLACE INTO provider_accounts (id, providerId, name, panelUrl, currency, billingMode, notes, apiType, apiBaseUrl, apiCredentials, balance_api, balance_currency, balance_updated_at, enoughmoneyto, balance_alert_below)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
acc.id ?? '',
|
||||
acc.providerId ?? '',
|
||||
acc.name ?? '',
|
||||
acc.panelUrl ?? '',
|
||||
acc.currency ?? '',
|
||||
acc.billingMode ?? '',
|
||||
acc.notes ?? '',
|
||||
acc.apiType ?? '',
|
||||
acc.apiBaseUrl ?? '',
|
||||
acc.apiCredentials ?? '',
|
||||
acc.balance_api ?? null,
|
||||
acc.balance_currency ?? null,
|
||||
acc.balance_updated_at ?? null,
|
||||
acc.enoughmoneyto ?? null,
|
||||
acc.balance_alert_below != null && acc.balance_alert_below !== '' ? Number(acc.balance_alert_below) : null,
|
||||
)
|
||||
}
|
||||
|
||||
consolidateAllProviderApiSources(db)
|
||||
|
||||
const settingsList = Array.isArray(data.settings) ? data.settings : data.settings ? [data.settings] : []
|
||||
for (const s of settingsList) {
|
||||
let customFields = s.customFields
|
||||
if (Array.isArray(customFields)) customFields = JSON.stringify(customFields)
|
||||
if (customFields === undefined) customFields = null
|
||||
run(
|
||||
`INSERT OR REPLACE INTO settings (id, baseCurrency, ratesUrl, autoConvert, ratesUpdatedAt, syncEnabled, syncIntervalMinutes, syncTariffsIntervalMinutes, telegramBotToken, telegramChatId, telegramMessageThreadId, notifyPaymentExpiryEnabled, notifyNewTariffsEnabled, customFields, notifyLowBalanceEnabled, notifySyncDigestEnabled)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
s.id ?? 'settings-main',
|
||||
s.baseCurrency ?? 'RUB',
|
||||
s.ratesUrl ?? '',
|
||||
s.autoConvert !== false && s.autoConvert !== 0 ? 1 : 0,
|
||||
s.ratesUpdatedAt ?? '',
|
||||
s.syncEnabled ? 1 : 0,
|
||||
s.syncIntervalMinutes ?? 60,
|
||||
s.syncTariffsIntervalMinutes ?? 1440,
|
||||
s.telegramBotToken ?? '',
|
||||
s.telegramChatId ?? '',
|
||||
s.telegramMessageThreadId ?? '',
|
||||
s.notifyPaymentExpiryEnabled ? 1 : 0,
|
||||
s.notifyNewTariffsEnabled ? 1 : 0,
|
||||
customFields,
|
||||
s.notifyLowBalanceEnabled ? 1 : 0,
|
||||
s.notifySyncDigestEnabled ? 1 : 0,
|
||||
)
|
||||
}
|
||||
|
||||
const vpsRows = Array.isArray(data.vps) ? data.vps : []
|
||||
const vpsSql = `INSERT OR REPLACE INTO vps (id, ip, ipv6, additionalIps, dns, providerId, providerAccountId, country, city, datacenter, os, vcpu, ramGb, diskGb, diskType, virtualization, bandwidthTb, sshPort, rootUser, purpose, environment, project, projectId, monitoringEnabled, backupEnabled, status, tariffType, currency, dailyRate, monthlyRate, createdAt, paidUntil, notes, userOverrides)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
for (const v of vpsRows) {
|
||||
const additionalIps = Array.isArray(v.additionalIps) ? JSON.stringify(v.additionalIps) : '[]'
|
||||
const dailyRate = v.dailyRate === '' || v.dailyRate == null ? null : Number(v.dailyRate)
|
||||
const monthlyRate = v.monthlyRate === '' || v.monthlyRate == null ? null : Number(v.monthlyRate)
|
||||
const userOverrides = Array.isArray(v.userOverrides) ? JSON.stringify(v.userOverrides) : (v.userOverrides ?? '[]')
|
||||
run(
|
||||
vpsSql,
|
||||
v.id ?? '',
|
||||
v.ip ?? '',
|
||||
v.ipv6 ?? '',
|
||||
additionalIps,
|
||||
v.dns ?? '',
|
||||
v.providerId ?? '',
|
||||
v.providerAccountId ?? '',
|
||||
v.country ?? '',
|
||||
v.city ?? '',
|
||||
v.datacenter ?? '',
|
||||
v.os ?? '',
|
||||
v.vcpu ?? 0,
|
||||
v.ramGb ?? 0,
|
||||
v.diskGb ?? 0,
|
||||
v.diskType ?? '',
|
||||
v.virtualization ?? '',
|
||||
v.bandwidthTb ?? 0,
|
||||
v.sshPort ?? 22,
|
||||
v.rootUser ?? '',
|
||||
v.purpose ?? '',
|
||||
v.environment ?? '',
|
||||
v.project ?? '',
|
||||
v.projectId ?? null,
|
||||
v.monitoringEnabled ? 1 : 0,
|
||||
v.backupEnabled ? 1 : 0,
|
||||
v.status ?? 'active',
|
||||
v.tariffType ?? '',
|
||||
v.currency ?? '',
|
||||
dailyRate,
|
||||
monthlyRate,
|
||||
v.createdAt ?? '',
|
||||
v.paidUntil ?? '',
|
||||
v.notes ?? '',
|
||||
userOverrides,
|
||||
)
|
||||
}
|
||||
|
||||
const payments = Array.isArray(data.payments) ? data.payments : []
|
||||
for (const pm of payments) {
|
||||
run(
|
||||
`INSERT OR REPLACE INTO payments (id, type, date, amount, currency, providerAccountId, vpsId, note) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
pm.id ?? '',
|
||||
pm.type ?? '',
|
||||
pm.date ?? '',
|
||||
Number(pm.amount) || 0,
|
||||
pm.currency ?? '',
|
||||
pm.providerAccountId ?? '',
|
||||
pm.vpsId ?? '',
|
||||
pm.note ?? '',
|
||||
)
|
||||
}
|
||||
|
||||
const ledger = Array.isArray(data.balanceLedger) ? data.balanceLedger : []
|
||||
for (const bl of ledger) {
|
||||
run(
|
||||
`INSERT OR REPLACE INTO balance_ledger (id, type, date, amount, currency, direction, providerAccountId, vpsId, note) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
bl.id ?? '',
|
||||
bl.type ?? '',
|
||||
bl.date ?? '',
|
||||
Number(bl.amount) || 0,
|
||||
bl.currency ?? '',
|
||||
bl.direction ?? '',
|
||||
bl.providerAccountId ?? '',
|
||||
bl.vpsId ?? '',
|
||||
bl.note ?? '',
|
||||
)
|
||||
}
|
||||
|
||||
const tariffs = Array.isArray(data.activeTariffs) ? data.activeTariffs : []
|
||||
for (const t of tariffs) {
|
||||
run(
|
||||
`INSERT OR REPLACE INTO active_tariffs (id, providerAccountId, providerId, externalId, datacenterKey, datacenterName, name, desc, vcpu, ramGb, diskGb, diskType, virtualization, channel, location, country, cpuModel, orderAvailable, price, syncedAt)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
t.id ?? '',
|
||||
t.providerAccountId ?? '',
|
||||
t.providerId ?? '',
|
||||
t.externalId ?? '',
|
||||
t.datacenterKey ?? '',
|
||||
t.datacenterName ?? '',
|
||||
t.name ?? '',
|
||||
t.desc ?? '',
|
||||
t.vcpu ?? 0,
|
||||
t.ramGb ?? 0,
|
||||
t.diskGb ?? 0,
|
||||
t.diskType ?? '',
|
||||
t.virtualization ?? '',
|
||||
t.channel ?? '',
|
||||
t.location ?? '',
|
||||
t.country ?? '',
|
||||
t.cpuModel ?? '',
|
||||
t.orderAvailable ? 1 : 0,
|
||||
t.price ?? '',
|
||||
t.syncedAt ?? '',
|
||||
)
|
||||
}
|
||||
|
||||
const tso = Array.isArray(data.tariffSyncOptions) ? data.tariffSyncOptions : []
|
||||
for (const o of tso) {
|
||||
const dcs = typeof o.datacenters === 'string' ? o.datacenters : JSON.stringify(o.datacenters || [])
|
||||
const pers = typeof o.periods === 'string' ? o.periods : JSON.stringify(o.periods || [])
|
||||
run(
|
||||
`INSERT OR REPLACE INTO tariff_sync_options (providerAccountId, datacenters, periods, syncedAt) VALUES (?, ?, ?, ?)`,
|
||||
o.providerAccountId ?? '',
|
||||
dcs,
|
||||
pers,
|
||||
o.syncedAt ?? '',
|
||||
)
|
||||
}
|
||||
|
||||
const logs = Array.isArray(data.syncLog) ? data.syncLog : []
|
||||
for (const log of logs) {
|
||||
run(
|
||||
`INSERT OR REPLACE INTO sync_log (id, accountId, startedAt, finishedAt, status, vpsCount, paymentsCount, error, summary) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
log.id ?? '',
|
||||
log.accountId ?? '',
|
||||
log.startedAt ?? '',
|
||||
log.finishedAt ?? null,
|
||||
log.status ?? '',
|
||||
log.vpsCount ?? null,
|
||||
log.paymentsCount ?? null,
|
||||
log.error ?? null,
|
||||
typeof log.summary === 'string' ? log.summary : log.summary ? JSON.stringify(log.summary) : null,
|
||||
)
|
||||
}
|
||||
|
||||
saveDb()
|
||||
}
|
||||
|
||||
router.post('/database', express.raw({ limit: '100mb', type: '*/*' }), async (req, res) => {
|
||||
try {
|
||||
const buf = Buffer.isBuffer(req.body) ? req.body : Buffer.from(req.body || [])
|
||||
if (!buf.length) {
|
||||
return res.status(400).json({ error: 'Пустой файл' })
|
||||
}
|
||||
await reloadDatabaseFromBuffer(buf)
|
||||
const { startScheduler } = await import('../sync-scheduler.js')
|
||||
startScheduler()
|
||||
res.json({ ok: true })
|
||||
} catch (err) {
|
||||
console.error('Backup DB restore error:', err)
|
||||
res.status(500).json({ error: err.message || 'Восстановление не удалось' })
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,82 @@
|
||||
import { Router } from 'express'
|
||||
import { getDb } from '../db.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
router.get('/', (req, res) => {
|
||||
try {
|
||||
const db = getDb()
|
||||
const rows = db.prepare('SELECT * FROM balance_ledger ORDER BY date DESC').all()
|
||||
res.json(rows)
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message })
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/', (req, res) => {
|
||||
try {
|
||||
const db = getDb()
|
||||
const r = req.body
|
||||
const id = r.id || `ledger-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`
|
||||
db.prepare(`
|
||||
INSERT INTO balance_ledger (id, type, date, amount, currency, direction, providerAccountId, vpsId, note)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
id,
|
||||
r.type ?? '',
|
||||
r.date ?? '',
|
||||
Number(r.amount) || 0,
|
||||
r.currency ?? '',
|
||||
r.direction ?? '',
|
||||
r.providerAccountId ?? '',
|
||||
r.vpsId ?? '',
|
||||
r.note ?? '',
|
||||
)
|
||||
const row = db.prepare('SELECT * FROM balance_ledger WHERE id = ?').get(id)
|
||||
res.status(201).json(row)
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message })
|
||||
}
|
||||
})
|
||||
|
||||
router.put('/:id', (req, res) => {
|
||||
try {
|
||||
const db = getDb()
|
||||
const { id } = req.params
|
||||
const r = req.body
|
||||
db.prepare(`
|
||||
UPDATE balance_ledger SET
|
||||
type = ?, date = ?, amount = ?, currency = ?, direction = ?, providerAccountId = ?, vpsId = ?, note = ?
|
||||
WHERE id = ?
|
||||
`).run(
|
||||
r.type ?? '',
|
||||
r.date ?? '',
|
||||
Number(r.amount) || 0,
|
||||
r.currency ?? '',
|
||||
r.direction ?? '',
|
||||
r.providerAccountId ?? '',
|
||||
r.vpsId ?? '',
|
||||
r.note ?? '',
|
||||
id,
|
||||
)
|
||||
const row = db.prepare('SELECT * FROM balance_ledger WHERE id = ?').get(id)
|
||||
if (!row) return res.status(404).json({ error: 'Not found' })
|
||||
res.json(row)
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message })
|
||||
}
|
||||
})
|
||||
|
||||
router.delete('/:id', (req, res) => {
|
||||
try {
|
||||
const db = getDb()
|
||||
const { id } = req.params
|
||||
const result = db.prepare('DELETE FROM balance_ledger WHERE id = ?').run(id)
|
||||
if (result.changes === 0) return res.status(404).json({ error: 'Not found' })
|
||||
res.status(204).send()
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message })
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,45 @@
|
||||
import { Router } from 'express'
|
||||
import { getDb } from '../db.js'
|
||||
import { rowToVps } from './vps.js'
|
||||
import { rowToSettings } from './settings.js'
|
||||
import { sanitizeAccount, rowToActiveTariff, rowToTariffSyncOptions } from '../utils/row-mappers.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
router.get('/', (req, res) => {
|
||||
try {
|
||||
const db = getDb()
|
||||
const vps = db.prepare('SELECT * FROM vps ORDER BY createdAt DESC').all()
|
||||
const providers = db.prepare('SELECT * FROM providers ORDER BY name').all()
|
||||
const providerAccounts = db.prepare('SELECT * FROM provider_accounts ORDER BY name').all()
|
||||
const payments = db.prepare('SELECT * FROM payments ORDER BY date DESC').all()
|
||||
const balanceLedger = db.prepare('SELECT * FROM balance_ledger ORDER BY date DESC').all()
|
||||
const settingsRows = db.prepare('SELECT * FROM settings ORDER BY id').all()
|
||||
const activeTariffs = db.prepare('SELECT * FROM active_tariffs ORDER BY name').all()
|
||||
const tariffSyncOptions = db.prepare('SELECT * FROM tariff_sync_options').all()
|
||||
let serverProjects = []
|
||||
try {
|
||||
serverProjects = db
|
||||
.prepare('SELECT id, name, color, sortOrder, notes, createdAt FROM server_projects ORDER BY name')
|
||||
.all()
|
||||
} catch {
|
||||
serverProjects = []
|
||||
}
|
||||
|
||||
res.json({
|
||||
vps: vps.map(rowToVps),
|
||||
serverProjects,
|
||||
providers,
|
||||
providerAccounts: providerAccounts.map(sanitizeAccount),
|
||||
payments,
|
||||
balanceLedger,
|
||||
settings: settingsRows.map(rowToSettings),
|
||||
activeTariffs: activeTariffs.map(rowToActiveTariff),
|
||||
tariffSyncOptions: tariffSyncOptions.map(rowToTariffSyncOptions),
|
||||
})
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message })
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,85 @@
|
||||
import { Router } from 'express'
|
||||
import { getDb, saveDb } from '../db.js'
|
||||
import { consolidateAllProviderApiSources } from '../db/migrations.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
router.post('/', (req, res) => {
|
||||
try {
|
||||
const db = getDb()
|
||||
const data = req.body
|
||||
if (!data || typeof data !== 'object') {
|
||||
return res.status(400).json({ error: 'Invalid payload' })
|
||||
}
|
||||
|
||||
const settingsList = Array.isArray(data.settings) ? data.settings : (data.settings ? [data.settings] : [])
|
||||
|
||||
if (Array.isArray(data.providers) && data.providers.length > 0) {
|
||||
const sql = `INSERT OR REPLACE INTO providers (id, name, website, contact, baseCurrency, usdRate, eurRate, notes, apiType, apiBaseUrl) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
for (const r of data.providers) {
|
||||
const p = typeof r === 'object' ? r : {}
|
||||
db.run(
|
||||
sql,
|
||||
p.id ?? '',
|
||||
p.name ?? '',
|
||||
p.website ?? '',
|
||||
p.contact ?? '',
|
||||
p.baseCurrency ?? '',
|
||||
p.usdRate ?? '',
|
||||
p.eurRate ?? '',
|
||||
p.notes ?? '',
|
||||
p.apiType ?? '',
|
||||
p.apiBaseUrl ?? '',
|
||||
)
|
||||
}
|
||||
}
|
||||
if (Array.isArray(data.providerAccounts) && data.providerAccounts.length > 0) {
|
||||
const sql = `INSERT OR REPLACE INTO provider_accounts (id, providerId, name, panelUrl, currency, billingMode, notes, apiType, apiBaseUrl, apiCredentials) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
for (const r of data.providerAccounts) {
|
||||
const acc = typeof r === 'object' ? r : {}
|
||||
db.run(sql, acc.id ?? '', acc.providerId ?? '', acc.name ?? '', acc.panelUrl ?? '', acc.currency ?? '', acc.billingMode ?? '', acc.notes ?? '', acc.apiType ?? '', acc.apiBaseUrl ?? '', acc.apiCredentials ?? '')
|
||||
}
|
||||
}
|
||||
if (Array.isArray(data.vps) && data.vps.length > 0) {
|
||||
const sql = `INSERT OR REPLACE INTO vps (id, ip, ipv6, additionalIps, dns, providerId, providerAccountId, country, city, datacenter, os, vcpu, ramGb, diskGb, diskType, virtualization, bandwidthTb, sshPort, rootUser, purpose, environment, project, projectId, monitoringEnabled, backupEnabled, status, tariffType, currency, dailyRate, monthlyRate, createdAt, paidUntil, notes, userOverrides) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
for (const r of data.vps) {
|
||||
const v = typeof r === 'object' ? r : {}
|
||||
const additionalIps = Array.isArray(v.additionalIps) ? JSON.stringify(v.additionalIps) : '[]'
|
||||
const dailyRate = v.dailyRate === '' || v.dailyRate == null ? null : Number(v.dailyRate)
|
||||
const monthlyRate = v.monthlyRate === '' || v.monthlyRate == null ? null : Number(v.monthlyRate)
|
||||
const userOverrides = Array.isArray(v.userOverrides) ? JSON.stringify(v.userOverrides) : (v.userOverrides ?? '[]')
|
||||
db.run(sql, v.id ?? '', v.ip ?? '', v.ipv6 ?? '', additionalIps, v.dns ?? '', v.providerId ?? '', v.providerAccountId ?? '', v.country ?? '', v.city ?? '', v.datacenter ?? '', v.os ?? '', v.vcpu ?? 0, v.ramGb ?? 0, v.diskGb ?? 0, v.diskType ?? '', v.virtualization ?? '', v.bandwidthTb ?? 0, v.sshPort ?? 22, v.rootUser ?? '', v.purpose ?? '', v.environment ?? '', v.project ?? '', v.projectId ?? null, v.monitoringEnabled ? 1 : 0, v.backupEnabled ? 1 : 0, v.status ?? 'active', v.tariffType ?? '', v.currency ?? '', dailyRate, monthlyRate, v.createdAt ?? '', v.paidUntil ?? '', v.notes ?? '', userOverrides)
|
||||
}
|
||||
}
|
||||
if (Array.isArray(data.payments) && data.payments.length > 0) {
|
||||
const sql = `INSERT OR REPLACE INTO payments (id, type, date, amount, currency, providerAccountId, vpsId, note) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
for (const r of data.payments) {
|
||||
const pm = typeof r === 'object' ? r : {}
|
||||
db.run(sql, pm.id ?? '', pm.type ?? '', pm.date ?? '', Number(pm.amount) || 0, pm.currency ?? '', pm.providerAccountId ?? '', pm.vpsId ?? '', pm.note ?? '')
|
||||
}
|
||||
}
|
||||
if (Array.isArray(data.balanceLedger) && data.balanceLedger.length > 0) {
|
||||
const sql = `INSERT OR REPLACE INTO balance_ledger (id, type, date, amount, currency, direction, providerAccountId, vpsId, note) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
for (const r of data.balanceLedger) {
|
||||
const bl = typeof r === 'object' ? r : {}
|
||||
db.run(sql, bl.id ?? '', bl.type ?? '', bl.date ?? '', Number(bl.amount) || 0, bl.currency ?? '', bl.direction ?? '', bl.providerAccountId ?? '', bl.vpsId ?? '', bl.note ?? '')
|
||||
}
|
||||
}
|
||||
if (settingsList.length > 0) {
|
||||
const sql = `INSERT OR REPLACE INTO settings (id, baseCurrency, ratesUrl, autoConvert, ratesUpdatedAt, syncEnabled, syncIntervalMinutes) VALUES (?, ?, ?, ?, ?, ?, ?)`
|
||||
for (const r of settingsList) {
|
||||
const s = typeof r === 'object' ? r : {}
|
||||
db.run(sql, s.id ?? 'settings-main', s.baseCurrency ?? 'RUB', s.ratesUrl ?? '', s.autoConvert !== false ? 1 : 0, s.ratesUpdatedAt ?? '', s.syncEnabled ? 1 : 0, s.syncIntervalMinutes ?? 60)
|
||||
}
|
||||
}
|
||||
consolidateAllProviderApiSources(db)
|
||||
saveDb()
|
||||
|
||||
res.json({ ok: true })
|
||||
} catch (err) {
|
||||
console.error('Migrate error:', err)
|
||||
res.status(500).json({ error: err.message || 'Migration failed' })
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,80 @@
|
||||
import { Router } from 'express'
|
||||
import { getDb } from '../db.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
router.get('/', (req, res) => {
|
||||
try {
|
||||
const db = getDb()
|
||||
const rows = db.prepare('SELECT * FROM payments ORDER BY date DESC').all()
|
||||
res.json(rows)
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message })
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/', (req, res) => {
|
||||
try {
|
||||
const db = getDb()
|
||||
const r = req.body
|
||||
const id = r.id || `pay-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`
|
||||
db.prepare(`
|
||||
INSERT INTO payments (id, type, date, amount, currency, providerAccountId, vpsId, note)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
id,
|
||||
r.type ?? '',
|
||||
r.date ?? '',
|
||||
Number(r.amount) || 0,
|
||||
r.currency ?? '',
|
||||
r.providerAccountId ?? '',
|
||||
r.vpsId ?? '',
|
||||
r.note ?? '',
|
||||
)
|
||||
const row = db.prepare('SELECT * FROM payments WHERE id = ?').get(id)
|
||||
res.status(201).json(row)
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message })
|
||||
}
|
||||
})
|
||||
|
||||
router.put('/:id', (req, res) => {
|
||||
try {
|
||||
const db = getDb()
|
||||
const { id } = req.params
|
||||
const r = req.body
|
||||
db.prepare(`
|
||||
UPDATE payments SET
|
||||
type = ?, date = ?, amount = ?, currency = ?, providerAccountId = ?, vpsId = ?, note = ?
|
||||
WHERE id = ?
|
||||
`).run(
|
||||
r.type ?? '',
|
||||
r.date ?? '',
|
||||
Number(r.amount) || 0,
|
||||
r.currency ?? '',
|
||||
r.providerAccountId ?? '',
|
||||
r.vpsId ?? '',
|
||||
r.note ?? '',
|
||||
id,
|
||||
)
|
||||
const row = db.prepare('SELECT * FROM payments WHERE id = ?').get(id)
|
||||
if (!row) return res.status(404).json({ error: 'Not found' })
|
||||
res.json(row)
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message })
|
||||
}
|
||||
})
|
||||
|
||||
router.delete('/:id', (req, res) => {
|
||||
try {
|
||||
const db = getDb()
|
||||
const { id } = req.params
|
||||
const result = db.prepare('DELETE FROM payments WHERE id = ?').run(id)
|
||||
if (result.changes === 0) return res.status(404).json({ error: 'Not found' })
|
||||
res.status(204).send()
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message })
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,60 @@
|
||||
import { Router } from 'express'
|
||||
import { getDb } from '../db.js'
|
||||
import {
|
||||
normalizeProjectNameInput,
|
||||
projectSuggestions,
|
||||
resolveOrCreateProject,
|
||||
} from '../projects-service.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
router.get('/', (req, res) => {
|
||||
try {
|
||||
const db = getDb()
|
||||
const rows = db
|
||||
.prepare('SELECT id, name, color, sortOrder, notes, createdAt FROM server_projects ORDER BY name')
|
||||
.all()
|
||||
res.json(rows)
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message })
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/suggest', (req, res) => {
|
||||
try {
|
||||
const db = getDb()
|
||||
const q = req.query.q ?? ''
|
||||
const limit = req.query.limit != null ? Number(req.query.limit) : 20
|
||||
const rows = projectSuggestions(db, q, limit)
|
||||
res.json(rows)
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message })
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/resolve-or-create', (req, res) => {
|
||||
try {
|
||||
const db = getDb()
|
||||
const name = req.body?.name
|
||||
const resolved = resolveOrCreateProject(db, name)
|
||||
res.json(resolved)
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message })
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/', (req, res) => {
|
||||
try {
|
||||
const db = getDb()
|
||||
const name = normalizeProjectNameInput(req.body?.name)
|
||||
if (!name) {
|
||||
return res.status(400).json({ error: 'name is required' })
|
||||
}
|
||||
const resolved = resolveOrCreateProject(db, name)
|
||||
res.status(201).json(resolved)
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message })
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,105 @@
|
||||
import { Router } from 'express'
|
||||
import { getDb } from '../db.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
function sanitizeAccount(row) {
|
||||
if (!row) return row
|
||||
const { apiCredentials, ...rest } = row
|
||||
return { ...rest, apiCredentialsSet: Boolean(apiCredentials) }
|
||||
}
|
||||
|
||||
router.get('/', (req, res) => {
|
||||
try {
|
||||
const db = getDb()
|
||||
const rows = db.prepare('SELECT * FROM provider_accounts ORDER BY name').all()
|
||||
res.json(rows.map(sanitizeAccount))
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message })
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/', (req, res) => {
|
||||
try {
|
||||
const db = getDb()
|
||||
const r = req.body
|
||||
const id = r.id || `account-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`
|
||||
const alertBelow =
|
||||
r.balance_alert_below != null && r.balance_alert_below !== ''
|
||||
? Number(r.balance_alert_below)
|
||||
: null
|
||||
db.prepare(`
|
||||
INSERT INTO provider_accounts (id, providerId, name, panelUrl, currency, billingMode, notes, apiType, apiBaseUrl, apiCredentials, balance_alert_below)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, '', '', ?, ?)
|
||||
`).run(
|
||||
id,
|
||||
r.providerId ?? '',
|
||||
r.name ?? '',
|
||||
r.panelUrl ?? '',
|
||||
r.currency ?? '',
|
||||
r.billingMode ?? '',
|
||||
r.notes ?? '',
|
||||
r.apiCredentials ?? '',
|
||||
Number.isFinite(alertBelow) ? alertBelow : null,
|
||||
)
|
||||
const row = db.prepare('SELECT * FROM provider_accounts WHERE id = ?').get(id)
|
||||
res.status(201).json(sanitizeAccount(row))
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message })
|
||||
}
|
||||
})
|
||||
|
||||
router.put('/:id', (req, res) => {
|
||||
try {
|
||||
const db = getDb()
|
||||
const { id } = req.params
|
||||
const r = req.body
|
||||
const existing = db.prepare('SELECT * FROM provider_accounts WHERE id = ?').get(id)
|
||||
if (!existing) return res.status(404).json({ error: 'Not found' })
|
||||
const apiCredentials = r.apiCredentials !== undefined ? String(r.apiCredentials || '') : (existing.apiCredentials || '')
|
||||
let balanceAlertBelow = existing.balance_alert_below
|
||||
if (r.balance_alert_below !== undefined) {
|
||||
const v = r.balance_alert_below
|
||||
balanceAlertBelow =
|
||||
v === '' || v == null
|
||||
? null
|
||||
: Number.isFinite(Number(v))
|
||||
? Number(v)
|
||||
: null
|
||||
}
|
||||
db.prepare(`
|
||||
UPDATE provider_accounts SET
|
||||
providerId = ?, name = ?, panelUrl = ?, currency = ?, billingMode = ?, notes = ?,
|
||||
apiType = '', apiBaseUrl = '', apiCredentials = ?, balance_alert_below = ?
|
||||
WHERE id = ?
|
||||
`).run(
|
||||
r.providerId ?? existing.providerId ?? '',
|
||||
r.name ?? existing.name ?? '',
|
||||
r.panelUrl ?? existing.panelUrl ?? '',
|
||||
r.currency ?? existing.currency ?? '',
|
||||
r.billingMode ?? existing.billingMode ?? '',
|
||||
r.notes ?? existing.notes ?? '',
|
||||
apiCredentials,
|
||||
balanceAlertBelow,
|
||||
id,
|
||||
)
|
||||
const row = db.prepare('SELECT * FROM provider_accounts WHERE id = ?').get(id)
|
||||
res.json(sanitizeAccount(row))
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message })
|
||||
}
|
||||
})
|
||||
|
||||
router.delete('/:id', (req, res) => {
|
||||
try {
|
||||
const db = getDb()
|
||||
const { id } = req.params
|
||||
const result = db.prepare('DELETE FROM provider_accounts WHERE id = ?').run(id)
|
||||
if (result.changes === 0) return res.status(404).json({ error: 'Not found' })
|
||||
res.status(204).send()
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message })
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,89 @@
|
||||
import { Router } from 'express'
|
||||
import { getDb } from '../db.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
router.get('/', (req, res) => {
|
||||
try {
|
||||
const db = getDb()
|
||||
const rows = db.prepare('SELECT * FROM providers ORDER BY name').all()
|
||||
res.json(rows)
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message })
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/', (req, res) => {
|
||||
try {
|
||||
const db = getDb()
|
||||
const r = req.body
|
||||
const id = r.id || `provider-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`
|
||||
db.prepare(`
|
||||
INSERT INTO providers (id, name, website, contact, baseCurrency, usdRate, eurRate, notes, apiType, apiBaseUrl)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
id,
|
||||
r.name ?? '',
|
||||
r.website ?? '',
|
||||
r.contact ?? '',
|
||||
r.baseCurrency ?? '',
|
||||
r.usdRate ?? '',
|
||||
r.eurRate ?? '',
|
||||
r.notes ?? '',
|
||||
r.apiType ?? '',
|
||||
r.apiBaseUrl ?? '',
|
||||
)
|
||||
const row = db.prepare('SELECT * FROM providers WHERE id = ?').get(id)
|
||||
res.status(201).json(row)
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message })
|
||||
}
|
||||
})
|
||||
|
||||
router.put('/:id', (req, res) => {
|
||||
try {
|
||||
const db = getDb()
|
||||
const { id } = req.params
|
||||
const r = req.body
|
||||
const existing = db.prepare('SELECT * FROM providers WHERE id = ?').get(id)
|
||||
if (!existing) return res.status(404).json({ error: 'Not found' })
|
||||
const apiType = r.apiType !== undefined ? String(r.apiType || '') : (existing.apiType || '')
|
||||
const apiBaseUrl =
|
||||
r.apiBaseUrl !== undefined ? String(r.apiBaseUrl || '') : (existing.apiBaseUrl || '')
|
||||
db.prepare(`
|
||||
UPDATE providers SET
|
||||
name = ?, website = ?, contact = ?, baseCurrency = ?, usdRate = ?, eurRate = ?, notes = ?,
|
||||
apiType = ?, apiBaseUrl = ?
|
||||
WHERE id = ?
|
||||
`).run(
|
||||
r.name ?? '',
|
||||
r.website ?? '',
|
||||
r.contact ?? '',
|
||||
r.baseCurrency ?? '',
|
||||
r.usdRate ?? '',
|
||||
r.eurRate ?? '',
|
||||
r.notes ?? '',
|
||||
apiType,
|
||||
apiBaseUrl,
|
||||
id,
|
||||
)
|
||||
const row = db.prepare('SELECT * FROM providers WHERE id = ?').get(id)
|
||||
res.json(row)
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message })
|
||||
}
|
||||
})
|
||||
|
||||
router.delete('/:id', (req, res) => {
|
||||
try {
|
||||
const db = getDb()
|
||||
const { id } = req.params
|
||||
const result = db.prepare('DELETE FROM providers WHERE id = ?').run(id)
|
||||
if (result.changes === 0) return res.status(404).json({ error: 'Not found' })
|
||||
res.status(204).send()
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message })
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Router } from 'express'
|
||||
|
||||
const router = Router()
|
||||
|
||||
router.get('/', async (req, res) => {
|
||||
const url = req.query.url
|
||||
if (!url) {
|
||||
return res.status(400).json({ error: 'Missing url parameter' })
|
||||
}
|
||||
try {
|
||||
const response = await fetch(url, { signal: AbortSignal.timeout(10_000) })
|
||||
if (!response.ok) {
|
||||
return res.status(502).json({ error: `Upstream returned ${response.status}` })
|
||||
}
|
||||
const data = await response.json()
|
||||
res.json(data)
|
||||
} catch (err) {
|
||||
res.status(502).json({ error: err.message || 'Failed to fetch rates' })
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,182 @@
|
||||
import { Router } from 'express'
|
||||
import { getDb } from '../db.js'
|
||||
import { startScheduler } from '../sync-scheduler.js'
|
||||
import { sendTelegramMessage } from '../telegram.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
export function rowToSettings(row) {
|
||||
if (!row) return null
|
||||
let customFields = []
|
||||
if (row.customFields) {
|
||||
try {
|
||||
customFields = JSON.parse(row.customFields)
|
||||
} catch {
|
||||
customFields = []
|
||||
}
|
||||
}
|
||||
const { telegramBotToken, ...rest } = row
|
||||
return {
|
||||
...rest,
|
||||
telegramBotTokenSet: Boolean(telegramBotToken?.trim()),
|
||||
autoConvert: Boolean(row.autoConvert),
|
||||
syncEnabled: Boolean(row.syncEnabled),
|
||||
notifyPaymentExpiryEnabled: Boolean(row.notifyPaymentExpiryEnabled),
|
||||
notifyNewTariffsEnabled: Boolean(row.notifyNewTariffsEnabled),
|
||||
notifyLowBalanceEnabled: Boolean(row.notifyLowBalanceEnabled),
|
||||
notifySyncDigestEnabled: Boolean(row.notifySyncDigestEnabled),
|
||||
customFields: Array.isArray(customFields) ? customFields : [],
|
||||
}
|
||||
}
|
||||
|
||||
router.post('/telegram/test', async (req, res) => {
|
||||
try {
|
||||
const db = getDb()
|
||||
const row = db.prepare('SELECT telegramBotToken, telegramChatId, telegramMessageThreadId FROM settings WHERE id = ?').get('settings-main')
|
||||
if (!row?.telegramBotToken?.trim() || !row?.telegramChatId?.trim()) {
|
||||
return res.status(400).json({ ok: false, error: 'Укажите токен бота и Chat ID в настройках' })
|
||||
}
|
||||
const text = '✅ <b>Тестовое уведомление</b>\n\nVPS Tracker — уведомления настроены корректно.'
|
||||
await sendTelegramMessage(row.telegramBotToken, row.telegramChatId, text, row.telegramMessageThreadId || undefined)
|
||||
res.json({ ok: true })
|
||||
} catch (err) {
|
||||
res.status(500).json({ ok: false, error: err.message || 'Ошибка отправки' })
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/', (req, res) => {
|
||||
try {
|
||||
const db = getDb()
|
||||
const rows = db.prepare('SELECT * FROM settings ORDER BY id').all()
|
||||
res.json(rows.map(rowToSettings))
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message })
|
||||
}
|
||||
})
|
||||
|
||||
function serializeCustomFields(val) {
|
||||
if (val == null) return null
|
||||
if (Array.isArray(val)) return JSON.stringify(val)
|
||||
if (typeof val === 'string') return val || null
|
||||
return null
|
||||
}
|
||||
|
||||
router.put('/:id', (req, res) => {
|
||||
try {
|
||||
const db = getDb()
|
||||
const { id } = req.params
|
||||
const r = req.body
|
||||
const existing = db.prepare('SELECT * FROM settings WHERE id = ?').get(id)
|
||||
const syncEnabled = r.syncEnabled !== undefined ? (r.syncEnabled ? 1 : 0) : (existing?.syncEnabled ? 1 : 0)
|
||||
const syncIntervalMinutes = r.syncIntervalMinutes !== undefined ? Math.max(15, Number(r.syncIntervalMinutes) || 60) : (existing?.syncIntervalMinutes ?? 60)
|
||||
const syncTariffsIntervalMinutes = r.syncTariffsIntervalMinutes !== undefined ? Math.max(60, Number(r.syncTariffsIntervalMinutes) || 1440) : (existing?.syncTariffsIntervalMinutes ?? 1440)
|
||||
const notifyPaymentExpiryEnabled = r.notifyPaymentExpiryEnabled !== undefined ? (r.notifyPaymentExpiryEnabled ? 1 : 0) : (existing?.notifyPaymentExpiryEnabled ? 1 : 0)
|
||||
const notifyNewTariffsEnabled = r.notifyNewTariffsEnabled !== undefined ? (r.notifyNewTariffsEnabled ? 1 : 0) : (existing?.notifyNewTariffsEnabled ? 1 : 0)
|
||||
const notifyLowBalanceEnabled = r.notifyLowBalanceEnabled !== undefined ? (r.notifyLowBalanceEnabled ? 1 : 0) : (existing?.notifyLowBalanceEnabled ? 1 : 0)
|
||||
const notifySyncDigestEnabled = r.notifySyncDigestEnabled !== undefined ? (r.notifySyncDigestEnabled ? 1 : 0) : (existing?.notifySyncDigestEnabled ? 1 : 0)
|
||||
const telegramBotToken = r.telegramBotToken !== undefined ? (r.telegramBotToken || '') : (existing?.telegramBotToken ?? '')
|
||||
const telegramChatId = r.telegramChatId !== undefined ? (r.telegramChatId || '') : (existing?.telegramChatId ?? '')
|
||||
const telegramMessageThreadId = r.telegramMessageThreadId !== undefined ? (r.telegramMessageThreadId || '') : (existing?.telegramMessageThreadId ?? '')
|
||||
const customFields = serializeCustomFields(r.customFields ?? existing?.customFields)
|
||||
if (existing) {
|
||||
db.prepare(`
|
||||
UPDATE settings SET
|
||||
baseCurrency = ?, ratesUrl = ?, autoConvert = ?, ratesUpdatedAt = ?, syncEnabled = ?, syncIntervalMinutes = ?, syncTariffsIntervalMinutes = ?,
|
||||
telegramBotToken = ?, telegramChatId = ?, telegramMessageThreadId = ?, notifyPaymentExpiryEnabled = ?, notifyNewTariffsEnabled = ?, customFields = ?,
|
||||
notifyLowBalanceEnabled = ?, notifySyncDigestEnabled = ?
|
||||
WHERE id = ?
|
||||
`).run(
|
||||
r.baseCurrency ?? existing.baseCurrency ?? 'RUB',
|
||||
r.ratesUrl ?? existing.ratesUrl ?? '',
|
||||
r.autoConvert !== undefined ? (r.autoConvert !== false ? 1 : 0) : (existing.autoConvert ? 1 : 0),
|
||||
r.ratesUpdatedAt ?? existing.ratesUpdatedAt ?? '',
|
||||
syncEnabled,
|
||||
syncIntervalMinutes,
|
||||
syncTariffsIntervalMinutes,
|
||||
telegramBotToken,
|
||||
telegramChatId,
|
||||
telegramMessageThreadId,
|
||||
notifyPaymentExpiryEnabled,
|
||||
notifyNewTariffsEnabled,
|
||||
customFields,
|
||||
notifyLowBalanceEnabled,
|
||||
notifySyncDigestEnabled,
|
||||
id,
|
||||
)
|
||||
} else {
|
||||
db.prepare(`
|
||||
INSERT INTO settings (id, baseCurrency, ratesUrl, autoConvert, ratesUpdatedAt, syncEnabled, syncIntervalMinutes, syncTariffsIntervalMinutes, telegramBotToken, telegramChatId, telegramMessageThreadId, notifyPaymentExpiryEnabled, notifyNewTariffsEnabled, customFields, notifyLowBalanceEnabled, notifySyncDigestEnabled)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
id,
|
||||
r.baseCurrency ?? 'RUB',
|
||||
r.ratesUrl ?? '',
|
||||
r.autoConvert !== false ? 1 : 0,
|
||||
r.ratesUpdatedAt ?? '',
|
||||
syncEnabled,
|
||||
syncIntervalMinutes,
|
||||
syncTariffsIntervalMinutes,
|
||||
telegramBotToken,
|
||||
telegramChatId,
|
||||
telegramMessageThreadId,
|
||||
notifyPaymentExpiryEnabled,
|
||||
notifyNewTariffsEnabled,
|
||||
customFields,
|
||||
notifyLowBalanceEnabled,
|
||||
notifySyncDigestEnabled,
|
||||
)
|
||||
}
|
||||
startScheduler()
|
||||
const row = db.prepare('SELECT * FROM settings WHERE id = ?').get(id)
|
||||
res.json(rowToSettings(row))
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message })
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/', (req, res) => {
|
||||
try {
|
||||
const db = getDb()
|
||||
const r = req.body
|
||||
const id = r.id ?? 'settings-main'
|
||||
const syncEnabled = r.syncEnabled ? 1 : 0
|
||||
const syncIntervalMinutes = Math.max(15, Number(r.syncIntervalMinutes) || 60)
|
||||
const syncTariffsIntervalMinutes = Math.max(60, Number(r.syncTariffsIntervalMinutes) || 1440)
|
||||
const notifyPaymentExpiryEnabled = r.notifyPaymentExpiryEnabled ? 1 : 0
|
||||
const notifyNewTariffsEnabled = r.notifyNewTariffsEnabled ? 1 : 0
|
||||
const notifyLowBalanceEnabled = r.notifyLowBalanceEnabled ? 1 : 0
|
||||
const notifySyncDigestEnabled = r.notifySyncDigestEnabled ? 1 : 0
|
||||
const telegramBotToken = r.telegramBotToken ?? ''
|
||||
const telegramChatId = r.telegramChatId ?? ''
|
||||
const telegramMessageThreadId = r.telegramMessageThreadId ?? ''
|
||||
const customFields = serializeCustomFields(r.customFields)
|
||||
db.prepare(`
|
||||
INSERT INTO settings (id, baseCurrency, ratesUrl, autoConvert, ratesUpdatedAt, syncEnabled, syncIntervalMinutes, syncTariffsIntervalMinutes, telegramBotToken, telegramChatId, telegramMessageThreadId, notifyPaymentExpiryEnabled, notifyNewTariffsEnabled, customFields, notifyLowBalanceEnabled, notifySyncDigestEnabled)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
id,
|
||||
r.baseCurrency ?? 'RUB',
|
||||
r.ratesUrl ?? 'https://www.cbr-xml-daily.ru/latest.js',
|
||||
r.autoConvert !== false ? 1 : 0,
|
||||
r.ratesUpdatedAt ?? '',
|
||||
syncEnabled,
|
||||
syncIntervalMinutes,
|
||||
syncTariffsIntervalMinutes,
|
||||
telegramBotToken,
|
||||
telegramChatId,
|
||||
telegramMessageThreadId,
|
||||
notifyPaymentExpiryEnabled,
|
||||
notifyNewTariffsEnabled,
|
||||
customFields,
|
||||
notifyLowBalanceEnabled,
|
||||
notifySyncDigestEnabled,
|
||||
)
|
||||
startScheduler()
|
||||
const row = db.prepare('SELECT * FROM settings WHERE id = ?').get(id)
|
||||
res.status(201).json(rowToSettings(row))
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message })
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,120 @@
|
||||
import { Router } from 'express'
|
||||
import { getDb } from '../db.js'
|
||||
import { fetchDashboardInfo, testConnection } from '../adapters/billmanager/index.js'
|
||||
import { runBillmanagerAccountSync } from '../sync-account-job.js'
|
||||
import { billmanagerAccountRowForSync } from '../utils/billmanager-context.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
router.post('/test-connection', async (req, res) => {
|
||||
try {
|
||||
const { apiBaseUrl, apiCredentials } = req.body || {}
|
||||
if (!apiBaseUrl?.trim() || !apiCredentials?.trim()) {
|
||||
return res.status(400).json({ ok: false, error: 'Укажите URL и учётные данные' })
|
||||
}
|
||||
const result = await testConnection(apiBaseUrl.trim(), apiCredentials.trim())
|
||||
res.json(result)
|
||||
} catch (err) {
|
||||
res.status(500).json({ ok: false, error: err.message || 'Ошибка проверки' })
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/status', (req, res) => {
|
||||
try {
|
||||
const db = getDb()
|
||||
const rows = db.prepare(`
|
||||
SELECT id, accountId, startedAt, finishedAt, status, vpsCount, paymentsCount, error, summary
|
||||
FROM sync_log ORDER BY startedAt DESC LIMIT 50
|
||||
`).all()
|
||||
res.json(
|
||||
rows.map((row) => {
|
||||
let summaryParsed = null
|
||||
if (row.summary) {
|
||||
try {
|
||||
summaryParsed = JSON.parse(row.summary)
|
||||
} catch {
|
||||
summaryParsed = null
|
||||
}
|
||||
}
|
||||
return { ...row, summary: summaryParsed }
|
||||
}),
|
||||
)
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message })
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/:accountId/balance', async (req, res) => {
|
||||
try {
|
||||
const db = getDb()
|
||||
const { accountId } = req.params
|
||||
const row = db.prepare('SELECT * FROM provider_accounts WHERE id = ?').get(accountId)
|
||||
if (!row) {
|
||||
return res.status(404).json({ error: 'Account not found' })
|
||||
}
|
||||
const provider = row.providerId
|
||||
? db.prepare('SELECT * FROM providers WHERE id = ?').get(row.providerId)
|
||||
: null
|
||||
const syncRow = billmanagerAccountRowForSync(row, provider)
|
||||
if (!syncRow) {
|
||||
return res.status(400).json({
|
||||
error:
|
||||
'Укажите в настройках хостера тип API BILLmanager и URL; в аккаунте — логин и пароль API',
|
||||
})
|
||||
}
|
||||
const info = await fetchDashboardInfo(syncRow.apiBaseUrl, syncRow.apiCredentials.trim(), {
|
||||
fallbackCurrency: row.currency,
|
||||
})
|
||||
db.run(
|
||||
'UPDATE provider_accounts SET balance_api=?, balance_currency=?, balance_updated_at=?, enoughmoneyto=? WHERE id=?',
|
||||
info.balance,
|
||||
info.currency || 'RUB',
|
||||
new Date().toISOString(),
|
||||
info.enoughmoneyto || '',
|
||||
accountId,
|
||||
)
|
||||
res.json({ ok: true, balance: info })
|
||||
} catch (err) {
|
||||
console.error('Balance fetch error:', err)
|
||||
res.status(500).json({ ok: false, error: err.message || 'Failed to fetch balance' })
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/:accountId', async (req, res) => {
|
||||
try {
|
||||
const db = getDb()
|
||||
const { accountId } = req.params
|
||||
const { onlyTariffs = false } = req.body || {}
|
||||
const row = db.prepare('SELECT * FROM provider_accounts WHERE id = ?').get(accountId)
|
||||
if (!row) {
|
||||
return res.status(404).json({ error: 'Account not found' })
|
||||
}
|
||||
const provider = row.providerId
|
||||
? db.prepare('SELECT * FROM providers WHERE id = ?').get(row.providerId)
|
||||
: null
|
||||
const syncRow = billmanagerAccountRowForSync(row, provider)
|
||||
if (!syncRow) {
|
||||
return res.status(400).json({
|
||||
error:
|
||||
'Укажите в настройках хостера тип API BILLmanager и URL; в аккаунте — логин и пароль API',
|
||||
})
|
||||
}
|
||||
|
||||
const opts = onlyTariffs ? { skipVpsPayments: true } : { skipTariffs: true }
|
||||
const result = await runBillmanagerAccountSync(syncRow, opts)
|
||||
|
||||
res.json({
|
||||
ok: true,
|
||||
synced: {
|
||||
vpsCount: result.vpsCount,
|
||||
paymentsCount: result.paymentsCount,
|
||||
tariffsCount: result.tariffsCount ?? 0,
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('Sync error:', err)
|
||||
res.status(500).json({ ok: false, error: err.message || 'Sync failed' })
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,304 @@
|
||||
import { Router } from 'express'
|
||||
import { getDb } from '../db.js'
|
||||
import { resolveOrCreateProject } from '../projects-service.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
function projectColumnsForSave(db, projectInput) {
|
||||
const resolved = resolveOrCreateProject(db, projectInput)
|
||||
if (!resolved.id) {
|
||||
return { project: '', projectId: '' }
|
||||
}
|
||||
return { project: resolved.name, projectId: resolved.id }
|
||||
}
|
||||
|
||||
export function rowToVps(row) {
|
||||
if (!row) return null
|
||||
let additionalIps = []
|
||||
try {
|
||||
additionalIps = row.additionalIps ? JSON.parse(row.additionalIps) : []
|
||||
} catch {
|
||||
additionalIps = []
|
||||
}
|
||||
let userOverrides = []
|
||||
try {
|
||||
userOverrides = row.userOverrides ? JSON.parse(row.userOverrides) : []
|
||||
} catch {
|
||||
userOverrides = []
|
||||
}
|
||||
return {
|
||||
...row,
|
||||
additionalIps,
|
||||
userOverrides,
|
||||
projectId: row.projectId ?? '',
|
||||
monitoringEnabled: Boolean(row.monitoringEnabled),
|
||||
backupEnabled: Boolean(row.backupEnabled),
|
||||
dailyRate: row.dailyRate != null ? row.dailyRate : '',
|
||||
monthlyRate: row.monthlyRate != null ? row.monthlyRate : '',
|
||||
}
|
||||
}
|
||||
|
||||
router.get('/', (req, res) => {
|
||||
try {
|
||||
const db = getDb()
|
||||
const rows = db.prepare('SELECT * FROM vps ORDER BY createdAt DESC').all()
|
||||
res.json(rows.map(rowToVps))
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message })
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/', (req, res) => {
|
||||
try {
|
||||
const db = getDb()
|
||||
const r = req.body
|
||||
const id = r.id || `vps-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`
|
||||
const additionalIps = Array.isArray(r.additionalIps) ? JSON.stringify(r.additionalIps) : '[]'
|
||||
const dailyRate = r.dailyRate === '' || r.dailyRate == null ? null : Number(r.dailyRate)
|
||||
const monthlyRate = r.monthlyRate === '' || r.monthlyRate == null ? null : Number(r.monthlyRate)
|
||||
const { project, projectId } = projectColumnsForSave(db, r.project)
|
||||
|
||||
db.prepare(`
|
||||
INSERT INTO vps (
|
||||
id, ip, ipv6, additionalIps, dns, providerId, providerAccountId, country, city, datacenter,
|
||||
os, vcpu, ramGb, diskGb, diskType, virtualization, bandwidthTb, sshPort, rootUser,
|
||||
purpose, environment, project, projectId, monitoringEnabled, backupEnabled, status, tariffType,
|
||||
currency, dailyRate, monthlyRate, createdAt, paidUntil, notes, userOverrides
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
id,
|
||||
r.ip ?? '',
|
||||
r.ipv6 ?? '',
|
||||
additionalIps,
|
||||
r.dns ?? '',
|
||||
r.providerId ?? '',
|
||||
r.providerAccountId ?? '',
|
||||
r.country ?? '',
|
||||
r.city ?? '',
|
||||
r.datacenter ?? '',
|
||||
r.os ?? '',
|
||||
r.vcpu ?? 0,
|
||||
r.ramGb ?? 0,
|
||||
r.diskGb ?? 0,
|
||||
r.diskType ?? '',
|
||||
r.virtualization ?? '',
|
||||
r.bandwidthTb ?? 0,
|
||||
r.sshPort ?? 22,
|
||||
r.rootUser ?? '',
|
||||
r.purpose ?? '',
|
||||
r.environment ?? '',
|
||||
project,
|
||||
projectId || null,
|
||||
r.monitoringEnabled ? 1 : 0,
|
||||
r.backupEnabled ? 1 : 0,
|
||||
r.status ?? 'active',
|
||||
r.tariffType ?? '',
|
||||
r.currency ?? '',
|
||||
dailyRate,
|
||||
monthlyRate,
|
||||
r.createdAt ?? new Date().toISOString().slice(0, 10),
|
||||
r.paidUntil ?? '',
|
||||
r.notes ?? '',
|
||||
r.userOverrides ? (Array.isArray(r.userOverrides) ? JSON.stringify(r.userOverrides) : r.userOverrides) : '[]',
|
||||
)
|
||||
const row = db.prepare('SELECT * FROM vps WHERE id = ?').get(id)
|
||||
res.status(201).json(rowToVps(row))
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message })
|
||||
}
|
||||
})
|
||||
|
||||
const USER_OVERRIDABLE_FIELDS = ['country', 'city', 'datacenter', 'os', 'vcpu', 'ramGb', 'diskGb', 'diskType', 'virtualization', 'purpose', 'environment', 'project', 'notes', 'sshPort', 'rootUser', 'bandwidthTb', 'monitoringEnabled', 'backupEnabled']
|
||||
|
||||
router.put('/:id', (req, res) => {
|
||||
try {
|
||||
const db = getDb()
|
||||
const { id } = req.params
|
||||
const r = req.body
|
||||
const existing = db.prepare('SELECT * FROM vps WHERE id = ?').get(id)
|
||||
if (!existing) return res.status(404).json({ error: 'Not found' })
|
||||
|
||||
let userOverrides = []
|
||||
try {
|
||||
userOverrides = existing.userOverrides ? JSON.parse(existing.userOverrides) : []
|
||||
} catch {
|
||||
userOverrides = []
|
||||
}
|
||||
const clearOverrides =
|
||||
r.userOverrides === 'clear' || (Array.isArray(r.userOverrides) && r.userOverrides.length === 0)
|
||||
if (clearOverrides) {
|
||||
userOverrides = []
|
||||
}
|
||||
|
||||
const additionalIps = Array.isArray(r.additionalIps) ? JSON.stringify(r.additionalIps) : '[]'
|
||||
const dailyRate = r.dailyRate === '' || r.dailyRate == null ? null : Number(r.dailyRate)
|
||||
const monthlyRate = r.monthlyRate === '' || r.monthlyRate == null ? null : Number(r.monthlyRate)
|
||||
|
||||
let projectOut = existing.project ?? ''
|
||||
let projectIdOut = existing.projectId ?? ''
|
||||
if (r.project !== undefined) {
|
||||
const resolved = projectColumnsForSave(db, r.project)
|
||||
projectOut = resolved.project
|
||||
projectIdOut = resolved.projectId
|
||||
} else if (r.projectId !== undefined) {
|
||||
if (!r.projectId) {
|
||||
projectOut = ''
|
||||
projectIdOut = ''
|
||||
} else {
|
||||
const prow = db.prepare('SELECT name FROM server_projects WHERE id = ?').get(r.projectId)
|
||||
projectOut = prow?.name ?? ''
|
||||
projectIdOut = r.projectId
|
||||
}
|
||||
}
|
||||
|
||||
if (!clearOverrides) {
|
||||
for (const f of USER_OVERRIDABLE_FIELDS) {
|
||||
if (f === 'project') {
|
||||
const projectChanged =
|
||||
String(projectOut ?? '') !== String(existing.project ?? '') ||
|
||||
String(projectIdOut ?? '') !== String(existing.projectId ?? '')
|
||||
if (projectChanged && !userOverrides.includes('project')) {
|
||||
userOverrides.push('project')
|
||||
}
|
||||
continue
|
||||
}
|
||||
const newVal = r[f]
|
||||
const oldVal = existing[f]
|
||||
const changed = String(newVal ?? '') !== String(oldVal ?? '')
|
||||
if (changed && !userOverrides.includes(f)) {
|
||||
userOverrides.push(f)
|
||||
}
|
||||
}
|
||||
}
|
||||
const userOverridesJson = JSON.stringify([...new Set(userOverrides)])
|
||||
|
||||
db.prepare(`
|
||||
UPDATE vps SET
|
||||
ip = ?, ipv6 = ?, additionalIps = ?, dns = ?, providerId = ?, providerAccountId = ?,
|
||||
country = ?, city = ?, datacenter = ?, os = ?, vcpu = ?, ramGb = ?, diskGb = ?, diskType = ?,
|
||||
virtualization = ?, bandwidthTb = ?, sshPort = ?, rootUser = ?, purpose = ?, environment = ?,
|
||||
project = ?, projectId = ?, monitoringEnabled = ?, backupEnabled = ?, status = ?, tariffType = ?,
|
||||
currency = ?, dailyRate = ?, monthlyRate = ?, createdAt = ?, paidUntil = ?, notes = ?,
|
||||
userOverrides = ?
|
||||
WHERE id = ?
|
||||
`).run(
|
||||
r.ip ?? '',
|
||||
r.ipv6 ?? '',
|
||||
additionalIps,
|
||||
r.dns ?? '',
|
||||
r.providerId ?? '',
|
||||
r.providerAccountId ?? '',
|
||||
r.country ?? '',
|
||||
r.city ?? '',
|
||||
r.datacenter ?? '',
|
||||
r.os ?? '',
|
||||
r.vcpu ?? 0,
|
||||
r.ramGb ?? 0,
|
||||
r.diskGb ?? 0,
|
||||
r.diskType ?? '',
|
||||
r.virtualization ?? '',
|
||||
r.bandwidthTb ?? 0,
|
||||
r.sshPort ?? 22,
|
||||
r.rootUser ?? '',
|
||||
r.purpose ?? '',
|
||||
r.environment ?? '',
|
||||
projectOut,
|
||||
projectIdOut || null,
|
||||
r.monitoringEnabled ? 1 : 0,
|
||||
r.backupEnabled ? 1 : 0,
|
||||
r.status ?? 'active',
|
||||
r.tariffType ?? '',
|
||||
r.currency ?? '',
|
||||
dailyRate,
|
||||
monthlyRate,
|
||||
r.createdAt ?? '',
|
||||
r.paidUntil ?? '',
|
||||
r.notes ?? '',
|
||||
userOverridesJson,
|
||||
id,
|
||||
)
|
||||
const row = db.prepare('SELECT * FROM vps WHERE id = ?').get(id)
|
||||
if (!row) return res.status(404).json({ error: 'Not found' })
|
||||
res.json(rowToVps(row))
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message })
|
||||
}
|
||||
})
|
||||
|
||||
router.delete('/:id', (req, res) => {
|
||||
try {
|
||||
const db = getDb()
|
||||
const { id } = req.params
|
||||
const result = db.prepare('DELETE FROM vps WHERE id = ?').run(id)
|
||||
if (result.changes === 0) return res.status(404).json({ error: 'Not found' })
|
||||
res.status(204).send()
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message })
|
||||
}
|
||||
})
|
||||
|
||||
router.patch('/bulk', (req, res) => {
|
||||
try {
|
||||
const db = getDb()
|
||||
const { ids = [], action, value } = req.body
|
||||
if (!Array.isArray(ids) || ids.length === 0) {
|
||||
return res.status(400).json({ error: 'ids must be a non-empty array' })
|
||||
}
|
||||
if (action === 'status' && value) {
|
||||
const validStatus = ['active', 'paused', 'archived']
|
||||
if (!validStatus.includes(value)) {
|
||||
return res.status(400).json({ error: 'value must be active, paused, or archived' })
|
||||
}
|
||||
const stmt = db.prepare('UPDATE vps SET status = ? WHERE id = ?')
|
||||
for (const id of ids) {
|
||||
stmt.run(value, id)
|
||||
}
|
||||
return res.json({ updated: ids.length, status: value })
|
||||
}
|
||||
if (action === 'delete') {
|
||||
const stmt = db.prepare('DELETE FROM vps WHERE id = ?')
|
||||
let deleted = 0
|
||||
for (const id of ids) {
|
||||
const result = stmt.run(id)
|
||||
if (result.changes > 0) deleted++
|
||||
}
|
||||
return res.json({ deleted })
|
||||
}
|
||||
if (action === 'project') {
|
||||
const projectValue = value == null ? '' : String(value)
|
||||
const { project: projName, projectId: projId } = projectColumnsForSave(db, projectValue)
|
||||
const getStmt = db.prepare('SELECT * FROM vps WHERE id = ?')
|
||||
const updStmt = db.prepare(
|
||||
'UPDATE vps SET project = ?, projectId = ?, userOverrides = ? WHERE id = ?',
|
||||
)
|
||||
let updated = 0
|
||||
for (const id of ids) {
|
||||
const existing = getStmt.get(id)
|
||||
if (!existing) continue
|
||||
if (
|
||||
String(existing.project ?? '') === projName &&
|
||||
String(existing.projectId ?? '') === String(projId ?? '')
|
||||
) {
|
||||
continue
|
||||
}
|
||||
let userOverrides = []
|
||||
try {
|
||||
userOverrides = existing.userOverrides ? JSON.parse(existing.userOverrides) : []
|
||||
} catch {
|
||||
userOverrides = []
|
||||
}
|
||||
if (!userOverrides.includes('project')) {
|
||||
userOverrides.push('project')
|
||||
}
|
||||
updStmt.run(projName, projId || null, JSON.stringify([...new Set(userOverrides)]), id)
|
||||
updated++
|
||||
}
|
||||
return res.json({ updated, project: projName, projectId: projId })
|
||||
}
|
||||
return res.status(400).json({ error: 'action must be status, delete, or project' })
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message })
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,83 @@
|
||||
import Fastify from 'fastify'
|
||||
import cors from '@fastify/cors'
|
||||
import sensible from '@fastify/sensible'
|
||||
import staticPlugin from '@fastify/static'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import { getDb } from '@cfdm/db'
|
||||
|
||||
import { dataRoutes } from './routes/data.js'
|
||||
import { vpsRoutes } from './routes/vps.js'
|
||||
import { providersRoutes } from './routes/providers.js'
|
||||
import { providerAccountsRoutes } from './routes/provider-accounts.js'
|
||||
import { paymentsRoutes } from './routes/payments.js'
|
||||
import { balanceLedgerRoutes } from './routes/balance-ledger.js'
|
||||
import { settingsRoutes } from './routes/settings.js'
|
||||
import { syncRoutes } from './routes/sync.js'
|
||||
import { projectsRoutes } from './routes/projects.js'
|
||||
import { backupRoutes } from './routes/backup.js'
|
||||
import { ratesProxyRoutes } from './routes/rates-proxy.js'
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
export interface BuildAppOptions {
|
||||
dbPath?: string
|
||||
staticDir?: string
|
||||
}
|
||||
|
||||
export async function buildApp(opts: BuildAppOptions = {}) {
|
||||
if (opts.dbPath) process.env.DB_PATH = opts.dbPath
|
||||
getDb()
|
||||
|
||||
const app = Fastify({
|
||||
logger: process.env.NODE_ENV !== 'production',
|
||||
})
|
||||
|
||||
await app.register(cors, { origin: true })
|
||||
await app.register(sensible)
|
||||
|
||||
await app.register(dataRoutes)
|
||||
await app.register(vpsRoutes)
|
||||
await app.register(providersRoutes)
|
||||
await app.register(providerAccountsRoutes)
|
||||
await app.register(paymentsRoutes)
|
||||
await app.register(balanceLedgerRoutes)
|
||||
await app.register(settingsRoutes)
|
||||
await app.register(syncRoutes)
|
||||
await app.register(projectsRoutes)
|
||||
await app.register(backupRoutes)
|
||||
await app.register(ratesProxyRoutes)
|
||||
|
||||
const staticDir = opts.staticDir ?? join(__dirname, '..', '..', 'web', 'dist')
|
||||
if (existsSync(staticDir)) {
|
||||
await app.register(staticPlugin, {
|
||||
root: staticDir,
|
||||
prefix: '/',
|
||||
wildcard: false,
|
||||
})
|
||||
app.setNotFoundHandler((req, reply) => {
|
||||
if (req.url.startsWith('/api')) {
|
||||
reply.code(404).send({ error: { code: 'NOT_FOUND', message: 'Not found' } })
|
||||
return
|
||||
}
|
||||
reply.sendFile('index.html')
|
||||
})
|
||||
}
|
||||
|
||||
return app
|
||||
}
|
||||
|
||||
async function start() {
|
||||
const port = Number(process.env.PORT ?? 3001)
|
||||
const app = await buildApp()
|
||||
try {
|
||||
await app.listen({ port, host: '0.0.0.0' })
|
||||
} catch (err) {
|
||||
app.log.error(err)
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
void start()
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { FastifyPluginAsync } from 'fastify'
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import { getDbPath } from '@cfdm/db'
|
||||
import { getSnapshot } from '@cfdm/db/repositories/snapshot'
|
||||
|
||||
const BACKUP_VERSION = 1
|
||||
|
||||
export const backupRoutes: FastifyPluginAsync = async (app) => {
|
||||
app.get('/api/backup/json', async (_req, reply) => {
|
||||
const snapshot = { backupVersion: BACKUP_VERSION, exportedAt: new Date().toISOString(), ...getSnapshot() }
|
||||
reply.header('Content-Type', 'application/json; charset=utf-8')
|
||||
reply.header('Content-Disposition', 'attachment; filename="vps-tracker-backup.json"')
|
||||
return reply.send(JSON.stringify(snapshot, null, 2))
|
||||
})
|
||||
|
||||
app.get('/api/backup/database', async (_req, reply) => {
|
||||
const dbPath = getDbPath()
|
||||
if (!existsSync(dbPath)) {
|
||||
return reply.code(404).send({ error: { code: 'NOT_FOUND', message: 'Файл базы не найден' } })
|
||||
}
|
||||
const buf = readFileSync(dbPath)
|
||||
reply.header('Content-Type', 'application/octet-stream')
|
||||
reply.header('Content-Disposition', 'attachment; filename="vps-tracker.db"')
|
||||
return reply.send(buf)
|
||||
})
|
||||
|
||||
app.post('/api/backup/json', async (req, reply) => {
|
||||
const payload = req.body
|
||||
if (!payload || typeof payload !== 'object') {
|
||||
return reply.code(400).send({ error: { code: 'VALIDATION', message: 'Неверное тело запроса' } })
|
||||
}
|
||||
// TODO: implement JSON snapshot import via repositories
|
||||
return reply.code(501).send({ error: { code: 'NOT_IMPLEMENTED', message: 'JSON import pending migration' } })
|
||||
})
|
||||
|
||||
app.post('/api/backup/database', async (req, reply) => {
|
||||
const buf = req.body as Buffer
|
||||
if (!buf || !buf.length) {
|
||||
return reply.code(400).send({ error: { code: 'VALIDATION', message: 'Пустой файл' } })
|
||||
}
|
||||
// TODO: implement DB restore via better-sqlite3 backup API
|
||||
return reply.code(501).send({ error: { code: 'NOT_IMPLEMENTED', message: 'DB restore pending migration' } })
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { FastifyPluginAsync } from 'fastify'
|
||||
import { balanceLedgerRepository } from '@cfdm/db/repositories/balance-ledger'
|
||||
import { balanceLedgerSchema } from '@cfdm/shared/contracts/balance-ledger'
|
||||
|
||||
export const balanceLedgerRoutes: FastifyPluginAsync = async (app) => {
|
||||
app.get('/api/balance-ledger', async () => balanceLedgerRepository.list())
|
||||
|
||||
app.post('/api/balance-ledger', async (req, reply) => {
|
||||
const parsed = balanceLedgerSchema.safeParse(req.body)
|
||||
if (!parsed.success) {
|
||||
return reply.code(400).send({ error: { code: 'VALIDATION', message: parsed.error.message } })
|
||||
}
|
||||
return reply.code(201).send(balanceLedgerRepository.create(parsed.data))
|
||||
})
|
||||
|
||||
app.put<{ Params: { id: string } }>('/api/balance-ledger/:id', async (req, reply) => {
|
||||
const parsed = balanceLedgerSchema.partial().safeParse(req.body)
|
||||
if (!parsed.success) {
|
||||
return reply.code(400).send({ error: { code: 'VALIDATION', message: parsed.error.message } })
|
||||
}
|
||||
const updated = balanceLedgerRepository.update(req.params.id, parsed.data)
|
||||
if (!updated) {
|
||||
return reply.code(404).send({ error: { code: 'NOT_FOUND', message: 'Not found' } })
|
||||
}
|
||||
return updated
|
||||
})
|
||||
|
||||
app.delete<{ Params: { id: string } }>('/api/balance-ledger/:id', async (req, reply) => {
|
||||
const ok = balanceLedgerRepository.delete(req.params.id)
|
||||
if (!ok) {
|
||||
return reply.code(404).send({ error: { code: 'NOT_FOUND', message: 'Not found' } })
|
||||
}
|
||||
return reply.code(204).send()
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { FastifyPluginAsync } from 'fastify'
|
||||
import { getSnapshot } from '@cfdm/db/repositories/snapshot'
|
||||
|
||||
export const dataRoutes: FastifyPluginAsync = async (app) => {
|
||||
app.get('/api/data', async () => getSnapshot())
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { FastifyPluginAsync } from 'fastify'
|
||||
import { paymentsRepository } from '@cfdm/db/repositories/payments'
|
||||
import { paymentSchema } from '@cfdm/shared/contracts/payment'
|
||||
|
||||
export const paymentsRoutes: FastifyPluginAsync = async (app) => {
|
||||
app.get('/api/payments', async () => paymentsRepository.list())
|
||||
|
||||
app.post('/api/payments', async (req, reply) => {
|
||||
const parsed = paymentSchema.safeParse(req.body)
|
||||
if (!parsed.success) {
|
||||
return reply.code(400).send({ error: { code: 'VALIDATION', message: parsed.error.message } })
|
||||
}
|
||||
return reply.code(201).send(paymentsRepository.create(parsed.data))
|
||||
})
|
||||
|
||||
app.put<{ Params: { id: string } }>('/api/payments/:id', async (req, reply) => {
|
||||
const parsed = paymentSchema.partial().safeParse(req.body)
|
||||
if (!parsed.success) {
|
||||
return reply.code(400).send({ error: { code: 'VALIDATION', message: parsed.error.message } })
|
||||
}
|
||||
const updated = paymentsRepository.update(req.params.id, parsed.data)
|
||||
if (!updated) {
|
||||
return reply.code(404).send({ error: { code: 'NOT_FOUND', message: 'Not found' } })
|
||||
}
|
||||
return updated
|
||||
})
|
||||
|
||||
app.delete<{ Params: { id: string } }>('/api/payments/:id', async (req, reply) => {
|
||||
const ok = paymentsRepository.delete(req.params.id)
|
||||
if (!ok) {
|
||||
return reply.code(404).send({ error: { code: 'NOT_FOUND', message: 'Not found' } })
|
||||
}
|
||||
return reply.code(204).send()
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { FastifyPluginAsync } from 'fastify'
|
||||
import {
|
||||
projectsRepository,
|
||||
projectSuggestions,
|
||||
resolveOrCreateProject,
|
||||
normalizeProjectNameInput,
|
||||
} from '@cfdm/db/repositories/projects'
|
||||
|
||||
export const projectsRoutes: FastifyPluginAsync = async (app) => {
|
||||
app.get('/api/projects', async () => projectsRepository.list())
|
||||
|
||||
app.get('/api/projects/suggest', async (req) => {
|
||||
const q = (req.query as { q?: string })?.q ?? ''
|
||||
const limit = (req.query as { limit?: string })?.limit
|
||||
return projectSuggestions(q, limit ? Number(limit) : 20)
|
||||
})
|
||||
|
||||
app.post('/api/projects/resolve-or-create', async (req) => {
|
||||
const name = (req.body as { name?: unknown })?.name
|
||||
return resolveOrCreateProject(name)
|
||||
})
|
||||
|
||||
app.post('/api/projects', async (req, reply) => {
|
||||
const name = normalizeProjectNameInput((req.body as { name?: unknown })?.name)
|
||||
if (!name) {
|
||||
return reply.code(400).send({ error: { code: 'VALIDATION', message: 'name is required' } })
|
||||
}
|
||||
return reply.code(201).send(resolveOrCreateProject(name))
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { FastifyPluginAsync } from 'fastify'
|
||||
import { providerAccountsRepository } from '@cfdm/db/repositories/provider-accounts'
|
||||
import { providerAccountSchema } from '@cfdm/shared/contracts/provider-account'
|
||||
|
||||
export const providerAccountsRoutes: FastifyPluginAsync = async (app) => {
|
||||
app.get('/api/provider-accounts', async () => providerAccountsRepository.list())
|
||||
|
||||
app.post('/api/provider-accounts', async (req, reply) => {
|
||||
const parsed = providerAccountSchema.safeParse(req.body)
|
||||
if (!parsed.success) {
|
||||
return reply.code(400).send({ error: { code: 'VALIDATION', message: parsed.error.message } })
|
||||
}
|
||||
const created = providerAccountsRepository.create(parsed.data)
|
||||
return reply.code(201).send(created)
|
||||
})
|
||||
|
||||
app.put<{ Params: { id: string } }>('/api/provider-accounts/:id', async (req, reply) => {
|
||||
const parsed = providerAccountSchema.partial().safeParse(req.body)
|
||||
if (!parsed.success) {
|
||||
return reply.code(400).send({ error: { code: 'VALIDATION', message: parsed.error.message } })
|
||||
}
|
||||
const updated = providerAccountsRepository.update(req.params.id, parsed.data)
|
||||
if (!updated) {
|
||||
return reply.code(404).send({ error: { code: 'NOT_FOUND', message: 'Not found' } })
|
||||
}
|
||||
return updated
|
||||
})
|
||||
|
||||
app.delete<{ Params: { id: string } }>('/api/provider-accounts/:id', async (req, reply) => {
|
||||
const ok = providerAccountsRepository.delete(req.params.id)
|
||||
if (!ok) {
|
||||
return reply.code(404).send({ error: { code: 'NOT_FOUND', message: 'Not found' } })
|
||||
}
|
||||
return reply.code(204).send()
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { FastifyPluginAsync } from 'fastify'
|
||||
import { providersRepository } from '@cfdm/db/repositories/providers'
|
||||
import { providerSchema } from '@cfdm/shared/contracts/provider'
|
||||
|
||||
export const providersRoutes: FastifyPluginAsync = async (app) => {
|
||||
app.get('/api/providers', async () => providersRepository.list())
|
||||
|
||||
app.post('/api/providers', async (req, reply) => {
|
||||
const parsed = providerSchema.safeParse(req.body)
|
||||
if (!parsed.success) {
|
||||
return reply.code(400).send({ error: { code: 'VALIDATION', message: parsed.error.message } })
|
||||
}
|
||||
const created = providersRepository.create(parsed.data)
|
||||
return reply.code(201).send(created)
|
||||
})
|
||||
|
||||
app.put<{ Params: { id: string } }>('/api/providers/:id', async (req, reply) => {
|
||||
const parsed = providerSchema.partial().safeParse(req.body)
|
||||
if (!parsed.success) {
|
||||
return reply.code(400).send({ error: { code: 'VALIDATION', message: parsed.error.message } })
|
||||
}
|
||||
const updated = providersRepository.update(req.params.id, parsed.data)
|
||||
if (!updated) {
|
||||
return reply.code(404).send({ error: { code: 'NOT_FOUND', message: 'Not found' } })
|
||||
}
|
||||
return updated
|
||||
})
|
||||
|
||||
app.delete<{ Params: { id: string } }>('/api/providers/:id', async (req, reply) => {
|
||||
const ok = providersRepository.delete(req.params.id)
|
||||
if (!ok) {
|
||||
return reply.code(404).send({ error: { code: 'NOT_FOUND', message: 'Not found' } })
|
||||
}
|
||||
return reply.code(204).send()
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { FastifyPluginAsync } from 'fastify'
|
||||
|
||||
export const ratesProxyRoutes: FastifyPluginAsync = async (app) => {
|
||||
app.get('/api/rates-proxy', async (req, reply) => {
|
||||
const url = (req.query as { url?: string })?.url
|
||||
if (!url) {
|
||||
return reply.code(400).send({ error: { code: 'VALIDATION', message: 'Missing url parameter' } })
|
||||
}
|
||||
try {
|
||||
const response = await fetch(url, { signal: AbortSignal.timeout(10_000) })
|
||||
if (!response.ok) {
|
||||
return reply.code(502).send({ error: { code: 'UPSTREAM', message: `Upstream returned ${response.status}` } })
|
||||
}
|
||||
return await response.json()
|
||||
} catch (err) {
|
||||
return reply.code(502).send({ error: { code: 'UPSTREAM', message: (err as Error).message || 'Failed to fetch rates' } })
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { FastifyPluginAsync } from 'fastify'
|
||||
import { settingsRepository } from '@cfdm/db/repositories/settings'
|
||||
import { settingsSchema } from '@cfdm/shared/contracts/settings'
|
||||
|
||||
export const settingsRoutes: FastifyPluginAsync = async (app) => {
|
||||
app.get('/api/settings', async () => settingsRepository.list())
|
||||
|
||||
app.post('/api/settings', async (req, reply) => {
|
||||
const parsed = settingsSchema.safeParse(req.body)
|
||||
if (!parsed.success) {
|
||||
return reply.code(400).send({ error: { code: 'VALIDATION', message: parsed.error.message } })
|
||||
}
|
||||
const id = (req.body as { id?: string })?.id ?? 'settings-main'
|
||||
return reply.code(201).send(settingsRepository.upsert(id, parsed.data))
|
||||
})
|
||||
|
||||
app.put<{ Params: { id: string } }>('/api/settings/:id', async (req, reply) => {
|
||||
const parsed = settingsSchema.partial().safeParse(req.body)
|
||||
if (!parsed.success) {
|
||||
return reply.code(400).send({ error: { code: 'VALIDATION', message: parsed.error.message } })
|
||||
}
|
||||
return settingsRepository.upsert(req.params.id, parsed.data)
|
||||
})
|
||||
|
||||
app.post('/api/settings/telegram/test', async () => ({ ok: true }))
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import type { FastifyPluginAsync } from 'fastify'
|
||||
import { desc } from 'drizzle-orm'
|
||||
import { getDb, schema } from '@cfdm/db'
|
||||
import { providerAccountsRepository } from '@cfdm/db/repositories/provider-accounts'
|
||||
import { providersRepository } from '@cfdm/db/repositories/providers'
|
||||
|
||||
interface SyncLogRow {
|
||||
id: string
|
||||
accountId: string
|
||||
startedAt: string
|
||||
finishedAt: string | null
|
||||
status: string | null
|
||||
vpsCount: number | null
|
||||
paymentsCount: number | null
|
||||
error: string | null
|
||||
summary: unknown
|
||||
}
|
||||
|
||||
function mapSyncLog(row: typeof schema.syncLog.$inferSelect): SyncLogRow {
|
||||
let summaryParsed: unknown = null
|
||||
if (row.summary) {
|
||||
try {
|
||||
summaryParsed = JSON.parse(row.summary)
|
||||
} catch {
|
||||
summaryParsed = null
|
||||
}
|
||||
}
|
||||
return {
|
||||
id: row.id,
|
||||
accountId: row.accountId,
|
||||
startedAt: row.startedAt,
|
||||
finishedAt: row.finishedAt,
|
||||
status: row.status,
|
||||
vpsCount: row.vpsCount,
|
||||
paymentsCount: row.paymentsCount,
|
||||
error: row.error,
|
||||
summary: summaryParsed,
|
||||
}
|
||||
}
|
||||
|
||||
export const syncRoutes: FastifyPluginAsync = async (app) => {
|
||||
app.get('/api/sync/status', async () => {
|
||||
const rows = getDb()
|
||||
.select()
|
||||
.from(schema.syncLog)
|
||||
.orderBy(desc(schema.syncLog.startedAt))
|
||||
.limit(50)
|
||||
.all()
|
||||
return rows.map(mapSyncLog)
|
||||
})
|
||||
|
||||
app.post<{ Params: { accountId: string } }>('/api/sync/:accountId', async (req, reply) => {
|
||||
const account = providerAccountsRepository.getWithCredentials(req.params.accountId)
|
||||
if (!account) {
|
||||
return reply.code(404).send({ error: { code: 'NOT_FOUND', message: 'Account not found' } })
|
||||
}
|
||||
const provider = account.providerId ? providersRepository.get(account.providerId) : undefined
|
||||
// TODO: port billmanager sync job
|
||||
return reply.code(501).send({
|
||||
accountId: req.params.accountId,
|
||||
provider: provider?.name ?? null,
|
||||
status: 'pending-migration',
|
||||
note: 'Sync job port pending migration from Express adapters',
|
||||
})
|
||||
})
|
||||
|
||||
app.get<{ Params: { accountId: string } }>('/api/sync/:accountId/balance', async (req, reply) => {
|
||||
const account = providerAccountsRepository.getWithCredentials(req.params.accountId)
|
||||
if (!account) {
|
||||
return reply.code(404).send({ error: { code: 'NOT_FOUND', message: 'Account not found' } })
|
||||
}
|
||||
// TODO: port fetchDashboardInfo
|
||||
return reply.code(501).send({
|
||||
accountId: req.params.accountId,
|
||||
status: 'pending-migration',
|
||||
note: 'Balance fetch pending migration from Express adapters',
|
||||
})
|
||||
})
|
||||
|
||||
app.post('/api/sync/test-connection', async (req, reply) => {
|
||||
const { apiBaseUrl, apiCredentials } = (req.body ?? {}) as {
|
||||
apiBaseUrl?: string
|
||||
apiCredentials?: string
|
||||
}
|
||||
if (!apiBaseUrl?.trim() || !apiCredentials?.trim()) {
|
||||
return reply.code(400).send({ error: { code: 'VALIDATION', message: 'Укажите URL и учётные данные' } })
|
||||
}
|
||||
// TODO: port testConnection
|
||||
return reply.code(501).send({
|
||||
ok: false,
|
||||
status: 'pending-migration',
|
||||
note: 'Connection test pending migration from Express adapters',
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { FastifyPluginAsync } from 'fastify'
|
||||
import { vpsRepository } from '@cfdm/db/repositories/vps'
|
||||
import { vpsSchema } from '@cfdm/shared/contracts/vps'
|
||||
|
||||
export const vpsRoutes: FastifyPluginAsync = async (app) => {
|
||||
app.get('/api/vps', async () => vpsRepository.list())
|
||||
|
||||
app.post('/api/vps', async (req, reply) => {
|
||||
const parsed = vpsSchema.safeParse(req.body)
|
||||
if (!parsed.success) {
|
||||
return reply.code(400).send({ error: { code: 'VALIDATION', message: parsed.error.message } })
|
||||
}
|
||||
return reply.code(201).send(vpsRepository.create(parsed.data))
|
||||
})
|
||||
|
||||
app.put<{ Params: { id: string } }>('/api/vps/:id', async (req, reply) => {
|
||||
const parsed = vpsSchema.partial().safeParse(req.body)
|
||||
if (!parsed.success) {
|
||||
return reply.code(400).send({ error: { code: 'VALIDATION', message: parsed.error.message } })
|
||||
}
|
||||
const updated = vpsRepository.update(req.params.id, parsed.data)
|
||||
if (!updated) {
|
||||
return reply.code(404).send({ error: { code: 'NOT_FOUND', message: 'Not found' } })
|
||||
}
|
||||
return updated
|
||||
})
|
||||
|
||||
app.delete<{ Params: { id: string } }>('/api/vps/:id', async (req, reply) => {
|
||||
const ok = vpsRepository.delete(req.params.id)
|
||||
if (!ok) {
|
||||
return reply.code(404).send({ error: { code: 'NOT_FOUND', message: 'Not found' } })
|
||||
}
|
||||
return reply.code(204).send()
|
||||
})
|
||||
|
||||
app.patch('/api/vps/bulk', async (req, reply) => {
|
||||
const body = req.body as { ids?: string[]; action?: string; value?: unknown }
|
||||
const ids = Array.isArray(body.ids) ? body.ids : []
|
||||
if (ids.length === 0) {
|
||||
return reply.code(400).send({ error: { code: 'VALIDATION', message: 'ids must be a non-empty array' } })
|
||||
}
|
||||
if (body.action === 'status') {
|
||||
const validStatus = ['active', 'paused', 'archived']
|
||||
const value = String(body.value ?? '')
|
||||
if (!validStatus.includes(value)) {
|
||||
return reply.code(400).send({ error: { code: 'VALIDATION', message: 'value must be active, paused, or archived' } })
|
||||
}
|
||||
return { updated: vpsRepository.bulkStatus(ids, value), status: value }
|
||||
}
|
||||
if (body.action === 'delete') {
|
||||
return { deleted: vpsRepository.bulkDelete(ids) }
|
||||
}
|
||||
if (body.action === 'project') {
|
||||
const value = body.value == null ? '' : String(body.value)
|
||||
return vpsRepository.bulkProject(ids, value)
|
||||
}
|
||||
return reply.code(400).send({ error: { code: 'VALIDATION', message: 'action must be status, delete, or project' } })
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Запуск синхронизации BILLmanager с записью в sync_log (для API и планировщика)
|
||||
*/
|
||||
|
||||
import { getDb } from './db.js'
|
||||
import { syncFromBillmanager } from './adapters/billmanager/index.js'
|
||||
|
||||
/**
|
||||
* @param {object} account - строка provider_accounts
|
||||
* @param {object} [opts] - { skipTariffs, skipVpsPayments }
|
||||
* @returns {Promise<object>} результат syncFromBillmanager + ok, logId
|
||||
*/
|
||||
export async function runBillmanagerAccountSync(account, opts = {}) {
|
||||
const db = getDb()
|
||||
const logId = `sync-${account.id}-${Date.now()}`
|
||||
db.prepare(`
|
||||
INSERT INTO sync_log (id, accountId, startedAt, status)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`).run(logId, account.id, new Date().toISOString(), 'running')
|
||||
|
||||
try {
|
||||
const result = await syncFromBillmanager(account, db, opts)
|
||||
const summaryPayload = {
|
||||
...(result.syncSummary || {}),
|
||||
vpsCount: result.vpsCount,
|
||||
paymentsCount: result.paymentsCount,
|
||||
tariffsCount: result.tariffsCount ?? 0,
|
||||
}
|
||||
db.prepare(`
|
||||
UPDATE sync_log SET finishedAt=?, status=?, vpsCount=?, paymentsCount=?, summary=?
|
||||
WHERE id=?
|
||||
`).run(
|
||||
new Date().toISOString(),
|
||||
'ok',
|
||||
result.vpsCount,
|
||||
result.paymentsCount,
|
||||
JSON.stringify(summaryPayload),
|
||||
logId,
|
||||
)
|
||||
return { ok: true, logId, ...result }
|
||||
} catch (err) {
|
||||
db.prepare(`
|
||||
UPDATE sync_log SET finishedAt=?, status=?, error=?, summary=?
|
||||
WHERE id=?
|
||||
`).run(
|
||||
new Date().toISOString(),
|
||||
'error',
|
||||
err.message || 'Unknown error',
|
||||
JSON.stringify({ error: err.message || 'Unknown error' }),
|
||||
logId,
|
||||
)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
import { getDb } from './db.js'
|
||||
import { runBillmanagerAccountSync } from './sync-account-job.js'
|
||||
import { sendTelegramMessage } from './telegram.js'
|
||||
import { billmanagerAccountRowForSync } from './utils/billmanager-context.js'
|
||||
|
||||
let syncIntervalId = null
|
||||
let syncTariffsIntervalId = null
|
||||
|
||||
const UPCOMING_DAYS = 7
|
||||
|
||||
function getAccountBalance(accountId, providerAccounts, balanceLedger) {
|
||||
const account = providerAccounts.find((a) => a.id === accountId)
|
||||
if (account?.balance_api != null && Number.isFinite(Number(account.balance_api))) {
|
||||
return Number(account.balance_api)
|
||||
}
|
||||
const rows = balanceLedger.filter((row) => row.providerAccountId === accountId)
|
||||
const credits = rows
|
||||
.filter((row) => row.direction === 'credit')
|
||||
.reduce((acc, row) => acc + Number(row.amount || 0), 0)
|
||||
const debits = rows
|
||||
.filter((row) => row.direction === 'debit')
|
||||
.reduce((acc, row) => acc + Number(row.amount || 0), 0)
|
||||
return credits - debits
|
||||
}
|
||||
|
||||
function getPaidUntilDate(db, vps, providerAccounts, payments, balanceLedger, now) {
|
||||
if (vps.status !== 'active') return null
|
||||
const account = providerAccounts.find((a) => a.id === vps.providerAccountId)
|
||||
const tariffType = vps.tariffType || (Number(vps.dailyRate || 0) > 0 ? 'daily' : 'monthly')
|
||||
const isDailyBilling = tariffType === 'daily' || account?.billingMode === 'daily'
|
||||
|
||||
let paidUntilFromApi = null
|
||||
if (vps.paidUntil) {
|
||||
const d = new Date(vps.paidUntil)
|
||||
paidUntilFromApi = Number.isNaN(d.getTime()) ? null : d
|
||||
}
|
||||
|
||||
const isPaidUntilNextDay =
|
||||
paidUntilFromApi &&
|
||||
(() => {
|
||||
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
||||
const diffMs = paidUntilFromApi - today
|
||||
const diffDays = Math.round(diffMs / (24 * 60 * 60 * 1000))
|
||||
return diffDays >= 0 && diffDays <= 2
|
||||
})()
|
||||
|
||||
const shouldCalculateFromBalance = isDailyBilling || isPaidUntilNextDay
|
||||
|
||||
if (!shouldCalculateFromBalance && paidUntilFromApi) return paidUntilFromApi
|
||||
|
||||
const dailyRate = Number(vps.dailyRate || 0)
|
||||
const monthlyRate = Number(vps.monthlyRate || 0)
|
||||
const burnRate = tariffType === 'daily' ? dailyRate : monthlyRate / 30
|
||||
if (!Number.isFinite(burnRate) || burnRate <= 0) return paidUntilFromApi
|
||||
|
||||
const accountBalance = getAccountBalance(vps.providerAccountId, providerAccounts, balanceLedger)
|
||||
const activeInAccount = db
|
||||
.prepare('SELECT id FROM vps WHERE providerAccountId = ? AND status = ?')
|
||||
.all(vps.providerAccountId, 'active').length
|
||||
const allocatedBalance = activeInAccount > 0 ? Math.max(0, accountBalance) / activeInAccount : 0
|
||||
const directPayments = payments
|
||||
.filter((p) => p.vpsId === vps.id && p.type === 'direct_vps_payment')
|
||||
.reduce((acc, p) => acc + Number(p.amount || 0), 0)
|
||||
const funds = directPayments + allocatedBalance
|
||||
const coveredDays = Math.floor(funds / burnRate)
|
||||
if (!Number.isFinite(coveredDays) || coveredDays <= 0) return paidUntilFromApi
|
||||
|
||||
const paidUntil = new Date(now)
|
||||
paidUntil.setDate(paidUntil.getDate() + coveredDays)
|
||||
return paidUntil
|
||||
}
|
||||
|
||||
async function sendPaymentExpiryNotifications(db) {
|
||||
const settings = db.prepare('SELECT * FROM settings WHERE id = ?').get('settings-main')
|
||||
if (!settings?.notifyPaymentExpiryEnabled || !settings?.telegramBotToken?.trim() || !settings?.telegramChatId?.trim()) {
|
||||
return
|
||||
}
|
||||
const vpsList = db.prepare('SELECT * FROM vps ORDER BY createdAt DESC').all()
|
||||
const providerAccounts = db.prepare('SELECT * FROM provider_accounts ORDER BY name').all()
|
||||
const payments = db.prepare('SELECT * FROM payments ORDER BY date DESC').all()
|
||||
const balanceLedger = db.prepare('SELECT * FROM balance_ledger ORDER BY date DESC').all()
|
||||
const providers = db.prepare('SELECT * FROM providers ORDER BY name').all()
|
||||
|
||||
const now = new Date()
|
||||
const threshold = new Date(now)
|
||||
threshold.setDate(threshold.getDate() + UPCOMING_DAYS)
|
||||
|
||||
const upcoming = []
|
||||
for (const vps of vpsList) {
|
||||
if (vps.status !== 'active') continue
|
||||
const paidUntil = getPaidUntilDate(db, vps, providerAccounts, payments, balanceLedger, now)
|
||||
if (!paidUntil || paidUntil > threshold || paidUntil < new Date(now.getFullYear(), now.getMonth(), now.getDate())) continue
|
||||
const provider = providers.find((p) => p.id === vps.providerId)
|
||||
upcoming.push({ vps, paidUntil, provider: provider?.name || '-' })
|
||||
}
|
||||
upcoming.sort((a, b) => a.paidUntil - b.paidUntil)
|
||||
|
||||
if (upcoming.length === 0) return
|
||||
|
||||
const lines = upcoming.slice(0, 10).map(({ vps, paidUntil, provider }) => {
|
||||
const dateStr = paidUntil.toLocaleDateString('ru-RU')
|
||||
return `• ${vps.dns || vps.ip} (${provider}) — до ${dateStr}`
|
||||
})
|
||||
const text = `⚠️ <b>Истекает оплата</b> (ближайшие ${UPCOMING_DAYS} дней):\n\n${lines.join('\n')}`
|
||||
await sendTelegramMessage(settings.telegramBotToken, settings.telegramChatId, text, settings.telegramMessageThreadId)
|
||||
}
|
||||
|
||||
export async function runScheduledSync() {
|
||||
try {
|
||||
const db = getDb()
|
||||
const settings = db.prepare('SELECT * FROM settings WHERE id = ?').get('settings-main')
|
||||
if (!settings?.syncEnabled) return
|
||||
const accountRows = db.prepare(`
|
||||
SELECT pa.* FROM provider_accounts pa
|
||||
INNER JOIN providers p ON p.id = pa.providerId
|
||||
WHERE lower(trim(COALESCE(p.apiType, ''))) = 'billmanager'
|
||||
AND length(trim(COALESCE(p.apiBaseUrl, ''))) > 0
|
||||
AND pa.apiCredentials IS NOT NULL AND length(trim(pa.apiCredentials)) > 0
|
||||
`).all()
|
||||
const providers = db.prepare('SELECT * FROM providers').all()
|
||||
const providerById = new Map(providers.map((p) => [p.id, p]))
|
||||
const accounts = accountRows
|
||||
.map((a) => billmanagerAccountRowForSync(a, providerById.get(a.providerId)))
|
||||
.filter(Boolean)
|
||||
|
||||
const digestLines = []
|
||||
const lowBalanceLines = []
|
||||
const token = settings?.telegramBotToken?.trim()
|
||||
const chatId = settings?.telegramChatId?.trim()
|
||||
const canTg = Boolean(token && chatId)
|
||||
|
||||
for (const account of accounts) {
|
||||
try {
|
||||
const result = await runBillmanagerAccountSync(account, { skipTariffs: true })
|
||||
const s = result.syncSummary || {}
|
||||
const parts = []
|
||||
if (s.added?.length) parts.push(`+${s.added.length} VPS`)
|
||||
if (s.updated?.length) parts.push(`изм. ${s.updated.length}`)
|
||||
if (result.paymentsCount) parts.push(`платежи +${result.paymentsCount}`)
|
||||
digestLines.push(`✓ ${account.name}: ${parts.length ? parts.join(', ') : 'без изменений'}`)
|
||||
|
||||
const apiBal = result.balance?.balance
|
||||
const threshold = account.balance_alert_below
|
||||
if (
|
||||
canTg &&
|
||||
settings.notifyLowBalanceEnabled &&
|
||||
threshold != null &&
|
||||
Number.isFinite(Number(threshold)) &&
|
||||
apiBal != null &&
|
||||
Number.isFinite(Number(apiBal)) &&
|
||||
Number(apiBal) < Number(threshold)
|
||||
) {
|
||||
const cur = result.balance?.currency || account.balance_currency || account.currency || ''
|
||||
lowBalanceLines.push(
|
||||
`• ${account.name}: ${apiBal} ${cur} (порог ${threshold})`,
|
||||
)
|
||||
}
|
||||
} catch (err) {
|
||||
digestLines.push(`✗ ${account.name}: ${err.message || 'ошибка'}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (canTg && settings.notifySyncDigestEnabled && digestLines.length > 0) {
|
||||
const text = `📋 <b>Синхронизация VPS</b>\n\n${digestLines.join('\n')}`
|
||||
await sendTelegramMessage(token, chatId, text, settings.telegramMessageThreadId)
|
||||
}
|
||||
if (canTg && settings.notifyLowBalanceEnabled && lowBalanceLines.length > 0) {
|
||||
const text = `💰 <b>Низкий баланс</b>\n\n${lowBalanceLines.join('\n')}`
|
||||
await sendTelegramMessage(token, chatId, text, settings.telegramMessageThreadId)
|
||||
}
|
||||
|
||||
if (settings?.notifyPaymentExpiryEnabled) {
|
||||
await sendPaymentExpiryNotifications(db)
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Scheduled sync error:', err.message)
|
||||
}
|
||||
}
|
||||
|
||||
export async function runScheduledSyncTariffs() {
|
||||
try {
|
||||
const db = getDb()
|
||||
const settings = db.prepare('SELECT * FROM settings WHERE id = ?').get('settings-main')
|
||||
if (!settings?.syncEnabled) return
|
||||
const accountRows = db.prepare(`
|
||||
SELECT pa.* FROM provider_accounts pa
|
||||
INNER JOIN providers p ON p.id = pa.providerId
|
||||
WHERE lower(trim(COALESCE(p.apiType, ''))) = 'billmanager'
|
||||
AND length(trim(COALESCE(p.apiBaseUrl, ''))) > 0
|
||||
AND pa.apiCredentials IS NOT NULL AND length(trim(pa.apiCredentials)) > 0
|
||||
`).all()
|
||||
const providers = db.prepare('SELECT * FROM providers ORDER BY name').all()
|
||||
const providerById = new Map(providers.map((p) => [p.id, p]))
|
||||
const accounts = accountRows
|
||||
.map((a) => billmanagerAccountRowForSync(a, providerById.get(a.providerId)))
|
||||
.filter(Boolean)
|
||||
|
||||
for (const account of accounts) {
|
||||
try {
|
||||
const result = await runBillmanagerAccountSync(account, { skipVpsPayments: true })
|
||||
const newTariffs = result?.newTariffs || []
|
||||
if (newTariffs.length > 0 && settings?.notifyNewTariffsEnabled && settings?.telegramBotToken?.trim() && settings?.telegramChatId?.trim()) {
|
||||
const provider = providers.find((p) => p.id === account.providerId)
|
||||
const providerName = provider?.name || account.name || '-'
|
||||
const lines = newTariffs.slice(0, 15).map((t) => `• ${t.name || '—'} — ${t.price || '—'}`)
|
||||
const text = `🆕 <b>Новые тарифы</b> (${providerName}):\n\n${lines.join('\n')}`
|
||||
await sendTelegramMessage(settings.telegramBotToken, settings.telegramChatId, text, settings.telegramMessageThreadId)
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(`Sync tariffs failed for account ${account.id}:`, err.message)
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Scheduled sync tariffs error:', err.message)
|
||||
}
|
||||
}
|
||||
|
||||
export function startScheduler() {
|
||||
if (syncIntervalId) clearInterval(syncIntervalId)
|
||||
syncIntervalId = null
|
||||
if (syncTariffsIntervalId) clearInterval(syncTariffsIntervalId)
|
||||
syncTariffsIntervalId = null
|
||||
try {
|
||||
const db = getDb()
|
||||
const settings = db.prepare('SELECT * FROM settings WHERE id = ?').get('settings-main')
|
||||
if (!settings?.syncEnabled) return
|
||||
const interval = Math.max(15, Number(settings.syncIntervalMinutes) || 60)
|
||||
const tariffsInterval = Math.max(60, Number(settings.syncTariffsIntervalMinutes) || 1440)
|
||||
syncIntervalId = setInterval(runScheduledSync, interval * 60 * 1000)
|
||||
syncTariffsIntervalId = setInterval(runScheduledSyncTariffs, tariffsInterval * 60 * 1000)
|
||||
console.log(`Scheduled sync enabled: VPS/payments every ${interval} min, tariffs every ${tariffsInterval} min`)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Telegram Bot API — отправка уведомлений
|
||||
*/
|
||||
|
||||
/**
|
||||
* @param {string} token - Bot token from @BotFather
|
||||
* @param {string|string[]} chatIds - Chat ID(s), comma-separated string or array
|
||||
* @param {string} text - Message text
|
||||
* @param {string|number} [messageThreadId] - ID топика в SuperGroup (для отправки в цепочку сообщений)
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function sendTelegramMessage(token, chatIds, text, messageThreadId) {
|
||||
if (!token?.trim() || !text?.trim()) return
|
||||
const ids = Array.isArray(chatIds)
|
||||
? chatIds
|
||||
: String(chatIds || '')
|
||||
.split(',')
|
||||
.map((id) => id.trim())
|
||||
.filter(Boolean)
|
||||
if (ids.length === 0) return
|
||||
|
||||
const threadId = messageThreadId != null && messageThreadId !== '' ? Number(messageThreadId) : null
|
||||
const payload = {
|
||||
text,
|
||||
parse_mode: 'HTML',
|
||||
disable_web_page_preview: true,
|
||||
}
|
||||
if (Number.isFinite(threadId)) payload.message_thread_id = threadId
|
||||
|
||||
const url = `https://api.telegram.org/bot${token.trim()}/sendMessage`
|
||||
for (const chatId of ids) {
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
...payload,
|
||||
chat_id: chatId,
|
||||
}),
|
||||
})
|
||||
const data = await res.json().catch(() => ({}))
|
||||
if (!data.ok) {
|
||||
console.warn(`Telegram sendMessage failed for chat ${chatId}:`, data.description || res.statusText)
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(`Telegram sendMessage error for chat ${chatId}:`, err.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"target": "ES2022",
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"noEmit": false,
|
||||
"verbatimModuleSyntax": false,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* BILLmanager: URL и тип API задаются на хостере (providers), учётные данные — на аккаунте.
|
||||
* Поддержка fallback на поля аккаунта для старых данных до миграции.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @param {object|null|undefined} accountRow
|
||||
* @param {object|null|undefined} providerRow
|
||||
* @returns {{ apiType: string, apiBaseUrl: string }}
|
||||
*/
|
||||
export function resolveBillmanagerApi(accountRow, providerRow) {
|
||||
const apiType = String(providerRow?.apiType || accountRow?.apiType || '').trim()
|
||||
const apiBaseUrl = String(providerRow?.apiBaseUrl || accountRow?.apiBaseUrl || '').trim()
|
||||
return { apiType, apiBaseUrl }
|
||||
}
|
||||
|
||||
/**
|
||||
* Объект аккаунта с подставленным URL для syncFromBillmanager / balance.
|
||||
* @param {object} accountRow
|
||||
* @param {object|null|undefined} providerRow
|
||||
* @returns {object|null} null если не готово к запросам API
|
||||
*/
|
||||
export function billmanagerAccountRowForSync(accountRow, providerRow) {
|
||||
if (!accountRow) return null
|
||||
const { apiType, apiBaseUrl } = resolveBillmanagerApi(accountRow, providerRow)
|
||||
const cred = String(accountRow.apiCredentials || '').trim()
|
||||
if (apiType !== 'billmanager' || !apiBaseUrl || !cred) return null
|
||||
return { ...accountRow, apiType: 'billmanager', apiBaseUrl }
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Map database rows to API response format
|
||||
*/
|
||||
|
||||
/**
|
||||
* @param {object} row - provider_account row
|
||||
* @returns {object} sanitized (apiCredentials hidden)
|
||||
*/
|
||||
export function sanitizeAccount(row) {
|
||||
if (!row) return row
|
||||
const { apiCredentials, ...rest } = row
|
||||
return { ...rest, apiCredentialsSet: Boolean(apiCredentials) }
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} row - active_tariffs row
|
||||
* @returns {object|null}
|
||||
*/
|
||||
export function rowToActiveTariff(row) {
|
||||
if (!row) return null
|
||||
return {
|
||||
...row,
|
||||
orderAvailable: Boolean(row.orderAvailable),
|
||||
ramGb: row.ramGb != null ? Number(row.ramGb) : 0,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} row - tariff_sync_options row
|
||||
* @returns {object|null}
|
||||
*/
|
||||
export function rowToTariffSyncOptions(row) {
|
||||
if (!row) return null
|
||||
let datacenters = []
|
||||
let periods = []
|
||||
try {
|
||||
datacenters = row.datacenters ? JSON.parse(row.datacenters) : []
|
||||
} catch {
|
||||
/* ignore parse error */
|
||||
}
|
||||
try {
|
||||
periods = row.periods ? JSON.parse(row.periods) : []
|
||||
} catch {
|
||||
/* ignore parse error */
|
||||
}
|
||||
return {
|
||||
...row,
|
||||
datacenters: Array.isArray(datacenters) ? datacenters : [],
|
||||
periods: Array.isArray(periods) ? periods : [],
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "base-nova",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "../../packages/ui/src/styles/globals.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true
|
||||
},
|
||||
"iconLibrary": "lucide",
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"hooks": "@/hooks",
|
||||
"lib": "@/lib",
|
||||
"utils": "@cfdm/ui/lib/utils",
|
||||
"ui": "@cfdm/ui/components"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>VPS Tracker</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"name": "@cfdm/web",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint .",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@base-ui/react": "^1.0.0",
|
||||
"@cfdm/shared": "workspace:*",
|
||||
"@cfdm/ui": "workspace:*",
|
||||
"@hookform/resolvers": "^3.10.0",
|
||||
"@tanstack/react-query": "^5.90.2",
|
||||
"@tanstack/react-query-devtools": "^5.90.2",
|
||||
"@tanstack/react-router": "^1.130.2",
|
||||
"@tanstack/react-router-devtools": "^1.130.2",
|
||||
"lucide-react": "^0.468.0",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-hook-form": "^7.60.0",
|
||||
"recharts": "3.8.0",
|
||||
"sonner": "^1.7.0",
|
||||
"zod": "^3.25.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.1.0",
|
||||
"@tanstack/router-plugin": "^1.130.0",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"happy-dom": "^18.0.0",
|
||||
"tailwindcss": "^4.1.0",
|
||||
"tw-animate-css": "^1.0.0",
|
||||
"typescript": "^5.9.2",
|
||||
"vite": "^7.3.1",
|
||||
"vitest": "^3.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,53 @@
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@cfdm/ui/components/alert-dialog'
|
||||
import type { ReactElement, ReactNode } from 'react'
|
||||
|
||||
interface ConfirmDialogProps {
|
||||
trigger: ReactElement
|
||||
title: string
|
||||
description?: ReactNode
|
||||
confirmLabel?: string
|
||||
cancelLabel?: string
|
||||
destructive?: boolean
|
||||
onConfirm: () => void
|
||||
}
|
||||
|
||||
export function ConfirmDialog({
|
||||
trigger,
|
||||
title,
|
||||
description,
|
||||
confirmLabel = 'Подтвердить',
|
||||
cancelLabel = 'Отмена',
|
||||
destructive,
|
||||
onConfirm,
|
||||
}: ConfirmDialogProps) {
|
||||
return (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger render={trigger} />
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{title}</AlertDialogTitle>
|
||||
{description ? <AlertDialogDescription>{description}</AlertDialogDescription> : null}
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{cancelLabel}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
variant={destructive ? 'destructive' : 'default'}
|
||||
onClick={onConfirm}
|
||||
>
|
||||
{confirmLabel}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@cfdm/ui/components/table'
|
||||
import { TableCard } from './table-card'
|
||||
import { EmptyState } from './empty-state'
|
||||
|
||||
export interface DataTableColumn<T> {
|
||||
key: string
|
||||
header: ReactNode
|
||||
cell: (row: T, index: number) => ReactNode
|
||||
className?: string
|
||||
headerClassName?: string
|
||||
}
|
||||
|
||||
interface DataTableCardProps<T> {
|
||||
title?: ReactNode
|
||||
description?: ReactNode
|
||||
actions?: ReactNode
|
||||
columns: DataTableColumn<T>[]
|
||||
data: T[]
|
||||
rowKey: (row: T, index: number) => string
|
||||
emptyTitle?: string
|
||||
emptyDescription?: string
|
||||
emptyAction?: ReactNode
|
||||
onRowClick?: (row: T) => void
|
||||
}
|
||||
|
||||
export function DataTableCard<T>({
|
||||
title,
|
||||
description,
|
||||
actions,
|
||||
columns,
|
||||
data,
|
||||
rowKey,
|
||||
emptyTitle = 'Нет записей',
|
||||
emptyDescription,
|
||||
emptyAction,
|
||||
onRowClick,
|
||||
}: DataTableCardProps<T>) {
|
||||
return (
|
||||
<TableCard title={title} description={description} actions={actions}>
|
||||
{data.length === 0 ? (
|
||||
<div className="p-4">
|
||||
<EmptyState title={emptyTitle} description={emptyDescription} action={emptyAction} />
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
{columns.map((col) => (
|
||||
<TableHead key={col.key} className={col.headerClassName}>
|
||||
{col.header}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data.map((row, index) => (
|
||||
<TableRow
|
||||
key={rowKey(row, index)}
|
||||
onClick={onRowClick ? () => onRowClick(row) : undefined}
|
||||
className={onRowClick ? 'cursor-pointer' : undefined}
|
||||
>
|
||||
{columns.map((col) => (
|
||||
<TableCell key={col.key} className={col.className}>
|
||||
{col.cell(row, index)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</TableCard>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
CartesianGrid,
|
||||
XAxis,
|
||||
YAxis,
|
||||
Cell,
|
||||
Pie,
|
||||
PieChart,
|
||||
Tooltip as RechartsTooltip,
|
||||
} from 'recharts'
|
||||
import {
|
||||
ChartContainer,
|
||||
ChartTooltipContent,
|
||||
type ChartConfig,
|
||||
} from '@cfdm/ui/components/chart'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@cfdm/ui/components/card'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import type { Vps, Provider, Payment, Settings, RatesData } from '@/types/entities'
|
||||
import { convertCurrency, formatCurrency, monthKey, toIsoCurrency } from '@/lib/format'
|
||||
import { providerByIdMap } from '@/lib/billmanager'
|
||||
|
||||
const EXPENSE_CONFIG: ChartConfig = {
|
||||
expense: { label: 'Расход', color: 'var(--chart-1)' },
|
||||
}
|
||||
|
||||
export function MonthlyExpenseChart({
|
||||
vps,
|
||||
providers,
|
||||
settings,
|
||||
ratesData,
|
||||
className,
|
||||
}: {
|
||||
vps: Vps[]
|
||||
providers: Provider[]
|
||||
settings: Settings[]
|
||||
ratesData: RatesData | null
|
||||
className?: string
|
||||
}) {
|
||||
const baseCurrency = (settings[0]?.baseCurrency ?? 'RUB').toUpperCase()
|
||||
const providerById = providerByIdMap(providers)
|
||||
|
||||
const monthlyByAccount = new Map<string, number>()
|
||||
for (const v of vps) {
|
||||
if (v.status !== 'active') continue
|
||||
const provider = providerById.get(v.providerId)
|
||||
const monthly = Number(v.monthlyRate || 0)
|
||||
const daily = Number(v.dailyRate || 0)
|
||||
const burn = v.tariffType === 'daily' ? daily * 30 : monthly
|
||||
const fromCurrency = toIsoCurrency(provider?.baseCurrency || v.currency || baseCurrency)
|
||||
const converted = convertCurrency(burn, fromCurrency, baseCurrency, ratesData)
|
||||
monthlyByAccount.set(v.providerAccountId, (monthlyByAccount.get(v.providerAccountId) ?? 0) + converted)
|
||||
}
|
||||
|
||||
const data = Array.from(monthlyByAccount.entries())
|
||||
.map(([accountId, value]) => ({
|
||||
accountId,
|
||||
name: providerById.get(accountId)?.name ?? accountId,
|
||||
expense: Math.round(value),
|
||||
}))
|
||||
.sort((a, b) => b.expense - a.expense)
|
||||
.slice(0, 10)
|
||||
|
||||
return (
|
||||
<Card className={className}>
|
||||
<CardHeader>
|
||||
<CardTitle>Расходы по хостерам (мес)</CardTitle>
|
||||
<CardDescription>Топ-10 по monthly rate, в {baseCurrency}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ChartContainer config={EXPENSE_CONFIG} className="h-72 w-full">
|
||||
<BarChart data={data} margin={{ top: 8, right: 8, bottom: 8, left: 8 }}>
|
||||
<CartesianGrid vertical={false} strokeDasharray="3 3" />
|
||||
<XAxis dataKey="name" tickLine={false} axisLine={false} tickMargin={8} />
|
||||
<YAxis tickLine={false} axisLine={false} width={48} />
|
||||
<RechartsTooltip cursor={false} content={<ChartTooltipContent formatter={(v) => formatCurrency(Number(v), baseCurrency)} />} />
|
||||
<Bar dataKey="expense" fill="var(--color-expense)" radius={4} />
|
||||
</BarChart>
|
||||
</ChartContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
const PIE_COLORS = ['var(--chart-1)', 'var(--chart-2)', 'var(--chart-3)', 'var(--chart-4)', 'var(--chart-5)']
|
||||
|
||||
const PAYMENTS_CONFIG: ChartConfig = {
|
||||
amount: { label: 'Платежи', color: 'var(--chart-2)' },
|
||||
}
|
||||
|
||||
export function PaymentsPieChart({
|
||||
payments,
|
||||
settings,
|
||||
ratesData,
|
||||
className,
|
||||
}: {
|
||||
payments: Payment[]
|
||||
settings: Settings[]
|
||||
ratesData: RatesData | null
|
||||
className?: string
|
||||
}) {
|
||||
const baseCurrency = (settings[0]?.baseCurrency ?? 'RUB').toUpperCase()
|
||||
const byType = new Map<string, number>()
|
||||
for (const p of payments) {
|
||||
const converted = convertCurrency(Number(p.amount), toIsoCurrency(p.currency), baseCurrency, ratesData)
|
||||
byType.set(p.type, (byType.get(p.type) ?? 0) + converted)
|
||||
}
|
||||
const data = Array.from(byType.entries()).map(([type, amount]) => ({ type, amount: Math.round(amount) }))
|
||||
|
||||
return (
|
||||
<Card className={className}>
|
||||
<CardHeader>
|
||||
<CardTitle>Платежи по типам</CardTitle>
|
||||
<CardDescription>Структура в {baseCurrency}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ChartContainer config={PAYMENTS_CONFIG} className="mx-auto h-72 w-full">
|
||||
<PieChart>
|
||||
<RechartsTooltip content={<ChartTooltipContent nameKey="type" formatter={(v) => formatCurrency(Number(v), baseCurrency)} />} />
|
||||
<Pie data={data} dataKey="amount" nameKey="type" innerRadius={50} outerRadius={90} strokeWidth={2}>
|
||||
{data.map((_, i) => (
|
||||
<Cell key={i} fill={PIE_COLORS[i % PIE_COLORS.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
</PieChart>
|
||||
</ChartContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
export function MonthlyTrendChart({
|
||||
payments,
|
||||
settings,
|
||||
ratesData,
|
||||
className,
|
||||
}: {
|
||||
payments: Payment[]
|
||||
settings: Settings[]
|
||||
ratesData: RatesData | null
|
||||
className?: string
|
||||
}) {
|
||||
const baseCurrency = (settings[0]?.baseCurrency ?? 'RUB').toUpperCase()
|
||||
const byMonth = new Map<string, number>()
|
||||
for (const p of payments) {
|
||||
const key = monthKey(p.date)
|
||||
if (!key) continue
|
||||
const converted = convertCurrency(Number(p.amount), toIsoCurrency(p.currency), baseCurrency, ratesData)
|
||||
byMonth.set(key, (byMonth.get(key) ?? 0) + converted)
|
||||
}
|
||||
const data = Array.from(byMonth.entries())
|
||||
.map(([month, amount]) => ({ month, amount: Math.round(amount) }))
|
||||
.sort((a, b) => a.month.localeCompare(b.month))
|
||||
.slice(-12)
|
||||
|
||||
const trendConfig: ChartConfig = { amount: { label: 'Платежи', color: 'var(--chart-3)' } }
|
||||
|
||||
return (
|
||||
<Card className={className}>
|
||||
<CardHeader>
|
||||
<CardTitle>Динамика платежей</CardTitle>
|
||||
<CardDescription>Последние 12 месяцев, {baseCurrency}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ChartContainer config={trendConfig} className="h-72 w-full">
|
||||
<BarChart data={data} margin={{ top: 8, right: 8, bottom: 8, left: 8 }}>
|
||||
<CartesianGrid vertical={false} strokeDasharray="3 3" />
|
||||
<XAxis dataKey="month" tickLine={false} axisLine={false} tickMargin={8} />
|
||||
<YAxis tickLine={false} axisLine={false} width={48} />
|
||||
<RechartsTooltip cursor={false} content={<ChartTooltipContent formatter={(v) => formatCurrency(Number(v), baseCurrency)} />} />
|
||||
<Bar dataKey="amount" fill="var(--color-amount)" radius={4} />
|
||||
</BarChart>
|
||||
</ChartContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
export function ChartsGrid({ children }: { children: ReactNode }) {
|
||||
return <div className="grid gap-4 lg:grid-cols-2">{children}</div>
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { cn } from '@cfdm/ui/lib/utils'
|
||||
|
||||
interface EmptyStateProps {
|
||||
title: string
|
||||
description?: string
|
||||
icon?: ReactNode
|
||||
action?: ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function EmptyState({ title, description, icon, action, className }: EmptyStateProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col items-center justify-center gap-3 rounded-lg border border-dashed p-8 text-center',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{icon ? <div className="text-muted-foreground">{icon}</div> : null}
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium">{title}</p>
|
||||
{description ? <p className="text-sm text-muted-foreground">{description}</p> : null}
|
||||
</div>
|
||||
{action ? <div className="mt-2">{action}</div> : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { Field, FieldError, FieldLabel } from '@cfdm/ui/components/field'
|
||||
|
||||
interface FormFieldProps {
|
||||
label: string
|
||||
htmlFor?: string
|
||||
error?: string
|
||||
invalid?: boolean
|
||||
description?: ReactNode
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export function FormField({ label, htmlFor, error, invalid, description, children }: FormFieldProps) {
|
||||
return (
|
||||
<Field data-invalid={invalid || Boolean(error)}>
|
||||
<FieldLabel htmlFor={htmlFor}>{label}</FieldLabel>
|
||||
{children}
|
||||
{description ? <p className="text-sm text-muted-foreground">{description}</p> : null}
|
||||
{error ? <FieldError>{error}</FieldError> : null}
|
||||
</Field>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import {
|
||||
useForm,
|
||||
type DefaultValues,
|
||||
type FieldValues,
|
||||
type SubmitHandler,
|
||||
type UseFormReturn,
|
||||
} from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import type { ZodType } from 'zod'
|
||||
|
||||
import { FormSheet } from './form-sheet'
|
||||
|
||||
interface FormSheetRhfProps<TField extends FieldValues> {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
title: string
|
||||
description?: string
|
||||
schema: ZodType<TField>
|
||||
defaultValues: DefaultValues<TField>
|
||||
onSubmit: (values: TField) => void
|
||||
submitting?: boolean
|
||||
submitLabel?: string
|
||||
children: (form: UseFormReturn<TField>) => ReactNode
|
||||
}
|
||||
|
||||
export function FormSheetRhf<TField extends FieldValues>({
|
||||
open,
|
||||
onOpenChange,
|
||||
title,
|
||||
description,
|
||||
schema,
|
||||
defaultValues,
|
||||
onSubmit,
|
||||
submitting,
|
||||
submitLabel,
|
||||
children,
|
||||
}: FormSheetRhfProps<TField>) {
|
||||
const form = useForm<TField>({
|
||||
resolver: zodResolver(schema) as never,
|
||||
defaultValues: defaultValues as DefaultValues<TField>,
|
||||
mode: 'onBlur',
|
||||
})
|
||||
|
||||
const submit: SubmitHandler<TField> = (values) => onSubmit(values)
|
||||
|
||||
return (
|
||||
<FormSheet
|
||||
open={open}
|
||||
onOpenChange={(o) => {
|
||||
if (!o) form.reset()
|
||||
onOpenChange(o)
|
||||
}}
|
||||
trigger={null}
|
||||
title={title}
|
||||
description={description}
|
||||
submitLabel={submitLabel}
|
||||
submitting={submitting}
|
||||
onSubmit={() => void form.handleSubmit(submit)()}
|
||||
>
|
||||
{children(form)}
|
||||
</FormSheet>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { ReactElement, ReactNode } from 'react'
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from '@cfdm/ui/components/sheet'
|
||||
import { LoadingButton } from './loading-button'
|
||||
|
||||
interface FormSheetProps {
|
||||
trigger?: ReactElement | null
|
||||
title: string
|
||||
description?: string
|
||||
open?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
onSubmit?: () => void
|
||||
submitLabel?: string
|
||||
submitting?: boolean
|
||||
submitDisabled?: boolean
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export function FormSheet({
|
||||
trigger,
|
||||
title,
|
||||
description,
|
||||
open,
|
||||
onOpenChange,
|
||||
onSubmit,
|
||||
submitLabel = 'Сохранить',
|
||||
submitting,
|
||||
submitDisabled,
|
||||
children,
|
||||
}: FormSheetProps) {
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
{trigger ? <SheetTrigger render={trigger} /> : null}
|
||||
<SheetContent side="right" className="w-full sm:max-w-md">
|
||||
<SheetHeader>
|
||||
<SheetTitle>{title}</SheetTitle>
|
||||
{description ? <SheetDescription>{description}</SheetDescription> : null}
|
||||
</SheetHeader>
|
||||
<form
|
||||
className="flex flex-1 flex-col gap-4 overflow-y-auto p-4"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
onSubmit?.()
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
{onSubmit ? (
|
||||
<SheetFooter className="mt-auto pt-4">
|
||||
<LoadingButton type="submit" loading={submitting} disabled={submitDisabled}>
|
||||
{submitLabel}
|
||||
</LoadingButton>
|
||||
</SheetFooter>
|
||||
) : null}
|
||||
</form>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Server,
|
||||
ServerCog,
|
||||
Building2,
|
||||
Wallet,
|
||||
CreditCard,
|
||||
Coins,
|
||||
ChartColumnBig,
|
||||
ChartBar,
|
||||
Settings,
|
||||
} from 'lucide-react'
|
||||
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarHeader,
|
||||
SidebarInset,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarProvider,
|
||||
SidebarTrigger,
|
||||
} from '@cfdm/ui/components/sidebar'
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
} from '@cfdm/ui/components/breadcrumb'
|
||||
import { Separator } from '@cfdm/ui/components/separator'
|
||||
|
||||
import { Link, useRouterState } from '@tanstack/react-router'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
interface NavItem {
|
||||
to: string
|
||||
label: string
|
||||
icon: typeof LayoutDashboard
|
||||
}
|
||||
|
||||
const NAV_ITEMS: NavItem[] = [
|
||||
{ to: '/dashboard', label: 'Дашборд', icon: LayoutDashboard },
|
||||
{ to: '/vps', label: 'VPS', icon: Server },
|
||||
{ to: '/tariffs', label: 'Активные тарифы', icon: ServerCog },
|
||||
{ to: '/providers', label: 'Хостеры', icon: Building2 },
|
||||
{ to: '/accounts', label: 'Аккаунты хостеров', icon: Wallet },
|
||||
{ to: '/payments', label: 'Платежи', icon: CreditCard },
|
||||
{ to: '/balance', label: 'Баланс и списания', icon: Coins },
|
||||
{ to: '/reports', label: 'Отчёты', icon: ChartColumnBig },
|
||||
{ to: '/resources', label: 'Ресурсы', icon: ChartBar },
|
||||
{ to: '/settings', label: 'Настройки', icon: Settings },
|
||||
]
|
||||
|
||||
const ROUTE_LABELS: Record<string, string> = Object.fromEntries(
|
||||
NAV_ITEMS.map((i) => [i.to, i.label]),
|
||||
)
|
||||
|
||||
export function AppShell({ children }: { children: ReactNode }) {
|
||||
const pathname = useRouterState({ select: (s) => s.location.pathname })
|
||||
const activeItem = NAV_ITEMS.find((i) => pathname.startsWith(i.to)) ?? NAV_ITEMS[0]
|
||||
|
||||
return (
|
||||
<SidebarProvider>
|
||||
<Sidebar collapsible="icon">
|
||||
<SidebarHeader>
|
||||
<div className="flex items-center gap-2 px-2 py-1.5">
|
||||
<div className="flex size-8 items-center justify-center rounded-md bg-primary text-primary-foreground">
|
||||
<Server className="size-4" />
|
||||
</div>
|
||||
<div className="flex flex-col text-left text-sm leading-tight group-data-[collapsible=icon]:hidden">
|
||||
<span className="font-semibold">VPS Tracker</span>
|
||||
<span className="text-xs text-muted-foreground">Учёт виртуальных серверов</span>
|
||||
</div>
|
||||
</div>
|
||||
</SidebarHeader>
|
||||
<SidebarContent>
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>Меню</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{NAV_ITEMS.map((item) => {
|
||||
const Icon = item.icon
|
||||
const isActive = pathname === item.to || pathname.startsWith(`${item.to}/`)
|
||||
return (
|
||||
<SidebarMenuItem key={item.to}>
|
||||
<SidebarMenuButton
|
||||
render={<Link to={item.to} />}
|
||||
isActive={isActive}
|
||||
tooltip={item.label}
|
||||
>
|
||||
<Icon />
|
||||
<span>{item.label}</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
)
|
||||
})}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
</SidebarContent>
|
||||
<SidebarFooter />
|
||||
</Sidebar>
|
||||
<SidebarInset>
|
||||
<header className="sticky top-0 flex h-16 shrink-0 items-center gap-2 border-b bg-background/95 px-4 backdrop-supports">
|
||||
<SidebarTrigger />
|
||||
<Separator orientation="vertical" className="mr-2 data-[orientation=vertical]:h-4" />
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage>{ROUTE_LABELS[activeItem.to] ?? ''}</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
</header>
|
||||
<main className="flex flex-1 flex-col gap-4 p-4 md:gap-6 md:p-6">{children}</main>
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import { Loader2Icon } from 'lucide-react'
|
||||
import type { ButtonHTMLAttributes, ReactNode } from 'react'
|
||||
|
||||
type LoadingButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
|
||||
loading?: boolean
|
||||
variant?: 'default' | 'outline' | 'secondary' | 'ghost' | 'destructive' | 'link'
|
||||
size?: 'default' | 'xs' | 'sm' | 'lg' | 'icon' | 'icon-xs' | 'icon-sm' | 'icon-lg'
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export function LoadingButton({ loading, disabled, children, ...props }: LoadingButtonProps) {
|
||||
return (
|
||||
<Button disabled={disabled || loading} {...props}>
|
||||
{loading ? <Loader2Icon className="animate-spin" data-icon="inline-start" /> : null}
|
||||
{children}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
interface PageHeaderProps {
|
||||
title: string
|
||||
description?: string
|
||||
actions?: ReactNode
|
||||
}
|
||||
|
||||
export function PageHeader({ title, description, actions }: PageHeaderProps) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
||||
<div className="space-y-1">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
|
||||
{description ? <p className="text-sm text-muted-foreground">{description}</p> : null}
|
||||
</div>
|
||||
{actions ? <div className="flex items-center gap-2">{actions}</div> : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { cn } from '@cfdm/ui/lib/utils'
|
||||
|
||||
export function PageShell({ children, className }: { children: ReactNode; className?: string }) {
|
||||
return <div className={cn('flex flex-col gap-4 md:gap-6', className)}>{children}</div>
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { AlertCircle, RefreshCwIcon } from 'lucide-react'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import { Skeleton } from '@cfdm/ui/components/skeleton'
|
||||
import { EmptyState } from './empty-state'
|
||||
|
||||
interface QueryStateProps<T> {
|
||||
data: T | undefined
|
||||
isLoading: boolean
|
||||
isError: boolean
|
||||
error?: unknown
|
||||
empty?: boolean
|
||||
emptyTitle?: string
|
||||
emptyDescription?: string
|
||||
emptyAction?: ReactNode
|
||||
onRetry?: () => void
|
||||
skeleton?: ReactNode
|
||||
children: (data: T) => ReactNode
|
||||
}
|
||||
|
||||
export function QueryState<T>({
|
||||
data,
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
empty,
|
||||
emptyTitle = 'Нет данных',
|
||||
emptyDescription,
|
||||
emptyAction,
|
||||
onRetry,
|
||||
skeleton,
|
||||
children,
|
||||
}: QueryStateProps<T>) {
|
||||
if (isLoading) {
|
||||
return <>{skeleton ?? <DefaultSkeleton />}</>
|
||||
}
|
||||
if (isError) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={<AlertCircle className="size-8" />}
|
||||
title="Ошибка загрузки"
|
||||
description={error instanceof Error ? error.message : 'Не удалось загрузить данные'}
|
||||
action={
|
||||
onRetry ? (
|
||||
<Button variant="outline" size="sm" onClick={onRetry}>
|
||||
<RefreshCwIcon data-icon="inline-start" />
|
||||
Повторить
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (empty || data == null) {
|
||||
return <EmptyState title={emptyTitle} description={emptyDescription} action={emptyAction} />
|
||||
}
|
||||
return <>{children(data)}</>
|
||||
}
|
||||
|
||||
function DefaultSkeleton() {
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<Skeleton className="h-32 w-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { ReactElement, ReactNode } from 'react'
|
||||
import { Card, CardContent } from '@cfdm/ui/components/card'
|
||||
import { cn } from '@cfdm/ui/lib/utils'
|
||||
|
||||
export interface SectionCardItem {
|
||||
label: ReactNode
|
||||
value: string | number | ReactElement
|
||||
hint?: ReactNode
|
||||
icon?: ReactNode
|
||||
}
|
||||
|
||||
export function SectionCards({ items, className }: { items: SectionCardItem[]; className?: string }) {
|
||||
return (
|
||||
<div className={cn('grid gap-4 sm:grid-cols-2 lg:grid-cols-4', className)}>
|
||||
{items.map((item, idx) => (
|
||||
<Card key={typeof item.label === 'string' ? item.label : idx} className="gap-0">
|
||||
<CardContent className="flex flex-col gap-1 p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground">{item.label}</span>
|
||||
{item.icon ? <span className="text-muted-foreground">{item.icon}</span> : null}
|
||||
</div>
|
||||
<span className="text-2xl font-semibold tabular-nums">{item.value}</span>
|
||||
{item.hint ? <span className="text-xs text-muted-foreground">{item.hint}</span> : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { SectionCards } from './section-cards'
|
||||
import { Skeleton } from '@cfdm/ui/components/skeleton'
|
||||
import { Card, CardContent } from '@cfdm/ui/components/card'
|
||||
|
||||
export function SectionCardsSkeleton({ count = 4 }: { count?: number }) {
|
||||
return (
|
||||
<SectionCards
|
||||
items={Array.from({ length: count }, (_, i) => ({
|
||||
label: <Skeleton className="h-4 w-24" key={`label-${i}`} />,
|
||||
value: <Skeleton className="h-7 w-20" key={`value-${i}`} />,
|
||||
}))}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function TableSkeleton({ rows = 6, cols = 4 }: { rows?: number; cols?: number }) {
|
||||
return (
|
||||
<Card className="gap-0">
|
||||
<CardContent className="p-0">
|
||||
<div className="flex flex-col">
|
||||
<div className="flex gap-2 border-b p-3">
|
||||
{Array.from({ length: cols }).map((_, i) => (
|
||||
<Skeleton className="h-4 flex-1" key={`h-${i}`} />
|
||||
))}
|
||||
</div>
|
||||
{Array.from({ length: rows }).map((_, r) => (
|
||||
<div className="flex gap-2 border-b p-3" key={`r-${r}`}>
|
||||
{Array.from({ length: cols }).map((_, c) => (
|
||||
<Skeleton className="h-4 flex-1" key={`c-${r}-${c}`} />
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Badge } from '@cfdm/ui/components/badge'
|
||||
import type { ComponentProps } from 'react'
|
||||
|
||||
type BadgeVariant = NonNullable<ComponentProps<typeof Badge>['variant']>
|
||||
|
||||
const STATUS_VARIANT: Record<string, BadgeVariant> = {
|
||||
active: 'default',
|
||||
ok: 'default',
|
||||
paid: 'default',
|
||||
paused: 'secondary',
|
||||
archived: 'outline',
|
||||
error: 'destructive',
|
||||
running: 'secondary',
|
||||
overdue: 'destructive',
|
||||
stale: 'destructive',
|
||||
}
|
||||
|
||||
export function StatusBadge({ status, label }: { status: string; label?: string }) {
|
||||
const variant = STATUS_VARIANT[status] ?? 'outline'
|
||||
return <Badge variant={variant}>{label ?? status}</Badge>
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@cfdm/ui/components/card'
|
||||
import { cn } from '@cfdm/ui/lib/utils'
|
||||
|
||||
interface TableCardProps {
|
||||
title?: ReactNode
|
||||
description?: ReactNode
|
||||
actions?: ReactNode
|
||||
children: ReactNode
|
||||
className?: string
|
||||
contentClassName?: string
|
||||
}
|
||||
|
||||
export function TableCard({ title, description, actions, children, className, contentClassName }: TableCardProps) {
|
||||
return (
|
||||
<Card className={cn('gap-0', className)}>
|
||||
{(title || actions) && (
|
||||
<CardHeader className="flex flex-row items-center justify-between gap-2">
|
||||
<div className="space-y-1">
|
||||
{title ? <CardTitle>{title}</CardTitle> : null}
|
||||
{description ? <p className="text-sm text-muted-foreground">{description}</p> : null}
|
||||
</div>
|
||||
{actions ? <div className="flex items-center gap-2">{actions}</div> : null}
|
||||
</CardHeader>
|
||||
)}
|
||||
<CardContent className={cn('p-0', contentClassName)}>{children}</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import type {
|
||||
DataSnapshot,
|
||||
RatesData,
|
||||
Settings,
|
||||
Vps,
|
||||
Provider,
|
||||
ProviderAccount,
|
||||
Payment,
|
||||
BalanceLedgerRow,
|
||||
} from '@/types/entities'
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_URL ?? ''
|
||||
|
||||
export class ApiError extends Error {
|
||||
status?: number
|
||||
constructor(message: string, status?: number) {
|
||||
super(message)
|
||||
this.name = 'ApiError'
|
||||
this.status = status
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchApi<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||
const url = `${API_BASE}${path.startsWith('/') ? path : `/${path}`}`
|
||||
const res = await fetch(url, {
|
||||
headers: { 'Content-Type': 'application/json', ...options.headers },
|
||||
...options,
|
||||
})
|
||||
if (!res.ok) {
|
||||
let message = res.statusText || 'API error'
|
||||
try {
|
||||
const data = (await res.json()) as { error?: string }
|
||||
if (data?.error) message = data.error
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
throw new ApiError(message, res.status)
|
||||
}
|
||||
if (res.status === 204) return null as T
|
||||
return (await res.json()) as T
|
||||
}
|
||||
|
||||
export type CollectionName =
|
||||
| 'vps'
|
||||
| 'providers'
|
||||
| 'providerAccounts'
|
||||
| 'payments'
|
||||
| 'balanceLedger'
|
||||
| 'settings'
|
||||
|
||||
const COLLECTION_PATHS: Record<CollectionName, string> = {
|
||||
vps: '/api/vps',
|
||||
providers: '/api/providers',
|
||||
providerAccounts: '/api/provider-accounts',
|
||||
payments: '/api/payments',
|
||||
balanceLedger: '/api/balance-ledger',
|
||||
settings: '/api/settings',
|
||||
}
|
||||
|
||||
function uid(): string {
|
||||
if (typeof crypto !== 'undefined' && crypto.randomUUID) return crypto.randomUUID()
|
||||
return `id-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`
|
||||
}
|
||||
|
||||
export const api = {
|
||||
fetchData: () => fetchApi<DataSnapshot>('/api/data'),
|
||||
fetchCollection: <T>(name: CollectionName) => fetchApi<T[]>(COLLECTION_PATHS[name]),
|
||||
|
||||
create: <T extends { id?: string }>(name: CollectionName, record: T) =>
|
||||
fetchApi<T[]>(COLLECTION_PATHS[name], {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ ...record, id: record.id || uid() }),
|
||||
}),
|
||||
|
||||
update: <T>(name: CollectionName, id: string, patch: Partial<T>) =>
|
||||
fetchApi<T[]>(`${COLLECTION_PATHS[name]}/${encodeURIComponent(id)}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(patch),
|
||||
}),
|
||||
|
||||
remove: <T>(name: CollectionName, id: string) =>
|
||||
fetchApi<T[]>(`${COLLECTION_PATHS[name]}/${encodeURIComponent(id)}`, { method: 'DELETE' }),
|
||||
|
||||
bulkUpdateVps: (ids: string[], action: string, value: unknown) =>
|
||||
fetchApi('/api/vps/bulk', {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ ids, action, value }),
|
||||
}),
|
||||
|
||||
syncAccount: (accountId: string, opts: Record<string, unknown> = {}) =>
|
||||
fetchApi(`/api/sync/${encodeURIComponent(accountId)}`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(opts),
|
||||
}),
|
||||
|
||||
fetchAccountBalance: (accountId: string) =>
|
||||
fetchApi<{ balance: number; currency: string }>(
|
||||
`/api/sync/${encodeURIComponent(accountId)}/balance`,
|
||||
),
|
||||
|
||||
testConnection: (apiBaseUrl: string, apiCredentials: string) =>
|
||||
fetchApi('/api/sync/test-connection', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ apiBaseUrl, apiCredentials }),
|
||||
}),
|
||||
|
||||
fetchSyncStatus: () => fetchApi('/api/sync/status'),
|
||||
sendTelegramTest: () =>
|
||||
fetchApi('/api/settings/telegram/test', { method: 'POST' }),
|
||||
|
||||
fetchProjectSuggestions: (q = '', limit = 25) => {
|
||||
const params = new URLSearchParams()
|
||||
if (q) params.set('q', q)
|
||||
params.set('limit', String(limit))
|
||||
return fetchApi<string[]>(`/api/projects/suggest?${params.toString()}`)
|
||||
},
|
||||
|
||||
downloadBackupJson: async (): Promise<Blob> => {
|
||||
const res = await fetch(`${API_BASE}/api/backup/json`)
|
||||
if (!res.ok) throw new ApiError(res.statusText || 'Ошибка выгрузки', res.status)
|
||||
return res.blob()
|
||||
},
|
||||
|
||||
downloadBackupDatabase: async (): Promise<Blob> => {
|
||||
const res = await fetch(`${API_BASE}/api/backup/database`)
|
||||
if (!res.ok) throw new ApiError(res.statusText || 'Ошибка выгрузки', res.status)
|
||||
return res.blob()
|
||||
},
|
||||
|
||||
importBackupJson: (payload: unknown) =>
|
||||
fetchApi('/api/backup/json', { method: 'POST', body: JSON.stringify(payload) }),
|
||||
|
||||
importBackupDatabase: async (buffer: ArrayBuffer) => {
|
||||
const res = await fetch(`${API_BASE}/api/backup/database`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/octet-stream' },
|
||||
body: buffer,
|
||||
})
|
||||
if (!res.ok) throw new ApiError(res.statusText || 'Ошибка восстановления', res.status)
|
||||
return res.json()
|
||||
},
|
||||
}
|
||||
|
||||
export type {
|
||||
DataSnapshot,
|
||||
RatesData,
|
||||
Settings,
|
||||
Vps,
|
||||
Provider,
|
||||
ProviderAccount,
|
||||
Payment,
|
||||
BalanceLedgerRow,
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { ProviderAccount, Provider } from '@/types/entities'
|
||||
|
||||
export function providerByIdMap(providers: Provider[]): Map<string, Provider> {
|
||||
return new Map(providers.map((p) => [p.id, p]))
|
||||
}
|
||||
|
||||
export function billmanagerSyncableAccounts(
|
||||
providerAccounts: ProviderAccount[],
|
||||
providers: Provider[],
|
||||
): ProviderAccount[] {
|
||||
const pmap = providerByIdMap(providers)
|
||||
return providerAccounts.filter((a) => {
|
||||
const p = pmap.get(a.providerId)
|
||||
return p?.apiType === 'billmanager' && Boolean((p.apiBaseUrl || '').trim()) && a.apiCredentialsSet
|
||||
})
|
||||
}
|
||||
|
||||
export function accountBillmanagerUiReady(
|
||||
account: ProviderAccount,
|
||||
provider?: Provider | null,
|
||||
): boolean {
|
||||
return (
|
||||
provider?.apiType === 'billmanager' &&
|
||||
Boolean((provider.apiBaseUrl || '').trim()) &&
|
||||
Boolean(account.apiCredentialsSet)
|
||||
)
|
||||
}
|
||||
|
||||
export function accountUsesBillmanagerBalanceApi(
|
||||
account: ProviderAccount,
|
||||
provider?: Provider | null,
|
||||
): boolean {
|
||||
return provider?.apiType === 'billmanager' && account.balance_api != null
|
||||
}
|
||||
|
||||
export function accountSelectLabel(
|
||||
account: ProviderAccount,
|
||||
providerById: Map<string, Provider>,
|
||||
scopedProviderId?: string,
|
||||
): string {
|
||||
const name = account.name?.trim() || '—'
|
||||
if (scopedProviderId) return name
|
||||
const providerName = providerById.get(account.providerId)?.name ?? '—'
|
||||
return `${providerName} / ${name}`
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
import type { Settings, RatesData, Vps, Provider } from '@/types/entities'
|
||||
|
||||
export function uid(): string {
|
||||
if (typeof crypto !== 'undefined' && crypto.randomUUID) return crypto.randomUUID()
|
||||
return `id-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`
|
||||
}
|
||||
|
||||
export function normalizeWebsiteUrl(website?: string): string {
|
||||
if (!website) return ''
|
||||
if (website.startsWith('http://') || website.startsWith('https://')) return website
|
||||
return `https://${website}`
|
||||
}
|
||||
|
||||
export function faviconUrlFromWebsite(website?: string): string {
|
||||
const normalized = normalizeWebsiteUrl(website)
|
||||
if (!normalized) return ''
|
||||
try {
|
||||
const { hostname } = new URL(normalized)
|
||||
return `https://www.google.com/s2/favicons?domain=${hostname}&sz=32`
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
const COUNTRY_CODE_BY_NAME: Record<string, string> = {
|
||||
germany: 'DE', netherlands: 'NL', russia: 'RU', usa: 'US', 'united states': 'US',
|
||||
ukraine: 'UA', poland: 'PL', france: 'FR', spain: 'ES', italy: 'IT',
|
||||
estonia: 'EE', finland: 'FI', sweden: 'SE', norway: 'NO', latvia: 'LV',
|
||||
lithuania: 'LT', czechia: 'CZ', czech: 'CZ', singapore: 'SG', japan: 'JP',
|
||||
canada: 'CA', brazil: 'BR', turkey: 'TR', georgia: 'GE', kazakhstan: 'KZ',
|
||||
}
|
||||
|
||||
export function getCountryFlagEmoji(country?: string): string {
|
||||
if (!country) return '🌐'
|
||||
const code = COUNTRY_CODE_BY_NAME[country.trim().toLowerCase()]
|
||||
if (!code) return '🌐'
|
||||
return code.toUpperCase().split('').map((c) => String.fromCodePoint(127397 + c.charCodeAt(0))).join('')
|
||||
}
|
||||
|
||||
const PAYMENT_TYPE_LABELS: Record<string, string> = {
|
||||
direct_vps_payment: 'Прямой платеж за VPS',
|
||||
provider_balance_topup: 'Пополнение баланса хостера',
|
||||
daily_debit: 'Ежедневное списание',
|
||||
monthly_debit: 'Ежемесячное списание',
|
||||
}
|
||||
|
||||
export function paymentTypeLabel(type: string): string {
|
||||
return PAYMENT_TYPE_LABELS[type] ?? type
|
||||
}
|
||||
|
||||
const VPS_STATUS_LABELS: Record<string, string> = {
|
||||
active: 'Активен', paused: 'Приостановлен', archived: 'Архив',
|
||||
}
|
||||
|
||||
export function vpsStatusLabel(status: string): string {
|
||||
return VPS_STATUS_LABELS[status] ?? status
|
||||
}
|
||||
|
||||
const BILLING_MODE_LABELS: Record<string, string> = {
|
||||
daily: 'Ежедневно', monthly: 'Ежемесячно',
|
||||
}
|
||||
|
||||
export function billingModeLabel(mode: string): string {
|
||||
return BILLING_MODE_LABELS[mode] ?? mode
|
||||
}
|
||||
|
||||
const TARIFF_TYPE_LABELS: Record<string, string> = {
|
||||
daily: 'Суточный', monthly: 'Месячный',
|
||||
}
|
||||
|
||||
export function tariffTypeLabel(type: string): string {
|
||||
return TARIFF_TYPE_LABELS[type] ?? type
|
||||
}
|
||||
|
||||
const CURRENCY_SYMBOL_MAP: Record<string, string> = {
|
||||
'€': 'EUR', '$': 'USD', '₽': 'RUB', '£': 'GBP', '¥': 'JPY', '₴': 'UAH', '₸': 'KZT',
|
||||
}
|
||||
|
||||
export function toIsoCurrency(currency?: string | null): string {
|
||||
if (!currency || typeof currency !== 'string') return 'USD'
|
||||
const trimmed = currency.trim()
|
||||
if (CURRENCY_SYMBOL_MAP[trimmed]) return CURRENCY_SYMBOL_MAP[trimmed]
|
||||
const upper = trimmed.toUpperCase()
|
||||
if (upper === 'RUR') return 'RUB'
|
||||
if (trimmed.length === 3 && /^[A-Z]{3}$/i.test(trimmed)) return upper
|
||||
return 'USD'
|
||||
}
|
||||
|
||||
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)
|
||||
return 'RUB'
|
||||
}
|
||||
|
||||
export function formatCurrency(amount: number, currency = 'USD'): string {
|
||||
const safeAmount = Number.isFinite(Number(amount)) ? Number(amount) : 0
|
||||
const isoCurrency = toIsoCurrency(currency)
|
||||
return new Intl.NumberFormat('ru-RU', {
|
||||
style: 'currency', currency: isoCurrency, minimumFractionDigits: 2,
|
||||
}).format(safeAmount)
|
||||
}
|
||||
|
||||
export function parseProviderFxRate(raw: unknown): number {
|
||||
if (raw == null) return NaN
|
||||
const s = String(raw).trim()
|
||||
if (!s) return NaN
|
||||
if (s.toLowerCase() === 'auto') return NaN
|
||||
const n = Number(s.replace(',', '.'))
|
||||
if (!Number.isFinite(n) || n <= 0) return NaN
|
||||
return n
|
||||
}
|
||||
|
||||
export function normalizeRatesPayload(payload: unknown): RatesData | null {
|
||||
if (!payload || typeof payload !== 'object') return null
|
||||
const p = payload as Record<string, unknown>
|
||||
if (p.base && p.rates && typeof p.rates === 'object') return p as unknown as RatesData
|
||||
const valute = p.Valute as Record<string, { CharCode?: string; Value?: number; Nominal?: number }> | undefined
|
||||
if (valute && typeof valute === 'object') {
|
||||
const rates: Record<string, number> = {}
|
||||
for (const v of Object.values(valute)) {
|
||||
const code = v?.CharCode
|
||||
const val = Number(v?.Value)
|
||||
const nom = Number(v?.Nominal) || 1
|
||||
if (typeof code === 'string' && code.length === 3 && Number.isFinite(val) && val > 0 && nom > 0) {
|
||||
rates[code] = nom / val
|
||||
}
|
||||
}
|
||||
if (Object.keys(rates).length === 0) return null
|
||||
return { base: 'RUB', rates, date: typeof p.Date === 'string' ? p.Date : '' }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function convertCurrency(
|
||||
amount: number,
|
||||
fromCurrency: string,
|
||||
toCurrency: string,
|
||||
ratesData: RatesData | null,
|
||||
): number {
|
||||
const safeAmount = Number(amount)
|
||||
if (!Number.isFinite(safeAmount)) return 0
|
||||
const from = toIsoCurrency(fromCurrency)
|
||||
const to = toIsoCurrency(toCurrency)
|
||||
if (!from || !to || from === to) return safeAmount
|
||||
if (!ratesData || !ratesData.rates || !ratesData.base) return safeAmount
|
||||
const apiBase = ratesData.base.toUpperCase()
|
||||
const rates: Record<string, number> = { ...ratesData.rates, [apiBase]: 1 }
|
||||
const rateFrom = rates[from]
|
||||
const rateTo = rates[to]
|
||||
if (!Number.isFinite(rateFrom) || rateFrom <= 0 || !Number.isFinite(rateTo) || rateTo <= 0) {
|
||||
return safeAmount
|
||||
}
|
||||
return (safeAmount / rateFrom) * rateTo
|
||||
}
|
||||
|
||||
export function formatInBaseCurrency(
|
||||
amount: number,
|
||||
currency: string,
|
||||
appSettings: Settings[] | Settings | null | undefined,
|
||||
ratesData: RatesData | null,
|
||||
): string {
|
||||
const settings: Partial<Settings> = Array.isArray(appSettings)
|
||||
? (appSettings[0] ?? {})
|
||||
: (appSettings ?? {})
|
||||
const baseCurrency = settings.baseCurrency || 'RUB'
|
||||
const autoConvert = settings.autoConvert !== false
|
||||
if (!autoConvert) return formatCurrency(amount, currency)
|
||||
const converted = convertCurrency(amount, currency, baseCurrency, ratesData)
|
||||
return formatCurrency(converted, baseCurrency)
|
||||
}
|
||||
|
||||
export interface ConvertedWithProvider {
|
||||
value: number
|
||||
currency: string
|
||||
source: 'native' | 'provider' | 'global' | 'no-rates'
|
||||
}
|
||||
|
||||
export function convertWithProviderRate(
|
||||
amount: number,
|
||||
currency: string,
|
||||
provider: Provider | null | undefined,
|
||||
appSettings: Settings[] | Settings | null,
|
||||
ratesData: RatesData | null,
|
||||
): ConvertedWithProvider {
|
||||
const safeAmount = Number(amount)
|
||||
const settings: Partial<Settings> = Array.isArray(appSettings)
|
||||
? (appSettings[0] ?? {})
|
||||
: (appSettings ?? {})
|
||||
const appBase = (settings.baseCurrency || 'RUB').toUpperCase()
|
||||
if (!Number.isFinite(safeAmount)) return { value: 0, currency: appBase, source: 'global' }
|
||||
const fromCurrency = toIsoCurrency(currency || appBase)
|
||||
if (fromCurrency === appBase) return { value: safeAmount, currency: appBase, source: 'native' }
|
||||
|
||||
const usdRate = parseProviderFxRate(provider?.usdRate)
|
||||
const eurRate = parseProviderFxRate(provider?.eurRate)
|
||||
if (fromCurrency === 'USD' && Number.isFinite(usdRate)) {
|
||||
return { value: safeAmount * usdRate, currency: appBase, source: 'provider' }
|
||||
}
|
||||
if (fromCurrency === 'EUR' && Number.isFinite(eurRate)) {
|
||||
return { value: safeAmount * eurRate, currency: appBase, source: 'provider' }
|
||||
}
|
||||
if (!ratesData || !ratesData.rates || !ratesData.base) {
|
||||
return { value: safeAmount, currency: fromCurrency, source: 'no-rates' }
|
||||
}
|
||||
const converted = convertCurrency(safeAmount, fromCurrency, appBase, ratesData)
|
||||
const oneConverted = convertCurrency(1, fromCurrency, appBase, ratesData)
|
||||
const globalRatesWork = Number.isFinite(oneConverted) && Math.abs(oneConverted - 1) > 1e-8
|
||||
return { value: converted, currency: appBase, source: globalRatesWork ? 'global' : 'no-rates' }
|
||||
}
|
||||
|
||||
export function formatInProviderCurrency(
|
||||
amount: number,
|
||||
currency: string,
|
||||
provider: Provider | null | undefined,
|
||||
appSettings: Settings[] | Settings | null,
|
||||
ratesData: RatesData | null,
|
||||
): string {
|
||||
const converted = convertWithProviderRate(amount, currency, provider, appSettings, ratesData)
|
||||
return formatCurrency(converted.value, converted.currency)
|
||||
}
|
||||
|
||||
export function monthKey(dateString: string): string {
|
||||
const date = new Date(dateString)
|
||||
if (Number.isNaN(date.getTime())) return ''
|
||||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
export function toCsv(rows: Record<string, unknown>[]): string {
|
||||
if (!rows.length) return ''
|
||||
const headers = Object.keys(rows[0])
|
||||
const escapeValue = (value: unknown) => {
|
||||
const str = `${value ?? ''}`
|
||||
if (str.includes('"') || str.includes(',') || str.includes('\n')) {
|
||||
return `"${str.replaceAll('"', '""')}"`
|
||||
}
|
||||
return str
|
||||
}
|
||||
const lines = [headers.join(',')]
|
||||
for (const row of rows) {
|
||||
lines.push(headers.map((h) => escapeValue(row[h])).join(','))
|
||||
}
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
export function downloadTextFile(fileName: string, content: string): void {
|
||||
const blob = new Blob([content], { type: 'text/csv;charset=utf-8;' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const anchor = document.createElement('a')
|
||||
anchor.href = url
|
||||
anchor.download = fileName
|
||||
anchor.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
export function downloadBlob(fileName: string, blob: Blob): void {
|
||||
const url = URL.createObjectURL(blob)
|
||||
const anchor = document.createElement('a')
|
||||
anchor.href = url
|
||||
anchor.download = fileName
|
||||
anchor.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
import type {
|
||||
Vps,
|
||||
ProviderAccount,
|
||||
Provider,
|
||||
Payment,
|
||||
BalanceLedgerRow,
|
||||
SyncLogRow,
|
||||
SyncSummary,
|
||||
} from '@/types/entities'
|
||||
import { getPaidUntilDate } from './paid-until'
|
||||
|
||||
const STALE_SYNC_HOURS = 48
|
||||
|
||||
function ledgerRowsInAccountCurrency(
|
||||
account: ProviderAccount,
|
||||
balanceLedger: BalanceLedgerRow[],
|
||||
): BalanceLedgerRow[] {
|
||||
const cur = (account.balance_currency || account.currency || '').trim()
|
||||
const rows = balanceLedger.filter((row) => row.providerAccountId === account.id)
|
||||
if (!cur) return rows
|
||||
return rows.filter((row) => !row.currency || row.currency === cur)
|
||||
}
|
||||
|
||||
function ledgerBalanceInCurrency(
|
||||
account: ProviderAccount,
|
||||
balanceLedger: BalanceLedgerRow[],
|
||||
): number {
|
||||
const filtered = ledgerRowsInAccountCurrency(account, balanceLedger)
|
||||
const credits = filtered.filter((r) => r.direction === 'credit').reduce((acc, r) => acc + Number(r.amount || 0), 0)
|
||||
const debits = filtered.filter((r) => r.direction === 'debit').reduce((acc, r) => acc + Number(r.amount || 0), 0)
|
||||
return credits - debits
|
||||
}
|
||||
|
||||
export function accountHasApiLedgerMismatch(
|
||||
account: ProviderAccount,
|
||||
balanceLedger: BalanceLedgerRow[],
|
||||
): boolean {
|
||||
if (account.balance_api == null || !Number.isFinite(Number(account.balance_api))) return false
|
||||
const rows = ledgerRowsInAccountCurrency(account, balanceLedger)
|
||||
if (rows.length === 0) return false
|
||||
const ledger = ledgerBalanceInCurrency(account, balanceLedger)
|
||||
if (!Number.isFinite(ledger)) return false
|
||||
const apiBalance = Number(account.balance_api)
|
||||
const diff = Math.abs(apiBalance - ledger)
|
||||
const tol = Math.max(10, Math.abs(apiBalance) * 0.05)
|
||||
return diff > tol
|
||||
}
|
||||
|
||||
export function lastOkSyncFinishedAt(
|
||||
accountId: string,
|
||||
syncLog: SyncLogRow[] = [],
|
||||
): number | null {
|
||||
const rows = syncLog.filter((r) => r.accountId === accountId && r.status === 'ok' && r.finishedAt)
|
||||
let best: number | null = null
|
||||
for (const r of rows) {
|
||||
const t = new Date(r.finishedAt as string).getTime()
|
||||
if (!Number.isNaN(t) && (best == null || t > best)) best = t
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
export interface InventoryIssue {
|
||||
key: string
|
||||
title: string
|
||||
count: number
|
||||
to: string
|
||||
hint?: string
|
||||
}
|
||||
|
||||
export interface InventoryHealthInput {
|
||||
vps: Vps[]
|
||||
providerAccounts: ProviderAccount[]
|
||||
providers?: Provider[]
|
||||
payments: Payment[]
|
||||
balanceLedger: BalanceLedgerRow[]
|
||||
syncLog?: SyncLogRow[]
|
||||
}
|
||||
|
||||
export function computeInventoryHealth(input: InventoryHealthInput): InventoryIssue[] {
|
||||
const { vps, providerAccounts, providers = [], payments, balanceLedger, syncLog = [] } = input
|
||||
const providerById = new Map(providers.map((p) => [p.id, p]))
|
||||
const now = new Date()
|
||||
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
||||
const ctx = { vps, providerAccounts, payments, balanceLedger, now }
|
||||
|
||||
const issues: InventoryIssue[] = []
|
||||
|
||||
const noProject = vps.filter((v) => v.status === 'active' && !(v.project || '').trim())
|
||||
if (noProject.length) {
|
||||
issues.push({ key: 'no-project', title: 'Активные VPS без проекта', count: noProject.length, to: '/vps?health=no-project' })
|
||||
}
|
||||
|
||||
const noRate = vps.filter((v) => {
|
||||
if (v.status !== 'active') return false
|
||||
const dr = Number(v.dailyRate || 0)
|
||||
const mr = Number(v.monthlyRate || 0)
|
||||
const noMoney = (!Number.isFinite(dr) || dr <= 0) && (!Number.isFinite(mr) || mr <= 0)
|
||||
const noCur = !(v.currency || '').trim()
|
||||
return noMoney || noCur
|
||||
})
|
||||
if (noRate.length) {
|
||||
issues.push({ key: 'no-rate', title: 'Нет ставки или валюты', count: noRate.length, to: '/vps?health=no-rate' })
|
||||
}
|
||||
|
||||
const paidOverdue = vps.filter((v) => {
|
||||
if (v.status !== 'active') return false
|
||||
const d = getPaidUntilDate(v, ctx)
|
||||
return d != null && d < todayStart
|
||||
})
|
||||
if (paidOverdue.length) {
|
||||
issues.push({ key: 'paid-overdue', title: 'Просрочена оплата (оценка)', count: paidOverdue.length, to: '/vps?health=paid-overdue' })
|
||||
}
|
||||
|
||||
const bmAccounts = providerAccounts.filter((a) => {
|
||||
const p = providerById.get(a.providerId)
|
||||
return p?.apiType === 'billmanager' && Boolean((p.apiBaseUrl || '').trim()) && a.apiCredentialsSet
|
||||
})
|
||||
const staleMs = STALE_SYNC_HOURS * 60 * 60 * 1000
|
||||
const staleAccounts = bmAccounts.filter((a) => {
|
||||
const t = lastOkSyncFinishedAt(a.id, syncLog)
|
||||
if (t == null) return true
|
||||
return now.getTime() - t > staleMs
|
||||
})
|
||||
if (staleAccounts.length) {
|
||||
issues.push({
|
||||
key: 'stale-sync',
|
||||
title: `Нет успешного синка > ${STALE_SYNC_HOURS} ч`,
|
||||
count: staleAccounts.length,
|
||||
to: '/accounts?health=stale-sync',
|
||||
hint: 'Проверьте API и журнал синхронизации',
|
||||
})
|
||||
}
|
||||
|
||||
const mismatchAccounts = providerAccounts.filter((a) => accountHasApiLedgerMismatch(a, balanceLedger))
|
||||
if (mismatchAccounts.length) {
|
||||
issues.push({
|
||||
key: 'balance-mismatch',
|
||||
title: 'Баланс API и ledger расходятся',
|
||||
count: mismatchAccounts.length,
|
||||
to: '/accounts?health=balance-mismatch',
|
||||
hint: 'Считается только если в журнале «Баланс и списания» есть движения по аккаунту',
|
||||
})
|
||||
}
|
||||
|
||||
return issues
|
||||
}
|
||||
|
||||
export function getStaleSyncAccountIds(
|
||||
providerAccounts: ProviderAccount[],
|
||||
providers: Provider[],
|
||||
syncLog: SyncLogRow[] = [],
|
||||
now = new Date(),
|
||||
): string[] {
|
||||
const providerById = new Map(providers.map((p) => [p.id, p]))
|
||||
const bmAccounts = providerAccounts.filter((a) => {
|
||||
const p = providerById.get(a.providerId)
|
||||
return p?.apiType === 'billmanager' && Boolean((p.apiBaseUrl || '').trim()) && a.apiCredentialsSet
|
||||
})
|
||||
const staleMs = STALE_SYNC_HOURS * 60 * 60 * 1000
|
||||
return bmAccounts
|
||||
.filter((a) => {
|
||||
const t = lastOkSyncFinishedAt(a.id, syncLog)
|
||||
if (t == null) return true
|
||||
return now.getTime() - t > staleMs
|
||||
})
|
||||
.map((a) => a.id)
|
||||
}
|
||||
|
||||
export function getBalanceMismatchAccountIds(
|
||||
providerAccounts: ProviderAccount[],
|
||||
balanceLedger: BalanceLedgerRow[],
|
||||
): string[] {
|
||||
return providerAccounts.filter((a) => accountHasApiLedgerMismatch(a, balanceLedger)).map((a) => a.id)
|
||||
}
|
||||
|
||||
export function formatSyncSummaryLine(summary: SyncSummary | null | undefined): string {
|
||||
if (!summary || typeof summary !== 'object') return ''
|
||||
if (summary.error) return String(summary.error)
|
||||
const parts: string[] = []
|
||||
if (summary.added?.length) parts.push(`+${summary.added.length} VPS`)
|
||||
if (summary.updated?.length) parts.push(`~${summary.updated.length} изм.`)
|
||||
if (summary.paymentsAdded) parts.push(`+${summary.paymentsAdded} платежей`)
|
||||
if (summary.tariffsOnly && summary.tariffsCount != null) parts.push(`тарифы: ${summary.tariffsCount}`)
|
||||
if (parts.length) return parts.join(', ')
|
||||
if (summary.vpsCount != null || summary.paymentsCount != null) {
|
||||
return `синк: VPS ${summary.vpsCount ?? 0}, платежи ${summary.paymentsCount ?? 0}`
|
||||
}
|
||||
return ''
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { HTMLInputProps } from '@/types/dom'
|
||||
|
||||
export const noBrowserSuggestProps: HTMLInputProps = Object.freeze({
|
||||
autoComplete: 'off',
|
||||
'data-lpignore': 'true',
|
||||
'data-1p-ignore': 'true',
|
||||
'data-bwignore': 'true',
|
||||
'data-form-type': 'other',
|
||||
} as HTMLInputProps)
|
||||
|
||||
export const passwordCredentialInputProps: HTMLInputProps = Object.freeze({
|
||||
autoComplete: 'new-password',
|
||||
} as HTMLInputProps)
|
||||
@@ -0,0 +1,72 @@
|
||||
import type { Vps, ProviderAccount, Payment, BalanceLedgerRow } from '@/types/entities'
|
||||
|
||||
function getAccountBalance(
|
||||
accountId: string,
|
||||
providerAccounts: ProviderAccount[],
|
||||
balanceLedger: BalanceLedgerRow[],
|
||||
): number {
|
||||
const account = providerAccounts.find((a) => a.id === accountId)
|
||||
if (account?.balance_api != null && Number.isFinite(Number(account.balance_api))) {
|
||||
return Number(account.balance_api)
|
||||
}
|
||||
const ledgerRows = balanceLedger.filter((row) => row.providerAccountId === accountId)
|
||||
const credits = ledgerRows.filter((r) => r.direction === 'credit').reduce((acc, r) => acc + Number(r.amount || 0), 0)
|
||||
const debits = ledgerRows.filter((r) => r.direction === 'debit').reduce((acc, r) => acc + Number(r.amount || 0), 0)
|
||||
return credits - debits
|
||||
}
|
||||
|
||||
export interface PaidUntilContext {
|
||||
vps: Vps[]
|
||||
providerAccounts: ProviderAccount[]
|
||||
payments: Payment[]
|
||||
balanceLedger: BalanceLedgerRow[]
|
||||
now?: Date
|
||||
}
|
||||
|
||||
export function getPaidUntilDate(item: Vps, ctx: PaidUntilContext): Date | null {
|
||||
const { vps, providerAccounts, payments, balanceLedger, now = new Date() } = ctx
|
||||
if (item.status !== 'active') return null
|
||||
const account = providerAccounts.find((a) => a.id === item.providerAccountId)
|
||||
const tariffType = item.tariffType || (Number(item.dailyRate || 0) > 0 ? 'daily' : 'monthly')
|
||||
const isDailyBilling = tariffType === 'daily' || account?.billingMode === 'daily'
|
||||
|
||||
const paidUntilFromApi = item.paidUntil
|
||||
? (() => {
|
||||
const d = new Date(item.paidUntil)
|
||||
return Number.isNaN(d.getTime()) ? null : d
|
||||
})()
|
||||
: null
|
||||
|
||||
const isPaidUntilNextDay =
|
||||
paidUntilFromApi != null &&
|
||||
(() => {
|
||||
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
||||
const diffMs = paidUntilFromApi.getTime() - today.getTime()
|
||||
const diffDays = Math.round(diffMs / (24 * 60 * 60 * 1000))
|
||||
return diffDays >= 0 && diffDays <= 2
|
||||
})()
|
||||
|
||||
const shouldCalculateFromBalance = isDailyBilling || isPaidUntilNextDay
|
||||
if (!shouldCalculateFromBalance && paidUntilFromApi) return paidUntilFromApi
|
||||
|
||||
const dailyRate = Number(item.dailyRate || 0)
|
||||
const monthlyRate = Number(item.monthlyRate || 0)
|
||||
const burnRate = tariffType === 'daily' ? dailyRate : monthlyRate / 30
|
||||
if (!Number.isFinite(burnRate) || burnRate <= 0) return paidUntilFromApi
|
||||
|
||||
const accountBalance = getAccountBalance(item.providerAccountId, providerAccounts, balanceLedger)
|
||||
const activeInAccount = vps.filter(
|
||||
(v) => v.providerAccountId === item.providerAccountId && v.status === 'active',
|
||||
).length
|
||||
const allocatedBalance = activeInAccount > 0 ? Math.max(0, accountBalance) / activeInAccount : 0
|
||||
const directPayments = payments
|
||||
.filter((p) => p.vpsId === item.id && p.type === 'direct_vps_payment')
|
||||
.reduce((acc, p) => acc + Number(p.amount || 0), 0)
|
||||
const funds = directPayments + allocatedBalance
|
||||
const coveredDays = Math.floor(funds / burnRate)
|
||||
if (!Number.isFinite(coveredDays) || coveredDays <= 0) return paidUntilFromApi
|
||||
|
||||
const paidUntil = new Date(now)
|
||||
paidUntil.setDate(paidUntil.getDate() + coveredDays)
|
||||
return paidUntil
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { QueryClient } from '@tanstack/react-query'
|
||||
|
||||
export const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 60_000,
|
||||
retry: 1,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,25 @@
|
||||
import { QueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
createRouter as tanstackCreateRouter,
|
||||
rootRouteId,
|
||||
} from '@tanstack/react-router'
|
||||
|
||||
import { routeTree } from '../routeTree.gen'
|
||||
import { queryClient } from './queryClient'
|
||||
|
||||
declare module '@tanstack/react-router' {
|
||||
interface Register {
|
||||
router: ReturnType<typeof createRouter>
|
||||
}
|
||||
}
|
||||
|
||||
export function createRouter(opts?: { context?: { queryClient: QueryClient } }) {
|
||||
return tanstackCreateRouter({
|
||||
routeTree,
|
||||
context: opts?.context ?? { queryClient },
|
||||
defaultPreload: 'intent',
|
||||
scrollRestoration: true,
|
||||
})
|
||||
}
|
||||
|
||||
export { rootRouteId }
|
||||
@@ -0,0 +1,92 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const vpsStatusSchema = z.enum(['active', 'paused', 'archived'])
|
||||
export const tariffTypeSchema = z.enum(['daily', 'monthly'])
|
||||
export const billingModeSchema = z.enum(['daily', 'monthly'])
|
||||
export const paymentTypeSchema = z.enum([
|
||||
'direct_vps_payment',
|
||||
'provider_balance_topup',
|
||||
'daily_debit',
|
||||
'monthly_debit',
|
||||
])
|
||||
export const ledgerDirectionSchema = z.enum(['credit', 'debit'])
|
||||
export const apiTypeSchema = z.enum(['billmanager', 'none'])
|
||||
|
||||
export const providerSchema = z.object({
|
||||
id: z.string().min(1).optional(),
|
||||
name: z.string().min(1, 'Название обязательно'),
|
||||
website: z.string().url('Невалидный URL').or(z.literal('')).optional(),
|
||||
apiType: apiTypeSchema,
|
||||
apiBaseUrl: z.string().optional().default(''),
|
||||
baseCurrency: z.string().min(1).default('RUB'),
|
||||
usdRate: z.string().optional().default(''),
|
||||
eurRate: z.string().optional().default(''),
|
||||
supportPhone: z.string().optional().default(''),
|
||||
supportUrl: z.string().optional().default(''),
|
||||
notes: z.string().optional().default(''),
|
||||
})
|
||||
|
||||
export const providerAccountSchema = z.object({
|
||||
id: z.string().min(1).optional(),
|
||||
providerId: z.string().min(1, 'Выберите хостера'),
|
||||
name: z.string().min(1, 'Название обязательно'),
|
||||
login: z.string().optional().default(''),
|
||||
apiCredentials: z.string().optional().default(''),
|
||||
billingMode: billingModeSchema.default('monthly'),
|
||||
notes: z.string().optional().default(''),
|
||||
})
|
||||
|
||||
export const vpsSchema = z.object({
|
||||
id: z.string().min(1).optional(),
|
||||
ip: z.string().min(1, 'IP обязателен'),
|
||||
dns: z.string().optional().default(''),
|
||||
providerId: z.string().min(1, 'Выберите хостера'),
|
||||
providerAccountId: z.string().min(1, 'Выберите аккаунт'),
|
||||
vcpu: z.coerce.number().int().min(0).default(1),
|
||||
ramGb: z.coerce.number().min(0).default(1),
|
||||
diskGb: z.coerce.number().min(0).default(10),
|
||||
status: vpsStatusSchema.default('active'),
|
||||
tariffType: tariffTypeSchema.default('monthly'),
|
||||
currency: z.string().min(1, 'Валюта обязательна').default('RUB'),
|
||||
monthlyRate: z.coerce.number().min(0).default(0),
|
||||
dailyRate: z.coerce.number().min(0).default(0),
|
||||
paidUntil: z.string().optional().default(''),
|
||||
project: z.string().optional().default(''),
|
||||
notes: z.string().optional().default(''),
|
||||
})
|
||||
|
||||
export const paymentSchema = z.object({
|
||||
id: z.string().min(1).optional(),
|
||||
type: paymentTypeSchema,
|
||||
date: z.string().min(1, 'Дата обязательна'),
|
||||
amount: z.coerce.number().min(0, 'Сумма должна быть ≥ 0'),
|
||||
currency: z.string().min(1).default('RUB'),
|
||||
providerAccountId: z.string().min(1, 'Выберите аккаунт'),
|
||||
note: z.string().optional().default(''),
|
||||
})
|
||||
|
||||
export const balanceLedgerSchema = z.object({
|
||||
providerAccountId: z.string().min(1, 'Выберите аккаунт'),
|
||||
direction: ledgerDirectionSchema,
|
||||
amount: z.coerce.number().min(0, 'Сумма должна быть ≥ 0'),
|
||||
currency: z.string().min(1).default('RUB'),
|
||||
date: z.string().min(1, 'Дата обязательна'),
|
||||
note: z.string().optional().default(''),
|
||||
})
|
||||
|
||||
export const settingsSchema = z.object({
|
||||
id: z.string().optional(),
|
||||
baseCurrency: z.string().min(1).default('RUB'),
|
||||
ratesUrl: z.string().url('Невалидный URL').or(z.literal('')).optional(),
|
||||
autoConvert: z.boolean().default(true),
|
||||
syncEnabled: z.boolean().optional().default(true),
|
||||
telegramChatId: z.string().optional().default(''),
|
||||
telegramBotToken: z.string().optional().default(''),
|
||||
})
|
||||
|
||||
export type ProviderFormValues = z.infer<typeof providerSchema>
|
||||
export type ProviderAccountFormValues = z.infer<typeof providerAccountSchema>
|
||||
export type VpsFormValues = z.infer<typeof vpsSchema>
|
||||
export type PaymentFormValues = z.infer<typeof paymentSchema>
|
||||
export type BalanceLedgerFormValues = z.infer<typeof balanceLedgerSchema>
|
||||
export type SettingsFormValues = z.infer<typeof settingsSchema>
|
||||
@@ -0,0 +1,24 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { QueryClientProvider } from '@tanstack/react-query'
|
||||
import { RouterProvider } from '@tanstack/react-router'
|
||||
import { Toaster } from '@cfdm/ui/components/sonner'
|
||||
|
||||
import '@cfdm/ui/globals.css'
|
||||
|
||||
import { queryClient } from '@/lib/queryClient'
|
||||
import { createRouter } from '@/lib/router'
|
||||
|
||||
const router = createRouter({ context: { queryClient } })
|
||||
|
||||
const rootEl = document.getElementById('root')
|
||||
if (!rootEl) throw new Error('Root element #root not found')
|
||||
|
||||
createRoot(rootEl).render(
|
||||
<StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RouterProvider router={router} />
|
||||
<Toaster richColors position="top-right" />
|
||||
</QueryClientProvider>
|
||||
</StrictMode>,
|
||||
)
|
||||
@@ -0,0 +1,47 @@
|
||||
import { queryClient } from '../lib/queryClient'
|
||||
import { api } from '../lib/api-client'
|
||||
|
||||
export const snapshotKeys = {
|
||||
all: ['snapshot'] as const,
|
||||
}
|
||||
|
||||
export const snapshotQueryOptions = () => ({
|
||||
queryKey: snapshotKeys.all,
|
||||
queryFn: () => api.fetchData(),
|
||||
staleTime: 30_000,
|
||||
})
|
||||
|
||||
export const ratesKeys = {
|
||||
all: ['rates'] as const,
|
||||
}
|
||||
|
||||
export const ratesQueryOptions = (ratesUrl?: string) => ({
|
||||
queryKey: ratesKeys.all,
|
||||
queryFn: async () => {
|
||||
if (!ratesUrl) return null
|
||||
const proxyUrl = `/api/rates-proxy?url=${encodeURIComponent(ratesUrl)}`
|
||||
try {
|
||||
const res = await fetch(proxyUrl)
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
return await res.json()
|
||||
} catch {
|
||||
const direct = await fetch(ratesUrl)
|
||||
if (!direct.ok) throw new Error(`HTTP ${direct.status}`)
|
||||
return await direct.json()
|
||||
}
|
||||
},
|
||||
enabled: Boolean(ratesUrl),
|
||||
})
|
||||
|
||||
export const projectsKeys = {
|
||||
suggest: (q: string) => ['projects', 'suggest', q] as const,
|
||||
}
|
||||
|
||||
export const projectsSuggestQueryOptions = (q: string) => ({
|
||||
queryKey: projectsKeys.suggest(q),
|
||||
queryFn: () => api.fetchProjectSuggestions(q),
|
||||
enabled: q.length >= 2,
|
||||
})
|
||||
|
||||
export type SnapshotFromQuery = Awaited<ReturnType<typeof api.fetchData>>
|
||||
export { queryClient }
|
||||
@@ -0,0 +1,297 @@
|
||||
/* eslint-disable */
|
||||
|
||||
// @ts-nocheck
|
||||
|
||||
// noinspection JSUnusedGlobalSymbols
|
||||
|
||||
// This file was automatically generated by TanStack Router.
|
||||
// You should NOT make any changes in this file as it will be overwritten.
|
||||
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
|
||||
|
||||
import { Route as rootRouteImport } from './routes/__root'
|
||||
import { Route as AuthRouteImport } from './routes/_auth'
|
||||
import { Route as IndexRouteImport } from './routes/index'
|
||||
import { Route as AuthVpsRouteImport } from './routes/_auth/vps'
|
||||
import { Route as AuthTariffsRouteImport } from './routes/_auth/tariffs'
|
||||
import { Route as AuthSettingsRouteImport } from './routes/_auth/settings'
|
||||
import { Route as AuthResourcesRouteImport } from './routes/_auth/resources'
|
||||
import { Route as AuthReportsRouteImport } from './routes/_auth/reports'
|
||||
import { Route as AuthProvidersRouteImport } from './routes/_auth/providers'
|
||||
import { Route as AuthPaymentsRouteImport } from './routes/_auth/payments'
|
||||
import { Route as AuthDashboardRouteImport } from './routes/_auth/dashboard'
|
||||
import { Route as AuthBalanceRouteImport } from './routes/_auth/balance'
|
||||
import { Route as AuthAccountsRouteImport } from './routes/_auth/accounts'
|
||||
|
||||
const AuthRoute = AuthRouteImport.update({
|
||||
id: '/_auth',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const IndexRoute = IndexRouteImport.update({
|
||||
id: '/',
|
||||
path: '/',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const AuthVpsRoute = AuthVpsRouteImport.update({
|
||||
id: '/vps',
|
||||
path: '/vps',
|
||||
getParentRoute: () => AuthRoute,
|
||||
} as any)
|
||||
const AuthTariffsRoute = AuthTariffsRouteImport.update({
|
||||
id: '/tariffs',
|
||||
path: '/tariffs',
|
||||
getParentRoute: () => AuthRoute,
|
||||
} as any)
|
||||
const AuthSettingsRoute = AuthSettingsRouteImport.update({
|
||||
id: '/settings',
|
||||
path: '/settings',
|
||||
getParentRoute: () => AuthRoute,
|
||||
} as any)
|
||||
const AuthResourcesRoute = AuthResourcesRouteImport.update({
|
||||
id: '/resources',
|
||||
path: '/resources',
|
||||
getParentRoute: () => AuthRoute,
|
||||
} as any)
|
||||
const AuthReportsRoute = AuthReportsRouteImport.update({
|
||||
id: '/reports',
|
||||
path: '/reports',
|
||||
getParentRoute: () => AuthRoute,
|
||||
} as any)
|
||||
const AuthProvidersRoute = AuthProvidersRouteImport.update({
|
||||
id: '/providers',
|
||||
path: '/providers',
|
||||
getParentRoute: () => AuthRoute,
|
||||
} as any)
|
||||
const AuthPaymentsRoute = AuthPaymentsRouteImport.update({
|
||||
id: '/payments',
|
||||
path: '/payments',
|
||||
getParentRoute: () => AuthRoute,
|
||||
} as any)
|
||||
const AuthDashboardRoute = AuthDashboardRouteImport.update({
|
||||
id: '/dashboard',
|
||||
path: '/dashboard',
|
||||
getParentRoute: () => AuthRoute,
|
||||
} as any)
|
||||
const AuthBalanceRoute = AuthBalanceRouteImport.update({
|
||||
id: '/balance',
|
||||
path: '/balance',
|
||||
getParentRoute: () => AuthRoute,
|
||||
} as any)
|
||||
const AuthAccountsRoute = AuthAccountsRouteImport.update({
|
||||
id: '/accounts',
|
||||
path: '/accounts',
|
||||
getParentRoute: () => AuthRoute,
|
||||
} as any)
|
||||
|
||||
export interface FileRoutesByFullPath {
|
||||
'/': typeof IndexRoute
|
||||
'/accounts': typeof AuthAccountsRoute
|
||||
'/balance': typeof AuthBalanceRoute
|
||||
'/dashboard': typeof AuthDashboardRoute
|
||||
'/payments': typeof AuthPaymentsRoute
|
||||
'/providers': typeof AuthProvidersRoute
|
||||
'/reports': typeof AuthReportsRoute
|
||||
'/resources': typeof AuthResourcesRoute
|
||||
'/settings': typeof AuthSettingsRoute
|
||||
'/tariffs': typeof AuthTariffsRoute
|
||||
'/vps': typeof AuthVpsRoute
|
||||
}
|
||||
export interface FileRoutesByTo {
|
||||
'/': typeof IndexRoute
|
||||
'/accounts': typeof AuthAccountsRoute
|
||||
'/balance': typeof AuthBalanceRoute
|
||||
'/dashboard': typeof AuthDashboardRoute
|
||||
'/payments': typeof AuthPaymentsRoute
|
||||
'/providers': typeof AuthProvidersRoute
|
||||
'/reports': typeof AuthReportsRoute
|
||||
'/resources': typeof AuthResourcesRoute
|
||||
'/settings': typeof AuthSettingsRoute
|
||||
'/tariffs': typeof AuthTariffsRoute
|
||||
'/vps': typeof AuthVpsRoute
|
||||
}
|
||||
export interface FileRoutesById {
|
||||
__root__: typeof rootRouteImport
|
||||
'/': typeof IndexRoute
|
||||
'/_auth': typeof AuthRouteWithChildren
|
||||
'/_auth/accounts': typeof AuthAccountsRoute
|
||||
'/_auth/balance': typeof AuthBalanceRoute
|
||||
'/_auth/dashboard': typeof AuthDashboardRoute
|
||||
'/_auth/payments': typeof AuthPaymentsRoute
|
||||
'/_auth/providers': typeof AuthProvidersRoute
|
||||
'/_auth/reports': typeof AuthReportsRoute
|
||||
'/_auth/resources': typeof AuthResourcesRoute
|
||||
'/_auth/settings': typeof AuthSettingsRoute
|
||||
'/_auth/tariffs': typeof AuthTariffsRoute
|
||||
'/_auth/vps': typeof AuthVpsRoute
|
||||
}
|
||||
export interface FileRouteTypes {
|
||||
fileRoutesByFullPath: FileRoutesByFullPath
|
||||
fullPaths:
|
||||
| '/'
|
||||
| '/accounts'
|
||||
| '/balance'
|
||||
| '/dashboard'
|
||||
| '/payments'
|
||||
| '/providers'
|
||||
| '/reports'
|
||||
| '/resources'
|
||||
| '/settings'
|
||||
| '/tariffs'
|
||||
| '/vps'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
to:
|
||||
| '/'
|
||||
| '/accounts'
|
||||
| '/balance'
|
||||
| '/dashboard'
|
||||
| '/payments'
|
||||
| '/providers'
|
||||
| '/reports'
|
||||
| '/resources'
|
||||
| '/settings'
|
||||
| '/tariffs'
|
||||
| '/vps'
|
||||
id:
|
||||
| '__root__'
|
||||
| '/'
|
||||
| '/_auth'
|
||||
| '/_auth/accounts'
|
||||
| '/_auth/balance'
|
||||
| '/_auth/dashboard'
|
||||
| '/_auth/payments'
|
||||
| '/_auth/providers'
|
||||
| '/_auth/reports'
|
||||
| '/_auth/resources'
|
||||
| '/_auth/settings'
|
||||
| '/_auth/tariffs'
|
||||
| '/_auth/vps'
|
||||
fileRoutesById: FileRoutesById
|
||||
}
|
||||
export interface RootRouteChildren {
|
||||
IndexRoute: typeof IndexRoute
|
||||
AuthRoute: typeof AuthRouteWithChildren
|
||||
}
|
||||
|
||||
declare module '@tanstack/react-router' {
|
||||
interface FileRoutesByPath {
|
||||
'/_auth': {
|
||||
id: '/_auth'
|
||||
path: ''
|
||||
fullPath: '/'
|
||||
preLoaderRoute: typeof AuthRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/': {
|
||||
id: '/'
|
||||
path: '/'
|
||||
fullPath: '/'
|
||||
preLoaderRoute: typeof IndexRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/_auth/vps': {
|
||||
id: '/_auth/vps'
|
||||
path: '/vps'
|
||||
fullPath: '/vps'
|
||||
preLoaderRoute: typeof AuthVpsRouteImport
|
||||
parentRoute: typeof AuthRoute
|
||||
}
|
||||
'/_auth/tariffs': {
|
||||
id: '/_auth/tariffs'
|
||||
path: '/tariffs'
|
||||
fullPath: '/tariffs'
|
||||
preLoaderRoute: typeof AuthTariffsRouteImport
|
||||
parentRoute: typeof AuthRoute
|
||||
}
|
||||
'/_auth/settings': {
|
||||
id: '/_auth/settings'
|
||||
path: '/settings'
|
||||
fullPath: '/settings'
|
||||
preLoaderRoute: typeof AuthSettingsRouteImport
|
||||
parentRoute: typeof AuthRoute
|
||||
}
|
||||
'/_auth/resources': {
|
||||
id: '/_auth/resources'
|
||||
path: '/resources'
|
||||
fullPath: '/resources'
|
||||
preLoaderRoute: typeof AuthResourcesRouteImport
|
||||
parentRoute: typeof AuthRoute
|
||||
}
|
||||
'/_auth/reports': {
|
||||
id: '/_auth/reports'
|
||||
path: '/reports'
|
||||
fullPath: '/reports'
|
||||
preLoaderRoute: typeof AuthReportsRouteImport
|
||||
parentRoute: typeof AuthRoute
|
||||
}
|
||||
'/_auth/providers': {
|
||||
id: '/_auth/providers'
|
||||
path: '/providers'
|
||||
fullPath: '/providers'
|
||||
preLoaderRoute: typeof AuthProvidersRouteImport
|
||||
parentRoute: typeof AuthRoute
|
||||
}
|
||||
'/_auth/payments': {
|
||||
id: '/_auth/payments'
|
||||
path: '/payments'
|
||||
fullPath: '/payments'
|
||||
preLoaderRoute: typeof AuthPaymentsRouteImport
|
||||
parentRoute: typeof AuthRoute
|
||||
}
|
||||
'/_auth/dashboard': {
|
||||
id: '/_auth/dashboard'
|
||||
path: '/dashboard'
|
||||
fullPath: '/dashboard'
|
||||
preLoaderRoute: typeof AuthDashboardRouteImport
|
||||
parentRoute: typeof AuthRoute
|
||||
}
|
||||
'/_auth/balance': {
|
||||
id: '/_auth/balance'
|
||||
path: '/balance'
|
||||
fullPath: '/balance'
|
||||
preLoaderRoute: typeof AuthBalanceRouteImport
|
||||
parentRoute: typeof AuthRoute
|
||||
}
|
||||
'/_auth/accounts': {
|
||||
id: '/_auth/accounts'
|
||||
path: '/accounts'
|
||||
fullPath: '/accounts'
|
||||
preLoaderRoute: typeof AuthAccountsRouteImport
|
||||
parentRoute: typeof AuthRoute
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface AuthRouteChildren {
|
||||
AuthAccountsRoute: typeof AuthAccountsRoute
|
||||
AuthBalanceRoute: typeof AuthBalanceRoute
|
||||
AuthDashboardRoute: typeof AuthDashboardRoute
|
||||
AuthPaymentsRoute: typeof AuthPaymentsRoute
|
||||
AuthProvidersRoute: typeof AuthProvidersRoute
|
||||
AuthReportsRoute: typeof AuthReportsRoute
|
||||
AuthResourcesRoute: typeof AuthResourcesRoute
|
||||
AuthSettingsRoute: typeof AuthSettingsRoute
|
||||
AuthTariffsRoute: typeof AuthTariffsRoute
|
||||
AuthVpsRoute: typeof AuthVpsRoute
|
||||
}
|
||||
|
||||
const AuthRouteChildren: AuthRouteChildren = {
|
||||
AuthAccountsRoute: AuthAccountsRoute,
|
||||
AuthBalanceRoute: AuthBalanceRoute,
|
||||
AuthDashboardRoute: AuthDashboardRoute,
|
||||
AuthPaymentsRoute: AuthPaymentsRoute,
|
||||
AuthProvidersRoute: AuthProvidersRoute,
|
||||
AuthReportsRoute: AuthReportsRoute,
|
||||
AuthResourcesRoute: AuthResourcesRoute,
|
||||
AuthSettingsRoute: AuthSettingsRoute,
|
||||
AuthTariffsRoute: AuthTariffsRoute,
|
||||
AuthVpsRoute: AuthVpsRoute,
|
||||
}
|
||||
|
||||
const AuthRouteWithChildren = AuthRoute._addFileChildren(AuthRouteChildren)
|
||||
|
||||
const rootRouteChildren: RootRouteChildren = {
|
||||
IndexRoute: IndexRoute,
|
||||
AuthRoute: AuthRouteWithChildren,
|
||||
}
|
||||
export const routeTree = rootRouteImport
|
||||
._addFileChildren(rootRouteChildren)
|
||||
._addFileTypes<FileRouteTypes>()
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Outlet, createRootRouteWithContext } from '@tanstack/react-router'
|
||||
import { AppShell } from '@/components/layout/app-shell'
|
||||
|
||||
interface RouterContext {
|
||||
queryClient: import('@tanstack/react-query').QueryClient
|
||||
}
|
||||
|
||||
export const Route = createRootRouteWithContext<RouterContext>()({
|
||||
component: RootComponent,
|
||||
})
|
||||
|
||||
function RootComponent() {
|
||||
return (
|
||||
<AppShell>
|
||||
<Outlet />
|
||||
</AppShell>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Outlet, createFileRoute } from '@tanstack/react-router'
|
||||
import { snapshotQueryOptions } from '@/queries/snapshot'
|
||||
|
||||
export const Route = createFileRoute('/_auth')({
|
||||
loader: ({ context: { queryClient } }) =>
|
||||
queryClient.ensureQueryData(snapshotQueryOptions()),
|
||||
component: AuthLayout,
|
||||
})
|
||||
|
||||
function AuthLayout() {
|
||||
return <Outlet />
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useState } from 'react'
|
||||
import { PlusIcon, PencilIcon, Trash2Icon, RefreshCwIcon } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { snapshotQueryOptions } from '@/queries/snapshot'
|
||||
import { api, ApiError } from '@/lib/api-client'
|
||||
import { PageShell } from '@/components/page-shell'
|
||||
import { PageHeader } from '@/components/page-header'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import { Badge } from '@cfdm/ui/components/badge'
|
||||
import { DataTableCard, type DataTableColumn } from '@/components/data-table-card'
|
||||
import { QueryState } from '@/components/query-state'
|
||||
import { TableSkeleton } from '@/components/skeletons'
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
import { FormSheet } from '@/components/form-sheet'
|
||||
import { FormField } from '@/components/form-field'
|
||||
import { Input } from '@cfdm/ui/components/input'
|
||||
import { Textarea } from '@cfdm/ui/components/textarea'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@cfdm/ui/components/select'
|
||||
import { LoadingButton } from '@/components/loading-button'
|
||||
|
||||
import type { ProviderAccount, BillingMode } from '@/types/entities'
|
||||
import { providerByIdMap, accountBillmanagerUiReady } from '@/lib/billmanager'
|
||||
import { billingModeLabel, formatCurrency } from '@/lib/format'
|
||||
|
||||
export const Route = createFileRoute('/_auth/accounts')({
|
||||
loader: ({ context: { queryClient } }) =>
|
||||
queryClient.ensureQueryData(snapshotQueryOptions()),
|
||||
component: AccountsPage,
|
||||
})
|
||||
|
||||
interface FormState {
|
||||
id?: string
|
||||
providerId: string
|
||||
name: string
|
||||
login: string
|
||||
apiCredentials: string
|
||||
billingMode: BillingMode
|
||||
notes: string
|
||||
}
|
||||
|
||||
const EMPTY: FormState = { providerId: '', name: '', login: '', apiCredentials: '', billingMode: 'monthly', notes: '' }
|
||||
|
||||
function AccountsPage() {
|
||||
const queryClient = useQueryClient()
|
||||
const { data: snapshot, isLoading, isError, error, refetch } = useQuery(snapshotQueryOptions())
|
||||
const [open, setOpen] = useState(false)
|
||||
const [form, setForm] = useState<FormState>(EMPTY)
|
||||
|
||||
const saveMut = useMutation({
|
||||
mutationFn: (r: FormState) => {
|
||||
const { apiCredentials, ...rest } = r
|
||||
const payload = apiCredentials ? { ...rest, apiCredentials } : rest
|
||||
return r.id
|
||||
? api.update<ProviderAccount>('providerAccounts', r.id, payload as unknown as Partial<ProviderAccount>)
|
||||
: api.create('providerAccounts', payload as unknown as ProviderAccount)
|
||||
},
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ['snapshot'] })
|
||||
toast.success('Аккаунт сохранён')
|
||||
setOpen(false)
|
||||
},
|
||||
onError: (e: unknown) => toast.error(e instanceof ApiError ? e.message : 'Ошибка'),
|
||||
})
|
||||
const delMut = useMutation({
|
||||
mutationFn: (id: string) => api.remove<ProviderAccount>('providerAccounts', id),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ['snapshot'] })
|
||||
toast.success('Аккаунт удалён')
|
||||
},
|
||||
onError: (e: unknown) => toast.error(e instanceof ApiError ? e.message : 'Ошибка'),
|
||||
})
|
||||
const syncMut = useMutation({
|
||||
mutationFn: (id: string) => api.syncAccount(id),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ['snapshot'] })
|
||||
toast.success('Синк запущен')
|
||||
},
|
||||
onError: (e: unknown) => toast.error(e instanceof ApiError ? e.message : 'Ошибка синка'),
|
||||
})
|
||||
|
||||
const openCreate = () => { setForm({ ...EMPTY, providerId: snapshot?.providers[0]?.id ?? '' }); setOpen(true) }
|
||||
const openEdit = (a: ProviderAccount) => {
|
||||
setForm({
|
||||
id: a.id, providerId: a.providerId, name: a.name, login: a.login ?? '',
|
||||
apiCredentials: '', billingMode: a.billingMode ?? 'monthly', notes: a.notes ?? '',
|
||||
})
|
||||
setOpen(true)
|
||||
}
|
||||
|
||||
const providerById = snapshot ? providerByIdMap(snapshot.providers) : new Map()
|
||||
|
||||
const columns: DataTableColumn<ProviderAccount>[] = [
|
||||
{
|
||||
key: 'name',
|
||||
header: 'Аккаунт',
|
||||
cell: (a) => (
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{a.name}</span>
|
||||
<span className="text-xs text-muted-foreground">{providerById.get(a.providerId)?.name ?? '—'}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{ key: 'login', header: 'Логин', cell: (a) => <span className="text-muted-foreground">{a.login || '—'}</span> },
|
||||
{
|
||||
key: 'creds',
|
||||
header: 'API-доступ',
|
||||
cell: (a) => <Badge variant={a.apiCredentialsSet ? 'default' : 'outline'}>{a.apiCredentialsSet ? 'установлены' : 'нет'}</Badge>,
|
||||
},
|
||||
{
|
||||
key: 'mode',
|
||||
header: 'Биллинг',
|
||||
cell: (a) => <span>{billingModeLabel(a.billingMode ?? 'monthly')}</span>,
|
||||
},
|
||||
{
|
||||
key: 'balance',
|
||||
header: 'Баланс (API)',
|
||||
cell: (a) => {
|
||||
const provider = providerById.get(a.providerId)
|
||||
if (!accountBillmanagerUiReady(a, provider)) return <span className="text-muted-foreground">—</span>
|
||||
const cur = a.balance_currency || a.currency || provider?.baseCurrency || 'USD'
|
||||
return <span className="tabular-nums">{formatCurrency(Number(a.balance_api ?? 0), cur)}</span>
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
header: '',
|
||||
className: 'w-32 text-right',
|
||||
cell: (a) => {
|
||||
const provider = providerById.get(a.providerId)
|
||||
const canSync = accountBillmanagerUiReady(a, provider)
|
||||
return (
|
||||
<div className="flex justify-end gap-1">
|
||||
<LoadingButton
|
||||
variant="outline"
|
||||
size="sm"
|
||||
loading={syncMut.isPending && syncMut.variables === a.id}
|
||||
disabled={!canSync}
|
||||
onClick={() => syncMut.mutate(a.id)}
|
||||
>
|
||||
<RefreshCwIcon data-icon="inline-start" />
|
||||
Синк
|
||||
</LoadingButton>
|
||||
<Button variant="ghost" size="icon-sm" onClick={() => openEdit(a)} aria-label="Редактировать">
|
||||
<PencilIcon />
|
||||
</Button>
|
||||
<ConfirmDialog
|
||||
trigger={<Button variant="ghost" size="icon-sm" aria-label="Удалить"><Trash2Icon /></Button>}
|
||||
title="Удалить аккаунт?"
|
||||
description={`«${a.name}» будет удалён.`}
|
||||
destructive
|
||||
confirmLabel="Удалить"
|
||||
onConfirm={() => delMut.mutate(a.id)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<PageShell>
|
||||
<PageHeader
|
||||
title="Аккаунты хостеров"
|
||||
description="Аккаунты провайдеров с API-доступом"
|
||||
actions={<Button onClick={openCreate}><PlusIcon data-icon="inline-start" />Добавить</Button>}
|
||||
/>
|
||||
<QueryState
|
||||
data={snapshot}
|
||||
isLoading={isLoading}
|
||||
isError={isError}
|
||||
error={error}
|
||||
onRetry={() => refetch()}
|
||||
skeleton={<TableSkeleton />}
|
||||
empty={snapshot?.providerAccounts.length === 0}
|
||||
emptyTitle="Аккаунты не найдены"
|
||||
emptyAction={<Button onClick={openCreate}><PlusIcon data-icon="inline-start" />Добавить аккаунт</Button>}
|
||||
>
|
||||
{(snap) => <DataTableCard columns={columns} data={snap.providerAccounts} rowKey={(a) => a.id} />}
|
||||
</QueryState>
|
||||
|
||||
<FormSheet
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
trigger={null}
|
||||
title={form.id ? 'Редактировать аккаунт' : 'Новый аккаунт'}
|
||||
description="API-креды хранятся на сервере и используются для синка с BILLmanager"
|
||||
onSubmit={() => saveMut.mutate(form)}
|
||||
submitting={saveMut.isPending}
|
||||
>
|
||||
<FormField label="Хостер" htmlFor="acc-provider">
|
||||
<Select value={form.providerId} onValueChange={(v) => setForm({ ...form, providerId: v ?? '' })}>
|
||||
<SelectTrigger id="acc-provider"><SelectValue placeholder="Выберите хостера" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{snapshot?.providers.map((p) => <SelectItem key={p.id} value={p.id}>{p.name}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormField>
|
||||
<FormField label="Название" htmlFor="acc-name">
|
||||
<Input id="acc-name" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
|
||||
</FormField>
|
||||
<FormField label="Логин" htmlFor="acc-login">
|
||||
<Input id="acc-login" value={form.login} onChange={(e) => setForm({ ...form, login: e.target.value })} />
|
||||
</FormField>
|
||||
<FormField
|
||||
label={form.id ? 'Новый API-пароль (необязательно)' : 'API-пароль (логин:пароль)'}
|
||||
htmlFor="acc-creds"
|
||||
description="Оставьте пустым при редактировании, чтобы сохранить существующий"
|
||||
>
|
||||
<Input id="acc-creds" type="password" value={form.apiCredentials} onChange={(e) => setForm({ ...form, apiCredentials: e.target.value })} />
|
||||
</FormField>
|
||||
<FormField label="Режим биллинга" htmlFor="acc-mode">
|
||||
<Select value={form.billingMode} onValueChange={(v) => setForm({ ...form, billingMode: (v ?? 'monthly') as BillingMode })}>
|
||||
<SelectTrigger id="acc-mode"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="monthly">{billingModeLabel('monthly')}</SelectItem>
|
||||
<SelectItem value="daily">{billingModeLabel('daily')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormField>
|
||||
<FormField label="Заметки" htmlFor="acc-notes">
|
||||
<Textarea id="acc-notes" value={form.notes} onChange={(e) => setForm({ ...form, notes: e.target.value })} />
|
||||
</FormField>
|
||||
</FormSheet>
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useState } from 'react'
|
||||
import { PlusIcon, Trash2Icon, ArrowDownUpIcon } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { snapshotQueryOptions } from '@/queries/snapshot'
|
||||
import { api, ApiError } from '@/lib/api-client'
|
||||
import { PageShell } from '@/components/page-shell'
|
||||
import { PageHeader } from '@/components/page-header'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import { Badge } from '@cfdm/ui/components/badge'
|
||||
import { DataTableCard, type DataTableColumn } from '@/components/data-table-card'
|
||||
import { QueryState } from '@/components/query-state'
|
||||
import { SectionCardsSkeleton } from '@/components/skeletons'
|
||||
import { SectionCards } from '@/components/section-cards'
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
import { FormSheet } from '@/components/form-sheet'
|
||||
import { FormField } from '@/components/form-field'
|
||||
import { Input } from '@cfdm/ui/components/input'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@cfdm/ui/components/select'
|
||||
import { Textarea } from '@cfdm/ui/components/textarea'
|
||||
|
||||
import type { BalanceLedgerRow, LedgerDirection } from '@/types/entities'
|
||||
import { formatCurrency } from '@/lib/format'
|
||||
import { providerByIdMap, accountSelectLabel } from '@/lib/billmanager'
|
||||
|
||||
export const Route = createFileRoute('/_auth/balance')({
|
||||
loader: ({ context: { queryClient } }) =>
|
||||
queryClient.ensureQueryData(snapshotQueryOptions()),
|
||||
component: BalancePage,
|
||||
})
|
||||
|
||||
interface FormState {
|
||||
providerAccountId: string
|
||||
direction: LedgerDirection
|
||||
amount: number
|
||||
currency: string
|
||||
date: string
|
||||
note: string
|
||||
}
|
||||
|
||||
const TODAY = new Date().toISOString().slice(0, 10)
|
||||
const EMPTY: FormState = { providerAccountId: '', direction: 'credit', amount: 0, currency: 'RUB', date: TODAY, note: '' }
|
||||
|
||||
function BalancePage() {
|
||||
const queryClient = useQueryClient()
|
||||
const { data: snapshot, isLoading, isError, error, refetch } = useQuery(snapshotQueryOptions())
|
||||
const [open, setOpen] = useState(false)
|
||||
const [form, setForm] = useState<FormState>(EMPTY)
|
||||
|
||||
const addMut = useMutation({
|
||||
mutationFn: (r: FormState) => api.create('balanceLedger', r as unknown as BalanceLedgerRow),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ['snapshot'] })
|
||||
toast.success('Запись добавлена')
|
||||
setOpen(false)
|
||||
},
|
||||
onError: (e: unknown) => toast.error(e instanceof ApiError ? e.message : 'Ошибка'),
|
||||
})
|
||||
const delMut = useMutation({
|
||||
mutationFn: (id: string) => api.remove<BalanceLedgerRow>('balanceLedger', id),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ['snapshot'] })
|
||||
toast.success('Запись удалена')
|
||||
},
|
||||
onError: (e: unknown) => toast.error(e instanceof ApiError ? e.message : 'Ошибка'),
|
||||
})
|
||||
|
||||
const openCreate = () => { setForm({ ...EMPTY, providerAccountId: snapshot?.providerAccounts[0]?.id ?? '' }); setOpen(true) }
|
||||
|
||||
const providerById = snapshot ? providerByIdMap(snapshot.providers) : new Map()
|
||||
|
||||
const columns: DataTableColumn<BalanceLedgerRow>[] = [
|
||||
{ key: 'date', header: 'Дата', cell: (r) => <span className="tabular-nums">{r.date}</span> },
|
||||
{
|
||||
key: 'account',
|
||||
header: 'Аккаунт',
|
||||
cell: (r) => {
|
||||
const acc = snapshot?.providerAccounts.find((a) => a.id === r.providerAccountId)
|
||||
return acc ? accountSelectLabel(acc, providerById) : '—'
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'dir',
|
||||
header: 'Движение',
|
||||
cell: (r) => (
|
||||
<Badge variant={r.direction === 'credit' ? 'default' : 'destructive'}>
|
||||
<ArrowDownUpIcon data-icon="inline-start" />
|
||||
{r.direction === 'credit' ? 'Приход' : 'Списание'}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'amount',
|
||||
header: 'Сумма',
|
||||
cell: (r) => (
|
||||
<span className={`tabular-nums ${r.direction === 'credit' ? '' : 'text-destructive'}`}>
|
||||
{r.direction === 'credit' ? '+' : '−'}{formatCurrency(Number(r.amount), r.currency ?? 'RUB')}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{ key: 'note', header: 'Заметка', cell: (r) => <span className="text-muted-foreground">{r.note || '—'}</span> },
|
||||
{
|
||||
key: 'actions',
|
||||
header: '',
|
||||
className: 'w-16 text-right',
|
||||
cell: (r) => (
|
||||
<ConfirmDialog
|
||||
trigger={<Button variant="ghost" size="icon-sm" aria-label="Удалить"><Trash2Icon /></Button>}
|
||||
title="Удалить запись?"
|
||||
confirmLabel="Удалить"
|
||||
destructive
|
||||
onConfirm={() => delMut.mutate(r.id)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
const rows = [...(snapshot?.balanceLedger ?? [])].sort((a, b) => b.date.localeCompare(a.date))
|
||||
|
||||
const totalCredit = rows.filter((r) => r.direction === 'credit').reduce((acc, r) => acc + Number(r.amount || 0), 0)
|
||||
const totalDebit = rows.filter((r) => r.direction === 'debit').reduce((acc, r) => acc + Number(r.amount || 0), 0)
|
||||
|
||||
return (
|
||||
<PageShell>
|
||||
<PageHeader
|
||||
title="Баланс и списания"
|
||||
description="Журнал движений по аккаунтам"
|
||||
actions={<Button onClick={openCreate}><PlusIcon data-icon="inline-start" />Добавить запись</Button>}
|
||||
/>
|
||||
<QueryState
|
||||
data={snapshot}
|
||||
isLoading={isLoading}
|
||||
isError={isError}
|
||||
error={error}
|
||||
onRetry={() => refetch()}
|
||||
skeleton={<SectionCardsSkeleton count={3} />}
|
||||
>
|
||||
{(snap) => (
|
||||
<>
|
||||
<SectionCards
|
||||
items={[
|
||||
{ label: 'Всего приходов', value: formatCurrency(totalCredit, snap.settings[0]?.baseCurrency ?? 'RUB') },
|
||||
{ label: 'Всего списаний', value: formatCurrency(totalDebit, snap.settings[0]?.baseCurrency ?? 'RUB') },
|
||||
{ label: 'Чистый баланс (ledger)', value: formatCurrency(totalCredit - totalDebit, snap.settings[0]?.baseCurrency ?? 'RUB') },
|
||||
]}
|
||||
/>
|
||||
<DataTableCard
|
||||
columns={columns}
|
||||
data={rows}
|
||||
rowKey={(r) => r.id}
|
||||
emptyTitle="Записей нет"
|
||||
emptyAction={<Button onClick={openCreate}><PlusIcon data-icon="inline-start" />Добавить</Button>}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</QueryState>
|
||||
|
||||
<FormSheet
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
trigger={null}
|
||||
title="Новая запись"
|
||||
onSubmit={() => addMut.mutate(form)}
|
||||
submitting={addMut.isPending}
|
||||
>
|
||||
<FormField label="Аккаунт" htmlFor="bl-acc">
|
||||
<Select value={form.providerAccountId} onValueChange={(v) => setForm({ ...form, providerAccountId: v ?? '' })}>
|
||||
<SelectTrigger id="bl-acc"><SelectValue placeholder="Выберите аккаунт" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{snapshot?.providerAccounts.map((a) => (
|
||||
<SelectItem key={a.id} value={a.id}>{accountSelectLabel(a, providerById)}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormField>
|
||||
<FormField label="Движение" htmlFor="bl-dir">
|
||||
<Select value={form.direction} onValueChange={(v) => setForm({ ...form, direction: (v ?? 'credit') as LedgerDirection })}>
|
||||
<SelectTrigger id="bl-dir"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="credit">Приход</SelectItem>
|
||||
<SelectItem value="debit">Списание</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormField>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<FormField label="Дата" htmlFor="bl-date">
|
||||
<Input id="bl-date" type="date" value={form.date} onChange={(e) => setForm({ ...form, date: e.target.value })} />
|
||||
</FormField>
|
||||
<FormField label="Сумма" htmlFor="bl-amount">
|
||||
<Input id="bl-amount" type="number" step="0.01" value={form.amount} onChange={(e) => setForm({ ...form, amount: Number(e.target.value) })} />
|
||||
</FormField>
|
||||
<FormField label="Валюта" htmlFor="bl-cur">
|
||||
<Input id="bl-cur" value={form.currency} onChange={(e) => setForm({ ...form, currency: e.target.value })} />
|
||||
</FormField>
|
||||
</div>
|
||||
<FormField label="Заметка" htmlFor="bl-note">
|
||||
<Textarea id="bl-note" value={form.note} onChange={(e) => setForm({ ...form, note: e.target.value })} />
|
||||
</FormField>
|
||||
</FormSheet>
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { ServerIcon, AlertTriangleIcon, WalletIcon, TrendingUpIcon } from 'lucide-react'
|
||||
|
||||
import { snapshotQueryOptions, ratesQueryOptions } from '@/queries/snapshot'
|
||||
import { PageShell } from '@/components/page-shell'
|
||||
import { PageHeader } from '@/components/page-header'
|
||||
import { SectionCards } from '@/components/section-cards'
|
||||
import { QueryState } from '@/components/query-state'
|
||||
import { TableCard } from '@/components/table-card'
|
||||
import { SectionCardsSkeleton } from '@/components/skeletons'
|
||||
import { EmptyState } from '@/components/empty-state'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import { Badge } from '@cfdm/ui/components/badge'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@cfdm/ui/components/table'
|
||||
|
||||
import { computeInventoryHealth } from '@/lib/inventory-health'
|
||||
import { formatInBaseCurrency, normalizeRatesPayload } from '@/lib/format'
|
||||
import { vpsStatusLabel } from '@/lib/format'
|
||||
|
||||
export const Route = createFileRoute('/_auth/dashboard')({
|
||||
loader: ({ context: { queryClient } }) =>
|
||||
queryClient.ensureQueryData(snapshotQueryOptions()),
|
||||
component: DashboardPage,
|
||||
})
|
||||
|
||||
function DashboardPage() {
|
||||
const navigate = useNavigate()
|
||||
const { data: snapshot, isLoading, isError, error, refetch } = useQuery(snapshotQueryOptions())
|
||||
const settings = snapshot?.settings?.[0]
|
||||
const { data: rawRates } = useQuery(ratesQueryOptions(settings?.ratesUrl))
|
||||
const ratesData = normalizeRatesPayload(rawRates) ?? rawRates ?? null
|
||||
|
||||
return (
|
||||
<PageShell>
|
||||
<PageHeader title="Дашборд" description="Сводка по VPS, балансам и здоровью инвентаря" />
|
||||
|
||||
<QueryState
|
||||
data={snapshot}
|
||||
isLoading={isLoading}
|
||||
isError={isError}
|
||||
error={error}
|
||||
onRetry={() => refetch()}
|
||||
skeleton={<SectionCardsSkeleton />}
|
||||
>
|
||||
{(snap) => {
|
||||
const activeVps = snap.vps.filter((v) => v.status === 'active')
|
||||
const monthlyTotal = activeVps.reduce((acc, v) => {
|
||||
const monthly = Number(v.monthlyRate || 0)
|
||||
const daily = Number(v.dailyRate || 0)
|
||||
const burn = v.tariffType === 'daily' ? daily * 30 : monthly
|
||||
return acc + (Number.isFinite(burn) ? burn : 0)
|
||||
}, 0)
|
||||
const issues = computeInventoryHealth(snap)
|
||||
const totalBalance = snap.providerAccounts.reduce(
|
||||
(acc, a) => acc + (Number(a.balance_api ?? 0) || 0),
|
||||
0,
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<SectionCards
|
||||
items={[
|
||||
{
|
||||
label: 'Активные VPS',
|
||||
value: activeVps.length,
|
||||
icon: <ServerIcon className="size-4" />,
|
||||
hint: `всего ${snap.vps.length}`,
|
||||
},
|
||||
{
|
||||
label: 'Хостеры',
|
||||
value: snap.providers.length,
|
||||
icon: <WalletIcon className="size-4" />,
|
||||
hint: `${snap.providerAccounts.length} аккаунтов`,
|
||||
},
|
||||
{
|
||||
label: 'Расход/мес (оценка)',
|
||||
value: formatInBaseCurrency(monthlyTotal, snap.vps[0]?.currency ?? 'USD', snap.settings, ratesData),
|
||||
icon: <TrendingUpIcon className="size-4" />,
|
||||
},
|
||||
{
|
||||
label: 'Баланс аккаунтов (API)',
|
||||
value: formatInBaseCurrency(totalBalance, snap.settings[0]?.baseCurrency ?? 'RUB', snap.settings, ratesData),
|
||||
icon: <WalletIcon className="size-4" />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<TableCard
|
||||
title="Здоровье инвентаря"
|
||||
description="Подсказки: нет проекта, нет ставки, просрочка, устаревший синк, расхождения баланса"
|
||||
actions={
|
||||
issues.length > 0 ? (
|
||||
<Badge variant="destructive">{issues.length}</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary">всё ок</Badge>
|
||||
)
|
||||
}
|
||||
>
|
||||
{issues.length === 0 ? (
|
||||
<div className="p-4">
|
||||
<EmptyState
|
||||
icon={<TrendingUpIcon className="size-8" />}
|
||||
title="Проблем не найдено"
|
||||
description="Все активные VPS имеют проект, ставку и актуальный синк"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Проблема</TableHead>
|
||||
<TableHead className="w-24 text-right">Кол-во</TableHead>
|
||||
<TableHead className="w-32">Действие</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{issues.map((issue) => (
|
||||
<TableRow key={issue.key}>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertTriangleIcon className="size-4 text-destructive" />
|
||||
<div className="flex flex-col">
|
||||
<span>{issue.title}</span>
|
||||
{issue.hint ? (
|
||||
<span className="text-xs text-muted-foreground">{issue.hint}</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{issue.count}</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => navigate({ to: issue.to })}
|
||||
>
|
||||
Открыть
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</TableCard>
|
||||
|
||||
<TableCard title="Последние VPS" description="Активные серверы">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>IP / DNS</TableHead>
|
||||
<TableHead>Проект</TableHead>
|
||||
<TableHead>Статус</TableHead>
|
||||
<TableHead className="text-right">Ставка/мес</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{activeVps.slice(0, 8).map((v) => (
|
||||
<TableRow key={v.id}>
|
||||
<TableCell className="font-medium">{v.ip || v.dns}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{v.project || '—'}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={v.status === 'active' ? 'default' : 'secondary'}>
|
||||
{vpsStatusLabel(v.status)}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{formatInBaseCurrency(
|
||||
v.tariffType === 'daily' ? Number(v.dailyRate || 0) * 30 : Number(v.monthlyRate || 0),
|
||||
v.currency,
|
||||
snap.settings,
|
||||
ratesData,
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableCard>
|
||||
</>
|
||||
)
|
||||
}}
|
||||
</QueryState>
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useState } from 'react'
|
||||
import { PlusIcon, PencilIcon, Trash2Icon } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { snapshotQueryOptions } from '@/queries/snapshot'
|
||||
import { api, ApiError } from '@/lib/api-client'
|
||||
import { PageShell } from '@/components/page-shell'
|
||||
import { PageHeader } from '@/components/page-header'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import { DataTableCard, type DataTableColumn } from '@/components/data-table-card'
|
||||
import { QueryState } from '@/components/query-state'
|
||||
import { TableSkeleton } from '@/components/skeletons'
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
import { FormSheet } from '@/components/form-sheet'
|
||||
import { FormField } from '@/components/form-field'
|
||||
import { Input } from '@cfdm/ui/components/input'
|
||||
import { Textarea } from '@cfdm/ui/components/textarea'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@cfdm/ui/components/select'
|
||||
|
||||
import type { Payment, PaymentType } from '@/types/entities'
|
||||
import { paymentTypeLabel, formatCurrency } from '@/lib/format'
|
||||
import { providerByIdMap, accountSelectLabel } from '@/lib/billmanager'
|
||||
|
||||
export const Route = createFileRoute('/_auth/payments')({
|
||||
loader: ({ context: { queryClient } }) =>
|
||||
queryClient.ensureQueryData(snapshotQueryOptions()),
|
||||
component: PaymentsPage,
|
||||
})
|
||||
|
||||
interface FormState {
|
||||
id?: string
|
||||
type: PaymentType
|
||||
date: string
|
||||
amount: number
|
||||
currency: string
|
||||
providerAccountId: string
|
||||
note: string
|
||||
}
|
||||
|
||||
const TODAY = new Date().toISOString().slice(0, 10)
|
||||
const EMPTY: FormState = { type: 'provider_balance_topup', date: TODAY, amount: 0, currency: 'RUB', providerAccountId: '', note: '' }
|
||||
|
||||
function PaymentsPage() {
|
||||
const queryClient = useQueryClient()
|
||||
const { data: snapshot, isLoading, isError, error, refetch } = useQuery(snapshotQueryOptions())
|
||||
const [open, setOpen] = useState(false)
|
||||
const [form, setForm] = useState<FormState>(EMPTY)
|
||||
|
||||
const saveMut = useMutation({
|
||||
mutationFn: (r: FormState) => r.id
|
||||
? api.update<Payment>('payments', r.id, r as unknown as Partial<Payment>)
|
||||
: api.create('payments', r as unknown as Payment),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ['snapshot'] })
|
||||
toast.success('Платёж сохранён')
|
||||
setOpen(false)
|
||||
},
|
||||
onError: (e: unknown) => toast.error(e instanceof ApiError ? e.message : 'Ошибка'),
|
||||
})
|
||||
const delMut = useMutation({
|
||||
mutationFn: (id: string) => api.remove<Payment>('payments', id),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ['snapshot'] })
|
||||
toast.success('Платёж удалён')
|
||||
},
|
||||
onError: (e: unknown) => toast.error(e instanceof ApiError ? e.message : 'Ошибка'),
|
||||
})
|
||||
|
||||
const openCreate = () => { setForm({ ...EMPTY, providerAccountId: snapshot?.providerAccounts[0]?.id ?? '' }); setOpen(true) }
|
||||
const openEdit = (p: Payment) => {
|
||||
setForm({
|
||||
id: p.id, type: p.type, date: p.date, amount: p.amount, currency: p.currency,
|
||||
providerAccountId: p.providerAccountId, note: p.note ?? '',
|
||||
})
|
||||
setOpen(true)
|
||||
}
|
||||
|
||||
const providerById = snapshot ? providerByIdMap(snapshot.providers) : new Map()
|
||||
|
||||
const columns: DataTableColumn<Payment>[] = [
|
||||
{ key: 'date', header: 'Дата', cell: (p) => <span className="tabular-nums">{p.date}</span> },
|
||||
{
|
||||
key: 'account',
|
||||
header: 'Аккаунт',
|
||||
cell: (p) => {
|
||||
const acc = snapshot?.providerAccounts.find((a) => a.id === p.providerAccountId)
|
||||
return acc ? accountSelectLabel(acc, providerById) : '—'
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'type',
|
||||
header: 'Тип',
|
||||
cell: (p) => <span>{paymentTypeLabel(p.type)}</span>,
|
||||
},
|
||||
{
|
||||
key: 'amount',
|
||||
header: 'Сумма',
|
||||
cell: (p) => <span className="tabular-nums">{formatCurrency(p.amount, p.currency)}</span>,
|
||||
},
|
||||
{ key: 'note', header: 'Заметка', cell: (p) => <span className="text-muted-foreground">{p.note || '—'}</span> },
|
||||
{
|
||||
key: 'actions',
|
||||
header: '',
|
||||
className: 'w-24 text-right',
|
||||
cell: (p) => (
|
||||
<div className="flex justify-end gap-1">
|
||||
<Button variant="ghost" size="icon-sm" onClick={() => openEdit(p)} aria-label="Редактировать">
|
||||
<PencilIcon />
|
||||
</Button>
|
||||
<ConfirmDialog
|
||||
trigger={<Button variant="ghost" size="icon-sm" aria-label="Удалить"><Trash2Icon /></Button>}
|
||||
title="Удалить платёж?"
|
||||
confirmLabel="Удалить"
|
||||
destructive
|
||||
onConfirm={() => delMut.mutate(p.id)}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
const sorted = [...(snapshot?.payments ?? [])].sort((a, b) => b.date.localeCompare(a.date))
|
||||
|
||||
return (
|
||||
<PageShell>
|
||||
<PageHeader
|
||||
title="Платежи"
|
||||
description="Пополнения балансов и прямые платежи за VPS"
|
||||
actions={<Button onClick={openCreate}><PlusIcon data-icon="inline-start" />Добавить</Button>}
|
||||
/>
|
||||
<QueryState
|
||||
data={snapshot}
|
||||
isLoading={isLoading}
|
||||
isError={isError}
|
||||
error={error}
|
||||
onRetry={() => refetch()}
|
||||
skeleton={<TableSkeleton />}
|
||||
empty={snapshot?.payments.length === 0}
|
||||
emptyTitle="Платежей нет"
|
||||
emptyAction={<Button onClick={openCreate}><PlusIcon data-icon="inline-start" />Добавить платёж</Button>}
|
||||
>
|
||||
{() => <DataTableCard columns={columns} data={sorted} rowKey={(p) => p.id} />}
|
||||
</QueryState>
|
||||
|
||||
<FormSheet
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
trigger={null}
|
||||
title={form.id ? 'Редактировать платёж' : 'Новый платёж'}
|
||||
onSubmit={() => saveMut.mutate(form)}
|
||||
submitting={saveMut.isPending}
|
||||
>
|
||||
<FormField label="Тип" htmlFor="pay-type">
|
||||
<Select value={form.type} onValueChange={(v) => setForm({ ...form, type: (v ?? 'provider_balance_topup') as PaymentType })}>
|
||||
<SelectTrigger id="pay-type"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="provider_balance_topup">{paymentTypeLabel('provider_balance_topup')}</SelectItem>
|
||||
<SelectItem value="direct_vps_payment">{paymentTypeLabel('direct_vps_payment')}</SelectItem>
|
||||
<SelectItem value="daily_debit">{paymentTypeLabel('daily_debit')}</SelectItem>
|
||||
<SelectItem value="monthly_debit">{paymentTypeLabel('monthly_debit')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormField>
|
||||
<FormField label="Аккаунт" htmlFor="pay-acc">
|
||||
<Select value={form.providerAccountId} onValueChange={(v) => setForm({ ...form, providerAccountId: v ?? '' })}>
|
||||
<SelectTrigger id="pay-acc"><SelectValue placeholder="Выберите аккаунт" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{snapshot?.providerAccounts.map((a) => (
|
||||
<SelectItem key={a.id} value={a.id}>{accountSelectLabel(a, providerById)}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormField>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<FormField label="Дата" htmlFor="pay-date">
|
||||
<Input id="pay-date" type="date" value={form.date} onChange={(e) => setForm({ ...form, date: e.target.value })} />
|
||||
</FormField>
|
||||
<FormField label="Сумма" htmlFor="pay-amount">
|
||||
<Input id="pay-amount" type="number" step="0.01" value={form.amount} onChange={(e) => setForm({ ...form, amount: Number(e.target.value) })} />
|
||||
</FormField>
|
||||
<FormField label="Валюта" htmlFor="pay-cur">
|
||||
<Input id="pay-cur" value={form.currency} onChange={(e) => setForm({ ...form, currency: e.target.value })} />
|
||||
</FormField>
|
||||
</div>
|
||||
<FormField label="Заметка" htmlFor="pay-note">
|
||||
<Textarea id="pay-note" value={form.note} onChange={(e) => setForm({ ...form, note: e.target.value })} />
|
||||
</FormField>
|
||||
</FormSheet>
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useState } from 'react'
|
||||
import { PlusIcon, PencilIcon, Trash2Icon, BuildingIcon } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { snapshotQueryOptions } from '@/queries/snapshot'
|
||||
import { api, ApiError } from '@/lib/api-client'
|
||||
import { PageShell } from '@/components/page-shell'
|
||||
import { PageHeader } from '@/components/page-header'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import { Badge } from '@cfdm/ui/components/badge'
|
||||
import { DataTableCard, type DataTableColumn } from '@/components/data-table-card'
|
||||
import { QueryState } from '@/components/query-state'
|
||||
import { TableSkeleton } from '@/components/skeletons'
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
import { FormSheet } from '@/components/form-sheet'
|
||||
import { FormField } from '@/components/form-field'
|
||||
import { Input } from '@cfdm/ui/components/input'
|
||||
import { Textarea } from '@cfdm/ui/components/textarea'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@cfdm/ui/components/select'
|
||||
import { faviconUrlFromWebsite } from '@/lib/format'
|
||||
import type { Provider, ApiType } from '@/types/entities'
|
||||
|
||||
export const Route = createFileRoute('/_auth/providers')({
|
||||
loader: ({ context: { queryClient } }) =>
|
||||
queryClient.ensureQueryData(snapshotQueryOptions()),
|
||||
component: ProvidersPage,
|
||||
})
|
||||
|
||||
interface FormState {
|
||||
id?: string
|
||||
name: string
|
||||
website: string
|
||||
apiType: ApiType
|
||||
apiBaseUrl: string
|
||||
baseCurrency: string
|
||||
usdRate: string
|
||||
eurRate: string
|
||||
supportPhone: string
|
||||
supportUrl: string
|
||||
notes: string
|
||||
}
|
||||
|
||||
const EMPTY: FormState = {
|
||||
name: '', website: '', apiType: 'billmanager', apiBaseUrl: '', baseCurrency: 'RUB',
|
||||
usdRate: '', eurRate: '', supportPhone: '', supportUrl: '', notes: '',
|
||||
}
|
||||
|
||||
function ProvidersPage() {
|
||||
const queryClient = useQueryClient()
|
||||
const { data: snapshot, isLoading, isError, error, refetch } = useQuery(snapshotQueryOptions())
|
||||
const [open, setOpen] = useState(false)
|
||||
const [form, setForm] = useState<FormState>(EMPTY)
|
||||
|
||||
const saveMut = useMutation({
|
||||
mutationFn: (r: FormState) => (r.id ? api.update<Provider>('providers', r.id, r as unknown as Provider) : api.create('providers', r as unknown as Provider)),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ['snapshot'] })
|
||||
toast.success('Хостер сохранён')
|
||||
setOpen(false)
|
||||
},
|
||||
onError: (e: unknown) => toast.error(e instanceof ApiError ? e.message : 'Ошибка'),
|
||||
})
|
||||
const delMut = useMutation({
|
||||
mutationFn: (id: string) => api.remove<Provider>('providers', id),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ['snapshot'] })
|
||||
toast.success('Хостер удалён')
|
||||
},
|
||||
onError: (e: unknown) => toast.error(e instanceof ApiError ? e.message : 'Ошибка'),
|
||||
})
|
||||
|
||||
const openCreate = () => { setForm(EMPTY); setOpen(true) }
|
||||
const openEdit = (p: Provider) => {
|
||||
setForm({
|
||||
id: p.id, name: p.name, website: p.website ?? '', apiType: p.apiType, apiBaseUrl: p.apiBaseUrl ?? '',
|
||||
baseCurrency: p.baseCurrency ?? 'RUB', usdRate: String(p.usdRate ?? ''), eurRate: String(p.eurRate ?? ''),
|
||||
supportPhone: p.supportPhone ?? '', supportUrl: p.supportUrl ?? '', notes: p.notes ?? '',
|
||||
})
|
||||
setOpen(true)
|
||||
}
|
||||
|
||||
const columns: DataTableColumn<Provider>[] = [
|
||||
{
|
||||
key: 'name',
|
||||
header: 'Хостер',
|
||||
cell: (p) => (
|
||||
<div className="flex items-center gap-2">
|
||||
{p.website ? (
|
||||
<img src={faviconUrlFromWebsite(p.website)} alt="" className="size-4 rounded-sm" />
|
||||
) : (
|
||||
<BuildingIcon className="size-4 text-muted-foreground" />
|
||||
)}
|
||||
<span className="font-medium">{p.name}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{ key: 'api', header: 'API', cell: (p) => <Badge variant="outline">{p.apiType}</Badge> },
|
||||
{ key: 'cur', header: 'Валюта', cell: (p) => <span className="tabular-nums">{p.baseCurrency ?? '—'}</span> },
|
||||
{
|
||||
key: 'actions',
|
||||
header: '',
|
||||
className: 'w-24 text-right',
|
||||
cell: (p) => (
|
||||
<div className="flex justify-end gap-1">
|
||||
<Button variant="ghost" size="icon-sm" onClick={() => openEdit(p)} aria-label="Редактировать">
|
||||
<PencilIcon />
|
||||
</Button>
|
||||
<ConfirmDialog
|
||||
trigger={<Button variant="ghost" size="icon-sm" aria-label="Удалить"><Trash2Icon /></Button>}
|
||||
title="Удалить хостера?"
|
||||
description={`«${p.name}» будет удалён. Аккаунты и VPS не затрагиваются, но потеряют привязку.`}
|
||||
destructive
|
||||
confirmLabel="Удалить"
|
||||
onConfirm={() => delMut.mutate(p.id)}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<PageShell>
|
||||
<PageHeader
|
||||
title="Хостеры"
|
||||
description="Провайдеры хостинга и параметры API"
|
||||
actions={<Button onClick={openCreate}><PlusIcon data-icon="inline-start" />Добавить</Button>}
|
||||
/>
|
||||
<QueryState
|
||||
data={snapshot}
|
||||
isLoading={isLoading}
|
||||
isError={isError}
|
||||
error={error}
|
||||
onRetry={() => refetch()}
|
||||
skeleton={<TableSkeleton />}
|
||||
empty={snapshot?.providers.length === 0}
|
||||
emptyTitle="Хостеры не найдены"
|
||||
emptyAction={<Button onClick={openCreate}><PlusIcon data-icon="inline-start" />Добавить хостера</Button>}
|
||||
>
|
||||
{(snap) => <DataTableCard columns={columns} data={snap.providers} rowKey={(p) => p.id} />}
|
||||
</QueryState>
|
||||
|
||||
<FormSheet
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
trigger={null}
|
||||
title={form.id ? 'Редактировать хостера' : 'Новый хостер'}
|
||||
onSubmit={() => saveMut.mutate(form)}
|
||||
submitting={saveMut.isPending}
|
||||
>
|
||||
<FormField label="Название" htmlFor="pr-name">
|
||||
<Input id="pr-name" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
|
||||
</FormField>
|
||||
<FormField label="Сайт" htmlFor="pr-site">
|
||||
<Input id="pr-site" value={form.website} onChange={(e) => setForm({ ...form, website: e.target.value })} />
|
||||
</FormField>
|
||||
<FormField label="Тип API" htmlFor="pr-api">
|
||||
<Select value={form.apiType} onValueChange={(v) => setForm({ ...form, apiType: (v ?? 'none') as ApiType })}>
|
||||
<SelectTrigger id="pr-api"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="billmanager">BILLmanager</SelectItem>
|
||||
<SelectItem value="none">Нет</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormField>
|
||||
<FormField label="API URL" htmlFor="pr-apiurl" description="Один URL на хостера для BILLmanager">
|
||||
<Input id="pr-apiurl" value={form.apiBaseUrl} onChange={(e) => setForm({ ...form, apiBaseUrl: e.target.value })} />
|
||||
</FormField>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<FormField label="Валюта" htmlFor="pr-cur">
|
||||
<Input id="pr-cur" value={form.baseCurrency} onChange={(e) => setForm({ ...form, baseCurrency: e.target.value })} />
|
||||
</FormField>
|
||||
<FormField label="Курс USD" htmlFor="pr-usd">
|
||||
<Input id="pr-usd" value={form.usdRate} onChange={(e) => setForm({ ...form, usdRate: e.target.value })} />
|
||||
</FormField>
|
||||
<FormField label="Курс EUR" htmlFor="pr-eur">
|
||||
<Input id="pr-eur" value={form.eurRate} onChange={(e) => setForm({ ...form, eurRate: e.target.value })} />
|
||||
</FormField>
|
||||
</div>
|
||||
<FormField label="Заметки" htmlFor="pr-notes">
|
||||
<Textarea id="pr-notes" value={form.notes} onChange={(e) => setForm({ ...form, notes: e.target.value })} />
|
||||
</FormField>
|
||||
</FormSheet>
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { DownloadIcon } from 'lucide-react'
|
||||
|
||||
import { snapshotQueryOptions, ratesQueryOptions } from '@/queries/snapshot'
|
||||
import { PageShell } from '@/components/page-shell'
|
||||
import { PageHeader } from '@/components/page-header'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import { QueryState } from '@/components/query-state'
|
||||
import { SectionCardsSkeleton } from '@/components/skeletons'
|
||||
import { SectionCards } from '@/components/section-cards'
|
||||
import { ChartsGrid, MonthlyExpenseChart, PaymentsPieChart, MonthlyTrendChart } from '@/components/domain/charts'
|
||||
|
||||
import { normalizeRatesPayload, formatCurrency, toCsv, downloadTextFile } from '@/lib/format'
|
||||
|
||||
export const Route = createFileRoute('/_auth/reports')({
|
||||
loader: ({ context: { queryClient } }) =>
|
||||
queryClient.ensureQueryData(snapshotQueryOptions()),
|
||||
component: ReportsPage,
|
||||
})
|
||||
|
||||
function ReportsPage() {
|
||||
const { data: snapshot, isLoading, isError, error, refetch } = useQuery(snapshotQueryOptions())
|
||||
const settings = snapshot?.settings?.[0]
|
||||
const { data: rawRates } = useQuery(ratesQueryOptions(settings?.ratesUrl))
|
||||
const ratesData = normalizeRatesPayload(rawRates) ?? rawRates ?? null
|
||||
|
||||
const exportCsv = () => {
|
||||
if (!snapshot) return
|
||||
const rows = snapshot.vps.map((v) => ({
|
||||
ip: v.ip, project: v.project ?? '', status: v.status, vcpu: v.vcpu, ramGb: v.ramGb, diskGb: v.diskGb,
|
||||
monthlyRate: v.monthlyRate ?? 0, currency: v.currency,
|
||||
}))
|
||||
downloadTextFile('vps-report.csv', toCsv(rows))
|
||||
}
|
||||
|
||||
return (
|
||||
<PageShell>
|
||||
<PageHeader
|
||||
title="Отчёты"
|
||||
description="Расходы, платежи и динамика"
|
||||
actions={
|
||||
<Button variant="outline" onClick={exportCsv} disabled={!snapshot}>
|
||||
<DownloadIcon data-icon="inline-start" />
|
||||
Экспорт CSV
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<QueryState
|
||||
data={snapshot}
|
||||
isLoading={isLoading}
|
||||
isError={isError}
|
||||
error={error}
|
||||
onRetry={() => refetch()}
|
||||
skeleton={<SectionCardsSkeleton count={3} />}
|
||||
>
|
||||
{(snap) => {
|
||||
const monthly = snap.vps.filter((v) => v.status === 'active').reduce((acc, v) => {
|
||||
const burn = v.tariffType === 'daily' ? Number(v.dailyRate || 0) * 30 : Number(v.monthlyRate || 0)
|
||||
return acc + burn
|
||||
}, 0)
|
||||
return (
|
||||
<>
|
||||
<SectionCards
|
||||
items={[
|
||||
{ label: 'Расход/мес (в валюте VPS)', value: formatCurrency(monthly, snap.vps[0]?.currency ?? 'RUB') },
|
||||
{ label: 'Платежей всего', value: snap.payments.length },
|
||||
{ label: 'Активных VPS', value: snap.vps.filter((v) => v.status === 'active').length },
|
||||
]}
|
||||
/>
|
||||
<ChartsGrid>
|
||||
<MonthlyExpenseChart vps={snap.vps} providers={snap.providers} settings={snap.settings} ratesData={ratesData} />
|
||||
<PaymentsPieChart payments={snap.payments} settings={snap.settings} ratesData={ratesData} />
|
||||
<MonthlyTrendChart payments={snap.payments} settings={snap.settings} ratesData={ratesData} className="lg:col-span-2" />
|
||||
</ChartsGrid>
|
||||
</>
|
||||
)
|
||||
}}
|
||||
</QueryState>
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { CpuIcon, MemoryStickIcon, HardDriveIcon } from 'lucide-react'
|
||||
|
||||
import { snapshotQueryOptions } from '@/queries/snapshot'
|
||||
import { PageShell } from '@/components/page-shell'
|
||||
import { PageHeader } from '@/components/page-header'
|
||||
import { QueryState } from '@/components/query-state'
|
||||
import { SectionCardsSkeleton } from '@/components/skeletons'
|
||||
import { SectionCards } from '@/components/section-cards'
|
||||
import {
|
||||
ChartContainer,
|
||||
ChartTooltipContent,
|
||||
type ChartConfig,
|
||||
} from '@cfdm/ui/components/chart'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@cfdm/ui/components/card'
|
||||
import { Bar, BarChart, CartesianGrid, XAxis, YAxis } from 'recharts'
|
||||
import { Tooltip as RechartsTooltip } from 'recharts'
|
||||
|
||||
export const Route = createFileRoute('/_auth/resources')({
|
||||
loader: ({ context: { queryClient } }) =>
|
||||
queryClient.ensureQueryData(snapshotQueryOptions()),
|
||||
component: ResourcesPage,
|
||||
})
|
||||
|
||||
const RESOURCE_CONFIG: ChartConfig = {
|
||||
vcpu: { label: 'vCPU', color: 'var(--chart-1)' },
|
||||
ram: { label: 'RAM (GB)', color: 'var(--chart-2)' },
|
||||
disk: { label: 'Disk (GB)', color: 'var(--chart-3)' },
|
||||
}
|
||||
|
||||
function ResourcesPage() {
|
||||
const { data: snapshot, isLoading, isError, error, refetch } = useQuery(snapshotQueryOptions())
|
||||
|
||||
return (
|
||||
<PageShell>
|
||||
<PageHeader title="Ресурсы" description="Сводка по вычислительным ресурсам активных VPS" />
|
||||
<QueryState
|
||||
data={snapshot}
|
||||
isLoading={isLoading}
|
||||
isError={isError}
|
||||
error={error}
|
||||
onRetry={() => refetch()}
|
||||
skeleton={<SectionCardsSkeleton count={3} />}
|
||||
>
|
||||
{(snap) => {
|
||||
const active = snap.vps.filter((v) => v.status === 'active')
|
||||
const totals = active.reduce(
|
||||
(acc, v) => ({
|
||||
vcpu: acc.vcpu + Number(v.vcpu || 0),
|
||||
ram: acc.ram + Number(v.ramGb || 0),
|
||||
disk: acc.disk + Number(v.diskGb || 0),
|
||||
}),
|
||||
{ vcpu: 0, ram: 0, disk: 0 },
|
||||
)
|
||||
|
||||
const byProvider = new Map<string, { name: string; vcpu: number; ram: number; disk: number }>()
|
||||
for (const v of active) {
|
||||
const provider = snap.providers.find((p) => p.id === v.providerId)
|
||||
const name = provider?.name ?? '—'
|
||||
const key = provider?.id ?? 'unknown'
|
||||
const entry = byProvider.get(key) ?? { name, vcpu: 0, ram: 0, disk: 0 }
|
||||
entry.vcpu += Number(v.vcpu || 0)
|
||||
entry.ram += Number(v.ramGb || 0)
|
||||
entry.disk += Number(v.diskGb || 0)
|
||||
byProvider.set(key, entry)
|
||||
}
|
||||
const chartData = Array.from(byProvider.values())
|
||||
|
||||
return (
|
||||
<>
|
||||
<SectionCards
|
||||
items={[
|
||||
{ label: 'vCPU', value: totals.vcpu, icon: <CpuIcon className="size-4" /> },
|
||||
{ label: 'RAM (GB)', value: totals.ram, icon: <MemoryStickIcon className="size-4" /> },
|
||||
{ label: 'Disk (GB)', value: totals.disk, icon: <HardDriveIcon className="size-4" /> },
|
||||
]}
|
||||
/>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Ресурсы по хостерам</CardTitle>
|
||||
<CardDescription>Только активные VPS</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ChartContainer config={RESOURCE_CONFIG} className="h-80 w-full">
|
||||
<BarChart data={chartData} margin={{ top: 8, right: 8, bottom: 8, left: 8 }}>
|
||||
<CartesianGrid vertical={false} strokeDasharray="3 3" />
|
||||
<XAxis dataKey="name" tickLine={false} axisLine={false} tickMargin={8} />
|
||||
<YAxis tickLine={false} axisLine={false} width={48} />
|
||||
<RechartsTooltip cursor={false} content={<ChartTooltipContent />} />
|
||||
<Bar dataKey="vcpu" fill="var(--color-vcpu)" radius={4} />
|
||||
<Bar dataKey="ram" fill="var(--color-ram)" radius={4} />
|
||||
<Bar dataKey="disk" fill="var(--color-disk)" radius={4} />
|
||||
</BarChart>
|
||||
</ChartContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
)
|
||||
}}
|
||||
</QueryState>
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { snapshotQueryOptions } from '@/queries/snapshot'
|
||||
import { api, ApiError } from '@/lib/api-client'
|
||||
import { PageShell } from '@/components/page-shell'
|
||||
import { PageHeader } from '@/components/page-header'
|
||||
import { QueryState } from '@/components/query-state'
|
||||
import { SectionCardsSkeleton } from '@/components/skeletons'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@cfdm/ui/components/card'
|
||||
import { Field, FieldGroup, FieldLabel } from '@cfdm/ui/components/field'
|
||||
import { Input } from '@cfdm/ui/components/input'
|
||||
import { LoadingButton } from '@/components/loading-button'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@cfdm/ui/components/select'
|
||||
|
||||
import type { Settings } from '@/types/entities'
|
||||
import { useState } from 'react'
|
||||
|
||||
export const Route = createFileRoute('/_auth/settings')({
|
||||
loader: ({ context: { queryClient } }) =>
|
||||
queryClient.ensureQueryData(snapshotQueryOptions()),
|
||||
component: SettingsPage,
|
||||
})
|
||||
|
||||
const CURRENCIES = ['RUB', 'USD', 'EUR', 'UAH', 'KZT']
|
||||
|
||||
function SettingsPage() {
|
||||
const queryClient = useQueryClient()
|
||||
const { data: snapshot, isLoading, isError, error, refetch } = useQuery(snapshotQueryOptions())
|
||||
const current = snapshot?.settings?.[0]
|
||||
const [form, setForm] = useState<Partial<Settings> | null>(null)
|
||||
const draft = form ?? current ?? {}
|
||||
|
||||
const upsertMut = useMutation({
|
||||
mutationFn: (patch: Partial<Settings>) => {
|
||||
if (current?.id) return api.update<Settings>('settings', current.id, patch)
|
||||
return api.create<Settings>('settings', {
|
||||
id: 'settings-main',
|
||||
baseCurrency: 'RUB',
|
||||
ratesUrl: 'https://www.cbr-xml-daily.ru/latest.js',
|
||||
autoConvert: true,
|
||||
...patch,
|
||||
} as Settings)
|
||||
},
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ['snapshot'] })
|
||||
toast.success('Настройки сохранены')
|
||||
setForm(null)
|
||||
},
|
||||
onError: (e: unknown) => toast.error(e instanceof ApiError ? e.message : 'Ошибка'),
|
||||
})
|
||||
|
||||
const telegramTestMut = useMutation({
|
||||
mutationFn: () => api.sendTelegramTest(),
|
||||
onSuccess: () => toast.success('Тестовое сообщение отправлено'),
|
||||
onError: (e: unknown) => toast.error(e instanceof ApiError ? e.message : 'Ошибка отправки'),
|
||||
})
|
||||
|
||||
return (
|
||||
<PageShell>
|
||||
<PageHeader title="Настройки" description="Базовая валюта, курсы, синк, Telegram" />
|
||||
<QueryState
|
||||
data={snapshot}
|
||||
isLoading={isLoading}
|
||||
isError={isError}
|
||||
error={error}
|
||||
onRetry={() => refetch()}
|
||||
skeleton={<SectionCardsSkeleton count={1} />}
|
||||
>
|
||||
{() => (
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Валюта и курсы</CardTitle>
|
||||
<CardDescription>Отображение сумм и источник курсов</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<FieldGroup>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="set-cur">Базовая валюта</FieldLabel>
|
||||
<Select
|
||||
value={draft.baseCurrency ?? 'RUB'}
|
||||
onValueChange={(v) => setForm({ ...draft, baseCurrency: v ?? 'RUB' })}
|
||||
>
|
||||
<SelectTrigger id="set-cur">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{CURRENCIES.map((c) => (
|
||||
<SelectItem key={c} value={c}>{c}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="set-rates">URL курсов (JSON)</FieldLabel>
|
||||
<Input
|
||||
id="set-rates"
|
||||
value={draft.ratesUrl ?? ''}
|
||||
onChange={(e) => setForm({ ...draft, ratesUrl: e.target.value })}
|
||||
placeholder="https://www.cbr-xml-daily.ru/latest.js"
|
||||
/>
|
||||
</Field>
|
||||
<Field orientation="horizontal">
|
||||
<FieldLabel htmlFor="set-auto">Автоконвертация</FieldLabel>
|
||||
<Select
|
||||
value={draft.autoConvert === false ? 'off' : 'on'}
|
||||
onValueChange={(v) => setForm({ ...draft, autoConvert: (v ?? 'on') === 'on' })}
|
||||
>
|
||||
<SelectTrigger id="set-auto" className="w-32">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="on">Включена</SelectItem>
|
||||
<SelectItem value="off">Выключена</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<LoadingButton
|
||||
className="w-fit"
|
||||
onClick={() => upsertMut.mutate(draft)}
|
||||
loading={upsertMut.isPending}
|
||||
disabled={!form}
|
||||
>
|
||||
Сохранить
|
||||
</LoadingButton>
|
||||
</FieldGroup>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Telegram</CardTitle>
|
||||
<CardDescription>Уведомления о здоровье инвентаря</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<FieldGroup>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="set-tg-chat">Chat ID</FieldLabel>
|
||||
<Input
|
||||
id="set-tg-chat"
|
||||
value={draft.telegramChatId ?? ''}
|
||||
onChange={(e) => setForm({ ...draft, telegramChatId: e.target.value })}
|
||||
placeholder="-1001234567890"
|
||||
/>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="set-tg-token">Bot token</FieldLabel>
|
||||
<Input
|
||||
id="set-tg-token"
|
||||
type="password"
|
||||
value={draft.telegramBotToken ?? ''}
|
||||
onChange={(e) => setForm({ ...draft, telegramBotToken: e.target.value })}
|
||||
placeholder="123456:ABC-DEF..."
|
||||
/>
|
||||
</Field>
|
||||
<div className="flex gap-2">
|
||||
<LoadingButton onClick={() => upsertMut.mutate(draft)} loading={upsertMut.isPending} disabled={!form}>
|
||||
Сохранить
|
||||
</LoadingButton>
|
||||
<LoadingButton variant="outline" onClick={() => telegramTestMut.mutate()} loading={telegramTestMut.isPending}>
|
||||
Тест
|
||||
</LoadingButton>
|
||||
</div>
|
||||
</FieldGroup>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</QueryState>
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
|
||||
import { snapshotQueryOptions } from '@/queries/snapshot'
|
||||
import { PageShell } from '@/components/page-shell'
|
||||
import { PageHeader } from '@/components/page-header'
|
||||
import { Badge } from '@cfdm/ui/components/badge'
|
||||
import { DataTableCard, type DataTableColumn } from '@/components/data-table-card'
|
||||
import { QueryState } from '@/components/query-state'
|
||||
import { TableSkeleton } from '@/components/skeletons'
|
||||
import { EmptyState } from '@/components/empty-state'
|
||||
import { ServerCogIcon } from 'lucide-react'
|
||||
|
||||
import type { ActiveTariff } from '@/types/entities'
|
||||
import { providerByIdMap, accountSelectLabel } from '@/lib/billmanager'
|
||||
import { formatCurrency } from '@/lib/format'
|
||||
|
||||
export const Route = createFileRoute('/_auth/tariffs')({
|
||||
loader: ({ context: { queryClient } }) =>
|
||||
queryClient.ensureQueryData(snapshotQueryOptions()),
|
||||
component: TariffsPage,
|
||||
})
|
||||
|
||||
function TariffsPage() {
|
||||
const { data: snapshot, isLoading, isError, error, refetch } = useQuery(snapshotQueryOptions())
|
||||
const providerById = snapshot ? providerByIdMap(snapshot.providers) : new Map()
|
||||
|
||||
const columns: DataTableColumn<ActiveTariff>[] = [
|
||||
{
|
||||
key: 'name',
|
||||
header: 'Тариф',
|
||||
cell: (t) => <span className="font-medium">{t.name || `#${t.pricelistId ?? t.id}`}</span>,
|
||||
},
|
||||
{
|
||||
key: 'account',
|
||||
header: 'Аккаунт',
|
||||
cell: (t) => {
|
||||
const acc = snapshot?.providerAccounts.find((a) => a.id === t.providerAccountId)
|
||||
return acc ? accountSelectLabel(acc, providerById) : '—'
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'specs',
|
||||
header: 'Ресурсы',
|
||||
cell: (t) => (
|
||||
<span className="tabular-nums text-muted-foreground">
|
||||
{t.vcpu ?? '—'} vCPU / {t.ramGb ?? '—'} GB / {t.diskGb ?? '—'} GB
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'price',
|
||||
header: 'Цена/мес',
|
||||
cell: (t) => <span className="tabular-nums">{formatCurrency(Number(t.monthlyRate ?? 0), t.currency ?? 'RUB')}</span>,
|
||||
},
|
||||
{ key: 'disk', header: 'Диск', cell: (t) => <Badge variant="outline">{t.diskType ?? '—'}</Badge> },
|
||||
]
|
||||
|
||||
return (
|
||||
<PageShell>
|
||||
<PageHeader title="Активные тарифы" description="Тарифы, загруженные из BILLmanager vds.order" />
|
||||
<QueryState
|
||||
data={snapshot}
|
||||
isLoading={isLoading}
|
||||
isError={isError}
|
||||
error={error}
|
||||
onRetry={() => refetch()}
|
||||
skeleton={<TableSkeleton />}
|
||||
empty={snapshot?.activeTariffs.length === 0}
|
||||
emptyTitle="Тарифы не загружены"
|
||||
emptyDescription="Выполните синхронизацию аккаунта BILLmanager, чтобы загрузить тарифы"
|
||||
emptyAction={<EmptyState icon={<ServerCogIcon className="size-8" />} title="Нет тарифов" />}
|
||||
>
|
||||
{(snap) => <DataTableCard columns={columns} data={snap.activeTariffs} rowKey={(t) => t.id} />}
|
||||
</QueryState>
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,346 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useState } from 'react'
|
||||
import { PlusIcon, PencilIcon, Trash2Icon } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { snapshotQueryOptions } from '@/queries/snapshot'
|
||||
import { api, ApiError } from '@/lib/api-client'
|
||||
import { vpsSchema, type VpsFormValues } from '@/lib/schemas'
|
||||
import { PageShell } from '@/components/page-shell'
|
||||
import { PageHeader } from '@/components/page-header'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import { Badge } from '@cfdm/ui/components/badge'
|
||||
import { DataTableCard, type DataTableColumn } from '@/components/data-table-card'
|
||||
import { QueryState } from '@/components/query-state'
|
||||
import { TableSkeleton } from '@/components/skeletons'
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
import { FormSheetRhf } from '@/components/form-sheet-rhf'
|
||||
import { FormField } from '@/components/form-field'
|
||||
import { Input } from '@cfdm/ui/components/input'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@cfdm/ui/components/select'
|
||||
import { Textarea } from '@cfdm/ui/components/textarea'
|
||||
|
||||
import type { Vps } from '@/types/entities'
|
||||
import { vpsStatusLabel, tariffTypeLabel, formatInBaseCurrency } from '@/lib/format'
|
||||
import { providerByIdMap, accountSelectLabel } from '@/lib/billmanager'
|
||||
|
||||
export const Route = createFileRoute('/_auth/vps')({
|
||||
loader: ({ context: { queryClient } }) =>
|
||||
queryClient.ensureQueryData(snapshotQueryOptions()),
|
||||
component: VpsPage,
|
||||
})
|
||||
|
||||
const EMPTY_FORM: VpsFormValues = {
|
||||
ip: '', dns: '', providerId: '', providerAccountId: '',
|
||||
vcpu: 1, ramGb: 1, diskGb: 10, status: 'active', tariffType: 'monthly',
|
||||
currency: 'RUB', monthlyRate: 0, dailyRate: 0, paidUntil: '', project: '', notes: '',
|
||||
}
|
||||
|
||||
function VpsPage() {
|
||||
const queryClient = useQueryClient()
|
||||
const { data: snapshot, isLoading, isError, error, refetch } = useQuery(snapshotQueryOptions())
|
||||
const [sheetOpen, setSheetOpen] = useState(false)
|
||||
const [editingId, setEditingId] = useState<string | null>(null)
|
||||
const [defaultValues, setDefaultValues] = useState<VpsFormValues>(EMPTY_FORM)
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (record: VpsFormValues) => api.create('vps', record as unknown as Vps),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ['snapshot'] })
|
||||
toast.success('VPS создан')
|
||||
setSheetOpen(false)
|
||||
},
|
||||
onError: (e: unknown) => toast.error(e instanceof ApiError ? e.message : 'Ошибка создания'),
|
||||
})
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ id, patch }: { id: string; patch: Partial<Vps> }) =>
|
||||
api.update<Vps>('vps', id, patch),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ['snapshot'] })
|
||||
toast.success('VPS обновлён')
|
||||
setSheetOpen(false)
|
||||
},
|
||||
onError: (e: unknown) => toast.error(e instanceof ApiError ? e.message : 'Ошибка обновления'),
|
||||
})
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => api.remove<Vps>('vps', id),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ['snapshot'] })
|
||||
toast.success('VPS удалён')
|
||||
},
|
||||
onError: (e: unknown) => toast.error(e instanceof ApiError ? e.message : 'Ошибка удаления'),
|
||||
})
|
||||
|
||||
const openCreate = () => {
|
||||
setEditingId(null)
|
||||
setDefaultValues({
|
||||
...EMPTY_FORM,
|
||||
providerId: snapshot?.providers[0]?.id ?? '',
|
||||
providerAccountId: snapshot?.providerAccounts[0]?.id ?? '',
|
||||
})
|
||||
setSheetOpen(true)
|
||||
}
|
||||
const openEdit = (v: Vps) => {
|
||||
setEditingId(v.id)
|
||||
setDefaultValues({
|
||||
id: v.id, ip: v.ip, dns: v.dns ?? '', providerId: v.providerId, providerAccountId: v.providerAccountId,
|
||||
vcpu: v.vcpu, ramGb: v.ramGb, diskGb: v.diskGb, status: v.status, tariffType: v.tariffType,
|
||||
currency: v.currency, monthlyRate: Number(v.monthlyRate ?? 0), dailyRate: Number(v.dailyRate ?? 0),
|
||||
paidUntil: v.paidUntil ?? '', project: v.project ?? '', notes: v.notes ?? '',
|
||||
})
|
||||
setSheetOpen(true)
|
||||
}
|
||||
const submit = (values: VpsFormValues) => {
|
||||
if (editingId) {
|
||||
void updateMutation.mutate({ id: editingId, patch: values as unknown as Partial<Vps> })
|
||||
} else {
|
||||
void createMutation.mutate(values)
|
||||
}
|
||||
}
|
||||
|
||||
const providerById = snapshot ? providerByIdMap(snapshot.providers) : new Map()
|
||||
|
||||
const columns: DataTableColumn<Vps>[] = [
|
||||
{
|
||||
key: 'ip',
|
||||
header: 'IP / DNS',
|
||||
cell: (v) => (
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{v.ip || '—'}</span>
|
||||
{v.dns ? <span className="text-xs text-muted-foreground">{v.dns}</span> : null}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'account',
|
||||
header: 'Аккаунт',
|
||||
cell: (v) => {
|
||||
const acc = snapshot?.providerAccounts.find((a) => a.id === v.providerAccountId)
|
||||
return acc ? accountSelectLabel(acc, providerById) : '—'
|
||||
},
|
||||
},
|
||||
{ key: 'project', header: 'Проект', cell: (v) => <span className="text-muted-foreground">{v.project || '—'}</span> },
|
||||
{
|
||||
key: 'specs',
|
||||
header: 'Ресурсы',
|
||||
cell: (v) => (
|
||||
<span className="tabular-nums text-muted-foreground">
|
||||
{v.vcpu} vCPU / {v.ramGb} GB / {v.diskGb} GB
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
header: 'Статус',
|
||||
cell: (v) => (
|
||||
<Badge variant={v.status === 'active' ? 'default' : v.status === 'archived' ? 'outline' : 'secondary'}>
|
||||
{vpsStatusLabel(v.status)}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'tariff',
|
||||
header: 'Тариф',
|
||||
cell: (v) => (
|
||||
<span className="tabular-nums">
|
||||
{formatInBaseCurrency(
|
||||
v.tariffType === 'daily' ? Number(v.dailyRate || 0) * 30 : Number(v.monthlyRate || 0),
|
||||
v.currency,
|
||||
snapshot?.settings ?? [],
|
||||
null,
|
||||
)}
|
||||
<span className="ml-1 text-xs text-muted-foreground">{tariffTypeLabel(v.tariffType)}</span>
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
header: '',
|
||||
className: 'w-24 text-right',
|
||||
cell: (v) => (
|
||||
<div className="flex justify-end gap-1">
|
||||
<Button variant="ghost" size="icon-sm" onClick={() => openEdit(v)} aria-label="Редактировать">
|
||||
<PencilIcon />
|
||||
</Button>
|
||||
<ConfirmDialog
|
||||
trigger={
|
||||
<Button variant="ghost" size="icon-sm" aria-label="Удалить">
|
||||
<Trash2Icon />
|
||||
</Button>
|
||||
}
|
||||
title="Удалить VPS?"
|
||||
description={`IP ${v.ip} будет удалён безвозвратно.`}
|
||||
destructive
|
||||
confirmLabel="Удалить"
|
||||
onConfirm={() => deleteMutation.mutate(v.id)}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<PageShell>
|
||||
<PageHeader
|
||||
title="VPS"
|
||||
description="Виртуальные серверы"
|
||||
actions={
|
||||
<Button onClick={openCreate}>
|
||||
<PlusIcon data-icon="inline-start" />
|
||||
Добавить
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<QueryState
|
||||
data={snapshot}
|
||||
isLoading={isLoading}
|
||||
isError={isError}
|
||||
error={error}
|
||||
onRetry={() => refetch()}
|
||||
skeleton={<TableSkeleton />}
|
||||
empty={snapshot?.vps.length === 0}
|
||||
emptyTitle="VPS не найдены"
|
||||
emptyDescription="Добавьте первый виртуальный сервер"
|
||||
emptyAction={
|
||||
<Button onClick={openCreate}>
|
||||
<PlusIcon data-icon="inline-start" />
|
||||
Добавить VPS
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{(snap) => (
|
||||
<DataTableCard
|
||||
columns={columns}
|
||||
data={snap.vps}
|
||||
rowKey={(v) => v.id}
|
||||
emptyTitle="VPS не найдены"
|
||||
/>
|
||||
)}
|
||||
</QueryState>
|
||||
|
||||
<FormSheetRhf
|
||||
open={sheetOpen}
|
||||
onOpenChange={setSheetOpen}
|
||||
title={editingId ? 'Редактировать VPS' : 'Новый VPS'}
|
||||
description="Заполните параметры сервера"
|
||||
schema={vpsSchema as unknown as import('zod').ZodType<VpsFormValues>}
|
||||
defaultValues={defaultValues}
|
||||
onSubmit={submit}
|
||||
submitting={createMutation.isPending || updateMutation.isPending}
|
||||
>
|
||||
{(form) => {
|
||||
const { register, formState: { errors }, watch, setValue } = form
|
||||
const providerId = watch('providerId')
|
||||
return (
|
||||
<>
|
||||
<FormField label="IP" htmlFor="vps-ip" error={errors.ip?.message} invalid={!!errors.ip}>
|
||||
<Input id="vps-ip" {...register('ip')} />
|
||||
</FormField>
|
||||
<FormField label="DNS" htmlFor="vps-dns">
|
||||
<Input id="vps-dns" {...register('dns')} />
|
||||
</FormField>
|
||||
<FormField label="Хостер" htmlFor="vps-provider" error={errors.providerId?.message}>
|
||||
<Select
|
||||
value={providerId}
|
||||
onValueChange={(v) => setValue('providerId', v ?? '', { shouldValidate: true })}
|
||||
>
|
||||
<SelectTrigger id="vps-provider">
|
||||
<SelectValue placeholder="Выберите хостера" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{snapshot?.providers.map((p) => (
|
||||
<SelectItem key={p.id} value={p.id}>{p.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormField>
|
||||
<FormField label="Аккаунт" htmlFor="vps-account" error={errors.providerAccountId?.message}>
|
||||
<Select
|
||||
value={watch('providerAccountId')}
|
||||
onValueChange={(v) => setValue('providerAccountId', v ?? '', { shouldValidate: true })}
|
||||
>
|
||||
<SelectTrigger id="vps-account">
|
||||
<SelectValue placeholder="Выберите аккаунт" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{snapshot?.providerAccounts
|
||||
.filter((a) => !providerId || a.providerId === providerId)
|
||||
.map((a) => (
|
||||
<SelectItem key={a.id} value={a.id}>{a.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormField>
|
||||
<FormField label="Проект" htmlFor="vps-project">
|
||||
<Input id="vps-project" {...register('project')} />
|
||||
</FormField>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<FormField label="vCPU" htmlFor="vps-vcpu" error={errors.vcpu?.message}>
|
||||
<Input id="vps-vcpu" type="number" min={0} {...register('vcpu', { valueAsNumber: true })} />
|
||||
</FormField>
|
||||
<FormField label="RAM (GB)" htmlFor="vps-ram" error={errors.ramGb?.message}>
|
||||
<Input id="vps-ram" type="number" min={0} {...register('ramGb', { valueAsNumber: true })} />
|
||||
</FormField>
|
||||
<FormField label="Disk (GB)" htmlFor="vps-disk" error={errors.diskGb?.message}>
|
||||
<Input id="vps-disk" type="number" min={0} {...register('diskGb', { valueAsNumber: true })} />
|
||||
</FormField>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<FormField label="Статус" htmlFor="vps-status">
|
||||
<Select
|
||||
value={watch('status')}
|
||||
onValueChange={(v) => setValue('status', (v ?? 'active') as 'active' | 'paused' | 'archived')}
|
||||
>
|
||||
<SelectTrigger id="vps-status">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="active">{vpsStatusLabel('active')}</SelectItem>
|
||||
<SelectItem value="paused">{vpsStatusLabel('paused')}</SelectItem>
|
||||
<SelectItem value="archived">{vpsStatusLabel('archived')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormField>
|
||||
<FormField label="Тип тарифа" htmlFor="vps-tariff">
|
||||
<Select
|
||||
value={watch('tariffType')}
|
||||
onValueChange={(v) => setValue('tariffType', (v ?? 'monthly') as 'daily' | 'monthly')}
|
||||
>
|
||||
<SelectTrigger id="vps-tariff">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="monthly">{tariffTypeLabel('monthly')}</SelectItem>
|
||||
<SelectItem value="daily">{tariffTypeLabel('daily')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormField>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<FormField label="Валюта" htmlFor="vps-cur" error={errors.currency?.message}>
|
||||
<Input id="vps-cur" {...register('currency')} />
|
||||
</FormField>
|
||||
<FormField label="Ставка/мес" htmlFor="vps-monthly">
|
||||
<Input id="vps-monthly" type="number" min={0} {...register('monthlyRate', { valueAsNumber: true })} />
|
||||
</FormField>
|
||||
<FormField label="Ставка/день" htmlFor="vps-daily">
|
||||
<Input id="vps-daily" type="number" min={0} {...register('dailyRate', { valueAsNumber: true })} />
|
||||
</FormField>
|
||||
</div>
|
||||
<FormField label="Оплачено до" htmlFor="vps-paid">
|
||||
<Input id="vps-paid" type="date" {...register('paidUntil')} />
|
||||
</FormField>
|
||||
<FormField label="Заметки" htmlFor="vps-notes">
|
||||
<Textarea id="vps-notes" {...register('notes')} />
|
||||
</FormField>
|
||||
</>
|
||||
)
|
||||
}}
|
||||
</FormSheetRhf>
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { createFileRoute, redirect } from '@tanstack/react-router'
|
||||
|
||||
export const Route = createFileRoute('/')({
|
||||
beforeLoad: () => {
|
||||
throw redirect({ to: '/dashboard' })
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,6 @@
|
||||
export interface HTMLInputProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||
'data-lpignore'?: string
|
||||
'data-1p-ignore'?: string
|
||||
'data-bwignore'?: string
|
||||
'data-form-type'?: string
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
export type VpsStatus = 'active' | 'paused' | 'archived'
|
||||
export type TariffType = 'daily' | 'monthly'
|
||||
export type BillingMode = 'daily' | 'monthly'
|
||||
export type PaymentType =
|
||||
| 'direct_vps_payment'
|
||||
| 'provider_balance_topup'
|
||||
| 'daily_debit'
|
||||
| 'monthly_debit'
|
||||
export type LedgerDirection = 'credit' | 'debit'
|
||||
export type ApiType = 'billmanager' | 'none'
|
||||
|
||||
export interface Provider {
|
||||
id: string
|
||||
name: string
|
||||
website?: string
|
||||
apiType: ApiType
|
||||
apiBaseUrl?: string
|
||||
baseCurrency?: string
|
||||
usdRate?: string | number | null
|
||||
eurRate?: string | number | null
|
||||
supportPhone?: string
|
||||
supportUrl?: string
|
||||
notes?: string
|
||||
}
|
||||
|
||||
export interface ProviderAccount {
|
||||
id: string
|
||||
providerId: string
|
||||
name: string
|
||||
login?: string
|
||||
apiCredentialsSet?: boolean
|
||||
billingMode?: BillingMode
|
||||
balance_api?: number | null
|
||||
balance_currency?: string
|
||||
currency?: string
|
||||
notes?: string
|
||||
}
|
||||
|
||||
export interface Vps {
|
||||
id: string
|
||||
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?: 'prod' | 'dev' | 'staging'
|
||||
project?: string
|
||||
monitoringEnabled?: boolean
|
||||
backupEnabled?: boolean
|
||||
status: VpsStatus
|
||||
tariffType: TariffType
|
||||
currency: string
|
||||
dailyRate: number | null
|
||||
monthlyRate: number | null
|
||||
createdAt: string
|
||||
paidUntil?: string
|
||||
notes?: string
|
||||
}
|
||||
|
||||
export interface Payment {
|
||||
id: string
|
||||
externalId?: string
|
||||
type: PaymentType
|
||||
date: string
|
||||
amount: number
|
||||
currency: string
|
||||
providerAccountId: string
|
||||
vpsId?: string | null
|
||||
note?: string
|
||||
}
|
||||
|
||||
export interface BalanceLedgerRow {
|
||||
id: string
|
||||
providerAccountId: string
|
||||
direction: LedgerDirection
|
||||
amount: number
|
||||
currency?: string
|
||||
date: string
|
||||
note?: string
|
||||
}
|
||||
|
||||
export interface Settings {
|
||||
id: string
|
||||
baseCurrency: string
|
||||
ratesUrl?: string
|
||||
autoConvert?: boolean
|
||||
syncEnabled?: boolean
|
||||
telegramChatId?: string
|
||||
telegramBotToken?: string
|
||||
}
|
||||
|
||||
export interface ActiveTariff {
|
||||
id: string
|
||||
providerAccountId: string
|
||||
pricelistId?: string
|
||||
name?: string
|
||||
vcpu?: number
|
||||
ramGb?: number
|
||||
diskGb?: number
|
||||
diskType?: string
|
||||
monthlyRate?: number
|
||||
currency?: string
|
||||
}
|
||||
|
||||
export interface SyncLogRow {
|
||||
id: string
|
||||
accountId: string
|
||||
status: 'ok' | 'error' | 'running'
|
||||
startedAt?: string
|
||||
finishedAt?: string
|
||||
summary?: Record<string, unknown> | null
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface SyncSummary {
|
||||
added?: unknown[]
|
||||
updated?: unknown[]
|
||||
paymentsAdded?: number
|
||||
tariffsOnly?: boolean
|
||||
tariffsCount?: number
|
||||
vpsCount?: number
|
||||
paymentsCount?: number
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface RatesData {
|
||||
base: string
|
||||
rates: Record<string, number>
|
||||
date?: string
|
||||
}
|
||||
|
||||
export interface DataSnapshot {
|
||||
vps: Vps[]
|
||||
providers: Provider[]
|
||||
providerAccounts: ProviderAccount[]
|
||||
payments: Payment[]
|
||||
balanceLedger: BalanceLedgerRow[]
|
||||
settings: Settings[]
|
||||
activeTariffs: ActiveTariff[]
|
||||
tariffSyncOptions?: unknown[]
|
||||
serverProjects?: unknown[]
|
||||
syncLog?: SyncLogRow[]
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"],
|
||||
"@cfdm/ui/components/*": ["../../packages/ui/src/components/*"],
|
||||
"@cfdm/ui/hooks/*": ["../../packages/ui/src/hooks/*"],
|
||||
"@cfdm/ui/lib/utils": ["../../packages/ui/src/lib/utils.ts"],
|
||||
"@cfdm/ui/globals.css": ["../../packages/ui/src/styles/globals.css"],
|
||||
"@cfdm/shared": ["../../packages/shared/src/index.ts"],
|
||||
"@cfdm/shared/contracts/*": ["../../packages/shared/src/contracts/*"],
|
||||
"@cfdm/shared/types/*": ["../../packages/shared/src/types/*"],
|
||||
"@cfdm/db": ["../../packages/db/src/index.ts"],
|
||||
"@cfdm/db/schema": ["../../packages/db/src/schema/index.ts"],
|
||||
"@cfdm/db/repositories/*": ["../../packages/db/src/repositories/*"]
|
||||
},
|
||||
"types": ["vite/client"]
|
||||
},
|
||||
"include": ["src", "vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import { TanStackRouterVite as TanStackRouterPlugin } from '@tanstack/router-plugin/vite'
|
||||
import path from 'node:path'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
TanStackRouterPlugin({ target: 'react', autoCodeSplitting: true }),
|
||||
react(),
|
||||
tailwindcss(),
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
'@cfdm/ui/components': path.resolve(__dirname, '../../packages/ui/src/components'),
|
||||
'@cfdm/ui/hooks': path.resolve(__dirname, '../../packages/ui/src/hooks'),
|
||||
'@cfdm/ui/lib/utils': path.resolve(__dirname, '../../packages/ui/src/lib/utils.ts'),
|
||||
'@cfdm/shared': path.resolve(__dirname, '../../packages/shared/src/index.ts'),
|
||||
'@cfdm/db': path.resolve(__dirname, '../../packages/db/src/index.ts'),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:3001',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
test: {
|
||||
environment: 'happy-dom',
|
||||
globals: true,
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user