feat: Implement server configuration management API and integrate server management features in FilterManager for enhanced server handling capabilities
Publish Fast Tabler Docker image / build-and-push-fast (push) Failing after 4m8s

This commit is contained in:
2025-07-14 09:54:16 +07:00
parent 43a77bc3b1
commit a42e103bba
2 changed files with 441 additions and 101 deletions
+132
View File
@@ -516,6 +516,138 @@ app.post('/api/filters/export-config', async (req, res) => {
}
});
// --- Server Configs Routes ---
// Get server configs list
app.get('/api/server-configs', async (req, res) => {
const params = {
Bucket: BUCKET_NAME,
Key: 'server-configs.json',
};
try {
const data = await s3.getObject(params).promise();
const fileContent = data.Body.toString('utf-8');
let servers = [];
try {
servers = JSON.parse(fileContent);
if (!Array.isArray(servers)) {
servers = [];
}
} catch (parseError) {
console.error('Error parsing server-configs.json:', parseError);
servers = [];
}
res.json(servers);
} catch (error) {
if (error.code === 'NoSuchKey') {
res.json([]); // Return empty array if file does not exist
} else {
console.error(error);
res.status(500).send('Error reading server configs from S3');
}
}
});
// Update server configs list
app.post('/api/server-configs', async (req, res) => {
const { servers } = req.body;
// Validate servers structure
if (!Array.isArray(servers)) {
return res.status(400).send('Servers must be an array');
}
// Validate each server has required fields
for (let i = 0; i < servers.length; i++) {
const server = servers[i];
if (!server.id || !server.name) {
return res.status(400).send(`Server at index ${i} is missing required fields`);
}
}
const params = {
Bucket: BUCKET_NAME,
Key: 'server-configs.json',
Body: JSON.stringify(servers, null, 2), // Pretty print JSON
ContentType: 'application/json',
};
try {
await s3.putObject(params).promise();
res.send('Server configs updated successfully');
} catch (error) {
console.error(error);
res.status(500).send('Error writing server configs to S3');
}
});
// Get specific server config
app.get('/api/server-configs/:serverId', async (req, res) => {
const { serverId } = req.params;
const params = {
Bucket: BUCKET_NAME,
Key: `config-${serverId}.txt`,
};
try {
const data = await s3.getObject(params).promise();
const config = data.Body.toString('utf-8');
res.json({ config });
} catch (error) {
if (error.code === 'NoSuchKey') {
res.json({ config: '// Конфигурация не найдена' });
} else {
console.error(error);
res.status(500).send('Error reading server config from S3');
}
}
});
// Save specific server config
app.post('/api/server-configs/:serverId', async (req, res) => {
const { serverId } = req.params;
const { config } = req.body;
if (!config) {
return res.status(400).send('Config is required');
}
const params = {
Bucket: BUCKET_NAME,
Key: `config-${serverId}.txt`,
Body: config,
ContentType: 'text/plain',
};
try {
await s3.putObject(params).promise();
res.send('Server config saved successfully');
} catch (error) {
console.error(error);
res.status(500).send('Error writing server config to S3');
}
});
// Delete specific server config
app.delete('/api/server-configs/:serverId', async (req, res) => {
const { serverId } = req.params;
const params = {
Bucket: BUCKET_NAME,
Key: `config-${serverId}.txt`,
};
try {
await s3.deleteObject(params).promise();
res.send('Server config deleted successfully');
} catch (error) {
console.error(error);
res.status(500).send('Error deleting server config from S3');
}
});
// The "catchall" handler: for any request that doesn't
// match one above, send back React's index.html file.
app.get('*', (req, res) => {
+309 -101
View File
@@ -16,7 +16,10 @@ import {
IconChevronDown,
IconLink,
IconCopy,
IconEye
IconEye,
IconServer,
IconSettings,
IconSave
} from '@tabler/icons-react';
const API_URL = '/api';
@@ -53,8 +56,16 @@ function FilterManager() {
// Состояние для модального окна добавления фильтра
const [addFilterModalOpen, setAddFilterModalOpen] = useState(false);
// Новые состояния для управления серверами
const [servers, setServers] = useState([]);
const [selectedServer, setSelectedServer] = useState(null);
const [serverConfigModalOpen, setServerConfigModalOpen] = useState(false);
const [newServer, setNewServer] = useState({ name: '', description: '', enabled: true });
const [serverConfigs, setServerConfigs] = useState({});
useEffect(() => {
fetchFilters();
fetchServers();
}, []);
const fetchFilters = async () => {
@@ -71,6 +82,89 @@ function FilterManager() {
}
};
// Функции для управления серверами
const fetchServers = async () => {
try {
const response = await axios.get(`${API_URL}/server-configs`);
setServers(response.data);
} catch (error) {
console.error('Error fetching servers:', error);
// Если файл не существует, создаем пустой массив
setServers([]);
}
};
const handleAddServer = async () => {
if (!newServer.name.trim()) {
setError('Имя сервера обязательно для заполнения.');
return;
}
try {
const serverToAdd = {
id: Date.now().toString(),
...newServer,
createdAt: new Date().toISOString()
};
const updatedServers = [...servers, serverToAdd];
await axios.post(`${API_URL}/server-configs`, { servers: updatedServers });
setServers(updatedServers);
setNewServer({ name: '', description: '', enabled: true });
setServerConfigModalOpen(false);
setSuccess('Сервер успешно добавлен!');
setTimeout(() => setSuccess(''), 3000);
} catch (error) {
console.error('Error adding server:', error);
setError('Не удалось добавить сервер.');
}
};
const handleDeleteServer = async (serverId) => {
try {
const updatedServers = servers.filter(s => s.id !== serverId);
await axios.post(`${API_URL}/server-configs`, { servers: updatedServers });
// Также удаляем файл конфигурации сервера
await axios.delete(`${API_URL}/server-configs/${serverId}`);
setServers(updatedServers);
if (selectedServer?.id === serverId) {
setSelectedServer(null);
}
setSuccess('Сервер успешно удален!');
setTimeout(() => setSuccess(''), 3000);
} catch (error) {
console.error('Error deleting server:', error);
setError('Не удалось удалить сервер.');
}
};
const handleSaveServerConfig = async (serverId) => {
try {
const config = await generateMikrotikConfig();
await axios.post(`${API_URL}/server-configs/${serverId}`, { config });
setSuccess(`Конфигурация для сервера "${servers.find(s => s.id === serverId)?.name}" сохранена!`);
setTimeout(() => setSuccess(''), 3000);
} catch (error) {
console.error('Error saving server config:', error);
setError('Не удалось сохранить конфигурацию сервера.');
}
};
const handleLoadServerConfig = async (serverId) => {
try {
const response = await axios.get(`${API_URL}/server-configs/${serverId}`);
setGeneratedConfig(response.data.config);
setPreviewModalOpen(true);
} catch (error) {
console.error('Error loading server config:', error);
setError('Не удалось загрузить конфигурацию сервера.');
}
};
const handleAddFilter = () => {
if (!newFilter.community.trim() || !newFilter.gateway.trim()) {
setError('Поля Community и Gateway должны быть заполнены.');
@@ -355,6 +449,102 @@ function FilterManager() {
</div>
)}
{/* Секция управления серверами */}
<div className="row mb-4">
<div className="col-12">
<div className="card">
<div className="card-header">
<h3 className="card-title">
<IconServer className="me-2" />
Готовые конфигурации серверов
</h3>
<div className="card-actions">
<button
className="btn btn-primary btn-sm"
onClick={() => setServerConfigModalOpen(true)}
>
<IconPlus className="me-2" />
Добавить сервер
</button>
</div>
</div>
<div className="card-body">
{servers.length === 0 ? (
<div className="text-center text-muted py-4">
<IconServer className="icon-lg mb-2" />
<p>Нет добавленных серверов</p>
<button
className="btn btn-primary"
onClick={() => setServerConfigModalOpen(true)}
>
<IconPlus className="me-2" />
Добавить первый сервер
</button>
</div>
) : (
<div className="row g-3">
{servers.map(server => (
<div key={server.id} className="col-md-6 col-lg-4">
<div className={`card ${selectedServer?.id === server.id ? 'border-primary' : ''}`}>
<div className="card-body">
<div className="d-flex justify-content-between align-items-start mb-2">
<h4 className="card-title mb-0">{server.name}</h4>
<div className="form-check form-switch">
<input
className="form-check-input"
type="checkbox"
checked={server.enabled}
onChange={async () => {
const updatedServers = servers.map(s =>
s.id === server.id ? { ...s, enabled: !s.enabled } : s
);
try {
await axios.post(`${API_URL}/server-configs`, { servers: updatedServers });
setServers(updatedServers);
} catch (error) {
setError('Не удалось обновить статус сервера');
}
}}
/>
</div>
</div>
{server.description && (
<p className="text-muted small mb-3">{server.description}</p>
)}
<div className="d-flex gap-2">
<button
className="btn btn-outline-primary btn-sm"
onClick={() => handleLoadServerConfig(server.id)}
>
<IconEye className="me-1" />
Просмотр
</button>
<button
className="btn btn-outline-success btn-sm"
onClick={() => handleSaveServerConfig(server.id)}
>
<IconSave className="me-1" />
Сохранить
</button>
<button
className="btn btn-outline-danger btn-sm"
onClick={() => handleDeleteServer(server.id)}
>
<IconTrash className="me-1" />
Удалить
</button>
</div>
</div>
</div>
</div>
))}
</div>
)}
</div>
</div>
</div>
</div>
{/* Фильтры и поиск */}
<div className="card w-100">
<div className="card-header d-flex justify-content-between align-items-center">
@@ -401,107 +591,34 @@ function FilterManager() {
</span>
)}
</th>
<th style={{minWidth: '180px', maxWidth: '300px'}}>DESCRIPTION</th>
<th className="text-end w-1" style={{ minWidth: 110 }}></th>
<th>ОПИСАНИЕ</th>
<th className="text-end">ДЕЙСТВИЯ</th>
</tr>
</thead>
<tbody>
{filteredFilters.map((filter, index) => (
<tr key={index}>
<td className="font-monospace align-middle">
{editingFilter === filter.community ? (
<input
ref={editInputRef}
type="text"
className="form-control form-control-sm"
value={editingValues[filter.community]?.community || ''}
onChange={(e) => setEditingValues({
...editingValues,
[filter.community]: {
...editingValues[filter.community],
community: e.target.value
}
})}
onKeyDown={(e) => handleEditKeyDown(e, filter.community)}
style={{minWidth: '120px'}}
/>
) : (
<span>{filter.community}</span>
)}
{filteredFilters.map((filter) => (
<tr key={filter.community}>
<td>
<span className="badge bg-blue-lt text-blue">{filter.community}</span>
</td>
<td className="align-middle text-center">
{editingFilter === filter.community ? (
<input
type="text"
className="form-control form-control-sm"
value={editingValues[filter.community]?.gateway || ''}
onChange={(e) => setEditingValues({
...editingValues,
[filter.community]: {
...editingValues[filter.community],
gateway: e.target.value
}
})}
onKeyDown={(e) => handleEditKeyDown(e, filter.community)}
style={{minWidth: '120px'}}
/>
) : (
<span className="badge bg-blue-lt text-blue" style={{letterSpacing: '0.5px'}}>{filter.gateway}</span>
)}
<td>
<span className="badge bg-green-lt text-green">{filter.gateway}</span>
</td>
<td className="align-middle text-muted" style={{fontSize: '14px', maxWidth: '300px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap'}}>
{editingFilter === filter.community ? (
<input
type="text"
className="form-control form-control-sm"
value={editingValues[filter.community]?.description || ''}
onChange={(e) => setEditingValues({
...editingValues,
[filter.community]: {
...editingValues[filter.community],
description: e.target.value
}
})}
onKeyDown={(e) => handleEditKeyDown(e, filter.community)}
style={{minWidth: '120px'}}
/>
) : (
<span>{filter.description}</span>
)}
</td>
<td className="text-end w-1 align-middle" style={{ minWidth: 110 }}>
<div className="btn-list gap-1 mb-0">
{editingFilter === filter.community ? (
<>
<button
className="btn btn-icon btn-sm btn-success rounded"
onClick={() => handleSaveEdit(filter.community)}
>
<IconCheck size={18} />
</button>
<button
className="btn btn-icon btn-sm btn-secondary rounded"
onClick={() => handleCancelEdit(filter.community)}
>
<IconX size={18} />
</button>
</>
) : (
<>
<button
className="btn btn-icon btn-sm btn-outline-primary rounded"
onClick={() => startEdit(filter)}
>
<IconEdit size={18} />
</button>
<button
className="btn btn-icon btn-sm btn-outline-danger rounded"
onClick={() => handleDelete(filter)}
>
<IconTrash size={18} />
</button>
</>
)}
<td>{filter.description || '-'}</td>
<td className="text-end">
<div className="btn-list">
<button
className="btn btn-outline-primary btn-icon btn-sm"
onClick={() => handleEdit(filter)}
>
<IconEdit size={16} />
</button>
<button
className="btn btn-outline-danger btn-icon btn-sm"
onClick={() => handleDelete(filter)}
>
<IconTrash size={16} />
</button>
</div>
</td>
</tr>
@@ -512,7 +629,7 @@ function FilterManager() {
</div>
{/* Модальные окна */}
<AddFilterModal
<AddFilterModal
show={addFilterModalOpen}
newFilter={newFilter}
onNewFilterChange={setNewFilter}
@@ -521,7 +638,7 @@ function FilterManager() {
error={error}
/>
<EditFilterModal
<EditFilterModal
show={editModalOpen}
filter={editModalFilter}
onChange={handleEditModalChange}
@@ -529,19 +646,32 @@ function FilterManager() {
onClose={handleEditModalClose}
/>
<DeleteFilterModal
<DeleteFilterModal
show={showDeleteModal}
filter={filterToDelete}
onDelete={confirmDelete}
onClose={() => setShowDeleteModal(false)}
/>
<PreviewConfigModal
<PreviewConfigModal
show={previewModalOpen}
config={generatedConfig}
onClose={() => setPreviewModalOpen(false)}
onCopy={copyConfigToClipboard}
/>
{/* Модальное окно добавления сервера */}
<AddServerModal
show={serverConfigModalOpen}
newServer={newServer}
onNewServerChange={setNewServer}
onAddServer={handleAddServer}
onClose={() => {
setServerConfigModalOpen(false);
setNewServer({ name: '', description: '', enabled: true });
}}
error={error}
/>
</div>
);
}
@@ -773,4 +903,82 @@ function PreviewConfigModal({ show, config, onClose, onCopy }) {
);
}
// Модальное окно добавления сервера
function AddServerModal({ show, newServer, onNewServerChange, onAddServer, onClose, error }) {
if (!show) return null;
const handleChange = (field, value) => {
onNewServerChange({ ...newServer, [field]: value });
};
const handleSubmit = (e) => {
e.preventDefault();
onAddServer();
};
return (
<div className="modal show d-block" style={{ backgroundColor: 'rgba(0,0,0,0.5)' }}>
<div className="modal-dialog">
<div className="modal-content">
<div className="modal-header">
<h5 className="modal-title">Добавить новый сервер</h5>
<button type="button" className="btn-close" onClick={onClose}></button>
</div>
<form onSubmit={handleSubmit}>
<div className="modal-body">
{error && (
<div className="alert alert-danger">
<IconAlertTriangle className="me-2" />
{error}
</div>
)}
<div className="mb-3">
<label className="form-label">Имя сервера *</label>
<input
type="text"
className="form-control"
placeholder="Например, SWE-HIPHOST"
value={newServer.name}
onChange={(e) => handleChange('name', e.target.value)}
required
/>
</div>
<div className="mb-3">
<label className="form-label">Описание</label>
<input
type="text"
className="form-control"
placeholder="Необязательное описание сервера"
value={newServer.description}
onChange={(e) => handleChange('description', e.target.value)}
/>
</div>
<div className="mb-3">
<label className="form-label">Включен</label>
<div className="form-check">
<input
className="form-check-input"
type="checkbox"
checked={newServer.enabled}
onChange={(e) => handleChange('enabled', e.target.checked)}
/>
</div>
</div>
</div>
<div className="modal-footer">
<button type="button" className="btn btn-secondary" onClick={onClose}>
Отмена
</button>
<button type="submit" className="btn btn-primary">
<IconPlus className="me-2" />
Добавить
</button>
</div>
</form>
</div>
</div>
</div>
);
}
export default FilterManager;