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:
@@ -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={
|
||||
|
||||
@@ -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 },
|
||||
]
|
||||
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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}`)
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user