diff --git a/server/adapters/billmanager/sync.js b/server/adapters/billmanager/sync.js index a858786..d7b6441 100644 --- a/server/adapters/billmanager/sync.js +++ b/server/adapters/billmanager/sync.js @@ -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++ diff --git a/server/db/index.js b/server/db/index.js index 4f0753f..13e08a9 100644 --- a/server/db/index.js +++ b/server/db/index.js @@ -51,6 +51,9 @@ export async function initDb() { } dbInstance = db + if (existsSync(DB_PATH)) { + saveDb() + } return db } diff --git a/server/db/migrations.js b/server/db/migrations.js index b2573eb..0d3f0ef 100644 --- a/server/db/migrations.js +++ b/server/db/migrations.js @@ -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`) + }, + }, ] diff --git a/server/db/schema.js b/server/db/schema.js index eca155b..3aa19d5 100644 --- a/server/db/schema.js +++ b/server/db/schema.js @@ -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 ( diff --git a/server/db/seed.js b/server/db/seed.js index 29b1fef..70764d5 100644 --- a/server/db/seed.js +++ b/server/db/seed.js @@ -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 ?? '', + '[]', ], ) } diff --git a/server/index.js b/server/index.js index a469171..e287172 100644 --- a/server/index.js +++ b/server/index.js @@ -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() diff --git a/server/projects-service.js b/server/projects-service.js new file mode 100644 index 0000000..18a8e40 --- /dev/null +++ b/server/projects-service.js @@ -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} 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} 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} 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) +} diff --git a/server/routes/data.js b/server/routes/data.js index 9a606aa..c6e113b 100644 --- a/server/routes/data.js +++ b/server/routes/data.js @@ -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, diff --git a/server/routes/migrate.js b/server/routes/migrate.js index 805ba1f..9012c33 100644 --- a/server/routes/migrate.js +++ b/server/routes/migrate.js @@ -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) { diff --git a/server/routes/projects.js b/server/routes/projects.js new file mode 100644 index 0000000..832437b --- /dev/null +++ b/server/routes/projects.js @@ -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 diff --git a/server/routes/vps.js b/server/routes/vps.js index aeab3ec..aead416 100644 --- a/server/routes/vps.js +++ b/server/routes/vps.js @@ -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 }) } diff --git a/src/App.jsx b/src/App.jsx index a214b9a..b8a5005 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -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={} /> + } + /> { + 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 ( + <> + onChange(e.target.value)} + placeholder={placeholder} + disabled={disabled} + autoComplete="off" + spellCheck={false} + aria-label={ariaLabel} + /> + + {merged.map((name) => ( + + + ) +} diff --git a/src/lib/api.js b/src/lib/api.js index 0b0024a..f523684 100644 --- a/src/lib/api.js +++ b/src/lib/api.js @@ -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}`) diff --git a/src/pages/DashboardPage.jsx b/src/pages/DashboardPage.jsx index 7458861..77a6f9b 100644 --- a/src/pages/DashboardPage.jsx +++ b/src/pages/DashboardPage.jsx @@ -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 }) { +
+
+
+

Прогноз по проектам (активные VPS)

+
+
+ + + + + + + + + + {forecastByProject.map((row) => ( + + + + + + ))} + {forecastByProject.length === 0 ? ( + + ) : null} + +
Проект / пулVPSПрогноз / мес
{row.label}{row.count}{formatCurrency(row.forecast, baseCurrency)}
+
+
+
+ +
+
+
+

Прогноз по аккаунтам (активные VPS)

+
+
+ + + + + + + + + + {forecastByAccount.map((row) => ( + + + + + + ))} + {forecastByAccount.length === 0 ? ( + + ) : null} + +
АккаунтVPSПрогноз / мес
{row.account.name}{row.count}{formatCurrency(row.forecast, baseCurrency)}
+
+
+
+
diff --git a/src/pages/ReportsPage.jsx b/src/pages/ReportsPage.jsx index 8feeed8..a84df0a 100644 --- a/src/pages/ReportsPage.jsx +++ b/src/pages/ReportsPage.jsx @@ -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 }) {
+
+ + +
+
+ + +
Хостер + Аккаунт + Проект VPS Локация Статус @@ -248,6 +320,8 @@ export function ReportsPage({ db, settings, ratesData }) { {rows.map((row) => ( {row.provider} + {row.account} + {row.project}
{row.vps}
{row.ip}
@@ -268,7 +342,7 @@ export function ReportsPage({ db, settings, ratesData }) { ))} {rows.length === 0 ? ( - + ) : null} diff --git a/src/pages/ResourcesPage.jsx b/src/pages/ResourcesPage.jsx new file mode 100644 index 0000000..4dbbd8a --- /dev/null +++ b/src/pages/ResourcesPage.jsx @@ -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 ( + <> + +
+
+
+
+

Группировка и фильтры

+
+ +
+
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + setFilters((prev) => ({ ...prev, country: e.target.value }))} + placeholder="Часть названия" + /> +
+
+ + + setFilters((prev) => ({ ...prev, datacenter: e.target.value })) + } + placeholder="Часть названия ДЦ" + /> +
+ {customFields.map((f) => ( +
+ + + setFilters((prev) => ({ ...prev, [f.key]: e.target.value })) + } + placeholder={f.label} + /> +
+ ))} +
+
+ Учтено VPS: {vpsFiltered.length} из {(db.vps || []).length}. Прогноз / мес — только + активные (в базовой валюте). +
+
+
+
+ +
+
+
+

Сводка по группам

+
+
+ + + + + + + + + + + + + + + {groups.map((g) => ( + + + + + + + + + + + ))} + {groups.length > 0 ? ( + + + + + + + + + + ) : null} + {groups.length === 0 ? ( + + ) : null} + +
ГруппаVPSАктивныхvCPURAM, GBДиск, GBПрогноз / месДоля прогноза
{g.label}{g.count}{g.activeCount}{g.vcpu}{Number(g.ramGb.toFixed(1))}{g.diskGb}{formatCurrency(g.forecast, baseCurrency)} +
0 ? Math.round((g.forecast / maxForecast) * 100) : 0}% от макс. группы`} + > +
0 ? Math.min(100, (g.forecast / maxForecast) * 100) : 0}%`, + }} + /> +
+
Итого{totals.count}{totals.activeCount}{totals.vcpu}{Number(totals.ramGb.toFixed(1))}{totals.diskGb}{formatCurrency(totals.forecast, baseCurrency)} +
+
+
+
+
+ + ) +} diff --git a/src/pages/VpsPage.jsx b/src/pages/VpsPage.jsx index 6090508..a3a3b16 100644 --- a/src/pages/VpsPage.jsx +++ b/src/pages/VpsPage.jsx @@ -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 }) { />
+
+ +
{showAdvancedFilters ? ( <>
@@ -673,43 +837,137 @@ export function VpsPage({ db, actions, settings, ratesData }) { ) : null}
+
+
+ +
+
+ +
+
+ +
+
+ + +
+
-

VPS список

+

+ VPS список + + Показано {filteredVps.length} из {db.vps.length} + +

{selectedIds.size > 0 ? ( -
+
Выбрано: {selectedIds.size} -
+
+
+ + + +
+
+ +
- -
- +
- {filteredVps.map((item) => { + {tableSections.map((section) => ( + + {filters.groupByProject && section.label ? ( + + + + ) : 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 }) { ) - })} + })} + + ))} {filteredVps.length === 0 ? ( ) : null} @@ -1280,13 +1557,18 @@ export function VpsPage({ db, actions, settings, ratesData }) {
- - Проект / пул + setForm((prev) => ({ ...prev, project: e.target.value }))} - placeholder="my-project" + onChange={(v) => setForm((prev) => ({ ...prev, project: v }))} + placeholder="Умный дом, прокси…" + aria-label="Проект или пул" /> +
+ Подсказки — уже созданные группы; новое имя создастся при сохранении. +
@@ -829,7 +1089,22 @@ export function VpsPage({ db, actions, settings, ratesData }) {
+
+ {section.label} + {section.count} VPS + + Прогноз / мес: {formatCurrency(section.forecast, baseCurrency)} + +
+