Enhance database management and synchronization features
- Added new backup and restore functionality for JSON and SQLite database formats. - Implemented notification settings for low balance alerts and sync digests in the settings page. - Updated database schema to include new columns for balance alert thresholds and notification preferences. - Refactored synchronization logic to provide detailed summaries and improved error handling. - Enhanced user interface for managing accounts and settings, including new input fields for balance alerts. - Improved sync log display to include summary information for better tracking of synchronization results.
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { EmptyState } from './EmptyState'
|
||||
import { formatSyncSummaryLine } from '../lib/inventory-health'
|
||||
|
||||
export function SyncLogTable({ syncLog = [], providerAccounts = [] }) {
|
||||
const getAccountName = (accountId) => {
|
||||
@@ -38,6 +39,7 @@ export function SyncLogTable({ syncLog = [], providerAccounts = [] }) {
|
||||
<th>Статус</th>
|
||||
<th>VPS</th>
|
||||
<th>Платежи</th>
|
||||
<th>Итог</th>
|
||||
<th>Ошибка</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -54,6 +56,9 @@ export function SyncLogTable({ syncLog = [], providerAccounts = [] }) {
|
||||
</td>
|
||||
<td>{row.vpsCount ?? '—'}</td>
|
||||
<td>{row.paymentsCount ?? '—'}</td>
|
||||
<td className="small">
|
||||
{formatSyncSummaryLine(row.summary) || '—'}
|
||||
</td>
|
||||
<td>
|
||||
{row.error ? (
|
||||
<span className="text-danger small" title={row.error}>
|
||||
@@ -66,7 +71,7 @@ export function SyncLogTable({ syncLog = [], providerAccounts = [] }) {
|
||||
</tr>
|
||||
))}
|
||||
{syncLog.length === 0 ? (
|
||||
<EmptyState message="Нет записей синхронизации" colSpan={7} />
|
||||
<EmptyState message="Нет записей синхронизации" colSpan={8} />
|
||||
) : null}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -176,3 +176,64 @@ export async function sendTelegramTestNotification() {
|
||||
const res = await fetchApi('/api/settings/telegram/test', { method: 'POST' })
|
||||
return res
|
||||
}
|
||||
|
||||
export async function downloadBackupJsonBlob() {
|
||||
const url = `${API_BASE}/api/backup/json`
|
||||
const res = await fetch(url)
|
||||
if (!res.ok) {
|
||||
let message = res.statusText || 'Ошибка выгрузки'
|
||||
try {
|
||||
const data = await res.json()
|
||||
if (data?.error) message = data.error
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
throw new Error(message)
|
||||
}
|
||||
return res.blob()
|
||||
}
|
||||
|
||||
export async function downloadBackupDatabaseBlob() {
|
||||
const url = `${API_BASE}/api/backup/database`
|
||||
const res = await fetch(url)
|
||||
if (!res.ok) {
|
||||
let message = res.statusText || 'Ошибка выгрузки'
|
||||
try {
|
||||
const data = await res.json()
|
||||
if (data?.error) message = data.error
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
throw new Error(message)
|
||||
}
|
||||
return res.blob()
|
||||
}
|
||||
|
||||
export async function importBackupJson(payload) {
|
||||
return fetchApi('/api/backup/json', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
export async function importBackupDatabaseBuffer(buffer) {
|
||||
const url = `${API_BASE}/api/backup/database`
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/octet-stream' },
|
||||
body: buffer,
|
||||
})
|
||||
if (!res.ok) {
|
||||
let message = res.statusText || 'Ошибка восстановления'
|
||||
try {
|
||||
const data = await res.json()
|
||||
if (data?.error) message = data.error
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
const err = new Error(message)
|
||||
err.status = res.status
|
||||
throw err
|
||||
}
|
||||
return res.json()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
import { getPaidUntilDate } from './paid-until'
|
||||
|
||||
const STALE_SYNC_HOURS = 48
|
||||
|
||||
function ledgerBalanceInCurrency(account, balanceLedger) {
|
||||
const cur = (account.balance_currency || account.currency || '').trim()
|
||||
const rows = balanceLedger.filter((row) => row.providerAccountId === account.id)
|
||||
const filtered = cur
|
||||
? rows.filter((row) => !row.currency || row.currency === cur)
|
||||
: rows
|
||||
const credits = filtered
|
||||
.filter((row) => row.direction === 'credit')
|
||||
.reduce((acc, row) => acc + Number(row.amount || 0), 0)
|
||||
const debits = filtered
|
||||
.filter((row) => row.direction === 'debit')
|
||||
.reduce((acc, row) => acc + Number(row.amount || 0), 0)
|
||||
return credits - debits
|
||||
}
|
||||
|
||||
export function lastOkSyncFinishedAt(accountId, syncLog) {
|
||||
const rows = (syncLog || []).filter(
|
||||
(r) => r.accountId === accountId && r.status === 'ok' && r.finishedAt,
|
||||
)
|
||||
let best = null
|
||||
for (const r of rows) {
|
||||
const t = new Date(r.finishedAt).getTime()
|
||||
if (!Number.isNaN(t) && (!best || t > best)) best = t
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{
|
||||
* vps: object[],
|
||||
* providerAccounts: object[],
|
||||
* payments: object[],
|
||||
* balanceLedger: object[],
|
||||
* syncLog?: object[],
|
||||
* }} input
|
||||
*/
|
||||
export function computeInventoryHealth(input) {
|
||||
const { vps, providerAccounts, payments, balanceLedger, syncLog = [] } = input
|
||||
const now = new Date()
|
||||
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
||||
const ctx = { vps, providerAccounts, payments, balanceLedger, now }
|
||||
|
||||
/** @type {{ key: string, title: string, count: number, to: string, hint?: string }[]} */
|
||||
const issues = []
|
||||
|
||||
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 && d < todayStart
|
||||
})
|
||||
if (paidOverdue.length) {
|
||||
issues.push({
|
||||
key: 'paid-overdue',
|
||||
title: 'Просрочена оплата (оценка)',
|
||||
count: paidOverdue.length,
|
||||
to: '/vps?health=paid-overdue',
|
||||
})
|
||||
}
|
||||
|
||||
const bmAccounts = providerAccounts.filter(
|
||||
(a) => a.apiType === 'billmanager' && (a.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) => {
|
||||
if (a.balance_api == null || !Number.isFinite(Number(a.balance_api))) return false
|
||||
const ledger = ledgerBalanceInCurrency(a, balanceLedger)
|
||||
if (!Number.isFinite(ledger)) return false
|
||||
const api = Number(a.balance_api)
|
||||
const diff = Math.abs(api - ledger)
|
||||
const tol = Math.max(10, Math.abs(api) * 0.05)
|
||||
return diff > tol
|
||||
})
|
||||
if (mismatchAccounts.length) {
|
||||
issues.push({
|
||||
key: 'balance-mismatch',
|
||||
title: 'Баланс API и ledger расходятся',
|
||||
count: mismatchAccounts.length,
|
||||
to: '/accounts?health=balance-mismatch',
|
||||
})
|
||||
}
|
||||
|
||||
return issues
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object[]} providerAccounts
|
||||
* @param {object[]} syncLog
|
||||
*/
|
||||
export function getStaleSyncAccountIds(providerAccounts, syncLog, now = new Date()) {
|
||||
const bmAccounts = providerAccounts.filter(
|
||||
(a) => a.apiType === 'billmanager' && (a.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)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object[]} providerAccounts
|
||||
* @param {object[]} balanceLedger
|
||||
*/
|
||||
export function getBalanceMismatchAccountIds(providerAccounts, balanceLedger) {
|
||||
return providerAccounts
|
||||
.filter((a) => {
|
||||
if (a.balance_api == null || !Number.isFinite(Number(a.balance_api))) return false
|
||||
const ledger = ledgerBalanceInCurrency(a, balanceLedger)
|
||||
if (!Number.isFinite(ledger)) return false
|
||||
const api = Number(a.balance_api)
|
||||
const diff = Math.abs(api - ledger)
|
||||
const tol = Math.max(10, Math.abs(api) * 0.05)
|
||||
return diff > tol
|
||||
})
|
||||
.map((a) => a.id)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object|null} summary
|
||||
*/
|
||||
export function formatSyncSummaryLine(summary) {
|
||||
if (!summary || typeof summary !== 'object') return ''
|
||||
if (summary.error) return String(summary.error)
|
||||
const parts = []
|
||||
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,74 @@
|
||||
/**
|
||||
* Дата «оплачено до» для VPS (как на дашборде).
|
||||
*/
|
||||
|
||||
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 ledgerRows = balanceLedger.filter((row) => row.providerAccountId === accountId)
|
||||
const credits = ledgerRows
|
||||
.filter((row) => row.direction === 'credit')
|
||||
.reduce((acc, row) => acc + Number(row.amount || 0), 0)
|
||||
const debits = ledgerRows
|
||||
.filter((row) => row.direction === 'debit')
|
||||
.reduce((acc, row) => acc + Number(row.amount || 0), 0)
|
||||
return credits - debits
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} item - VPS
|
||||
* @param {{ vps: object[], providerAccounts: object[], payments: object[], balanceLedger: object[], now?: Date }} ctx
|
||||
* @returns {Date|null}
|
||||
*/
|
||||
export function getPaidUntilDate(item, ctx) {
|
||||
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 &&
|
||||
(() => {
|
||||
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(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
|
||||
}
|
||||
@@ -340,3 +340,16 @@ export function downloadTextFile(fileName, content) {
|
||||
anchor.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} fileName
|
||||
* @param {Blob} blob
|
||||
*/
|
||||
export function downloadBlob(fileName, blob) {
|
||||
const url = URL.createObjectURL(blob)
|
||||
const anchor = document.createElement('a')
|
||||
anchor.href = url
|
||||
anchor.download = fileName
|
||||
anchor.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Link, useSearchParams } from 'react-router-dom'
|
||||
import {
|
||||
billingModeLabel,
|
||||
faviconUrlFromWebsite,
|
||||
@@ -10,6 +11,7 @@ import { PageHeader } from '../components/PageHeader'
|
||||
import { ConvertedAmount } from '../components/ConvertedAmount'
|
||||
import { syncAccount, testApiConnection, fetchAccountBalance, fetchSyncStatus } from '../lib/api'
|
||||
import { noBrowserSuggestProps, passwordCredentialInputProps } from '../lib/noBrowserSuggestProps'
|
||||
import { getBalanceMismatchAccountIds, getStaleSyncAccountIds } from '../lib/inventory-health'
|
||||
import { IconRefresh, IconPlugConnected } from '@tabler/icons-react'
|
||||
|
||||
const emptyForm = {
|
||||
@@ -23,9 +25,12 @@ const emptyForm = {
|
||||
apiBaseUrl: '',
|
||||
apiLogin: '',
|
||||
apiPassword: '',
|
||||
balance_alert_below: '',
|
||||
}
|
||||
|
||||
export function AccountsPage({ db, actions, settings, ratesData }) {
|
||||
const [searchParams] = useSearchParams()
|
||||
const accountsHealth = (searchParams.get('health') || '').trim()
|
||||
const [form, setForm] = useState(emptyForm)
|
||||
const [editingId, setEditingId] = useState(null)
|
||||
const [isModalOpen, setIsModalOpen] = useState(false)
|
||||
@@ -53,6 +58,16 @@ export function AccountsPage({ db, actions, settings, ratesData }) {
|
||||
const [testConnectionResult, setTestConnectionResult] = useState(null)
|
||||
const [syncLog, setSyncLog] = useState([])
|
||||
|
||||
const highlightAccountIds = useMemo(() => {
|
||||
if (accountsHealth === 'stale-sync') {
|
||||
return new Set(getStaleSyncAccountIds(db.providerAccounts, syncLog))
|
||||
}
|
||||
if (accountsHealth === 'balance-mismatch') {
|
||||
return new Set(getBalanceMismatchAccountIds(db.providerAccounts, db.balanceLedger))
|
||||
}
|
||||
return null
|
||||
}, [accountsHealth, db.providerAccounts, db.balanceLedger, syncLog])
|
||||
|
||||
const balances = useMemo(() => {
|
||||
return db.providerAccounts.map((account) => {
|
||||
const rows = db.balanceLedger.filter((row) => row.providerAccountId === account.id)
|
||||
@@ -107,6 +122,14 @@ export function AccountsPage({ db, actions, settings, ratesData }) {
|
||||
if (form.apiType === 'billmanager' && form.apiLogin && form.apiPassword) {
|
||||
payload.apiCredentials = `${form.apiLogin}:${form.apiPassword}`
|
||||
}
|
||||
{
|
||||
let b = null
|
||||
if (String(form.balance_alert_below || '').trim() !== '') {
|
||||
const x = Number(form.balance_alert_below)
|
||||
if (Number.isFinite(x)) b = x
|
||||
}
|
||||
payload.balance_alert_below = b
|
||||
}
|
||||
setSaveError(null)
|
||||
try {
|
||||
if (editingId) {
|
||||
@@ -151,6 +174,10 @@ export function AccountsPage({ db, actions, settings, ratesData }) {
|
||||
apiBaseUrl: account.apiBaseUrl || '',
|
||||
apiLogin: '',
|
||||
apiPassword: '',
|
||||
balance_alert_below:
|
||||
account.balance_alert_below != null && account.balance_alert_below !== ''
|
||||
? String(account.balance_alert_below)
|
||||
: '',
|
||||
})
|
||||
setEditingId(account.id)
|
||||
setIsModalOpen(true)
|
||||
@@ -212,6 +239,44 @@ export function AccountsPage({ db, actions, settings, ratesData }) {
|
||||
return (
|
||||
<>
|
||||
<PageHeader pretitle="Справочники" title="Аккаунты хостеров" />
|
||||
{accountsHealth === 'stale-sync' ? (
|
||||
highlightAccountIds?.size ? (
|
||||
<div className="alert alert-warning d-flex align-items-center justify-content-between flex-wrap gap-2">
|
||||
<span>
|
||||
Подсвечены аккаунты без успешного синка дольше 48 ч или без записей в журнале.
|
||||
</span>
|
||||
<Link to="/accounts" className="btn btn-sm btn-outline-secondary">
|
||||
Сбросить фильтр
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="alert alert-success d-flex align-items-center justify-content-between flex-wrap gap-2">
|
||||
<span>Все BILLmanager-аккаунты имеют недавний успешный синк.</span>
|
||||
<Link to="/accounts" className="btn btn-sm btn-outline-secondary">
|
||||
Закрыть
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
) : null}
|
||||
{accountsHealth === 'balance-mismatch' ? (
|
||||
highlightAccountIds?.size ? (
|
||||
<div className="alert alert-warning d-flex align-items-center justify-content-between flex-wrap gap-2">
|
||||
<span>
|
||||
Подсвечены аккаунты, где баланс API заметно расходится с суммой по ledger (та же валюта).
|
||||
</span>
|
||||
<Link to="/accounts" className="btn btn-sm btn-outline-secondary">
|
||||
Сбросить фильтр
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="alert alert-success d-flex align-items-center justify-content-between flex-wrap gap-2">
|
||||
<span>Расхождений баланса API и ledger по выбранным правилам не найдено.</span>
|
||||
<Link to="/accounts" className="btn btn-sm btn-outline-secondary">
|
||||
Закрыть
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
) : null}
|
||||
<div className="row row-cards">
|
||||
<div className="col-12">
|
||||
<div className="card">
|
||||
@@ -267,8 +332,9 @@ export function AccountsPage({ db, actions, settings, ratesData }) {
|
||||
{db.providerAccounts.map((account) => {
|
||||
const provider = db.providers.find((item) => item.id === account.providerId)
|
||||
const linkedVps = db.vps.filter((item) => item.providerAccountId === account.id)
|
||||
const rowWarn = highlightAccountIds?.has(account.id)
|
||||
return (
|
||||
<tr key={account.id}>
|
||||
<tr key={account.id} className={rowWarn ? 'table-warning' : undefined}>
|
||||
<td>{account.name}</td>
|
||||
<td>
|
||||
<div className="d-flex align-items-center gap-2">
|
||||
@@ -508,6 +574,23 @@ export function AccountsPage({ db, actions, settings, ratesData }) {
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="col-12">
|
||||
<label className="form-label">Порог баланса для Telegram</label>
|
||||
<input
|
||||
{...noBrowserSuggestProps}
|
||||
type="number"
|
||||
step="any"
|
||||
className="form-control"
|
||||
placeholder="Пусто — не слать по балансу"
|
||||
value={form.balance_alert_below}
|
||||
onChange={(e) =>
|
||||
setForm((prev) => ({ ...prev, balance_alert_below: e.target.value }))
|
||||
}
|
||||
/>
|
||||
<div className="form-hint">
|
||||
При плановом синке, если баланс API ниже этого значения — уведомление (вкл. в настройках).
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
<div className="col-12">
|
||||
|
||||
+123
-82
@@ -1,10 +1,9 @@
|
||||
import { useMemo } from 'react'
|
||||
import {
|
||||
billingModeLabel,
|
||||
convertCurrency,
|
||||
formatCurrency,
|
||||
monthKey,
|
||||
} from '../lib/utils'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { convertCurrency, formatCurrency, monthKey } from '../lib/utils'
|
||||
import { getPaidUntilDate as computePaidUntil } from '../lib/paid-until'
|
||||
import { computeInventoryHealth, formatSyncSummaryLine } from '../lib/inventory-health'
|
||||
import { fetchSyncStatus } from '../lib/api'
|
||||
import { ConvertedAmount } from '../components/ConvertedAmount'
|
||||
import { EmptyState } from '../components/EmptyState'
|
||||
import { ExpenseChart } from '../components/ExpenseChart'
|
||||
@@ -24,6 +23,13 @@ export function DashboardPage({ db = {}, settings, ratesData }) {
|
||||
const payments = Array.isArray(db.payments) ? db.payments : []
|
||||
const providers = Array.isArray(db.providers) ? db.providers : []
|
||||
|
||||
const [syncLogRows, setSyncLogRows] = useState([])
|
||||
useEffect(() => {
|
||||
fetchSyncStatus()
|
||||
.then(setSyncLogRows)
|
||||
.catch(() => setSyncLogRows([]))
|
||||
}, [db.vps?.length, db.providerAccounts?.length])
|
||||
|
||||
const now = new Date()
|
||||
const currentMonth = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`
|
||||
const baseCurrency = settings?.[0]?.baseCurrency || 'RUB'
|
||||
@@ -189,89 +195,124 @@ export function DashboardPage({ db = {}, settings, ratesData }) {
|
||||
)
|
||||
|
||||
const UPCOMING_DAYS = 7
|
||||
const getAccountBalance = (accountId) => {
|
||||
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((row) => row.direction === 'credit')
|
||||
.reduce((acc, row) => acc + Number(row.amount || 0), 0)
|
||||
const debits = ledgerRows
|
||||
.filter((row) => row.direction === 'debit')
|
||||
.reduce((acc, row) => acc + Number(row.amount || 0), 0)
|
||||
return credits - debits
|
||||
}
|
||||
const paidUntilCtx = { vps, providerAccounts, payments, balanceLedger, now }
|
||||
const getPaidUntilDate = (item) => computePaidUntil(item, paidUntilCtx)
|
||||
|
||||
const getPaidUntilDate = (item) => {
|
||||
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 inventoryIssues = useMemo(
|
||||
() =>
|
||||
computeInventoryHealth({
|
||||
vps,
|
||||
providerAccounts,
|
||||
payments,
|
||||
balanceLedger,
|
||||
syncLog: syncLogRows,
|
||||
}),
|
||||
[vps, providerAccounts, payments, balanceLedger, syncLogRows],
|
||||
)
|
||||
|
||||
const paidUntilFromApi = item.paidUntil
|
||||
? (() => {
|
||||
const d = new Date(item.paidUntil)
|
||||
return Number.isNaN(d.getTime()) ? null : d
|
||||
})()
|
||||
: null
|
||||
const recentSyncFeed = useMemo(() => {
|
||||
return [...syncLogRows]
|
||||
.filter((r) => r.finishedAt && (r.status === 'ok' || r.status === 'error'))
|
||||
.slice(0, 12)
|
||||
}, [syncLogRows])
|
||||
|
||||
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(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)
|
||||
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()
|
||||
paidUntil.setDate(paidUntil.getDate() + coveredDays)
|
||||
return paidUntil
|
||||
}
|
||||
|
||||
const upcoming = useMemo(() => {
|
||||
const threshold = new Date()
|
||||
threshold.setDate(threshold.getDate() + UPCOMING_DAYS)
|
||||
return vps
|
||||
.filter((item) => item.status === 'active')
|
||||
.map((item) => {
|
||||
const date = getPaidUntilDate(item)
|
||||
return { vps: item, paidUntil: date }
|
||||
})
|
||||
.filter(({ paidUntil }) => paidUntil && paidUntil <= threshold && paidUntil >= new Date(now.getFullYear(), now.getMonth(), now.getDate()))
|
||||
.sort((a, b) => a.paidUntil - b.paidUntil)
|
||||
.slice(0, 10)
|
||||
}, [vps, payments, balanceLedger, providerAccounts])
|
||||
const upcomingThreshold = new Date(now)
|
||||
upcomingThreshold.setDate(upcomingThreshold.getDate() + UPCOMING_DAYS)
|
||||
const todayStartForUpcoming = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
||||
const upcoming = vps
|
||||
.filter((item) => item.status === 'active')
|
||||
.map((item) => {
|
||||
const date = getPaidUntilDate(item)
|
||||
return { vps: item, paidUntil: date }
|
||||
})
|
||||
.filter(
|
||||
({ paidUntil }) =>
|
||||
paidUntil &&
|
||||
paidUntil <= upcomingThreshold &&
|
||||
paidUntil >= todayStartForUpcoming,
|
||||
)
|
||||
.sort((a, b) => a.paidUntil - b.paidUntil)
|
||||
.slice(0, 10)
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader pretitle="Обзор" title="Дашборд" />
|
||||
<div className="row row-cards">
|
||||
{inventoryIssues.length > 0 ? (
|
||||
<div className="col-12">
|
||||
<div className="card border-warning">
|
||||
<div className="card-header">
|
||||
<h3 className="card-title">Здоровье инвентаря</h3>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
<div className="row g-2">
|
||||
{inventoryIssues.map((issue) => (
|
||||
<div className="col-md-6 col-xl-4" key={issue.key}>
|
||||
<Link className="card card-link" to={issue.to}>
|
||||
<div className="card-body py-2 px-3">
|
||||
<div className="d-flex justify-content-between align-items-center">
|
||||
<span className="fw-medium">{issue.title}</span>
|
||||
<span className="badge bg-orange-lt text-orange">{issue.count}</span>
|
||||
</div>
|
||||
{issue.hint ? <div className="text-secondary small mt-1">{issue.hint}</div> : null}
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="col-12">
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<h3 className="card-title">Последние синхронизации</h3>
|
||||
<div className="card-actions">
|
||||
<Link to="/accounts" className="btn btn-sm btn-outline-secondary">
|
||||
Аккаунты
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<div className="list-group list-group-flush">
|
||||
{recentSyncFeed.map((row) => {
|
||||
const acc = providerAccounts.find((a) => a.id === row.accountId)
|
||||
const line = formatSyncSummaryLine(row.summary)
|
||||
return (
|
||||
<div key={row.id} className="list-group-item">
|
||||
<div className="d-flex justify-content-between align-items-start gap-2">
|
||||
<div>
|
||||
<div className="fw-medium">{acc?.name || row.accountId}</div>
|
||||
<div className="text-secondary small">
|
||||
{row.status === 'error' ? (
|
||||
<span className="text-danger">{row.error || line}</span>
|
||||
) : (
|
||||
line || 'OK'
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<span className={`badge ${row.status === 'ok' ? 'bg-green-lt text-green' : 'bg-red-lt text-red'}`}>
|
||||
{row.status === 'ok' ? 'OK' : 'Ошибка'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-secondary small mt-1">
|
||||
{row.finishedAt
|
||||
? new Date(row.finishedAt).toLocaleString('ru-RU')
|
||||
: '—'}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{recentSyncFeed.length === 0 ? (
|
||||
<div className="list-group-item text-secondary text-center py-4">
|
||||
Запустите синхронизацию на странице аккаунтов — здесь появится краткий итог
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="col-sm-6 col-lg-3">
|
||||
<div className="card metric-card metric-blue h-100">
|
||||
<div className="card-body">
|
||||
|
||||
+157
-3
@@ -1,7 +1,14 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { IconPlus, IconSend, IconTrash } from '@tabler/icons-react'
|
||||
import { PageHeader } from '../components/PageHeader'
|
||||
import { sendTelegramTestNotification } from '../lib/api'
|
||||
import {
|
||||
downloadBackupDatabaseBlob,
|
||||
downloadBackupJsonBlob,
|
||||
importBackupDatabaseBuffer,
|
||||
importBackupJson,
|
||||
sendTelegramTestNotification,
|
||||
} from '../lib/api'
|
||||
import { downloadBlob } from '../lib/utils'
|
||||
import { noBrowserSuggestProps, passwordCredentialInputProps } from '../lib/noBrowserSuggestProps'
|
||||
|
||||
const defaultSettings = {
|
||||
@@ -16,6 +23,8 @@ const defaultSettings = {
|
||||
telegramMessageThreadId: '',
|
||||
notifyPaymentExpiryEnabled: false,
|
||||
notifyNewTariffsEnabled: false,
|
||||
notifyLowBalanceEnabled: false,
|
||||
notifySyncDigestEnabled: false,
|
||||
}
|
||||
|
||||
export function SettingsPage({ db, actions, ratesData, ratesError }) {
|
||||
@@ -32,15 +41,18 @@ export function SettingsPage({ db, actions, ratesData, ratesError }) {
|
||||
telegramMessageThreadId: current.telegramMessageThreadId ?? '',
|
||||
notifyPaymentExpiryEnabled: Boolean(current.notifyPaymentExpiryEnabled),
|
||||
notifyNewTariffsEnabled: Boolean(current.notifyNewTariffsEnabled),
|
||||
notifyLowBalanceEnabled: Boolean(current.notifyLowBalanceEnabled),
|
||||
notifySyncDigestEnabled: Boolean(current.notifySyncDigestEnabled),
|
||||
})
|
||||
const [telegramTokenEdited, setTelegramTokenEdited] = useState(false)
|
||||
const [telegramTestLoading, setTelegramTestLoading] = useState(false)
|
||||
const [telegramTestMessage, setTelegramTestMessage] = useState(null)
|
||||
const [backupBusy, setBackupBusy] = useState(false)
|
||||
const [backupMessage, setBackupMessage] = useState(null)
|
||||
const [newFieldLabel, setNewFieldLabel] = useState('')
|
||||
const customFields = Array.isArray(current.customFields) ? current.customFields : []
|
||||
|
||||
useEffect(() => {
|
||||
/* eslint-disable-next-line react-hooks/set-state-in-effect -- sync form when settings change from parent */
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
baseCurrency: current.baseCurrency || 'RUB',
|
||||
@@ -53,8 +65,10 @@ export function SettingsPage({ db, actions, ratesData, ratesError }) {
|
||||
telegramMessageThreadId: current.telegramMessageThreadId ?? '',
|
||||
notifyPaymentExpiryEnabled: Boolean(current.notifyPaymentExpiryEnabled),
|
||||
notifyNewTariffsEnabled: Boolean(current.notifyNewTariffsEnabled),
|
||||
notifyLowBalanceEnabled: Boolean(current.notifyLowBalanceEnabled),
|
||||
notifySyncDigestEnabled: Boolean(current.notifySyncDigestEnabled),
|
||||
}))
|
||||
}, [current.baseCurrency, current.ratesUrl, current.autoConvert, current.syncEnabled, current.syncIntervalMinutes, current.syncTariffsIntervalMinutes, current.telegramChatId, current.telegramMessageThreadId, current.notifyPaymentExpiryEnabled, current.notifyNewTariffsEnabled])
|
||||
}, [current.baseCurrency, current.ratesUrl, current.autoConvert, current.syncEnabled, current.syncIntervalMinutes, current.syncTariffsIntervalMinutes, current.telegramChatId, current.telegramMessageThreadId, current.notifyPaymentExpiryEnabled, current.notifyNewTariffsEnabled, current.notifyLowBalanceEnabled, current.notifySyncDigestEnabled])
|
||||
|
||||
const availableCurrencies = useMemo(() => {
|
||||
const list = new Set(['RUB', 'USD', 'EUR'])
|
||||
@@ -103,6 +117,8 @@ export function SettingsPage({ db, actions, ratesData, ratesError }) {
|
||||
telegramMessageThreadId: form.telegramMessageThreadId || '',
|
||||
notifyPaymentExpiryEnabled: form.notifyPaymentExpiryEnabled,
|
||||
notifyNewTariffsEnabled: form.notifyNewTariffsEnabled,
|
||||
notifyLowBalanceEnabled: form.notifyLowBalanceEnabled,
|
||||
notifySyncDigestEnabled: form.notifySyncDigestEnabled,
|
||||
}
|
||||
if (telegramTokenEdited && form.telegramBotToken !== undefined) {
|
||||
payload.telegramBotToken = form.telegramBotToken || ''
|
||||
@@ -368,6 +384,30 @@ export function SettingsPage({ db, actions, ratesData, ratesError }) {
|
||||
<span className="form-check-label">Уведомления о новых тарифах</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="col-12">
|
||||
<label className="form-check">
|
||||
<input {...noBrowserSuggestProps}
|
||||
className="form-check-input"
|
||||
type="checkbox"
|
||||
checked={form.notifyLowBalanceEnabled}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, notifyLowBalanceEnabled: e.target.checked }))}
|
||||
/>
|
||||
<span className="form-check-label">
|
||||
Низкий баланс (порог задаётся у каждого аккаунта BILLmanager)
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="col-12">
|
||||
<label className="form-check">
|
||||
<input {...noBrowserSuggestProps}
|
||||
className="form-check-input"
|
||||
type="checkbox"
|
||||
checked={form.notifySyncDigestEnabled}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, notifySyncDigestEnabled: e.target.checked }))}
|
||||
/>
|
||||
<span className="form-check-label">Краткий итог после планового синка VPS</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="col-12 d-flex justify-content-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
@@ -401,6 +441,120 @@ export function SettingsPage({ db, actions, ratesData, ratesError }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="col-12">
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<h3 className="card-title">Резервная копия</h3>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
<p className="text-secondary small mb-3">
|
||||
JSON включает все данные (в т.ч. API-ключи и токен Telegram). Файл SQLite — точная копия базы.
|
||||
Восстановление перезаписывает текущие данные.
|
||||
</p>
|
||||
<div className="d-flex flex-wrap gap-2 mb-3">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline-primary"
|
||||
disabled={backupBusy}
|
||||
onClick={async () => {
|
||||
setBackupMessage(null)
|
||||
setBackupBusy(true)
|
||||
try {
|
||||
const blob = await downloadBackupJsonBlob()
|
||||
downloadBlob('vps-tracker-backup.json', blob)
|
||||
} catch (err) {
|
||||
setBackupMessage({ type: 'danger', text: err.message || 'Ошибка выгрузки' })
|
||||
} finally {
|
||||
setBackupBusy(false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
Скачать JSON
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline-primary"
|
||||
disabled={backupBusy}
|
||||
onClick={async () => {
|
||||
setBackupMessage(null)
|
||||
setBackupBusy(true)
|
||||
try {
|
||||
const blob = await downloadBackupDatabaseBlob()
|
||||
downloadBlob('vps-tracker.db', blob)
|
||||
} catch (err) {
|
||||
setBackupMessage({ type: 'danger', text: err.message || 'Ошибка выгрузки' })
|
||||
} finally {
|
||||
setBackupBusy(false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
Скачать SQLite
|
||||
</button>
|
||||
</div>
|
||||
<div className="row g-2 align-items-end">
|
||||
<div className="col-12 col-md-6">
|
||||
<label className="form-label">Восстановить из JSON</label>
|
||||
<input
|
||||
{...noBrowserSuggestProps}
|
||||
type="file"
|
||||
accept="application/json,.json"
|
||||
className="form-control"
|
||||
disabled={backupBusy}
|
||||
onChange={async (e) => {
|
||||
const file = e.target.files?.[0]
|
||||
e.target.value = ''
|
||||
if (!file) return
|
||||
setBackupMessage(null)
|
||||
setBackupBusy(true)
|
||||
try {
|
||||
const text = await file.text()
|
||||
const data = JSON.parse(text)
|
||||
await importBackupJson(data)
|
||||
await actions.refreshData()
|
||||
setBackupMessage({ type: 'success', text: 'Данные восстановлены из JSON' })
|
||||
} catch (err) {
|
||||
setBackupMessage({ type: 'danger', text: err.message || 'Ошибка импорта' })
|
||||
} finally {
|
||||
setBackupBusy(false)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-12 col-md-6">
|
||||
<label className="form-label">Восстановить из SQLite</label>
|
||||
<input
|
||||
{...noBrowserSuggestProps}
|
||||
type="file"
|
||||
accept=".db,application/octet-stream"
|
||||
className="form-control"
|
||||
disabled={backupBusy}
|
||||
onChange={async (e) => {
|
||||
const file = e.target.files?.[0]
|
||||
e.target.value = ''
|
||||
if (!file) return
|
||||
setBackupMessage(null)
|
||||
setBackupBusy(true)
|
||||
try {
|
||||
const buf = await file.arrayBuffer()
|
||||
await importBackupDatabaseBuffer(buf)
|
||||
await actions.refreshData()
|
||||
setBackupMessage({ type: 'success', text: 'База восстановлена из SQLite' })
|
||||
} catch (err) {
|
||||
setBackupMessage({ type: 'danger', text: err.message || 'Ошибка восстановления' })
|
||||
} finally {
|
||||
setBackupBusy(false)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{backupMessage ? (
|
||||
<div className={`alert alert-${backupMessage.type} py-2 mt-3 mb-0`}>{backupMessage.text}</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="col-12 col-lg-6">
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
|
||||
+55
-1
@@ -1,4 +1,5 @@
|
||||
import { Fragment, useMemo, useState } from 'react'
|
||||
import { Link, useSearchParams } from 'react-router-dom'
|
||||
import {
|
||||
convertCurrency,
|
||||
faviconUrlFromWebsite,
|
||||
@@ -25,6 +26,7 @@ import { EmptyState } from '../components/EmptyState'
|
||||
import { PageHeader } from '../components/PageHeader'
|
||||
import { ProjectSuggestInput } from '../components/ProjectSuggestInput'
|
||||
import { noBrowserSuggestProps } from '../lib/noBrowserSuggestProps'
|
||||
import { getPaidUntilDate as computePaidUntilForHealth } from '../lib/paid-until'
|
||||
|
||||
const emptyForm = {
|
||||
ip: '',
|
||||
@@ -111,6 +113,9 @@ function buildDefaultVpsFilters(customFields) {
|
||||
}
|
||||
|
||||
export function VpsPage({ db, actions, settings, ratesData }) {
|
||||
const [searchParams] = useSearchParams()
|
||||
const healthKey = (searchParams.get('health') || '').trim()
|
||||
|
||||
const customFields = Array.isArray(settings?.[0]?.customFields) ? settings[0].customFields : []
|
||||
const [form, setForm] = useState(emptyForm)
|
||||
const [editingId, setEditingId] = useState(null)
|
||||
@@ -150,8 +155,43 @@ export function VpsPage({ db, actions, settings, ratesData }) {
|
||||
tableCompact: false,
|
||||
})
|
||||
|
||||
const healthPredicate = useMemo(() => {
|
||||
if (!healthKey) return null
|
||||
const todayStart = new Date()
|
||||
todayStart.setHours(0, 0, 0, 0)
|
||||
const ctx = {
|
||||
vps: db.vps,
|
||||
providerAccounts: db.providerAccounts,
|
||||
payments: db.payments,
|
||||
balanceLedger: db.balanceLedger,
|
||||
}
|
||||
if (healthKey === 'no-project') {
|
||||
return (item) => item.status === 'active' && !(item.project || '').trim()
|
||||
}
|
||||
if (healthKey === 'no-rate') {
|
||||
return (item) => {
|
||||
if (item.status !== 'active') return false
|
||||
const dr = Number(item.dailyRate || 0)
|
||||
const mr = Number(item.monthlyRate || 0)
|
||||
const noMoney =
|
||||
(!Number.isFinite(dr) || dr <= 0) && (!Number.isFinite(mr) || mr <= 0)
|
||||
const noCur = !(item.currency || '').trim()
|
||||
return noMoney || noCur
|
||||
}
|
||||
}
|
||||
if (healthKey === 'paid-overdue') {
|
||||
return (item) => {
|
||||
if (item.status !== 'active') return false
|
||||
const d = computePaidUntilForHealth(item, ctx)
|
||||
return Boolean(d && d < todayStart)
|
||||
}
|
||||
}
|
||||
return null
|
||||
}, [healthKey, db.vps, db.providerAccounts, db.payments, db.balanceLedger])
|
||||
|
||||
const filteredVps = useMemo(() => {
|
||||
return db.vps.filter((item) => {
|
||||
if (healthPredicate && !healthPredicate(item)) return false
|
||||
const search = filters.search.toLowerCase()
|
||||
const extraIps = Array.isArray(item.additionalIps) ? item.additionalIps.join(' ') : ''
|
||||
const minVcpu = Number(filters.minVcpu || 0)
|
||||
@@ -217,7 +257,7 @@ export function VpsPage({ db, actions, settings, ratesData }) {
|
||||
byProject
|
||||
)
|
||||
})
|
||||
}, [db.vps, filters, customFields])
|
||||
}, [db.vps, filters, customFields, healthPredicate])
|
||||
|
||||
const projectNameOptions = useMemo(() => {
|
||||
const names = new Set()
|
||||
@@ -598,6 +638,20 @@ export function VpsPage({ db, actions, settings, ratesData }) {
|
||||
return (
|
||||
<>
|
||||
<PageHeader pretitle="Управление инфраструктурой" title="VPS серверы" />
|
||||
{healthKey && healthPredicate ? (
|
||||
<div className="alert alert-info d-flex align-items-center justify-content-between flex-wrap gap-2">
|
||||
<span>Показаны только VPS по замечанию с дашборда ({healthKey}).</span>
|
||||
<Link to="/vps" className="btn btn-sm btn-outline-primary">
|
||||
Сбросить ссылку
|
||||
</Link>
|
||||
</div>
|
||||
) : null}
|
||||
{healthKey && !healthPredicate ? (
|
||||
<div className="alert alert-warning">
|
||||
Неизвестный параметр health="{healthKey}".{' '}
|
||||
<Link to="/vps">К полному списку</Link>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="row row-cards">
|
||||
<div className="col-12 card-stack">
|
||||
<div className="card">
|
||||
|
||||
Reference in New Issue
Block a user