Files
Learn_System/tools/control-panel.ps1
T
Maxim Dolgolyov 6eefb70ce7 feat(panel): бэкап/восстановление БД, умный статус, создать админа, watchdog
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) <noreply@anthropic.com>
2026-06-19 22:40:45 +03:00

270 lines
15 KiB
PowerShell
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
param(
[switch]$Start, # неинтерактивно: запустить и выйти
[switch]$Stop # неинтерактивно: остановить и выйти
)
# LearnSpace — панель управления сервером (консоль-меню).
# Запуск: двойной клик по control-panel.bat. Сервер работает в фоне и переживает
# закрытие панели. Живые логи открываются в отдельном окне.
$ErrorActionPreference = 'Continue'
try { chcp 65001 > $null } catch {}
try { [Console]::OutputEncoding = [System.Text.UTF8Encoding]::new() } catch {}
$root = Split-Path -Parent $PSScriptRoot
$backend = Join-Path $root 'backend'
$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 {
$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
if ($c) { return Get-Process -Id $c.OwningProcess -ErrorAction SilentlyContinue }
} catch {}
return $null
}
# Полный статус (процесс + 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 = 'не отвечает' }
}
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
Start-Process -FilePath node -ArgumentList 'src/server.js' -WorkingDirectory $backend -WindowStyle Hidden -RedirectStandardOutput $log -RedirectStandardError $errlog | Out-Null
for ($i = 0; $i -lt 14; $i++) { Start-Sleep -Milliseconds 600; if (Server-Proc) { break } }
if (Server-Proc) {
Write-Host (' Сервер запущен на http://localhost:' + $script:Port) -ForegroundColor Green
} else {
Write-Host ' Не удалось запустить. Последние строки ошибок:' -ForegroundColor Red
if (Test-Path $errlog) { Get-Content $errlog -Tail 14 -ErrorAction SilentlyContinue }
}
}
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 600
Write-Host (' Сервер остановлен (PID ' + $p.Id + ').') -ForegroundColor Green
}
function Open-Logs {
if (-not (Test-Path $log)) {
Write-Host ' Логов нет: сервер не запускался через панель. Запустите его пунктом [1] или [3].' -ForegroundColor Yellow
return
}
$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
Write-Host ''
& $block
Write-Host ''
[void](Read-Host ' [Enter] вернуться в меню')
}
# ── Неинтерактивные режимы (для start/stop-server.bat) ──
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
$s = $script:Stat
$bar = ' ' + ('=' * 58)
Write-Host ''
Write-Host $bar -ForegroundColor DarkCyan
Write-Host ' LearnSpace — Панель управления' -ForegroundColor Cyan
Write-Host $bar -ForegroundColor DarkCyan
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 $bar -ForegroundColor DarkCyan
Write-Host ''
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 ' Данные и обслуживание:' -ForegroundColor DarkCyan
Write-Host ' [B] Бэкап БД [R] Восстановить БД [A] Создать админа [W] Сторож [E] Ошибки'
Write-Host ''
$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 ''
Write-Host ' Панель закрыта. Сервер, если запущен, продолжает работать в фоне.' -ForegroundColor Cyan
Write-Host ' Остановить позже: stop-server.bat или пункт [2] панели.' -ForegroundColor DarkGray