feat: АРМ администратора — записи, состояние стенда, аудит, копии (lct-24)
Целый раздел ТЗ, от которого не было ни одной функции. - Учётные записи: завести, сменить роль и службу, заблокировать, сбросить пароль. Обучающемуся заводится и карточка курсанта — на ней висят профиль и проверка «это твой разбор». - Состояние компонентов: база, модели речи, эмбеддинги, провайдер LLM, версия классификатора, число живых занятий. Отдельной строкой — секрет сессии: значение по умолчанию не ошибка запуска, но на стенде это дыра, и увидеть её должен администратор, а не проверяющий на защите. - Журнал действий с фильтром, только на чтение. - Резервные копии: кнопка и make backup для cron. pg_dump берётся локальный, а если его нет — из контейнера базы; если нет ни того ни другого, администратор видит, чего не хватает, а кнопка не молчит. Границы роли выражены отсутствием маршрутов, а не проверками внутри них: в разделе нет ни одной точки, трогающей оценки или пишущей в аудит. Два теста проверяют это перебором маршрутов роутера — так запрет нельзя ослабить, случайно добавив обработчик. Найдено по ходу: тесты с базой падали в компании и проходили поодиночке. Движок SQLAlchemy кэшировался на процесс, а каждый TestClient поднимает свой событийный цикл — пул привязан к первому. Появился db.base.reset() и фикстура, дающая движок на тест. 193 теста зелёных (12 новых).
This commit is contained in:
parent
d55c8bc8cd
commit
641e505c67
13 changed files with 948 additions and 1 deletions
|
|
@ -3,6 +3,7 @@ import { createBrowserRouter, Navigate } from "react-router-dom";
|
|||
import { Call } from "@/pages/trainee/Call";
|
||||
import { Instructor } from "@/pages/instructor/Instructor";
|
||||
import { Dds } from "@/pages/dds/Dds";
|
||||
import { Admin } from "@/pages/admin/Admin";
|
||||
import { Profile } from "@/pages/profile/Profile";
|
||||
import { Wall } from "@/pages/wall/Wall";
|
||||
import { RequireAuth } from "@/features/auth/Login";
|
||||
|
|
@ -23,4 +24,5 @@ export const router = createBrowserRouter([
|
|||
{ path: "/wall", element: guarded("/wall", <Wall />) },
|
||||
{ path: "/profile", element: guarded("/profile", <Profile />) },
|
||||
{ path: "/dds", element: guarded("/dds", <Dds />) },
|
||||
{ path: "/admin", element: guarded("/admin", <Admin />) },
|
||||
]);
|
||||
|
|
|
|||
211
frontend/src/pages/admin/Admin.tsx
Normal file
211
frontend/src/pages/admin/Admin.tsx
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
// АРМ администратора: учётные записи, состояние стенда, журнал аудита, копии.
|
||||
//
|
||||
// Того, чего администратору нельзя по ТЗ, здесь нет вовсе — ни правки оценок,
|
||||
// ни вмешательства в занятие. Запрет выражен отсутствием кнопки, а не
|
||||
// disabled-атрибутом (lct-24).
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
import { useLogout, useMe } from "@/features/auth/useAuth";
|
||||
import type { Role } from "@/shared/types/generated";
|
||||
|
||||
import {
|
||||
useAudit, useBackups, useCreateBackup, useCreateUser, usePatchUser, useStatus, useUsers,
|
||||
} from "./api";
|
||||
|
||||
const ROLE_LABELS: Record<Role, string> = {
|
||||
admin: "Администратор",
|
||||
instructor: "Преподаватель",
|
||||
trainee: "Обучающийся",
|
||||
};
|
||||
|
||||
export function Admin() {
|
||||
const me = useMe();
|
||||
const logout = useLogout();
|
||||
|
||||
return (
|
||||
<main className="page">
|
||||
<h1>АРМ администратора</h1>
|
||||
<p>
|
||||
{me.data?.full_name}{" "}
|
||||
<button type="button" onClick={() => logout.mutate()}>Выйти</button>
|
||||
</p>
|
||||
<Status />
|
||||
<Users />
|
||||
<Backups />
|
||||
<Audit />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function Status() {
|
||||
const status = useStatus();
|
||||
return (
|
||||
<>
|
||||
<h2>Состояние стенда</h2>
|
||||
<table className="grid">
|
||||
<tbody>
|
||||
{(status.data ?? []).map((item) => (
|
||||
<tr key={item.name}>
|
||||
<th>{item.name}</th>
|
||||
<td className={item.ok ? "state-ok" : "state-violated"}>{item.detail}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Users() {
|
||||
const users = useUsers();
|
||||
const create = useCreateUser();
|
||||
const patch = usePatchUser();
|
||||
const [form, setForm] = useState({ login: "", full_name: "", password: "", role: "trainee" as Role, service: "" });
|
||||
|
||||
return (
|
||||
<>
|
||||
<h2>Учётные записи</h2>
|
||||
<table className="grid">
|
||||
<thead>
|
||||
<tr><th>Логин</th><th>ФИО</th><th>Роль</th><th>Служба</th><th>Состояние</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(users.data ?? []).map((user) => (
|
||||
<tr key={user.id}>
|
||||
<th>{user.login}</th>
|
||||
<td>{user.full_name}</td>
|
||||
<td>{ROLE_LABELS[user.role]}</td>
|
||||
<td>{user.service ?? "—"}</td>
|
||||
<td>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => patch.mutate({ id: user.id, body: { blocked: !user.blocked } })}
|
||||
>
|
||||
{user.blocked ? "Разблокировать" : "Заблокировать"}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h3>Завести запись</h3>
|
||||
<form
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
create.mutate(
|
||||
{ ...form, service: form.service || null },
|
||||
{ onSuccess: () => setForm({ ...form, login: "", full_name: "", password: "" }) },
|
||||
);
|
||||
}}
|
||||
>
|
||||
<table className="grid">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th>Логин</th>
|
||||
<td><input value={form.login} onChange={(e) => setForm({ ...form, login: e.target.value })} /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>ФИО</th>
|
||||
<td><input value={form.full_name} onChange={(e) => setForm({ ...form, full_name: e.target.value })} /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Пароль</th>
|
||||
<td>
|
||||
<input
|
||||
type="password"
|
||||
value={form.password}
|
||||
onChange={(e) => setForm({ ...form, password: e.target.value })}
|
||||
/>{" "}
|
||||
не короче восьми знаков
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Роль</th>
|
||||
<td>
|
||||
<select value={form.role} onChange={(e) => setForm({ ...form, role: e.target.value as Role })}>
|
||||
{Object.entries(ROLE_LABELS).map(([value, label]) => (
|
||||
<option key={value} value={value}>{label}</option>
|
||||
))}
|
||||
</select>
|
||||
</td>
|
||||
</tr>
|
||||
{/* Служба осмысленна только у обучающегося: по ней в его ленту
|
||||
попадают профильные события (docs/spec/DATASET.md). */}
|
||||
{form.role === "trainee" && (
|
||||
<tr>
|
||||
<th>Служба ДДС</th>
|
||||
<td><input value={form.service} onChange={(e) => setForm({ ...form, service: e.target.value })} /></td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
<p>
|
||||
<button type="submit" disabled={!form.login || !form.full_name || form.password.length < 8}>
|
||||
Завести
|
||||
</button>
|
||||
{create.isError && <span className="warn"> Логин уже занят</span>}
|
||||
</p>
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Backups() {
|
||||
const backups = useBackups();
|
||||
const create = useCreateBackup();
|
||||
return (
|
||||
<>
|
||||
<h2>Резервные копии</h2>
|
||||
<p>
|
||||
<button type="button" disabled={create.isPending} onClick={() => create.mutate()}>
|
||||
Снять копию сейчас
|
||||
</button>
|
||||
{create.isError && <span className="warn"> {String(create.error)}</span>}
|
||||
</p>
|
||||
<table className="grid">
|
||||
<tbody>
|
||||
{(backups.data ?? []).map((item) => (
|
||||
<tr key={item.name}>
|
||||
<th>{item.name}</th>
|
||||
<td>{Math.round(item.size_bytes / 1024)} КБ</td>
|
||||
<td>{new Date(item.at).toLocaleString("ru-RU")}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{(backups.data ?? []).length === 0 && <p>Копий пока нет.</p>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Audit() {
|
||||
const [action, setAction] = useState("");
|
||||
const audit = useAudit(action);
|
||||
return (
|
||||
<>
|
||||
<h2>Журнал действий</h2>
|
||||
<p>
|
||||
Фильтр по действию:{" "}
|
||||
<input value={action} placeholder="lesson.start" onChange={(e) => setAction(e.target.value)} />
|
||||
</p>
|
||||
<table className="grid">
|
||||
<thead>
|
||||
<tr><th>Когда</th><th>Кто</th><th>Действие</th><th>Над чем</th><th>Подробности</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(audit.data ?? []).map((row, index) => (
|
||||
<tr key={index}>
|
||||
<th>{new Date(row.at).toLocaleString("ru-RU")}</th>
|
||||
<td>{row.actor}</td>
|
||||
<td>{row.action}</td>
|
||||
<td>{row.object_id ?? "—"}</td>
|
||||
<td>{row.detail}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</>
|
||||
);
|
||||
}
|
||||
80
frontend/src/pages/admin/api.ts
Normal file
80
frontend/src/pages/admin/api.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
// Запросы АРМ администратора. Типы описаны руками, как и остальной HTTP:
|
||||
// в generated.ts едут только события сокетов (docs/arch/CONTRACT.md).
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import type { Role } from "@/shared/types/generated";
|
||||
|
||||
export interface AdminUser {
|
||||
id: string;
|
||||
login: string;
|
||||
full_name: string;
|
||||
role: Role;
|
||||
service: string | null;
|
||||
blocked: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface ServiceState { name: string; ok: boolean; detail: string }
|
||||
export interface AuditRow {
|
||||
at: string; actor: string; role: string; action: string; object_id: string | null; detail: string;
|
||||
}
|
||||
export interface Backup { name: string; size_bytes: number; at: string }
|
||||
|
||||
async function call<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const response = await fetch(path, {
|
||||
...init,
|
||||
headers: { "Content-Type": "application/json", ...init?.headers },
|
||||
});
|
||||
if (!response.ok) {
|
||||
const body = await response.json().catch(() => ({}));
|
||||
throw new Error(body.detail ?? String(response.status));
|
||||
}
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export const useUsers = () =>
|
||||
useQuery({ queryKey: ["admin-users"], queryFn: () => call<AdminUser[]>("/api/admin/users") });
|
||||
|
||||
export const useStatus = () =>
|
||||
useQuery({
|
||||
queryKey: ["admin-status"],
|
||||
queryFn: () => call<ServiceState[]>("/api/admin/status"),
|
||||
// Состояние стенда смотрят перед занятием и во время: пусть обновляется само.
|
||||
refetchInterval: 10_000,
|
||||
});
|
||||
|
||||
export const useAudit = (action: string) =>
|
||||
useQuery({
|
||||
queryKey: ["admin-audit", action],
|
||||
queryFn: () => call<AuditRow[]>(`/api/admin/audit${action ? `?action=${encodeURIComponent(action)}` : ""}`),
|
||||
});
|
||||
|
||||
export const useBackups = () =>
|
||||
useQuery({ queryKey: ["admin-backups"], queryFn: () => call<Backup[]>("/api/admin/backups") });
|
||||
|
||||
export function useCreateUser() {
|
||||
const cache = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (body: Omit<AdminUser, "id" | "blocked" | "created_at"> & { password: string }) =>
|
||||
call<AdminUser>("/api/admin/users", { method: "POST", body: JSON.stringify(body) }),
|
||||
onSuccess: () => cache.invalidateQueries({ queryKey: ["admin-users"] }),
|
||||
});
|
||||
}
|
||||
|
||||
export function usePatchUser() {
|
||||
const cache = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, body }: { id: string; body: Record<string, unknown> }) =>
|
||||
call<AdminUser>(`/api/admin/users/${id}`, { method: "PATCH", body: JSON.stringify(body) }),
|
||||
onSuccess: () => cache.invalidateQueries({ queryKey: ["admin-users"] }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateBackup() {
|
||||
const cache = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: () => call<Backup>("/api/admin/backups", { method: "POST" }),
|
||||
onSuccess: () => cache.invalidateQueries({ queryKey: ["admin-backups"] }),
|
||||
});
|
||||
}
|
||||
Loading…
Reference in a new issue