feat: Implement multi-tenancy with dynamic data source routing and introduce a database management UI.
Some checks failed
Build and Push Docker Images / build-and-push-backend (push) Successful in 33s
Build and Push Docker Images / build-and-push-frontend (push) Successful in 16s
Build and Push Docker Images / deploy-to-k8s (push) Failing after 5m31s

This commit is contained in:
Zuev
2026-03-12 22:15:28 +03:00
parent 9e55472de7
commit 14cc006f06
12 changed files with 780 additions and 6 deletions

View File

@@ -95,6 +95,14 @@
</svg>
Расписание занятий
</a>
<a href="#" class="nav-item" data-tab="database">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<ellipse cx="12" cy="5" rx="9" ry="3"></ellipse>
<path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"></path>
<path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"></path>
</svg>
База данных
</a>
</nav>
<div class="sidebar-footer">
<button class="btn-logout" id="btn-logout">

View File

@@ -8,6 +8,7 @@ import { initEquipments } from './views/equipments.js';
import { initClassrooms } from './views/classrooms.js';
import { initSubjects } from './views/subjects.js';
import {initSchedule} from "./views/schedule.js";
import {initDatabase} from "./views/database.js";
// Configuration
const ROUTES = {
@@ -17,8 +18,8 @@ const ROUTES = {
equipments: { title: 'Оборудование', file: 'views/equipments.html', init: initEquipments },
classrooms: { title: 'Аудитории', file: 'views/classrooms.html', init: initClassrooms },
subjects: { title: 'Дисциплины и преподаватели', file: 'views/subjects.html', init: initSubjects },
// Новая вкладка
schedule: { title: 'Расписание занятий', file: 'views/schedule.html', init: initSchedule },
database: { title: 'База данных', file: 'views/database.html', init: initDatabase },
};
let currentTab = null;

View File

@@ -0,0 +1,157 @@
import { api } from '../api.js';
import { escapeHtml, showAlert, hideAlert } from '../utils.js';
export async function initDatabase() {
const tenantsTbody = document.getElementById('tenants-tbody');
const addTenantForm = document.getElementById('add-tenant-form');
const statusInfo = document.getElementById('db-status-info');
const btnTest = document.getElementById('btn-test-connection');
// === Загрузка статуса текущего подключения ===
async function loadStatus() {
try {
const data = await api.get('/api/database/status');
const statusBadge = data.connected
? '<span class="badge badge-available">Online</span>'
: '<span class="badge badge-unavailable">Offline</span>';
statusInfo.innerHTML = `
<div style="display: flex; align-items: center; gap: 1rem; flex-wrap: wrap;">
<div>
<span style="color: var(--text-secondary); font-size: 0.85rem;">Тенант:</span>
<strong>${escapeHtml(data.tenant || '—')}</strong>
</div>
<div>
<span style="color: var(--text-secondary); font-size: 0.85rem;">Название:</span>
<strong>${escapeHtml(data.name || '—')}</strong>
</div>
<div>
<span style="color: var(--text-secondary); font-size: 0.85rem;">Статус:</span>
${statusBadge}
</div>
${data.url ? `<div>
<span style="color: var(--text-secondary); font-size: 0.85rem;">URL:</span>
<code style="font-size: 0.85rem;">${escapeHtml(data.url)}</code>
</div>` : ''}
</div>
`;
} catch (e) {
statusInfo.innerHTML = `<div class="form-alert error" style="display:block">Ошибка загрузки статуса: ${e.message}</div>`;
}
}
// === Загрузка списка тенантов ===
async function loadTenants() {
try {
const tenants = await api.get('/api/database/tenants');
renderTenantsTable(tenants);
} catch (e) {
tenantsTbody.innerHTML = `<tr><td colspan="6" class="loading-row">Ошибка загрузки: ${e.message}</td></tr>`;
}
}
function renderTenantsTable(tenants) {
if (!tenants || !tenants.length) {
tenantsTbody.innerHTML = '<tr><td colspan="6" class="loading-row">Нет подключённых тенантов</td></tr>';
return;
}
tenantsTbody.innerHTML = tenants.map(t => {
const statusBadge = t.connected
? '<span class="badge badge-available">Online</span>'
: '<span class="badge badge-unavailable">Offline</span>';
return `
<tr>
<td>${escapeHtml(t.name || '—')}</td>
<td><code>${escapeHtml(t.domain)}</code></td>
<td><code style="font-size: 0.82rem;">${escapeHtml(t.url)}</code></td>
<td>${escapeHtml(t.username || '—')}</td>
<td>${statusBadge}</td>
<td><button class="btn-delete" data-domain="${escapeHtml(t.domain)}">Удалить</button></td>
</tr>`;
}).join('');
}
// === Тест подключения ===
btnTest.addEventListener('click', async () => {
hideAlert('add-tenant-alert');
const url = document.getElementById('tenant-url').value.trim();
const username = document.getElementById('tenant-username').value.trim();
const password = document.getElementById('tenant-password').value;
if (!url) {
showAlert('add-tenant-alert', 'Введите JDBC URL', 'error');
return;
}
btnTest.textContent = '...';
btnTest.disabled = true;
try {
const result = await api.post('/api/database/test', { url, username, password });
if (result.success) {
showAlert('add-tenant-alert', '✓ Подключение успешно!', 'success');
} else {
showAlert('add-tenant-alert', `${result.message}`, 'error');
}
} catch (e) {
showAlert('add-tenant-alert', `Ошибка: ${e.message}`, 'error');
} finally {
btnTest.textContent = 'Тест';
btnTest.disabled = false;
}
});
// === Добавление тенанта ===
addTenantForm.addEventListener('submit', async (e) => {
e.preventDefault();
hideAlert('add-tenant-alert');
const name = document.getElementById('tenant-name').value.trim();
const domain = document.getElementById('tenant-domain').value.trim().toLowerCase();
const url = document.getElementById('tenant-url').value.trim();
const username = document.getElementById('tenant-username').value.trim();
const password = document.getElementById('tenant-password').value;
if (!name || !domain || !url) {
showAlert('add-tenant-alert', 'Заполните все обязательные поля', 'error');
return;
}
try {
const result = await api.post('/api/database/tenants', { name, domain, url, username, password });
if (result.success) {
showAlert('add-tenant-alert', `Тенант "${escapeHtml(domain)}" добавлен!`, 'success');
addTenantForm.reset();
loadTenants();
loadStatus();
} else {
showAlert('add-tenant-alert', result.message, 'error');
}
} catch (e) {
showAlert('add-tenant-alert', `Ошибка: ${e.message}`, 'error');
}
});
// === Удаление тенанта ===
tenantsTbody.addEventListener('click', async (e) => {
const btn = e.target.closest('.btn-delete');
if (!btn) return;
const domain = btn.dataset.domain;
if (!confirm(`Удалить тенант "${domain}"? Пул соединений будет закрыт.`)) return;
try {
await api.delete(`/api/database/tenants/${domain}`);
loadTenants();
loadStatus();
} catch (e) {
alert(`Ошибка: ${e.message}`);
}
});
// === Init ===
loadStatus();
loadTenants();
}

View File

@@ -0,0 +1,73 @@
<!-- ===== Database / Tenants Tab ===== -->
<!-- Текущее подключение -->
<div class="card">
<h2>Текущее подключение</h2>
<div id="db-status-info" class="db-status-card">
<div class="loading-row">Загрузка...</div>
</div>
</div>
<!-- Таблица тенантов -->
<div class="card">
<h2>Подключённые университеты</h2>
<div class="table-wrap">
<table id="tenants-table">
<thead>
<tr>
<th>Название</th>
<th>Домен</th>
<th>JDBC URL</th>
<th>Пользователь</th>
<th>Статус</th>
<th>Действия</th>
</tr>
</thead>
<tbody id="tenants-tbody">
<tr>
<td colspan="6" class="loading-row">Загрузка...</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Добавить тенант -->
<div class="card create-card">
<h2>Добавить подключение</h2>
<form id="add-tenant-form">
<div class="form-row">
<div class="form-group">
<label for="tenant-name">Название университета</label>
<input type="text" id="tenant-name" placeholder="ЮЗГУ" required>
</div>
<div class="form-group">
<label for="tenant-domain">Поддомен</label>
<input type="text" id="tenant-domain" placeholder="swsu" required>
</div>
</div>
<div class="form-row" style="margin-top: 0.75rem;">
<div class="form-group" style="flex: 3;">
<label for="tenant-url">JDBC URL</label>
<input type="text" id="tenant-url" placeholder="jdbc:postgresql://192.168.1.50:5432/magistr_db" required>
</div>
</div>
<div class="form-row" style="margin-top: 0.75rem;">
<div class="form-group">
<label for="tenant-username">Пользователь</label>
<input type="text" id="tenant-username" placeholder="postgres" required>
</div>
<div class="form-group">
<label for="tenant-password">Пароль</label>
<input type="password" id="tenant-password" placeholder="••••••••" required>
</div>
<button type="button" class="btn-primary" id="btn-test-connection" style="height: fit-content;">
Тест
</button>
<button type="submit" class="btn-primary" style="height: fit-content;">
Добавить
</button>
</div>
<div class="form-alert" id="add-tenant-alert" role="alert"></div>
</form>
</div>