Files
magistr/frontend/react/settings/DatabaseTab.jsx
dipatrik10 97bf3cc50a 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
2026-09-28 23:15:04 +03:00

319 lines
14 KiB
JavaScript
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 { 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()}
/>
)}
</>
);
}