Files
Learn_System/tools/control-panel.ps1
T
Maxim Dolgolyov 27f51f1a61 style(panel): дашборд с рамкой, цветной статус-маркер, сгруппированное меню
UI консольной панели:
- Закрытая рамка из box-символов (╔═╗║╠╣╚╝) вместо голых ===; правый бордюр выровнен
  на всех строках (хелперы B-Top/B-Mid/B-Bot/B-Line + B-Status с цветным маркером ●).
- Статус-блок: ● зелёный РАБОТАЕТ / серый ОСТАНОВЛЕН, health цветом по состоянию,
  строка БД (размер · юзеры · миграция-номер · бэкапов), Node/JWT/LLM/время обновления, URL.
- Меню в две выровненные колонки СЕРВЕР | ОБСЛУЖИВАНИЕ (ключи голубые, подписи серые),
  отдельная строка ДИАГНОСТИКА; промпт с ▶.
Чистый рефактор отрисовки — логика switch/функции не тронуты. UTF-8 BOM, парсинг OK,
рендер-смоук показал ровное выравнивание.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 23:00:02 +03:00

315 lines
17 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 = '-'; at = (Get-Date -Format 'HH:mm:ss'); backups = 0 }
try { $s.backups = @(Get-ChildItem $script:BackupDir -Filter 'learnspace-*.db' -ErrorAction SilentlyContinue).Count } catch {}
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] вернуться в меню')
}
# ── Рисование рамки/дашборда ──
$script:W = 62 # внутренняя ширина рамки (символов)
function B-Top { Write-Host (' ╔' + ('═' * ($script:W + 2)) + '╗') -ForegroundColor DarkCyan }
function B-Mid { Write-Host (' ╠' + ('═' * ($script:W + 2)) + '╣') -ForegroundColor DarkCyan }
function B-Bot { Write-Host (' ╚' + ('═' * ($script:W + 2)) + '╝') -ForegroundColor DarkCyan }
function B-Line($text, $color) {
$t = [string]$text
if ($t.Length -gt $script:W) { $t = $t.Substring(0, $script:W) }
Write-Host ' ║ ' -ForegroundColor DarkCyan -NoNewline
Write-Host $t.PadRight($script:W) -ForegroundColor $color -NoNewline
Write-Host ' ║' -ForegroundColor DarkCyan
}
# Строка с цветным маркером ● слева (статус). Маркер + пробел = 2 столбца.
function B-Status($markerColor, $text, $textColor) {
$avail = $script:W - 2
$t = [string]$text
if ($t.Length -gt $avail) { $t = $t.Substring(0, $avail) }
Write-Host ' ║ ' -ForegroundColor DarkCyan -NoNewline
Write-Host '●' -ForegroundColor $markerColor -NoNewline
Write-Host ' ' -NoNewline
Write-Host $t.PadRight($avail) -ForegroundColor $textColor -NoNewline
Write-Host ' ║' -ForegroundColor DarkCyan
}
# Заголовок секции меню (две колонки выравниваются под пунктами).
function Menu-Head($left, $right) { Write-Host (' ' + $left.PadRight(34) + $right) -ForegroundColor DarkCyan }
# Пункт меню в две колонки: ключ голубой, подпись серая.
function Menu-Row($lk, $ll, $rk, $rl) {
Write-Host ' ' -NoNewline
Write-Host $lk -ForegroundColor Cyan -NoNewline
Write-Host ((' ' + $ll).PadRight(31)) -ForegroundColor Gray -NoNewline
if ($rk) {
Write-Host $rk -ForegroundColor Cyan -NoNewline
Write-Host (' ' + $rl) -ForegroundColor Gray
} else { Write-Host '' }
}
# ── Неинтерактивные режимы (для 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
$migrShort = $s.migr; if ($s.migr -match '^(\d+)') { $migrShort = $matches[1] }
Write-Host ''
B-Top
B-Line 'LearnSpace · Панель управления сервером' Cyan
B-Mid
if ($s.running) {
B-Status Green ('РАБОТАЕТ PID ' + $s.procId + ' · порт ' + $script:Port + ' · аптайм ' + $s.uptime) Green
$hc = if ($s.health -eq 'healthy') { 'Green' } else { 'Yellow' }
$hm = if ($s.healthMs -ne $null) { " ($($s.healthMs) ms)" } else { '' }
B-Line ('Health: ' + $s.health + $hm) $hc
} else {
B-Status DarkGray 'ОСТАНОВЛЕН' Yellow
}
B-Line ('БД ' + $s.dbSize + ' · пользователей: ' + $s.users + ' · миграция: ' + $migrShort + ' · бэкапов: ' + $s.backups) Gray
B-Line ('Node ' + $script:NodeVer + ' · JWT: ' + $script:EnvSum.jwt + ' · LLM: ' + $script:EnvSum.llm + ' · обновлено ' + $s.at) DarkGray
B-Line ('URL ' + $script:EnvSum.origin) DarkGray
B-Bot
Write-Host ''
Menu-Head 'СЕРВЕР' 'ОБСЛУЖИВАНИЕ'
Menu-Row '[1]' 'Запустить' '[B]' 'Бэкап БД'
Menu-Row '[2]' 'Остановить' '[R]' 'Восстановить БД'
Menu-Row '[3]' 'Перезапустить' '[A]' 'Создать админа'
Menu-Row '[4]' 'Живые логи (окно)' '[W]' 'Сторож (авто-рестарт)'
Menu-Row '[5]' 'Применить миграции' '[E]' 'Ошибки в логах'
Write-Host ''
Menu-Head 'ДИАГНОСТИКА И ПРОЧЕЕ' ''
Write-Host ' ' -NoNewline
Write-Host '[6]' -ForegroundColor Cyan -NoNewline; Write-Host ' Тесты ' -ForegroundColor Gray -NoNewline
Write-Host '[7]' -ForegroundColor Cyan -NoNewline; Write-Host ' Lint роутов ' -ForegroundColor Gray -NoNewline
Write-Host '[8]' -ForegroundColor Cyan -NoNewline; Write-Host ' Сайт ' -ForegroundColor Gray -NoNewline
Write-Host '[9]' -ForegroundColor Cyan -NoNewline; Write-Host ' Обновить ' -ForegroundColor Gray -NoNewline
Write-Host '[0]' -ForegroundColor Cyan -NoNewline; Write-Host ' Выход' -ForegroundColor Gray
Write-Host ''
Write-Host ' ▶ ' -ForegroundColor Cyan -NoNewline
$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