From 6eefb70ce757ef4f31c810f5729359abb649b528 Mon Sep 17 00:00:00 2001 From: Maxim Dolgolyov Date: Fri, 19 Jun 2026 22:40:45 +0300 Subject: [PATCH] =?UTF-8?q?feat(panel):=20=D0=B1=D1=8D=D0=BA=D0=B0=D0=BF/?= =?UTF-8?q?=D0=B2=D0=BE=D1=81=D1=81=D1=82=D0=B0=D0=BD=D0=BE=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=91=D0=94,=20=D1=83=D0=BC=D0=BD?= =?UTF-8?q?=D1=8B=D0=B9=20=D1=81=D1=82=D0=B0=D1=82=D1=83=D1=81,=20=D1=81?= =?UTF-8?q?=D0=BE=D0=B7=D0=B4=D0=B0=D1=82=D1=8C=20=D0=B0=D0=B4=D0=BC=D0=B8?= =?UTF-8?q?=D0=BD=D0=B0,=20watchdog?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit control-panel.ps1 расширена: - Бэкап БД [B] (копия learnspace.db+wal/shm с датой в data/backups) и восстановление [R] (выбор из списка, страховочная копия .pre-restore, авто-стоп/старт сервера). - Умный статус: health-пинг /api/health (+ms), размер БД, кол-во пользователей, последняя миграция (db-status.js), версия Node, сводка .env (CLIENT_ORIGIN/JWT/LLM). Кэш в . - Создать админа [A] → scripts/create-admin.js (bcrypt, upsert role=admin, busy_timeout). - Сторож [W]: авто-перезапуск при падении (выход по клавише). Логи в backend/logs (не %TEMP%), [E] ошибки из логов. Фиксы PS 5.1: порт/путь БД читаются из .env (inline node -e с кавычками 5.1 ломает); db-status вынесен в файл-скрипт; миграция по filename DESC; UTF-8 BOM, парсинг OK. Меню — лат.+рус. клавиши. Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/scripts/create-admin.js | 42 +++++++ backend/scripts/db-status.js | 15 +++ tools/control-panel.ps1 | 211 ++++++++++++++++++++++++++------ 3 files changed, 230 insertions(+), 38 deletions(-) create mode 100644 backend/scripts/create-admin.js create mode 100644 backend/scripts/db-status.js diff --git a/backend/scripts/create-admin.js b/backend/scripts/create-admin.js new file mode 100644 index 0000000..949fa4e --- /dev/null +++ b/backend/scripts/create-admin.js @@ -0,0 +1,42 @@ +'use strict'; +/* create-admin.js — создать или повысить пользователя до администратора. + * + * Запуск (значения через переменные окружения, чтобы пароль не светился в argv): + * ADMIN_EMAIL=a@b.c ADMIN_PASSWORD=secret12 ADMIN_NAME="Имя" node scripts/create-admin.js + * Используется панелью управления (control-panel.ps1, пункт «Создать админа»). + * + * Если пользователь с таким email уже есть — обновляет пароль/имя, ставит role='admin' + * и инкрементит token_version (старые токены становятся недействительны). Иначе создаёт. + */ +const path = require('path'); +const bcrypt = require('bcryptjs'); +const { DatabaseSync } = require('node:sqlite'); + +const email = String(process.env.ADMIN_EMAIL || '').trim().toLowerCase(); +const password = String(process.env.ADMIN_PASSWORD || ''); +const name = String(process.env.ADMIN_NAME || '').trim() || 'Администратор'; + +function fail(msg) { console.error('✗ ' + msg); process.exit(1); } + +if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) fail('Некорректный email (ADMIN_EMAIL).'); +if (password.length < 8) fail('Пароль минимум 8 символов (ADMIN_PASSWORD).'); + +const DB = process.env.DB_PATH || path.join(__dirname, '..', 'data', 'learnspace.db'); +const db = new DatabaseSync(DB); +try { db.exec('PRAGMA busy_timeout=4000'); } catch (_) {} // подождать, если сервер пишет + +(async () => { + const hash = await bcrypt.hash(password, 12); + const existing = db.prepare('SELECT id, role FROM users WHERE email = ?').get(email); + if (existing) { + db.prepare(`UPDATE users SET password_hash = ?, name = ?, role = 'admin', + token_version = COALESCE(token_version,0) + 1 WHERE id = ?`) + .run(hash, name, existing.id); + console.log(`✓ Пользователь ${email} обновлён: роль admin, новый пароль (id=${existing.id}).`); + } else { + const r = db.prepare(`INSERT INTO users (email, password_hash, name, role) VALUES (?,?,?, 'admin')`) + .run(email, hash, name); + console.log(`✓ Создан администратор ${email} (id=${r.lastInsertRowid}).`); + } + db.close(); +})().catch(e => fail(e.message)); diff --git a/backend/scripts/db-status.js b/backend/scripts/db-status.js new file mode 100644 index 0000000..a2d7026 --- /dev/null +++ b/backend/scripts/db-status.js @@ -0,0 +1,15 @@ +'use strict'; +/* db-status.js — краткая сводка БД для панели управления: "|". + Путь к БД: env DB_PATH, либо argv[2], либо стандартный. Никогда не бросает. */ +const path = require('path'); +const { DatabaseSync } = require('node:sqlite'); + +const DB = process.env.DB_PATH || process.argv[2] || path.join(__dirname, '..', 'data', 'learnspace.db'); +let users = '?', migr = '-'; +try { + const db = new DatabaseSync(DB); + try { users = db.prepare('SELECT COUNT(*) AS n FROM users').get().n; } catch (_) {} + try { const r = db.prepare('SELECT filename FROM _migrations ORDER BY filename DESC LIMIT 1').get(); if (r) migr = r.filename; } catch (_) {} + db.close(); +} catch (_) {} +process.stdout.write(String(users) + '|' + String(migr)); diff --git a/tools/control-panel.ps1 b/tools/control-panel.ps1 index d8302a3..1a42d78 100644 --- a/tools/control-panel.ps1 +++ b/tools/control-panel.ps1 @@ -12,19 +12,49 @@ try { [Console]::OutputEncoding = [System.Text.UTF8Encoding]::new() } catch {} $root = Split-Path -Parent $PSScriptRoot $backend = Join-Path $root 'backend' -$log = Join-Path $env:TEMP 'learnspace-server.log' -$errlog = Join-Path $env:TEMP 'learnspace-server.err.log' +$logsDir = Join-Path $backend 'logs' +$log = Join-Path $logsDir 'server.log' +$errlog = Join-Path $logsDir 'server.err.log' Set-Location $backend +# Порт читаем из .env (PORT=...), иначе 3000. (node -e с кавычками PS 5.1 ломает — не используем.) function Get-Port { - try { - $p = & node -e 'try{process.stdout.write(String(require("./src/config").PORT||3000))}catch(e){process.stdout.write("3000")}' 2>$null - if ("$p" -match '^\d+$') { return [int]"$p" } - } catch {} + $envf = Join-Path $backend '.env' + if (Test-Path $envf) { + $m = Select-String -Path $envf -Pattern '^\s*PORT\s*=\s*(\d+)' | Select-Object -First 1 + if ($m) { return [int]$m.Matches[0].Groups[1].Value } + } return 3000 } $script:Port = Get-Port +# Путь к БД (из .env DB_PATH, иначе стандартный) и папка бэкапов. +function Get-DbPath { + $envf = Join-Path $backend '.env' + if (Test-Path $envf) { + $m = Select-String -Path $envf -Pattern '^\s*DB_PATH\s*=\s*(.+)$' | Select-Object -First 1 + if ($m) { $v = $m.Matches[0].Groups[1].Value.Trim(); if ($v) { return $v } } + } + return (Join-Path $backend 'data\learnspace.db') +} +$script:DbPath = Get-DbPath +$script:BackupDir = Join-Path $backend 'data\backups' +$script:NodeVer = '?' +try { $script:NodeVer = (& node -v) } catch {} + +# Сводка .env (кэш). +$script:EnvSum = @{ origin = '—'; jwt = 'нет'; llm = 'нет' } +function Load-EnvSummary { + $f = Join-Path $backend '.env' + if (-not (Test-Path $f)) { return } + foreach ($line in (Get-Content $f)) { + if ($line -match '^\s*CLIENT_ORIGIN\s*=\s*(.+)$') { $script:EnvSum.origin = $matches[1].Trim() } + if ($line -match '^\s*JWT_SECRET\s*=\s*(\S.*)$') { $script:EnvSum.jwt = 'задан' } + if ($line -match '^\s*ASSISTANT_LLM_KEY\s*=\s*(\S.*)$') { $script:EnvSum.llm = 'задан' } + } +} +Load-EnvSummary + function Server-Proc { try { $c = Get-NetTCPConnection -LocalPort $script:Port -State Listen -ErrorAction Stop | Select-Object -First 1 @@ -33,18 +63,34 @@ function Server-Proc { return $null } -function Status-Line { - $p = Server-Proc - if ($p) { - $ups = '?' - try { $u = (Get-Date) - $p.StartTime; $ups = ('{0}ч {1}м' -f [int]$u.TotalHours, $u.Minutes) } catch {} - return ('РАБОТАЕТ — PID ' + $p.Id + ', порт ' + $script:Port + ', аптайм ' + $ups) +# Полный статус (процесс + health + БД), кэшируется в $script:Stat. +$script:Stat = $null +function Refresh-Status { + $proc = Server-Proc + $s = @{ running = ($null -ne $proc); procId = '-'; uptime = '-'; health = '—'; healthMs = $null; + dbSize = '—'; users = '?'; migr = '-' } + if ($proc) { + $s.procId = $proc.Id + try { $u = (Get-Date) - $proc.StartTime; $s.uptime = ('{0}ч {1}м' -f [int]$u.TotalHours, $u.Minutes) } catch {} + $sw = [System.Diagnostics.Stopwatch]::StartNew() + try { + Invoke-WebRequest ("http://localhost:{0}/api/health" -f $script:Port) -UseBasicParsing -TimeoutSec 2 | Out-Null + $sw.Stop(); $s.health = 'healthy'; $s.healthMs = [int]$sw.ElapsedMilliseconds + } catch { $sw.Stop(); $s.health = 'не отвечает' } } - return 'остановлен' + if (Test-Path $script:DbPath) { + $s.dbSize = ('{0:N1} МБ' -f ((Get-Item $script:DbPath).Length / 1MB)) + try { + $out = & node scripts/db-status.js "$($script:DbPath)" 2>$null + $parts = "$out".Split('|'); if ($parts.Count -ge 2) { $s.users = $parts[0]; $s.migr = $parts[1] } + } catch {} + } + $script:Stat = $s } function Start-Server { if (Server-Proc) { Write-Host ' Сервер уже работает.' -ForegroundColor Yellow; return } + New-Item -ItemType Directory -Force -Path $logsDir | Out-Null Write-Host ' Применяю миграции (идемпотентно)...' -ForegroundColor DarkGray try { & node src/db/migrations-runner.js | Out-Host } catch { Write-Host (' Миграции: ' + $_.Exception.Message) -ForegroundColor Red } Remove-Item $log, $errlog -ErrorAction SilentlyContinue @@ -62,7 +108,7 @@ function Stop-Server { $p = Server-Proc if (-not $p) { Write-Host ' Сервер не запущен.' -ForegroundColor Yellow; return } Stop-Process -Id $p.Id -Force -ErrorAction SilentlyContinue - Start-Sleep -Milliseconds 500 + Start-Sleep -Milliseconds 600 Write-Host (' Сервер остановлен (PID ' + $p.Id + ').') -ForegroundColor Green } @@ -71,11 +117,85 @@ function Open-Logs { Write-Host ' Логов нет: сервер не запускался через панель. Запустите его пунктом [1] или [3].' -ForegroundColor Yellow return } - $cmd = "`$Host.UI.RawUI.WindowTitle='LearnSpace — живые логи'; Write-Host 'Живые логи сервера (закройте окно, чтобы прекратить просмотр):' -ForegroundColor Cyan; Get-Content -Path '$log' -Wait -Tail 50" + $cmd = "`$Host.UI.RawUI.WindowTitle='LearnSpace — живые логи'; Write-Host 'Живые логи (закройте окно, чтобы прекратить просмотр):' -ForegroundColor Cyan; Get-Content -Path '$log' -Wait -Tail 50" Start-Process powershell -ArgumentList '-NoProfile', '-NoExit', '-Command', $cmd Write-Host ' Логи открыты в отдельном окне.' -ForegroundColor Green } +function Show-Errors { + $lines = @() + if (Test-Path $errlog) { $lines += (Get-Content $errlog -Tail 20 | Where-Object { $_ -and ($_ -notmatch 'ExperimentalWarning|trace-warnings') }) } + if (Test-Path $log) { $lines += (Get-Content $log -Tail 300 | Select-String -Pattern 'ERROR|FATAL|✗|Unhandled|Error:' | Select-Object -Last 15 | ForEach-Object { $_.Line }) } + Write-Host '' + if (-not $lines -or $lines.Count -eq 0) { Write-Host ' Ошибок в логах не найдено.' -ForegroundColor Green; return } + $lines | ForEach-Object { Write-Host (' ' + $_) -ForegroundColor Red } +} + +function Backup-Db { + if (-not (Test-Path $script:DbPath)) { Write-Host ' БД не найдена.' -ForegroundColor Yellow; return } + New-Item -ItemType Directory -Force -Path $script:BackupDir | Out-Null + $ts = Get-Date -Format 'yyyyMMdd-HHmmss' + $dest = Join-Path $script:BackupDir ("learnspace-$ts.db") + Copy-Item $script:DbPath $dest -Force + foreach ($ext in '-wal', '-shm') { $sp = $script:DbPath + $ext; if (Test-Path $sp) { Copy-Item $sp ($dest + $ext) -Force } } + $sz = '{0:N1} МБ' -f ((Get-Item $dest).Length / 1MB) + Write-Host (' Бэкап создан: ' + (Split-Path $dest -Leaf) + " ($sz)") -ForegroundColor Green + Write-Host (' Папка: ' + $script:BackupDir) -ForegroundColor DarkGray + if (Server-Proc) { Write-Host ' (сервер работает — для 100% консистентности лучше делать на остановленном)' -ForegroundColor DarkGray } +} + +function Restore-Db { + if (-not (Test-Path $script:BackupDir)) { Write-Host ' Папки бэкапов нет — сначала сделайте бэкап ([B]).' -ForegroundColor Yellow; return } + $files = @(Get-ChildItem $script:BackupDir -Filter 'learnspace-*.db' -ErrorAction SilentlyContinue | Sort-Object LastWriteTime -Descending) + if ($files.Count -eq 0) { Write-Host ' Бэкапов нет.' -ForegroundColor Yellow; return } + Write-Host '' + for ($i = 0; $i -lt $files.Count; $i++) { + Write-Host (' [{0}] {1} ({2:N1} МБ, {3:yyyy-MM-dd HH:mm})' -f ($i + 1), $files[$i].Name, ($files[$i].Length / 1MB), $files[$i].LastWriteTime) + } + $sel = Read-Host ' Номер бэкапа (Enter = отмена)' + if (-not ($sel -match '^\d+$')) { Write-Host ' Отменено.'; return } + $idx = [int]$sel - 1 + if ($idx -lt 0 -or $idx -ge $files.Count) { Write-Host ' Неверный номер.' -ForegroundColor Yellow; return } + if ((Read-Host (' Перезаписать ТЕКУЩУЮ БД из ' + $files[$idx].Name + '? Текущая будет утеряна [y/N]')) -notmatch '^[YyНн]') { Write-Host ' Отменено.'; return } + $wasRunning = [bool](Server-Proc) + if ($wasRunning) { Stop-Server } + if (Test-Path $script:DbPath) { Copy-Item $script:DbPath ($script:DbPath + '.pre-restore') -Force } # страховка + Copy-Item $files[$idx].FullName $script:DbPath -Force + foreach ($ext in '-wal', '-shm') { + $sp = $script:DbPath + $ext; if (Test-Path $sp) { Remove-Item $sp -Force } + $bsrc = $files[$idx].FullName + $ext; if (Test-Path $bsrc) { Copy-Item $bsrc $sp -Force } + } + Write-Host ' БД восстановлена (прежняя сохранена рядом как *.pre-restore).' -ForegroundColor Green + if ($wasRunning) { Start-Server } +} + +function Create-Admin { + $em = Read-Host ' Email админа' + if (-not $em.Trim()) { Write-Host ' Отменено.'; return } + $nm = Read-Host ' Имя (Enter = «Администратор»)' + $sec = Read-Host ' Пароль (мин. 8 символов)' -AsSecureString + $pw = [Runtime.InteropServices.Marshal]::PtrToStringAuto([Runtime.InteropServices.Marshal]::SecureStringToBSTR($sec)) + if ($pw.Length -lt 8) { Write-Host ' Пароль слишком короткий (мин. 8).' -ForegroundColor Yellow; return } + $env:ADMIN_EMAIL = $em.Trim(); $env:ADMIN_PASSWORD = $pw; $env:ADMIN_NAME = $nm.Trim(); $env:DB_PATH = $script:DbPath + try { & node scripts/create-admin.js } catch { Write-Host (' Ошибка: ' + $_.Exception.Message) -ForegroundColor Red } + finally { Remove-Item Env:ADMIN_EMAIL, Env:ADMIN_PASSWORD, Env:ADMIN_NAME -ErrorAction SilentlyContinue } +} + +function Watchdog { + Clear-Host + Write-Host ' Режим «Сторож»: проверка каждые ~8 с, авто-перезапуск при падении.' -ForegroundColor Cyan + Write-Host ' Нажмите любую клавишу для выхода в меню.' -ForegroundColor DarkGray + Write-Host '' + while (-not [Console]::KeyAvailable) { + $t = Get-Date -Format 'HH:mm:ss' + $p = Server-Proc + if ($p) { Write-Host (" [$t] работает (PID $($p.Id))") -ForegroundColor DarkGray } + else { Write-Host (" [$t] СЕРВЕР УПАЛ — поднимаю...") -ForegroundColor Yellow; Start-Server } + for ($i = 0; $i -lt 16; $i++) { if ([Console]::KeyAvailable) { break }; Start-Sleep -Milliseconds 500 } + } + [void][Console]::ReadKey($true) +} + function Run-Cmd($title, $block) { Write-Host '' Write-Host (' >>> ' + $title) -ForegroundColor Cyan @@ -90,41 +210,56 @@ if ($Stop) { Stop-Server; Start-Sleep -Milliseconds 800; exit } if ($Start) { Start-Server; Start-Sleep -Milliseconds 800; exit } # ── Меню ── +Refresh-Status $run = $true while ($run) { Clear-Host - $bar = ' ' + ('=' * 56) + $s = $script:Stat + $bar = ' ' + ('=' * 58) Write-Host '' Write-Host $bar -ForegroundColor DarkCyan Write-Host ' LearnSpace — Панель управления' -ForegroundColor Cyan Write-Host $bar -ForegroundColor DarkCyan - $st = Status-Line - if ($st -like 'РАБОТАЕТ*') { Write-Host (' Статус: ' + $st) -ForegroundColor Green } - else { Write-Host (' Статус: ' + $st) -ForegroundColor Yellow } + if ($s.running) { + Write-Host (' Статус: РАБОТАЕТ — PID ' + $s.procId + ', порт ' + $script:Port + ', аптайм ' + $s.uptime) -ForegroundColor Green + $hc = if ($s.health -eq 'healthy') { 'Green' } else { 'Yellow' } + $hm = if ($s.healthMs -ne $null) { " ($($s.healthMs) ms)" } else { '' } + Write-Host (' Health: ' + $s.health + $hm) -ForegroundColor $hc + } else { + Write-Host ' Статус: остановлен' -ForegroundColor Yellow + } + Write-Host (' БД: ' + $s.dbSize + ', пользователей: ' + $s.users + ', миграций до: ' + $s.migr) -ForegroundColor Gray + Write-Host (' Node: ' + $script:NodeVer + ' | .env: CLIENT_ORIGIN=' + $script:EnvSum.origin + ', JWT_SECRET=' + $script:EnvSum.jwt + ', LLM=' + $script:EnvSum.llm) -ForegroundColor DarkGray Write-Host (' URL: http://localhost:' + $script:Port) -ForegroundColor DarkGray - Write-Host (' Папка: ' + $backend) -ForegroundColor DarkGray Write-Host $bar -ForegroundColor DarkCyan Write-Host '' - Write-Host ' [1] Запустить сервер [5] Применить миграции' - Write-Host ' [2] Остановить сервер [6] Тесты (npm test)' - Write-Host ' [3] Перезапустить [7] Проверка роутов (lint)' - Write-Host ' [4] Живые логи (окно) [8] Открыть сайт в браузере' + Write-Host ' [1] Запустить сервер [6] Тесты (npm test)' + Write-Host ' [2] Остановить сервер [7] Проверка роутов (lint)' + Write-Host ' [3] Перезапустить [8] Открыть сайт в браузере' + Write-Host ' [4] Живые логи (окно) [9] Обновить статус' + Write-Host ' [5] Применить миграции [0] Выход' Write-Host '' - Write-Host ' [9] Обновить статус [0] Выход' + Write-Host ' Данные и обслуживание:' -ForegroundColor DarkCyan + Write-Host ' [B] Бэкап БД [R] Восстановить БД [A] Создать админа [W] Сторож [E] Ошибки' Write-Host '' - $c = Read-Host ' Выбор' - switch ($c.Trim()) { - '1' { Start-Server; Start-Sleep 1 } - '2' { Stop-Server; Start-Sleep 1 } - '3' { Stop-Server; Start-Sleep -Milliseconds 800; Start-Server; Start-Sleep 1 } - '4' { Open-Logs; Start-Sleep 1 } - '5' { Run-Cmd 'Применение миграций' { & node src/db/migrations-runner.js | Out-Host } } - '6' { Run-Cmd 'Тесты (npm test)' { & npm test } } - '7' { Run-Cmd 'Проверка авторизации роутов' { & npm run lint:routes } } - '8' { Start-Process ('http://localhost:' + $script:Port) } - '9' { } - '0' { $run = $false } - default { } + $c = (Read-Host ' Выбор').Trim().ToUpper() + switch -Regex ($c) { + '^1$' { Start-Server; Refresh-Status; Start-Sleep 1 } + '^2$' { Stop-Server; Refresh-Status; Start-Sleep 1 } + '^3$' { Stop-Server; Start-Sleep -Milliseconds 800; Start-Server; Refresh-Status; Start-Sleep 1 } + '^4$' { Open-Logs; Start-Sleep 1 } + '^5$' { Run-Cmd 'Применение миграций' { & node src/db/migrations-runner.js | Out-Host }; Refresh-Status } + '^6$' { Run-Cmd 'Тесты (npm test)' { & npm test } } + '^7$' { Run-Cmd 'Проверка авторизации роутов' { & npm run lint:routes } } + '^8$' { Start-Process ('http://localhost:' + $script:Port) } + '^9$' { Refresh-Status } + '^(B|И)$' { Run-Cmd 'Бэкап БД' { Backup-Db } } + '^(R|К)$' { Run-Cmd 'Восстановление БД' { Restore-Db }; Refresh-Status } + '^(A|Ф)$' { Run-Cmd 'Создать администратора' { Create-Admin } } + '^(W|Ц)$' { Watchdog; Refresh-Status } + '^(E|У)$' { Run-Cmd 'Ошибки в логах' { Show-Errors } } + '^0$' { $run = $false } + default { } } } Write-Host ''