4 Commits
Author SHA1 Message Date
maenekoandClaude Opus 5 bb56e9dc3b Панель как надстройка: образ, subtree и разделение двух укладов
Dockerfile и .dockerignore: панель — один процесс и один образ, express
отдаёт и API, и собранный фронт. Контекст сборки — каталог панели, ядро
приезжает в образ закоммиченным, поэтому образ собирается из чистого
checkout'а без ma7engine рядом.

Скаффолдер больше не кладёт панели update.sh и хуки predev/prebuild: ядро
в продукт приходит через git subtree, а копирование затёрло бы поддерево.
Печатает готовые команды subtree add.

Доктор различает два уклада ядра — subtree в продукте и копию в шаблоне —
и проверяет каждый по своим правилам.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019LeBkZLiQrvdECf5nfgiMz
2026-08-26 20:46:26 +03:00
maenekoandClaude Opus 5 7977ea6738 release.sh: переставлять split-ветки принудительно
git subtree split -b отказывается двигать существующую ветку, если новый
коммит ей не потомок — после reset в ядре ветка молча оставалась старой,
и продукт тянул устаревшую половину. Ветки производные, поэтому branch -f.

Поймано doctor'ом: сверка поддерева с ядром показала расхождение серверной
половины при совпадающей браузерной.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019LeBkZLiQrvdECf5nfgiMz
2026-08-26 20:45:08 +03:00
maenekoandClaude Opus 5 0c7fee73a6 release.sh: split-ветки половин ядра для git subtree
core/browser → dist/browser, core/server → dist/server. Продукты тянут
каждую в свой prefix; ветки производные и руками не правятся.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019LeBkZLiQrvdECf5nfgiMz
2026-08-26 20:40:10 +03:00
maenekoandClaude Opus 5 18bd05bcbb ma7engine: ядро, шаблон панели, скаффолдер и доктор
Первый коммит существующего состояния. Ядро кладётся под git ради того,
чтобы половины разъезжались в продуктовые репозитории через git subtree,
а не копированием: subtree делает настоящий merge, хранит историю и умеет
отдавать правки обратно.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019LeBkZLiQrvdECf5nfgiMz
2026-08-26 20:39:37 +03:00
51 changed files with 7761 additions and 0 deletions
+13
View File
@@ -0,0 +1,13 @@
# Что не должно попадать в контекст сборки.
node_modules
**/node_modules
.git
.DS_Store
*.log
# Локальная сборка фронта: в образе он собирается заново.
frontend/dist
# Вендоренная копия ядра здесь НЕ исключается и исключаться не должна: в образ
# она обязана приехать вместе с исходниками, иначе сборка встанет на импорте
# @core. Ровно эту ошибку проверяет `npm run doctor`.
+13
View File
@@ -0,0 +1,13 @@
node_modules/
.DS_Store
# Сборка шаблона
frontend/dist
# Вендоренная копия ядра в шаблоне (npm run update)
frontend/src/core
backend/core
# Доктор в шаблоне — копия scripts/doctor.mjs, её кладёт тот же update.sh.
# В настоящей панели он, наоборот, коммитится: там ma7engine рядом может не быть.
frontend/scripts/doctor.mjs
+56
View File
@@ -0,0 +1,56 @@
# syntax=docker/dockerfile:1
# Панель — один процесс и один образ: express отдаёт и API, и собранный фронт
# (core/server/spa.ts), отдельный nginx ей не нужен.
#
# Контекст сборки — каталог самой панели, и ничего выше него не требуется:
# вендоренная копия ядра закоммичена вместе с исходниками, поэтому образ
# собирается из чистого checkout'а, без ma7engine рядом. Обновляется копия
# отдельно и осознанно — `npm run update` во фронте, диффом в ревью.
#
# docker build -t admin-hub .
# docker run -p 8080:8080 -e ADMIN_PASS=… -e ADMIN_JWT_SECRET=… admin-hub
# ─── фронт: собирается и остаётся только как dist ───────────────────────────
FROM node:24-alpine AS frontend
WORKDIR /app/frontend
COPY frontend/package.json frontend/package-lock.json ./
RUN npm ci
COPY frontend/ ./
# prebuild зовёт update.sh. Ядра рядом в образе нет, и скрипт штатно берёт уже
# синхронизированную копию из src/core — это его нормальная ветка, не ошибка.
RUN npm run build
# ─── рантайм: бэкенд плюс собранный фронт ───────────────────────────────────
FROM node:24-alpine AS runtime
WORKDIR /app/backend
COPY backend/package.json backend/package-lock.json ./
# tsx лежит в dependencies намеренно: сервер и в деве, и в образе стартует одной
# и той же командой. Компиляция в JS дала бы образ меньше, но развела бы дев и
# прод, а в этой панели пути и команды всюду одинаковы — на том же основании
# ядро ездит копией, а не алиасом.
RUN npm ci --omit=dev
COPY backend/ ./
# staticDir по умолчанию — ../frontend/dist от каталога сервера.
COPY --from=frontend /app/frontend/dist /app/frontend/dist
ENV NODE_ENV=production
ENV PORT=8080
EXPOSE 8080
# NODE_ENV=production включает разбор X-Forwarded-For в рейт-лимите: за прокси
# без него все клиенты схлопываются в один ключ и блокируют вход всем сразу.
#
# ADMIN_PASS (или ADMIN_PASS_HASH) и ADMIN_JWT_SECRET значений по умолчанию не
# имеют — без них процесс не поднимется. Это не недосмотр: тихо работающая
# панель с паролем по умолчанию хуже не стартовавшей.
USER node
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget -qO- "http://127.0.0.1:${PORT}/" >/dev/null 2>&1 || exit 1
CMD ["npm", "start"]
+532
View File
@@ -0,0 +1,532 @@
# ma7engine
Общее ядро админ-панелей: оболочка, реестр вкладок и дизайн-система. Родилось из
трёх форков одного каркаса (`admin-hub`, `admin-ma7`, `awg-ui`), где ~3600 строк
жили в трёх копиях: 77 из 78 CSS-правил и 194 из 197 строк `App.tsx` совпадали
побайтово.
```
core/ ядро; половины разъезжаются по панели врозь
browser/ браузер → <панель>/frontend/src/core
shell/ <Panel config={…} /> — логин, тема, drawer/сайдбар, снэкбар
shell/styles.css — дизайн-система (публичный контракт классов)
tabs/ buildTabs() — валидация манифестов, порядок, права, категории
http/ apiFetch, errText
utils/ bytes, timeAgo, fmtDateTime, copyText, downloadFile
auth/ hasPermission, OWNER_PERMISSION, типы сессии и PageProps
icons/ 10 иконок, совпадавших во всех панелях
server/ node → <панель>/backend/core
index.ts createPanelServer() — сборка всего в правильном порядке
ratelimit.ts лимит попыток логина по IP (с разбором X-Forwarded-For)
password.ts сравнение за постоянное время, scrypt-хэш, fail-closed
session.ts выпуск/проверка JWT, отзыв токена
auth.ts requireAuth, requirePermission
spa.ts раздача собранного фронта с fallback
errors.ts проброс статуса и текста ошибки вышестоящего API
frontend/ шаблон панели: конфиг + четыре вкладки
backend/server.ts шаблон бэкенда: конфиг + три демо-маршрута
Dockerfile один процесс и один образ: express отдаёт API и фронт
scripts/new-panel.mjs заводит новую панель из шаблона (npm run new-panel)
scripts/release.sh split-ветки половин ядра для subtree (npm run release)
scripts/doctor.mjs проверяет, что панель по стандарту (npm run doctor)
scripts/update.sh раскладывает ядро внутри шаблона (npm run update)
```
Половины лежат врозь не по вкусу, а потому что это две разные сборки: у них
разные tsconfig (`lib: DOM` + JSX + резолв бандлера против `lib: ES2020` +
CommonJS), разные адресаты копии и разные зависимости — в `core/browser`
импортируются только `react` и `axios`, в `core/server` — только `express`,
`jsonwebtoken` и `node:*`. Пересечение — ноль файлов.
## Что уже общее
Всё перечисленное панель получает готовым и у себя не пишет. Список
исчерпывающий: чего в нём нет — того нет и в ядре, значит это продуктовый код
конкретной панели.
### Оболочка
`<Panel config={…} />` — один и тот же код у панели с ролями и без, разница
только в конфиге:
- **Вход.** Форма логина с брендингом, `POST /login`, разбор ответа: 401 —
«Неверный логин или пароль», 429 — текст лимита, пришедший с сервера,
остальное — «Сервер недоступен».
- **Сессия.** Токен кладётся в `localStorage` и восстанавливается при загрузке;
протухший стирается молча, панель показывает логин.
- **Единый разлогин по 401.** Любой `apiFetch` из любой вкладки, получивший 401,
разлогинивает панель целиком — вкладки этот случай не обрабатывают.
- **Выход.** `POST /logout` (сервер отзывает токен), очистка стораджа и
состояния.
- **Тема.** Кнопка-переключатель, `data-theme` на `<html>`, запоминание выбора.
- **Меню.** Строится из вкладок, доступных правам текущей учётки, сгруппированных
по категориям; пустой список даёт заглушку «вкладок пока нет» или «ни одна
вкладка не доступна твоим правам».
- **Мобильный режим.** Топ-бар с гамбургером, drawer с затемнением, закрытие по
Escape, по клику в затемнение и свайпом влево, блокировка прокрутки фона.
- **Снэкбар.** `showMsg(text)` — сообщение на 3 секунды, общий для вкладок и
слотов.
- **Слоты.** `aboveTab` и `headerExtra` рисуются, если заданы в конфиге. Слот
получает активную вкладку в `ctx.tab` и сам решает, где не рисоваться.
- **Пропсы вкладки.** `token`, `showMsg`, `username`, `userId`, `role`,
`permissions`, `availablePermissions`, `onNavigate`.
Дефолты, когда конфиг молчит: ключи `admin_token` и `admin_theme`,
`POST /login`, `POST /logout`, `GET /api/session`.
### Браузерные модули
| модуль | что даёт |
|---|---|
| `@core/shell` | `Panel`; типы `PanelConfig`, `SlotContext` |
| `@core/tabs` | `buildTabs()`; типы `TabMeta`, `TabDef`, `TabGroup`, `BuiltTabs` |
| `@core/auth` | `hasPermission()`, `OWNER_PERMISSION`, `EMPTY_SESSION`; типы `Session`, `BasePageProps`, `SessionPageProps` |
| `@core/http` | `apiFetch()`, `errText()`, `setUnauthorizedHandler()` |
| `@core/utils` | `fmtDateTime()`, `timeAgo()`, `bytes()`, `downloadFile()`, `copyText()` |
| `@core/icons` | хром `IcoMenu`, `IcoLogout`, `IcoSun`, `IcoMoon`; карта `coreIcons` (`key`, `globe`); отдельные `IcoPlus`, `IcoTrash`, `IcoRefresh`, `IcoCopy`, `IcoGlobe`, `IcoKey` |
Плюс дизайн-система целиком: `core/browser/shell/styles.css` — 28 токенов палитры на `:root`
с переопределением на `:root[data-theme="dark"]` и контракт классов (см.
«Контракт CSS-классов»). Импортит её сама оболочка; локальный `App.css`
подключается после.
### Серверные модули
| модуль | что даёт |
|---|---|
| `createPanelServer()` | сборка всего в фиксированном порядке: json → логин/сессия → маршруты панели → статика с fallback |
| `createLoginRateLimit()`, `clientIp()` | 5 попыток с IP за 15 минут, с разбором `X-Forwarded-For` в проде |
| `createPasswordCheck()`, `hashPassword()`, `timingSafeEquals()` | сравнение за постоянное время, `scrypt`-хэш, fail-closed при старте |
| `createSessionStore()` | выпуск и проверка JWT (TTL 12 ч), отзыв токена |
| `createAuth()`, `bearerToken()` | `requireAuth`, `requirePermission(perm)` |
| `serveSpa()` | раздача `frontend/dist` с fallback; неизвестный `/api/*` — JSON-404, а не `index.html` |
| `forwardError()` | проброс статуса и текста ошибки вышестоящего API |
Чем эти модули строже того, что было в панелях по отдельности, — в разделе
«Что ядро делает строже панелей».
### Чего в списке нет
Три вещи у панелей разные и общими не станут: **конфиг** (`PanelConfig` — имя,
версия, ключи стораджа, эндпоинты, `loadSession`, слоты), **продуктовый код**
(вкладки, свои типы и утилиты, карта `ICONS`, маршруты бэкенда) и **дельта
стилей** в `App.css`. Где проходит граница — в разделе «Что в ядро не идёт».
## Базовая форма — панель без ролей
Ядро по умолчанию — плоский список вкладок, доступных всем: `category`,
`permission` и `devOnly` опциональны. Панель с правами получается включением
слоя, а не наоборот. Это не косметика: панель, которой права не нужны, не тащит
их машинерию и не обязана реализовывать эндпоинт сессии.
## Шаблон новой панели
Ядро здесь же и шаблон: `frontend/` и `backend/` в корне — рабочая минимальная
панель на этом самом ядре, а не отдельный пример, который живёт своей жизнью.
Ломающая правка ядра ломает и её, и это видно сразу.
### Запуск демо
Нужен Node 20+ (проверялось на 22). Одна команда — **из каталога `ma7engine`**,
не из каталога, где он лежит:
```bash
cd ma7engine # у меня это ~/Documents/admins/ma7engine
npm run demo
```
Скрипт при первом запуске сам поставит зависимости обеих половин, поднимет
бэкенд на `:8080` и Vite на `:5173`, а по Ctrl+C погасит оба. Открывать нужно
**`http://localhost:5173`**: Vite отдаёт фронт и проксирует `/login`, `/logout`
и `/api` на бэкенд, поэтому порт нигде не зашит. Логин — `admin` / `demo`
(переопределяется: `ADMIN_USER=root ADMIN_PASS=secret npm run demo`).
Если половины нужны врозь — два терминала, полные пути, потому что `npm start`
терминал не возвращает:
```bash
# терминал 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
```
Прод-режим — одним процессом, без Vite:
```bash
cd frontend && npm run build # → frontend/dist
cd backend && npm start # :8080 отдаёт и API, и собранный фронт
```
Ядро в шаблон приезжает копией, как в любую панель: `npm run update` кладёт
`core/browser` в `frontend/src/core`, а `core/server` — в `backend/core`. Руками
его звать не нужно, он вызывается сам перед `npm run dev` и `npm run build`,
поэтому правка в ядре подхватывается следующим запуском.
**Что может пойти не так**
| симптом | причина |
|---|---|
| `пароль панели не задан` при старте | не передан `ADMIN_PASS` — так и задумано, fail-closed |
| `не задан секрет подписи сессий` | не передан `ADMIN_JWT_SECRET` |
| `в frontend/dist нет index.html` | фронт не собран. Для дева это норма — открывай `:5173` |
| порт занят | `PORT=8081 npm start` и `VITE_PROXY_TARGET=http://localhost:8081 npm run dev` |
| `Could not read package.json` | команда запущена не из `ma7engine`, а из каталога уровнем выше |
| логин отвечает 429 | сработал рейт-лимит: 5 неудачных попыток с IP за 15 минут. Перезапусти бэкенд — счётчик в памяти |
Четыре вкладки: **«Инструкция»** — как устроен код панели: что где лежит, как
добавить вкладку, что откуда импортировать, скелет вкладки и маршрута;
**«Кто я»** — без `permission`, видна всем залогиненным, ходит в
`/api/demo/whoami`; **«Секреты»** — закрыта правом `secrets.view`; **«Витрина»** —
живой справочник дизайн-системы: все классы публичного контракта с подписями.
Первая и последняя ничего не грузят и открываются без бэкенда. Переименовал
класс в ядре — увидел это на витрине.
Заодно показано: свой класс в `App.css` и как (не) трогать палитру, расширение
карты иконок (`{ ...coreIcons, star: IcoStar }`), проброс ошибки вышестоящего API.
Чтобы увидеть гейт по правам в работе, задай в `backend/server.ts`
`claims: { permissions: [] }` — вкладка «Секреты» исчезнет из меню, а её маршрут
начнёт отвечать 403.
### Как из него сделать свою панель
Одной командой:
```sh
npm run new-panel -- admin-hub # → ../admin-hub, соседом ma7engine
npm run new-panel -- admin-hub ~/work/hub # явный путь
```
Панель — надстройка: она лежит подкаталогом продуктового репозитория
(`ghosthub/admin`, `awg-control/admin`), а не самостоятельным проектом.
Скаффолдер копирует **`frontend/` и `backend/`**, но не `core/`: ядро приходит
отдельно, через `git subtree` — см. следующий раздел.
Что он делает за тебя — ровно то, что раньше правилось руками и потому забывалось:
1. **Имена** в `package.json` обеих половин.
2. **Брендинг и ключи `localStorage`** в `App.tsx` — `admin-hub` даёт
`admin_hub_token` и `admin_hub_theme`. Забытые ключи не абстрактны: две
панели на одном домене затирают друг другу токен и тему.
3. **Демо-часть**: вкладки `hello` и `secrets` и маршруты `/api/demo/*` уезжают,
`mountRoutes` остаётся пустым с образцом в комментарии. `docs` и `kit`
остаются — обе самодостаточны, и панель запускается непустой.
4. **`.gitignore`**, `Dockerfile`, `.dockerignore` и `scripts/dev.sh`.
Остаётся только **учётка и секрет** — `ADMIN_USER`, `ADMIN_PASS` (или
`ADMIN_PASS_HASH`), `ADMIN_JWT_SECRET` в окружении бэкенда; без них ядро не даст
процессу подняться.
### Ядро в продукт — `git subtree`
У ядра две половины и два адресата, а `git subtree` работает с одним `prefix` за
раз, поэтому каждая половина выделяется в свою производную ветку:
```sh
npm run release # core/browser → dist/browser, core/server → dist/server
```
В репозитории продукта, где панель лежит подкаталогом `admin/`:
```sh
git subtree add --prefix=admin/frontend/src/core <ядро> dist/browser --squash
git subtree add --prefix=admin/backend/core <ядро> dist/server --squash
git subtree pull --prefix=admin/frontend/src/core <ядро> dist/browser --squash
git subtree pull --prefix=admin/backend/core <ядро> dist/server --squash
```
`<ядро>` — путь к ma7engine либо URL его репозитория.
**Почему поддерево, а не копирование.** `update.sh` сносил каталог целиком
(`rm -rf` + `cp -R`): локальная правка ядра в продукте исчезала молча, истории
не оставалось, и «панель разошлась с ядром» выяснялось постфактум. `git subtree`
делает настоящий merge — встречные правки одной строки дают конфликт **в момент
обновления**, а правку, сделанную в продукте, можно вернуть в ядро
(`git subtree push`). Ядро при этом лежит в репозитории продукта закоммиченным,
поэтому образ собирается из чистого checkout'а, без ma7engine рядом.
Ветки `dist/*` полностью производные: руками не правятся и не мержатся обратно —
правится `core/`, после чего `npm run release` гоняется снова.
**В самом ma7engine** ядро по-прежнему раскладывается копией (`npm run update`
из соседнего `core/`) — там источник лежит в том же репозитории, и поддерево
было бы поддеревом самого себя. Доктор различает эти два уклада и проверяет
каждый по своим правилам; в панели он ругается на оставшийся `update.sh`, потому
что тот затёр бы поддерево.
### Проверка соответствия — `npm run doctor`
Скаффолдер заводит панель по стандарту, доктор отвечает, соответствует ли она
ему до сих пор:
```sh
cd <панель>/frontend && npm run doctor
node scripts/doctor.mjs ../admin-hub # из ma7engine, по чужому пути
```
**Ошибки** (выход 1) — то, из-за чего панель соберётся неправильно или не
соберётся вовсе: пропал `update.sh` или `predev`/`prebuild`; разъехались алиасы
`@core` в `tsconfig.json` и `vite.config.ts`; `core` не закрыт `.gitignore` или,
наоборот, исключён `.dockerignore` (тогда образ соберётся без ядра); у вкладки
нет `metadata.json`, дублируется `id`, иконка не в карте `ICONS`; в бэкенде нет
`express` или `jsonwebtoken`.
Главная проверка — **сверка вендоренной копии с ядром по хэшу дерева**: она
ловит правку `src/core/` руками, то есть ровно ту первопричину, из-за которой
три панели когда-то и разъехались. Ядро ищется тем же перебором, что в
`update.sh`; не найдено — проверка честно печатается как пропущенная.
**Предупреждения** (выход 0, чтобы косметика не ломала CI) — следы шаблона:
`demo_token`, `Demo Panel`, имена `ma7engine-example-*`, разъехавшиеся между
половинами `typescript` и `@types/node`. На самом шаблоне они подавлены.
Доктор едет в панель вместе с ядром — его кладёт `update.sh` рядом с собой, так
что проверки в панели всегда те же, что в ядре.
## Подключение
Это про панель, которая уже есть; новая начинается с шаблона выше.
Ядро вендорится в `src/core` потребителя и подключается алиасом. Копия, а не
алиас на соседнюю папку, потому что контекст сборки образа панели — её
собственный каталог (`COPY frontend/ ./`), и путь выше него внутри контейнера не
разрешится. При копии `./src/core` одинаков в деве, в `tsc --noEmit` и в Docker,
и Dockerfile трогать не нужно.
```bash
cp <путь>/ma7engine/scripts/update.sh admin/frontend/scripts/
cd admin/frontend && npm run update # или sh scripts/update.sh
```
```jsonc
// package.json — обновление руками и перед каждым дев-запуском и сборкой
"scripts": {
"update": "sh scripts/update.sh",
"predev": "npm run update",
"prebuild": "npm run update"
}
```
```jsonc
// tsconfig.json
"baseUrl": ".",
"paths": {
"@core/*": ["./src/core/*"], // ядро
"@/*": ["./src/*"] // свой код панели
}
```
```ts
// vite.config.ts
resolve: { alias: {
'@core': path.resolve(__dirname, 'src/core'),
'@': path.resolve(__dirname, 'src'),
} }
```
Второй алиас — не косметика: вкладка объявлена самодостаточной папкой, но с
`../../lib/shared` внутри она не переносится между панелями и ломается при
изменении вложенности. Конфликта между `@` и `@core` нет ни в Vite, ни в
TypeScript: rollup-алиас матчит строку как `id === key || id.startsWith(key + '/')`,
а `paths` выбирает самый длинный подходящий префикс.
**Внутри самого ядра алиасов быть не должно** — оно импортит себя относительными
путями (`./session`, `../auth`). Иначе вендоренная копия потянет за собой
требование к настройке потребителя, и «скопировал папку — работает» перестанет
быть правдой.
`src/core/` (и `backend/core/`) — в `.gitignore`, но **не** в `.dockerignore`: в
образ они должны приехать вместе с остальными исходниками. Путь к ядру берётся
из `$MA7ENGINE`, иначе перебором кандидатов от ближнего к дальнему — ближний
выигрывает, чтобы панель не утащила молча старую копию из каталога уровнем выше.
Выбранный путь печатается.
Серверная половина кладётся в `<панель>/backend/core`. Панель из одной папки, где
сервер лежит рядом с фронтом (`server.ts` в корне, как в `awg-ui`), получает её в
`<панель>/core`.
В контейнере ядра рядом нет — и это норма: `update.sh` видит уже
синхронизированную копию, печатает это и выходит с нулём, поэтому `prebuild`
внутри образа не падает.
Позже это меняется на настоящую зависимость без правки импортов: спецификаторы
`@core/...` остаются, меняется только строка резолва.
## Точка сборки панели
```tsx
import Panel, { type PanelConfig } from '@core/shell';
import './App.css'; // локальные стили — после стилей ядра
import * as tabs from './tabs';
const config: PanelConfig = {
tabs,
branding: { name: 'MA7 Admin', version: '0.1.0' },
storageKeys: { token: 'admin_token', theme: 'admin_theme' },
// loadSession — по умолчанию GET /api/session; панель без ролей отдаёт
// отсюда permissions: ['*']
// slots — свой хром: aboveTab (над вкладкой), headerExtra (в углу шапки)
};
export default () => <Panel config={config} />;
```
## Реестр вкладок
`import.meta.glob` обязан остаться в приложении: это compile-time фича Vite, её
паттерн резолвится относительно файла, где написан, — уехав в ядро, он сканировал
бы ядро, а не `src/tabs/` приложения. Поэтому в приложении остаётся 8 строк:
```ts
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,
);
```
`metadata.json` компилятором не проверяется, поэтому валидируется в рантайме —
кривой манифест падает с внятной ошибкой при сборке `TABS`, а не тихо в UI:
| поле | обяз. | смысл |
|---|---|---|
| `id` | да | ключ активной вкладки, должен быть уникален |
| `label` | да | подпись в меню |
| `icon` | да | имя в карте `ICONS` |
| `category` | нет | группа в меню; без неё — плоский список |
| `permission` | нет | право доступа; без него видна всем залогиненным |
| `order` | нет | порядок по возрастанию, по умолчанию 0 |
| `devOnly` | нет | вырезается из prod-сборки (`import.meta.env.PROD`) |
## Иконки
`coreIcons` — только иконки **вкладок** (`key`, `globe`). Хром оболочки
(`IcoMenu`, `IcoLogout`, `IcoSun`, `IcoMoon`) — именованные экспорты, через карту
не ищется. Приложение расширяет и перекрывает: `{ ...coreIcons, ...myIcons }`.
## Контракт CSS-классов
Имена классов из `core/browser/shell/styles.css` — публичный API: на них опирается код вкладок
во всех панелях, поэтому переименование любого из них ломающее. Полный список — в
шапке самого файла. Коротко: `.btn` с вариантами, `.field`, `.table-card`,
`.chip`, `.toolbar`, `.modal-*`, `.actions`, `.empty`, `.td-mono`, `.td-num`.
Цвета — только через токены `var(--…)`; палитра задаётся на `:root` и
переопределяется на `:root[data-theme="dark"]`.
## Что в ядро не идёт
Брендинг (это поле конфига), карта `ICONS` целиком, продуктовые типы и утилиты,
вкладки, бэкенды. Компонент, нужный **одной** панели, остаётся в ней: обобщать по
одному потребителю рано. Понадобился второй — тогда и переезжает.
## Сервер
```ts
import { createPanelServer } from './core';
const app = createPanelServer({
auth: {
user: process.env.ADMIN_USER ?? 'admin',
password: process.env.ADMIN_PASS, // либо passwordHash
secret: process.env.ADMIN_JWT_SECRET ?? '',
revocable: true, // логаут реально отзывает токен
},
staticDir: '../frontend/dist',
mountRoutes(app, { auth, forwardError }) {
app.get('/api/x', auth.requireAuth, auth.requirePermission('x.view'), handler);
},
});
app.listen(8080);
```
Порядок монтирования фиксирован внутри и не может быть нарушен снаружи:
`json` → логин/сессия → маршруты панели → статика с fallback. Fallback ловит
любой GET, поэтому маршрут, зарегистрированный после него, не сработал бы.
Панель с готовым бэкендом может брать модули по отдельности
(`createSessionStore`, `createAuth`, `createLoginRateLimit`, `serveSpa`) — так
подключён MA7, у которого 38 своих маршрутов уже были написаны.
### Авторизация из коробки, и панель-прокси
`createPanelServer` всегда собирается с авторизацией: учётка задаётся в
`auth: { user, password | passwordHash, secret }`, панель сама выпускает и
проверяет токен, `/login`, `/logout` и `/api/session` монтируются готовыми.
Своей авторизации панель не пишет, а `sessions` и `auth` в контексте
гарантированы — проверять их на существование не нужно.
Панель, у которой собственных учёток нет (логин форвардится наверх, где живут
настоящие учётки), `createPanelServer` не использует — она собирает express
сама из кусков ядра:
```ts
const app = express();
const rateLimit = createLoginRateLimit();
// Тело JSON не парсим: прокси должен переслать сырой поток запроса как есть.
app.post('/login', rateLimit.middleware, createProxyMiddleware({ … }));
// …остальные прокси-маршруты…
serveSpa(app, STATIC_DIR); // последним: fallback ловит любой GET
```
Рейт-лимит и раздачу фронта наверх не вынести: вышестоящий API не видит IP
клиента панели отдельно от IP самого прокси.
### Что ядро делает строже панелей
- **Пароль сравнивается за постоянное время** (SHA-256 + `timingSafeEqual`), а
не через `!==`. Поддержан `scrypt`-хэш — тогда открытого пароля в окружении
нет вовсе, `hashPassword()` его генерирует.
- **Пароль проверяется даже при несовпавшем логине** — иначе по времени ответа
отличается «нет такого пользователя» от «неверный пароль».
- **Fail-closed при старте.** Без пароля или без секрета подписи процесс не
поднимается. Панель, тихо работающая с паролем `admin`, — худший исход, чем
не стартовавшая панель.
- **Рейт-лимит всегда разбирает `X-Forwarded-For`** (в проде; в деве заголовку
доверять нельзя). Панель, бравшая `req.socket.remoteAddress` напрямую, за
nginx схлопывала всех клиентов в один ключ, и пять неудачных попыток кем
угодно блокировали вход всем.
- **Неизвестный `/api/*` отдаёт JSON-404**, а не `index.html` со статусом 200,
на котором клиент падал бы при разборе ответа.
### Ограничения, о которых надо знать
Счётчик попыток и список отозванных токенов — в памяти процесса. Они не
переживают рестарт и не общие для нескольких инстансов за балансировщиком.
Для защиты от перебора этого достаточно; для настоящего отзыва на нескольких
инстансах передай своё `revocationStore`.
`jsonwebtoken` и `express` — peer-зависимости, ставятся в бэкенде панели.
## Кто на нём сидит
Три панели, ради которых ядро и появилось. Полезно как три разных ответа на
вопрос «сколько своего остаётся».
**admin-ma7** — панель с правами и своим бэкендом. Фронт: `App.tsx` — 18 строк
конфига (было 232), реестр вкладок — 20 (было 105), `App.css` — 52 строки
плиток дашборда (было 612, остальное нашлось в ядре). Бэкенд собран
`createPanelServer`, все 38 маршрутов ушли в `mountRoutes`; своя обёртка над
`forwardError` — только ради текста «API MA7 недоступен».
**admin-hub** — панель с правами, логин делегирует наверх. Фронт: `App.tsx` —
23 строки конфига, реестр — 23, `App.css` **удалён**: все 88 его правил
побайтово совпали с дизайн-системой ядра. Бэкенд берёт `createLoginRateLimit` и
`serveSpa` отдельными модулями, а не через `createPanelServer`: своей
авторизации у него нет вовсе, и тянуть ради неё `jsonwebtoken` незачем.
**awg-ui** — панель без ролей, живёт одной папкой (сервер рядом с фронтом).
Ролей нет: `loadSession` ходит в `/health` — это одновременно проверка токена и
данные для сервер-бара — и отдаёт `permissions: ['*']`. Свой хром — в слотах:
сервер-бар в `aboveTab`, кнопка перезапуска AWG в `headerExtra`. Своих стилей —
29 правил (было 700 строк). Сервер собран `createPanelServer`; ключи внешнего
API в SQLite и прокси к `awg-ctrl` остались своими.
+1629
View File
File diff suppressed because it is too large Load Diff
+21
View File
@@ -0,0 +1,21 @@
{
"name": "ma7engine-example-backend",
"version": "0.1.0",
"private": true,
"scripts": {
"start": "tsx server.ts",
"dev": "tsx watch server.ts",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"express": "^4.19.0",
"jsonwebtoken": "^9.0.0",
"tsx": "^4.7.0"
},
"devDependencies": {
"@types/express": "^4.17.0",
"@types/jsonwebtoken": "^9.0.0",
"@types/node": "^26.3.0",
"typescript": "^5.4.0"
}
}
+63
View File
@@ -0,0 +1,63 @@
// Шаблон бэкенда панели — с этого начинается новый. Копируется вместе с
// ../frontend; ядро в свою панель приезжает через `npm run update`.
//
// Всё, что здесь есть: конфиг и свои маршруты. Логин, сессия, права,
// рейт-лимит, раздача SPA и порядок их монтирования — в createPanelServer.
//
// Запуск:
// ADMIN_PASS=... ADMIN_JWT_SECRET=... npm start
//
// Панель-прокси, у которой своих учёток нет (авторизация живёт в основном
// продукте), createPanelServer не использует и собирает express сама из кусков
// ядра — см. раздел про панель-прокси в README.
import path from 'node:path';
import type { Request, Response } from 'express';
// Ядро — вендоренная копия в ./core: её кладёт `npm run update`, запущенный во
// фронте (он же predev/prebuild). Поэтому express и jsonwebtoken резолвятся из
// backend/node_modules, как в любой панели.
import { createPanelServer } from './core';
const PORT = Number(process.env.PORT) || 8080;
const app = createPanelServer({
auth: {
user: process.env.ADMIN_USER ?? 'admin',
password: process.env.ADMIN_PASS,
passwordHash: process.env.ADMIN_PASS_HASH,
secret: process.env.ADMIN_JWT_SECRET ?? '',
ttl: '12h',
revocable: true,
// claims не задан — вошедший получает permissions: ['*'], то есть
// видит все вкладки. Появятся учётки с разным доступом — размечать
// здесь.
},
// Раскладка как в панелях: <панель>/backend рядом с <панель>/frontend.
staticDir: process.env.STATIC_DIR ?? path.join(__dirname, '..', 'frontend', 'dist'),
mountRoutes(app, { auth, forwardError }) {
const { requireAuth, requirePermission, getSession } = auth;
// Открыт любому залогиненному — у вкладки в metadata.json нет permission.
app.get('/api/demo/whoami', requireAuth, (req: Request, res: Response) => {
res.json(getSession(req));
});
// Закрыт правом. Гейт на фронте (permission во вкладке) — это только
// UX; настоящая граница здесь.
app.get('/api/demo/secrets', requireAuth, requirePermission('secrets.view'),
(_req: Request, res: Response) => {
res.json({ items: ['первый', 'второй'] });
});
// Поход в вышестоящий API: его статус и текст пробрасываются клиенту,
// а не превращаются в 500.
app.get('/api/demo/upstream', requireAuth, async (_req: Request, res: Response) => {
try {
throw { response: { status: 404, data: { message: 'Не найдено наверху' } } };
} catch (e) { forwardError(res, e); }
});
},
});
app.listen(PORT, () => console.log(`demo-panel: listening on :${PORT}`));
+14
View File
@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2020",
"lib": ["ES2020"],
"module": "CommonJS",
"moduleResolution": "node",
"strict": true,
"skipLibCheck": true,
"noEmit": true,
"esModuleInterop": true,
"resolveJsonModule": true
},
"include": ["server.ts"]
}
+40
View File
@@ -0,0 +1,40 @@
// Слой сессии и прав. Опционален: панель без учёток и ролей (плоский список
// вкладок, доступных всем) обходится BasePageProps и в этот модуль не заходит.
// "*" в permissions — владелец, доступ ко всему в обход остальных проверок.
export const OWNER_PERMISSION = '*';
export function hasPermission(permissions: string[], perm: string): boolean {
return permissions.includes(OWNER_PERMISSION) || permissions.includes(perm);
}
// Что оболочка узнаёт о вошедшем. Отдаётся эндпоинтом сессии, адрес и форма
// ответа которого задаются в конфиге панели (loadSession).
export interface Session {
user: string | null;
userId: string | null;
role: string;
permissions: string[];
}
export const EMPTY_SESSION: Session = { user: null, userId: null, role: '', permissions: [] };
// Пропсы вкладки в панели без прав: вкладке нужен только токен и снэкбар.
export interface BasePageProps {
token: string;
showMsg: (text: string) => void;
}
// Пропсы вкладки в панели с сессией и правами.
export interface SessionPageProps extends BasePageProps {
username: string;
userId: string;
role: string;
permissions: string[];
// Все permission-строки, встречающиеся у вкладок — нужно только тем вкладкам,
// которые сами выдают права; остальные это поле игнорируют.
availablePermissions: string[];
// Переключить активную вкладку по её id из metadata.json. Роутера в панели
// нет — активная вкладка это состояние оболочки.
onNavigate: (tabId: string) => void;
}
+33
View File
@@ -0,0 +1,33 @@
// Единственная точка выхода вкладок в сеть. Вкладка не создаёт свой axios и не
// собирает заголовок авторизации руками — иначе обработка протухшего токена
// разъезжается по вкладкам.
import axios from 'axios';
// Что делать, когда сервер ответил 401. Ставит оболочка при старте: токен
// протух или отозван — надо разлогинить, а не показывать ошибку в снэкбаре.
// Само ядро про localStorage не знает — ключ хранения принадлежит приложению.
let onUnauthorized: (() => void) | null = null;
export function setUnauthorizedHandler(fn: (() => void) | null) {
onUnauthorized = fn;
}
export async function apiFetch(method: string, path: string, token: string, body?: object): Promise<any> {
try {
const r = await axios({ method, url: path, headers: { Authorization: `Bearer ${token}` }, data: body });
return r.data;
} catch (e) {
if (axios.isAxiosError(e) && e.response?.status === 401) {
onUnauthorized?.();
}
throw e;
}
}
// Бэкенды панелей отдают ошибку по-разному: одни как {error}, другие как
// {message}. Вкладки читают через этот хелпер, а не разбирают форму ответа
// в каждом catch.
export function errText(e: unknown, fallback = 'Сервер недоступен'): string {
const data = (e as { response?: { data?: { error?: string; message?: string } } })?.response?.data;
return data?.error ?? data?.message ?? fallback;
}
+79
View File
@@ -0,0 +1,79 @@
// Иконки ядра — только те, что уже совпадали побайтово в панелях-потребителях.
// Продуктовые иконки живут в приложении; карта ICONS собирается там же:
// export const ICONS = { ...coreIcons, ...myIcons };
// Все иконки — инлайновый SVG на currentColor, без внешних зависимостей.
export const IcoMenu = () => (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<line x1="3" y1="6" x2="21" y2="6"/>
<line x1="3" y1="12" x2="21" y2="12"/>
<line x1="3" y1="18" x2="21" y2="18"/>
</svg>
);
export const IcoLogout = () => (
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>
<polyline points="16 17 21 12 16 7"/>
<line x1="21" y1="12" x2="9" y2="12"/>
</svg>
);
export const IcoSun = () => (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="4"/>
<path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41"/>
</svg>
);
export const IcoMoon = () => (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
</svg>
);
export const IcoPlus = () => (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
</svg>
);
export const IcoTrash = () => (
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polyline points="3 6 5 6 21 6"/>
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/>
<path d="M10 11v6M14 11v6M9 6V4h6v2"/>
</svg>
);
export const IcoRefresh = () => (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polyline points="23 4 23 10 17 10"/>
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/>
</svg>
);
export const IcoCopy = () => (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="9" y="9" width="13" height="13" rx="2"/>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
</svg>
);
export const IcoGlobe = () => (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10"/>
<line x1="2" y1="12" x2="22" y2="12"/>
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>
</svg>
);
export const IcoKey = () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="7.5" cy="15.5" r="4.5"/>
<path d="M10.7 12.3 21 2"/><path d="M16 7l3 3"/><path d="M18 5l2 2"/>
</svg>
);
// Карта иконок ВКЛАДОК — по ней реестр резолвит поле "icon" из metadata.json.
// Сюда попадают только иконки, осмысленные как иконка раздела; хром оболочки
// (menu/logout/sun/moon) оболочка импортирует именованными экспортами и через
// карту никогда не ищет.
//
// Приложение расширяет карту своими и при совпадении ключа перекрывает:
// export const ICONS = { ...coreIcons, ...myIcons };
export const coreIcons: Record<string, () => JSX.Element> = {
key: IcoKey,
globe: IcoGlobe,
};
+301
View File
@@ -0,0 +1,301 @@
// Оболочка панели: логин, тема, drawer/сайдбар, снэкбар и переключение вкладок.
// Про конкретные вкладки не знает ничего — их приносит реестр из конфига.
//
// Всё продуктовое вынесено в PanelConfig: имя и версия, ключи localStorage,
// адреса эндпоинтов, способ загрузки сессии и слоты для своего хрома. Панель
// без ролей (сессия отдаёт permissions: ['*']) и панель с правами — один и тот
// же код, отличаются только конфигом.
import { useState, useEffect, useRef, useCallback, type ReactNode } from 'react';
import axios from 'axios';
import './styles.css';
import { IcoMenu, IcoLogout, IcoSun, IcoMoon } from '../icons';
import { setUnauthorizedHandler } from '../http';
import { EMPTY_SESSION, type Session } from '../auth';
import type { BuiltTabs, TabDef } from '../tabs';
// Контекст, который оболочка отдаёт слотам — чтобы свой хром мог ходить в сеть
// и показывать сообщения теми же средствами, что и вкладки.
export interface SlotContext {
token: string;
showMsg: (text: string) => void;
session: Session;
tab: TabDef | null;
}
export interface PanelConfig {
// Реестр вкладок: результат buildTabs(...) из приложения. Передаётся сюда,
// а не импортируется, потому что import.meta.glob обязан остаться в
// приложении — см. комментарий в tabs/index.ts.
tabs: BuiltTabs;
branding: { name: string; version?: string };
// Ключи localStorage. Разводятся по панелям, чтобы две панели на одном
// домене не затирали друг другу токен и тему.
storageKeys?: { token?: string; theme?: string };
endpoints?: { login?: string; logout?: string };
// Как узнать, жив ли токен, и получить роль с правами. По умолчанию —
// GET /api/session. Панель без ролей отдаёт отсюда permissions: ['*'].
loadSession?: (token: string) => Promise<Session>;
// Свой хром вокруг вкладки. Слот получает активную вкладку в ctx.tab и
// может вернуть null там, где рисоваться не должен.
slots?: {
aboveTab?: (ctx: SlotContext) => ReactNode;
headerExtra?: (ctx: SlotContext) => ReactNode;
};
}
export default function Panel({ config }: { config: PanelConfig }) {
const { TABS, ALL_TAB_PERMISSIONS, visibleTabsByCategory } = config.tabs;
const TOKEN_KEY = config.storageKeys?.token ?? 'admin_token';
const THEME_KEY = config.storageKeys?.theme ?? 'admin_theme';
const LOGIN_URL = config.endpoints?.login ?? '/login';
const LOGOUT_URL = config.endpoints?.logout ?? '/logout';
const APP_NAME = config.branding.name;
const APP_VERSION = config.branding.version;
const [token, setToken] = useState(() => localStorage.getItem(TOKEN_KEY) ?? '');
const [loginUser, setLoginUser] = useState('');
const [loginPass, setLoginPass] = useState('');
const [loginError, setLoginError] = useState('');
const [msg, setMsg] = useState('');
const [drawerOpen, setDrawerOpen] = useState(false);
const [activeTab, setActiveTab] = useState('');
const [session, setSession] = useState<Session>(EMPTY_SESSION);
const [theme, setTheme] = useState<'light' | 'dark'>(() =>
localStorage.getItem(THEME_KEY) === 'dark' ? 'dark' : 'light');
const touchStartX = useRef(0);
const touchCurrentX = useRef(0);
const showMsg = useCallback((text: string) => {
setMsg(text);
setTimeout(() => setMsg(''), 3000);
}, []);
// Проверяет, жив ли токен, и заодно тянет роль/права — по ним фильтруется
// меню. Данные конкретных вкладок грузят сами вкладки.
const loadSession = config.loadSession;
const startSession = useCallback(async (tok: string) => {
const data = loadSession
? await loadSession(tok)
: (await axios.get<Session>('/api/session', { headers: { Authorization: `Bearer ${tok}` } })).data;
setSession(data);
return data;
}, [loadSession]);
const login = useCallback(async () => {
try {
const { data: auth } = await axios.post(LOGIN_URL, { user: loginUser, pass: loginPass });
const tok = auth.token as string;
await startSession(tok);
localStorage.setItem(TOKEN_KEY, tok);
setLoginError('');
setToken(tok);
} catch (e) {
// Все три причины отказа пишем под форму, а не в снэкбар: тот живёт
// 3 секунды и уезжает, а человек в этот момент смотрит на поля и
// перенабирает пароль. Особенно важно для 429 — там верный пароль
// тоже не пустит, и без текста это выглядит как «пароль не тот».
if (axios.isAxiosError(e) && e.response?.status === 401) {
setLoginError('Неверный логин или пароль');
} else if (axios.isAxiosError(e) && e.response?.status === 429) {
setLoginError(e.response.data?.error ?? 'Слишком много попыток');
} else {
setLoginError('Сервер недоступен');
}
}
}, [loginUser, loginPass, startSession, showMsg, LOGIN_URL, TOKEN_KEY]);
const logout = useCallback(async () => {
try {
await axios.post(LOGOUT_URL, null, { headers: { Authorization: `Bearer ${token}` } });
} catch { /* токен уже мог истечь */ }
localStorage.removeItem(TOKEN_KEY);
setToken('');
setSession(EMPTY_SESSION);
setActiveTab('');
setDrawerOpen(false);
}, [token, LOGOUT_URL, TOKEN_KEY]);
// Любой 401 из apiFetch вкладки означает, что токен протух или отозван —
// разлогиниваем сразу, а не показываем ошибку в снэкбаре и ждём, пока
// пользователь сам догадается перезайти.
useEffect(() => {
setUnauthorizedHandler(() => {
localStorage.removeItem(TOKEN_KEY);
setToken('');
setSession(EMPTY_SESSION);
setActiveTab('');
});
return () => setUnauthorizedHandler(null);
}, [TOKEN_KEY]);
const handleTouchStart = useCallback((e: React.TouchEvent) => {
touchStartX.current = e.touches[0].clientX;
touchCurrentX.current = e.touches[0].clientX;
}, []);
const handleTouchMove = useCallback((e: React.TouchEvent) => {
touchCurrentX.current = e.touches[0].clientX;
}, []);
const handleTouchEnd = useCallback(() => {
if (touchStartX.current - touchCurrentX.current > 40) setDrawerOpen(false);
}, []);
// Восстановить сессию из сохранённого токена
useEffect(() => {
const saved = localStorage.getItem(TOKEN_KEY);
if (saved) {
startSession(saved).catch(() => {
localStorage.removeItem(TOKEN_KEY);
setToken('');
});
}
}, [startSession, TOKEN_KEY]);
// Применять и сохранять тему
useEffect(() => {
document.documentElement.setAttribute('data-theme', theme);
localStorage.setItem(THEME_KEY, theme);
}, [theme, THEME_KEY]);
// Блокировать прокрутку фона когда drawer открыт
useEffect(() => {
document.body.style.overflow = drawerOpen ? 'hidden' : '';
return () => { document.body.style.overflow = ''; };
}, [drawerOpen]);
// Закрывать drawer по Escape
useEffect(() => {
if (!drawerOpen) return;
const h = (e: KeyboardEvent) => { if (e.key === 'Escape') setDrawerOpen(false); };
document.addEventListener('keydown', h);
return () => document.removeEventListener('keydown', h);
}, [drawerOpen]);
// Меню строится из вкладок, доступных правам текущей учётки, сгруппированных
// по категориям. Вкладок (или прав на них) может не быть вовсе — тогда
// вместо контента показываем заглушку.
const groups = visibleTabsByCategory(session.permissions);
const visible = groups.flatMap(g => g.tabs);
const tab = visible.find(t => t.id === activeTab) ?? visible[0] ?? null;
// Слот рисуется, если он задан в конфиге. Погасить его на конкретной вкладке
// решает сам слот: активная вкладка лежит в ctx.tab.
const slotCtx: SlotContext = { token, showMsg, session, tab };
const slot = (name: 'aboveTab' | 'headerExtra') => config.slots?.[name]?.(slotCtx) ?? null;
return (
<div className="app">
<button
className="theme-toggle"
aria-label={theme === 'dark' ? 'Светлая тема' : 'Тёмная тема'}
onClick={() => setTheme(t => (t === 'dark' ? 'light' : 'dark'))}
>
{theme === 'dark' ? <IcoSun /> : <IcoMoon />}
</button>
{token && slot('headerExtra')}
{!token ? (
<div className="login-screen">
<div className="login-card">
<span className="logo-name">{APP_NAME}</span>
<p className="login-sub">Войдите, чтобы продолжить</p>
<input
className="field"
placeholder="Логин"
value={loginUser}
onChange={e => setLoginUser(e.target.value)}
autoFocus
/>
<input
className="field"
type="password"
placeholder="Пароль"
value={loginPass}
onChange={e => setLoginPass(e.target.value)}
onKeyDown={e => e.key === 'Enter' && login()}
/>
<button className="btn btn--primary btn--full" onClick={login}>
Войти
</button>
{loginError && <p className="login-error">{loginError}</p>}
</div>
</div>
) : (
<>
{/* Top bar — видим только на мобильном */}
<header className="top-bar">
<button
className="hamburger-btn"
aria-label="Открыть меню"
onClick={() => setDrawerOpen(true)}
>
<IcoMenu />
</button>
<span className="top-bar-title">{tab?.label ?? APP_NAME}</span>
</header>
{/* Затемнение под drawer */}
<div
className={`drawer-scrim${drawerOpen ? ' drawer-scrim--visible' : ''}`}
onClick={() => setDrawerOpen(false)}
/>
<div className="layout">
<aside
className={`sidebar${drawerOpen ? ' sidebar--open' : ''}`}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
>
<div className="sidebar-logo">
<span className="logo-name">{APP_NAME}</span>
{APP_VERSION && <span className="sidebar-version">{APP_VERSION}</span>}
</div>
<hr className="drawer-divider" />
{groups.map(g => (
<div key={g.category} className="nav-group">
{/* Вкладки без category собраны в одну безымянную группу —
заголовка у неё нет, меню выглядит плоским списком. */}
{g.category && <div className="nav-category">{g.category}</div>}
{g.tabs.map(t => (
<div
key={t.id}
className={`nav-item${t.id === tab?.id ? ' nav-item--active' : ''}`}
onClick={() => { setActiveTab(t.id); setDrawerOpen(false); }}
>
<t.Icon /> {t.label}
</div>
))}
</div>
))}
<div className="sidebar-spacer" />
<button className="btn btn--danger" onClick={logout}>
<IcoLogout /> Выйти
</button>
</aside>
<main className="main">
<h2 className="page-title">{tab?.label ?? APP_NAME}</h2>
{slot('aboveTab')}
{tab
? <tab.Page token={token} showMsg={showMsg}
username={session.user ?? ''} userId={session.userId ?? ''}
role={session.role} permissions={session.permissions}
availablePermissions={ALL_TAB_PERMISSIONS}
onNavigate={setActiveTab} />
: <p className="placeholder">
{TABS.length === 0
? <>Вкладок пока нет — добавь папку в <code>src/tabs/</code></>
: 'Ни одна вкладка не доступна твоим правам'}
</p>}
</main>
</div>
</>
)}
{msg && <div className="snack">{msg}</div>}
</div>
);
}
+583
View File
@@ -0,0 +1,583 @@
/*
Дизайн-система панели: тёплая кремовая база + прохладный фиолетовый акцент
в ролях Material 3. Здесь только общий язык — стили конкретной вкладки живут
в её папке (tabs/<name>/<name>.css) и импортятся её же компонентом.
ПУБЛИЧНЫЙ КОНТРАКТ. На эти имена классов опирается код вкладок во всех
панелях-потребителях, поэтому переименование любого из них — ломающее
изменение, а не рефакторинг:
кнопки .btn .btn--primary .btn--tonal .btn--outline .btn--danger
.btn--full .btn--sq .btn-icon .btn-icon--danger .btn-icon--neutral
формы .field
таблица .table-card .td-mono .td-num .td-actions
прочее .chip (+--online/--offline/--error) .chip-dot
.toolbar .toolbar-btns .actions .empty
модалка .modal-backdrop .modal-card .modal-title .modal-actions .modal-hint
Всё остальное (.app, .sidebar, .drawer-*, .top-bar, .nav-*, .login-*, .snack,
.main, .page-title, .placeholder) принадлежит оболочке — вкладки этих имён
не используют.
Живая витрина всех этих классов — вкладка «Витрина» в frontend/ (шаблон панели).
Палитра задаётся токенами на :root и переопределяется на [data-theme="dark"];
цвета в правилах — только через var(--token), литералов в компонентах нет.
*/
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html, body, #root { height: 100%; }
:root {
--primary: #6a45b8;
--on-primary: #ffffff;
--primary-container: #ebddff;
--on-primary-container: #5a2da8;
--surface: #faf6f0;
--surface-bright: #fdfaf6;
--surface-container: #f0e8dd;
--surface-hi: #ece2d3;
--on-surface: #3a2e22;
--outline: #d8cdbe;
--outline-variant: #ece2d3;
--success-bg: #c0dd97; --success-text: #2c4d0a; --success-dot: #639922;
--neutral-bg: #e4dac9; --neutral-text: #7a6a55; --neutral-dot: #a99a85;
--error-bg: #ffdad6; --error-text: #410002; --error-dot: #ba1a1a;
--danger-border: #e6c4bc;
--danger-text: #b34a3a;
/* приглушённый «бровый» лейбл — заголовки таблицы, неактивная навигация */
--label: #5a4a3a;
/* state layers / тени — зависят от темы */
--state-primary: rgba(106, 69, 184, .10);
--state-row: rgba(106, 69, 184, .035);
--state-neutral: rgba(58, 46, 34, .08);
--scrim: rgba(58, 46, 34, .45);
--shadow: rgba(58, 46, 34, .15);
--font: system-ui, -apple-system, 'Inter', sans-serif;
--mono: 'JetBrains Mono', 'Fira Code', ui-monospace, monospace;
--r-pill: 100px;
--r-card: 24px;
--r-table: 16px;
--r-logo: 9px;
}
:root[data-theme="dark"] {
--primary: #cbb3ff;
--on-primary: #381b6c;
--primary-container: #52389e;
--on-primary-container: #ebddff;
--surface: #1a1611;
--surface-bright: #2b251d;
--surface-container: #251f18;
--surface-hi: #302820;
--on-surface: #ede2d4;
--outline: #4d4334;
--outline-variant: #342c20;
--success-bg: #2d4413; --success-text: #c8e3a0; --success-dot: #8fc24f;
--neutral-bg: #34291c; --neutral-text: #d0c0a4; --neutral-dot: #b0a085;
--error-bg: #5c1414; --error-text: #ffb4ab; --error-dot: #ff5449;
--danger-border: #6e4540;
--danger-text: #ffb4ab;
--label: #b6a994;
--state-primary: rgba(203, 179, 255, .14);
--state-row: rgba(203, 179, 255, .05);
--state-neutral: rgba(237, 226, 212, .08);
--scrim: rgba(0, 0, 0, .55);
--shadow: rgba(0, 0, 0, .45);
}
body {
font-family: var(--font);
font-size: 14px;
font-weight: 400;
color: var(--on-surface);
background: var(--surface);
-webkit-font-smoothing: antialiased;
transition: background 0.2s ease, color 0.2s ease;
}
.app { min-height: 100vh; display: flex; flex-direction: column; position: relative; }
.logo-name { font-size: 17px; font-weight: 500; color: var(--on-surface); }
.login-screen {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
background: var(--surface);
}
.login-card {
width: 352px;
background: var(--surface-bright);
border-radius: var(--r-card);
border: 1px solid var(--outline-variant);
padding: 36px 32px 32px;
display: flex;
flex-direction: column;
gap: 12px;
}
.login-sub {
font-size: 14px;
color: var(--neutral-text);
margin-bottom: 4px;
}
.login-error {
font-size: 13px;
color: var(--danger-text);
text-align: center;
}
.field {
height: 44px;
padding: 0 16px;
border: 1.5px solid var(--outline);
border-radius: var(--r-pill);
background: var(--surface-bright);
font-family: var(--font);
font-size: 14px;
color: var(--on-surface);
outline: none;
transition: border-color 0.15s;
width: 100%;
}
.field:focus { border-color: var(--primary); }
.field::placeholder { color: var(--neutral-dot); }
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
height: 44px;
padding: 0 20px;
border-radius: var(--r-pill);
border: none;
font-family: var(--font);
font-size: 14px;
font-weight: 500;
cursor: pointer;
white-space: nowrap;
transition: opacity 0.12s, background 0.12s;
flex-shrink: 0;
}
.btn:hover { opacity: 0.88; }
.btn:active { opacity: 0.74; }
.btn:disabled { opacity: 0.45; cursor: not-allowed; }
.btn--primary { background: var(--primary); color: var(--on-primary); }
.btn--tonal { background: var(--primary-container); color: var(--on-primary-container); border: none; }
.btn--outline {
background: transparent;
border: 1.5px solid var(--outline);
color: var(--primary);
}
.btn--danger {
background: transparent;
border: 1.5px solid var(--danger-border);
color: var(--danger-text);
}
.btn--full { width: 100%; }
.btn--sq { width: 44px; padding: 0; flex-shrink: 0; }
/* icon-only circular button */
.btn-icon {
width: 30px; height: 30px;
border-radius: 50%;
border: none;
background: transparent;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--primary);
transition: background 0.12s;
flex-shrink: 0;
}
.btn-icon:hover { background: var(--state-primary); }
.btn-icon--danger { color: var(--danger-text); }
.btn-icon--danger:hover { background: rgba(179,74,58,.10); }
.btn-icon--neutral { color: var(--neutral-text); }
.btn-icon--neutral:hover { background: var(--state-neutral); }
.theme-toggle {
position: fixed;
top: 16px;
right: 16px;
z-index: 250;
width: 40px;
height: 40px;
border-radius: 50%;
border: 1.5px solid var(--outline);
background: var(--surface-bright);
color: var(--on-surface);
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
transition: background 0.12s, border-color 0.12s, color 0.12s;
}
.theme-toggle:hover { border-color: var(--primary); color: var(--primary); }
.top-bar {
display: none;
align-items: center;
gap: 4px;
height: 56px;
padding: 0 8px 0 4px;
background: var(--surface);
flex-shrink: 0;
z-index: 10;
}
.hamburger-btn {
width: 48px; height: 48px;
border: none;
background: transparent;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
color: var(--on-surface);
flex-shrink: 0;
-webkit-tap-highlight-color: transparent;
}
.hamburger-btn:active { background: var(--state-neutral); }
.top-bar-title {
font-size: 20px;
font-weight: 500;
color: var(--on-surface);
}
.drawer-scrim {
display: none;
position: fixed;
inset: 0;
background: var(--scrim);
z-index: 300;
opacity: 0;
pointer-events: none;
transition: opacity 0.27s cubic-bezier(0.2, 0, 0, 1);
}
.drawer-scrim--visible {
opacity: 1;
pointer-events: auto;
}
.layout {
flex: 1;
display: flex;
min-height: 100vh;
}
.sidebar {
width: 240px;
flex-shrink: 0;
background: var(--surface-container);
display: flex;
flex-direction: column;
padding: 24px 12px;
gap: 0;
}
.sidebar-logo {
padding: 0 8px 20px;
display: flex;
flex-direction: column;
gap: 2px;
}
.sidebar-version {
font-size: 11px;
color: var(--neutral-dot);
margin-top: 2px;
}
.drawer-divider {
border: none;
border-top: 0.5px solid var(--outline);
margin: 0 -12px 8px;
}
.nav-group + .nav-group { margin-top: 8px; }
.nav-category {
padding: 12px 12px 4px;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--neutral-dot);
}
.nav-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
border-radius: var(--r-pill);
font-size: 14px;
font-weight: 400;
color: var(--label);
cursor: default;
user-select: none;
}
.nav-item--active {
background: var(--primary-container);
color: var(--on-primary-container);
font-weight: 500;
}
.sidebar-spacer { flex: 1; }
.chip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
border-radius: var(--r-pill);
font-size: 12px;
white-space: nowrap;
}
.chip-dot {
width: 6px; height: 6px;
border-radius: 50%;
flex-shrink: 0;
}
.chip--online { background: var(--success-bg); color: var(--success-text); }
.chip--online .chip-dot { background: var(--success-dot); }
.chip--offline { background: var(--neutral-bg); color: var(--neutral-text); }
.chip--offline .chip-dot { background: var(--neutral-dot); }
.chip--error { background: var(--error-bg); color: var(--error-text); }
.chip--error .chip-dot { background: var(--error-dot); }
.main {
flex: 1;
padding: 32px 36px;
display: flex;
flex-direction: column;
gap: 20px;
overflow: auto;
min-width: 0;
}
.page-title {
font-size: 20px;
font-weight: 500;
color: var(--on-surface);
}
/* Заглушка на месте контента — когда вкладок ещё нет */
.placeholder {
color: var(--neutral-text);
font-size: 14px;
}
.placeholder code {
font-family: var(--mono);
font-size: 13px;
background: var(--surface-container);
padding: 2px 6px;
border-radius: 6px;
}
/* Тултип без задержки: нативный title браузер показывает через ~500 мс */
.tip-wrap {
position: relative;
display: inline-flex;
}
.tip-wrap::after {
content: attr(data-tip);
position: absolute;
bottom: calc(100% + 7px);
left: 50%;
transform: translateX(-50%);
background: var(--on-surface);
color: var(--surface-bright);
font-size: 12px;
white-space: nowrap;
padding: 5px 12px;
border-radius: var(--r-pill);
pointer-events: none;
opacity: 0;
transition: opacity 0.12s;
}
.tip-wrap:hover::after { opacity: 1; }
.toolbar {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.toolbar .field {
width: 224px;
height: 44px;
flex-shrink: 0;
}
/* На десктопе кнопки участвуют в flex toolbar'а напрямую */
.toolbar-btns { display: contents; }
.table-card {
background: var(--surface-bright);
border-radius: var(--r-table);
border: 1px solid var(--outline-variant);
overflow: hidden;
}
table {
width: 100%;
border-collapse: collapse;
}
thead th {
background: var(--surface-container);
color: var(--label);
font-weight: 500;
font-size: 13px;
text-align: left;
padding: 11px 14px;
}
thead th:first-child { padding-left: 20px; }
thead th:last-child { padding-right: 20px; }
tbody tr { border-top: 0.5px solid var(--outline-variant); }
tbody tr:hover { background: var(--state-row); }
tbody td {
padding: 11px 14px;
font-size: 13px;
color: var(--on-surface);
vertical-align: middle;
}
tbody td:first-child { padding-left: 20px; }
tbody td:last-child { padding-right: 20px; }
.td-num { font-size: 12px; color: var(--neutral-dot); width: 32px; }
.td-mono { font-family: var(--mono); font-size: 12px; }
.td-actions { width: 96px; }
.actions { display: flex; align-items: center; gap: 2px; }
.empty {
text-align: center;
color: var(--neutral-text);
padding: 48px 0 !important;
font-size: 14px;
}
/* Модалка — общий каркас (backdrop + карточка). Содержимое даёт вкладка. */
@keyframes backdrop-in {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes card-in {
from { opacity: 0; transform: scale(.92) translateY(12px); }
to { opacity: 1; transform: scale(1) translateY(0); }
}
.modal-backdrop {
position: fixed; inset: 0;
background: var(--scrim);
display: flex; align-items: center; justify-content: center;
z-index: 300;
animation: backdrop-in 0.18s ease;
}
.modal-card {
background: var(--surface-bright);
border-radius: var(--r-card);
border: 1px solid var(--outline-variant);
padding: 28px 28px 24px;
display: flex; flex-direction: column; align-items: center; gap: 14px;
width: 320px;
animation: card-in 0.22s cubic-bezier(.34,1.26,.64,1);
}
.modal-title { font-size: 16px; font-weight: 500; color: var(--on-surface); }
.modal-hint { font-size: 12px; color: var(--neutral-text); text-align: center; }
.modal-actions { display: flex; gap: 8px; width: 100%; }
.modal-actions .btn { flex: 1; }
.snack {
position: fixed;
bottom: 28px;
left: 50%;
transform: translateX(-50%);
background: var(--on-surface);
color: var(--surface-bright);
font-size: 13px;
padding: 10px 22px;
border-radius: var(--r-pill);
z-index: 500;
white-space: nowrap;
pointer-events: none;
box-shadow: 0 4px 16px var(--shadow);
}
@media (max-width: 640px) {
/* Top bar */
.top-bar { display: flex; }
.page-title { display: none; }
/* Drawer scrim */
.drawer-scrim { display: block; }
/* Запретить горизонтальный скролл страницы */
body, .app { overflow-x: hidden; }
/* Sidebar → модальный drawer */
.layout {
min-height: 0; /* flex: 1 на .app справится сам */
overflow: visible;
}
/* main — не скролл-контейнер, пусть скроллит body */
.main { overflow: visible; }
.sidebar {
position: fixed;
top: 0; left: 0;
height: 100%;
width: 280px;
z-index: 400;
border-radius: 0 var(--r-card) var(--r-card) 0;
transform: translateX(-100%);
transition: transform 0.27s cubic-bezier(0.2, 0, 0, 1);
box-shadow: 4px 0 24px var(--shadow);
padding-top: 36px;
}
.sidebar--open { transform: translateX(0); }
/* Main: одна колонка, поля 16px */
.main {
padding: 16px;
gap: 12px;
}
/* Toolbar → вертикально */
.toolbar {
flex-direction: column;
align-items: stretch;
gap: 8px;
}
.toolbar .field { width: 100%; }
.toolbar-btns {
display: flex;
gap: 8px;
}
.toolbar-btns .btn { flex: 1; }
/* Таблицы на мобильном заменяются карточками — их даёт сама вкладка */
.table-card { display: none; }
/* Модалка — по ширине экрана */
.modal-card {
width: calc(100vw - 32px);
max-width: 360px;
padding: 24px 20px 20px;
}
}
+150
View File
@@ -0,0 +1,150 @@
// Реестр вкладок. Каждая вкладка — самодостаточная папка tabs/<name>/:
// index.tsx — компонент + логика
// <name>.css — стили вкладки (импортит сам компонент)
// metadata.json — манифест: { id, label, icon, category?, permission?, order?, devOnly? }
//
// Сам поиск папок (import.meta.glob) остаётся в приложении и передаётся сюда
// аргументами — это compile-time фича Vite, её паттерн резолвится относительно
// файла, где она написана. Уехав в ядро, она сканировала бы ядро, а не
// src/tabs/ приложения.
//
// metadata.json НЕ проверяется компилятором (это JSON), поэтому содержимое
// валидируется здесь в рантайме — кривой манифест падает с внятной ошибкой при
// сборке TABS, а не «тихо» в UI.
import type { ComponentType } from 'react';
import { OWNER_PERMISSION } from '../auth';
// Сериализуемая часть из metadata.json. Обязательны только id, label и icon:
// панель без ролей и без группировки меню — базовый случай, а не урезанный.
export interface TabMeta {
id: string;
label: string;
icon: string; // имя иконки → карта ICONS
// Группа в меню. Без неё вкладка попадает в единственную безымянную группу,
// и сайдбар рисует плоский список.
category?: string;
// Право, нужное для доступа. Без него вкладка видна любому залогиненному.
permission?: string;
order?: number; // порядок в меню (по возрастанию; без него — 0)
// Вкладка, которой не должно быть в проде: её роут на бэкенде там всё равно
// отдаёт 404 независимо от прав, и вкладка вела бы в тупик.
devOnly?: boolean;
}
// Собранная вкладка: метадата + резолвнутые Icon и Page.
export interface TabDef extends TabMeta {
Icon: () => JSX.Element;
Page: ComponentType<any>;
}
export interface TabGroup {
category: string;
tabs: TabDef[];
}
export interface BuiltTabs {
TABS: TabDef[];
ALL_TAB_PERMISSIONS: string[];
visibleTabsByCategory: (permissions: string[]) => TabGroup[];
}
type MetaModules = Record<string, { default: unknown }>;
type PageModules = Record<string, { default: ComponentType<any> }>;
type IconMap = Record<string, () => JSX.Element>;
// './users/metadata.json' → 'users'
const folderOf = (path: string) => path.split('/')[1];
const REQUIRED_STRINGS = ['id', 'label', 'icon'] as const;
const OPTIONAL_STRINGS = ['category', 'permission'] as const;
// Валидация сырого metadata.json → типизированный TabMeta (или внятная ошибка).
function toMeta(name: string, raw: unknown): TabMeta {
const m = (raw ?? {}) as Record<string, unknown>;
const fail = (what: string): never => {
throw new Error(`tabs/${name}/metadata.json: ${what}`);
};
const nonEmpty = (v: unknown) => typeof v === 'string' && v.trim() !== '';
for (const key of REQUIRED_STRINGS) {
if (!nonEmpty(m[key])) fail(`поле "${key}" должно быть непустой строкой`);
}
for (const key of OPTIONAL_STRINGS) {
if (m[key] !== undefined && !nonEmpty(m[key])) fail(`"${key}" должно быть непустой строкой`);
}
if (m.order !== undefined && typeof m.order !== 'number') fail('"order" должно быть числом');
if (m.devOnly !== undefined && typeof m.devOnly !== 'boolean') fail('"devOnly" должно быть булевым');
return {
id: m.id as string,
label: m.label as string,
icon: m.icon as string,
category: m.category as string | undefined,
permission: m.permission as string | undefined,
order: m.order as number | undefined,
devOnly: m.devOnly as boolean | undefined,
};
}
// Собирает реестр вкладок из результатов import.meta.glob приложения:
//
// export const { TABS, ALL_TAB_PERMISSIONS, visibleTabsByCategory } = buildTabs(
// import.meta.glob('./*/metadata.json', { eager: true }),
// import.meta.glob('./*/index.tsx', { eager: true }),
// ICONS,
// );
//
// Вкладок может не быть вовсе (чистый каркас) — тогда TABS пустой, и оболочка
// показывает заглушку вместо контента.
export function buildTabs(metas: MetaModules, pages: PageModules, icons: IconMap): BuiltTabs {
const TABS: TabDef[] = Object.entries(metas)
.map(([path, mod]) => {
const name = folderOf(path);
const meta = toMeta(name, mod.default);
const page = pages[`./${name}/index.tsx`]?.default;
if (!page) {
throw new Error(`tabs/${name}: нет index.tsx рядом с metadata.json`);
}
if (!icons[meta.icon]) {
throw new Error(`tabs/${name}: иконка "${meta.icon}" не зарегистрирована в ICONS`);
}
return { ...meta, Icon: icons[meta.icon], Page: page };
})
// import.meta.env.PROD — режим сборки этого фронта (vite build), а не
// окружения бэкенда, к которому он ходит.
.filter(t => !t.devOnly || !import.meta.env.PROD)
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
// id вкладок используются как ключ активной вкладки — дубли ломают переключение.
const ids = TABS.map(t => t.id);
const dup = ids.find((id, i) => ids.indexOf(id) !== i);
if (dup) {
throw new Error(`tabs: дублирующийся id "${dup}" — id вкладок должны быть уникальны`);
}
// Полный список прав, встречающихся у вкладок — по нему строится форма
// выдачи прав в панелях, где такая вкладка есть: появилась вкладка с новым
// permission — появился и чекбокс, без ручной синхронизации списков.
const ALL_TAB_PERMISSIONS = Array.from(
new Set(TABS.map(t => t.permission).filter((p): p is string => !!p)),
);
// Вкладки, доступные набору прав, сгруппированные по категориям в порядке
// первого появления (сами вкладки уже отсортированы по order). Вкладки без
// category собираются в одну безымянную группу — сайдбар рисует её без
// заголовка, то есть плоским списком.
function visibleTabsByCategory(permissions: string[]): TabGroup[] {
const owner = permissions.includes(OWNER_PERMISSION);
const visible = TABS.filter(t => !t.permission || owner || permissions.includes(t.permission));
const groups: TabGroup[] = [];
for (const tab of visible) {
const category = tab.category ?? '';
let group = groups.find(g => g.category === category);
if (!group) { group = { category, tabs: [] }; groups.push(group); }
group.tabs.push(tab);
}
return groups;
}
return { TABS, ALL_TAB_PERMISSIONS, visibleTabsByCategory };
}
+52
View File
@@ -0,0 +1,52 @@
// Форматирование и работа с буфером/файлами. Чистые функции без зависимостей —
// то, что во всех панелях уже совпадало побайтово.
// ISO-дата → «2026-08-01 12:34». Без секунд и без часового пояса —
// формат для таблиц, где важна разница между записями, а не точность до сек.
export function fmtDateTime(iso: string | null | undefined): string {
if (!iso) return '—';
return iso.replace('T', ' ').slice(0, 16);
}
// unix-секунды → «5 мин» / «3 ч» (без суффикса «назад» — его добавляет вызывающий)
export function timeAgo(ts: number | undefined): string {
if (!ts) return '—';
const sec = Math.floor(Date.now() / 1000) - ts;
if (sec < 60) return sec + ' сек';
if (sec < 3600) return Math.floor(sec / 60) + ' мин';
return Math.floor(sec / 3600) + ' ч';
}
export function bytes(n: number | undefined): string {
if (!n) return '—';
if (n < 1024) return n + ' B';
if (n < 1_048_576) return (n / 1024).toFixed(1) + ' KB';
if (n < 1_073_741_824) return (n / 1_048_576).toFixed(1) + ' MB';
return (n / 1_073_741_824).toFixed(2) + ' GB';
}
export function downloadFile(filename: string, text: string) {
const url = URL.createObjectURL(new Blob([text], { type: 'text/plain;charset=utf-8' }));
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
}
// navigator.clipboard недоступен вне https/localhost — отсюда фолбэк на execCommand.
export async function copyText(text: string) {
if (navigator.clipboard) {
await navigator.clipboard.writeText(text);
} else {
const ta = document.createElement('textarea');
ta.value = text;
ta.style.cssText = 'position:fixed;opacity:0';
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
}
}
View File
View File
View File
View File
+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,
},
},
});
+45
View File
@@ -0,0 +1,45 @@
{
"name": "ma7engine",
"version": "0.1.0",
"private": true,
"description": "Общее ядро админ-панелей: оболочка, реестр вкладок, дизайн-система",
"scripts": {
"demo": "sh scripts/demo.sh",
"new-panel": "node scripts/new-panel.mjs",
"release": "sh scripts/release.sh",
"typecheck": "tsc --noEmit",
"typecheck:server": "tsc --noEmit -p tsconfig.server.json"
},
"peerDependencies": {
"axios": "^1.7.0",
"react": "^18.3.0",
"react-dom": "^18.3.0",
"express": "^4.19.0",
"jsonwebtoken": "^9.0.0"
},
"devDependencies": {
"@types/react": "^18.3.0",
"@types/react-dom": "^18.3.0",
"axios": "^1.7.0",
"react": "^18.3.0",
"typescript": "^5.4.0",
"vite": "^5.4.0"
},
"peerDependenciesMeta": {
"express": {
"optional": true
},
"jsonwebtoken": {
"optional": true
},
"react": {
"optional": true
},
"react-dom": {
"optional": true
},
"axios": {
"optional": true
}
}
}
+44
View File
@@ -0,0 +1,44 @@
#!/bin/sh
# Поднимает демо-панель целиком: бэкенд в фоне, Vite на переднем плане.
# Ctrl+C гасит обе половины.
#
# npm run demo (из корня ma7engine)
# sh scripts/demo.sh (откуда угодно — пути считаются от самого скрипта)
#
# Учётка по умолчанию — admin / demo. Переопределяется окружением:
# ADMIN_USER=root ADMIN_PASS=secret npm run demo
set -eu
here=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
root=$(dirname "$here")
# Пароль и секрет обязательны для ядра (fail-closed), поэтому у демо есть
# заведомо непригодные для прода значения по умолчанию.
ADMIN_PASS=${ADMIN_PASS:-demo}
ADMIN_JWT_SECRET=${ADMIN_JWT_SECRET:-dev-secret}
export ADMIN_PASS ADMIN_JWT_SECRET
for half in backend frontend; do
if [ ! -d "$root/$half/node_modules" ]; then
echo "demo: ставлю зависимости в $half…"
(cd "$root/$half" && npm i)
fi
done
(cd "$root/backend" && npm start) &
backend=$!
# Гасим бэкенд вместе с собой — иначе он останется висеть на :8080. Идём по
# всему дереву: между нами и слушающим порт node стоят подоболочка, npm и sh,
# и сигнал в верхний процесс до нижнего сам не доходит — тот просто осиротеет.
kill_tree() {
for child in $(pgrep -P "$1" 2>/dev/null); do kill_tree "$child"; done
kill "$1" 2>/dev/null || true
}
cleanup() { kill_tree "$backend"; }
trap cleanup EXIT INT TERM
echo "demo: бэкенд на :8080, фронт поднимается на :5173 — открывать его"
echo "demo: логин admin / ${ADMIN_PASS}"
cd "$root/frontend" && npm run dev
+379
View File
@@ -0,0 +1,379 @@
#!/usr/bin/env node
// Проверяет, что панель собрана и осталась по стандарту ma7engine. Отвечает на
// вопрос, на который раньше ответа не было: «эта панель ещё соответствует ядру,
// или уже уехала?» — именно расхождение трёх форков и породило само ядро.
//
// npm run doctor (из <панель>/frontend)
// node scripts/doctor.mjs ../admin-hub (из ma7engine, по чужому пути)
//
// Ошибки (выход 1) — то, из-за чего панель соберётся неправильно или не
// соберётся вовсе. Предупреждения (выход 0) — следы шаблона, оставшиеся после
// копирования: они не ломают сборку, поэтому не должны ломать и CI.
//
// Скрипт распространяется в панели вместе с ядром — его кладёт update.sh рядом
// с собой, так что проверки в панели всегда те же, что в ядре.
import fs from 'node:fs';
import path from 'node:path';
import crypto from 'node:crypto';
import { execFileSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
const HERE = path.dirname(fileURLToPath(import.meta.url));
// ─── где панель ─────────────────────────────────────────────────────────────
// Аргументом — путь к панели; без аргумента корнем считается ближайший предок,
// в котором лежит frontend/package.json. Скрипт живёт либо в
// <панель>/frontend/scripts (куда его кладёт update.sh), либо в
// ma7engine/scripts — оба случая этот подъём разбирает одинаково, и он не
// зависит от того, какие ещё каталоги оказались рядом.
function resolvePanel() {
const arg = process.argv[2];
if (arg) return path.resolve(arg);
for (let dir = HERE; ; dir = path.dirname(dir)) {
if (fs.existsSync(path.join(dir, 'frontend/package.json'))) return dir;
if (dir === path.dirname(dir)) break;
}
return path.dirname(HERE);
}
const PANEL = resolvePanel();
const FRONT = path.join(PANEL, 'frontend');
// Панель из одной папки (сервер рядом с фронтом, как было в awg-ui) держит
// серверную половину ядра в <панель>/core, а не в <панель>/backend/core.
const BACK = fs.existsSync(path.join(PANEL, 'backend')) ? path.join(PANEL, 'backend') : PANEL;
const SERVER_CORE = path.join(BACK, 'core');
// Сам шаблон ma7engine — панель по всем структурным признакам, но demo_token и
// «Demo Panel» в нём норма, а не недоделка. Узнаётся по каталогу core/ рядом.
const IS_TEMPLATE = fs.existsSync(path.join(PANEL, 'core', 'browser'));
// Ядро попадает в панель одним из двух способов, и проверки у них разные:
//
// subtree — панель лежит в git-репозитории продукта, ядро подтянуто
// `git subtree add` и потому отслеживается git. Обновляется
// `git subtree pull`; update.sh здесь не нужен и вреден — он
// сносит каталог целиком и затёр бы поддерево.
//
// copy — ядро раскладывает update.sh из соседнего ma7engine. Так устроен
// сам шаблон, и так живёт панель вне git.
function coreIsTracked(dir) {
try {
const out = execFileSync('git', ['ls-files', '--', dir],
{ cwd: PANEL, stdio: ['ignore', 'pipe', 'ignore'] });
return out.toString().trim() !== '';
} catch { return false; }
}
const MODE = !IS_TEMPLATE && coreIsTracked(path.join(FRONT, 'src/core')) ? 'subtree' : 'copy';
// ─── накопитель результатов ─────────────────────────────────────────────────
const errors = [], warns = [], skips = [], oks = [];
const ok = (m) => oks.push(m);
const err = (m, w) => errors.push(w ? `${m}\n ${w}` : m);
const warn = (m, w) => warns.push(w ? `${m}\n ${w}` : m);
const skip = (m) => skips.push(m);
const read = (p) => { try { return fs.readFileSync(p, 'utf8'); } catch { return null; } };
// Проверка «файл есть и в нём встречается X»; возвращает текст либо null.
function need(file, label) {
const text = read(file);
if (text === null) err(`${label}: нет файла ${path.relative(PANEL, file)}`);
return text;
}
// ─── 1. раскладка и сборка ──────────────────────────────────────────────────
if (!fs.existsSync(FRONT)) {
console.error(`doctor: в ${PANEL} нет каталога frontend — это не панель ma7engine`);
process.exit(1);
}
const updateSh = path.join(FRONT, 'scripts/update.sh');
if (MODE === 'subtree') {
if (fs.existsSync(updateSh)) {
err('в subtree-панели остался frontend/scripts/update.sh',
'он сносит каталог ядра целиком (rm -rf) и затрёт подтянутое поддерево — ' +
'обновление здесь делается git subtree pull');
} else ok('ядро приходит через git subtree (update.sh не нужен)');
} else if (fs.existsSync(updateSh)) ok('update.sh на месте');
else err('нет frontend/scripts/update.sh', 'без него ядро в панели не обновляется');
const frontPkgText = need(path.join(FRONT, 'package.json'), 'фронт');
const frontPkg = frontPkgText ? JSON.parse(frontPkgText) : null;
if (frontPkg) {
const s = frontPkg.scripts ?? {};
if (MODE === 'subtree') {
const stale = ['update', 'predev', 'prebuild'].filter(h => /update/.test(s[h] ?? ''));
if (stale.length) err(`frontend/package.json: остались хуки копирования (${stale.join(', ')})`,
'ядро приходит поддеревом — эти скрипты его затрут');
else ok('в package.json нет хуков копирования ядра');
} else {
if (/update\.sh/.test(s.update ?? '')) ok('скрипт update ведёт на scripts/update.sh');
else err('frontend/package.json: скрипт "update" не зовёт scripts/update.sh');
for (const hook of ['predev', 'prebuild']) {
if (/update/.test(s[hook] ?? '')) ok(`${hook} обновляет ядро`);
else err(`frontend/package.json: нет "${hook}": "npm run update"`,
'без него dev и build идут на устаревшей копии ядра');
}
}
}
// tsconfig.json шаблона содержит //-комментарий — JSON.parse на нём падает,
// поэтому конфиги проверяются регулярками, а не разбором.
const tsconf = need(path.join(FRONT, 'tsconfig.json'), 'фронт');
if (tsconf) {
const pairs = [[/"@core\/\*"\s*:\s*\[\s*"\.\/src\/core\/\*"\s*\]/, '@core/* → ./src/core/*'],
[/"@\/\*"\s*:\s*\[\s*"\.\/src\/\*"\s*\]/, '@/* → ./src/*']];
for (const [re, label] of pairs) {
if (re.test(tsconf)) ok(`tsconfig: ${label}`);
else err(`tsconfig.json: нет пути ${label}`, 'вкладки перестанут резолвить импорты ядра');
}
}
const viteConf = need(path.join(FRONT, 'vite.config.ts'), 'фронт');
if (viteConf) {
for (const [re, label] of [[/'@core'\s*:/, '@core'], [/'@'\s*:/, '@']]) {
if (re.test(viteConf)) ok(`vite: алиас ${label}`);
else err(`vite.config.ts: нет алиаса ${label}`, 'сборка не найдёт то, что находит tsc');
}
}
// ─── 2. вендоренная копия ядра ──────────────────────────────────────────────
const BROWSER_CORE = path.join(FRONT, 'src/core');
if (!fs.existsSync(BROWSER_CORE)) {
err('нет frontend/src/core', 'ядро ни разу не приезжало — запусти npm run update');
}
// Хэш дерева: имена файлов и содержимое, в устойчивом порядке.
function treeHash(dir) {
if (!fs.existsSync(dir)) return null;
const h = crypto.createHash('sha256');
const walk = (d, prefix) => {
for (const e of fs.readdirSync(d, { withFileTypes: true }).sort((a, b) => a.name < b.name ? -1 : 1)) {
if (e.name === '.DS_Store') continue; // Finder кладёт его в любую папку
const p = path.join(d, e.name);
if (e.isDirectory()) walk(p, `${prefix}${e.name}/`);
else { h.update(`${prefix}${e.name}\0`); h.update(fs.readFileSync(p)); }
}
};
walk(dir, '');
return h.digest('hex');
}
// Ядро ищется тем же перебором, что в update.sh — порядок от ближнего к дальнему.
function findEngine() {
if (process.env.MA7ENGINE) return process.env.MA7ENGINE;
if (IS_TEMPLATE) return PANEL;
for (const c of [path.join(FRONT, '../ma7engine'),
path.join(FRONT, '../../ma7engine'),
path.join(FRONT, '../../../ma7engine'),
path.join(FRONT, '../../../admins/ma7engine')]) {
if (fs.existsSync(path.join(c, 'core/browser'))) return path.resolve(c);
}
return null;
}
const engine = findEngine();
if (!engine) {
skip('сверка копии ядра — ma7engine рядом не найден (укажи MA7ENGINE=/путь)');
} else {
for (const [copy, src, label] of [[BROWSER_CORE, path.join(engine, 'core/browser'), 'браузерная'],
[SERVER_CORE, path.join(engine, 'core/server'), 'серверная']]) {
if (!fs.existsSync(copy)) { skip(`сверка: ${label} половина ядра в панель не приезжала`); continue; }
if (treeHash(copy) === treeHash(src)) ok(`копия ядра совпадает с ядром (${label})`);
else err(`копия ядра разошлась с ma7engine (${label}: ${path.relative(PANEL, copy)})`,
'ядро правили руками либо копия устарела — npm run update перезальёт её');
}
}
// Ядро обязано ходить относительными путями: с @core внутри себя вендоренная
// копия потянула бы за собой требование к настройке потребителя.
for (const [dir, label] of [[BROWSER_CORE, 'browser'], [SERVER_CORE, 'server']]) {
if (!fs.existsSync(dir)) continue;
const bad = [];
const walk = (d) => {
for (const e of fs.readdirSync(d, { withFileTypes: true })) {
const p = path.join(d, e.name);
if (e.isDirectory()) walk(p);
else if (/\.tsx?$/.test(e.name) && /from\s+'@(core)?\//.test(read(p) ?? '')) bad.push(p);
}
};
walk(dir);
if (bad.length) err(`внутри копии ядра есть алиасные импорты (${label}): ` +
bad.map(p => path.relative(PANEL, p)).join(', '),
'ядро должно импортировать себя относительными путями');
else ok(`копия ядра ходит относительными путями (${label})`);
}
// ─── 3. игнор-файлы ─────────────────────────────────────────────────────────
// Копия ядра обязана попадать и в репозиторий панели, и в образ: контекст
// сборки — каталог панели, ma7engine рядом при сборке нет.
//
// В самом ma7engine наоборот: там копия — артефакт `npm run update`, источник
// лежит в соседнем core/, и коммитить её значило бы держать ядро в одном
// репозитории трижды.
const gitignore = read(path.join(PANEL, '.gitignore')) ?? read(path.join(FRONT, '.gitignore'));
if (gitignore === null) {
skip('.gitignore — файла нет, проверять нечего');
} else {
const ignoresCore = /(^|\/)(frontend\/)?src\/core\s*$/m.test(gitignore)
|| /(^|\/)backend\/core\s*$/m.test(gitignore)
|| /^core\/?\s*$/m.test(gitignore);
if (IS_TEMPLATE) {
if (ignoresCore) ok('.gitignore закрывает копию ядра (шаблон — источник рядом в core/)');
else warn('шаблон: .gitignore не закрывает копию ядра',
'в ma7engine она артефакт npm run update, а источник — соседний core/');
} else if (ignoresCore) {
err('.gitignore исключает копию ядра из репозитория панели',
'образ собирается из чистого checkout\'а, где ma7engine рядом нет — ' +
'без закоммиченной копии сборка встанет на импорте @core');
} else {
ok('копия ядра коммитится вместе с панелью');
}
}
// ─── 3b. образ ──────────────────────────────────────────────────────────────
const dockerfile = read(path.join(PANEL, 'Dockerfile'));
if (dockerfile === null) {
warn('нет Dockerfile', 'панель — один процесс и один образ; шаблон везёт готовый');
} else {
if (/COPY --from=frontend .*dist/.test(dockerfile)) ok('Dockerfile кладёт собранный фронт в образ');
else warn('Dockerfile не копирует frontend/dist из стадии сборки',
'сервер отдаёт фронт сам — без dist панель поднимется пустой');
}
const dockerignore = read(path.join(PANEL, '.dockerignore'));
if (dockerignore === null) {
skip('.dockerignore — файла нет, проверять нечего');
} else if (/(^|\/|\*)core\/?\s*$/m.test(dockerignore)) {
err('.dockerignore исключает core', 'в образ копия ядра должна приехать с исходниками — ' +
'иначе сборка встанет на импорте @core');
} else {
ok('.dockerignore не исключает копию ядра');
}
// ─── 4. вкладки ─────────────────────────────────────────────────────────────
// Те же инварианты, что buildTabs проверяет в рантайме, но до сборки.
const tabsDir = path.join(FRONT, 'src/tabs');
if (!fs.existsSync(tabsDir)) {
warn('нет src/tabs', 'панель без вкладок покажет заглушку');
} else {
// Имена иконок берутся только из тел карт ICONS и coreIcons — разбирать
// файл целиком значило бы ловить в известные и случайные `что-то: Ico…`,
// а ложная ошибка здесь хуже пропущенной: buildTabs всё равно поймает
// незарегистрированную иконку в рантайме.
const mapBody = (text, name) => {
const start = text.indexOf(`export const ${name}`);
if (start === -1) return null;
const open = text.indexOf('{', start), close = text.indexOf('\n};', open);
return open === -1 || close === -1 ? null : text.slice(open, close);
};
const bodies = [mapBody(read(path.join(FRONT, 'src/components/icons.tsx')) ?? '', 'ICONS'),
mapBody(read(path.join(BROWSER_CORE, 'icons/index.tsx')) ?? '', 'coreIcons')];
const known = bodies.some(b => b === null)
? null // карту не нашли — проверку пропускаем
: new Set([...bodies.join('\n').matchAll(/(\w+)\s*:\s*Ico\w+/g)].map(m => m[1]));
if (known === null) skip('сверка иконок вкладок — карта ICONS не разобрана');
const ids = new Map();
let clean = true;
for (const e of fs.readdirSync(tabsDir, { withFileTypes: true })) {
if (!e.isDirectory()) continue;
const dir = path.join(tabsDir, e.name);
const metaPath = path.join(dir, 'metadata.json');
if (!fs.existsSync(path.join(dir, 'index.tsx'))) { err(`tabs/${e.name}: нет index.tsx`); clean = false; continue; }
if (!fs.existsSync(metaPath)) { err(`tabs/${e.name}: нет metadata.json`); clean = false; continue; }
let meta;
try { meta = JSON.parse(read(metaPath)); }
catch { err(`tabs/${e.name}/metadata.json: не разбирается как JSON`); clean = false; continue; }
for (const key of ['id', 'label', 'icon']) {
if (typeof meta[key] !== 'string' || !meta[key].trim()) {
err(`tabs/${e.name}/metadata.json: поле "${key}" должно быть непустой строкой`); clean = false;
}
}
if (meta.id) {
if (ids.has(meta.id)) { err(`дублирующийся id вкладки "${meta.id}": ${ids.get(meta.id)} и ${e.name}`); clean = false; }
else ids.set(meta.id, e.name);
}
if (meta.icon && known && !known.has(meta.icon)) {
err(`tabs/${e.name}: иконка "${meta.icon}" не найдена в карте ICONS`); clean = false;
}
}
if (clean && ids.size) ok(`вкладки в порядке (${ids.size} шт.)`);
}
// ─── 5. бэкенд ──────────────────────────────────────────────────────────────
const serverTs = read(path.join(BACK, 'server.ts'));
if (serverTs === null) {
skip('server.ts не найден — панель, видимо, без своего бэкенда');
} else {
if (/from\s+'\.\/core'/.test(serverTs)) ok("server.ts импортирует './core'");
else warn("server.ts не импортирует './core'", 'панель собирает сервер сама — это допустимо для панели-прокси');
const backPkgText = read(path.join(BACK, 'package.json'));
if (backPkgText) {
const deps = JSON.parse(backPkgText).dependencies ?? {};
for (const dep of ['express', 'jsonwebtoken']) {
if (deps[dep]) ok(`бэкенд: ${dep} в зависимостях`);
else err(`backend/package.json: нет зависимости ${dep}`,
'серверная половина ядра объявляет её peer-зависимостью');
}
}
}
// ─── 6. следы шаблона (предупреждения) ──────────────────────────────────────
if (!IS_TEMPLATE) {
const app = read(path.join(FRONT, 'src/App.tsx')) ?? '';
if (/demo_token|demo_theme/.test(app)) {
warn('storageKeys остались демонстрационными (demo_token/demo_theme)',
'две панели на одном домене затрут друг другу токен и тему');
}
if (/name:\s*'Demo Panel'/.test(app)) warn("branding.name остался 'Demo Panel'");
// Заголовок вкладки браузера живёт отдельно от branding.name и виден до
// того, как смонтируется React, — забывается чаще всего.
const html = read(path.join(FRONT, 'index.html')) ?? '';
if (/<title>Demo Panel<\/title>/.test(html)) warn("index.html: <title> остался 'Demo Panel'");
for (const [pkgPath, label] of [[path.join(FRONT, 'package.json'), 'frontend'],
[path.join(BACK, 'package.json'), 'backend']]) {
const t = read(pkgPath);
if (t && /"ma7engine-example-/.test(t)) warn(`${label}/package.json: имя осталось от шаблона`);
}
}
// Половины ставят TypeScript и @types/node врозь — расхождение мажоров значит,
// что tsc во фронте и в бэкенде проверяют код по разным правилам.
{
const major = (r) => (r ?? '').replace(/^[^\d]*/, '').split('.')[0];
const f = frontPkg?.devDependencies ?? {};
const bText = read(path.join(BACK, 'package.json'));
const b = bText ? (JSON.parse(bText).devDependencies ?? {}) : {};
for (const dep of ['typescript', '@types/node']) {
if (f[dep] && b[dep] && major(f[dep]) !== major(b[dep])) {
warn(`${dep} разъехался между половинами: ${f[dep]} и ${b[dep]}`);
}
}
}
// ─── вывод ──────────────────────────────────────────────────────────────────
const rel = path.relative(process.cwd(), PANEL) || '.';
console.log(`doctor: ${rel}${IS_TEMPLATE ? ' (шаблон ma7engine)' : ` (ядро: ${MODE})`}\n`);
for (const m of oks) console.log(` ok ${m}`);
for (const m of skips) console.log(` — ${m}`);
for (const m of warns) console.log(` warn ${m}`);
for (const m of errors) console.log(` ОШИБКА ${m}`);
console.log();
if (errors.length) {
console.log(`doctor: ошибок ${errors.length}, предупреждений ${warns.length}`);
process.exit(1);
}
console.log(warns.length
? `doctor: ошибок нет, предупреждений ${warns.length}`
: 'doctor: панель соответствует стандарту');
+254
View File
@@ -0,0 +1,254 @@
#!/usr/bin/env node
// Заводит новую панель на этом ядре — копирует обе половины шаблона и
// подставляет то, что README раньше предписывал править руками: имена в
// package.json, брендинг и ключи localStorage. Забытые ключи — не мелочь: две
// панели на одном домене затирают друг другу токен и тему.
//
// npm run new-panel -- admin-hub # → ../admin-hub
// npm run new-panel -- admin-hub ~/work/hub # явный путь
//
// По умолчанию панель кладётся соседом ma7engine: именно там её найдёт перебор
// кандидатов в scripts/update.sh, и `npm run update` заработает без $MA7ENGINE.
//
// Не sh, в отличие от update.sh: здесь многострочная правка исходников, а не
// копирование каталогов, и sed по номерам строк ломается от любой правки
// шаблона. Node гарантированно есть — обе половины и так npm-проекты.
import fs from 'node:fs';
import { execFileSync } from 'node:child_process';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const HERE = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.dirname(HERE); // …/ma7engine
const die = (msg) => { console.error(`new-panel: ${msg}`); process.exit(1); };
// ─── аргументы ──────────────────────────────────────────────────────────────
const [slug, dirArg] = process.argv.slice(2);
if (!slug || slug === '--help' || slug === '-h') {
console.log('Использование: npm run new-panel -- <slug> [каталог]\n' +
' slug — имя панели строчными буквами через дефис, например admin-hub');
process.exit(slug ? 0 : 1);
}
if (!/^[a-z][a-z0-9-]*$/.test(slug)) {
die(`slug "${slug}" негоден — строчные латинские буквы, цифры и дефис, начинается с буквы`);
}
const target = path.resolve(dirArg ?? path.join(path.dirname(ROOT), slug));
if (fs.existsSync(target) && fs.readdirSync(target).length > 0) {
die(`каталог ${target} существует и не пуст`);
}
// admin-hub → admin_hub (ключи стораджа) и «Admin Hub» (брендинг)
const keyPrefix = slug.replace(/-/g, '_');
const display = slug.split('-').map(w => w[0].toUpperCase() + w.slice(1)).join(' ');
// ─── копирование половин ────────────────────────────────────────────────────
// Вендоренная копия ядра не копируется: она приедет своим `npm run update`.
// Так новая панель гарантированно получает актуальное ядро, а не слепок чужого.
const SKIP = new Set(['node_modules', 'dist', 'core', 'package-lock.json']);
function copyTree(from, to) {
fs.mkdirSync(to, { recursive: true });
for (const entry of fs.readdirSync(from, { withFileTypes: true })) {
if (SKIP.has(entry.name)) continue;
const src = path.join(from, entry.name);
const dst = path.join(to, entry.name);
if (entry.isDirectory()) copyTree(src, dst);
else fs.copyFileSync(src, dst);
}
}
copyTree(path.join(ROOT, 'frontend'), path.join(target, 'frontend'));
copyTree(path.join(ROOT, 'backend'), path.join(target, 'backend'));
// ─── подстановки ────────────────────────────────────────────────────────────
// Каждая замена обязана сработать: молча пропущенная — это ровно тот случай,
// когда панель уезжает с чужим ключом стораджа и ловится потом руками.
function edit(relPath, pairs) {
const file = path.join(target, relPath);
let text = fs.readFileSync(file, 'utf8');
for (const [from, to] of pairs) {
if (!text.includes(from)) die(`в ${relPath} не найдено для замены:\n---\n${from}\n---`);
text = text.replace(from, to);
}
fs.writeFileSync(file, text);
}
edit('frontend/package.json', [
['"ma7engine-example-frontend"', `"${slug}-frontend"`],
// В панели ядро приезжает через git subtree, а не копированием: хуки
// predev/prebuild и сам update.sh здесь не нужны и вредны — update.sh
// сносит каталог целиком (rm -rf) и затёр бы подтянутое поддерево.
[` "predev": "npm run update",\n`, ''],
[` "update": "sh scripts/update.sh",\n`, ''],
[` "prebuild": "npm run update",\n`, ''],
]);
// Заголовок вкладки браузера — не то же, что branding.name в App.tsx: его видно
// до того, как React смонтируется, и в закладках.
edit('frontend/index.html', [['<title>Demo Panel</title>', `<title>${display}</title>`]]);
edit('backend/package.json', [['"ma7engine-example-backend"', `"${slug}-backend"`]]);
edit('frontend/src/App.tsx', [
[`// Шаблон фронта панели — с этого начинается новый. Копируется вместе с
// ../backend; ядро в свою панель приезжает через \`npm run update\`.
//
// Всё, что здесь есть: конфиг. Логин, тема, drawer/сайдбар, снэкбар, сессия,
// меню по правам и переключение вкладок — в <Panel />.
//
// Парная половина — backend/server.ts: вкладки ниже ходят в его
// маршруты /api/demo/*, так что две половины запускаются вместе и работают.`,
`// Фронт панели ${display}. Всё, что здесь есть: конфиг. Логин, тема,
// drawer/сайдбар, снэкбар, сессия, меню по правам и переключение вкладок —
// в <Panel /> из ядра.
//
// Ядро приезжает копией в src/core через \`npm run update\` (его зовут predev
// и prebuild). Парная половина — backend/server.ts.`],
[`branding: { name: 'Demo Panel', version: '0.1.0' },`,
`branding: { name: '${display}', version: '0.1.0' },`],
[`storageKeys: { token: 'demo_token', theme: 'demo_theme' },`,
`storageKeys: { token: '${keyPrefix}_token', theme: '${keyPrefix}_theme' },`],
[` // slots: { aboveTab: ctx => ctx.tab?.id === 'docs' ? null : <ServerBar /> },`,
` // slots: { aboveTab: ctx => ctx.tab?.id === 'kit' ? null : <ServerBar /> },`],
]);
// ─── бэкенд: шапка, лог и вырезание демо-маршрутов ──────────────────────────
const serverFile = path.join(target, 'backend/server.ts');
let server = fs.readFileSync(serverFile, 'utf8');
const headOld = `// Шаблон бэкенда панели — с этого начинается новый. Копируется вместе с
// ../frontend; ядро в свою панель приезжает через \`npm run update\`.
//
// Всё, что здесь есть: конфиг и свои маршруты. Логин, сессия, права,
// рейт-лимит, раздача SPA и порядок их монтирования — в createPanelServer.`;
const headNew = `// Бэкенд панели ${display}. Всё, что здесь есть: конфиг и свои маршруты.
// Логин, сессия, права, рейт-лимит, раздача SPA и порядок их монтирования —
// в createPanelServer. Ядро приезжает копией в ./core через \`npm run update\`,
// запущенный во фронте.`;
if (!server.includes(headOld)) die('backend/server.ts: не найдена шапка шаблона');
server = server.replace(headOld, headNew);
// mountRoutes с демо-маршрутами уезжает целиком: три роута /api/demo/* нужны
// только вкладкам hello и secrets, которых в новой панели не будет. Образец
// остаётся комментарием — вместе со строкой импорта, которая ему понадобится.
const startMark = '\n mountRoutes(app, { auth, forwardError }) {\n';
const endMark = '\n },\n});\n';
const start = server.indexOf(startMark);
const end = server.indexOf(endMark, start);
if (start === -1 || end === -1) die('backend/server.ts: не найден блок mountRoutes');
server = server.slice(0, start) + `
// Маршруты панели. Монтируются ядром после логина и до статики — порядок
// зафиксирован внутри createPanelServer и снаружи не нарушается.
//
// import type { Request, Response } from 'express';
//
// mountRoutes(app, { auth, forwardError }) {
// // Открыт любому залогиненному — у вкладки нет permission.
// app.get('/api/reports', auth.requireAuth, async (req: Request, res: Response) => {
// try { res.json(await db.reports()); }
// catch (e) { forwardError(res, e); }
// });
//
// // Закрыт правом. Гейт на фронте (permission во вкладке) — это
// // только UX; настоящая граница здесь.
// app.post('/api/reports', auth.requireAuth,
// auth.requirePermission('reports.manage'), handler);
// },
});
` + server.slice(end + endMark.length);
// Request/Response остались бы висеть неиспользованными — образец выше
// показывает, как вернуть импорт вместе с маршрутом.
const importLine = "import type { Request, Response } from 'express';\n";
if (server.includes(importLine)) server = server.replace(importLine, '');
if (!server.includes('demo-panel: listening on')) die('backend/server.ts: не найдена строка лога');
server = server.replace('demo-panel: listening on', `${slug}: listening on`);
fs.writeFileSync(serverFile, server);
// ─── демо-вкладки ───────────────────────────────────────────────────────────
// hello и secrets ходят в /api/demo/*, которых больше нет — уезжают. docs
// (справочник по ядру) и kit (живой каталог классов дизайн-системы)
// самодостаточны и остаются: новая панель запускается непустой.
for (const tab of ['hello', 'secrets']) {
fs.rmSync(path.join(target, 'frontend/src/tabs', tab), { recursive: true, force: true });
}
// ─── файлы уровня панели ────────────────────────────────────────────────────
// Копия ядра в панели — закоммиченный исходник, а не артефакт сборки: образ
// собирается из чистого checkout'а, где ma7engine рядом нет. Обновляется она
// осознанно, через `npm run update`, и приходит в ревью диффом; расхождение с
// ядром ловит `npm run doctor`.
fs.writeFileSync(path.join(target, '.gitignore'),
`node_modules/
# Сборка фронта — в образе он собирается заново.
frontend/dist
# frontend/src/core и backend/core здесь НЕ игнорируются намеренно: копия ядра
# едет в репозиторий панели, иначе образ не собрать без ma7engine рядом.
`);
// Dockerfile и .dockerignore едут из шаблона как есть: контекст сборки — каталог
// панели, поэтому ни одного пути в них править не нужно.
for (const f of ['Dockerfile', '.dockerignore']) {
fs.copyFileSync(path.join(ROOT, f), path.join(target, f));
}
// Доктор берётся из scripts/, а не тот, что мог приехать внутри frontend/:
// в шаблоне frontend/scripts/doctor.mjs — вендоренная копия, и она бывает
// старше исходника. Панель должна получить актуальные проверки.
fs.mkdirSync(path.join(target, 'frontend/scripts'), { recursive: true });
fs.copyFileSync(path.join(ROOT, 'scripts/doctor.mjs'),
path.join(target, 'frontend/scripts/doctor.mjs'));
fs.mkdirSync(path.join(target, 'scripts'), { recursive: true });
const dev = fs.readFileSync(path.join(ROOT, 'scripts/demo.sh'), 'utf8')
.replace('# Поднимает демо-панель целиком: бэкенд в фоне, Vite на переднем плане.',
`# Поднимает панель ${display} целиком: бэкенд в фоне, Vite на переднем плане.`)
.replace('# npm run demo (из корня ma7engine)\n', '')
.replace('# sh scripts/demo.sh (откуда угодно — пути считаются от самого скрипта)',
'# sh scripts/dev.sh (откуда угодно — пути считаются от самого скрипта)')
.replace('# Учётка по умолчанию — admin / demo. Переопределяется окружением:\n' +
'# ADMIN_USER=root ADMIN_PASS=secret npm run demo',
'# Учётка по умолчанию — admin / demo, только для дева. Переопределяется\n' +
'# окружением: ADMIN_USER=root ADMIN_PASS=secret sh scripts/dev.sh')
.replaceAll('demo: ', `${slug}: `);
fs.writeFileSync(path.join(target, 'scripts/dev.sh'), dev, { mode: 0o755 });
// ─── ядро ───────────────────────────────────────────────────────────────────
// Ядро сюда не копируется: его приносит `git subtree add` — командами ниже.
// Копия и поддерево не уживаются, поэтому update.sh в панель не едет вовсе.
fs.rmSync(path.join(target, 'frontend/scripts/update.sh'), { force: true });
// ─── что дальше ─────────────────────────────────────────────────────────────
// Панель — надстройка: она обычно лежит подкаталогом продуктового репозитория,
// и prefix для subtree считается от его корня. Угадать корень отсюда нельзя,
// поэтому в подсказке путь даётся относительно каталога панели.
const prefix = `${path.basename(target)}/`;
console.log(`
new-panel: панель ${display} создана в ${target}
Дальше:
cd ${target}/frontend && npm i
cd ${target}/backend && npm i
cd ${target} && sh scripts/dev.sh # обе половины, фронт на :5173
Пароль и секрет обязательны — без них ядро не даст процессу подняться:
ADMIN_PASS=... ADMIN_JWT_SECRET=...
Ядро приносит git subtree — из каталога панели в её репозитории:
git subtree add --prefix=${prefix}frontend/src/core ${ROOT} dist/browser --squash
git subtree add --prefix=${prefix}backend/core ${ROOT} dist/server --squash
Дальше обновляется тем же git subtree pull. Копировать ядро руками не нужно —
и не стоит: поддерево умеет merge и покажет конфликт, а копия молча затрёт.
Проверить, что панель собрана по стандарту:
cd ${target}/frontend && npm run doctor`);
+59
View File
@@ -0,0 +1,59 @@
#!/bin/sh
# Готовит ветки, из которых продукты тянут ядро через `git subtree pull`.
#
# sh scripts/release.sh (или npm run release)
#
# У ядра две половины и два адресата, а `git subtree` работает с одним prefix
# за раз — поэтому каждая половина выделяется в свою ветку с собственной
# историей:
#
# core/browser → ветка dist/browser → <панель>/frontend/src/core
# core/server → ветка dist/server → <панель>/backend/core
#
# `git subtree split` переписывает историю выбранного каталога так, будто он
# всегда был корнем репозитория. Ветки полностью производные: их не правят
# руками и не мержат обратно — правится core/, после чего скрипт гоняется снова.
#
# Почему subtree, а не копирование: копия затирает любую локальную правку и не
# оставляет истории, поэтому «ядро в панели разошлось» выясняется постфактум.
# subtree делает настоящий merge — расхождение видно конфликтом в момент
# обновления, а правку, сделанную в продукте, можно отдать обратно в ядро
# (`git subtree push --prefix=<путь> <ядро> <ветка>`).
set -eu
here=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
root=$(dirname "$here")
cd "$root"
if [ -n "$(git status --porcelain)" ]; then
echo "release: рабочее дерево грязное — закоммить или спрячь правки" >&2
git status --short >&2
exit 1
fi
# `git subtree split -b <ветка>` умеет только создать ветку и отказывается
# двигать существующую, если новый коммит ей не потомок, — а после любого
# reset/rebase в ядре это ровно так. Поэтому split считает коммит, а ветка
# переставляется принудительно: ветки производные, своей истории у них нет.
split() { # split <каталог> <ветка>
printf 'release: %s → %s\n' "$1" "$2"
rev=$(git subtree split --prefix="$1" -q)
git branch -f "$2" "$rev"
printf ' %s\n' "$(git log -1 --format='%h %s' "$2")"
}
split core/browser dist/browser
split core/server dist/server
cat <<TXT
Готово. В продукте, где панель лежит подкаталогом:
# первый раз
git subtree add --prefix=<панель>/frontend/src/core $root dist/browser --squash
git subtree add --prefix=<панель>/backend/core $root dist/server --squash
# дальше
git subtree pull --prefix=<панель>/frontend/src/core $root dist/browser --squash
git subtree pull --prefix=<панель>/backend/core $root dist/server --squash
TXT
+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
+16
View File
@@ -0,0 +1,16 @@
{
"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"]
},
"include": ["core/browser"]
}
+14
View File
@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2020",
"lib": ["ES2020"],
"module": "CommonJS",
"moduleResolution": "node",
"strict": true,
"skipLibCheck": true,
"noEmit": true,
"esModuleInterop": true,
"resolveJsonModule": true
},
"include": ["core/server"]
}