diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..b8c0baf --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,59 @@ +# Copilot Instructions for Ospabhost 8.1 + +## Архитектура проекта +- **Монорепозиторий**: две основные части — `backend` (Express, TypeScript, Prisma) и `frontend` (React, Vite, TypeScript). +- **Backend**: + - Основной сервер: `backend/src/index.ts` — точка входа, маршрутизация, CORS, логирование. + - Модули: `backend/src/modules/*` — бизнес-логика по доменам (auth, ticket, check, os, server, tariff). + - Интеграция с Proxmox: через API, см. `backend/src/modules/server/proxmoxApi.ts`. + - ORM: Prisma, схема — `backend/prisma/schema.prisma`. + - Статические файлы чеков: `backend/uploads/checks`. +- **Frontend**: + - SPA на React + Vite, точка входа: `frontend/src/main.tsx`. + - Страницы: `frontend/src/pages/*`, компоненты: `frontend/src/components/*`. + - Контекст авторизации: `frontend/src/context/authcontext.tsx`, `useAuth.ts`. + +## Важные паттерны и конвенции +- **Маршруты API**: начинаются с `/api/`, см. `backend/src/index.ts`. +- **Модули backend**: каждый домен — отдельная папка, экспортирует маршруты и сервисы. +- **Работа с Proxmox**: все операции (создание контейнера, управление, статистика) через функции из `proxmoxApi.ts`. +- **Статусные поля**: для сущностей (Server, Check, Ticket) используются строковые статусы (`creating`, `running`, `pending`, `open` и т.д.). +- **Пароли**: генерируются через `generateSecurePassword` (см. `proxmoxApi.ts`). +- **Описание тарифа**: парсится для выделения ресурсов (ядра, RAM, SSD) при создании контейнера. + +## Сборка и запуск +- **Backend**: + - `npm run dev` — запуск с hot-reload (ts-node-dev). + - `npm run build` — компиляция TypeScript. + - `npm start` — запуск собранного кода. +- **Frontend**: + - `npm run dev` — запуск Vite dev server. + - `npm run build` — сборка. + - `npm run preview` — предпросмотр production-сборки. + - `npm run lint` — проверка ESLint. + +## Взаимодействие компонентов +- **Frontend ↔ Backend**: через REST API, адреса `/api/*`. +- **Backend ↔ Proxmox**: через HTTP API, параметры берутся из `.env`. +- **Prisma**: миграции и seed-скрипты — в `backend/prisma/`. + +## Внешние зависимости +- **Backend**: express, prisma, axios, bcrypt, jsonwebtoken, multer, dotenv. +- **Frontend**: react, react-dom, react-router-dom, tailwindcss, axios. + +## Примеры ключевых файлов +- `backend/src/index.ts` — точка входа, маршрутизация. +- `backend/src/modules/server/proxmoxApi.ts` — интеграция с Proxmox. +- `backend/prisma/schema.prisma` — схема данных. +- `frontend/src/pages/*` — страницы SPA. +- `frontend/src/context/authcontext.tsx` — авторизация. + +## Особенности +- **CORS**: разрешены только локальные адреса для разработки. +- **Логирование**: каждый запрос логируется с датой и методом. +- **Статические файлы**: чеки доступны по `/uploads/checks`. +- **Пароли root**: генерируются и меняются через API Proxmox. + +--- + +_Обновите этот файл при изменении архитектуры или ключевых паттернов. Для уточнения разделов — дайте обратную связь!_ \ No newline at end of file diff --git a/ospabhost/backend/prisma/migrations/20250920154605_add_proxmox_fields/migration.sql b/ospabhost/backend/prisma/migrations/20250920154605_add_proxmox_fields/migration.sql new file mode 100644 index 0000000..ecdcb2d --- /dev/null +++ b/ospabhost/backend/prisma/migrations/20250920154605_add_proxmox_fields/migration.sql @@ -0,0 +1,12 @@ +-- AlterTable +ALTER TABLE `server` ADD COLUMN `cpuUsage` DOUBLE NULL DEFAULT 0, + ADD COLUMN `diskUsage` DOUBLE NULL DEFAULT 0, + ADD COLUMN `ipAddress` VARCHAR(191) NULL, + ADD COLUMN `lastPing` DATETIME(3) NULL, + ADD COLUMN `macAddress` VARCHAR(191) NULL, + ADD COLUMN `memoryUsage` DOUBLE NULL DEFAULT 0, + ADD COLUMN `networkIn` DOUBLE NULL DEFAULT 0, + ADD COLUMN `networkOut` DOUBLE NULL DEFAULT 0, + ADD COLUMN `rootPassword` VARCHAR(191) NULL, + ADD COLUMN `sshPublicKey` VARCHAR(191) NULL, + MODIFY `status` VARCHAR(191) NOT NULL DEFAULT 'creating'; diff --git a/ospabhost/backend/prisma/migrations/20250920175043_add_notification_relation/migration.sql b/ospabhost/backend/prisma/migrations/20250920175043_add_notification_relation/migration.sql new file mode 100644 index 0000000..e98d14c --- /dev/null +++ b/ospabhost/backend/prisma/migrations/20250920175043_add_notification_relation/migration.sql @@ -0,0 +1,13 @@ +-- CreateTable +CREATE TABLE `Notification` ( + `id` INTEGER NOT NULL AUTO_INCREMENT, + `userId` INTEGER NOT NULL, + `title` VARCHAR(191) NOT NULL, + `message` VARCHAR(191) NOT NULL, + `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- AddForeignKey +ALTER TABLE `Notification` ADD CONSTRAINT `Notification_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/ospabhost/backend/prisma/schema.prisma b/ospabhost/backend/prisma/schema.prisma index 442a2f6..2346b28 100644 --- a/ospabhost/backend/prisma/schema.prisma +++ b/ospabhost/backend/prisma/schema.prisma @@ -33,15 +33,33 @@ model Server { userId Int tariffId Int osId Int - status String @default("stopped") + status String @default("creating") // creating, running, stopped, suspended, error createdAt DateTime @default(now()) updatedAt DateTime @updatedAt user User @relation(fields: [userId], references: [id]) tariff Tariff @relation(fields: [tariffId], references: [id]) os OperatingSystem @relation(fields: [osId], references: [id]) + + // Proxmox данные node String? diskTemplate String? - proxmoxId Int? + proxmoxId Int? + + // Сетевые настройки + ipAddress String? // Локальный IP адрес + macAddress String? // MAC адрес + + // Доступы + rootPassword String? // Зашифрованный root пароль + sshPublicKey String? // SSH публичный ключ (опционально) + + // Мониторинг + lastPing DateTime? + cpuUsage Float? @default(0) + memoryUsage Float? @default(0) + diskUsage Float? @default(0) + networkIn Float? @default(0) + networkOut Float? @default(0) } model User { @@ -57,6 +75,7 @@ model User { checks Check[] @relation("UserChecks") balance Float @default(0) servers Server[] + notifications Notification[] } model Check { @@ -109,4 +128,12 @@ model Response { createdAt DateTime @default(now()) ticket Ticket @relation("TicketResponses", fields: [ticketId], references: [id]) operator User @relation("OperatorResponses", fields: [operatorId], references: [id]) +} +model Notification { + id Int @id @default(autoincrement()) + user User @relation(fields: [userId], references: [id]) + userId Int + title String + message String + createdAt DateTime @default(now()) } \ No newline at end of file diff --git a/ospabhost/backend/src/modules/notification/notification.model.ts b/ospabhost/backend/src/modules/notification/notification.model.ts new file mode 100644 index 0000000..385ff0c --- /dev/null +++ b/ospabhost/backend/src/modules/notification/notification.model.ts @@ -0,0 +1,9 @@ +import { Prisma } from '@prisma/client'; + +export type Notification = { + id: number; + userId: number; + title: string; + message: string; + createdAt: Date; +}; diff --git a/ospabhost/backend/src/modules/notification/notification.routes.ts b/ospabhost/backend/src/modules/notification/notification.routes.ts new file mode 100644 index 0000000..6868bc0 --- /dev/null +++ b/ospabhost/backend/src/modules/notification/notification.routes.ts @@ -0,0 +1,9 @@ +import { Router } from 'express'; +import { getNotifications } from './notification.service'; +import { authMiddleware } from '../auth/auth.middleware'; + +const router = Router(); + +router.get('/', authMiddleware, getNotifications); + +export default router; diff --git a/ospabhost/backend/src/modules/notification/notification.service.ts b/ospabhost/backend/src/modules/notification/notification.service.ts new file mode 100644 index 0000000..55b1c97 --- /dev/null +++ b/ospabhost/backend/src/modules/notification/notification.service.ts @@ -0,0 +1,28 @@ + +import { Request, Response } from 'express'; +import { prisma } from '../../prisma/client'; + +export const getNotifications = async (req: Request, res: Response) => { + try { + // @ts-ignore + const userId = req.user?.id; + if (!userId) return res.status(401).json({ error: 'Нет авторизации' }); + const notifications = await prisma.notification.findMany({ + where: { userId }, + orderBy: { createdAt: 'desc' }, + }); + res.json({ notifications }); + } catch (err) { + res.status(500).json({ error: 'Ошибка получения уведомлений' }); + } +}; + +export const createNotification = async (userId: number, title: string, message: string) => { + return prisma.notification.create({ + data: { + userId, + title, + message, + }, + }); +}; diff --git a/ospabhost/backend/src/modules/server/proxmoxApi.ts b/ospabhost/backend/src/modules/server/proxmoxApi.ts index 5c0539d..aa3aacf 100644 --- a/ospabhost/backend/src/modules/server/proxmoxApi.ts +++ b/ospabhost/backend/src/modules/server/proxmoxApi.ts @@ -1,60 +1,369 @@ import axios from 'axios'; +import crypto from 'crypto'; import dotenv from 'dotenv'; dotenv.config(); const PROXMOX_API_URL = process.env.PROXMOX_API_URL; const PROXMOX_TOKEN_ID = process.env.PROXMOX_TOKEN_ID; const PROXMOX_TOKEN_SECRET = process.env.PROXMOX_TOKEN_SECRET; +const PROXMOX_NODE = process.env.PROXMOX_NODE || 'proxmox'; -export async function createProxmoxContainer({ os, tariff, user }: any) { - try { - const node = process.env.PROXMOX_NODE; - const diskTemplate = process.env.PROXMOX_DISK_TEMPLATE; - if (!node || !diskTemplate) { - return { status: 'fail', message: 'Не указаны PROXMOX_NODE или PROXMOX_DISK_TEMPLATE в .env' }; - } - const vmId = Math.floor(10000 + Math.random() * 89999); - const res = await axios.post( - `${PROXMOX_API_URL}/nodes/${node}/qemu`, - { - vmid: vmId, - name: `user${user.id}-vm${vmId}`, - ostype: os.code || 'l26', - cores: tariff.cores || 2, - memory: tariff.memory || 2048, - storage: diskTemplate, - }, - { - headers: { - 'Authorization': `PVEAPIToken=${PROXMOX_TOKEN_ID}=${PROXMOX_TOKEN_SECRET}`, - 'Content-Type': 'application/json' - } - } - ); - if (res.data && res.data.data) { - return { status: 'ok', proxmoxId: vmId, message: 'Сервер создан на Proxmox', proxmox: res.data.data }; - } - return { status: 'fail', message: 'Не удалось создать сервер на Proxmox', details: res.data }; - } catch (err) { - return { status: 'fail', message: 'Ошибка создания сервера на Proxmox', error: err }; - } +function getProxmoxHeaders(): Record { + return { + 'Authorization': `PVEAPIToken=${PROXMOX_TOKEN_ID}=${PROXMOX_TOKEN_SECRET}`, + 'Content-Type': 'application/json' + }; } -export async function checkProxmoxConnection() { +// Генерация случайного пароля +export function generateSecurePassword(length: number = 16): string { + const charset = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*'; + let password = ''; + for (let i = 0; i < length; i++) { + password += charset.charAt(Math.floor(Math.random() * charset.length)); + } + return password; +} + +// Получение следующего доступного VMID +export async function getNextVMID(): Promise { try { const res = await axios.get( - `${PROXMOX_API_URL}/version`, - { - headers: { - 'Authorization': `PVEAPIToken=${PROXMOX_TOKEN_ID}=${PROXMOX_TOKEN_SECRET}` - } - } + `${PROXMOX_API_URL}/cluster/nextid`, + { headers: getProxmoxHeaders() } ); - if (res.data && res.data.data) { - return { status: 'ok', message: 'Соединение с Proxmox установлено', version: res.data.data.version }; - } - return { status: 'fail', message: 'Не удалось получить версию Proxmox' }; - } catch (err) { - return { status: 'fail', message: 'Ошибка соединения с Proxmox', error: err }; + return res.data.data || Math.floor(100 + Math.random() * 899); + } catch (error) { + console.error('Ошибка получения VMID:', error); + return Math.floor(100 + Math.random() * 899); + } +} + +// Создание LXC контейнера +export interface CreateContainerParams { + os: { template: string; type: string }; + tariff: { name: string; price: number; description?: string }; + user: { id: number; username: string }; + hostname?: string; +} + +export async function createLXContainer({ os, tariff, user }: CreateContainerParams) { + try { + const vmid = await getNextVMID(); + const rootPassword = generateSecurePassword(); + // Используем hostname из параметров, если есть + const hostname = arguments[0].hostname || `user${user.id}-${tariff.name.toLowerCase().replace(/\s/g, '-')}`; + + // Определяем ресурсы по названию тарифа (парсим описание) + const description = tariff.description || '1 ядро, 1ГБ RAM, 20ГБ SSD'; + const cores = parseInt(description.match(/(\d+)\s*ядр/)?.[1] || '1'); + const memory = parseInt(description.match(/(\d+)ГБ\s*RAM/)?.[1] || '1') * 1024; // в MB + const diskSize = parseInt(description.match(/(\d+)ГБ\s*SSD/)?.[1] || '20'); + + const containerConfig = { + vmid, + hostname, + password: rootPassword, + ostemplate: os.template, + cores, + memory, + rootfs: `local:${diskSize}`, + net0: 'name=eth0,bridge=vmbr0,ip=dhcp', + unprivileged: 1, + start: 1, // Автостарт после создания + protection: 0, + console: 1, + cmode: 'console' + }; + + console.log('Создание LXC контейнера с параметрами:', containerConfig); + + const response = await axios.post( + `${PROXMOX_API_URL}/nodes/${PROXMOX_NODE}/lxc`, + containerConfig, + { headers: getProxmoxHeaders() } + ); + + if (response.data?.data) { + // Polling статуса контейнера до running или timeout + let status = ''; + let attempts = 0; + const maxAttempts = 10; + while (attempts < maxAttempts) { + await new Promise(resolve => setTimeout(resolve, 3000)); + const info = await getContainerStatus(vmid); + status = info?.status || ''; + if (status === 'running' || status === 'stopped' || status === 'created') break; + attempts++; + } + // Получаем IP адрес контейнера + const ipAddress = await getContainerIP(vmid); + return { + status: 'success', + vmid, + rootPassword, + ipAddress, + hostname, + taskId: response.data.data, + containerStatus: status + }; + } +// Получить статус контейнера по VMID +async function getContainerStatus(vmid: number): Promise<{ status: string }> { + try { + const res = await axios.get( + `${PROXMOX_API_URL}/nodes/${PROXMOX_NODE}/lxc/${vmid}/status/current`, + { headers: getProxmoxHeaders() } + ); + return { status: res.data.data.status }; + } catch (error) { + return { status: 'error' }; + } +} + + throw new Error('Не удалось создать контейнер'); + } catch (error: any) { + console.error('Ошибка создания LXC контейнера:', error); + return { + status: 'error', + message: error.response?.data?.errors || error.message + }; + } +} + +// Получение IP адреса контейнера +export async function getContainerIP(vmid: number): Promise { + try { + await new Promise(resolve => setTimeout(resolve, 10000)); // Ждём запуска + + const response = await axios.get( + `${PROXMOX_API_URL}/nodes/${PROXMOX_NODE}/lxc/${vmid}/interfaces`, + { headers: getProxmoxHeaders() } + ); + + const interfaces = response.data?.data; + if (interfaces && interfaces.length > 0) { + for (const iface of interfaces) { + if (iface.inet && iface.inet !== '127.0.0.1') { + return iface.inet.split('/')[0]; // Убираем маску подсети + } + } + } + + return null; + } catch (error) { + console.error('Ошибка получения IP:', error); + return null; + } +} + +// Управление контейнером (старт/стоп/перезагрузка) +export async function controlContainer(vmid: number, action: 'start' | 'stop' | 'restart' | 'suspend' | 'resume') { + try { + const response = await axios.post( + `${PROXMOX_API_URL}/nodes/${PROXMOX_NODE}/lxc/${vmid}/status/${action}`, + {}, + { headers: getProxmoxHeaders() } + ); + return { + status: 'success', + action, + taskId: response.data?.data + }; + } catch (error: any) { + console.error(`Ошибка ${action} контейнера:`, error); + return { + status: 'error', + message: error.response?.data?.errors || error.message + }; + } +} + +// Удаление контейнера +export async function deleteContainer(vmid: number) { + try { + const response = await axios.delete( + `${PROXMOX_API_URL}/nodes/${PROXMOX_NODE}/lxc/${vmid}`, + { headers: getProxmoxHeaders() } + ); + return { + status: 'success', + taskId: response.data?.data + }; + } catch (error: any) { + console.error('Ошибка удаления контейнера:', error); + return { + status: 'error', + message: error.response?.data?.errors || error.message + }; + } +} + +// Получение статистики контейнера +export async function getContainerStats(vmid: number) { + try { + // Получаем текущий статус + const statusResponse = await axios.get( + `${PROXMOX_API_URL}/nodes/${PROXMOX_NODE}/lxc/${vmid}/status/current`, + { headers: getProxmoxHeaders() } + ); + + const status = statusResponse.data?.data; + + // Получаем статистику RRD (за последний час) + let rrdData = []; + let latest: any = {}; + try { + const rrdResponse = await axios.get( + `${PROXMOX_API_URL}/nodes/${PROXMOX_NODE}/lxc/${vmid}/rrd?timeframe=hour`, + { headers: getProxmoxHeaders() } + ); + rrdData = rrdResponse.data?.data || []; + latest = rrdData[rrdData.length - 1] || {}; + } catch (err: any) { + // Если ошибка 400, возвращаем пустую статистику, но не ошибку + if (err?.response?.status === 400) { + return { + status: 'success', + data: { + vmid, + status: status?.status || 'unknown', + uptime: status?.uptime || 0, + cpu: 0, + memory: { + used: status?.mem || 0, + max: status?.maxmem || 0, + usage: 0 + }, + disk: { + used: status?.disk || 0, + max: status?.maxdisk || 0, + usage: 0 + }, + network: { + in: 0, + out: 0 + }, + rrdData: [] + } + }; + } else { + throw err; + } + } + return { + status: 'success', + data: { + vmid, + status: status?.status || 'unknown', + uptime: status?.uptime || 0, + cpu: latest.cpu || 0, + memory: { + used: status?.mem || 0, + max: status?.maxmem || 0, + usage: status?.maxmem ? (status.mem / status.maxmem) * 100 : 0 + }, + disk: { + used: status?.disk || 0, + max: status?.maxdisk || 0, + usage: status?.maxdisk ? (status.disk / status.maxdisk) * 100 : 0 + }, + network: { + in: latest.netin || 0, + out: latest.netout || 0 + }, + rrdData: rrdData.slice(-60) // Последние 60 точек для графиков + } + }; + } catch (error: any) { + console.error('Ошибка получения статистики:', error); + return { + status: 'error', + message: error.response?.data?.errors || error.message + }; + } +} + +// Смена root пароля +export async function changeRootPassword(vmid: number): Promise<{ status: string; password?: string; message?: string }> { + try { + const newPassword = generateSecurePassword(); + + // Выполняем команду смены пароля в контейнере + const response = await axios.post( + `${PROXMOX_API_URL}/nodes/${PROXMOX_NODE}/lxc/${vmid}/status/exec`, + { + command: `echo 'root:${newPassword}' | chpasswd` + }, + { headers: getProxmoxHeaders() } + ); + + return { + status: 'success', + password: newPassword + }; + } catch (error: any) { + console.error('Ошибка смены пароля:', error); + return { + status: 'error', + message: error.response?.data?.errors || error.message + }; + } +} + +// Получение ссылки на noVNC консоль +export async function getConsoleURL(vmid: number): Promise<{ status: string; url?: string; message?: string }> { + try { + const response = await axios.post( + `${PROXMOX_API_URL}/nodes/${PROXMOX_NODE}/lxc/${vmid}/vncproxy`, + { + websocket: 1 + }, + { headers: getProxmoxHeaders() } + ); + + const data = response.data?.data; + if (data?.ticket && data?.port) { + const consoleUrl = `${process.env.PROXMOX_WEB_URL}/?console=lxc&vmid=${vmid}&node=${PROXMOX_NODE}&resize=off&ticket=${data.ticket}&port=${data.port}`; + + return { + status: 'success', + url: consoleUrl + }; + } + + throw new Error('Не удалось получить данные для консоли'); + } catch (error: any) { + console.error('Ошибка получения консоли:', error); + return { + status: 'error', + message: error.response?.data?.errors || error.message + }; + } +} + +// Проверка соединения с Proxmox +export async function checkProxmoxConnection() { + try { + const response = await axios.get( + `${PROXMOX_API_URL}/version`, + { headers: getProxmoxHeaders() } + ); + + if (response.data?.data) { + return { + status: 'success', + message: 'Соединение с Proxmox установлено', + version: response.data.data.version, + node: PROXMOX_NODE + }; + } + return { status: 'error', message: 'Не удалось получить версию Proxmox' }; + } catch (error: any) { + return { + status: 'error', + message: 'Ошибка соединения с Proxmox', + error: error.response?.data || error.message + }; } } diff --git a/ospabhost/backend/src/modules/server/server.controller.ts b/ospabhost/backend/src/modules/server/server.controller.ts new file mode 100644 index 0000000..e1a2832 --- /dev/null +++ b/ospabhost/backend/src/modules/server/server.controller.ts @@ -0,0 +1,214 @@ +import { Request, Response } from 'express'; +import { PrismaClient } from '@prisma/client'; +import { + createLXContainer, + controlContainer, + getContainerStats, + changeRootPassword as proxmoxChangeRootPassword, + deleteContainer +} from './proxmoxApi'; + +const prisma = new PrismaClient(); + +// Создание сервера (контейнера) +export async function createServer(req: Request, res: Response) { + try { + const { osId, tariffId } = req.body; + const userId = req.user?.id; + if (!userId) return res.status(401).json({ error: 'Нет авторизации' }); + + const os = await prisma.operatingSystem.findUnique({ where: { id: osId } }); + const tariff = await prisma.tariff.findUnique({ where: { id: tariffId } }); + const user = await prisma.user.findUnique({ where: { id: userId } }); + if (!os || !tariff || !user) return res.status(400).json({ error: 'Некорректные параметры' }); + + // Проверка баланса пользователя + if (user.balance < tariff.price) { + return res.status(400).json({ error: 'Недостаточно средств на балансе' }); + } + + // Списываем средства + await prisma.user.update({ where: { id: userId }, data: { balance: { decrement: tariff.price } } }); + + // Генерация hostname из email + let hostname = user.email.split('@')[0]; + hostname = hostname.replace(/[^a-zA-Z0-9-]/g, ''); + if (hostname.length < 3) hostname = `user${userId}`; + if (hostname.length > 32) hostname = hostname.slice(0, 32); + if (/^[0-9-]/.test(hostname)) hostname = `u${hostname}`; + + // Создаём контейнер в Proxmox + const result = await createLXContainer({ + os: { template: os.template || '', type: os.type }, + tariff: { name: tariff.name, price: tariff.price, description: tariff.description || undefined }, + user: { id: user.id, username: user.username }, + hostname + }); + if (result.status !== 'success') { + // Возвращаем деньги обратно, если не удалось создать + await prisma.user.update({ where: { id: userId }, data: { balance: { increment: tariff.price } } }); + // Логируем полный текст ошибки в файл + const fs = require('fs'); + const logMsg = `[${new Date().toISOString()}] Ошибка Proxmox: ${JSON.stringify(result, null, 2)}\n`; + fs.appendFile('proxmox-errors.log', logMsg, (err: NodeJS.ErrnoException | null) => { + if (err) console.error('Ошибка записи лога:', err); + }); + console.error('Ошибка Proxmox:', result.message); + return res.status(500).json({ + error: 'Ошибка создания сервера в Proxmox', + details: result.message, + fullError: result + }); + } + + // Сохраняем сервер в БД с реальным статусом + const server = await prisma.server.create({ + data: { + userId, + tariffId, + osId, + status: result.containerStatus || 'creating', + proxmoxId: Number(result.vmid), + ipAddress: result.ipAddress, + rootPassword: result.rootPassword, + } + }); + res.json(server); + } catch (error: any) { + console.error('Ошибка покупки сервера:', error); + res.status(500).json({ error: error?.message || 'Ошибка покупки сервера' }); + } +} + +// Получить статус сервера +export async function getServerStatus(req: Request, res: Response) { + try { + const id = Number(req.params.id); + const server = await prisma.server.findUnique({ where: { id } }); + if (!server) return res.status(404).json({ error: 'Сервер не найден' }); + if (!server.proxmoxId) return res.status(400).json({ error: 'Нет VMID Proxmox' }); + const stats = await getContainerStats(server.proxmoxId); + if (stats.status === 'error') { + // Если контейнер не найден в Proxmox, возвращаем статус deleted и пустую статистику + return res.json({ + ...server, + status: 'deleted', + stats: { + data: { + cpu: 0, + memory: { usage: 0 } + } + }, + error: 'Контейнер не найден в Proxmox', + details: stats.message + }); + } + res.json({ ...server, stats }); + } catch (error: any) { + res.status(500).json({ error: error?.message || 'Ошибка получения статуса' }); + } +} + +// Запустить сервер +export async function startServer(req: Request, res: Response) { + await handleControl(req, res, 'start'); +} +// Остановить сервер +export async function stopServer(req: Request, res: Response) { + await handleControl(req, res, 'stop'); +} +// Перезагрузить сервер +export async function restartServer(req: Request, res: Response) { + await handleControl(req, res, 'restart'); +} + +async function handleControl(req: Request, res: Response, action: 'start' | 'stop' | 'restart') { + try { + const id = Number(req.params.id); + const server = await prisma.server.findUnique({ where: { id } }); + if (!server || !server.proxmoxId) return res.status(404).json({ error: 'Сервер не найден или нет VMID' }); + // Получаем текущий статус VM + const stats = await getContainerStats(server.proxmoxId); + const currentStatus = stats.status === 'success' && stats.data ? stats.data.status : server.status; + // Ограничения на действия + if (action === 'start' && currentStatus === 'running') { + return res.status(400).json({ error: 'Сервер уже запущен' }); + } + if (action === 'stop' && currentStatus === 'stopped') { + return res.status(400).json({ error: 'Сервер уже остановлен' }); + } + // Выполняем действие + const result = await controlContainer(server.proxmoxId, action); + // Polling статуса VM после управления + let newStatus = server.status; + if (result.status === 'success') { + let status = ''; + let attempts = 0; + const maxAttempts = 10; + while (attempts < maxAttempts) { + await new Promise(resolve => setTimeout(resolve, 3000)); + const stats = await getContainerStats(server.proxmoxId); + if (stats.status === 'success' && stats.data) { + status = stats.data.status; + if ((action === 'start' && status === 'running') || + (action === 'stop' && status === 'stopped') || + (action === 'restart' && status === 'running')) { + break; + } + } + attempts++; + } + switch (status) { + case 'running': + newStatus = 'running'; + break; + case 'stopped': + newStatus = 'stopped'; + break; + case 'suspended': + newStatus = 'suspended'; + break; + default: + newStatus = status || server.status; + } + await prisma.server.update({ where: { id }, data: { status: newStatus } }); + } + res.json({ ...result, status: newStatus }); + } catch (error: any) { + res.status(500).json({ error: error?.message || 'Ошибка управления сервером' }); + } +} + +// Удалить сервер +export async function deleteServer(req: Request, res: Response) { + try { + const id = Number(req.params.id); + const server = await prisma.server.findUnique({ where: { id } }); + if (!server || !server.proxmoxId) return res.status(404).json({ error: 'Сервер не найден или нет VMID' }); + // Удаляем контейнер в Proxmox + const proxmoxResult = await deleteContainer(server.proxmoxId); + if (proxmoxResult.status !== 'success') { + return res.status(500).json({ error: 'Ошибка удаления контейнера в Proxmox', details: proxmoxResult }); + } + await prisma.server.delete({ where: { id } }); + res.json({ status: 'deleted' }); + } catch (error: any) { + res.status(500).json({ error: error?.message || 'Ошибка удаления сервера' }); + } +} + +// Сменить root-пароль +export async function changeRootPassword(req: Request, res: Response) { + try { + const id = Number(req.params.id); + const server = await prisma.server.findUnique({ where: { id } }); + if (!server || !server.proxmoxId) return res.status(404).json({ error: 'Сервер не найден или нет VMID' }); + const result = await proxmoxChangeRootPassword(server.proxmoxId); + if (result?.status === 'success' && result.password) { + await prisma.server.update({ where: { id }, data: { rootPassword: result.password } }); + } + res.json(result); + } catch (error: any) { + res.status(500).json({ error: error?.message || 'Ошибка смены пароля' }); + } +} diff --git a/ospabhost/backend/src/modules/server/server.routes.ts b/ospabhost/backend/src/modules/server/server.routes.ts index 298cd75..2c17b28 100644 --- a/ospabhost/backend/src/modules/server/server.routes.ts +++ b/ospabhost/backend/src/modules/server/server.routes.ts @@ -1,119 +1,75 @@ import { Router } from 'express'; -import { PrismaClient } from '@prisma/client'; import { authMiddleware } from '../auth/auth.middleware'; -import { checkProxmoxConnection, createProxmoxContainer } from './proxmoxApi'; +import { + createServer, + startServer, + stopServer, + restartServer, + getServerStatus, + deleteServer, + changeRootPassword +} from './server.controller'; +import { PrismaClient } from '@prisma/client'; +const prisma = new PrismaClient(); const router = Router(); -const prisma = new PrismaClient(); + router.use(authMiddleware); -router.get('/proxmox-status', async (req, res) => { - try { - console.log('Попытка подключения к серверу Proxmox...'); - const status = await checkProxmoxConnection(); - console.log('Статус подключения к Proxmox:', status); - res.json(status); - } catch (err) { - console.error('Ошибка подключения к Proxmox:', err); - res.status(500).json({ error: 'Ошибка подключения к Proxmox' }); - } -}); -router.post('/create', async (req, res) => { - try { - const { tariffId, osId } = req.body; - const userId = req.user?.id; - if (!userId) return res.status(401).json({ error: 'Нет авторизации' }); - const tariff = await prisma.tariff.findUnique({ where: { id: tariffId } }); - const os = await prisma.operatingSystem.findUnique({ where: { id: osId } }); - if (!tariff || !os) { - return res.status(400).json({ error: 'Тариф или ОС не найдены' }); - } - - const user = await prisma.user.findUnique({ where: { id: userId } }); - if (!user) return res.status(404).json({ error: 'Пользователь не найден' }); - if (user.balance < tariff.price) { - return res.status(400).json({ error: 'Недостаточно средств на балансе' }); - } - - let proxmoxResult; - try { - proxmoxResult = await createProxmoxContainer({ os, tariff, user }); - } catch (proxmoxErr) { - console.error('Ошибка Proxmox:', proxmoxErr); - return res.status(500).json({ error: 'Ошибка создания сервера на Proxmox', details: proxmoxErr }); - } - if (!proxmoxResult || proxmoxResult.status !== 'ok') { - return res.status(500).json({ error: 'Сервер не создан на Proxmox', details: proxmoxResult }); - } - - await prisma.user.update({ - where: { id: userId }, - data: { - balance: { - decrement: tariff.price - } - } - }); - - const node = process.env.PROXMOX_NODE; - const diskTemplate = process.env.PROXMOX_DISK_TEMPLATE; - const server = await prisma.server.create({ - data: { - userId, - tariffId, - osId, - status: 'active', - node, - diskTemplate, - proxmoxId: proxmoxResult.proxmoxId || null, - }, - }); - res.json({ success: true, server }); - } catch (err) { - console.error('Ошибка создания сервера:', err); - return res.status(500).json({ error: 'Ошибка создания сервера' }); - } -}); - -// GET /api/server — получить все серверы пользователя +// Получить список всех серверов (для фронта) router.get('/', async (req, res) => { + const userId = req.user?.id; + // Если нужен только свои сервера: + const where = userId ? { userId } : {}; + const servers = await prisma.server.findMany({ + where, + include: { + os: true, + tariff: true + } + }); + res.json(servers); +}); + +// Получить информацию о сервере (для фронта) +router.get('/:id', async (req, res) => { + const id = Number(req.params.id); + const server = await prisma.server.findUnique({ + where: { id }, + include: { + os: true, + tariff: true + } + }); + if (!server) return res.status(404).json({ error: 'Сервер не найден' }); + res.json(server); +}); + + +// Получить статистику сервера (CPU, RAM и т.д.) +router.get('/:id/status', getServerStatus); + +// Получить ссылку на noVNC консоль +import { getConsoleURL } from './proxmoxApi'; +router.post('/console', async (req, res) => { + const { vmid } = req.body; + if (!vmid) return res.status(400).json({ status: 'error', message: 'Не указан VMID' }); try { - const userId = req.user?.id; - if (!userId) return res.status(401).json({ error: 'Нет авторизации' }); - const servers = await prisma.server.findMany({ - where: { userId }, - include: { - os: true, - tariff: true, - }, - }); - console.log('API /api/server ответ:', servers); - res.json(servers); - } catch (err) { - console.error('Ошибка получения серверов:', err); - res.status(500).json({ error: 'Ошибка получения серверов' }); + const result = await getConsoleURL(Number(vmid)); + res.json(result); + } catch (error: any) { + res.status(500).json({ status: 'error', message: error?.message || 'Ошибка получения консоли' }); } }); -// GET /api/server/:id — получить один сервер пользователя по id -router.get('/:id', async (req, res) => { - try { - const userId = req.user?.id; - if (!userId) return res.status(401).json({ error: 'Нет авторизации' }); - const serverId = Number(req.params.id); - const server = await prisma.server.findFirst({ - where: { id: serverId, userId }, - include: { os: true, tariff: true }, - }); - if (!server) return res.status(404).json({ error: 'Сервер не найден' }); - res.json(server); - } catch (err) { - console.error('Ошибка получения сервера:', err); - res.status(500).json({ error: 'Ошибка получения сервера' }); - } -}); +router.post('/create', createServer); +router.post('/:id/start', startServer); +router.post('/:id/stop', stopServer); +router.post('/:id/restart', restartServer); +router.delete('/:id', deleteServer); +router.post('/:id/password', changeRootPassword); export default router; \ No newline at end of file diff --git a/ospabhost/backend/src/prisma/client.ts b/ospabhost/backend/src/prisma/client.ts index e69de29..0a7fbaa 100644 --- a/ospabhost/backend/src/prisma/client.ts +++ b/ospabhost/backend/src/prisma/client.ts @@ -0,0 +1,2 @@ +import { PrismaClient } from '@prisma/client'; +export const prisma = new PrismaClient(); diff --git a/ospabhost/backend/uploads/checks/1758389905202-775586440-check-subtotal-1.jpg b/ospabhost/backend/uploads/checks/1758389905202-775586440-check-subtotal-1.jpg new file mode 100644 index 0000000..9c012fc Binary files /dev/null and b/ospabhost/backend/uploads/checks/1758389905202-775586440-check-subtotal-1.jpg differ diff --git a/ospabhost/frontend/src/pages/dashboard/billing.tsx b/ospabhost/frontend/src/pages/dashboard/billing.tsx index 44b9d0c..19796c3 100644 --- a/ospabhost/frontend/src/pages/dashboard/billing.tsx +++ b/ospabhost/frontend/src/pages/dashboard/billing.tsx @@ -32,7 +32,7 @@ const Billing = () => { if (!checkFile || amount <= 0) return; setUploadLoading(true); try { - const token = localStorage.getItem('token'); + const token = localStorage.getItem('access_token'); const formData = new FormData(); formData.append('file', checkFile); formData.append('amount', String(amount)); diff --git a/ospabhost/frontend/src/pages/dashboard/checkverification.tsx b/ospabhost/frontend/src/pages/dashboard/checkverification.tsx index 93ec7e9..19607c8 100644 --- a/ospabhost/frontend/src/pages/dashboard/checkverification.tsx +++ b/ospabhost/frontend/src/pages/dashboard/checkverification.tsx @@ -31,7 +31,7 @@ const CheckVerification: React.FC = () => { setLoading(true); setError(''); try { - const token = localStorage.getItem('token'); + const token = localStorage.getItem('access_token'); const res = await axios.get(API_URL, { headers: { Authorization: `Bearer ${token}` }, withCredentials: true, @@ -50,17 +50,17 @@ const CheckVerification: React.FC = () => { setActionLoading(checkId); setError(''); try { - const token = localStorage.getItem('token'); + const token = localStorage.getItem('access_token'); await axios.post(`${API_URL}/${action}`, { checkId }, { - headers: { Authorization: `Bearer ${token}` }, + headers: { Authorization: `Bearer ${token}` }, withCredentials: true, }); setChecks((prevChecks: ICheck[]) => prevChecks.map((c: ICheck) => c.id === checkId ? { ...c, status: action === 'approve' ? 'approved' : 'rejected' } : c)); // Если подтверждение — обновить баланс пользователя if (action === 'approve') { try { - const userToken = localStorage.getItem('access_token') || token; - const headers = { Authorization: `Bearer ${userToken}` }; + const token = localStorage.getItem('access_token'); + const headers = { Authorization: `Bearer ${token}` }; const userRes = await axios.get('http://localhost:5000/api/auth/me', { headers }); // Глобально обновить userData через типизированное событие (для Dashboard) window.dispatchEvent(new CustomEvent('userDataUpdate', { diff --git a/ospabhost/frontend/src/pages/dashboard/mainpage.tsx b/ospabhost/frontend/src/pages/dashboard/mainpage.tsx index fd22051..c62411a 100644 --- a/ospabhost/frontend/src/pages/dashboard/mainpage.tsx +++ b/ospabhost/frontend/src/pages/dashboard/mainpage.tsx @@ -12,6 +12,7 @@ import ServerPanel from './serverpanel'; import TicketsPage from './tickets'; import Billing from './billing'; import Settings from './settings'; +import Notifications from './notificatons'; import CheckVerification from './checkverification'; import TicketResponse from './ticketresponse'; import Checkout from './checkout'; @@ -117,6 +118,7 @@ const Dashboard = () => { { key: 'tickets', label: 'Тикеты', to: '/dashboard/tickets' }, { key: 'billing', label: 'Баланс', to: '/dashboard/billing' }, { key: 'settings', label: 'Настройки', to: '/dashboard/settings' }, + { key: 'notifications', label: 'Уведомления', to: '/dashboard/notifications' }, ]; const adminTabs = [ { key: 'checkverification', label: 'Проверка чеков', to: '/dashboard/checkverification' }, @@ -210,6 +212,7 @@ const Dashboard = () => { } /> )} } /> + } /> {isOperator && ( <> } /> diff --git a/ospabhost/frontend/src/pages/dashboard/notificatons.tsx b/ospabhost/frontend/src/pages/dashboard/notificatons.tsx new file mode 100644 index 0000000..97ce43b --- /dev/null +++ b/ospabhost/frontend/src/pages/dashboard/notificatons.tsx @@ -0,0 +1,41 @@ +const notificationsList = [ + { + title: "Создание сервера", + description: "Вы получите уведомление при успешном создании нового сервера или контейнера.", + }, + { + title: "Списание оплаты за месяц", + description: "Напоминание о предстоящем списании средств за продление тарифа.", + }, + { + title: "Истечение срока действия тарифа", + description: "Уведомление о необходимости продлить тариф, чтобы избежать отключения.", + }, + { + title: "Ответ на тикет", + description: "Вы получите уведомление, когда оператор ответит на ваш тикет поддержки.", + }, + { + title: "Поступление оплаты", + description: "Уведомление о зачислении средств на ваш баланс.", + }, +]; + +const Notifications = () => { + return ( +
+

Типы уведомлений

+
    + {notificationsList.map((n, idx) => ( +
  • +
    {n.title}
    +
    {n.description}
    +
  • + ))} +
+
Настройка каналов уведомлений (email, Telegram) появится позже.
+
+ ); +}; + +export default Notifications; diff --git a/ospabhost/frontend/src/pages/dashboard/serverpanel.tsx b/ospabhost/frontend/src/pages/dashboard/serverpanel.tsx index c029d97..fcc9c4a 100644 --- a/ospabhost/frontend/src/pages/dashboard/serverpanel.tsx +++ b/ospabhost/frontend/src/pages/dashboard/serverpanel.tsx @@ -1,5 +1,52 @@ import React, { useEffect, useState } from 'react'; + +// Встроенная секция консоли +function ConsoleSection({ serverId }: { serverId: number }) { + const [consoleUrl, setConsoleUrl] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + + const handleOpenConsole = async () => { + setLoading(true); + setError(''); + try { + const token = localStorage.getItem('access_token'); + const headers = token ? { Authorization: `Bearer ${token}` } : {}; + const res = await axios.post(`http://localhost:5000/api/proxmox/console`, { vmid: serverId }, { headers }); + if (res.data?.status === 'success' && res.data.url) { + setConsoleUrl(res.data.url); + } else { + setError('Ошибка открытия консоли'); + } + } catch { + setError('Ошибка открытия консоли'); + } finally { + setLoading(false); + } + }; + + return ( +
+
Консоль сервера
+ {!consoleUrl ? ( + + ) : ( +