Update to latest tmux pane handling

This commit is contained in:
DariusIII
2026-08-04 11:51:35 +02:00
parent 6a0b943d92
commit fbf036598a
11 changed files with 921 additions and 215 deletions
+15 -5
View File
@@ -4,10 +4,12 @@ declare(strict_types=1);
namespace App\Console\Commands;
use App\Enums\TmuxPaneRole;
use App\Models\Settings;
use App\Services\Tmux\TmuxPaneManager;
use App\Services\Tmux\TmuxSessionManager;
use Illuminate\Console\Command;
use RuntimeException;
class TmuxAttach extends Command
{
@@ -49,12 +51,20 @@ class TmuxAttach extends Command
// Select monitor pane before attaching so user lands there
$paneManager = new TmuxPaneManager($sessionName);
$paneManager->selectWindow(0);
$paneManager->selectPane('0.0');
try {
$monitorPane = $paneManager->paneForRole(TmuxPaneRole::Monitor, '0.0');
} catch (RuntimeException $exception) {
$this->error('❌ '.$exception->getMessage());
// Execute tmux attach
passthru("tmux attach -t {$sessionName}");
return Command::FAILURE;
}
return Command::SUCCESS;
if (! $paneManager->selectWindow(0) || ! $paneManager->selectPane($monitorPane)) {
$this->error('❌ Unable to select the tmux monitor pane.');
return Command::FAILURE;
}
return $sessionManager->attachSession() ? Command::SUCCESS : Command::FAILURE;
}
}
+15 -5
View File
@@ -4,7 +4,9 @@ declare(strict_types=1);
namespace App\Console\Commands;
use App\Enums\TmuxPaneRole;
use App\Models\Settings;
use App\Services\Tmux\TmuxPaneManager;
use App\Services\Tmux\TmuxSessionManager;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
@@ -77,7 +79,7 @@ class TmuxHealthCheck extends Command
if ($monitorPaneDead) {
if (! $quiet) {
$this->warn('⚠️ Monitor pane (0.0) is dead.');
$this->warn('⚠️ Monitor pane is dead.');
}
if ($autoRestart) {
@@ -92,7 +94,7 @@ class TmuxHealthCheck extends Command
}
if (! $quiet) {
$this->info('✅ Monitor pane (0.0) is alive and running.');
$this->info('✅ Monitor pane is alive and running.');
}
$this->logHealthCheck('info', 'healthy');
@@ -111,12 +113,20 @@ class TmuxHealthCheck extends Command
}
/**
* Check if the monitor pane (0.0) is dead.
* Check if the monitor pane is dead.
*/
private function isMonitorPaneDead(): bool
{
$paneManager = new TmuxPaneManager($this->sessionName);
try {
$monitorPane = $paneManager->paneForRole(TmuxPaneRole::Monitor, '0.0');
} catch (\RuntimeException) {
return true;
}
$result = Process::timeout(10)->run(
"tmux display-message -p -t {$this->sessionName}:0.0 '#{pane_dead}'"
['tmux', 'display-message', '-p', '-t', $monitorPane, '#{pane_dead}']
);
if (! $result->successful()) {
@@ -130,7 +140,7 @@ class TmuxHealthCheck extends Command
}
$commandResult = Process::timeout(10)->run(
"tmux display-message -p -t {$this->sessionName}:0.0 '#{pane_current_command}'"
['tmux', 'display-message', '-p', '-t', $monitorPane, '#{pane_current_command}']
);
if (! $commandResult->successful()) {
+2 -2
View File
@@ -193,7 +193,7 @@ class TmuxMonitor extends Command
$this->taskRunner->runBackfill($runVar);
// Update releases
$this->taskRunner->runReleasesUpdate(array_merge($runVar, ['pane' => '0.3']));
$this->taskRunner->runReleasesUpdate($runVar);
// Post-processing and cleanup tasks
$this->runPostProcessingTasks($runVar);
@@ -207,7 +207,7 @@ class TmuxMonitor extends Command
private function runBasicTasks(array $runVar): void
{
// Update releases
$this->taskRunner->runReleasesUpdate(array_merge($runVar, ['pane' => '0.1']));
$this->taskRunner->runReleasesUpdate($runVar);
// Post-processing and cleanup tasks
$this->runPostProcessingTasks($runVar);
+24 -7
View File
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Console\Commands;
use App\Enums\TmuxPaneRole;
use App\Models\Collection;
use App\Models\Settings;
use App\Services\Tmux\TmuxLayoutBuilder;
@@ -36,6 +37,8 @@ class TmuxStart extends Command
private TmuxLayoutBuilder $layoutBuilder;
private bool $sessionCreated = false;
/**
* Execute the console command.
*/
@@ -86,12 +89,13 @@ class TmuxStart extends Command
// Build the tmux layout
if (! $this->layoutBuilder->buildLayout($sequential)) {
$this->error('❌ Failed to build tmux layout');
$this->error('❌ Failed to build tmux layout: '.($this->layoutBuilder->lastError() ?? 'unknown error'));
return Command::FAILURE;
}
$this->info('✅ Tmux layout created');
$this->sessionCreated = true;
// Set running flag
Settings::query()->where('name', 'running')->update(['value' => 1]);
@@ -101,17 +105,23 @@ class TmuxStart extends Command
$this->info('🚀 Starting monitor...');
$this->startMonitor($sessionName);
// Select monitor pane (0.0) so attach lands there
// Select monitor pane so attach lands there
$paneManager = new TmuxPaneManager($sessionName);
$paneManager->selectWindow(0);
$paneManager->selectPane('0.0');
if (! $paneManager->selectWindow(0)
|| ! $paneManager->selectPane($paneManager->paneForRole(TmuxPaneRole::Monitor, '0.0'))) {
throw new \RuntimeException($paneManager->lastError() ?? 'Unable to select the tmux monitor pane.');
}
$this->info("✅ Tmux session '{$sessionName}' started successfully");
// Attach if requested
if ($this->option('attach')) {
$this->info('📎 Attaching to session...');
$this->sessionManager->attachSession();
if (! $this->sessionManager->attachSession()) {
$this->error('❌ Unable to attach to the tmux session.');
return Command::FAILURE;
}
} else {
$this->info("💡 To attach to the session, run: tmux attach -t {$sessionName}");
$this->info('💡 Or use: php artisan tmux:attach');
@@ -120,6 +130,11 @@ class TmuxStart extends Command
return Command::SUCCESS;
} catch (\Exception $e) {
if ($this->sessionCreated && isset($this->sessionManager)) {
$this->sessionManager->killSession();
Settings::query()->where('name', 'running')->update(['value' => 0]);
}
$this->error('❌ Failed to start tmux: '.$e->getMessage());
logger()->error('Tmux start error', [
'message' => $e->getMessage(),
@@ -192,7 +207,9 @@ class TmuxStart extends Command
$this->warn(' ⚠ Using artisan command (not recommended for pane)');
}
// Spawn monitor in pane 0.0
$paneManager->respawnPane('0.0', $command);
$monitorPane = $paneManager->paneForRole(TmuxPaneRole::Monitor, '0.0');
if (! $paneManager->respawnPane($monitorPane, $command)) {
throw new \RuntimeException('Unable to start the tmux monitor pane.');
}
}
}
+29
View File
@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace App\Enums;
enum TmuxPaneRole: string
{
case Monitor = 'monitor';
case Binaries = 'binaries';
case Backfill = 'backfill';
case Releases = 'releases';
case Sequential = 'sequential';
case FixNames = 'fix_names';
case RemoveCrap = 'remove_crap';
case PostAdditional = 'post_additional';
case PostTv = 'post_tv';
case PostMetadata = 'post_metadata';
case PostMovies = 'post_movies';
case IrcScraper = 'irc_scraper';
case Htop = 'htop';
case Nmon = 'nmon';
case Vnstat = 'vnstat';
case Tcptrack = 'tcptrack';
case BandwidthMonitor = 'bandwidth_monitor';
case Mytop = 'mytop';
case Redis = 'redis';
case Console = 'console';
}
+243 -106
View File
@@ -4,8 +4,11 @@ declare(strict_types=1);
namespace App\Services\Tmux;
use App\Enums\TmuxPaneRole;
use App\Models\Settings;
use Illuminate\Support\Facades\Process;
use RuntimeException;
use Throwable;
/**
* Service for building tmux window layouts based on sequential mode
@@ -20,6 +23,10 @@ class TmuxLayoutBuilder
protected string $sessionName;
private ?string $lastError = null;
private bool $sessionCreated = false;
/**
* Pane name icons mapping - uses Nerd Font symbols
* Requires a Nerd Font to display properly (FiraCode NF, JetBrains Mono NF, etc.)
@@ -61,7 +68,7 @@ class TmuxLayoutBuilder
public function __construct(TmuxSessionManager $sessionManager)
{
$this->sessionManager = $sessionManager;
$this->sessionName = $sessionManager->getSessionName();
$this->sessionName = $sessionManager->sessionName();
$this->paneManager = new TmuxPaneManager($this->sessionName);
}
@@ -78,55 +85,90 @@ class TmuxLayoutBuilder
*/
public function buildLayout(int $sequentialMode): bool
{
return match ($sequentialMode) {
1 => $this->buildBasicLayout(),
2 => $this->buildStrippedLayout(),
default => $this->buildFullLayout(),
};
try {
match ($sequentialMode) {
1 => $this->buildBasicLayout(),
2 => $this->buildStrippedLayout(),
default => $this->buildFullLayout(),
};
return true;
} catch (Throwable $exception) {
$this->lastError = $exception->getMessage();
if ($this->sessionCreated) {
$this->sessionManager->killSession();
}
return false;
}
}
/**
* Build full non-sequential layout (mode 0)
*/
protected function buildFullLayout(): bool
protected function buildFullLayout(): void
{
// Window 0: Monitor + Binaries + Backfill + Releases
if (! $this->sessionManager->createSession($this->getPaneDisplayName('Monitor'))) {
return false;
}
// Select pane 0.0, then split for binaries (right side, 67%)
$this->paneManager->selectPane('0.0');
$this->paneManager->splitHorizontal('0', '67%', $this->getPaneDisplayName('update_binaries'));
// Select pane 0.1 (binaries), then split for backfill (bottom, 67%)
$this->paneManager->selectPane('0.1');
$this->paneManager->splitVertical('0', '67%', $this->getPaneDisplayName('backfill'));
// Split again for releases (bottom, 50%)
$this->paneManager->splitVertical('0', '50%', $this->getPaneDisplayName('update_releases'));
$monitor = $this->createSessionPane(TmuxPaneRole::Monitor, $this->getPaneDisplayName('Monitor'));
$binaries = $this->splitHorizontal(
$monitor,
67,
TmuxPaneRole::Binaries,
$this->getPaneDisplayName('update_binaries'),
);
$backfill = $this->splitVertical(
$binaries,
67,
TmuxPaneRole::Backfill,
$this->getPaneDisplayName('backfill'),
);
$this->splitVertical(
$backfill,
50,
TmuxPaneRole::Releases,
$this->getPaneDisplayName('update_releases'),
);
// Window 1: Fix names + Remove crap
$this->paneManager->createWindow(1, ' Utils');
$this->paneManager->setPaneTitle('1.0', $this->getPaneDisplayName('fixReleaseNames'));
$this->paneManager->selectPane('1.0');
$this->paneManager->splitHorizontal('1', '50%', $this->getPaneDisplayName('removeCrapReleases'));
$fixNames = $this->createWindowPane(
1,
' Utils',
TmuxPaneRole::FixNames,
$this->getPaneDisplayName('fixReleaseNames'),
);
$this->splitHorizontal(
$fixNames,
50,
TmuxPaneRole::RemoveCrap,
$this->getPaneDisplayName('removeCrapReleases'),
);
// Window 2: Postprocessing (Left: Additional + TV + Metadata, Right: Movies + XXX)
$this->paneManager->createWindow(2, ' Post');
$this->paneManager->setPaneTitle('2.0', $this->getPaneDisplayName('postprocessing_additional'));
// Split horizontally to create left and right halves (don't set title yet)
$this->paneManager->splitHorizontal('2', '50%', '');
// Left side (2.0): split vertically for TV and Metadata
$this->paneManager->selectPane('2.0');
$this->paneManager->splitVertical('2', '67%', $this->getPaneDisplayName('postprocessing_tv'));
$this->paneManager->splitVertical('2', '50%', $this->getPaneDisplayName('postprocessing_amazon'));
// Right side: After left splits, right side is now 2.3
$this->paneManager->selectPane('2.3');
$this->paneManager->setPaneTitle('2.3', $this->getPaneDisplayName('postprocessing_movies'));
// Window 2: Postprocessing (left stack and right-side movies)
$additional = $this->createWindowPane(
2,
' Post',
TmuxPaneRole::PostAdditional,
$this->getPaneDisplayName('postprocessing_additional'),
);
$this->splitHorizontal(
$additional,
50,
TmuxPaneRole::PostMovies,
$this->getPaneDisplayName('postprocessing_movies'),
);
$tv = $this->splitVertical(
$additional,
67,
TmuxPaneRole::PostTv,
$this->getPaneDisplayName('postprocessing_tv'),
);
$this->splitVertical(
$tv,
50,
TmuxPaneRole::PostMetadata,
$this->getPaneDisplayName('postprocessing_amazon'),
);
// Window 3: IRC Scraper
$this->createIRCScraperWindow();
@@ -134,90 +176,115 @@ class TmuxLayoutBuilder
// Additional windows (optional monitoring tools)
$this->createOptionalWindows();
return true;
}
/**
* Build basic sequential layout (mode 1)
*/
protected function buildBasicLayout(): bool
protected function buildBasicLayout(): void
{
// Window 0: Monitor + Releases
if (! $this->sessionManager->createSession($this->getPaneDisplayName('Monitor'))) {
return false;
}
// Select pane 0.0, then split for releases
$this->paneManager->selectPane('0.0');
$this->paneManager->splitHorizontal('0', '67%', $this->getPaneDisplayName('update_releases'));
$monitor = $this->createSessionPane(TmuxPaneRole::Monitor, $this->getPaneDisplayName('Monitor'));
$this->splitHorizontal(
$monitor,
67,
TmuxPaneRole::Releases,
$this->getPaneDisplayName('update_releases'),
);
// Window 1: Utils (Fix names + Remove crap)
$this->paneManager->createWindow(1, ' Utils');
$this->paneManager->setPaneTitle('1.0', $this->getPaneDisplayName('fixReleaseNames'));
$this->paneManager->selectPane('1.0');
$this->paneManager->splitHorizontal('1', '50%', $this->getPaneDisplayName('removeCrapReleases'));
$fixNames = $this->createWindowPane(
1,
' Utils',
TmuxPaneRole::FixNames,
$this->getPaneDisplayName('fixReleaseNames'),
);
$this->splitHorizontal(
$fixNames,
50,
TmuxPaneRole::RemoveCrap,
$this->getPaneDisplayName('removeCrapReleases'),
);
// Window 2: Postprocessing (Left: Additional + TV + Metadata, Right: Movies + XXX)
$this->paneManager->createWindow(2, ' Post');
$this->paneManager->setPaneTitle('2.0', $this->getPaneDisplayName('postprocessing_additional'));
// Split horizontally to create left and right halves (don't set title yet)
$this->paneManager->splitHorizontal('2', '50%', '');
// Left side (2.0): split vertically for TV and Metadata
$this->paneManager->selectPane('2.0');
$this->paneManager->splitVertical('2', '67%', $this->getPaneDisplayName('postprocessing_tv'));
$this->paneManager->splitVertical('2', '50%', $this->getPaneDisplayName('postprocessing_amazon'));
// Right side: After left splits, right side is now 2.3
$this->paneManager->selectPane('2.3');
$this->paneManager->setPaneTitle('2.3', $this->getPaneDisplayName('postprocessing_movies'));
// Window 2: Postprocessing
$additional = $this->createWindowPane(
2,
' Post',
TmuxPaneRole::PostAdditional,
$this->getPaneDisplayName('postprocessing_additional'),
);
$this->splitHorizontal(
$additional,
50,
TmuxPaneRole::PostMovies,
$this->getPaneDisplayName('postprocessing_movies'),
);
$tv = $this->splitVertical(
$additional,
67,
TmuxPaneRole::PostTv,
$this->getPaneDisplayName('postprocessing_tv'),
);
$this->splitVertical(
$tv,
50,
TmuxPaneRole::PostMetadata,
$this->getPaneDisplayName('postprocessing_amazon'),
);
// Window 3: IRC Scraper
$this->createIRCScraperWindow();
$this->createOptionalWindows();
return true;
}
/**
* Build stripped sequential layout (mode 2)
*/
protected function buildStrippedLayout(): bool
protected function buildStrippedLayout(): void
{
// Window 0: Monitor + Sequential
if (! $this->sessionManager->createSession($this->getPaneDisplayName('Monitor'))) {
return false;
}
// Select pane 0.0, then split for sequential
$this->paneManager->selectPane('0.0');
$this->paneManager->splitHorizontal('0', '67%', $this->getPaneDisplayName('sequential'));
$monitor = $this->createSessionPane(TmuxPaneRole::Monitor, $this->getPaneDisplayName('Monitor'));
$this->splitHorizontal(
$monitor,
67,
TmuxPaneRole::Sequential,
$this->getPaneDisplayName('sequential'),
);
// Window 1: Metadata postprocessing
$this->paneManager->createWindow(1, ' Utils');
$this->paneManager->setPaneTitle('1.0', $this->getPaneDisplayName('fixReleaseNames'));
$this->paneManager->selectPane('1.0');
$this->paneManager->splitHorizontal('1', '50%', $this->getPaneDisplayName('postprocessing_amazon'));
$fixNames = $this->createWindowPane(
1,
' Utils',
TmuxPaneRole::FixNames,
$this->getPaneDisplayName('fixReleaseNames'),
);
$this->splitHorizontal(
$fixNames,
50,
TmuxPaneRole::PostMetadata,
$this->getPaneDisplayName('postprocessing_amazon'),
);
// Window 2: IRC Scraper
$this->createIRCScraperWindow();
$this->createOptionalWindows();
return true;
}
/**
* Create IRC scraper window
*/
protected function createIRCScraperWindow(): bool
protected function createIRCScraperWindow(): void
{
$this->paneManager->createWindow(3, '󰻞 IRC');
$this->paneManager->setPaneTitle('3.0', $this->getPaneDisplayName('scrapeIRC'));
return true;
$this->createWindowPane(
3,
'󰻞 IRC',
TmuxPaneRole::IrcScraper,
$this->getPaneDisplayName('scrapeIRC'),
);
}
/**
@@ -239,45 +306,45 @@ class TmuxLayoutBuilder
// htop
if ((int) Settings::settingValue('htop') === 1 && $this->commandExists('htop')) {
$this->paneManager->createWindow($windowIndex, $this->getPaneDisplayName('htop'));
$this->paneManager->respawnPane("{$windowIndex}.0", 'htop');
$pane = $this->createWindowPane($windowIndex, $this->getPaneDisplayName('htop'), TmuxPaneRole::Htop, $this->getPaneDisplayName('htop'));
$this->requireSuccess($this->paneManager->respawnPane($pane, 'htop'), 'respawn htop pane');
$windowIndex++;
}
// nmon
if ((int) Settings::settingValue('nmon') === 1 && $this->commandExists('nmon')) {
$this->paneManager->createWindow($windowIndex, $this->getPaneDisplayName('nmon'));
$this->paneManager->respawnPane("{$windowIndex}.0", 'nmon -t');
$pane = $this->createWindowPane($windowIndex, $this->getPaneDisplayName('nmon'), TmuxPaneRole::Nmon, $this->getPaneDisplayName('nmon'));
$this->requireSuccess($this->paneManager->respawnPane($pane, 'nmon -t'), 'respawn nmon pane');
$windowIndex++;
}
// vnstat
if ((int) Settings::settingValue('vnstat') === 1 && $this->commandExists('vnstat')) {
$vnstatArgs = Settings::settingValue('vnstat_args') ?? '';
$this->paneManager->createWindow($windowIndex, $this->getPaneDisplayName('vnstat'));
$this->paneManager->respawnPane("{$windowIndex}.0", "watch -n10 'vnstat {$vnstatArgs}'");
$pane = $this->createWindowPane($windowIndex, $this->getPaneDisplayName('vnstat'), TmuxPaneRole::Vnstat, $this->getPaneDisplayName('vnstat'));
$this->requireSuccess($this->paneManager->respawnPane($pane, "watch -n10 'vnstat {$vnstatArgs}'"), 'respawn vnstat pane');
$windowIndex++;
}
// tcptrack
if ((int) Settings::settingValue('tcptrack') === 1 && $this->commandExists('tcptrack')) {
$tcptrackArgs = Settings::settingValue('tcptrack_args') ?? '';
$this->paneManager->createWindow($windowIndex, $this->getPaneDisplayName('tcptrack'));
$this->paneManager->respawnPane("{$windowIndex}.0", "tcptrack {$tcptrackArgs}");
$pane = $this->createWindowPane($windowIndex, $this->getPaneDisplayName('tcptrack'), TmuxPaneRole::Tcptrack, $this->getPaneDisplayName('tcptrack'));
$this->requireSuccess($this->paneManager->respawnPane($pane, "tcptrack {$tcptrackArgs}"), 'respawn tcptrack pane');
$windowIndex++;
}
// bwm-ng
if ((int) Settings::settingValue('bwmng') === 1 && $this->commandExists('bwm-ng')) {
$this->paneManager->createWindow($windowIndex, $this->getPaneDisplayName('bwm-ng'));
$this->paneManager->respawnPane("{$windowIndex}.0", 'bwm-ng');
$pane = $this->createWindowPane($windowIndex, $this->getPaneDisplayName('bwm-ng'), TmuxPaneRole::BandwidthMonitor, $this->getPaneDisplayName('bwm-ng'));
$this->requireSuccess($this->paneManager->respawnPane($pane, 'bwm-ng'), 'respawn bwm-ng pane');
$windowIndex++;
}
// mytop
if ((int) Settings::settingValue('mytop') === 1 && $this->commandExists('mytop')) {
$this->paneManager->createWindow($windowIndex, $this->getPaneDisplayName('mytop'));
$this->paneManager->respawnPane("{$windowIndex}.0", 'mytop -u');
$pane = $this->createWindowPane($windowIndex, $this->getPaneDisplayName('mytop'), TmuxPaneRole::Mytop, $this->getPaneDisplayName('mytop'));
$this->requireSuccess($this->paneManager->respawnPane($pane, 'mytop -u'), 'respawn mytop pane');
$windowIndex++;
}
@@ -286,13 +353,13 @@ class TmuxLayoutBuilder
$redisArgs = Settings::settingValue('redis_args') ?? '';
$refreshInterval = 30;
$this->paneManager->createWindow($windowIndex, $this->getPaneDisplayName('redis'));
$pane = $this->createWindowPane($windowIndex, $this->getPaneDisplayName('redis'), TmuxPaneRole::Redis, $this->getPaneDisplayName('redis'));
// Check if custom args provided for simple redis-cli output
if (! empty($redisArgs) && $redisArgs !== 'NULL' && $this->commandExists('redis-cli')) {
$redisHost = config('database.redis.default.host', '127.0.0.1');
$redisPort = config('database.redis.default.port', 6379);
$this->paneManager->respawnPane("{$windowIndex}.0", "watch -n{$refreshInterval} -c 'redis-cli -h {$redisHost} -p {$redisPort} {$redisArgs}'");
$this->requireSuccess($this->paneManager->respawnPane($pane, "watch -n{$refreshInterval} -c 'redis-cli -h {$redisHost} -p {$redisPort} {$redisArgs}'"), 'respawn Redis pane');
} else {
// Use PHP-based Redis monitor service
$redisHost = config('database.redis.default.host', '127.0.0.1');
@@ -305,11 +372,11 @@ class TmuxLayoutBuilder
if ($connectionInfo['use_sail']) {
// Use sail to run inside Docker container
$sail = base_path('sail');
$this->paneManager->respawnPane("{$windowIndex}.0", "{$sail} artisan redis:monitor --refresh={$refreshInterval}");
$this->requireSuccess($this->paneManager->respawnPane($pane, "{$sail} artisan redis:monitor --refresh={$refreshInterval}"), 'respawn Redis pane');
} else {
// Run directly, potentially with host override
$envPrefix = $connectionInfo['override_host'] ? "REDIS_HOST={$connectionInfo['host']} " : '';
$this->paneManager->respawnPane("{$windowIndex}.0", "{$envPrefix}php {$artisan} redis:monitor --refresh={$refreshInterval}");
$this->requireSuccess($this->paneManager->respawnPane($pane, "{$envPrefix}php {$artisan} redis:monitor --refresh={$refreshInterval}"), 'respawn Redis pane');
}
}
$windowIndex++;
@@ -317,8 +384,78 @@ class TmuxLayoutBuilder
// bash console
if ((int) Settings::settingValue('console') === 1) {
$this->paneManager->createWindow($windowIndex, $this->getPaneDisplayName('bash'));
$this->paneManager->respawnPane("{$windowIndex}.0", 'bash -i');
$pane = $this->createWindowPane($windowIndex, $this->getPaneDisplayName('bash'), TmuxPaneRole::Console, $this->getPaneDisplayName('bash'));
$this->requireSuccess($this->paneManager->respawnPane($pane, 'bash -i'), 'respawn console pane');
}
}
public function lastError(): ?string
{
return $this->lastError;
}
private function createSessionPane(TmuxPaneRole $role, string $title): string
{
$paneId = $this->sessionManager->createSession($title);
if ($paneId === null) {
throw new RuntimeException($this->sessionManager->lastError() ?? 'Unable to create tmux session.');
}
$this->sessionCreated = true;
$this->configurePane($paneId, $role, $title);
return $paneId;
}
private function createWindowPane(int $index, string $windowName, TmuxPaneRole $role, string $title): string
{
$paneId = $this->paneManager->createWindow($index, $windowName);
if ($paneId === null) {
throw new RuntimeException($this->paneManager->lastError() ?? "Unable to create tmux window {$index}.");
}
$this->configurePane($paneId, $role, $title);
return $paneId;
}
private function splitHorizontal(string $target, int $percentage, TmuxPaneRole $role, string $title): string
{
$paneId = $this->paneManager->splitHorizontal($target, $percentage);
if ($paneId === null) {
throw new RuntimeException($this->paneManager->lastError() ?? "Unable to split tmux pane {$target}.");
}
$this->configurePane($paneId, $role, $title);
return $paneId;
}
private function splitVertical(string $target, int $percentage, TmuxPaneRole $role, string $title): string
{
$paneId = $this->paneManager->splitVertical($target, $percentage);
if ($paneId === null) {
throw new RuntimeException($this->paneManager->lastError() ?? "Unable to split tmux pane {$target}.");
}
$this->configurePane($paneId, $role, $title);
return $paneId;
}
private function configurePane(string $paneId, TmuxPaneRole $role, string $title): void
{
$this->requireSuccess($this->paneManager->setPaneTitle($paneId, $title), "set title for {$role->value} pane");
$this->requireSuccess($this->paneManager->setPaneRole($paneId, $role), "tag {$role->value} pane");
}
private function requireSuccess(bool $successful, string $operation): void
{
if (! $successful) {
$error = $this->paneManager->lastError();
$suffix = $error === null || $error === '' ? '' : ": {$error}";
throw new RuntimeException("Unable to {$operation}{$suffix}.");
}
}
@@ -328,7 +465,7 @@ class TmuxLayoutBuilder
protected function commandExists(string $command): bool
{
$result = Process::timeout(5)
->run("which {$command} 2>/dev/null");
->run(['which', $command]);
return $result->successful() && str_contains($result->output(), $command);
}
+180 -44
View File
@@ -4,15 +4,27 @@ declare(strict_types=1);
namespace App\Services\Tmux;
use App\Enums\TmuxPaneRole;
use Illuminate\Contracts\Process\ProcessResult;
use Illuminate\Support\Facades\Process;
use RuntimeException;
/**
* Service for managing individual tmux panes
*/
class TmuxPaneManager
{
private const ROLE_OPTION = '@nntmux_role';
protected string $sessionName;
/**
* @var array<string, string>|null
*/
private ?array $roleTargets = null;
private ?string $lastError = null;
public function __construct(string $sessionName)
{
$this->sessionName = $sessionName;
@@ -21,48 +33,37 @@ class TmuxPaneManager
/**
* Create a new window
*/
public function createWindow(int $index, string $name): bool
public function createWindow(int $index, string $name): ?string
{
// Escape single quotes in the name for shell
$escapedName = str_replace("'", "'\\''", $name);
$result = Process::timeout(10)->run(
"tmux new-window -t {$this->sessionName}:{$index} -n '{$escapedName}' 'printf \"\\033]2;{$escapedName}\\033\"'"
['tmux', 'new-window', '-P', '-F', '#{pane_id}', '-t', "{$this->sessionName}:{$index}", '-n', $name, 'true']
);
return $result->successful();
return $this->createdPaneId($result->successful(), $result->output(), $result->errorOutput());
}
/**
* Split a pane horizontally
*/
public function splitHorizontal(string $target, string $percentage, string $title = ''): bool
public function splitHorizontal(string $target, int $percentage): ?string
{
// Escape single quotes in the title for shell
$escapedTitle = str_replace("'", "'\\''", $title);
$titleCmd = $title ? "printf \"\\033]2;{$escapedTitle}\\033\"" : '';
$result = Process::timeout(10)->run(
"tmux splitw -t {$this->sessionName}:{$target} -h -l {$percentage} '{$titleCmd}'"
['tmux', 'split-window', '-P', '-F', '#{pane_id}', '-t', $this->target($target), '-h', '-l', "{$percentage}%", 'true']
);
return $result->successful();
return $this->createdPaneId($result->successful(), $result->output(), $result->errorOutput());
}
/**
* Split a pane vertically
*/
public function splitVertical(string $target, string $percentage, string $title = ''): bool
public function splitVertical(string $target, int $percentage): ?string
{
// Escape single quotes in the title for shell
$escapedTitle = str_replace("'", "'\\''", $title);
$titleCmd = $title ? "printf \"\\033]2;{$escapedTitle}\\033\"" : '';
$result = Process::timeout(10)->run(
"tmux splitw -t {$this->sessionName}:{$target} -v -l {$percentage} '{$titleCmd}'"
['tmux', 'split-window', '-P', '-F', '#{pane_id}', '-t', $this->target($target), '-v', '-l', "{$percentage}%", 'true']
);
return $result->successful();
return $this->createdPaneId($result->successful(), $result->output(), $result->errorOutput());
}
/**
@@ -71,10 +72,10 @@ class TmuxPaneManager
public function selectPane(string $target): bool
{
$result = Process::timeout(5)->run(
"tmux selectp -t {$this->sessionName}:{$target}"
['tmux', 'select-pane', '-t', $this->target($target)]
);
return $result->successful();
return $this->recordResult($result);
}
/**
@@ -83,10 +84,10 @@ class TmuxPaneManager
public function selectWindow(int $window): bool
{
$result = Process::timeout(5)->run(
"tmux select-window -t {$this->sessionName}:{$window}"
['tmux', 'select-window', '-t', "{$this->sessionName}:{$window}"]
);
return $result->successful();
return $this->recordResult($result);
}
/**
@@ -94,17 +95,17 @@ class TmuxPaneManager
*/
public function respawnPane(string $target, string $command, bool $kill = false): bool
{
$killFlag = $kill ? '-k' : '';
// Escape the command for tmux by replacing double quotes with escaped quotes
$escapedCommand = str_replace('"', '\\"', $command);
$escapedCommand = str_replace('$', '\\$', $escapedCommand);
$arguments = ['tmux', 'respawn-pane'];
if ($kill) {
$arguments[] = '-k';
}
array_push($arguments, '-t', $this->target($target), $command);
$result = Process::timeout(10)->run(
"tmux respawnp {$killFlag} -t {$this->sessionName}:{$target} \"{$escapedCommand}\""
$arguments
);
return $result->successful();
return $this->recordResult($result);
}
/**
@@ -112,13 +113,14 @@ class TmuxPaneManager
*/
public function sendKeys(string $target, string $keys, bool $enter = true): bool
{
$enterKey = $enter ? ' Enter' : '';
$result = Process::timeout(5)->run(
"tmux send-keys -t {$this->sessionName}:{$target} '{$keys}'{$enterKey}"
array_values(array_filter(
['tmux', 'send-keys', '-t', $this->target($target), '--', $keys, $enter ? 'Enter' : null],
static fn (?string $argument): bool => $argument !== null,
))
);
return $result->successful();
return $this->recordResult($result);
}
/**
@@ -127,10 +129,10 @@ class TmuxPaneManager
public function killPane(string $target): bool
{
$result = Process::timeout(5)->run(
"tmux kill-pane -t {$this->sessionName}:{$target}"
['tmux', 'kill-pane', '-t', $this->target($target)]
);
return $result->successful();
return $this->recordResult($result);
}
/**
@@ -138,14 +140,11 @@ class TmuxPaneManager
*/
public function setPaneTitle(string $target, string $title): bool
{
// Escape single quotes in the title for shell
$escapedTitle = str_replace("'", "'\\''", $title);
$result = Process::timeout(5)->run(
"tmux select-pane -t {$this->sessionName}:{$target} -T '{$escapedTitle}'"
['tmux', 'select-pane', '-t', $this->target($target), '-T', $title]
);
return $result->successful();
return $this->recordResult($result);
}
/**
@@ -154,7 +153,7 @@ class TmuxPaneManager
public function getPaneTitle(string $target): ?string
{
$result = Process::timeout(5)->run(
"tmux display-message -p -t {$this->sessionName}:{$target} '#{pane_title}'"
['tmux', 'display-message', '-p', '-t', $this->target($target), '#{pane_title}']
);
return $result->successful() ? trim($result->output()) : null;
@@ -166,9 +165,146 @@ class TmuxPaneManager
public function capturePane(string $target, int $lines = 100): string
{
$result = Process::timeout(10)->run(
"tmux capture-pane -p -t {$this->sessionName}:{$target} -S -{$lines}"
['tmux', 'capture-pane', '-p', '-t', $this->target($target), '-S', "-{$lines}"]
);
return $result->successful() ? $result->output() : '';
}
public function setPaneRole(string $target, TmuxPaneRole $role): bool
{
$result = Process::timeout(5)->run(
['tmux', 'set-option', '-p', '-t', $this->target($target), self::ROLE_OPTION, $role->value]
);
if ($this->recordResult($result)) {
$this->roleTargets = null;
return true;
}
return false;
}
public function paneForRole(TmuxPaneRole $role, ?string $legacyTarget = null): string
{
$targets = $this->roleTargets();
if (isset($targets[$role->value])) {
return $targets[$role->value];
}
if ($legacyTarget !== null) {
return $this->tagLegacyPane($role, $legacyTarget);
}
throw new RuntimeException("Tmux pane role '{$role->value}' was not found in session '{$this->sessionName}'.");
}
public function lastError(): ?string
{
return $this->lastError;
}
/**
* @return array<string, string>
*/
private function roleTargets(): array
{
if ($this->roleTargets !== null) {
return $this->roleTargets;
}
$result = Process::timeout(10)->run(
['tmux', 'list-panes', '-s', '-t', $this->sessionName, '-F', "#{pane_id}\t#{".self::ROLE_OPTION.'}']
);
if (! $result->successful()) {
throw new RuntimeException(
"Unable to list panes for tmux session '{$this->sessionName}': ".trim($result->errorOutput())
);
}
$targets = [];
foreach (preg_split('/\R/', trim($result->output())) ?: [] as $line) {
if ($line === '') {
continue;
}
[$paneId, $role] = array_pad(explode("\t", $line, 2), 2, '');
if ($role === '') {
continue;
}
if (isset($targets[$role])) {
throw new RuntimeException(
"Tmux pane role '{$role}' is assigned more than once in session '{$this->sessionName}'."
);
}
$targets[$role] = $paneId;
}
return $this->roleTargets = $targets;
}
private function target(string $target): string
{
return str_starts_with($target, '%') ? $target : "{$this->sessionName}:{$target}";
}
private function tagLegacyPane(TmuxPaneRole $role, string $legacyTarget): string
{
$result = Process::timeout(5)->run(
['tmux', 'display-message', '-p', '-t', $this->target($legacyTarget), '#{pane_id}']
);
$paneId = trim($result->output());
if (! $result->successful() || ! preg_match('/^%[0-9]+$/', $paneId)) {
throw new RuntimeException(
"Tmux pane role '{$role->value}' and legacy target '{$legacyTarget}' were not found in session '{$this->sessionName}'."
);
}
if (! $this->setPaneRole($paneId, $role)) {
throw new RuntimeException(
"Unable to tag legacy tmux pane '{$legacyTarget}' as '{$role->value}': {$this->lastError}."
);
}
return $paneId;
}
private function createdPaneId(bool $successful, string $output, string $errorOutput): ?string
{
if (! $successful) {
$this->lastError = trim($errorOutput);
return null;
}
$paneId = trim($output);
if (! preg_match('/^%[0-9]+$/', $paneId)) {
$this->lastError = "Tmux returned an invalid pane ID: '{$paneId}'.";
return null;
}
$this->lastError = null;
return $paneId;
}
private function recordResult(ProcessResult $result): bool
{
if ($result->successful()) {
$this->lastError = null;
return true;
}
$this->lastError = trim($result->errorOutput());
return false;
}
}
+57 -19
View File
@@ -16,6 +16,8 @@ class TmuxSessionManager
protected string $configFile;
private ?string $lastError = null;
public function __construct(?string $sessionName = null)
{
$this->sessionName = $sessionName ?? $this->getSessionName();
@@ -32,13 +34,18 @@ class TmuxSessionManager
?? config('tmux.session.default_name', 'nntmux');
}
public function sessionName(): string
{
return $this->sessionName;
}
/**
* Check if tmux session exists
*/
public function sessionExists(): bool
{
$result = Process::timeout(10)
->run("tmux list-sessions 2>/dev/null | grep -q '^{$this->sessionName}:'");
->run(['tmux', 'has-session', '-t', $this->sessionName]);
return $result->successful();
}
@@ -46,22 +53,50 @@ class TmuxSessionManager
/**
* Create a new tmux session
*/
public function createSession(string $windowName = 'Monitor'): bool
public function createSession(string $windowName = 'Monitor'): ?string
{
if ($this->sessionExists()) {
return false;
$this->lastError = "Tmux session '{$this->sessionName}' already exists.";
return null;
}
$configOption = file_exists($this->configFile) ? "-f {$this->configFile}" : '';
// Escape single quotes in the window name for shell
$escapedWindowName = str_replace("'", "'\\''", $windowName);
$result = Process::timeout(30)->run(
"tmux {$configOption} new-session -d -s {$this->sessionName} -n '{$escapedWindowName}' 'printf \"\\033]2;{$escapedWindowName}\\033\"'"
$arguments = ['tmux'];
if (file_exists($this->configFile)) {
array_push($arguments, '-f', $this->configFile);
}
array_push(
$arguments,
'new-session',
'-d',
'-P',
'-F',
'#{pane_id}',
'-s',
$this->sessionName,
'-n',
$windowName,
'true',
);
return $result->successful();
$result = Process::timeout(30)->run($arguments);
if (! $result->successful()) {
$this->lastError = trim($result->errorOutput());
return null;
}
$paneId = trim($result->output());
if (! preg_match('/^%[0-9]+$/', $paneId)) {
$this->lastError = "Tmux returned an invalid pane ID: '{$paneId}'.";
return null;
}
$this->lastError = null;
return $paneId;
}
/**
@@ -73,7 +108,7 @@ class TmuxSessionManager
return true;
}
$result = Process::timeout(30)->run("tmux kill-session -t {$this->sessionName}");
$result = Process::timeout(30)->run(['tmux', 'kill-session', '-t', $this->sessionName]);
return $result->successful();
}
@@ -87,11 +122,9 @@ class TmuxSessionManager
return false;
}
$result = Process::timeout(5)->run(
"tmux select-window -t {$this->sessionName}:0; tmux attach-session -d -t {$this->sessionName}"
);
passthru('tmux attach-session -t '.escapeshellarg($this->sessionName), $exitCode);
return $result->successful();
return $exitCode === 0;
}
/**
@@ -106,7 +139,7 @@ class TmuxSessionManager
}
$result = Process::timeout(10)->run(
"tmux list-panes -s -t {$this->sessionName} -F '#{window_index}:#{pane_index} #{pane_title}'"
['tmux', 'list-panes', '-s', '-t', $this->sessionName, '-F', "#{window_index}:#{pane_index}\t#{pane_title}"]
);
if (! $result->successful()) {
@@ -121,7 +154,7 @@ class TmuxSessionManager
continue;
}
[$position, $title] = explode(' ', $line, 2);
[$position, $title] = array_pad(explode("\t", $line, 2), 2, '');
$panes[$position] = $title;
}
@@ -134,7 +167,7 @@ class TmuxSessionManager
public function getPaneStatus(string $window, string $pane): ?string
{
$result = Process::timeout(5)->run(
"tmux display-message -p -t {$this->sessionName}:{$window}.{$pane} '#{pane_current_command}'"
['tmux', 'display-message', '-p', '-t', "{$this->sessionName}:{$window}.{$pane}", '#{pane_current_command}']
);
return $result->successful() ? trim($result->output()) : null;
@@ -149,4 +182,9 @@ class TmuxSessionManager
return $status !== null && $status !== 'bash' && $status !== 'sh';
}
public function lastError(): ?string
{
return $this->lastError;
}
}
+21 -15
View File
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Services\Tmux;
use App\Enums\TmuxPaneRole;
use App\Models\Settings;
/**
@@ -49,15 +50,18 @@ class TmuxTaskRunner
*/
public function runTask(string $taskName, array $config): bool
{
$pane = $config['pane'] ?? null;
$role = $config['role'] ?? null;
$command = $config['command'] ?? null;
$enabled = $config['enabled'] ?? true;
$workAvailable = $config['work_available'] ?? true;
if (! $pane || ! $command) {
if (! $role instanceof TmuxPaneRole || ! $command) {
return false;
}
$legacyPane = $config['legacy_pane'] ?? null;
$pane = $this->paneManager->paneForRole($role, is_string($legacyPane) ? $legacyPane : null);
// Check if task is enabled and has work
if (! $enabled) {
return $this->disablePane($pane, $taskName, 'disabled in settings');
@@ -183,7 +187,7 @@ class TmuxTaskRunner
public function runIRCScraper(array $config): bool
{
$runScraper = (int) ($config['constants']['run_ircscraper'] ?? 0);
$pane = '3.0';
$pane = $this->paneManager->paneForRole(TmuxPaneRole::IrcScraper, '3.0');
if ($runScraper !== 1) {
return $this->disablePane($pane, 'IRC Scraper', 'disabled in settings');
@@ -206,7 +210,7 @@ class TmuxTaskRunner
{
$enabled = (int) ($config['settings']['binaries_run'] ?? 0);
$killswitch = $config['killswitch']['pp'] ?? false;
$pane = '0.1';
$pane = $this->paneManager->paneForRole(TmuxPaneRole::Binaries, '0.1');
if (! $enabled) {
return $this->disablePane($pane, 'Update Binaries', 'disabled in settings');
@@ -243,7 +247,7 @@ class TmuxTaskRunner
$enabled = (int) ($config['settings']['backfill'] ?? 0);
$collKillswitch = $config['killswitch']['coll'] ?? false;
$ppKillswitch = $config['killswitch']['pp'] ?? false;
$pane = '0.2';
$pane = $this->paneManager->paneForRole(TmuxPaneRole::Backfill, '0.2');
if (! $enabled) {
return $this->disablePane($pane, 'Backfill', 'disabled in settings');
@@ -287,7 +291,8 @@ class TmuxTaskRunner
public function runReleasesUpdate(array $config): bool
{
$enabled = (int) ($config['settings']['releases_run'] ?? 0);
$pane = $config['pane'] ?? '0.3';
$legacyPane = (int) ($config['constants']['sequential'] ?? 0) === 1 ? '0.1' : '0.3';
$pane = $this->paneManager->paneForRole(TmuxPaneRole::Releases, $legacyPane);
if (! $enabled) {
return $this->disablePane($pane, 'Update Releases', 'disabled in settings');
@@ -354,7 +359,7 @@ class TmuxTaskRunner
// For now, delegate to existing methods
$this->runBinariesUpdate($runVar);
$this->runBackfill($runVar);
$this->runReleasesUpdate(array_merge($runVar, ['pane' => '0.3']));
$this->runReleasesUpdate($runVar);
return true;
}
@@ -366,7 +371,7 @@ class TmuxTaskRunner
*/
protected function runMainBasic(array $runVar): bool
{
return $this->runReleasesUpdate(array_merge($runVar, ['pane' => '0.1']));
return $this->runReleasesUpdate($runVar);
}
/**
@@ -377,7 +382,7 @@ class TmuxTaskRunner
protected function runMainSequential(array $runVar): bool
{
// Full sequential mode - runs group:update-all for each group
$pane = '0.1';
$pane = $this->paneManager->paneForRole(TmuxPaneRole::Sequential, '0.1');
$niceness = $this->getNiceness();
$artisan = base_path('artisan');
@@ -396,7 +401,7 @@ class TmuxTaskRunner
{
$enabled = (int) ($runVar['settings']['fix_names'] ?? 0);
$work = (int) ($runVar['counts']['now']['processrenames'] ?? 0);
$pane = '1.0';
$pane = $this->paneManager->paneForRole(TmuxPaneRole::FixNames, '1.0');
if ($enabled !== 1) {
return $this->disablePane($pane, 'Fix Release Names', 'disabled in settings');
@@ -431,7 +436,7 @@ class TmuxTaskRunner
protected function runRemoveCrapTask(array $runVar): bool
{
$option = $runVar['settings']['fix_crap_opt'] ?? 'Disabled';
$pane = '1.1';
$pane = $this->paneManager->paneForRole(TmuxPaneRole::RemoveCrap, '1.1');
// Handle disabled state
if ($option === 'Disabled' || $option === 0 || $option === '0') {
@@ -545,7 +550,7 @@ class TmuxTaskRunner
protected function runPostProcessAdditional(array $runVar): bool
{
$postSetting = (int) ($runVar['settings']['post'] ?? 0);
$pane = '2.0';
$pane = $this->paneManager->paneForRole(TmuxPaneRole::PostAdditional, '2.0');
// Check if post processing is enabled (1 = additional, 2 = nfo, 3 = both)
if ($postSetting === 0) {
@@ -610,7 +615,7 @@ class TmuxTaskRunner
protected function runTvTask(array $runVar): bool
{
$enabled = (int) ($runVar['settings']['post_non'] ?? 0);
$pane = '2.1';
$pane = $this->paneManager->paneForRole(TmuxPaneRole::PostTv, '2.1');
if ($enabled !== 1) {
return $this->disablePane($pane, 'Post-process TV/Anime', 'disabled in settings');
@@ -670,7 +675,7 @@ class TmuxTaskRunner
protected function runMoviesTask(array $runVar): bool
{
$enabled = (int) ($runVar['settings']['post_non'] ?? 0);
$pane = '2.3';
$pane = $this->paneManager->paneForRole(TmuxPaneRole::PostMovies, '2.3');
if ($enabled !== 1) {
return $this->disablePane($pane, 'Post-process Movies', 'disabled in settings');
@@ -720,7 +725,8 @@ class TmuxTaskRunner
protected function runAmazonTask(array $runVar): bool
{
$enabled = (int) ($runVar['settings']['post_amazon'] ?? 0);
$pane = '2.2';
$legacyPane = (int) ($runVar['constants']['sequential'] ?? 0) === 2 ? '1.1' : '2.2';
$pane = $this->paneManager->paneForRole(TmuxPaneRole::PostMetadata, $legacyPane);
if ($enabled !== 1) {
return $this->disablePane($pane, 'Post-process Metadata', 'disabled in settings');