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
+4 -2
View File
@@ -36,8 +36,8 @@ export async function syncFromBillmanager(account, db, opts = {}) {
let vpsCount = 0
if (fetchVpsPayments) {
const vpsInsertSql = `INSERT INTO vps (id, ip, ipv6, additionalIps, dns, providerId, providerAccountId, country, city, datacenter, os, vcpu, ramGb, diskGb, diskType, virtualization, bandwidthTb, sshPort, rootUser, purpose, environment, project, monitoringEnabled, backupEnabled, status, tariffType, currency, dailyRate, monthlyRate, createdAt, paidUntil, notes)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
const vpsInsertSql = `INSERT INTO vps (id, ip, ipv6, additionalIps, dns, providerId, providerAccountId, country, city, datacenter, os, vcpu, ramGb, diskGb, diskType, virtualization, bandwidthTb, sshPort, rootUser, purpose, environment, project, projectId, monitoringEnabled, backupEnabled, status, tariffType, currency, dailyRate, monthlyRate, createdAt, paidUntil, notes, userOverrides)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
const vpsUpdateSql = `UPDATE vps SET ip=?, ipv6=?, additionalIps=?, dns=?, country=?, city=?, datacenter=?, os=?, status=?, tariffType=?, currency=?, dailyRate=?, monthlyRate=?, paidUntil=?, notes=?
WHERE id=?`
@@ -124,6 +124,7 @@ export async function syncFromBillmanager(account, db, opts = {}) {
vps.purpose,
vps.environment,
vps.project,
null,
vps.monitoringEnabled ? 1 : 0,
vps.backupEnabled ? 1 : 0,
vps.status,
@@ -134,6 +135,7 @@ export async function syncFromBillmanager(account, db, opts = {}) {
vps.createdAt,
paidUntil,
notes,
'[]',
)
}
vpsCount++
+3
View File
@@ -51,6 +51,9 @@ export async function initDb() {
}
dbInstance = db
if (existsSync(DB_PATH)) {
saveDb()
}
return db
}
+58
View File
@@ -2,6 +2,8 @@
* Database migrations — add columns to existing tables
*/
import { randomUUID } from 'node:crypto'
export const MIGRATIONS = [
{
name: 'provider_accounts_api',
@@ -173,4 +175,60 @@ export const MIGRATIONS = [
}
},
},
{
name: 'server_projects',
run(db) {
db.run(
`CREATE TABLE IF NOT EXISTS server_projects (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
color TEXT,
sortOrder INTEGER DEFAULT 0,
notes TEXT,
createdAt TEXT
)`,
)
try {
db.run('ALTER TABLE vps ADD COLUMN projectId TEXT')
} catch (e) {
if (!String(e.message || e).includes('duplicate column')) throw e
}
const distinctStmt = db.prepare(
`SELECT DISTINCT trim(project) AS n FROM vps WHERE length(trim(COALESCE(project, ''))) > 0`,
)
const seenLower = new Set()
const findStmt = db.prepare(
'SELECT id FROM server_projects WHERE LOWER(name) = LOWER(?) LIMIT 1',
)
while (distinctStmt.step()) {
const row = distinctStmt.getAsObject()
const t = String(row.n ?? '').trim()
if (!t) continue
const lk = t.toLowerCase()
if (seenLower.has(lk)) continue
seenLower.add(lk)
findStmt.bind([t])
const exists = Boolean(findStmt.step())
findStmt.reset()
if (!exists) {
const id = `proj-${randomUUID()}`
const now = new Date().toISOString()
db.run(
`INSERT INTO server_projects (id, name, color, sortOrder, notes, createdAt) VALUES (?, ?, NULL, 0, NULL, ?)`,
[id, t, now],
)
}
}
distinctStmt.free()
findStmt.free()
db.run(`UPDATE vps SET projectId = (
SELECT sp.id FROM server_projects sp
WHERE LOWER(sp.name) = LOWER(trim(COALESCE(vps.project, '')))
LIMIT 1
) WHERE length(trim(COALESCE(vps.project, ''))) > 0`)
},
},
]
+12 -1
View File
@@ -28,6 +28,15 @@ CREATE TABLE IF NOT EXISTS provider_accounts (
FOREIGN KEY (providerId) REFERENCES providers(id)
);
CREATE TABLE IF NOT EXISTS server_projects (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
color TEXT,
sortOrder INTEGER DEFAULT 0,
notes TEXT,
createdAt TEXT
);
CREATE TABLE IF NOT EXISTS vps (
id TEXT PRIMARY KEY,
ip TEXT,
@@ -51,6 +60,7 @@ CREATE TABLE IF NOT EXISTS vps (
purpose TEXT,
environment TEXT,
project TEXT,
projectId TEXT,
monitoringEnabled INTEGER,
backupEnabled INTEGER,
status TEXT,
@@ -63,7 +73,8 @@ CREATE TABLE IF NOT EXISTS vps (
notes TEXT,
userOverrides TEXT,
FOREIGN KEY (providerId) REFERENCES providers(id),
FOREIGN KEY (providerAccountId) REFERENCES provider_accounts(id)
FOREIGN KEY (providerAccountId) REFERENCES provider_accounts(id),
FOREIGN KEY (projectId) REFERENCES server_projects(id)
);
CREATE TABLE IF NOT EXISTS payments (
+3 -1
View File
@@ -50,7 +50,7 @@ export function seed(db, seedDir) {
const dailyRate = r.dailyRate === '' || r.dailyRate == null ? null : Number(r.dailyRate)
const monthlyRate = r.monthlyRate === '' || r.monthlyRate == null ? null : Number(r.monthlyRate)
run(
`INSERT OR IGNORE INTO vps (id, ip, ipv6, additionalIps, dns, providerId, providerAccountId, country, city, datacenter, os, vcpu, ramGb, diskGb, diskType, virtualization, bandwidthTb, sshPort, rootUser, purpose, environment, project, monitoringEnabled, backupEnabled, status, tariffType, currency, dailyRate, monthlyRate, createdAt, paidUntil, notes) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
`INSERT OR IGNORE INTO vps (id, ip, ipv6, additionalIps, dns, providerId, providerAccountId, country, city, datacenter, os, vcpu, ramGb, diskGb, diskType, virtualization, bandwidthTb, sshPort, rootUser, purpose, environment, project, projectId, monitoringEnabled, backupEnabled, status, tariffType, currency, dailyRate, monthlyRate, createdAt, paidUntil, notes, userOverrides) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
r.id,
r.ip ?? '',
@@ -74,6 +74,7 @@ export function seed(db, seedDir) {
r.purpose ?? '',
r.environment ?? '',
r.project ?? '',
r.projectId ?? null,
r.monitoringEnabled ? 1 : 0,
r.backupEnabled ? 1 : 0,
r.status ?? 'active',
@@ -84,6 +85,7 @@ export function seed(db, seedDir) {
r.createdAt ?? '',
r.paidUntil ?? '',
r.notes ?? '',
'[]',
],
)
}
+2
View File
@@ -10,6 +10,7 @@ import paymentsRouter from './routes/payments.js'
import balanceLedgerRouter from './routes/balance-ledger.js'
import settingsRouter from './routes/settings.js'
import syncRouter from './routes/sync.js'
import projectsRouter from './routes/projects.js'
const app = express()
const PORT = process.env.PORT || 3001
@@ -29,6 +30,7 @@ app.use(express.json())
app.use('/api/balance-ledger', balanceLedgerRouter)
app.use('/api/settings', settingsRouter)
app.use('/api/sync', syncRouter)
app.use('/api/projects', projectsRouter)
const { startScheduler } = await import('./sync-scheduler.js')
startScheduler()
+73
View File
@@ -0,0 +1,73 @@
/**
* Справочник проектов (пулов): поиск без учёта регистра, автосоздание, подсказки.
*/
import { randomUUID } from 'node:crypto'
/**
* @param {unknown} name
* @returns {string}
*/
export function normalizeProjectNameInput(name) {
if (name == null) return ''
return String(name).trim()
}
/**
* @param {ReturnType<import('./db.js').getDb>} db
* @param {string} name — уже нормализованное имя (trim)
* @returns {{ id: string, name: string, color?: string, sortOrder?: number, notes?: string, createdAt?: string } | null}
*/
export function findProjectByNameCaseInsensitive(db, name) {
const n = normalizeProjectNameInput(name)
if (!n) return null
return db
.prepare(
`SELECT * FROM server_projects WHERE LOWER(name) = LOWER(?) LIMIT 1`,
)
.get(n)
}
/**
* Найти существующий проект или создать новую строку.
* @param {ReturnType<import('./db.js').getDb>} db
* @param {string} name
* @returns {{ id: string | null, name: string }}
*/
export function resolveOrCreateProject(db, name) {
const n = normalizeProjectNameInput(name)
if (!n) return { id: null, name: '' }
const existing = findProjectByNameCaseInsensitive(db, n)
if (existing) {
return { id: existing.id, name: existing.name }
}
const id = `proj-${randomUUID()}`
const now = new Date().toISOString()
db.prepare(
`INSERT INTO server_projects (id, name, color, sortOrder, notes, createdAt) VALUES (?, ?, ?, 0, ?, ?)`,
).run(id, n, null, null, now)
return { id, name: n }
}
/**
* @param {ReturnType<import('./db.js').getDb>} db
* @param {string} q
* @param {number} limit
* @returns {{ id: string, name: string }[]}
*/
export function projectSuggestions(db, q, limit = 20) {
const term = normalizeProjectNameInput(q)
const lim = Math.min(50, Math.max(1, Number(limit) || 20))
if (!term) {
return db
.prepare(`SELECT id, name FROM server_projects ORDER BY name LIMIT ?`)
.all(lim)
}
const esc = term.replace(/\\/g, '\\\\').replace(/%/g, '\\%').replace(/_/g, '\\_')
const pattern = `%${esc.toLowerCase()}%`
return db
.prepare(
`SELECT id, name FROM server_projects WHERE LOWER(name) LIKE ? ESCAPE '\\' ORDER BY name LIMIT ?`,
)
.all(pattern, lim)
}
+9
View File
@@ -17,9 +17,18 @@ router.get('/', (req, res) => {
const settingsRows = db.prepare('SELECT * FROM settings ORDER BY id').all()
const activeTariffs = db.prepare('SELECT * FROM active_tariffs ORDER BY name').all()
const tariffSyncOptions = db.prepare('SELECT * FROM tariff_sync_options').all()
let serverProjects = []
try {
serverProjects = db
.prepare('SELECT id, name, color, sortOrder, notes, createdAt FROM server_projects ORDER BY name')
.all()
} catch {
serverProjects = []
}
res.json({
vps: vps.map(rowToVps),
serverProjects,
providers,
providerAccounts: providerAccounts.map(sanitizeAccount),
payments,
+3 -3
View File
@@ -28,14 +28,14 @@ router.post('/', (req, res) => {
}
}
if (Array.isArray(data.vps) && data.vps.length > 0) {
const sql = `INSERT OR REPLACE INTO vps (id, ip, ipv6, additionalIps, dns, providerId, providerAccountId, country, city, datacenter, os, vcpu, ramGb, diskGb, diskType, virtualization, bandwidthTb, sshPort, rootUser, purpose, environment, project, monitoringEnabled, backupEnabled, status, tariffType, currency, dailyRate, monthlyRate, createdAt, paidUntil, notes, userOverrides) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
const sql = `INSERT OR REPLACE INTO vps (id, ip, ipv6, additionalIps, dns, providerId, providerAccountId, country, city, datacenter, os, vcpu, ramGb, diskGb, diskType, virtualization, bandwidthTb, sshPort, rootUser, purpose, environment, project, projectId, monitoringEnabled, backupEnabled, status, tariffType, currency, dailyRate, monthlyRate, createdAt, paidUntil, notes, userOverrides) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
for (const r of data.vps) {
const v = typeof r === 'object' ? r : {}
const additionalIps = Array.isArray(v.additionalIps) ? JSON.stringify(v.additionalIps) : '[]'
const dailyRate = v.dailyRate === '' || v.dailyRate == null ? null : Number(v.dailyRate)
const monthlyRate = v.monthlyRate === '' || v.monthlyRate == null ? null : Number(v.monthlyRate)
const userOverrides = Array.isArray(v.userOverrides) ? JSON.stringify(v.userOverrides) : (v.userOverrides ?? '')
db.run(sql, v.id ?? '', v.ip ?? '', v.ipv6 ?? '', additionalIps, v.dns ?? '', v.providerId ?? '', v.providerAccountId ?? '', v.country ?? '', v.city ?? '', v.datacenter ?? '', v.os ?? '', v.vcpu ?? 0, v.ramGb ?? 0, v.diskGb ?? 0, v.diskType ?? '', v.virtualization ?? '', v.bandwidthTb ?? 0, v.sshPort ?? 22, v.rootUser ?? '', v.purpose ?? '', v.environment ?? '', v.project ?? '', v.monitoringEnabled ? 1 : 0, v.backupEnabled ? 1 : 0, v.status ?? 'active', v.tariffType ?? '', v.currency ?? '', dailyRate, monthlyRate, v.createdAt ?? '', v.paidUntil ?? '', v.notes ?? '', userOverrides)
const userOverrides = Array.isArray(v.userOverrides) ? JSON.stringify(v.userOverrides) : (v.userOverrides ?? '[]')
db.run(sql, v.id ?? '', v.ip ?? '', v.ipv6 ?? '', additionalIps, v.dns ?? '', v.providerId ?? '', v.providerAccountId ?? '', v.country ?? '', v.city ?? '', v.datacenter ?? '', v.os ?? '', v.vcpu ?? 0, v.ramGb ?? 0, v.diskGb ?? 0, v.diskType ?? '', v.virtualization ?? '', v.bandwidthTb ?? 0, v.sshPort ?? 22, v.rootUser ?? '', v.purpose ?? '', v.environment ?? '', v.project ?? '', v.projectId ?? null, v.monitoringEnabled ? 1 : 0, v.backupEnabled ? 1 : 0, v.status ?? 'active', v.tariffType ?? '', v.currency ?? '', dailyRate, monthlyRate, v.createdAt ?? '', v.paidUntil ?? '', v.notes ?? '', userOverrides)
}
}
if (Array.isArray(data.payments) && data.payments.length > 0) {
+60
View File
@@ -0,0 +1,60 @@
import { Router } from 'express'
import { getDb } from '../db.js'
import {
normalizeProjectNameInput,
projectSuggestions,
resolveOrCreateProject,
} from '../projects-service.js'
const router = Router()
router.get('/', (req, res) => {
try {
const db = getDb()
const rows = db
.prepare('SELECT id, name, color, sortOrder, notes, createdAt FROM server_projects ORDER BY name')
.all()
res.json(rows)
} catch (err) {
res.status(500).json({ error: err.message })
}
})
router.get('/suggest', (req, res) => {
try {
const db = getDb()
const q = req.query.q ?? ''
const limit = req.query.limit != null ? Number(req.query.limit) : 20
const rows = projectSuggestions(db, q, limit)
res.json(rows)
} catch (err) {
res.status(500).json({ error: err.message })
}
})
router.post('/resolve-or-create', (req, res) => {
try {
const db = getDb()
const name = req.body?.name
const resolved = resolveOrCreateProject(db, name)
res.json(resolved)
} catch (err) {
res.status(500).json({ error: err.message })
}
})
router.post('/', (req, res) => {
try {
const db = getDb()
const name = normalizeProjectNameInput(req.body?.name)
if (!name) {
return res.status(400).json({ error: 'name is required' })
}
const resolved = resolveOrCreateProject(db, name)
res.status(201).json(resolved)
} catch (err) {
res.status(500).json({ error: err.message })
}
})
export default router
+86 -12
View File
@@ -1,8 +1,17 @@
import { Router } from 'express'
import { getDb } from '../db.js'
import { resolveOrCreateProject } from '../projects-service.js'
const router = Router()
function projectColumnsForSave(db, projectInput) {
const resolved = resolveOrCreateProject(db, projectInput)
if (!resolved.id) {
return { project: '', projectId: '' }
}
return { project: resolved.name, projectId: resolved.id }
}
export function rowToVps(row) {
if (!row) return null
let additionalIps = []
@@ -21,6 +30,7 @@ export function rowToVps(row) {
...row,
additionalIps,
userOverrides,
projectId: row.projectId ?? '',
monitoringEnabled: Boolean(row.monitoringEnabled),
backupEnabled: Boolean(row.backupEnabled),
dailyRate: row.dailyRate != null ? row.dailyRate : '',
@@ -46,14 +56,15 @@ router.post('/', (req, res) => {
const additionalIps = Array.isArray(r.additionalIps) ? JSON.stringify(r.additionalIps) : '[]'
const dailyRate = r.dailyRate === '' || r.dailyRate == null ? null : Number(r.dailyRate)
const monthlyRate = r.monthlyRate === '' || r.monthlyRate == null ? null : Number(r.monthlyRate)
const { project, projectId } = projectColumnsForSave(db, r.project)
db.prepare(`
INSERT INTO vps (
id, ip, ipv6, additionalIps, dns, providerId, providerAccountId, country, city, datacenter,
os, vcpu, ramGb, diskGb, diskType, virtualization, bandwidthTb, sshPort, rootUser,
purpose, environment, project, monitoringEnabled, backupEnabled, status, tariffType,
purpose, environment, project, projectId, monitoringEnabled, backupEnabled, status, tariffType,
currency, dailyRate, monthlyRate, createdAt, paidUntil, notes, userOverrides
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
id,
r.ip ?? '',
@@ -76,7 +87,8 @@ router.post('/', (req, res) => {
r.rootUser ?? '',
r.purpose ?? '',
r.environment ?? '',
r.project ?? '',
project,
projectId || null,
r.monitoringEnabled ? 1 : 0,
r.backupEnabled ? 1 : 0,
r.status ?? 'active',
@@ -112,10 +124,44 @@ router.put('/:id', (req, res) => {
} catch {
userOverrides = []
}
if (r.userOverrides === 'clear' || (Array.isArray(r.userOverrides) && r.userOverrides.length === 0)) {
const clearOverrides =
r.userOverrides === 'clear' || (Array.isArray(r.userOverrides) && r.userOverrides.length === 0)
if (clearOverrides) {
userOverrides = []
} else {
}
const additionalIps = Array.isArray(r.additionalIps) ? JSON.stringify(r.additionalIps) : '[]'
const dailyRate = r.dailyRate === '' || r.dailyRate == null ? null : Number(r.dailyRate)
const monthlyRate = r.monthlyRate === '' || r.monthlyRate == null ? null : Number(r.monthlyRate)
let projectOut = existing.project ?? ''
let projectIdOut = existing.projectId ?? ''
if (r.project !== undefined) {
const resolved = projectColumnsForSave(db, r.project)
projectOut = resolved.project
projectIdOut = resolved.projectId
} else if (r.projectId !== undefined) {
if (!r.projectId) {
projectOut = ''
projectIdOut = ''
} else {
const prow = db.prepare('SELECT name FROM server_projects WHERE id = ?').get(r.projectId)
projectOut = prow?.name ?? ''
projectIdOut = r.projectId
}
}
if (!clearOverrides) {
for (const f of USER_OVERRIDABLE_FIELDS) {
if (f === 'project') {
const projectChanged =
String(projectOut ?? '') !== String(existing.project ?? '') ||
String(projectIdOut ?? '') !== String(existing.projectId ?? '')
if (projectChanged && !userOverrides.includes('project')) {
userOverrides.push('project')
}
continue
}
const newVal = r[f]
const oldVal = existing[f]
const changed = String(newVal ?? '') !== String(oldVal ?? '')
@@ -126,16 +172,12 @@ router.put('/:id', (req, res) => {
}
const userOverridesJson = JSON.stringify([...new Set(userOverrides)])
const additionalIps = Array.isArray(r.additionalIps) ? JSON.stringify(r.additionalIps) : '[]'
const dailyRate = r.dailyRate === '' || r.dailyRate == null ? null : Number(r.dailyRate)
const monthlyRate = r.monthlyRate === '' || r.monthlyRate == null ? null : Number(r.monthlyRate)
db.prepare(`
UPDATE vps SET
ip = ?, ipv6 = ?, additionalIps = ?, dns = ?, providerId = ?, providerAccountId = ?,
country = ?, city = ?, datacenter = ?, os = ?, vcpu = ?, ramGb = ?, diskGb = ?, diskType = ?,
virtualization = ?, bandwidthTb = ?, sshPort = ?, rootUser = ?, purpose = ?, environment = ?,
project = ?, monitoringEnabled = ?, backupEnabled = ?, status = ?, tariffType = ?,
project = ?, projectId = ?, monitoringEnabled = ?, backupEnabled = ?, status = ?, tariffType = ?,
currency = ?, dailyRate = ?, monthlyRate = ?, createdAt = ?, paidUntil = ?, notes = ?,
userOverrides = ?
WHERE id = ?
@@ -160,7 +202,8 @@ router.put('/:id', (req, res) => {
r.rootUser ?? '',
r.purpose ?? '',
r.environment ?? '',
r.project ?? '',
projectOut,
projectIdOut || null,
r.monitoringEnabled ? 1 : 0,
r.backupEnabled ? 1 : 0,
r.status ?? 'active',
@@ -221,7 +264,38 @@ router.patch('/bulk', (req, res) => {
}
return res.json({ deleted })
}
return res.status(400).json({ error: 'action must be status or delete' })
if (action === 'project') {
const projectValue = value == null ? '' : String(value)
const { project: projName, projectId: projId } = projectColumnsForSave(db, projectValue)
const getStmt = db.prepare('SELECT * FROM vps WHERE id = ?')
const updStmt = db.prepare(
'UPDATE vps SET project = ?, projectId = ?, userOverrides = ? WHERE id = ?',
)
let updated = 0
for (const id of ids) {
const existing = getStmt.get(id)
if (!existing) continue
if (
String(existing.project ?? '') === projName &&
String(existing.projectId ?? '') === String(projId ?? '')
) {
continue
}
let userOverrides = []
try {
userOverrides = existing.userOverrides ? JSON.parse(existing.userOverrides) : []
} catch {
userOverrides = []
}
if (!userOverrides.includes('project')) {
userOverrides.push('project')
}
updStmt.run(projName, projId || null, JSON.stringify([...new Set(userOverrides)]), id)
updated++
}
return res.json({ updated, project: projName, projectId: projId })
}
return res.status(400).json({ error: 'action must be status, delete, or project' })
} catch (err) {
res.status(500).json({ error: err.message })
}
+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>