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:
@@ -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++
|
||||
|
||||
@@ -51,6 +51,9 @@ export async function initDb() {
|
||||
}
|
||||
|
||||
dbInstance = db
|
||||
if (existsSync(DB_PATH)) {
|
||||
saveDb()
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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 ?? '',
|
||||
'[]',
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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