feat: prisma schema, groups API, deploy workflow
This commit is contained in:
+1
-1
@@ -1,4 +1,4 @@
|
||||
# Nanaxe Server Configuration
|
||||
# XFlow Server Configuration
|
||||
# Copy this file to .env and update the values
|
||||
|
||||
# Domain configuration
|
||||
|
||||
Generated
-5453
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "nanaxe-api-server",
|
||||
"name": "xflow-api-server",
|
||||
"version": "1.0.0",
|
||||
"description": "API server for Nanaxe CRM mindmap synchronization",
|
||||
"description": "API server for XFlow CRM",
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
"start": "node src/index.js",
|
||||
@@ -31,4 +31,4 @@
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,8 +5,6 @@ const morgan = require('morgan');
|
||||
const rateLimit = require('express-rate-limit');
|
||||
require('dotenv').config();
|
||||
|
||||
const mindmapRoutes = require('./routes/mindmap');
|
||||
const syncRoutes = require('./routes/sync');
|
||||
const authRoutes = require('./routes/auth');
|
||||
const { errorHandler } = require('./middleware/errorHandler');
|
||||
|
||||
@@ -37,8 +35,8 @@ app.use(morgan('combined'));
|
||||
|
||||
// Health check
|
||||
app.get('/health', (req, res) => {
|
||||
res.json({
|
||||
status: 'ok',
|
||||
res.json({
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
version: '1.0.0'
|
||||
});
|
||||
@@ -46,8 +44,6 @@ app.get('/health', (req, res) => {
|
||||
|
||||
// API Routes
|
||||
app.use('/api/auth', authRoutes);
|
||||
app.use('/api/mindmaps', mindmapRoutes);
|
||||
app.use('/api/sync', syncRoutes);
|
||||
|
||||
// Error handling
|
||||
app.use(errorHandler);
|
||||
@@ -58,7 +54,5 @@ app.use((req, res) => {
|
||||
});
|
||||
|
||||
app.listen(PORT, '0.0.0.0', () => {
|
||||
console.log(`🚀 Nanaxe API Server running on port ${PORT}`);
|
||||
console.log(`📁 Mindmap storage: ${process.env.MINDMAP_STORAGE_PATH || '/app/mindmaps'}`);
|
||||
console.log(`🔗 Gitea URL: ${process.env.GITEA_URL || 'http://gitea:3000'}`);
|
||||
console.log(`🚀 XFlow API Server running on port ${PORT}`);
|
||||
});
|
||||
|
||||
@@ -1,266 +0,0 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const fs = require('fs-extra');
|
||||
const path = require('path');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const { authenticateToken } = require('../middleware/auth');
|
||||
const MarkdownConverter = require('../utils/markdownConverter');
|
||||
|
||||
const STORAGE_PATH = process.env.MINDMAP_STORAGE_PATH || '/app/mindmaps';
|
||||
|
||||
// Ensure storage directory exists
|
||||
fs.ensureDirSync(STORAGE_PATH);
|
||||
|
||||
/**
|
||||
* GET /api/mindmaps - List all mindmaps
|
||||
*/
|
||||
router.get('/', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const userId = req.user.id;
|
||||
const userDir = path.join(STORAGE_PATH, userId);
|
||||
|
||||
await fs.ensureDir(userDir);
|
||||
|
||||
const files = await fs.readdir(userDir);
|
||||
const mindmaps = [];
|
||||
|
||||
for (const file of files) {
|
||||
if (file.endsWith('.md')) {
|
||||
const filePath = path.join(userDir, file);
|
||||
const content = await fs.readFile(filePath, 'utf-8');
|
||||
const mindmap = MarkdownConverter.fromMarkdown(content);
|
||||
|
||||
mindmaps.push({
|
||||
id: mindmap.id,
|
||||
title: mindmap.title,
|
||||
description: mindmap.description,
|
||||
nodeCount: mindmap.nodes.length,
|
||||
updatedAt: mindmap.updatedAt,
|
||||
filename: file
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by updatedAt desc
|
||||
mindmaps.sort((a, b) => new Date(b.updatedAt) - new Date(a.updatedAt));
|
||||
|
||||
res.json({
|
||||
count: mindmaps.length,
|
||||
mindmaps
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error listing mindmaps:', error);
|
||||
res.status(500).json({ error: 'Failed to list mindmaps' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/mindmaps/:id - Get single mindmap
|
||||
*/
|
||||
router.get('/:id', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const userId = req.user.id;
|
||||
const filePath = path.join(STORAGE_PATH, userId, `${id}.md`);
|
||||
|
||||
if (!await fs.pathExists(filePath)) {
|
||||
return res.status(404).json({ error: 'Mindmap not found' });
|
||||
}
|
||||
|
||||
const content = await fs.readFile(filePath, 'utf-8');
|
||||
const mindmap = MarkdownConverter.fromMarkdown(content, id);
|
||||
|
||||
res.json(mindmap);
|
||||
} catch (error) {
|
||||
console.error('Error getting mindmap:', error);
|
||||
res.status(500).json({ error: 'Failed to get mindmap' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/mindmaps - Create new mindmap
|
||||
*/
|
||||
router.post('/', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const userId = req.user.id;
|
||||
const { title, description, nodes = [], settings = {} } = req.body;
|
||||
|
||||
if (!title) {
|
||||
return res.status(400).json({ error: 'Title is required' });
|
||||
}
|
||||
|
||||
const id = uuidv4();
|
||||
const now = new Date().toISOString();
|
||||
|
||||
const mindmap = {
|
||||
id,
|
||||
title,
|
||||
description: description || '',
|
||||
nodes: nodes.length > 0 ? nodes : [{
|
||||
id: uuidv4(),
|
||||
title: 'Root',
|
||||
level: 0,
|
||||
parentId: null,
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
}],
|
||||
settings,
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
};
|
||||
|
||||
const userDir = path.join(STORAGE_PATH, userId);
|
||||
await fs.ensureDir(userDir);
|
||||
|
||||
const markdown = MarkdownConverter.toMarkdown(mindmap);
|
||||
const filePath = path.join(userDir, `${id}.md`);
|
||||
|
||||
await fs.writeFile(filePath, markdown, 'utf-8');
|
||||
|
||||
res.status(201).json(mindmap);
|
||||
} catch (error) {
|
||||
console.error('Error creating mindmap:', error);
|
||||
res.status(500).json({ error: 'Failed to create mindmap' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PUT /api/mindmaps/:id - Update mindmap
|
||||
*/
|
||||
router.put('/:id', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const userId = req.user.id;
|
||||
const updates = req.body;
|
||||
|
||||
const filePath = path.join(STORAGE_PATH, userId, `${id}.md`);
|
||||
|
||||
if (!await fs.pathExists(filePath)) {
|
||||
return res.status(404).json({ error: 'Mindmap not found' });
|
||||
}
|
||||
|
||||
// Read existing
|
||||
const content = await fs.readFile(filePath, 'utf-8');
|
||||
const existing = MarkdownConverter.fromMarkdown(content, id);
|
||||
|
||||
// Merge updates
|
||||
const updated = {
|
||||
...existing,
|
||||
...updates,
|
||||
id, // Preserve ID
|
||||
updatedAt: new Date().toISOString()
|
||||
};
|
||||
|
||||
// Save
|
||||
const markdown = MarkdownConverter.toMarkdown(updated);
|
||||
await fs.writeFile(filePath, markdown, 'utf-8');
|
||||
|
||||
res.json(updated);
|
||||
} catch (error) {
|
||||
console.error('Error updating mindmap:', error);
|
||||
res.status(500).json({ error: 'Failed to update mindmap' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/mindmaps/:id - Delete mindmap
|
||||
*/
|
||||
router.delete('/:id', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const userId = req.user.id;
|
||||
const filePath = path.join(STORAGE_PATH, userId, `${id}.md`);
|
||||
|
||||
if (!await fs.pathExists(filePath)) {
|
||||
return res.status(404).json({ error: 'Mindmap not found' });
|
||||
}
|
||||
|
||||
await fs.remove(filePath);
|
||||
|
||||
res.json({ message: 'Mindmap deleted successfully' });
|
||||
} catch (error) {
|
||||
console.error('Error deleting mindmap:', error);
|
||||
res.status(500).json({ error: 'Failed to delete mindmap' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/mindmaps/:id/markdown - Get mindmap as raw markdown
|
||||
*/
|
||||
router.get('/:id/markdown', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const userId = req.user.id;
|
||||
const filePath = path.join(STORAGE_PATH, userId, `${id}.md`);
|
||||
|
||||
if (!await fs.pathExists(filePath)) {
|
||||
return res.status(404).json({ error: 'Mindmap not found' });
|
||||
}
|
||||
|
||||
const content = await fs.readFile(filePath, 'utf-8');
|
||||
|
||||
res.setHeader('Content-Type', 'text/markdown');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${id}.md"`);
|
||||
res.send(content);
|
||||
} catch (error) {
|
||||
console.error('Error getting markdown:', error);
|
||||
res.status(500).json({ error: 'Failed to get markdown' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/mindmaps/:id/markdown - Update mindmap from markdown
|
||||
*/
|
||||
router.post('/:id/markdown', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const userId = req.user.id;
|
||||
const { markdown } = req.body;
|
||||
|
||||
if (!markdown) {
|
||||
return res.status(400).json({ error: 'Markdown content is required' });
|
||||
}
|
||||
|
||||
const userDir = path.join(STORAGE_PATH, userId);
|
||||
await fs.ensureDir(userDir);
|
||||
|
||||
const filePath = path.join(userDir, `${id}.md`);
|
||||
|
||||
await fs.writeFile(filePath, markdown, 'utf-8');
|
||||
|
||||
// Parse and return
|
||||
const mindmap = MarkdownConverter.fromMarkdown(markdown, id);
|
||||
|
||||
res.json(mindmap);
|
||||
} catch (error) {
|
||||
console.error('Error saving markdown:', error);
|
||||
res.status(500).json({ error: 'Failed to save markdown' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/mindmaps/:id/markmap - Get mindmap in Markmap format
|
||||
*/
|
||||
router.get('/:id/markmap', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const userId = req.user.id;
|
||||
const filePath = path.join(STORAGE_PATH, userId, `${id}.md`);
|
||||
|
||||
if (!await fs.pathExists(filePath)) {
|
||||
return res.status(404).json({ error: 'Mindmap not found' });
|
||||
}
|
||||
|
||||
const content = await fs.readFile(filePath, 'utf-8');
|
||||
const mindmap = MarkdownConverter.fromMarkdown(content, id);
|
||||
const markmap = MarkdownConverter.toMarkmap(mindmap);
|
||||
|
||||
res.setHeader('Content-Type', 'text/markdown');
|
||||
res.send(markmap);
|
||||
} catch (error) {
|
||||
console.error('Error getting markmap:', error);
|
||||
res.status(500).json({ error: 'Failed to get markmap' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -1,268 +0,0 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const fs = require('fs-extra');
|
||||
const path = require('path');
|
||||
const simpleGit = require('simple-git');
|
||||
const { authenticateToken } = require('../middleware/auth');
|
||||
const MarkdownConverter = require('../utils/markdownConverter');
|
||||
|
||||
const STORAGE_PATH = process.env.MINDMAP_STORAGE_PATH || '/app/mindmaps';
|
||||
const GITEA_URL = process.env.GITEA_URL || 'http://gitea:3000';
|
||||
const GITEA_TOKEN = process.env.GITEA_TOKEN;
|
||||
|
||||
/**
|
||||
* POST /api/sync/gitea/push - Push mindmaps to Gitea repository
|
||||
*/
|
||||
router.post('/gitea/push', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const userId = req.user.id;
|
||||
const { repoName, commitMessage } = req.body;
|
||||
|
||||
const userDir = path.join(STORAGE_PATH, userId);
|
||||
const repoDir = path.join(userDir, '.git-repo');
|
||||
|
||||
if (!await fs.pathExists(userDir)) {
|
||||
return res.status(404).json({ error: 'No mindmaps found' });
|
||||
}
|
||||
|
||||
// Initialize or open git repo
|
||||
const git = simpleGit(repoDir);
|
||||
|
||||
if (!await fs.pathExists(path.join(repoDir, '.git'))) {
|
||||
await fs.ensureDir(repoDir);
|
||||
await git.init();
|
||||
|
||||
// Configure git
|
||||
await git.addConfig('user.email', req.user.email || 'user@nanaxe.app');
|
||||
await git.addConfig('user.name', req.user.username || 'Nanaxe User');
|
||||
}
|
||||
|
||||
// Copy all mindmaps to repo
|
||||
const files = await fs.readdir(userDir);
|
||||
for (const file of files) {
|
||||
if (file.endsWith('.md')) {
|
||||
await fs.copy(
|
||||
path.join(userDir, file),
|
||||
path.join(repoDir, file)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Commit changes
|
||||
await git.add('.');
|
||||
const status = await git.status();
|
||||
|
||||
if (status.files.length === 0) {
|
||||
return res.json({ message: 'No changes to commit', synced: false });
|
||||
}
|
||||
|
||||
await git.commit(commitMessage || 'Sync mindmaps from Nanaxe CRM');
|
||||
|
||||
// Push to Gitea if configured
|
||||
if (GITEA_TOKEN) {
|
||||
const remoteUrl = `${GITEA_URL}/api/v1/repos/${req.user.username}/${repoName || 'mindmaps'}`;
|
||||
|
||||
try {
|
||||
// Check if remote exists
|
||||
const remotes = await git.getRemotes(true);
|
||||
const originExists = remotes.some(r => r.name === 'origin');
|
||||
|
||||
if (!originExists) {
|
||||
await git.addRemote('origin', remoteUrl);
|
||||
}
|
||||
|
||||
await git.push('origin', 'master');
|
||||
} catch (pushError) {
|
||||
console.error('Push error:', pushError);
|
||||
// Continue even if push fails - local commit is successful
|
||||
}
|
||||
}
|
||||
|
||||
res.json({
|
||||
message: 'Mindmaps synced successfully',
|
||||
synced: true,
|
||||
filesCommitted: status.files.length,
|
||||
commitMessage: commitMessage || 'Sync mindmaps from Nanaxe CRM'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error syncing to Gitea:', error);
|
||||
res.status(500).json({ error: 'Failed to sync mindmaps' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/sync/gitea/pull - Pull mindmaps from Gitea repository
|
||||
*/
|
||||
router.post('/gitea/pull', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const userId = req.user.id;
|
||||
const { repoName } = req.body;
|
||||
|
||||
const userDir = path.join(STORAGE_PATH, userId);
|
||||
const repoDir = path.join(userDir, '.git-repo');
|
||||
|
||||
if (!GITEA_TOKEN) {
|
||||
return res.status(400).json({ error: 'Gitea not configured' });
|
||||
}
|
||||
|
||||
const git = simpleGit(repoDir);
|
||||
|
||||
if (!await fs.pathExists(path.join(repoDir, '.git'))) {
|
||||
await fs.ensureDir(repoDir);
|
||||
|
||||
// Clone from Gitea
|
||||
const cloneUrl = `${GITEA_URL}/api/v1/repos/${req.user.username}/${repoName || 'mindmaps'}`;
|
||||
await git.clone(cloneUrl, repoDir);
|
||||
} else {
|
||||
// Pull latest
|
||||
await git.pull('origin', 'master');
|
||||
}
|
||||
|
||||
// Copy files from repo to storage
|
||||
const files = await fs.readdir(repoDir);
|
||||
let importedCount = 0;
|
||||
|
||||
for (const file of files) {
|
||||
if (file.endsWith('.md') && !file.startsWith('.')) {
|
||||
await fs.copy(
|
||||
path.join(repoDir, file),
|
||||
path.join(userDir, file),
|
||||
{ overwrite: true }
|
||||
);
|
||||
importedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
res.json({
|
||||
message: 'Mindmaps pulled successfully',
|
||||
imported: importedCount
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error pulling from Gitea:', error);
|
||||
res.status(500).json({ error: 'Failed to pull mindmaps' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/sync/status - Get sync status
|
||||
*/
|
||||
router.get('/status', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const userId = req.user.id;
|
||||
const userDir = path.join(STORAGE_PATH, userId);
|
||||
const repoDir = path.join(userDir, '.git-repo');
|
||||
|
||||
const status = {
|
||||
hasLocalData: await fs.pathExists(userDir),
|
||||
hasGitRepo: await fs.pathExists(path.join(repoDir, '.git')),
|
||||
giteaConfigured: !!GITEA_TOKEN,
|
||||
lastSync: null,
|
||||
pendingChanges: 0
|
||||
};
|
||||
|
||||
if (status.hasGitRepo) {
|
||||
const git = simpleGit(repoDir);
|
||||
const gitStatus = await git.status();
|
||||
status.pendingChanges = gitStatus.files.length;
|
||||
|
||||
// Get last commit date
|
||||
try {
|
||||
const log = await git.log({ maxCount: 1 });
|
||||
if (log.latest) {
|
||||
status.lastSync = log.latest.date;
|
||||
}
|
||||
} catch (e) {
|
||||
// No commits yet
|
||||
}
|
||||
}
|
||||
|
||||
res.json(status);
|
||||
} catch (error) {
|
||||
console.error('Error getting sync status:', error);
|
||||
res.status(500).json({ error: 'Failed to get sync status' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/sync/export - Export all mindmaps as zip
|
||||
*/
|
||||
router.post('/export', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const userId = req.user.id;
|
||||
const userDir = path.join(STORAGE_PATH, userId);
|
||||
|
||||
if (!await fs.pathExists(userDir)) {
|
||||
return res.status(404).json({ error: 'No mindmaps found' });
|
||||
}
|
||||
|
||||
// Create temporary zip
|
||||
const archiver = require('archiver');
|
||||
const archive = archiver('zip', { zlib: { level: 9 } });
|
||||
|
||||
res.setHeader('Content-Type', 'application/zip');
|
||||
res.setHeader('Content-Disposition', 'attachment; filename="nanaxe-mindmaps.zip"');
|
||||
|
||||
archive.pipe(res);
|
||||
|
||||
const files = await fs.readdir(userDir);
|
||||
for (const file of files) {
|
||||
if (file.endsWith('.md')) {
|
||||
archive.file(path.join(userDir, file), { name: file });
|
||||
}
|
||||
}
|
||||
|
||||
await archive.finalize();
|
||||
} catch (error) {
|
||||
console.error('Error exporting mindmaps:', error);
|
||||
res.status(500).json({ error: 'Failed to export mindmaps' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/sync/import - Import mindmaps from files
|
||||
*/
|
||||
router.post('/import', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const userId = req.user.id;
|
||||
const { files } = req.body; // Array of { filename, content }
|
||||
|
||||
if (!files || !Array.isArray(files)) {
|
||||
return res.status(400).json({ error: 'Files array required' });
|
||||
}
|
||||
|
||||
const userDir = path.join(STORAGE_PATH, userId);
|
||||
await fs.ensureDir(userDir);
|
||||
|
||||
const results = {
|
||||
imported: 0,
|
||||
errors: []
|
||||
};
|
||||
|
||||
for (const file of files) {
|
||||
try {
|
||||
if (!file.filename.endsWith('.md')) {
|
||||
results.errors.push({ file: file.filename, error: 'Not a markdown file' });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Validate markdown
|
||||
const mindmap = MarkdownConverter.fromMarkdown(file.content);
|
||||
|
||||
// Save
|
||||
const filePath = path.join(userDir, file.filename);
|
||||
await fs.writeFile(filePath, file.content, 'utf-8');
|
||||
|
||||
results.imported++;
|
||||
} catch (error) {
|
||||
results.errors.push({ file: file.filename, error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
res.json(results);
|
||||
} catch (error) {
|
||||
console.error('Error importing mindmaps:', error);
|
||||
res.status(500).json({ error: 'Failed to import mindmaps' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -1,261 +0,0 @@
|
||||
/**
|
||||
* Конвертер между MindMap форматом и Markdown (Obsidian-совместимый)
|
||||
*
|
||||
* Obsidian MindMap формат:
|
||||
* - Заголовки (# ## ###) = уровни узлов
|
||||
* - Списки (- *) = дочерние узлы
|
||||
* - [[ссылки]] = внутренние связи
|
||||
* - Теги #тег = метки узлов
|
||||
*/
|
||||
|
||||
const YAML = require('yaml');
|
||||
|
||||
class MarkdownConverter {
|
||||
/**
|
||||
* Конвертирует MindMap из JSON в Markdown
|
||||
* @param {Object} mindmap - Объект mindmap
|
||||
* @returns {string} Markdown строка
|
||||
*/
|
||||
static toMarkdown(mindmap) {
|
||||
const { id, title, description, nodes, settings = {} } = mindmap;
|
||||
|
||||
// Frontmatter с метаданными
|
||||
const frontmatter = {
|
||||
id,
|
||||
title,
|
||||
description,
|
||||
createdAt: mindmap.createdAt,
|
||||
updatedAt: mindmap.updatedAt,
|
||||
...settings
|
||||
};
|
||||
|
||||
let markdown = '---\n';
|
||||
markdown += YAML.stringify(frontmatter);
|
||||
markdown += '---\n\n';
|
||||
|
||||
// Заголовок mindmap
|
||||
markdown += `# ${title}\n\n`;
|
||||
|
||||
if (description) {
|
||||
markdown += `> ${description}\n\n`;
|
||||
}
|
||||
|
||||
// Конвертируем узлы в иерархию
|
||||
if (nodes && nodes.length > 0) {
|
||||
const rootNodes = nodes.filter(n => n.level === 0 || !n.parentId);
|
||||
|
||||
for (const root of rootNodes) {
|
||||
markdown += this._nodeToMarkdown(nodes, root, 0);
|
||||
}
|
||||
}
|
||||
|
||||
return markdown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Рекурсивно конвертирует узел в Markdown
|
||||
*/
|
||||
static _nodeToMarkdown(allNodes, node, level) {
|
||||
const indent = ' '.repeat(level);
|
||||
let markdown = '';
|
||||
|
||||
// Заголовок или список в зависимости от уровня
|
||||
if (level === 0) {
|
||||
markdown += `## ${node.title}\n`;
|
||||
} else if (level === 1) {
|
||||
markdown += `### ${node.title}\n`;
|
||||
} else if (level === 2) {
|
||||
markdown += `#### ${node.title}\n`;
|
||||
} else {
|
||||
markdown += `${indent}- ${node.title}\n`;
|
||||
}
|
||||
|
||||
// Добавляем метаданные узла
|
||||
if (node.taskId) {
|
||||
markdown += `${indent} - Task: [[${node.taskId}]]\n`;
|
||||
}
|
||||
if (node.color) {
|
||||
markdown += `${indent} - Color: \`${node.color}\`\n`;
|
||||
}
|
||||
|
||||
// Находим дочерние узлы
|
||||
const children = allNodes.filter(n => n.parentId === node.id);
|
||||
|
||||
for (const child of children) {
|
||||
markdown += this._nodeToMarkdown(allNodes, child, level + 1);
|
||||
}
|
||||
|
||||
markdown += '\n';
|
||||
return markdown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Парсит Markdown в MindMap JSON
|
||||
* @param {string} markdown - Markdown строка
|
||||
* @returns {Object} Объект mindmap
|
||||
*/
|
||||
static fromMarkdown(markdown, existingId = null) {
|
||||
const lines = markdown.split('\n');
|
||||
|
||||
// Парсим frontmatter
|
||||
let frontmatter = {};
|
||||
let contentStart = 0;
|
||||
|
||||
if (lines[0] === '---') {
|
||||
const endIndex = lines.indexOf('---', 1);
|
||||
if (endIndex !== -1) {
|
||||
const yamlContent = lines.slice(1, endIndex).join('\n');
|
||||
frontmatter = YAML.parse(yamlContent) || {};
|
||||
contentStart = endIndex + 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Парсим контент
|
||||
const content = lines.slice(contentStart).join('\n');
|
||||
const nodes = this._parseNodes(content);
|
||||
|
||||
return {
|
||||
id: existingId || frontmatter.id || this._generateId(),
|
||||
title: frontmatter.title || this._extractTitle(content) || 'Untitled MindMap',
|
||||
description: frontmatter.description || '',
|
||||
nodes,
|
||||
settings: frontmatter.settings || {},
|
||||
createdAt: frontmatter.createdAt || new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString()
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Парсит узлы из Markdown контента
|
||||
*/
|
||||
static _parseNodes(content) {
|
||||
const lines = content.split('\n');
|
||||
const nodes = [];
|
||||
const nodeStack = []; // Стек для отслеживания родительских узлов
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
|
||||
// Определяем уровень и тип
|
||||
let level = 0;
|
||||
let title = '';
|
||||
let parentId = null;
|
||||
|
||||
// Заголовки H2-H4
|
||||
const headingMatch = trimmed.match(/^(#{2,4})\s+(.+)$/);
|
||||
if (headingMatch) {
|
||||
level = headingMatch[1].length - 1; // ## = 1, ### = 2, #### = 3
|
||||
title = headingMatch[2].trim();
|
||||
}
|
||||
// Списки
|
||||
else if (trimmed.match(/^[-*]\s+/)) {
|
||||
const indent = line.search(/\S/);
|
||||
level = Math.floor(indent / 2) + 3;
|
||||
title = trimmed.replace(/^[-*]\s+/, '');
|
||||
}
|
||||
// Пропускаем H1 (это заголовок карты)
|
||||
else if (trimmed.startsWith('# ')) {
|
||||
continue;
|
||||
}
|
||||
else {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Находим родителя
|
||||
while (nodeStack.length > 0 && nodeStack[nodeStack.length - 1].level >= level) {
|
||||
nodeStack.pop();
|
||||
}
|
||||
|
||||
if (nodeStack.length > 0) {
|
||||
parentId = nodeStack[nodeStack.length - 1].id;
|
||||
}
|
||||
|
||||
const node = {
|
||||
id: this._generateId(),
|
||||
title,
|
||||
level,
|
||||
parentId,
|
||||
color: this._extractColor(trimmed),
|
||||
taskId: this._extractTaskId(trimmed),
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString()
|
||||
};
|
||||
|
||||
nodes.push(node);
|
||||
nodeStack.push({ id: node.id, level });
|
||||
}
|
||||
|
||||
return nodes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Извлекает цвет из строки
|
||||
*/
|
||||
static _extractColor(line) {
|
||||
const colorMatch = line.match(/`#([0-9A-Fa-f]{6})`/);
|
||||
return colorMatch ? `#${colorMatch[1]}` : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Извлекает ID задачи из ссылки
|
||||
*/
|
||||
static _extractTaskId(line) {
|
||||
const taskMatch = line.match(/\[\[([^\]]+)\]\]/);
|
||||
return taskMatch ? taskMatch[1] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Извлекает заголовок из контента
|
||||
*/
|
||||
static _extractTitle(content) {
|
||||
const match = content.match(/^#\s+(.+)$/m);
|
||||
return match ? match[1].trim() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Генерирует уникальный ID
|
||||
*/
|
||||
static _generateId() {
|
||||
return 'mm_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
|
||||
}
|
||||
|
||||
/**
|
||||
* Конвертирует MindMap в формат Markmap (для визуализации)
|
||||
* @param {Object} mindmap - Объект mindmap
|
||||
* @returns {string} Markmap Markdown
|
||||
*/
|
||||
static toMarkmap(mindmap) {
|
||||
const { title, nodes } = mindmap;
|
||||
|
||||
let markdown = `# ${title}\n\n`;
|
||||
|
||||
if (nodes && nodes.length > 0) {
|
||||
const rootNodes = nodes.filter(n => n.level === 0 || !n.parentId);
|
||||
|
||||
for (const root of rootNodes) {
|
||||
markdown += this._nodeToMarkmap(nodes, root, 0);
|
||||
}
|
||||
}
|
||||
|
||||
return markdown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Рекурсивно конвертирует узел в Markmap формат
|
||||
*/
|
||||
static _nodeToMarkmap(allNodes, node, level) {
|
||||
const indent = ' '.repeat(level);
|
||||
let markdown = `${indent}- ${node.title}\n`;
|
||||
|
||||
const children = allNodes.filter(n => n.parentId === node.id);
|
||||
|
||||
for (const child of children) {
|
||||
markdown += this._nodeToMarkmap(allNodes, child, level + 1);
|
||||
}
|
||||
|
||||
return markdown;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = MarkdownConverter;
|
||||
@@ -1,4 +1,4 @@
|
||||
# Nanaxe Server + Consyscab Integration
|
||||
# XFlow Server + Consyscab Integration
|
||||
# Объединённая конфигурация для совместного развёртывания
|
||||
|
||||
services:
|
||||
@@ -15,8 +15,6 @@ services:
|
||||
volumes:
|
||||
- pg_data:/var/lib/postgresql/data
|
||||
- ./docker/postgres/init-db.sh:/docker-entrypoint-initdb.d/init-db.sh:ro
|
||||
# Nanaxe: инициализация базы Gitea
|
||||
- ./nanaxe/postgres-init:/docker-entrypoint-initdb.d/nanaxe:ro
|
||||
networks:
|
||||
- consyscab-network
|
||||
healthcheck:
|
||||
@@ -47,7 +45,7 @@ services:
|
||||
- consyscab-network
|
||||
|
||||
# ==========================================
|
||||
# 3. Nginx (Consyscab + Nanaxe)
|
||||
# 3. Nginx (Consyscab + XFlow)
|
||||
# ==========================================
|
||||
consyscab_nginx:
|
||||
image: nginx:alpine
|
||||
@@ -59,13 +57,12 @@ services:
|
||||
- ./backend:/var/www/symfony
|
||||
- ./moodle:/var/www/moodle
|
||||
- ./docker/nginx/conf.d:/etc/nginx/conf.d
|
||||
# Nanaxe: дополнительная конфигурация
|
||||
- ./nanaxe/nginx:/etc/nginx/conf.d/nanaxe:ro
|
||||
- ./nanaxe/html:/usr/share/nginx/html/nanaxe:ro
|
||||
# XFlow: дополнительная конфигурация
|
||||
- ./xflow/nginx:/etc/nginx/conf.d/xflow:ro
|
||||
- ./xflow/html:/usr/share/nginx/html/xflow:ro
|
||||
depends_on:
|
||||
- consyscab_php
|
||||
- nanaxe_gitea
|
||||
- nanaxe_api
|
||||
- xflow_gitea
|
||||
networks:
|
||||
- consyscab-network
|
||||
|
||||
@@ -124,11 +121,11 @@ services:
|
||||
- consyscab-network
|
||||
|
||||
# ==========================================
|
||||
# 8. Nanaxe Gitea (Git сервер)
|
||||
# 8. XFlow Gitea (Git сервер)
|
||||
# ==========================================
|
||||
nanaxe_gitea:
|
||||
xflow_gitea:
|
||||
image: gitea/gitea:latest
|
||||
container_name: nanaxe_gitea
|
||||
container_name: xflow_gitea
|
||||
environment:
|
||||
- USER_UID=1000
|
||||
- USER_GID=1000
|
||||
@@ -162,49 +159,6 @@ services:
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
|
||||
# ==========================================
|
||||
# 9. Nanaxe API Server
|
||||
# ==========================================
|
||||
nanaxe_api:
|
||||
build:
|
||||
context: ./nanaxe/api
|
||||
dockerfile: Dockerfile
|
||||
container_name: nanaxe_api
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- PORT=8080
|
||||
- GITEA_URL=http://nanaxe_gitea:3000
|
||||
- GITEA_TOKEN=${GITEA_TOKEN}
|
||||
- JWT_SECRET=${NANAXE_JWT_SECRET}
|
||||
- MINDMAP_STORAGE_PATH=/app/mindmaps
|
||||
volumes:
|
||||
- mindmap_data:/app/mindmaps
|
||||
- ./nanaxe/logs:/app/logs
|
||||
depends_on:
|
||||
- nanaxe_gitea
|
||||
- consyscab_db
|
||||
networks:
|
||||
- consyscab-network
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:8080/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
|
||||
# ==========================================
|
||||
# 10. Nanaxe WebDAV (опционально)
|
||||
# ==========================================
|
||||
nanaxe_webdav:
|
||||
image: bytemark/webdav
|
||||
container_name: nanaxe_webdav
|
||||
environment:
|
||||
- AUTH_TYPE=Basic
|
||||
- USERNAME=${WEBDAV_USER:-admin}
|
||||
- PASSWORD=${WEBDAV_PASSWORD}
|
||||
volumes:
|
||||
- mindmap_data:/var/lib/dav
|
||||
networks:
|
||||
- consyscab-network
|
||||
|
||||
networks:
|
||||
consyscab-network:
|
||||
@@ -213,4 +167,3 @@ networks:
|
||||
volumes:
|
||||
pg_data:
|
||||
gitea_data:
|
||||
mindmap_data:
|
||||
|
||||
@@ -15,7 +15,7 @@ services:
|
||||
ports:
|
||||
- "5432:5432"
|
||||
|
||||
nanaxe-api:
|
||||
xflow-api:
|
||||
ports:
|
||||
- "8080:8080"
|
||||
environment:
|
||||
|
||||
@@ -9,10 +9,10 @@ services:
|
||||
# ==========================================
|
||||
gitea:
|
||||
image: gitea/gitea:latest-rootless
|
||||
container_name: nanaxe-gitea
|
||||
container_name: xflow-gitea
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- nanaxe-network
|
||||
- xflow-network
|
||||
volumes:
|
||||
- gitea-data:/data
|
||||
- /etc/timezone:/etc/timezone:ro
|
||||
@@ -56,10 +56,10 @@ services:
|
||||
# ==========================================
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
container_name: nanaxe-postgres
|
||||
container_name: xflow-postgres
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- nanaxe-network
|
||||
- xflow-network
|
||||
volumes:
|
||||
- postgres-data:/var/lib/postgresql/data
|
||||
- ./backups:/backups
|
||||
@@ -82,65 +82,15 @@ services:
|
||||
cpus: '0.1'
|
||||
memory: 64M
|
||||
|
||||
# ==========================================
|
||||
# Nanaxe API - Production
|
||||
# ==========================================
|
||||
nanaxe-api:
|
||||
image: ghcr.io/${GITHUB_REPOSITORY:-nanaxe}/nanaxe-api:latest
|
||||
container_name: nanaxe-api
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- nanaxe-network
|
||||
volumes:
|
||||
- mindmap-data:/app/mindmaps
|
||||
- api-logs:/app/logs
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- PORT=8080
|
||||
- GITEA_URL=http://gitea:3000
|
||||
- GITEA_TOKEN=${GITEA_TOKEN}
|
||||
- JWT_SECRET=${JWT_SECRET}
|
||||
- MINDMAP_STORAGE_PATH=/app/mindmaps
|
||||
- LOG_LEVEL=info
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:8080/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: '0.5'
|
||||
memory: 256M
|
||||
reservations:
|
||||
cpus: '0.1'
|
||||
memory: 64M
|
||||
replicas: 1
|
||||
update_config:
|
||||
parallelism: 1
|
||||
delay: 10s
|
||||
failure_action: rollback
|
||||
order: start-first
|
||||
rollback_config:
|
||||
parallelism: 1
|
||||
delay: 10s
|
||||
failure_action: pause
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
gitea:
|
||||
condition: service_healthy
|
||||
|
||||
# ==========================================
|
||||
# Nginx - Production with SSL
|
||||
# ==========================================
|
||||
nginx:
|
||||
image: nginx:alpine
|
||||
container_name: nanaxe-nginx
|
||||
container_name: xflow-nginx
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- nanaxe-network
|
||||
- xflow-network
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
@@ -162,17 +112,16 @@ services:
|
||||
memory: 128M
|
||||
depends_on:
|
||||
- gitea
|
||||
- nanaxe-api
|
||||
|
||||
# ==========================================
|
||||
# Certbot - SSL Certificates
|
||||
# ==========================================
|
||||
certbot:
|
||||
image: certbot/certbot
|
||||
container_name: nanaxe-certbot
|
||||
container_name: xflow-certbot
|
||||
restart: "no"
|
||||
networks:
|
||||
- nanaxe-network
|
||||
- xflow-network
|
||||
volumes:
|
||||
- certbot-data:/etc/letsencrypt
|
||||
- certbot-www:/var/www/certbot
|
||||
@@ -185,10 +134,10 @@ services:
|
||||
# ==========================================
|
||||
watchtower:
|
||||
image: containrrr/watchtower
|
||||
container_name: nanaxe-watchtower
|
||||
container_name: xflow-watchtower
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- nanaxe-network
|
||||
- xflow-network
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
environment:
|
||||
@@ -207,10 +156,10 @@ services:
|
||||
# ==========================================
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: nanaxe-redis
|
||||
container_name: xflow-redis
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- nanaxe-network
|
||||
- xflow-network
|
||||
volumes:
|
||||
- redis-data:/data
|
||||
command: redis-server --appendonly yes --maxmemory 64mb --maxmemory-policy allkeys-lru
|
||||
@@ -228,7 +177,7 @@ services:
|
||||
- cache
|
||||
|
||||
networks:
|
||||
nanaxe-network:
|
||||
xflow-network:
|
||||
driver: bridge
|
||||
ipam:
|
||||
config:
|
||||
@@ -239,8 +188,6 @@ volumes:
|
||||
driver: local
|
||||
postgres-data:
|
||||
driver: local
|
||||
mindmap-data:
|
||||
driver: local
|
||||
api-logs:
|
||||
driver: local
|
||||
certbot-data:
|
||||
|
||||
+72
-96
@@ -1,121 +1,97 @@
|
||||
version: '3.8'
|
||||
|
||||
# Nanaxe Server - Mindmap Synchronization
|
||||
# Run alongside other Docker projects by changing ports
|
||||
# Инфраструктура AVTEX
|
||||
# FastPanel проксирует порты напрямую — nginx контейнер не нужен
|
||||
# Один postgres для всех проектов организации
|
||||
|
||||
services:
|
||||
# Gitea - Self-hosted Git server для хранения markdown-файлов
|
||||
gitea:
|
||||
image: gitea/gitea:latest
|
||||
container_name: nanaxe-gitea
|
||||
environment:
|
||||
- USER_UID=1000
|
||||
- USER_GID=1000
|
||||
- GITEA__database__DB_TYPE=postgres
|
||||
- GITEA__database__HOST=postgres:5432
|
||||
- GITEA__database__NAME=gitea
|
||||
- GITEA__database__USER=gitea
|
||||
- GITEA__database__PASSWD=gitea_password
|
||||
- GITEA__server__DOMAIN=git.a-crm.controlsystem.kz
|
||||
- GITEA__server__ROOT_URL=http://git.a-crm.controlsystem.kz/
|
||||
- GITEA__server__SSH_DOMAIN=git.a-crm.controlsystem.kz
|
||||
- GITEA__server__SSH_PORT=2221
|
||||
- GITEA__server__SSH_LISTEN_PORT=22
|
||||
- GITEA__security__INSTALL_LOCK=true
|
||||
- GITEA__service__DISABLE_REGISTRATION=false
|
||||
- GITEA__service__REQUIRE_SIGNIN_VIEW=false
|
||||
|
||||
# PostgreSQL — общий для всех проектов
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
container_name: postgres
|
||||
restart: always
|
||||
networks:
|
||||
- nanaxe-network
|
||||
- infra-network
|
||||
ports:
|
||||
- "5432:5432"
|
||||
environment:
|
||||
POSTGRES_USER: ${POSTGRES_USER:-admin}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
POSTGRES_DB: postgres
|
||||
volumes:
|
||||
- postgres-data:/var/lib/postgresql/data
|
||||
- /etc/timezone:/etc/timezone:ro
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
|
||||
# Gitea — Git сервер организации AVTEX
|
||||
gitea:
|
||||
image: gitea/gitea:latest
|
||||
container_name: gitea
|
||||
restart: always
|
||||
networks:
|
||||
- infra-network
|
||||
ports:
|
||||
- "3001:3000"
|
||||
- "2221:22"
|
||||
environment:
|
||||
USER_UID: 1000
|
||||
USER_GID: 1000
|
||||
GITEA__database__DB_TYPE: postgres
|
||||
GITEA__database__HOST: postgres:5432
|
||||
GITEA__database__NAME: gitea
|
||||
GITEA__database__USER: gitea
|
||||
GITEA__database__PASSWD: ${GITEA_DB_PASSWORD:-gitea_password}
|
||||
GITEA__server__DOMAIN: git.a-crm.controlsystem.kz
|
||||
GITEA__server__ROOT_URL: http://git.a-crm.controlsystem.kz/
|
||||
GITEA__server__SSH_DOMAIN: git.a-crm.controlsystem.kz
|
||||
GITEA__server__SSH_PORT: 2221
|
||||
GITEA__server__SSH_LISTEN_PORT: 22
|
||||
GITEA__security__INSTALL_LOCK: "true"
|
||||
GITEA__service__DISABLE_REGISTRATION: "false"
|
||||
volumes:
|
||||
- gitea-data:/data
|
||||
- /etc/timezone:/etc/timezone:ro
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
ports:
|
||||
- "3001:3000" # Changed from 3000 to avoid conflicts
|
||||
- "2221:22" # Changed from 222 to avoid conflicts
|
||||
depends_on:
|
||||
- postgres
|
||||
|
||||
# PostgreSQL для Gitea
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
container_name: nanaxe-postgres
|
||||
ports:
|
||||
- "5433:5432" # Expose on different port
|
||||
environment:
|
||||
- POSTGRES_USER=gitea
|
||||
- POSTGRES_PASSWORD=gitea_password
|
||||
- POSTGRES_DB=gitea
|
||||
# XFlow — Mind Map приложение
|
||||
xflow:
|
||||
image: node:20-alpine
|
||||
container_name: xflow
|
||||
restart: always
|
||||
working_dir: /app
|
||||
command: node .output/server/index.mjs
|
||||
networks:
|
||||
- nanaxe-network
|
||||
volumes:
|
||||
- postgres-data:/var/lib/postgresql/data
|
||||
|
||||
# Nanaxe API Server - для синхронизации mindmaps
|
||||
nanaxe-api:
|
||||
build:
|
||||
context: ./api
|
||||
dockerfile: Dockerfile
|
||||
container_name: nanaxe-api
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- PORT=8080
|
||||
- GITEA_URL=http://gitea:3000
|
||||
- GITEA_TOKEN=${GITEA_TOKEN}
|
||||
- JWT_SECRET=${JWT_SECRET:-your-super-secret-jwt-key-change-in-production}
|
||||
- MINDMAP_STORAGE_PATH=/app/mindmaps
|
||||
restart: always
|
||||
networks:
|
||||
- nanaxe-network
|
||||
volumes:
|
||||
- mindmap-data:/app/mindmaps
|
||||
- ./api/logs:/app/logs
|
||||
- infra-network
|
||||
ports:
|
||||
- "8082:8080" # Changed from 8080 to avoid conflicts
|
||||
- "3000:3000"
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
PORT: 3000
|
||||
NUXT_JWT_SECRET: ${NUXT_JWT_SECRET}
|
||||
GITEA_URL: http://gitea:3000
|
||||
GITEA_TOKEN: ${GITEA_TOKEN}
|
||||
GITEA_ORG: ${GITEA_ORG:-AVTEX}
|
||||
GITEA_MINDMAP_REPO: ${GITEA_MINDMAP_REPO:-MMAVTEX}
|
||||
GITEA_PROJECT_REPO: ${GITEA_PROJECT_REPO:-XFlow}
|
||||
DATABASE_URL: postgresql://xflow:${XFLOW_DB_PASSWORD:-xflow_pass}@postgres:5432/xflow
|
||||
NUXT_PUBLIC_FIREBASE_API_KEY: ${NUXT_PUBLIC_FIREBASE_API_KEY}
|
||||
NUXT_PUBLIC_FIREBASE_AUTH_DOMAIN: ${NUXT_PUBLIC_FIREBASE_AUTH_DOMAIN}
|
||||
NUXT_PUBLIC_FIREBASE_PROJECT_ID: ${NUXT_PUBLIC_FIREBASE_PROJECT_ID}
|
||||
NUXT_PUBLIC_FIREBASE_APP_ID: ${NUXT_PUBLIC_FIREBASE_APP_ID}
|
||||
NUXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID: ${NUXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID}
|
||||
volumes:
|
||||
- /opt/xflow/.output:/app/.output:ro
|
||||
depends_on:
|
||||
- gitea
|
||||
- postgres
|
||||
|
||||
# Nginx - Reverse Proxy и статические файлы
|
||||
nginx:
|
||||
image: nginx:alpine
|
||||
container_name: nanaxe-nginx
|
||||
restart: always
|
||||
networks:
|
||||
- nanaxe-network
|
||||
volumes:
|
||||
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
|
||||
- ./nginx/html:/usr/share/nginx/html:ro
|
||||
ports:
|
||||
- "8083:80" # Changed from 80 to avoid conflicts
|
||||
- "8444:443" # Changed from 443 to avoid conflicts
|
||||
depends_on:
|
||||
- gitea
|
||||
- nanaxe-api
|
||||
|
||||
# (Опционально) WebDAV сервер для прямого доступа к файлам
|
||||
webdav:
|
||||
image: bytemark/webdav
|
||||
container_name: nanaxe-webdav
|
||||
restart: always
|
||||
networks:
|
||||
- nanaxe-network
|
||||
volumes:
|
||||
- mindmap-data:/var/lib/dav
|
||||
environment:
|
||||
- AUTH_TYPE=Basic
|
||||
- USERNAME=${WEBDAV_USER:-admin}
|
||||
- PASSWORD=${WEBDAV_PASSWORD:-admin}
|
||||
ports:
|
||||
- "8084:80" # Changed from 8081 to avoid conflicts
|
||||
|
||||
networks:
|
||||
nanaxe-network:
|
||||
infra-network:
|
||||
driver: bridge
|
||||
|
||||
volumes:
|
||||
gitea-data:
|
||||
postgres-data:
|
||||
mindmap-data:
|
||||
gitea-data:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
module.exports = {
|
||||
apps: [{
|
||||
name: 'nanaxe-api',
|
||||
name: 'xflow-api',
|
||||
script: './api/src/index.js',
|
||||
instances: 1,
|
||||
exec_mode: 'fork',
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Nanaxe Server</title>
|
||||
<title>XFlow Server</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
@@ -20,24 +21,24 @@
|
||||
justify-content: center;
|
||||
color: white;
|
||||
}
|
||||
|
||||
|
||||
.container {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
|
||||
h1 {
|
||||
font-size: 3rem;
|
||||
margin-bottom: 1rem;
|
||||
text-shadow: 2px 2px 4px rgba(0,0,0,0.3);
|
||||
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
|
||||
p {
|
||||
font-size: 1.2rem;
|
||||
margin-bottom: 2rem;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
|
||||
.services {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
@@ -45,7 +46,7 @@
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
|
||||
.service-card {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
backdrop-filter: blur(10px);
|
||||
@@ -54,22 +55,22 @@
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
transition: transform 0.3s, box-shadow 0.3s;
|
||||
}
|
||||
|
||||
|
||||
.service-card:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 10px 30px rgba(0,0,0,0.2);
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
|
||||
.service-card h3 {
|
||||
font-size: 1.3rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
|
||||
.service-card p {
|
||||
font-size: 0.95rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
|
||||
.service-card a {
|
||||
display: inline-block;
|
||||
padding: 0.5rem 1.5rem;
|
||||
@@ -80,12 +81,12 @@
|
||||
font-weight: 600;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
|
||||
.service-card a:hover {
|
||||
background: #f0f0f0;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
|
||||
.status {
|
||||
margin-top: 2rem;
|
||||
padding: 1rem;
|
||||
@@ -93,7 +94,7 @@
|
||||
border-radius: 8px;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
|
||||
.status-indicator {
|
||||
display: inline-block;
|
||||
width: 10px;
|
||||
@@ -103,42 +104,51 @@
|
||||
margin-right: 0.5rem;
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>🚀 Nanaxe Server</h1>
|
||||
<p>Self-hosted mindmap synchronization server</p>
|
||||
|
||||
<h1>🚀 XFlow Server</h1>
|
||||
<p>XFlow CRM Server</p>
|
||||
|
||||
<div class="services">
|
||||
<div class="service-card">
|
||||
<h3>📚 Git Repository</h3>
|
||||
<p>Access your mindmaps via Git</p>
|
||||
<p>Self-hosted Git server</p>
|
||||
<a href="/git/" target="_blank">Open Gitea</a>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="service-card">
|
||||
<h3>🔌 API</h3>
|
||||
<p>REST API for mindmap operations</p>
|
||||
<p>REST API for CRM operations</p>
|
||||
<a href="/api/health" target="_blank">Check API</a>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="service-card">
|
||||
<h3>📱 WebDAV</h3>
|
||||
<p>Direct file access</p>
|
||||
<a href="http://a-crm.controlsystem.kz:8084" target="_blank">Open WebDAV</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="status">
|
||||
<span class="status-indicator"></span>
|
||||
<span>Server is running</span>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
</html>
|
||||
@@ -42,7 +42,7 @@ http {
|
||||
|
||||
# API proxy
|
||||
location /api/ {
|
||||
set $api_backend nanaxe-api:8080;
|
||||
set $api_backend xflow-api:8080;
|
||||
proxy_pass http://$api_backend;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
@@ -88,7 +88,7 @@ http {
|
||||
# }
|
||||
#
|
||||
# location /api/ {
|
||||
# proxy_pass http://nanaxe_api/;
|
||||
# proxy_pass http://xflow_api/;
|
||||
# proxy_http_version 1.1;
|
||||
# proxy_set_header Host $host;
|
||||
# proxy_set_header X-Real-IP $remote_addr;
|
||||
|
||||
+4
-4
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "nanaxe-api-server",
|
||||
"name": "xflow-api-server",
|
||||
"version": "1.0.0",
|
||||
"description": "API server for Nanaxe CRM mindmap synchronization - FastPanel compatible",
|
||||
"description": "API server for XFlow CRM - FastPanel compatible",
|
||||
"main": "api/src/index.js",
|
||||
"scripts": {
|
||||
"start": "node api/src/index.js",
|
||||
@@ -9,7 +9,7 @@
|
||||
"pm2:start": "pm2 start ecosystem.config.js",
|
||||
"pm2:stop": "pm2 stop ecosystem.config.js",
|
||||
"pm2:restart": "pm2 restart ecosystem.config.js",
|
||||
"pm2:logs": "pm2 logs nanaxe-api",
|
||||
"pm2:logs": "pm2 logs xflow-api",
|
||||
"install:deps": "npm ci --production"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -35,4 +35,4 @@
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
#!/bin/bash
|
||||
|
||||
# ==========================================
|
||||
# Nanaxe Server Deployment Script
|
||||
# XFlow Server Deployment Script
|
||||
# ==========================================
|
||||
|
||||
set -e
|
||||
@@ -87,12 +87,6 @@ create_backup() {
|
||||
docker compose exec -T postgres pg_dump -U gitea gitea > "$BACKUP_DIR/db-$(date +%Y%m%d-%H%M%S).sql" 2>/dev/null || warning "Database backup failed"
|
||||
fi
|
||||
|
||||
# Backup mindmaps
|
||||
if [ -d "mindmap-data" ]; then
|
||||
log "Backing up mindmaps..."
|
||||
tar -czf "$BACKUP_DIR/mindmaps-$(date +%Y%m%d-%H%M%S).tar.gz" mindmap-data/ 2>/dev/null || warning "Mindmaps backup failed"
|
||||
fi
|
||||
|
||||
success "Backup completed"
|
||||
}
|
||||
|
||||
@@ -196,15 +190,14 @@ rollback() {
|
||||
error "Deployment failed! Rolling back..."
|
||||
|
||||
# Get previous image
|
||||
local previous_image=$(docker images ghcr.io/*/nanaxe-api --format "{{.Repository}}:{{.Tag}}" | grep -v latest | head -1)
|
||||
local previous_image=$(docker images ghcr.io/*/xflow-api --format "{{.Repository}}:{{.Tag}}" | grep -v latest | head -1)
|
||||
|
||||
if [ -n "$previous_image" ]; then
|
||||
log "Reverting to: $previous_image"
|
||||
docker compose -f "$COMPOSE_FILE" stop api
|
||||
docker compose -f "$COMPOSE_FILE" rm -f api
|
||||
docker run -d --name nanaxe-api-rollback \
|
||||
--network nanaxe-network \
|
||||
-v mindmap-data:/app/mindmaps \
|
||||
docker run -d --name xflow-api-rollback \
|
||||
--network xflow-network \
|
||||
-e NODE_ENV=production \
|
||||
-e PORT=8080 \
|
||||
-p 8080:8080 \
|
||||
@@ -230,7 +223,7 @@ show_logs() {
|
||||
|
||||
main() {
|
||||
log "======================================"
|
||||
log "Nanaxe Server Deployment"
|
||||
log "XFlow Server Deployment"
|
||||
log "======================================"
|
||||
|
||||
# Create logs directory
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#!/bin/bash
|
||||
|
||||
# ==========================================
|
||||
# VPS Setup Script for Nanaxe Server
|
||||
# VPS Setup Script for XFlow Server
|
||||
# Run this on your fresh VPS
|
||||
# ==========================================
|
||||
|
||||
@@ -21,7 +21,7 @@ error() { echo -e "${RED}[ERROR]${NC} $1"; }
|
||||
# ==========================================
|
||||
# Configuration
|
||||
# ==========================================
|
||||
DEPLOY_DIR="/opt/nanaxe-server"
|
||||
DEPLOY_DIR="/opt/xflow-server"
|
||||
REPO_URL="" # Will be set interactively
|
||||
DOMAIN="" # Will be set interactively
|
||||
|
||||
@@ -191,9 +191,9 @@ EOF
|
||||
setup_systemd() {
|
||||
log "Creating systemd service..."
|
||||
|
||||
cat > /etc/systemd/system/nanaxe-server.service << EOF
|
||||
cat > /etc/systemd/system/xflow-server.service << EOF
|
||||
[Unit]
|
||||
Description=Nanaxe Server
|
||||
Description=XFlow Server
|
||||
Requires=docker.service
|
||||
After=docker.service
|
||||
|
||||
@@ -210,7 +210,7 @@ WantedBy=multi-user.target
|
||||
EOF
|
||||
|
||||
systemctl daemon-reload
|
||||
systemctl enable nanaxe-server.service
|
||||
systemctl enable xflow-server.service
|
||||
|
||||
success "Systemd service created"
|
||||
}
|
||||
@@ -221,7 +221,7 @@ EOF
|
||||
interactive_setup() {
|
||||
echo ""
|
||||
echo "======================================"
|
||||
echo "Nanaxe Server VPS Setup"
|
||||
echo "XFlow Server VPS Setup"
|
||||
echo "======================================"
|
||||
echo ""
|
||||
|
||||
@@ -269,12 +269,12 @@ main() {
|
||||
echo " 3. Open Gitea at http://${DOMAIN:-your-server-ip}:3000"
|
||||
echo " 4. Create admin account and API token"
|
||||
echo " 5. Add GITEA_TOKEN to .env file"
|
||||
echo " 6. Restart: systemctl restart nanaxe-server"
|
||||
echo " 6. Restart: systemctl restart xflow-server"
|
||||
echo ""
|
||||
log "Useful commands:"
|
||||
echo " - View logs: docker compose -f docker-compose.prod.yml logs -f"
|
||||
echo " - Restart: systemctl restart nanaxe-server"
|
||||
echo " - Update: cd $DEPLOY_DIR && git pull && systemctl restart nanaxe-server"
|
||||
echo " - Restart: systemctl restart xflow-server"
|
||||
echo " - Update: cd $DEPLOY_DIR && git pull && systemctl restart xflow-server"
|
||||
echo ""
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Nanaxe Server Nginx Configuration
|
||||
# XFlow Server Nginx Configuration
|
||||
# Добавьте этот файл в ./docker/nginx/conf.d/ вашего друга
|
||||
|
||||
# Resolver для Docker DNS
|
||||
@@ -14,7 +14,7 @@ server {
|
||||
client_max_body_size 512M;
|
||||
|
||||
location / {
|
||||
set $gitea_backend nanaxe_gitea:3000;
|
||||
set $gitea_backend xflow_gitea:3000;
|
||||
proxy_pass http://$gitea_backend;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
@@ -34,14 +34,14 @@ server {
|
||||
}
|
||||
|
||||
# ==========================================
|
||||
# Nanaxe API
|
||||
# XFlow API
|
||||
# ==========================================
|
||||
server {
|
||||
listen 80;
|
||||
server_name api.* ~^api\..+$;
|
||||
|
||||
location / {
|
||||
set $api_backend nanaxe_api:8080;
|
||||
set $api_backend xflow_api:8080;
|
||||
proxy_pass http://$api_backend;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
@@ -66,19 +66,19 @@ server {
|
||||
}
|
||||
|
||||
# ==========================================
|
||||
# Nanaxe Dashboard (опционально)
|
||||
# XFlow Dashboard (опционально)
|
||||
# ==========================================
|
||||
server {
|
||||
listen 80;
|
||||
server_name nanaxe.* ~^nanaxe\..+$;
|
||||
server_name xflow.* ~^xflow\..+$;
|
||||
|
||||
location / {
|
||||
root /usr/share/nginx/html/nanaxe;
|
||||
root /usr/share/nginx/html/xflow;
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
location /api/ {
|
||||
set $api_backend nanaxe_api:8080;
|
||||
set $api_backend xflow_api:8080;
|
||||
proxy_pass http://$api_backend/;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
@@ -97,7 +97,7 @@ server {
|
||||
server_name webdav.* ~^webdav\..+$;
|
||||
|
||||
location / {
|
||||
set $webdav_backend nanaxe_webdav:80;
|
||||
set $webdav_backend xflow_webdav:80;
|
||||
proxy_pass http://$webdav_backend;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
Reference in New Issue
Block a user