ma7engine: ядро, шаблон панели, скаффолдер и доктор

Первый коммит существующего состояния. Ядро кладётся под git ради того,
чтобы половины разъезжались в продуктовые репозитории через git subtree,
а не копированием: subtree делает настоящий merge, хранит историю и умеет
отдавать правки обратно.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019LeBkZLiQrvdECf5nfgiMz
This commit is contained in:
2026-08-26 20:39:37 +03:00
co-authored by Claude Opus 5
commit 18bd05bcbb
50 changed files with 8152 additions and 0 deletions
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Demo Panel</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+2117
View File
File diff suppressed because it is too large Load Diff
+27
View File
@@ -0,0 +1,27 @@
{
"name": "ma7engine-example-frontend",
"version": "0.1.0",
"private": true,
"scripts": {
"predev": "npm run update",
"dev": "vite",
"update": "sh scripts/update.sh",
"prebuild": "npm run update",
"build": "vite build",
"typecheck": "tsc --noEmit",
"doctor": "node scripts/doctor.mjs"
},
"dependencies": {
"axios": "^1.7.0",
"react": "^18.3.0",
"react-dom": "^18.3.0"
},
"devDependencies": {
"@types/node": "^26.3.0",
"@types/react": "^18.3.0",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.0",
"typescript": "^5.4.0",
"vite": "^5.4.0"
}
}
+116
View File
@@ -0,0 +1,116 @@
#!/bin/sh
# Обновляет вендоренную копию ядра (ma7engine) в панели:
# ma7engine/core/browser → <панель>/frontend/src/core (браузерная половина)
# ma7engine/core/server → <панель>/backend/core (серверная половина)
#
# Панель из одной папки, где сервер лежит рядом с фронтом (server.ts в корне,
# как в awg-ui), получает серверную часть в <панель>/core.
#
# Зачем копия, а не алиас прямо на соседнюю папку: контекст сборки образа
# панели — её собственный каталог (COPY frontend/ ./ в Dockerfile), и путь,
# уходящий выше него, внутри контейнера не разрешится. При копии пути
# ./src/core и ./core одинаковы в деве, в tsc --noEmit и в Docker, и Dockerfile
# трогать не нужно вообще.
#
# Файл кладётся в репозиторий потребителя (frontend/scripts/), потому что
# вызывать его нужно и тогда, когда ma7engine рядом нет.
#
# Использование: npm run update (или sh scripts/update.sh)
# Где искать ядро: $MA7ENGINE, иначе перебор кандидатов ниже — первый
# существующий выигрывает, выбранный путь печатается.
set -eu
abspath() { (CDPATH= cd -- "$1" 2>/dev/null && pwd); }
here=$(abspath "$(dirname -- "$0")")
app=$(dirname "$here") # .../<панель>/frontend или корень панели из одной папки
panel=$(dirname "$app") # .../<панель> или каталог, где лежат панели
# Ядро может лежать где угодно относительно панели: рядом с продуктовым
# репозиторием, рядом с остальными панелями или внутри самого репозитория.
# Перебираем, вместо того чтобы зашивать одну раскладку — переезд ядра иначе
# молча уводит синхронизацию на старую копию.
#
# Порядок — от ближнего к дальнему: панель, у которой ядро лежит рядом, не
# должна утащить копию из каталога уровнем выше (такая копия обычно старая —
# именно от неё и уезжает синхронизация молча).
CANDIDATES="
$app/../ma7engine
$app/../../ma7engine
$app/../../../ma7engine
$app/../../../admins/ma7engine
"
find_engine() {
if [ -n "${MA7ENGINE:-}" ]; then
if [ ! -d "$MA7ENGINE/core/browser" ]; then
echo "update: в MA7ENGINE=$MA7ENGINE нет каталога core/browser" >&2
exit 1
fi
abspath "$MA7ENGINE"
return
fi
for candidate in $CANDIDATES; do
if [ -d "$candidate/core/browser" ]; then
abspath "$candidate"
return
fi
done
}
# Куда класть серверную половину. Панели без своего сервера не получают её вовсе.
server_target() {
if [ -d "$panel/backend" ]; then echo "$panel/backend/core"
elif [ -f "$app/server.ts" ]; then echo "$app/core"
fi
}
# Скрипт обязан запускаться из фронта панели: $app определяется как каталог
# уровнем выше scripts/, и запущенный не оттуда он молча создаёт постороннюю
# src/core рядом с чем угодно. index.html — маркер vite-фронта.
if [ ! -f "$app/index.html" ]; then
echo "update: $app не похож на фронт панели (нет index.html)." >&2
echo " Скрипт запускается из <панель>/frontend: npm run update" >&2
exit 1
fi
front_to=$app/src/core
server_to=$(server_target)
engine=$(find_engine)
if [ -z "$engine" ]; then
# В контейнере ядра нет — оно уже приехало внутри src/core (и backend/core)
# вместе с остальными исходниками. Это норма, а не ошибка сборки.
if [ -d "$front_to" ] || { [ -n "$server_to" ] && [ -d "$server_to" ]; }; then
echo "update: ma7engine рядом не найден, использую уже синхронизированные копии"
exit 0
fi
echo "update: ma7engine не найден ни в одном из мест:" >&2
for candidate in $CANDIDATES; do echo " $candidate" >&2; done
echo " Укажи путь через MA7ENGINE=/путь/к/ma7engine" >&2
exit 1
fi
echo "update: ядро — $engine"
copy() { # copy <откуда> <куда> <подпись>
[ -d "$1" ] || return 0
rm -rf "$2"
mkdir -p "$(dirname "$2")"
cp -R "$1" "$2"
echo " $3: $1 → $2"
}
copy "$engine/core/browser" "$front_to" "фронт"
if [ -n "$server_to" ]; then
copy "$engine/core/server" "$server_to" "сервер"
fi
# Доктор едет вместе с ядром: проверки в панели должны быть те же, что в ядре,
# иначе панель отчитывается о соответствии по устаревшему списку. Кладётся
# рядом с этим скриптом — оттуда его зовёт `npm run doctor`.
if [ -f "$engine/scripts/doctor.mjs" ]; then
cp "$engine/scripts/doctor.mjs" "$here/doctor.mjs"
echo " доктор: $engine/scripts/doctor.mjs → $here/doctor.mjs"
fi
+21
View File
@@ -0,0 +1,21 @@
/* Стили панели поверх дизайн-системы ядра (@core/shell/styles.css). Файл
подключается после неё, поэтому здесь и добавляются свои классы, и — если
очень нужно — переопределяются токены палитры.
Шаблон палитру НЕ трогает: он показывает дизайн-систему как есть. Свой акцент
задаётся так, и обязательно в обеих темах — тёмная переопределяет те же
токены у себя, иначе цвет виден только в светлой:
:root { --primary: #2f6f4f; }
:root[data-theme="dark"] { --primary: #7fc4a0; }
Но менять стоит всю связку токенов акцента (--primary, --primary-container,
--on-primary-container, --state-primary), иначе кнопка станет своего цвета,
а тональная кнопка и активный пункт меню останутся прежними. */
/* Своё правило: имя с префиксом панели, чтобы не столкнуться с контрактом ядра. */
.demo-note {
margin-top: 12px;
color: var(--neutral-text);
font-size: 13px;
}
+36
View File
@@ -0,0 +1,36 @@
// Шаблон фронта панели — с этого начинается новый. Копируется вместе с
// ../backend; ядро в свою панель приезжает через `npm run update`.
//
// Всё, что здесь есть: конфиг. Логин, тема, drawer/сайдбар, снэкбар, сессия,
// меню по правам и переключение вкладок — в <Panel />.
//
// Парная половина — backend/server.ts: вкладки ниже ходят в его
// маршруты /api/demo/*, так что две половины запускаются вместе и работают.
import Panel, { type PanelConfig } from '@core/shell';
import './App.css'; // локальные стили — после стилей ядра
import * as tabs from '@/tabs';
const config: PanelConfig = {
tabs,
branding: { name: 'Demo Panel', version: '0.1.0' },
// Ключи localStorage разводятся по панелям: две панели на одном домене
// иначе затирают друг другу токен и тему.
storageKeys: { token: 'demo_token', theme: 'demo_theme' },
// loadSession не задан — ядро берёт GET /api/session. Панель без ролей
// задаёт его сама и отдаёт permissions: ['*'], тогда эндпоинт сессии
// реализовывать не нужно:
//
// loadSession: async () => ({ user: null, userId: null, role: '', permissions: ['*'] }),
// slots — свой хром вокруг вкладки: aboveTab (над вкладкой) и headerExtra
// (в углу шапки). Слот получает активную вкладку в ctx.tab, поэтому сам
// решает, где ему не рисоваться:
//
// slots: { aboveTab: ctx => ctx.tab?.id === 'docs' ? null : <ServerBar /> },
};
export default function App() {
return <Panel config={config} />;
}
+33
View File
@@ -0,0 +1,33 @@
// Иконки панели. Общие приезжают из ядра и реэкспортятся отсюда, чтобы вкладки
// брали все иконки из одного места; здесь остаётся только своё.
import { coreIcons } from '@core/icons';
export * from '@core/icons';
export const IcoStar = () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinejoin="round">
<path d="M12 3l2.7 5.5 6.1.9-4.4 4.3 1 6.1-5.4-2.9-5.4 2.9 1-6.1L3.2 9.4l6.1-.9z"/>
</svg>
);
export const IcoGrid = () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinejoin="round">
<rect x="3" y="3" width="7" height="7" rx="1.5"/><rect x="14" y="3" width="7" height="7" rx="1.5"/>
<rect x="3" y="14" width="7" height="7" rx="1.5"/><rect x="14" y="14" width="7" height="7" rx="1.5"/>
</svg>
);
export const IcoBook = () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinejoin="round">
<path d="M4 4.5A2.5 2.5 0 0 1 6.5 2H20v18H6.5A2.5 2.5 0 0 0 4 22z"/><path d="M4 19.5h16"/>
</svg>
);
// Карта иконок вкладок: имя из metadata.json → компонент. Своё перекрывает
// одноимённое из ядра.
export const ICONS: Record<string, () => JSX.Element> = {
...coreIcons, // key, globe
star: IcoStar,
grid: IcoGrid,
book: IcoBook,
};
+9
View File
@@ -0,0 +1,9 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
);
+56
View File
@@ -0,0 +1,56 @@
/* Стили только этой вкладки. Всё, что можно было взять из дизайн-системы ядра
(.table-card, .chip, .empty), взято оттуда; здесь — типографика документации
и блоки кода. */
.doc-section { margin-bottom: 32px; max-width: 900px; }
.doc-section > h3 {
font-size: 15px;
font-weight: 500;
color: var(--on-surface);
margin-bottom: 8px;
}
.doc-section > p,
.doc-section > ul {
font-size: 13px;
line-height: 1.6;
color: var(--neutral-text);
margin-bottom: 10px;
}
.doc-section > ul { padding-left: 18px; }
.doc-section > ul > li { margin-bottom: 4px; }
/* Блок кода: моноширинный, со скроллом по горизонтали — длинная строка не
растягивает страницу. */
.doc-code {
font-family: var(--mono);
font-size: 12px;
line-height: 1.55;
color: var(--on-surface);
background: var(--surface-bright);
border: 1px solid var(--outline);
border-radius: var(--r-card);
padding: 12px 14px;
margin-bottom: 12px;
overflow-x: auto;
white-space: pre;
}
.doc-inline {
font-family: var(--mono);
font-size: 12px;
color: var(--primary);
}
/* Предупреждение — то, что ломается молча, если сделать иначе. */
.doc-warn {
display: flex;
gap: 8px;
font-size: 13px;
line-height: 1.55;
color: var(--on-surface);
background: var(--error-bg);
border-radius: var(--r-card);
padding: 10px 14px;
margin-bottom: 12px;
}
+461
View File
@@ -0,0 +1,461 @@
// Инструкция к коду панели — прямо в панели. Не пересказ README ядра целиком,
// а то, что нужно под рукой, когда пишешь вкладку: как запустить, куда класть
// файлы, что откуда импортировать, где границы ядра и на чём тут спотыкаются.
//
// Ничего не грузит: открывается без бэкенда.
import type { SessionPageProps } from '@core/auth';
import './docs.css';
const RUN = `cd ma7engine # именно из него, не из каталога уровнем выше
npm run demo
# ставит зависимости при первом запуске, поднимает бэкенд :8080 и Vite :5173,
# по Ctrl+C гасит обе половины. Открывать :5173, логин admin / demo.`;
const RUN_SPLIT = `# терминал 1
cd ma7engine/backend && npm i && ADMIN_PASS=demo ADMIN_JWT_SECRET=dev-secret npm start
# терминал 2
cd ma7engine/frontend && npm i && npm run dev`;
const RUN_PROD = `cd frontend && npm run build # → frontend/dist
cd backend && npm start # :8080 отдаёт и API, и собранный фронт`;
const TREE = `frontend/
src/App.tsx конфиг панели: брендинг, ключи стораджа, слоты
src/App.css своё поверх дизайн-системы ядра
src/components/icons.tsx свои иконки + реэкспорт ядерных, карта ICONS
src/tabs/index.ts import.meta.glob → buildTabs (трогать не нужно)
src/tabs/<name>/ вкладка: index.tsx + metadata.json + <name>.css
src/core/ ЯДРО — копия, правки затираются (npm run update)
backend/
server.ts конфиг сервера + свои маршруты
core/ ЯДРО — копия, правки затираются`;
const NEW_TAB = `src/tabs/reports/
metadata.json { "id": "reports", "label": "Отчёты", "icon": "grid", "order": 40 }
index.tsx export default function ReportsPage({ token, showMsg }: SessionPageProps) { … }
reports.css стили только этой вкладки (импортит сам index.tsx)`;
const TAB_CODE = `import { useCallback, useEffect, useState } from 'react';
import type { SessionPageProps } from '@core/auth';
import { apiFetch, errText } from '@core/http';
import { IcoRefresh } from '@/components/icons';
import './reports.css';
export default function ReportsPage({ token, showMsg }: SessionPageProps) {
const [rows, setRows] = useState<Row[]>([]);
const load = useCallback(async () => {
try {
setRows(await apiFetch('GET', '/api/reports', token));
} catch (e) {
showMsg(errText(e)); // 401 ловить не надо — ядро разлогинит само
}
}, [token, showMsg]);
useEffect(() => { load(); }, [load]);
return rows.length === 0 ? <p className="empty">Пусто</p> : ( … );
}`;
const SESSION_FLOW = `1. Логин POST /login { user, pass } → { token }
2. Токен localStorage[storageKeys.token]
3. Кто вошёл loadSession(token) → { user, userId, role, permissions }
по умолчанию GET /api/session
4. Меню вкладки фильтруются по permissions
5. Запросы вкладок apiFetch(...) с Authorization: Bearer <token>
6. Любой 401 ядро чистит сторадж и показывает логин — во всей панели сразу
7. Логаут POST /logout, сервер отзывает токен`;
const SLOTS_CODE = `slots: {
// над контентом вкладки
aboveTab: ctx => <ServerBar token={ctx.token} showMsg={ctx.showMsg} />,
// в углу шапки, рядом с переключателем темы
headerExtra: ctx => <button className="btn btn--tonal">Действие</button>,
}
// ctx: { token, showMsg, session, tab }
// Слот гасит себя сам: aboveTab: ctx => ctx.tab?.id === 'docs' ? null : <ServerBar />`;
const ROUTE_CODE = `mountRoutes(app, { auth, forwardError }) {
// Открыт любому залогиненному — у вкладки нет permission.
app.get('/api/reports', auth.requireAuth, async (req, res) => {
try { res.json(await db.reports()); }
catch (e) { forwardError(res, e); }
});
// Закрыт правом. Гейт на фронте — только UX, граница здесь.
app.post('/api/reports', auth.requireAuth,
auth.requirePermission('reports.manage'), handler);
}`;
const HASH_CODE = `# сгенерировать scrypt-хэш — из backend/
npx tsx -e "import { hashPassword } from './core/password'; console.log(hashPassword('s3cret'))"
# → scrypt:a62e2888…:82651449…
# запуск без открытого пароля в окружении
ADMIN_USER=root ADMIN_PASS_HASH='scrypt:…' ADMIN_JWT_SECRET=… npm start`;
const CONFIG_CODE = `const config: PanelConfig = {
tabs, // результат buildTabs()
branding: { name: 'Demo Panel', version: '0.1.0' },
storageKeys: { token: 'demo_token', theme: 'demo_theme' },
endpoints: { login: '/login', logout: '/logout' },
loadSession: async token => ({ … }), // по умолчанию GET /api/session
slots: { aboveTab, headerExtra },
};`;
const NO_ROLES_CODE = `// Панель без ролей: эндпоинт сессии реализовывать не нужно —
// отдаём владельца прямо из конфига, и все вкладки видны.
loadSession: async () => ({
user: null, userId: null, role: '', permissions: ['*'],
}),`;
const META = [
['id', 'да', 'ключ активной вкладки, должен быть уникален'],
['label', 'да', 'подпись в меню и заголовок раздела'],
['icon', 'да', 'имя из карты ICONS в src/components/icons.tsx'],
['category', 'нет', 'группа в меню; без неё — плоский список'],
['permission', 'нет', 'право доступа; без него вкладка видна всем залогиненным'],
['order', 'нет', 'порядок по возрастанию, по умолчанию 0'],
['devOnly', 'нет', 'вырезается из прод-сборки (import.meta.env.PROD)'],
];
const IMPORTS = [
['@core/auth', 'SessionPageProps, BasePageProps, hasPermission, OWNER_PERMISSION'],
['@core/http', 'apiFetch, errText'],
['@core/utils', 'fmtDateTime, timeAgo, bytes, downloadFile, copyText'],
['@core/icons', 'coreIcons и Ico* — но вкладки берут их через @/components/icons'],
['@core/shell', 'Panel, PanelConfig, SlotContext — только для App.tsx'],
['@core/tabs', 'buildTabs — только для src/tabs/index.ts'],
['@/…', 'свой код панели: @/components/icons, @/lib/…'],
];
const PROPS = [
['token', 'строка для apiFetch'],
['showMsg(text)', 'снэкбар на 3 секунды'],
['username, userId, role', 'кто вошёл'],
['permissions', 'права текущей учётки; hasPermission() из @core/auth'],
['availablePermissions', 'все permission всех вкладок — нужно вкладке выдачи прав'],
['onNavigate(tabId)', 'переключить активную вкладку (роутера в панели нет)'],
];
const ENV = [
['ADMIN_USER', 'нет', 'логин; по умолчанию admin'],
['ADMIN_PASS', 'да', 'пароль открытым текстом — либо ADMIN_PASS_HASH'],
['ADMIN_PASS_HASH', 'нет', 'scrypt-хэш: тогда открытого пароля в окружении нет'],
['ADMIN_JWT_SECRET','да', 'секрет подписи сессий; без него процесс не стартует'],
['PORT', 'нет', 'порт бэкенда, по умолчанию 8080'],
['STATIC_DIR', 'нет', 'где лежит собранный фронт, по умолчанию ../frontend/dist'],
];
const TOKENS = [
['акцент', '--primary --on-primary --primary-container --on-primary-container'],
['фон', '--surface --surface-bright --surface-container --surface-hi --on-surface'],
['границы', '--outline --outline-variant'],
['статусы', '--success-bg/-text/-dot --neutral-bg/-text/-dot --error-bg/-text/-dot'],
['опасное', '--danger-text --danger-border'],
['прочее', '--label --state-primary --state-row --state-neutral --scrim --shadow'],
['типографика', '--font --mono'],
['радиусы', '--r-pill --r-card --r-table --r-logo'],
];
const MISTAKES: [string, string][] = [
['Правка в src/core или backend/core',
'затрётся при следующем npm run update. Нужно поведение иначе — новое поле в PanelConfig ядра либо своё в панели.'],
['Иконка не зарегистрирована в ICONS',
'сборка TABS падает с внятной ошибкой при старте — это не баг, а валидация манифеста.'],
['Импорт через ../../lib/…',
'папку вкладки нельзя перенести. Только @core/* и @/*.'],
['Свой цвет задан одним :root',
'в тёмной теме его не видно: она переопределяет те же токены. Задавай в обеих.'],
['permission у вкладки без проверки на маршруте',
'доступ не закрыт: гейт на фронте только прячет пункт меню.'],
['Панель не стартует без ADMIN_PASS',
'так задумано (fail-closed): панель с паролем по умолчанию хуже не поднявшейся.'],
];
export default function DocsPage(_props: SessionPageProps) {
return (
<>
<section className="doc-section">
<h3>Запуск демо</h3>
<p>
Шаблон — рабочая панель на ядре. Две половины запускаются отдельно: бэкенд отдаёт
API, Vite — фронт и проксирует на бэкенд <span className="doc-inline">/login</span>,{' '}
<span className="doc-inline">/logout</span> и <span className="doc-inline">/api</span>.
</p>
<div className="doc-code">{RUN}</div>
<p>
Если половины нужны врозь — два терминала, полными путями:{' '}
<span className="doc-inline">npm start</span> терминал не возвращает.
</p>
<div className="doc-code">{RUN_SPLIT}</div>
<p>Прод-режим — одним процессом, без Vite:</p>
<div className="doc-code">{RUN_PROD}</div>
<p>
Занят порт — <span className="doc-inline">PORT=8081 npm start</span> и{' '}
<span className="doc-inline">VITE_PROXY_TARGET=http://localhost:8081 npm run dev</span>.
Ядро приезжает копией: <span className="doc-inline">npm run update</span> кладёт его в{' '}
<span className="doc-inline">src/core</span> и{' '}
<span className="doc-inline">backend/core</span>. Руками звать не нужно — он
вызывается сам перед <span className="doc-inline">dev</span> и{' '}
<span className="doc-inline">build</span>.
</p>
</section>
<section className="doc-section">
<h3>Что где лежит</h3>
<p>
Панель — это конфиг плюс вкладки. Оболочка (логин, тема, меню, сессия),
дизайн-система и сборка реестра вкладок живут в ядре и приезжают копией.
</p>
<div className="doc-code">{TREE}</div>
<div className="doc-warn">
<span>⚠</span>
<span>
В <span className="doc-inline">src/core</span> и{' '}
<span className="doc-inline">backend/core</span> писать нельзя: это копия,
её затирает <span className="doc-inline">npm run update</span>. Нужно поведение
иначе — либо новое поле в <span className="doc-inline">PanelConfig</span> ядра,
либо своё в панели (слот, свой класс, свой маршрут).
</span>
</div>
</section>
<section className="doc-section">
<h3>Как добавить вкладку</h3>
<p>
Создай папку с тремя файлами — реестр найдёт её сам через{' '}
<span className="doc-inline">import.meta.glob</span>, править{' '}
<span className="doc-inline">src/tabs/index.ts</span> не нужно.
</p>
<div className="doc-code">{NEW_TAB}</div>
<div className="table-card">
<table>
<thead><tr><th>поле metadata.json</th><th>обяз.</th><th>смысл</th></tr></thead>
<tbody>
{META.map(([field, req, meaning]) => (
<tr key={field}>
<td className="td-mono">{field}</td>
<td>
<span className={`chip chip--${req === 'да' ? 'online' : 'offline'}`}>
<span className="chip-dot" />{req}
</span>
</td>
<td>{meaning}</td>
</tr>
))}
</tbody>
</table>
</div>
<p>
Манифест — JSON, компилятор его не проверяет, поэтому он валидируется в рантайме:
опечатка или незарегистрированная иконка падают с внятной ошибкой при сборке{' '}
<span className="doc-inline">TABS</span>, а не тихо в UI.
</p>
</section>
<section className="doc-section">
<h3>Скелет вкладки</h3>
<div className="doc-code">{TAB_CODE}</div>
<div className="table-card">
<table>
<thead><tr><th>проп</th><th>что это</th></tr></thead>
<tbody>
{PROPS.map(([name, meaning]) => (
<tr key={name}><td className="td-mono">{name}</td><td>{meaning}</td></tr>
))}
</tbody>
</table>
</div>
<p>
Панель без ролей объявляет <span className="doc-inline">BasePageProps</span> — там
только <span className="doc-inline">token</span> и{' '}
<span className="doc-inline">showMsg</span>.
</p>
</section>
<section className="doc-section">
<h3>Откуда что импортировать</h3>
<p>
Только через алиасы: <span className="doc-inline">@core/*</span> — ядро,{' '}
<span className="doc-inline">@/*</span> — свой код. Относительные{' '}
<span className="doc-inline">../../</span> ломаются при переносе папки вкладки.
</p>
<div className="table-card">
<table>
<thead><tr><th>модуль</th><th>что берут</th></tr></thead>
<tbody>
{IMPORTS.map(([mod, what]) => (
<tr key={mod}><td className="td-mono">{mod}</td><td>{what}</td></tr>
))}
</tbody>
</table>
</div>
</section>
<section className="doc-section">
<h3>Жизненный цикл сессии</h3>
<p>Всё это делает оболочка — вкладке остаётся только ходить с токеном в свои маршруты.</p>
<div className="doc-code">{SESSION_FLOW}</div>
</section>
<section className="doc-section">
<h3>Права — два слоя</h3>
<p>
<b>Фронт:</b> <span className="doc-inline">permission</span> в{' '}
<span className="doc-inline">metadata.json</span> прячет вкладку из меню, а{' '}
<span className="doc-inline">hasPermission(permissions, 'x')</span> — отдельные кнопки
внутри неё. Это UX.
</p>
<p>
<b>Бэкенд:</b> <span className="doc-inline">auth.requirePermission('x')</span> на
маршруте. Это граница. Первое без второго — не защита: запрос можно послать руками.
</p>
<p>
<span className="doc-inline">'*'</span> в{' '}
<span className="doc-inline">permissions</span> — владелец, проходит любую проверку.
Панель без ролей просто отдаёт его всем:
</p>
<div className="doc-code">{NO_ROLES_CODE}</div>
</section>
<section className="doc-section">
<h3>Свой хром — слоты</h3>
<p>
Если нужен общий для вкладок элемент (панель выбора сервера, кнопка действия в шапке),
он не копируется по вкладкам, а отдаётся оболочке слотом.
</p>
<div className="doc-code">{SLOTS_CODE}</div>
</section>
<section className="doc-section">
<h3>Конфиг панели — App.tsx</h3>
<p>Всё, чем панели отличаются друг от друга, живёт здесь и больше нигде.</p>
<div className="doc-code">{CONFIG_CODE}</div>
</section>
<section className="doc-section">
<h3>Маршруты — backend/server.ts</h3>
<p>
Логин, сессия, права, рейт-лимит (5 попыток с IP за 15 минут) и раздача фронта — в{' '}
<span className="doc-inline">createPanelServer</span>; свои маршруты монтируются
между логином и статикой. Порядок фиксирован внутри: статика отвечает на любой GET,
поэтому маршрут после неё не сработал бы.
</p>
<div className="doc-code">{ROUTE_CODE}</div>
<div className="table-card">
<table>
<thead><tr><th>переменная</th><th>обяз.</th><th>смысл</th></tr></thead>
<tbody>
{ENV.map(([name, req, meaning]) => (
<tr key={name}>
<td className="td-mono">{name}</td>
<td>
<span className={`chip chip--${req === 'да' ? 'online' : 'offline'}`}>
<span className="chip-dot" />{req}
</span>
</td>
<td>{meaning}</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="doc-warn">
<span>⚠</span>
<span>
Без <span className="doc-inline">ADMIN_PASS</span> и{' '}
<span className="doc-inline">ADMIN_JWT_SECRET</span> процесс не поднимется — это
не баг: панель, тихо работающая с паролем по умолчанию, хуже не стартовавшей.
</span>
</div>
</section>
<section className="doc-section">
<h3>Учётка: логин и пароль</h3>
<p>
Учётка в панели одна и живёт в окружении бэкенда, а не в базе:{' '}
<span className="doc-inline">ADMIN_USER</span> (по умолчанию{' '}
<span className="doc-inline">admin</span>) и{' '}
<span className="doc-inline">ADMIN_PASS</span>. В демо пароль —{' '}
<span className="doc-inline">demo</span>, его подставляет скрипт запуска.
</p>
<p>
Пароль сравнивается за постоянное время и проверяется даже при несовпавшем
логине — иначе по времени ответа «нет такого пользователя» отличается от
«неверный пароль». Чтобы открытого пароля не было в окружении вовсе, вместо{' '}
<span className="doc-inline">ADMIN_PASS</span> задаётся{' '}
<span className="doc-inline">ADMIN_PASS_HASH</span>:
</p>
<div className="doc-code">{HASH_CODE}</div>
<p>
После входа панель кладёт JWT (TTL 12 часов) в{' '}
<span className="doc-inline">localStorage</span> под ключом из{' '}
<span className="doc-inline">storageKeys.token</span>; логаут отзывает токен на
сервере. Учёток нужно несколько — либо размечай их сам и выдавай разные{' '}
<span className="doc-inline">claims</span>, либо не заводи учёток в панели
вовсе: тогда она собирает сервер сама из кусков ядра, а логин форвардит в
основной продукт, где настоящие учётки уже есть.
</p>
</section>
<section className="doc-section">
<h3>Стили и токены</h3>
<p>
Имена классов ядра — публичный контракт, все они с подписями на вкладке «Витрина».
Цвета — только через <span className="doc-inline">var(--…)</span>: палитра задана на{' '}
<span className="doc-inline">:root</span> и переопределена на{' '}
<span className="doc-inline">:root[data-theme="dark"]</span>, поэтому свой цвет
задаётся сразу в обеих темах. Несуществующий токен не ошибка — правило просто молча
не применится, отсюда список ниже.
</p>
<div className="table-card">
<table>
<thead><tr><th>группа</th><th>токены</th></tr></thead>
<tbody>
{TOKENS.map(([group, list]) => (
<tr key={group}><td>{group}</td><td className="td-mono">{list}</td></tr>
))}
</tbody>
</table>
</div>
<p>
Стили вкладки лежат в её папке и импортятся её же компонентом; общий{' '}
<span className="doc-inline">App.css</span> подключается после стилей ядра.
</p>
</section>
<section className="doc-section">
<h3>Обновление ядра</h3>
<p>
<span className="doc-inline">npm run update</span> во фронте копирует обе половины
ядра в панель: <span className="doc-inline">core/browser</span> →{' '}
<span className="doc-inline">frontend/src/core</span> и{' '}
<span className="doc-inline">core/server</span> →{' '}
<span className="doc-inline">backend/core</span>. Он же вызывается сам перед{' '}
<span className="doc-inline">npm run dev</span> и{' '}
<span className="doc-inline">npm run build</span>. В контейнере ядра рядом нет —
скрипт видит уже синхронизированную копию и молча выходит с нулём. Путь к ядру можно
задать явно: <span className="doc-inline">MA7ENGINE=/путь/к/ma7engine</span>.
</p>
</section>
<section className="doc-section">
<h3>На чём тут спотыкаются</h3>
<div className="table-card">
<table>
<thead><tr><th>что сделал</th><th>что будет</th></tr></thead>
<tbody>
{MISTAKES.map(([what, result]) => (
<tr key={what}><td>{what}</td><td>{result}</td></tr>
))}
</tbody>
</table>
</div>
</section>
</>
);
}
+6
View File
@@ -0,0 +1,6 @@
{
"id": "docs",
"label": "Инструкция",
"icon": "book",
"order": 5
}
+62
View File
@@ -0,0 +1,62 @@
// Вкладка без permission — видна любому залогиненному. Ходит в /api/demo/whoami
// из backend/server.ts и показывает клеймы текущей сессии.
//
// Пропсы приходят от оболочки: SessionPageProps — это token, showMsg и данные
// сессии. Панель без ролей объявляет BasePageProps: только token и showMsg.
import { useCallback, useEffect, useState } from 'react';
import type { SessionPageProps } from '@core/auth';
import { apiFetch, errText } from '@core/http';
import { IcoRefresh } from '@/components/icons';
export default function HelloPage({ token, showMsg, username, role }: SessionPageProps) {
const [session, setSession] = useState<Record<string, unknown> | null>(null);
const [loading, setLoading] = useState(false);
const load = useCallback(async () => {
setLoading(true);
try {
setSession(await apiFetch('GET', '/api/demo/whoami', token));
} catch (e) {
// Ловить 401 не нужно: ядро на него разлогинивает панель само.
showMsg(errText(e));
} finally {
setLoading(false);
}
}, [token, showMsg]);
useEffect(() => { load(); }, [load]);
return (
<>
<div className="toolbar">
<span className="chip chip--online">
<span className="chip-dot" />{username || '—'} · {role || 'без роли'}
</span>
<div className="toolbar-btns">
<button className="btn btn--tonal" onClick={load} disabled={loading}>
<IcoRefresh /> {loading ? 'Обновляю…' : 'Обновить'}
</button>
</div>
</div>
<div className="table-card">
<table>
<thead><tr><th>Поле</th><th>Значение</th></tr></thead>
<tbody>
{Object.entries(session ?? {}).map(([key, value]) => (
<tr key={key}>
<td className="td-mono">{key}</td>
<td className="td-mono">{JSON.stringify(value)}</td>
</tr>
))}
</tbody>
</table>
{!session && !loading && <p className="empty">Сессия не загружена</p>}
</div>
<p className="demo-note">
Классы выше — из дизайн-системы ядра; <code>.demo-note</code> — своё, из App.css.
</p>
</>
);
}
+6
View File
@@ -0,0 +1,6 @@
{
"id": "hello",
"label": "Кто я",
"icon": "star",
"order": 10
}
+15
View File
@@ -0,0 +1,15 @@
// Реестр вкладок. Сборка (валидация metadata.json, порядок, категории, права,
// devOnly) живёт в ядре — в панели остаётся только поиск папок: import.meta.glob
// это compile-time фича Vite, её паттерн резолвится относительно файла, где
// написан, и уехав в ядро она сканировала бы ядро, а не src/tabs/ панели.
//
// Добавить вкладку = создать папку с index.tsx и metadata.json. Этот файл
// трогать не нужно.
import { buildTabs } from '@core/tabs';
import { ICONS } from '@/components/icons';
export const { TABS, ALL_TAB_PERMISSIONS, visibleTabsByCategory } = buildTabs(
import.meta.glob<{ default: unknown }>('./*/metadata.json', { eager: true }),
import.meta.glob<{ default: any }>('./*/index.tsx', { eager: true }),
ICONS,
);
+151
View File
@@ -0,0 +1,151 @@
// Витрина дизайн-системы ядра: все классы публичного контракта из
// @core/shell/styles.css в одном месте, с подписями. Ничего не грузит —
// смотреть можно и без бэкенда.
//
// Заодно это тест на ломающие правки: переименовал класс в ядре — увидел здесь.
import { useState } from 'react';
import type { SessionPageProps } from '@core/auth';
import { IcoCopy, IcoPlus, IcoRefresh, IcoTrash } from '@/components/icons';
import './kit.css';
// Подпись с именем класса под каждым образцом.
function Item({ name, children }: { name: string; children: React.ReactNode }) {
return (
<div className="kit-item">
{children}
<span className="kit-name">{name}</span>
</div>
);
}
export default function KitPage({ showMsg }: SessionPageProps) {
const [modal, setModal] = useState(false);
return (
<>
<section className="kit-section">
<h3>Кнопки</h3>
<div className="kit-row">
<Item name=".btn .btn--primary"><button className="btn btn--primary">Сохранить</button></Item>
<Item name=".btn .btn--tonal"><button className="btn btn--tonal"><IcoRefresh /> Обновить</button></Item>
<Item name=".btn .btn--outline"><button className="btn btn--outline">Отмена</button></Item>
<Item name=".btn .btn--danger"><button className="btn btn--danger"><IcoTrash /> Удалить</button></Item>
<Item name=":disabled"><button className="btn btn--primary" disabled>Недоступно</button></Item>
<Item name=".btn--sq"><button className="btn btn--primary btn--sq" aria-label="Добавить"><IcoPlus /></button></Item>
</div>
</section>
<section className="kit-section">
<h3>Кнопки-иконки — для действий в строке таблицы</h3>
<div className="kit-row">
<Item name=".btn-icon"><button className="btn-icon" aria-label="Копировать"><IcoCopy /></button></Item>
<Item name=".btn-icon--neutral"><button className="btn-icon btn-icon--neutral" aria-label="Обновить"><IcoRefresh /></button></Item>
<Item name=".btn-icon--danger"><button className="btn-icon btn-icon--danger" aria-label="Удалить"><IcoTrash /></button></Item>
</div>
</section>
<section className="kit-section">
<h3>Поля</h3>
<div className="kit-row">
<Item name=".field"><input className="field" placeholder="Обычное поле" /></Item>
<Item name=".field type=password"><input className="field" type="password" placeholder="Пароль" /></Item>
<Item name=".field :disabled"><input className="field" placeholder="Недоступно" disabled /></Item>
</div>
<div className="kit-row" style={{ marginTop: 12 }}>
<Item name=".btn--full"><button className="btn btn--primary btn--full">Во всю ширину</button></Item>
</div>
</section>
<section className="kit-section">
<h3>Тулбар — поле растягивается, кнопки прижаты вправо</h3>
<div className="toolbar">
<input className="field" placeholder="Поиск" />
<div className="toolbar-btns">
<button className="btn btn--tonal"><IcoRefresh /> Обновить</button>
<button className="btn btn--primary"><IcoPlus /> Добавить</button>
</div>
</div>
<span className="kit-name">.toolbar · .toolbar-btns</span>
</section>
<section className="kit-section">
<h3>Чипы — статус одним взглядом</h3>
<div className="kit-row">
<Item name=".chip--online"><span className="chip chip--online"><span className="chip-dot" />онлайн</span></Item>
<Item name=".chip--offline"><span className="chip chip--offline"><span className="chip-dot" />офлайн</span></Item>
<Item name=".chip--error"><span className="chip chip--error"><span className="chip-dot" />ошибка</span></Item>
</div>
</section>
<section className="kit-section">
<h3>Таблица</h3>
<div className="table-card">
<table>
<thead>
<tr><th>#</th><th>Имя</th><th>Ключ</th><th>Статус</th><th /></tr>
</thead>
<tbody>
<tr>
<td className="td-num">1</td>
<td>Первая запись</td>
<td className="td-mono">a1b2c3d4</td>
<td><span className="chip chip--online"><span className="chip-dot" />онлайн</span></td>
<td className="td-actions">
<div className="actions">
<button className="btn-icon" aria-label="Копировать"><IcoCopy /></button>
<button className="btn-icon btn-icon--danger" aria-label="Удалить"><IcoTrash /></button>
</div>
</td>
</tr>
<tr>
<td className="td-num">2</td>
<td>Вторая запись</td>
<td className="td-mono">e5f6a7b8</td>
<td><span className="chip chip--offline"><span className="chip-dot" />офлайн</span></td>
<td className="td-actions">
<div className="actions">
<button className="btn-icon" aria-label="Копировать"><IcoCopy /></button>
<button className="btn-icon btn-icon--danger" aria-label="Удалить"><IcoTrash /></button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<span className="kit-name">.table-card · .td-num · .td-mono · .td-actions · .actions</span>
</section>
<section className="kit-section">
<h3>Пустое состояние и снэкбар</h3>
<div className="table-card">
<p className="empty">Ничего не найдено</p>
</div>
<div className="kit-row" style={{ marginTop: 10 }}>
<Item name="showMsg() → .snack">
<button className="btn btn--outline" onClick={() => showMsg('Сообщение живёт 3 секунды')}>
Показать снэкбар
</button>
</Item>
<Item name=".modal-*">
<button className="btn btn--outline" onClick={() => setModal(true)}>Открыть модалку</button>
</Item>
</div>
</section>
{modal && (
<div className="modal-backdrop" onClick={() => setModal(false)}>
<div className="modal-card" onClick={e => e.stopPropagation()}>
<span className="modal-title">Удалить запись?</span>
<p className="modal-hint">Действие необратимо.</p>
<div className="modal-actions">
<button className="btn btn--outline" onClick={() => setModal(false)}>Отмена</button>
<button className="btn btn--danger" onClick={() => { setModal(false); showMsg('Удалено'); }}>
Удалить
</button>
</div>
</div>
</div>
)}
</>
);
}
+30
View File
@@ -0,0 +1,30 @@
/* Стили только этой вкладки — живут в её папке и импортятся её же компонентом.
Всё, что показано ниже, нарисовано классами ядра; здесь только раскладка
витрины и подписи с именами классов. */
.kit-section {
margin-bottom: 28px;
}
.kit-section > h3 {
font-size: 13px;
font-weight: 500;
color: var(--neutral-text);
margin-bottom: 10px;
}
.kit-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
/* Подпись с именем класса — чтобы витрина была ещё и справочником. */
.kit-name {
font-family: var(--mono);
font-size: 11px;
color: var(--neutral-dot);
}
.kit-item {
display: flex;
flex-direction: column;
gap: 4px;
}
+6
View File
@@ -0,0 +1,6 @@
{
"id": "kit",
"label": "Витрина",
"icon": "grid",
"order": 30
}
+36
View File
@@ -0,0 +1,36 @@
// Вкладка с permission: без права secrets.view её нет в меню. Гейт на фронте —
// это только UX; настоящая граница на маршруте бэкенда (requirePermission в
// backend/server.ts).
import { useCallback, useEffect, useState } from 'react';
import type { SessionPageProps } from '@core/auth';
import { apiFetch, errText } from '@core/http';
export default function SecretsPage({ token, showMsg }: SessionPageProps) {
const [items, setItems] = useState<string[]>([]);
const load = useCallback(async () => {
try {
const data = await apiFetch('GET', '/api/demo/secrets', token);
setItems(data.items ?? []);
} catch (e) {
showMsg(errText(e));
}
}, [token, showMsg]);
useEffect(() => { load(); }, [load]);
if (items.length === 0) return <p className="empty">Пусто</p>;
return (
<div className="table-card">
<table>
<thead><tr><th>#</th><th>Секрет</th></tr></thead>
<tbody>
{items.map((item, i) => (
<tr key={item}><td className="td-num">{i + 1}</td><td>{item}</td></tr>
))}
</tbody>
</table>
</div>
);
}
+7
View File
@@ -0,0 +1,7 @@
{
"id": "secrets",
"label": "Секреты",
"icon": "key",
"permission": "secrets.view",
"order": 20
}
+21
View File
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2020",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"skipLibCheck": true,
"noEmit": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"types": ["vite/client", "node"], // node — для vite.config.ts (__dirname, node:path)
"baseUrl": ".",
"paths": {
"@core/*": ["./src/core/*"],
"@/*": ["./src/*"]
}
},
"include": ["src", "vite.config.ts"]
}
+27
View File
@@ -0,0 +1,27 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import path from 'node:path';
// В деве Vite (:5173) проксирует запросы на бэкенд (:8080) — фронт всегда ходит
// по относительным путям, порт нигде не зашит.
const target = process.env.VITE_PROXY_TARGET ?? 'http://localhost:8080';
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
// Ядро — вендоренная копия: `npm run update` (он же predev/prebuild)
// кладёт сюда ma7engine/core/browser. Копия, а не путь в соседнюю
// папку, потому что в образе панели пути выше её каталога нет.
'@core': path.resolve(__dirname, 'src/core'),
'@': path.resolve(__dirname, 'src'),
},
},
server: {
proxy: {
'/api': target,
'/login': target,
'/logout': target,
},
},
});