Files
multy-stub/server/server.ts
Primakov Alexandr Alexandrovich 8a1868482c feat: обновление конфигурации проекта с использованием TypeScript и улучшение обработки ошибок
- Переписаны основные файлы сервера с JavaScript на TypeScript.
- Добавлен новый обработчик ошибок с логированием в базу данных.
- Обновлен Dockerfile для поддержки сборки TypeScript.
- Изменены настройки окружения для MongoDB в docker-compose.
- Удалены устаревшие файлы и добавлены новые модели и утилиты для работы с MongoDB.
- Обновлены зависимости в package.json и package-lock.json.
2025-05-08 14:18:03 +03:00

963 lines
29 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import fs from 'fs'
import path from 'path'
import { Router } from 'express'
import mongoose from 'mongoose'
import pkg from '../package.json'
import './utils/mongoose'
import { ErrorLog } from './models/ErrorLog'
const folderPath = path.resolve(__dirname, './routers')
const folders = fs.readdirSync(folderPath)
// Определение типов
interface FileInfo {
path: string;
type: 'file';
endpoints: number;
}
interface DirectoryInfo {
path: string;
type: 'directory';
endpoints: number;
children: (FileInfo | DirectoryInfo)[];
}
interface DirScanResult {
items: (FileInfo | DirectoryInfo)[];
totalEndpoints: number;
}
// Функция для поиска эндпоинтов в файлах
function countEndpoints(filePath) {
if (!fs.existsSync(filePath) || !filePath.endsWith('.js') && !filePath.endsWith('.ts')) {
return 0;
}
try {
const content = fs.readFileSync(filePath, 'utf8');
const httpMethods = ['get', 'post', 'put', 'delete', 'patch'];
return httpMethods.reduce((count, method) => {
const regex = new RegExp(`router\\.${method}\\(`, 'gi');
const matches = content.match(regex) || [];
return count + matches.length;
}, 0);
} catch (err) {
return 0;
}
}
// Функция для рекурсивного обхода директорий
function getAllDirs(dir, basePath = ''): DirScanResult {
const items: (FileInfo | DirectoryInfo)[] = [];
let totalEndpoints = 0;
try {
const dirItems = fs.readdirSync(dir);
for (const item of dirItems) {
const itemPath = path.join(dir, item);
const relativePath = path.join(basePath, item);
const stat = fs.statSync(itemPath);
if (stat.isDirectory()) {
const dirResult = getAllDirs(itemPath, relativePath);
totalEndpoints += dirResult.totalEndpoints;
items.push({
path: relativePath,
type: 'directory',
endpoints: dirResult.totalEndpoints,
children: dirResult.items
});
} else {
const fileEndpoints = countEndpoints(itemPath);
totalEndpoints += fileEndpoints;
items.push({
path: relativePath,
type: 'file',
endpoints: fileEndpoints
});
}
}
} catch (err) {
console.error(`Ошибка при чтении директории ${dir}:`, err);
}
return {
items,
totalEndpoints
};
}
// Функция для генерации HTML-списка директорий
function generateDirList(dirs: (FileInfo | DirectoryInfo)[], level = 0) {
if (dirs.length === 0) return '';
const indent = level * 20;
return `<ul class="directory-list" style="padding-left: ${indent}px">${dirs.map(item => {
if (item.type === 'directory') {
const endpointClass = item.endpoints > 0 ? 'has-endpoints' : '';
const endpointInfo = item.endpoints > 0
? `<span class="endpoint-count" title="Количество эндпоинтов в директории">${item.endpoints}</span>`
: '';
return `
<li class="directory ${endpointClass}">
<div class="dir-item" data-expandable="true">
<span class="expand-icon">▶</span>
<span class="dir-name">${path.basename(item.path)}</span>
${endpointInfo}
</div>
<div class="subdirectory" style="display: none;">
${generateDirList(item.children, level + 1)}
</div>
</li>
`;
} else {
const endpointClass = item.endpoints > 0 ? 'has-endpoints' : '';
const endpointInfo = item.endpoints > 0
? `<span class="endpoint-count" title="Количество эндпоинтов">${item.endpoints}</span>`
: '';
return `
<li class="file ${endpointClass}">
<div class="file-item" data-path="${item.path}">
<span class="file-icon">📄</span>
<span class="file-name">${path.basename(item.path)}</span>
${endpointInfo}
</div>
</li>
`;
}
}).join('')}</ul>`;
}
const router = Router()
// Эндпоинт для получения содержимого файла
router.get('/file-content', async (req, res) => {
try {
const filePath = req.query.path as string;
if (!filePath) {
return res.status(400).json({ error: 'Путь к файлу не указан' });
}
// Используем корень проекта для получения абсолютного пути к файлу
const projectRoot = path.resolve(__dirname, 'routers');
const absolutePath = path.join(projectRoot, filePath);
// Проверка, что путь не выходит за пределы проекта
if (!absolutePath.startsWith(projectRoot)) {
return res.status(403).json({ error: 'Доступ запрещен' });
}
if (!fs.existsSync(absolutePath)) {
return res.status(404).json({ error: 'Файл не найден' });
}
// Проверяем, что это файл, а не директория
const stat = fs.statSync(absolutePath);
if (!stat.isFile()) {
return res.status(400).json({ error: 'Указанный путь не является файлом' });
}
// Проверяем размер файла, чтобы не загружать слишком большие файлы
const MAX_FILE_SIZE = 2 * 1024 * 1024; // 2MB
if (stat.size > MAX_FILE_SIZE) {
return res.status(413).json({
error: 'Файл слишком большой',
size: stat.size,
maxSize: MAX_FILE_SIZE
});
}
const content = fs.readFileSync(absolutePath, 'utf8');
const fileExtension = path.extname(absolutePath).slice(1);
res.json({
content,
extension: fileExtension,
fileName: path.basename(absolutePath)
});
} catch (err) {
console.error('Ошибка при чтении файла:', err);
res.status(500).json({ error: 'Ошибка при чтении файла' });
}
});
router.get('/', async (req, res) => {
// throw new Error('check error message')
// Используем корень проекта вместо только директории routers
const projectRoot = path.resolve(__dirname, 'routers');
const routersPath = path.resolve(__dirname, 'routers');
const routerFolders = fs.readdirSync(routersPath);
const directoryResult = getAllDirs(projectRoot);
const totalEndpoints = directoryResult.totalEndpoints;
// Получаем последние 10 ошибок
const latestErrors = await ErrorLog.find().sort({ createdAt: -1 }).limit(10);
// Сформируем HTML для секции с ошибками
let errorsHtml = '';
if (latestErrors.length > 0) {
errorsHtml = latestErrors.map(error => `
<div class="error-card">
<div class="error-header">
<h3 class="error-message">${error.message}</h3>
<span class="error-date">${new Date(error.createdAt).toLocaleString()}</span>
</div>
<div class="error-details">
${error.path ? `<div class="error-path">${error.method || 'GET'} ${error.path}</div>` : ''}
${error.stack ? `<div class="error-stack">${error.stack}</div>` : ''}
</div>
</div>
`).join('');
} else {
errorsHtml = '<p>Нет зарегистрированных ошибок</p>';
}
// Создаем JavaScript для клиентской части
const clientScript = `
document.addEventListener('DOMContentLoaded', function() {
// Директории
document.querySelectorAll('.dir-item[data-expandable="true"]').forEach(item => {
item.addEventListener('click', function(e) {
const subdirectory = this.nextElementSibling;
const isExpanded = this.classList.toggle('expanded');
if (isExpanded) {
subdirectory.style.display = 'block';
this.querySelector('.expand-icon').textContent = '▼';
} else {
subdirectory.style.display = 'none';
this.querySelector('.expand-icon').textContent = '▶';
}
e.stopPropagation();
});
});
// Модальное окно
const modal = document.getElementById('fileModal');
const closeBtn = document.querySelector('.close-modal');
const fileContent = document.getElementById('fileContent');
const fileLoader = document.getElementById('fileLoader');
const modalFileName = document.getElementById('modalFileName');
// Закрытие модального окна
closeBtn.addEventListener('click', function() {
modal.style.display = 'none';
});
window.addEventListener('click', function(event) {
if (event.target == modal) {
modal.style.display = 'none';
}
});
// Обработчик для файлов
document.querySelectorAll('.file-item').forEach(item => {
item.addEventListener('click', async function() {
const filePath = this.getAttribute('data-path');
if (!filePath) return;
// Показываем модальное окно и лоадер
modal.style.display = 'block';
fileContent.style.display = 'none';
fileLoader.style.display = 'block';
modalFileName.textContent = 'Загрузка...';
try {
const response = await fetch('/file-content?path=' + encodeURIComponent(filePath));
if (!response.ok) {
throw new Error('Ошибка при загрузке файла');
}
const data = await response.json();
// Отображаем содержимое файла
fileLoader.style.display = 'none';
fileContent.style.display = 'block';
fileContent.textContent = data.content;
modalFileName.textContent = data.fileName;
// Подсветка синтаксиса
const extensionMap = {
'js': 'javascript',
'ts': 'typescript',
'json': 'json',
'css': 'css',
'html': 'xml',
'xml': 'xml',
'md': 'markdown',
'yaml': 'yaml',
'yml': 'yaml',
'sh': 'bash',
'bash': 'bash'
};
const language = extensionMap[data.extension] || '';
if (language) {
fileContent.className = 'language-' + language;
hljs.highlightElement(fileContent);
}
} catch (error) {
fileLoader.style.display = 'none';
fileContent.style.display = 'block';
fileContent.textContent = 'Ошибка при загрузке файла: ' + error.message;
modalFileName.textContent = 'Ошибка';
}
});
});
// Обработчик кнопки очистки ошибок
const clearErrorsBtn = document.getElementById('clearErrorsBtn');
const successAction = document.getElementById('successAction');
clearErrorsBtn.addEventListener('click', async function() {
try {
const response = await fetch('/clear-old-errors', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
}
});
if (!response.ok) {
throw new Error('Ошибка при очистке старых ошибок');
}
const data = await response.json();
// Показываем сообщение об успехе
successAction.textContent = 'Удалено ' + data.deletedCount + ' записей';
successAction.style.display = 'block';
// Перезагружаем страницу через 2 секунды
setTimeout(() => {
window.location.reload();
}, 2000);
} catch (error) {
console.error('Ошибка:', error);
alert('Произошла ошибка: ' + error.message);
}
});
});
`;
res.send(`
<!DOCTYPE html>
<html>
<head>
<title>Multy Stub v${pkg.version}</title>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;700&display=swap">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.7.0/styles/atom-one-dark.min.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.7.0/highlight.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.7.0/languages/javascript.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.7.0/languages/typescript.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.7.0/languages/json.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.7.0/languages/css.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.7.0/languages/xml.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.7.0/languages/markdown.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.7.0/languages/yaml.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.7.0/languages/bash.min.js"></script>
<style>
:root {
--primary-color: #3f51b5;
--secondary-color: #f50057;
--bg-color: #f9f9f9;
--card-bg: #ffffff;
--text-color: #333333;
--border-radius: 8px;
--shadow: 0 4px 6px rgba(0,0,0,0.1);
--hover-shadow: 0 6px 12px rgba(0,0,0,0.15);
--transition: all 0.3s ease;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: 'Roboto', Arial, sans-serif;
line-height: 1.6;
background-color: var(--bg-color);
color: var(--text-color);
padding: 20px;
}
h1, h2, h3 {
color: var(--primary-color);
margin-bottom: 16px;
}
.container {
max-width: 1200px;
margin: 0 auto;
background-color: var(--card-bg);
padding: 30px;
border-radius: var(--border-radius);
box-shadow: var(--shadow);
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 30px;
padding-bottom: 15px;
border-bottom: 1px solid #eee;
}
.stats-card {
background: linear-gradient(135deg, var(--primary-color), #5c6bc0);
color: white;
padding: 15px;
border-radius: var(--border-radius);
box-shadow: var(--shadow);
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
text-align: center;
}
.stats-value {
font-size: 28px;
font-weight: bold;
}
.stats-label {
font-size: 14px;
opacity: 0.9;
}
.directory-list {
list-style-type: none;
padding-left: 20px;
}
.directory, .file {
margin: 5px 0;
transition: var(--transition);
}
.dir-item, .file-item {
padding: 8px 12px;
border-radius: var(--border-radius);
cursor: pointer;
display: flex;
align-items: center;
transition: var(--transition);
}
.dir-item:hover {
background-color: rgba(63, 81, 181, 0.1);
}
.file-item:hover {
background-color: rgba(0, 0, 0, 0.03);
}
.dir-name {
font-weight: 500;
color: var(--primary-color);
}
.file-name {
color: var(--text-color);
}
.expand-icon {
display: inline-block;
margin-right: 8px;
transition: transform 0.3s ease;
font-size: 12px;
width: 16px;
height: 16px;
text-align: center;
line-height: 16px;
}
.file-icon {
margin-right: 8px;
font-size: 14px;
}
.expanded .expand-icon {
transform: rotate(90deg);
}
.endpoint-count {
margin-left: 10px;
font-size: 0.75rem;
color: #fff;
background-color: var(--secondary-color);
padding: 2px 8px;
border-radius: 12px;
font-weight: 500;
}
.has-endpoints .dir-item {
border-left: 3px solid var(--secondary-color);
}
.has-endpoints .file-item {
border-left: 3px solid var(--secondary-color);
}
.section {
margin-bottom: 30px;
padding: 20px;
background-color: var(--card-bg);
border-radius: var(--border-radius);
box-shadow: var(--shadow);
transition: var(--transition);
}
.section:hover {
box-shadow: var(--hover-shadow);
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 20px;
margin: 20px 0;
}
.tooltip {
position: relative;
display: inline-block;
}
.tooltip:hover .tooltip-text {
visibility: visible;
opacity: 1;
}
/* Стили для модального окна с содержимым файла */
.modal {
display: none;
position: fixed;
z-index: 1000;
left: 0;
top: 0;
width: 100%;
height: 100%;
background-color: rgba(0,0,0,0.5);
overflow: auto;
}
.modal-content {
background-color: var(--card-bg);
margin: 5% auto;
padding: 20px;
border-radius: var(--border-radius);
box-shadow: var(--shadow);
width: 80%;
max-width: 1000px;
max-height: 80vh;
overflow: auto;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding-bottom: 10px;
margin-bottom: 15px;
border-bottom: 1px solid #eee;
}
.modal-title {
margin: 0;
font-size: 1.5rem;
color: var(--primary-color);
}
.close-modal {
font-size: 28px;
font-weight: bold;
cursor: pointer;
color: #aaa;
transition: color 0.2s ease;
}
.close-modal:hover {
color: var(--secondary-color);
}
.file-content {
background-color: #282c34;
border-radius: var(--border-radius);
padding: 15px;
overflow: auto;
max-height: 60vh;
}
.file-content pre {
margin: 0;
white-space: pre-wrap;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', 'Consolas', monospace;
font-size: 14px;
line-height: 1.5;
}
.file-content code {
display: block;
width: 100%;
}
.loader {
border: 4px solid rgba(0, 0, 0, 0.1);
border-left: 4px solid var(--primary-color);
border-radius: 50%;
width: 30px;
height: 30px;
animation: spin 1s linear infinite;
margin: 30px auto;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
/* Стили для списка моделей */
.models-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 20px;
margin-top: 15px;
}
.model-card {
background-color: var(--card-bg);
border-radius: var(--border-radius);
box-shadow: var(--shadow);
overflow: hidden;
transition: var(--transition);
border-left: 4px solid var(--primary-color);
}
.model-card:hover {
box-shadow: var(--hover-shadow);
transform: translateY(-3px);
}
.model-header {
background-color: rgba(63, 81, 181, 0.1);
padding: 12px 15px;
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: flex-start;
border-bottom: 1px solid #eee;
}
.model-name {
margin: 0;
color: var(--primary-color);
font-size: 1.2rem;
}
.model-count {
background-color: var(--secondary-color);
color: white;
padding: 3px 10px;
border-radius: 12px;
font-size: 0.8rem;
font-weight: 500;
}
.model-details {
padding: 15px;
}
.model-fields h4 {
margin-bottom: 8px;
font-size: 1rem;
color: #555;
}
.fields-list {
list-style-type: none;
padding-left: 5px;
}
.fields-list li {
margin-bottom: 5px;
font-size: 0.9rem;
display: flex;
align-items: center;
}
.field-name {
font-weight: 500;
margin-right: 5px;
}
.field-type {
color: #666;
background-color: #f0f0f0;
padding: 2px 6px;
border-radius: 4px;
font-size: 0.8rem;
font-family: monospace;
}
/* Стили для раздела с ошибками */
.error-card {
background-color: var(--card-bg);
border-radius: var(--border-radius);
box-shadow: var(--shadow);
overflow: hidden;
transition: var(--transition);
border-left: 4px solid #f44336;
margin-bottom: 10px;
}
.error-card:hover {
box-shadow: var(--hover-shadow);
}
.error-header {
background-color: rgba(244, 67, 54, 0.1);
padding: 12px 15px;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid #eee;
}
.error-message {
margin: 0;
color: #f44336;
font-weight: 500;
}
.error-date {
color: #777;
font-size: 0.85rem;
}
.error-details {
padding: 15px;
font-size: 0.9rem;
}
.error-path {
margin-bottom: 8px;
font-family: monospace;
background-color: #f5f5f5;
padding: 3px 8px;
border-radius: 4px;
display: inline-block;
}
.error-stack {
background-color: #282c34;
color: #e0e0e0;
padding: 10px;
border-radius: 4px;
font-family: monospace;
white-space: pre-wrap;
font-size: 0.8rem;
max-height: 150px;
overflow-y: auto;
}
.button {
background-color: var(--primary-color);
color: white;
border: none;
padding: 10px 15px;
border-radius: var(--border-radius);
cursor: pointer;
font-weight: 500;
transition: var(--transition);
text-align: center;
display: inline-block;
margin: 10px 0;
}
.button:hover {
background-color: #303f9f;
box-shadow: var(--shadow);
}
.button-danger {
background-color: #f44336;
}
.button-danger:hover {
background-color: #d32f2f;
}
.button-success-action {
display: none;
background-color: #4CAF50;
color: white;
padding: 5px 10px;
border-radius: 4px;
position: fixed;
top: 20px;
right: 20px;
z-index: 1000;
box-shadow: 0 2px 10px rgba(0,0,0,0.2);
animation: fadeOut 3s forwards;
}
@keyframes fadeOut {
0% { opacity: 1; }
70% { opacity: 1; }
100% { opacity: 0; }
}
@media (max-width: 768px) {
.container {
padding: 15px;
}
.grid {
grid-template-columns: 1fr;
}
.modal-content {
width: 95%;
margin: 10% auto;
}
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>Multy Stub v${pkg.version}</h1>
<div class="stats-card">
<div class="stats-value">${totalEndpoints}</div>
<div class="stats-label">Всего эндпоинтов</div>
</div>
</div>
<div class="section">
<h2>Routers:</h2>
<div class="grid">
${routerFolders.map((f) => `
<div class="stats-card" style="background: linear-gradient(135deg, #26c6da, #00acc1);">
<div class="stats-value">${f}</div>
</div>
`).join('')}
</div>
</div>
<div class="section">
<h2>Структура директорий проекта:</h2>
${generateDirList(directoryResult.items)}
</div>
<div class="section">
<h2>Models:</h2>
<div class="models-grid">
${
(await Promise.all(
(await mongoose.modelNames()).map(async (name) => {
const model = mongoose.model(name);
const count = await model.countDocuments();
// Получаем информацию о полях модели
const schema = model.schema;
const fields = Object.keys(schema.paths).filter(field => !['__v', '_id'].includes(field));
return `
<div class="model-card">
<div class="model-header">
<h3 class="model-name">${name}</h3>
<p class="model-count">${count} документов</p>
</div>
<div class="model-details">
<div class="model-fields">
<h4>Поля:</h4>
<ul class="fields-list">
${fields.map(field => {
const fieldType = schema.paths[field].instance;
return `<li><span class="field-name">${field}</span>: <span class="field-type">${fieldType}</span></li>`;
}).join('')}
</ul>
</div>
</div>
</div>
`;
}
)
)).join('')
}
</div>
</div>
<div class="section">
<h2>Последние ошибки:</h2>
<button id="clearErrorsBtn" class="button button-danger">Очистить ошибки старше 10 дней</button>
<div id="errorsContainer">
${errorsHtml}
</div>
</div>
</div>
<!-- Модальное окно для отображения содержимого файла -->
<div id="fileModal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h3 class="modal-title" id="modalFileName">File Name</h3>
<span class="close-modal">&times;</span>
</div>
<div id="fileContentContainer" class="file-content">
<div id="fileLoader" class="loader"></div>
<pre><code id="fileContent"></code></pre>
</div>
</div>
</div>
<div id="successAction" class="button-success-action">Операция выполнена успешно</div>
<script>${clientScript}</script>
</body>
</html>
`)
})
// Эндпоинт для очистки ошибок старше 10 дней
router.post('/clear-old-errors', async (req, res) => {
try {
const tenDaysAgo = new Date();
tenDaysAgo.setDate(tenDaysAgo.getDate() - 10);
const result = await ErrorLog.deleteMany({ createdAt: { $lt: tenDaysAgo } });
res.json({
success: true,
deletedCount: result.deletedCount || 0
});
} catch (error) {
console.error('Ошибка при очистке старых ошибок:', error);
res.status(500).json({
success: false,
error: 'Ошибка при очистке старых ошибок'
});
}
});
export default router