Compare commits
4
Commits
dist/browser
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bb56e9dc3b | ||
|
|
7977ea6738 | ||
|
|
0c7fee73a6 | ||
|
|
18bd05bcbb |
@@ -0,0 +1,13 @@
|
|||||||
|
# Что не должно попадать в контекст сборки.
|
||||||
|
node_modules
|
||||||
|
**/node_modules
|
||||||
|
.git
|
||||||
|
.DS_Store
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Локальная сборка фронта: в образе он собирается заново.
|
||||||
|
frontend/dist
|
||||||
|
|
||||||
|
# Вендоренная копия ядра здесь НЕ исключается и исключаться не должна: в образ
|
||||||
|
# она обязана приехать вместе с исходниками, иначе сборка встанет на импорте
|
||||||
|
# @core. Ровно эту ошибку проверяет `npm run doctor`.
|
||||||
+13
@@ -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
@@ -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"]
|
||||||
@@ -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` остались своими.
|
||||||
Generated
+1629
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}`));
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
// Мидлвары авторизации: «пускать ли вообще» и «пускать ли к этому разделу».
|
||||||
|
//
|
||||||
|
// Проверка прав на фронте (permission в metadata.json вкладки) — это только
|
||||||
|
// UX: не показывать то, что всё равно отклонит сервер. Настоящая граница здесь,
|
||||||
|
// и роут без requirePermission открыт всем залогиненным независимо от того, как
|
||||||
|
// размечена вкладка.
|
||||||
|
//
|
||||||
|
// Откуда берутся права — развилка без единственно верного ответа, поэтому она
|
||||||
|
// вынесена в конфиг (loadPermissions):
|
||||||
|
// из клеймов токена — дёшево, но снятое право действует к истечению TTL;
|
||||||
|
// из БД на запрос — дороже, зато отзыв мгновенный.
|
||||||
|
import type { NextFunction, Request, Response } from 'express';
|
||||||
|
import type { SessionClaims, SessionStore } from './session';
|
||||||
|
|
||||||
|
export const OWNER_PERMISSION = '*';
|
||||||
|
|
||||||
|
// Ключ на объекте запроса. Symbol, а не req.session, чтобы не столкнуться с
|
||||||
|
// express-session и другими мидлварами, занимающими это имя.
|
||||||
|
const SESSION = Symbol.for('ma7engine.session');
|
||||||
|
|
||||||
|
export type Middleware = (req: Request, res: Response, next: NextFunction) => void;
|
||||||
|
|
||||||
|
export interface AuthOptions {
|
||||||
|
sessions: SessionStore;
|
||||||
|
/**
|
||||||
|
* Права на конкретный запрос. По умолчанию — из клеймов токена.
|
||||||
|
* Верни свежие права из БД, если нужен мгновенный отзыв.
|
||||||
|
*/
|
||||||
|
loadPermissions?: (claims: SessionClaims, req: Request) => string[] | Promise<string[]>;
|
||||||
|
/** Тексты отказов — панели отдают ошибки как {message}. */
|
||||||
|
messages?: { unauthorized?: string; forbidden?: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Auth {
|
||||||
|
/** Пускает только с живым токеном; кладёт клеймы в запрос. */
|
||||||
|
requireAuth: Middleware;
|
||||||
|
/** Пускает только с указанным правом (или '*'). Ставится после requireAuth. */
|
||||||
|
requirePermission: (permission: string) => Middleware;
|
||||||
|
/** Клеймы текущего запроса. Осмысленны только после requireAuth. */
|
||||||
|
getSession: (req: Request) => SessionClaims;
|
||||||
|
/** Bearer-токен запроса — нужен, например, чтобы отозвать его на логауте. */
|
||||||
|
bearerToken: (req: Request) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function bearerToken(req: Request): string {
|
||||||
|
const header = req.headers.authorization ?? '';
|
||||||
|
return header.startsWith('Bearer ') ? header.slice(7) : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createAuth(options: AuthOptions): Auth {
|
||||||
|
const { sessions, loadPermissions } = options;
|
||||||
|
const unauthorized = options.messages?.unauthorized ?? 'Не авторизован';
|
||||||
|
const forbidden = options.messages?.forbidden ?? 'Недостаточно прав';
|
||||||
|
|
||||||
|
const getSession = (req: Request): SessionClaims =>
|
||||||
|
(req as Request & { [SESSION]?: SessionClaims })[SESSION] as SessionClaims;
|
||||||
|
|
||||||
|
const requireAuth: Middleware = (req, res, next) => {
|
||||||
|
const claims = sessions.verify(bearerToken(req));
|
||||||
|
if (!claims) {
|
||||||
|
res.status(401).json({ message: unauthorized });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
(req as Request & { [SESSION]?: SessionClaims })[SESSION] = claims;
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
|
||||||
|
const requirePermission = (permission: string): Middleware => (req, res, next) => {
|
||||||
|
const claims = getSession(req);
|
||||||
|
if (!claims) {
|
||||||
|
res.status(401).json({ message: unauthorized });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Promise.resolve(loadPermissions ? loadPermissions(claims, req) : claims.permissions)
|
||||||
|
.then((permissions) => {
|
||||||
|
if (permissions.includes(OWNER_PERMISSION) || permissions.includes(permission)) {
|
||||||
|
next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.status(403).json({ message: forbidden });
|
||||||
|
})
|
||||||
|
.catch(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
return { requireAuth, requirePermission, getSession, bearerToken };
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
// Проброс ошибок из вышестоящего API наружу.
|
||||||
|
//
|
||||||
|
// Панель, которая ходит за данными в основной API продукта, должна отдать
|
||||||
|
// клиенту его статус и текст, а не превратить всё в 500: «пользователь не
|
||||||
|
// найден» и «API лежит» — разные ситуации для оператора.
|
||||||
|
//
|
||||||
|
// Форма ошибки axios здесь проверяется по структуре, а не через
|
||||||
|
// axios.isAxiosError: ядру не нужна зависимость от axios ради одной проверки,
|
||||||
|
// а панель может ходить наверх и через fetch.
|
||||||
|
import type { Response } from 'express';
|
||||||
|
|
||||||
|
interface UpstreamError {
|
||||||
|
response?: {
|
||||||
|
status?: number;
|
||||||
|
data?: { message?: string; error?: string };
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ForwardErrorOptions {
|
||||||
|
/** Текст, когда вышестоящий API недоступен вовсе. */
|
||||||
|
unavailable?: string;
|
||||||
|
/** Текст, когда API ответил ошибкой без внятного сообщения. */
|
||||||
|
fallback?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function forwardError(res: Response, error: unknown, options: ForwardErrorOptions = {}) {
|
||||||
|
const unavailable = options.unavailable ?? 'Вышестоящий API недоступен';
|
||||||
|
const fallback = options.fallback ?? 'Ошибка API';
|
||||||
|
|
||||||
|
const response = (error as UpstreamError)?.response;
|
||||||
|
if (response && typeof response.status === 'number') {
|
||||||
|
const data = response.data;
|
||||||
|
res.status(response.status).json({ message: data?.message ?? data?.error ?? fallback });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.status(502).json({ message: unavailable });
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
// Серверная половина ядра панели: логин, сессия, права, рейт-лимит и раздача
|
||||||
|
// собранного фронта. Всё, что во всех панелях было одинаковым, — здесь;
|
||||||
|
// продуктовые маршруты остаются в самой панели.
|
||||||
|
//
|
||||||
|
// Авторизация есть всегда и приезжает готовой: задай
|
||||||
|
// auth: { user, password | passwordHash, secret } — и панель сама выпускает и
|
||||||
|
// проверяет токен, монтирует /login, /logout и /api/session. Своей авторизации
|
||||||
|
// панель не пишет.
|
||||||
|
//
|
||||||
|
// Панель, у которой собственных учёток нет вовсе (логин форвардится в основной
|
||||||
|
// продукт, где живут настоящие учётки), createPanelServer не использует —
|
||||||
|
// она собирает express сама из кусков ядра: createLoginRateLimit на маршрут
|
||||||
|
// логина, свои прокси-маршруты, serveSpa последним. См. раздел про
|
||||||
|
// панель-прокси в README.
|
||||||
|
//
|
||||||
|
// Порядок монтирования фиксирован и потому не может быть нарушен снаружи:
|
||||||
|
// json → логин/сессия → маршруты панели → статика с fallback. Fallback ловит
|
||||||
|
// любой GET, поэтому маршрут после него уже не сработал бы.
|
||||||
|
import express, { type Express, type Request, type Response } from 'express';
|
||||||
|
import { createLoginRateLimit, type LoginRateLimit, type RateLimitOptions } from './ratelimit';
|
||||||
|
import { createPasswordCheck, type PasswordOptions } from './password';
|
||||||
|
import { createSessionStore, type SessionClaims, type SessionOptions, type SessionStore } from './session';
|
||||||
|
import { createAuth, type Auth } from './auth';
|
||||||
|
import { forwardError } from './errors';
|
||||||
|
import { serveSpa } from './spa';
|
||||||
|
|
||||||
|
const LOGIN_PATH = '/login';
|
||||||
|
const LOGOUT_PATH = '/logout';
|
||||||
|
const SESSION_PATH = '/api/session';
|
||||||
|
|
||||||
|
export interface LocalAuthOptions extends PasswordOptions, Omit<SessionOptions, 'secret'> {
|
||||||
|
/** Логин единственной учётки панели. */
|
||||||
|
user: string;
|
||||||
|
/** Секрет подписи сессий — свой, отдельный от секретов основного продукта. */
|
||||||
|
secret: string;
|
||||||
|
/**
|
||||||
|
* Клеймы, которые получит вошедший. По умолчанию — владелец: permissions
|
||||||
|
* ['*'], то есть доступ ко всем вкладкам. Появится несколько учёток —
|
||||||
|
* здесь и появится разметка.
|
||||||
|
*/
|
||||||
|
claims?: Partial<Omit<SessionClaims, 'user'>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PanelServerOptions {
|
||||||
|
/** Учётка панели. Обязательна: панель без авторизации ядром не собирается. */
|
||||||
|
auth: LocalAuthOptions;
|
||||||
|
/** Каталог со сборкой фронта. Без него статика не монтируется. */
|
||||||
|
staticDir?: string;
|
||||||
|
rateLimit?: RateLimitOptions;
|
||||||
|
/** Маршруты самой панели. Монтируются после логина и до статики. */
|
||||||
|
mountRoutes?: (app: Express, ctx: PanelServerContext) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PanelServerContext {
|
||||||
|
app: Express;
|
||||||
|
rateLimit: LoginRateLimit;
|
||||||
|
sessions: SessionStore;
|
||||||
|
auth: Auth;
|
||||||
|
forwardError: typeof forwardError;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createPanelServer(options: PanelServerOptions): Express {
|
||||||
|
const app = express();
|
||||||
|
const cfg = options.auth;
|
||||||
|
|
||||||
|
app.use(express.json());
|
||||||
|
|
||||||
|
const rateLimit = createLoginRateLimit(options.rateLimit);
|
||||||
|
|
||||||
|
// Обе проверки бросают при негодной конфигурации — панель не поднимется
|
||||||
|
// с паролем по умолчанию или с известным секретом.
|
||||||
|
const password = createPasswordCheck(cfg);
|
||||||
|
const sessions = createSessionStore({
|
||||||
|
secret: cfg.secret,
|
||||||
|
ttl: cfg.ttl,
|
||||||
|
revocable: cfg.revocable,
|
||||||
|
revocationStore: cfg.revocationStore,
|
||||||
|
});
|
||||||
|
const auth = createAuth({ sessions });
|
||||||
|
|
||||||
|
app.post(LOGIN_PATH, rateLimit.middleware, (req: Request, res: Response) => {
|
||||||
|
const { user, pass } = (req.body ?? {}) as { user?: string; pass?: string };
|
||||||
|
// Пароль проверяется всегда, даже когда логин уже не совпал: иначе
|
||||||
|
// по времени ответа отличается «нет такого пользователя» от
|
||||||
|
// «пароль неверный».
|
||||||
|
const passOk = password.verify(pass ?? '');
|
||||||
|
const userOk = typeof user === 'string' && user === cfg.user;
|
||||||
|
if (!userOk || !passOk) {
|
||||||
|
res.status(401).json({ message: 'Неверный логин или пароль' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
rateLimit.clear(req);
|
||||||
|
res.json({
|
||||||
|
token: sessions.issue({
|
||||||
|
user: cfg.user,
|
||||||
|
userId: cfg.claims?.userId ?? '1',
|
||||||
|
role: cfg.claims?.role ?? 'admin',
|
||||||
|
permissions: cfg.claims?.permissions ?? ['*'],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post(LOGOUT_PATH, auth.requireAuth, (req: Request, res: Response) => {
|
||||||
|
sessions.revoke(auth.bearerToken(req));
|
||||||
|
res.status(204).end();
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get(SESSION_PATH, auth.requireAuth, (req: Request, res: Response) => {
|
||||||
|
res.json(auth.getSession(req));
|
||||||
|
});
|
||||||
|
|
||||||
|
options.mountRoutes?.(app, { app, rateLimit, sessions, auth, forwardError });
|
||||||
|
|
||||||
|
if (options.staticDir) serveSpa(app, options.staticDir);
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { createLoginRateLimit, clientIp } from './ratelimit';
|
||||||
|
export { createPasswordCheck, hashPassword, timingSafeEquals } from './password';
|
||||||
|
export { createSessionStore } from './session';
|
||||||
|
export { createAuth, bearerToken, OWNER_PERMISSION } from './auth';
|
||||||
|
export { forwardError } from './errors';
|
||||||
|
export { serveSpa } from './spa';
|
||||||
|
export type { RateLimitOptions, LoginRateLimit } from './ratelimit';
|
||||||
|
export type { PasswordOptions, PasswordCheck } from './password';
|
||||||
|
export type { SessionClaims, SessionOptions, SessionStore, RevocationStore } from './session';
|
||||||
|
export type { Auth, AuthOptions, Middleware } from './auth';
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
// Проверка пароля единственной учётки панели.
|
||||||
|
//
|
||||||
|
// Две вещи, которые в панелях были сделаны по-разному, здесь сведены к одному
|
||||||
|
// поведению:
|
||||||
|
//
|
||||||
|
// 1. Сравнение. Везде было плоское `pass !== ADMIN_PASS` — время сравнения
|
||||||
|
// строк зависит от длины общего префикса. Здесь сравниваются SHA-256
|
||||||
|
// дайджесты через timingSafeEqual: одинаковая длина (иначе timingSafeEqual
|
||||||
|
// бросает) и постоянное время.
|
||||||
|
//
|
||||||
|
// 2. Ненастроенный пароль. Одна панель падала в открытую (дефолт "admin" и
|
||||||
|
// предупреждение в консоль), другая — в закрытую (вход запрещён). Ядро
|
||||||
|
// падает в закрытую и максимально рано: не на первом логине, а при старте
|
||||||
|
// процесса. Тихо работающая панель с паролем по умолчанию — худший из
|
||||||
|
// возможных исходов.
|
||||||
|
import crypto from 'node:crypto';
|
||||||
|
|
||||||
|
/** Сравнение за постоянное время. Строки произвольной длины. */
|
||||||
|
export function timingSafeEquals(a: string, b: string): boolean {
|
||||||
|
const ha = crypto.createHash('sha256').update(a).digest();
|
||||||
|
const hb = crypto.createHash('sha256').update(b).digest();
|
||||||
|
return crypto.timingSafeEqual(ha, hb);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PasswordOptions {
|
||||||
|
/** Пароль открытым текстом (обычно из ADMIN_PASS). */
|
||||||
|
password?: string;
|
||||||
|
/**
|
||||||
|
* Либо хэш вида `scrypt:<salt-hex>:<key-hex>` (обычно из ADMIN_PASS_HASH) —
|
||||||
|
* тогда открытый пароль не хранится в окружении вовсе.
|
||||||
|
* Сгенерировать: `node -e "…"` — см. hashPassword ниже.
|
||||||
|
*/
|
||||||
|
passwordHash?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PasswordCheck {
|
||||||
|
verify: (input: string) => boolean;
|
||||||
|
/** Как настроен пароль — для строки в логе при старте. */
|
||||||
|
kind: 'plain' | 'scrypt';
|
||||||
|
}
|
||||||
|
|
||||||
|
const SCRYPT_PREFIX = 'scrypt:';
|
||||||
|
|
||||||
|
/** Готовый ADMIN_PASS_HASH для указанного пароля. */
|
||||||
|
export function hashPassword(password: string): string {
|
||||||
|
const salt = crypto.randomBytes(16);
|
||||||
|
const key = crypto.scryptSync(password, salt, 32);
|
||||||
|
return `${SCRYPT_PREFIX}${salt.toString('hex')}:${key.toString('hex')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createPasswordCheck(options: PasswordOptions): PasswordCheck {
|
||||||
|
const { password, passwordHash } = options;
|
||||||
|
|
||||||
|
if (passwordHash) {
|
||||||
|
if (!passwordHash.startsWith(SCRYPT_PREFIX)) {
|
||||||
|
throw new Error('panel-server: passwordHash должен начинаться с "scrypt:" — см. hashPassword()');
|
||||||
|
}
|
||||||
|
const [, saltHex, keyHex] = passwordHash.split(':');
|
||||||
|
if (!saltHex || !keyHex) {
|
||||||
|
throw new Error('panel-server: passwordHash повреждён, ожидается scrypt:<salt-hex>:<key-hex>');
|
||||||
|
}
|
||||||
|
const salt = Buffer.from(saltHex, 'hex');
|
||||||
|
const expected = Buffer.from(keyHex, 'hex');
|
||||||
|
return {
|
||||||
|
kind: 'scrypt',
|
||||||
|
verify(input) {
|
||||||
|
const actual = crypto.scryptSync(input, salt, expected.length);
|
||||||
|
return crypto.timingSafeEqual(actual, expected);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!password) {
|
||||||
|
throw new Error(
|
||||||
|
'panel-server: пароль панели не задан.\n' +
|
||||||
|
' Укажи ADMIN_PASS (или ADMIN_PASS_HASH — тогда открытого пароля в окружении не будет).\n' +
|
||||||
|
' Значения по умолчанию здесь намеренно нет: панель с паролем "admin" опаснее, чем не поднявшаяся панель.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { kind: 'plain', verify: (input) => timingSafeEquals(input, password) };
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
// Ограничение перебора пароля на /login. Во всех трёх панелях этот код был
|
||||||
|
// скопирован дословно (те же 5 попыток за 15 минут), но в одной из них —
|
||||||
|
// с багом: IP брался как req.socket.remoteAddress без разбора X-Forwarded-For.
|
||||||
|
// За обратным прокси это адрес самого прокси, все клиенты схлопываются в один
|
||||||
|
// ключ, и пять неудачных попыток кем угодно блокируют вход всем.
|
||||||
|
//
|
||||||
|
// Счётчик — Map в памяти: он не переживает рестарт и не общий для нескольких
|
||||||
|
// инстансов за балансировщиком. Для защиты от перебора этого достаточно
|
||||||
|
// (атакующему всё равно приходится укладываться в лимит на каждом инстансе),
|
||||||
|
// но знать об этом надо — распределённый лимит требует общего хранилища.
|
||||||
|
import type { NextFunction, Request, Response } from 'express';
|
||||||
|
|
||||||
|
export interface RateLimitOptions {
|
||||||
|
/** Сколько неудачных попыток разрешено в окне. По умолчанию 5. */
|
||||||
|
limit?: number;
|
||||||
|
/** Длина окна в миллисекундах. По умолчанию 15 минут. */
|
||||||
|
windowMs?: number;
|
||||||
|
/**
|
||||||
|
* Доверять ли заголовку X-Forwarded-For. По умолчанию — только в проде:
|
||||||
|
* в деве перед сервером прокси нет, и подставить чужой IP заголовком мог
|
||||||
|
* бы кто угодно, кто достучался напрямую.
|
||||||
|
*/
|
||||||
|
trustProxy?: boolean;
|
||||||
|
/** Текст ответа. Получает количество секунд до сброса. */
|
||||||
|
message?: (retryAfter: number) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RateEntry { count: number; resetAt: number }
|
||||||
|
|
||||||
|
type IpSource = Pick<Request, 'headers'> & { socket: { remoteAddress?: string } };
|
||||||
|
|
||||||
|
export function clientIp(req: IpSource, trustProxy: boolean): string {
|
||||||
|
if (trustProxy) {
|
||||||
|
const forwarded = req.headers['x-forwarded-for'];
|
||||||
|
const first = Array.isArray(forwarded) ? forwarded[0] : forwarded;
|
||||||
|
if (first) return first.split(',')[0].trim();
|
||||||
|
}
|
||||||
|
return req.socket.remoteAddress ?? 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoginRateLimit {
|
||||||
|
/** Мидлвар на маршрут логина. */
|
||||||
|
middleware: (req: Request, res: Response, next: NextFunction) => void;
|
||||||
|
/**
|
||||||
|
* Снять счётчик после успешного входа — иначе три опечатки подряд перед
|
||||||
|
* удачным логином всё ещё считались бы попытками перебора.
|
||||||
|
*/
|
||||||
|
clear: (req: IpSource) => void;
|
||||||
|
/** IP запроса по тем же правилам, что использует лимитер. */
|
||||||
|
ipOf: (req: IpSource) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createLoginRateLimit(options: RateLimitOptions = {}): LoginRateLimit {
|
||||||
|
const limit = options.limit ?? 5;
|
||||||
|
const windowMs = options.windowMs ?? 15 * 60 * 1000;
|
||||||
|
const trustProxy = options.trustProxy ?? process.env.NODE_ENV === 'production';
|
||||||
|
const message = options.message
|
||||||
|
?? ((retryAfter: number) => `Слишком много попыток. Повтори через ${retryAfter} сек.`);
|
||||||
|
|
||||||
|
const attempts = new Map<string, RateEntry>();
|
||||||
|
const ipOf = (req: IpSource) => clientIp(req, trustProxy);
|
||||||
|
|
||||||
|
return {
|
||||||
|
ipOf,
|
||||||
|
clear: (req) => { attempts.delete(ipOf(req)); },
|
||||||
|
middleware(req, res, next) {
|
||||||
|
const ip = ipOf(req);
|
||||||
|
const now = Date.now();
|
||||||
|
const entry = attempts.get(ip);
|
||||||
|
if (!entry || entry.resetAt < now) {
|
||||||
|
attempts.set(ip, { count: 1, resetAt: now + windowMs });
|
||||||
|
} else if (entry.count >= limit) {
|
||||||
|
const retryAfter = Math.ceil((entry.resetAt - now) / 1000);
|
||||||
|
res.setHeader('Retry-After', String(retryAfter));
|
||||||
|
res.status(429).json({ error: message(retryAfter) });
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
entry.count++;
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
// Сессия оператора панели: выпуск и проверка токена.
|
||||||
|
//
|
||||||
|
// Реализаций в панелях было две — подписанный JWT через jsonwebtoken и
|
||||||
|
// самописный HMAC на node:crypto. Ядро оставляет первую: алгоритм тот же, но
|
||||||
|
// краевые случаи (разбор exp/nbf, подмена alg) уже разобраны в библиотеке.
|
||||||
|
// jsonwebtoken объявлен peer-зависимостью — его надо поставить в бэкенд панели.
|
||||||
|
//
|
||||||
|
// Отзыв токена. JWT самодостаточен: сервер не хранит выданные токены и потому
|
||||||
|
// не может их отменить — логаут по умолчанию чисто клиентский. Опция
|
||||||
|
// `revocable` включает список отозванных подписей в памяти, и тогда логаут
|
||||||
|
// действительно убивает токен. Цена: список не переживает рестарт и не общий
|
||||||
|
// для нескольких инстансов за балансировщиком — после рестарта отозванный
|
||||||
|
// токен снова станет валидным до истечения TTL. Нужен настоящий отзыв на
|
||||||
|
// нескольких инстансах — нужно общее хранилище (Redis и т.п.); тогда передай
|
||||||
|
// свой store вместо встроенного.
|
||||||
|
import jwt from 'jsonwebtoken';
|
||||||
|
|
||||||
|
export interface SessionClaims {
|
||||||
|
user: string;
|
||||||
|
userId: string;
|
||||||
|
role: string;
|
||||||
|
permissions: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Хранилище отозванных токенов. По умолчанию — Set в памяти. */
|
||||||
|
export interface RevocationStore {
|
||||||
|
revoke: (signature: string) => void;
|
||||||
|
isRevoked: (signature: string) => boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionOptions {
|
||||||
|
/** Секрет подписи. Свой, отдельный от секретов основного продукта. */
|
||||||
|
secret: string;
|
||||||
|
/** Время жизни токена. По умолчанию 12 часов. */
|
||||||
|
ttl?: string | number;
|
||||||
|
/** Включить отзыв токенов на логауте. По умолчанию выключено. */
|
||||||
|
revocable?: boolean;
|
||||||
|
/** Своё хранилище отозванных — например, общее на несколько инстансов. */
|
||||||
|
revocationStore?: RevocationStore;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionStore {
|
||||||
|
issue: (claims: SessionClaims) => string;
|
||||||
|
/** Клеймы живого токена, либо null — протух, подделан или отозван. */
|
||||||
|
verify: (token: string) => SessionClaims | null;
|
||||||
|
/** Отозвать токен (если отзыв включён). */
|
||||||
|
revoke: (token: string) => void;
|
||||||
|
revocable: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function memoryRevocationStore(): RevocationStore {
|
||||||
|
const revoked = new Set<string>();
|
||||||
|
return {
|
||||||
|
revoke: (sig) => { revoked.add(sig); },
|
||||||
|
isRevoked: (sig) => revoked.has(sig),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Подпись однозначно идентифицирует токен и короче его самого.
|
||||||
|
const signatureOf = (token: string) => token.split('.')[2] ?? token;
|
||||||
|
|
||||||
|
export function createSessionStore(options: SessionOptions): SessionStore {
|
||||||
|
const { secret } = options;
|
||||||
|
if (!secret) {
|
||||||
|
throw new Error(
|
||||||
|
'panel-server: не задан секрет подписи сессий (ADMIN_JWT_SECRET).\n' +
|
||||||
|
' Значения по умолчанию нет намеренно: с известным секретом токен подделывается кем угодно.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const ttl = options.ttl ?? '12h';
|
||||||
|
const revocable = options.revocable ?? false;
|
||||||
|
const store = options.revocationStore ?? memoryRevocationStore();
|
||||||
|
|
||||||
|
return {
|
||||||
|
revocable,
|
||||||
|
issue: (claims) => jwt.sign(claims, secret, { expiresIn: ttl } as jwt.SignOptions),
|
||||||
|
verify(token) {
|
||||||
|
if (!token) return null;
|
||||||
|
if (revocable && store.isRevoked(signatureOf(token))) return null;
|
||||||
|
try {
|
||||||
|
// algorithms задан явно: без него подошёл бы токен, подписанный
|
||||||
|
// другим алгоритмом из заголовка самого токена.
|
||||||
|
const claims = jwt.verify(token, secret, { algorithms: ['HS256'] });
|
||||||
|
return claims as SessionClaims & jwt.JwtPayload;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
revoke(token) {
|
||||||
|
if (revocable && token) store.revoke(signatureOf(token));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
// Раздача собранного фронта. Панель — один процесс и один образ: сервер сам
|
||||||
|
// отдаёт frontend/dist, отдельный nginx ей не нужен.
|
||||||
|
//
|
||||||
|
// Монтируется последним, после всех API-маршрутов: fallback отвечает на любой
|
||||||
|
// GET, поэтому маршрут, зарегистрированный после него, уже не сработает.
|
||||||
|
import express, { type Express, type Request, type Response } from 'express';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
// Пути под этими префиксами получают 404, а не index.html: иначе опечатка в
|
||||||
|
// адресе API возвращает HTML со статусом 200, и клиент падает на разборе JSON
|
||||||
|
// вместо внятной ошибки.
|
||||||
|
const API_PREFIXES = ['/api'];
|
||||||
|
|
||||||
|
export function serveSpa(app: Express, dir: string) {
|
||||||
|
const root = path.resolve(dir);
|
||||||
|
|
||||||
|
if (!fs.existsSync(path.join(root, 'index.html'))) {
|
||||||
|
console.warn(`panel-server: в ${root} нет index.html — фронт не собран (npm run build) ` +
|
||||||
|
'или STATIC_DIR указывает не туда');
|
||||||
|
}
|
||||||
|
|
||||||
|
app.use(express.static(root));
|
||||||
|
|
||||||
|
app.get('*', (req: Request, res: Response) => {
|
||||||
|
if (API_PREFIXES.some(p => req.path.startsWith(p))) {
|
||||||
|
res.status(404).json({ message: 'Не найдено' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.sendFile(path.join(root, 'index.html'));
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
Generated
+2117
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+116
@@ -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
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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} />;
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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>,
|
||||||
|
);
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"id": "docs",
|
||||||
|
"label": "Инструкция",
|
||||||
|
"icon": "book",
|
||||||
|
"order": 5
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"id": "hello",
|
||||||
|
"label": "Кто я",
|
||||||
|
"icon": "star",
|
||||||
|
"order": 10
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
);
|
||||||
@@ -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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"id": "kit",
|
||||||
|
"label": "Витрина",
|
||||||
|
"icon": "grid",
|
||||||
|
"order": 30
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"id": "secrets",
|
||||||
|
"label": "Секреты",
|
||||||
|
"icon": "key",
|
||||||
|
"permission": "secrets.view",
|
||||||
|
"order": 20
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+44
@@ -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
|
||||||
Executable
+379
@@ -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: панель соответствует стандарту');
|
||||||
Executable
+254
@@ -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`);
|
||||||
Executable
+59
@@ -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
|
||||||
Executable
+116
@@ -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
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user