Frontend:
- apps/web (Vite+TS, TanStack Router/Query, shadcn/ui @cfdm/ui base-nova)
- 10 страниц в routes/_auth/, Recharts через shadcn Chart, lucide-react
- формы на RHF + Zod (FormSheet/FormField)
- удалены Tabler, Chart.js, react-router-dom
Backend (параллельный трек):
- apps/api (Fastify 5 + Drizzle + better-sqlite3)
- packages/db: Drizzle-схема и repositories по сущностям
- packages/shared: Zod-контракты
- роуты с валидацией и единым форматом ошибок { error: { code, message } }
- sync/backup — заглушки 501 (billmanager-адаптеры переносятся отдельно)
- legacy Express оставлен как runtime по умолчанию (RUNTIME=express)
Infra:
- Dockerfile multi-stage под pnpm workspaces
- .dockerignore и docker-compose обновлены под monorepo
Rules:
- удалены нерелевантные правила (rust, cloudflare, server/frontend-conventions)
- project-structure.mdc и AGENTS.md переписаны под monorepo
- frontend-shadcn.mdc, shadcn-ui-production.mdc, sqlite.mdc обновлены
Co-authored-by: Cursor <cursoragent@cursor.com>
305 lines
10 KiB
JavaScript
305 lines
10 KiB
JavaScript
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 = []
|
|
try {
|
|
additionalIps = row.additionalIps ? JSON.parse(row.additionalIps) : []
|
|
} catch {
|
|
additionalIps = []
|
|
}
|
|
let userOverrides = []
|
|
try {
|
|
userOverrides = row.userOverrides ? JSON.parse(row.userOverrides) : []
|
|
} catch {
|
|
userOverrides = []
|
|
}
|
|
return {
|
|
...row,
|
|
additionalIps,
|
|
userOverrides,
|
|
projectId: row.projectId ?? '',
|
|
monitoringEnabled: Boolean(row.monitoringEnabled),
|
|
backupEnabled: Boolean(row.backupEnabled),
|
|
dailyRate: row.dailyRate != null ? row.dailyRate : '',
|
|
monthlyRate: row.monthlyRate != null ? row.monthlyRate : '',
|
|
}
|
|
}
|
|
|
|
router.get('/', (req, res) => {
|
|
try {
|
|
const db = getDb()
|
|
const rows = db.prepare('SELECT * FROM vps ORDER BY createdAt DESC').all()
|
|
res.json(rows.map(rowToVps))
|
|
} catch (err) {
|
|
res.status(500).json({ error: err.message })
|
|
}
|
|
})
|
|
|
|
router.post('/', (req, res) => {
|
|
try {
|
|
const db = getDb()
|
|
const r = req.body
|
|
const id = r.id || `vps-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`
|
|
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, projectId, monitoringEnabled, backupEnabled, status, tariffType,
|
|
currency, dailyRate, monthlyRate, createdAt, paidUntil, notes, userOverrides
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
`).run(
|
|
id,
|
|
r.ip ?? '',
|
|
r.ipv6 ?? '',
|
|
additionalIps,
|
|
r.dns ?? '',
|
|
r.providerId ?? '',
|
|
r.providerAccountId ?? '',
|
|
r.country ?? '',
|
|
r.city ?? '',
|
|
r.datacenter ?? '',
|
|
r.os ?? '',
|
|
r.vcpu ?? 0,
|
|
r.ramGb ?? 0,
|
|
r.diskGb ?? 0,
|
|
r.diskType ?? '',
|
|
r.virtualization ?? '',
|
|
r.bandwidthTb ?? 0,
|
|
r.sshPort ?? 22,
|
|
r.rootUser ?? '',
|
|
r.purpose ?? '',
|
|
r.environment ?? '',
|
|
project,
|
|
projectId || null,
|
|
r.monitoringEnabled ? 1 : 0,
|
|
r.backupEnabled ? 1 : 0,
|
|
r.status ?? 'active',
|
|
r.tariffType ?? '',
|
|
r.currency ?? '',
|
|
dailyRate,
|
|
monthlyRate,
|
|
r.createdAt ?? new Date().toISOString().slice(0, 10),
|
|
r.paidUntil ?? '',
|
|
r.notes ?? '',
|
|
r.userOverrides ? (Array.isArray(r.userOverrides) ? JSON.stringify(r.userOverrides) : r.userOverrides) : '[]',
|
|
)
|
|
const row = db.prepare('SELECT * FROM vps WHERE id = ?').get(id)
|
|
res.status(201).json(rowToVps(row))
|
|
} catch (err) {
|
|
res.status(500).json({ error: err.message })
|
|
}
|
|
})
|
|
|
|
const USER_OVERRIDABLE_FIELDS = ['country', 'city', 'datacenter', 'os', 'vcpu', 'ramGb', 'diskGb', 'diskType', 'virtualization', 'purpose', 'environment', 'project', 'notes', 'sshPort', 'rootUser', 'bandwidthTb', 'monitoringEnabled', 'backupEnabled']
|
|
|
|
router.put('/:id', (req, res) => {
|
|
try {
|
|
const db = getDb()
|
|
const { id } = req.params
|
|
const r = req.body
|
|
const existing = db.prepare('SELECT * FROM vps WHERE id = ?').get(id)
|
|
if (!existing) return res.status(404).json({ error: 'Not found' })
|
|
|
|
let userOverrides = []
|
|
try {
|
|
userOverrides = existing.userOverrides ? JSON.parse(existing.userOverrides) : []
|
|
} catch {
|
|
userOverrides = []
|
|
}
|
|
const clearOverrides =
|
|
r.userOverrides === 'clear' || (Array.isArray(r.userOverrides) && r.userOverrides.length === 0)
|
|
if (clearOverrides) {
|
|
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)
|
|
|
|
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 ?? '')
|
|
if (changed && !userOverrides.includes(f)) {
|
|
userOverrides.push(f)
|
|
}
|
|
}
|
|
}
|
|
const userOverridesJson = JSON.stringify([...new Set(userOverrides)])
|
|
|
|
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 = ?, projectId = ?, monitoringEnabled = ?, backupEnabled = ?, status = ?, tariffType = ?,
|
|
currency = ?, dailyRate = ?, monthlyRate = ?, createdAt = ?, paidUntil = ?, notes = ?,
|
|
userOverrides = ?
|
|
WHERE id = ?
|
|
`).run(
|
|
r.ip ?? '',
|
|
r.ipv6 ?? '',
|
|
additionalIps,
|
|
r.dns ?? '',
|
|
r.providerId ?? '',
|
|
r.providerAccountId ?? '',
|
|
r.country ?? '',
|
|
r.city ?? '',
|
|
r.datacenter ?? '',
|
|
r.os ?? '',
|
|
r.vcpu ?? 0,
|
|
r.ramGb ?? 0,
|
|
r.diskGb ?? 0,
|
|
r.diskType ?? '',
|
|
r.virtualization ?? '',
|
|
r.bandwidthTb ?? 0,
|
|
r.sshPort ?? 22,
|
|
r.rootUser ?? '',
|
|
r.purpose ?? '',
|
|
r.environment ?? '',
|
|
projectOut,
|
|
projectIdOut || null,
|
|
r.monitoringEnabled ? 1 : 0,
|
|
r.backupEnabled ? 1 : 0,
|
|
r.status ?? 'active',
|
|
r.tariffType ?? '',
|
|
r.currency ?? '',
|
|
dailyRate,
|
|
monthlyRate,
|
|
r.createdAt ?? '',
|
|
r.paidUntil ?? '',
|
|
r.notes ?? '',
|
|
userOverridesJson,
|
|
id,
|
|
)
|
|
const row = db.prepare('SELECT * FROM vps WHERE id = ?').get(id)
|
|
if (!row) return res.status(404).json({ error: 'Not found' })
|
|
res.json(rowToVps(row))
|
|
} catch (err) {
|
|
res.status(500).json({ error: err.message })
|
|
}
|
|
})
|
|
|
|
router.delete('/:id', (req, res) => {
|
|
try {
|
|
const db = getDb()
|
|
const { id } = req.params
|
|
const result = db.prepare('DELETE FROM vps WHERE id = ?').run(id)
|
|
if (result.changes === 0) return res.status(404).json({ error: 'Not found' })
|
|
res.status(204).send()
|
|
} catch (err) {
|
|
res.status(500).json({ error: err.message })
|
|
}
|
|
})
|
|
|
|
router.patch('/bulk', (req, res) => {
|
|
try {
|
|
const db = getDb()
|
|
const { ids = [], action, value } = req.body
|
|
if (!Array.isArray(ids) || ids.length === 0) {
|
|
return res.status(400).json({ error: 'ids must be a non-empty array' })
|
|
}
|
|
if (action === 'status' && value) {
|
|
const validStatus = ['active', 'paused', 'archived']
|
|
if (!validStatus.includes(value)) {
|
|
return res.status(400).json({ error: 'value must be active, paused, or archived' })
|
|
}
|
|
const stmt = db.prepare('UPDATE vps SET status = ? WHERE id = ?')
|
|
for (const id of ids) {
|
|
stmt.run(value, id)
|
|
}
|
|
return res.json({ updated: ids.length, status: value })
|
|
}
|
|
if (action === 'delete') {
|
|
const stmt = db.prepare('DELETE FROM vps WHERE id = ?')
|
|
let deleted = 0
|
|
for (const id of ids) {
|
|
const result = stmt.run(id)
|
|
if (result.changes > 0) deleted++
|
|
}
|
|
return res.json({ deleted })
|
|
}
|
|
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 })
|
|
}
|
|
})
|
|
|
|
export default router
|