feat: завершить миграцию фронтенда на React (этапы 1-5)
Админка, настройки, логин, кабинеты преподавателя и студента полностью переведены на React 19 (модель strangler, docs/REACT_MIGRATION.md). - 15 вкладок админки: lazy-загрузка, общий error boundary, сервисный слой - Удалены legacy-views и переходный слой LegacyTabView (45 файлов) - Сборка esbuild (frontend/scripts/build-react.mjs) с бюджетами gzip на чанки - CSP-хэши, кэширование: chunk'и immutable, CSS/HTML no-cache (react-cache.conf) - Фиксы: lazy-импорт именованных экспортов (краш после авторизации), растяжение #admin-root/#settings-root на всю ширину, возврат subjects/lessonTypes из справочников (краш просмотра расписаний), отказ от заведомо широкого поиска расписания без выбранной цели, центрирование карточки логина - Тесты: 237/237 (npm run check), регрессионные на lazy-вкладки и ширину - Документация: REACT_MIGRATION.md, REACT_MIGRATION_RESULT.md, FRONTEND.md
This commit is contained in:
318
frontend/react/settings/DatabaseTab.jsx
Normal file
318
frontend/react/settings/DatabaseTab.jsx
Normal file
@@ -0,0 +1,318 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { ConfirmDialog } from '../shared/ui/ConfirmDialog.jsx';
|
||||
import {
|
||||
createTenant,
|
||||
deleteTenant,
|
||||
fetchDatabaseStatus,
|
||||
fetchTenants,
|
||||
normalizeDomain,
|
||||
testTenantConnection,
|
||||
validateTenantFields
|
||||
} from './settings-service.js';
|
||||
|
||||
export function DatabaseTab() {
|
||||
const [status, setStatus] = useState(null);
|
||||
const [statusError, setStatusError] = useState(false);
|
||||
const [tenants, setTenants] = useState(null);
|
||||
const [tenantsError, setTenantsError] = useState(false);
|
||||
const [form, setForm] = useState({ domain: '', name: '', password: '', url: '', username: '' });
|
||||
const [alert, setAlert] = useState({ text: '', isError: false });
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [deleting, setDeleting] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
(async () => {
|
||||
const [statusResult, tenantsResult] = await Promise.allSettled([
|
||||
fetchDatabaseStatus(),
|
||||
fetchTenants()
|
||||
]);
|
||||
if (!active) return;
|
||||
if (statusResult.status === 'fulfilled') {
|
||||
setStatus(statusResult.value);
|
||||
} else {
|
||||
setStatusError(true);
|
||||
}
|
||||
if (tenantsResult.status === 'fulfilled') {
|
||||
setTenants(tenantsResult.value);
|
||||
} else {
|
||||
setTenantsError(true);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
async function reload() {
|
||||
const [statusResult, tenantsResult] = await Promise.allSettled([
|
||||
fetchDatabaseStatus(),
|
||||
fetchTenants()
|
||||
]);
|
||||
if (statusResult.status === 'fulfilled') {
|
||||
setStatus(statusResult.value);
|
||||
setStatusError(false);
|
||||
} else {
|
||||
setStatusError(true);
|
||||
}
|
||||
if (tenantsResult.status === 'fulfilled') {
|
||||
setTenants(tenantsResult.value);
|
||||
setTenantsError(false);
|
||||
} else {
|
||||
setTenantsError(true);
|
||||
}
|
||||
}
|
||||
|
||||
function updateField(field, value) {
|
||||
setForm(current => ({ ...current, [field]: value }));
|
||||
}
|
||||
|
||||
async function testConnection() {
|
||||
setAlert({ text: '', isError: false });
|
||||
if (!form.url.trim()) {
|
||||
setAlert({ text: 'Введите JDBC URL', isError: true });
|
||||
return;
|
||||
}
|
||||
setTesting(true);
|
||||
try {
|
||||
const result = await testTenantConnection({
|
||||
password: form.password,
|
||||
url: form.url.trim(),
|
||||
username: form.username.trim()
|
||||
});
|
||||
if (result && result.success) {
|
||||
setAlert({ text: '✓ Подключение успешно!', isError: false });
|
||||
} else {
|
||||
setAlert({ text: `✗ ${(result && result.message) || 'Подключение не выполнено'}`, isError: true });
|
||||
}
|
||||
} catch {
|
||||
setAlert({ text: 'Не удалось проверить подключение', isError: true });
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function submit(event) {
|
||||
event.preventDefault();
|
||||
setAlert({ text: '', isError: false });
|
||||
|
||||
const payload = {
|
||||
domain: normalizeDomain(form.domain),
|
||||
name: form.name.trim(),
|
||||
url: form.url.trim(),
|
||||
username: form.username.trim(),
|
||||
password: form.password
|
||||
};
|
||||
const validation = validateTenantFields(payload);
|
||||
if (validation) {
|
||||
setAlert({ text: validation, isError: true });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await createTenant(payload);
|
||||
if (result && result.success) {
|
||||
setAlert({ text: `Тенант «${payload.domain}» добавлен`, isError: false });
|
||||
setForm({ domain: '', name: '', password: '', url: '', username: '' });
|
||||
await reload();
|
||||
} else {
|
||||
setAlert({ text: (result && result.message) || 'Не удалось добавить тенант', isError: true });
|
||||
}
|
||||
} catch {
|
||||
setAlert({ text: 'Не удалось добавить тенант', isError: true });
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
const tenant = deleting;
|
||||
setDeleting(null);
|
||||
try {
|
||||
await deleteTenant(tenant.domain);
|
||||
await reload();
|
||||
} catch (error) {
|
||||
setAlert({ text: error.message || 'Не удалось удалить тенант', isError: true });
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="card">
|
||||
<h2>Текущее подключение</h2>
|
||||
<div className="db-status-card">
|
||||
{statusError ? (
|
||||
<div className="loading-row">Не удалось загрузить статус подключения</div>
|
||||
) : status === null ? (
|
||||
<div className="loading-row">Загрузка...</div>
|
||||
) : (
|
||||
<div className="db-status-grid">
|
||||
<div>
|
||||
<span>Тенант:</span>
|
||||
<strong>{status.tenant || '—'}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Название:</span>
|
||||
<strong>{status.name || '—'}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Статус:</span>
|
||||
<span className={`badge ${status.connected ? 'badge-available' : 'badge-unavailable'}`}>
|
||||
{status.connected ? 'Доступно' : 'Недоступно'}
|
||||
</span>
|
||||
</div>
|
||||
{status.url && (
|
||||
<div>
|
||||
<span>URL:</span>
|
||||
<code>{status.url}</code>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<h2>Подключённые университеты</h2>
|
||||
<div className="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Название</th>
|
||||
<th>Домен</th>
|
||||
<th>JDBC URL</th>
|
||||
<th>Пользователь</th>
|
||||
<th>Статус</th>
|
||||
<th>Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{tenantsError ? (
|
||||
<tr><td colSpan="6" className="loading-row">Не удалось загрузить список тенантов</td></tr>
|
||||
) : tenants === null ? (
|
||||
<tr><td colSpan="6" className="loading-row">Загрузка...</td></tr>
|
||||
) : tenants.length === 0 ? (
|
||||
<tr><td colSpan="6" className="loading-row">Нет подключённых тенантов</td></tr>
|
||||
) : tenants.map(tenant => (
|
||||
<tr key={tenant.domain}>
|
||||
<td>{tenant.name || '—'}</td>
|
||||
<td><code>{tenant.domain}</code></td>
|
||||
<td><code>{tenant.url}</code></td>
|
||||
<td>{tenant.username || '—'}</td>
|
||||
<td>
|
||||
<span className={`badge ${tenant.connected ? 'badge-available' : 'badge-unavailable'}`}>
|
||||
{tenant.connected ? 'Доступно' : 'Недоступно'}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-danger"
|
||||
onClick={() => setDeleting(tenant)}
|
||||
>
|
||||
Удалить
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card create-card">
|
||||
<h2>Добавить подключение</h2>
|
||||
<form onSubmit={submit}>
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label htmlFor="tenant-name">Название университета</label>
|
||||
<input
|
||||
type="text"
|
||||
id="tenant-name"
|
||||
placeholder="ЮЗГУ"
|
||||
required
|
||||
value={form.name}
|
||||
onChange={event => updateField('name', event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="tenant-domain">Поддомен</label>
|
||||
<input
|
||||
type="text"
|
||||
id="tenant-domain"
|
||||
placeholder="swsu"
|
||||
required
|
||||
value={form.domain}
|
||||
onChange={event => updateField('domain', event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<div className="form-group form-group-wide">
|
||||
<label htmlFor="tenant-url">JDBC URL</label>
|
||||
<input
|
||||
type="text"
|
||||
id="tenant-url"
|
||||
placeholder="jdbc:postgresql://192.168.1.50:5432/magistr_db"
|
||||
required
|
||||
value={form.url}
|
||||
onChange={event => updateField('url', event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label htmlFor="tenant-username">Пользователь</label>
|
||||
<input
|
||||
type="text"
|
||||
id="tenant-username"
|
||||
placeholder="postgres"
|
||||
autoComplete="username"
|
||||
required
|
||||
value={form.username}
|
||||
onChange={event => updateField('username', event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="tenant-password">Пароль</label>
|
||||
<input
|
||||
type="password"
|
||||
id="tenant-password"
|
||||
placeholder="••••••••"
|
||||
autoComplete="new-password"
|
||||
required
|
||||
value={form.password}
|
||||
onChange={event => updateField('password', event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-md btn-secondary form-row-actions"
|
||||
disabled={testing}
|
||||
onClick={() => void testConnection()}
|
||||
>
|
||||
{testing ? '...' : 'Тест'}
|
||||
</button>
|
||||
<button type="submit" className="btn btn-md btn-primary form-row-actions">Добавить</button>
|
||||
</div>
|
||||
<div
|
||||
className={`form-alert${alert.text ? (alert.isError ? ' error' : ' success') : ''}`}
|
||||
role="alert"
|
||||
>
|
||||
{alert.text}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{deleting && (
|
||||
<ConfirmDialog
|
||||
title="Удаление тенанта"
|
||||
message={`Удалить тенант «${deleting.domain}»? Пул соединений будет закрыт.`}
|
||||
confirmLabel="Удалить тенант"
|
||||
danger
|
||||
onClose={() => setDeleting(null)}
|
||||
onConfirm={() => void confirmDelete()}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user