Add project management features and enhance database schema

- Introduced a new `server_projects` table to manage project details.
- Updated the `vps` table to include a foreign key reference to `server_projects`.
- Enhanced the API to support project-related data retrieval and manipulation.
- Implemented project filtering in the Reports and VPS pages.
- Added project selection functionality in the UI for better resource management.
- Improved data seeding and migration scripts to accommodate new project structure.
This commit is contained in:
Denozordec
2026-03-20 22:50:57 +07:00
parent 7ce754ca90
commit 9d5b434bea
19 changed files with 1298 additions and 77 deletions
+6
View File
@@ -8,6 +8,7 @@ import { AccountsPage } from './pages/AccountsPage'
import { PaymentsPage } from './pages/PaymentsPage'
import { BalancePage } from './pages/BalancePage'
import { ReportsPage } from './pages/ReportsPage'
import { ResourcesPage } from './pages/ResourcesPage'
import { SettingsPage } from './pages/SettingsPage'
import { TariffsPage } from './pages/TariffsPage'
import {
@@ -30,6 +31,7 @@ function App() {
settings: [],
activeTariffs: [],
tariffSyncOptions: [],
serverProjects: [],
})
const [ratesData, setRatesData] = useState(null)
const [ratesError, setRatesError] = useState('')
@@ -166,6 +168,10 @@ function App() {
path="/reports"
element={<ReportsPage db={db} settings={db.settings} ratesData={ratesData} />}
/>
<Route
path="/resources"
element={<ResourcesPage db={db} settings={db.settings} ratesData={ratesData} />}
/>
<Route
path="/settings"
element={
+2
View File
@@ -1,6 +1,7 @@
import { NavLink, useLocation } from 'react-router-dom'
import {
IconBuildingSkyscraper,
IconChartBar,
IconChartHistogram,
IconCoin,
IconCreditCardPay,
@@ -20,6 +21,7 @@ const menuItems = [
{ to: '/payments', label: 'Платежи', icon: IconCreditCardPay },
{ to: '/balance', label: 'Баланс и списания', icon: IconCoin },
{ to: '/reports', label: 'Отчёты', icon: IconChartHistogram },
{ to: '/resources', label: 'Ресурсы', icon: IconChartBar },
{ to: '/settings', label: 'Настройки', icon: IconSettings },
]
+86
View File
@@ -0,0 +1,86 @@
import { useEffect, useMemo, useState } from 'react'
import { fetchProjectSuggestions } from '../lib/api'
/**
* Поле ввода проекта с datalist: подсказки из БД при вводе + локальный кэш serverProjects.
* Новое имя сохранится на сервере при сохранении VPS (resolve-or-create).
*/
export function ProjectSuggestInput({
value,
onChange,
serverProjects = [],
id,
className = 'form-control',
placeholder,
disabled,
'aria-label': ariaLabel,
}) {
const [remoteNames, setRemoteNames] = useState([])
const listId = id ? `${id}-project-datalist` : 'project-datalist'
useEffect(() => {
const q = (value || '').trim()
let cancelled = false
const t = setTimeout(async () => {
try {
const rows = await fetchProjectSuggestions(q, 25)
if (!cancelled) {
setRemoteNames((rows || []).map((r) => r.name).filter(Boolean))
}
} catch {
if (!cancelled) setRemoteNames([])
}
}, 200)
return () => {
cancelled = true
clearTimeout(t)
}
}, [value])
const localNames = useMemo(() => {
const q = (value || '').trim().toLowerCase()
const list = Array.isArray(serverProjects) ? serverProjects : []
if (!q) {
return list.slice(0, 30).map((p) => p.name)
}
return list
.filter((p) => (p.name || '').toLowerCase().includes(q))
.slice(0, 20)
.map((p) => p.name)
}, [serverProjects, value])
const merged = useMemo(() => {
const out = []
const seen = new Set()
for (const n of [...localNames, ...remoteNames]) {
if (!n || seen.has(n)) continue
seen.add(n)
out.push(n)
if (out.length >= 45) break
}
return out
}, [localNames, remoteNames])
return (
<>
<input
id={id}
type="text"
className={className}
list={listId}
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
disabled={disabled}
autoComplete="off"
spellCheck={false}
aria-label={ariaLabel}
/>
<datalist id={listId}>
{merged.map((name) => (
<option key={name} value={name} />
))}
</datalist>
</>
)
}
+8
View File
@@ -98,9 +98,17 @@ export async function loadDataSet() {
[COLLECTIONS.settings]: data.settings ?? [],
activeTariffs: data.activeTariffs ?? [],
tariffSyncOptions: data.tariffSyncOptions ?? [],
serverProjects: data.serverProjects ?? [],
}
}
export async function fetchProjectSuggestions(q = '', limit = 25) {
const params = new URLSearchParams()
if (q) params.set('q', q)
params.set('limit', String(limit))
return fetchApi(`/api/projects/suggest?${params.toString()}`)
}
async function fetchCollection(collectionName) {
const path = API_PATHS[collectionName]
if (!path) throw new Error(`Unknown collection: ${collectionName}`)
+104
View File
@@ -123,6 +123,48 @@ export function DashboardPage({ db = {}, settings, ratesData }) {
}))
}, [payments, balanceLedger, vps, providerAccounts, providers, baseCurrency, ratesData])
const forecastByProject = useMemo(() => {
const map = {}
for (const item of vps.filter((x) => x.status === 'active')) {
const key = (item.project || '').trim() || '__none__'
const label = key === '__none__' ? 'Без проекта' : key
if (!map[key]) {
map[key] = { key, label, count: 0, forecast: 0 }
}
map[key].count += 1
const tariffType = item.tariffType || (Number(item.dailyRate || 0) > 0 ? 'daily' : 'monthly')
const amount =
tariffType === 'daily'
? Number(item.dailyRate || 0) * 30
: Number(item.monthlyRate || 0)
map[key].forecast += convertCurrency(amount, item.currency || 'USD', baseCurrency, ratesData)
}
return Object.values(map).sort((a, b) => {
if (a.key === '__none__') return 1
if (b.key === '__none__') return -1
return a.label.localeCompare(b.label, 'ru')
})
}, [vps, baseCurrency, ratesData])
const forecastByAccount = useMemo(() => {
return providerAccounts
.map((acc) => {
const items = vps.filter((x) => x.providerAccountId === acc.id && x.status === 'active')
let forecast = 0
for (const item of items) {
const tariffType = item.tariffType || (Number(item.dailyRate || 0) > 0 ? 'daily' : 'monthly')
const amount =
tariffType === 'daily'
? Number(item.dailyRate || 0) * 30
: Number(item.monthlyRate || 0)
forecast += convertCurrency(amount, item.currency || 'USD', baseCurrency, ratesData)
}
return { account: acc, count: items.length, forecast }
})
.filter((row) => row.count > 0)
.sort((a, b) => b.forecast - a.forecast)
}, [vps, providerAccounts, baseCurrency, ratesData])
const accountBalances = providerAccounts.map((account) => {
if (account.balance_api != null && Number.isFinite(Number(account.balance_api))) {
return {
@@ -319,6 +361,68 @@ export function DashboardPage({ db = {}, settings, ratesData }) {
</div>
</div>
<div className="col-12 col-xl-6">
<div className="card">
<div className="card-header">
<h3 className="card-title">Прогноз по проектам (активные VPS)</h3>
</div>
<div className="table-responsive">
<table className="table card-table table-vcenter">
<thead>
<tr>
<th>Проект / пул</th>
<th className="text-end">VPS</th>
<th className="text-end">Прогноз / мес</th>
</tr>
</thead>
<tbody>
{forecastByProject.map((row) => (
<tr key={row.key}>
<td>{row.label}</td>
<td className="text-end">{row.count}</td>
<td className="text-end">{formatCurrency(row.forecast, baseCurrency)}</td>
</tr>
))}
{forecastByProject.length === 0 ? (
<EmptyState message="Нет активных VPS" colSpan={3} />
) : null}
</tbody>
</table>
</div>
</div>
</div>
<div className="col-12 col-xl-6">
<div className="card">
<div className="card-header">
<h3 className="card-title">Прогноз по аккаунтам (активные VPS)</h3>
</div>
<div className="table-responsive">
<table className="table card-table table-vcenter">
<thead>
<tr>
<th>Аккаунт</th>
<th className="text-end">VPS</th>
<th className="text-end">Прогноз / мес</th>
</tr>
</thead>
<tbody>
{forecastByAccount.map((row) => (
<tr key={row.account.id}>
<td>{row.account.name}</td>
<td className="text-end">{row.count}</td>
<td className="text-end">{formatCurrency(row.forecast, baseCurrency)}</td>
</tr>
))}
{forecastByAccount.length === 0 ? (
<EmptyState message="Нет активных VPS по аккаунтам" colSpan={3} />
) : null}
</tbody>
</table>
</div>
</div>
</div>
<div className="col-12 col-xl-7">
<div className="card">
<div className="card-header">
+78 -4
View File
@@ -22,12 +22,34 @@ function isDateInRange(dateStr, dateFrom, dateTo) {
export function ReportsPage({ db, settings, ratesData }) {
const [filters, setFilters] = useState({
providerId: '',
providerAccountId: '',
project: '',
country: '',
month: '',
dateFrom: '',
dateTo: '',
})
const projectNameOptions = useMemo(() => {
const names = new Set()
for (const p of db.serverProjects || []) {
if ((p.name || '').trim()) names.add(p.name.trim())
}
for (const v of db.vps || []) {
const p = (v.project || '').trim()
if (p) names.add(p)
}
return [...names].sort((a, b) => a.localeCompare(b, 'ru'))
}, [db.serverProjects, db.vps])
const accountFilterOptions = useMemo(
() =>
(db.providerAccounts || []).filter(
(account) => !filters.providerId || account.providerId === filters.providerId,
),
[db.providerAccounts, filters.providerId],
)
const rows = useMemo(() => {
const dateFrom = filters.dateFrom || (filters.month ? `${filters.month}-01` : '')
const dateTo = filters.dateTo || (filters.month ? (() => {
@@ -38,9 +60,15 @@ export function ReportsPage({ db, settings, ratesData }) {
const filteredVps = (db.vps || []).filter((vps) => {
const byProvider = !filters.providerId || vps.providerId === filters.providerId
const byAccount =
!filters.providerAccountId || vps.providerAccountId === filters.providerAccountId
const proj = (vps.project || '').trim()
const byProject =
!filters.project ||
(filters.project === '__none__' ? !proj : proj === filters.project)
const byCountry =
!filters.country || vps.country?.toLowerCase().includes(filters.country.toLowerCase())
return byProvider && byCountry
return byProvider && byAccount && byProject && byCountry
})
const vpsByAccount = new Map()
@@ -52,6 +80,7 @@ export function ReportsPage({ db, settings, ratesData }) {
return filteredVps.map((vps) => {
const provider = db.providers.find((item) => item.id === vps.providerId)
const account = db.providerAccounts?.find((a) => a.id === vps.providerAccountId)
const vpsIdNorm = (id) => (id == null || id === '' ? '' : String(id))
const paymentsDirect = (db.payments || []).filter(
@@ -96,6 +125,8 @@ export function ReportsPage({ db, settings, ratesData }) {
return {
providerId: vps.providerId,
provider: provider?.name || '-',
account: account?.name || '-',
project: (vps.project || '').trim() || '—',
vps: vps.dns || vps.ip,
ip: vps.ip,
country: vps.country || '',
@@ -105,7 +136,7 @@ export function ReportsPage({ db, settings, ratesData }) {
currency: vps.currency || 'USD',
}
})
}, [db.payments, db.balanceLedger, db.providers, db.vps, filters])
}, [db.payments, db.balanceLedger, db.providers, db.providerAccounts, db.vps, filters])
const baseCurrency = settings?.[0]?.baseCurrency || 'RUB'
const totalExpense = rows.reduce(
@@ -129,7 +160,13 @@ export function ReportsPage({ db, settings, ratesData }) {
<select
className="form-select"
value={filters.providerId}
onChange={(e) => setFilters((prev) => ({ ...prev, providerId: e.target.value }))}
onChange={(e) =>
setFilters((prev) => ({
...prev,
providerId: e.target.value,
providerAccountId: '',
}))
}
>
<option value="">Все хостеры</option>
{db.providers.map((provider) => (
@@ -139,6 +176,39 @@ export function ReportsPage({ db, settings, ratesData }) {
))}
</select>
</div>
<div className="col-12 col-md-6 col-xl-3">
<label className="form-label">Аккаунт</label>
<select
className="form-select"
value={filters.providerAccountId}
onChange={(e) =>
setFilters((prev) => ({ ...prev, providerAccountId: e.target.value }))
}
>
<option value="">Все аккаунты</option>
{accountFilterOptions.map((account) => (
<option key={account.id} value={account.id}>
{account.name}
</option>
))}
</select>
</div>
<div className="col-12 col-md-6 col-xl-3">
<label className="form-label">Проект / пул</label>
<select
className="form-select"
value={filters.project}
onChange={(e) => setFilters((prev) => ({ ...prev, project: e.target.value }))}
>
<option value="">Все проекты</option>
<option value="__none__">Без проекта</option>
{projectNameOptions.map((name) => (
<option key={name} value={name}>
{name}
</option>
))}
</select>
</div>
<div className="col-12 col-md-6 col-xl-3">
<label className="form-label">Страна</label>
<input
@@ -238,6 +308,8 @@ export function ReportsPage({ db, settings, ratesData }) {
<thead>
<tr>
<th>Хостер</th>
<th>Аккаунт</th>
<th>Проект</th>
<th>VPS</th>
<th>Локация</th>
<th>Статус</th>
@@ -248,6 +320,8 @@ export function ReportsPage({ db, settings, ratesData }) {
{rows.map((row) => (
<tr key={`${row.ip}-${row.vps}`}>
<td>{row.provider}</td>
<td>{row.account}</td>
<td>{row.project}</td>
<td>
<div>{row.vps}</div>
<div className="text-secondary">{row.ip}</div>
@@ -268,7 +342,7 @@ export function ReportsPage({ db, settings, ratesData }) {
</tr>
))}
{rows.length === 0 ? (
<EmptyState message="Нет данных под фильтр" colSpan={5} />
<EmptyState message="Нет данных под фильтр" colSpan={7} />
) : null}
</tbody>
</table>
+365
View File
@@ -0,0 +1,365 @@
import { useMemo, useState } from 'react'
import {
convertCurrency,
formatCurrency,
} from '../lib/utils'
import { EmptyState } from '../components/EmptyState'
import { PageHeader } from '../components/PageHeader'
function vpsMonthlyEstimateInBase(item, baseCurrency, ratesData) {
if (item.status !== 'active') return 0
const tariffType = item.tariffType || (Number(item.dailyRate || 0) > 0 ? 'daily' : 'monthly')
const amount =
tariffType === 'daily'
? Number(item.dailyRate || 0) * 30
: Number(item.monthlyRate || 0)
return convertCurrency(amount, item.currency || 'USD', baseCurrency, ratesData)
}
export function ResourcesPage({ db, settings, ratesData }) {
const baseCurrency = settings?.[0]?.baseCurrency || 'RUB'
const [groupBy, setGroupBy] = useState('project')
const [filters, setFilters] = useState({
providerId: '',
providerAccountId: '',
project: '',
country: '',
datacenter: '',
})
const customFields = Array.isArray(settings?.[0]?.customFields) ? settings[0].customFields : []
const projectNameOptions = useMemo(() => {
const names = new Set()
for (const p of db.serverProjects || []) {
if ((p.name || '').trim()) names.add(p.name.trim())
}
for (const v of db.vps || []) {
const p = (v.project || '').trim()
if (p) names.add(p)
}
return [...names].sort((a, b) => a.localeCompare(b, 'ru'))
}, [db.serverProjects, db.vps])
const accountFilterOptions = useMemo(
() =>
(db.providerAccounts || []).filter(
(account) => !filters.providerId || account.providerId === filters.providerId,
),
[db.providerAccounts, filters.providerId],
)
const vpsFiltered = useMemo(() => {
return (db.vps || []).filter((item) => {
const byProvider = !filters.providerId || item.providerId === filters.providerId
const byAccount =
!filters.providerAccountId || item.providerAccountId === filters.providerAccountId
const proj = (item.project || '').trim()
const byProject =
!filters.project ||
(filters.project === '__none__' ? !proj : proj === filters.project)
const byCountry =
!filters.country || item.country?.toLowerCase().includes(filters.country.toLowerCase())
const byDc =
!filters.datacenter ||
item.datacenter?.toLowerCase().includes(filters.datacenter.toLowerCase())
const byCustom = customFields.every((f) => {
const filterVal = (filters[f.key] || '').trim().toLowerCase()
if (!filterVal) return true
const itemVal = (item[f.key] || '').toLowerCase()
return itemVal.includes(filterVal)
})
return byProvider && byAccount && byProject && byCountry && byDc && byCustom
})
}, [db.vps, filters, customFields])
const groups = useMemo(() => {
const map = new Map()
const accounts = db.providerAccounts || []
for (const item of vpsFiltered) {
let key
let label
switch (groupBy) {
case 'account': {
const aid = item.providerAccountId || ''
key = aid || '__none__'
label = accounts.find((a) => a.id === aid)?.name || '—'
break
}
case 'country': {
const c = (item.country || '').trim()
key = c || '__none__'
label = key === '__none__' ? 'Не указано' : c
break
}
case 'datacenter': {
const dc = (item.datacenter || '').trim()
key = dc || '__none__'
label = key === '__none__' ? 'Не указано' : dc
break
}
default: {
const p = (item.project || '').trim()
key = p || '__none__'
label = key === '__none__' ? 'Без проекта' : p
}
}
if (!map.has(key)) {
map.set(key, {
key,
label,
count: 0,
activeCount: 0,
vcpu: 0,
ramGb: 0,
diskGb: 0,
forecast: 0,
})
}
const g = map.get(key)
g.count += 1
if (item.status === 'active') g.activeCount += 1
g.vcpu += Number(item.vcpu || 0)
g.ramGb += Number(item.ramGb || 0)
g.diskGb += Number(item.diskGb || 0)
g.forecast += vpsMonthlyEstimateInBase(item, baseCurrency, ratesData)
}
const list = [...map.values()]
list.sort((a, b) => {
if (groupBy === 'account' || groupBy === 'project') {
if (a.key === '__none__') return 1
if (b.key === '__none__') return -1
}
return b.forecast - a.forecast || b.vcpu - a.vcpu
})
return list
}, [vpsFiltered, groupBy, db.providerAccounts, baseCurrency, ratesData])
const maxForecast = useMemo(
() => groups.reduce((m, g) => Math.max(m, g.forecast), 0),
[groups],
)
const totals = useMemo(() => {
return groups.reduce(
(acc, g) => ({
count: acc.count + g.count,
activeCount: acc.activeCount + g.activeCount,
vcpu: acc.vcpu + g.vcpu,
ramGb: acc.ramGb + g.ramGb,
diskGb: acc.diskGb + g.diskGb,
forecast: acc.forecast + g.forecast,
}),
{ count: 0, activeCount: 0, vcpu: 0, ramGb: 0, diskGb: 0, forecast: 0 },
)
}, [groups])
const defaultFilters = () => ({
providerId: '',
providerAccountId: '',
project: '',
country: '',
datacenter: '',
...customFields.reduce((acc, f) => {
acc[f.key] = ''
return acc
}, {}),
})
return (
<>
<PageHeader pretitle="Аналитика" title="Ресурсы и прогноз" />
<div className="row row-cards">
<div className="col-12">
<div className="card">
<div className="card-header">
<h3 className="card-title">Группировка и фильтры</h3>
<div className="card-actions">
<button
type="button"
className="btn btn-sm btn-outline-secondary"
onClick={() => setFilters(defaultFilters())}
>
Сбросить
</button>
</div>
</div>
<div className="card-body">
<div className="row g-2">
<div className="col-12 col-md-6 col-xl-3">
<label className="form-label">Группировать по</label>
<select
className="form-select"
value={groupBy}
onChange={(e) => setGroupBy(e.target.value)}
>
<option value="project">Проект / пул</option>
<option value="account">Аккаунту</option>
<option value="country">Стране</option>
<option value="datacenter">Датацентру</option>
</select>
</div>
<div className="col-12 col-md-6 col-xl-3">
<label className="form-label">Хостер</label>
<select
className="form-select"
value={filters.providerId}
onChange={(e) =>
setFilters((prev) => ({
...prev,
providerId: e.target.value,
providerAccountId: '',
}))
}
>
<option value="">Все</option>
{(db.providers || []).map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
</div>
<div className="col-12 col-md-6 col-xl-3">
<label className="form-label">Аккаунт</label>
<select
className="form-select"
value={filters.providerAccountId}
onChange={(e) =>
setFilters((prev) => ({ ...prev, providerAccountId: e.target.value }))
}
>
<option value="">Все</option>
{accountFilterOptions.map((a) => (
<option key={a.id} value={a.id}>
{a.name}
</option>
))}
</select>
</div>
<div className="col-12 col-md-6 col-xl-3">
<label className="form-label">Проект (фильтр)</label>
<select
className="form-select"
value={filters.project}
onChange={(e) => setFilters((prev) => ({ ...prev, project: e.target.value }))}
>
<option value="">Все</option>
<option value="__none__">Без проекта</option>
{projectNameOptions.map((name) => (
<option key={name} value={name}>
{name}
</option>
))}
</select>
</div>
<div className="col-12 col-md-6 col-xl-3">
<label className="form-label">Страна</label>
<input
className="form-control"
value={filters.country}
onChange={(e) => setFilters((prev) => ({ ...prev, country: e.target.value }))}
placeholder="Часть названия"
/>
</div>
<div className="col-12 col-md-6 col-xl-3">
<label className="form-label">Датацентр</label>
<input
className="form-control"
value={filters.datacenter}
onChange={(e) =>
setFilters((prev) => ({ ...prev, datacenter: e.target.value }))
}
placeholder="Часть названия ДЦ"
/>
</div>
{customFields.map((f) => (
<div key={f.key} className="col-12 col-md-6 col-xl-3">
<label className="form-label">{f.label}</label>
<input
className="form-control"
value={filters[f.key] ?? ''}
onChange={(e) =>
setFilters((prev) => ({ ...prev, [f.key]: e.target.value }))
}
placeholder={f.label}
/>
</div>
))}
</div>
<div className="text-secondary small mt-2">
Учтено VPS: {vpsFiltered.length} из {(db.vps || []).length}. Прогноз / мес только
активные (в базовой валюте).
</div>
</div>
</div>
</div>
<div className="col-12">
<div className="card">
<div className="card-header">
<h3 className="card-title">Сводка по группам</h3>
</div>
<div className="table-responsive">
<table className="table card-table table-vcenter">
<thead>
<tr>
<th>Группа</th>
<th className="text-end">VPS</th>
<th className="text-end">Активных</th>
<th className="text-end">vCPU</th>
<th className="text-end">RAM, GB</th>
<th className="text-end">Диск, GB</th>
<th className="text-end">Прогноз / мес</th>
<th style={{ minWidth: 120 }}>Доля прогноза</th>
</tr>
</thead>
<tbody>
{groups.map((g) => (
<tr key={g.key}>
<td className="fw-medium">{g.label}</td>
<td className="text-end">{g.count}</td>
<td className="text-end">{g.activeCount}</td>
<td className="text-end">{g.vcpu}</td>
<td className="text-end">{Number(g.ramGb.toFixed(1))}</td>
<td className="text-end">{g.diskGb}</td>
<td className="text-end">{formatCurrency(g.forecast, baseCurrency)}</td>
<td>
<div
className="progress progress-sm"
title={`${maxForecast > 0 ? Math.round((g.forecast / maxForecast) * 100) : 0}% от макс. группы`}
>
<div
className="progress-bar bg-primary"
style={{
width: `${maxForecast > 0 ? Math.min(100, (g.forecast / maxForecast) * 100) : 0}%`,
}}
/>
</div>
</td>
</tr>
))}
{groups.length > 0 ? (
<tr className="table-active fw-medium">
<td>Итого</td>
<td className="text-end">{totals.count}</td>
<td className="text-end">{totals.activeCount}</td>
<td className="text-end">{totals.vcpu}</td>
<td className="text-end">{Number(totals.ramGb.toFixed(1))}</td>
<td className="text-end">{totals.diskGb}</td>
<td className="text-end">{formatCurrency(totals.forecast, baseCurrency)}</td>
<td />
</tr>
) : null}
{groups.length === 0 ? (
<EmptyState message="Нет VPS по фильтрам" colSpan={8} />
) : null}
</tbody>
</table>
</div>
</div>
</div>
</div>
</>
)
}
+336 -54
View File
@@ -1,6 +1,8 @@
import { useMemo, useState } from 'react'
import { Fragment, useMemo, useState } from 'react'
import {
convertCurrency,
faviconUrlFromWebsite,
formatCurrency,
getCountryFlagEmoji,
normalizeWebsiteUrl,
tariffTypeLabel,
@@ -21,6 +23,7 @@ import { UiModal } from '../components/UiModal'
import { ConvertedAmount } from '../components/ConvertedAmount'
import { EmptyState } from '../components/EmptyState'
import { PageHeader } from '../components/PageHeader'
import { ProjectSuggestInput } from '../components/ProjectSuggestInput'
const emptyForm = {
ip: '',
@@ -57,6 +60,55 @@ const emptyForm = {
resetToApi: false,
}
const VPS_FILTER_PRESETS_KEY = 'vps-tracker:vps-filter-presets'
function loadFilterPresets() {
try {
const raw = localStorage.getItem(VPS_FILTER_PRESETS_KEY)
if (!raw) return []
const parsed = JSON.parse(raw)
return Array.isArray(parsed) ? parsed : []
} catch {
return []
}
}
function vpsMonthlyEstimateInBase(item, baseCurrency, ratesData) {
if (item.status !== 'active') return 0
const tariffType = item.tariffType || (Number(item.dailyRate || 0) > 0 ? 'daily' : 'monthly')
const amount =
tariffType === 'daily'
? Number(item.dailyRate || 0) * 30
: Number(item.monthlyRate || 0)
return convertCurrency(amount, item.currency || 'USD', baseCurrency, ratesData)
}
function buildDefaultVpsFilters(customFields) {
return {
search: '',
providerId: '',
providerAccountId: '',
country: '',
city: '',
datacenter: '',
status: 'all',
environment: 'all',
tariffType: 'all',
monitoring: 'all',
backup: 'all',
minVcpu: '',
minRamGb: '',
minDiskGb: '',
project: '',
groupByProject: false,
tableCompact: false,
...customFields.reduce((acc, f) => {
acc[f.key] = ''
return acc
}, {}),
}
}
export function VpsPage({ db, actions, settings, ratesData }) {
const customFields = Array.isArray(settings?.[0]?.customFields) ? settings[0].customFields : []
const [form, setForm] = useState(emptyForm)
@@ -68,6 +120,10 @@ export function VpsPage({ db, actions, settings, ratesData }) {
const [syncMessage, setSyncMessage] = useState(null)
const [selectedIds, setSelectedIds] = useState(new Set())
const [bulkLoading, setBulkLoading] = useState(false)
const [bulkProjectValue, setBulkProjectValue] = useState('')
const [filterPresets, setFilterPresets] = useState(loadFilterPresets)
const baseCurrency = settings?.[0]?.baseCurrency || 'RUB'
const billmanagerAccounts = useMemo(
() => db.providerAccounts.filter((a) => a.apiType === 'billmanager' && a.apiBaseUrl),
@@ -88,6 +144,9 @@ export function VpsPage({ db, actions, settings, ratesData }) {
minVcpu: '',
minRamGb: '',
minDiskGb: '',
project: '',
groupByProject: false,
tableCompact: false,
})
const filteredVps = useMemo(() => {
@@ -128,6 +187,10 @@ export function VpsPage({ db, actions, settings, ratesData }) {
const byCpu = !minVcpu || Number(item.vcpu || 0) >= minVcpu
const byRam = !minRamGb || Number(item.ramGb || 0) >= minRamGb
const byDisk = !minDiskGb || Number(item.diskGb || 0) >= minDiskGb
const proj = (item.project || '').trim()
const byProject =
!filters.project ||
(filters.project === '__none__' ? !proj : proj === filters.project)
const byCustomFields = customFields.every((f) => {
const filterVal = (filters[f.key] || '').trim().toLowerCase()
if (!filterVal) return true
@@ -149,11 +212,68 @@ export function VpsPage({ db, actions, settings, ratesData }) {
byCustomFields &&
byCpu &&
byRam &&
byDisk
byDisk &&
byProject
)
})
}, [db.vps, filters, customFields])
const projectNameOptions = useMemo(() => {
const names = new Set()
for (const p of db.serverProjects || []) {
if ((p.name || '').trim()) names.add(p.name.trim())
}
for (const v of db.vps) {
const p = (v.project || '').trim()
if (p) names.add(p)
}
return [...names].sort((a, b) => a.localeCompare(b, 'ru'))
}, [db.serverProjects, db.vps])
const tableSections = useMemo(() => {
if (!filters.groupByProject) {
return [
{
key: '_flat',
label: null,
items: filteredVps,
count: filteredVps.length,
forecast: filteredVps.reduce(
(acc, item) => acc + vpsMonthlyEstimateInBase(item, baseCurrency, ratesData),
0,
),
},
]
}
const map = new Map()
for (const item of filteredVps) {
const key = (item.project || '').trim() || '__none__'
if (!map.has(key)) map.set(key, [])
map.get(key).push(item)
}
const keys = [...map.keys()].sort((a, b) => {
if (a === '__none__') return 1
if (b === '__none__') return -1
return a.localeCompare(b, 'ru')
})
return keys.map((key) => {
const items = map.get(key)
const forecast = items.reduce(
(acc, item) => acc + vpsMonthlyEstimateInBase(item, baseCurrency, ratesData),
0,
)
return {
key,
label: key === '__none__' ? 'Без проекта' : key,
items,
count: items.length,
forecast,
}
})
}, [filteredVps, filters.groupByProject, baseCurrency, ratesData])
const tableColCount = 8 + (viewMode === 'extended' ? 16 + customFields.length : 0)
const accountFilterOptions = useMemo(
() =>
db.providerAccounts.filter(
@@ -173,6 +293,12 @@ export function VpsPage({ db, actions, settings, ratesData }) {
if (customFields.some((f) => f.key === key)) {
return Boolean(value)
}
if (key === 'project') {
return Boolean(value)
}
if (key === 'groupByProject' || key === 'tableCompact') {
return false
}
return Boolean(value)
}).length
}, [filters, customFields])
@@ -423,27 +549,49 @@ export function VpsPage({ db, actions, settings, ratesData }) {
}
}
const onBulkProject = async () => {
const ids = [...selectedIds]
if (ids.length === 0) return
setBulkLoading(true)
try {
await bulkUpdateVps(ids, 'project', bulkProjectValue)
setBulkProjectValue('')
setSelectedIds(new Set())
await actions.refreshData()
} catch (err) {
setSyncMessage(err.message || 'Ошибка назначения проекта')
} finally {
setBulkLoading(false)
}
}
const resetFilters = () => {
setFilters({
search: '',
providerId: '',
providerAccountId: '',
country: '',
city: '',
datacenter: '',
status: 'all',
environment: 'all',
tariffType: 'all',
monitoring: 'all',
backup: 'all',
minVcpu: '',
minRamGb: '',
minDiskGb: '',
...customFields.reduce((acc, f) => {
acc[f.key] = ''
return acc
}, {}),
})
setFilters(buildDefaultVpsFilters(customFields))
}
const persistFilterPresets = (next) => {
setFilterPresets(next)
localStorage.setItem(VPS_FILTER_PRESETS_KEY, JSON.stringify(next))
}
const applyFilterPreset = (preset) => {
if (!preset?.filters) return
setFilters({ ...buildDefaultVpsFilters(customFields), ...preset.filters })
}
const saveCurrentFilterPreset = () => {
const name = window.prompt('Имя пресета фильтров')
if (!name?.trim()) return
const trimmed = name.trim()
const next = [...filterPresets.filter((p) => p.name !== trimmed), { name: trimmed, filters: { ...filters } }]
persistFilterPresets(next)
}
const deleteFilterPresetByName = () => {
const name = window.prompt('Имя пресета для удаления')
if (!name?.trim()) return
const trimmed = name.trim()
persistFilterPresets(filterPresets.filter((p) => p.name !== trimmed))
}
return (
@@ -487,6 +635,22 @@ export function VpsPage({ db, actions, settings, ratesData }) {
/>
</div>
</div>
<div className="col-xl-2 col-lg-4 col-md-6">
<select
className="form-select"
value={filters.project}
onChange={(e) => setFilters((prev) => ({ ...prev, project: e.target.value }))}
aria-label="Фильтр по проекту"
>
<option value="">Все проекты</option>
<option value="__none__">Без проекта</option>
{projectNameOptions.map((name) => (
<option key={name} value={name}>
{name}
</option>
))}
</select>
</div>
{showAdvancedFilters ? (
<>
<div className="col-xl-2 col-lg-4 col-md-6">
@@ -673,43 +837,137 @@ export function VpsPage({ db, actions, settings, ratesData }) {
</>
) : null}
</div>
<div className="row g-2 mt-2 align-items-center flex-wrap">
<div className="col-12 col-sm-auto">
<label className="form-check mb-0">
<input
type="checkbox"
className="form-check-input"
checked={filters.groupByProject}
onChange={(e) =>
setFilters((prev) => ({ ...prev, groupByProject: e.target.checked }))
}
/>
<span className="form-check-label">Группировать по проекту</span>
</label>
</div>
<div className="col-12 col-sm-auto">
<label className="form-check mb-0">
<input
type="checkbox"
className="form-check-input"
checked={filters.tableCompact}
onChange={(e) =>
setFilters((prev) => ({ ...prev, tableCompact: e.target.checked }))
}
/>
<span className="form-check-label">Компактная таблица</span>
</label>
</div>
<div className="col-12 col-sm-auto">
<select
className="form-select form-select-sm"
style={{ minWidth: '11rem' }}
defaultValue=""
onChange={(e) => {
const name = e.target.value
const preset = filterPresets.find((p) => p.name === name)
if (preset) applyFilterPreset(preset)
e.target.value = ''
}}
aria-label="Применить пресет фильтров"
>
<option value="">Пресет фильтров</option>
{filterPresets.map((p) => (
<option key={p.name} value={p.name}>
{p.name}
</option>
))}
</select>
</div>
<div className="col-12 col-sm-auto d-flex flex-wrap gap-1">
<button
type="button"
className="btn btn-sm btn-outline-secondary"
onClick={saveCurrentFilterPreset}
>
Сохранить пресет
</button>
<button
type="button"
className="btn btn-sm btn-outline-secondary"
onClick={deleteFilterPresetByName}
disabled={filterPresets.length === 0}
>
Удалить пресет
</button>
</div>
</div>
</div>
</div>
<div className="card">
<div className="card-header">
<h3 className="card-title">VPS список</h3>
<h3 className="card-title">
VPS список
<span className="text-secondary fw-normal ms-2 small">
Показано {filteredVps.length} из {db.vps.length}
</span>
</h3>
{selectedIds.size > 0 ? (
<div className="d-flex align-items-center gap-2 me-2">
<div className="d-flex align-items-center flex-wrap gap-2 me-2">
<span className="text-secondary small">Выбрано: {selectedIds.size}</span>
<div className="btn-group btn-group-sm">
<div className="d-flex flex-wrap align-items-center gap-1">
<div className="btn-group btn-group-sm">
<button
type="button"
className="btn btn-outline-secondary"
onClick={() => onBulkStatus('archived')}
disabled={bulkLoading}
>
В архив
</button>
<button
type="button"
className="btn btn-outline-secondary"
onClick={() => onBulkStatus('active')}
disabled={bulkLoading}
>
Активен
</button>
<button
type="button"
className="btn btn-outline-secondary"
onClick={() => onBulkStatus('paused')}
disabled={bulkLoading}
>
Приостановлен
</button>
</div>
<div style={{ width: '11rem' }}>
<ProjectSuggestInput
id="vps-bulk-project"
className="form-control form-control-sm"
serverProjects={db.serverProjects}
value={bulkProjectValue}
onChange={setBulkProjectValue}
placeholder="Проект / пул"
disabled={bulkLoading}
aria-label="Проект для массового назначения"
/>
</div>
<button
type="button"
className="btn btn-outline-secondary"
onClick={() => onBulkStatus('archived')}
className="btn btn-sm btn-outline-primary"
onClick={onBulkProject}
disabled={bulkLoading}
title="Назначить проект выбранным VPS"
>
В архив
В проект
</button>
<button
type="button"
className="btn btn-outline-secondary"
onClick={() => onBulkStatus('active')}
disabled={bulkLoading}
>
Активен
</button>
<button
type="button"
className="btn btn-outline-secondary"
onClick={() => onBulkStatus('paused')}
disabled={bulkLoading}
>
Приостановлен
</button>
<button
type="button"
className="btn btn-outline-danger"
className="btn btn-sm btn-outline-danger"
onClick={onBulkDelete}
disabled={bulkLoading}
>
@@ -784,7 +1042,9 @@ export function VpsPage({ db, actions, settings, ratesData }) {
</div>
</div>
<div className="table-responsive">
<table className="table card-table table-vcenter">
<table
className={`table card-table table-vcenter${filters.tableCompact ? ' table-sm' : ''}`}
>
<thead>
<tr>
<th style={{ width: 40 }}>
@@ -829,7 +1089,22 @@ export function VpsPage({ db, actions, settings, ratesData }) {
</tr>
</thead>
<tbody>
{filteredVps.map((item) => {
{tableSections.map((section) => (
<Fragment key={section.key}>
{filters.groupByProject && section.label ? (
<tr className="table-active">
<td colSpan={tableColCount}>
<div className="d-flex flex-wrap align-items-center gap-2">
<span>{section.label}</span>
<span className="badge bg-secondary-lt">{section.count} VPS</span>
<span className="text-secondary small">
Прогноз / мес: {formatCurrency(section.forecast, baseCurrency)}
</span>
</div>
</td>
</tr>
) : null}
{section.items.map((item) => {
const provider = db.providers.find((providerRow) => providerRow.id === item.providerId)
const account = db.providerAccounts.find(
(accountRow) => accountRow.id === item.providerAccountId,
@@ -1003,11 +1278,13 @@ export function VpsPage({ db, actions, settings, ratesData }) {
</td>
</tr>
)
})}
})}
</Fragment>
))}
{filteredVps.length === 0 ? (
<EmptyState
message="По фильтрам ничего не найдено"
colSpan={viewMode === 'extended' ? 25 + customFields.length : 9}
colSpan={tableColCount}
/>
) : null}
</tbody>
@@ -1280,13 +1557,18 @@ export function VpsPage({ db, actions, settings, ratesData }) {
</select>
</div>
<div className="col-12 col-sm-6">
<label className="form-label">Проект</label>
<input
className="form-control"
<label className="form-label">Проект / пул</label>
<ProjectSuggestInput
id="vps-form-project"
serverProjects={db.serverProjects}
value={form.project}
onChange={(e) => setForm((prev) => ({ ...prev, project: e.target.value }))}
placeholder="my-project"
onChange={(v) => setForm((prev) => ({ ...prev, project: v }))}
placeholder="Умный дом, прокси…"
aria-label="Проект или пул"
/>
<div className="text-secondary small mt-1">
Подсказки уже созданные группы; новое имя создастся при сохранении.
</div>
</div>
<div className="col-12 col-sm-6">
<label className="form-label">Назначение</label>