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:
Denozordec
2026-03-20 23:11:58 +07:00
parent 994dde2314
commit 196c5be532
20 changed files with 1342 additions and 130 deletions
+6 -1
View File
@@ -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>
+61
View File
@@ -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()
}
+184
View File
@@ -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 ''
}
+74
View File
@@ -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
}
+13
View File
@@ -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)
}
+84 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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=&quot;{healthKey}&quot;.{' '}
<Link to="/vps">К полному списку</Link>
</div>
) : null}
<div className="row row-cards">
<div className="col-12 card-stack">
<div className="card">